feat(M04-D): 完成最小权限订单分享

This commit is contained in:
Codex
2026-06-20 14:20:37 +08:00
parent 3c5cf071b9
commit 91801fe6a7
17 changed files with 694 additions and 14 deletions
@@ -24,6 +24,9 @@ import {
import {
OrderManagementError, OrderManagementRepository
} from '../dist/orders/order-management-repository.js';
import {
OrderShareError, OrderShareRepository
} from '../dist/orders/order-share-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -42,6 +45,7 @@ const expectedTables = [
'qipai_members',
'qipai_order_adjustments',
'qipai_order_price_snapshots',
'qipai_order_shares',
'qipai_order_status_history',
'qipai_order_user_access',
'qipai_orders',
@@ -89,11 +93,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', '2026062012', '2026062013']
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014']
);
return rows;
}
@@ -890,6 +894,133 @@ async function assertOrderAdjustments(pool, context) {
]);
}
async function assertOrderShares(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 [roomRows] = await pool.query(
`SELECT id FROM qipai_rooms
WHERE tenant_id = ? AND name = 'M04C Target Room' LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const customerId = String(customerRows[0].id);
const roomId = String(roomRows[0].id);
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
const startAt = new Date(Date.now() + 20 * 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 state = new OrderStateRepository(pool);
await state.transition({
tenantId: context.tenantId, userId: adminId, actorType: 'USER',
source: 'ADMIN', traceId: 'm04d-order-paid', ip: '127.0.0.1',
userAgent: 'M04-D live test', access
}, order.orderId, 'CONFIRM_PAYMENT');
const repository = new OrderShareRepository(pool);
const base = {
tenantId: context.tenantId, userId: customerId, orderId: order.orderId,
access: { roles: ['CUSTOMER'], capabilities: ['order.self.read'], storeIds: [] },
ip: '127.0.0.1', userAgent: 'M04-D live test'
};
const defaultShare = await repository.create({
...base, traceId: 'm04d-share-default'
});
assert.deepEqual(defaultShare.permissions.sort(), ['OPEN_DOOR', 'VIEW_ROOM']);
const [stored] = await pool.query(
`SELECT token_hash AS tokenHash, token_prefix AS tokenPrefix,
CAST(metadata AS CHAR) AS metadata
FROM qipai_order_shares s
LEFT JOIN qipai_audit_logs a
ON a.tenant_id = s.tenant_id AND a.resource_id = s.order_id
AND a.trace_id = 'm04d-share-default'
WHERE s.id = ?`,
[defaultShare.shareId]
);
assert.notEqual(stored[0].tokenHash, defaultShare.token);
assert.equal(stored[0].tokenHash.length, 64);
assert.equal(stored[0].tokenPrefix, defaultShare.token.slice(0, 10));
assert.doesNotMatch(stored[0].metadata, new RegExp(defaultShare.token));
const viewed = await repository.resolve(defaultShare.token, 'VIEW_ROOM', {
traceId: 'm04d-share-view', ip: '127.0.0.1', userAgent: 'M04-D recipient'
});
assert.equal(viewed.order.roomId, roomId);
assert.equal('phone' in viewed.order, false);
assert.equal('payment' in viewed.order, false);
await repository.resolve(defaultShare.token, 'OPEN_DOOR', {
traceId: 'm04d-share-door', ip: '127.0.0.1', userAgent: 'M04-D recipient'
});
await assert.rejects(
() => repository.resolve(defaultShare.token, 'RENEW', {
traceId: 'm04d-share-renew-denied', ip: '127.0.0.1', userAgent: 'M04-D recipient'
}),
(error) => error instanceof OrderShareError
&& error.code === 'ORDER_SHARE_PERMISSION_DENIED'
);
const renewShare = await repository.create({
...base, permissions: ['RENEW'], ttlMinutes: 5, traceId: 'm04d-share-renew'
});
const renewed = await repository.resolve(renewShare.token, 'RENEW', {
traceId: 'm04d-share-renew-used', ip: '127.0.0.1', userAgent: 'M04-D recipient'
});
assert.equal(renewed.grantedPermission, 'RENEW');
assert.equal(renewed.order.roomId, undefined);
await repository.revoke({
...base, shareId: renewShare.shareId, traceId: 'm04d-share-revoke'
});
await assert.rejects(
() => repository.resolve(renewShare.token, 'RENEW', {
traceId: 'm04d-share-revoked-use', ip: '127.0.0.1', userAgent: 'M04-D recipient'
}),
(error) => error instanceof OrderShareError && error.code === 'ORDER_SHARE_INVALID'
);
const expiredShare = await repository.create({
...base, traceId: 'm04d-share-expiry'
});
await pool.query(
`UPDATE qipai_order_shares
SET expires_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND) WHERE id = ?`,
[expiredShare.shareId]
);
await assert.rejects(
() => repository.resolve(expiredShare.token, 'VIEW_ROOM', {
traceId: 'm04d-share-expired-use', ip: '127.0.0.1', userAgent: 'M04-D recipient'
}),
(error) => error instanceof OrderShareError && error.code === 'ORDER_SHARE_INVALID'
);
const terminalShare = await repository.create({
...base, traceId: 'm04d-share-terminal'
});
await state.transition({
tenantId: context.tenantId, userId: adminId, actorType: 'USER',
source: 'ADMIN', traceId: 'm04d-order-cancel', ip: '127.0.0.1',
userAgent: 'M04-D live test', access
}, order.orderId, 'CANCEL');
await assert.rejects(
() => repository.resolve(terminalShare.token, 'OPEN_DOOR', {
traceId: 'm04d-share-terminal-use', ip: '127.0.0.1', userAgent: 'M04-D recipient'
}),
(error) => error instanceof OrderShareError && error.code === 'ORDER_SHARE_INACTIVE'
);
}
async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -990,7 +1121,8 @@ try {
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
{ version: '2026061811', name: 'm04a_pricing_reservations' },
{ version: '2026062012', name: 'm04b_order_state_machine' },
{ version: '2026062013', name: 'm04c_order_adjustments' }
{ version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1003,13 +1135,14 @@ try {
await assertPricingAndReservations(pool, loginContext);
await assertOrderStateMachine(pool, loginContext);
await assertOrderAdjustments(pool, loginContext);
await assertOrderShares(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-C tables.');
console.log('PASS: down removed all M01-B through M04-D tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -1027,7 +1160,8 @@ try {
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
{ version: '2026061811', name: 'm04a_pricing_reservations' },
{ version: '2026062012', name: 'm04b_order_state_machine' },
{ version: '2026062013', name: 'm04c_order_adjustments' }
{ version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -1095,7 +1229,13 @@ try {
'room-change conflict transaction rollback',
'room price difference and manager time adjustment',
'configured cancellation fee quote',
'store-scoped on-behalf booking rejection'
'store-scoped on-behalf booking rejection',
'share token stored as SHA-256 only',
'default view and door permissions',
'renew permission denied by default',
'explicit renew permission without room disclosure',
'share revocation and expiry',
'terminal order invalidates share'
]
}, null, 2));
} finally {