feat(M05-D): 完成收款配置与幂等分账
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user