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
+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
}