feat(M05-A): 建立统一支付领域与幂等回调
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 唯一约束防止重复记账。
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user