perf: 优化空场监控
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
src/main/bookings/slotSequence.test.ts
Normal file
39
src/main/bookings/slotSequence.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BookingSlot } from '../../shared/contracts'
|
||||
import { findBestCompleteContinuousSequence } from './slotSequence'
|
||||
|
||||
function slot(id: string, startTime: string, endTime: string, priceCents = 8000): BookingSlot {
|
||||
return {
|
||||
id,
|
||||
courtLabel: '1号风雨场',
|
||||
startTime,
|
||||
endTime,
|
||||
priceCents,
|
||||
status: 'available',
|
||||
providerReference: {
|
||||
classroomUid: 'court-1',
|
||||
beginDatetime: `2026-07-16 ${startTime}:00`,
|
||||
endDatetime: `2026-07-16 ${endTime}:00`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('连续场地组合', () => {
|
||||
it('存在重复和重叠时段时仍能找到精确覆盖范围的稳定路径', () => {
|
||||
const result = findBestCompleteContinuousSequence([
|
||||
slot('duplicate-expensive', '19:00', '19:59', 9000),
|
||||
slot('first', '19:00', '19:59', 8000),
|
||||
slot('overlap', '19:00', '20:29', 20000),
|
||||
slot('second', '20:00', '20:59', 8000)
|
||||
], '19:00', '21:00')
|
||||
|
||||
expect(result?.map((item) => item.id)).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('没有任何路径完整到达结束时间时返回空', () => {
|
||||
expect(findBestCompleteContinuousSequence([
|
||||
slot('first', '19:00', '19:59'),
|
||||
slot('gap', '20:30', '20:59')
|
||||
], '19:00', '21:00')).toBeNull()
|
||||
})
|
||||
})
|
||||
94
src/main/bookings/slotSequence.ts
Normal file
94
src/main/bookings/slotSequence.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { BookingSlot } from '../../shared/contracts'
|
||||
|
||||
export function timeMinutes(time: string) {
|
||||
const [hour, minute] = time.split(':').map(Number)
|
||||
return hour! * 60 + minute!
|
||||
}
|
||||
|
||||
export function slotEndExclusiveMinutes(slot: Pick<BookingSlot, 'endTime'>) {
|
||||
const end = timeMinutes(slot.endTime)
|
||||
return end % 60 === 59 ? end + 1 : end
|
||||
}
|
||||
|
||||
export function physicalCourtKey(slot: BookingSlot) {
|
||||
return slot.providerReference?.classroomUid ?? slot.courtLabel
|
||||
}
|
||||
|
||||
export function sortSlots(slots: BookingSlot[]) {
|
||||
return [...slots].sort((left, right) =>
|
||||
timeMinutes(left.startTime) - timeMinutes(right.startTime) ||
|
||||
slotEndExclusiveMinutes(left) - slotEndExclusiveMinutes(right) ||
|
||||
left.id.localeCompare(right.id)
|
||||
)
|
||||
}
|
||||
|
||||
export function isCompleteContinuousSequence(
|
||||
slots: BookingSlot[],
|
||||
startTime: string,
|
||||
endTime: string
|
||||
) {
|
||||
if (slots.length === 0) return false
|
||||
const ordered = sortSlots(slots)
|
||||
if (!isContinuousSingleCourtSequence(ordered)) return false
|
||||
if (timeMinutes(ordered[0]!.startTime) !== timeMinutes(startTime)) return false
|
||||
if (slotEndExclusiveMinutes(ordered.at(-1)!) !== timeMinutes(endTime)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function isContinuousSingleCourtSequence(slots: BookingSlot[]) {
|
||||
if (slots.length === 0) return false
|
||||
const ordered = sortSlots(slots)
|
||||
if (new Set(ordered.map(physicalCourtKey)).size !== 1) return false
|
||||
return ordered.every((slot, index) =>
|
||||
index === 0 ||
|
||||
timeMinutes(slot.startTime) === slotEndExclusiveMinutes(ordered[index - 1]!)
|
||||
)
|
||||
}
|
||||
|
||||
function sequenceRank(slots: BookingSlot[]) {
|
||||
return {
|
||||
price: slots.reduce((total, slot) => total + slot.priceCents, 0),
|
||||
count: slots.length,
|
||||
ids: slots.map((slot) => slot.id).join('|')
|
||||
}
|
||||
}
|
||||
|
||||
function betterSequence(left: BookingSlot[], right: BookingSlot[] | null) {
|
||||
if (!right) return true
|
||||
const leftRank = sequenceRank(left)
|
||||
const rightRank = sequenceRank(right)
|
||||
return leftRank.price < rightRank.price ||
|
||||
(leftRank.price === rightRank.price && leftRank.count < rightRank.count) ||
|
||||
(leftRank.price === rightRank.price &&
|
||||
leftRank.count === rightRank.count &&
|
||||
leftRank.ids.localeCompare(rightRank.ids) < 0)
|
||||
}
|
||||
|
||||
export function findBestCompleteContinuousSequence(
|
||||
slots: BookingSlot[],
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): BookingSlot[] | null {
|
||||
const targetStart = timeMinutes(startTime)
|
||||
const targetEnd = timeMinutes(endTime)
|
||||
const candidates = sortSlots(slots).filter((slot) => {
|
||||
const start = timeMinutes(slot.startTime)
|
||||
const end = slotEndExclusiveMinutes(slot)
|
||||
return start >= targetStart && end <= targetEnd && end > start
|
||||
})
|
||||
const memo = new Map<number, BookingSlot[] | null>()
|
||||
const solve = (cursor: number): BookingSlot[] | null => {
|
||||
if (cursor === targetEnd) return []
|
||||
if (memo.has(cursor)) return memo.get(cursor) ?? null
|
||||
let best: BookingSlot[] | null = null
|
||||
for (const slot of candidates.filter((candidate) => timeMinutes(candidate.startTime) === cursor)) {
|
||||
const remainder = solve(slotEndExclusiveMinutes(slot))
|
||||
if (!remainder) continue
|
||||
const sequence = [slot, ...remainder]
|
||||
if (betterSequence(sequence, best)) best = sequence
|
||||
}
|
||||
memo.set(cursor, best)
|
||||
return best
|
||||
}
|
||||
return solve(targetStart)
|
||||
}
|
||||
@@ -21,6 +21,8 @@ app.setName(APPLICATION_NAME)
|
||||
|
||||
let monitorService: MonitorService | null = null
|
||||
let monitorServiceStartError: string | null = null
|
||||
let monitorStopPromise: Promise<void> | null = null
|
||||
let allowQuitAfterMonitorFlush = false
|
||||
const pendingMonitorAlerts: import('../shared/contracts').MonitorAlert[] = []
|
||||
|
||||
function createWindow() {
|
||||
@@ -138,6 +140,10 @@ app.whenReady().then(() => {
|
||||
if (monitorServiceStartError) throw new Error(monitorServiceStartError)
|
||||
return monitorService!.listTasks()
|
||||
})
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.getMonitorTaskDetail,
|
||||
(_event, taskId: unknown) => monitorService!.getTaskDetail(taskId)
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.createMonitorTask,
|
||||
(_event, input: unknown) => monitorService!.createTask(input)
|
||||
@@ -147,6 +153,11 @@ app.whenReady().then(() => {
|
||||
(_event, taskId: unknown, enabled: unknown) =>
|
||||
monitorService!.setEnabled(taskId, enabled)
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.setMonitorTaskAutoBooking,
|
||||
(_event, taskId: unknown, enabled: unknown) =>
|
||||
monitorService!.setAutoBookingEnabled(taskId, enabled)
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.deleteMonitorTask,
|
||||
(_event, taskId: unknown) => monitorService!.deleteTask(taskId)
|
||||
@@ -167,7 +178,20 @@ app.whenReady().then(() => {
|
||||
})
|
||||
}
|
||||
|
||||
app.on('before-quit', () => monitorService?.stop())
|
||||
app.on('before-quit', (event) => {
|
||||
if (!monitorService || allowQuitAfterMonitorFlush) return
|
||||
event.preventDefault()
|
||||
if (monitorStopPromise) return
|
||||
monitorStopPromise = monitorService.stop()
|
||||
void monitorStopPromise
|
||||
.catch((error: unknown) => {
|
||||
console.error('[tennis-book][monitor-stop]', error)
|
||||
})
|
||||
.finally(() => {
|
||||
allowQuitAfterMonitorFlush = true
|
||||
app.quit()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AvailabilityDay } from '../../shared/contracts'
|
||||
import type { AdapterRegistry } from '../adapters/registry'
|
||||
import { MonitorService } from './MonitorService'
|
||||
import { MonitorTaskStore } from './MonitorTaskStore'
|
||||
import { createAutoBookingState, MonitorTaskStore } from './MonitorTaskStore'
|
||||
|
||||
function availability(
|
||||
status: 'available' | 'booked',
|
||||
@@ -26,6 +26,40 @@ function availability(
|
||||
}
|
||||
}
|
||||
|
||||
function eveningAvailability(
|
||||
slots: Array<{ courtLabel: string; startTime: string; endTime: string }>
|
||||
): AvailabilityDay {
|
||||
return {
|
||||
courtId: 'fansibote-fuzhong',
|
||||
date: '2026-07-16',
|
||||
updatedAt: new Date().toISOString(),
|
||||
slots: slots.map((slot, index) => ({
|
||||
id: `slot-evening-${index}`,
|
||||
courtLabel: slot.courtLabel,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
priceCents: 8000,
|
||||
status: 'available',
|
||||
providerReference: {
|
||||
classroomUid: slot.courtLabel,
|
||||
beginDatetime: `2026-07-16 ${slot.startTime}:00`,
|
||||
endDatetime: `2026-07-16 ${slot.endTime}:00`
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const pendingBooking = {
|
||||
courtId: 'fansibote-fuzhong',
|
||||
orderId: 'order-auto-1',
|
||||
venueAppointmentIds: ['order-auto-1'],
|
||||
amountCents: 8000,
|
||||
courtLabel: '1号风雨场',
|
||||
startTime: '08:00',
|
||||
endTime: '08:59',
|
||||
status: 'pending-payment' as const
|
||||
}
|
||||
|
||||
describe('MonitorService', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
@@ -64,7 +98,7 @@ describe('MonitorService', () => {
|
||||
await service.runOnceForTest()
|
||||
expect(onAlert).toHaveBeenCalledTimes(1)
|
||||
expect(onAlert).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
slotLabels: ['1号风雨场 08:00–08:59']
|
||||
slotLabels: ['1号风雨场 08:00–09:00']
|
||||
}))
|
||||
|
||||
vi.advanceTimersByTime(10_000)
|
||||
@@ -79,6 +113,398 @@ describe('MonitorService', () => {
|
||||
expect(getAvailability).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('进入整点时绕过 10 秒冷却立即检查且同一整点不重复触发', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:59:55.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-hourly-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: []
|
||||
})
|
||||
const getAvailability = vi.fn().mockResolvedValue(availability('booked'))
|
||||
const service = new MonitorService({
|
||||
getAvailability,
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
await service.runOnceForTest()
|
||||
expect(getAvailability).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(5_000)
|
||||
await service.runOnceForTest()
|
||||
expect(getAvailability).toHaveBeenCalledTimes(2)
|
||||
|
||||
await service.runOnceForTest()
|
||||
expect(getAvailability).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('自动预定成功后转为待支付且后续轮询不再下单', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-auto-book-success-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: [],
|
||||
autoBooking: createAutoBookingState(true)
|
||||
})
|
||||
const createBooking = vi.fn().mockResolvedValue(pendingBooking)
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('available')),
|
||||
getPendingBooking: vi.fn().mockResolvedValue(null),
|
||||
createBooking,
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
await service.runOnceForTest()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
await service.runOnceForTest()
|
||||
|
||||
expect(createBooking).toHaveBeenCalledTimes(1)
|
||||
expect(createBooking).toHaveBeenCalledWith(expect.objectContaining({
|
||||
date: '2026-07-16',
|
||||
slotIds: ['slot-08'],
|
||||
expectedPriceCents: 8000
|
||||
}))
|
||||
expect((await service.getTaskDetail('task-1')).task.autoBooking).toEqual(expect.objectContaining({
|
||||
enabled: false,
|
||||
status: 'pending-payment',
|
||||
attemptCount: 1,
|
||||
orderId: 'order-auto-1'
|
||||
}))
|
||||
})
|
||||
|
||||
it('自动预定明确失败后只重试一次并熔断', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-auto-book-retry-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: [],
|
||||
autoBooking: createAutoBookingState(true)
|
||||
})
|
||||
const createBooking = vi.fn().mockRejectedValue(new Error('provider rejected'))
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('available')),
|
||||
getPendingBooking: vi.fn().mockResolvedValue(null),
|
||||
createBooking,
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
await service.runOnceForTest()
|
||||
|
||||
expect(createBooking).toHaveBeenCalledTimes(2)
|
||||
const autoBooking = (await service.getTaskDetail('task-1')).task.autoBooking
|
||||
expect(autoBooking).toEqual(expect.objectContaining({
|
||||
enabled: false,
|
||||
status: 'stopped',
|
||||
attemptCount: 2,
|
||||
lastError: 'provider rejected'
|
||||
}))
|
||||
})
|
||||
|
||||
it('第一次明确失败后重试成功并立即停止后续自动下单', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-auto-book-retry-success-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: [],
|
||||
autoBooking: createAutoBookingState(true)
|
||||
})
|
||||
const createBooking = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('provider rejected'))
|
||||
.mockResolvedValueOnce(pendingBooking)
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('available')),
|
||||
getPendingBooking: vi.fn().mockResolvedValue(null),
|
||||
createBooking,
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
await service.runOnceForTest()
|
||||
|
||||
expect(createBooking).toHaveBeenCalledTimes(2)
|
||||
expect((await service.getTaskDetail('task-1')).task.autoBooking).toEqual(expect.objectContaining({
|
||||
enabled: false,
|
||||
status: 'pending-payment',
|
||||
attemptCount: 2,
|
||||
orderId: 'order-auto-1'
|
||||
}))
|
||||
})
|
||||
|
||||
it('自动预定结果不确定时不重试并立即熔断', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-auto-book-uncertain-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: [],
|
||||
autoBooking: createAutoBookingState(true)
|
||||
})
|
||||
const uncertain = { ...pendingBooking, status: 'submitting-uncertain' as const }
|
||||
const getPendingBooking = vi.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(uncertain)
|
||||
const createBooking = vi.fn().mockRejectedValue(new Error('connection reset'))
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('available')),
|
||||
getPendingBooking,
|
||||
createBooking,
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
await service.runOnceForTest()
|
||||
|
||||
expect(createBooking).toHaveBeenCalledTimes(1)
|
||||
expect((await service.getTaskDetail('task-1')).task.autoBooking).toEqual(expect.objectContaining({
|
||||
enabled: false,
|
||||
status: 'blocked',
|
||||
attemptCount: 1,
|
||||
lastError: 'connection reset'
|
||||
}))
|
||||
})
|
||||
|
||||
it('同一球场已有待处理订单时只轮询且不调用下单', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-auto-book-pending-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: [],
|
||||
autoBooking: createAutoBookingState(true)
|
||||
})
|
||||
const createBooking = vi.fn()
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('available')),
|
||||
getPendingBooking: vi.fn().mockResolvedValue(pendingBooking),
|
||||
createBooking,
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
await service.runOnceForTest()
|
||||
|
||||
expect(createBooking).not.toHaveBeenCalled()
|
||||
expect((await service.getTaskDetail('task-1')).task.autoBooking).toEqual(expect.objectContaining({
|
||||
enabled: false,
|
||||
status: 'blocked'
|
||||
}))
|
||||
})
|
||||
|
||||
it('用户关闭自动预定后使在途列表响应失效且不下单', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-auto-book-disable-race-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: [],
|
||||
autoBooking: createAutoBookingState(true)
|
||||
})
|
||||
let releaseAttemptWrite!: () => void
|
||||
let markAttemptWritten!: () => void
|
||||
const attemptWritten = new Promise<void>((resolve) => {
|
||||
markAttemptWritten = resolve
|
||||
})
|
||||
const attemptWriteGate = new Promise<void>((resolve) => {
|
||||
releaseAttemptWrite = resolve
|
||||
})
|
||||
const originalUpdate = store.update.bind(store)
|
||||
let updateCount = 0
|
||||
vi.spyOn(store, 'update').mockImplementation(async (...args) => {
|
||||
updateCount += 1
|
||||
const result = await originalUpdate(...args)
|
||||
if (updateCount === 2) {
|
||||
markAttemptWritten()
|
||||
await attemptWriteGate
|
||||
}
|
||||
return result
|
||||
})
|
||||
const createBooking = vi.fn()
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('available')),
|
||||
getPendingBooking: vi.fn().mockResolvedValue(null),
|
||||
createBooking,
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
const checking = service.runOnceForTest()
|
||||
await attemptWritten
|
||||
const disabling = service.setAutoBookingEnabled('task-1', false)
|
||||
await disabling
|
||||
releaseAttemptWrite()
|
||||
await checking
|
||||
|
||||
expect(createBooking).not.toHaveBeenCalled()
|
||||
expect((await service.getTaskDetail('task-1')).task.autoBooking.status).toBe('disabled')
|
||||
})
|
||||
|
||||
it('锁场成功后任务状态写入失败时以内存待支付状态阻止重复下单', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-auto-book-state-write-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: [],
|
||||
autoBooking: createAutoBookingState(true)
|
||||
})
|
||||
const originalUpdate = store.update.bind(store)
|
||||
let updateCount = 0
|
||||
vi.spyOn(store, 'update').mockImplementation(async (...args) => {
|
||||
updateCount += 1
|
||||
if (updateCount >= 3) throw new Error('disk unavailable')
|
||||
return originalUpdate(...args)
|
||||
})
|
||||
const createBooking = vi.fn().mockResolvedValue(pendingBooking)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('available')),
|
||||
getPendingBooking: vi.fn().mockResolvedValue(null),
|
||||
createBooking,
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
await service.runOnceForTest()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(createBooking).toHaveBeenCalledTimes(1)
|
||||
expect((await service.getTaskDetail('task-1')).task.autoBooking).toEqual(expect.objectContaining({
|
||||
enabled: false,
|
||||
status: 'pending-payment',
|
||||
orderId: 'order-auto-1'
|
||||
}))
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('重启时将结果不确定的自动下单恢复为阻止状态而不是待支付', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-auto-book-recovery-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: [],
|
||||
autoBooking: {
|
||||
enabled: true,
|
||||
status: 'attempting',
|
||||
attemptCount: 1,
|
||||
lastAttemptAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
const uncertain = { ...pendingBooking, status: 'submitting-uncertain' as const }
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('booked')),
|
||||
getPendingBooking: vi.fn().mockResolvedValue(uncertain),
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
await service.start()
|
||||
|
||||
expect((await service.getTaskDetail('task-1')).task.autoBooking).toEqual(expect.objectContaining({
|
||||
enabled: false,
|
||||
status: 'blocked',
|
||||
lastError: expect.stringContaining('待人工核实')
|
||||
}))
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it('重启时仅将确认成功的订单恢复为待支付', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-auto-book-pending-recovery-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: [],
|
||||
autoBooking: {
|
||||
enabled: true,
|
||||
status: 'attempting',
|
||||
attemptCount: 1,
|
||||
lastAttemptAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('booked')),
|
||||
getPendingBooking: vi.fn().mockResolvedValue(pendingBooking),
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
await service.start()
|
||||
|
||||
expect((await service.getTaskDetail('task-1')).task.autoBooking).toEqual(expect.objectContaining({
|
||||
enabled: false,
|
||||
status: 'pending-payment',
|
||||
orderId: 'order-auto-1'
|
||||
}))
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it('时间范围外的空场不提醒', async () => {
|
||||
vi.useFakeTimers()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-range-'))
|
||||
@@ -104,6 +530,155 @@ describe('MonitorService', () => {
|
||||
expect(onAlert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('19至21点只有一个小时可订时不算完整空场且不自动下单', async () => {
|
||||
vi.useFakeTimers()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-incomplete-range-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '19:00',
|
||||
endTime: '21:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: [],
|
||||
autoBooking: createAutoBookingState(true)
|
||||
})
|
||||
const createBooking = vi.fn()
|
||||
const onAlert = vi.fn()
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(eveningAvailability([
|
||||
{ courtLabel: '1号风雨场', startTime: '19:00', endTime: '19:59' }
|
||||
])),
|
||||
getPendingBooking: vi.fn().mockResolvedValue(null),
|
||||
createBooking,
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, onAlert)
|
||||
|
||||
await service.runOnceForTest()
|
||||
|
||||
expect((await service.listTasks())[0]?.availableCount).toBe(0)
|
||||
expect(onAlert).not.toHaveBeenCalled()
|
||||
expect(createBooking).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('19至21点同一场连续两小时才算一个空场并整组自动下单', async () => {
|
||||
vi.useFakeTimers()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-complete-range-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '19:00',
|
||||
endTime: '21:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: [],
|
||||
autoBooking: createAutoBookingState(true)
|
||||
})
|
||||
const createBooking = vi.fn().mockResolvedValue({
|
||||
...pendingBooking,
|
||||
amountCents: 16000,
|
||||
startTime: '19:00',
|
||||
endTime: '20:59'
|
||||
})
|
||||
const onAlert = vi.fn()
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(eveningAvailability([
|
||||
{ courtLabel: '1号风雨场', startTime: '19:00', endTime: '19:59' },
|
||||
{ courtLabel: '1号风雨场', startTime: '20:00', endTime: '20:59' }
|
||||
])),
|
||||
getPendingBooking: vi.fn().mockResolvedValue(null),
|
||||
createBooking,
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, onAlert)
|
||||
|
||||
await service.runOnceForTest()
|
||||
|
||||
expect((await service.listTasks())[0]?.availableCount).toBe(1)
|
||||
expect(onAlert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
slotLabels: ['1号风雨场 19:00–21:00']
|
||||
}))
|
||||
expect(createBooking).toHaveBeenCalledWith({
|
||||
courtId: 'fansibote-fuzhong',
|
||||
date: '2026-07-16',
|
||||
slotIds: ['slot-evening-0', 'slot-evening-1'],
|
||||
expectedPriceCents: 16000,
|
||||
expectedStartTime: '19:00',
|
||||
expectedEndTime: '21:00'
|
||||
})
|
||||
})
|
||||
|
||||
it('旧版已记录的两个单小时升级后迁移为组合去重且不重复提醒下单', async () => {
|
||||
vi.useFakeTimers()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-sequence-migration-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '19:00',
|
||||
endTime: '21:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: [
|
||||
'1号风雨场:2026-07-16 19:00:00',
|
||||
'1号风雨场:2026-07-16 20:00:00'
|
||||
],
|
||||
autoBooking: createAutoBookingState(true)
|
||||
})
|
||||
const createBooking = vi.fn()
|
||||
const onAlert = vi.fn()
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(eveningAvailability([
|
||||
{ courtLabel: '1号风雨场', startTime: '19:00', endTime: '19:59' },
|
||||
{ courtLabel: '1号风雨场', startTime: '20:00', endTime: '20:59' }
|
||||
])),
|
||||
getPendingBooking: vi.fn().mockResolvedValue(null),
|
||||
createBooking,
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, onAlert)
|
||||
|
||||
await service.runOnceForTest()
|
||||
|
||||
expect(onAlert).not.toHaveBeenCalled()
|
||||
expect(createBooking).not.toHaveBeenCalled()
|
||||
expect((await store.list())[0]?.seenAvailableSlotIds).toEqual([
|
||||
'1号风雨场:slot-evening-0|slot-evening-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('两个小时分别属于不同场地时不拼成连续空场', async () => {
|
||||
vi.useFakeTimers()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-cross-court-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '19:00',
|
||||
endTime: '21:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: []
|
||||
})
|
||||
const onAlert = vi.fn()
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(eveningAvailability([
|
||||
{ courtLabel: '1号风雨场', startTime: '19:00', endTime: '19:59' },
|
||||
{ courtLabel: '2号风雨场', startTime: '20:00', endTime: '20:59' }
|
||||
])),
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, onAlert)
|
||||
|
||||
await service.runOnceForTest()
|
||||
|
||||
expect((await service.listTasks())[0]?.availableCount).toBe(0)
|
||||
expect(onAlert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('上游误标为可订的双打活动不计入空场且不提醒', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
@@ -166,6 +741,211 @@ describe('MonitorService', () => {
|
||||
expect(getAvailability).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('高频检查只更新内存遥测,停止服务时一次批量持久化', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-batch-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
const baseTask = {
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: []
|
||||
}
|
||||
await store.add({ id: 'task-1', startTime: '08:00', endTime: '09:00', ...baseTask })
|
||||
await store.add({ id: 'task-2', startTime: '09:00', endTime: '10:00', ...baseTask })
|
||||
const telemetryWrite = vi.spyOn(store, 'updateTelemetry')
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('booked')),
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
await service.runOnceForTest()
|
||||
expect(telemetryWrite).not.toHaveBeenCalled()
|
||||
|
||||
await service.stop()
|
||||
expect(telemetryWrite).toHaveBeenCalledOnce()
|
||||
expect((await store.list()).map((task) => task.telemetry.stats.totalChecks)).toEqual([1, 1])
|
||||
})
|
||||
|
||||
it('通知派发异常不把成功检查重复记录成失败', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-alert-error-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: []
|
||||
})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('available')),
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, () => {
|
||||
throw new Error('notification failed')
|
||||
})
|
||||
|
||||
await service.runOnceForTest()
|
||||
|
||||
expect((await service.getTaskDetail('task-1')).task.stats).toEqual(expect.objectContaining({
|
||||
totalChecks: 1,
|
||||
successfulChecks: 1,
|
||||
failedChecks: 0,
|
||||
alertsSent: 0
|
||||
}))
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('提醒统计落盘失败时保留成功检查语义并等待后续重试', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-flush-error-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: []
|
||||
})
|
||||
vi.spyOn(store, 'updateTelemetry').mockRejectedValueOnce(new Error('disk unavailable'))
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('available')),
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
await service.runOnceForTest()
|
||||
|
||||
const detail = await service.getTaskDetail('task-1')
|
||||
expect(detail.task.stats).toEqual(expect.objectContaining({
|
||||
totalChecks: 1,
|
||||
successfulChecks: 1,
|
||||
failedChecks: 0,
|
||||
alertsSent: 1
|
||||
}))
|
||||
expect(detail.task.lastError).toBe('监控统计暂未写入本机,正在重试')
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('暂停状态写入失败时回滚生命周期日志和内存状态', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-pause-write-error-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: []
|
||||
})
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('booked')),
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
await service.runOnceForTest()
|
||||
const transientTask = (await store.list())[0]!
|
||||
let releaseWrite!: () => void
|
||||
let callbackReached!: () => void
|
||||
const writeGate = new Promise<void>((resolve) => { releaseWrite = resolve })
|
||||
const callbackGate = new Promise<void>((resolve) => { callbackReached = resolve })
|
||||
vi.spyOn(store, 'update').mockImplementationOnce(async (_taskId, update) => {
|
||||
update(transientTask)
|
||||
callbackReached()
|
||||
await writeGate
|
||||
throw new Error('rename failed')
|
||||
})
|
||||
const telemetryWrite = vi.spyOn(store, 'updateTelemetry')
|
||||
|
||||
const pausePromise = service.setEnabled('task-1', false)
|
||||
await callbackGate
|
||||
await (service as unknown as { flushTelemetry(): Promise<void> }).flushTelemetry()
|
||||
expect(telemetryWrite).not.toHaveBeenCalled()
|
||||
releaseWrite()
|
||||
await expect(pausePromise).rejects.toThrow('rename failed')
|
||||
|
||||
const detail = await service.getTaskDetail('task-1')
|
||||
expect(detail.task.enabled).toBe(true)
|
||||
expect(detail.logs.some((log) => log.message === '监控任务已暂停')).toBe(false)
|
||||
expect(detail.task.stats.totalChecks).toBe(1)
|
||||
})
|
||||
|
||||
it('退出不等待悬挂网络请求并使其结果失效', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-stop-timeout-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: []
|
||||
})
|
||||
const getAvailability = vi.fn().mockReturnValue(new Promise(() => undefined))
|
||||
const service = new MonitorService({
|
||||
getAvailability,
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
await service.start()
|
||||
await vi.waitFor(() => expect(getAvailability).toHaveBeenCalled())
|
||||
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it('退出前等待正在进行的暂停状态写入', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-stop-write-'))
|
||||
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
|
||||
await store.add({
|
||||
id: 'task-1',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: []
|
||||
})
|
||||
let releaseWrite!: () => void
|
||||
const writeGate = new Promise<void>((resolve) => { releaseWrite = resolve })
|
||||
const writableStore = store as unknown as { writeQueue: Promise<void> }
|
||||
writableStore.writeQueue = writeGate
|
||||
const service = new MonitorService({
|
||||
getAvailability: vi.fn().mockResolvedValue(availability('booked')),
|
||||
listCourts: () => [{ id: 'fansibote-fuzhong' }]
|
||||
} as unknown as AdapterRegistry, store, vi.fn())
|
||||
|
||||
const pausePromise = service.setEnabled('task-1', false)
|
||||
let stopped = false
|
||||
const stopPromise = service.stop().then(() => { stopped = true })
|
||||
await Promise.resolve()
|
||||
expect(stopped).toBe(false)
|
||||
|
||||
releaseWrite()
|
||||
await pausePromise
|
||||
await stopPromise
|
||||
expect((await store.list())[0]?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('不限日期任务持续聚合未来七天、跨日滚动且同一空场不重复提醒', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
|
||||
@@ -214,6 +994,21 @@ describe('MonitorService', () => {
|
||||
expect(onAlert).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
targetDate: '2026-07-22'
|
||||
}))
|
||||
const detail = await service.getTaskDetail('task-recurring')
|
||||
expect(detail.task.stats).toEqual(expect.objectContaining({
|
||||
totalChecks: 3,
|
||||
successfulChecks: 3,
|
||||
alertsSent: 8,
|
||||
availableObservations: 21
|
||||
}))
|
||||
expect(detail.dailyStats).toEqual([
|
||||
expect.objectContaining({ checks: 2, alerts: 7 }),
|
||||
expect.objectContaining({ checks: 1, alerts: 1 })
|
||||
])
|
||||
expect(detail.logs[0]).toEqual(expect.objectContaining({
|
||||
type: 'alert',
|
||||
level: 'success'
|
||||
}))
|
||||
})
|
||||
|
||||
it('不限日期的单日请求失败时继续处理其他日期并保留失败日期去重状态', async () => {
|
||||
@@ -257,6 +1052,13 @@ describe('MonitorService', () => {
|
||||
availableCount: 3,
|
||||
lastError: '部分日期检查失败:2026-07-15'
|
||||
}))
|
||||
expect((await service.getTaskDetail('task-recurring')).task.stats).toEqual(
|
||||
expect.objectContaining({
|
||||
totalChecks: 1,
|
||||
partialFailureChecks: 1,
|
||||
availableObservations: 2
|
||||
})
|
||||
)
|
||||
expect((await store.list())[0]?.seenAvailableSlotIds).toEqual([
|
||||
'2026-07-15:slot-08',
|
||||
'2026-07-16:slot-08',
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
AvailabilityDay,
|
||||
BookingSlot,
|
||||
CreateMonitorTaskInput,
|
||||
MonitorAlert,
|
||||
MonitorAutoBookingState,
|
||||
MonitorTask
|
||||
} from '../../shared/contracts'
|
||||
import type { AdapterRegistry } from '../adapters/registry'
|
||||
import { MonitorTaskStore, type StoredMonitorTask } from './MonitorTaskStore'
|
||||
import {
|
||||
findBestCompleteContinuousSequence,
|
||||
physicalCourtKey,
|
||||
slotEndExclusiveMinutes,
|
||||
timeMinutes
|
||||
} from '../bookings/slotSequence'
|
||||
import {
|
||||
createAutoBookingState,
|
||||
createMonitorTelemetry,
|
||||
MonitorTaskStore,
|
||||
recordMonitorAlerts,
|
||||
recordMonitorAutoBooking,
|
||||
recordMonitorCheck,
|
||||
recordMonitorLifecycle,
|
||||
type StoredMonitorTask
|
||||
} from './MonitorTaskStore'
|
||||
|
||||
const POLL_INTERVAL_MS = 10_000
|
||||
const TELEMETRY_FLUSH_INTERVAL_MS = 5 * 60_000
|
||||
const STOP_WAIT_TIMEOUT_MS = 3_000
|
||||
const ROLLING_DATE_COUNT = 7
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
||||
const TIME_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d$/
|
||||
@@ -25,11 +44,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function minutes(time: string) {
|
||||
const [hour, minute] = time.split(':').map(Number)
|
||||
return hour! * 60 + minute!
|
||||
}
|
||||
|
||||
function localDateValue(dayOffset = 0) {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + dayOffset)
|
||||
@@ -39,6 +53,16 @@ function localDateValue(dayOffset = 0) {
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function localHourKey(timestamp: number) {
|
||||
const date = new Date(timestamp)
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
String(date.getHours()).padStart(2, '0')
|
||||
].join('-')
|
||||
}
|
||||
|
||||
function taskEndTimestamp(task: Pick<StoredMonitorTask, 'targetDate' | 'endTime'>) {
|
||||
if (!task.targetDate) return Number.POSITIVE_INFINITY
|
||||
return new Date(`${task.targetDate}T${task.endTime}:00`).getTime()
|
||||
@@ -54,7 +78,67 @@ function targetDates(task: Pick<StoredMonitorTask, 'targetDate'>) {
|
||||
: Array.from({ length: ROLLING_DATE_COUNT }, (_, index) => localDateValue(index))
|
||||
}
|
||||
|
||||
function publicTask(task: StoredMonitorTask, runtime?: RuntimeStatus): MonitorTask {
|
||||
interface ContinuousAvailabilityMatch {
|
||||
date: string
|
||||
slots: BookingSlot[]
|
||||
trackingId: string
|
||||
courtLabel: string
|
||||
totalPriceCents: number
|
||||
legacyTrackingIds: string[]
|
||||
}
|
||||
|
||||
function continuousAvailabilityMatches(
|
||||
task: StoredMonitorTask,
|
||||
availabilityDays: AvailabilityDay[]
|
||||
): ContinuousAvailabilityMatch[] {
|
||||
return availabilityDays.flatMap((availability) => {
|
||||
const byCourt = new Map<string, BookingSlot[]>()
|
||||
for (const slot of availability.slots) {
|
||||
if (
|
||||
slot.enrollment ||
|
||||
(slot.status !== 'available' && slot.status !== 'limited') ||
|
||||
timeMinutes(slot.startTime) < timeMinutes(task.startTime) ||
|
||||
slotEndExclusiveMinutes(slot) > timeMinutes(task.endTime)
|
||||
) continue
|
||||
const key = physicalCourtKey(slot)
|
||||
byCourt.set(key, [...(byCourt.get(key) ?? []), slot])
|
||||
}
|
||||
return [...byCourt.entries()].flatMap(([courtKey, candidateSlots]) => {
|
||||
const slots = findBestCompleteContinuousSequence(
|
||||
candidateSlots,
|
||||
task.startTime,
|
||||
task.endTime
|
||||
)
|
||||
if (!slots) return []
|
||||
const sequenceId = slots.length === 1
|
||||
? slots[0]!.id
|
||||
: `${courtKey}:${slots.map((slot) => slot.id).join('|')}`
|
||||
const legacyTrackingIds = slots.map((slot) => {
|
||||
const legacyId = slot.providerReference
|
||||
? `${slot.providerReference.classroomUid}:${slot.providerReference.beginDatetime}`
|
||||
: slot.id
|
||||
return task.targetDate ? legacyId : `${availability.date}:${legacyId}`
|
||||
})
|
||||
return [{
|
||||
date: availability.date,
|
||||
slots,
|
||||
trackingId: task.targetDate
|
||||
? sequenceId
|
||||
: `${availability.date}:${sequenceId}`,
|
||||
courtLabel: slots[0]!.courtLabel,
|
||||
totalPriceCents: slots.reduce((total, slot) => total + slot.priceCents, 0),
|
||||
legacyTrackingIds
|
||||
}]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function publicTask(
|
||||
task: StoredMonitorTask,
|
||||
runtime?: RuntimeStatus,
|
||||
telemetry = task.telemetry,
|
||||
autoBooking = task.autoBooking
|
||||
): MonitorTask {
|
||||
return {
|
||||
id: task.id,
|
||||
courtId: task.courtId,
|
||||
@@ -65,7 +149,9 @@ function publicTask(task: StoredMonitorTask, runtime?: RuntimeStatus): MonitorTa
|
||||
createdAt: task.createdAt,
|
||||
lastCheckedAt: runtime?.lastCheckedAt,
|
||||
lastError: runtime?.lastError,
|
||||
availableCount: runtime?.availableCount ?? 0
|
||||
availableCount: runtime?.availableCount ?? 0,
|
||||
stats: { ...telemetry.stats },
|
||||
autoBooking: { ...autoBooking }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +159,16 @@ export class MonitorService {
|
||||
private readonly runtime = new Map<string, RuntimeStatus>()
|
||||
private readonly groupNextCheckAt = new Map<string, number>()
|
||||
private readonly runningGroups = new Set<string>()
|
||||
private readonly telemetry = new Map<string, StoredMonitorTask['telemetry']>()
|
||||
private readonly telemetryVersions = new Map<string, number>()
|
||||
private readonly dirtyTelemetry = new Set<string>()
|
||||
private readonly criticalTelemetryWrites = new Set<string>()
|
||||
private readonly autoBookingOverrides = new Map<string, MonitorAutoBookingState>()
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private telemetryTimer: NodeJS.Timeout | null = null
|
||||
private scheduledTickPromise: Promise<void> | null = null
|
||||
private lastObservedHourKey: string | null = null
|
||||
private stopping = false
|
||||
private serviceError: string | null = null
|
||||
|
||||
constructor(
|
||||
@@ -83,35 +178,109 @@ export class MonitorService {
|
||||
) {}
|
||||
|
||||
async start() {
|
||||
this.stopping = false
|
||||
this.lastObservedHourKey = null
|
||||
this.groupNextCheckAt.clear()
|
||||
const tasks = await this.store.list()
|
||||
for (const task of tasks) this.ensureRuntime(task.id, task.enabled)
|
||||
for (const task of tasks) {
|
||||
this.ensureRuntime(task.id, task.enabled)
|
||||
this.ensureTelemetry(task)
|
||||
if (task.autoBooking.status === 'attempting') {
|
||||
await this.recoverInterruptedAutoBooking(task)
|
||||
}
|
||||
}
|
||||
this.timer = setInterval(() => this.scheduleTick(), 1_000)
|
||||
this.telemetryTimer = setInterval(
|
||||
() => void this.flushTelemetry().catch((error) => {
|
||||
console.error('[tennis-book][monitor-telemetry]', error)
|
||||
}),
|
||||
TELEMETRY_FLUSH_INTERVAL_MS
|
||||
)
|
||||
this.scheduleTick()
|
||||
}
|
||||
|
||||
stop() {
|
||||
async stop() {
|
||||
this.stopping = true
|
||||
if (this.timer) clearInterval(this.timer)
|
||||
if (this.telemetryTimer) clearInterval(this.telemetryTimer)
|
||||
this.timer = null
|
||||
this.telemetryTimer = null
|
||||
for (const runtime of this.runtime.values()) {
|
||||
runtime.generation += 1
|
||||
runtime.enabled = false
|
||||
}
|
||||
const deadline = Date.now() + STOP_WAIT_TIMEOUT_MS
|
||||
const writesDrained = await this.waitUntil(this.store.drainWrites(), deadline)
|
||||
if (!writesDrained) {
|
||||
console.error('[tennis-book][monitor-stop]', '等待监控状态写入超时')
|
||||
return
|
||||
}
|
||||
const telemetryFlushed = await this.waitUntil(this.flushTelemetry(), deadline)
|
||||
if (!telemetryFlushed) {
|
||||
console.error('[tennis-book][monitor-stop]', '等待监控统计写入超时')
|
||||
}
|
||||
}
|
||||
|
||||
async listTasks() {
|
||||
if (this.serviceError) throw new Error(this.serviceError)
|
||||
const tasks = await this.store.list()
|
||||
return tasks.map((task) => publicTask(task, this.runtime.get(task.id)))
|
||||
return tasks.map((task) => publicTask(
|
||||
task,
|
||||
this.runtime.get(task.id),
|
||||
this.ensureTelemetry(task),
|
||||
this.autoBookingOverrides.get(task.id)
|
||||
))
|
||||
}
|
||||
|
||||
async getTaskDetail(taskId: unknown) {
|
||||
if (typeof taskId !== 'string') throw new Error('监控任务标识格式错误')
|
||||
const task = (await this.store.list()).find((candidate) => candidate.id === taskId)
|
||||
if (!task) throw new Error('监控任务不存在')
|
||||
return {
|
||||
task: publicTask(
|
||||
task,
|
||||
this.runtime.get(task.id),
|
||||
this.ensureTelemetry(task),
|
||||
this.autoBookingOverrides.get(task.id)
|
||||
),
|
||||
dailyStats: this.ensureTelemetry(task).dailyStats.map((item) => ({ ...item })),
|
||||
logs: this.ensureTelemetry(task).logs.map((log) => ({
|
||||
...log,
|
||||
failedDates: log.failedDates ? [...log.failedDates] : undefined
|
||||
})).reverse()
|
||||
}
|
||||
}
|
||||
|
||||
async createTask(input: unknown) {
|
||||
const parsed = this.parseCreateInput(input)
|
||||
this.registry.listCourts().find((court) => court.id === parsed.courtId) ??
|
||||
(() => { throw new Error('球场不存在') })()
|
||||
if (parsed.autoBookingEnabled) await this.assertAutoBookingCanBeEnabled(parsed.courtId)
|
||||
const createdAt = new Date().toISOString()
|
||||
const telemetry = createMonitorTelemetry(createdAt)
|
||||
if (parsed.autoBookingEnabled) {
|
||||
recordMonitorAutoBooking(
|
||||
telemetry,
|
||||
createdAt,
|
||||
'自动预定已授权,发现新空场后将自动锁定一个场地',
|
||||
'info'
|
||||
)
|
||||
}
|
||||
const task: StoredMonitorTask = {
|
||||
id: randomUUID(),
|
||||
...parsed,
|
||||
courtId: parsed.courtId,
|
||||
...(parsed.targetDate ? { targetDate: parsed.targetDate } : {}),
|
||||
startTime: parsed.startTime,
|
||||
endTime: parsed.endTime,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
seenAvailableSlotIds: []
|
||||
createdAt,
|
||||
seenAvailableSlotIds: [],
|
||||
autoBooking: createAutoBookingState(Boolean(parsed.autoBookingEnabled)),
|
||||
telemetry
|
||||
}
|
||||
await this.store.addUnique(task)
|
||||
this.telemetry.set(task.id, task.telemetry)
|
||||
this.telemetryVersions.set(task.id, 0)
|
||||
this.ensureRuntime(task.id, true)
|
||||
this.groupNextCheckAt.set(groupKey(task), 0)
|
||||
this.scheduleTick()
|
||||
@@ -122,18 +291,63 @@ export class MonitorService {
|
||||
if (typeof taskId !== 'string' || typeof enabled !== 'boolean') {
|
||||
throw new Error('监控任务状态参数错误')
|
||||
}
|
||||
const task = await this.store.update(taskId, (current) => {
|
||||
current.enabled = enabled
|
||||
if (enabled) current.seenAvailableSlotIds = []
|
||||
})
|
||||
const runtime = this.ensureRuntime(taskId, enabled)
|
||||
const previousEnabled = runtime.enabled
|
||||
runtime.generation += 1
|
||||
runtime.enabled = enabled
|
||||
let task: StoredMonitorTask
|
||||
let nextTelemetry: StoredMonitorTask['telemetry'] | null = null
|
||||
const previousTelemetryVersion = this.telemetryVersions.get(taskId) ?? 0
|
||||
this.criticalTelemetryWrites.add(taskId)
|
||||
try {
|
||||
task = await this.store.update(taskId, (current) => {
|
||||
current.enabled = enabled
|
||||
if (enabled) current.seenAvailableSlotIds = []
|
||||
nextTelemetry = structuredClone(this.ensureTelemetry(current))
|
||||
recordMonitorLifecycle(nextTelemetry, new Date().toISOString(), enabled)
|
||||
current.telemetry = structuredClone(nextTelemetry)
|
||||
})
|
||||
} catch (error) {
|
||||
runtime.generation += 1
|
||||
runtime.enabled = previousEnabled
|
||||
throw error
|
||||
} finally {
|
||||
this.criticalTelemetryWrites.delete(taskId)
|
||||
}
|
||||
this.telemetry.set(taskId, nextTelemetry!)
|
||||
this.telemetryVersions.set(taskId, previousTelemetryVersion + 1)
|
||||
this.dirtyTelemetry.delete(taskId)
|
||||
if (enabled) this.groupNextCheckAt.set(groupKey(task), 0)
|
||||
if (enabled) this.scheduleTick()
|
||||
return publicTask(task, this.runtime.get(taskId))
|
||||
}
|
||||
|
||||
async setAutoBookingEnabled(taskId: unknown, enabled: unknown) {
|
||||
if (typeof taskId !== 'string' || typeof enabled !== 'boolean') {
|
||||
throw new Error('自动预定状态参数错误')
|
||||
}
|
||||
const task = (await this.store.list()).find((candidate) => candidate.id === taskId)
|
||||
if (!task) throw new Error('监控任务不存在')
|
||||
if (enabled) await this.assertAutoBookingCanBeEnabled(task.courtId)
|
||||
const runtime = this.ensureRuntime(taskId, task.enabled)
|
||||
runtime.generation += 1
|
||||
const occurredAt = new Date().toISOString()
|
||||
const updated = await this.persistAutoBookingState(
|
||||
taskId,
|
||||
createAutoBookingState(enabled),
|
||||
occurredAt,
|
||||
enabled
|
||||
? '自动预定已开启,命中新空场后将尝试锁场'
|
||||
: '自动预定已关闭,任务只发送空场提醒',
|
||||
'info'
|
||||
)
|
||||
if (enabled && runtime.enabled) {
|
||||
this.groupNextCheckAt.set(groupKey(updated), 0)
|
||||
this.scheduleTick()
|
||||
}
|
||||
return publicTask(updated, runtime)
|
||||
}
|
||||
|
||||
async deleteTask(taskId: unknown) {
|
||||
if (typeof taskId !== 'string') throw new Error('监控任务标识格式错误')
|
||||
const runtime = this.runtime.get(taskId)
|
||||
@@ -143,6 +357,11 @@ export class MonitorService {
|
||||
}
|
||||
await this.store.delete(taskId)
|
||||
this.runtime.delete(taskId)
|
||||
this.telemetry.delete(taskId)
|
||||
this.telemetryVersions.delete(taskId)
|
||||
this.dirtyTelemetry.delete(taskId)
|
||||
this.criticalTelemetryWrites.delete(taskId)
|
||||
this.autoBookingOverrides.delete(taskId)
|
||||
}
|
||||
|
||||
async runOnceForTest() {
|
||||
@@ -160,8 +379,135 @@ export class MonitorService {
|
||||
return runtime
|
||||
}
|
||||
|
||||
private async waitUntil(operation: Promise<void>, deadline: number) {
|
||||
const remaining = Math.max(0, deadline - Date.now())
|
||||
if (remaining === 0) return false
|
||||
return Promise.race([
|
||||
operation.then(() => true),
|
||||
new Promise<false>((resolve) => setTimeout(() => resolve(false), remaining))
|
||||
])
|
||||
}
|
||||
|
||||
private ensureTelemetry(task: StoredMonitorTask) {
|
||||
const current = this.telemetry.get(task.id)
|
||||
if (current) return current
|
||||
this.telemetry.set(task.id, task.telemetry)
|
||||
this.telemetryVersions.set(task.id, 0)
|
||||
return task.telemetry
|
||||
}
|
||||
|
||||
private async assertAutoBookingCanBeEnabled(courtId: string) {
|
||||
const settings = await this.registry.getCourtSettings(courtId)
|
||||
if (!settings.visitorIdConfigured) {
|
||||
throw new Error('开启自动预定前,请先配置当前球场的 PSPLVISITORID')
|
||||
}
|
||||
if (await this.registry.getPendingBooking(courtId)) {
|
||||
throw new Error('当前球场已有待处理订单,请先支付或释放后再开启自动预定')
|
||||
}
|
||||
}
|
||||
|
||||
private async persistAutoBookingState(
|
||||
taskId: string,
|
||||
state: MonitorAutoBookingState,
|
||||
occurredAt: string,
|
||||
message: string,
|
||||
level: 'info' | 'warning' | 'success'
|
||||
) {
|
||||
const previousTelemetryVersion = this.telemetryVersions.get(taskId) ?? 0
|
||||
let nextTelemetry: StoredMonitorTask['telemetry'] | null = null
|
||||
this.criticalTelemetryWrites.add(taskId)
|
||||
let task: StoredMonitorTask
|
||||
try {
|
||||
task = await this.store.update(taskId, (current) => {
|
||||
current.autoBooking = { ...state }
|
||||
nextTelemetry = structuredClone(this.ensureTelemetry(current))
|
||||
recordMonitorAutoBooking(nextTelemetry, occurredAt, message, level)
|
||||
current.telemetry = structuredClone(nextTelemetry)
|
||||
})
|
||||
} finally {
|
||||
this.criticalTelemetryWrites.delete(taskId)
|
||||
}
|
||||
this.telemetry.set(taskId, nextTelemetry!)
|
||||
this.telemetryVersions.set(taskId, previousTelemetryVersion + 1)
|
||||
this.dirtyTelemetry.delete(taskId)
|
||||
this.autoBookingOverrides.delete(taskId)
|
||||
return task!
|
||||
}
|
||||
|
||||
private async recoverInterruptedAutoBooking(task: StoredMonitorTask) {
|
||||
let pending
|
||||
try {
|
||||
pending = await this.registry.getPendingBooking(task.courtId)
|
||||
} catch {
|
||||
pending = null
|
||||
}
|
||||
const occurredAt = new Date().toISOString()
|
||||
const state: MonitorAutoBookingState = pending?.status === 'pending-payment'
|
||||
? {
|
||||
enabled: false,
|
||||
status: 'pending-payment',
|
||||
attemptCount: task.autoBooking.attemptCount,
|
||||
lastAttemptAt: task.autoBooking.lastAttemptAt,
|
||||
orderId: pending.orderId,
|
||||
bookedAt: occurredAt,
|
||||
bookedSlotLabel: `${pending.courtLabel} ${pending.startTime}–${pending.endTime}`
|
||||
}
|
||||
: pending
|
||||
? {
|
||||
enabled: false,
|
||||
status: 'blocked',
|
||||
attemptCount: task.autoBooking.attemptCount,
|
||||
lastAttemptAt: task.autoBooking.lastAttemptAt,
|
||||
lastError: pending.status === 'release-uncertain'
|
||||
? '释放结果待人工核实,禁止继续自动预定'
|
||||
: '下单结果待人工核实,禁止继续自动预定'
|
||||
}
|
||||
: {
|
||||
enabled: false,
|
||||
status: 'stopped',
|
||||
attemptCount: task.autoBooking.attemptCount,
|
||||
lastAttemptAt: task.autoBooking.lastAttemptAt,
|
||||
lastError: '应用退出前的自动下单结果无法确认,已停止自动预定'
|
||||
}
|
||||
const updated = await this.persistAutoBookingState(
|
||||
task.id,
|
||||
state,
|
||||
occurredAt,
|
||||
pending?.status === 'pending-payment'
|
||||
? '检测到应用退出前留下的待支付订单,自动预定已停止'
|
||||
: pending
|
||||
? '检测到结果不确定的待处理订单,请人工核实或尝试释放'
|
||||
: '无法确认应用退出前的自动下单结果,自动预定已熔断',
|
||||
'warning'
|
||||
)
|
||||
task.autoBooking = { ...updated.autoBooking }
|
||||
}
|
||||
|
||||
private markTelemetryDirty(taskId: string) {
|
||||
this.dirtyTelemetry.add(taskId)
|
||||
this.telemetryVersions.set(taskId, (this.telemetryVersions.get(taskId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
private async flushTelemetry(taskIds?: Iterable<string>) {
|
||||
const ids = [...new Set(taskIds ?? this.dirtyTelemetry)]
|
||||
.filter((id) =>
|
||||
this.dirtyTelemetry.has(id) &&
|
||||
this.telemetry.has(id) &&
|
||||
!this.criticalTelemetryWrites.has(id)
|
||||
)
|
||||
if (ids.length === 0) return
|
||||
const versions = new Map(ids.map((id) => [id, this.telemetryVersions.get(id) ?? 0]))
|
||||
const snapshots = new Map(ids.map((id) => [id, structuredClone(this.telemetry.get(id)!)]))
|
||||
await this.store.updateTelemetry(snapshots)
|
||||
for (const id of ids) {
|
||||
if (this.telemetryVersions.get(id) === versions.get(id)) this.dirtyTelemetry.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleTick() {
|
||||
void this.tick()
|
||||
if (this.stopping) return
|
||||
if (this.scheduledTickPromise) return
|
||||
const operation = this.tick()
|
||||
.then(() => {
|
||||
this.serviceError = null
|
||||
})
|
||||
@@ -169,19 +515,45 @@ export class MonitorService {
|
||||
this.serviceError = `空场监控运行失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||
console.error('[tennis-book][monitor-service]', this.serviceError)
|
||||
})
|
||||
this.scheduledTickPromise = operation
|
||||
void operation.finally(() => {
|
||||
if (this.scheduledTickPromise === operation) this.scheduledTickPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
private async tick() {
|
||||
const tasks = await this.store.list()
|
||||
const now = Date.now()
|
||||
const currentHourKey = localHourKey(now)
|
||||
const isNewHour = this.lastObservedHourKey !== null &&
|
||||
this.lastObservedHourKey !== currentHourKey
|
||||
this.lastObservedHourKey = currentHourKey
|
||||
await Promise.all(tasks.map(async (task) => {
|
||||
const runtime = this.ensureRuntime(task.id, task.enabled)
|
||||
if (task.enabled && taskEndTimestamp(task) <= now) {
|
||||
runtime.generation += 1
|
||||
runtime.enabled = false
|
||||
await this.store.update(task.id, (current) => {
|
||||
current.enabled = false
|
||||
})
|
||||
const previousTelemetryVersion = this.telemetryVersions.get(task.id) ?? 0
|
||||
let nextTelemetry: StoredMonitorTask['telemetry'] | null = null
|
||||
this.criticalTelemetryWrites.add(task.id)
|
||||
try {
|
||||
await this.store.update(task.id, (current) => {
|
||||
current.enabled = false
|
||||
nextTelemetry = structuredClone(this.ensureTelemetry(current))
|
||||
recordMonitorLifecycle(
|
||||
nextTelemetry,
|
||||
new Date().toISOString(),
|
||||
false,
|
||||
'指定日期监控已到期'
|
||||
)
|
||||
current.telemetry = structuredClone(nextTelemetry)
|
||||
})
|
||||
} finally {
|
||||
this.criticalTelemetryWrites.delete(task.id)
|
||||
}
|
||||
this.telemetry.set(task.id, nextTelemetry!)
|
||||
this.telemetryVersions.set(task.id, previousTelemetryVersion + 1)
|
||||
this.dirtyTelemetry.delete(task.id)
|
||||
task.enabled = false
|
||||
}
|
||||
}))
|
||||
@@ -203,7 +575,10 @@ export class MonitorService {
|
||||
return request
|
||||
}
|
||||
await Promise.all([...groups.entries()].map(async ([key, groupTasks]) => {
|
||||
if (this.runningGroups.has(key) || (this.groupNextCheckAt.get(key) ?? 0) > now) return
|
||||
if (
|
||||
this.runningGroups.has(key) ||
|
||||
(!isNewHour && (this.groupNextCheckAt.get(key) ?? 0) > now)
|
||||
) return
|
||||
this.runningGroups.add(key)
|
||||
this.groupNextCheckAt.set(key, now + POLL_INTERVAL_MS)
|
||||
const checks = groupTasks.map((task) => {
|
||||
@@ -240,8 +615,19 @@ export class MonitorService {
|
||||
} catch (error) {
|
||||
for (const { task, runtime, generation } of checks) {
|
||||
if (!this.isCurrent(task.id, runtime, generation)) continue
|
||||
runtime.lastCheckedAt = new Date().toISOString()
|
||||
runtime.lastError = error instanceof Error ? error.message : '列表请求失败'
|
||||
const occurredAt = new Date().toISOString()
|
||||
const errorMessage = error instanceof Error ? error.message : '列表请求失败'
|
||||
recordMonitorCheck(this.ensureTelemetry(task), {
|
||||
occurredAt,
|
||||
status: 'failed',
|
||||
availableCount: 0,
|
||||
checkedDateCount: 0,
|
||||
failedDates: targetDates(task),
|
||||
errorMessage: `全部日期检查失败:${errorMessage.slice(0, 200)}`
|
||||
})
|
||||
this.markTelemetryDirty(task.id)
|
||||
runtime.lastCheckedAt = occurredAt
|
||||
runtime.lastError = errorMessage
|
||||
}
|
||||
} finally {
|
||||
this.runningGroups.delete(key)
|
||||
@@ -257,20 +643,12 @@ export class MonitorService {
|
||||
failedDates: string[] = []
|
||||
) {
|
||||
if (!this.isCurrent(task.id, runtime, generation)) return
|
||||
const available = availabilityDays.flatMap((availability) =>
|
||||
availability.slots.filter((slot) =>
|
||||
!slot.enrollment &&
|
||||
(slot.status === 'available' || slot.status === 'limited') &&
|
||||
minutes(slot.startTime) >= minutes(task.startTime) &&
|
||||
minutes(slot.endTime) <= minutes(task.endTime)
|
||||
).map((slot) => ({
|
||||
date: availability.date,
|
||||
slot,
|
||||
trackingId: task.targetDate ? slot.id : `${availability.date}:${slot.id}`
|
||||
}))
|
||||
)
|
||||
const available = continuousAvailabilityMatches(task, availabilityDays)
|
||||
const previous = new Set(task.seenAvailableSlotIds)
|
||||
const newlyAvailable = available.filter(({ trackingId }) => !previous.has(trackingId))
|
||||
const newlyAvailable = available.filter(({ trackingId, legacyTrackingIds }) =>
|
||||
!previous.has(trackingId) &&
|
||||
!legacyTrackingIds.every((legacyId) => previous.has(legacyId))
|
||||
)
|
||||
const preservedFailedIds = task.targetDate
|
||||
? []
|
||||
: task.seenAvailableSlotIds.filter((id) =>
|
||||
@@ -280,6 +658,18 @@ export class MonitorService {
|
||||
...available.map(({ trackingId }) => trackingId),
|
||||
...preservedFailedIds
|
||||
].sort()
|
||||
const occurredAt = new Date().toISOString()
|
||||
recordMonitorCheck(this.ensureTelemetry(task), {
|
||||
occurredAt,
|
||||
status: failedDates.length > 0 ? 'partial-failure' : 'success',
|
||||
availableCount: available.length,
|
||||
checkedDateCount: availabilityDays.length,
|
||||
failedDates,
|
||||
errorMessage: failedDates.length > 0
|
||||
? `部分日期检查失败:${failedDates.join('、')}`
|
||||
: undefined
|
||||
})
|
||||
this.markTelemetryDirty(task.id)
|
||||
if (
|
||||
nextIds.length !== task.seenAvailableSlotIds.length ||
|
||||
nextIds.some((id) => !previous.has(id))
|
||||
@@ -287,9 +677,10 @@ export class MonitorService {
|
||||
await this.store.update(task.id, (current) => {
|
||||
current.seenAvailableSlotIds = nextIds
|
||||
})
|
||||
task.seenAvailableSlotIds = [...nextIds]
|
||||
}
|
||||
if (!this.isCurrent(task.id, runtime, generation)) return
|
||||
runtime.lastCheckedAt = new Date().toISOString()
|
||||
runtime.lastCheckedAt = occurredAt
|
||||
runtime.lastError = failedDates.length > 0
|
||||
? `部分日期检查失败:${failedDates.join('、')}`
|
||||
: undefined
|
||||
@@ -298,19 +689,256 @@ export class MonitorService {
|
||||
for (const match of newlyAvailable) {
|
||||
alertsByDate.set(match.date, [...(alertsByDate.get(match.date) ?? []), match])
|
||||
}
|
||||
const dispatchedDates: string[] = []
|
||||
let dispatchedSlotCount = 0
|
||||
for (const [targetDate, matches] of alertsByDate) {
|
||||
this.onAlert({
|
||||
taskId: task.id,
|
||||
try {
|
||||
this.onAlert({
|
||||
taskId: task.id,
|
||||
courtId: task.courtId,
|
||||
targetDate,
|
||||
startTime: task.startTime,
|
||||
endTime: task.endTime,
|
||||
slotLabels: matches.map(({ courtLabel }) =>
|
||||
`${courtLabel} ${task.startTime}–${task.endTime}`
|
||||
),
|
||||
occurredAt
|
||||
})
|
||||
dispatchedDates.push(targetDate)
|
||||
dispatchedSlotCount += matches.length
|
||||
} catch (error) {
|
||||
console.error('[tennis-book][monitor-alert]', error)
|
||||
}
|
||||
}
|
||||
if (dispatchedDates.length > 0) {
|
||||
recordMonitorAlerts(
|
||||
this.ensureTelemetry(task),
|
||||
occurredAt,
|
||||
dispatchedDates.length,
|
||||
dispatchedSlotCount,
|
||||
dispatchedDates
|
||||
)
|
||||
this.markTelemetryDirty(task.id)
|
||||
try {
|
||||
await this.flushTelemetry([task.id])
|
||||
} catch (error) {
|
||||
runtime.lastError = '监控统计暂未写入本机,正在重试'
|
||||
console.error('[tennis-book][monitor-telemetry]', error)
|
||||
}
|
||||
}
|
||||
await this.maybeAutoBook(task, runtime, generation, newlyAvailable)
|
||||
}
|
||||
|
||||
private async maybeAutoBook(
|
||||
task: StoredMonitorTask,
|
||||
runtime: RuntimeStatus,
|
||||
generation: number,
|
||||
newlyAvailable: ContinuousAvailabilityMatch[]
|
||||
) {
|
||||
if (
|
||||
newlyAvailable.length === 0 ||
|
||||
!task.autoBooking.enabled ||
|
||||
task.autoBooking.status !== 'armed' ||
|
||||
!this.isCurrent(task.id, runtime, generation)
|
||||
) return
|
||||
|
||||
let existingBooking
|
||||
try {
|
||||
existingBooking = await this.registry.getPendingBooking(task.courtId)
|
||||
} catch (error) {
|
||||
if (!this.isCurrent(task.id, runtime, generation)) return
|
||||
const errorMessage = error instanceof Error ? error.message : '待处理订单状态读取失败'
|
||||
const stoppedTask = await this.persistAutoBookingState(
|
||||
task.id,
|
||||
{
|
||||
enabled: false,
|
||||
status: 'stopped',
|
||||
attemptCount: task.autoBooking.attemptCount,
|
||||
lastError: errorMessage
|
||||
},
|
||||
new Date().toISOString(),
|
||||
`无法确认当前球场是否已有订单,自动预定已停止:${errorMessage.slice(0, 180)}`,
|
||||
'warning'
|
||||
)
|
||||
task.autoBooking = { ...stoppedTask.autoBooking }
|
||||
return
|
||||
}
|
||||
if (!this.isCurrent(task.id, runtime, generation)) return
|
||||
if (existingBooking) {
|
||||
const blockedMessage = existingBooking.status === 'pending-payment'
|
||||
? '同一球场已有待支付订单,自动预定已阻止;释放后可手动重新开启'
|
||||
: '同一球场有结果待核实的订单,自动预定已阻止;请先人工核实或释放'
|
||||
const blockedTask = await this.persistAutoBookingState(
|
||||
task.id,
|
||||
{
|
||||
enabled: false,
|
||||
status: 'blocked',
|
||||
attemptCount: task.autoBooking.attemptCount,
|
||||
lastError: blockedMessage
|
||||
},
|
||||
new Date().toISOString(),
|
||||
blockedMessage,
|
||||
'warning'
|
||||
)
|
||||
task.autoBooking = { ...blockedTask.autoBooking }
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = [...newlyAvailable].sort((left, right) =>
|
||||
left.date.localeCompare(right.date) ||
|
||||
left.totalPriceCents - right.totalPriceCents ||
|
||||
left.courtLabel.localeCompare(right.courtLabel, 'zh-CN')
|
||||
)[0]!
|
||||
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
if (!this.isCurrent(task.id, runtime, generation)) return
|
||||
const occurredAt = new Date().toISOString()
|
||||
const attemptingState: MonitorAutoBookingState = {
|
||||
enabled: true,
|
||||
status: 'attempting',
|
||||
attemptCount: attempt,
|
||||
lastAttemptAt: occurredAt
|
||||
}
|
||||
const attemptingTask = await this.persistAutoBookingState(
|
||||
task.id,
|
||||
attemptingState,
|
||||
occurredAt,
|
||||
`自动预定第 ${attempt} 次尝试:${candidate.date} ${candidate.courtLabel} ${task.startTime}–${task.endTime}`,
|
||||
'info'
|
||||
)
|
||||
task.autoBooking = { ...attemptingTask.autoBooking }
|
||||
if (!this.isCurrent(task.id, runtime, generation)) return
|
||||
|
||||
let booking
|
||||
try {
|
||||
booking = await this.registry.createBooking({
|
||||
courtId: task.courtId,
|
||||
targetDate,
|
||||
startTime: task.startTime,
|
||||
endTime: task.endTime,
|
||||
slotLabels: matches.map(({ slot }) =>
|
||||
`${slot.courtLabel} ${slot.startTime}–${slot.endTime}`
|
||||
),
|
||||
occurredAt: new Date().toISOString()
|
||||
date: candidate.date,
|
||||
slotIds: candidate.slots.map((slot) => slot.id),
|
||||
expectedPriceCents: candidate.totalPriceCents,
|
||||
expectedStartTime: task.startTime,
|
||||
expectedEndTime: task.endTime
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '自动下单失败'
|
||||
let pending
|
||||
try {
|
||||
pending = await this.registry.getPendingBooking(task.courtId)
|
||||
} catch {
|
||||
pending = { status: 'submitting-uncertain' as const }
|
||||
}
|
||||
if (pending?.status === 'pending-payment') {
|
||||
const blockedState: MonitorAutoBookingState = {
|
||||
enabled: false,
|
||||
status: 'blocked',
|
||||
attemptCount: attempt,
|
||||
lastAttemptAt: occurredAt,
|
||||
lastError: '同一球场已有其他待支付订单,本任务已阻止自动预定'
|
||||
}
|
||||
const blockedTask = await this.persistAutoBookingState(
|
||||
task.id,
|
||||
blockedState,
|
||||
new Date().toISOString(),
|
||||
'同一球场已产生待支付订单,本任务已阻止且未重复提交',
|
||||
'warning'
|
||||
)
|
||||
task.autoBooking = { ...blockedTask.autoBooking }
|
||||
return
|
||||
}
|
||||
if (pending) {
|
||||
const stoppedState: MonitorAutoBookingState = {
|
||||
enabled: false,
|
||||
status: 'blocked',
|
||||
attemptCount: attempt,
|
||||
lastAttemptAt: occurredAt,
|
||||
lastError: errorMessage
|
||||
}
|
||||
const stoppedTask = await this.persistAutoBookingState(
|
||||
task.id,
|
||||
stoppedState,
|
||||
new Date().toISOString(),
|
||||
`下单结果不确定,为避免重复锁场已熔断:${errorMessage.slice(0, 180)}`,
|
||||
'warning'
|
||||
)
|
||||
task.autoBooking = { ...stoppedTask.autoBooking }
|
||||
return
|
||||
}
|
||||
if (attempt === 1) {
|
||||
const retryState: MonitorAutoBookingState = {
|
||||
enabled: true,
|
||||
status: 'armed',
|
||||
attemptCount: 1,
|
||||
lastAttemptAt: occurredAt,
|
||||
lastError: errorMessage
|
||||
}
|
||||
const retryTask = await this.persistAutoBookingState(
|
||||
task.id,
|
||||
retryState,
|
||||
new Date().toISOString(),
|
||||
`第一次自动预定失败,将重试一次:${errorMessage.slice(0, 180)}`,
|
||||
'warning'
|
||||
)
|
||||
task.autoBooking = { ...retryTask.autoBooking }
|
||||
continue
|
||||
}
|
||||
const stoppedState: MonitorAutoBookingState = {
|
||||
enabled: false,
|
||||
status: 'stopped',
|
||||
attemptCount: 2,
|
||||
lastAttemptAt: occurredAt,
|
||||
lastError: errorMessage
|
||||
}
|
||||
const stoppedTask = await this.persistAutoBookingState(
|
||||
task.id,
|
||||
stoppedState,
|
||||
new Date().toISOString(),
|
||||
`自动预定连续两次失败,已停止:${errorMessage.slice(0, 180)}`,
|
||||
'warning'
|
||||
)
|
||||
task.autoBooking = { ...stoppedTask.autoBooking }
|
||||
return
|
||||
}
|
||||
|
||||
const bookedAt = new Date().toISOString()
|
||||
const bookedState: MonitorAutoBookingState = {
|
||||
enabled: false,
|
||||
status: 'pending-payment',
|
||||
attemptCount: attempt,
|
||||
lastAttemptAt: occurredAt,
|
||||
orderId: booking.orderId,
|
||||
bookedAt,
|
||||
bookedDate: candidate.date,
|
||||
bookedSlotLabel: `${candidate.courtLabel} ${task.startTime}–${task.endTime}`
|
||||
}
|
||||
try {
|
||||
const bookedTask = await this.persistAutoBookingState(
|
||||
task.id,
|
||||
bookedState,
|
||||
bookedAt,
|
||||
`自动预定成功,已锁定 ${candidate.date} ${bookedState.bookedSlotLabel},等待支付`,
|
||||
'success'
|
||||
)
|
||||
task.autoBooking = { ...bookedTask.autoBooking }
|
||||
} catch (error) {
|
||||
const recoveryState = {
|
||||
...bookedState,
|
||||
lastError: '锁场成功,但自动预定任务状态暂未写入本机;正在重试恢复'
|
||||
}
|
||||
task.autoBooking = recoveryState
|
||||
this.autoBookingOverrides.set(task.id, recoveryState)
|
||||
console.error('[tennis-book][monitor-auto-booking]', '锁场成功但任务状态写入失败,正在重试本机记录', error)
|
||||
void this.persistAutoBookingState(
|
||||
task.id,
|
||||
recoveryState,
|
||||
new Date().toISOString(),
|
||||
`锁场已成功,本机任务状态恢复完成:${candidate.date} ${bookedState.bookedSlotLabel}`,
|
||||
'success'
|
||||
).catch((retryError) => {
|
||||
console.error('[tennis-book][monitor-auto-booking]', '锁场成功后的任务状态重试写入失败', retryError)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private isCurrent(taskId: string, runtime: RuntimeStatus, generation: number) {
|
||||
@@ -330,12 +958,13 @@ export class MonitorService {
|
||||
typeof input.courtId !== 'string' ||
|
||||
typeof input.startTime !== 'string' ||
|
||||
typeof input.endTime !== 'string' ||
|
||||
(input.autoBookingEnabled !== undefined && typeof input.autoBookingEnabled !== 'boolean') ||
|
||||
(targetDate !== undefined && typeof targetDate !== 'string') ||
|
||||
(typeof targetDate === 'string' && !DATE_PATTERN.test(targetDate)) ||
|
||||
!TIME_PATTERN.test(input.startTime) ||
|
||||
!TIME_PATTERN.test(input.endTime) ||
|
||||
(typeof targetDate === 'string' && ![localDateValue(), localDateValue(1)].includes(targetDate)) ||
|
||||
minutes(input.startTime) >= minutes(input.endTime) ||
|
||||
timeMinutes(input.startTime) >= timeMinutes(input.endTime) ||
|
||||
(typeof targetDate === 'string' &&
|
||||
new Date(`${targetDate}T${input.endTime}:00`).getTime() <= Date.now())
|
||||
) {
|
||||
@@ -345,7 +974,8 @@ export class MonitorService {
|
||||
courtId: input.courtId,
|
||||
...(typeof targetDate === 'string' ? { targetDate } : {}),
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime
|
||||
endTime: input.endTime,
|
||||
...(input.autoBookingEnabled === true ? { autoBookingEnabled: true } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MonitorTaskStore } from './MonitorTaskStore'
|
||||
import {
|
||||
createMonitorTelemetry,
|
||||
MonitorTaskStore,
|
||||
recordMonitorCheck
|
||||
} from './MonitorTaskStore'
|
||||
|
||||
describe('MonitorTaskStore', () => {
|
||||
it('持久化监控任务和已提醒的空场集合', async () => {
|
||||
@@ -66,4 +70,73 @@ describe('MonitorTaskStore', () => {
|
||||
])
|
||||
expect(tasks[0]).not.toHaveProperty('targetDate')
|
||||
})
|
||||
|
||||
it('读取旧版任务时自动补齐统计和生命周期日志', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitors-legacy-'))
|
||||
const filePath = join(directory, 'monitor-tasks.json')
|
||||
await writeFile(filePath, JSON.stringify({
|
||||
version: 1,
|
||||
tasks: [{
|
||||
id: 'legacy-task',
|
||||
courtId: 'fansibote-fuzhong',
|
||||
targetDate: '2026-07-16',
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
enabled: true,
|
||||
createdAt: '2026-07-15T10:00:00.000Z',
|
||||
seenAvailableSlotIds: []
|
||||
}]
|
||||
}))
|
||||
|
||||
const [task] = await new MonitorTaskStore(filePath).list()
|
||||
expect(task?.telemetry.stats.totalChecks).toBe(0)
|
||||
expect(task?.telemetry.logs).toEqual([
|
||||
expect.objectContaining({ type: 'lifecycle', message: '监控任务已创建' })
|
||||
])
|
||||
expect(task?.autoBooking).toEqual({
|
||||
enabled: false,
|
||||
status: 'disabled',
|
||||
attemptCount: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('累计统计不丢失,并限制为近30天和最近240条日志', () => {
|
||||
const telemetry = createMonitorTelemetry('2026-06-01T12:00:00.000Z')
|
||||
for (let index = 0; index < 250; index += 1) {
|
||||
const date = new Date('2026-06-01T12:00:00.000Z')
|
||||
date.setDate(date.getDate() + index)
|
||||
recordMonitorCheck(telemetry, {
|
||||
occurredAt: date.toISOString(),
|
||||
status: 'success',
|
||||
availableCount: 1,
|
||||
checkedDateCount: 1,
|
||||
failedDates: []
|
||||
})
|
||||
}
|
||||
|
||||
expect(telemetry.stats.totalChecks).toBe(250)
|
||||
expect(telemetry.stats.availableObservations).toBe(250)
|
||||
expect(telemetry.logs).toHaveLength(240)
|
||||
expect(telemetry.dailyStats).toHaveLength(30)
|
||||
})
|
||||
|
||||
it('按最近30个自然日淘汰长期暂停前的旧汇总', () => {
|
||||
const telemetry = createMonitorTelemetry('2026-01-01T12:00:00.000Z')
|
||||
recordMonitorCheck(telemetry, {
|
||||
occurredAt: '2026-01-01T12:00:00.000Z',
|
||||
status: 'success',
|
||||
availableCount: 1,
|
||||
checkedDateCount: 1,
|
||||
failedDates: []
|
||||
})
|
||||
recordMonitorCheck(telemetry, {
|
||||
occurredAt: '2026-07-15T12:00:00.000Z',
|
||||
status: 'success',
|
||||
availableCount: 1,
|
||||
checkedDateCount: 1,
|
||||
failedDates: []
|
||||
})
|
||||
|
||||
expect(telemetry.dailyStats.map(({ date }) => date)).toEqual(['2026-07-15'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
import { dirname } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import type { MonitorTask } from '../../shared/contracts'
|
||||
import type {
|
||||
MonitorAutoBookingState,
|
||||
MonitorDailyStats,
|
||||
MonitorTask,
|
||||
MonitorTaskLog,
|
||||
MonitorTaskStats
|
||||
} from '../../shared/contracts'
|
||||
|
||||
export interface StoredMonitorTask extends Omit<MonitorTask, 'lastCheckedAt' | 'lastError' | 'availableCount'> {
|
||||
const MAX_LOGS = 240
|
||||
const MAX_DAILY_STATS = 30
|
||||
|
||||
export interface MonitorTaskTelemetry {
|
||||
stats: MonitorTaskStats
|
||||
dailyStats: MonitorDailyStats[]
|
||||
logs: MonitorTaskLog[]
|
||||
}
|
||||
|
||||
export interface StoredMonitorTask extends Omit<
|
||||
MonitorTask,
|
||||
'lastCheckedAt' | 'lastError' | 'availableCount' | 'stats'
|
||||
> {
|
||||
seenAvailableSlotIds: string[]
|
||||
telemetry: MonitorTaskTelemetry
|
||||
}
|
||||
|
||||
type MonitorTaskDraft = Omit<StoredMonitorTask, 'telemetry' | 'autoBooking'> & {
|
||||
telemetry?: MonitorTaskTelemetry
|
||||
autoBooking?: MonitorAutoBookingState
|
||||
}
|
||||
|
||||
interface MonitorFile {
|
||||
@@ -15,6 +40,242 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function emptyStats(): MonitorTaskStats {
|
||||
return {
|
||||
totalChecks: 0,
|
||||
successfulChecks: 0,
|
||||
partialFailureChecks: 0,
|
||||
failedChecks: 0,
|
||||
alertsSent: 0,
|
||||
availableObservations: 0
|
||||
}
|
||||
}
|
||||
|
||||
export function createAutoBookingState(enabled = false): MonitorAutoBookingState {
|
||||
return {
|
||||
enabled,
|
||||
status: enabled ? 'armed' : 'disabled',
|
||||
attemptCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
function appendLog(telemetry: MonitorTaskTelemetry, log: Omit<MonitorTaskLog, 'id'>) {
|
||||
telemetry.logs.push({ id: randomUUID(), ...log })
|
||||
telemetry.logs = telemetry.logs.slice(-MAX_LOGS)
|
||||
}
|
||||
|
||||
function localDateValue(iso: string) {
|
||||
const date = new Date(iso)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function rollingDailyCutoff(iso: string) {
|
||||
const date = new Date(iso)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
date.setDate(date.getDate() - (MAX_DAILY_STATS - 1))
|
||||
return localDateValue(date.toISOString())
|
||||
}
|
||||
|
||||
function dailyStats(telemetry: MonitorTaskTelemetry, occurredAt: string) {
|
||||
const date = localDateValue(occurredAt)
|
||||
let daily = telemetry.dailyStats.find((item) => item.date === date)
|
||||
if (!daily) {
|
||||
daily = {
|
||||
date,
|
||||
checks: 0,
|
||||
failedChecks: 0,
|
||||
alerts: 0,
|
||||
availableObservations: 0,
|
||||
maxAvailable: 0
|
||||
}
|
||||
telemetry.dailyStats.push(daily)
|
||||
}
|
||||
const cutoff = rollingDailyCutoff(occurredAt)
|
||||
telemetry.dailyStats = telemetry.dailyStats
|
||||
.filter((item) => item.date >= cutoff)
|
||||
.sort((left, right) => left.date.localeCompare(right.date))
|
||||
return daily
|
||||
}
|
||||
|
||||
export function createMonitorTelemetry(createdAt: string): MonitorTaskTelemetry {
|
||||
const telemetry: MonitorTaskTelemetry = {
|
||||
stats: emptyStats(),
|
||||
dailyStats: [],
|
||||
logs: []
|
||||
}
|
||||
appendLog(telemetry, {
|
||||
occurredAt: createdAt,
|
||||
type: 'lifecycle',
|
||||
level: 'info',
|
||||
message: '监控任务已创建'
|
||||
})
|
||||
return telemetry
|
||||
}
|
||||
|
||||
export function recordMonitorLifecycle(
|
||||
telemetry: MonitorTaskTelemetry,
|
||||
occurredAt: string,
|
||||
enabled: boolean,
|
||||
message?: string
|
||||
) {
|
||||
appendLog(telemetry, {
|
||||
occurredAt,
|
||||
type: 'lifecycle',
|
||||
level: 'info',
|
||||
message: message ?? (enabled ? '监控任务已恢复' : '监控任务已暂停')
|
||||
})
|
||||
}
|
||||
|
||||
export function recordMonitorCheck(
|
||||
telemetry: MonitorTaskTelemetry,
|
||||
input: {
|
||||
occurredAt: string
|
||||
status: 'success' | 'partial-failure' | 'failed'
|
||||
availableCount: number
|
||||
checkedDateCount: number
|
||||
failedDates: string[]
|
||||
errorMessage?: string
|
||||
}
|
||||
) {
|
||||
telemetry.stats.totalChecks += 1
|
||||
if (input.status === 'success') telemetry.stats.successfulChecks += 1
|
||||
if (input.status === 'partial-failure') telemetry.stats.partialFailureChecks += 1
|
||||
if (input.status === 'failed') telemetry.stats.failedChecks += 1
|
||||
telemetry.stats.availableObservations += input.availableCount
|
||||
const daily = dailyStats(telemetry, input.occurredAt)
|
||||
daily.checks += 1
|
||||
if (input.status !== 'success') daily.failedChecks += 1
|
||||
daily.availableObservations += input.availableCount
|
||||
daily.maxAvailable = Math.max(daily.maxAvailable, input.availableCount)
|
||||
const isFailure = input.status !== 'success'
|
||||
appendLog(telemetry, {
|
||||
occurredAt: input.occurredAt,
|
||||
type: isFailure ? 'error' : 'check',
|
||||
level: isFailure ? 'warning' : 'info',
|
||||
message: input.errorMessage ??
|
||||
`完成 ${input.checkedDateCount} 个日期检查,发现 ${input.availableCount} 个空场`,
|
||||
availableCount: input.availableCount,
|
||||
checkedDateCount: input.checkedDateCount,
|
||||
failedDates: input.failedDates.length > 0 ? [...input.failedDates] : undefined
|
||||
})
|
||||
}
|
||||
|
||||
export function recordMonitorAlerts(
|
||||
telemetry: MonitorTaskTelemetry,
|
||||
occurredAt: string,
|
||||
alertCount: number,
|
||||
slotCount: number,
|
||||
targetDates: string[]
|
||||
) {
|
||||
telemetry.stats.alertsSent += alertCount
|
||||
telemetry.stats.lastAlertAt = occurredAt
|
||||
dailyStats(telemetry, occurredAt).alerts += alertCount
|
||||
appendLog(telemetry, {
|
||||
occurredAt,
|
||||
type: 'alert',
|
||||
level: 'success',
|
||||
message: `发现 ${slotCount} 个新空场,已发送 ${alertCount} 条提醒`,
|
||||
checkedDateCount: targetDates.length,
|
||||
failedDates: []
|
||||
})
|
||||
}
|
||||
|
||||
export function recordMonitorAutoBooking(
|
||||
telemetry: MonitorTaskTelemetry,
|
||||
occurredAt: string,
|
||||
message: string,
|
||||
level: MonitorTaskLog['level']
|
||||
) {
|
||||
appendLog(telemetry, {
|
||||
occurredAt,
|
||||
type: 'booking',
|
||||
level,
|
||||
message
|
||||
})
|
||||
}
|
||||
|
||||
function parseAutoBooking(value: unknown): MonitorAutoBookingState {
|
||||
if (value === undefined) return createAutoBookingState()
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.enabled !== 'boolean' ||
|
||||
!['disabled', 'armed', 'attempting', 'pending-payment', 'blocked', 'stopped'].includes(String(value.status)) ||
|
||||
typeof value.attemptCount !== 'number' ||
|
||||
!Number.isInteger(value.attemptCount) ||
|
||||
value.attemptCount < 0 ||
|
||||
['lastAttemptAt', 'lastError', 'orderId', 'bookedAt', 'bookedDate', 'bookedSlotLabel']
|
||||
.some((key) => value[key] !== undefined && typeof value[key] !== 'string')
|
||||
) {
|
||||
throw new Error('本机自动预定状态格式错误')
|
||||
}
|
||||
return value as unknown as MonitorAutoBookingState
|
||||
}
|
||||
|
||||
function parseTelemetry(value: unknown, createdAt: string): MonitorTaskTelemetry {
|
||||
if (value === undefined) return createMonitorTelemetry(createdAt)
|
||||
if (!isRecord(value) || !isRecord(value.stats) || !Array.isArray(value.dailyStats) || !Array.isArray(value.logs)) {
|
||||
throw new Error('本机空场监控统计格式错误')
|
||||
}
|
||||
const stats = value.stats
|
||||
const numberKeys: Array<keyof Omit<MonitorTaskStats, 'lastAlertAt'>> = [
|
||||
'totalChecks',
|
||||
'successfulChecks',
|
||||
'partialFailureChecks',
|
||||
'failedChecks',
|
||||
'alertsSent',
|
||||
'availableObservations'
|
||||
]
|
||||
if (
|
||||
numberKeys.some((key) =>
|
||||
typeof stats[key] !== 'number' || !Number.isInteger(stats[key]) || stats[key] < 0
|
||||
) ||
|
||||
(stats.lastAlertAt !== undefined && typeof stats.lastAlertAt !== 'string')
|
||||
) {
|
||||
throw new Error('本机空场监控统计格式错误')
|
||||
}
|
||||
const parsedDailyStats = value.dailyStats.map((item) => {
|
||||
if (
|
||||
!isRecord(item) ||
|
||||
typeof item.date !== 'string' ||
|
||||
!['checks', 'failedChecks', 'alerts', 'availableObservations', 'maxAvailable'].every((key) =>
|
||||
typeof item[key] === 'number' && Number.isInteger(item[key]) && item[key] >= 0
|
||||
)
|
||||
) {
|
||||
throw new Error('本机空场监控统计格式错误')
|
||||
}
|
||||
return item as unknown as MonitorDailyStats
|
||||
})
|
||||
const parsedLogs = value.logs.map((log) => {
|
||||
if (
|
||||
!isRecord(log) ||
|
||||
typeof log.id !== 'string' ||
|
||||
typeof log.occurredAt !== 'string' ||
|
||||
!['check', 'alert', 'error', 'lifecycle', 'booking'].includes(String(log.type)) ||
|
||||
!['info', 'warning', 'success'].includes(String(log.level)) ||
|
||||
typeof log.message !== 'string' ||
|
||||
(log.availableCount !== undefined && typeof log.availableCount !== 'number') ||
|
||||
(log.checkedDateCount !== undefined && typeof log.checkedDateCount !== 'number') ||
|
||||
(log.failedDates !== undefined && (
|
||||
!Array.isArray(log.failedDates) ||
|
||||
!log.failedDates.every((date) => typeof date === 'string')
|
||||
))
|
||||
) {
|
||||
throw new Error('本机空场监控日志格式错误')
|
||||
}
|
||||
return log as unknown as MonitorTaskLog
|
||||
})
|
||||
return {
|
||||
stats: stats as unknown as MonitorTaskStats,
|
||||
dailyStats: parsedDailyStats
|
||||
.filter((item) => item.date >= rollingDailyCutoff(new Date().toISOString()))
|
||||
.sort((left, right) => left.date.localeCompare(right.date)),
|
||||
logs: parsedLogs.slice(-MAX_LOGS)
|
||||
}
|
||||
}
|
||||
|
||||
function parseTask(value: unknown): StoredMonitorTask {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
@@ -30,7 +291,11 @@ function parseTask(value: unknown): StoredMonitorTask {
|
||||
) {
|
||||
throw new Error('本机空场监控任务格式错误')
|
||||
}
|
||||
return value as unknown as StoredMonitorTask
|
||||
return {
|
||||
...(value as unknown as Omit<StoredMonitorTask, 'telemetry'>),
|
||||
autoBooking: parseAutoBooking(value.autoBooking),
|
||||
telemetry: parseTelemetry(value.telemetry, value.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
export class MonitorTaskStore {
|
||||
@@ -42,13 +307,17 @@ export class MonitorTaskStore {
|
||||
return (await this.read()).tasks
|
||||
}
|
||||
|
||||
async add(task: StoredMonitorTask): Promise<void> {
|
||||
async add(task: MonitorTaskDraft): Promise<void> {
|
||||
return this.write((file) => {
|
||||
file.tasks.push(task)
|
||||
file.tasks.push({
|
||||
...task,
|
||||
autoBooking: task.autoBooking ?? createAutoBookingState(),
|
||||
telemetry: task.telemetry ?? createMonitorTelemetry(task.createdAt)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async addUnique(task: StoredMonitorTask): Promise<void> {
|
||||
async addUnique(task: MonitorTaskDraft): Promise<void> {
|
||||
return this.write((file) => {
|
||||
const duplicate = file.tasks.some((current) =>
|
||||
current.courtId === task.courtId &&
|
||||
@@ -57,7 +326,11 @@ export class MonitorTaskStore {
|
||||
current.endTime === task.endTime
|
||||
)
|
||||
if (duplicate) throw new Error('相同日期范围和时间段的监控任务已存在')
|
||||
file.tasks.push(task)
|
||||
file.tasks.push({
|
||||
...task,
|
||||
autoBooking: task.autoBooking ?? createAutoBookingState(),
|
||||
telemetry: task.telemetry ?? createMonitorTelemetry(task.createdAt)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,7 +340,11 @@ export class MonitorTaskStore {
|
||||
const task = file.tasks.find((candidate) => candidate.id === taskId)
|
||||
if (!task) throw new Error('监控任务不存在')
|
||||
update(task)
|
||||
updated = { ...task, seenAvailableSlotIds: [...task.seenAvailableSlotIds] }
|
||||
updated = {
|
||||
...task,
|
||||
autoBooking: { ...task.autoBooking },
|
||||
seenAvailableSlotIds: [...task.seenAvailableSlotIds]
|
||||
}
|
||||
})
|
||||
return updated!
|
||||
}
|
||||
@@ -80,6 +357,20 @@ export class MonitorTaskStore {
|
||||
})
|
||||
}
|
||||
|
||||
async updateTelemetry(entries: ReadonlyMap<string, MonitorTaskTelemetry>): Promise<void> {
|
||||
if (entries.size === 0) return
|
||||
return this.write((file) => {
|
||||
for (const task of file.tasks) {
|
||||
const telemetry = entries.get(task.id)
|
||||
if (telemetry) task.telemetry = structuredClone(telemetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async drainWrites(): Promise<void> {
|
||||
await this.writeQueue
|
||||
}
|
||||
|
||||
private async write(update: (file: MonitorFile) => void): Promise<void> {
|
||||
const operation = this.writeQueue.then(async () => {
|
||||
const file = await this.read()
|
||||
|
||||
Reference in New Issue
Block a user