feat(M05-D): 完成收款配置与幂等分账
This commit is contained in:
@@ -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.');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
Reference in New Issue
Block a user