feat(M08-A): 接入充值微信支付调起

This commit is contained in:
Codex
2026-06-25 12:40:21 +08:00
parent 2bbb16e6f0
commit 53f5d6776f
17 changed files with 701 additions and 11 deletions
+15 -1
View File
@@ -78,6 +78,9 @@ const rechargeVerifySql = read('database/migrations/2026062422_m07b_recharge_pla
const benefitUpSql = read('database/migrations/2026062423_m07c_benefits.up.sql');
const benefitDownSql = read('database/migrations/2026062423_m07c_benefits.down.sql');
const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.verify.sql');
const rechargeWechatUpSql = read('database/migrations/2026062524_m08a_recharge_wechat.up.sql');
const rechargeWechatDownSql = read('database/migrations/2026062524_m08a_recharge_wechat.down.sql');
const rechargeWechatVerifySql = read('database/migrations/2026062524_m08a_recharge_wechat.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -364,4 +367,15 @@ assert.match(benefitUpSql, /remaining_amount_cents INT UNSIGNED/);
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_request/);
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_order_benefit/);
console.log('PASS: M01-B through M07-C migration contracts are present.');
assert.match(rechargeWechatUpSql, /ALTER TABLE qipai_recharge_orders/);
assert.match(rechargeWechatUpSql, /provider_prepay_id VARCHAR\(128\) NULL/);
assert.match(rechargeWechatUpSql, /provider_payment_id VARCHAR\(128\) NULL/);
assert.match(rechargeWechatUpSql, /provider_callback_id VARCHAR\(128\) NULL/);
assert.match(rechargeWechatUpSql, /raw_notify JSON NULL/);
assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_payment/);
assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_callback/);
assert.match(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/);
assert.match(rechargeWechatVerifySql, /'provider_payment_id'/);
assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/);
console.log('PASS: M01-B through M08-A migration contracts are present.');
+1 -1
View File
@@ -34,7 +34,7 @@ assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/);
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
assert.match(plan.file, /2026062423_m07c_benefits\.up\.sql$/);
assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
+46
View File
@@ -10,6 +10,8 @@ const token = signAccessToken({
let createdInput;
let listInput;
let prepayInput;
let notifyInput;
const app = await buildApp({
recharge: {
jwtSecret: secret,
@@ -61,6 +63,23 @@ const app = await buildApp({
giftAmountCents: 2000,
idempotent: false
};
},
async createWechatPrepay(input) {
prepayInput = input;
return {
rechargeOrderId: input.rechargeOrderId,
rechargeNo: 'RCHTEST',
amountCents: 10000,
timeStamp: '1782360000',
nonceStr: 'nonce',
package: 'prepay_id=wx-recharge-prepay',
signType: 'RSA',
paySign: 'signed'
};
},
async processWechatNotification(headers, rawBody, traceId) {
notifyInput = { headers, rawBody, traceId };
return { rechargeOrderId: '901', status: 'CREDITED', idempotent: false };
}
}
}
@@ -91,6 +110,33 @@ assert.equal(createdInput.userId, '21');
assert.equal(createdInput.planId, '31');
assert.equal(createdInput.traceId, 'm08a-recharge-order');
const prepay = await app.inject({
method: 'POST',
url: '/app-api/recharge/orders/901/wechat-prepay',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(prepay.statusCode, 200);
assert.equal(prepayInput.tenantId, '7');
assert.equal(prepayInput.platformAppId, '9');
assert.equal(prepayInput.userId, '21');
assert.equal(prepay.json().data.package, 'prepay_id=wx-recharge-prepay');
const notify = await app.inject({
method: 'POST',
url: '/app-api/recharge/wechat/notify',
headers: {
'content-type': 'application/json',
'wechatpay-timestamp': '1782360001',
'wechatpay-nonce': 'notify-nonce',
'wechatpay-serial': 'PLATFORM-SERIAL',
'wechatpay-signature': 'signature'
},
payload: '{"resource":"encrypted"}'
});
assert.equal(notify.statusCode, 200);
assert.equal(notifyInput.headers.serial, 'PLATFORM-SERIAL');
assert.equal(notifyInput.rawBody, '{"resource":"encrypted"}');
const invalid = await app.inject({
method: 'POST',
url: '/app-api/recharge/orders',
+150 -1
View File
@@ -21,6 +21,7 @@ function createHarness(overrides = {}) {
...overrides.plan
},
orders: [],
callbacks: [],
nextOrderId: 900,
purchaseCount: overrides.purchaseCount ?? 0,
walletCredits: []
@@ -71,12 +72,60 @@ function createHarness(overrides = {}) {
state.orders.push(order);
return [{ insertId: order.id, affectedRows: 1 }, []];
}
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('user_id = ?')) {
const order = state.orders.find((item) =>
item.tenantId === String(params[0])
&& item.id === String(params[1])
&& item.userId === String(params[2])
);
return [order ? [toOrderRow(order)] : [], []];
}
if (sql.includes('SELECT openid FROM qipai_user_identities')) {
return [[{ openid: 'openid-recharge-test' }], []];
}
if (sql.includes('provider_prepay_id')) {
const order = state.orders.find((item) =>
item.tenantId === String(params[1])
&& item.id === String(params[2])
&& item.userId === String(params[3])
);
order.providerPrepayId = params[0];
return [{ affectedRows: 1 }, []];
}
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('recharge_no = ?')) {
const order = state.orders.find((item) => item.rechargeNo === String(params[0]));
return [order ? [{ ...toOrderRow(order), tenantId: order.tenantId }] : [], []];
}
if (sql.includes('FROM qipai_recharge_orders') && sql.includes('FOR UPDATE')) {
const order = state.orders.find((item) =>
item.tenantId === String(params[0]) && item.id === String(params[1])
);
return [order ? [toOrderRow(order)] : [], []];
}
if (sql.includes('INSERT IGNORE INTO qipai_payment_callbacks')) {
const duplicate = state.callbacks.some((item) =>
item.tenantId === String(params[0]) && item.callbackId === String(params[1])
);
if (!duplicate) {
state.callbacks.push({
tenantId: String(params[0]),
callbackId: String(params[1]),
status: 'RECEIVED',
payload: params[2]
});
}
return [{ affectedRows: duplicate ? 0 : 1 }, []];
}
if (sql.includes('provider_payment_id')) {
const order = state.orders.find((item) =>
item.tenantId === String(params[3]) && item.id === String(params[4])
);
order.status = 'PAID';
order.providerPaymentId = String(params[0]);
order.providerCallbackId = String(params[1]);
order.rawNotify = params[2];
return [{ affectedRows: 1 }, []];
}
if (sql.includes("SET status = 'PAID'")) {
const order = state.orders.find((item) =>
item.tenantId === String(params[1]) && item.id === String(params[2])
@@ -92,11 +141,23 @@ function createHarness(overrides = {}) {
order.status = 'CREDITED';
return [{ affectedRows: 1 }, []];
}
if (sql.includes('UPDATE qipai_payment_callbacks')) {
const callback = state.callbacks.find((item) =>
item.tenantId === String(params[0]) && item.callbackId === String(params[1])
) ?? state.callbacks.find((item) =>
item.tenantId === String(params[1]) && item.callbackId === String(params[2])
);
if (callback) callback.status = sql.includes('REJECTED') ? 'REJECTED' : 'PROCESSED';
return [{ affectedRows: 1 }, []];
}
throw new Error(`Unexpected SQL: ${sql}`);
}
};
const pool = {
async execute(sql, params) {
return connection.execute(sql, params);
},
async getConnection() {
return connection;
}
@@ -113,7 +174,58 @@ function createHarness(overrides = {}) {
}
};
return { service: new RechargeService(pool, wallet), state };
const wechat = {
paymentRepository: {
async resolveConfig(_connection, tenantId, platformAppId, storeId, provider) {
state.resolvedConfig = { tenantId, platformAppId, storeId, provider };
return {
credentialRef: 'wechat-test',
settings: {
rechargeDescription: '测试充值',
rechargeNotifyUrl: 'https://api.txyundm.cn/app-api/recharge/wechat/notify'
}
};
}
},
client: {
async createJsapiPrepay(_credential, input) {
state.prepayInput = input;
return {
prepayId: 'wx-recharge-prepay',
paymentParams: {
timeStamp: '1782360000',
nonceStr: 'nonce',
package: 'prepay_id=wx-recharge-prepay',
signType: 'RSA',
paySign: 'signed'
}
};
},
verifyAndDecrypt(_credential, headers, rawBody) {
state.verifiedNotification = { headers, rawBody };
return {
out_trade_no: state.orders[0].rechargeNo,
transaction_id: 'wx-recharge-transaction',
trade_state: 'SUCCESS',
amount: { total: state.orders[0].payAmountCents },
payer: { openid: 'redacted' }
};
}
},
credentials: new Map([[
'wechat-test',
{
appId: 'wx-test-app',
merchantId: '1900000109',
serialNo: 'MERCHANT-SERIAL',
privateKeyPem: 'test-private-key',
apiV3Key: '0123456789abcdef0123456789abcdef',
platformCertificates: { 'PLATFORM-SERIAL': 'test-cert' }
}
]])
};
return { service: new RechargeService(pool, wallet, wechat), state };
}
function toOrderRow(order) {
@@ -217,4 +329,41 @@ const baseOrder = {
assert.equal(state.walletCredits.length, 1);
}
{
const { service, state } = createHarness();
const created = await service.createRechargeOrder(baseOrder);
const prepay = await service.createWechatPrepay({
tenantId: '7',
platformAppId: '9',
userId: '21',
rechargeOrderId: created.rechargeOrderId
});
assert.equal(prepay.package, 'prepay_id=wx-recharge-prepay');
assert.equal(state.resolvedConfig.storeId, '11');
assert.equal(state.prepayInput.outTradeNo, created.rechargeNo);
assert.equal(state.prepayInput.amountCents, 10000);
assert.equal(state.orders[0].providerPrepayId, 'wx-recharge-prepay');
const notified = await service.processWechatNotification({
timestamp: '1782360001',
nonce: 'notify-nonce',
serial: 'PLATFORM-SERIAL',
signature: 'signed'
}, '{"resource":"encrypted"}', 'recharge-notify');
assert.equal(notified.status, 'CREDITED');
assert.equal(state.orders[0].providerPaymentId, 'wx-recharge-transaction');
assert.equal(state.orders[0].providerCallbackId, 'PLATFORM-SERIAL:wx-recharge-transaction:SUCCESS');
assert.equal(state.walletCredits.length, 1);
assert.equal(JSON.parse(state.orders[0].rawNotify).payer, undefined);
const duplicate = await service.processWechatNotification({
timestamp: '1782360001',
nonce: 'notify-nonce',
serial: 'PLATFORM-SERIAL',
signature: 'signed'
}, '{"resource":"encrypted"}', 'recharge-notify');
assert.equal(duplicate.idempotent, true);
assert.equal(state.walletCredits.length, 1);
}
console.log('PASS: M07-B recharge plans validate limits and credit paid callbacks idempotently.');