feat(M08-C): 建立管理员房态运营入口

This commit is contained in:
Codex
2026-08-10 10:06:12 +08:00
parent 1160f16e8d
commit b70ada06a8
24 changed files with 699 additions and 15 deletions
+2
View File
@@ -140,6 +140,8 @@ export class AuthRepository {
SELECT ?, r.id, p.id FROM qipai_roles r
INNER JOIN qipai_permissions p ON
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'STAFF'
AND p.code IN ('profile.read', 'store.operation.read'))
OR (r.code = 'STORE_ADMIN'
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write',
+2
View File
@@ -36,6 +36,8 @@ export class RbacRepository {
SELECT ?, r.id, p.id FROM qipai_roles r
INNER JOIN qipai_permissions p ON
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'STAFF'
AND p.code IN ('profile.read', 'store.operation.read'))
OR (r.code = 'CLEANER'
AND p.code IN ('profile.read', 'cleaning.task.read',
'cleaning.task.write', 'cleaning.statistics.read'))
+6 -2
View File
@@ -49,7 +49,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062626_m08b_cleaning_settlements.up.sql',
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql',
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql'
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
'database/migrations/2026081001_m08c_staff_management_access.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -80,9 +81,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062626_m08b_cleaning_settlements.verify.sql',
'database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql',
'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql'
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
'database/migrations/2026081001_m08c_staff_management_access.verify.sql'
],
down: [
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
'database/migrations/2026062728_m08b_cleaning_payouts.down.sql',
'database/migrations/2026062627_m08b_cleaning_collaboration.down.sql',
@@ -251,6 +254,7 @@ export async function executeMigrationPlan(
1, 1, 1, 4, 7, 1,
1, 1, 7, 7, 1,
5, 10, 7, 3, 1,
5, 2,
2, 8, 4, 3, 1,
2, 9, 5, 2, 1,
1, 7, 5, 1,
+1 -1
View File
@@ -304,7 +304,7 @@ export class PricingRepository {
r.deposit_cents AS depositCents,
r.minimum_minutes AS minimumMinutes,
r.max_advance_days AS maxAdvanceDays,
r.room_category_id AS roomCategoryId
r.category_id AS roomCategoryId
FROM qipai_rooms r
INNER JOIN qipai_stores s
ON s.id = r.store_id AND s.tenant_id = r.tenant_id AND s.deleted_at IS NULL
+40 -1
View File
@@ -64,11 +64,21 @@ const disabledPeriodSchema = z.object({
endsAt: z.coerce.date(),
reason: z.string().trim().max(255).default('')
}).refine((value) => value.endsAt > value.startsAt);
const roomStatusSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
configurationStatus: z.enum(['ENABLED', 'DISABLED']).optional(),
operationalStatus: z.enum([
'AVAILABLE', 'MAINTENANCE', 'RESERVED', 'IN_USE', 'CLEANING_REQUIRED'
]).optional(),
reason: z.string().trim().min(1).max(255)
}).strict().refine((value) => value.configurationStatus || value.operationalStatus, {
message: 'at least one room status is required'
});
export interface StoreRoomRouteOptions {
repository: Pick<StoreRoomRepository,
'listStores' | 'createStore' | 'updateStore' | 'archiveStore' | 'listRooms'
| 'createRoom' | 'updateRoom' | 'archiveRoom' | 'addDisabledPeriod'>;
| 'createRoom' | 'updateRoom' | 'updateRoomStatus' | 'archiveRoom' | 'addDisabledPeriod'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -80,6 +90,11 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
if (!actor) return;
return { code: 0, data: await options.repository.listStores(actor), traceId: request.traceId };
});
app.get('/app-api/management/stores', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
if (!actor) return;
return { code: 0, data: await options.repository.listStores(actor), traceId: request.traceId };
});
app.post('/admin-api/stores', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const body = storeSchema.safeParse(request.body);
@@ -119,6 +134,15 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
traceId: request.traceId
}));
});
app.get('/app-api/management/stores/:storeId/rooms', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const params = storeIdSchema.safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return mutate(reply, request.traceId, async () => ({
code: 0, data: await options.repository.listRooms(actor, params.data.storeId),
traceId: request.traceId
}));
});
app.post('/admin-api/rooms', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const body = roomSchema.safeParse(request.body);
@@ -140,6 +164,21 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
traceId: request.traceId
}));
});
for (const path of ['/admin-api/rooms/:id/status', '/app-api/management/rooms/:id/status']) {
app.patch(path, async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const params = idSchema.safeParse(request.params);
const body = roomStatusSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return mutate(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateRoomStatus(actor, params.data.id, body.data),
traceId: request.traceId
}));
});
}
app.delete('/admin-api/rooms/:id', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const params = idSchema.safeParse(request.params);
+42 -6
View File
@@ -47,6 +47,13 @@ export interface RoomInput {
sortOrder: number;
}
export interface RoomStatusInput {
storeId: string;
configurationStatus?: 'ENABLED' | 'DISABLED';
operationalStatus?: 'AVAILABLE' | 'MAINTENANCE' | 'RESERVED' | 'IN_USE' | 'CLEANING_REQUIRED';
reason: string;
}
interface IdRow extends RowDataPacket { id: string }
interface CountRow extends RowDataPacket { total: number }
interface StoreRow extends RowDataPacket {
@@ -152,7 +159,7 @@ export class StoreRoomRepository {
}
async listRooms(actor: ManagementActor, storeId: string) {
this.assertStoreScope(actor, storeId);
this.assertStoreScope(actor, storeId, false);
const [rows] = await this.pool.execute<RoomRow[]>(
`SELECT r.id, r.store_id AS storeId, COALESCE(c.name, '') AS categoryName,
r.name, r.room_no AS roomNo, r.capacity,
@@ -226,6 +233,33 @@ export class StoreRoomRepository {
});
}
async updateRoomStatus(actor: ManagementActor, roomId: string, input: RoomStatusInput) {
this.assertStoreScope(actor, input.storeId, true);
return this.transaction(async (connection) => {
await this.lockRoom(connection, actor, roomId, input.storeId);
await connection.execute(
`UPDATE qipai_rooms
SET configuration_status = COALESCE(?, configuration_status),
operational_status = COALESCE(?, operational_status),
status = CASE
WHEN COALESCE(?, configuration_status) = 'DISABLED' THEN 'DISABLED'
ELSE COALESCE(?, operational_status)
END
WHERE tenant_id = ? AND store_id = ? AND id = ?`,
[input.configurationStatus ?? null, input.operationalStatus ?? null,
input.configurationStatus ?? null, input.operationalStatus ?? null,
actor.tenantId, input.storeId, roomId]
);
await this.audit(connection, actor, 'ROOM_STATUS_UPDATED', 'ROOM', roomId, {
storeId: input.storeId,
configurationStatus: input.configurationStatus ?? null,
operationalStatus: input.operationalStatus ?? null,
reason: input.reason
});
return { roomId, ...input };
});
}
async archiveRoom(actor: ManagementActor, roomId: string, storeId: string) {
this.assertStoreScope(actor, storeId);
return this.transaction(async (connection) => {
@@ -265,10 +299,11 @@ export class StoreRoomRepository {
}
}
private assertStoreScope(actor: ManagementActor, storeId: string) {
private assertStoreScope(actor: ManagementActor, storeId: string, write = true) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
if (!actor.access.capabilities.includes('store.operation.write')
const capability = write ? 'store.operation.write' : 'store.operation.read';
if (!actor.access.capabilities.includes(capability)
|| !actor.access.storeIds.includes(storeId)) {
throw new StoreRoomError('STORE_SCOPE_FORBIDDEN');
}
@@ -343,15 +378,16 @@ export class StoreRoomRepository {
private async audit(
connection: PoolConnection, actor: ManagementActor,
action: string, resourceType: string, resourceId: string
action: string, resourceType: string, resourceId: string,
metadata: Record<string, unknown> = {}
) {
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', ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
VALUES (?, 'USER', ?, ?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, actor.userId, action, resourceType, resourceId,
actor.traceId, actor.ip, actor.userAgent.slice(0, 255)]
actor.traceId, actor.ip, actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
);
}