From 53f5d6776f2f41922ea88f41000f8828d850dff5 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 25 Jun 2026 12:40:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(M08-A):=20=E6=8E=A5=E5=85=A5=E5=85=85?= =?UTF-8?q?=E5=80=BC=E5=BE=AE=E4=BF=A1=E6=94=AF=E4=BB=98=E8=B0=83=E8=B5=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db/migration-runner.ts | 7 +- backend/src/routes/recharge.ts | 81 ++++- backend/src/server.ts | 6 +- backend/src/wallets/recharge-service.ts | 293 +++++++++++++++++- backend/tests/migration-contract.test.mjs | 16 +- backend/tests/migration-runner.test.mjs | 2 +- backend/tests/recharge-route.test.mjs | 46 +++ backend/tests/recharge-service.test.mjs | 151 ++++++++- .../2026062524_m08a_recharge_wechat.down.sql | 11 + .../2026062524_m08a_recharge_wechat.up.sql | 13 + ...2026062524_m08a_recharge_wechat.verify.sql | 20 ++ .../2026-06-25-M08-A-recharge-wechat.md | 11 + .../2026-06-25-M08-A-recharge-wechat.md | 7 + docs/devlogs/2026-06-24-M08-A-顾客端.md | 29 ++ miniapp/pages/recharge/index.js | 16 + miniapp/pages/recharge/index.wxml | 1 + scripts/check-miniapp-m08-a.mjs | 2 + 17 files changed, 701 insertions(+), 11 deletions(-) create mode 100644 database/migrations/2026062524_m08a_recharge_wechat.down.sql create mode 100644 database/migrations/2026062524_m08a_recharge_wechat.up.sql create mode 100644 database/migrations/2026062524_m08a_recharge_wechat.verify.sql create mode 100644 docs/api-changelog/2026-06-25-M08-A-recharge-wechat.md create mode 100644 docs/db-changelog/2026-06-25-M08-A-recharge-wechat.md diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index e94c11b..0a34840 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -43,7 +43,8 @@ const migrationFiles: Record = { 'database/migrations/2026062220_m06c_iot_messages.up.sql', 'database/migrations/2026062421_m07a_wallet_ledger.up.sql', 'database/migrations/2026062422_m07b_recharge_plans.up.sql', - 'database/migrations/2026062423_m07c_benefits.up.sql' + 'database/migrations/2026062423_m07c_benefits.up.sql', + 'database/migrations/2026062524_m08a_recharge_wechat.up.sql' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -68,9 +69,11 @@ const migrationFiles: Record = { 'database/migrations/2026062220_m06c_iot_messages.verify.sql', 'database/migrations/2026062421_m07a_wallet_ledger.verify.sql', 'database/migrations/2026062422_m07b_recharge_plans.verify.sql', - 'database/migrations/2026062423_m07c_benefits.verify.sql' + 'database/migrations/2026062423_m07c_benefits.verify.sql', + 'database/migrations/2026062524_m08a_recharge_wechat.verify.sql' ], down: [ + 'database/migrations/2026062524_m08a_recharge_wechat.down.sql', 'database/migrations/2026062423_m07c_benefits.down.sql', 'database/migrations/2026062422_m07b_recharge_plans.down.sql', 'database/migrations/2026062421_m07a_wallet_ledger.down.sql', diff --git a/backend/src/routes/recharge.ts b/backend/src/routes/recharge.ts index 59e1b31..e27b954 100644 --- a/backend/src/routes/recharge.ts +++ b/backend/src/routes/recharge.ts @@ -1,9 +1,11 @@ -import type { FastifyInstance, FastifyReply } from 'fastify'; +import type { FastifyInstance, FastifyReply, preParsingHookHandler } from 'fastify'; +import { Transform } from 'node:stream'; import { z } from 'zod'; import type { AuthRepository } from '../auth/auth-repository.js'; import { authenticateAccessToken } from '../auth/authenticate.js'; import type { AccessProfile } from '../auth/rbac-repository.js'; import { RechargeError, type RechargeService } from '../wallets/recharge-service.js'; +import { WechatPayError } from '../payments/wechat-pay-client.js'; const listSchema = z.object({ storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional() @@ -13,9 +15,15 @@ const createSchema = z.object({ storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(), clientRequestId: z.string().min(8).max(128) }).strict(); +const orderParams = z.object({ + rechargeOrderId: z.string().regex(/^[1-9]\d{0,19}$/) +}); export interface RechargeRouteOptions { - service: Pick; + service: Pick< + RechargeService, + 'listAvailablePlans' | 'createRechargeOrder' | 'createWechatPrepay' | 'processWechatNotification' + >; authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; jwtSecret: string; @@ -58,6 +66,36 @@ export async function registerRechargeRoutes( traceId: request.traceId })); }); + + app.post('/app-api/recharge/orders/:rechargeOrderId/wechat-prepay', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const params = orderParams.safeParse(request.params); + if (!auth) return unauthorized(reply, request.traceId); + if (!params.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.createWechatPrepay({ + tenantId: auth.tenantId, + platformAppId: auth.platformAppId, + userId: auth.userId, + rechargeOrderId: params.data.rechargeOrderId + }), + traceId: request.traceId + })); + }); + + app.post('/app-api/recharge/wechat/notify', { + preParsing: captureRawBody + }, async (request, reply) => { + return handle(reply, request.traceId, async () => { + const data = await options.service.processWechatNotification( + notificationHeaders(request.headers), + request.rawBody, + request.traceId + ); + return reply.send({ code: 'SUCCESS', message: '成功', data }); + }); + }); } async function authenticate( @@ -77,6 +115,7 @@ async function authenticate( if (!access.capabilities.includes('profile.read')) return null; return { tenantId: result.session.tenantId, + platformAppId: result.session.platformAppId, userId: result.session.user.id }; } @@ -85,11 +124,12 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise< try { return await work(); } catch (error) { - if (!(error instanceof RechargeError)) throw error; + if (!(error instanceof RechargeError) && !(error instanceof WechatPayError)) throw error; const status = error.code === 'RECHARGE_PLAN_NOT_FOUND' || error.code === 'RECHARGE_ORDER_NOT_FOUND' ? 404 - : error.code === 'RECHARGE_LIMIT_REACHED' ? 409 : 400; + : error.code === 'RECHARGE_LIMIT_REACHED' ? 409 + : error.code.includes('FORBIDDEN') ? 403 : 400; return reply.status(status).send({ code: error.code, message: 'The recharge request is not available.', @@ -98,6 +138,39 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise< } } +const captureRawBody: preParsingHookHandler = (request, _reply, payload, done) => { + const chunks: Buffer[] = []; + const capture = new Transform({ + transform(chunk, _encoding, callback) { + chunks.push(Buffer.from(chunk)); + callback(null, chunk); + }, + flush(callback) { + request.rawBody = Buffer.concat(chunks).toString('utf8'); + callback(); + } + }); + const transformed = payload.pipe(capture) as typeof payload; + transformed.receivedEncodedLength = payload.receivedEncodedLength; + done(null, transformed); +}; + +function notificationHeaders(headers: Record) { + const read = (name: string) => { + const value = headers[name]; + if (typeof value !== 'string' || value.length === 0) { + throw new WechatPayError('WECHAT_NOTIFICATION_HEADER_INVALID'); + } + return value; + }; + return { + timestamp: read('wechatpay-timestamp'), + nonce: read('wechatpay-nonce'), + serial: read('wechatpay-serial'), + signature: read('wechatpay-signature') + }; +} + function unauthorized(reply: FastifyReply, traceId: string) { return reply.status(401).send({ code: 'AUTH_SESSION_INVALID', diff --git a/backend/src/server.ts b/backend/src/server.ts index b434ebe..9d0065f 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -174,7 +174,11 @@ const app = await buildApp({ jwtSecret: config.auth.jwtSecret }, recharge: { - service: new RechargeService(pool, walletLedgerService), + service: new RechargeService(pool, walletLedgerService, { + paymentRepository, + client: wechatPayClient, + credentials: wechatCredentials + }), authRepository, accessControl, jwtSecret: config.auth.jwtSecret diff --git a/backend/src/wallets/recharge-service.ts b/backend/src/wallets/recharge-service.ts index 84b9196..8024bf9 100644 --- a/backend/src/wallets/recharge-service.ts +++ b/backend/src/wallets/recharge-service.ts @@ -1,6 +1,13 @@ import { randomBytes } from 'node:crypto'; import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; import type { MySqlPool } from '../db/mysql.js'; +import type { PaymentRepository } from '../payments/payment-repository.js'; +import { + WechatPayClient, + WechatPayError, + type WechatNotificationHeaders, + type WechatPayCredential +} from '../payments/wechat-pay-client.js'; import type { WalletLedgerService } from './wallet-ledger-service.js'; interface PlanRow extends RowDataPacket { @@ -26,6 +33,7 @@ interface RechargeOrderRow extends RowDataPacket { payAmountCents: number; giftAmountCents: number; status: string; + providerPaymentId?: string | null; } interface CountRow extends RowDataPacket { total: number } @@ -37,7 +45,12 @@ export class RechargeError extends Error { export class RechargeService { constructor( private readonly pool: MySqlPool, - private readonly wallet: Pick + private readonly wallet: Pick, + private readonly wechat?: { + paymentRepository: Pick; + client: WechatPayClient; + credentials: ReadonlyMap; + } ) {} async listAvailablePlans(input: { @@ -158,6 +171,113 @@ export class RechargeService { }); } + async createWechatPrepay(input: { + tenantId: string; + platformAppId: string; + userId: string; + rechargeOrderId: string; + }) { + if (!this.wechat) throw new WechatPayError('WECHAT_PAYMENT_NOT_CONFIGURED'); + const order = await this.loadOwnedRechargeOrder(input, false); + if (order.status === 'CREDITED') throw new RechargeError('RECHARGE_ALREADY_CREDITED'); + if (order.status !== 'PENDING_PAYMENT' && order.status !== 'PAID') { + throw new RechargeError('RECHARGE_STATUS_INVALID'); + } + const config = await this.wechat.paymentRepository.resolveConfig( + this.pool, + input.tenantId, + input.platformAppId, + order.storeId ?? '0', + 'WECHAT' + ); + const credential = this.resolveWechatCredential(config.credentialRef); + const settings = config.settings as Record; + const [identityRows] = await this.pool.execute( + `SELECT openid FROM qipai_user_identities + WHERE tenant_id = ? AND platform_app_id = ? AND user_id = ? + AND provider = 'WECHAT' LIMIT 1`, + [input.tenantId, input.platformAppId, input.userId] + ); + if (!identityRows[0]?.openid) throw new WechatPayError('WECHAT_OPENID_NOT_FOUND'); + const result = await this.wechat.client.createJsapiPrepay(credential, { + description: typeof settings.rechargeDescription === 'string' + ? settings.rechargeDescription : `棋牌室余额充值 ${order.rechargeNo}`, + outTradeNo: order.rechargeNo, + notifyUrl: settingUrl( + settings, 'rechargeNotifyUrl', 'https://api.txyundm.cn/app-api/recharge/wechat/notify' + ), + amountCents: Number(order.payAmountCents), + payerOpenId: String(identityRows[0].openid) + }); + await this.pool.execute( + `UPDATE qipai_recharge_orders + SET payment_provider = 'WECHAT', provider_prepay_id = ? + WHERE tenant_id = ? AND id = ? AND user_id = ?`, + [result.prepayId, input.tenantId, input.rechargeOrderId, input.userId] + ); + return { + rechargeOrderId: input.rechargeOrderId, + rechargeNo: order.rechargeNo, + amountCents: Number(order.payAmountCents), + ...result.paymentParams + }; + } + + async processWechatNotification( + headers: WechatNotificationHeaders, + rawBody: string, + traceId: string + ) { + if (!this.wechat) throw new WechatPayError('WECHAT_PAYMENT_NOT_CONFIGURED'); + const data = this.verifyWithConfiguredCredential(headers, rawBody); + const rechargeNo = requiredString(data, 'out_trade_no'); + const transactionId = requiredString(data, 'transaction_id'); + const tradeState = requiredString(data, 'trade_state'); + const amount = objectValue(data.amount); + const total = requiredNumber(amount, 'total'); + return this.transaction(async (connection) => { + const order = await this.loadRechargeOrderByNo(connection, rechargeNo, true); + const callbackId = `${headers.serial}:${transactionId}:${tradeState}`; + const [callback] = await connection.execute( + `INSERT IGNORE INTO qipai_payment_callbacks + (tenant_id, payment_id, provider, callback_id, callback_type, + verified, payload) + VALUES (?, NULL, 'WECHAT_RECHARGE', ?, 'PAYMENT_NOTIFICATION', 1, CAST(? AS JSON))`, + [order.tenantId, callbackId, JSON.stringify(redactNotification(data))] + ); + if (callback.affectedRows === 0) { + return { rechargeOrderId: order.id, status: order.status, idempotent: true }; + } + if (tradeState !== 'SUCCESS' || total !== Number(order.payAmountCents)) { + const code = tradeState !== 'SUCCESS' + ? `WECHAT_TRADE_${tradeState}` : 'PAYMENT_AMOUNT_MISMATCH'; + await connection.execute( + `UPDATE qipai_payment_callbacks + SET processing_status = 'REJECTED', error_code = ?, + processed_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND provider = 'WECHAT_RECHARGE' AND callback_id = ?`, + [code, order.tenantId, callbackId] + ); + return { rechargeOrderId: order.id, status: 'REJECTED', code, idempotent: false }; + } + const credited = await this.creditRechargeOrder(connection, { + tenantId: String(order.tenantId), + rechargeOrderId: String(order.id), + providerTransactionId: transactionId, + providerCallbackId: callbackId, + rawNotify: redactNotification(data), + traceId + }); + await connection.execute( + `UPDATE qipai_payment_callbacks + SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND provider = 'WECHAT_RECHARGE' AND callback_id = ?`, + [order.tenantId, callbackId] + ); + return credited; + }); + } + private async loadPlan(connection: PoolConnection, tenantId: string, planId: string) { const [rows] = await connection.execute( `SELECT id, tenant_id AS tenantId, store_id AS storeId, name, @@ -217,6 +337,128 @@ export class RechargeService { }; } + private async loadOwnedRechargeOrder( + input: { tenantId: string; userId: string; rechargeOrderId: string }, + lock: boolean + ) { + const [rows] = await this.pool.execute( + `SELECT id, user_id AS userId, store_id AS storeId, plan_id AS planId, + recharge_no AS rechargeNo, pay_amount_cents AS payAmountCents, + gift_amount_cents AS giftAmountCents, status, + provider_payment_id AS providerPaymentId + FROM qipai_recharge_orders + WHERE tenant_id = ? AND id = ? AND user_id = ? + ${lock ? 'FOR UPDATE' : ''}`, + [input.tenantId, input.rechargeOrderId, input.userId] + ); + if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND'); + return normalizeRechargeOrder(rows[0]); + } + + private async loadRechargeOrderByNo( + connection: PoolConnection, + rechargeNo: string, + lock: boolean + ) { + const [rows] = await connection.execute>( + `SELECT id, tenant_id AS tenantId, user_id AS userId, + store_id AS storeId, plan_id AS planId, recharge_no AS rechargeNo, + pay_amount_cents AS payAmountCents, + gift_amount_cents AS giftAmountCents, status, + provider_payment_id AS providerPaymentId + FROM qipai_recharge_orders + WHERE recharge_no = ? + ${lock ? 'FOR UPDATE' : ''}`, + [rechargeNo] + ); + if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND'); + return { ...normalizeRechargeOrder(rows[0]), tenantId: String(rows[0].tenantId) }; + } + + private async creditRechargeOrder( + connection: PoolConnection, + input: { + tenantId: string; + rechargeOrderId: string; + providerTransactionId: string; + providerCallbackId: string; + rawNotify: Record; + traceId: string; + } + ) { + const order = await this.loadRechargeOrder(connection, input, true); + if (order.status === 'CREDITED') { + return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: true }; + } + if (order.status !== 'PENDING_PAYMENT' && order.status !== 'PAID') { + throw new RechargeError('RECHARGE_STATUS_INVALID'); + } + await connection.execute( + `UPDATE qipai_recharge_orders + SET status = 'PAID', + provider_payment_id = ?, + provider_callback_id = ?, + raw_notify = CAST(? AS JSON), + paid_at = COALESCE(paid_at, UTC_TIMESTAMP(3)) + WHERE tenant_id = ? AND id = ?`, + [ + input.providerTransactionId, + input.providerCallbackId, + JSON.stringify(input.rawNotify), + input.tenantId, + order.id + ] + ); + const plan = await this.loadPlan(connection, input.tenantId, order.planId); + await this.wallet.credit({ + tenantId: input.tenantId, + userId: String(order.userId), + scopeType: plan.scopeType, + storeId: plan.scopeType === 'STORE' ? order.storeId : null, + businessType: 'RECHARGE', + businessId: order.id, + entryType: 'RECHARGE', + cashDeltaCents: Number(order.payAmountCents), + giftDeltaCents: Number(order.giftAmountCents), + traceId: input.traceId, + metadata: { + provider: 'WECHAT', + providerTransactionId: input.providerTransactionId, + rechargeNo: order.rechargeNo + } + }); + await connection.execute( + `UPDATE qipai_recharge_orders + SET status = 'CREDITED', credited_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND id = ?`, + [input.tenantId, order.id] + ); + return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: false }; + } + + private resolveWechatCredential(reference: string) { + const credential = this.wechat?.credentials.get(reference) + ?? this.wechat?.credentials.get(reference.replace(/^env:/, '')); + if (!credential) throw new WechatPayError('WECHAT_CREDENTIAL_NOT_CONFIGURED'); + return credential; + } + + private verifyWithConfiguredCredential( + headers: WechatNotificationHeaders, + rawBody: string + ) { + let lastError: unknown; + for (const credential of this.wechat?.credentials.values() ?? []) { + if (!credential.platformCertificates[headers.serial]) continue; + try { + return this.wechat!.client.verifyAndDecrypt(credential, headers, rawBody); + } catch (error) { + lastError = error; + } + } + throw lastError ?? new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND'); + } + private async assertPurchaseLimit( connection: PoolConnection, tenantId: string, @@ -276,3 +518,52 @@ function rechargeResponse(row: RechargeOrderRow, idempotent: boolean) { idempotent }; } + +function normalizeRechargeOrder(row: RechargeOrderRow): RechargeOrderRow { + return { + ...row, + id: String(row.id), + userId: String(row.userId), + storeId: row.storeId === null ? null : String(row.storeId), + planId: String(row.planId), + payAmountCents: Number(row.payAmountCents), + giftAmountCents: Number(row.giftAmountCents) + }; +} + +function settingUrl( + settings: Record, key: string, fallback: string +) { + const value = settings[key]; + return typeof value === 'string' && value.startsWith('https://') ? value : fallback; +} + +function requiredString(value: Record, key: string) { + const field = value[key]; + if (typeof field !== 'string' || field.length === 0) { + throw new WechatPayError('WECHAT_RESOURCE_INVALID'); + } + return field; +} + +function requiredNumber(value: Record, key: string) { + const field = value[key]; + if (typeof field !== 'number' || !Number.isInteger(field)) { + throw new WechatPayError('WECHAT_RESOURCE_INVALID'); + } + return field; +} + +function objectValue(value: unknown) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new WechatPayError('WECHAT_RESOURCE_INVALID'); + } + return value as Record; +} + +function redactNotification(value: Record) { + const copy = { ...value }; + delete copy.payer; + delete copy.user_received_account; + return copy; +} diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 77c1aa5..2ca6340 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -78,6 +78,9 @@ const rechargeVerifySql = read('database/migrations/2026062422_m07b_recharge_pla const benefitUpSql = read('database/migrations/2026062423_m07c_benefits.up.sql'); const benefitDownSql = read('database/migrations/2026062423_m07c_benefits.down.sql'); const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.verify.sql'); +const rechargeWechatUpSql = read('database/migrations/2026062524_m08a_recharge_wechat.up.sql'); +const rechargeWechatDownSql = read('database/migrations/2026062524_m08a_recharge_wechat.down.sql'); +const rechargeWechatVerifySql = read('database/migrations/2026062524_m08a_recharge_wechat.verify.sql'); const coreTables = [ 'qipai_schema_migrations', @@ -364,4 +367,15 @@ assert.match(benefitUpSql, /remaining_amount_cents INT UNSIGNED/); assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_request/); assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_order_benefit/); -console.log('PASS: M01-B through M07-C migration contracts are present.'); +assert.match(rechargeWechatUpSql, /ALTER TABLE qipai_recharge_orders/); +assert.match(rechargeWechatUpSql, /provider_prepay_id VARCHAR\(128\) NULL/); +assert.match(rechargeWechatUpSql, /provider_payment_id VARCHAR\(128\) NULL/); +assert.match(rechargeWechatUpSql, /provider_callback_id VARCHAR\(128\) NULL/); +assert.match(rechargeWechatUpSql, /raw_notify JSON NULL/); +assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_payment/); +assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_callback/); +assert.match(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/); +assert.match(rechargeWechatVerifySql, /'provider_payment_id'/); +assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/); + +console.log('PASS: M01-B through M08-A migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index bd731d5..426fc75 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -34,7 +34,7 @@ assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/); assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/); assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/); assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/); -assert.match(plan.file, /2026062423_m07c_benefits\.up\.sql$/); +assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.ok(plan.statements.length >= 11); diff --git a/backend/tests/recharge-route.test.mjs b/backend/tests/recharge-route.test.mjs index a045927..71bcd21 100644 --- a/backend/tests/recharge-route.test.mjs +++ b/backend/tests/recharge-route.test.mjs @@ -10,6 +10,8 @@ const token = signAccessToken({ let createdInput; let listInput; +let prepayInput; +let notifyInput; const app = await buildApp({ recharge: { jwtSecret: secret, @@ -61,6 +63,23 @@ const app = await buildApp({ giftAmountCents: 2000, idempotent: false }; + }, + async createWechatPrepay(input) { + prepayInput = input; + return { + rechargeOrderId: input.rechargeOrderId, + rechargeNo: 'RCHTEST', + amountCents: 10000, + timeStamp: '1782360000', + nonceStr: 'nonce', + package: 'prepay_id=wx-recharge-prepay', + signType: 'RSA', + paySign: 'signed' + }; + }, + async processWechatNotification(headers, rawBody, traceId) { + notifyInput = { headers, rawBody, traceId }; + return { rechargeOrderId: '901', status: 'CREDITED', idempotent: false }; } } } @@ -91,6 +110,33 @@ assert.equal(createdInput.userId, '21'); assert.equal(createdInput.planId, '31'); assert.equal(createdInput.traceId, 'm08a-recharge-order'); +const prepay = await app.inject({ + method: 'POST', + url: '/app-api/recharge/orders/901/wechat-prepay', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(prepay.statusCode, 200); +assert.equal(prepayInput.tenantId, '7'); +assert.equal(prepayInput.platformAppId, '9'); +assert.equal(prepayInput.userId, '21'); +assert.equal(prepay.json().data.package, 'prepay_id=wx-recharge-prepay'); + +const notify = await app.inject({ + method: 'POST', + url: '/app-api/recharge/wechat/notify', + headers: { + 'content-type': 'application/json', + 'wechatpay-timestamp': '1782360001', + 'wechatpay-nonce': 'notify-nonce', + 'wechatpay-serial': 'PLATFORM-SERIAL', + 'wechatpay-signature': 'signature' + }, + payload: '{"resource":"encrypted"}' +}); +assert.equal(notify.statusCode, 200); +assert.equal(notifyInput.headers.serial, 'PLATFORM-SERIAL'); +assert.equal(notifyInput.rawBody, '{"resource":"encrypted"}'); + const invalid = await app.inject({ method: 'POST', url: '/app-api/recharge/orders', diff --git a/backend/tests/recharge-service.test.mjs b/backend/tests/recharge-service.test.mjs index 3b2fb3b..1f369bc 100644 --- a/backend/tests/recharge-service.test.mjs +++ b/backend/tests/recharge-service.test.mjs @@ -21,6 +21,7 @@ function createHarness(overrides = {}) { ...overrides.plan }, orders: [], + callbacks: [], nextOrderId: 900, purchaseCount: overrides.purchaseCount ?? 0, walletCredits: [] @@ -71,12 +72,60 @@ function createHarness(overrides = {}) { state.orders.push(order); return [{ insertId: order.id, affectedRows: 1 }, []]; } + if (sql.includes('FROM qipai_recharge_orders') && sql.includes('user_id = ?')) { + const order = state.orders.find((item) => + item.tenantId === String(params[0]) + && item.id === String(params[1]) + && item.userId === String(params[2]) + ); + return [order ? [toOrderRow(order)] : [], []]; + } + if (sql.includes('SELECT openid FROM qipai_user_identities')) { + return [[{ openid: 'openid-recharge-test' }], []]; + } + if (sql.includes('provider_prepay_id')) { + const order = state.orders.find((item) => + item.tenantId === String(params[1]) + && item.id === String(params[2]) + && item.userId === String(params[3]) + ); + order.providerPrepayId = params[0]; + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('FROM qipai_recharge_orders') && sql.includes('recharge_no = ?')) { + const order = state.orders.find((item) => item.rechargeNo === String(params[0])); + return [order ? [{ ...toOrderRow(order), tenantId: order.tenantId }] : [], []]; + } if (sql.includes('FROM qipai_recharge_orders') && sql.includes('FOR UPDATE')) { const order = state.orders.find((item) => item.tenantId === String(params[0]) && item.id === String(params[1]) ); return [order ? [toOrderRow(order)] : [], []]; } + if (sql.includes('INSERT IGNORE INTO qipai_payment_callbacks')) { + const duplicate = state.callbacks.some((item) => + item.tenantId === String(params[0]) && item.callbackId === String(params[1]) + ); + if (!duplicate) { + state.callbacks.push({ + tenantId: String(params[0]), + callbackId: String(params[1]), + status: 'RECEIVED', + payload: params[2] + }); + } + return [{ affectedRows: duplicate ? 0 : 1 }, []]; + } + if (sql.includes('provider_payment_id')) { + const order = state.orders.find((item) => + item.tenantId === String(params[3]) && item.id === String(params[4]) + ); + order.status = 'PAID'; + order.providerPaymentId = String(params[0]); + order.providerCallbackId = String(params[1]); + order.rawNotify = params[2]; + return [{ affectedRows: 1 }, []]; + } if (sql.includes("SET status = 'PAID'")) { const order = state.orders.find((item) => item.tenantId === String(params[1]) && item.id === String(params[2]) @@ -92,11 +141,23 @@ function createHarness(overrides = {}) { order.status = 'CREDITED'; return [{ affectedRows: 1 }, []]; } + if (sql.includes('UPDATE qipai_payment_callbacks')) { + const callback = state.callbacks.find((item) => + item.tenantId === String(params[0]) && item.callbackId === String(params[1]) + ) ?? state.callbacks.find((item) => + item.tenantId === String(params[1]) && item.callbackId === String(params[2]) + ); + if (callback) callback.status = sql.includes('REJECTED') ? 'REJECTED' : 'PROCESSED'; + return [{ affectedRows: 1 }, []]; + } throw new Error(`Unexpected SQL: ${sql}`); } }; const pool = { + async execute(sql, params) { + return connection.execute(sql, params); + }, async getConnection() { return connection; } @@ -113,7 +174,58 @@ function createHarness(overrides = {}) { } }; - return { service: new RechargeService(pool, wallet), state }; + const wechat = { + paymentRepository: { + async resolveConfig(_connection, tenantId, platformAppId, storeId, provider) { + state.resolvedConfig = { tenantId, platformAppId, storeId, provider }; + return { + credentialRef: 'wechat-test', + settings: { + rechargeDescription: '测试充值', + rechargeNotifyUrl: 'https://api.txyundm.cn/app-api/recharge/wechat/notify' + } + }; + } + }, + client: { + async createJsapiPrepay(_credential, input) { + state.prepayInput = input; + return { + prepayId: 'wx-recharge-prepay', + paymentParams: { + timeStamp: '1782360000', + nonceStr: 'nonce', + package: 'prepay_id=wx-recharge-prepay', + signType: 'RSA', + paySign: 'signed' + } + }; + }, + verifyAndDecrypt(_credential, headers, rawBody) { + state.verifiedNotification = { headers, rawBody }; + return { + out_trade_no: state.orders[0].rechargeNo, + transaction_id: 'wx-recharge-transaction', + trade_state: 'SUCCESS', + amount: { total: state.orders[0].payAmountCents }, + payer: { openid: 'redacted' } + }; + } + }, + credentials: new Map([[ + 'wechat-test', + { + appId: 'wx-test-app', + merchantId: '1900000109', + serialNo: 'MERCHANT-SERIAL', + privateKeyPem: 'test-private-key', + apiV3Key: '0123456789abcdef0123456789abcdef', + platformCertificates: { 'PLATFORM-SERIAL': 'test-cert' } + } + ]]) + }; + + return { service: new RechargeService(pool, wallet, wechat), state }; } function toOrderRow(order) { @@ -217,4 +329,41 @@ const baseOrder = { assert.equal(state.walletCredits.length, 1); } +{ + const { service, state } = createHarness(); + const created = await service.createRechargeOrder(baseOrder); + const prepay = await service.createWechatPrepay({ + tenantId: '7', + platformAppId: '9', + userId: '21', + rechargeOrderId: created.rechargeOrderId + }); + assert.equal(prepay.package, 'prepay_id=wx-recharge-prepay'); + assert.equal(state.resolvedConfig.storeId, '11'); + assert.equal(state.prepayInput.outTradeNo, created.rechargeNo); + assert.equal(state.prepayInput.amountCents, 10000); + assert.equal(state.orders[0].providerPrepayId, 'wx-recharge-prepay'); + + const notified = await service.processWechatNotification({ + timestamp: '1782360001', + nonce: 'notify-nonce', + serial: 'PLATFORM-SERIAL', + signature: 'signed' + }, '{"resource":"encrypted"}', 'recharge-notify'); + assert.equal(notified.status, 'CREDITED'); + assert.equal(state.orders[0].providerPaymentId, 'wx-recharge-transaction'); + assert.equal(state.orders[0].providerCallbackId, 'PLATFORM-SERIAL:wx-recharge-transaction:SUCCESS'); + assert.equal(state.walletCredits.length, 1); + assert.equal(JSON.parse(state.orders[0].rawNotify).payer, undefined); + + const duplicate = await service.processWechatNotification({ + timestamp: '1782360001', + nonce: 'notify-nonce', + serial: 'PLATFORM-SERIAL', + signature: 'signed' + }, '{"resource":"encrypted"}', 'recharge-notify'); + assert.equal(duplicate.idempotent, true); + assert.equal(state.walletCredits.length, 1); +} + console.log('PASS: M07-B recharge plans validate limits and credit paid callbacks idempotently.'); diff --git a/database/migrations/2026062524_m08a_recharge_wechat.down.sql b/database/migrations/2026062524_m08a_recharge_wechat.down.sql new file mode 100644 index 0000000..5a5e1b1 --- /dev/null +++ b/database/migrations/2026062524_m08a_recharge_wechat.down.sql @@ -0,0 +1,11 @@ +ALTER TABLE qipai_recharge_orders + DROP KEY uq_qipai_recharge_provider_callback, + DROP KEY uq_qipai_recharge_provider_payment, + DROP COLUMN raw_notify, + DROP COLUMN provider_callback_id, + DROP COLUMN provider_payment_id, + DROP COLUMN provider_prepay_id, + DROP COLUMN payment_provider; + +DELETE FROM qipai_schema_migrations +WHERE version = '2026062524'; diff --git a/database/migrations/2026062524_m08a_recharge_wechat.up.sql b/database/migrations/2026062524_m08a_recharge_wechat.up.sql new file mode 100644 index 0000000..b03600a --- /dev/null +++ b/database/migrations/2026062524_m08a_recharge_wechat.up.sql @@ -0,0 +1,13 @@ +ALTER TABLE qipai_recharge_orders + ADD COLUMN payment_provider VARCHAR(32) NOT NULL DEFAULT 'WECHAT' AFTER payment_id, + ADD COLUMN provider_prepay_id VARCHAR(128) NULL AFTER payment_provider, + ADD COLUMN provider_payment_id VARCHAR(128) NULL AFTER provider_prepay_id, + ADD COLUMN provider_callback_id VARCHAR(128) NULL AFTER provider_payment_id, + ADD COLUMN raw_notify JSON NULL AFTER provider_callback_id, + ADD UNIQUE KEY uq_qipai_recharge_provider_payment + (tenant_id, payment_provider, provider_payment_id), + ADD UNIQUE KEY uq_qipai_recharge_provider_callback + (tenant_id, payment_provider, provider_callback_id); + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026062524', 'm08a_recharge_wechat'); diff --git a/database/migrations/2026062524_m08a_recharge_wechat.verify.sql b/database/migrations/2026062524_m08a_recharge_wechat.verify.sql new file mode 100644 index 0000000..6d1ef37 --- /dev/null +++ b/database/migrations/2026062524_m08a_recharge_wechat.verify.sql @@ -0,0 +1,20 @@ +SELECT COUNT(*) AS expected_columns +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'qipai_recharge_orders' + AND column_name IN ( + 'payment_provider', + 'provider_prepay_id', + 'provider_payment_id', + 'provider_callback_id', + 'raw_notify' + ); + +SELECT COUNT(*) AS expected_indexes +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'qipai_recharge_orders' + AND index_name IN ( + 'uq_qipai_recharge_provider_payment', + 'uq_qipai_recharge_provider_callback' + ); diff --git a/docs/api-changelog/2026-06-25-M08-A-recharge-wechat.md b/docs/api-changelog/2026-06-25-M08-A-recharge-wechat.md new file mode 100644 index 0000000..a09d19b --- /dev/null +++ b/docs/api-changelog/2026-06-25-M08-A-recharge-wechat.md @@ -0,0 +1,11 @@ +# M08-A 充值微信支付 API + +- `POST /app-api/recharge/orders/:rechargeOrderId/wechat-prepay` + - 鉴权:顾客登录态,需 `profile.read`。 + - 行为:校验充值单属于当前用户,读取微信支付配置和当前用户 openid,创建 JSAPI 预支付。 + - 返回:`rechargeOrderId`、`rechargeNo`、`amountCents`、`timeStamp`、`nonceStr`、`package`、`signType`、`paySign`。 + +- `POST /app-api/recharge/wechat/notify` + - 鉴权:微信支付平台证书签名和 AES-GCM 资源解密。 + - 行为:按 `out_trade_no` 定位充值单,校验金额,写入 `qipai_payment_callbacks` 幂等记录,成功后给钱包现金/赠送余额入账。 + - 隐私:通知 payload 入库前移除 `payer` 等敏感对象。 diff --git a/docs/db-changelog/2026-06-25-M08-A-recharge-wechat.md b/docs/db-changelog/2026-06-25-M08-A-recharge-wechat.md new file mode 100644 index 0000000..29fab30 --- /dev/null +++ b/docs/db-changelog/2026-06-25-M08-A-recharge-wechat.md @@ -0,0 +1,7 @@ +# M08-A 充值微信支付字段 + +- 迁移:`database/migrations/2026062524_m08a_recharge_wechat.*.sql` +- 目的:让充值单独立记录微信预支付、交易号、回调幂等键和脱敏通知,不复用订单支付的 `order_id` 语义。 +- 正向变更:`qipai_recharge_orders` 新增 `payment_provider`、`provider_prepay_id`、`provider_payment_id`、`provider_callback_id`、`raw_notify`,并增加交易号与回调唯一键。 +- 回滚:删除新增唯一键和字段,删除迁移版本 `2026062524`。 +- 验证:检查 5 个新增字段和 2 个唯一索引存在。 diff --git a/docs/devlogs/2026-06-24-M08-A-顾客端.md b/docs/devlogs/2026-06-24-M08-A-顾客端.md index ede0555..f2c6726 100644 --- a/docs/devlogs/2026-06-24-M08-A-顾客端.md +++ b/docs/devlogs/2026-06-24-M08-A-顾客端.md @@ -157,3 +157,32 @@ M08-A 仍为 `PARTIAL`。本次完成顾客端第一段工程闭环,但订单 - 工程提交:`e24920c` - 远端校验:待本轮 push 后执行 `HEAD == origin/main`。 + +## 2026-06-25 续接:顾客端充值微信支付调起 + +- 数据库新增 `2026062524_m08a_recharge_wechat` 迁移,为 `qipai_recharge_orders` 增加微信预支付 ID、微信交易号、回调幂等 ID 和脱敏通知 JSON,避免把充值单伪造成订单支付。 +- `RechargeService` 新增 `createWechatPrepay` 和 `processWechatNotification`,充值预支付复用微信支付配置、顾客 openid 和 JSAPI 参数生成;微信通知验签解密后按充值单号入账,重复通知不重复加余额。 +- `POST /app-api/recharge/orders/:rechargeOrderId/wechat-prepay` 暴露给小程序,校验当前登录顾客只能为本人充值单调起支付。 +- `POST /app-api/recharge/wechat/notify` 新增充值微信支付通知入口,通知 payload 入库前移除 `payer`。 +- 小程序充值页新增“微信支付”按钮,创建充值单后可调起微信支付;到账结果以后端通知和个人中心余额为准。 +- 迁移 runner 白名单纳入新迁移,并补充 API/DB changelog。 + +验收: + +- `npm run build`(`backend/`):PASS。 +- `node scripts/check-miniapp-m08-a.mjs`:PASS。 +- `node tests/recharge-service.test.mjs`:PASS。 +- `node tests/recharge-route.test.mjs`:PASS。 +- `node tests/migration-contract.test.mjs`:PASS。 +- `node tests/migration-runner.test.mjs`:PASS。 +- `node tests/wechat-pay.test.mjs`:PASS。 +- `npm test`(`backend/`):PASS。 + +状态: + +- M08-A 仍为 `PARTIAL`;余额/套餐/优惠券下单抵扣深度集成、真机合法域名、真实微信支付和实物开门验证待继续。 + +提交: + +- 工程提交:待本轮 commit 后记录。 +- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。 diff --git a/miniapp/pages/recharge/index.js b/miniapp/pages/recharge/index.js index f418c0f..7e96e5e 100644 --- a/miniapp/pages/recharge/index.js +++ b/miniapp/pages/recharge/index.js @@ -3,6 +3,7 @@ const { ensureLogin, cents, clientRequestId, + requestWechatPayment, } = require('../../utils/api.js') Page({ @@ -76,6 +77,21 @@ Page({ }) }, + async payRechargeOrder() { + if (!this.data.rechargeOrder || !this.data.rechargeOrder.rechargeOrderId) { + this.setData({ errorMessage: '请先创建充值单' }) + return + } + await this.withRequest(async () => { + const response = await request( + `/recharge/orders/${encodeURIComponent(this.data.rechargeOrder.rechargeOrderId)}/wechat-prepay`, + { method: 'POST' } + ) + await requestWechatPayment(response.data) + this.setData({ successMessage: '微信支付已提交,到账结果以服务端通知为准' }) + }) + }, + async withRequest(work) { this.setData({ loading: true, errorMessage: '', successMessage: '' }) try { diff --git a/miniapp/pages/recharge/index.wxml b/miniapp/pages/recharge/index.wxml index 785c6e9..abcf4cf 100644 --- a/miniapp/pages/recharge/index.wxml +++ b/miniapp/pages/recharge/index.wxml @@ -30,6 +30,7 @@ 状态:{{rechargeOrder.status}} 实付:{{rechargeOrder.payText}} 赠送:{{rechargeOrder.giftText}} + diff --git a/scripts/check-miniapp-m08-a.mjs b/scripts/check-miniapp-m08-a.mjs index 556cd0b..6b2db9e 100644 --- a/scripts/check-miniapp-m08-a.mjs +++ b/scripts/check-miniapp-m08-a.mjs @@ -94,6 +94,8 @@ const recharge = read('miniapp/pages/recharge/index.js') for (const pattern of [ '/recharge/plans', '/recharge/orders', + '/wechat-prepay', + 'requestWechatPayment', 'clientRequestId', 'selectedPlanId', 'rechargeOrder'