feat(M08-B): 接入保洁微信转账适配器
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadConfig } from '../dist/config.js';
|
||||
import {
|
||||
CleaningPayoutError,
|
||||
CleaningPayoutService
|
||||
} from '../dist/cleaning/cleaning-payout-service.js';
|
||||
|
||||
assert.equal(loadConfig({
|
||||
NODE_ENV: 'production',
|
||||
QIPAI_MQTT_USERNAME: 'backend-test',
|
||||
QIPAI_MQTT_PASSWORD: 'not-a-real-secret',
|
||||
QIPAI_JWT_SECRET: 'production-cleaning-payout-secret-long-enough',
|
||||
QIPAI_CLEANING_PAYOUT_MOCK_ENABLED: 'true'
|
||||
}).payment.cleaningPayoutMockEnabled, false);
|
||||
assert.equal(loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
QIPAI_CLEANING_PAYOUT_MOCK_ENABLED: 'true'
|
||||
}).payment.cleaningPayoutMockEnabled, true);
|
||||
|
||||
const actor = {
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
|
||||
traceId: 'cleaning-payout-test'
|
||||
};
|
||||
|
||||
{
|
||||
const harness = createHarness({ transferState: 'SUCCESS' });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'API'
|
||||
});
|
||||
assert.equal(result.transferState, 'SUCCESS');
|
||||
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER');
|
||||
assert.equal(harness.state.transferInput.amountCents, 1200);
|
||||
assert.equal(harness.state.transferInput.openid, 'openid-cleaner');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ transferState: 'WAIT_USER_CONFIRM', packageInfo: 'package-info' });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'API',
|
||||
note: 'need user confirm'
|
||||
});
|
||||
assert.equal(result.transferState, 'WAIT_USER_CONFIRM');
|
||||
assert.equal(harness.repository.pending[0].payoutState, 'WAIT_USER_CONFIRM');
|
||||
assert.equal(harness.repository.pending[0].payoutPackageInfo, 'package-info');
|
||||
assert.equal(harness.repository.paid.length, 0);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ transferState: 'FAIL', failReason: 'REAL_NAME_CHECK_FAILED' });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'API'
|
||||
});
|
||||
assert.equal(result.transferState, 'FAIL');
|
||||
assert.equal(harness.repository.failures[0].error, 'REAL_NAME_CHECK_FAILED');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ mockEnabled: true });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'MOCK'
|
||||
});
|
||||
assert.equal(result.transferState, 'SUCCESS');
|
||||
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER_MOCK');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ mockEnabled: false });
|
||||
await assert.rejects(
|
||||
() => harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'MOCK'
|
||||
}),
|
||||
(error) => error instanceof CleaningPayoutError
|
||||
&& error.code === 'CLEANING_PAYOUT_MOCK_DISABLED'
|
||||
);
|
||||
}
|
||||
|
||||
function createHarness(options = {}) {
|
||||
const state = {
|
||||
transferInput: null,
|
||||
settlement: {
|
||||
id: '501',
|
||||
settlementNo: 'CLS-20260627-501',
|
||||
cleanerUserId: '31',
|
||||
storeId: '11',
|
||||
status: options.status ?? 'CONFIRMED',
|
||||
totalRewardCents: 1200,
|
||||
payoutReference: '',
|
||||
payoutState: ''
|
||||
},
|
||||
account: {
|
||||
id: '41',
|
||||
platformAppId: '9',
|
||||
storeId: '11',
|
||||
merchantId: '1900000109',
|
||||
credentialRef: 'wechat-cleaning',
|
||||
authorizationStatus: options.authorizationStatus ?? 'AUTHORIZED'
|
||||
}
|
||||
};
|
||||
const pool = {
|
||||
async execute(sql) {
|
||||
if (sql.includes('FROM qipai_cleaning_settlements')) {
|
||||
return [[state.settlement], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_collection_accounts')) {
|
||||
return [[state.account], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_user_identities')) {
|
||||
return [[{ openid: 'openid-cleaner' }], []];
|
||||
}
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
}
|
||||
};
|
||||
const repository = {
|
||||
paid: [],
|
||||
failures: [],
|
||||
pending: [],
|
||||
async markSettlementPaid(input) {
|
||||
this.paid.push(input);
|
||||
return { ...state.settlement, status: 'PAID', payoutReference: input.payoutReference };
|
||||
},
|
||||
async recordSettlementPayoutFailure(input) {
|
||||
this.failures.push(input);
|
||||
return { ...state.settlement, payoutError: input.error };
|
||||
},
|
||||
async recordSettlementPayoutPending(input) {
|
||||
this.pending.push(input);
|
||||
return { ...state.settlement, payoutState: input.payoutState };
|
||||
}
|
||||
};
|
||||
const client = {
|
||||
async createMerchantTransfer(_credential, input) {
|
||||
state.transferInput = input;
|
||||
return {
|
||||
outBillNo: input.outBillNo,
|
||||
transferBillNo: 'wx-transfer-501',
|
||||
state: options.transferState ?? 'SUCCESS',
|
||||
failReason: options.failReason ?? '',
|
||||
packageInfo: options.packageInfo ?? ''
|
||||
};
|
||||
}
|
||||
};
|
||||
const credential = {
|
||||
appId: 'wx-test',
|
||||
merchantId: '1900000109',
|
||||
serialNo: 'serial',
|
||||
privateKeyPem: 'private-key',
|
||||
apiV3Key: '0123456789abcdef0123456789abcdef',
|
||||
platformCertificates: {},
|
||||
transferSceneId: '1000',
|
||||
transferSceneReportInfos: [{ infoType: '岗位类型', infoContent: '保洁员' }]
|
||||
};
|
||||
return {
|
||||
state,
|
||||
repository,
|
||||
service: new CleaningPayoutService(
|
||||
pool,
|
||||
repository,
|
||||
client,
|
||||
new Map([['wechat-cleaning', credential]]),
|
||||
options.mockEnabled ?? false
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
console.log('PASS: M08-B cleaning payout service handles WeChat transfer states and mock gate.');
|
||||
@@ -151,6 +151,16 @@ const app = await buildApp({
|
||||
return { byStatus: { SUBMITTED: 2 }, pendingSettlementCents: 1200 };
|
||||
}
|
||||
},
|
||||
payoutService: {
|
||||
async executeWechatTransfer(input) {
|
||||
calls.push(['executeWechatTransfer', input]);
|
||||
return {
|
||||
settlement: settlementResponse(input.mode === 'MOCK' ? 'PAID' : 'CONFIRMED'),
|
||||
transferState: input.mode === 'MOCK' ? 'SUCCESS' : 'WAIT_USER_CONFIRM',
|
||||
idempotent: false
|
||||
};
|
||||
}
|
||||
},
|
||||
mediaStorage: {
|
||||
async storeImage(input) {
|
||||
calls.push(['storeImage', input]);
|
||||
@@ -373,6 +383,18 @@ assert.equal(paidSettlement.json().data.status, 'PAID');
|
||||
assert.equal(paidSettlement.json().data.payoutReference, 'wx-paid-001');
|
||||
assert.equal(calls.at(-1)[0], 'markSettlementPaid');
|
||||
|
||||
const wechatTransfer = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/wechat-transfer',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { mode: 'API', note: 'wechat transfer' }
|
||||
});
|
||||
assert.equal(wechatTransfer.statusCode, 200);
|
||||
assert.equal(wechatTransfer.json().data.transferState, 'WAIT_USER_CONFIRM');
|
||||
assert.equal(calls.at(-1)[0], 'executeWechatTransfer');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
assert.equal(calls.at(-1)[1].mode, 'API');
|
||||
|
||||
const reclaimed = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/reclaim-timeouts',
|
||||
|
||||
@@ -93,6 +93,9 @@ const cleaningCollaborationVerifySql = read('database/migrations/2026062627_m08b
|
||||
const cleaningPayoutUpSql = read('database/migrations/2026062728_m08b_cleaning_payouts.up.sql');
|
||||
const cleaningPayoutDownSql = read('database/migrations/2026062728_m08b_cleaning_payouts.down.sql');
|
||||
const cleaningPayoutVerifySql = read('database/migrations/2026062728_m08b_cleaning_payouts.verify.sql');
|
||||
const cleaningTransferStateUpSql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql');
|
||||
const cleaningTransferStateDownSql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql');
|
||||
const cleaningTransferStateVerifySql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql');
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -440,4 +443,11 @@ assert.match(cleaningPayoutDownSql, /DROP COLUMN payout_error/);
|
||||
assert.match(cleaningPayoutVerifySql, /'payout_reference'/);
|
||||
assert.match(cleaningPayoutVerifySql, /'2026062728'/);
|
||||
|
||||
assert.match(cleaningTransferStateUpSql, /ADD COLUMN payout_state VARCHAR\(32\) NOT NULL DEFAULT ''/);
|
||||
assert.match(cleaningTransferStateUpSql, /ADD COLUMN payout_package_info VARCHAR\(1024\) NOT NULL DEFAULT ''/);
|
||||
assert.match(cleaningTransferStateUpSql, /idx_qipai_cleaning_settlement_transfer_state/);
|
||||
assert.match(cleaningTransferStateDownSql, /DROP COLUMN payout_package_info/);
|
||||
assert.match(cleaningTransferStateVerifySql, /'payout_state'/);
|
||||
assert.match(cleaningTransferStateVerifySql, /'2026062729'/);
|
||||
|
||||
console.log('PASS: M01-B through M08-B migration contracts are present.');
|
||||
|
||||
@@ -38,7 +38,8 @@ assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql/);
|
||||
assert.match(plan.file, /2026062525_m08b_cleaner_tasks\.up\.sql/);
|
||||
assert.match(plan.file, /2026062626_m08b_cleaning_settlements\.up\.sql/);
|
||||
assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.up\.sql/);
|
||||
assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql$/);
|
||||
assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/);
|
||||
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
|
||||
@@ -29,7 +29,13 @@ 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' }
|
||||
{ download_url: 'https://api.mch.weixin.qq.com/v3/billdownload/file?token=sanitized' },
|
||||
{
|
||||
out_bill_no: 'CLP501',
|
||||
transfer_bill_no: 'wx-transfer-501',
|
||||
state: 'WAIT_USER_CONFIRM',
|
||||
package_info: 'package-info'
|
||||
}
|
||||
];
|
||||
const transport = {
|
||||
async request(input) {
|
||||
@@ -73,6 +79,21 @@ assert.match((await client.downloadTradeBill(
|
||||
credential, '2026-06-21', 'ALL'
|
||||
)).download_url, /^https:/);
|
||||
|
||||
const transfer = await client.createMerchantTransfer(credential, {
|
||||
outBillNo: 'CLP501',
|
||||
openid: 'openid-sanitized',
|
||||
amountCents: 800,
|
||||
remark: '保洁结算501',
|
||||
sceneId: '1000',
|
||||
reportInfos: [{ infoType: '岗位类型', infoContent: '保洁员' }]
|
||||
});
|
||||
assert.equal(transfer.state, 'WAIT_USER_CONFIRM');
|
||||
assert.equal(transfer.packageInfo, 'package-info');
|
||||
assert.match(requests[4].url, /\/v3\/fund-app\/mch-transfer\/transfer-bills$/);
|
||||
assert.match(requests[4].body, /"out_bill_no":"CLP501"/);
|
||||
assert.match(requests[4].body, /"transfer_amount":800/);
|
||||
assert.equal(requests[4].headers.Authorization.includes(merchantKeys.privateKey), false);
|
||||
|
||||
const notification = encryptedNotification({
|
||||
out_trade_no: 'PAY-M05B-001',
|
||||
transaction_id: 'wx-transaction-test',
|
||||
|
||||
Reference in New Issue
Block a user