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
+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}` },