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
+11 -1
View File
@@ -48,6 +48,9 @@ const orderStateVerifySql = read('database/migrations/2026062012_m04b_order_stat
const adjustmentUpSql = read('database/migrations/2026062013_m04c_order_adjustments.up.sql');
const adjustmentDownSql = read('database/migrations/2026062013_m04c_order_adjustments.down.sql');
const adjustmentVerifySql = read('database/migrations/2026062013_m04c_order_adjustments.verify.sql');
const shareUpSql = read('database/migrations/2026062014_m04d_order_shares.up.sql');
const shareDownSql = read('database/migrations/2026062014_m04d_order_shares.down.sql');
const shareVerifySql = read('database/migrations/2026062014_m04d_order_shares.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -216,5 +219,12 @@ assert.match(adjustmentVerifySql, /'qipai_order_adjustments'/);
assert.match(adjustmentUpSql, /cancellation_cutoff_minutes/);
assert.match(adjustmentUpSql, /amount_delta_cents INT/);
assert.match(adjustmentUpSql, /UNIQUE KEY uq_qipai_order_adjustment_trace/);
assert.match(shareUpSql, /CREATE TABLE IF NOT EXISTS qipai_order_shares/);
assert.match(shareDownSql, /DROP TABLE IF EXISTS qipai_order_shares/);
assert.match(shareVerifySql, /'qipai_order_shares'/);
assert.match(shareUpSql, /token_hash CHAR\(64\)/);
assert.match(shareUpSql, /allow_open_door TINYINT/);
assert.match(shareUpSql, /allow_renew TINYINT/);
assert.match(shareUpSql, /UNIQUE KEY uq_qipai_order_share_token_hash/);
console.log('PASS: M01-B through M04-C migration contracts are present.');
console.log('PASS: M01-B through M04-D migration contracts are present.');
+2 -1
View File
@@ -24,7 +24,8 @@ 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, /2026062012_m04b_order_state_machine\.up\.sql/);
assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql$/);
assert.match(plan.file, /2026062013_m04c_order_adjustments\.up\.sql/);
assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -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 {
+85
View File
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
const secret = 'test-only-order-share-jwt-secret-32-chars';
const token = signAccessToken({
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
let createInput;
let resolveInput;
const shareToken = 'a'.repeat(43);
const app = await buildApp({
orderShare: {
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: 'CUSTOMER', status: 'ACTIVE',
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
}
};
}
},
accessControl: {
async getAccessProfile() {
return { roles: ['CUSTOMER'], capabilities: ['order.self.read'], storeIds: [] };
}
},
repository: {
async create(input) {
createInput = input;
return {
shareId: '41', token: shareToken,
permissions: input.permissions ?? ['VIEW_ROOM', 'OPEN_DOOR']
};
},
async revoke() { return { shareId: '41', revoked: true }; },
async resolve(tokenValue, permission) {
resolveInput = { tokenValue, permission };
return {
shareId: '41',
order: {
orderId: '31', orderNo: 'QP-SAFE', status: 'PAID',
startAt: new Date(), endAt: new Date()
},
grantedPermission: permission
};
}
}
}
});
const created = await app.inject({
method: 'POST',
url: '/app-api/orders/31/shares',
headers: { authorization: `Bearer ${token}` },
payload: {}
});
assert.equal(created.statusCode, 201);
assert.equal(createInput.orderId, '31');
assert.deepEqual(created.json().data.permissions, ['VIEW_ROOM', 'OPEN_DOOR']);
const resolved = await app.inject({
method: 'POST',
url: `/app-api/order-shares/${shareToken}/resolve`,
payload: { permission: 'OPEN_DOOR' }
});
assert.equal(resolved.statusCode, 200);
assert.deepEqual(resolveInput, { tokenValue: shareToken, permission: 'OPEN_DOOR' });
const response = JSON.stringify(resolved.json());
assert.doesNotMatch(response, /phone|balance|payment/i);
const invalidPermission = await app.inject({
method: 'POST',
url: `/app-api/order-shares/${shareToken}/resolve`,
payload: { permission: 'ADMIN' }
});
assert.equal(invalidPermission.statusCode, 400);
await app.close();
console.log('PASS: M04-D routes provide minimal share permissions without private fields.');