diff --git a/backend/package.json b/backend/package.json index 0613eee..81c89b6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.ts b/backend/src/app.ts index 3fd1973..02566e8 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 = { '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 = { '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( diff --git a/backend/src/routes/store-room-management.ts b/backend/src/routes/store-room-management.ts new file mode 100644 index 0000000..0395416 --- /dev/null +++ b/backend/src/routes/store-room-management.ts @@ -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; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + 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 { + 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) { + 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 + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 2f4f047..611f086 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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 () => { diff --git a/backend/src/stores/store-room-repository.ts b/backend/src/stores/store-room-repository.ts new file mode 100644 index 0000000..c693cb6 --- /dev/null +++ b/backend/src/stores/store-room-repository.ts @@ -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( + `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( + `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( + `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( + `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( + `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( + `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( + `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( + `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( + `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(work: (connection: PoolConnection) => Promise) { + 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 []; + } +} diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 92aa081..8bf5406 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -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.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 2345f15..25bef74 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -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); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index fe2e8c3..7639f8c 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -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 { diff --git a/backend/tests/store-room.test.mjs b/backend/tests/store-room.test.mjs new file mode 100644 index 0000000..99a4293 --- /dev/null +++ b/backend/tests/store-room.test.mjs @@ -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.'); diff --git a/database/migrations/2026061807_m03a_store_room_domain.down.sql b/database/migrations/2026061807_m03a_store_room_domain.down.sql new file mode 100644 index 0000000..f45ac9c --- /dev/null +++ b/database/migrations/2026061807_m03a_store_room_domain.down.sql @@ -0,0 +1,26 @@ +DELETE FROM qipai_schema_migrations WHERE version = '2026061807'; +DROP TABLE IF EXISTS qipai_room_disabled_periods; +ALTER TABLE qipai_rooms + DROP FOREIGN KEY fk_qipai_rooms_category, + DROP COLUMN sort_order, + DROP COLUMN images, + DROP COLUMN tags, + DROP COLUMN operational_status, + DROP COLUMN configuration_status, + DROP COLUMN max_advance_days, + DROP COLUMN max_advance_start_minutes, + DROP COLUMN minimum_minutes, + DROP COLUMN deposit_cents, + DROP COLUMN overnight_price_cents, + DROP COLUMN holiday_price_cents, + DROP COLUMN weekday_price_cents, + DROP COLUMN category_id; +DROP TABLE IF EXISTS qipai_room_categories; +DROP TABLE IF EXISTS qipai_store_business_hours; +ALTER TABLE qipai_stores + DROP COLUMN sort_order, + DROP COLUMN notification_url, + DROP COLUMN wifi_password, + DROP COLUMN wifi_ssid, + DROP COLUMN latitude, + DROP COLUMN longitude; diff --git a/database/migrations/2026061807_m03a_store_room_domain.up.sql b/database/migrations/2026061807_m03a_store_room_domain.up.sql new file mode 100644 index 0000000..5f68412 --- /dev/null +++ b/database/migrations/2026061807_m03a_store_room_domain.up.sql @@ -0,0 +1,80 @@ +ALTER TABLE qipai_stores + ADD COLUMN longitude DECIMAL(10, 7) NULL AFTER address, + ADD COLUMN latitude DECIMAL(10, 7) NULL AFTER longitude, + ADD COLUMN wifi_ssid VARCHAR(128) NOT NULL DEFAULT '' AFTER contact_phone, + ADD COLUMN wifi_password VARCHAR(255) NOT NULL DEFAULT '' AFTER wifi_ssid, + ADD COLUMN notification_url VARCHAR(512) NOT NULL DEFAULT '' AFTER wifi_password, + ADD COLUMN sort_order INT NOT NULL DEFAULT 0 AFTER business_status; + +CREATE TABLE IF NOT EXISTS qipai_store_business_hours ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + store_id BIGINT UNSIGNED NOT NULL, + weekday TINYINT UNSIGNED NOT NULL, + open_minute SMALLINT UNSIGNED NOT NULL, + close_minute SMALLINT UNSIGNED NOT NULL, + is_closed TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_store_hours_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_store_hours_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + UNIQUE KEY uq_qipai_store_hours_weekday (tenant_id, store_id, weekday), + CONSTRAINT chk_qipai_store_hours_weekday CHECK (weekday BETWEEN 1 AND 7), + CONSTRAINT chk_qipai_store_hours_minute CHECK (open_minute <= 1439 AND close_minute <= 1439) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_room_categories ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + store_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(128) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + CONSTRAINT fk_qipai_room_categories_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_room_categories_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + UNIQUE KEY uq_qipai_room_categories_name (tenant_id, store_id, name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE qipai_rooms + ADD COLUMN category_id BIGINT UNSIGNED NULL AFTER store_id, + ADD COLUMN weekday_price_cents INT UNSIGNED NOT NULL DEFAULT 0 AFTER base_price_cents, + ADD COLUMN holiday_price_cents INT UNSIGNED NOT NULL DEFAULT 0 AFTER weekday_price_cents, + ADD COLUMN overnight_price_cents INT UNSIGNED NOT NULL DEFAULT 0 AFTER holiday_price_cents, + ADD COLUMN deposit_cents INT UNSIGNED NOT NULL DEFAULT 0 AFTER overnight_price_cents, + ADD COLUMN minimum_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 60 AFTER deposit_cents, + ADD COLUMN max_advance_start_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 0 AFTER minimum_minutes, + ADD COLUMN max_advance_days SMALLINT UNSIGNED NOT NULL DEFAULT 30 AFTER max_advance_start_minutes, + ADD COLUMN configuration_status VARCHAR(32) NOT NULL DEFAULT 'ENABLED' AFTER max_advance_days, + ADD COLUMN operational_status VARCHAR(32) NOT NULL DEFAULT 'AVAILABLE' AFTER configuration_status, + ADD COLUMN tags JSON NULL AFTER operational_status, + ADD COLUMN images JSON NULL AFTER tags, + ADD COLUMN sort_order INT NOT NULL DEFAULT 0 AFTER images, + ADD CONSTRAINT fk_qipai_rooms_category FOREIGN KEY (category_id) REFERENCES qipai_room_categories(id); + +UPDATE qipai_rooms +SET configuration_status = CASE WHEN status = 'DISABLED' THEN 'DISABLED' ELSE 'ENABLED' END, + operational_status = CASE + WHEN status IN ('MAINTENANCE', 'RESERVED', 'IN_USE', 'CLEANING_REQUIRED') THEN status + ELSE 'AVAILABLE' + END; + +CREATE TABLE IF NOT EXISTS qipai_room_disabled_periods ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + room_id BIGINT UNSIGNED NOT NULL, + starts_at DATETIME(3) NOT NULL, + ends_at DATETIME(3) NOT NULL, + reason VARCHAR(255) NOT NULL DEFAULT '', + created_by BIGINT UNSIGNED NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_room_disabled_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_room_disabled_room FOREIGN KEY (room_id) REFERENCES qipai_rooms(id), + CONSTRAINT fk_qipai_room_disabled_creator FOREIGN KEY (created_by) REFERENCES qipai_users(id), + KEY idx_qipai_room_disabled_window (tenant_id, room_id, starts_at, ends_at), + CONSTRAINT chk_qipai_room_disabled_window CHECK (ends_at > starts_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026061807', 'm03a_store_room_domain'); diff --git a/database/migrations/2026061807_m03a_store_room_domain.verify.sql b/database/migrations/2026061807_m03a_store_room_domain.verify.sql new file mode 100644 index 0000000..b4238b6 --- /dev/null +++ b/database/migrations/2026061807_m03a_store_room_domain.verify.sql @@ -0,0 +1,18 @@ +SELECT table_name FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name IN ('qipai_store_business_hours', 'qipai_room_categories', 'qipai_room_disabled_periods') +ORDER BY table_name; + +SELECT column_name FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'qipai_stores' + AND column_name IN ('longitude', 'latitude', 'wifi_ssid', 'wifi_password', 'notification_url', 'sort_order') +ORDER BY column_name; + +SELECT column_name FROM information_schema.columns +WHERE table_schema = DATABASE() AND table_name = 'qipai_rooms' + AND column_name IN ('category_id', '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') +ORDER BY column_name; + +SELECT version, name FROM qipai_schema_migrations WHERE version = '2026061807'; diff --git a/docs/api-changelog/2026-06-18-M03-A-store-room.md b/docs/api-changelog/2026-06-18-M03-A-store-room.md new file mode 100644 index 0000000..51d3667 --- /dev/null +++ b/docs/api-changelog/2026-06-18-M03-A-store-room.md @@ -0,0 +1,26 @@ +# M03-A 门店与房间 API + +## 门店 + +- `GET /admin-api/stores` +- `POST /admin-api/stores` +- `PUT /admin-api/stores/:id` +- `DELETE /admin-api/stores/:id`:软删除;存在有效房间时拒绝。 + +门店支持地址、经纬度、营业状态、时区、客服电话、Wi-Fi、通知地址、排序和每周营业时间。列表不返回 Wi-Fi 密码。 + +## 房间 + +- `GET /admin-api/stores/:storeId/rooms` +- `POST /admin-api/rooms` +- `PUT /admin-api/rooms/:id` +- `DELETE /admin-api/rooms/:id?storeId=...`:软删除。 +- `POST /admin-api/rooms/:id/disabled-periods` + +房间支持类别、整数分价格、工作日/节假日/通宵价、押金、最低时长、提前规则、标签、图片和禁用时段。配置状态与运营状态独立保存。 + +## 权限 + +- 租户管理员管理本租户全部门店。 +- 门店管理员仅能读写授权门店。 +- 所有写操作记录审计日志。 diff --git a/docs/db-changelog/2026-06-18-M03-A-store-room.md b/docs/db-changelog/2026-06-18-M03-A-store-room.md new file mode 100644 index 0000000..52a6e0a --- /dev/null +++ b/docs/db-changelog/2026-06-18-M03-A-store-room.md @@ -0,0 +1,11 @@ +# M03-A 门店与房间数据库变更 + +- 迁移版本:`2026061807` +- 新表:`qipai_store_business_hours` +- 新表:`qipai_room_categories` +- 新表:`qipai_room_disabled_periods` +- 扩展:`qipai_stores`、`qipai_rooms` + +价格与押金统一使用整数分,营业时间使用星期和分钟数。房间的 `configuration_status` 表示配置启停,`operational_status` 表示空闲、维修、预订、使用中或待清洁。 + +回滚先删除禁用时段和房型表,再移除房间及门店新增字段;旧 `status` 字段继续保留兼容既有读取。 diff --git a/docs/devlogs/2026-06-18-M03-A-门店与房间基础.md b/docs/devlogs/2026-06-18-M03-A-门店与房间基础.md new file mode 100644 index 0000000..1b0d90c --- /dev/null +++ b/docs/devlogs/2026-06-18-M03-A-门店与房间基础.md @@ -0,0 +1,29 @@ +# M03-A 门店与房间基础 + +- 日期:2026-06-18 +- 起始 commit:`47094d5` +- 工程 commit:本阶段工程提交 +- ENGINEERING_DELTA=YES +- 子阶段状态:待 push 与远端校验 + +## 工程增量 + +- 门店 CRUD、每周营业时间、经纬度、时区、电话、Wi-Fi、通知地址和排序。 +- 房型与房间 CRUD,工作日/节假日/通宵价、押金、预约限制、标签和图片。 +- 房间配置状态与运营状态分离,支持维修禁用时段。 +- 租户管理员和门店管理员数据范围鉴权;写操作审计。 +- 数据删除采用软删除,门店仍有有效房间时禁止归档。 + +## 验证 + +- Windows `npm test`:M01-A 至 M03-A 全量测试通过。 +- WSL MySQL 8.4.9 临时库:`up → verify → down → up → verify` 通过。 +- 真实迁移语句:up 40、verify 23、down 37。 +- 实测营业时间、房型、价格、状态分离、禁用时段、审计和跨门店拒绝。 + +## 影响 + +- 数据库迁移:`2026061807_m03a_store_room_domain`。 +- API:新增门店与房间后台管理接口。 +- 配置:无新增环境变量或秘密。 +- 部署:生产迁移前必须备份,通过 Ubuntu 菜单执行。 diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index a3bfa90..876b2dd 100644 --- a/scripts/dev/wsl/mysql-migration-roundtrip.sh +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}" export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_CONNECTION_LIMIT=2 -echo "INFO: MySQL ${mysql_version}; running M01-B through M02-D migration roundtrip in a temporary database." +echo "INFO: MySQL ${mysql_version}; running M01-B through M03-A migration roundtrip in a temporary database." npm --prefix backend run test:mysql:migration -echo "PASS: M01-B through M02-D live MySQL migration roundtrip completed." +echo "PASS: M01-B through M03-A live MySQL migration roundtrip completed."