feat(M04-D): 完成最小权限订单分享
This commit is contained in:
@@ -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.');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
Reference in New Issue
Block a user