feat(M08-B): 补微信转账预检
This commit is contained in:
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user