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