feat(M05-D): 完成收款配置与幂等分账

This commit is contained in:
Codex
2026-06-22 11:49:37 +08:00
parent 52edf2482e
commit 1680d734bf
16 changed files with 1203 additions and 18 deletions
+19 -1
View File
@@ -60,6 +60,9 @@ const wechatRefundVerifySql = read('database/migrations/2026062216_m05b_wechat_r
const thirdPartyUpSql = read('database/migrations/2026062217_m05c_third_party.up.sql');
const thirdPartyDownSql = read('database/migrations/2026062217_m05c_third_party.down.sql');
const thirdPartyVerifySql = read('database/migrations/2026062217_m05c_third_party.verify.sql');
const profitSharingUpSql = read('database/migrations/2026062218_m05d_profit_sharing.up.sql');
const profitSharingDownSql = read('database/migrations/2026062218_m05d_profit_sharing.down.sql');
const profitSharingVerifySql = read('database/migrations/2026062218_m05d_profit_sharing.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -267,5 +270,20 @@ assert.match(thirdPartyUpSql, /voucher_hash CHAR\(64\)/);
assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_group_redemption_voucher/);
assert.match(thirdPartyUpSql, /UNIQUE KEY uq_qipai_direct_booking_event/);
assert.doesNotMatch(thirdPartyUpSql, /voucher_code|api_token|webhook_secret/i);
for (const table of [
'qipai_collection_accounts', 'qipai_profit_share_receivers',
'qipai_profit_share_policies'
]) {
assert.match(profitSharingUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(profitSharingDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(profitSharingVerifySql, new RegExp(`'${table}'`));
}
assert.match(profitSharingUpSql, /receiver_hash CHAR\(64\)/);
assert.match(profitSharingUpSql, /percentage_bps SMALLINT UNSIGNED/);
assert.match(profitSharingUpSql, /uq_qipai_profit_share_payment_receiver/);
assert.doesNotMatch(
profitSharingUpSql,
/\bprivate_key\b|\bapi_v3_key\b|\breceiver_account\b/i
);
console.log('PASS: M01-B through M05-C migration contracts are present.');
console.log('PASS: M01-B through M05-D migration contracts are present.');
+2 -1
View File
@@ -28,7 +28,8 @@ assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql/);
assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql/);
assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql/);
assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql/);
assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql$/);
assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql/);
assert.match(plan.file, /2026062218_m05d_profit_sharing\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -31,6 +31,8 @@ import {
import {
PaymentError, PaymentRepository
} from '../dist/payments/payment-repository.js';
import { ProfitSharingService } from '../dist/payments/profit-sharing-service.js';
import { WechatPayClient } from '../dist/payments/wechat-pay-client.js';
import { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
import {
@@ -44,6 +46,7 @@ const expectedTables = [
'qipai_async_tasks',
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_collection_accounts',
'qipai_devices',
'qipai_direct_bookings',
'qipai_group_redemptions',
@@ -65,6 +68,8 @@ const expectedTables = [
'qipai_payments',
'qipai_permissions',
'qipai_platform_apps',
'qipai_profit_share_policies',
'qipai_profit_share_receivers',
'qipai_profit_shares',
'qipai_reconciliation_runs',
'qipai_refunds',
@@ -110,12 +115,12 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217']
'2026062015', '2026062216', '2026062217', '2026062218']
);
return rows;
}
@@ -1340,6 +1345,175 @@ async function assertThirdPartyDomain(pool, context) {
assert.equal(unmapped.status, 'PENDING_MAPPING');
}
async function assertProfitSharingDomain(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`,
[context.tenantId]
);
const [paymentRows] = await pool.query(
`SELECT p.id, p.order_id AS orderId, p.store_id AS storeId,
p.amount_cents AS amountCents
FROM qipai_payments p
WHERE p.tenant_id = ? AND p.provider = 'GROUP_BUY'
AND p.status = 'SUCCEEDED' ORDER BY p.id DESC LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const payment = paymentRows[0];
await pool.query(
`INSERT INTO qipai_payments
(tenant_id, platform_app_id, order_id, store_id, payment_no,
channel, provider, client_request_id, status, amount_cents,
provider_payment_id, paid_at)
VALUES (?, ?, ?, ?, 'M05D-WECHAT-PAYMENT', 'WECHAT', 'WECHAT',
'm05d-wechat-payment', 'SUCCEEDED', ?, 'wx-m05d-transaction',
UTC_TIMESTAMP(3))`,
[context.tenantId, context.platformAppId, payment.orderId,
payment.storeId, payment.amountCents]
);
const [wechatPaymentRows] = await pool.query(
`SELECT id FROM qipai_payments
WHERE tenant_id = ? AND client_request_id = 'm05d-wechat-payment'`,
[context.tenantId]
);
const access = {
roles: ['TENANT_ADMIN'],
capabilities: ['tenant.manage'],
storeIds: []
};
const service = new ProfitSharingService(
pool,
new WechatPayClient({
async request() {
throw new Error('M05-D MySQL test uses the explicit mock adapter only.');
}
}),
new Map(),
true
);
await assert.rejects(
() => service.saveCollectionAccount({
tenantId: context.tenantId,
platformAppId: context.platformAppId,
actorId: adminId,
access,
storeId: String(payment.storeId),
merchantId: '1900000109',
credentialRef: 'env:WX_M05D',
authorizationStatus: 'PENDING',
profitSharingEnabled: true,
enabled: true
}),
(error) => error.code === 'PROFIT_SHARING_NOT_AUTHORIZED'
);
const account = await service.saveCollectionAccount({
tenantId: context.tenantId,
platformAppId: context.platformAppId,
actorId: adminId,
access,
storeId: String(payment.storeId),
merchantId: '1900000109',
credentialRef: 'env:WX_M05D',
authorizationStatus: 'AUTHORIZED',
profitSharingEnabled: true,
enabled: true
});
const receiverA = await service.saveReceiver({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
receiverType: 'MERCHANT_ID',
receiverAccount: 'receiver-private-account-a',
receiverCredentialRef: 'receiver:STORE_A',
relationType: 'PARTNER',
name: 'M05D Receiver A',
authorizationStatus: 'AUTHORIZED',
enabled: true
});
const receiverB = await service.saveReceiver({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
receiverType: 'MERCHANT_ID',
receiverAccount: 'receiver-private-account-b',
receiverCredentialRef: 'receiver:STORE_B',
relationType: 'PARTNER',
name: 'M05D Receiver B',
authorizationStatus: 'AUTHORIZED',
enabled: true
});
await service.savePolicy({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
storeId: String(payment.storeId),
receiverId: receiverA.receiverId,
percentageBps: 3000,
enabled: true
});
await service.savePolicy({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
storeId: String(payment.storeId),
receiverId: receiverB.receiverId,
percentageBps: 2000,
enabled: true
});
await assert.rejects(
() => service.savePolicy({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
storeId: String(payment.storeId),
receiverId: receiverB.receiverId,
percentageBps: 8000,
enabled: true
}),
(error) => error.code === 'PROFIT_SHARE_TOTAL_EXCEEDED'
);
const result = await service.execute({
tenantId: context.tenantId,
actorId: adminId,
access,
paymentId: String(wechatPaymentRows[0].id),
clientRequestId: 'm05d-profit-share-request',
mode: 'MOCK'
});
assert.equal(result.shares.length, 2);
assert.equal(result.shares.every((share) => share.status === 'SUCCEEDED'), true);
const duplicate = await service.execute({
tenantId: context.tenantId,
actorId: adminId,
access,
paymentId: String(wechatPaymentRows[0].id),
clientRequestId: 'm05d-profit-share-request',
mode: 'MOCK'
});
assert.equal(duplicate.idempotent, true);
assert.equal(duplicate.shares.length, 2);
const [shareRows] = await pool.query(
`SELECT ps.status, ps.percentage_bps AS percentageBps,
ps.amount_cents AS amountCents, ps.receiver_ref AS receiverMasked,
r.receiver_hash AS receiverHash, r.receiver_credential_ref AS receiverRef
FROM qipai_profit_shares ps
INNER JOIN qipai_profit_share_receivers r
ON r.tenant_id = ps.tenant_id AND r.id = ps.receiver_id
WHERE ps.tenant_id = ? AND ps.batch_request_id = ?
ORDER BY ps.id`,
[context.tenantId, 'm05d-profit-share-request']
);
assert.equal(shareRows.length, 2);
assert.deepEqual(shareRows.map((row) => row.percentageBps), [3000, 2000]);
assert.equal(shareRows.every((row) => row.status === 'SUCCEEDED'), true);
assert.equal(shareRows.every((row) => /^[a-f0-9]{64}$/.test(row.receiverHash)), true);
assert.equal(shareRows.some((row) => row.receiverMasked.includes('private')), false);
assert.equal(shareRows.every((row) => row.receiverRef.startsWith('receiver:')), true);
}
async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -1444,7 +1618,8 @@ try {
{ version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' }
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1460,13 +1635,14 @@ try {
await assertOrderShares(pool, loginContext);
await assertPaymentDomain(pool, loginContext);
await assertThirdPartyDomain(pool, loginContext);
await assertProfitSharingDomain(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M05-C tables.');
console.log('PASS: down removed all M01-B through M05-D tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -1488,7 +1664,8 @@ try {
{ version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' }
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -1576,6 +1753,11 @@ try {
'third-party booking webhook idempotency',
'mapped booking claim creates a paid order',
'unmapped booking enters manual queue'
,
'collection account authorization gate',
'profit-share percentage total validation',
'receiver hash and masked storage',
'payment and receiver idempotent profit sharing'
]
}, null, 2));
} finally {
+132
View File
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import { generateKeyPairSync } from 'node:crypto';
import { buildApp } from '../dist/app.js';
import { loadConfig } from '../dist/config.js';
import { signAccessToken } from '../dist/auth/jwt.js';
import { WechatPayClient } from '../dist/payments/wechat-pay-client.js';
assert.equal(loadConfig({
NODE_ENV: 'production',
QIPAI_JWT_SECRET: 'production-profit-share-secret-long-enough',
QIPAI_PROFIT_SHARE_MOCK_ENABLED: 'true'
}).payment.profitShareMockEnabled, false);
assert.equal(loadConfig({
NODE_ENV: 'test',
QIPAI_PROFIT_SHARE_MOCK_ENABLED: 'true'
}).payment.profitShareMockEnabled, true);
const keys = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
let apiRequest;
const client = new WechatPayClient({
async request(input) {
apiRequest = input;
return {
status: 200,
headers: {},
body: JSON.stringify({ order_id: 'wx-share-order', state: 'FINISHED' })
};
}
});
const shareResult = await client.createProfitSharing({
appId: 'wx-test',
merchantId: '1900000109',
serialNo: 'serial-test',
privateKeyPem: keys.privateKey,
apiV3Key: '0123456789abcdef0123456789abcdef',
platformCertificates: {},
profitShareReceivers: {}
}, {
transactionId: 'wx-transaction-001',
outOrderNo: 'share-request-001',
receivers: [{
type: 'MERCHANT_ID',
account: 'receiver-account-private',
amountCents: 1200,
description: 'sanitized share'
}],
finish: true
});
assert.equal(shareResult.state, 'FINISHED');
assert.match(apiRequest.url, /\/v3\/profitsharing\/orders$/);
assert.match(apiRequest.body, /"amount":1200/);
assert.equal(apiRequest.headers.Authorization.includes(keys.privateKey), false);
const secret = 'profit-sharing-route-secret-32-characters';
const token = signAccessToken({
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
let executeInput;
const app = await buildApp({
payment: {
jwtSecret: secret,
testAdapterEnabled: false,
authRepository: {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tenantId: '7', platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: '21', tenantId: '7', userType: 'ADMIN', status: 'ACTIVE',
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
}
};
}
},
accessControl: {
async getAccessProfile() {
return {
roles: ['TENANT_ADMIN'],
capabilities: ['tenant.manage'],
storeIds: []
};
}
},
repository: {
async createPayment() { return {}; },
async processTestCallback() { return {}; }
},
profitSharing: {
async saveCollectionAccount() {
return { accountId: '41', created: true };
},
async saveReceiver() {
return { receiverId: '42', created: true };
},
async savePolicy() {
return { saved: true };
},
async execute(input) {
executeInput = input;
return {
shares: [{ shareId: '43', amountCents: 1200, status: 'SUCCEEDED' }],
idempotent: false
};
},
async list() {
return { accounts: [], shares: [] };
}
}
}
});
const executed = await app.inject({
method: 'POST',
url: '/admin-api/pay/profit-shares',
headers: { authorization: `Bearer ${token}` },
payload: {
paymentId: '31',
clientRequestId: 'share-request-001',
mode: 'MOCK'
}
});
assert.equal(executed.statusCode, 200);
assert.equal(executeInput.tenantId, '7');
assert.equal(executeInput.access.capabilities[0], 'tenant.manage');
await app.close();
console.log('PASS: M05-D Wechat profit-sharing request, production mock gate and admin routes.');