feat(M04-B): 完成订单状态机与迁移历史
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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` 基线历史。
|
||||||
|
|
||||||
|
状态迁移与房间预占、订单现场访问授权更新处于同一数据库事务。进入已支付/已预留/使用中时消费预占;取消、退款完成或关闭时释放预占并撤销现场权限。
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user