feat(M08-B): 补微信转账预检

This commit is contained in:
Codex
2026-06-29 11:23:49 +08:00
parent 1fada09249
commit 9506021b29
14 changed files with 410 additions and 22 deletions
@@ -38,6 +38,14 @@ interface IdentityRow extends RowDataPacket {
openid: string;
}
type PreflightStatus = 'PASS' | 'WARN' | 'FAIL';
interface PreflightCheck {
key: string;
status: PreflightStatus;
message: string;
}
export class CleaningPayoutError extends Error {
constructor(public readonly code: string, message = code) {
super(message);
@@ -54,6 +62,145 @@ export class CleaningPayoutService {
private readonly mockEnabled: boolean
) {}
async preflightWechatTransfer(input: CleaningActor & { settlementId: string }) {
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
const checks: PreflightCheck[] = [];
checks.push({
key: 'settlement_status',
status: settlement.status === 'CONFIRMED' ? 'PASS' : 'FAIL',
message: settlement.status === 'CONFIRMED'
? 'Settlement is confirmed and can start WeChat transfer.'
: `Settlement status must be CONFIRMED, current status is ${settlement.status}.`
});
checks.push({
key: 'settlement_amount',
status: Number(settlement.totalRewardCents) > 0 ? 'PASS' : 'FAIL',
message: Number(settlement.totalRewardCents) > 0
? 'Settlement amount is greater than zero.'
: 'Settlement amount must be greater than zero.'
});
let account: AccountRow | null = null;
try {
account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
checks.push({
key: 'collection_account',
status: 'PASS',
message: account.storeId ? 'Store scoped WeChat collection account is configured.'
: 'Tenant level WeChat collection account is configured.'
});
checks.push({
key: 'collection_account_authorized',
status: account.authorizationStatus === 'AUTHORIZED' ? 'PASS' : 'FAIL',
message: account.authorizationStatus === 'AUTHORIZED'
? 'Collection account is authorized.'
: `Collection account authorization status is ${account.authorizationStatus}.`
});
} catch (error) {
if (!(error instanceof CleaningPayoutError)) throw error;
checks.push({
key: 'collection_account',
status: 'FAIL',
message: 'No enabled WeChat collection account matches this settlement store.'
});
}
const credential = account ? this.resolveCredential(account.credentialRef) : null;
checks.push({
key: 'wechat_credential',
status: credential ? 'PASS' : 'FAIL',
message: credential ? 'WeChat Pay credential reference is resolvable.'
: 'WeChat Pay credential reference is missing or not loaded.'
});
if (account && credential) {
checks.push({
key: 'merchant_match',
status: credential.merchantId === account.merchantId ? 'PASS' : 'FAIL',
message: credential.merchantId === account.merchantId
? 'Credential merchant id matches the collection account.'
: 'Credential merchant id does not match the collection account.'
});
checks.push({
key: 'transfer_scene_id',
status: credential.transferSceneId ? 'PASS' : 'FAIL',
message: credential.transferSceneId
? 'Merchant transfer scene id is configured.'
: 'Merchant transfer scene id is required before real transfer.'
});
checks.push({
key: 'transfer_scene_report_infos',
status: credential.transferSceneReportInfos?.length ? 'PASS' : 'WARN',
message: credential.transferSceneReportInfos?.length
? `${credential.transferSceneReportInfos.length} transfer scene report info item(s) configured.`
: 'No transfer scene report info is configured; confirm whether the selected WeChat scene requires it.'
});
const notifyUrl = credential.transferNotifyUrl || '';
checks.push({
key: 'transfer_notify_url',
status: notifyUrl.startsWith('https://') && notifyUrl.includes('/app-api/cleaning/wechat-transfer/notify')
? 'PASS' : 'WARN',
message: notifyUrl
? 'Transfer notification URL is configured.'
: 'Transfer notification URL is not configured; active polling can still sync intermediate states.'
});
checks.push({
key: 'platform_certificates',
status: Object.keys(credential.platformCertificates).length > 0 ? 'PASS' : 'FAIL',
message: Object.keys(credential.platformCertificates).length > 0
? 'At least one WeChat platform certificate is loaded for notification verification.'
: 'No WeChat platform certificate is loaded.'
});
}
let cleanerOpenidConfigured = false;
if (account) {
try {
await this.resolveCleanerOpenid(input.tenantId, account.platformAppId, settlement.cleanerUserId);
cleanerOpenidConfigured = true;
} catch (error) {
if (!(error instanceof CleaningPayoutError)) throw error;
}
}
checks.push({
key: 'cleaner_openid',
status: cleanerOpenidConfigured ? 'PASS' : 'FAIL',
message: cleanerOpenidConfigured
? 'Cleaner has a WeChat miniapp openid for this platform app.'
: 'Cleaner WeChat miniapp openid is missing.'
});
return {
ready: checks.every((check) => check.status !== 'FAIL'),
settlement: {
id: settlement.id,
settlementNo: settlement.settlementNo,
status: settlement.status,
totalRewardCents: Number(settlement.totalRewardCents),
cleanerUserId: settlement.cleanerUserId,
storeId: settlement.storeId
},
account: account ? {
configured: true,
id: account.id,
storeScoped: account.storeId !== null,
merchantIdMasked: maskIdentifier(account.merchantId),
credentialRefMasked: maskIdentifier(account.credentialRef),
authorizationStatus: account.authorizationStatus
} : { configured: false },
credential: credential ? {
configured: true,
appIdPresent: credential.appId.length > 0,
serialNoPresent: credential.serialNo.length > 0,
transferSceneId: credential.transferSceneId || '',
reportInfoCount: credential.transferSceneReportInfos?.length ?? 0,
transferNotifyUrlConfigured: Boolean(credential.transferNotifyUrl),
platformCertificateCount: Object.keys(credential.platformCertificates).length
} : { configured: false },
cleaner: { openidConfigured: cleanerOpenidConfigured },
checks
};
}
async executeWechatTransfer(input: CleaningActor & {
settlementId: string;
mode: 'API' | 'MOCK';
@@ -357,3 +504,8 @@ function optionalStringPayload(payload: Record<string, unknown>, key: string) {
const value = payload[key];
return typeof value === 'string' ? value : '';
}
function maskIdentifier(value: string) {
if (value.length <= 4) return '****';
return `${value.slice(0, 2)}***${value.slice(-4)}`;
}
+22 -1
View File
@@ -94,7 +94,8 @@ export interface CleaningRouteOptions {
| 'assertCanUploadPhoto' | 'stats'>;
mediaStorage?: MediaStorage;
payoutService?: Pick<CleaningPayoutService,
'executeWechatTransfer' | 'syncWechatTransfer' | 'processWechatTransferNotification'>;
'preflightWechatTransfer' | 'executeWechatTransfer' | 'syncWechatTransfer'
| 'processWechatTransferNotification'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -465,6 +466,26 @@ export async function registerCleaningRoutes(
}));
});
app.get('/admin-api/cleaning/settlements/:settlementId/wechat-transfer/preflight', async (request, reply) => {
if (!options.payoutService) return reply.status(501).send({
code: 'CLEANING_PAYOUT_UNAVAILABLE',
message: 'Cleaning payout service is not configured.',
traceId: request.traceId
});
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.payoutService!.preflightWechatTransfer({
...actor,
settlementId: params.data.settlementId
}),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/settlements/:settlementId/wechat-transfer/sync', async (request, reply) => {
if (!options.payoutService) return reply.status(501).send({
code: 'CLEANING_PAYOUT_UNAVAILABLE',
+30 -1
View File
@@ -24,6 +24,34 @@ const actor = {
traceId: 'cleaning-payout-test'
};
{
const harness = createHarness({});
const result = await harness.service.preflightWechatTransfer({
...actor,
settlementId: '501'
});
assert.equal(result.ready, true);
assert.equal(result.account.merchantIdMasked, '19***0109');
assert.equal(result.credential.reportInfoCount, 1);
assert.equal(result.cleaner.openidConfigured, true);
assert.equal(result.checks.find((item) => item.key === 'transfer_scene_id').status, 'PASS');
}
{
const harness = createHarness({
credentialPatch: { transferSceneId: '', transferSceneReportInfos: [] },
openid: ''
});
const result = await harness.service.preflightWechatTransfer({
...actor,
settlementId: '501'
});
assert.equal(result.ready, false);
assert.equal(result.checks.find((item) => item.key === 'transfer_scene_id').status, 'FAIL');
assert.equal(result.checks.find((item) => item.key === 'transfer_scene_report_infos').status, 'WARN');
assert.equal(result.checks.find((item) => item.key === 'cleaner_openid').status, 'FAIL');
}
{
const harness = createHarness({ transferState: 'SUCCESS' });
const result = await harness.service.executeWechatTransfer({
@@ -176,7 +204,7 @@ function createHarness(options = {}) {
return [[state.account], []];
}
if (sql.includes('FROM qipai_user_identities')) {
return [[{ openid: 'openid-cleaner' }], []];
return [options.openid === '' ? [] : [{ openid: options.openid ?? 'openid-cleaner' }], []];
}
throw new Error(`Unexpected SQL: ${sql}`);
}
@@ -235,6 +263,7 @@ function createHarness(options = {}) {
transferSceneId: '1000',
transferSceneReportInfos: [{ infoType: '岗位类型', infoContent: '保洁员' }]
};
Object.assign(credential, options.credentialPatch ?? {});
return {
state,
repository,
+22
View File
@@ -152,6 +152,17 @@ const app = await buildApp({
}
},
payoutService: {
async preflightWechatTransfer(input) {
calls.push(['preflightWechatTransfer', input]);
return {
ready: true,
settlement: { id: input.settlementId, settlementNo: 'CLS-501', status: 'CONFIRMED' },
account: { configured: true, merchantIdMasked: '19***0109' },
credential: { configured: true, transferSceneId: '1000', reportInfoCount: 1 },
cleaner: { openidConfigured: true },
checks: [{ key: 'transfer_scene_id', status: 'PASS', message: 'ok' }]
};
},
async executeWechatTransfer(input) {
calls.push(['executeWechatTransfer', input]);
return {
@@ -419,6 +430,17 @@ assert.equal(calls.at(-1)[0], 'executeWechatTransfer');
assert.equal(calls.at(-1)[1].settlementId, '501');
assert.equal(calls.at(-1)[1].mode, 'API');
const wechatTransferPreflight = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/settlements/501/wechat-transfer/preflight',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(wechatTransferPreflight.statusCode, 200);
assert.equal(wechatTransferPreflight.json().data.ready, true);
assert.equal(wechatTransferPreflight.json().data.account.merchantIdMasked, '19***0109');
assert.equal(calls.at(-1)[0], 'preflightWechatTransfer');
assert.equal(calls.at(-1)[1].settlementId, '501');
const syncedWechatTransfer = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements/501/wechat-transfer/sync',