feat(M05-B): 完成微信支付退款与对账基础

This commit is contained in:
Codex
2026-06-22 11:15:22 +08:00
parent dea2ee5ee1
commit 4c66e192b9
17 changed files with 1316 additions and 18 deletions
+156 -2
View File
@@ -1,10 +1,16 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import type {
FastifyInstance, FastifyReply, preParsingHookHandler
} from 'fastify';
import { Transform } from 'node:stream';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { RbacRepository } from '../auth/rbac-repository.js';
import {
PaymentError, type PaymentRepository
} from '../payments/payment-repository.js';
import type { WechatPaymentService } from '../payments/wechat-payment-service.js';
import { WechatPayError } from '../payments/wechat-pay-client.js';
const createSchema = z.object({
orderId: z.string().regex(/^[1-9]\d{0,19}$/),
@@ -16,10 +22,23 @@ const callbackSchema = z.object({
callbackId: z.string().min(8).max(128),
amountCents: z.number().int().positive()
}).strict();
const refundSchema = z.object({
paymentId: z.string().regex(/^[1-9]\d{0,19}$/),
amountCents: z.number().int().positive(),
reason: z.string().min(1).max(512),
clientRequestId: z.string().min(8).max(128)
}).strict();
const billSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
billDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
billType: z.enum(['ALL', 'SUCCESS', 'REFUND'])
}).strict();
export interface PaymentRouteOptions {
repository: Pick<PaymentRepository, 'createPayment' | 'processTestCallback'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl?: Pick<RbacRepository, 'getAccessProfile'>;
wechat?: WechatPaymentService;
jwtSecret: string;
testAdapterEnabled: boolean;
}
@@ -74,6 +93,97 @@ export async function registerPaymentRoutes(
return { code: 0, data, traceId: request.traceId };
});
});
if (options.wechat) {
app.post('/app-api/pay/wechat/prepay', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = z.object({
paymentId: z.string().regex(/^[1-9]\d{0,19}$/)
}).strict().safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.wechat!.createPrepay({
...auth, paymentId: body.data.paymentId
}),
traceId: request.traceId
}));
});
app.get('/app-api/pay/wechat/payments/:paymentId', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paymentParams.safeParse(request.params);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.wechat!.queryPayment({
...auth, paymentId: params.data.paymentId
}),
traceId: request.traceId
}));
});
app.post('/app-api/pay/wechat/notify', {
preParsing: captureRawBody
}, async (request, reply) => {
return handle(reply, request.traceId, async () => {
const data = await options.wechat!.processPaymentNotification(
notificationHeaders(request.headers),
request.rawBody,
request.traceId
);
return reply.send({ code: 'SUCCESS', message: '成功', data });
});
});
app.post('/app-api/pay/wechat/refund-notify', {
preParsing: captureRawBody
}, async (request, reply) => {
return handle(reply, request.traceId, async () => {
const data = await options.wechat!.processRefundNotification(
notificationHeaders(request.headers),
request.rawBody
);
return reply.send({ code: 'SUCCESS', message: '成功', data });
});
});
app.post('/admin-api/pay/refund', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const body = refundSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.wechat!.createRefund({
tenantId: auth.tenantId,
platformAppId: auth.platformAppId,
actorId: auth.userId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/pay/reconciliation', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const body = billSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.wechat!.requestReconciliation({
tenantId: auth.tenantId,
platformAppId: auth.platformAppId,
actorId: auth.userId,
...body.data
}),
traceId: request.traceId
}));
});
}
}
async function authenticate(
@@ -90,11 +200,22 @@ async function authenticate(
};
}
async function authenticateAdmin(
authorization: string | undefined, options: PaymentRouteOptions
) {
const auth = await authenticate(authorization, options);
if (!auth || !options.accessControl) return null;
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
if (!access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) return null;
return auth;
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof PaymentError)) throw error;
if (!(error instanceof PaymentError) && !(error instanceof WechatPayError)) throw error;
const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'PAYMENT_NOT_FOUND'
? 404 : error.code === 'PAYMENT_IDEMPOTENCY_CONFLICT' ? 409
: error.code.includes('FORBIDDEN') ? 403 : 400;
@@ -104,6 +225,39 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
}
}
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);
};
function notificationHeaders(headers: Record<string, unknown>) {
const read = (name: string) => {
const value = headers[name];
if (typeof value !== 'string' || value.length === 0) {
throw new WechatPayError('WECHAT_NOTIFICATION_HEADER_INVALID');
}
return value;
};
return {
timestamp: read('wechatpay-timestamp'),
nonce: read('wechatpay-nonce'),
serial: read('wechatpay-serial'),
signature: read('wechatpay-signature')
};
}
function unauthorized(reply: FastifyReply, traceId: string) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId