perf: 优化空场监控

This commit is contained in:
richarjiang
2026-07-15 20:09:02 +08:00
parent 4b465843a8
commit 9e0b44dfd3
18 changed files with 2731 additions and 118 deletions

View File

@@ -88,6 +88,38 @@ describe('范思伯特福中福响应映射', () => {
})
})
it('把同一场地连续时段作为一笔组合订单提交并汇总金额', () => {
const first = createSlot(0, true)
const second = {
...createSlot(0, true),
beginDatetime: '2026-07-16 09:00:00',
endDatetime: '2026-07-16 09:59:00',
cost: 90
}
const slots = parseAvailabilityResponse({
successed: true,
result: { slots: [first, second] }
}, query).slots
expect(buildCreateBookingRequestBody(slots)).toEqual({
userId: 6038652,
projectType: 0,
classroomItems: [{
classroomUid: '1775556146897330236',
beginDatetime: '2026-07-16 08:00:00',
endDatetime: '2026-07-16 08:59:00',
peopleNum: 1
}, {
classroomUid: '1775556146897330236',
beginDatetime: '2026-07-16 09:00:00',
endDatetime: '2026-07-16 09:59:00',
peopleNum: 1
}],
remark: '',
combinationPayments: [{ paymentMethod: 900, cost: 170 }]
})
})
it('把下单成功响应映射为待支付锁场,不暴露支付脚本给公共订单', () => {
const slot = parseAvailabilityResponse({
successed: true,
@@ -146,7 +178,7 @@ describe('范思伯特福中福响应映射', () => {
visitorId: 'visitor-token'
}
await fansiboteFuzhongAdapter.createBooking!(slot, settings)
await fansiboteFuzhongAdapter.createBooking!([slot], settings)
await fansiboteFuzhongAdapter.cancelBooking!(settings)
const createRequest = fetchMock.mock.calls[0]?.[1] as RequestInit
@@ -181,7 +213,7 @@ describe('范思伯特福中福响应映射', () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(fansiboteFuzhongAdapter.createBooking!(slot, {
await expect(fansiboteFuzhongAdapter.createBooking!([slot], {
courtId: query.courtId
})).rejects.toThrow('PSPLVISITORID')
expect(fetchMock).not.toHaveBeenCalled()
@@ -198,7 +230,7 @@ describe('范思伯特福中福响应映射', () => {
expect(result.slots).toEqual([
expect.objectContaining({
id: '1775556146897330236:2026-07-16 08:00:00',
id: '1775556146897330236:2026-07-16 08:00:00:2026-07-16 08:59:00',
courtLabel: '1号风雨场',
startTime: '08:00',
endTime: '08:59',

View File

@@ -8,6 +8,7 @@ import type {
EnrollmentActivity
} from '../../../shared/contracts'
import type { CourtRuntimeSettings } from '../../settings/types'
import { isContinuousSingleCourtSequence, sortSlots } from '../../bookings/slotSequence'
import { ProviderMutationError, type AdapterBookingResult, type CourtAdapter } from '../types'
const AVAILABILITY_ENDPOINT =
@@ -213,7 +214,9 @@ function parseSlot(value: unknown, enrollments: ParsedEnrollment[]): BookingSlot
if (
typeof slot.txtClassroomUid !== 'string' ||
slot.txtClassroomUid.trim() === '' ||
typeof slot.classRoomName !== 'string' ||
slot.classRoomName.trim() === '' ||
typeof slot.beginDatetime !== 'string' ||
typeof slot.endDatetime !== 'string' ||
startTime === null ||
@@ -245,7 +248,7 @@ function parseSlot(value: unknown, enrollments: ParsedEnrollment[]): BookingSlot
: []
return {
id: `${slot.txtClassroomUid}:${slotBeginDatetime}`,
id: `${slot.txtClassroomUid}:${slotBeginDatetime}:${slotEndDatetime}`,
courtLabel: slot.classRoomName,
startTime,
endTime,
@@ -355,32 +358,42 @@ function createWriteHeaders(settings: CourtRuntimeSettings) {
}
export function buildCreateBookingRequestBody(
slot: BookingSlot
input: BookingSlot | BookingSlot[]
) {
if (!slot.providerReference) throw new Error('时段缺少下单引用信息')
const slots = sortSlots(Array.isArray(input) ? input : [input])
if (slots.length === 0 || slots.some((slot) => !slot.providerReference)) {
throw new Error('时段缺少下单引用信息')
}
if (!isContinuousSingleCourtSequence(slots)) {
throw new Error('下单时段不是同一场地的连续区间')
}
return {
userId: Number(DEFAULT_VENUE_ID),
projectType: 0,
classroomItems: [{
classroomUid: slot.providerReference.classroomUid,
beginDatetime: slot.providerReference.beginDatetime,
endDatetime: slot.providerReference.endDatetime,
classroomItems: slots.map((slot) => ({
classroomUid: slot.providerReference!.classroomUid,
beginDatetime: slot.providerReference!.beginDatetime,
endDatetime: slot.providerReference!.endDatetime,
peopleNum: 1
}],
})),
remark: '',
combinationPayments: [{
paymentMethod: 900,
cost: slot.priceCents / 100
cost: slots.reduce((total, slot) => total + slot.priceCents, 0) / 100
}]
}
}
export function parseCreateBookingResponse(
payload: unknown,
slot: BookingSlot,
input: BookingSlot | BookingSlot[],
courtId: string
): AdapterBookingResult {
const slots = sortSlots(Array.isArray(input) ? input : [input])
if (slots.length === 0) {
throw new ProviderMutationError('下单时段为空', 'rejected')
}
if (!isRecord(payload)) {
throw new ProviderMutationError('球场下单接口响应格式错误', 'uncertain')
}
@@ -410,10 +423,10 @@ export function parseCreateBookingResponse(
courtId,
orderId: result.apptUid,
venueAppointmentIds: result.venueAppointUids,
amountCents: slot.priceCents,
courtLabel: slot.courtLabel,
startTime: slot.startTime,
endTime: slot.endTime,
amountCents: slots.reduce((total, slot) => total + slot.priceCents, 0),
courtLabel: slots[0]!.courtLabel,
startTime: slots[0]!.startTime,
endTime: slots.at(-1)!.endTime,
expiresAt: result.script.expireTime,
status: 'pending-payment'
},
@@ -422,15 +435,20 @@ export function parseCreateBookingResponse(
}
async function createBooking(
slot: BookingSlot,
slots: BookingSlot[],
settings: CourtRuntimeSettings
): Promise<AdapterBookingResult> {
if (slot.status !== 'available' && slot.status !== 'limited') {
if (slots.some((slot) => slot.status !== 'available' && slot.status !== 'limited')) {
throw new Error('该时段当前不可预约')
}
if (slot.enrollment) throw new Error('活动时段不能通过普通场地接口下单')
if (slots.some((slot) => slot.enrollment)) {
throw new Error('活动时段不能通过普通场地接口下单')
}
if (!isContinuousSingleCourtSequence(slots)) {
throw new Error('下单时段不是同一场地的连续区间')
}
const headers = createWriteHeaders(settings)
const requestBody = buildCreateBookingRequestBody(slot)
const requestBody = buildCreateBookingRequestBody(slots)
logDevRequest('booking.request', {
url: CREATE_BOOKING_ENDPOINT,
method: 'POST',
@@ -482,7 +500,7 @@ async function createBooking(
'rejected'
)
}
return parseCreateBookingResponse(payload, slot, settings.courtId)
return parseCreateBookingResponse(payload, slots, settings.courtId)
}
async function cancelBooking(settings: CourtRuntimeSettings): Promise<void> {

View File

@@ -7,7 +7,7 @@ import { PendingBookingStore } from '../bookings/PendingBookingStore'
import { AdapterRegistry } from './registry'
const courtId = 'fansibote-fuzhong'
const slotId = '1775556146897330236:2026-07-16 08:00:00'
const slotId = '1775556146897330236:2026-07-16 08:00:00:2026-07-16 08:59:00'
function availabilityPayload() {
return {
@@ -82,6 +82,145 @@ describe('AdapterRegistry 预约状态机', () => {
await expect(registry.getPendingBooking(courtId)).resolves.toBeNull()
})
it('重新校验同场连续时段并作为一笔组合订单下单', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-registry-sequence-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const secondSlotId = '1775556146897330236:2026-07-16 09:00:00:2026-07-16 09:59:00'
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
successed: true,
result: {
slots: [
...availabilityPayload().result.slots,
{
...availabilityPayload().result.slots[0],
beginDatetime: '2026-07-16 09:00:00',
endDatetime: '2026-07-16 09:59:00',
cost: 90
}
]
}
})
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
successed: true,
result: {
apptUid: 'order-sequence',
script: { expireTime: '2026-07-16 08:10:00' },
venueAppointUids: ['order-sequence']
}
})
})
vi.stubGlobal('fetch', fetchMock)
const booking = await registry.createBooking({
courtId,
date: '2026-07-16',
slotIds: [slotId, secondSlotId],
expectedPriceCents: 17000,
expectedStartTime: '08:00',
expectedEndTime: '10:00'
})
expect(booking).toEqual(expect.objectContaining({
amountCents: 17000,
startTime: '08:00',
endTime: '09:59'
}))
const writeBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))
expect(writeBody.classroomItems).toHaveLength(2)
expect(writeBody.combinationPayments).toEqual([{ paymentMethod: 900, cost: 170 }])
})
it('组合时段来自不同物理场地时拒绝发送下单请求', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-registry-cross-court-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const secondSlotId = '1775556146897330999:2026-07-16 09:00:00:2026-07-16 09:59:00'
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
successed: true,
result: {
slots: [
...availabilityPayload().result.slots,
{
...availabilityPayload().result.slots[0],
txtClassroomUid: '1775556146897330999',
classRoomName: '2号风雨场',
beginDatetime: '2026-07-16 09:00:00',
endDatetime: '2026-07-16 09:59:00'
}
]
}
})
})
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking({
courtId,
date: '2026-07-16',
slotIds: [slotId, secondSlotId],
expectedPriceCents: 16000,
expectedStartTime: '08:00',
expectedEndTime: '10:00'
})).rejects.toThrow('不是同一场地')
expect(fetchMock).toHaveBeenCalledOnce()
})
it('写前重拉后的组合不再完整覆盖预期范围时拒绝下单', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-registry-range-drift-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))
await settingsStore.update({ courtId, visitorId: 'visitor-token' })
const registry = new AdapterRegistry(
settingsStore,
new PendingBookingStore(join(directory, 'pending.json'))
)
const secondSlotId = '1775556146897330236:2026-07-16 09:00:00:2026-07-16 09:59:00'
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
successed: true,
result: {
slots: [
...availabilityPayload().result.slots,
{
...availabilityPayload().result.slots[0],
beginDatetime: '2026-07-16 09:00:00',
endDatetime: '2026-07-16 09:59:00'
}
]
}
})
})
vi.stubGlobal('fetch', fetchMock)
await expect(registry.createBooking({
courtId,
date: '2026-07-16',
slotIds: [slotId, secondSlotId],
expectedPriceCents: 16000,
expectedStartTime: '08:00',
expectedEndTime: '11:00'
})).rejects.toThrow('不能完整覆盖')
expect(fetchMock).toHaveBeenCalledOnce()
})
it('在主进程串行化并发下单,只允许生成一个锁场订单', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-concurrent-'))
const settingsStore = new CourtSettingsStore(join(directory, 'settings.json'))

View File

@@ -8,9 +8,18 @@ import type { CourtSettingsStore } from '../settings/CourtSettingsStore'
import type { PendingBookingStore } from '../bookings/PendingBookingStore'
import { fansiboteFuzhongAdapter } from './fansibote-fuzhong/adapter'
import { ProviderMutationError, type CourtAdapter } from './types'
import {
isCompleteContinuousSequence,
isContinuousSingleCourtSequence,
sortSlots
} from '../bookings/slotSequence'
const adapters: CourtAdapter[] = [fansiboteFuzhongAdapter]
interface ParsedCreateBookingInput extends Omit<CreateBookingInput, 'slotId' | 'slotIds'> {
slotIds: string[]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -107,7 +116,7 @@ export class AdapterRegistry {
return pending
}
private async createBookingExclusive(parsedInput: CreateBookingInput) {
private async createBookingExclusive(parsedInput: ParsedCreateBookingInput) {
if (await this.pendingBookingStore.get(parsedInput.courtId)) {
throw new Error('当前球场已有待支付订单,请先取消释放')
}
@@ -122,13 +131,42 @@ export class AdapterRegistry {
courtId: parsedInput.courtId,
date: parsedInput.date
})
const slot = availability.slots.find((candidate) => candidate.id === parsedInput.slotId)
if (!slot) throw new Error('目标时段已不存在,请刷新后重试')
if (slot.status !== 'available' && slot.status !== 'limited') {
throw new Error('目标时段已不可预约,请刷新列表')
const selectedSlots = parsedInput.slotIds.map((slotId) =>
availability.slots.filter((candidate) => candidate.id === slotId)
)
if (selectedSlots.some((matches) => matches.length !== 1)) {
throw new Error('目标连续时段已不完整,请刷新后重试')
}
if (slot.enrollment) throw new Error('活动时段不能通过普通场地接口下单')
if (slot.priceCents !== parsedInput.expectedPriceCents) {
const slots = sortSlots(selectedSlots.map((matches) => matches[0]!))
if (slots.some((slot) => slot.status !== 'available' && slot.status !== 'limited')) {
throw new Error('目标连续时段已不可预约,请刷新列表')
}
if (slots.some((slot) => slot.enrollment)) {
throw new Error('活动时段不能通过普通场地接口下单')
}
if (slots.some((slot) =>
!slot.providerReference ||
slot.providerReference.beginDatetime.slice(0, 10) !== parsedInput.date ||
slot.providerReference.endDatetime.slice(0, 10) !== parsedInput.date
)) {
throw new Error('目标时段日期与预约日期不一致')
}
if (!isContinuousSingleCourtSequence(slots)) {
throw new Error('目标时段不是同一场地的连续区间')
}
if (
parsedInput.expectedStartTime &&
parsedInput.expectedEndTime &&
!isCompleteContinuousSequence(
slots,
parsedInput.expectedStartTime,
parsedInput.expectedEndTime
)
) {
throw new Error('目标时段已不能完整覆盖监控时间范围')
}
const totalPriceCents = slots.reduce((total, slot) => total + slot.priceCents, 0)
if (totalPriceCents !== parsedInput.expectedPriceCents) {
throw new Error('价格已变化,请刷新列表并重新确认金额')
}
adapter.preflightWriteSettings?.(settings)
@@ -137,17 +175,17 @@ export class AdapterRegistry {
courtId: parsedInput.courtId,
orderId: `uncertain:${Date.now()}`,
venueAppointmentIds: [],
amountCents: slot.priceCents,
courtLabel: slot.courtLabel,
startTime: slot.startTime,
endTime: slot.endTime,
amountCents: totalPriceCents,
courtLabel: slots[0]!.courtLabel,
startTime: slots[0]!.startTime,
endTime: slots.at(-1)!.endTime,
status: 'submitting-uncertain' as const
}
await this.pendingBookingStore.set(uncertainBooking)
let result
try {
result = await adapter.createBooking(slot, settings)
result = await adapter.createBooking(slots, settings)
} catch (error) {
if (error instanceof ProviderMutationError && error.outcome === 'rejected') {
await this.pendingBookingStore.clear(parsedInput.courtId)
@@ -220,12 +258,29 @@ export class AdapterRegistry {
this.releaseConfirmedCourts.delete(input.courtId)
}
private parseCreateBookingInput(input: unknown): CreateBookingInput {
private parseCreateBookingInput(input: unknown): ParsedCreateBookingInput {
const hasSingleSlot = isRecord(input) && typeof input.slotId === 'string'
const slotIds = isRecord(input) && Array.isArray(input.slotIds)
? input.slotIds
: null
const hasSlotList = slotIds !== null
if (
!isRecord(input) ||
typeof input.courtId !== 'string' ||
typeof input.date !== 'string' ||
typeof input.slotId !== 'string' ||
hasSingleSlot === hasSlotList ||
(hasSlotList && (
slotIds.length === 0 ||
!slotIds.every((slotId) => typeof slotId === 'string') ||
new Set(slotIds).size !== slotIds.length
)) ||
(input.expectedStartTime !== undefined && typeof input.expectedStartTime !== 'string') ||
(input.expectedEndTime !== undefined && typeof input.expectedEndTime !== 'string') ||
((input.expectedStartTime === undefined) !== (input.expectedEndTime === undefined)) ||
(hasSlotList && slotIds.length > 1 && (
typeof input.expectedStartTime !== 'string' ||
typeof input.expectedEndTime !== 'string'
)) ||
typeof input.expectedPriceCents !== 'number' ||
!Number.isSafeInteger(input.expectedPriceCents) ||
input.expectedPriceCents < 0
@@ -235,8 +290,14 @@ export class AdapterRegistry {
return {
courtId: input.courtId,
date: input.date,
slotId: input.slotId,
expectedPriceCents: input.expectedPriceCents
slotIds: hasSingleSlot ? [input.slotId as string] : [...slotIds as string[]],
expectedPriceCents: input.expectedPriceCents,
...(typeof input.expectedStartTime === 'string'
? {
expectedStartTime: input.expectedStartTime,
expectedEndTime: input.expectedEndTime as string
}
: {})
}
}

View File

@@ -28,7 +28,7 @@ export interface CourtAdapter {
getAvailability(query: AvailabilityQuery): Promise<AvailabilityDay>
preflightWriteSettings?(settings: CourtRuntimeSettings): void
createBooking?(
slot: BookingSlot,
slots: BookingSlot[],
settings: CourtRuntimeSettings
): Promise<AdapterBookingResult>
cancelBooking?(settings: CourtRuntimeSettings): Promise<void>