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
+1
View File
@@ -12,6 +12,7 @@ QIPAI_JWT_SECRET=<not-set>
QIPAI_ACCESS_TOKEN_TTL_SECONDS=900 QIPAI_ACCESS_TOKEN_TTL_SECONDS=900
QIPAI_SESSION_TTL_SECONDS=604800 QIPAI_SESSION_TTL_SECONDS=604800
QIPAI_WECHAT_APP_SECRETS={} QIPAI_WECHAT_APP_SECRETS={}
QIPAI_TEST_PAYMENT_ENABLED=false
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883 QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
QIPAI_MQTT_USERNAME= QIPAI_MQTT_USERNAME=
QIPAI_MQTT_PASSWORD= QIPAI_MQTT_PASSWORD=
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs" "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+5
View File
@@ -37,6 +37,7 @@ import {
registerOrderManagementRoutes, type OrderManagementRouteOptions registerOrderManagementRoutes, type OrderManagementRouteOptions
} from './routes/order-management.js'; } from './routes/order-management.js';
import { registerOrderShareRoutes, type OrderShareRouteOptions } from './routes/order-share.js'; import { registerOrderShareRoutes, type OrderShareRouteOptions } from './routes/order-share.js';
import { registerPaymentRoutes, type PaymentRouteOptions } from './routes/payments.js';
export interface BuildAppOptions { export interface BuildAppOptions {
config?: AppConfig; config?: AppConfig;
@@ -51,6 +52,7 @@ export interface BuildAppOptions {
orderState?: OrderStateRouteOptions; orderState?: OrderStateRouteOptions;
orderManagement?: OrderManagementRouteOptions; orderManagement?: OrderManagementRouteOptions;
orderShare?: OrderShareRouteOptions; orderShare?: OrderShareRouteOptions;
payment?: PaymentRouteOptions;
} }
declare module 'fastify' { declare module 'fastify' {
@@ -126,6 +128,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.orderShare) { if (options.orderShare) {
await registerOrderShareRoutes(app, options.orderShare); await registerOrderShareRoutes(app, options.orderShare);
} }
if (options.payment) {
await registerPaymentRoutes(app, options.payment);
}
return app; 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_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_SESSION_TTL_SECONDS: z.coerce.number().int().min(300).max(2592000).default(604800),
QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'), 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_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
QIPAI_MQTT_USERNAME: z.string().default(''), QIPAI_MQTT_USERNAME: z.string().default(''),
QIPAI_MQTT_PASSWORD: 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, sessionTtlSeconds: parsed.QIPAI_SESSION_TTL_SECONDS,
wechatAppSecretsJson: parsed.QIPAI_WECHAT_APP_SECRETS wechatAppSecretsJson: parsed.QIPAI_WECHAT_APP_SECRETS
}, },
payment: {
testAdapterEnabled: parsed.NODE_ENV !== 'production'
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true'
},
mqtt: { mqtt: {
url: parsed.QIPAI_MQTT_URL, url: parsed.QIPAI_MQTT_URL,
usernameConfigured: parsed.QIPAI_MQTT_USERNAME.length > 0, 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/2026061811_m04a_pricing_reservations.up.sql',
'database/migrations/2026062012_m04b_order_state_machine.up.sql', 'database/migrations/2026062012_m04b_order_state_machine.up.sql',
'database/migrations/2026062013_m04c_order_adjustments.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: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', '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/2026061811_m04a_pricing_reservations.verify.sql',
'database/migrations/2026062012_m04b_order_state_machine.verify.sql', 'database/migrations/2026062012_m04b_order_state_machine.verify.sql',
'database/migrations/2026062013_m04c_order_adjustments.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: [ down: [
'database/migrations/2026062015_m05a_payment_domain.down.sql',
'database/migrations/2026062014_m04d_order_shares.down.sql', 'database/migrations/2026062014_m04d_order_shares.down.sql',
'database/migrations/2026062013_m04c_order_adjustments.down.sql', 'database/migrations/2026062013_m04c_order_adjustments.down.sql',
'database/migrations/2026062012_m04b_order_state_machine.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, 3, 1,
2, 1, 3, 1, 2, 1, 3, 1,
2, 2, 1, 2, 1, 2, 2, 1, 2, 1,
1, 8, 3, 1 1, 8, 3, 1,
5, 8, 4, 1
][index] ?? 1; ][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( 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 { OrderStateRepository } from './orders/order-state-repository.js';
import { OrderManagementRepository } from './orders/order-management-repository.js'; import { OrderManagementRepository } from './orders/order-management-repository.js';
import { OrderShareRepository } from './orders/order-share-repository.js'; import { OrderShareRepository } from './orders/order-share-repository.js';
import { PaymentRepository } from './payments/payment-repository.js';
const config = loadConfig(); const config = loadConfig();
const pool = createMySqlPool(config); const pool = createMySqlPool(config);
@@ -86,6 +87,12 @@ const app = await buildApp({
authRepository, authRepository,
accessControl, accessControl,
jwtSecret: config.auth.jwtSecret jwtSecret: config.auth.jwtSecret
},
payment: {
repository: new PaymentRepository(pool),
authRepository,
jwtSecret: config.auth.jwtSecret,
testAdapterEnabled: config.payment.testAdapterEnabled
} }
}); });
app.addHook('onClose', async () => { app.addHook('onClose', async () => {
+17 -1
View File
@@ -51,6 +51,9 @@ const adjustmentVerifySql = read('database/migrations/2026062013_m04c_order_adju
const shareUpSql = read('database/migrations/2026062014_m04d_order_shares.up.sql'); const shareUpSql = read('database/migrations/2026062014_m04d_order_shares.up.sql');
const shareDownSql = read('database/migrations/2026062014_m04d_order_shares.down.sql'); const shareDownSql = read('database/migrations/2026062014_m04d_order_shares.down.sql');
const shareVerifySql = read('database/migrations/2026062014_m04d_order_shares.verify.sql'); const shareVerifySql = read('database/migrations/2026062014_m04d_order_shares.verify.sql');
const paymentUpSql = read('database/migrations/2026062015_m05a_payment_domain.up.sql');
const paymentDownSql = read('database/migrations/2026062015_m05a_payment_domain.down.sql');
const paymentVerifySql = read('database/migrations/2026062015_m05a_payment_domain.verify.sql');
const coreTables = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -226,5 +229,18 @@ assert.match(shareUpSql, /token_hash CHAR\(64\)/);
assert.match(shareUpSql, /allow_open_door TINYINT/); assert.match(shareUpSql, /allow_open_door TINYINT/);
assert.match(shareUpSql, /allow_renew TINYINT/); assert.match(shareUpSql, /allow_renew TINYINT/);
assert.match(shareUpSql, /UNIQUE KEY uq_qipai_order_share_token_hash/); assert.match(shareUpSql, /UNIQUE KEY uq_qipai_order_share_token_hash/);
for (const table of [
'qipai_payment_attempts', 'qipai_payment_callbacks', 'qipai_refunds',
'qipai_profit_shares', 'qipai_payment_configs'
]) {
assert.match(paymentUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(paymentDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(paymentVerifySql, new RegExp(`'${table}'`));
}
assert.match(paymentUpSql, /client_request_id VARCHAR/);
assert.match(paymentUpSql, /UNIQUE KEY uq_qipai_payment_callback_provider/);
assert.match(paymentUpSql, /credential_ref VARCHAR/);
assert.match(paymentUpSql, /scope_key VARCHAR/);
assert.doesNotMatch(paymentUpSql, /credential_secret|private_key|api_secret/i);
console.log('PASS: M01-B through M04-D migration contracts are present.'); console.log('PASS: M01-B through M05-A migration contracts are present.');
+2 -1
View File
@@ -25,7 +25,8 @@ assert.match(plan.file, /2026061810_m03d_scene_wifi_access\.up\.sql/);
assert.match(plan.file, /2026061811_m04a_pricing_reservations\.up\.sql/); assert.match(plan.file, /2026061811_m04a_pricing_reservations\.up\.sql/);
assert.match(plan.file, /2026062012_m04b_order_state_machine\.up\.sql/); assert.match(plan.file, /2026062012_m04b_order_state_machine\.up\.sql/);
assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql/); assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql/);
assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql$/); assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql/);
assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); assert.ok(plan.statements.length >= 11);
@@ -27,6 +27,9 @@ import {
import { import {
OrderShareError, OrderShareRepository OrderShareError, OrderShareRepository
} from '../dist/orders/order-share-repository.js'; } from '../dist/orders/order-share-repository.js';
import {
PaymentError, PaymentRepository
} from '../dist/payments/payment-repository.js';
import { import {
executeMigrationPlan, executeMigrationPlan,
loadMigrationPlan, loadMigrationPlan,
@@ -50,9 +53,14 @@ const expectedTables = [
'qipai_order_user_access', 'qipai_order_user_access',
'qipai_orders', 'qipai_orders',
'qipai_outbox_events', 'qipai_outbox_events',
'qipai_payment_attempts',
'qipai_payment_callbacks',
'qipai_payment_configs',
'qipai_payments', 'qipai_payments',
'qipai_permissions', 'qipai_permissions',
'qipai_platform_apps', 'qipai_platform_apps',
'qipai_profit_shares',
'qipai_refunds',
'qipai_role_permissions', 'qipai_role_permissions',
'qipai_roles', 'qipai_roles',
'qipai_room_categories', 'qipai_room_categories',
@@ -93,11 +101,12 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT version, name `SELECT version, name
FROM qipai_schema_migrations FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`, ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804', ['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809', '2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014'] '2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015']
); );
return rows; return rows;
} }
@@ -1021,6 +1030,127 @@ async function assertOrderShares(pool, context) {
); );
} }
async function assertPaymentDomain(pool, context) {
const [customerRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_identities i
ON i.tenant_id = u.tenant_id AND i.user_id = u.id
WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`,
[context.tenantId]
);
const [roomRows] = await pool.query(
`SELECT store_id AS storeId, id AS roomId FROM qipai_rooms
WHERE tenant_id = ? AND name = 'M04C Target Room' LIMIT 1`,
[context.tenantId]
);
const customerId = String(customerRows[0].id);
const storeId = String(roomRows[0].storeId);
const roomId = String(roomRows[0].roomId);
const startAt = new Date(Date.now() + 25 * 86400000);
startAt.setUTCHours(2, 0, 0, 0);
const endAt = new Date(startAt.getTime() + 2 * 3600000);
const order = await new PricingRepository(pool).reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt, endAt, pricingMode: 'HOURLY'
});
await pool.query(
`INSERT INTO qipai_payment_configs
(tenant_id, platform_app_id, store_id, provider, scope_key, credential_ref, settings)
VALUES
(NULL, ?, NULL, 'WECHAT', ?, 'env:WX_PLATFORM', JSON_OBJECT('level', 'app')),
(?, ?, NULL, 'WECHAT', ?, 'env:WX_TENANT', JSON_OBJECT('level', 'tenant')),
(?, ?, ?, 'WECHAT', ?, 'env:WX_STORE', JSON_OBJECT('level', 'store'))`,
[context.platformAppId, `app:${context.platformAppId}`,
context.tenantId, context.platformAppId,
`tenant:${context.tenantId}:app:${context.platformAppId}`,
context.tenantId, context.platformAppId, storeId,
`tenant:${context.tenantId}:app:${context.platformAppId}:store:${storeId}`]
);
const repository = new PaymentRepository(pool);
const resolved = await repository.resolveConfig(
pool, context.tenantId, context.platformAppId, storeId, 'WECHAT'
);
assert.equal(resolved.credentialRef, 'env:WX_STORE');
assert.equal(resolved.settings.level, 'store');
const created = await repository.createPayment({
tenantId: context.tenantId, platformAppId: context.platformAppId,
userId: customerId, orderId: order.orderId, provider: 'TEST',
clientRequestId: 'm05a-payment-request-1', testAdapterEnabled: true
});
assert.equal(created.amountCents, order.quote.totalCents);
const duplicateCreate = await repository.createPayment({
tenantId: context.tenantId, platformAppId: context.platformAppId,
userId: customerId, orderId: order.orderId, provider: 'TEST',
clientRequestId: 'm05a-payment-request-1', testAdapterEnabled: true
});
assert.equal(duplicateCreate.paymentId, created.paymentId);
assert.equal(duplicateCreate.idempotent, true);
await assert.rejects(
() => repository.createPayment({
tenantId: context.tenantId, platformAppId: context.platformAppId,
userId: customerId, orderId: order.orderId, provider: 'TEST',
clientRequestId: 'm05a-payment-disabled', testAdapterEnabled: false
}),
(error) => error instanceof PaymentError && error.code === 'TEST_PAYMENT_DISABLED'
);
const rejected = await repository.processTestCallback({
tenantId: context.tenantId, userId: customerId, paymentId: created.paymentId,
callbackId: 'm05a-callback-wrong-amount', amountCents: created.amountCents - 1,
testAdapterEnabled: true, traceId: 'm05a-wrong-amount'
});
assert.equal(rejected.status, 'REJECTED');
const [afterRejected] = await pool.query(
`SELECT o.status, o.paid_amount_cents AS paidAmountCents,
c.processing_status AS callbackStatus
FROM qipai_orders o
INNER JOIN qipai_payment_callbacks c ON c.payment_id = ?
WHERE o.id = ? AND c.callback_id = 'm05a-callback-wrong-amount'`,
[created.paymentId, order.orderId]
);
assert.equal(afterRejected[0].status, 'PENDING_PAYMENT');
assert.equal(afterRejected[0].paidAmountCents, 0);
assert.equal(afterRejected[0].callbackStatus, 'REJECTED');
const succeeded = await repository.processTestCallback({
tenantId: context.tenantId, userId: customerId, paymentId: created.paymentId,
callbackId: 'm05a-callback-success', amountCents: created.amountCents,
testAdapterEnabled: true, traceId: 'm05a-payment-success'
});
assert.equal(succeeded.status, 'SUCCEEDED');
const duplicateCallback = await repository.processTestCallback({
tenantId: context.tenantId, userId: customerId, paymentId: created.paymentId,
callbackId: 'm05a-callback-success', amountCents: created.amountCents,
testAdapterEnabled: true, traceId: 'm05a-payment-success-duplicate'
});
assert.equal(duplicateCallback.idempotent, true);
const [paidRows] = await pool.query(
`SELECT o.status, o.paid_amount_cents AS paidAmountCents,
p.status AS paymentStatus,
(SELECT COUNT(*) FROM qipai_order_status_history h
WHERE h.order_id = o.id AND h.to_status = 'PAID') AS paidHistoryCount,
(SELECT COUNT(*) FROM qipai_payment_attempts a
WHERE a.payment_id = p.id) AS attemptCount
FROM qipai_orders o
INNER JOIN qipai_payments p ON p.order_id = o.id
WHERE o.id = ? AND p.id = ?`,
[order.orderId, created.paymentId]
);
assert.equal(paidRows[0].status, 'PAID');
assert.equal(paidRows[0].paidAmountCents, created.amountCents);
assert.equal(paidRows[0].paymentStatus, 'SUCCEEDED');
assert.equal(Number(paidRows[0].paidHistoryCount), 1);
assert.equal(Number(paidRows[0].attemptCount), 1);
const [configRows] = await pool.query(
`SELECT credential_ref AS credentialRef, CAST(settings AS CHAR) AS settings
FROM qipai_payment_configs WHERE tenant_id = ?`,
[context.tenantId]
);
assert.equal(configRows.every((row) => row.credentialRef.startsWith('env:')), true);
assert.equal(configRows.some((row) => /secret|private.key/i.test(row.settings)), false);
}
async function assertContentManagement(pool, context) { async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query( const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u `SELECT u.id FROM qipai_users u
@@ -1122,7 +1252,8 @@ try {
{ version: '2026061811', name: 'm04a_pricing_reservations' }, { version: '2026061811', name: 'm04a_pricing_reservations' },
{ version: '2026062012', name: 'm04b_order_state_machine' }, { version: '2026062012', name: 'm04b_order_state_machine' },
{ version: '2026062013', name: 'm04c_order_adjustments' }, { version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' } { version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' }
]); ]);
await assertTaskDurability(pool); await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool); const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1136,13 +1267,14 @@ try {
await assertOrderStateMachine(pool, loginContext); await assertOrderStateMachine(pool, loginContext);
await assertOrderAdjustments(pool, loginContext); await assertOrderAdjustments(pool, loginContext);
await assertOrderShares(pool, loginContext); await assertOrderShares(pool, loginContext);
await assertPaymentDomain(pool, loginContext);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.'); console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down); await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []); assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M04-D tables.'); console.log('PASS: down removed all M01-B through M05-A tables.');
await executeMigrationPlan(pool, plans.up); await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify); await executeMigrationPlan(pool, plans.verify);
@@ -1161,7 +1293,8 @@ try {
{ version: '2026061811', name: 'm04a_pricing_reservations' }, { version: '2026061811', name: 'm04a_pricing_reservations' },
{ version: '2026062012', name: 'm04b_order_state_machine' }, { version: '2026062012', name: 'm04b_order_state_machine' },
{ version: '2026062013', name: 'm04c_order_adjustments' }, { version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' } { version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' }
]); ]);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.'); console.log('PASS: second up and verify restored the schema.');
@@ -1235,7 +1368,13 @@ try {
'renew permission denied by default', 'renew permission denied by default',
'explicit renew permission without room disclosure', 'explicit renew permission without room disclosure',
'share revocation and expiry', 'share revocation and expiry',
'terminal order invalidates share' 'terminal order invalidates share',
'payment config store precedence',
'server-derived payment amount',
'idempotent payment creation',
'mismatched callback retained without accounting',
'duplicate success callback does not double account',
'test adapter explicit non-production gate'
] ]
}, null, 2)); }, null, 2));
} finally { } finally {
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { loadConfig } from '../dist/config.js';
import { signAccessToken } from '../dist/auth/jwt.js';
assert.equal(loadConfig({
NODE_ENV: 'production',
QIPAI_JWT_SECRET: 'production-test-secret-that-is-long-enough',
QIPAI_TEST_PAYMENT_ENABLED: 'true'
}).payment.testAdapterEnabled, false);
assert.equal(loadConfig({
NODE_ENV: 'test',
QIPAI_TEST_PAYMENT_ENABLED: 'true'
}).payment.testAdapterEnabled, true);
const secret = 'test-only-payment-jwt-secret-32-chars';
const token = signAccessToken({
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
let createInput;
let callbackInput;
const authRepository = {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000),
user: {
id: '21', tenantId: '7', userType: 'CUSTOMER', status: 'ACTIVE',
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
}
};
}
};
const app = await buildApp({
payment: {
jwtSecret: secret,
authRepository,
testAdapterEnabled: true,
repository: {
async createPayment(input) {
createInput = input;
return {
paymentId: '51', orderId: input.orderId, provider: input.provider,
status: 'PENDING', amountCents: 3600
};
},
async processTestCallback(input) {
callbackInput = input;
return { paymentId: input.paymentId, status: 'SUCCEEDED', idempotent: false };
}
}
}
});
const rejectedAmount = await app.inject({
method: 'POST',
url: '/app-api/payments',
headers: { authorization: `Bearer ${token}` },
payload: {
orderId: '31', provider: 'TEST', clientRequestId: 'request-0001',
amountCents: 1
}
});
assert.equal(rejectedAmount.statusCode, 400);
const created = await app.inject({
method: 'POST',
url: '/app-api/payments',
headers: { authorization: `Bearer ${token}` },
payload: { orderId: '31', provider: 'TEST', clientRequestId: 'request-0001' }
});
assert.equal(created.statusCode, 201);
assert.equal(createInput.platformAppId, '9');
assert.equal('amountCents' in createInput, false);
const completed = await app.inject({
method: 'POST',
url: '/app-api/payments/51/test-complete',
headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm05a-test-callback' },
payload: { callbackId: 'callback-0001', amountCents: 3600 }
});
assert.equal(completed.statusCode, 200);
assert.equal(callbackInput.traceId, 'm05a-test-callback');
await app.close();
console.log('PASS: M05-A payment routes trust server amounts and gate the test adapter.');
@@ -0,0 +1,19 @@
DELETE FROM qipai_schema_migrations WHERE version = '2026062015';
DROP TABLE IF EXISTS qipai_payment_configs;
DROP TABLE IF EXISTS qipai_profit_shares;
DROP TABLE IF EXISTS qipai_refunds;
DROP TABLE IF EXISTS qipai_payment_callbacks;
DROP TABLE IF EXISTS qipai_payment_attempts;
ALTER TABLE qipai_payments
DROP INDEX uq_qipai_payments_provider_id,
DROP INDEX uq_qipai_payments_client_request,
DROP FOREIGN KEY fk_qipai_payments_store,
DROP FOREIGN KEY fk_qipai_payments_platform_app,
DROP COLUMN failure_code,
DROP COLUMN failed_at,
DROP COLUMN provider_payment_id,
DROP COLUMN currency,
DROP COLUMN client_request_id,
DROP COLUMN provider,
DROP COLUMN store_id,
DROP COLUMN platform_app_id;
@@ -0,0 +1,138 @@
ALTER TABLE qipai_payments
ADD COLUMN platform_app_id BIGINT UNSIGNED NULL AFTER tenant_id,
ADD COLUMN store_id BIGINT UNSIGNED NULL AFTER order_id,
ADD COLUMN provider VARCHAR(32) NOT NULL DEFAULT 'WECHAT' AFTER channel,
ADD COLUMN client_request_id VARCHAR(128) NULL AFTER provider,
ADD COLUMN currency CHAR(3) NOT NULL DEFAULT 'CNY' AFTER amount_cents,
ADD COLUMN provider_payment_id VARCHAR(128) NULL AFTER currency,
ADD COLUMN failed_at DATETIME(3) NULL AFTER paid_at,
ADD COLUMN failure_code VARCHAR(64) NOT NULL DEFAULT '' AFTER failed_at;
UPDATE qipai_payments p
INNER JOIN qipai_orders o
ON o.tenant_id = p.tenant_id AND o.id = p.order_id
SET p.store_id = o.store_id,
p.client_request_id = CONCAT('legacy-payment-', p.id)
WHERE p.store_id IS NULL OR p.client_request_id IS NULL;
ALTER TABLE qipai_payments
MODIFY COLUMN store_id BIGINT UNSIGNED NOT NULL,
MODIFY COLUMN client_request_id VARCHAR(128) NOT NULL,
ADD CONSTRAINT fk_qipai_payments_platform_app
FOREIGN KEY (platform_app_id) REFERENCES qipai_platform_apps(id),
ADD CONSTRAINT fk_qipai_payments_store
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
ADD UNIQUE KEY uq_qipai_payments_client_request
(tenant_id, client_request_id),
ADD UNIQUE KEY uq_qipai_payments_provider_id
(tenant_id, provider, provider_payment_id);
CREATE TABLE IF NOT EXISTS qipai_payment_attempts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
payment_id BIGINT UNSIGNED NOT NULL,
attempt_no INT UNSIGNED NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'CREATED',
request_payload JSON NOT NULL,
response_payload JSON NULL,
error_code VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
CONSTRAINT fk_qipai_payment_attempt_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_payment_attempt_payment
FOREIGN KEY (payment_id) REFERENCES qipai_payments(id),
UNIQUE KEY uq_qipai_payment_attempt_no (tenant_id, payment_id, attempt_no),
KEY idx_qipai_payment_attempt_status (tenant_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_payment_callbacks (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
payment_id BIGINT UNSIGNED NULL,
provider VARCHAR(32) NOT NULL,
callback_id VARCHAR(128) NOT NULL,
callback_type VARCHAR(32) NOT NULL,
verified TINYINT(1) NOT NULL DEFAULT 0,
processing_status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED',
payload JSON NOT NULL,
error_code VARCHAR(64) NOT NULL DEFAULT '',
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
processed_at DATETIME(3) NULL,
CONSTRAINT fk_qipai_payment_callback_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_payment_callback_payment
FOREIGN KEY (payment_id) REFERENCES qipai_payments(id),
UNIQUE KEY uq_qipai_payment_callback_provider
(tenant_id, provider, callback_id),
KEY idx_qipai_payment_callback_status
(tenant_id, processing_status, received_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_refunds (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
payment_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
refund_no VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
amount_cents INT UNSIGNED NOT NULL,
reason VARCHAR(512) NOT NULL DEFAULT '',
provider_refund_id VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
CONSTRAINT fk_qipai_refund_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_refund_payment FOREIGN KEY (payment_id) REFERENCES qipai_payments(id),
CONSTRAINT fk_qipai_refund_order FOREIGN KEY (order_id) REFERENCES qipai_orders(id),
UNIQUE KEY uq_qipai_refund_no (tenant_id, refund_no),
UNIQUE KEY uq_qipai_refund_provider (tenant_id, provider_refund_id),
KEY idx_qipai_refund_status (tenant_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_profit_shares (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
payment_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
share_no VARCHAR(64) NOT NULL,
receiver_type VARCHAR(32) NOT NULL,
receiver_ref VARCHAR(128) NOT NULL,
amount_cents INT UNSIGNED NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
provider_share_id VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
CONSTRAINT fk_qipai_profit_share_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_profit_share_payment FOREIGN KEY (payment_id) REFERENCES qipai_payments(id),
CONSTRAINT fk_qipai_profit_share_order FOREIGN KEY (order_id) REFERENCES qipai_orders(id),
UNIQUE KEY uq_qipai_profit_share_no (tenant_id, share_no),
KEY idx_qipai_profit_share_status (tenant_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_payment_configs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NULL,
platform_app_id BIGINT UNSIGNED NULL,
store_id BIGINT UNSIGNED NULL,
provider VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
credential_ref VARCHAR(255) NOT NULL DEFAULT '',
settings JSON NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_payment_config_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_payment_config_app
FOREIGN KEY (platform_app_id) REFERENCES qipai_platform_apps(id),
CONSTRAINT fk_qipai_payment_config_store
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
UNIQUE KEY uq_qipai_payment_config_scope
(provider, scope_key),
KEY idx_qipai_payment_config_resolution
(provider, enabled, tenant_id, platform_app_id, store_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026062015', 'm05a_payment_domain');
@@ -0,0 +1,26 @@
SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name IN (
'qipai_payment_attempts', 'qipai_payment_callbacks', 'qipai_refunds',
'qipai_profit_shares', 'qipai_payment_configs'
) ORDER BY table_name;
SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'qipai_payments'
AND column_name IN (
'platform_app_id', 'store_id', 'provider', 'client_request_id',
'currency', 'provider_payment_id', 'failed_at', 'failure_code'
) ORDER BY column_name;
SELECT table_name, index_name FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND ((table_name = 'qipai_payments'
AND index_name IN (
'uq_qipai_payments_client_request', 'uq_qipai_payments_provider_id'
))
OR (table_name = 'qipai_payment_callbacks'
AND index_name = 'uq_qipai_payment_callback_provider')
OR (table_name = 'qipai_payment_configs'
AND index_name = 'idx_qipai_payment_config_resolution'))
GROUP BY table_name, index_name ORDER BY table_name, index_name;
SELECT version, name FROM qipai_schema_migrations WHERE version = '2026062015';
@@ -0,0 +1,8 @@
# M05-A 统一支付领域 API
- `POST /app-api/payments`:基于订单服务端未付金额创建支付单;客户端不能提交金额。
- `POST /app-api/payments/:paymentId/test-complete`:仅在非生产环境且显式开启时模拟可信支付回调。
支付单使用 `clientRequestId` 幂等。测试回调使用 provider + callbackId 唯一键;错误金额回调会保存为拒绝状态但不记账。成功回调在同一事务内更新支付单、订单已付金额、订单状态历史和房间预占。
测试适配器默认关闭,生产环境即使配置开关为 true 也强制关闭。
@@ -0,0 +1,10 @@
# M05-A 统一支付领域数据库变更
- 迁移版本:`2026062015`
- 扩展 `qipai_payments`:小程序、门店、provider、客户端幂等键、币种、provider 支付号和失败信息。
- 新增 `qipai_payment_attempts``qipai_payment_callbacks``qipai_refunds``qipai_profit_shares`
- 新增 `qipai_payment_configs`,按 platform_app / tenant / store 分层解析。
- 支付配置只保存 `credential_ref`,例如 `env:WX_STORE`,不保存密钥、证书或 Token 正文。
- 旧支付记录迁移时从订单回填门店,并生成稳定的 legacy client request id。
所有金额使用整数分。支付回调以 tenant + provider + callbackId 唯一约束防止重复记账。
+3
View File
@@ -58,6 +58,9 @@ $requiredFiles = @(
"database/migrations/2026062014_m04d_order_shares.up.sql", "database/migrations/2026062014_m04d_order_shares.up.sql",
"database/migrations/2026062014_m04d_order_shares.down.sql", "database/migrations/2026062014_m04d_order_shares.down.sql",
"database/migrations/2026062014_m04d_order_shares.verify.sql", "database/migrations/2026062014_m04d_order_shares.verify.sql",
"database/migrations/2026062015_m05a_payment_domain.up.sql",
"database/migrations/2026062015_m05a_payment_domain.down.sql",
"database/migrations/2026062015_m05a_payment_domain.verify.sql",
"database/seeds/2026061601_m01b_minimal_seed.sql", "database/seeds/2026061601_m01b_minimal_seed.sql",
"deploy/pm2/ecosystem.config.cjs" "deploy/pm2/ecosystem.config.cjs"
) )
+2 -2
View File
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2 export QIPAI_MYSQL_CONNECTION_LIMIT=2
echo "INFO: MySQL ${mysql_version}; running M01-B through M04-D migration roundtrip in a temporary database." echo "INFO: MySQL ${mysql_version}; running M01-B through M05-A migration roundtrip in a temporary database."
npm --prefix backend run test:mysql:migration npm --prefix backend run test:mysql:migration
echo "PASS: M01-B through M04-D live MySQL migration roundtrip completed." echo "PASS: M01-B through M05-A live MySQL migration roundtrip completed."