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)]
);
}
+14 -1
View File
@@ -96,6 +96,9 @@ const cleaningPayoutVerifySql = read('database/migrations/2026062728_m08b_cleani
const cleaningTransferStateUpSql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql');
const cleaningTransferStateDownSql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql');
const cleaningTransferStateVerifySql = read('database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql');
const staffManagementUpSql = read('database/migrations/2026081001_m08c_staff_management_access.up.sql');
const staffManagementDownSql = read('database/migrations/2026081001_m08c_staff_management_access.down.sql');
const staffManagementVerifySql = read('database/migrations/2026081001_m08c_staff_management_access.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -392,6 +395,9 @@ assert.match(rechargeWechatUpSql, /UNIQUE KEY uq_qipai_recharge_provider_callbac
assert.match(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/);
assert.match(rechargeWechatVerifySql, /'provider_payment_id'/);
assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/);
assert.match(rechargeWechatVerifySql, /SELECT column_name/);
assert.match(rechargeWechatVerifySql, /SELECT index_name/);
assert.doesNotMatch(rechargeWechatVerifySql, /COUNT\(\*\) AS expected_/);
for (const table of ['qipai_cleaning_tasks', 'qipai_cleaning_task_events']) {
assert.match(cleaningUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
@@ -450,4 +456,11 @@ assert.match(cleaningTransferStateDownSql, /DROP COLUMN payout_package_info/);
assert.match(cleaningTransferStateVerifySql, /'payout_state'/);
assert.match(cleaningTransferStateVerifySql, /'2026062729'/);
console.log('PASS: M01-B through M08-B migration contracts are present.');
assert.match(staffManagementUpSql, /r\.code = 'STAFF'/);
assert.match(staffManagementUpSql, /'profile\.read', 'store\.operation\.read'/);
assert.match(staffManagementUpSql, /'2026081001'/);
assert.match(staffManagementDownSql, /DELETE rp FROM qipai_role_permissions/);
assert.match(staffManagementVerifySql, /fully_granted_staff_roles/);
assert.match(staffManagementVerifySql, /HAVING COUNT\(DISTINCT p\.code\) = 2/);
console.log('PASS: M01-B through M08-C migration contracts are present.');
+6 -1
View File
@@ -39,10 +39,15 @@ assert.match(plan.file, /2026062525_m08b_cleaner_tasks\.up\.sql/);
assert.match(plan.file, /2026062626_m08b_cleaning_settlements\.up\.sql/);
assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.up\.sql/);
assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/);
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql$/);
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
const verifyPlan = await loadMigrationPlan('verify');
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
const calls = [];
const fakePool = {
async query(sql) {
+9
View File
@@ -1,8 +1,17 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
const secret = 'test-only-pricing-jwt-secret-with-32-characters';
const pricingSource = readFileSync(
join(dirname(dirname(fileURLToPath(import.meta.url))), 'src/orders/pricing-repository.ts'),
'utf8'
);
assert.match(pricingSource, /r\.category_id AS roomCategoryId/);
assert.doesNotMatch(pricingSource, /r\.room_category_id AS roomCategoryId/);
const token = signAccessToken({
sub: '21',
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
+2
View File
@@ -27,5 +27,7 @@ assert.equal(await repository.grantStore({
}), true);
assert.match(calls.at(-1)[0], /s\.tenant_id = \?/);
assert.match(calls.at(-1)[0], /u\.tenant_id = \?/);
await repository.ensureCustomerRole('7', '21');
assert.ok(calls.some(([sql]) => sql.includes("r.code = 'STAFF'") && sql.includes('store.operation.read')));
console.log('PASS: M02-C roles, capabilities and tenant-scoped store grants are present.');
+39 -1
View File
@@ -23,6 +23,10 @@ const repository = new StoreRoomRepository({
}
});
assert.equal((await repository.listStores(storeActor))[0].id, '11');
assert.deepEqual(await repository.listRooms({
...storeActor,
access: { roles: ['STAFF'], capabilities: ['store.operation.read'], storeIds: ['11'] }
}, '11'), []);
await assert.rejects(
() => repository.listRooms({ ...storeActor, access: { ...storeActor.access, storeIds: ['12'] } }, '11'),
(error) => error instanceof StoreRoomError && error.code === 'STORE_SCOPE_FORBIDDEN'
@@ -34,6 +38,8 @@ const token = signAccessToken({
tid: '7', aid: '9', rv: 1
}, secret, 900);
let roomInput;
let roomStatusInput;
let currentAccess = tenantActor.access;
const app = await buildApp({
storeRoom: {
jwtSecret: secret,
@@ -49,7 +55,7 @@ const app = await buildApp({
};
}
},
accessControl: { async getAccessProfile() { return tenantActor.access; } },
accessControl: { async getAccessProfile() { return currentAccess; } },
repository: {
async listStores() { return []; },
async createStore() { return { storeId: '11' }; },
@@ -58,6 +64,14 @@ const app = await buildApp({
async listRooms() { return []; },
async createRoom(_actor, input) { roomInput = input; return { roomId: '31' }; },
async updateRoom() { return { roomId: '31' }; },
async updateRoomStatus(actor, _roomId, input) {
if (!actor.access.capabilities.includes('store.operation.write')
&& !actor.access.capabilities.includes('tenant.manage')) {
throw new StoreRoomError('STORE_SCOPE_FORBIDDEN');
}
roomStatusInput = input;
return { roomId: '31', ...input };
},
async archiveRoom() { return { roomId: '31', archived: true }; },
async addDisabledPeriod() { return { disabledPeriodId: '41' }; }
}
@@ -78,6 +92,30 @@ const created = await app.inject({
assert.equal(created.statusCode, 201);
assert.equal(created.json().data.roomId, '31');
assert.equal(roomInput.weekdayPriceCents, 2800);
const managementStores = await app.inject({
method: 'GET', url: '/app-api/management/stores',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(managementStores.statusCode, 200);
const statusUpdated = await app.inject({
method: 'PATCH', url: '/app-api/management/rooms/31/status',
headers: { authorization: `Bearer ${token}` },
payload: { storeId: '11', operationalStatus: 'MAINTENANCE', reason: '现场维护' }
});
assert.equal(statusUpdated.statusCode, 200);
assert.equal(roomStatusInput.operationalStatus, 'MAINTENANCE');
currentAccess = { roles: ['STAFF'], capabilities: ['store.operation.read'], storeIds: ['11'] };
const staffRooms = await app.inject({
method: 'GET', url: '/app-api/management/stores/11/rooms',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(staffRooms.statusCode, 200);
const staffWrite = await app.inject({
method: 'PATCH', url: '/app-api/management/rooms/31/status',
headers: { authorization: `Bearer ${token}` },
payload: { storeId: '11', operationalStatus: 'AVAILABLE', reason: '越权尝试' }
});
assert.equal(staffWrite.statusCode, 403);
const invalid = await app.inject({
method: 'POST', url: '/admin-api/rooms/31/disabled-periods',
headers: { authorization: `Bearer ${token}` },