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',