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
@@ -18,6 +18,9 @@ import { ContentRepository, ContentError } from '../dist/content/content-reposit
import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js';
import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js';
import { PricingRepository, PricingError } from '../dist/orders/pricing-repository.js';
import {
OrderStateError, OrderStateRepository
} from '../dist/orders/order-state-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -35,6 +38,7 @@ const expectedTables = [
'qipai_media_assets',
'qipai_members',
'qipai_order_price_snapshots',
'qipai_order_status_history',
'qipai_order_user_access',
'qipai_orders',
'qipai_outbox_events',
@@ -81,11 +85,11 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811']
'2026061810', '2026061811', '2026062012']
);
return rows;
}
@@ -627,6 +631,98 @@ async function assertPricingAndReservations(pool, context) {
assert.equal(replacement.quote.unitPriceCents, 9000);
}
async function assertOrderStateMachine(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`,
[context.tenantId]
);
const [customerRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_identities i
ON i.tenant_id = u.tenant_id AND i.user_id = u.id
WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`,
[context.tenantId]
);
const [targetRows] = await pool.query(
`SELECT r.id AS roomId
FROM qipai_rooms r
INNER JOIN qipai_stores s ON s.id = r.store_id AND s.tenant_id = r.tenant_id
WHERE r.tenant_id = ? AND s.name = 'M03A Store' LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const customerId = String(customerRows[0].id);
const roomId = String(targetRows[0].roomId);
const startAt = new Date(Date.now() + 15 * 86400000);
startAt.setUTCHours(2, 0, 0, 0);
const endAt = new Date(startAt.getTime() + 2 * 3600000);
const order = await new PricingRepository(pool).reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt, endAt, pricingMode: 'HOURLY'
});
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
const repository = new OrderStateRepository(pool);
const actor = (traceId) => ({
tenantId: context.tenantId, userId: adminId, actorType: 'USER',
source: 'ADMIN', traceId, ip: '127.0.0.1',
userAgent: 'M04-B live state test', access
});
const paid = await repository.transition(
actor('m04b-paid'), order.orderId, 'CONFIRM_PAYMENT', 'payment accepted'
);
assert.equal(paid.status, 'PAID');
assert.equal(paid.statusVersion, 2);
const duplicate = await repository.transition(
actor('m04b-paid'), order.orderId, 'CONFIRM_PAYMENT', 'duplicate callback'
);
assert.equal(duplicate.idempotent, true);
await repository.transition(actor('m04b-reserved'), order.orderId, 'RESERVE');
await repository.transition(actor('m04b-start'), order.orderId, 'START');
await repository.transition(actor('m04b-finish'), order.orderId, 'FINISH');
const closed = await repository.transition(actor('m04b-close'), order.orderId, 'CLOSE');
assert.equal(closed.status, 'CLOSED');
assert.equal(closed.statusVersion, 6);
await assert.rejects(
() => repository.transition(actor('m04b-invalid'), order.orderId, 'START'),
(error) => error instanceof OrderStateError
&& error.code === 'ORDER_TRANSITION_NOT_ALLOWED'
);
const history = await repository.history(context.tenantId, customerId, order.orderId, {
roles: ['CUSTOMER'], capabilities: ['order.self.read'], storeIds: []
});
assert.deepEqual(history.map((item) => item.toStatus), [
'PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED', 'CLOSED'
]);
const [stateRows] = await pool.query(
`SELECT o.status, o.status_version AS statusVersion,
r.status AS reservationStatus, a.revoked_at AS revokedAt
FROM qipai_orders o
INNER JOIN qipai_room_reservations r
ON r.tenant_id = o.tenant_id AND r.order_id = o.id
INNER JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.user_id = ?
WHERE o.tenant_id = ? AND o.id = ?`,
[customerId, context.tenantId, order.orderId]
);
assert.equal(stateRows[0].status, 'CLOSED');
assert.equal(stateRows[0].statusVersion, 6);
assert.equal(stateRows[0].reservationStatus, 'RELEASED');
assert.ok(stateRows[0].revokedAt);
const [auditRows] = await pool.query(
`SELECT action FROM qipai_audit_logs
WHERE tenant_id = ? AND trace_id LIKE 'm04b-%' ORDER BY id`,
[context.tenantId]
);
assert.deepEqual(auditRows.map((row) => row.action), [
'ORDER_STATUS_CHANGED', 'ORDER_STATUS_CHANGED', 'ORDER_STATUS_CHANGED',
'ORDER_STATUS_CHANGED', 'ORDER_STATUS_CHANGED'
]);
}
async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -725,7 +821,8 @@ try {
{ version: '2026061808', name: 'm03b_decoration_ads_media' },
{ version: '2026061809', name: 'm03c_store_discovery' },
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
{ version: '2026061811', name: 'm04a_pricing_reservations' }
{ version: '2026061811', name: 'm04a_pricing_reservations' },
{ version: '2026062012', name: 'm04b_order_state_machine' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -736,13 +833,14 @@ try {
await assertStoreDiscovery(pool, loginContext);
await assertSceneAndWifiAccess(pool, loginContext);
await assertPricingAndReservations(pool, loginContext);
await assertOrderStateMachine(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M04-A tables.');
console.log('PASS: down removed all M01-B through M04-B tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -758,7 +856,8 @@ try {
{ version: '2026061808', name: 'm03b_decoration_ads_media' },
{ version: '2026061809', name: 'm03c_store_discovery' },
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
{ version: '2026061811', name: 'm04a_pricing_reservations' }
{ version: '2026061811', name: 'm04a_pricing_reservations' },
{ version: '2026062012', name: 'm04b_order_state_machine' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -816,7 +915,11 @@ try {
'holiday and minimum-spend pricing',
'immutable order price snapshot',
'concurrent room hold conflict',
'expired hold release'
'expired hold release',
'controlled order actions',
'idempotent transition trace',
'complete order status history',
'reservation and access release on close'
]
}, null, 2));
} finally {