diff --git a/.gitignore b/.gitignore index 9dacc57..2a7b08e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist tmp target boundle.log +google_cloud.json \ No newline at end of file diff --git a/package.json b/package.json index d0180f9..001e8f7 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "fastify": "^4.8.1", "fastify-plugin": "^4.2.1", "google-auth-library": "^8.5.2", + "googleapis": "^120.0.0", "mongoose": "^6.6.5", "mongoose-findorcreate": "^3.0.0", "nanoid": "^3.1.23", @@ -35,8 +36,8 @@ }, "devDependencies": { "@typegoose/typegoose": "^9.12.1", - "@types/node-schedule": "^2.1.0", "@types/dotenv": "^8.2.0", + "@types/node-schedule": "^2.1.0", "@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 9a90725..cfd48a3 100644 --- a/src/api.server.ts +++ b/src/api.server.ts @@ -8,6 +8,7 @@ import config from 'config/config' import { ConnectOptions } from 'mongoose' import CodeTaskSchedule from 'schedule/codetask.schedule' import { PriceSvr } from 'service/price.svr' +import { GooglePaySvr } from 'service/googlepay.svr' const zReqParserPlugin = require('plugins/zReqParser') @@ -108,6 +109,10 @@ export class ApiServer { logger.log(`DB Connection Error: ${err.message}`) } } + async initOtherServices() { + await new GooglePaySvr().init() + } + private setErrHandler() { this.server.setNotFoundHandler(function ( request: any, @@ -167,6 +172,7 @@ export class ApiServer { self.setErrHandler() self.setFormatSend() self.initSchedules() + await self.initOtherServices() 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 new file mode 100644 index 0000000..fe284b9 --- /dev/null +++ b/src/controllers/googlepay.controller.ts @@ -0,0 +1,63 @@ +import BaseController from 'common/base.controller' +import { ZError } from 'common/ZError' +import { router } from 'decorators/router' +import logger from 'logger/logger' +import { GoogleInApp, GooglePayStatus } from 'modules/GoogleInApp' +import { GooglePaySvr } from 'service/googlepay.svr' + +class GooglePayController extends BaseController { + @router('post /pay/google/verify') + async verifyGooglePay(req, res) { + const user = req.user + const { list } = req.params + if (!list || !list.length) { + throw new ZError(10, 'purchase data is empty') + } + logger.info(`verify google purchase::list=${list}`) + let results = [] + for (let sub of list) { + let infoRes = await new GooglePaySvr().fetchPurchaseData(sub.id, sub.token) + // logger.log(JSON.stringify(infoRes)) + if (infoRes.status !== 200) { + throw new ZError(20, 'fetch purchase data failed') + } + let info = infoRes.data + let status = GooglePayStatus.PENDING + if (info.purchaseState === 0 && info.consumptionState === 0) { + status = GooglePayStatus.PURCHASED + } + if (status === GooglePayStatus.PURCHASED) { + try { + let record = await GoogleInApp.insertOrUpdate( + { account: user.id, outOrderId: info.orderId }, + { + purchaseToken: sub.token, + productId: sub.id, + gameOrderId: info.obfuscatedExternalAccountId, + country: info.regionCode, + status, + kind: info.kind, + purchaseTime: info.purchaseTimeMillis, + data: info, + $inc: { version: 1 }, + }, + ) + await new GooglePaySvr().consumePurchase(sub.id, sub.token) + record.status = GooglePayStatus.SUCCESS + record.consumeTime = Date.now() + await record.save() + results.push({ + productId: sub.id, + gameOrderId: record.gameOrderId, + orderId: record.outOrderId, + status: record.status, + }) + } catch (err) { + logger.info(`consume purchase:: productId: ${sub.id} purchaseToken: ${sub.token} failed`, err.message || err) + } + } + } + //TODO:: 通知游戏服 + return results + } +} diff --git a/src/modules/GoogleInApp.ts b/src/modules/GoogleInApp.ts new file mode 100644 index 0000000..d0df3bf --- /dev/null +++ b/src/modules/GoogleInApp.ts @@ -0,0 +1,85 @@ +import { getModelForClass, index, modelOptions, mongoose, prop, ReturnModelType, Severity } from '@typegoose/typegoose' +import { dbconn } from 'decorators/dbconn' +import { BaseModule } from './Base' + +export enum GooglePayStatus { + PENDING = 0, // 默认状态, 未支付 + TRANSFERING = 1, //只有购买链上资产才会有该状态 + TRANSFERED = 2, //只有购买链上资产才会有该状态 + PURCHASED = 3, //用户已支付, 但未确认和消费 + SUCCESS = 9, + VOIDED = 96, // 作废, 取消, 退款 + USER_CANCEL = 97, // 用户取消 + TRANSFER_FAIL = 98, // 转账错误 + FAIL = 99, // 交易失败 +} + +@dbconn('pay') +@index({ gameOrderId: 1 }, { unique: true, partialFilterExpression: { gameOrderId: { $exists: true } } }) +@index({ account: 1, outOrderId: 1 }, { unique: true }) +@index({ account: 1 }, { unique: false }) +@modelOptions({ + schemaOptions: { collection: 'google_inapp_record', timestamps: true }, + options: { allowMixed: Severity.ALLOW }, +}) +export class GoogleInAppClass extends BaseModule { + // 用户账号 + @prop({ required: true }) + public account: string + // 渠道返回的订单id + @prop() + public outOrderId: string + // 国家, 这里取自订单信息中返回的国家代码 + @prop() + public country?: string + // 游戏服传入的订单id, + // 由于有非游戏内购买的可能, 所以该字段可能不存在 + @prop() + public gameOrderId?: string + // 交易状态 + @prop({ required: true, default: GooglePayStatus.PENDING }) + public status: GooglePayStatus + // 渠道返回的原始资料 + @prop({ type: mongoose.Schema.Types.Mixed }) + public outData: any + + // 对应google play console中的 productid + @prop({ required: true }) + public productId: string + @prop() + public purchaseToken: string + + @prop({ required: true, default: 1 }) + public amount: number + // 表示 androidpublisherservice 中的 inappPurchase 对象 + @prop() + public kind: string + + // 支付时间, 取自渠道订单信息中的purchaseTimeMillis + @prop() + public purchaseTime: number + @prop() + public gameAccountId?: string + // 消费时间 + @prop() + public consumeTime?: number + + // voided 相关信息 + @prop() + public voidedTime?: number + // 发起作废购买交易的发起者(可能的值:0)。用户 1. 开发者 2. Google + @prop() + public voidedSource?: number + // 购买作废的原因可能为:0。其他 1. 反悔 2。未收到: 3. 有缺陷 4. Accidental_purchase 5. 欺诈 6. Friendly_fraud 7. 退款 + @prop() + public voidedReason?: number + + @prop({ default: 0 }) + public version: number + + public static async findByRecordId(this: ReturnModelType, outOrderId: string) { + return this.findOne({ outOrderId }).exec() + } +} + +export const GoogleInApp = getModelForClass(GoogleInAppClass, { existingConnection: GoogleInAppClass.db }) diff --git a/src/service/googlepay.svr.ts b/src/service/googlepay.svr.ts new file mode 100644 index 0000000..42ea2eb --- /dev/null +++ b/src/service/googlepay.svr.ts @@ -0,0 +1,54 @@ +import { singleton } from 'decorators/singleton' +import { androidpublisher_v3, google } from 'googleapis' +import logger from 'logger/logger' + +const PAGEAGE_NAME = 'com.cege.games.release' + +@singleton +export class GooglePaySvr { + private androidpublisher: androidpublisher_v3.Androidpublisher + constructor() {} + + public async init() { + // this.androidpublisher = google.androidpublisher('v3') + const auth = new google.auth.GoogleAuth({ + keyFile: 'google_cloud.json', + // Scopes can be specified either as an array or as a single, space-delimited string. + scopes: ['https://www.googleapis.com/auth/androidpublisher'], + }) + this.androidpublisher = google.androidpublisher({ + version: 'v3', + auth, + }) + } + + public async fetchPurchaseData(productId: string, purchaseToken: string) { + logger.info(`fetchPurchaseData::productId=${productId}, purchaseToken=${purchaseToken}`) + return this.androidpublisher.purchases.products.get({ + packageName: PAGEAGE_NAME, + productId: productId, + token: purchaseToken, + }) + } + + public async consumePurchase(productId: string, purchaseToken: string) { + return this.androidpublisher.purchases.products.consume({ + packageName: PAGEAGE_NAME, + productId: productId, + token: purchaseToken, + }) + } + /** + * 查询作废的交易 + * @param startTime + * @param endTime + */ + public async queryVoidedPurchases(startTime: string, endTime: string) { + return this.androidpublisher.purchases.voidedpurchases.list({ + packageName: PAGEAGE_NAME, + type: 0, + startTime, + endTime, + }) + } +} diff --git a/yarn.lock b/yarn.lock index 6945586..5255c8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -619,6 +619,14 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" +call-bind@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" + integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.2" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1168,6 +1176,24 @@ gcp-metadata@^5.0.0: gaxios "^5.0.0" json-bigint "^1.0.0" +gcp-metadata@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-5.3.0.tgz#6f45eb473d0cb47d15001476b48b663744d25408" + integrity sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w== + dependencies: + gaxios "^5.0.0" + json-bigint "^1.0.0" + +get-intrinsic@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82" + integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-proto "^1.0.1" + has-symbols "^1.0.3" + glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -1213,6 +1239,21 @@ globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" +google-auth-library@^8.0.2: + version "8.9.0" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-8.9.0.tgz#15a271eb2ec35d43b81deb72211bd61b1ef14dd0" + integrity sha512-f7aQCJODJFmYWN6PeNKzgvy9LI2tYmXnzpNDHEjG5sDNPgGb2FXQyTBnXeSH+PAtpKESFD+LmHw3Ox3mN7e1Fg== + dependencies: + arrify "^2.0.0" + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + fast-text-encoding "^1.0.0" + gaxios "^5.0.0" + gcp-metadata "^5.3.0" + gtoken "^6.1.0" + jws "^4.0.0" + lru-cache "^6.0.0" + google-auth-library@^8.5.2: version "8.5.2" resolved "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-8.5.2.tgz#bcdced8f7b475b80bf0e9c109c7c7e930866947b" @@ -1235,6 +1276,26 @@ google-p12-pem@^4.0.0: dependencies: node-forge "^1.3.1" +googleapis-common@^6.0.0: + version "6.0.4" + resolved "https://registry.yarnpkg.com/googleapis-common/-/googleapis-common-6.0.4.tgz#bd968bef2a478bcd3db51b27655502a11eaf8bf4" + integrity sha512-m4ErxGE8unR1z0VajT6AYk3s6a9gIMM6EkDZfkPnES8joeOlEtFEJeF8IyZkb0tjPXkktUfYrE4b3Li1DNyOwA== + dependencies: + extend "^3.0.2" + gaxios "^5.0.1" + google-auth-library "^8.0.2" + qs "^6.7.0" + url-template "^2.0.8" + uuid "^9.0.0" + +googleapis@^120.0.0: + version "120.0.0" + resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-120.0.0.tgz#0ca260fad6cc7575e5d762e6a33ea88000f8e6bc" + integrity sha512-Reo5PpERv0Df/L8Jx8CrPHMI3oEXmPBDHLXCY12biXUtveVgWVEoQN4Inn/85+eoNRsDiVyeEH/MjAa3hPHHrA== + dependencies: + google-auth-library "^8.0.2" + googleapis-common "^6.0.0" + grapheme-splitter@^1.0.4: version "1.0.4" resolved "https://registry.npmmirror.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" @@ -1254,6 +1315,16 @@ has-flag@^4.0.0: resolved "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" + integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg== + +has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + has@^1.0.3: version "1.0.3" resolved "https://registry.npmmirror.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -1780,6 +1851,11 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: resolved "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== +object-inspect@^1.9.0: + version "1.12.3" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" + integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== + obliterator@^2.0.1: version "2.0.4" resolved "https://registry.npmmirror.com/obliterator/-/obliterator-2.0.4.tgz#fa650e019b2d075d745e44f1effeb13a2adbe816" @@ -1940,6 +2016,13 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.npmmirror.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +qs@^6.7.0: + version "6.11.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9" + integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA== + dependencies: + side-channel "^1.0.4" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -2100,6 +2183,15 @@ shebang-regex@^3.0.0: resolved "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + dependencies: + call-bind "^1.0.0" + get-intrinsic "^1.0.2" + object-inspect "^1.9.0" + sift@16.0.0: version "16.0.0" resolved "https://registry.npmmirror.com/sift/-/sift-16.0.0.tgz#447991577db61f1a8fab727a8a98a6db57a23eb8" @@ -2356,6 +2448,16 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-template@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21" + integrity sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw== + +uuid@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" + integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.npmmirror.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"