diff --git a/backend/.env.example b/backend/.env.example index bad6772..bef9430 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -12,6 +12,7 @@ QIPAI_JWT_SECRET= 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= diff --git a/backend/package.json b/backend/package.json index f17496d..10a364d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.ts b/backend/src/app.ts index 3e6e13f..0656088 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 0, diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index c5a5445..11a318c 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -34,7 +34,8 @@ const migrationFiles: Record = { '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 = { '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( diff --git a/backend/src/payments/payment-repository.ts b/backend/src/payments/payment-repository.ts new file mode 100644 index 0000000..c6279af --- /dev/null +++ b/backend/src/payments/payment-repository.ts @@ -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( + `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( + `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( + `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, + tenantId: string, + platformAppId: string, + storeId: string, + provider: PaymentProvider + ) { + const [rows] = await connection.execute( + `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>( + `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( + `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( + `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(work: (connection: PoolConnection) => Promise) { + 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(); + } + } +} diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts new file mode 100644 index 0000000..876b653 --- /dev/null +++ b/backend/src/routes/payments.ts @@ -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; + authRepository: Pick; + 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) { + 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 + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 38f25b4..bec17d4 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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 () => { diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index c40a83a..7170fb6 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -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.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 1cf1cb3..bd3ff99 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -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); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index 9e0af44..a5f5fed 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -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 { diff --git a/backend/tests/payment.test.mjs b/backend/tests/payment.test.mjs new file mode 100644 index 0000000..98a6636 --- /dev/null +++ b/backend/tests/payment.test.mjs @@ -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.'); diff --git a/database/migrations/2026062015_m05a_payment_domain.down.sql b/database/migrations/2026062015_m05a_payment_domain.down.sql new file mode 100644 index 0000000..584ec4c --- /dev/null +++ b/database/migrations/2026062015_m05a_payment_domain.down.sql @@ -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; diff --git a/database/migrations/2026062015_m05a_payment_domain.up.sql b/database/migrations/2026062015_m05a_payment_domain.up.sql new file mode 100644 index 0000000..4acedf6 --- /dev/null +++ b/database/migrations/2026062015_m05a_payment_domain.up.sql @@ -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'); diff --git a/database/migrations/2026062015_m05a_payment_domain.verify.sql b/database/migrations/2026062015_m05a_payment_domain.verify.sql new file mode 100644 index 0000000..36565c9 --- /dev/null +++ b/database/migrations/2026062015_m05a_payment_domain.verify.sql @@ -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'; diff --git a/docs/api-changelog/2026-06-20-M05-A-payment-domain.md b/docs/api-changelog/2026-06-20-M05-A-payment-domain.md new file mode 100644 index 0000000..f6c4768 --- /dev/null +++ b/docs/api-changelog/2026-06-20-M05-A-payment-domain.md @@ -0,0 +1,8 @@ +# M05-A 统一支付领域 API + +- `POST /app-api/payments`:基于订单服务端未付金额创建支付单;客户端不能提交金额。 +- `POST /app-api/payments/:paymentId/test-complete`:仅在非生产环境且显式开启时模拟可信支付回调。 + +支付单使用 `clientRequestId` 幂等。测试回调使用 provider + callbackId 唯一键;错误金额回调会保存为拒绝状态但不记账。成功回调在同一事务内更新支付单、订单已付金额、订单状态历史和房间预占。 + +测试适配器默认关闭,生产环境即使配置开关为 true 也强制关闭。 diff --git a/docs/db-changelog/2026-06-20-M05-A-payment-domain.md b/docs/db-changelog/2026-06-20-M05-A-payment-domain.md new file mode 100644 index 0000000..1cba4a9 --- /dev/null +++ b/docs/db-changelog/2026-06-20-M05-A-payment-domain.md @@ -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 唯一约束防止重复记账。 diff --git a/scripts/dev/windows/check-backend.ps1 b/scripts/dev/windows/check-backend.ps1 index 6bb21c7..aa67122 100644 --- a/scripts/dev/windows/check-backend.ps1 +++ b/scripts/dev/windows/check-backend.ps1 @@ -58,6 +58,9 @@ $requiredFiles = @( "database/migrations/2026062014_m04d_order_shares.up.sql", "database/migrations/2026062014_m04d_order_shares.down.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", "deploy/pm2/ecosystem.config.cjs" ) diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index 053d702..89c1f90 100644 --- a/scripts/dev/wsl/mysql-migration-roundtrip.sh +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}" export QIPAI_MYSQL_PASSWORD="${password}" 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 -echo "PASS: M01-B through M04-D live MySQL migration roundtrip completed." +echo "PASS: M01-B through M05-A live MySQL migration roundtrip completed."