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',
|
||||
|
||||
Reference in New Issue
Block a user