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
+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 () => {