feat(M03-A): 完成门店与房间基础管理

This commit is contained in:
Codex
2026-06-18 14:55:04 +08:00
parent 47094d5309
commit 15139b3962
17 changed files with 1013 additions and 13 deletions
+15 -1
View File
@@ -27,6 +27,9 @@ const rbacVerifySql = read('database/migrations/2026061805_m02c_rbac.verify.sql'
const userManagementUpSql = read('database/migrations/2026061806_m02d_user_management.up.sql');
const userManagementDownSql = read('database/migrations/2026061806_m02d_user_management.down.sql');
const userManagementVerifySql = read('database/migrations/2026061806_m02d_user_management.verify.sql');
const storeRoomUpSql = read('database/migrations/2026061807_m03a_store_room_domain.up.sql');
const storeRoomDownSql = read('database/migrations/2026061807_m03a_store_room_domain.down.sql');
const storeRoomVerifySql = read('database/migrations/2026061807_m03a_store_room_domain.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -133,5 +136,16 @@ assert.match(userManagementVerifySql, /'qipai_user_admin_profiles'/);
for (const permission of ['user.read', 'staff.manage', 'session.reset']) {
assert.match(userManagementUpSql, new RegExp(permission.replace('.', '\\.')));
}
for (const table of [
'qipai_store_business_hours', 'qipai_room_categories', 'qipai_room_disabled_periods'
]) {
assert.match(storeRoomUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(storeRoomDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(storeRoomVerifySql, new RegExp(`'${table}'`));
}
assert.match(storeRoomUpSql, /configuration_status VARCHAR/);
assert.match(storeRoomUpSql, /operational_status VARCHAR/);
assert.match(storeRoomUpSql, /weekday_price_cents INT UNSIGNED/);
assert.match(storeRoomUpSql, /CHECK \(ends_at > starts_at\)/);
console.log('PASS: M01-B through M02-D migration contracts are present.');
console.log('PASS: M01-B through M03-A migration contracts are present.');
+2 -1
View File
@@ -17,7 +17,8 @@ assert.match(plan.file, /2026061802_m01c_async_tasks\.up\.sql/);
assert.match(plan.file, /2026061803_m02a_tenant_apps\.up\.sql/);
assert.match(plan.file, /2026061804_m02b_wechat_auth\.up\.sql/);
assert.match(plan.file, /2026061805_m02c_rbac\.up\.sql/);
assert.match(plan.file, /2026061806_m02d_user_management\.up\.sql$/);
assert.match(plan.file, /2026061806_m02d_user_management\.up\.sql/);
assert.match(plan.file, /2026061807_m03a_store_room_domain\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -13,6 +13,7 @@ import {
import { AuthRepository } from '../dist/auth/auth-repository.js';
import { RbacRepository } from '../dist/auth/rbac-repository.js';
import { UserManagementRepository } from '../dist/auth/user-management-repository.js';
import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -33,8 +34,11 @@ const expectedTables = [
'qipai_platform_apps',
'qipai_role_permissions',
'qipai_roles',
'qipai_room_categories',
'qipai_room_disabled_periods',
'qipai_rooms',
'qipai_schema_migrations',
'qipai_store_business_hours',
'qipai_stores',
'qipai_tenant_apps',
'qipai_tenant_configs',
@@ -64,9 +68,10 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804', '2026061805', '2026061806']
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807']
);
return rows;
}
@@ -315,6 +320,75 @@ async function assertUserManagement(pool, context) {
assert.deepEqual(auditRows.map((row) => row.action), ['STAFF_CREATED', 'USER_UPDATED']);
}
async function assertStoreRoomDomain(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const rbac = new RbacRepository(pool);
const access = await rbac.getAccessProfile(context.tenantId, adminId);
const repository = new StoreRoomRepository(pool);
const actor = {
tenantId: context.tenantId, userId: adminId, access,
traceId: 'm03a-live-test', ip: '127.0.0.1', userAgent: 'M03-A live test'
};
const store = await repository.createStore(actor, {
name: 'M03A Store', address: 'Sanitized address',
longitude: 121.4737, latitude: 31.2304, contactPhone: '13800000003',
timezone: 'Asia/Shanghai', businessStatus: 'OPEN',
wifiSsid: 'M03A-WIFI', wifiPassword: 'sanitized-password',
notificationUrl: '', sortOrder: 1,
businessHours: [
{ weekday: 1, openMinute: 600, closeMinute: 1320, isClosed: false },
{ weekday: 2, openMinute: 600, closeMinute: 1320, isClosed: false }
]
});
const roomInput = {
storeId: store.storeId, categoryName: '标准间', name: 'M03A Room', roomNo: 'A01',
capacity: 4, basePriceCents: 3000, weekdayPriceCents: 2800,
holidayPriceCents: 3500, overnightPriceCents: 12000, depositCents: 5000,
minimumMinutes: 60, maxAdvanceStartMinutes: 30, maxAdvanceDays: 30,
configurationStatus: 'ENABLED', operationalStatus: 'AVAILABLE',
tags: ['麻将', '禁烟'], images: ['https://api.txyundm.cn/uploads/test-room.jpg'],
sortOrder: 1
};
const room = await repository.createRoom(actor, roomInput);
await repository.addDisabledPeriod(actor, room.roomId, {
storeId: store.storeId,
startsAt: new Date('2026-06-20T02:00:00.000Z'),
endsAt: new Date('2026-06-20T04:00:00.000Z'),
reason: 'maintenance rehearsal'
});
const stores = await repository.listStores(actor);
const rooms = await repository.listRooms(actor, store.storeId);
assert.equal(stores.find((item) => item.id === store.storeId)?.timezone, 'Asia/Shanghai');
assert.equal(rooms.find((item) => item.id === room.roomId)?.holidayPriceCents, 3500);
assert.deepEqual(rooms.find((item) => item.id === room.roomId)?.tags, ['麻将', '禁烟']);
await assert.rejects(
() => repository.listRooms({
...actor,
access: {
roles: ['STORE_ADMIN'],
capabilities: ['store.operation.read', 'store.operation.write'],
storeIds: [String(Number(store.storeId) + 999)]
}
}, store.storeId),
(error) => error instanceof StoreRoomError && error.code === 'STORE_SCOPE_FORBIDDEN'
);
const [auditRows] = await pool.query(
`SELECT action FROM qipai_audit_logs
WHERE tenant_id = ? AND trace_id = 'm03a-live-test' ORDER BY id`,
[context.tenantId]
);
assert.deepEqual(auditRows.map((row) => row.action), [
'STORE_CREATED', 'ROOM_CREATED', 'ROOM_DISABLED_PERIOD_CREATED'
]);
}
const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match(
@@ -344,12 +418,14 @@ try {
{ version: '2026061803', name: 'm02a_tenant_apps' },
{ version: '2026061804', name: 'm02b_wechat_auth' },
{ version: '2026061805', name: 'm02c_rbac' },
{ version: '2026061806', name: 'm02d_user_management' }
{ version: '2026061806', name: 'm02d_user_management' },
{ version: '2026061807', name: 'm03a_store_room_domain' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
await assertRevocableAuthSession(pool, loginContext);
await assertUserManagement(pool, loginContext);
await assertStoreRoomDomain(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
@@ -367,7 +443,8 @@ try {
{ version: '2026061803', name: 'm02a_tenant_apps' },
{ version: '2026061804', name: 'm02b_wechat_auth' },
{ version: '2026061805', name: 'm02c_rbac' },
{ version: '2026061806', name: 'm02d_user_management' }
{ version: '2026061806', name: 'm02d_user_management' },
{ version: '2026061807', name: 'm03a_store_room_domain' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -403,7 +480,12 @@ try {
'cross-tenant store grant rejection',
'staff creation and store assignment',
'access-change session revocation',
'user-management audit log'
'user-management audit log',
'store business hours and coordinates',
'room category and integer-cent pricing',
'configuration and operational status separation',
'room disabled period',
'cross-store management rejection'
]
}, null, 2));
} finally {
+89
View File
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
import { StoreRoomError, StoreRoomRepository } from '../dist/stores/store-room-repository.js';
const tenantActor = {
tenantId: '7', userId: '21',
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
traceId: 'trace', ip: '127.0.0.1', userAgent: 'test'
};
const storeActor = {
tenantId: '7', userId: '22',
access: {
roles: ['STORE_ADMIN'], capabilities: ['store.operation.read', 'store.operation.write'],
storeIds: ['11']
},
traceId: 'trace', ip: '127.0.0.1', userAgent: 'test'
};
const repository = new StoreRoomRepository({
async execute(sql) {
if (sql.includes('FROM qipai_stores')) return [[{ id: 11, name: 'A', longitude: null, latitude: null }], []];
return [[], []];
}
});
assert.equal((await repository.listStores(storeActor))[0].id, '11');
await assert.rejects(
() => repository.listRooms({ ...storeActor, access: { ...storeActor.access, storeIds: ['12'] } }, '11'),
(error) => error instanceof StoreRoomError && error.code === 'STORE_SCOPE_FORBIDDEN'
);
const secret = 'test-only-jwt-secret-with-at-least-32-characters';
const token = signAccessToken({
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
let roomInput;
const app = await buildApp({
storeRoom: {
jwtSecret: secret,
authRepository: {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000),
user: {
id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
}
};
}
},
accessControl: { async getAccessProfile() { return tenantActor.access; } },
repository: {
async listStores() { return []; },
async createStore() { return { storeId: '11' }; },
async updateStore() { return { storeId: '11' }; },
async archiveStore() { return { storeId: '11', archived: true }; },
async listRooms() { return []; },
async createRoom(_actor, input) { roomInput = input; return { roomId: '31' }; },
async updateRoom() { return { roomId: '31' }; },
async archiveRoom() { return { roomId: '31', archived: true }; },
async addDisabledPeriod() { return { disabledPeriodId: '41' }; }
}
}
});
const created = await app.inject({
method: 'POST', url: '/admin-api/rooms',
headers: { authorization: `Bearer ${token}` },
payload: {
storeId: '11', categoryName: '标准间', name: 'A01', roomNo: 'A01', capacity: 4,
basePriceCents: 3000, weekdayPriceCents: 2800, holidayPriceCents: 3500,
overnightPriceCents: 12000, depositCents: 0, minimumMinutes: 60,
maxAdvanceStartMinutes: 30, maxAdvanceDays: 30,
configurationStatus: 'ENABLED', operationalStatus: 'AVAILABLE',
tags: ['麻将'], images: [], sortOrder: 1
}
});
assert.equal(created.statusCode, 201);
assert.equal(created.json().data.roomId, '31');
assert.equal(roomInput.weekdayPriceCents, 2800);
const invalid = await app.inject({
method: 'POST', url: '/admin-api/rooms/31/disabled-periods',
headers: { authorization: `Bearer ${token}` },
payload: { storeId: '11', startsAt: '2026-06-20T12:00:00Z', endsAt: '2026-06-20T11:00:00Z' }
});
assert.equal(invalid.statusCode, 400);
await app.close();
console.log('PASS: M03-A store scope, room pricing/status validation and management routes are present.');