From 91801fe6a76b62b18c9b4fa323e4350f5c0cea72 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 20 Jun 2026 14:20:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(M04-D):=20=E5=AE=8C=E6=88=90=E6=9C=80?= =?UTF-8?q?=E5=B0=8F=E6=9D=83=E9=99=90=E8=AE=A2=E5=8D=95=E5=88=86=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/app.ts | 5 + backend/src/db/migration-runner.ts | 10 +- backend/src/orders/order-share-repository.ts | 234 ++++++++++++++++++ backend/src/routes/order-share.ts | 120 +++++++++ backend/src/server.ts | 7 + backend/tests/migration-contract.test.mjs | 12 +- backend/tests/migration-runner.test.mjs | 3 +- .../tests/mysql-migration-roundtrip.test.mjs | 152 +++++++++++- backend/tests/order-share.test.mjs | 85 +++++++ .../2026062014_m04d_order_shares.down.sql | 2 + .../2026062014_m04d_order_shares.up.sql | 31 +++ .../2026062014_m04d_order_shares.verify.sql | 21 ++ .../2026-06-20-M04-D-order-shares.md | 7 + .../2026-06-20-M04-D-order-shares.md | 10 + scripts/dev/windows/check-backend.ps1 | 3 + scripts/dev/wsl/mysql-migration-roundtrip.sh | 4 +- 17 files changed, 694 insertions(+), 14 deletions(-) create mode 100644 backend/src/orders/order-share-repository.ts create mode 100644 backend/src/routes/order-share.ts create mode 100644 backend/tests/order-share.test.mjs create mode 100644 database/migrations/2026062014_m04d_order_shares.down.sql create mode 100644 database/migrations/2026062014_m04d_order_shares.up.sql create mode 100644 database/migrations/2026062014_m04d_order_shares.verify.sql create mode 100644 docs/api-changelog/2026-06-20-M04-D-order-shares.md create mode 100644 docs/db-changelog/2026-06-20-M04-D-order-shares.md diff --git a/backend/package.json b/backend/package.json index b289c08..f17496d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.ts b/backend/src/app.ts index 6a13627..3e6e13f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 = { '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 = { '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( diff --git a/backend/src/orders/order-share-repository.ts b/backend/src/orders/order-share-repository.ts new file mode 100644 index 0000000..c46869f --- /dev/null +++ b/backend/src/orders/order-share-repository.ts @@ -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( + `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( + `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( + `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( + `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( + `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(work: (connection: PoolConnection) => Promise) { + 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)); +} diff --git a/backend/src/routes/order-share.ts b/backend/src/routes/order-share.ts new file mode 100644 index 0000000..41efe84 --- /dev/null +++ b/backend/src/routes/order-share.ts @@ -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; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + 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) { + 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 + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 0bd7575..38f25b4 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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 () => { diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 791b9f8..c40a83a 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -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.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index e8a23fd..1cf1cb3 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -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); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index 342478a..9e0af44 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -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 { diff --git a/backend/tests/order-share.test.mjs b/backend/tests/order-share.test.mjs new file mode 100644 index 0000000..faebc0c --- /dev/null +++ b/backend/tests/order-share.test.mjs @@ -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.'); diff --git a/database/migrations/2026062014_m04d_order_shares.down.sql b/database/migrations/2026062014_m04d_order_shares.down.sql new file mode 100644 index 0000000..a08007c --- /dev/null +++ b/database/migrations/2026062014_m04d_order_shares.down.sql @@ -0,0 +1,2 @@ +DELETE FROM qipai_schema_migrations WHERE version = '2026062014'; +DROP TABLE IF EXISTS qipai_order_shares; diff --git a/database/migrations/2026062014_m04d_order_shares.up.sql b/database/migrations/2026062014_m04d_order_shares.up.sql new file mode 100644 index 0000000..2618426 --- /dev/null +++ b/database/migrations/2026062014_m04d_order_shares.up.sql @@ -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'); diff --git a/database/migrations/2026062014_m04d_order_shares.verify.sql b/database/migrations/2026062014_m04d_order_shares.verify.sql new file mode 100644 index 0000000..3fbbfa3 --- /dev/null +++ b/database/migrations/2026062014_m04d_order_shares.verify.sql @@ -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'; diff --git a/docs/api-changelog/2026-06-20-M04-D-order-shares.md b/docs/api-changelog/2026-06-20-M04-D-order-shares.md new file mode 100644 index 0000000..263058c --- /dev/null +++ b/docs/api-changelog/2026-06-20-M04-D-order-shares.md @@ -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 小时。响应不包含手机号、余额或支付信息;续费权限解析不返回房间信息。 diff --git a/docs/db-changelog/2026-06-20-M04-D-order-shares.md b/docs/db-changelog/2026-06-20-M04-D-order-shares.md new file mode 100644 index 0000000..df9e382 --- /dev/null +++ b/docs/db-changelog/2026-06-20-M04-D-order-shares.md @@ -0,0 +1,10 @@ +# M04-D 订单分享数据库变更 + +- 迁移版本:`2026062014` +- 新增 `qipai_order_shares`。 +- 数据库仅保存令牌 SHA-256 和短前缀,不保存明文令牌。 +- 独立保存查看房间、开门和续费权限。 +- 保存到期时间、撤销人、撤销时间、最后使用时间和使用次数。 +- 令牌哈希全局唯一;订单分享查询和过期清理均有索引。 + +撤销、过期或订单进入终态后,分享令牌立即失效。创建、撤销和使用均记录审计,但审计不记录明文令牌。 diff --git a/scripts/dev/windows/check-backend.ps1 b/scripts/dev/windows/check-backend.ps1 index 91a5a19..6bb21c7 100644 --- a/scripts/dev/windows/check-backend.ps1 +++ b/scripts/dev/windows/check-backend.ps1 @@ -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" ) diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index 2c2ce73..053d702 100644 --- a/scripts/dev/wsl/mysql-migration-roundtrip.sh +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -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."