feat(M05-B): 完成微信支付退款与对账基础
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user