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
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs"
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+5
View File
@@ -36,6 +36,7 @@ import {
import {
registerOrderManagementRoutes, type OrderManagementRouteOptions
} from './routes/order-management.js';
import { registerOrderShareRoutes, type OrderShareRouteOptions } from './routes/order-share.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -49,6 +50,7 @@ export interface BuildAppOptions {
pricing?: PricingRouteOptions;
orderState?: OrderStateRouteOptions;
orderManagement?: OrderManagementRouteOptions;
orderShare?: OrderShareRouteOptions;
}
declare module 'fastify' {
@@ -121,6 +123,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.orderManagement) {
await registerOrderManagementRoutes(app, options.orderManagement);
}
if (options.orderShare) {
await registerOrderShareRoutes(app, options.orderShare);
}
return app;
}
+7 -3
View File
@@ -33,7 +33,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026061810_m03d_scene_wifi_access.up.sql',
'database/migrations/2026061811_m04a_pricing_reservations.up.sql',
'database/migrations/2026062012_m04b_order_state_machine.up.sql',
'database/migrations/2026062013_m04c_order_adjustments.up.sql'
'database/migrations/2026062013_m04c_order_adjustments.up.sql',
'database/migrations/2026062014_m04d_order_shares.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -48,9 +49,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql',
'database/migrations/2026061811_m04a_pricing_reservations.verify.sql',
'database/migrations/2026062012_m04b_order_state_machine.verify.sql',
'database/migrations/2026062013_m04c_order_adjustments.verify.sql'
'database/migrations/2026062013_m04c_order_adjustments.verify.sql',
'database/migrations/2026062014_m04d_order_shares.verify.sql'
],
down: [
'database/migrations/2026062014_m04d_order_shares.down.sql',
'database/migrations/2026062013_m04c_order_adjustments.down.sql',
'database/migrations/2026062012_m04b_order_state_machine.down.sql',
'database/migrations/2026061811_m04a_pricing_reservations.down.sql',
@@ -192,7 +195,8 @@ export async function executeMigrationPlan(
3, 3, 1,
2, 1, 3, 3, 1,
2, 1, 3, 1,
2, 2, 1, 2, 1
2, 2, 1, 2, 1,
1, 8, 3, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
@@ -0,0 +1,234 @@
import { createHash, randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { MySqlPool } from '../db/mysql.js';
export type SharePermission = 'VIEW_ROOM' | 'OPEN_DOOR' | 'RENEW';
interface OrderRow extends RowDataPacket {
id: string;
orderNo: string;
storeId: string;
roomId: string;
status: string;
startAt: Date;
endAt: Date;
}
interface ShareRow extends RowDataPacket {
id: string;
tenantId: string;
orderId: string;
orderNo: string;
storeId: string;
roomId: string;
status: string;
startAt: Date;
endAt: Date;
allowViewRoom: number;
allowOpenDoor: number;
allowRenew: number;
expiresAt: Date;
revokedAt: Date | null;
}
export class OrderShareError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class OrderShareRepository {
constructor(private readonly pool: MySqlPool) {}
async create(input: {
tenantId: string; userId: string; orderId: string; access: AccessProfile;
permissions?: SharePermission[]; ttlMinutes?: number;
traceId: string; ip: string; userAgent: string;
}) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, input.tenantId, input.orderId, true);
await this.assertOwnerOrManager(
connection, input.tenantId, input.userId, order, input.access
);
if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
throw new OrderShareError('ORDER_SHARE_STATUS_INVALID');
}
const permissions = new Set(input.permissions ?? ['VIEW_ROOM', 'OPEN_DOOR']);
if (permissions.size === 0) throw new OrderShareError('ORDER_SHARE_PERMISSION_REQUIRED');
const ttlMinutes = Math.min(Math.max(input.ttlMinutes ?? 30, 5), 1440);
const token = randomBytes(32).toString('base64url');
const tokenHash = hashToken(token);
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_order_shares
(tenant_id, order_id, token_hash, token_prefix, allow_view_room,
allow_open_door, allow_renew, expires_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?,
DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE), ?)`,
[input.tenantId, input.orderId, tokenHash, token.slice(0, 10),
permissions.has('VIEW_ROOM'), permissions.has('OPEN_DOOR'),
permissions.has('RENEW'), ttlMinutes, input.userId]
);
await this.audit(connection, input, 'ORDER_SHARE_CREATED', input.orderId, {
shareId: String(result.insertId),
permissions: [...permissions],
ttlMinutes
});
return {
shareId: String(result.insertId),
token,
expiresInMinutes: ttlMinutes,
permissions: [...permissions]
};
});
}
async revoke(input: {
tenantId: string; userId: string; orderId: string; shareId: string;
access: AccessProfile; traceId: string; ip: string; userAgent: string;
}) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, input.tenantId, input.orderId, true);
await this.assertOwnerOrManager(
connection, input.tenantId, input.userId, order, input.access
);
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_order_shares
SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP(3)),
revoked_by = COALESCE(revoked_by, ?)
WHERE tenant_id = ? AND order_id = ? AND id = ?`,
[input.userId, input.tenantId, input.orderId, input.shareId]
);
if (result.affectedRows !== 1) throw new OrderShareError('ORDER_SHARE_NOT_FOUND');
await this.audit(connection, input, 'ORDER_SHARE_REVOKED', input.orderId, {
shareId: input.shareId
});
return { shareId: input.shareId, revoked: true };
});
}
async resolve(token: string, permission: SharePermission, context: {
traceId: string; ip: string; userAgent: string;
}) {
if (!/^[A-Za-z0-9_-]{40,64}$/.test(token)) {
throw new OrderShareError('ORDER_SHARE_INVALID');
}
return this.transaction(async (connection) => {
const [rows] = await connection.execute<ShareRow[]>(
`SELECT s.id, s.tenant_id AS tenantId, s.order_id AS orderId,
o.order_no AS orderNo, o.store_id AS storeId, o.room_id AS roomId,
o.status, o.start_at AS startAt, o.end_at AS endAt,
s.allow_view_room AS allowViewRoom,
s.allow_open_door AS allowOpenDoor, s.allow_renew AS allowRenew,
s.expires_at AS expiresAt, s.revoked_at AS revokedAt
FROM qipai_order_shares s
INNER JOIN qipai_orders o
ON o.tenant_id = s.tenant_id AND o.id = s.order_id AND o.deleted_at IS NULL
WHERE s.token_hash = ? LIMIT 1 FOR UPDATE`,
[hashToken(token)]
);
const share = rows[0];
if (!share || share.revokedAt || share.expiresAt <= new Date()) {
throw new OrderShareError('ORDER_SHARE_INVALID');
}
if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(share.status)) {
throw new OrderShareError('ORDER_SHARE_INACTIVE');
}
const allowed = permission === 'VIEW_ROOM' ? Boolean(share.allowViewRoom)
: permission === 'OPEN_DOOR' ? Boolean(share.allowOpenDoor)
: Boolean(share.allowRenew);
if (!allowed) throw new OrderShareError('ORDER_SHARE_PERMISSION_DENIED');
await connection.execute(
`UPDATE qipai_order_shares
SET last_used_at = UTC_TIMESTAMP(3), use_count = use_count + 1 WHERE id = ?`,
[share.id]
);
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'SHARE_TOKEN', NULL, 'ORDER_SHARE_USED', 'ORDER', ?, ?, ?, ?,
JSON_OBJECT('shareId', ?, 'permission', ?))`,
[share.tenantId, share.orderId, context.traceId, context.ip,
context.userAgent.slice(0, 255), share.id, permission]
);
return {
shareId: String(share.id),
order: {
orderId: String(share.orderId),
orderNo: share.orderNo,
status: share.status,
startAt: share.startAt,
endAt: share.endAt,
storeId: permission === 'VIEW_ROOM' ? String(share.storeId) : undefined,
roomId: permission === 'VIEW_ROOM' ? String(share.roomId) : undefined
},
grantedPermission: permission
};
});
}
private async loadOrder(
connection: PoolConnection, tenantId: string, orderId: string, lock: boolean
) {
const [rows] = await connection.execute<OrderRow[]>(
`SELECT id, order_no AS orderNo, store_id AS storeId, room_id AS roomId,
status, start_at AS startAt, end_at AS endAt
FROM qipai_orders WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
${lock ? 'FOR UPDATE' : ''}`,
[tenantId, orderId]
);
if (!rows[0]) throw new OrderShareError('ORDER_NOT_FOUND');
return rows[0];
}
private async assertOwnerOrManager(
connection: PoolConnection, tenantId: string, userId: string,
order: OrderRow, access: AccessProfile
) {
if (canManageStore(access, order.storeId)) return;
const [rows] = await connection.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`,
[tenantId, order.id, userId]
);
if (!rows[0]) throw new OrderShareError('ORDER_ACCESS_FORBIDDEN');
}
private async audit(
connection: PoolConnection,
input: { tenantId: string; userId: string; traceId: string; ip: string; userAgent: string },
action: string, orderId: string, metadata: object
) {
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'USER', ?, ?, 'ORDER', ?, ?, ?, ?, ?)`,
[input.tenantId, input.userId, action, orderId, input.traceId,
input.ip, input.userAgent.slice(0, 255), JSON.stringify(metadata)]
);
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
function hashToken(token: string) {
return createHash('sha256').update(token).digest('hex');
}
function canManageStore(access: AccessProfile, storeId: string) {
return access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN')
|| (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId));
}
+120
View File
@@ -0,0 +1,120 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import {
OrderShareError, type OrderShareRepository
} from '../orders/order-share-repository.js';
const orderParams = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) });
const revokeParams = orderParams.extend({ shareId: z.string().regex(/^[1-9]\d{0,19}$/) });
const tokenParams = z.object({ token: z.string().min(40).max(64) });
const createSchema = z.object({
permissions: z.array(z.enum(['VIEW_ROOM', 'OPEN_DOOR', 'RENEW'])).min(1).max(3)
.optional(),
ttlMinutes: z.number().int().min(5).max(1440).optional()
}).strict();
const resolveSchema = z.object({
permission: z.enum(['VIEW_ROOM', 'OPEN_DOOR', 'RENEW'])
}).strict();
export interface OrderShareRouteOptions {
repository: Pick<OrderShareRepository, 'create' | 'revoke' | 'resolve'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerOrderShareRoutes(
app: FastifyInstance, options: OrderShareRouteOptions
) {
app.post('/app-api/orders/:orderId/shares', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = orderParams.safeParse(request.params);
const body = createSchema.safeParse(request.body ?? {});
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.create({
tenantId: auth.tenantId, userId: auth.userId, orderId: params.data.orderId,
access: auth.access, permissions: body.data.permissions,
ttlMinutes: body.data.ttlMinutes, traceId: request.traceId,
ip: request.ip, userAgent: request.headers['user-agent'] ?? ''
}),
traceId: request.traceId
}));
});
app.delete('/app-api/orders/:orderId/shares/:shareId', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = revokeParams.safeParse(request.params);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.revoke({
tenantId: auth.tenantId, userId: auth.userId,
orderId: params.data.orderId, shareId: params.data.shareId,
access: auth.access, traceId: request.traceId, ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
}),
traceId: request.traceId
}));
});
app.post('/app-api/order-shares/:token/resolve', async (request, reply) => {
const params = tokenParams.safeParse(request.params);
const body = resolveSchema.safeParse(request.body);
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.resolve(params.data.token, body.data.permission, {
traceId: request.traceId, ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
}),
traceId: request.traceId
}));
});
}
async function authenticate(
authorization: string | undefined, options: OrderShareRouteOptions
) {
const result = await authenticateAccessToken(
authorization, options.authRepository, options.jwtSecret
);
if (!result) return null;
const tenantId = result.session.tenantId;
const userId = result.session.user.id;
return {
tenantId, userId,
access: await options.accessControl.getAccessProfile(tenantId, userId)
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof OrderShareError)) throw error;
const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'ORDER_SHARE_NOT_FOUND'
? 404 : error.code === 'ORDER_SHARE_PERMISSION_DENIED' ? 403 : 400;
return reply.status(status).send({
code: error.code, message: 'The order share is not available.', traceId
});
}
}
function unauthorized(reply: FastifyReply, traceId: string) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId
});
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_ORDER_SHARE_REQUEST', message: 'The share request is invalid.', traceId
});
}
+7
View File
@@ -15,6 +15,7 @@ import { StoreAccessRepository } from './stores/access-repository.js';
import { PricingRepository } from './orders/pricing-repository.js';
import { OrderStateRepository } from './orders/order-state-repository.js';
import { OrderManagementRepository } from './orders/order-management-repository.js';
import { OrderShareRepository } from './orders/order-share-repository.js';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -79,6 +80,12 @@ const app = await buildApp({
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
orderShare: {
repository: new OrderShareRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
}
});
app.addHook('onClose', async () => {
+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.');
@@ -0,0 +1,2 @@
DELETE FROM qipai_schema_migrations WHERE version = '2026062014';
DROP TABLE IF EXISTS qipai_order_shares;
@@ -0,0 +1,31 @@
CREATE TABLE IF NOT EXISTS qipai_order_shares (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL,
token_prefix VARCHAR(12) NOT NULL,
allow_view_room TINYINT(1) NOT NULL DEFAULT 1,
allow_open_door TINYINT(1) NOT NULL DEFAULT 1,
allow_renew TINYINT(1) NOT NULL DEFAULT 0,
expires_at DATETIME(3) NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
revoked_at DATETIME(3) NULL,
revoked_by BIGINT UNSIGNED NULL,
last_used_at DATETIME(3) NULL,
use_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_order_share_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_order_share_order
FOREIGN KEY (order_id) REFERENCES qipai_orders(id),
CONSTRAINT fk_qipai_order_share_creator
FOREIGN KEY (created_by) REFERENCES qipai_users(id),
CONSTRAINT fk_qipai_order_share_revoker
FOREIGN KEY (revoked_by) REFERENCES qipai_users(id),
UNIQUE KEY uq_qipai_order_share_token_hash (token_hash),
KEY idx_qipai_order_share_order (tenant_id, order_id, revoked_at, expires_at),
KEY idx_qipai_order_share_expiry (expires_at, revoked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026062014', 'm04d_order_shares');
@@ -0,0 +1,21 @@
SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'qipai_order_shares';
SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'qipai_order_shares'
AND column_name IN (
'token_hash', 'allow_view_room', 'allow_open_door', 'allow_renew',
'expires_at', 'revoked_at', 'last_used_at', 'use_count'
)
ORDER BY column_name;
SELECT index_name FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'qipai_order_shares'
AND index_name IN (
'uq_qipai_order_share_token_hash',
'idx_qipai_order_share_order',
'idx_qipai_order_share_expiry'
)
GROUP BY index_name ORDER BY index_name;
SELECT version, name FROM qipai_schema_migrations WHERE version = '2026062014';
@@ -0,0 +1,7 @@
# M04-D 订单分享 API
- `POST /app-api/orders/:orderId/shares`:订单所有者或有门店管理权限的人员创建短期分享令牌。
- `DELETE /app-api/orders/:orderId/shares/:shareId`:撤销分享。
- `POST /app-api/order-shares/:token/resolve`:按 `VIEW_ROOM``OPEN_DOOR``RENEW` 单项权限解析。
默认权限为查看房间和开门,不包含续费。令牌默认有效 30 分钟,最短 5 分钟、最长 24 小时。响应不包含手机号、余额或支付信息;续费权限解析不返回房间信息。
@@ -0,0 +1,10 @@
# M04-D 订单分享数据库变更
- 迁移版本:`2026062014`
- 新增 `qipai_order_shares`
- 数据库仅保存令牌 SHA-256 和短前缀,不保存明文令牌。
- 独立保存查看房间、开门和续费权限。
- 保存到期时间、撤销人、撤销时间、最后使用时间和使用次数。
- 令牌哈希全局唯一;订单分享查询和过期清理均有索引。
撤销、过期或订单进入终态后,分享令牌立即失效。创建、撤销和使用均记录审计,但审计不记录明文令牌。
+3
View File
@@ -55,6 +55,9 @@ $requiredFiles = @(
"database/migrations/2026062013_m04c_order_adjustments.up.sql",
"database/migrations/2026062013_m04c_order_adjustments.down.sql",
"database/migrations/2026062013_m04c_order_adjustments.verify.sql",
"database/migrations/2026062014_m04d_order_shares.up.sql",
"database/migrations/2026062014_m04d_order_shares.down.sql",
"database/migrations/2026062014_m04d_order_shares.verify.sql",
"database/seeds/2026061601_m01b_minimal_seed.sql",
"deploy/pm2/ecosystem.config.cjs"
)
+2 -2
View File
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2
echo "INFO: MySQL ${mysql_version}; running M01-B through M04-C migration roundtrip in a temporary database."
echo "INFO: MySQL ${mysql_version}; running M01-B through M04-D migration roundtrip in a temporary database."
npm --prefix backend run test:mysql:migration
echo "PASS: M01-B through M04-C live MySQL migration roundtrip completed."
echo "PASS: M01-B through M04-D live MySQL migration roundtrip completed."