feat(M08-B): 补保洁微信转账回调与轮询
This commit is contained in:
@@ -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 : '';
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user