feat(M08-A): 接入充值微信支付调起

This commit is contained in:
Codex
2026-06-25 12:40:21 +08:00
parent 2bbb16e6f0
commit 53f5d6776f
17 changed files with 701 additions and 11 deletions
+77 -4
View File
@@ -1,9 +1,11 @@
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 { AccessProfile } from '../auth/rbac-repository.js';
import { RechargeError, type RechargeService } from '../wallets/recharge-service.js';
import { WechatPayError } from '../payments/wechat-pay-client.js';
const listSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
@@ -13,9 +15,15 @@ const createSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
clientRequestId: z.string().min(8).max(128)
}).strict();
const orderParams = z.object({
rechargeOrderId: z.string().regex(/^[1-9]\d{0,19}$/)
});
export interface RechargeRouteOptions {
service: Pick<RechargeService, 'listAvailablePlans' | 'createRechargeOrder'>;
service: Pick<
RechargeService,
'listAvailablePlans' | 'createRechargeOrder' | 'createWechatPrepay' | 'processWechatNotification'
>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -58,6 +66,36 @@ export async function registerRechargeRoutes(
traceId: request.traceId
}));
});
app.post('/app-api/recharge/orders/:rechargeOrderId/wechat-prepay', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = orderParams.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.service.createWechatPrepay({
tenantId: auth.tenantId,
platformAppId: auth.platformAppId,
userId: auth.userId,
rechargeOrderId: params.data.rechargeOrderId
}),
traceId: request.traceId
}));
});
app.post('/app-api/recharge/wechat/notify', {
preParsing: captureRawBody
}, async (request, reply) => {
return handle(reply, request.traceId, async () => {
const data = await options.service.processWechatNotification(
notificationHeaders(request.headers),
request.rawBody,
request.traceId
);
return reply.send({ code: 'SUCCESS', message: '成功', data });
});
});
}
async function authenticate(
@@ -77,6 +115,7 @@ async function authenticate(
if (!access.capabilities.includes('profile.read')) return null;
return {
tenantId: result.session.tenantId,
platformAppId: result.session.platformAppId,
userId: result.session.user.id
};
}
@@ -85,11 +124,12 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
try {
return await work();
} catch (error) {
if (!(error instanceof RechargeError)) throw error;
if (!(error instanceof RechargeError) && !(error instanceof WechatPayError)) throw error;
const status = error.code === 'RECHARGE_PLAN_NOT_FOUND'
|| error.code === 'RECHARGE_ORDER_NOT_FOUND'
? 404
: error.code === 'RECHARGE_LIMIT_REACHED' ? 409 : 400;
: error.code === 'RECHARGE_LIMIT_REACHED' ? 409
: error.code.includes('FORBIDDEN') ? 403 : 400;
return reply.status(status).send({
code: error.code,
message: 'The recharge request is not available.',
@@ -98,6 +138,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',