feat(M04-B): 完成订单状态机与迁移历史
This commit is contained in:
@@ -42,6 +42,9 @@ const accessVerifySql = read('database/migrations/2026061810_m03d_scene_wifi_acc
|
||||
const pricingUpSql = read('database/migrations/2026061811_m04a_pricing_reservations.up.sql');
|
||||
const pricingDownSql = read('database/migrations/2026061811_m04a_pricing_reservations.down.sql');
|
||||
const pricingVerifySql = read('database/migrations/2026061811_m04a_pricing_reservations.verify.sql');
|
||||
const orderStateUpSql = read('database/migrations/2026062012_m04b_order_state_machine.up.sql');
|
||||
const orderStateDownSql = read('database/migrations/2026062012_m04b_order_state_machine.down.sql');
|
||||
const orderStateVerifySql = read('database/migrations/2026062012_m04b_order_state_machine.verify.sql');
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -196,5 +199,13 @@ for (const table of [
|
||||
assert.match(pricingUpSql, /full_day_price_cents/);
|
||||
assert.match(pricingUpSql, /minimum_spend_cents/);
|
||||
assert.match(pricingUpSql, /idx_qipai_reservation_overlap/);
|
||||
assert.match(orderStateUpSql, /CREATE TABLE IF NOT EXISTS qipai_order_status_history/);
|
||||
assert.match(orderStateDownSql, /DROP TABLE IF EXISTS qipai_order_status_history/);
|
||||
assert.match(orderStateVerifySql, /'qipai_order_status_history'/);
|
||||
assert.match(orderStateUpSql, /status_version INT UNSIGNED/);
|
||||
assert.match(orderStateUpSql, /UNIQUE KEY uq_qipai_order_history_trace/);
|
||||
assert.match(orderStateUpSql, /actor_type VARCHAR/);
|
||||
assert.match(orderStateUpSql, /source VARCHAR/);
|
||||
assert.match(orderStateUpSql, /trace_id VARCHAR/);
|
||||
|
||||
console.log('PASS: M01-B through M04-A migration contracts are present.');
|
||||
console.log('PASS: M01-B through M04-B migration contracts are present.');
|
||||
|
||||
@@ -22,7 +22,8 @@ assert.match(plan.file, /2026061807_m03a_store_room_domain\.up\.sql/);
|
||||
assert.match(plan.file, /2026061808_m03b_decoration_ads_media\.up\.sql/);
|
||||
assert.match(plan.file, /2026061809_m03c_store_discovery\.up\.sql/);
|
||||
assert.match(plan.file, /2026061810_m03d_scene_wifi_access\.up\.sql/);
|
||||
assert.match(plan.file, /2026061811_m04a_pricing_reservations\.up\.sql$/);
|
||||
assert.match(plan.file, /2026061811_m04a_pricing_reservations\.up\.sql/);
|
||||
assert.match(plan.file, /2026062012_m04b_order_state_machine\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
Reference in New Issue
Block a user