diff --git a/.env.development b/.env.development index dccd251..9910a51 100644 --- a/.env.development +++ b/.env.development @@ -44,4 +44,5 @@ PAY_TRANSFER_CB_URL='http://127.0.0.1:3007/api/internal/update_task' HASH_SALT='iG4Rpsa)6U31$H#^T85$^^3' # 游戏服, 支付上报地址 -GAME_PAY_CB_URL=https://game2006api-test.kingsome.cn/webapp/index.php?c=Shop&a=buyGoodsDirect \ No newline at end of file +GAME_PAY_CB_URL=https://game2006api-test.kingsome.cn/webapp/index.php +REDIS=redis://127.0.0.1:6379/14 \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 370882e..a607322 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,5 +17,18 @@ ], "type": "pwa-node" }, + { + "name": "Debug Monitor", + "request": "launch", + "runtimeArgs": [ + "run-script", + "dev:monitor" + ], + "runtimeExecutable": "npm", + "skipFiles": [ + "/**" + ], + "type": "pwa-node" + }, ] } \ No newline at end of file diff --git a/package.json b/package.json index 001e8f7..95d1bf2 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "index.js", "scripts": { "dev:api": "ts-node -r tsconfig-paths/register src/api.ts", + "dev:monitor": "ts-node -r tsconfig-paths/register src/google.monitor.ts", "build": "tsc", "prod:api": "NODE_ENV=production NODE_PATH=./dist node dist/api.js", "lint": "eslint --ext .ts src/**", @@ -31,6 +32,7 @@ "mongoose-findorcreate": "^3.0.0", "nanoid": "^3.1.23", "node-schedule": "^2.1.1", + "redis": "^3.1.2", "tracer": "^1.1.6", "verify-apple-id-token": "^3.0.0" }, @@ -38,6 +40,7 @@ "@typegoose/typegoose": "^9.12.1", "@types/dotenv": "^8.2.0", "@types/node-schedule": "^2.1.0", + "@types/redis": "^2.8.28", "@typescript-eslint/eslint-plugin": "^5.40.1", "@typescript-eslint/parser": "^5.40.1", "eslint": "^8.25.0", diff --git a/src/api.server.ts b/src/api.server.ts index cfd48a3..376d54d 100644 --- a/src/api.server.ts +++ b/src/api.server.ts @@ -171,8 +171,8 @@ export class ApiServer { self.registerRouter() self.setErrHandler() self.setFormatSend() - self.initSchedules() await self.initOtherServices() + self.initSchedules() this.server.listen({ port: config.api.port, host: config.api.host }, (err: any, address: any) => { if (err) { logger.log(err) diff --git a/src/controllers/googlepay.controller.ts b/src/controllers/googlepay.controller.ts index fe284b9..283ed4d 100644 --- a/src/controllers/googlepay.controller.ts +++ b/src/controllers/googlepay.controller.ts @@ -3,6 +3,7 @@ import { ZError } from 'common/ZError' import { router } from 'decorators/router' import logger from 'logger/logger' import { GoogleInApp, GooglePayStatus } from 'modules/GoogleInApp' +import { IPayResult, reportGooglePurchaseResult } from 'service/game.svr' import { GooglePaySvr } from 'service/googlepay.svr' class GooglePayController extends BaseController { @@ -14,7 +15,7 @@ class GooglePayController extends BaseController { throw new ZError(10, 'purchase data is empty') } logger.info(`verify google purchase::list=${list}`) - let results = [] + let results: IPayResult[] = [] for (let sub of list) { let infoRes = await new GooglePaySvr().fetchPurchaseData(sub.id, sub.token) // logger.log(JSON.stringify(infoRes)) @@ -58,6 +59,16 @@ class GooglePayController extends BaseController { } } //TODO:: 通知游戏服 + if (results.length) { + setImmediate(async () => { + try { + await reportGooglePurchaseResult(results) + } catch (err) { + logger.error('report google voided purchases failed', err.message || err) + } + }) + } + return results } } diff --git a/src/google.monitor.ts b/src/google.monitor.ts new file mode 100644 index 0000000..3a4e564 --- /dev/null +++ b/src/google.monitor.ts @@ -0,0 +1,23 @@ +import * as dotenv from 'dotenv' +import logger from 'logger/logger' +import { RedisClient } from 'redis/RedisClient' +const envFile = process.env.NODE_ENV && process.env.NODE_ENV === 'production' ? `.env.production` : '.env.development' +dotenv.config({ path: envFile }) + +import 'common/Extend' +import { GooglePaySvr } from 'service/googlepay.svr' +import GooglePurchaseSchedule from 'schedule/googlepurchase.schedule' + +async function main() { + let opts = { url: process.env.REDIS } + new RedisClient(opts) + logger.info('REDIS Connected') + await new GooglePaySvr().init() + logger.info('GooglePaySvr Connected') + setInterval(function () { + new GooglePurchaseSchedule().parseAllRecord() + }, 60 * 60 * 1000) + new GooglePurchaseSchedule().parseAllRecord() +} + +main() diff --git a/src/modules/GoogleInApp.ts b/src/modules/GoogleInApp.ts index d0df3bf..c8b9bdc 100644 --- a/src/modules/GoogleInApp.ts +++ b/src/modules/GoogleInApp.ts @@ -1,6 +1,8 @@ import { getModelForClass, index, modelOptions, mongoose, prop, ReturnModelType, Severity } from '@typegoose/typegoose' import { dbconn } from 'decorators/dbconn' import { BaseModule } from './Base' +import logger from 'logger/logger' +import { IPayResult, reportGooglePurchaseResult } from 'service/game.svr' export enum GooglePayStatus { PENDING = 0, // 默认状态, 未支付 @@ -80,6 +82,38 @@ export class GoogleInAppClass extends BaseModule { public static async findByRecordId(this: ReturnModelType, outOrderId: string) { return this.findOne({ outOrderId }).exec() } + + public static async parseVoidedRecords(this: ReturnModelType, voidedPurchases: any) { + if (!voidedPurchases || !voidedPurchases.length) { + return + } + let results: IPayResult[] = [] + for (let sub of voidedPurchases) { + let { orderId, voidedTimeMillis, voidedSource, voidedReason } = sub + // orderId = 'GPA.3355-1172-9416-16839' + const record = await GoogleInApp.findOneAndUpdate( + { outOrderId: orderId, status: GooglePayStatus.SUCCESS }, + { $set: { voidedTime: voidedTimeMillis, voidedSource, voidedReason, status: GooglePayStatus.VOIDED } }, + { returnDocument: 'after' }, + ) + if (record) { + results.push({ + productId: record.productId, + gameOrderId: record.gameOrderId, + orderId: record.outOrderId, + status: record.status, + }) + } + } + + if (results.length) { + try { + await reportGooglePurchaseResult(results) + } catch (err) { + logger.error('report google voided purchases failed', err.message || err) + } + } + } } export const GoogleInApp = getModelForClass(GoogleInAppClass, { existingConnection: GoogleInAppClass.db }) diff --git a/src/redis/RedisClient.ts b/src/redis/RedisClient.ts new file mode 100644 index 0000000..2eee25c --- /dev/null +++ b/src/redis/RedisClient.ts @@ -0,0 +1,306 @@ +import { resolveCname } from 'dns' +import redis from 'redis' +import { promisify } from 'util' +import { singleton } from '../decorators/singleton' + +type Callback = (...args: any[]) => void + +@singleton +export class RedisClient { + public pub: redis.RedisClient + public sub: redis.RedisClient + + protected subscribeAsync: any + protected unsubscribeAsync: any + protected publishAsync: any + + protected subscriptions: { [channel: string]: Callback[] } = {} + + protected smembersAsync: any + protected sismemberAsync: any + protected hgetAsync: any + protected hlenAsync: any + protected pubsubAsync: any + protected incrAsync: any + protected decrAsync: any + + constructor(opts?: redis.ClientOpts) { + this.sub = redis.createClient(opts) + this.pub = redis.createClient(opts) + + // no listener limit + this.sub.setMaxListeners(0) + + // create promisified pub/sub methods. + this.subscribeAsync = promisify(this.sub.subscribe).bind(this.sub) + this.unsubscribeAsync = promisify(this.sub.unsubscribe).bind(this.sub) + + this.publishAsync = promisify(this.pub.publish).bind(this.pub) + + // create promisified redis methods. + this.smembersAsync = promisify(this.pub.smembers).bind(this.pub) + this.sismemberAsync = promisify(this.pub.sismember).bind(this.pub) + this.hlenAsync = promisify(this.pub.hlen).bind(this.pub) + this.hgetAsync = promisify(this.pub.hget).bind(this.pub) + this.pubsubAsync = promisify(this.pub.pubsub).bind(this.pub) + this.decrAsync = promisify(this.pub.decr).bind(this.pub) + this.incrAsync = promisify(this.pub.incr).bind(this.pub) + } + + public async subscribe(topic: string, callback: Callback) { + if (!this.subscriptions[topic]) { + this.subscriptions[topic] = [] + } + + this.subscriptions[topic].push(callback) + + if (this.sub.listeners('message').length === 0) { + this.sub.addListener('message', this.handleSubscription) + } + + await this.subscribeAsync(topic) + + return this + } + + public async unsubscribe(topic: string, callback?: Callback) { + if (callback) { + const index = this.subscriptions[topic].indexOf(callback) + this.subscriptions[topic].splice(index, 1) + } else { + this.subscriptions[topic] = [] + } + + if (this.subscriptions[topic].length === 0) { + await this.unsubscribeAsync(topic) + } + + return this + } + + public async publish(topic: string, data: any) { + if (data === undefined) { + data = false + } + + await this.publishAsync(topic, JSON.stringify(data)) + } + + public async exists(roomId: string): Promise { + return (await this.pubsubAsync('channels', roomId)).length > 0 + } + + public async setex(key: string, value: string, seconds: number) { + return new Promise(resolve => this.pub.setex(key, seconds, value, resolve)) + } + + public async expire(key: string, seconds: number) { + return new Promise(resolve => this.pub.expire(key, seconds, resolve)) + } + + public async get(key: string): Promise { + return new Promise((resolve, reject) => { + this.pub.get(key, (err, data: string | null) => { + if (err) { + return reject(err) + } + resolve(data) + }) + }) + } + + public async set(key: string, val: string) { + return new Promise(resolve => { + this.pub.set(key, val, () => { + resolve && resolve('') + }) + }) + } + + public async del(roomId: string) { + return new Promise(resolve => { + this.pub.del(roomId, resolve) + }) + } + + public async sadd(key: string, value: any) { + return new Promise(resolve => { + this.pub.sadd(key, value, resolve) + }) + } + + public async smembers(key: string): Promise { + return await this.smembersAsync(key) + } + + public async sismember(key: string, field: string): Promise { + return await this.sismemberAsync(key, field) + } + + public async srem(key: string, value: any) { + return new Promise(resolve => { + this.pub.srem(key, value, resolve) + }) + } + + public async scard(key: string) { + return new Promise((resolve, reject) => { + this.pub.scard(key, (err, data) => { + if (err) { + return reject(err) + } + resolve(data) + }) + }) + } + public async srandmember(key: string) { + return new Promise((resolve, reject) => { + this.pub.srandmember(key, (err, data) => { + if (err) { + return reject(err) + } + resolve(data) + }) + }) + } + + public async sinter(...keys: string[]) { + return new Promise((resolve, reject) => { + this.pub.sinter(...keys, (err, data) => { + if (err) { + return reject(err) + } + resolve(data) + }) + }) + } + + public async zadd(key: string, value: any, member: string) { + return new Promise(resolve => { + this.pub.zadd(key, value, member, resolve) + }) + } + public async zrangebyscore(key: string, min: number, max: number) { + return new Promise((resolve, reject) => { + this.pub.zrangebyscore(key, min, max, 'withscores', (err, data) => { + if (err) { + return reject(err) + } + resolve(data) + }) + }) + } + + public async zcard(key: string) { + return new Promise((resolve, reject) => { + this.pub.zcard(key, (err, data) => { + if (err) { + return reject(err) + } + resolve(data) + }) + }) + } + + public async zcount(key: string, min: number, max: number) { + return new Promise((resolve, reject) => { + this.pub.zcount(key, min, max, (err, data) => { + if (err) { + return reject(err) + } + resolve(data) + }) + }) + } + + public async zrevrank(key: string, member: string) { + return new Promise((resolve, reject) => { + this.pub.zrevrank(key, member, (err, data) => { + if (err) { + return reject(err) + } + resolve(data) + }) + }) + } + + public async zscore(key: string, member: string) { + return new Promise((resolve, reject) => { + this.pub.zscore(key, member, (err, data) => { + if (err) { + return reject(err) + } + resolve(data) + }) + }) + } + + public async zrevrange(key: string, start: number, end: number) { + return new Promise((resolve, reject) => { + this.pub.zrevrange(key, start, end, 'withscores', (err, data) => { + if (err) { + return reject(err) + } + resolve(data) + }) + }) + } + + public async hset(key: string, field: string, value: string) { + return new Promise(resolve => { + this.pub.hset(key, field, value, resolve) + }) + } + + public async hincrby(key: string, field: string, value: number) { + return new Promise(resolve => { + this.pub.hincrby(key, field, value, resolve) + }) + } + + public async hget(key: string, field: string) { + return await this.hgetAsync(key, field) + } + + public async hgetall(key: string) { + return new Promise<{ [key: string]: string }>((resolve, reject) => { + this.pub.hgetall(key, (err, values) => { + if (err) { + return reject(err) + } + resolve(values) + }) + }) + } + + public async hdel(key: string, field: string) { + return new Promise((resolve, reject) => { + this.pub.hdel(key, field, (err, ok) => { + if (err) { + return reject(err) + } + resolve(ok) + }) + }) + } + + public async hlen(key: string): Promise { + return await this.hlenAsync(key) + } + + public async incr(key: string): Promise { + return await this.incrAsync(key) + } + + public async decr(key: string): Promise { + return await this.decrAsync(key) + } + + protected handleSubscription = (channel: string, message: string) => { + if (this.subscriptions[channel]) { + for (let i = 0, l = this.subscriptions[channel].length; i < l; i++) { + this.subscriptions[channel][i](JSON.parse(message)) + } + } + } +} diff --git a/src/schedule/googlepurchase.schedule.ts b/src/schedule/googlepurchase.schedule.ts new file mode 100644 index 0000000..2cb4f06 --- /dev/null +++ b/src/schedule/googlepurchase.schedule.ts @@ -0,0 +1,48 @@ +import { singleton } from 'decorators/singleton' +import logger from 'logger/logger' +import { GoogleInApp } from 'modules/GoogleInApp' +import * as schedule from 'node-schedule' +import { RedisClient } from 'redis/RedisClient' +import { GooglePaySvr } from 'service/googlepay.svr' + +/** + * 定时更新发送邮件验证码的过期状态 + * + * * * * * * + ┬ ┬ ┬ ┬ ┬ ┬ + │ │ │ │ │ │ + │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun) + │ │ │ │ └───── month (1 - 12) + │ │ │ └────────── day of month (1 - 31) + │ │ └─────────────── hour (0 - 23) + │ └──────────────────── minute (0 - 59) + └───────────────────────── second (0 - 59, OPTIONAL) + */ +@singleton +export default class GooglePurchaseSchedule { + async parseAllRecord() { + const timeKey = 'google_inapp_voided_check_time' + let timeStr = await new RedisClient().get(timeKey) + let startTime = timeStr ? parseInt(timeStr) : Date.now() - 24 * 60 * 60 * 1000 + let endTime = Date.now() + try { + let res = await new GooglePaySvr().queryVoidedPurchases(startTime, endTime) + if (res.status !== 200) { + logger.info('error check google voided purchase', res.status, res.statusText) + return + } + const { data } = res + await GoogleInApp.parseVoidedRecords(data.voidedPurchases) + await new RedisClient().set(timeKey, endTime + '') + logger.info(`success check google voided purchase:: voidedPurchases: ${data.voidedPurchases?.length || 0}`) + } catch (err) { + logger.info('error check google voided purchase', err.message || err) + } + } + scheduleAll() { + const job = schedule.scheduleJob('1 * * * *', async () => { + await this.parseAllRecord() + }) + this.parseAllRecord() + } +} diff --git a/src/service/game.svr.ts b/src/service/game.svr.ts index b8b6f34..b46354b 100644 --- a/src/service/game.svr.ts +++ b/src/service/game.svr.ts @@ -2,22 +2,31 @@ import axios from 'axios' import { PayRecordClass } from 'modules/PayRecord' import { DocumentType } from '@typegoose/typegoose' import { hmacsha256 } from 'utils/security.util' +import { NetClient } from 'net/NetClient' +import logger from 'logger/logger' + +export interface IPayResult { + productId: string + gameOrderId: string + orderId: string + status: number +} export async function reportPayResult(data: DocumentType) { - let repData = { + const repData = { account_id: data.gameAccountId, order_id: data.gameOrderId, status: data.status, id: data.id, txhash: data.txHash, } - let signStr = Object.keys(repData) + const signStr = Object.keys(repData) .sort() .map(key => `${key}=${encodeURIComponent(repData[key])}`) .join('&') const sign = hmacsha256(signStr, process.env.HASH_SALT) - let url = `${process.env.GAME_PAY_CB_URL}&${signStr}&sign=${sign}` - let reqConfig: any = { + const url = `${process.env.GAME_PAY_CB_URL}?c=Shop&a=buyGoodsDirect&${signStr}&sign=${sign}` + const reqConfig: any = { method: 'get', url, headers: { @@ -26,3 +35,27 @@ export async function reportPayResult(data: DocumentType) { } return axios(reqConfig) } +// 上报google支付结果 +export async function reportGooglePurchaseResult(records: IPayResult[]) { + const url = `${process.env.GAME_PAY_CB_URL}?c=Shop&a=inappPurchaseDiamonds` + let reportData: any = { + channel: 'google', + records, + } + let signStr = 'channel=google&' + signStr += records + .map(record => + Object.keys(record) + .sort() + .map(key => `${key}=${record[key]}`) + .join('&'), + ) + .join('&') + const sign = hmacsha256(signStr, process.env.HASH_SALT) + reportData.sign = sign + const reqData = { + url, + data: JSON.stringify(reportData), + } + return new NetClient().httpPost(reqData) +} diff --git a/src/service/googlepay.svr.ts b/src/service/googlepay.svr.ts index 42ea2eb..4c31d21 100644 --- a/src/service/googlepay.svr.ts +++ b/src/service/googlepay.svr.ts @@ -43,12 +43,12 @@ export class GooglePaySvr { * @param startTime * @param endTime */ - public async queryVoidedPurchases(startTime: string, endTime: string) { + public async queryVoidedPurchases(startTime: number, endTime: number) { return this.androidpublisher.purchases.voidedpurchases.list({ packageName: PAGEAGE_NAME, type: 0, - startTime, - endTime, + startTime: startTime + '', + endTime: endTime + '', }) } } diff --git a/yarn.lock b/yarn.lock index 5255c8a..54e5e5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -272,6 +272,13 @@ resolved "https://registry.npmmirror.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/redis@^2.8.28": + version "2.8.32" + resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.32.tgz#1d3430219afbee10f8cfa389dad2571a05ecfb11" + integrity sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w== + dependencies: + "@types/node" "*" + "@types/semver@^7.3.12": version "7.3.12" resolved "https://registry.npmmirror.com/@types/semver/-/semver-7.3.12.tgz#920447fdd78d76b19de0438b7f60df3c4a80bf1c" @@ -737,6 +744,11 @@ delayed-stream@~1.0.0: resolved "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +denque@^1.5.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.1.tgz#07f670e29c9a78f8faecb2566a1e2c11929c5cbf" + integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== + denque@^2.1.0: version "2.1.0" resolved "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" @@ -2055,6 +2067,33 @@ real-require@^0.2.0: resolved "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== +redis-commands@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89" + integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ== + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== + dependencies: + redis-errors "^1.0.0" + +redis@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/redis/-/redis-3.1.2.tgz#766851117e80653d23e0ed536254677ab647638c" + integrity sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw== + dependencies: + denque "^1.5.0" + redis-commands "^1.7.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + reflect-metadata@^0.1.13: version "0.1.13" resolved "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08"