feat(M08-B): 补保洁微信转账回调与轮询

This commit is contained in:
Codex
2026-06-29 10:47:54 +08:00
parent b07c451700
commit a1bace2dac
12 changed files with 446 additions and 21 deletions
+158 -1
View File
@@ -3,6 +3,7 @@ import type { MySqlPool } from '../db/mysql.js';
import {
WechatPayClient,
WechatPayError,
type WechatNotificationHeaders,
type WechatPayCredential
} from '../payments/wechat-pay-client.js';
import {
@@ -18,8 +19,10 @@ interface SettlementPayoutRow extends RowDataPacket {
storeId: string | null;
status: 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
totalRewardCents: number;
payoutChannel: string;
payoutReference: string;
payoutState: string;
payoutPackageInfo: string;
}
interface AccountRow extends RowDataPacket {
@@ -147,11 +150,120 @@ export class CleaningPayoutService {
}
}
async syncWechatTransfer(input: CleaningActor & { settlementId: string; note?: string }) {
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
if (settlement.status === 'PAID') {
return { settlement, idempotent: true, transferState: 'SUCCESS' };
}
if (settlement.status !== 'CONFIRMED') {
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
}
if (settlement.payoutChannel !== 'WECHAT_TRANSFER' || !settlement.payoutReference) {
throw new CleaningPayoutError('WECHAT_TRANSFER_NOT_STARTED');
}
const account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
const credential = this.resolveCredential(account.credentialRef);
if (!credential) throw new CleaningPayoutError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
if (credential.merchantId !== account.merchantId) {
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
}
const outBillNo = normalizeOutBillNo(settlement.settlementNo, settlement.id);
const result = await this.client.queryMerchantTransferByOutBillNo(credential, outBillNo);
if (result.merchantId !== account.merchantId) {
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
}
if (result.amountCents > 0 && result.amountCents !== Number(settlement.totalRewardCents)) {
throw new CleaningPayoutError('WECHAT_TRANSFER_AMOUNT_MISMATCH');
}
if (result.state === 'SUCCESS') {
const paid = await this.repository.markSettlementPaid({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
note: input.note
});
return { settlement: paid, idempotent: false, transferState: result.state };
}
if (result.state === 'FAIL') {
const failed = await this.repository.recordSettlementPayoutFailure({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
note: input.note
});
return { settlement: failed, idempotent: false, transferState: result.state };
}
const pending = await this.repository.recordSettlementPayoutPending({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
payoutState: result.state,
payoutPackageInfo: settlement.payoutPackageInfo,
note: input.note
});
return { settlement: pending, idempotent: false, transferState: result.state };
}
async processWechatTransferNotification(
headers: WechatNotificationHeaders,
rawBody: string,
traceId: string
) {
const { credential, payload } = this.decryptNotification(headers, rawBody);
const merchantId = stringPayload(payload, 'mch_id');
if (merchantId !== credential.merchantId) {
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
}
const outBillNo = stringPayload(payload, 'out_bill_no');
const transferBillNo = optionalStringPayload(payload, 'transfer_bill_no');
const settlement = await this.findSettlementByTransferReference(outBillNo, transferBillNo);
const actor: CleaningActor = {
tenantId: settlement.tenantId,
userId: '0',
access: { roles: ['PLATFORM_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
traceId
};
const state = stringPayload(payload, 'state');
if (state === 'SUCCESS') {
const paid = await this.repository.markSettlementPaid({
...actor,
settlementId: settlement.id,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: transferBillNo || outBillNo,
note: '微信转账回调确认成功'
});
return { settlement: paid, transferState: state, idempotent: false };
}
if (state === 'FAIL') {
const failed = await this.repository.recordSettlementPayoutFailure({
...actor,
settlementId: settlement.id,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: transferBillNo || outBillNo,
error: optionalStringPayload(payload, 'fail_reason') || 'WECHAT_TRANSFER_FAILED',
note: '微信转账回调确认失败'
});
return { settlement: failed, transferState: state, idempotent: false };
}
const pending = await this.repository.recordSettlementPayoutPending({
...actor,
settlementId: settlement.id,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: transferBillNo || outBillNo,
payoutState: state,
payoutPackageInfo: settlement.payoutPackageInfo,
note: '微信转账回调更新中间态'
});
return { settlement: pending, transferState: state, idempotent: false };
}
private async loadSettlement(tenantId: string, settlementId: string) {
const [rows] = await this.pool.execute<SettlementPayoutRow[]>(
`SELECT id, settlement_no AS settlementNo, cleaner_user_id AS cleanerUserId,
store_id AS storeId, status, total_reward_cents AS totalRewardCents,
payout_reference AS payoutReference, payout_state AS payoutState
payout_channel AS payoutChannel, payout_reference AS payoutReference,
payout_state AS payoutState, payout_package_info AS payoutPackageInfo
FROM qipai_cleaning_settlements
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`,
[tenantId, settlementId]
@@ -194,9 +306,54 @@ export class CleaningPayoutService {
return this.credentials.get(reference)
?? this.credentials.get(reference.replace(/^env:/, ''));
}
private decryptNotification(headers: WechatNotificationHeaders, rawBody: string) {
let lastError: unknown;
for (const credential of this.credentials.values()) {
if (!credential.platformCertificates[headers.serial]) continue;
try {
return { credential, payload: this.client.verifyAndDecrypt(credential, headers, rawBody) };
} catch (error) {
lastError = error;
}
}
if (lastError instanceof Error) throw lastError;
throw new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
}
private async findSettlementByTransferReference(outBillNo: string, transferBillNo: string) {
const references = transferBillNo ? [outBillNo, transferBillNo] : [outBillNo];
const [rows] = await this.pool.execute<Array<SettlementPayoutRow & { tenantId: string }>>(
`SELECT tenant_id AS tenantId, id, settlement_no AS settlementNo,
cleaner_user_id AS cleanerUserId, store_id AS storeId, status,
total_reward_cents AS totalRewardCents, payout_channel AS payoutChannel,
payout_reference AS payoutReference, payout_state AS payoutState,
payout_package_info AS payoutPackageInfo
FROM qipai_cleaning_settlements
WHERE payout_channel = 'WECHAT_TRANSFER' AND deleted_at IS NULL
AND payout_reference IN (${references.map(() => '?').join(',')})
ORDER BY updated_at DESC, id DESC LIMIT 1`,
references
);
if (!rows[0]) throw new CleaningPayoutError('WECHAT_TRANSFER_SETTLEMENT_NOT_FOUND');
return rows[0];
}
}
function normalizeOutBillNo(settlementNo: string, settlementId: string) {
const normalized = settlementNo.replace(/[^A-Za-z0-9_-]/g, '');
return (normalized || `CLP${settlementId}`).slice(0, 32);
}
function stringPayload(payload: Record<string, unknown>, key: string) {
const value = payload[key];
if (typeof value !== 'string' || value.length === 0) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return value;
}
function optionalStringPayload(payload: Record<string, unknown>, key: string) {
const value = payload[key];
return typeof value === 'string' ? value : '';
}
+21
View File
@@ -210,6 +210,22 @@ export class WechatPayClient {
};
}
async queryMerchantTransferByOutBillNo(credential: WechatPayCredential, outBillNo: string) {
const result = await this.apiRequest(
credential,
'GET',
`/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/${encodeURIComponent(outBillNo)}`
);
return {
merchantId: stringField(result, 'mch_id'),
outBillNo: stringField(result, 'out_bill_no'),
transferBillNo: optionalStringField(result, 'transfer_bill_no'),
state: stringField(result, 'state'),
failReason: optionalStringField(result, 'fail_reason'),
amountCents: optionalNumberField(result, 'transfer_amount')
};
}
verifyAndDecrypt(
credential: WechatPayCredential,
headers: WechatNotificationHeaders,
@@ -368,6 +384,11 @@ function optionalStringField(value: Record<string, unknown>, key: string) {
return typeof field === 'string' ? field : '';
}
function optionalNumberField(value: Record<string, unknown>, key: string) {
const field = value[key];
return typeof field === 'number' ? field : 0;
}
function parseTransferReportInfos(value: unknown) {
if (!Array.isArray(value)) return undefined;
return value.map((item) => {
+82 -3
View File
@@ -1,4 +1,7 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { Transform } from 'node:stream';
import type {
FastifyInstance, FastifyReply, FastifyRequest, preParsingHookHandler
} from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
@@ -14,7 +17,7 @@ import {
CleaningPayoutError,
type CleaningPayoutService
} from '../cleaning/cleaning-payout-service.js';
import { WechatPayError } from '../payments/wechat-pay-client.js';
import { WechatPayError, type WechatNotificationHeaders } from '../payments/wechat-pay-client.js';
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
@@ -74,6 +77,9 @@ const settlementWechatTransferSchema = z.object({
mode: z.enum(['API', 'MOCK']).default('API'),
note: z.string().trim().max(512).optional()
}).strict();
const settlementWechatSyncSchema = z.object({
note: z.string().trim().max(512).optional()
}).strict();
const reclaimSchema = z.object({
olderThanMinutes: z.coerce.number().int().min(5).max(1440).default(60),
limit: z.coerce.number().int().min(1).max(100).default(20)
@@ -87,7 +93,8 @@ export interface CleaningRouteOptions {
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'stats'>;
mediaStorage?: MediaStorage;
payoutService?: Pick<CleaningPayoutService, 'executeWechatTransfer'>;
payoutService?: Pick<CleaningPayoutService,
'executeWechatTransfer' | 'syncWechatTransfer' | 'processWechatTransferNotification'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -457,6 +464,48 @@ export async function registerCleaningRoutes(
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',
message: 'Cleaning payout service is not configured.',
traceId: request.traceId
});
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
const body = settlementWechatSyncSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.payoutService!.syncWechatTransfer({
...actor,
settlementId: params.data.settlementId,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/wechat-transfer/notify', {
preParsing: captureRawBody
}, 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 headers = wechatHeaders(request);
if (!headers) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => {
await options.payoutService!.processWechatTransferNotification(
headers,
request.rawBody,
request.traceId
);
return { code: 'SUCCESS', message: '成功', traceId: request.traceId };
});
});
}
async function requireActor(
@@ -532,3 +581,33 @@ function invalid(reply: FastifyReply, traceId: string) {
traceId
});
}
function wechatHeaders(request: FastifyRequest): WechatNotificationHeaders | null {
const read = (key: string) => singleHeader(request.headers[key]);
const headers = {
timestamp: read('wechatpay-timestamp'),
nonce: read('wechatpay-nonce'),
serial: read('wechatpay-serial'),
signature: read('wechatpay-signature')
};
return headers.timestamp && headers.nonce && headers.serial && headers.signature
? headers as WechatNotificationHeaders
: null;
}
const captureRawBody: preParsingHookHandler = (request, _reply, payload, done) => {
const chunks: Buffer[] = [];
const capture = new Transform({
transform(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback(null, chunk);
},
flush(callback) {
request.rawBody = Buffer.concat(chunks).toString('utf8');
callback();
}
});
const transformed = payload.pipe(capture) as typeof payload;
transformed.receivedEncodedLength = payload.receivedEncodedLength;
done(null, transformed);
};