feat(M05-B): 完成微信支付退款与对账基础
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user