feat(M05-A): 建立统一支付领域与幂等回调

This commit is contained in:
Codex
2026-06-20 14:30:35 +08:00
parent f9ec0af2ee
commit 6c506ff862
19 changed files with 905 additions and 14 deletions
+5
View File
@@ -37,6 +37,7 @@ import {
registerOrderManagementRoutes, type OrderManagementRouteOptions
} from './routes/order-management.js';
import { registerOrderShareRoutes, type OrderShareRouteOptions } from './routes/order-share.js';
import { registerPaymentRoutes, type PaymentRouteOptions } from './routes/payments.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -51,6 +52,7 @@ export interface BuildAppOptions {
orderState?: OrderStateRouteOptions;
orderManagement?: OrderManagementRouteOptions;
orderShare?: OrderShareRouteOptions;
payment?: PaymentRouteOptions;
}
declare module 'fastify' {
@@ -126,6 +128,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.orderShare) {
await registerOrderShareRoutes(app, options.orderShare);
}
if (options.payment) {
await registerPaymentRoutes(app, options.payment);
}
return app;
}
+5
View File
@@ -16,6 +16,7 @@ const configSchema = z.object({
QIPAI_ACCESS_TOKEN_TTL_SECONDS: z.coerce.number().int().min(60).max(86400).default(900),
QIPAI_SESSION_TTL_SECONDS: z.coerce.number().int().min(300).max(2592000).default(604800),
QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'),
QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'),
QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
QIPAI_MQTT_USERNAME: z.string().default(''),
QIPAI_MQTT_PASSWORD: z.string().default('')
@@ -53,6 +54,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
sessionTtlSeconds: parsed.QIPAI_SESSION_TTL_SECONDS,
wechatAppSecretsJson: parsed.QIPAI_WECHAT_APP_SECRETS
},
payment: {
testAdapterEnabled: parsed.NODE_ENV !== 'production'
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true'
},
mqtt: {
url: parsed.QIPAI_MQTT_URL,
usernameConfigured: parsed.QIPAI_MQTT_USERNAME.length > 0,
+7 -3
View File
@@ -34,7 +34,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026061811_m04a_pricing_reservations.up.sql',
'database/migrations/2026062012_m04b_order_state_machine.up.sql',
'database/migrations/2026062013_m04c_order_adjustments.up.sql',
'database/migrations/2026062014_m04d_order_shares.up.sql'
'database/migrations/2026062014_m04d_order_shares.up.sql',
'database/migrations/2026062015_m05a_payment_domain.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -50,9 +51,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026061811_m04a_pricing_reservations.verify.sql',
'database/migrations/2026062012_m04b_order_state_machine.verify.sql',
'database/migrations/2026062013_m04c_order_adjustments.verify.sql',
'database/migrations/2026062014_m04d_order_shares.verify.sql'
'database/migrations/2026062014_m04d_order_shares.verify.sql',
'database/migrations/2026062015_m05a_payment_domain.verify.sql'
],
down: [
'database/migrations/2026062015_m05a_payment_domain.down.sql',
'database/migrations/2026062014_m04d_order_shares.down.sql',
'database/migrations/2026062013_m04c_order_adjustments.down.sql',
'database/migrations/2026062012_m04b_order_state_machine.down.sql',
@@ -196,7 +199,8 @@ export async function executeMigrationPlan(
2, 1, 3, 3, 1,
2, 1, 3, 1,
2, 2, 1, 2, 1,
1, 8, 3, 1
1, 8, 3, 1,
5, 8, 4, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
+305
View File
@@ -0,0 +1,305 @@
import { randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
export type PaymentProvider = 'WECHAT' | 'BALANCE' | 'PACKAGE' | 'GROUP_BUY' | 'TEST';
interface OrderRow extends RowDataPacket {
id: string;
storeId: string;
status: string;
totalAmountCents: number;
paidAmountCents: number;
}
interface PaymentRow extends RowDataPacket {
id: string;
orderId: string;
paymentNo: string;
provider: PaymentProvider;
status: string;
amountCents: number;
}
interface ConfigRow extends RowDataPacket {
id: string;
credentialRef: string;
settings: string | object;
scopeKey: string;
}
export class PaymentError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class PaymentRepository {
constructor(private readonly pool: MySqlPool) {}
async createPayment(input: {
tenantId: string;
platformAppId: string;
userId: string;
orderId: string;
provider: PaymentProvider;
clientRequestId: string;
testAdapterEnabled: boolean;
}) {
return this.transaction(async (connection) => {
const order = await this.loadOwnedOrder(
connection, input.tenantId, input.userId, input.orderId, true
);
if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
throw new PaymentError('PAYMENT_ORDER_STATUS_INVALID');
}
const amountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents);
if (amountCents <= 0) throw new PaymentError('PAYMENT_NOT_REQUIRED');
if (input.provider === 'TEST' && !input.testAdapterEnabled) {
throw new PaymentError('TEST_PAYMENT_DISABLED');
}
if (input.provider !== 'TEST') {
await this.resolveConfig(
connection, input.tenantId, input.platformAppId, order.storeId, input.provider
);
}
const [existing] = await connection.execute<PaymentRow[]>(
`SELECT id, order_id AS orderId, payment_no AS paymentNo, provider,
status, amount_cents AS amountCents
FROM qipai_payments
WHERE tenant_id = ? AND client_request_id = ? LIMIT 1`,
[input.tenantId, input.clientRequestId]
);
if (existing[0]) {
if (String(existing[0].orderId) !== input.orderId
|| existing[0].provider !== input.provider
|| Number(existing[0].amountCents) !== amountCents) {
throw new PaymentError('PAYMENT_IDEMPOTENCY_CONFLICT');
}
return this.paymentResponse(existing[0], true, input.testAdapterEnabled);
}
const paymentNo = `PAY${Date.now()}${randomBytes(5).toString('hex').toUpperCase()}`;
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_payments
(tenant_id, platform_app_id, order_id, store_id, payment_no,
channel, provider, client_request_id, status, amount_cents)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?)`,
[input.tenantId, input.platformAppId, input.orderId, order.storeId,
paymentNo, input.provider, input.provider, input.clientRequestId, amountCents]
);
const paymentId = String(result.insertId);
await connection.execute(
`INSERT INTO qipai_payment_attempts
(tenant_id, payment_id, attempt_no, status, request_payload, response_payload,
completed_at)
VALUES (?, ?, 1, 'CREATED',
JSON_OBJECT('provider', ?, 'amountCents', ?),
JSON_OBJECT('adapter', ?, 'credentialExposed', FALSE), UTC_TIMESTAMP(3))`,
[input.tenantId, paymentId, input.provider, amountCents,
input.provider === 'TEST' ? 'test' : 'configured']
);
return this.paymentResponse({
id: paymentId, orderId: input.orderId, paymentNo, provider: input.provider,
status: 'PENDING', amountCents
} as PaymentRow, false, input.testAdapterEnabled);
});
}
async processTestCallback(input: {
tenantId: string;
userId: string;
paymentId: string;
callbackId: string;
amountCents: number;
testAdapterEnabled: boolean;
traceId: string;
}) {
if (!input.testAdapterEnabled) throw new PaymentError('TEST_PAYMENT_DISABLED');
return this.transaction(async (connection) => {
const payment = await this.loadPayment(connection, input.tenantId, input.paymentId, true);
await this.assertOrderOwner(connection, input.tenantId, payment.orderId, input.userId);
if (payment.provider !== 'TEST') throw new PaymentError('PAYMENT_PROVIDER_INVALID');
const [callbackResult] = await connection.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_payment_callbacks
(tenant_id, payment_id, provider, callback_id, callback_type,
verified, payload)
VALUES (?, ?, 'TEST', ?, 'PAYMENT_SUCCEEDED', 1,
JSON_OBJECT('amountCents', ?))`,
[input.tenantId, input.paymentId, input.callbackId, input.amountCents]
);
if (callbackResult.affectedRows === 0) {
return { paymentId: input.paymentId, status: payment.status, idempotent: true };
}
if (Number(payment.amountCents) !== input.amountCents) {
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'REJECTED', error_code = 'PAYMENT_AMOUNT_MISMATCH',
processed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND provider = 'TEST' AND callback_id = ?`,
[input.tenantId, input.callbackId]
);
return {
paymentId: input.paymentId,
status: 'REJECTED',
code: 'PAYMENT_AMOUNT_MISMATCH',
idempotent: false
};
}
if (payment.status === 'SUCCEEDED') {
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'DUPLICATE', processed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND provider = 'TEST' AND callback_id = ?`,
[input.tenantId, input.callbackId]
);
return { paymentId: input.paymentId, status: 'SUCCEEDED', idempotent: true };
}
await connection.execute(
`UPDATE qipai_payments
SET status = 'SUCCEEDED', provider_payment_id = ?,
paid_at = UTC_TIMESTAMP(3), raw_notify = JSON_OBJECT('verified', TRUE)
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'`,
[`TEST-${input.callbackId}`, input.tenantId, input.paymentId]
);
await connection.execute(
`UPDATE qipai_orders
SET paid_amount_cents = paid_amount_cents + ?
WHERE tenant_id = ? AND id = ?`,
[payment.amountCents, input.tenantId, payment.orderId]
);
const order = await this.loadOrder(connection, input.tenantId, payment.orderId, true);
if (Number(order.paidAmountCents) >= Number(order.totalAmountCents)
&& order.status === 'PENDING_PAYMENT') {
const nextVersion = Number((order as OrderRow & { statusVersion?: number }).statusVersion ?? 1) + 1;
await connection.execute(
`UPDATE qipai_orders SET status = 'PAID', status_version = ?,
status_updated_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[nextVersion, input.tenantId, payment.orderId]
);
await connection.execute(
`UPDATE qipai_room_reservations
SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at)
WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`,
[input.tenantId, payment.orderId]
);
await connection.execute(
`INSERT INTO qipai_order_status_history
(tenant_id, order_id, from_status, to_status, action, actor_type,
actor_id, source, reason, trace_id, metadata)
VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM',
NULL, 'PAYMENT', 'Verified payment callback', ?,
JSON_OBJECT('statusVersion', ?, 'paymentId', ?))`,
[input.tenantId, payment.orderId, input.traceId, nextVersion, input.paymentId]
);
}
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND provider = 'TEST' AND callback_id = ?`,
[input.tenantId, input.callbackId]
);
return { paymentId: input.paymentId, status: 'SUCCEEDED', idempotent: false };
});
}
async resolveConfig(
connection: Pick<MySqlPool, 'execute'>,
tenantId: string,
platformAppId: string,
storeId: string,
provider: PaymentProvider
) {
const [rows] = await connection.execute<ConfigRow[]>(
`SELECT id, credential_ref AS credentialRef, settings, scope_key AS scopeKey
FROM qipai_payment_configs
WHERE provider = ? AND enabled = 1
AND (tenant_id IS NULL OR tenant_id = ?)
AND (platform_app_id IS NULL OR platform_app_id = ?)
AND (store_id IS NULL OR store_id = ?)
ORDER BY (store_id IS NOT NULL) DESC,
(tenant_id IS NOT NULL) DESC,
(platform_app_id IS NOT NULL) DESC, id DESC LIMIT 1`,
[provider, tenantId, platformAppId, storeId]
);
if (!rows[0]) throw new PaymentError('PAYMENT_CONFIG_NOT_FOUND');
return {
id: String(rows[0].id),
credentialRef: rows[0].credentialRef,
scopeKey: rows[0].scopeKey,
settings: typeof rows[0].settings === 'string'
? JSON.parse(rows[0].settings) : rows[0].settings
};
}
private paymentResponse(row: PaymentRow, idempotent: boolean, testEnabled: boolean) {
return {
paymentId: String(row.id),
orderId: String(row.orderId),
paymentNo: row.paymentNo,
provider: row.provider,
status: row.status,
amountCents: Number(row.amountCents),
idempotent,
testCompletionAvailable: row.provider === 'TEST' && testEnabled
};
}
private async loadOwnedOrder(
connection: PoolConnection, tenantId: string, userId: string,
orderId: string, lock: boolean
) {
await this.assertOrderOwner(connection, tenantId, orderId, userId);
return this.loadOrder(connection, tenantId, orderId, lock);
}
private async loadOrder(
connection: PoolConnection, tenantId: string, orderId: string, lock: boolean
) {
const [rows] = await connection.execute<Array<OrderRow & { statusVersion: number }>>(
`SELECT id, store_id AS storeId, status, status_version AS statusVersion,
total_amount_cents AS totalAmountCents, paid_amount_cents AS paidAmountCents
FROM qipai_orders WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
${lock ? 'FOR UPDATE' : ''}`,
[tenantId, orderId]
);
if (!rows[0]) throw new PaymentError('ORDER_NOT_FOUND');
return rows[0];
}
private async loadPayment(
connection: PoolConnection, tenantId: string, paymentId: string, lock: boolean
) {
const [rows] = await connection.execute<PaymentRow[]>(
`SELECT id, order_id AS orderId, payment_no AS paymentNo, provider,
status, amount_cents AS amountCents
FROM qipai_payments WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
${lock ? 'FOR UPDATE' : ''}`,
[tenantId, paymentId]
);
if (!rows[0]) throw new PaymentError('PAYMENT_NOT_FOUND');
return rows[0];
}
private async assertOrderOwner(
connection: PoolConnection, tenantId: string, orderId: string, userId: string
) {
const [rows] = await connection.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? AND user_id = ?`,
[tenantId, orderId, userId]
);
if (!rows[0]) throw new PaymentError('ORDER_ACCESS_FORBIDDEN');
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import {
PaymentError, type PaymentRepository
} from '../payments/payment-repository.js';
const createSchema = z.object({
orderId: z.string().regex(/^[1-9]\d{0,19}$/),
provider: z.enum(['WECHAT', 'BALANCE', 'PACKAGE', 'GROUP_BUY', 'TEST']),
clientRequestId: z.string().min(8).max(128)
}).strict();
const paymentParams = z.object({ paymentId: z.string().regex(/^[1-9]\d{0,19}$/) });
const callbackSchema = z.object({
callbackId: z.string().min(8).max(128),
amountCents: z.number().int().positive()
}).strict();
export interface PaymentRouteOptions {
repository: Pick<PaymentRepository, 'createPayment' | 'processTestCallback'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
jwtSecret: string;
testAdapterEnabled: boolean;
}
export async function registerPaymentRoutes(
app: FastifyInstance, options: PaymentRouteOptions
) {
app.post('/app-api/payments', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = createSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createPayment({
tenantId: auth.tenantId,
platformAppId: auth.platformAppId,
userId: auth.userId,
orderId: body.data.orderId,
provider: body.data.provider,
clientRequestId: body.data.clientRequestId,
testAdapterEnabled: options.testAdapterEnabled
}),
traceId: request.traceId
}));
});
app.post('/app-api/payments/:paymentId/test-complete', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paymentParams.safeParse(request.params);
const body = callbackSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => {
const data = await options.repository.processTestCallback({
tenantId: auth.tenantId,
userId: auth.userId,
paymentId: params.data.paymentId,
callbackId: body.data.callbackId,
amountCents: body.data.amountCents,
testAdapterEnabled: options.testAdapterEnabled,
traceId: request.traceId
});
if ('code' in data && data.code === 'PAYMENT_AMOUNT_MISMATCH') {
return reply.status(400).send({
code: data.code,
message: 'The callback amount does not match the payment.',
data,
traceId: request.traceId
});
}
return { code: 0, data, traceId: request.traceId };
});
});
}
async function authenticate(
authorization: string | undefined, options: PaymentRouteOptions
) {
const result = await authenticateAccessToken(
authorization, options.authRepository, options.jwtSecret
);
if (!result) return null;
return {
tenantId: result.session.tenantId,
platformAppId: result.session.platformAppId,
userId: result.session.user.id
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof PaymentError)) 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;
return reply.status(status).send({
code: error.code, message: 'The payment request is not available.', traceId
});
}
}
function unauthorized(reply: FastifyReply, traceId: string) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId
});
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_PAYMENT_REQUEST', message: 'The payment request is invalid.', traceId
});
}
+7
View File
@@ -16,6 +16,7 @@ import { PricingRepository } from './orders/pricing-repository.js';
import { OrderStateRepository } from './orders/order-state-repository.js';
import { OrderManagementRepository } from './orders/order-management-repository.js';
import { OrderShareRepository } from './orders/order-share-repository.js';
import { PaymentRepository } from './payments/payment-repository.js';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -86,6 +87,12 @@ const app = await buildApp({
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
payment: {
repository: new PaymentRepository(pool),
authRepository,
jwtSecret: config.auth.jwtSecret,
testAdapterEnabled: config.payment.testAdapterEnabled
}
});
app.addHook('onClose', async () => {