feat(M05-B): 完成微信支付退款与对账基础

This commit is contained in:
Codex
2026-06-22 11:15:22 +08:00
parent dea2ee5ee1
commit 4c66e192b9
17 changed files with 1316 additions and 18 deletions
+1
View File
@@ -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=
+1 -1
View File
@@ -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",
+2
View File
@@ -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<FastifyIn
});
app.decorateRequest('traceId', '');
app.decorateRequest('rawBody', '');
app.addHook('onRequest', async (request, reply) => {
const traceHeader = request.headers['x-trace-id'];
request.traceId = Array.isArray(traceHeader) ? traceHeader[0] : traceHeader || request.id;
+3 -1
View File
@@ -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,
+7 -3
View File
@@ -35,7 +35,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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<MigrationDirection, readonly string[]> = {
'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(
+288
View File
@@ -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<string, string>;
}
export interface WechatPayTransport {
request(input: {
method: 'GET' | 'POST';
url: string;
headers: Record<string, string>;
body?: string;
}): Promise<{ status: number; headers: Record<string, string>; 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<string, string>;
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<string, unknown> {
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<string, WechatPayCredential>();
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<string, unknown>;
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<string, unknown> {
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<string, unknown>, key: string) {
const field = value[key];
if (!field || typeof field !== 'object' || Array.isArray(field)) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return field as Record<string, unknown>;
}
function stringField(value: Record<string, unknown>, 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<string, unknown>, key: string) {
const field = value[key];
return typeof field === 'string' ? field : '';
}
@@ -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<string, WechatPayCredential>
) {}
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<string, unknown>;
const [identityRows] = await this.pool.execute<RowDataPacket[]>(
`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<ResultSetHeader>(
`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<RowDataPacket[]>(
`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<RefundRow[]>(
`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<ResultSetHeader>(
`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<string, unknown>;
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<RefundRow[]>(
`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<ResultSetHeader>(
`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<RowDataPacket[]>(
`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<ResultSetHeader>(
`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<RowDataPacket[]>(
`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<PaymentRow[]>(
`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<PaymentRow[]>(
`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<RefundRow[]>(
`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<PaymentRow[]>(
`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<RowDataPacket[]>(
`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<T>(work: (connection: PoolConnection) => Promise<T>) {
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<string, unknown>, 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<string, unknown>, 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<string, unknown>;
}
function stringValue(value: unknown) {
return typeof value === 'string' ? value : '';
}
function settingUrl(
settings: Record<string, unknown>, 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<string, unknown>) {
const copy = { ...value };
delete copy.payer;
delete copy.user_received_account;
return copy;
}
+156 -2
View File
@@ -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<PaymentRepository, 'createPayment' | 'processTestCallback'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl?: Pick<RbacRepository, 'getAccessProfile'>;
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<unknown>) {
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<string, unknown>) {
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
+14 -1
View File
@@ -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
}
+11 -1
View File
@@ -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.');
+2 -1
View File
@@ -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);
@@ -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 {
+137
View File
@@ -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');
}
@@ -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;
@@ -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');
@@ -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';
+2 -2
View File
@@ -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."