feat(M04-D): 完成最小权限订单分享
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"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",
|
"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: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": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
registerOrderManagementRoutes, type OrderManagementRouteOptions
|
registerOrderManagementRoutes, type OrderManagementRouteOptions
|
||||||
} from './routes/order-management.js';
|
} from './routes/order-management.js';
|
||||||
|
import { registerOrderShareRoutes, type OrderShareRouteOptions } from './routes/order-share.js';
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
config?: AppConfig;
|
config?: AppConfig;
|
||||||
@@ -49,6 +50,7 @@ export interface BuildAppOptions {
|
|||||||
pricing?: PricingRouteOptions;
|
pricing?: PricingRouteOptions;
|
||||||
orderState?: OrderStateRouteOptions;
|
orderState?: OrderStateRouteOptions;
|
||||||
orderManagement?: OrderManagementRouteOptions;
|
orderManagement?: OrderManagementRouteOptions;
|
||||||
|
orderShare?: OrderShareRouteOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -121,6 +123,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||||||
if (options.orderManagement) {
|
if (options.orderManagement) {
|
||||||
await registerOrderManagementRoutes(app, options.orderManagement);
|
await registerOrderManagementRoutes(app, options.orderManagement);
|
||||||
}
|
}
|
||||||
|
if (options.orderShare) {
|
||||||
|
await registerOrderShareRoutes(app, options.orderShare);
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026061810_m03d_scene_wifi_access.up.sql',
|
'database/migrations/2026061810_m03d_scene_wifi_access.up.sql',
|
||||||
'database/migrations/2026061811_m04a_pricing_reservations.up.sql',
|
'database/migrations/2026061811_m04a_pricing_reservations.up.sql',
|
||||||
'database/migrations/2026062012_m04b_order_state_machine.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: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'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/2026061810_m03d_scene_wifi_access.verify.sql',
|
||||||
'database/migrations/2026061811_m04a_pricing_reservations.verify.sql',
|
'database/migrations/2026061811_m04a_pricing_reservations.verify.sql',
|
||||||
'database/migrations/2026062012_m04b_order_state_machine.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: [
|
down: [
|
||||||
|
'database/migrations/2026062014_m04d_order_shares.down.sql',
|
||||||
'database/migrations/2026062013_m04c_order_adjustments.down.sql',
|
'database/migrations/2026062013_m04c_order_adjustments.down.sql',
|
||||||
'database/migrations/2026062012_m04b_order_state_machine.down.sql',
|
'database/migrations/2026062012_m04b_order_state_machine.down.sql',
|
||||||
'database/migrations/2026061811_m04a_pricing_reservations.down.sql',
|
'database/migrations/2026061811_m04a_pricing_reservations.down.sql',
|
||||||
@@ -192,7 +195,8 @@ export async function executeMigrationPlan(
|
|||||||
3, 3, 1,
|
3, 3, 1,
|
||||||
2, 1, 3, 3, 1,
|
2, 1, 3, 3, 1,
|
||||||
2, 1, 3, 1,
|
2, 1, 3, 1,
|
||||||
2, 2, 1, 2, 1
|
2, 2, 1, 2, 1,
|
||||||
|
1, 8, 3, 1
|
||||||
][index] ?? 1;
|
][index] ?? 1;
|
||||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
throw new Error(
|
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));
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { StoreAccessRepository } from './stores/access-repository.js';
|
|||||||
import { PricingRepository } from './orders/pricing-repository.js';
|
import { PricingRepository } from './orders/pricing-repository.js';
|
||||||
import { OrderStateRepository } from './orders/order-state-repository.js';
|
import { OrderStateRepository } from './orders/order-state-repository.js';
|
||||||
import { OrderManagementRepository } from './orders/order-management-repository.js';
|
import { OrderManagementRepository } from './orders/order-management-repository.js';
|
||||||
|
import { OrderShareRepository } from './orders/order-share-repository.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const pool = createMySqlPool(config);
|
const pool = createMySqlPool(config);
|
||||||
@@ -79,6 +80,12 @@ const app = await buildApp({
|
|||||||
authRepository,
|
authRepository,
|
||||||
accessControl,
|
accessControl,
|
||||||
jwtSecret: config.auth.jwtSecret
|
jwtSecret: config.auth.jwtSecret
|
||||||
|
},
|
||||||
|
orderShare: {
|
||||||
|
repository: new OrderShareRepository(pool),
|
||||||
|
authRepository,
|
||||||
|
accessControl,
|
||||||
|
jwtSecret: config.auth.jwtSecret
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
|||||||
@@ -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 adjustmentUpSql = read('database/migrations/2026062013_m04c_order_adjustments.up.sql');
|
||||||
const adjustmentDownSql = read('database/migrations/2026062013_m04c_order_adjustments.down.sql');
|
const adjustmentDownSql = read('database/migrations/2026062013_m04c_order_adjustments.down.sql');
|
||||||
const adjustmentVerifySql = read('database/migrations/2026062013_m04c_order_adjustments.verify.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 = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'qipai_schema_migrations',
|
||||||
@@ -216,5 +219,12 @@ assert.match(adjustmentVerifySql, /'qipai_order_adjustments'/);
|
|||||||
assert.match(adjustmentUpSql, /cancellation_cutoff_minutes/);
|
assert.match(adjustmentUpSql, /cancellation_cutoff_minutes/);
|
||||||
assert.match(adjustmentUpSql, /amount_delta_cents INT/);
|
assert.match(adjustmentUpSql, /amount_delta_cents INT/);
|
||||||
assert.match(adjustmentUpSql, /UNIQUE KEY uq_qipai_order_adjustment_trace/);
|
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, /2026061810_m03d_scene_wifi_access\.up\.sql/);
|
||||||
assert.match(plan.file, /2026061811_m04a_pricing_reservations\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
OrderManagementError, OrderManagementRepository
|
OrderManagementError, OrderManagementRepository
|
||||||
} from '../dist/orders/order-management-repository.js';
|
} from '../dist/orders/order-management-repository.js';
|
||||||
|
import {
|
||||||
|
OrderShareError, OrderShareRepository
|
||||||
|
} from '../dist/orders/order-share-repository.js';
|
||||||
import {
|
import {
|
||||||
executeMigrationPlan,
|
executeMigrationPlan,
|
||||||
loadMigrationPlan,
|
loadMigrationPlan,
|
||||||
@@ -42,6 +45,7 @@ const expectedTables = [
|
|||||||
'qipai_members',
|
'qipai_members',
|
||||||
'qipai_order_adjustments',
|
'qipai_order_adjustments',
|
||||||
'qipai_order_price_snapshots',
|
'qipai_order_price_snapshots',
|
||||||
|
'qipai_order_shares',
|
||||||
'qipai_order_status_history',
|
'qipai_order_status_history',
|
||||||
'qipai_order_user_access',
|
'qipai_order_user_access',
|
||||||
'qipai_orders',
|
'qipai_orders',
|
||||||
@@ -89,11 +93,11 @@ async function readMigrationVersions(pool) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT version, name
|
`SELECT version, name
|
||||||
FROM qipai_schema_migrations
|
FROM qipai_schema_migrations
|
||||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ORDER BY version`,
|
ORDER BY version`,
|
||||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||||
'2026061810', '2026061811', '2026062012', '2026062013']
|
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014']
|
||||||
);
|
);
|
||||||
return rows;
|
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) {
|
async function assertContentManagement(pool, context) {
|
||||||
const [adminRows] = await pool.query(
|
const [adminRows] = await pool.query(
|
||||||
`SELECT u.id FROM qipai_users u
|
`SELECT u.id FROM qipai_users u
|
||||||
@@ -990,7 +1121,8 @@ try {
|
|||||||
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
|
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
|
||||||
{ version: '2026061811', name: 'm04a_pricing_reservations' },
|
{ version: '2026061811', name: 'm04a_pricing_reservations' },
|
||||||
{ version: '2026062012', name: 'm04b_order_state_machine' },
|
{ 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);
|
await assertTaskDurability(pool);
|
||||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||||
@@ -1003,13 +1135,14 @@ try {
|
|||||||
await assertPricingAndReservations(pool, loginContext);
|
await assertPricingAndReservations(pool, loginContext);
|
||||||
await assertOrderStateMachine(pool, loginContext);
|
await assertOrderStateMachine(pool, loginContext);
|
||||||
await assertOrderAdjustments(pool, loginContext);
|
await assertOrderAdjustments(pool, loginContext);
|
||||||
|
await assertOrderShares(pool, loginContext);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
||||||
|
|
||||||
await executeMigrationPlan(pool, plans.down);
|
await executeMigrationPlan(pool, plans.down);
|
||||||
assert.deepEqual(await readCoreTables(pool), []);
|
assert.deepEqual(await readCoreTables(pool), []);
|
||||||
await assertLegacyCompatibility(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.up);
|
||||||
await executeMigrationPlan(pool, plans.verify);
|
await executeMigrationPlan(pool, plans.verify);
|
||||||
@@ -1027,7 +1160,8 @@ try {
|
|||||||
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
|
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
|
||||||
{ version: '2026061811', name: 'm04a_pricing_reservations' },
|
{ version: '2026061811', name: 'm04a_pricing_reservations' },
|
||||||
{ version: '2026062012', name: 'm04b_order_state_machine' },
|
{ 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);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: second up and verify restored the schema.');
|
console.log('PASS: second up and verify restored the schema.');
|
||||||
@@ -1095,7 +1229,13 @@ try {
|
|||||||
'room-change conflict transaction rollback',
|
'room-change conflict transaction rollback',
|
||||||
'room price difference and manager time adjustment',
|
'room price difference and manager time adjustment',
|
||||||
'configured cancellation fee quote',
|
'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));
|
}, null, 2));
|
||||||
} finally {
|
} 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.');
|
||||||
@@ -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 和短前缀,不保存明文令牌。
|
||||||
|
- 独立保存查看房间、开门和续费权限。
|
||||||
|
- 保存到期时间、撤销人、撤销时间、最后使用时间和使用次数。
|
||||||
|
- 令牌哈希全局唯一;订单分享查询和过期清理均有索引。
|
||||||
|
|
||||||
|
撤销、过期或订单进入终态后,分享令牌立即失效。创建、撤销和使用均记录审计,但审计不记录明文令牌。
|
||||||
@@ -55,6 +55,9 @@ $requiredFiles = @(
|
|||||||
"database/migrations/2026062013_m04c_order_adjustments.up.sql",
|
"database/migrations/2026062013_m04c_order_adjustments.up.sql",
|
||||||
"database/migrations/2026062013_m04c_order_adjustments.down.sql",
|
"database/migrations/2026062013_m04c_order_adjustments.down.sql",
|
||||||
"database/migrations/2026062013_m04c_order_adjustments.verify.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",
|
"database/seeds/2026061601_m01b_minimal_seed.sql",
|
||||||
"deploy/pm2/ecosystem.config.cjs"
|
"deploy/pm2/ecosystem.config.cjs"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
|
|||||||
export QIPAI_MYSQL_PASSWORD="${password}"
|
export QIPAI_MYSQL_PASSWORD="${password}"
|
||||||
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
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
|
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."
|
||||||
|
|||||||
Reference in New Issue
Block a user