feat(M08-C): 建立管理员房态运营入口
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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}` },
|
||||
|
||||
Reference in New Issue
Block a user