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.');
@@ -0,0 +1,8 @@
DELETE FROM qipai_schema_migrations WHERE version = '2026061811';
DROP TABLE IF EXISTS qipai_room_reservations;
DROP TABLE IF EXISTS qipai_order_price_snapshots;
DROP TABLE IF EXISTS qipai_holiday_calendar;
ALTER TABLE qipai_orders DROP COLUMN hold_expires_at;
ALTER TABLE qipai_rooms
DROP COLUMN minimum_spend_cents,
DROP COLUMN full_day_price_cents;
@@ -0,0 +1,60 @@
ALTER TABLE qipai_rooms
ADD COLUMN full_day_price_cents INT UNSIGNED NOT NULL DEFAULT 0 AFTER overnight_price_cents,
ADD COLUMN minimum_spend_cents INT UNSIGNED NOT NULL DEFAULT 0 AFTER full_day_price_cents;
ALTER TABLE qipai_orders
ADD COLUMN hold_expires_at DATETIME(3) NULL AFTER end_at;
CREATE TABLE IF NOT EXISTS qipai_holiday_calendar (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
holiday_date DATE NOT NULL,
name VARCHAR(128) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_holiday_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
UNIQUE KEY uq_qipai_holiday_date (tenant_id, holiday_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_order_price_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'CNY',
pricing_mode VARCHAR(16) NOT NULL,
duration_minutes INT UNSIGNED NOT NULL,
unit_price_cents INT UNSIGNED NOT NULL,
subtotal_cents INT UNSIGNED NOT NULL,
minimum_spend_cents INT UNSIGNED NOT NULL DEFAULT 0,
deposit_cents INT UNSIGNED NOT NULL DEFAULT 0,
discount_cents INT UNSIGNED NOT NULL DEFAULT 0,
package_credit_cents INT UNSIGNED NOT NULL DEFAULT 0,
total_cents INT UNSIGNED NOT NULL,
rules JSON NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_price_snapshot_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_price_snapshot_order FOREIGN KEY (order_id) REFERENCES qipai_orders(id),
UNIQUE KEY uq_qipai_price_snapshot_order (tenant_id, order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_room_reservations (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
room_id BIGINT UNSIGNED NOT NULL,
starts_at DATETIME(3) NOT NULL,
ends_at DATETIME(3) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'HELD',
expires_at DATETIME(3) NOT NULL,
released_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_reservation_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_reservation_order FOREIGN KEY (order_id) REFERENCES qipai_orders(id),
CONSTRAINT fk_qipai_reservation_room FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
UNIQUE KEY uq_qipai_reservation_order (tenant_id, order_id),
KEY idx_qipai_reservation_overlap (tenant_id, room_id, status, starts_at, ends_at),
KEY idx_qipai_reservation_expiry (status, expires_at),
CONSTRAINT chk_qipai_reservation_window CHECK (ends_at > starts_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026061811', 'm04a_pricing_reservations');
@@ -0,0 +1,27 @@
SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'qipai_rooms'
AND column_name IN ('full_day_price_cents', 'minimum_spend_cents')
ORDER BY column_name;
SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'qipai_orders'
AND column_name = 'hold_expires_at';
SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN (
'qipai_holiday_calendar',
'qipai_order_price_snapshots',
'qipai_room_reservations'
)
ORDER BY table_name;
SELECT table_name, index_name FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND ((table_name = 'qipai_order_price_snapshots'
AND index_name = 'uq_qipai_price_snapshot_order')
OR (table_name = 'qipai_room_reservations'
AND index_name IN ('idx_qipai_reservation_overlap', 'idx_qipai_reservation_expiry')))
GROUP BY table_name, index_name ORDER BY table_name, index_name;
SELECT version, name FROM qipai_schema_migrations WHERE version = '2026061811';
@@ -0,0 +1,7 @@
# M04-A 定价与时段预占 API
- `POST /app-api/pricing/quote`:按房间配置、节假日和定价模式返回服务端报价。
- `POST /app-api/orders/reserve`:事务内锁定房间、检查重叠并创建待支付订单、价格快照和限时预占。
- `POST /admin-api/reservations/release-expired`:租户管理员释放已过期待支付预占。
客户端提交的最终金额、折扣或套餐抵扣字段会被忽略。优惠和套餐抵扣只允许后续 M05 的可信服务端权益模块传入定价引擎。
@@ -0,0 +1,10 @@
# M04-A 定价与预占数据库变更
- 迁移版本:`2026061811`
- 房间新增包场价和最低消费。
- 订单新增预占过期时间。
- `qipai_holiday_calendar`:租户节假日日历。
- `qipai_order_price_snapshots`:不可变订单价格快照。
- `qipai_room_reservations`:房间时段预占、过期和释放状态。
并发控制使用事务锁定房间行,再检查重叠预占;不引入 Redis。
@@ -0,0 +1,27 @@
# M04-A 定价引擎和可用性
- 日期:2026-06-18
- 起始 commit`6be7fa7`
- 工程 commit:本阶段工程提交
- ENGINEERING_DELTA=YES
- 子阶段状态:待 push 与远端校验
## 工程增量
- 统一小时、工作日、节假日、通宵和包场定价。
- 支持最低消费、押金及可信服务端优惠/套餐抵扣。
- 创建订单时保存独立价格快照。
- 房间行锁串行化并发预占,事务内检查重叠。
- 待支付预占带过期时间,报价、预占和管理入口均可释放过期占位。
- 客户端最终金额、优惠和套餐字段不进入定价输入。
## 验证
- Windows 后端全量测试通过。
- WSL MySQL 8.4.9 往返迁移通过。
- 迁移语句:up 56、verify 37、down 53。
- 实测节假日/最低消费定价、历史快照不变、同房同时段并发仅一个成功、过期释放后可重新预占。
## 后续
M04-B 建立完整订单状态机、状态迁移历史和来源/原因审计。
+2 -2
View File
@@ -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 M03-D migration roundtrip in a temporary database."
echo "INFO: MySQL ${mysql_version}; running M01-B through M04-A migration roundtrip in a temporary database."
npm --prefix backend run test:mysql:migration
echo "PASS: M01-B through M03-D live MySQL migration roundtrip completed."
echo "PASS: M01-B through M04-A live MySQL migration roundtrip completed."