From 4c66e192b9f90b3988b59115e98d6c4353d04851 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 22 Jun 2026 11:15:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(M05-B):=20=E5=AE=8C=E6=88=90=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E9=80=80=E6=AC=BE=E4=B8=8E=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 1 + backend/package.json | 2 +- backend/src/app.ts | 2 + backend/src/config.ts | 4 +- backend/src/db/migration-runner.ts | 10 +- backend/src/payments/wechat-pay-client.ts | 288 ++++++++ .../src/payments/wechat-payment-service.ts | 616 ++++++++++++++++++ backend/src/routes/payments.ts | 158 ++++- backend/src/server.ts | 15 +- backend/tests/migration-contract.test.mjs | 12 +- backend/tests/migration-runner.test.mjs | 3 +- .../tests/mysql-migration-roundtrip.test.mjs | 17 +- backend/tests/wechat-pay.test.mjs | 137 ++++ .../2026062216_m05b_wechat_refunds.down.sql | 10 + .../2026062216_m05b_wechat_refunds.up.sql | 33 + .../2026062216_m05b_wechat_refunds.verify.sql | 22 + scripts/dev/wsl/mysql-migration-roundtrip.sh | 4 +- 17 files changed, 1316 insertions(+), 18 deletions(-) create mode 100644 backend/src/payments/wechat-pay-client.ts create mode 100644 backend/src/payments/wechat-payment-service.ts create mode 100644 backend/tests/wechat-pay.test.mjs create mode 100644 database/migrations/2026062216_m05b_wechat_refunds.down.sql create mode 100644 database/migrations/2026062216_m05b_wechat_refunds.up.sql create mode 100644 database/migrations/2026062216_m05b_wechat_refunds.verify.sql diff --git a/backend/.env.example b/backend/.env.example index bef9430..08868d7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,6 +13,7 @@ QIPAI_ACCESS_TOKEN_TTL_SECONDS=900 QIPAI_SESSION_TTL_SECONDS=604800 QIPAI_WECHAT_APP_SECRETS={} QIPAI_TEST_PAYMENT_ENABLED=false +QIPAI_WECHAT_PAY_CREDENTIALS={} QIPAI_MQTT_URL=mqtt://101.42.38.246:1883 QIPAI_MQTT_USERNAME= QIPAI_MQTT_PASSWORD= diff --git a/backend/package.json b/backend/package.json index 10a364d..69274ad 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,7 @@ "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", - "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs" + "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 0656088..51cd465 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -58,6 +58,7 @@ export interface BuildAppOptions { declare module 'fastify' { interface FastifyRequest { traceId: string; + rawBody: string; } } @@ -71,6 +72,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { const traceHeader = request.headers['x-trace-id']; request.traceId = Array.isArray(traceHeader) ? traceHeader[0] : traceHeader || request.id; diff --git a/backend/src/config.ts b/backend/src/config.ts index 8b9113b..c1003bf 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -17,6 +17,7 @@ const configSchema = z.object({ QIPAI_SESSION_TTL_SECONDS: z.coerce.number().int().min(300).max(2592000).default(604800), QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'), QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'), + QIPAI_WECHAT_PAY_CREDENTIALS: z.string().default('{}'), QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'), QIPAI_MQTT_USERNAME: z.string().default(''), QIPAI_MQTT_PASSWORD: z.string().default('') @@ -56,7 +57,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) { }, payment: { testAdapterEnabled: parsed.NODE_ENV !== 'production' - && parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true' + && parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true', + wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS }, mqtt: { url: parsed.QIPAI_MQTT_URL, diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index 11a318c..52c3070 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -35,7 +35,8 @@ const migrationFiles: Record = { 'database/migrations/2026062012_m04b_order_state_machine.up.sql', 'database/migrations/2026062013_m04c_order_adjustments.up.sql', 'database/migrations/2026062014_m04d_order_shares.up.sql', - 'database/migrations/2026062015_m05a_payment_domain.up.sql' + 'database/migrations/2026062015_m05a_payment_domain.up.sql', + 'database/migrations/2026062216_m05b_wechat_refunds.up.sql' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -52,9 +53,11 @@ const migrationFiles: Record = { 'database/migrations/2026062012_m04b_order_state_machine.verify.sql', 'database/migrations/2026062013_m04c_order_adjustments.verify.sql', 'database/migrations/2026062014_m04d_order_shares.verify.sql', - 'database/migrations/2026062015_m05a_payment_domain.verify.sql' + 'database/migrations/2026062015_m05a_payment_domain.verify.sql', + 'database/migrations/2026062216_m05b_wechat_refunds.verify.sql' ], down: [ + 'database/migrations/2026062216_m05b_wechat_refunds.down.sql', 'database/migrations/2026062015_m05a_payment_domain.down.sql', 'database/migrations/2026062014_m04d_order_shares.down.sql', 'database/migrations/2026062013_m04c_order_adjustments.down.sql', @@ -200,7 +203,8 @@ export async function executeMigrationPlan( 2, 1, 3, 1, 2, 2, 1, 2, 1, 1, 8, 3, 1, - 5, 8, 4, 1 + 5, 8, 4, 1, + 1, 5, 3, 1 ][index] ?? 1; if (!Array.isArray(result) || result.length < minimumRows) { throw new Error( diff --git a/backend/src/payments/wechat-pay-client.ts b/backend/src/payments/wechat-pay-client.ts new file mode 100644 index 0000000..4538cdc --- /dev/null +++ b/backend/src/payments/wechat-pay-client.ts @@ -0,0 +1,288 @@ +import { + constants, createDecipheriv, createSign, createVerify, randomBytes +} from 'node:crypto'; + +export interface WechatPayCredential { + appId: string; + merchantId: string; + serialNo: string; + privateKeyPem: string; + apiV3Key: string; + platformCertificates: Record; +} + +export interface WechatPayTransport { + request(input: { + method: 'GET' | 'POST'; + url: string; + headers: Record; + body?: string; + }): Promise<{ status: number; headers: Record; body: string }>; +} + +export interface WechatNotificationHeaders { + timestamp: string; + nonce: string; + serial: string; + signature: string; +} + +export class WechatPayError extends Error { + constructor(public readonly code: string, message = code) { + super(message); + } +} + +export class FetchWechatPayTransport implements WechatPayTransport { + async request(input: { + method: 'GET' | 'POST'; + url: string; + headers: Record; + body?: string; + }) { + const response = await fetch(input.url, { + method: input.method, + headers: input.headers, + body: input.body + }); + return { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + body: await response.text() + }; + } +} + +export class WechatPayClient { + constructor( + private readonly transport: WechatPayTransport, + private readonly apiBase = 'https://api.mch.weixin.qq.com' + ) {} + + async createJsapiPrepay( + credential: WechatPayCredential, + input: { + description: string; + outTradeNo: string; + notifyUrl: string; + amountCents: number; + payerOpenId: string; + } + ) { + const result = await this.apiRequest(credential, 'POST', '/v3/pay/transactions/jsapi', { + appid: credential.appId, + mchid: credential.merchantId, + description: input.description.slice(0, 127), + out_trade_no: input.outTradeNo, + notify_url: input.notifyUrl, + amount: { total: input.amountCents, currency: 'CNY' }, + payer: { openid: input.payerOpenId } + }); + const prepayId = stringField(result, 'prepay_id'); + const timeStamp = String(Math.floor(Date.now() / 1000)); + const nonceStr = randomBytes(16).toString('hex'); + const packageValue = `prepay_id=${prepayId}`; + const paySign = signMessage( + credential.privateKeyPem, + `${credential.appId}\n${timeStamp}\n${nonceStr}\n${packageValue}\n` + ); + return { + prepayId, + paymentParams: { + timeStamp, + nonceStr, + package: packageValue, + signType: 'RSA' as const, + paySign + } + }; + } + + async queryTransaction(credential: WechatPayCredential, outTradeNo: string) { + return this.apiRequest( + credential, + 'GET', + `/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}` + + `?mchid=${encodeURIComponent(credential.merchantId)}` + ); + } + + async createRefund( + credential: WechatPayCredential, + input: { + outTradeNo: string; + outRefundNo: string; + reason: string; + notifyUrl: string; + refundCents: number; + totalCents: number; + } + ) { + return this.apiRequest(credential, 'POST', '/v3/refund/domestic/refunds', { + out_trade_no: input.outTradeNo, + out_refund_no: input.outRefundNo, + reason: input.reason.slice(0, 80), + notify_url: input.notifyUrl, + amount: { + refund: input.refundCents, + total: input.totalCents, + currency: 'CNY' + } + }); + } + + async downloadTradeBill( + credential: WechatPayCredential, + billDate: string, + billType: 'ALL' | 'SUCCESS' | 'REFUND' + ) { + return this.apiRequest( + credential, + 'GET', + `/v3/bill/tradebill?bill_date=${encodeURIComponent(billDate)}` + + `&bill_type=${encodeURIComponent(billType)}` + ); + } + + verifyAndDecrypt( + credential: WechatPayCredential, + headers: WechatNotificationHeaders, + rawBody: string + ): Record { + const certificate = credential.platformCertificates[headers.serial]; + if (!certificate) throw new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND'); + const verifier = createVerify('RSA-SHA256'); + verifier.update(`${headers.timestamp}\n${headers.nonce}\n${rawBody}\n`); + verifier.end(); + if (!verifier.verify(certificate, headers.signature, 'base64')) { + throw new WechatPayError('WECHAT_SIGNATURE_INVALID'); + } + const envelope = parseJson(rawBody); + const resource = objectField(envelope, 'resource'); + const ciphertext = Buffer.from(stringField(resource, 'ciphertext'), 'base64'); + if (ciphertext.length <= 16) throw new WechatPayError('WECHAT_RESOURCE_INVALID'); + const decipher = createDecipheriv( + 'aes-256-gcm', + Buffer.from(credential.apiV3Key, 'utf8'), + Buffer.from(stringField(resource, 'nonce'), 'utf8') + ); + const associatedData = optionalStringField(resource, 'associated_data'); + if (associatedData) decipher.setAAD(Buffer.from(associatedData, 'utf8')); + decipher.setAuthTag(ciphertext.subarray(ciphertext.length - 16)); + const plaintext = Buffer.concat([ + decipher.update(ciphertext.subarray(0, ciphertext.length - 16)), + decipher.final() + ]).toString('utf8'); + return parseJson(plaintext); + } + + private async apiRequest( + credential: WechatPayCredential, + method: 'GET' | 'POST', + path: string, + payload?: unknown + ) { + const body = payload === undefined ? '' : JSON.stringify(payload); + const timestamp = String(Math.floor(Date.now() / 1000)); + const nonce = randomBytes(16).toString('hex'); + const signature = signMessage( + credential.privateKeyPem, + `${method}\n${path}\n${timestamp}\n${nonce}\n${body}\n` + ); + const response = await this.transport.request({ + method, + url: `${this.apiBase}${path}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${credential.merchantId}",` + + `nonce_str="${nonce}",timestamp="${timestamp}",` + + `serial_no="${credential.serialNo}",signature="${signature}"`, + 'User-Agent': 'qipai-backend/0.1' + }, + body: body || undefined + }); + if (response.status < 200 || response.status >= 300) { + throw new WechatPayError( + 'WECHAT_API_FAILED', + `Wechat Pay API returned HTTP ${response.status}.` + ); + } + return parseJson(response.body); + } +} + +export function parseWechatPayCredentials(value: string) { + const parsed = parseJson(value); + const credentials = new Map(); + for (const [key, raw] of Object.entries(parsed)) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new WechatPayError('WECHAT_CREDENTIAL_INVALID'); + } + const item = raw as Record; + const apiV3Key = stringField(item, 'apiV3Key'); + if (Buffer.byteLength(apiV3Key, 'utf8') !== 32) { + throw new WechatPayError('WECHAT_API_V3_KEY_INVALID'); + } + const certificates = objectField(item, 'platformCertificates'); + credentials.set(key, { + appId: stringField(item, 'appId'), + merchantId: stringField(item, 'merchantId'), + serialNo: stringField(item, 'serialNo'), + privateKeyPem: stringField(item, 'privateKeyPem'), + apiV3Key, + platformCertificates: Object.fromEntries( + Object.entries(certificates).map(([serial, certificate]) => { + if (typeof certificate !== 'string') { + throw new WechatPayError('WECHAT_CERTIFICATE_INVALID'); + } + return [serial, certificate]; + }) + ) + }); + } + return credentials; +} + +function signMessage(privateKeyPem: string, message: string) { + const signer = createSign('RSA-SHA256'); + signer.update(message); + signer.end(); + return signer.sign({ + key: privateKeyPem, + padding: constants.RSA_PKCS1_PADDING + }, 'base64'); +} + +function parseJson(value: string): Record { + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('object required'); + } + return parsed; + } catch { + throw new WechatPayError('WECHAT_JSON_INVALID'); + } +} + +function objectField(value: Record, key: string) { + const field = value[key]; + if (!field || typeof field !== 'object' || Array.isArray(field)) { + throw new WechatPayError('WECHAT_RESOURCE_INVALID'); + } + return field as Record; +} + +function stringField(value: Record, key: string) { + const field = value[key]; + if (typeof field !== 'string' || field.length === 0) { + throw new WechatPayError('WECHAT_RESOURCE_INVALID'); + } + return field; +} + +function optionalStringField(value: Record, key: string) { + const field = value[key]; + return typeof field === 'string' ? field : ''; +} diff --git a/backend/src/payments/wechat-payment-service.ts b/backend/src/payments/wechat-payment-service.ts new file mode 100644 index 0000000..e5923dc --- /dev/null +++ b/backend/src/payments/wechat-payment-service.ts @@ -0,0 +1,616 @@ +import { randomBytes } from 'node:crypto'; +import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; +import type { MySqlPool } from '../db/mysql.js'; +import type { PaymentRepository } from './payment-repository.js'; +import { + WechatPayClient, WechatPayError, type WechatNotificationHeaders, + type WechatPayCredential +} from './wechat-pay-client.js'; + +interface PaymentRow extends RowDataPacket { + id: string; + tenantId: string; + platformAppId: string; + orderId: string; + storeId: string; + paymentNo: string; + status: string; + amountCents: number; + paidAmountCents: number; + totalAmountCents: number; + orderStatus: string; +} + +interface RefundRow extends RowDataPacket { + id: string; + tenantId: string; + paymentId: string; + orderId: string; + refundNo: string; + status: string; + amountCents: number; +} + +export class WechatPaymentService { + constructor( + private readonly pool: MySqlPool, + private readonly paymentRepository: PaymentRepository, + private readonly client: WechatPayClient, + private readonly credentials: ReadonlyMap + ) {} + + async createPrepay(input: { + tenantId: string; + platformAppId: string; + userId: string; + paymentId: string; + }) { + const payment = await this.loadOwnedPayment(input, false); + if (payment.status === 'SUCCEEDED') { + throw new WechatPayError('PAYMENT_ALREADY_SUCCEEDED'); + } + if (payment.status !== 'PENDING') throw new WechatPayError('PAYMENT_STATUS_INVALID'); + const config = await this.paymentRepository.resolveConfig( + this.pool, input.tenantId, input.platformAppId, payment.storeId, 'WECHAT' + ); + const credential = this.resolveCredential(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.client.createJsapiPrepay(credential, { + description: typeof settings.description === 'string' + ? settings.description : `棋牌室订单 ${payment.paymentNo}`, + outTradeNo: payment.paymentNo, + notifyUrl: settingUrl( + settings, 'paymentNotifyUrl', 'https://api.txyundm.cn/app-api/pay/wechat/notify' + ), + amountCents: Number(payment.amountCents), + payerOpenId: String(identityRows[0].openid) + }); + await this.pool.execute( + `UPDATE qipai_payment_attempts + SET status = 'PREPAY_CREATED', + response_payload = JSON_OBJECT( + 'prepayId', ?, 'credentialExposed', FALSE + ), + completed_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND payment_id = ? AND attempt_no = 1`, + [result.prepayId, input.tenantId, input.paymentId] + ); + return { + paymentId: input.paymentId, + paymentNo: payment.paymentNo, + amountCents: Number(payment.amountCents), + ...result.paymentParams + }; + } + + async queryPayment(input: { + tenantId: string; + platformAppId: string; + userId: string; + paymentId: string; + }) { + const payment = await this.loadOwnedPayment(input, false); + const config = await this.paymentRepository.resolveConfig( + this.pool, input.tenantId, input.platformAppId, payment.storeId, 'WECHAT' + ); + const result = await this.client.queryTransaction( + this.resolveCredential(config.credentialRef), payment.paymentNo + ); + return { + paymentId: payment.id, + localStatus: payment.status, + providerStatus: stringValue(result.trade_state), + providerTransactionId: stringValue(result.transaction_id) + }; + } + + async processPaymentNotification( + headers: WechatNotificationHeaders, + rawBody: string, + traceId: string + ) { + const data = this.verifyWithConfiguredCredential(headers, rawBody); + const paymentNo = 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 payment = await this.loadPaymentByNo(connection, paymentNo, 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 (?, ?, 'WECHAT', ?, 'PAYMENT_NOTIFICATION', 1, CAST(? AS JSON))`, + [payment.tenantId, payment.id, callbackId, JSON.stringify(redactNotification(data))] + ); + if (callback.affectedRows === 0) { + return { paymentId: payment.id, status: payment.status, idempotent: true }; + } + if (tradeState !== 'SUCCESS' || total !== Number(payment.amountCents)) { + const code = tradeState !== 'SUCCESS' + ? `WECHAT_TRADE_${tradeState}` : 'PAYMENT_AMOUNT_MISMATCH'; + await this.rejectCallback(connection, payment.tenantId, callbackId, code); + return { paymentId: payment.id, status: 'REJECTED', code, idempotent: false }; + } + await this.applyPaymentSuccess( + connection, payment, transactionId, callbackId, traceId + ); + return { paymentId: payment.id, status: 'SUCCEEDED', idempotent: false }; + }); + } + + async createRefund(input: { + tenantId: string; + platformAppId: string; + actorId: string; + paymentId: string; + amountCents: number; + reason: string; + clientRequestId: string; + }) { + const payment = await this.loadPayment(input.tenantId, input.paymentId, false); + if (payment.status !== 'SUCCEEDED' && payment.status !== 'PARTIALLY_REFUNDED') { + throw new WechatPayError('PAYMENT_NOT_REFUNDABLE'); + } + const [sumRows] = await this.pool.execute( + `SELECT COALESCE(SUM(amount_cents), 0) AS refundedCents + FROM qipai_refunds + WHERE tenant_id = ? AND payment_id = ? + AND status IN ('PENDING', 'PROCESSING', 'SUCCEEDED')`, + [input.tenantId, input.paymentId] + ); + const refundable = Number(payment.amountCents) - Number(sumRows[0].refundedCents); + if (input.amountCents > refundable) throw new WechatPayError('REFUND_AMOUNT_EXCEEDED'); + const [existing] = await this.pool.execute( + `SELECT id, tenant_id AS tenantId, payment_id AS paymentId, + order_id AS orderId, refund_no AS refundNo, status, + amount_cents AS amountCents + FROM qipai_refunds + WHERE tenant_id = ? AND client_request_id = ? LIMIT 1`, + [input.tenantId, input.clientRequestId] + ); + if (existing[0]) { + if (String(existing[0].paymentId) !== input.paymentId + || Number(existing[0].amountCents) !== input.amountCents) { + throw new WechatPayError('REFUND_IDEMPOTENCY_CONFLICT'); + } + return { ...normalizeRefund(existing[0]), idempotent: true }; + } + const refundNo = `REF${Date.now()}${randomBytes(5).toString('hex').toUpperCase()}`; + const [insert] = await this.pool.execute( + `INSERT INTO qipai_refunds + (tenant_id, payment_id, order_id, client_request_id, provider, + refund_no, status, amount_cents, reason) + VALUES (?, ?, ?, ?, 'WECHAT', ?, 'PENDING', ?, ?)`, + [input.tenantId, input.paymentId, payment.orderId, input.clientRequestId, + refundNo, input.amountCents, input.reason.slice(0, 512)] + ); + const config = await this.paymentRepository.resolveConfig( + this.pool, input.tenantId, input.platformAppId, payment.storeId, 'WECHAT' + ); + const settings = config.settings as Record; + try { + const response = await this.client.createRefund( + this.resolveCredential(config.credentialRef), + { + outTradeNo: payment.paymentNo, + outRefundNo: refundNo, + reason: input.reason, + notifyUrl: settingUrl( + settings, 'refundNotifyUrl', 'https://api.txyundm.cn/app-api/pay/wechat/refund-notify' + ), + refundCents: input.amountCents, + totalCents: Number(payment.amountCents) + } + ); + const providerRefundId = requiredString(response, 'refund_id'); + const status = mapRefundStatus(requiredString(response, 'status')); + await this.pool.execute( + `UPDATE qipai_refunds + SET status = ?, provider_refund_id = ?, raw_response = CAST(? AS JSON), + completed_at = IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), NULL) + WHERE tenant_id = ? AND id = ?`, + [status, providerRefundId, JSON.stringify(redactNotification(response)), + status, input.tenantId, insert.insertId] + ); + if (status === 'SUCCEEDED') { + await this.finalizeRefund(input.tenantId, String(insert.insertId), 'refund-api'); + } + return { + refundId: String(insert.insertId), refundNo, status, + amountCents: input.amountCents, refundableCents: refundable - input.amountCents, + idempotent: false + }; + } catch (error) { + await this.pool.execute( + `UPDATE qipai_refunds + SET status = 'MANUAL_REVIEW', failure_code = ? + WHERE tenant_id = ? AND id = ?`, + [error instanceof WechatPayError ? error.code : 'WECHAT_REFUND_FAILED', + input.tenantId, insert.insertId] + ); + throw error; + } + } + + async processRefundNotification( + headers: WechatNotificationHeaders, + rawBody: string + ) { + const data = this.verifyWithConfiguredCredential(headers, rawBody); + const refundNo = requiredString(data, 'out_refund_no'); + const providerRefundId = requiredString(data, 'refund_id'); + const status = mapRefundStatus(requiredString(data, 'refund_status')); + const [rows] = await this.pool.execute( + `SELECT id, tenant_id AS tenantId, payment_id AS paymentId, + order_id AS orderId, refund_no AS refundNo, status, + amount_cents AS amountCents + FROM qipai_refunds WHERE refund_no = ? LIMIT 1`, + [refundNo] + ); + const refund = rows[0]; + if (!refund) throw new WechatPayError('REFUND_NOT_FOUND'); + if (refund.status === 'SUCCEEDED') { + return { refundId: String(refund.id), status: refund.status, idempotent: true }; + } + const callbackId = `${headers.serial}:${providerRefundId}:${status}`; + const [result] = await this.pool.execute( + `UPDATE qipai_refunds + SET status = ?, provider_refund_id = ?, provider_callback_id = ?, + raw_response = CAST(? AS JSON), + completed_at = IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), completed_at), + failure_code = IF(? = 'FAILED', 'WECHAT_REFUND_FAILED', failure_code) + WHERE tenant_id = ? AND id = ? + AND (provider_callback_id IS NULL OR provider_callback_id <> ?)`, + [status, providerRefundId, callbackId, + JSON.stringify(redactNotification(data)), status, status, + refund.tenantId, refund.id, callbackId] + ); + if (result.affectedRows === 0) { + return { refundId: String(refund.id), status: refund.status, idempotent: true }; + } + if (status === 'SUCCEEDED') { + await this.finalizeRefund(refund.tenantId, String(refund.id), callbackId); + } + return { refundId: String(refund.id), status, idempotent: false }; + } + + async requestReconciliation(input: { + tenantId: string; + platformAppId: string; + storeId: string; + actorId: string; + billDate: string; + billType: 'ALL' | 'SUCCESS' | 'REFUND'; + }) { + const config = await this.paymentRepository.resolveConfig( + this.pool, input.tenantId, input.platformAppId, input.storeId, 'WECHAT' + ); + const [existing] = await this.pool.execute( + `SELECT id, status, download_url AS downloadUrl + FROM qipai_reconciliation_runs + WHERE tenant_id = ? AND payment_config_id = ? AND bill_date = ? + AND bill_type = ? LIMIT 1`, + [input.tenantId, config.id, input.billDate, input.billType] + ); + if (existing[0]) return { ...existing[0], idempotent: true }; + const response = await this.client.downloadTradeBill( + this.resolveCredential(config.credentialRef), input.billDate, input.billType + ); + const downloadUrl = requiredString(response, 'download_url'); + const [result] = await this.pool.execute( + `INSERT INTO qipai_reconciliation_runs + (tenant_id, payment_config_id, bill_date, bill_type, status, + download_url, requested_by, completed_at) + VALUES (?, ?, ?, ?, 'READY', ?, ?, UTC_TIMESTAMP(3))`, + [input.tenantId, config.id, input.billDate, input.billType, + downloadUrl, input.actorId] + ); + return { id: String(result.insertId), status: 'READY', downloadUrl, idempotent: false }; + } + + private resolveCredential(reference: string) { + const credential = this.credentials.get(reference) + ?? this.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.credentials.values()) { + if (!credential.platformCertificates[headers.serial]) continue; + try { + return this.client.verifyAndDecrypt(credential, headers, rawBody); + } catch (error) { + lastError = error; + } + } + throw lastError ?? new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND'); + } + + private async loadOwnedPayment(input: { + tenantId: string; + platformAppId: string; + userId: string; + paymentId: string; + }, lock: boolean) { + const payment = await this.loadPayment(input.tenantId, input.paymentId, lock); + if (String(payment.platformAppId) !== input.platformAppId) { + throw new WechatPayError('PAYMENT_APP_MISMATCH'); + } + const [access] = await this.pool.execute( + `SELECT 1 FROM qipai_order_user_access + WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`, + [input.tenantId, payment.orderId, input.userId] + ); + if (!access[0]) throw new WechatPayError('ORDER_ACCESS_FORBIDDEN'); + return payment; + } + + private async loadPayment(tenantId: string, paymentId: string, lock: boolean) { + const connection = lock ? await this.pool.getConnection() : this.pool; + try { + const [rows] = await connection.execute( + `SELECT p.id, p.tenant_id AS tenantId, p.platform_app_id AS platformAppId, + p.order_id AS orderId, p.store_id AS storeId, + p.payment_no AS paymentNo, p.status, + p.amount_cents AS amountCents, + o.paid_amount_cents AS paidAmountCents, + o.total_amount_cents AS totalAmountCents, o.status AS orderStatus + FROM qipai_payments p + INNER JOIN qipai_orders o ON o.tenant_id = p.tenant_id AND o.id = p.order_id + WHERE p.tenant_id = ? AND p.id = ? AND p.provider = 'WECHAT' + AND p.deleted_at IS NULL ${lock ? 'FOR UPDATE' : ''}`, + [tenantId, paymentId] + ); + if (!rows[0]) throw new WechatPayError('PAYMENT_NOT_FOUND'); + return rows[0]; + } finally { + if (lock && 'release' in connection) connection.release(); + } + } + + private async loadPaymentByNo( + connection: PoolConnection, paymentNo: string, lock: boolean + ) { + const [rows] = await connection.execute( + `SELECT p.id, p.tenant_id AS tenantId, p.platform_app_id AS platformAppId, + p.order_id AS orderId, p.store_id AS storeId, + p.payment_no AS paymentNo, p.status, + p.amount_cents AS amountCents, + o.paid_amount_cents AS paidAmountCents, + o.total_amount_cents AS totalAmountCents, o.status AS orderStatus + FROM qipai_payments p + INNER JOIN qipai_orders o ON o.tenant_id = p.tenant_id AND o.id = p.order_id + WHERE p.payment_no = ? AND p.provider = 'WECHAT' + AND p.deleted_at IS NULL ${lock ? 'FOR UPDATE' : ''}`, + [paymentNo] + ); + if (!rows[0]) throw new WechatPayError('PAYMENT_NOT_FOUND'); + return rows[0]; + } + + private async applyPaymentSuccess( + connection: PoolConnection, + payment: PaymentRow, + transactionId: string, + callbackId: string, + traceId: string + ) { + if (payment.status === 'SUCCEEDED') { + await connection.execute( + `UPDATE qipai_payment_callbacks + SET processing_status = 'DUPLICATE', processed_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND provider = 'WECHAT' AND callback_id = ?`, + [payment.tenantId, callbackId] + ); + return; + } + await connection.execute( + `UPDATE qipai_payments + SET status = 'SUCCEEDED', provider_payment_id = ?, + paid_at = UTC_TIMESTAMP(3), raw_notify = JSON_OBJECT('verified', TRUE) + WHERE tenant_id = ? AND id = ? AND status = 'PENDING'`, + [transactionId, payment.tenantId, payment.id] + ); + await connection.execute( + `UPDATE qipai_orders SET paid_amount_cents = paid_amount_cents + ? + WHERE tenant_id = ? AND id = ?`, + [payment.amountCents, payment.tenantId, payment.orderId] + ); + const nextPaid = Number(payment.paidAmountCents) + Number(payment.amountCents); + if (nextPaid >= Number(payment.totalAmountCents) + && payment.orderStatus === 'PENDING_PAYMENT') { + await connection.execute( + `UPDATE qipai_orders + SET status = 'PAID', status_version = status_version + 1, + status_updated_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND id = ?`, + [payment.tenantId, payment.orderId] + ); + await connection.execute( + `UPDATE qipai_room_reservations + SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at) + WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`, + [payment.tenantId, payment.orderId] + ); + await connection.execute( + `INSERT INTO qipai_order_status_history + (tenant_id, order_id, from_status, to_status, action, actor_type, + actor_id, source, reason, trace_id, metadata) + VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM', + NULL, 'PAYMENT', 'Verified Wechat payment callback', ?, + JSON_OBJECT('paymentId', ?))`, + [payment.tenantId, payment.orderId, traceId, payment.id] + ); + } + await connection.execute( + `UPDATE qipai_payment_callbacks + SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND provider = 'WECHAT' AND callback_id = ?`, + [payment.tenantId, callbackId] + ); + } + + private async rejectCallback( + connection: PoolConnection, tenantId: string, callbackId: string, code: string + ) { + await connection.execute( + `UPDATE qipai_payment_callbacks + SET processing_status = 'REJECTED', error_code = ?, + processed_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND provider = 'WECHAT' AND callback_id = ?`, + [code, tenantId, callbackId] + ); + } + + private async finalizeRefund(tenantId: string, refundId: string, traceId: string) { + await this.transaction(async (connection) => { + const [rows] = await connection.execute( + `SELECT id, tenant_id AS tenantId, payment_id AS paymentId, + order_id AS orderId, refund_no AS refundNo, status, + amount_cents AS amountCents + FROM qipai_refunds WHERE tenant_id = ? AND id = ? FOR UPDATE`, + [tenantId, refundId] + ); + const refund = rows[0]; + if (!refund || refund.status !== 'SUCCEEDED') return; + const [paymentRows] = await connection.execute( + `SELECT p.id, p.tenant_id AS tenantId, p.platform_app_id AS platformAppId, + p.order_id AS orderId, p.store_id AS storeId, + p.payment_no AS paymentNo, p.status, + p.amount_cents AS amountCents, + o.paid_amount_cents AS paidAmountCents, + o.total_amount_cents AS totalAmountCents, o.status AS orderStatus + FROM qipai_payments p + INNER JOIN qipai_orders o ON o.tenant_id = p.tenant_id AND o.id = p.order_id + WHERE p.tenant_id = ? AND p.id = ? FOR UPDATE`, + [tenantId, refund.paymentId] + ); + const payment = paymentRows[0]; + const [sumRows] = await connection.execute( + `SELECT COALESCE(SUM(amount_cents), 0) AS refundedCents + FROM qipai_refunds + WHERE tenant_id = ? AND payment_id = ? AND status = 'SUCCEEDED'`, + [tenantId, refund.paymentId] + ); + const refundedCents = Number(sumRows[0].refundedCents); + const paymentStatus = refundedCents >= Number(payment.amountCents) + ? 'REFUNDED' : 'PARTIALLY_REFUNDED'; + await connection.execute( + `UPDATE qipai_payments SET status = ? WHERE tenant_id = ? AND id = ?`, + [paymentStatus, tenantId, refund.paymentId] + ); + await connection.execute( + `UPDATE qipai_orders + SET paid_amount_cents = GREATEST(0, total_amount_cents - ?) + WHERE tenant_id = ? AND id = ?`, + [refundedCents, tenantId, refund.orderId] + ); + if (paymentStatus === 'REFUNDED' + && ['CANCELLED', 'REFUNDING'].includes(payment.orderStatus)) { + await connection.execute( + `UPDATE qipai_orders + SET status = 'REFUNDED', status_version = status_version + 1, + status_updated_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND id = ?`, + [tenantId, refund.orderId] + ); + await connection.execute( + `INSERT INTO qipai_order_status_history + (tenant_id, order_id, from_status, to_status, action, actor_type, + actor_id, source, reason, trace_id, metadata) + VALUES (?, ?, ?, 'REFUNDED', 'COMPLETE_REFUND', 'SYSTEM', NULL, + 'PAYMENT', 'Verified Wechat refund', ?, + JSON_OBJECT('refundId', ?, 'amountCents', ?))`, + [tenantId, refund.orderId, payment.orderStatus, traceId, + refund.id, refund.amountCents] + ); + } + }); + } + + private async transaction(work: (connection: PoolConnection) => Promise) { + const connection = await this.pool.getConnection(); + try { + await connection.beginTransaction(); + const result = await work(connection); + await connection.commit(); + return result; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } +} + +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 stringValue(value: unknown) { + return typeof value === 'string' ? value : ''; +} + +function settingUrl( + settings: Record, key: string, fallback: string +) { + const value = settings[key]; + return typeof value === 'string' && value.startsWith('https://') ? value : fallback; +} + +function mapRefundStatus(value: string) { + if (value === 'SUCCESS') return 'SUCCEEDED'; + if (value === 'CLOSED' || value === 'ABNORMAL') return 'FAILED'; + return 'PROCESSING'; +} + +function normalizeRefund(row: RefundRow) { + return { + refundId: String(row.id), + refundNo: row.refundNo, + status: row.status, + amountCents: Number(row.amountCents) + }; +} + +function redactNotification(value: Record) { + const copy = { ...value }; + delete copy.payer; + delete copy.user_received_account; + return copy; +} diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index 876b653..b594670 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -1,10 +1,16 @@ -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 { RbacRepository } from '../auth/rbac-repository.js'; import { PaymentError, type PaymentRepository } from '../payments/payment-repository.js'; +import type { WechatPaymentService } from '../payments/wechat-payment-service.js'; +import { WechatPayError } from '../payments/wechat-pay-client.js'; const createSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/), @@ -16,10 +22,23 @@ const callbackSchema = z.object({ callbackId: z.string().min(8).max(128), amountCents: z.number().int().positive() }).strict(); +const refundSchema = z.object({ + paymentId: z.string().regex(/^[1-9]\d{0,19}$/), + amountCents: z.number().int().positive(), + reason: z.string().min(1).max(512), + clientRequestId: z.string().min(8).max(128) +}).strict(); +const billSchema = z.object({ + storeId: z.string().regex(/^[1-9]\d{0,19}$/), + billDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + billType: z.enum(['ALL', 'SUCCESS', 'REFUND']) +}).strict(); export interface PaymentRouteOptions { repository: Pick; authRepository: Pick; + accessControl?: Pick; + wechat?: WechatPaymentService; jwtSecret: string; testAdapterEnabled: boolean; } @@ -74,6 +93,97 @@ export async function registerPaymentRoutes( return { code: 0, data, traceId: request.traceId }; }); }); + + if (options.wechat) { + app.post('/app-api/pay/wechat/prepay', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const body = z.object({ + paymentId: z.string().regex(/^[1-9]\d{0,19}$/) + }).strict().safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.wechat!.createPrepay({ + ...auth, paymentId: body.data.paymentId + }), + traceId: request.traceId + })); + }); + + app.get('/app-api/pay/wechat/payments/:paymentId', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const params = paymentParams.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.wechat!.queryPayment({ + ...auth, paymentId: params.data.paymentId + }), + traceId: request.traceId + })); + }); + + app.post('/app-api/pay/wechat/notify', { + preParsing: captureRawBody + }, async (request, reply) => { + return handle(reply, request.traceId, async () => { + const data = await options.wechat!.processPaymentNotification( + notificationHeaders(request.headers), + request.rawBody, + request.traceId + ); + return reply.send({ code: 'SUCCESS', message: '成功', data }); + }); + }); + + app.post('/app-api/pay/wechat/refund-notify', { + preParsing: captureRawBody + }, async (request, reply) => { + return handle(reply, request.traceId, async () => { + const data = await options.wechat!.processRefundNotification( + notificationHeaders(request.headers), + request.rawBody + ); + return reply.send({ code: 'SUCCESS', message: '成功', data }); + }); + }); + + app.post('/admin-api/pay/refund', async (request, reply) => { + const auth = await authenticateAdmin(request.headers.authorization, options); + const body = refundSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.wechat!.createRefund({ + tenantId: auth.tenantId, + platformAppId: auth.platformAppId, + actorId: auth.userId, + ...body.data + }), + traceId: request.traceId + })); + }); + + app.post('/admin-api/pay/reconciliation', async (request, reply) => { + const auth = await authenticateAdmin(request.headers.authorization, options); + const body = billSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.wechat!.requestReconciliation({ + tenantId: auth.tenantId, + platformAppId: auth.platformAppId, + actorId: auth.userId, + ...body.data + }), + traceId: request.traceId + })); + }); + } } async function authenticate( @@ -90,11 +200,22 @@ async function authenticate( }; } +async function authenticateAdmin( + authorization: string | undefined, options: PaymentRouteOptions +) { + const auth = await authenticate(authorization, options); + if (!auth || !options.accessControl) return null; + const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId); + if (!access.capabilities.includes('tenant.manage') + && !access.roles.includes('PLATFORM_ADMIN')) return null; + return auth; +} + async function handle(reply: FastifyReply, traceId: string, work: () => Promise) { try { return await work(); } catch (error) { - if (!(error instanceof PaymentError)) throw error; + if (!(error instanceof PaymentError) && !(error instanceof WechatPayError)) throw error; const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'PAYMENT_NOT_FOUND' ? 404 : error.code === 'PAYMENT_IDEMPOTENCY_CONFLICT' ? 409 : error.code.includes('FORBIDDEN') ? 403 : 400; @@ -104,6 +225,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', message: 'Authentication required.', traceId diff --git a/backend/src/server.ts b/backend/src/server.ts index bec17d4..c59aa01 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -17,12 +17,18 @@ import { OrderStateRepository } from './orders/order-state-repository.js'; import { OrderManagementRepository } from './orders/order-management-repository.js'; import { OrderShareRepository } from './orders/order-share-repository.js'; import { PaymentRepository } from './payments/payment-repository.js'; +import { + FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient +} from './payments/wechat-pay-client.js'; +import { WechatPaymentService } from './payments/wechat-payment-service.js'; const config = loadConfig(); const pool = createMySqlPool(config); const authRepository = new AuthRepository(pool); const accessControl = new RbacRepository(pool); const orderManagementRepository = new OrderManagementRepository(pool); +const paymentRepository = new PaymentRepository(pool); +const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson); const app = await buildApp({ config, platformConfigRepository: new PlatformConfigRepository(pool), @@ -89,8 +95,15 @@ const app = await buildApp({ jwtSecret: config.auth.jwtSecret }, payment: { - repository: new PaymentRepository(pool), + repository: paymentRepository, + wechat: new WechatPaymentService( + pool, + paymentRepository, + new WechatPayClient(new FetchWechatPayTransport()), + wechatCredentials + ), authRepository, + accessControl, jwtSecret: config.auth.jwtSecret, testAdapterEnabled: config.payment.testAdapterEnabled } diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 7170fb6..3458b02 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -54,6 +54,9 @@ const shareVerifySql = read('database/migrations/2026062014_m04d_order_shares.ve const paymentUpSql = read('database/migrations/2026062015_m05a_payment_domain.up.sql'); const paymentDownSql = read('database/migrations/2026062015_m05a_payment_domain.down.sql'); const paymentVerifySql = read('database/migrations/2026062015_m05a_payment_domain.verify.sql'); +const wechatRefundUpSql = read('database/migrations/2026062216_m05b_wechat_refunds.up.sql'); +const wechatRefundDownSql = read('database/migrations/2026062216_m05b_wechat_refunds.down.sql'); +const wechatRefundVerifySql = read('database/migrations/2026062216_m05b_wechat_refunds.verify.sql'); const coreTables = [ 'qipai_schema_migrations', @@ -242,5 +245,12 @@ assert.match(paymentUpSql, /UNIQUE KEY uq_qipai_payment_callback_provider/); assert.match(paymentUpSql, /credential_ref VARCHAR/); assert.match(paymentUpSql, /scope_key VARCHAR/); assert.doesNotMatch(paymentUpSql, /credential_secret|private_key|api_secret/i); +assert.match(wechatRefundUpSql, /CREATE TABLE IF NOT EXISTS qipai_reconciliation_runs/); +assert.match(wechatRefundDownSql, /DROP TABLE IF EXISTS qipai_reconciliation_runs/); +assert.match(wechatRefundVerifySql, /'qipai_reconciliation_runs'/); +assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_refund_client_request/); +assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_refund_callback/); +assert.match(wechatRefundUpSql, /UNIQUE KEY uq_qipai_reconciliation_request/); +assert.doesNotMatch(wechatRefundUpSql, /private_key|api_v3_key|certificate_pem/i); -console.log('PASS: M01-B through M05-A migration contracts are present.'); +console.log('PASS: M01-B through M05-B migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index bd3ff99..cc035e7 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -26,7 +26,8 @@ assert.match(plan.file, /2026061811_m04a_pricing_reservations\.up\.sql/); assert.match(plan.file, /2026062012_m04b_order_state_machine\.up\.sql/); assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql/); assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql/); -assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql$/); +assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql/); +assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.ok(plan.statements.length >= 11); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index a5f5fed..9749d9a 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -60,6 +60,7 @@ const expectedTables = [ 'qipai_permissions', 'qipai_platform_apps', 'qipai_profit_shares', + 'qipai_reconciliation_runs', 'qipai_refunds', 'qipai_role_permissions', 'qipai_roles', @@ -101,12 +102,12 @@ async function readMigrationVersions(pool) { const [rows] = await pool.query( `SELECT version, name FROM qipai_schema_migrations - WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ORDER BY version`, ['2026061601', '2026061802', '2026061803', '2026061804', '2026061805', '2026061806', '2026061807', '2026061808', '2026061809', '2026061810', '2026061811', '2026062012', '2026062013', '2026062014', - '2026062015'] + '2026062015', '2026062216'] ); return rows; } @@ -1253,7 +1254,8 @@ try { { version: '2026062012', name: 'm04b_order_state_machine' }, { version: '2026062013', name: 'm04c_order_adjustments' }, { version: '2026062014', name: 'm04d_order_shares' }, - { version: '2026062015', name: 'm05a_payment_domain' } + { version: '2026062015', name: 'm05a_payment_domain' }, + { version: '2026062216', name: 'm05b_wechat_refunds' } ]); await assertTaskDurability(pool); const loginContext = await assertPlatformTenantIsolation(pool); @@ -1274,7 +1276,7 @@ try { await executeMigrationPlan(pool, plans.down); assert.deepEqual(await readCoreTables(pool), []); await assertLegacyCompatibility(pool); - console.log('PASS: down removed all M01-B through M05-A tables.'); + console.log('PASS: down removed all M01-B through M05-B tables.'); await executeMigrationPlan(pool, plans.up); await executeMigrationPlan(pool, plans.verify); @@ -1294,7 +1296,8 @@ try { { version: '2026062012', name: 'm04b_order_state_machine' }, { version: '2026062013', name: 'm04c_order_adjustments' }, { version: '2026062014', name: 'm04d_order_shares' }, - { version: '2026062015', name: 'm05a_payment_domain' } + { version: '2026062015', name: 'm05a_payment_domain' }, + { version: '2026062216', name: 'm05b_wechat_refunds' } ]); await assertLegacyCompatibility(pool); console.log('PASS: second up and verify restored the schema.'); @@ -1374,7 +1377,9 @@ try { 'idempotent payment creation', 'mismatched callback retained without accounting', 'duplicate success callback does not double account', - 'test adapter explicit non-production gate' + 'test adapter explicit non-production gate', + 'Wechat refund idempotency and callback indexes', + 'Wechat reconciliation request history' ] }, null, 2)); } finally { diff --git a/backend/tests/wechat-pay.test.mjs b/backend/tests/wechat-pay.test.mjs new file mode 100644 index 0000000..c51d4c9 --- /dev/null +++ b/backend/tests/wechat-pay.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { + createCipheriv, createSign, generateKeyPairSync, randomBytes +} from 'node:crypto'; +import { + WechatPayClient, WechatPayError +} from '../dist/payments/wechat-pay-client.js'; + +const merchantKeys = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' } +}); +const platformKeys = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' } +}); +const credential = { + appId: 'wx-test-app', + merchantId: '1900000109', + serialNo: 'MERCHANT-SERIAL', + privateKeyPem: merchantKeys.privateKey, + apiV3Key: '0123456789abcdef0123456789abcdef', + platformCertificates: { 'PLATFORM-SERIAL': platformKeys.publicKey } +}; +const requests = []; +const responses = [ + { prepay_id: 'wx-prepay-test' }, + { trade_state: 'SUCCESS', transaction_id: 'wx-transaction-test' }, + { refund_id: 'wx-refund-test', status: 'PROCESSING' }, + { download_url: 'https://api.mch.weixin.qq.com/v3/billdownload/file?token=sanitized' } +]; +const transport = { + async request(input) { + requests.push(input); + return { + status: 200, + headers: {}, + body: JSON.stringify(responses.shift()) + }; + } +}; +const client = new WechatPayClient(transport); +const prepay = await client.createJsapiPrepay(credential, { + description: 'M05-B sanitized order', + outTradeNo: 'PAY-M05B-001', + notifyUrl: 'https://api.txyundm.cn/app-api/pay/wechat/notify', + amountCents: 3600, + payerOpenId: 'openid-sanitized' +}); +assert.equal(prepay.prepayId, 'wx-prepay-test'); +assert.equal(prepay.paymentParams.package, 'prepay_id=wx-prepay-test'); +assert.equal(prepay.paymentParams.signType, 'RSA'); +assert.match(prepay.paymentParams.paySign, /^[A-Za-z0-9+/]+=*$/); +assert.match(requests[0].headers.Authorization, /mchid="1900000109"/); +assert.equal(requests[0].body.includes(merchantKeys.privateKey), false); + +assert.equal( + (await client.queryTransaction(credential, 'PAY-M05B-001')).trade_state, + 'SUCCESS' +); +assert.equal((await client.createRefund(credential, { + outTradeNo: 'PAY-M05B-001', + outRefundNo: 'REF-M05B-001', + reason: 'sanitized refund', + notifyUrl: 'https://api.txyundm.cn/app-api/pay/wechat/refund-notify', + refundCents: 1200, + totalCents: 3600 +})).status, 'PROCESSING'); +assert.match(requests[2].body, /"refund":1200/); +assert.match((await client.downloadTradeBill( + credential, '2026-06-21', 'ALL' +)).download_url, /^https:/); + +const notification = encryptedNotification({ + out_trade_no: 'PAY-M05B-001', + transaction_id: 'wx-transaction-test', + trade_state: 'SUCCESS', + amount: { total: 3600, currency: 'CNY' }, + payer: { openid: 'must-not-be-persisted' } +}, credential.apiV3Key); +const timestamp = '1782057600'; +const nonce = 'notification-nonce'; +const signature = sign( + platformKeys.privateKey, + `${timestamp}\n${nonce}\n${notification}\n` +); +const decrypted = client.verifyAndDecrypt(credential, { + timestamp, + nonce, + serial: 'PLATFORM-SERIAL', + signature +}, notification); +assert.equal(decrypted.transaction_id, 'wx-transaction-test'); +assert.equal(decrypted.amount.total, 3600); +assert.throws( + () => client.verifyAndDecrypt(credential, { + timestamp, + nonce, + serial: 'PLATFORM-SERIAL', + signature + }, `${notification} `), + (error) => error instanceof WechatPayError + && error.code === 'WECHAT_SIGNATURE_INVALID' +); + +console.log('PASS: M05-B Wechat Pay signing, API requests, notification verification and AES-GCM decryption.'); + +function encryptedNotification(resource, key) { + const nonce = randomBytes(12).toString('base64url').slice(0, 12); + const associatedData = 'transaction'; + const cipher = createCipheriv('aes-256-gcm', Buffer.from(key), Buffer.from(nonce)); + cipher.setAAD(Buffer.from(associatedData)); + const ciphertext = Buffer.concat([ + cipher.update(JSON.stringify(resource), 'utf8'), + cipher.final(), + cipher.getAuthTag() + ]).toString('base64'); + return JSON.stringify({ + id: 'notification-m05b-001', + event_type: 'TRANSACTION.SUCCESS', + resource: { + algorithm: 'AEAD_AES_256_GCM', + ciphertext, + nonce, + associated_data: associatedData + } + }); +} + +function sign(privateKey, message) { + const signer = createSign('RSA-SHA256'); + signer.update(message); + signer.end(); + return signer.sign(privateKey, 'base64'); +} diff --git a/database/migrations/2026062216_m05b_wechat_refunds.down.sql b/database/migrations/2026062216_m05b_wechat_refunds.down.sql new file mode 100644 index 0000000..4fde783 --- /dev/null +++ b/database/migrations/2026062216_m05b_wechat_refunds.down.sql @@ -0,0 +1,10 @@ +DELETE FROM qipai_schema_migrations WHERE version = '2026062216'; +DROP TABLE IF EXISTS qipai_reconciliation_runs; +ALTER TABLE qipai_refunds + DROP INDEX uq_qipai_refund_callback, + DROP INDEX uq_qipai_refund_client_request, + DROP COLUMN raw_response, + DROP COLUMN failure_code, + DROP COLUMN provider_callback_id, + DROP COLUMN provider, + DROP COLUMN client_request_id; diff --git a/database/migrations/2026062216_m05b_wechat_refunds.up.sql b/database/migrations/2026062216_m05b_wechat_refunds.up.sql new file mode 100644 index 0000000..8f1e4fb --- /dev/null +++ b/database/migrations/2026062216_m05b_wechat_refunds.up.sql @@ -0,0 +1,33 @@ +ALTER TABLE qipai_refunds + ADD COLUMN client_request_id VARCHAR(128) NOT NULL AFTER order_id, + ADD COLUMN provider VARCHAR(32) NOT NULL DEFAULT 'WECHAT' AFTER client_request_id, + ADD COLUMN provider_callback_id VARCHAR(128) NULL AFTER provider_refund_id, + ADD COLUMN failure_code VARCHAR(64) NOT NULL DEFAULT '' AFTER completed_at, + ADD COLUMN raw_response JSON NULL AFTER failure_code, + ADD UNIQUE KEY uq_qipai_refund_client_request (tenant_id, client_request_id), + ADD UNIQUE KEY uq_qipai_refund_callback + (tenant_id, provider, provider_callback_id); + +CREATE TABLE IF NOT EXISTS qipai_reconciliation_runs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + payment_config_id BIGINT UNSIGNED NOT NULL, + bill_date DATE NOT NULL, + bill_type VARCHAR(16) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'REQUESTED', + download_url VARCHAR(2048) NOT NULL DEFAULT '', + error_code VARCHAR(64) NOT NULL DEFAULT '', + requested_by BIGINT UNSIGNED NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + completed_at DATETIME(3) NULL, + CONSTRAINT fk_qipai_reconciliation_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_reconciliation_config + FOREIGN KEY (payment_config_id) REFERENCES qipai_payment_configs(id), + UNIQUE KEY uq_qipai_reconciliation_request + (tenant_id, payment_config_id, bill_date, bill_type), + KEY idx_qipai_reconciliation_status (tenant_id, status, bill_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026062216', 'm05b_wechat_refunds'); diff --git a/database/migrations/2026062216_m05b_wechat_refunds.verify.sql b/database/migrations/2026062216_m05b_wechat_refunds.verify.sql new file mode 100644 index 0000000..67cb2ff --- /dev/null +++ b/database/migrations/2026062216_m05b_wechat_refunds.verify.sql @@ -0,0 +1,22 @@ +SELECT table_name FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name = 'qipai_reconciliation_runs'; + +SELECT column_name FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'qipai_refunds' + AND column_name IN ( + 'client_request_id', 'provider', 'provider_callback_id', + 'failure_code', 'raw_response' + ) ORDER BY column_name; + +SELECT table_name, index_name FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND ((table_name = 'qipai_refunds' + AND index_name IN ( + 'uq_qipai_refund_client_request', 'uq_qipai_refund_callback' + )) + OR (table_name = 'qipai_reconciliation_runs' + AND index_name = 'uq_qipai_reconciliation_request')) +GROUP BY table_name, index_name ORDER BY table_name, index_name; + +SELECT version, name FROM qipai_schema_migrations WHERE version = '2026062216'; diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index 89c1f90..15509ed 100644 --- a/scripts/dev/wsl/mysql-migration-roundtrip.sh +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}" export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_CONNECTION_LIMIT=2 -echo "INFO: MySQL ${mysql_version}; running M01-B through M05-A migration roundtrip in a temporary database." +echo "INFO: MySQL ${mysql_version}; running M01-B through M05-B migration roundtrip in a temporary database." npm --prefix backend run test:mysql:migration -echo "PASS: M01-B through M05-A live MySQL migration roundtrip completed." +echo "PASS: M01-B through M05-B live MySQL migration roundtrip completed."