feat(M04-A): 完成定价快照与并发时段预占

This commit is contained in:
Codex
2026-06-18 16:07:00 +08:00
parent 6be7fa79c5
commit af7a45d878
19 changed files with 824 additions and 22 deletions
+1 -1
View File
@@ -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 && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.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 && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+5
View File
@@ -29,6 +29,7 @@ import {
registerStoreAccessRoutes,
type StoreAccessRouteOptions
} from './routes/store-access.js';
import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricing.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -39,6 +40,7 @@ export interface BuildAppOptions {
content?: ContentRouteOptions;
storeDiscovery?: StoreDiscoveryRouteOptions;
storeAccess?: StoreAccessRouteOptions;
pricing?: PricingRouteOptions;
}
declare module 'fastify' {
@@ -102,6 +104,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.storeAccess) {
await registerStoreAccessRoutes(app, options.storeAccess);
}
if (options.pricing) {
await registerPricingRoutes(app, options.pricing);
}
return app;
}
+7 -3
View File
@@ -30,7 +30,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026061807_m03a_store_room_domain.up.sql',
'database/migrations/2026061808_m03b_decoration_ads_media.up.sql',
'database/migrations/2026061809_m03c_store_discovery.up.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.up.sql'
'database/migrations/2026061810_m03d_scene_wifi_access.up.sql',
'database/migrations/2026061811_m04a_pricing_reservations.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -42,9 +43,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026061807_m03a_store_room_domain.verify.sql',
'database/migrations/2026061808_m03b_decoration_ads_media.verify.sql',
'database/migrations/2026061809_m03c_store_discovery.verify.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql'
'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql',
'database/migrations/2026061811_m04a_pricing_reservations.verify.sql'
],
down: [
'database/migrations/2026061811_m04a_pricing_reservations.down.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.down.sql',
'database/migrations/2026061809_m03c_store_discovery.down.sql',
'database/migrations/2026061808_m03b_decoration_ads_media.down.sql',
@@ -180,7 +183,8 @@ export async function executeMigrationPlan(
3, 6, 13, 1,
3, 3, 1,
2, 2, 1,
3, 3, 1
3, 3, 1,
2, 1, 3, 3, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
+307
View File
@@ -0,0 +1,307 @@
import { randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
export type PricingMode = 'HOURLY' | 'OVERNIGHT' | 'FULL_DAY';
export interface PricingAdjustment {
discountCents?: number;
packageCreditCents?: number;
}
export interface QuoteInput {
tenantId: string;
roomId: string;
startAt: Date;
endAt: Date;
pricingMode: PricingMode;
adjustment?: PricingAdjustment;
}
interface RoomPricingRow extends RowDataPacket {
id: string;
storeId: string;
timezone: string;
configurationStatus: string;
operationalStatus: string;
basePriceCents: number;
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceDays: number;
}
interface CountRow extends RowDataPacket { total: number }
export class PricingError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class PricingRepository {
constructor(private readonly pool: MySqlPool) {}
async quote(input: QuoteInput) {
const room = await this.loadRoom(this.pool, input.tenantId, input.roomId);
await this.releaseExpired(input.tenantId, input.roomId);
await this.assertAvailable(this.pool, input, false);
const isHoliday = await this.isHoliday(this.pool, input.tenantId, input.startAt, room.timezone);
return this.calculate(room, input, isHoliday);
}
async reserve(input: QuoteInput & {
userId: string;
holdMinutes?: number;
}) {
return this.transaction(async (connection) => {
const room = await this.loadRoom(connection, input.tenantId, input.roomId, true);
await this.releaseExpired(input.tenantId, input.roomId, connection);
await this.assertAvailable(connection, input, true);
const isHoliday = await this.isHoliday(
connection, input.tenantId, input.startAt, room.timezone
);
const quote = this.calculate(room, input, isHoliday);
const holdMinutes = Math.min(Math.max(input.holdMinutes ?? 15, 5), 30);
const orderNo = `QP${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`;
const [orderResult] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_orders
(tenant_id, store_id, room_id, order_no, status, start_at, end_at,
hold_expires_at, total_amount_cents)
VALUES (?, ?, ?, ?, 'PENDING_PAYMENT', ?, ?,
DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE), ?)`,
[input.tenantId, room.storeId, input.roomId, orderNo, input.startAt, input.endAt,
holdMinutes, quote.totalCents]
);
const orderId = String(orderResult.insertId);
await connection.execute(
`INSERT INTO qipai_order_price_snapshots
(tenant_id, order_id, pricing_mode, duration_minutes, unit_price_cents,
subtotal_cents, minimum_spend_cents, deposit_cents, discount_cents,
package_credit_cents, total_cents, rules)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.tenantId, orderId, input.pricingMode, quote.durationMinutes,
quote.unitPriceCents, quote.subtotalCents, quote.minimumSpendCents,
quote.depositCents, quote.discountCents, quote.packageCreditCents,
quote.totalCents, JSON.stringify(quote.rules)]
);
const [reservationResult] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_room_reservations
(tenant_id, order_id, room_id, starts_at, ends_at, expires_at)
VALUES (?, ?, ?, ?, ?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE))`,
[input.tenantId, orderId, input.roomId, input.startAt, input.endAt, holdMinutes]
);
await connection.execute(
`INSERT INTO qipai_order_user_access (tenant_id, order_id, user_id)
VALUES (?, ?, ?)`,
[input.tenantId, orderId, input.userId]
);
return {
orderId,
orderNo,
reservationId: String(reservationResult.insertId),
holdMinutes,
quote
};
});
}
async releaseExpired(
tenantId?: string,
roomId?: string,
connection: Pick<MySqlPool, 'execute'> | PoolConnection = this.pool
) {
const filters = [`r.status = 'HELD'`, 'r.expires_at <= UTC_TIMESTAMP(3)'];
const params: string[] = [];
if (tenantId) {
filters.push('r.tenant_id = ?');
params.push(tenantId);
}
if (roomId) {
filters.push('r.room_id = ?');
params.push(roomId);
}
const [counts] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_room_reservations r
WHERE ${filters.join(' AND ')}`,
params
);
await connection.execute<ResultSetHeader>(
`UPDATE qipai_room_reservations r
INNER JOIN qipai_orders o
ON o.id = r.order_id AND o.tenant_id = r.tenant_id
SET r.status = 'RELEASED', r.released_at = UTC_TIMESTAMP(3),
o.status = 'CLOSED'
WHERE ${filters.join(' AND ')}`,
params
);
return { released: Number(counts[0]?.total ?? 0) };
}
private calculate(room: RoomPricingRow, input: QuoteInput, isHoliday: boolean) {
this.validateWindow(room, input);
const durationMinutes = Math.ceil(
(input.endAt.getTime() - input.startAt.getTime()) / 60000
);
const localDate = localDateKey(input.startAt, room.timezone);
const weekday = localWeekday(input.startAt, room.timezone);
const adjustment = input.adjustment ?? {};
let unitPriceCents = room.basePriceCents;
let priceSource = 'base';
if (input.pricingMode === 'OVERNIGHT') {
unitPriceCents = room.overnightPriceCents || room.basePriceCents;
priceSource = 'overnight';
} else if (input.pricingMode === 'FULL_DAY') {
unitPriceCents = room.fullDayPriceCents || room.basePriceCents * 24;
priceSource = 'fullDay';
} else if (isHoliday && room.holidayPriceCents > 0) {
unitPriceCents = room.holidayPriceCents;
priceSource = 'holiday';
} else if (weekday >= 1 && weekday <= 5 && room.weekdayPriceCents > 0) {
unitPriceCents = room.weekdayPriceCents;
priceSource = 'weekday';
}
const subtotalCents = input.pricingMode === 'HOURLY'
? Math.ceil(durationMinutes / 60) * unitPriceCents
: unitPriceCents;
const minimumSpendCents = room.minimumSpendCents;
const billableCents = Math.max(subtotalCents, minimumSpendCents);
const discountCents = clampAdjustment(adjustment.discountCents, billableCents);
const afterDiscount = billableCents - discountCents;
const packageCreditCents = clampAdjustment(
adjustment.packageCreditCents, afterDiscount
);
const totalCents = afterDiscount - packageCreditCents + room.depositCents;
return {
durationMinutes,
unitPriceCents,
subtotalCents,
minimumSpendCents,
depositCents: room.depositCents,
discountCents,
packageCreditCents,
totalCents,
rules: {
priceSource,
isHoliday,
localDate,
timezone: room.timezone,
pricingMode: input.pricingMode,
minimumMinutes: room.minimumMinutes
}
};
}
private validateWindow(room: RoomPricingRow, input: QuoteInput) {
if (input.endAt <= input.startAt) throw new PricingError('INVALID_TIME_WINDOW');
const durationMinutes = (input.endAt.getTime() - input.startAt.getTime()) / 60000;
if (durationMinutes < room.minimumMinutes) throw new PricingError('MINIMUM_DURATION_NOT_MET');
if (input.startAt.getTime() > Date.now() + room.maxAdvanceDays * 86400000) {
throw new PricingError('ADVANCE_WINDOW_EXCEEDED');
}
if (room.configurationStatus !== 'ENABLED' || room.operationalStatus !== 'AVAILABLE') {
throw new PricingError('ROOM_NOT_AVAILABLE');
}
}
private async loadRoom(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
roomId: string,
lock = false
) {
const [rows] = await connection.execute<RoomPricingRow[]>(
`SELECT r.id, r.store_id AS storeId, s.timezone,
r.configuration_status AS configurationStatus,
r.operational_status AS operationalStatus,
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.full_day_price_cents AS fullDayPriceCents,
r.minimum_spend_cents AS minimumSpendCents,
r.deposit_cents AS depositCents,
r.minimum_minutes AS minimumMinutes,
r.max_advance_days AS maxAdvanceDays
FROM qipai_rooms r
INNER JOIN qipai_stores s
ON s.id = r.store_id AND s.tenant_id = r.tenant_id AND s.deleted_at IS NULL
WHERE r.tenant_id = ? AND r.id = ? AND r.deleted_at IS NULL
${lock ? 'FOR UPDATE' : ''}`,
[tenantId, roomId]
);
if (!rows[0]) throw new PricingError('ROOM_NOT_FOUND');
return rows[0];
}
private async assertAvailable(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
input: QuoteInput,
lock: boolean
) {
const [disabled] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_room_disabled_periods
WHERE tenant_id = ? AND room_id = ? AND starts_at < ? AND ends_at > ?`,
[input.tenantId, input.roomId, input.endAt, input.startAt]
);
if (Number(disabled[0]?.total ?? 0) > 0) throw new PricingError('ROOM_DISABLED_PERIOD');
const [reserved] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_room_reservations
WHERE tenant_id = ? AND room_id = ?
AND status IN ('HELD', 'CONSUMED')
AND (status = 'CONSUMED' OR expires_at > UTC_TIMESTAMP(3))
AND starts_at < ? AND ends_at > ?
${lock ? 'FOR UPDATE' : ''}`,
[input.tenantId, input.roomId, input.endAt, input.startAt]
);
if (Number(reserved[0]?.total ?? 0) > 0) throw new PricingError('TIME_SLOT_CONFLICT');
}
private async isHoliday(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
date: Date,
timezone: string
) {
const [rows] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_holiday_calendar
WHERE tenant_id = ? AND holiday_date = ?`,
[tenantId, localDateKey(date, timezone)]
);
return Number(rows[0]?.total ?? 0) > 0;
}
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 clampAdjustment(value: number | undefined, maximum: number) {
if (!value || value < 0) return 0;
return Math.min(Math.trunc(value), maximum);
}
function localDateKey(date: Date, timezone: string) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit'
}).format(date);
}
function localWeekday(date: Date, timezone: string) {
const day = new Intl.DateTimeFormat('en-US', {
timeZone: timezone, weekday: 'short'
}).format(date);
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(day);
}
+117
View File
@@ -0,0 +1,117 @@
import type { FastifyInstance, FastifyReply } 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 { PricingError, type PricingRepository } from '../orders/pricing-repository.js';
const requestSchema = z.object({
roomId: z.string().regex(/^[1-9]\d{0,19}$/),
startAt: z.coerce.date(),
endAt: z.coerce.date(),
pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY')
}).refine((value) => value.endAt > value.startAt);
export interface PricingRouteOptions {
repository: Pick<PricingRepository, 'quote' | 'reserve' | 'releaseExpired'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerPricingRoutes(app: FastifyInstance, options: PricingRouteOptions) {
app.post('/app-api/pricing/quote', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = requestSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.quote({
tenantId: auth.tenantId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/app-api/orders/reserve', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = requestSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.reserve({
tenantId: auth.tenantId,
userId: auth.userId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/reservations/release-expired', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
if (!auth) return unauthorized(reply, request.traceId);
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
if (!access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
return reply.status(403).send({
code: 'RESERVATION_RELEASE_FORBIDDEN',
message: 'Tenant management permission is required.',
traceId: request.traceId
});
}
return {
code: 0,
data: await options.repository.releaseExpired(auth.tenantId),
traceId: request.traceId
};
});
}
async function authenticate(
authorization: string | undefined,
options: PricingRouteOptions
) {
const result = await authenticateAccessToken(
authorization, options.authRepository, options.jwtSecret
);
if (!result) return null;
return {
tenantId: result.session.tenantId,
userId: result.session.user.id
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof PricingError)) throw error;
const conflict = error.code === 'TIME_SLOT_CONFLICT';
const notFound = error.code === 'ROOM_NOT_FOUND';
return reply.status(conflict ? 409 : notFound ? 404 : 400).send({
code: error.code,
message: 'The requested price or time slot is not available.',
traceId
});
}
}
function unauthorized(reply: FastifyReply, traceId: string) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'Authentication required.',
traceId
});
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_PRICING_REQUEST',
message: 'The pricing request is invalid.',
traceId
});
}
@@ -44,6 +44,8 @@ const roomSchema = z.object({
weekdayPriceCents: z.number().int().min(0).max(10000000),
holidayPriceCents: z.number().int().min(0).max(10000000),
overnightPriceCents: z.number().int().min(0).max(10000000),
fullDayPriceCents: z.number().int().min(0).max(10000000).default(0),
minimumSpendCents: z.number().int().min(0).max(10000000).default(0),
depositCents: z.number().int().min(0).max(10000000),
minimumMinutes: z.number().int().min(15).max(1440),
maxAdvanceStartMinutes: z.number().int().min(0).max(1440),
+7
View File
@@ -12,6 +12,7 @@ import { MediaStorage } from './content/media-storage.js';
import { resolve } from 'node:path';
import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js';
import { StoreAccessRepository } from './stores/access-repository.js';
import { PricingRepository } from './orders/pricing-repository.js';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -56,6 +57,12 @@ const app = await buildApp({
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
pricing: {
repository: new PricingRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
}
});
app.addHook('onClose', async () => {
+18 -8
View File
@@ -34,6 +34,8 @@ export interface RoomInput {
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceStartMinutes: number;
@@ -56,7 +58,8 @@ interface StoreRow extends RowDataPacket {
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;
overnightPriceCents: number; fullDayPriceCents: number; minimumSpendCents: number;
depositCents: number; minimumMinutes: number;
maxAdvanceStartMinutes: number; maxAdvanceDays: number; configurationStatus: string;
operationalStatus: string; tags: string | string[] | null; images: string | string[] | null;
sortOrder: number;
@@ -155,7 +158,10 @@ export class StoreRoomRepository {
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.overnight_price_cents AS overnightPriceCents,
r.full_day_price_cents AS fullDayPriceCents,
r.minimum_spend_cents AS minimumSpendCents,
r.deposit_cents AS depositCents,
r.minimum_minutes AS minimumMinutes,
r.max_advance_start_minutes AS maxAdvanceStartMinutes,
r.max_advance_days AS maxAdvanceDays,
@@ -185,10 +191,11 @@ export class StoreRoomRepository {
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,
weekday_price_cents, holiday_price_cents, overnight_price_cents,
full_day_price_cents, minimum_spend_cents, deposit_cents, minimum_minutes,
max_advance_start_minutes, max_advance_days,
configuration_status, operational_status, tags, images, sort_order, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
roomParams(actor.tenantId, categoryId, input)
);
const roomId = String(result.insertId);
@@ -207,7 +214,8 @@ export class StoreRoomRepository {
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 = ?,
overnight_price_cents = ?, full_day_price_cents = ?, minimum_spend_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 = ?`,
@@ -369,7 +377,8 @@ function roomParams(tenantId: string, categoryId: string, input: RoomInput) {
return [
tenantId, input.storeId, categoryId, input.name, input.roomNo, input.capacity,
input.basePriceCents, input.weekdayPriceCents, input.holidayPriceCents,
input.overnightPriceCents, input.depositCents, input.minimumMinutes,
input.overnightPriceCents, input.fullDayPriceCents ?? 0, input.minimumSpendCents ?? 0,
input.depositCents, input.minimumMinutes,
input.maxAdvanceStartMinutes, input.maxAdvanceDays, input.configurationStatus,
input.operationalStatus, JSON.stringify(input.tags), JSON.stringify(input.images),
input.sortOrder, legacyStatus
@@ -382,7 +391,8 @@ function roomUpdateParams(categoryId: string, input: RoomInput) {
return [
categoryId, input.name, input.roomNo, input.capacity, input.basePriceCents,
input.weekdayPriceCents, input.holidayPriceCents, input.overnightPriceCents,
input.depositCents, input.minimumMinutes, input.maxAdvanceStartMinutes,
input.fullDayPriceCents ?? 0, input.minimumSpendCents ?? 0, input.depositCents,
input.minimumMinutes, input.maxAdvanceStartMinutes,
input.maxAdvanceDays, input.configurationStatus, input.operationalStatus,
JSON.stringify(input.tags), JSON.stringify(input.images), input.sortOrder, legacyStatus
];
+14 -1
View File
@@ -39,6 +39,9 @@ const discoveryVerifySql = read('database/migrations/2026061809_m03c_store_disco
const accessUpSql = read('database/migrations/2026061810_m03d_scene_wifi_access.up.sql');
const accessDownSql = read('database/migrations/2026061810_m03d_scene_wifi_access.down.sql');
const accessVerifySql = read('database/migrations/2026061810_m03d_scene_wifi_access.verify.sql');
const pricingUpSql = read('database/migrations/2026061811_m04a_pricing_reservations.up.sql');
const pricingDownSql = read('database/migrations/2026061811_m04a_pricing_reservations.down.sql');
const pricingVerifySql = read('database/migrations/2026061811_m04a_pricing_reservations.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -183,5 +186,15 @@ for (const table of [
assert.match(accessUpSql, /scan_count BIGINT UNSIGNED/);
assert.match(accessUpSql, /target_type = 'STORE'/);
assert.match(accessUpSql, /PRIMARY KEY \(tenant_id, order_id, user_id\)/);
for (const table of [
'qipai_holiday_calendar', 'qipai_order_price_snapshots', 'qipai_room_reservations'
]) {
assert.match(pricingUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(pricingDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(pricingVerifySql, new RegExp(`'${table}'`));
}
assert.match(pricingUpSql, /full_day_price_cents/);
assert.match(pricingUpSql, /minimum_spend_cents/);
assert.match(pricingUpSql, /idx_qipai_reservation_overlap/);
console.log('PASS: M01-B through M03-D migration contracts are present.');
console.log('PASS: M01-B through M04-A migration contracts are present.');
+2 -1
View File
@@ -21,7 +21,8 @@ assert.match(plan.file, /2026061806_m02d_user_management\.up\.sql/);
assert.match(plan.file, /2026061807_m03a_store_room_domain\.up\.sql/);
assert.match(plan.file, /2026061808_m03b_decoration_ads_media\.up\.sql/);
assert.match(plan.file, /2026061809_m03c_store_discovery\.up\.sql/);
assert.match(plan.file, /2026061810_m03d_scene_wifi_access\.up\.sql$/);
assert.match(plan.file, /2026061810_m03d_scene_wifi_access\.up\.sql/);
assert.match(plan.file, /2026061811_m04a_pricing_reservations\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -17,6 +17,7 @@ import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-r
import { ContentRepository, ContentError } from '../dist/content/content-repository.js';
import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js';
import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js';
import { PricingRepository, PricingError } from '../dist/orders/pricing-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -29,9 +30,11 @@ const expectedTables = [
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_devices',
'qipai_holiday_calendar',
'qipai_legacy_table_mappings',
'qipai_media_assets',
'qipai_members',
'qipai_order_price_snapshots',
'qipai_order_user_access',
'qipai_orders',
'qipai_outbox_events',
@@ -42,6 +45,7 @@ const expectedTables = [
'qipai_roles',
'qipai_room_categories',
'qipai_room_disabled_periods',
'qipai_room_reservations',
'qipai_rooms',
'qipai_scene_codes',
'qipai_scene_scan_events',
@@ -77,11 +81,11 @@ 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', '2026061807', '2026061808', '2026061809',
'2026061810']
'2026061810', '2026061811']
);
return rows;
}
@@ -527,6 +531,102 @@ async function assertSceneAndWifiAccess(pool, context) {
assert.doesNotMatch(auditRows[0].metadata, /sanitized-password/);
}
async function assertPricingAndReservations(pool, context) {
const [customerRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_identities i
ON i.tenant_id = u.tenant_id AND i.user_id = u.id
WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`,
[context.tenantId]
);
const [targetRows] = await pool.query(
`SELECT s.id AS storeId, r.id AS roomId
FROM qipai_stores s
INNER JOIN qipai_rooms r ON r.tenant_id = s.tenant_id AND r.store_id = s.id
WHERE s.tenant_id = ? AND s.name = 'M03A Store' LIMIT 1`,
[context.tenantId]
);
const customerId = String(customerRows[0].id);
const roomId = String(targetRows[0].roomId);
await pool.query(
`UPDATE qipai_rooms SET base_price_cents = 1200, weekday_price_cents = 1000,
holiday_price_cents = 1800, overnight_price_cents = 5000,
full_day_price_cents = 9000, minimum_spend_cents = 2500,
deposit_cents = 500, minimum_minutes = 60, max_advance_days = 30,
configuration_status = 'ENABLED', operational_status = 'AVAILABLE'
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, roomId]
);
const startAt = new Date(Date.now() + 5 * 86400000);
startAt.setUTCHours(2, 0, 0, 0);
const endAt = new Date(startAt.getTime() + 2 * 3600000);
const holidayDate = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit'
}).format(startAt);
await pool.query(
`INSERT INTO qipai_holiday_calendar (tenant_id, holiday_date, name)
VALUES (?, ?, 'M04-A Test Holiday')`,
[context.tenantId, holidayDate]
);
const repository = new PricingRepository(pool);
const quote = await repository.quote({
tenantId: context.tenantId,
roomId,
startAt,
endAt,
pricingMode: 'HOURLY',
adjustment: { discountCents: 300, packageCreditCents: 200 }
});
assert.equal(quote.rules.priceSource, 'holiday');
assert.equal(quote.subtotalCents, 3600);
assert.equal(quote.totalCents, 3600);
const attempts = await Promise.allSettled([
repository.reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt, endAt, pricingMode: 'HOURLY'
}),
repository.reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt, endAt, pricingMode: 'HOURLY'
})
]);
assert.equal(attempts.filter((item) => item.status === 'fulfilled').length, 1);
assert.equal(attempts.filter((item) =>
item.status === 'rejected'
&& item.reason instanceof PricingError
&& item.reason.code === 'TIME_SLOT_CONFLICT'
).length, 1);
const first = attempts.find((item) => item.status === 'fulfilled').value;
const [snapshotBefore] = await pool.query(
`SELECT total_cents AS totalCents, rules
FROM qipai_order_price_snapshots WHERE tenant_id = ? AND order_id = ?`,
[context.tenantId, first.orderId]
);
await pool.query(
`UPDATE qipai_rooms SET holiday_price_cents = 9900
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, roomId]
);
const [snapshotAfter] = await pool.query(
`SELECT total_cents AS totalCents, rules
FROM qipai_order_price_snapshots WHERE tenant_id = ? AND order_id = ?`,
[context.tenantId, first.orderId]
);
assert.deepEqual(snapshotAfter, snapshotBefore);
await pool.query(
`UPDATE qipai_room_reservations SET expires_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND)
WHERE tenant_id = ? AND order_id = ?`,
[context.tenantId, first.orderId]
);
assert.equal((await repository.releaseExpired(context.tenantId, roomId)).released, 1);
const replacement = await repository.reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt, endAt, pricingMode: 'FULL_DAY'
});
assert.equal(replacement.quote.unitPriceCents, 9000);
}
async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -624,7 +724,8 @@ try {
{ version: '2026061807', name: 'm03a_store_room_domain' },
{ version: '2026061808', name: 'm03b_decoration_ads_media' },
{ version: '2026061809', name: 'm03c_store_discovery' },
{ version: '2026061810', name: 'm03d_scene_wifi_access' }
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
{ version: '2026061811', name: 'm04a_pricing_reservations' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -634,13 +735,14 @@ try {
await assertContentManagement(pool, loginContext);
await assertStoreDiscovery(pool, loginContext);
await assertSceneAndWifiAccess(pool, loginContext);
await assertPricingAndReservations(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B/M01-C tables.');
console.log('PASS: down removed all M01-B through M04-A tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -655,7 +757,8 @@ try {
{ version: '2026061807', name: 'm03a_store_room_domain' },
{ version: '2026061808', name: 'm03b_decoration_ads_media' },
{ version: '2026061809', name: 'm03c_store_discovery' },
{ version: '2026061810', name: 'm03d_scene_wifi_access' }
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
{ version: '2026061811', name: 'm04a_pricing_reservations' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -709,7 +812,11 @@ try {
'scene scan statistics',
'Wi-Fi denied without active order',
'Wi-Fi allowed by active order grant',
'Wi-Fi audit excludes password'
'Wi-Fi audit excludes password',
'holiday and minimum-spend pricing',
'immutable order price snapshot',
'concurrent room hold conflict',
'expired hold release'
]
}, null, 2));
} finally {
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
const secret = 'test-only-pricing-jwt-secret-with-32-characters';
const token = signAccessToken({
sub: '21',
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7',
aid: '9',
rv: 1
}, secret, 900);
let reserveInput;
const app = await buildApp({
pricing: {
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: 'CUSTOMER',
status: 'ACTIVE',
roleVersion: 1,
nickname: '',
avatarUrl: '',
phone: ''
}
};
}
},
accessControl: {
async getAccessProfile() {
return { roles: ['CUSTOMER'], capabilities: [], storeIds: [] };
}
},
repository: {
async quote() {
return { totalCents: 2500, depositCents: 500 };
},
async reserve(input) {
reserveInput = input;
return { orderId: '31', reservationId: '41', quote: { totalCents: 2500 } };
},
async releaseExpired() {
return { released: 0 };
}
}
}
});
const startAt = new Date(Date.now() + 3600000);
const endAt = new Date(startAt.getTime() + 7200000);
const reserved = await app.inject({
method: 'POST',
url: '/app-api/orders/reserve',
headers: { authorization: `Bearer ${token}` },
payload: {
roomId: '11',
startAt: startAt.toISOString(),
endAt: endAt.toISOString(),
pricingMode: 'HOURLY',
totalCents: 1,
discountCents: 999999
}
});
assert.equal(reserved.statusCode, 201);
assert.equal(reserveInput.tenantId, '7');
assert.equal(reserveInput.userId, '21');
assert.equal('totalCents' in reserveInput, false);
assert.equal('discountCents' in reserveInput, false);
const unauthenticated = await app.inject({
method: 'POST',
url: '/app-api/pricing/quote',
payload: {
roomId: '11',
startAt: startAt.toISOString(),
endAt: endAt.toISOString()
}
});
assert.equal(unauthenticated.statusCode, 401);
await app.close();
console.log('PASS: M04-A pricing routes ignore client totals and require an authenticated session.');