feat(M03-A): 完成门店与房间基础管理
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs"
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -13,12 +13,17 @@ import {
|
||||
registerUserManagementRoutes,
|
||||
type UserManagementRouteOptions
|
||||
} from './routes/user-management.js';
|
||||
import {
|
||||
registerStoreRoomRoutes,
|
||||
type StoreRoomRouteOptions
|
||||
} from './routes/store-room-management.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
platformConfigRepository?: PlatformConfigResolver;
|
||||
auth?: AuthRouteOptions;
|
||||
userManagement?: UserManagementRouteOptions;
|
||||
storeRoom?: StoreRoomRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -70,6 +75,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.userManagement) {
|
||||
await registerUserManagementRoutes(app, options.userManagement);
|
||||
}
|
||||
if (options.storeRoom) {
|
||||
await registerStoreRoomRoutes(app, options.storeRoom);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026061803_m02a_tenant_apps.up.sql',
|
||||
'database/migrations/2026061804_m02b_wechat_auth.up.sql',
|
||||
'database/migrations/2026061805_m02c_rbac.up.sql',
|
||||
'database/migrations/2026061806_m02d_user_management.up.sql'
|
||||
'database/migrations/2026061806_m02d_user_management.up.sql',
|
||||
'database/migrations/2026061807_m03a_store_room_domain.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -34,9 +35,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026061803_m02a_tenant_apps.verify.sql',
|
||||
'database/migrations/2026061804_m02b_wechat_auth.verify.sql',
|
||||
'database/migrations/2026061805_m02c_rbac.verify.sql',
|
||||
'database/migrations/2026061806_m02d_user_management.verify.sql'
|
||||
'database/migrations/2026061806_m02d_user_management.verify.sql',
|
||||
'database/migrations/2026061807_m03a_store_room_domain.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026061807_m03a_store_room_domain.down.sql',
|
||||
'database/migrations/2026061806_m02d_user_management.down.sql',
|
||||
'database/migrations/2026061805_m02c_rbac.down.sql',
|
||||
'database/migrations/2026061804_m02b_wechat_auth.down.sql',
|
||||
@@ -164,7 +167,8 @@ export async function executeMigrationPlan(
|
||||
3, 5, 1,
|
||||
3, 7, 1,
|
||||
5, 3, 7, 1,
|
||||
1, 3, 1
|
||||
1, 3, 1,
|
||||
3, 6, 13, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import {
|
||||
StoreRoomError,
|
||||
type StoreRoomRepository
|
||||
} from '../stores/store-room-repository.js';
|
||||
|
||||
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
const storeIdSchema = z.object({ storeId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
const coordinate = z.number().finite();
|
||||
const hoursSchema = z.array(z.object({
|
||||
weekday: z.number().int().min(1).max(7),
|
||||
openMinute: z.number().int().min(0).max(1439),
|
||||
closeMinute: z.number().int().min(0).max(1439),
|
||||
isClosed: z.boolean().default(false)
|
||||
})).max(7).refine((items) => new Set(items.map((item) => item.weekday)).size === items.length);
|
||||
const storeSchema = z.object({
|
||||
name: z.string().trim().min(1).max(128),
|
||||
address: z.string().trim().max(255).default(''),
|
||||
longitude: coordinate.min(-180).max(180).nullable().optional(),
|
||||
latitude: coordinate.min(-90).max(90).nullable().optional(),
|
||||
contactPhone: z.string().trim().max(32).default(''),
|
||||
timezone: z.string().trim().min(1).max(64).default('Asia/Shanghai'),
|
||||
businessStatus: z.enum(['OPEN', 'CLOSED', 'SUSPENDED']).default('OPEN'),
|
||||
wifiSsid: z.string().trim().max(128).default(''),
|
||||
wifiPassword: z.string().max(255).default(''),
|
||||
notificationUrl: z.union([z.string().url(), z.literal('')]).default(''),
|
||||
sortOrder: z.number().int().min(-100000).max(100000).default(0),
|
||||
businessHours: hoursSchema.default([])
|
||||
});
|
||||
const roomSchema = z.object({
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
categoryName: z.string().trim().min(1).max(128),
|
||||
name: z.string().trim().min(1).max(128),
|
||||
roomNo: z.string().trim().min(1).max(64),
|
||||
capacity: z.number().int().min(1).max(100),
|
||||
basePriceCents: z.number().int().min(0).max(10000000),
|
||||
weekdayPriceCents: z.number().int().min(0).max(10000000),
|
||||
holidayPriceCents: z.number().int().min(0).max(10000000),
|
||||
overnightPriceCents: z.number().int().min(0).max(10000000),
|
||||
depositCents: z.number().int().min(0).max(10000000),
|
||||
minimumMinutes: z.number().int().min(15).max(1440),
|
||||
maxAdvanceStartMinutes: z.number().int().min(0).max(1440),
|
||||
maxAdvanceDays: z.number().int().min(0).max(365),
|
||||
configurationStatus: z.enum(['ENABLED', 'DISABLED']),
|
||||
operationalStatus: z.enum([
|
||||
'AVAILABLE', 'MAINTENANCE', 'RESERVED', 'IN_USE', 'CLEANING_REQUIRED'
|
||||
]),
|
||||
tags: z.array(z.string().trim().min(1).max(32)).max(20).default([]),
|
||||
images: z.array(z.string().url()).max(20).default([]),
|
||||
sortOrder: z.number().int().min(-100000).max(100000).default(0)
|
||||
});
|
||||
const disabledPeriodSchema = z.object({
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
startsAt: z.coerce.date(),
|
||||
endsAt: z.coerce.date(),
|
||||
reason: z.string().trim().max(255).default('')
|
||||
}).refine((value) => value.endsAt > value.startsAt);
|
||||
|
||||
export interface StoreRoomRouteOptions {
|
||||
repository: Pick<StoreRoomRepository,
|
||||
'listStores' | 'createStore' | 'updateStore' | 'archiveStore' | 'listRooms'
|
||||
| 'createRoom' | 'updateRoom' | 'archiveRoom' | 'addDisabledPeriod'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerStoreRoomRoutes(app: FastifyInstance, options: StoreRoomRouteOptions) {
|
||||
app.get('/admin-api/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);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0, data: await options.repository.createStore(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.put('/admin-api/stores/:id', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const params = idSchema.safeParse(request.params);
|
||||
const body = storeSchema.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.updateStore(actor, params.data.id, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.delete('/admin-api/stores/:id', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const params = idSchema.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.archiveStore(actor, params.data.id),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.get('/admin-api/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);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0, data: await options.repository.createRoom(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.put('/admin-api/rooms/:id', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const params = idSchema.safeParse(request.params);
|
||||
const body = roomSchema.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.updateRoom(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);
|
||||
const query = storeIdSchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.archiveRoom(actor, params.data.id, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/rooms/:id/disabled-periods', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const params = idSchema.safeParse(request.params);
|
||||
const body = disabledPeriodSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return mutate(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.addDisabledPeriod(actor, params.data.id, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireOperator(
|
||||
request: FastifyRequest, reply: FastifyReply, options: StoreRoomRouteOptions
|
||||
): Promise<ManagementActor | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization, options.authRepository, options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
|
||||
traceId: request.traceId });
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId, auth.session.user.id
|
||||
);
|
||||
if (!access.capabilities.some((code) =>
|
||||
code === 'store.operation.read' || code === 'store.operation.write' || code === 'tenant.manage'
|
||||
) && !access.roles.includes('PLATFORM_ADMIN')) {
|
||||
reply.status(403).send({ code: 'STORE_OPERATION_FORBIDDEN',
|
||||
message: 'Store operation permission is required.', traceId: request.traceId });
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
|
||||
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
async function mutate(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof StoreRoomError)) throw error;
|
||||
const forbidden = error.code.endsWith('_FORBIDDEN');
|
||||
return reply.status(forbidden ? 403 : 404).send({
|
||||
code: error.code, message: 'The store or room operation is not allowed.', traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_STORE_ROOM_REQUEST', message: 'The store or room request is invalid.', traceId
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { AuthRepository } from './auth/auth-repository.js';
|
||||
import { RbacRepository } from './auth/rbac-repository.js';
|
||||
import { parseWechatAppSecrets, WechatHttpClient } from './auth/wechat-client.js';
|
||||
import { UserManagementRepository } from './auth/user-management-repository.js';
|
||||
import { StoreRoomRepository } from './stores/store-room-repository.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
@@ -27,6 +28,12 @@ const app = await buildApp({
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
storeRoom: {
|
||||
repository: new StoreRoomRepository(pool),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
}
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
|
||||
export interface StoreInput {
|
||||
name: string;
|
||||
address: string;
|
||||
longitude?: number | null;
|
||||
latitude?: number | null;
|
||||
contactPhone: string;
|
||||
timezone: string;
|
||||
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
|
||||
wifiSsid: string;
|
||||
wifiPassword: string;
|
||||
notificationUrl: string;
|
||||
sortOrder: number;
|
||||
businessHours: Array<{
|
||||
weekday: number;
|
||||
openMinute: number;
|
||||
closeMinute: number;
|
||||
isClosed: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RoomInput {
|
||||
storeId: string;
|
||||
categoryName: string;
|
||||
name: string;
|
||||
roomNo: string;
|
||||
capacity: number;
|
||||
basePriceCents: number;
|
||||
weekdayPriceCents: number;
|
||||
holidayPriceCents: number;
|
||||
overnightPriceCents: number;
|
||||
depositCents: number;
|
||||
minimumMinutes: number;
|
||||
maxAdvanceStartMinutes: number;
|
||||
maxAdvanceDays: number;
|
||||
configurationStatus: 'ENABLED' | 'DISABLED';
|
||||
operationalStatus: 'AVAILABLE' | 'MAINTENANCE' | 'RESERVED' | 'IN_USE' | 'CLEANING_REQUIRED';
|
||||
tags: string[];
|
||||
images: string[];
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface IdRow extends RowDataPacket { id: string }
|
||||
interface CountRow extends RowDataPacket { total: number }
|
||||
interface StoreRow extends RowDataPacket {
|
||||
id: string; name: string; address: string; longitude: string | null; latitude: string | null;
|
||||
contactPhone: string; timezone: string; businessStatus: string; wifiSsid: string;
|
||||
notificationUrl: string; sortOrder: number;
|
||||
}
|
||||
interface RoomRow extends RowDataPacket {
|
||||
id: string; storeId: string; categoryName: string; name: string; roomNo: string; capacity: number;
|
||||
basePriceCents: number; weekdayPriceCents: number; holidayPriceCents: number;
|
||||
overnightPriceCents: number; depositCents: number; minimumMinutes: number;
|
||||
maxAdvanceStartMinutes: number; maxAdvanceDays: number; configurationStatus: string;
|
||||
operationalStatus: string; tags: string | string[] | null; images: string | string[] | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export class StoreRoomError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class StoreRoomRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async listStores(actor: ManagementActor) {
|
||||
const scope = this.scope(actor, 's.id');
|
||||
const [rows] = await this.pool.execute<StoreRow[]>(
|
||||
`SELECT s.id, s.name, s.address, s.longitude, s.latitude,
|
||||
s.contact_phone AS contactPhone, s.timezone,
|
||||
s.business_status AS businessStatus, s.wifi_ssid AS wifiSsid,
|
||||
s.notification_url AS notificationUrl, s.sort_order AS sortOrder
|
||||
FROM qipai_stores s
|
||||
WHERE s.tenant_id = ? AND s.deleted_at IS NULL AND ${scope.sql}
|
||||
ORDER BY s.sort_order, s.id`,
|
||||
[actor.tenantId, ...scope.params]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
id: String(row.id),
|
||||
longitude: row.longitude === null ? null : Number(row.longitude),
|
||||
latitude: row.latitude === null ? null : Number(row.latitude)
|
||||
}));
|
||||
}
|
||||
|
||||
async createStore(actor: ManagementActor, input: StoreInput) {
|
||||
this.requireTenantManager(actor);
|
||||
return this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_stores
|
||||
(tenant_id, name, address, longitude, latitude, contact_phone, timezone,
|
||||
business_status, wifi_ssid, wifi_password, notification_url, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.name, input.address, input.longitude ?? null,
|
||||
input.latitude ?? null, input.contactPhone, input.timezone, input.businessStatus,
|
||||
input.wifiSsid, input.wifiPassword, input.notificationUrl, input.sortOrder]
|
||||
);
|
||||
const storeId = String(result.insertId);
|
||||
await this.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
|
||||
await this.audit(connection, actor, 'STORE_CREATED', 'STORE', storeId);
|
||||
return { storeId };
|
||||
});
|
||||
}
|
||||
|
||||
async updateStore(actor: ManagementActor, storeId: string, input: StoreInput) {
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockStore(connection, actor, storeId);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_stores SET name = ?, address = ?, longitude = ?, latitude = ?,
|
||||
contact_phone = ?, timezone = ?, business_status = ?, wifi_ssid = ?,
|
||||
wifi_password = ?, notification_url = ?, sort_order = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.name, input.address, input.longitude ?? null, input.latitude ?? null,
|
||||
input.contactPhone, input.timezone, input.businessStatus, input.wifiSsid,
|
||||
input.wifiPassword, input.notificationUrl, input.sortOrder, actor.tenantId, storeId]
|
||||
);
|
||||
await this.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
|
||||
await this.audit(connection, actor, 'STORE_UPDATED', 'STORE', storeId);
|
||||
return { storeId };
|
||||
});
|
||||
}
|
||||
|
||||
async archiveStore(actor: ManagementActor, storeId: string) {
|
||||
this.requireTenantManager(actor);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockStore(connection, actor, storeId);
|
||||
const [counts] = await connection.execute<CountRow[]>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_rooms
|
||||
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL`,
|
||||
[actor.tenantId, storeId]
|
||||
);
|
||||
if (Number(counts[0]?.total ?? 0) > 0) throw new StoreRoomError('STORE_HAS_ACTIVE_ROOMS');
|
||||
await connection.execute(
|
||||
`UPDATE qipai_stores SET deleted_at = UTC_TIMESTAMP(3), business_status = 'CLOSED'
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[actor.tenantId, storeId]
|
||||
);
|
||||
await this.audit(connection, actor, 'STORE_ARCHIVED', 'STORE', storeId);
|
||||
return { storeId, archived: true };
|
||||
});
|
||||
}
|
||||
|
||||
async listRooms(actor: ManagementActor, storeId: string) {
|
||||
this.assertStoreScope(actor, storeId);
|
||||
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,
|
||||
r.base_price_cents AS basePriceCents, r.weekday_price_cents AS weekdayPriceCents,
|
||||
r.holiday_price_cents AS holidayPriceCents,
|
||||
r.overnight_price_cents AS overnightPriceCents, r.deposit_cents AS depositCents,
|
||||
r.minimum_minutes AS minimumMinutes,
|
||||
r.max_advance_start_minutes AS maxAdvanceStartMinutes,
|
||||
r.max_advance_days AS maxAdvanceDays,
|
||||
r.configuration_status AS configurationStatus,
|
||||
r.operational_status AS operationalStatus, r.tags, r.images,
|
||||
r.sort_order AS sortOrder
|
||||
FROM qipai_rooms r
|
||||
LEFT JOIN qipai_room_categories c
|
||||
ON c.id = r.category_id AND c.tenant_id = r.tenant_id AND c.deleted_at IS NULL
|
||||
WHERE r.tenant_id = ? AND r.store_id = ? AND r.deleted_at IS NULL
|
||||
ORDER BY r.sort_order, r.id`,
|
||||
[actor.tenantId, storeId]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row, id: String(row.id), storeId: String(row.storeId),
|
||||
tags: parseJsonArray(row.tags), images: parseJsonArray(row.images)
|
||||
}));
|
||||
}
|
||||
|
||||
async createRoom(actor: ManagementActor, input: RoomInput) {
|
||||
this.assertStoreScope(actor, input.storeId);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockStore(connection, actor, input.storeId);
|
||||
const categoryId = await this.ensureCategory(
|
||||
connection, actor.tenantId, input.storeId, input.categoryName
|
||||
);
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_rooms
|
||||
(tenant_id, store_id, category_id, name, room_no, capacity, base_price_cents,
|
||||
weekday_price_cents, holiday_price_cents, overnight_price_cents, deposit_cents,
|
||||
minimum_minutes, max_advance_start_minutes, max_advance_days,
|
||||
configuration_status, operational_status, tags, images, sort_order, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
roomParams(actor.tenantId, categoryId, input)
|
||||
);
|
||||
const roomId = String(result.insertId);
|
||||
await this.audit(connection, actor, 'ROOM_CREATED', 'ROOM', roomId);
|
||||
return { roomId };
|
||||
});
|
||||
}
|
||||
|
||||
async updateRoom(actor: ManagementActor, roomId: string, input: RoomInput) {
|
||||
this.assertStoreScope(actor, input.storeId);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockRoom(connection, actor, roomId, input.storeId);
|
||||
const categoryId = await this.ensureCategory(
|
||||
connection, actor.tenantId, input.storeId, input.categoryName
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_rooms SET category_id = ?, name = ?, room_no = ?, capacity = ?,
|
||||
base_price_cents = ?, weekday_price_cents = ?, holiday_price_cents = ?,
|
||||
overnight_price_cents = ?, deposit_cents = ?, minimum_minutes = ?,
|
||||
max_advance_start_minutes = ?, max_advance_days = ?, configuration_status = ?,
|
||||
operational_status = ?, tags = ?, images = ?, sort_order = ?, status = ?
|
||||
WHERE tenant_id = ? AND store_id = ? AND id = ?`,
|
||||
[...roomUpdateParams(categoryId, input), actor.tenantId, input.storeId, roomId]
|
||||
);
|
||||
await this.audit(connection, actor, 'ROOM_UPDATED', 'ROOM', roomId);
|
||||
return { roomId };
|
||||
});
|
||||
}
|
||||
|
||||
async archiveRoom(actor: ManagementActor, roomId: string, storeId: string) {
|
||||
this.assertStoreScope(actor, storeId);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockRoom(connection, actor, roomId, storeId);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_rooms SET deleted_at = UTC_TIMESTAMP(3),
|
||||
configuration_status = 'DISABLED', status = 'DISABLED'
|
||||
WHERE tenant_id = ? AND store_id = ? AND id = ?`,
|
||||
[actor.tenantId, storeId, roomId]
|
||||
);
|
||||
await this.audit(connection, actor, 'ROOM_ARCHIVED', 'ROOM', roomId);
|
||||
return { roomId, archived: true };
|
||||
});
|
||||
}
|
||||
|
||||
async addDisabledPeriod(actor: ManagementActor, roomId: string, input: {
|
||||
storeId: string; startsAt: Date; endsAt: Date; reason: string;
|
||||
}) {
|
||||
this.assertStoreScope(actor, input.storeId);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockRoom(connection, actor, roomId, input.storeId);
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_room_disabled_periods
|
||||
(tenant_id, room_id, starts_at, ends_at, reason, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, roomId, input.startsAt, input.endsAt, input.reason, actor.userId]
|
||||
);
|
||||
await this.audit(connection, actor, 'ROOM_DISABLED_PERIOD_CREATED', 'ROOM', roomId);
|
||||
return { disabledPeriodId: String(result.insertId) };
|
||||
});
|
||||
}
|
||||
|
||||
private requireTenantManager(actor: ManagementActor) {
|
||||
if (!actor.access.capabilities.includes('tenant.manage')
|
||||
&& !actor.access.roles.includes('PLATFORM_ADMIN')) {
|
||||
throw new StoreRoomError('STORE_CREATE_FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
private assertStoreScope(actor: ManagementActor, storeId: string) {
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||
if (!actor.access.capabilities.includes('store.operation.write')
|
||||
|| !actor.access.storeIds.includes(storeId)) {
|
||||
throw new StoreRoomError('STORE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
private scope(actor: ManagementActor, expression: string) {
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return { sql: '1 = 1', params: [] as string[] };
|
||||
if (actor.access.storeIds.length === 0) return { sql: '1 = 0', params: [] as string[] };
|
||||
return {
|
||||
sql: `${expression} IN (${actor.access.storeIds.map(() => '?').join(',')})`,
|
||||
params: actor.access.storeIds
|
||||
};
|
||||
}
|
||||
|
||||
private async lockStore(connection: PoolConnection, actor: ManagementActor, storeId: string) {
|
||||
this.assertStoreScope(actor, storeId);
|
||||
const [rows] = await connection.execute<IdRow[]>(
|
||||
`SELECT id FROM qipai_stores
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
|
||||
[actor.tenantId, storeId]
|
||||
);
|
||||
if (!rows[0]) throw new StoreRoomError('STORE_NOT_FOUND');
|
||||
}
|
||||
|
||||
private async lockRoom(
|
||||
connection: PoolConnection, actor: ManagementActor, roomId: string, storeId: string
|
||||
) {
|
||||
await this.lockStore(connection, actor, storeId);
|
||||
const [rows] = await connection.execute<IdRow[]>(
|
||||
`SELECT id FROM qipai_rooms
|
||||
WHERE tenant_id = ? AND store_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
|
||||
[actor.tenantId, storeId, roomId]
|
||||
);
|
||||
if (!rows[0]) throw new StoreRoomError('ROOM_NOT_FOUND');
|
||||
}
|
||||
|
||||
private async ensureCategory(
|
||||
connection: PoolConnection, tenantId: string, storeId: string, name: string
|
||||
) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_room_categories (tenant_id, store_id, name)
|
||||
VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
|
||||
[tenantId, storeId, name]
|
||||
);
|
||||
const [rows] = await connection.execute<IdRow[]>(
|
||||
`SELECT id FROM qipai_room_categories
|
||||
WHERE tenant_id = ? AND store_id = ? AND name = ? AND deleted_at IS NULL`,
|
||||
[tenantId, storeId, name]
|
||||
);
|
||||
if (!rows[0]) throw new StoreRoomError('ROOM_CATEGORY_NOT_FOUND');
|
||||
return String(rows[0].id);
|
||||
}
|
||||
|
||||
private async replaceHours(
|
||||
connection: PoolConnection, tenantId: string, storeId: string,
|
||||
hours: StoreInput['businessHours']
|
||||
) {
|
||||
await connection.execute(
|
||||
'DELETE FROM qipai_store_business_hours WHERE tenant_id = ? AND store_id = ?',
|
||||
[tenantId, storeId]
|
||||
);
|
||||
for (const item of hours) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_store_business_hours
|
||||
(tenant_id, store_id, weekday, open_minute, close_minute, is_closed)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[tenantId, storeId, item.weekday, item.openMinute, item.closeMinute, item.isClosed]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async audit(
|
||||
connection: PoolConnection, actor: ManagementActor,
|
||||
action: string, resourceType: string, resourceId: string
|
||||
) {
|
||||
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())`,
|
||||
[actor.tenantId, actor.userId, action, resourceType, resourceId,
|
||||
actor.traceId, actor.ip, actor.userAgent.slice(0, 255)]
|
||||
);
|
||||
}
|
||||
|
||||
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||
const connection = await this.pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const result = await work(connection);
|
||||
await connection.commit();
|
||||
return result;
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function roomParams(tenantId: string, categoryId: string, input: RoomInput) {
|
||||
const legacyStatus = input.configurationStatus === 'DISABLED'
|
||||
? 'DISABLED' : input.operationalStatus;
|
||||
return [
|
||||
tenantId, input.storeId, categoryId, input.name, input.roomNo, input.capacity,
|
||||
input.basePriceCents, input.weekdayPriceCents, input.holidayPriceCents,
|
||||
input.overnightPriceCents, input.depositCents, input.minimumMinutes,
|
||||
input.maxAdvanceStartMinutes, input.maxAdvanceDays, input.configurationStatus,
|
||||
input.operationalStatus, JSON.stringify(input.tags), JSON.stringify(input.images),
|
||||
input.sortOrder, legacyStatus
|
||||
];
|
||||
}
|
||||
|
||||
function roomUpdateParams(categoryId: string, input: RoomInput) {
|
||||
const legacyStatus = input.configurationStatus === 'DISABLED'
|
||||
? 'DISABLED' : input.operationalStatus;
|
||||
return [
|
||||
categoryId, input.name, input.roomNo, input.capacity, input.basePriceCents,
|
||||
input.weekdayPriceCents, input.holidayPriceCents, input.overnightPriceCents,
|
||||
input.depositCents, input.minimumMinutes, input.maxAdvanceStartMinutes,
|
||||
input.maxAdvanceDays, input.configurationStatus, input.operationalStatus,
|
||||
JSON.stringify(input.tags), JSON.stringify(input.images), input.sortOrder, legacyStatus
|
||||
];
|
||||
}
|
||||
|
||||
function parseJsonArray(value: string | string[] | null): string[] {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string') ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
Reference in New Issue
Block a user