feat(M04-B): 完成订单状态机与迁移历史

This commit is contained in:
Codex
2026-06-20 13:53:55 +08:00
parent 725028cb2d
commit 40c891f1b7
19 changed files with 698 additions and 16 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs" "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": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+7
View File
@@ -30,6 +30,9 @@ import {
type StoreAccessRouteOptions type StoreAccessRouteOptions
} from './routes/store-access.js'; } from './routes/store-access.js';
import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricing.js'; import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricing.js';
import {
registerOrderStateRoutes, type OrderStateRouteOptions
} from './routes/order-state.js';
export interface BuildAppOptions { export interface BuildAppOptions {
config?: AppConfig; config?: AppConfig;
@@ -41,6 +44,7 @@ export interface BuildAppOptions {
storeDiscovery?: StoreDiscoveryRouteOptions; storeDiscovery?: StoreDiscoveryRouteOptions;
storeAccess?: StoreAccessRouteOptions; storeAccess?: StoreAccessRouteOptions;
pricing?: PricingRouteOptions; pricing?: PricingRouteOptions;
orderState?: OrderStateRouteOptions;
} }
declare module 'fastify' { declare module 'fastify' {
@@ -107,6 +111,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.pricing) { if (options.pricing) {
await registerPricingRoutes(app, options.pricing); await registerPricingRoutes(app, options.pricing);
} }
if (options.orderState) {
await registerOrderStateRoutes(app, options.orderState);
}
return app; return app;
} }
+7 -3
View File
@@ -31,7 +31,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026061808_m03b_decoration_ads_media.up.sql', 'database/migrations/2026061808_m03b_decoration_ads_media.up.sql',
'database/migrations/2026061809_m03c_store_discovery.up.sql', 'database/migrations/2026061809_m03c_store_discovery.up.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.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: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', 'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -44,9 +45,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026061808_m03b_decoration_ads_media.verify.sql', 'database/migrations/2026061808_m03b_decoration_ads_media.verify.sql',
'database/migrations/2026061809_m03c_store_discovery.verify.sql', 'database/migrations/2026061809_m03c_store_discovery.verify.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.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: [ down: [
'database/migrations/2026062012_m04b_order_state_machine.down.sql',
'database/migrations/2026061811_m04a_pricing_reservations.down.sql', 'database/migrations/2026061811_m04a_pricing_reservations.down.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.down.sql', 'database/migrations/2026061810_m03d_scene_wifi_access.down.sql',
'database/migrations/2026061809_m03c_store_discovery.down.sql', 'database/migrations/2026061809_m03c_store_discovery.down.sql',
@@ -184,7 +187,8 @@ export async function executeMigrationPlan(
3, 3, 1, 3, 3, 1,
2, 2, 1, 2, 2, 1,
3, 3, 1, 3, 3, 1,
2, 1, 3, 3, 1 2, 1, 3, 3, 1,
2, 1, 3, 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,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<OrderAction, OrderStatus> = {
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<OrderStatus, readonly OrderAction[]> = {
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<ResultSetHeader>(
`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<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 OrderStateError('ORDER_ACCESS_FORBIDDEN');
}
const [rows] = await this.pool.execute<HistoryRow[]>(
`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<RowDataPacket[]>(
`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<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
orderId: string,
lock: boolean
) {
const [rows] = await connection.execute<OrderRow[]>(
`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<HistoryRow[]>(
`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<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();
}
}
}
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));
}
+23 -1
View File
@@ -97,6 +97,14 @@ export class PricingRepository {
VALUES (?, ?, ?)`, VALUES (?, ?, ?)`,
[input.tenantId, orderId, input.userId] [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 { return {
orderId, orderId,
orderNo, orderNo,
@@ -127,12 +135,26 @@ export class PricingRepository {
WHERE ${filters.join(' AND ')}`, WHERE ${filters.join(' AND ')}`,
params params
); );
await connection.execute<ResultSetHeader>(
`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<ResultSetHeader>( await connection.execute<ResultSetHeader>(
`UPDATE qipai_room_reservations r `UPDATE qipai_room_reservations r
INNER JOIN qipai_orders o INNER JOIN qipai_orders o
ON o.id = r.order_id AND o.tenant_id = r.tenant_id ON o.id = r.order_id AND o.tenant_id = r.tenant_id
SET r.status = 'RELEASED', r.released_at = UTC_TIMESTAMP(3), 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 ')}`, WHERE ${filters.join(' AND ')}`,
params params
); );
+118
View File
@@ -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<OrderStateRepository, 'transition' | 'history'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
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<unknown>) {
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
});
}
+7
View File
@@ -13,6 +13,7 @@ import { resolve } from 'node:path';
import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js'; import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js';
import { StoreAccessRepository } from './stores/access-repository.js'; import { StoreAccessRepository } from './stores/access-repository.js';
import { PricingRepository } from './orders/pricing-repository.js'; import { PricingRepository } from './orders/pricing-repository.js';
import { OrderStateRepository } from './orders/order-state-repository.js';
const config = loadConfig(); const config = loadConfig();
const pool = createMySqlPool(config); const pool = createMySqlPool(config);
@@ -63,6 +64,12 @@ const app = await buildApp({
authRepository, authRepository,
accessControl, accessControl,
jwtSecret: config.auth.jwtSecret jwtSecret: config.auth.jwtSecret
},
orderState: {
repository: new OrderStateRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
} }
}); });
app.addHook('onClose', async () => { app.addHook('onClose', async () => {
+2 -1
View File
@@ -137,7 +137,8 @@ export class StoreAccessRepository {
INNER JOIN qipai_orders o INNER JOIN qipai_orders o
ON o.id = a.order_id AND o.tenant_id = a.tenant_id AND o.deleted_at IS NULL 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 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`, AND UTC_TIMESTAMP(3) BETWEEN DATE_SUB(o.start_at, INTERVAL 30 MINUTE) AND o.end_at`,
[input.tenantId, input.userId, input.storeId] [input.tenantId, input.userId, input.storeId]
); );
+12 -1
View File
@@ -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 pricingUpSql = read('database/migrations/2026061811_m04a_pricing_reservations.up.sql');
const pricingDownSql = read('database/migrations/2026061811_m04a_pricing_reservations.down.sql'); const pricingDownSql = read('database/migrations/2026061811_m04a_pricing_reservations.down.sql');
const pricingVerifySql = read('database/migrations/2026061811_m04a_pricing_reservations.verify.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 = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -196,5 +199,13 @@ for (const table of [
assert.match(pricingUpSql, /full_day_price_cents/); assert.match(pricingUpSql, /full_day_price_cents/);
assert.match(pricingUpSql, /minimum_spend_cents/); assert.match(pricingUpSql, /minimum_spend_cents/);
assert.match(pricingUpSql, /idx_qipai_reservation_overlap/); 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.');
+2 -1
View File
@@ -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, /2026061808_m03b_decoration_ads_media\.up\.sql/);
assert.match(plan.file, /2026061809_m03c_store_discovery\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); assert.ok(plan.statements.length >= 11);
@@ -18,6 +18,9 @@ import { ContentRepository, ContentError } from '../dist/content/content-reposit
import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js'; import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js';
import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js'; import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js';
import { PricingRepository, PricingError } from '../dist/orders/pricing-repository.js'; import { PricingRepository, PricingError } from '../dist/orders/pricing-repository.js';
import {
OrderStateError, OrderStateRepository
} from '../dist/orders/order-state-repository.js';
import { import {
executeMigrationPlan, executeMigrationPlan,
loadMigrationPlan, loadMigrationPlan,
@@ -35,6 +38,7 @@ const expectedTables = [
'qipai_media_assets', 'qipai_media_assets',
'qipai_members', 'qipai_members',
'qipai_order_price_snapshots', 'qipai_order_price_snapshots',
'qipai_order_status_history',
'qipai_order_user_access', 'qipai_order_user_access',
'qipai_orders', 'qipai_orders',
'qipai_outbox_events', 'qipai_outbox_events',
@@ -81,11 +85,11 @@ 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'] '2026061810', '2026061811', '2026062012']
); );
return rows; return rows;
} }
@@ -627,6 +631,98 @@ async function assertPricingAndReservations(pool, context) {
assert.equal(replacement.quote.unitPriceCents, 9000); 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) { 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
@@ -725,7 +821,8 @@ try {
{ version: '2026061808', name: 'm03b_decoration_ads_media' }, { version: '2026061808', name: 'm03b_decoration_ads_media' },
{ version: '2026061809', name: 'm03c_store_discovery' }, { version: '2026061809', name: 'm03c_store_discovery' },
{ version: '2026061810', name: 'm03d_scene_wifi_access' }, { 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); await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool); const loginContext = await assertPlatformTenantIsolation(pool);
@@ -736,13 +833,14 @@ try {
await assertStoreDiscovery(pool, loginContext); await assertStoreDiscovery(pool, loginContext);
await assertSceneAndWifiAccess(pool, loginContext); await assertSceneAndWifiAccess(pool, loginContext);
await assertPricingAndReservations(pool, loginContext); await assertPricingAndReservations(pool, loginContext);
await assertOrderStateMachine(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-A tables.'); console.log('PASS: down removed all M01-B through M04-B tables.');
await executeMigrationPlan(pool, plans.up); await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify); await executeMigrationPlan(pool, plans.verify);
@@ -758,7 +856,8 @@ try {
{ version: '2026061808', name: 'm03b_decoration_ads_media' }, { version: '2026061808', name: 'm03b_decoration_ads_media' },
{ version: '2026061809', name: 'm03c_store_discovery' }, { version: '2026061809', name: 'm03c_store_discovery' },
{ version: '2026061810', name: 'm03d_scene_wifi_access' }, { 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); await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.'); console.log('PASS: second up and verify restored the schema.');
@@ -816,7 +915,11 @@ try {
'holiday and minimum-spend pricing', 'holiday and minimum-spend pricing',
'immutable order price snapshot', 'immutable order price snapshot',
'concurrent room hold conflict', '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)); }, null, 2));
} finally { } finally {
+75
View File
@@ -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.');
@@ -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;
@@ -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');
@@ -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';
@@ -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 的重试幂等返回第一次结果。
@@ -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` 基线历史。
状态迁移与房间预占、订单现场访问授权更新处于同一数据库事务。进入已支付/已预留/使用中时消费预占;取消、退款完成或关闭时释放预占并撤销现场权限。
+3
View File
@@ -49,6 +49,9 @@ $requiredFiles = @(
"database/migrations/2026061805_m02c_rbac.up.sql", "database/migrations/2026061805_m02c_rbac.up.sql",
"database/migrations/2026061805_m02c_rbac.down.sql", "database/migrations/2026061805_m02c_rbac.down.sql",
"database/migrations/2026061805_m02c_rbac.verify.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", "database/seeds/2026061601_m01b_minimal_seed.sql",
"deploy/pm2/ecosystem.config.cjs" "deploy/pm2/ecosystem.config.cjs"
) )
+2 -2
View File
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2 export QIPAI_MYSQL_CONNECTION_LIMIT=2
echo "INFO: MySQL ${mysql_version}; running M01-B through M04-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 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."