diff --git a/backend/package.json b/backend/package.json index 5b35e94..d79bc55 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" + "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" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 3f4e3e7..c5a1cb8 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -30,6 +30,9 @@ import { type StoreAccessRouteOptions } from './routes/store-access.js'; import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricing.js'; +import { + registerOrderStateRoutes, type OrderStateRouteOptions +} from './routes/order-state.js'; export interface BuildAppOptions { config?: AppConfig; @@ -41,6 +44,7 @@ export interface BuildAppOptions { storeDiscovery?: StoreDiscoveryRouteOptions; storeAccess?: StoreAccessRouteOptions; pricing?: PricingRouteOptions; + orderState?: OrderStateRouteOptions; } declare module 'fastify' { @@ -107,6 +111,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise = { 'database/migrations/2026061808_m03b_decoration_ads_media.up.sql', 'database/migrations/2026061809_m03c_store_discovery.up.sql', 'database/migrations/2026061810_m03d_scene_wifi_access.up.sql', - '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' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -44,9 +45,11 @@ const migrationFiles: Record = { 'database/migrations/2026061808_m03b_decoration_ads_media.verify.sql', 'database/migrations/2026061809_m03c_store_discovery.verify.sql', 'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql', - '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' ], down: [ + 'database/migrations/2026062012_m04b_order_state_machine.down.sql', 'database/migrations/2026061811_m04a_pricing_reservations.down.sql', 'database/migrations/2026061810_m03d_scene_wifi_access.down.sql', 'database/migrations/2026061809_m03c_store_discovery.down.sql', @@ -184,7 +187,8 @@ export async function executeMigrationPlan( 3, 3, 1, 2, 2, 1, 3, 3, 1, - 2, 1, 3, 3, 1 + 2, 1, 3, 3, 1, + 2, 1, 3, 1 ][index] ?? 1; if (!Array.isArray(result) || result.length < minimumRows) { throw new Error( diff --git a/backend/src/orders/order-state-repository.ts b/backend/src/orders/order-state-repository.ts new file mode 100644 index 0000000..2801ba9 --- /dev/null +++ b/backend/src/orders/order-state-repository.ts @@ -0,0 +1,249 @@ +import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; +import type { AccessProfile } from '../auth/rbac-repository.js'; +import type { MySqlPool } from '../db/mysql.js'; + +export const orderActions = [ + 'SUBMIT', 'CONFIRM_PAYMENT', 'RESERVE', 'START', 'FINISH', 'CANCEL', + 'BEGIN_REFUND', 'COMPLETE_REFUND', 'CLOSE' +] as const; +export type OrderAction = typeof orderActions[number]; +export type OrderStatus = + | 'DRAFT' | 'PENDING_PAYMENT' | 'PAID' | 'RESERVED' | 'IN_PROGRESS' + | 'FINISHED' | 'CANCELLED' | 'REFUNDING' | 'REFUNDED' | 'CLOSED'; + +export interface OrderActor { + tenantId: string; + userId: string; + actorType: 'USER' | 'SYSTEM'; + source: 'APP' | 'ADMIN' | 'PAYMENT' | 'WORKER' | 'SYSTEM'; + traceId: string; + ip: string; + userAgent: string; + access?: AccessProfile; +} + +interface OrderRow extends RowDataPacket { + id: string; + storeId: string; + status: OrderStatus; + statusVersion: number; +} +interface HistoryRow extends RowDataPacket { + id: string; + fromStatus: OrderStatus | null; + toStatus: OrderStatus; + action: OrderAction | 'CREATED' | 'EXPIRED' | 'MIGRATED'; + actorType: string; + actorId: string | null; + source: string; + reason: string; + traceId: string; + createdAt: Date; +} + +const targetByAction: Record = { + SUBMIT: 'PENDING_PAYMENT', + CONFIRM_PAYMENT: 'PAID', + RESERVE: 'RESERVED', + START: 'IN_PROGRESS', + FINISH: 'FINISHED', + CANCEL: 'CANCELLED', + BEGIN_REFUND: 'REFUNDING', + COMPLETE_REFUND: 'REFUNDED', + CLOSE: 'CLOSED' +}; + +const allowedActions: Record = { + DRAFT: ['SUBMIT', 'CANCEL', 'CLOSE'], + PENDING_PAYMENT: ['CONFIRM_PAYMENT', 'CANCEL', 'CLOSE'], + PAID: ['RESERVE', 'START', 'CANCEL', 'BEGIN_REFUND'], + RESERVED: ['START', 'CANCEL', 'BEGIN_REFUND'], + IN_PROGRESS: ['FINISH', 'BEGIN_REFUND'], + FINISHED: ['BEGIN_REFUND', 'CLOSE'], + CANCELLED: ['BEGIN_REFUND', 'CLOSE'], + REFUNDING: ['COMPLETE_REFUND'], + REFUNDED: ['CLOSE'], + CLOSED: [] +}; + +export class OrderStateError extends Error { + constructor(public readonly code: string) { super(code); } +} + +export class OrderStateRepository { + constructor(private readonly pool: MySqlPool) {} + + async transition(actor: OrderActor, orderId: string, action: OrderAction, reason = '') { + return this.transaction(async (connection) => { + const order = await this.loadOrder(connection, actor.tenantId, orderId, true); + await this.assertAuthorized(connection, actor, order, action); + const duplicate = await this.findByTrace(connection, actor.tenantId, orderId, actor.traceId); + if (duplicate) { + return { + orderId, + status: duplicate.toStatus, + statusVersion: null, + historyId: duplicate.id, + idempotent: true + }; + } + if (!allowedActions[order.status].includes(action)) { + throw new OrderStateError('ORDER_TRANSITION_NOT_ALLOWED'); + } + const targetStatus = targetByAction[action]; + const nextVersion = Number(order.statusVersion) + 1; + await connection.execute( + `UPDATE qipai_orders + SET status = ?, status_version = ?, status_updated_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND id = ?`, + [targetStatus, nextVersion, actor.tenantId, orderId] + ); + await this.applyReservationState(connection, actor.tenantId, orderId, targetStatus); + const [result] = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT('statusVersion', ?))`, + [actor.tenantId, orderId, order.status, targetStatus, action, + actor.actorType, actor.userId, actor.source, reason.slice(0, 512), + actor.traceId, nextVersion] + ); + await connection.execute( + `INSERT INTO qipai_audit_logs + (tenant_id, actor_type, actor_id, action, resource_type, resource_id, + trace_id, ip, user_agent, metadata) + VALUES (?, ?, ?, 'ORDER_STATUS_CHANGED', 'ORDER', ?, ?, ?, ?, + JSON_OBJECT('fromStatus', ?, 'toStatus', ?, 'orderAction', ?))`, + [actor.tenantId, actor.actorType, actor.userId, orderId, actor.traceId, + actor.ip, actor.userAgent.slice(0, 255), order.status, targetStatus, action] + ); + return { + orderId, + status: targetStatus, + statusVersion: nextVersion, + historyId: String(result.insertId), + idempotent: false + }; + }); + } + + async history(tenantId: string, userId: string, orderId: string, access: AccessProfile) { + const order = await this.loadOrder(this.pool, tenantId, orderId, false); + const manager = canManageStore(access, order.storeId); + if (!manager) { + const [rows] = await this.pool.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 OrderStateError('ORDER_ACCESS_FORBIDDEN'); + } + const [rows] = await this.pool.execute( + `SELECT id, from_status AS fromStatus, to_status AS toStatus, action, + actor_type AS actorType, actor_id AS actorId, source, reason, + trace_id AS traceId, created_at AS createdAt + FROM qipai_order_status_history + WHERE tenant_id = ? AND order_id = ? ORDER BY id`, + [tenantId, orderId] + ); + return rows.map((row) => ({ + ...row, + id: String(row.id), + actorId: row.actorId === null ? null : String(row.actorId) + })); + } + + private async assertAuthorized( + connection: PoolConnection, actor: OrderActor, order: OrderRow, action: OrderAction + ) { + if (actor.source !== 'APP') { + if (!actor.access || !canManageStore(actor.access, order.storeId)) { + throw new OrderStateError('ORDER_MANAGEMENT_FORBIDDEN'); + } + return; + } + if (action !== 'CANCEL') throw new OrderStateError('ORDER_ACTION_FORBIDDEN'); + const [rows] = await connection.execute( + `SELECT 1 FROM qipai_order_user_access + WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`, + [actor.tenantId, order.id, actor.userId] + ); + if (!rows[0]) throw new OrderStateError('ORDER_ACCESS_FORBIDDEN'); + } + + private async loadOrder( + connection: Pick | PoolConnection, + tenantId: string, + orderId: string, + lock: boolean + ) { + const [rows] = await connection.execute( + `SELECT id, store_id AS storeId, status, status_version AS statusVersion + FROM qipai_orders + WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL + ${lock ? 'FOR UPDATE' : ''}`, + [tenantId, orderId] + ); + if (!rows[0]) throw new OrderStateError('ORDER_NOT_FOUND'); + return rows[0]; + } + + private async findByTrace( + connection: PoolConnection, tenantId: string, orderId: string, traceId: string + ) { + const [rows] = await connection.execute( + `SELECT id, to_status AS toStatus + FROM qipai_order_status_history + WHERE tenant_id = ? AND order_id = ? AND trace_id = ? LIMIT 1`, + [tenantId, orderId, traceId] + ); + return rows[0] ?? null; + } + + private async applyReservationState( + connection: PoolConnection, tenantId: string, orderId: string, status: OrderStatus + ) { + if (['PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED'].includes(status)) { + 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'`, + [tenantId, orderId] + ); + } else if (['CANCELLED', 'REFUNDED', 'CLOSED'].includes(status)) { + await connection.execute( + `UPDATE qipai_room_reservations + SET status = 'RELEASED', released_at = COALESCE(released_at, UTC_TIMESTAMP(3)) + WHERE tenant_id = ? AND order_id = ? AND status IN ('HELD', 'CONSUMED')`, + [tenantId, orderId] + ); + await connection.execute( + `UPDATE qipai_order_user_access + SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP(3)) + WHERE tenant_id = ? AND order_id = ?`, + [tenantId, orderId] + ); + } + } + + 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(); + } + } +} + +function canManageStore(access: AccessProfile, storeId: string) { + return access.capabilities.includes('tenant.manage') + || access.roles.includes('PLATFORM_ADMIN') + || (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId)); +} diff --git a/backend/src/orders/pricing-repository.ts b/backend/src/orders/pricing-repository.ts index 36879a8..53db4d5 100644 --- a/backend/src/orders/pricing-repository.ts +++ b/backend/src/orders/pricing-repository.ts @@ -97,6 +97,14 @@ export class PricingRepository { VALUES (?, ?, ?)`, [input.tenantId, orderId, input.userId] ); + 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 (?, ?, NULL, 'PENDING_PAYMENT', 'CREATED', 'USER', ?, + 'APP', 'Room hold created', ?, JSON_OBJECT('statusVersion', 1))`, + [input.tenantId, orderId, input.userId, `order-created-${orderId}`] + ); return { orderId, orderNo, @@ -127,12 +135,26 @@ export class PricingRepository { WHERE ${filters.join(' AND ')}`, params ); + await connection.execute( + `INSERT IGNORE INTO qipai_order_status_history + (tenant_id, order_id, from_status, to_status, action, actor_type, + actor_id, source, reason, trace_id, metadata) + SELECT o.tenant_id, o.id, o.status, 'CLOSED', 'EXPIRED', 'SYSTEM', + NULL, 'WORKER', 'Payment hold expired', + CONCAT('hold-expired-', o.id), JSON_OBJECT('statusVersion', o.status_version + 1) + FROM qipai_room_reservations r + INNER JOIN qipai_orders o + ON o.id = r.order_id AND o.tenant_id = r.tenant_id + WHERE ${filters.join(' AND ')}`, + params + ); await connection.execute( `UPDATE qipai_room_reservations r INNER JOIN qipai_orders o ON o.id = r.order_id AND o.tenant_id = r.tenant_id SET r.status = 'RELEASED', r.released_at = UTC_TIMESTAMP(3), - o.status = 'CLOSED' + o.status = 'CLOSED', o.status_version = o.status_version + 1, + o.status_updated_at = UTC_TIMESTAMP(3) WHERE ${filters.join(' AND ')}`, params ); diff --git a/backend/src/routes/order-state.ts b/backend/src/routes/order-state.ts new file mode 100644 index 0000000..4b20dbe --- /dev/null +++ b/backend/src/routes/order-state.ts @@ -0,0 +1,118 @@ +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 type { AccessProfile } from '../auth/rbac-repository.js'; +import { + OrderStateError, orderActions, type OrderStateRepository +} from '../orders/order-state-repository.js'; + +const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) }); +const transitionSchema = z.object({ + action: z.enum(orderActions), + reason: z.string().max(512).default('') +}).strict(); +const cancelSchema = z.object({ reason: z.string().max(512).default('') }).strict(); + +export interface OrderStateRouteOptions { + repository: Pick; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + jwtSecret: string; +} + +export async function registerOrderStateRoutes( + app: FastifyInstance, options: OrderStateRouteOptions +) { + app.get('/app-api/orders/:orderId/history', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const params = paramsSchema.safeParse(request.params); + if (!auth) return unauthorized(reply, request.traceId); + if (!params.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.repository.history( + auth.tenantId, auth.userId, params.data.orderId, auth.access + ), + traceId: request.traceId + })); + }); + + app.post('/app-api/orders/:orderId/cancel', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const params = paramsSchema.safeParse(request.params); + const body = cancelSchema.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 () => ({ + code: 0, + data: await options.repository.transition({ + tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER', + source: 'APP', traceId: request.traceId, ip: request.ip, + userAgent: request.headers['user-agent'] ?? '', access: auth.access + }, params.data.orderId, 'CANCEL', body.data.reason), + traceId: request.traceId + })); + }); + + app.post('/admin-api/orders/:orderId/actions', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const params = paramsSchema.safeParse(request.params); + const body = transitionSchema.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 () => ({ + code: 0, + data: await options.repository.transition({ + tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER', + source: 'ADMIN', traceId: request.traceId, ip: request.ip, + userAgent: request.headers['user-agent'] ?? '', access: auth.access + }, params.data.orderId, body.data.action, body.data.reason), + traceId: request.traceId + })); + }); +} + +async function authenticate( + authorization: string | undefined, options: OrderStateRouteOptions +) { + const result = await authenticateAccessToken( + authorization, options.authRepository, options.jwtSecret + ); + if (!result) return null; + const tenantId = result.session.tenantId; + const userId = result.session.user.id; + return { + tenantId, + userId, + access: await options.accessControl.getAccessProfile(tenantId, userId) + }; +} + +async function handle(reply: FastifyReply, traceId: string, work: () => Promise) { + try { + return await work(); + } catch (error) { + if (!(error instanceof OrderStateError)) throw error; + const status = error.code === 'ORDER_NOT_FOUND' ? 404 + : error.code === 'ORDER_TRANSITION_NOT_ALLOWED' ? 409 + : error.code.includes('FORBIDDEN') ? 403 : 400; + return reply.status(status).send({ + code: error.code, + message: 'The requested order action 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_ORDER_ACTION', message: 'The order action is invalid.', traceId + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 095c8fe..6939b22 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -13,6 +13,7 @@ import { resolve } from 'node:path'; import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js'; import { StoreAccessRepository } from './stores/access-repository.js'; import { PricingRepository } from './orders/pricing-repository.js'; +import { OrderStateRepository } from './orders/order-state-repository.js'; const config = loadConfig(); const pool = createMySqlPool(config); @@ -63,6 +64,12 @@ const app = await buildApp({ authRepository, accessControl, jwtSecret: config.auth.jwtSecret + }, + orderState: { + repository: new OrderStateRepository(pool), + authRepository, + accessControl, + jwtSecret: config.auth.jwtSecret } }); app.addHook('onClose', async () => { diff --git a/backend/src/stores/access-repository.ts b/backend/src/stores/access-repository.ts index a08fc98..b02b32b 100644 --- a/backend/src/stores/access-repository.ts +++ b/backend/src/stores/access-repository.ts @@ -137,7 +137,8 @@ export class StoreAccessRepository { INNER JOIN qipai_orders o ON o.id = a.order_id AND o.tenant_id = a.tenant_id AND o.deleted_at IS NULL WHERE a.tenant_id = ? AND a.user_id = ? AND a.revoked_at IS NULL - AND o.store_id = ? AND o.status IN ('PAID', 'CONFIRMED', 'IN_USE') + AND o.store_id = ? + AND o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS', 'CONFIRMED', 'IN_USE') AND UTC_TIMESTAMP(3) BETWEEN DATE_SUB(o.start_at, INTERVAL 30 MINUTE) AND o.end_at`, [input.tenantId, input.userId, input.storeId] ); diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index e32bf24..b75c5c5 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -42,6 +42,9 @@ const accessVerifySql = read('database/migrations/2026061810_m03d_scene_wifi_acc const pricingUpSql = read('database/migrations/2026061811_m04a_pricing_reservations.up.sql'); const pricingDownSql = read('database/migrations/2026061811_m04a_pricing_reservations.down.sql'); const pricingVerifySql = read('database/migrations/2026061811_m04a_pricing_reservations.verify.sql'); +const orderStateUpSql = read('database/migrations/2026062012_m04b_order_state_machine.up.sql'); +const orderStateDownSql = read('database/migrations/2026062012_m04b_order_state_machine.down.sql'); +const orderStateVerifySql = read('database/migrations/2026062012_m04b_order_state_machine.verify.sql'); const coreTables = [ 'qipai_schema_migrations', @@ -196,5 +199,13 @@ for (const table of [ assert.match(pricingUpSql, /full_day_price_cents/); assert.match(pricingUpSql, /minimum_spend_cents/); assert.match(pricingUpSql, /idx_qipai_reservation_overlap/); +assert.match(orderStateUpSql, /CREATE TABLE IF NOT EXISTS qipai_order_status_history/); +assert.match(orderStateDownSql, /DROP TABLE IF EXISTS qipai_order_status_history/); +assert.match(orderStateVerifySql, /'qipai_order_status_history'/); +assert.match(orderStateUpSql, /status_version INT UNSIGNED/); +assert.match(orderStateUpSql, /UNIQUE KEY uq_qipai_order_history_trace/); +assert.match(orderStateUpSql, /actor_type VARCHAR/); +assert.match(orderStateUpSql, /source VARCHAR/); +assert.match(orderStateUpSql, /trace_id VARCHAR/); -console.log('PASS: M01-B through M04-A migration contracts are present.'); +console.log('PASS: M01-B through M04-B migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 73712fa..c0c6665 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -22,7 +22,8 @@ assert.match(plan.file, /2026061807_m03a_store_room_domain\.up\.sql/); assert.match(plan.file, /2026061808_m03b_decoration_ads_media\.up\.sql/); assert.match(plan.file, /2026061809_m03c_store_discovery\.up\.sql/); 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.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 1d0403c..5f38aa3 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -18,6 +18,9 @@ import { ContentRepository, ContentError } from '../dist/content/content-reposit import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js'; import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js'; import { PricingRepository, PricingError } from '../dist/orders/pricing-repository.js'; +import { + OrderStateError, OrderStateRepository +} from '../dist/orders/order-state-repository.js'; import { executeMigrationPlan, loadMigrationPlan, @@ -35,6 +38,7 @@ const expectedTables = [ 'qipai_media_assets', 'qipai_members', 'qipai_order_price_snapshots', + 'qipai_order_status_history', 'qipai_order_user_access', 'qipai_orders', 'qipai_outbox_events', @@ -81,11 +85,11 @@ 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'] + '2026061810', '2026061811', '2026062012'] ); return rows; } @@ -627,6 +631,98 @@ async function assertPricingAndReservations(pool, context) { assert.equal(replacement.quote.unitPriceCents, 9000); } +async function assertOrderStateMachine(pool, context) { + const [adminRows] = await pool.query( + `SELECT u.id FROM qipai_users u + INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id + INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id + WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, + [context.tenantId] + ); + 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 [targetRows] = await pool.query( + `SELECT r.id AS roomId + FROM qipai_rooms r + INNER JOIN qipai_stores s ON s.id = r.store_id AND s.tenant_id = r.tenant_id + WHERE r.tenant_id = ? AND s.name = 'M03A Store' LIMIT 1`, + [context.tenantId] + ); + const adminId = String(adminRows[0].id); + const customerId = String(customerRows[0].id); + const roomId = String(targetRows[0].roomId); + const startAt = new Date(Date.now() + 15 * 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' + }); + const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId); + const repository = new OrderStateRepository(pool); + const actor = (traceId) => ({ + tenantId: context.tenantId, userId: adminId, actorType: 'USER', + source: 'ADMIN', traceId, ip: '127.0.0.1', + userAgent: 'M04-B live state test', access + }); + + const paid = await repository.transition( + actor('m04b-paid'), order.orderId, 'CONFIRM_PAYMENT', 'payment accepted' + ); + assert.equal(paid.status, 'PAID'); + assert.equal(paid.statusVersion, 2); + const duplicate = await repository.transition( + actor('m04b-paid'), order.orderId, 'CONFIRM_PAYMENT', 'duplicate callback' + ); + assert.equal(duplicate.idempotent, true); + await repository.transition(actor('m04b-reserved'), order.orderId, 'RESERVE'); + await repository.transition(actor('m04b-start'), order.orderId, 'START'); + await repository.transition(actor('m04b-finish'), order.orderId, 'FINISH'); + const closed = await repository.transition(actor('m04b-close'), order.orderId, 'CLOSE'); + assert.equal(closed.status, 'CLOSED'); + assert.equal(closed.statusVersion, 6); + await assert.rejects( + () => repository.transition(actor('m04b-invalid'), order.orderId, 'START'), + (error) => error instanceof OrderStateError + && error.code === 'ORDER_TRANSITION_NOT_ALLOWED' + ); + const history = await repository.history(context.tenantId, customerId, order.orderId, { + roles: ['CUSTOMER'], capabilities: ['order.self.read'], storeIds: [] + }); + assert.deepEqual(history.map((item) => item.toStatus), [ + 'PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED', 'CLOSED' + ]); + const [stateRows] = await pool.query( + `SELECT o.status, o.status_version AS statusVersion, + r.status AS reservationStatus, a.revoked_at AS revokedAt + FROM qipai_orders o + INNER JOIN qipai_room_reservations r + ON r.tenant_id = o.tenant_id AND r.order_id = o.id + INNER JOIN qipai_order_user_access a + ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.user_id = ? + WHERE o.tenant_id = ? AND o.id = ?`, + [customerId, context.tenantId, order.orderId] + ); + assert.equal(stateRows[0].status, 'CLOSED'); + assert.equal(stateRows[0].statusVersion, 6); + assert.equal(stateRows[0].reservationStatus, 'RELEASED'); + assert.ok(stateRows[0].revokedAt); + const [auditRows] = await pool.query( + `SELECT action FROM qipai_audit_logs + WHERE tenant_id = ? AND trace_id LIKE 'm04b-%' ORDER BY id`, + [context.tenantId] + ); + assert.deepEqual(auditRows.map((row) => row.action), [ + 'ORDER_STATUS_CHANGED', 'ORDER_STATUS_CHANGED', 'ORDER_STATUS_CHANGED', + 'ORDER_STATUS_CHANGED', 'ORDER_STATUS_CHANGED' + ]); +} + async function assertContentManagement(pool, context) { const [adminRows] = await pool.query( `SELECT u.id FROM qipai_users u @@ -725,7 +821,8 @@ try { { version: '2026061808', name: 'm03b_decoration_ads_media' }, { version: '2026061809', name: 'm03c_store_discovery' }, { version: '2026061810', name: 'm03d_scene_wifi_access' }, - { version: '2026061811', name: 'm04a_pricing_reservations' } + { version: '2026061811', name: 'm04a_pricing_reservations' }, + { version: '2026062012', name: 'm04b_order_state_machine' } ]); await assertTaskDurability(pool); const loginContext = await assertPlatformTenantIsolation(pool); @@ -736,13 +833,14 @@ try { await assertStoreDiscovery(pool, loginContext); await assertSceneAndWifiAccess(pool, loginContext); await assertPricingAndReservations(pool, loginContext); + await assertOrderStateMachine(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-A tables.'); + console.log('PASS: down removed all M01-B through M04-B tables.'); await executeMigrationPlan(pool, plans.up); await executeMigrationPlan(pool, plans.verify); @@ -758,7 +856,8 @@ try { { version: '2026061808', name: 'm03b_decoration_ads_media' }, { version: '2026061809', name: 'm03c_store_discovery' }, { version: '2026061810', name: 'm03d_scene_wifi_access' }, - { version: '2026061811', name: 'm04a_pricing_reservations' } + { version: '2026061811', name: 'm04a_pricing_reservations' }, + { version: '2026062012', name: 'm04b_order_state_machine' } ]); await assertLegacyCompatibility(pool); console.log('PASS: second up and verify restored the schema.'); @@ -816,7 +915,11 @@ try { 'holiday and minimum-spend pricing', 'immutable order price snapshot', 'concurrent room hold conflict', - 'expired hold release' + 'expired hold release', + 'controlled order actions', + 'idempotent transition trace', + 'complete order status history', + 'reservation and access release on close' ] }, null, 2)); } finally { diff --git a/backend/tests/order-state.test.mjs b/backend/tests/order-state.test.mjs new file mode 100644 index 0000000..fed0336 --- /dev/null +++ b/backend/tests/order-state.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { buildApp } from '../dist/app.js'; +import { signAccessToken } from '../dist/auth/jwt.js'; + +const secret = 'test-only-order-state-jwt-secret-32-chars'; +const token = signAccessToken({ + sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tid: '7', aid: '9', rv: 1 +}, secret, 900); +let transitionInput; +const options = { + jwtSecret: secret, + 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: 'ADMIN', status: 'ACTIVE', + roleVersion: 1, nickname: '', avatarUrl: '', phone: '' + } + }; + } + }, + accessControl: { + async getAccessProfile() { + return { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] }; + } + }, + repository: { + async transition(actor, orderId, action, reason) { + transitionInput = { actor, orderId, action, reason }; + return { orderId, status: action === 'CANCEL' ? 'CANCELLED' : 'PAID' }; + }, + async history() { return []; } + } +}; +const app = await buildApp({ orderState: options }); + +const rejectedTarget = await app.inject({ + method: 'POST', + url: '/admin-api/orders/31/actions', + headers: { authorization: `Bearer ${token}` }, + payload: { action: 'CONFIRM_PAYMENT', targetStatus: 'FINISHED' } +}); +assert.equal(rejectedTarget.statusCode, 400); + +const transitioned = await app.inject({ + method: 'POST', + url: '/admin-api/orders/31/actions', + headers: { + authorization: `Bearer ${token}`, + 'x-trace-id': 'm04b-route-transition' + }, + payload: { action: 'CONFIRM_PAYMENT', reason: 'sanitized callback rehearsal' } +}); +assert.equal(transitioned.statusCode, 200); +assert.equal(transitionInput.orderId, '31'); +assert.equal(transitionInput.action, 'CONFIRM_PAYMENT'); +assert.equal(transitionInput.actor.source, 'ADMIN'); +assert.equal(transitionInput.actor.traceId, 'm04b-route-transition'); + +const cancelled = await app.inject({ + method: 'POST', + url: '/app-api/orders/31/cancel', + headers: { authorization: `Bearer ${token}` }, + payload: { reason: 'customer changed plans' } +}); +assert.equal(cancelled.statusCode, 200); +assert.equal(transitionInput.action, 'CANCEL'); +assert.equal(transitionInput.actor.source, 'APP'); + +await app.close(); +console.log('PASS: M04-B routes accept business actions and reject direct target statuses.'); diff --git a/database/migrations/2026062012_m04b_order_state_machine.down.sql b/database/migrations/2026062012_m04b_order_state_machine.down.sql new file mode 100644 index 0000000..e0e50cf --- /dev/null +++ b/database/migrations/2026062012_m04b_order_state_machine.down.sql @@ -0,0 +1,5 @@ +DELETE FROM qipai_schema_migrations WHERE version = '2026062012'; +DROP TABLE IF EXISTS qipai_order_status_history; +ALTER TABLE qipai_orders + DROP COLUMN status_updated_at, + DROP COLUMN status_version; diff --git a/database/migrations/2026062012_m04b_order_state_machine.up.sql b/database/migrations/2026062012_m04b_order_state_machine.up.sql new file mode 100644 index 0000000..fe0e249 --- /dev/null +++ b/database/migrations/2026062012_m04b_order_state_machine.up.sql @@ -0,0 +1,38 @@ +ALTER TABLE qipai_orders + ADD COLUMN status_version INT UNSIGNED NOT NULL DEFAULT 1 AFTER status, + ADD COLUMN status_updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + AFTER status_version; + +CREATE TABLE IF NOT EXISTS qipai_order_status_history ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + from_status VARCHAR(32) NULL, + to_status VARCHAR(32) NOT NULL, + action VARCHAR(32) NOT NULL, + actor_type VARCHAR(32) NOT NULL, + actor_id BIGINT UNSIGNED NULL, + source VARCHAR(32) NOT NULL, + reason VARCHAR(512) NOT NULL DEFAULT '', + trace_id VARCHAR(128) NOT NULL, + metadata JSON NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_order_history_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_order_history_order + FOREIGN KEY (order_id) REFERENCES qipai_orders(id), + UNIQUE KEY uq_qipai_order_history_trace (tenant_id, order_id, trace_id), + KEY idx_qipai_order_history_order (tenant_id, order_id, id), + KEY idx_qipai_order_history_status (tenant_id, to_status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_order_status_history + (tenant_id, order_id, from_status, to_status, action, actor_type, + actor_id, source, reason, trace_id, metadata, created_at) +SELECT tenant_id, id, NULL, status, 'MIGRATED', 'SYSTEM', + NULL, 'MIGRATION', 'M04-B baseline history', + CONCAT('m04b-migration-', id), JSON_OBJECT('statusVersion', 1), created_at +FROM qipai_orders; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026062012', 'm04b_order_state_machine'); diff --git a/database/migrations/2026062012_m04b_order_state_machine.verify.sql b/database/migrations/2026062012_m04b_order_state_machine.verify.sql new file mode 100644 index 0000000..8bfe92c --- /dev/null +++ b/database/migrations/2026062012_m04b_order_state_machine.verify.sql @@ -0,0 +1,20 @@ +SELECT column_name FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'qipai_orders' + AND column_name IN ('status_version', 'status_updated_at') +ORDER BY column_name; + +SELECT table_name FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name = 'qipai_order_status_history'; + +SELECT index_name FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'qipai_order_status_history' + AND index_name IN ( + 'uq_qipai_order_history_trace', + 'idx_qipai_order_history_order', + 'idx_qipai_order_history_status' + ) +GROUP BY index_name ORDER BY index_name; + +SELECT version, name FROM qipai_schema_migrations WHERE version = '2026062012'; diff --git a/docs/api-changelog/2026-06-20-M04-B-order-state-machine.md b/docs/api-changelog/2026-06-20-M04-B-order-state-machine.md new file mode 100644 index 0000000..37585ec --- /dev/null +++ b/docs/api-changelog/2026-06-20-M04-B-order-state-machine.md @@ -0,0 +1,9 @@ +# M04-B 订单状态机 API + +- `GET /app-api/orders/:orderId/history`:订单所有者或有门店管理权限的人员查看完整状态历史。 +- `POST /app-api/orders/:orderId/cancel`:订单所有者发起取消。 +- `POST /admin-api/orders/:orderId/actions`:管理员提交受控业务动作。 + +管理接口只接受 `SUBMIT`、`CONFIRM_PAYMENT`、`RESERVE`、`START`、`FINISH`、`CANCEL`、`BEGIN_REFUND`、`COMPLETE_REFUND`、`CLOSE`,由服务端映射目标状态并校验合法迁移。请求不能直接提交目标状态或最终金额。 + +每次迁移记录操作者、来源、原因和 traceId;相同订单和 traceId 的重试幂等返回第一次结果。 diff --git a/docs/db-changelog/2026-06-20-M04-B-order-state-machine.md b/docs/db-changelog/2026-06-20-M04-B-order-state-machine.md new file mode 100644 index 0000000..2ea0732 --- /dev/null +++ b/docs/db-changelog/2026-06-20-M04-B-order-state-machine.md @@ -0,0 +1,9 @@ +# M04-B 订单状态机数据库变更 + +- 迁移版本:`2026062012` +- `qipai_orders` 新增 `status_version` 和 `status_updated_at`。 +- 新增 `qipai_order_status_history`,保存旧状态、新状态、业务动作、操作者、来源、原因、traceId 和版本元数据。 +- `(tenant_id, order_id, trace_id)` 唯一约束用于状态迁移幂等。 +- 已有订单在迁移时补一条 `MIGRATED` 基线历史。 + +状态迁移与房间预占、订单现场访问授权更新处于同一数据库事务。进入已支付/已预留/使用中时消费预占;取消、退款完成或关闭时释放预占并撤销现场权限。 diff --git a/scripts/dev/windows/check-backend.ps1 b/scripts/dev/windows/check-backend.ps1 index 726737c..9d62849 100644 --- a/scripts/dev/windows/check-backend.ps1 +++ b/scripts/dev/windows/check-backend.ps1 @@ -49,6 +49,9 @@ $requiredFiles = @( "database/migrations/2026061805_m02c_rbac.up.sql", "database/migrations/2026061805_m02c_rbac.down.sql", "database/migrations/2026061805_m02c_rbac.verify.sql", + "database/migrations/2026062012_m04b_order_state_machine.up.sql", + "database/migrations/2026062012_m04b_order_state_machine.down.sql", + "database/migrations/2026062012_m04b_order_state_machine.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 fb8cef1..31692cd 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-A migration roundtrip in a temporary database." +echo "INFO: MySQL ${mysql_version}; running M01-B through M04-B migration roundtrip in a temporary database." npm --prefix backend run test:mysql:migration -echo "PASS: M01-B through M04-A live MySQL migration roundtrip completed." +echo "PASS: M01-B through M04-B live MySQL migration roundtrip completed."