feat(M05-D): 完成收款配置与幂等分账

This commit is contained in:
Codex
2026-06-22 11:49:37 +08:00
parent 52edf2482e
commit 1680d734bf
16 changed files with 1203 additions and 18 deletions
+126 -2
View File
@@ -11,6 +11,9 @@ import {
} from '../payments/payment-repository.js';
import type { WechatPaymentService } from '../payments/wechat-payment-service.js';
import { WechatPayError } from '../payments/wechat-pay-client.js';
import {
ProfitSharingError, type ProfitSharingService
} from '../payments/profit-sharing-service.js';
const createSchema = z.object({
orderId: z.string().regex(/^[1-9]\d{0,19}$/),
@@ -33,12 +36,46 @@ const billSchema = z.object({
billDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
billType: z.enum(['ALL', 'SUCCESS', 'REFUND'])
}).strict();
const collectionAccountSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
merchantId: z.string().min(6).max(64),
credentialRef: z.string().min(5).max(255),
authorizationStatus: z.enum(['UNAUTHORIZED', 'PENDING', 'AUTHORIZED', 'REVOKED']),
profitSharingEnabled: z.boolean(),
enabled: z.boolean().default(true)
}).strict();
const receiverSchema = z.object({
collectionAccountId: z.string().regex(/^[1-9]\d{0,19}$/),
receiverType: z.enum(['MERCHANT_ID', 'PERSONAL_OPENID']),
receiverAccount: z.string().min(4).max(128),
receiverCredentialRef: z.string().min(10).max(255),
relationType: z.string().min(2).max(32),
name: z.string().min(1).max(128),
authorizationStatus: z.enum(['UNAUTHORIZED', 'PENDING', 'AUTHORIZED', 'REVOKED']),
enabled: z.boolean().default(true)
}).strict();
const policySchema = z.object({
collectionAccountId: z.string().regex(/^[1-9]\d{0,19}$/),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
receiverId: z.string().regex(/^[1-9]\d{0,19}$/),
percentageBps: z.number().int().min(1).max(10000),
enabled: z.boolean().default(true)
}).strict();
const executeShareSchema = z.object({
paymentId: z.string().regex(/^[1-9]\d{0,19}$/),
clientRequestId: z.string().min(8).max(96),
mode: z.enum(['API', 'MOCK'])
}).strict();
const shareListQuery = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
export interface PaymentRouteOptions {
repository: Pick<PaymentRepository, 'createPayment' | 'processTestCallback'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl?: Pick<RbacRepository, 'getAccessProfile'>;
wechat?: WechatPaymentService;
profitSharing?: ProfitSharingService;
jwtSecret: string;
testAdapterEnabled: boolean;
}
@@ -184,6 +221,91 @@ export async function registerPaymentRoutes(
}));
});
}
if (options.profitSharing) {
app.put('/admin-api/pay/collection-account', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const body = collectionAccountSchema.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.profitSharing!.saveCollectionAccount({
tenantId: auth.tenantId,
platformAppId: auth.platformAppId,
actorId: auth.userId,
access: auth.access,
...body.data
}),
traceId: request.traceId
}));
});
app.put('/admin-api/pay/profit-share-receiver', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const body = receiverSchema.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.profitSharing!.saveReceiver({
tenantId: auth.tenantId,
access: auth.access,
...body.data
}),
traceId: request.traceId
}));
});
app.put('/admin-api/pay/profit-share-policy', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const body = policySchema.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.profitSharing!.savePolicy({
tenantId: auth.tenantId,
access: auth.access,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/pay/profit-shares', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const body = executeShareSchema.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.profitSharing!.execute({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access,
...body.data
}),
traceId: request.traceId
}));
});
app.get('/admin-api/pay/profit-shares', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const query = shareListQuery.safeParse(request.query);
if (!auth) return unauthorized(reply, request.traceId);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.profitSharing!.list({
tenantId: auth.tenantId,
access: auth.access,
storeId: query.data.storeId
}),
traceId: request.traceId
}));
});
}
}
async function authenticate(
@@ -208,14 +330,16 @@ async function authenticateAdmin(
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;
return { ...auth, access };
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof PaymentError) && !(error instanceof WechatPayError)) throw error;
if (!(error instanceof PaymentError)
&& !(error instanceof WechatPayError)
&& !(error instanceof ProfitSharingError)) 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;