feat(M05-B): 完成微信支付退款与对账基础
This commit is contained in:
@@ -13,6 +13,7 @@ QIPAI_ACCESS_TOKEN_TTL_SECONDS=900
|
|||||||
QIPAI_SESSION_TTL_SECONDS=604800
|
QIPAI_SESSION_TTL_SECONDS=604800
|
||||||
QIPAI_WECHAT_APP_SECRETS={}
|
QIPAI_WECHAT_APP_SECRETS={}
|
||||||
QIPAI_TEST_PAYMENT_ENABLED=false
|
QIPAI_TEST_PAYMENT_ENABLED=false
|
||||||
|
QIPAI_WECHAT_PAY_CREDENTIALS={}
|
||||||
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
|
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
|
||||||
QIPAI_MQTT_USERNAME=
|
QIPAI_MQTT_USERNAME=
|
||||||
QIPAI_MQTT_PASSWORD=
|
QIPAI_MQTT_PASSWORD=
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"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",
|
"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: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": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface BuildAppOptions {
|
|||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
traceId: string;
|
traceId: string;
|
||||||
|
rawBody: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.decorateRequest('traceId', '');
|
app.decorateRequest('traceId', '');
|
||||||
|
app.decorateRequest('rawBody', '');
|
||||||
app.addHook('onRequest', async (request, reply) => {
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
const traceHeader = request.headers['x-trace-id'];
|
const traceHeader = request.headers['x-trace-id'];
|
||||||
request.traceId = Array.isArray(traceHeader) ? traceHeader[0] : traceHeader || request.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_SESSION_TTL_SECONDS: z.coerce.number().int().min(300).max(2592000).default(604800),
|
||||||
QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'),
|
QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'),
|
||||||
QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'),
|
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_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
|
||||||
QIPAI_MQTT_USERNAME: z.string().default(''),
|
QIPAI_MQTT_USERNAME: z.string().default(''),
|
||||||
QIPAI_MQTT_PASSWORD: z.string().default('')
|
QIPAI_MQTT_PASSWORD: z.string().default('')
|
||||||
@@ -56,7 +57,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
|
|||||||
},
|
},
|
||||||
payment: {
|
payment: {
|
||||||
testAdapterEnabled: parsed.NODE_ENV !== 'production'
|
testAdapterEnabled: parsed.NODE_ENV !== 'production'
|
||||||
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true'
|
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true',
|
||||||
|
wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS
|
||||||
},
|
},
|
||||||
mqtt: {
|
mqtt: {
|
||||||
url: parsed.QIPAI_MQTT_URL,
|
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/2026062012_m04b_order_state_machine.up.sql',
|
||||||
'database/migrations/2026062013_m04c_order_adjustments.up.sql',
|
'database/migrations/2026062013_m04c_order_adjustments.up.sql',
|
||||||
'database/migrations/2026062014_m04d_order_shares.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: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'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/2026062012_m04b_order_state_machine.verify.sql',
|
||||||
'database/migrations/2026062013_m04c_order_adjustments.verify.sql',
|
'database/migrations/2026062013_m04c_order_adjustments.verify.sql',
|
||||||
'database/migrations/2026062014_m04d_order_shares.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: [
|
down: [
|
||||||
|
'database/migrations/2026062216_m05b_wechat_refunds.down.sql',
|
||||||
'database/migrations/2026062015_m05a_payment_domain.down.sql',
|
'database/migrations/2026062015_m05a_payment_domain.down.sql',
|
||||||
'database/migrations/2026062014_m04d_order_shares.down.sql',
|
'database/migrations/2026062014_m04d_order_shares.down.sql',
|
||||||
'database/migrations/2026062013_m04c_order_adjustments.down.sql',
|
'database/migrations/2026062013_m04c_order_adjustments.down.sql',
|
||||||
@@ -200,7 +203,8 @@ export async function executeMigrationPlan(
|
|||||||
2, 1, 3, 1,
|
2, 1, 3, 1,
|
||||||
2, 2, 1, 2, 1,
|
2, 2, 1, 2, 1,
|
||||||
1, 8, 3, 1,
|
1, 8, 3, 1,
|
||||||
5, 8, 4, 1
|
5, 8, 4, 1,
|
||||||
|
1, 5, 3, 1
|
||||||
][index] ?? 1;
|
][index] ?? 1;
|
||||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
throw new Error(
|
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 { z } from 'zod';
|
||||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
|
import type { RbacRepository } from '../auth/rbac-repository.js';
|
||||||
import {
|
import {
|
||||||
PaymentError, type PaymentRepository
|
PaymentError, type PaymentRepository
|
||||||
} from '../payments/payment-repository.js';
|
} 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({
|
const createSchema = z.object({
|
||||||
orderId: z.string().regex(/^[1-9]\d{0,19}$/),
|
orderId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||||
@@ -16,10 +22,23 @@ const callbackSchema = z.object({
|
|||||||
callbackId: z.string().min(8).max(128),
|
callbackId: z.string().min(8).max(128),
|
||||||
amountCents: z.number().int().positive()
|
amountCents: z.number().int().positive()
|
||||||
}).strict();
|
}).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 {
|
export interface PaymentRouteOptions {
|
||||||
repository: Pick<PaymentRepository, 'createPayment' | 'processTestCallback'>;
|
repository: Pick<PaymentRepository, 'createPayment' | 'processTestCallback'>;
|
||||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl?: Pick<RbacRepository, 'getAccessProfile'>;
|
||||||
|
wechat?: WechatPaymentService;
|
||||||
jwtSecret: string;
|
jwtSecret: string;
|
||||||
testAdapterEnabled: boolean;
|
testAdapterEnabled: boolean;
|
||||||
}
|
}
|
||||||
@@ -74,6 +93,97 @@ export async function registerPaymentRoutes(
|
|||||||
return { code: 0, data, traceId: request.traceId };
|
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(
|
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>) {
|
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||||
try {
|
try {
|
||||||
return await work();
|
return await work();
|
||||||
} catch (error) {
|
} 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'
|
const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'PAYMENT_NOT_FOUND'
|
||||||
? 404 : error.code === 'PAYMENT_IDEMPOTENCY_CONFLICT' ? 409
|
? 404 : error.code === 'PAYMENT_IDEMPOTENCY_CONFLICT' ? 409
|
||||||
: error.code.includes('FORBIDDEN') ? 403 : 400;
|
: 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) {
|
function unauthorized(reply: FastifyReply, traceId: string) {
|
||||||
return reply.status(401).send({
|
return reply.status(401).send({
|
||||||
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId
|
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 { OrderManagementRepository } from './orders/order-management-repository.js';
|
||||||
import { OrderShareRepository } from './orders/order-share-repository.js';
|
import { OrderShareRepository } from './orders/order-share-repository.js';
|
||||||
import { PaymentRepository } from './payments/payment-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 config = loadConfig();
|
||||||
const pool = createMySqlPool(config);
|
const pool = createMySqlPool(config);
|
||||||
const authRepository = new AuthRepository(pool);
|
const authRepository = new AuthRepository(pool);
|
||||||
const accessControl = new RbacRepository(pool);
|
const accessControl = new RbacRepository(pool);
|
||||||
const orderManagementRepository = new OrderManagementRepository(pool);
|
const orderManagementRepository = new OrderManagementRepository(pool);
|
||||||
|
const paymentRepository = new PaymentRepository(pool);
|
||||||
|
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config,
|
config,
|
||||||
platformConfigRepository: new PlatformConfigRepository(pool),
|
platformConfigRepository: new PlatformConfigRepository(pool),
|
||||||
@@ -89,8 +95,15 @@ const app = await buildApp({
|
|||||||
jwtSecret: config.auth.jwtSecret
|
jwtSecret: config.auth.jwtSecret
|
||||||
},
|
},
|
||||||
payment: {
|
payment: {
|
||||||
repository: new PaymentRepository(pool),
|
repository: paymentRepository,
|
||||||
|
wechat: new WechatPaymentService(
|
||||||
|
pool,
|
||||||
|
paymentRepository,
|
||||||
|
new WechatPayClient(new FetchWechatPayTransport()),
|
||||||
|
wechatCredentials
|
||||||
|
),
|
||||||
authRepository,
|
authRepository,
|
||||||
|
accessControl,
|
||||||
jwtSecret: config.auth.jwtSecret,
|
jwtSecret: config.auth.jwtSecret,
|
||||||
testAdapterEnabled: config.payment.testAdapterEnabled
|
testAdapterEnabled: config.payment.testAdapterEnabled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 paymentUpSql = read('database/migrations/2026062015_m05a_payment_domain.up.sql');
|
||||||
const paymentDownSql = read('database/migrations/2026062015_m05a_payment_domain.down.sql');
|
const paymentDownSql = read('database/migrations/2026062015_m05a_payment_domain.down.sql');
|
||||||
const paymentVerifySql = read('database/migrations/2026062015_m05a_payment_domain.verify.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 = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'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, /credential_ref VARCHAR/);
|
||||||
assert.match(paymentUpSql, /scope_key VARCHAR/);
|
assert.match(paymentUpSql, /scope_key VARCHAR/);
|
||||||
assert.doesNotMatch(paymentUpSql, /credential_secret|private_key|api_secret/i);
|
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.');
|
||||||
|
|||||||
@@ -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, /2026062012_m04b_order_state_machine\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062013_m04c_order_adjustments\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const expectedTables = [
|
|||||||
'qipai_permissions',
|
'qipai_permissions',
|
||||||
'qipai_platform_apps',
|
'qipai_platform_apps',
|
||||||
'qipai_profit_shares',
|
'qipai_profit_shares',
|
||||||
|
'qipai_reconciliation_runs',
|
||||||
'qipai_refunds',
|
'qipai_refunds',
|
||||||
'qipai_role_permissions',
|
'qipai_role_permissions',
|
||||||
'qipai_roles',
|
'qipai_roles',
|
||||||
@@ -101,12 +102,12 @@ async function readMigrationVersions(pool) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT version, name
|
`SELECT version, name
|
||||||
FROM qipai_schema_migrations
|
FROM qipai_schema_migrations
|
||||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ORDER BY version`,
|
ORDER BY version`,
|
||||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||||
'2026062015']
|
'2026062015', '2026062216']
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -1253,7 +1254,8 @@ try {
|
|||||||
{ version: '2026062012', name: 'm04b_order_state_machine' },
|
{ version: '2026062012', name: 'm04b_order_state_machine' },
|
||||||
{ version: '2026062013', name: 'm04c_order_adjustments' },
|
{ version: '2026062013', name: 'm04c_order_adjustments' },
|
||||||
{ version: '2026062014', name: 'm04d_order_shares' },
|
{ 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);
|
await assertTaskDurability(pool);
|
||||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||||
@@ -1274,7 +1276,7 @@ try {
|
|||||||
await executeMigrationPlan(pool, plans.down);
|
await executeMigrationPlan(pool, plans.down);
|
||||||
assert.deepEqual(await readCoreTables(pool), []);
|
assert.deepEqual(await readCoreTables(pool), []);
|
||||||
await assertLegacyCompatibility(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.up);
|
||||||
await executeMigrationPlan(pool, plans.verify);
|
await executeMigrationPlan(pool, plans.verify);
|
||||||
@@ -1294,7 +1296,8 @@ try {
|
|||||||
{ version: '2026062012', name: 'm04b_order_state_machine' },
|
{ version: '2026062012', name: 'm04b_order_state_machine' },
|
||||||
{ version: '2026062013', name: 'm04c_order_adjustments' },
|
{ version: '2026062013', name: 'm04c_order_adjustments' },
|
||||||
{ version: '2026062014', name: 'm04d_order_shares' },
|
{ 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);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: second up and verify restored the schema.');
|
console.log('PASS: second up and verify restored the schema.');
|
||||||
@@ -1374,7 +1377,9 @@ try {
|
|||||||
'idempotent payment creation',
|
'idempotent payment creation',
|
||||||
'mismatched callback retained without accounting',
|
'mismatched callback retained without accounting',
|
||||||
'duplicate success callback does not double account',
|
'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));
|
}, null, 2));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
|
|||||||
export QIPAI_MYSQL_PASSWORD="${password}"
|
export QIPAI_MYSQL_PASSWORD="${password}"
|
||||||
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
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
|
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."
|
||||||
|
|||||||
Reference in New Issue
Block a user