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
+8
View File
@@ -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;
}
+7 -3
View File
@@ -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(
+210
View File
@@ -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
});
}
+7
View File
@@ -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 () => {
+395
View File
@@ -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 [];
}
}