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_SESSION_TTL_SECONDS=604800
QIPAI_WECHAT_APP_SECRETS={}
QIPAI_TEST_PAYMENT_ENABLED=false
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
QIPAI_MQTT_USERNAME=
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: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": "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": {
"@fastify/cors": "^11.2.0",
+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 () => {
+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 shareDownSql = read('database/migrations/2026062014_m04d_order_shares.down.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 = [
'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_renew TINYINT/);
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, /2026062012_m04b_order_state_machine\.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.ok(plan.statements.length >= 11);
@@ -27,6 +27,9 @@ import {
import {
OrderShareError, OrderShareRepository
} from '../dist/orders/order-share-repository.js';
import {
PaymentError, PaymentRepository
} from '../dist/payments/payment-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -50,9 +53,14 @@ const expectedTables = [
'qipai_order_user_access',
'qipai_orders',
'qipai_outbox_events',
'qipai_payment_attempts',
'qipai_payment_callbacks',
'qipai_payment_configs',
'qipai_payments',
'qipai_permissions',
'qipai_platform_apps',
'qipai_profit_shares',
'qipai_refunds',
'qipai_role_permissions',
'qipai_roles',
'qipai_room_categories',
@@ -93,11 +101,12 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014']
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015']
);
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) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -1122,7 +1252,8 @@ try {
{ version: '2026061811', name: 'm04a_pricing_reservations' },
{ version: '2026062012', name: 'm04b_order_state_machine' },
{ 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);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1136,13 +1267,14 @@ try {
await assertOrderStateMachine(pool, loginContext);
await assertOrderAdjustments(pool, loginContext);
await assertOrderShares(pool, loginContext);
await assertPaymentDomain(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(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.verify);
@@ -1161,7 +1293,8 @@ try {
{ version: '2026061811', name: 'm04a_pricing_reservations' },
{ version: '2026062012', name: 'm04b_order_state_machine' },
{ 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);
console.log('PASS: second up and verify restored the schema.');
@@ -1235,7 +1368,13 @@ try {
'renew permission denied by default',
'explicit renew permission without room disclosure',
'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));
} 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.');