feat: 支持任务监控系统

This commit is contained in:
richarjiang
2026-07-15 18:50:12 +08:00
parent f8c8c688a1
commit 4b465843a8
16 changed files with 1389 additions and 29 deletions

View File

@@ -53,15 +53,14 @@ describe('范思伯特福中福响应映射', () => {
})
vi.stubGlobal('fetch', fetchMock)
await fansiboteFuzhongAdapter.getAvailability(query, {
courtId: query.courtId
})
await fansiboteFuzhongAdapter.getAvailability(query)
expect(fetchMock).toHaveBeenCalledOnce()
const request = fetchMock.mock.calls[0]?.[1] as RequestInit
expect(request.headers).toEqual(expect.objectContaining({
STOREID: '6038652'
}))
expect(request.headers).not.toHaveProperty('PSPLVISITORID')
expect(JSON.parse(String(request.body))).toEqual({
dateTime: '2026-07-16',
userId: 6038652,
@@ -283,6 +282,13 @@ describe('范思伯特福中福响应映射', () => {
},
expect.objectContaining({ id: '1784027073124705081' })
])
expect(result.slots.map(({ status, unavailableLabel }) => ({
status,
unavailableLabel
}))).toEqual([
{ status: 'booked', unavailableLabel: '已订' },
{ status: 'booked', unavailableLabel: '已订' }
])
})
it('接口字段变化时明确失败,不静默丢时段', () => {

View File

@@ -228,19 +228,21 @@ function parseSlot(value: unknown, enrollments: ParsedEnrollment[]): BookingSlot
const slotBeginDatetime = slot.beginDatetime
const slotEndDatetime = slot.endDatetime
const status: BookingSlot['status'] = canBook
? 'available'
: slot.status === 1
? 'booked'
: 'blocked'
const bookedBy = status === 'booked' && appointment
? parseBookingContacts(appointment)
: []
const enrollment = enrollments.find((candidate) =>
candidate.classroomUid === slot.txtClassroomUid &&
slotBeginDatetime <= candidate.endDatetime &&
slotEndDatetime >= candidate.beginDatetime
)?.activity
const status: BookingSlot['status'] = enrollment
? 'booked'
: canBook
? 'available'
: slot.status === 1
? 'booked'
: 'blocked'
const bookedBy = status === 'booked' && appointment
? parseBookingContacts(appointment)
: []
return {
id: `${slot.txtClassroomUid}:${slotBeginDatetime}`,
@@ -298,13 +300,11 @@ export function buildAvailabilityRequestBody(date: string) {
}
async function getAvailability(
query: AvailabilityQuery,
settings: CourtRuntimeSettings
query: AvailabilityQuery
): Promise<AvailabilityDay> {
if (query.courtId !== fansiboteFuzhongCourt.id) {
throw new Error('球场标识与适配器不匹配')
}
if (settings.courtId !== query.courtId) throw new Error('球场配置与适配器不匹配')
if (!DATE_PATTERN.test(query.date)) throw new Error('预约日期格式错误')
const response = await fetch(AVAILABILITY_ENDPOINT, {

View File

@@ -32,11 +32,7 @@ export class AdapterRegistry {
async getAvailability(query: AvailabilityQuery) {
const adapter = this.getAdapter(query.courtId)
const settings = await this.settingsStore.get(
adapter.court.id,
adapter.defaultSettings
)
return adapter.getAvailability(query, settings)
return adapter.getAvailability(query)
}
async getCourtSettings(courtId: string) {
@@ -125,7 +121,7 @@ export class AdapterRegistry {
const availability = await adapter.getAvailability({
courtId: parsedInput.courtId,
date: parsedInput.date
}, settings)
})
const slot = availability.slots.find((candidate) => candidate.id === parsedInput.slotId)
if (!slot) throw new Error('目标时段已不存在,请刷新后重试')
if (slot.status !== 'available' && slot.status !== 'limited') {

View File

@@ -25,10 +25,7 @@ export class ProviderMutationError extends Error {
export interface CourtAdapter {
readonly court: CourtSummary
readonly defaultSettings: CourtRuntimeSettings
getAvailability(
query: AvailabilityQuery,
settings: CourtRuntimeSettings
): Promise<AvailabilityDay>
getAvailability(query: AvailabilityQuery): Promise<AvailabilityDay>
preflightWriteSettings?(settings: CourtRuntimeSettings): void
createBooking?(
slot: BookingSlot,

View File

@@ -1,13 +1,28 @@
import { join } from 'node:path'
import { app, BrowserWindow, ipcMain, shell } from 'electron'
import { mkdirSync } from 'node:fs'
import { app, BrowserWindow, ipcMain, Notification, shell } from 'electron'
import { AdapterRegistry } from './adapters/registry'
import { CourtSettingsStore } from './settings/CourtSettingsStore'
import { PendingBookingStore } from './bookings/PendingBookingStore'
import { MonitorService } from './monitors/MonitorService'
import { MonitorTaskStore } from './monitors/MonitorTaskStore'
import {
IPC_CHANNELS,
type AvailabilityQuery
} from '../shared/contracts'
const APPLICATION_NAME = 'Tennis Book'
const USER_DATA_DIRECTORY_NAME = 'tennis-book'
const userDataPath = join(app.getPath('appData'), USER_DATA_DIRECTORY_NAME)
mkdirSync(userDataPath, { recursive: true })
app.setPath('userData', userDataPath)
app.setName(APPLICATION_NAME)
let monitorService: MonitorService | null = null
let monitorServiceStartError: string | null = null
const pendingMonitorAlerts: import('../shared/contracts').MonitorAlert[] = []
function createWindow() {
const mainWindow = new BrowserWindow({
width: 1440,
@@ -37,8 +52,22 @@ function createWindow() {
} else {
void mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
return mainWindow
}
const hasSingleInstanceLock = app.requestSingleInstanceLock()
if (!hasSingleInstanceLock) {
app.quit()
} else {
app.on('second-instance', () => {
const window = BrowserWindow.getAllWindows()[0]
if (!window) return
if (window.isMinimized()) window.restore()
window.show()
window.focus()
})
app.whenReady().then(() => {
const settingsStore = new CourtSettingsStore(
join(app.getPath('userData'), 'court-settings.json')
@@ -47,6 +76,32 @@ app.whenReady().then(() => {
join(app.getPath('userData'), 'pending-bookings.json')
)
const registry = new AdapterRegistry(settingsStore, pendingBookingStore)
const monitorTaskStore = new MonitorTaskStore(
join(app.getPath('userData'), 'monitor-tasks.json')
)
monitorService = new MonitorService(registry, monitorTaskStore, (alert) => {
pendingMonitorAlerts.push(alert)
if (pendingMonitorAlerts.length > 100) pendingMonitorAlerts.shift()
const courtName = registry.listCourts().find((court) => court.id === alert.courtId)?.name ?? '球场'
const body = `${alert.targetDate} ${alert.slotLabels.join('、')}`
if (Notification.isSupported()) {
const notification = new Notification({
title: `${courtName}有空场释放`,
body,
silent: false
})
notification.on('click', () => {
const window = BrowserWindow.getAllWindows()[0] ?? createWindow()
if (window.isMinimized()) window.restore()
window.show()
window.focus()
})
notification.show()
}
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(IPC_CHANNELS.monitorAlert)
}
})
ipcMain.handle(IPC_CHANNELS.listCourts, () => registry.listCourts())
ipcMain.handle(
@@ -79,12 +134,40 @@ app.whenReady().then(() => {
return registry.getPendingBooking(courtId)
}
)
ipcMain.handle(IPC_CHANNELS.listMonitorTasks, () => {
if (monitorServiceStartError) throw new Error(monitorServiceStartError)
return monitorService!.listTasks()
})
ipcMain.handle(
IPC_CHANNELS.createMonitorTask,
(_event, input: unknown) => monitorService!.createTask(input)
)
ipcMain.handle(
IPC_CHANNELS.setMonitorTaskEnabled,
(_event, taskId: unknown, enabled: unknown) =>
monitorService!.setEnabled(taskId, enabled)
)
ipcMain.handle(
IPC_CHANNELS.deleteMonitorTask,
(_event, taskId: unknown) => monitorService!.deleteTask(taskId)
)
ipcMain.handle(
IPC_CHANNELS.takeMonitorAlerts,
() => pendingMonitorAlerts.splice(0, pendingMonitorAlerts.length)
)
createWindow()
void monitorService.start().catch((error: unknown) => {
monitorServiceStartError = `空场监控服务启动失败:${error instanceof Error ? error.message : '未知错误'}`
console.error('[tennis-book][monitor-service]', monitorServiceStartError)
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
}
app.on('before-quit', () => monitorService?.stop())
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()

View File

@@ -0,0 +1,390 @@
import { mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
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'
function availability(
status: 'available' | 'booked',
date = '2026-07-16'
): AvailabilityDay {
return {
courtId: 'fansibote-fuzhong',
date,
updatedAt: new Date().toISOString(),
slots: [{
id: 'slot-08',
courtLabel: '1号风雨场',
startTime: '08:00',
endTime: '08:59',
priceCents: 8000,
status
}]
}
}
describe('MonitorService', () => {
afterEach(() => vi.useRealTimers())
it('每 10 秒检查并只提醒新出现或再次释放的空场', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-service-'))
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()
.mockResolvedValueOnce(availability('booked'))
.mockResolvedValueOnce(availability('available'))
.mockResolvedValueOnce(availability('available'))
.mockResolvedValueOnce(availability('booked'))
.mockResolvedValueOnce(availability('available'))
const registry = {
getAvailability,
listCourts: () => [{ id: 'fansibote-fuzhong' }]
} as unknown as AdapterRegistry
const onAlert = vi.fn()
const service = new MonitorService(registry, store, onAlert)
await service.runOnceForTest()
expect(onAlert).not.toHaveBeenCalled()
vi.advanceTimersByTime(10_000)
await service.runOnceForTest()
expect(onAlert).toHaveBeenCalledTimes(1)
expect(onAlert).toHaveBeenLastCalledWith(expect.objectContaining({
slotLabels: ['1号风雨场 08:0008:59']
}))
vi.advanceTimersByTime(10_000)
await service.runOnceForTest()
expect(onAlert).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(10_000)
await service.runOnceForTest()
vi.advanceTimersByTime(10_000)
await service.runOnceForTest()
expect(onAlert).toHaveBeenCalledTimes(2)
expect(getAvailability).toHaveBeenCalledTimes(5)
})
it('时间范围外的空场不提醒', async () => {
vi.useFakeTimers()
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-range-'))
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
await store.add({
id: 'task-1',
courtId: 'fansibote-fuzhong',
targetDate: '2026-07-16',
startTime: '09:00',
endTime: '10:00',
enabled: true,
createdAt: new Date().toISOString(),
seenAvailableSlotIds: []
})
const registry = {
getAvailability: vi.fn().mockResolvedValue(availability('available')),
listCourts: () => [{ id: 'fansibote-fuzhong' }]
} as unknown as AdapterRegistry
const onAlert = vi.fn()
const service = new MonitorService(registry, store, onAlert)
await service.runOnceForTest()
expect(onAlert).not.toHaveBeenCalled()
})
it('上游误标为可订的双打活动不计入空场且不提醒', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-enrollment-'))
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 activitySlot = availability('available')
activitySlot.slots[0]!.enrollment = {
id: 'activity-1',
title: '【2.5双打】福中福8-9',
startTime: '08:00',
endTime: '09:00',
feeCents: 7000,
enrolledCount: 3,
capacity: 4,
participantNames: ['星', '浩哥']
}
const onAlert = vi.fn()
const service = new MonitorService({
getAvailability: vi.fn().mockResolvedValue(activitySlot),
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'))
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-group-'))
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: '08:00', endTime: '10:00', ...baseTask })
const getAvailability = vi.fn().mockResolvedValue(availability('available'))
const service = new MonitorService({
getAvailability,
listCourts: () => [{ id: 'fansibote-fuzhong' }]
} as unknown as AdapterRegistry, store, vi.fn())
await service.runOnceForTest()
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-recurring-'))
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
await store.add({
id: 'task-recurring',
courtId: 'fansibote-fuzhong',
startTime: '08:00',
endTime: '09:00',
enabled: true,
createdAt: new Date().toISOString(),
seenAvailableSlotIds: []
})
const getAvailability = vi.fn().mockImplementation(({ date }: { date: string }) =>
Promise.resolve(availability('available', date))
)
const onAlert = vi.fn()
const service = new MonitorService({
getAvailability,
listCourts: () => [{ id: 'fansibote-fuzhong' }]
} as unknown as AdapterRegistry, store, onAlert)
await service.runOnceForTest()
expect(getAvailability.mock.calls.map(([query]) => query.date).sort()).toEqual([
'2026-07-15', '2026-07-16', '2026-07-17', '2026-07-18',
'2026-07-19', '2026-07-20', '2026-07-21'
])
expect(onAlert).toHaveBeenCalledTimes(7)
expect(await service.listTasks()).toEqual([
expect.objectContaining({
targetDate: undefined,
enabled: true,
availableCount: 7
})
])
vi.advanceTimersByTime(10_000)
await service.runOnceForTest()
expect(onAlert).toHaveBeenCalledTimes(7)
vi.setSystemTime(new Date('2026-07-16T10:00:00.000Z'))
await service.runOnceForTest()
expect(onAlert).toHaveBeenCalledTimes(8)
expect(onAlert).toHaveBeenLastCalledWith(expect.objectContaining({
targetDate: '2026-07-22'
}))
})
it('不限日期的单日请求失败时继续处理其他日期并保留失败日期去重状态', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-partial-'))
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
await store.add({
id: 'task-recurring',
courtId: 'fansibote-fuzhong',
startTime: '08:00',
endTime: '09:00',
enabled: true,
createdAt: new Date().toISOString(),
seenAvailableSlotIds: [
'2026-07-15:slot-08',
'2026-07-16:slot-08'
]
})
const getAvailability = vi.fn().mockImplementation(({ date }: { date: string }) => {
if (date === '2026-07-15') return Promise.reject(new Error('single day failed'))
return Promise.resolve(availability(
date === '2026-07-16' || date === '2026-07-17' ? 'available' : 'booked',
date
))
})
const onAlert = vi.fn()
const service = new MonitorService({
getAvailability,
listCourts: () => [{ id: 'fansibote-fuzhong' }]
} as unknown as AdapterRegistry, store, onAlert)
await service.runOnceForTest()
expect(onAlert).toHaveBeenCalledOnce()
expect(onAlert).toHaveBeenCalledWith(expect.objectContaining({
targetDate: '2026-07-17'
}))
expect((await service.listTasks())[0]).toEqual(expect.objectContaining({
enabled: true,
availableCount: 3,
lastError: '部分日期检查失败2026-07-15'
}))
expect((await store.list())[0]?.seenAvailableSlotIds).toEqual([
'2026-07-15:slot-08',
'2026-07-16:slot-08',
'2026-07-17:slot-08'
])
})
it('暂停任务后丢弃仍在途的列表响应', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-pause-'))
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 resolveAvailability!: (value: ReturnType<typeof availability>) => void
const response = new Promise<ReturnType<typeof availability>>((resolve) => {
resolveAvailability = resolve
})
const onAlert = vi.fn()
const service = new MonitorService({
getAvailability: vi.fn().mockReturnValue(response),
listCourts: () => [{ id: 'fansibote-fuzhong' }]
} as unknown as AdapterRegistry, store, onAlert)
const checking = service.runOnceForTest()
await service.setEnabled('task-1', false)
resolveAvailability(availability('available'))
await checking
expect(onAlert).not.toHaveBeenCalled()
})
it('拒绝创建今日已经结束的时间段', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-expired-'))
const service = new MonitorService({
listCourts: () => [{ id: 'fansibote-fuzhong' }]
} as unknown as AdapterRegistry, new MonitorTaskStore(join(directory, 'tasks.json')), vi.fn())
await expect(service.createTask({
courtId: 'fansibote-fuzhong',
targetDate: '2026-07-15',
startTime: '08:00',
endTime: '09:00'
})).rejects.toThrow('参数格式错误')
})
it('任务在请求途中到达结束时间时丢弃响应', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-cross-end-'))
const store = new MonitorTaskStore(join(directory, 'tasks.json'))
await store.add({
id: 'task-1',
courtId: 'fansibote-fuzhong',
targetDate: '2026-07-15',
startTime: '09:00',
endTime: '10:01',
enabled: true,
createdAt: new Date().toISOString(),
seenAvailableSlotIds: []
})
let resolveAvailability!: (value: ReturnType<typeof availability>) => void
const response = new Promise<ReturnType<typeof availability>>((resolve) => {
resolveAvailability = resolve
})
const onAlert = vi.fn()
const service = new MonitorService({
getAvailability: vi.fn().mockReturnValue(response),
listCourts: () => [{ id: 'fansibote-fuzhong' }]
} as unknown as AdapterRegistry, store, onAlert)
const checking = service.runOnceForTest()
await Promise.resolve()
vi.advanceTimersByTime(120_000)
await service.runOnceForTest()
const released = availability('available')
released.slots[0]!.startTime = '09:00'
released.slots[0]!.endTime = '09:59'
resolveAvailability(released)
await checking
expect(onAlert).not.toHaveBeenCalled()
await expect(store.list()).resolves.toEqual([
expect.objectContaining({ enabled: false })
])
})
it('暂停发生在任务快照读取后时也不发起旧任务检查', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'))
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-snapshot-'))
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 staleSnapshot = await store.list()
let resolveSnapshot!: (tasks: typeof staleSnapshot) => void
const snapshot = new Promise<typeof staleSnapshot>((resolve) => {
resolveSnapshot = resolve
})
vi.spyOn(store, 'list').mockReturnValueOnce(snapshot)
const getAvailability = vi.fn().mockResolvedValue(availability('available'))
const onAlert = vi.fn()
const service = new MonitorService({
getAvailability,
listCourts: () => [{ id: 'fansibote-fuzhong' }]
} as unknown as AdapterRegistry, store, onAlert)
const checking = service.runOnceForTest()
await service.setEnabled('task-1', false)
resolveSnapshot(staleSnapshot)
await checking
expect(getAvailability).not.toHaveBeenCalled()
expect(onAlert).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,351 @@
import { randomUUID } from 'node:crypto'
import type {
AvailabilityDay,
CreateMonitorTaskInput,
MonitorAlert,
MonitorTask
} from '../../shared/contracts'
import type { AdapterRegistry } from '../adapters/registry'
import { MonitorTaskStore, type StoredMonitorTask } from './MonitorTaskStore'
const POLL_INTERVAL_MS = 10_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$/
interface RuntimeStatus {
lastCheckedAt?: string
lastError?: string
availableCount: number
generation: number
enabled: boolean
}
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)
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 taskEndTimestamp(task: Pick<StoredMonitorTask, 'targetDate' | 'endTime'>) {
if (!task.targetDate) return Number.POSITIVE_INFINITY
return new Date(`${task.targetDate}T${task.endTime}:00`).getTime()
}
function groupKey(task: Pick<StoredMonitorTask, 'courtId' | 'targetDate'>) {
return `${task.courtId}:${task.targetDate ?? '*'}`
}
function targetDates(task: Pick<StoredMonitorTask, 'targetDate'>) {
return task.targetDate
? [task.targetDate]
: Array.from({ length: ROLLING_DATE_COUNT }, (_, index) => localDateValue(index))
}
function publicTask(task: StoredMonitorTask, runtime?: RuntimeStatus): MonitorTask {
return {
id: task.id,
courtId: task.courtId,
targetDate: task.targetDate,
startTime: task.startTime,
endTime: task.endTime,
enabled: task.enabled,
createdAt: task.createdAt,
lastCheckedAt: runtime?.lastCheckedAt,
lastError: runtime?.lastError,
availableCount: runtime?.availableCount ?? 0
}
}
export class MonitorService {
private readonly runtime = new Map<string, RuntimeStatus>()
private readonly groupNextCheckAt = new Map<string, number>()
private readonly runningGroups = new Set<string>()
private timer: NodeJS.Timeout | null = null
private serviceError: string | null = null
constructor(
private readonly registry: AdapterRegistry,
private readonly store: MonitorTaskStore,
private readonly onAlert: (alert: MonitorAlert) => void
) {}
async start() {
const tasks = await this.store.list()
for (const task of tasks) this.ensureRuntime(task.id, task.enabled)
this.timer = setInterval(() => this.scheduleTick(), 1_000)
this.scheduleTick()
}
stop() {
if (this.timer) clearInterval(this.timer)
this.timer = null
}
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)))
}
async createTask(input: unknown) {
const parsed = this.parseCreateInput(input)
this.registry.listCourts().find((court) => court.id === parsed.courtId) ??
(() => { throw new Error('球场不存在') })()
const task: StoredMonitorTask = {
id: randomUUID(),
...parsed,
enabled: true,
createdAt: new Date().toISOString(),
seenAvailableSlotIds: []
}
await this.store.addUnique(task)
this.ensureRuntime(task.id, true)
this.groupNextCheckAt.set(groupKey(task), 0)
this.scheduleTick()
return publicTask(task, this.runtime.get(task.id))
}
async setEnabled(taskId: unknown, enabled: unknown) {
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)
runtime.generation += 1
runtime.enabled = enabled
if (enabled) this.groupNextCheckAt.set(groupKey(task), 0)
if (enabled) this.scheduleTick()
return publicTask(task, this.runtime.get(taskId))
}
async deleteTask(taskId: unknown) {
if (typeof taskId !== 'string') throw new Error('监控任务标识格式错误')
const runtime = this.runtime.get(taskId)
if (runtime) {
runtime.generation += 1
runtime.enabled = false
}
await this.store.delete(taskId)
this.runtime.delete(taskId)
}
async runOnceForTest() {
await this.tick()
}
private ensureRuntime(taskId: string, enabled = false) {
const current = this.runtime.get(taskId)
const runtime = current ?? {
availableCount: 0,
generation: 0,
enabled
}
this.runtime.set(taskId, runtime)
return runtime
}
private scheduleTick() {
void this.tick()
.then(() => {
this.serviceError = null
})
.catch((error: unknown) => {
this.serviceError = `空场监控运行失败:${error instanceof Error ? error.message : '未知错误'}`
console.error('[tennis-book][monitor-service]', this.serviceError)
})
}
private async tick() {
const tasks = await this.store.list()
const now = Date.now()
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
})
task.enabled = false
}
}))
const groups = new Map<string, StoredMonitorTask[]>()
for (const task of tasks.filter((candidate) =>
candidate.enabled && this.ensureRuntime(candidate.id, true).enabled
)) {
const key = groupKey(task)
groups.set(key, [...(groups.get(key) ?? []), task])
}
const availabilityRequests = new Map<string, Promise<AvailabilityDay>>()
const getAvailability = (courtId: string, date: string) => {
const key = `${courtId}:${date}`
const current = availabilityRequests.get(key)
if (current) return current
const request = this.registry.getAvailability({ courtId, date })
availabilityRequests.set(key, request)
return request
}
await Promise.all([...groups.entries()].map(async ([key, groupTasks]) => {
if (this.runningGroups.has(key) || (this.groupNextCheckAt.get(key) ?? 0) > now) return
this.runningGroups.add(key)
this.groupNextCheckAt.set(key, now + POLL_INTERVAL_MS)
const checks = groupTasks.map((task) => {
const runtime = this.ensureRuntime(task.id, task.enabled)
return { task, runtime, generation: runtime.generation }
}).filter(({ runtime }) => runtime.enabled)
if (checks.length === 0) {
this.runningGroups.delete(key)
return
}
try {
const first = groupTasks[0]!
const dateResults = await Promise.all(targetDates(first).map(async (date) => {
try {
return { date, availability: await getAvailability(first.courtId, date) }
} catch (error) {
return { date, error }
}
}))
const availability = dateResults.flatMap((result) =>
result.availability ? [result.availability] : []
)
const failures = dateResults.filter((result) => result.error !== undefined)
if (availability.length === 0) throw failures[0]?.error ?? new Error('列表请求失败')
await Promise.all(checks.map(({ task, runtime, generation }) =>
this.applyAvailability(
task,
runtime,
generation,
availability,
failures.map(({ date }) => date)
)
))
} 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 : '列表请求失败'
}
} finally {
this.runningGroups.delete(key)
}
}))
}
private async applyAvailability(
task: StoredMonitorTask,
runtime: RuntimeStatus,
generation: number,
availabilityDays: AvailabilityDay[],
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 previous = new Set(task.seenAvailableSlotIds)
const newlyAvailable = available.filter(({ trackingId }) => !previous.has(trackingId))
const preservedFailedIds = task.targetDate
? []
: task.seenAvailableSlotIds.filter((id) =>
failedDates.some((date) => id.startsWith(`${date}:`))
)
const nextIds = [
...available.map(({ trackingId }) => trackingId),
...preservedFailedIds
].sort()
if (
nextIds.length !== task.seenAvailableSlotIds.length ||
nextIds.some((id) => !previous.has(id))
) {
await this.store.update(task.id, (current) => {
current.seenAvailableSlotIds = nextIds
})
}
if (!this.isCurrent(task.id, runtime, generation)) return
runtime.lastCheckedAt = new Date().toISOString()
runtime.lastError = failedDates.length > 0
? `部分日期检查失败:${failedDates.join('、')}`
: undefined
runtime.availableCount = available.length + preservedFailedIds.length
const alertsByDate = new Map<string, typeof newlyAvailable>()
for (const match of newlyAvailable) {
alertsByDate.set(match.date, [...(alertsByDate.get(match.date) ?? []), match])
}
for (const [targetDate, matches] of alertsByDate) {
this.onAlert({
taskId: task.id,
courtId: task.courtId,
targetDate,
startTime: task.startTime,
endTime: task.endTime,
slotLabels: matches.map(({ slot }) =>
`${slot.courtLabel} ${slot.startTime}${slot.endTime}`
),
occurredAt: new Date().toISOString()
})
}
}
private isCurrent(taskId: string, runtime: RuntimeStatus, generation: number) {
return this.runtime.get(taskId) === runtime &&
runtime.enabled &&
runtime.generation === generation
}
private parseCreateInput(input: unknown): CreateMonitorTaskInput {
const targetDate = isRecord(input) && input.targetDate === ''
? undefined
: isRecord(input)
? input.targetDate
: undefined
if (
!isRecord(input) ||
typeof input.courtId !== 'string' ||
typeof input.startTime !== 'string' ||
typeof input.endTime !== 'string' ||
(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) ||
(typeof targetDate === 'string' &&
new Date(`${targetDate}T${input.endTime}:00`).getTime() <= Date.now())
) {
throw new Error('监控任务参数格式错误')
}
return {
courtId: input.courtId,
...(typeof targetDate === 'string' ? { targetDate } : {}),
startTime: input.startTime,
endTime: input.endTime
}
}
}

View File

@@ -0,0 +1,69 @@
import { mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it } from 'vitest'
import { MonitorTaskStore } from './MonitorTaskStore'
describe('MonitorTaskStore', () => {
it('持久化监控任务和已提醒的空场集合', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitors-'))
const filePath = join(directory, 'monitor-tasks.json')
const store = new MonitorTaskStore(filePath)
await store.add({
id: 'task-1',
courtId: 'fansibote-fuzhong',
targetDate: '2026-07-16',
startTime: '08:00',
endTime: '09:00',
enabled: true,
createdAt: '2026-07-15T10:00:00.000Z',
seenAvailableSlotIds: ['slot-1']
})
const reloaded = new MonitorTaskStore(filePath)
await expect(reloaded.list()).resolves.toEqual([
expect.objectContaining({ id: 'task-1', seenAvailableSlotIds: ['slot-1'] })
])
})
it('并发创建相同监控范围时只持久化一个任务', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitors-unique-'))
const store = new MonitorTaskStore(join(directory, 'monitor-tasks.json'))
const baseTask = {
courtId: 'fansibote-fuzhong',
targetDate: '2026-07-16',
startTime: '08:00',
endTime: '09:00',
enabled: true,
createdAt: '2026-07-15T10:00:00.000Z',
seenAvailableSlotIds: []
}
const results = await Promise.allSettled([
store.addUnique({ id: 'task-1', ...baseTask }),
store.addUnique({ id: 'task-2', ...baseTask })
])
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
await expect(store.list()).resolves.toHaveLength(1)
})
it('持久化没有指定日期的持续监控任务', async () => {
const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitors-recurring-'))
const filePath = join(directory, 'monitor-tasks.json')
await new MonitorTaskStore(filePath).add({
id: 'task-recurring',
courtId: 'fansibote-fuzhong',
startTime: '08:00',
endTime: '09:00',
enabled: true,
createdAt: '2026-07-15T10:00:00.000Z',
seenAvailableSlotIds: []
})
const tasks = await new MonitorTaskStore(filePath).list()
expect(tasks).toEqual([
expect.objectContaining({ id: 'task-recurring', enabled: true })
])
expect(tasks[0]).not.toHaveProperty('targetDate')
})
})

View File

@@ -0,0 +1,111 @@
import { dirname } from 'node:path'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import type { MonitorTask } from '../../shared/contracts'
export interface StoredMonitorTask extends Omit<MonitorTask, 'lastCheckedAt' | 'lastError' | 'availableCount'> {
seenAvailableSlotIds: string[]
}
interface MonitorFile {
version: 1
tasks: StoredMonitorTask[]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseTask(value: unknown): StoredMonitorTask {
if (
!isRecord(value) ||
typeof value.id !== 'string' ||
typeof value.courtId !== 'string' ||
(value.targetDate !== undefined && typeof value.targetDate !== 'string') ||
typeof value.startTime !== 'string' ||
typeof value.endTime !== 'string' ||
typeof value.enabled !== 'boolean' ||
typeof value.createdAt !== 'string' ||
!Array.isArray(value.seenAvailableSlotIds) ||
!value.seenAvailableSlotIds.every((id) => typeof id === 'string')
) {
throw new Error('本机空场监控任务格式错误')
}
return value as unknown as StoredMonitorTask
}
export class MonitorTaskStore {
private writeQueue: Promise<void> = Promise.resolve()
constructor(private readonly filePath: string) {}
async list(): Promise<StoredMonitorTask[]> {
return (await this.read()).tasks
}
async add(task: StoredMonitorTask): Promise<void> {
return this.write((file) => {
file.tasks.push(task)
})
}
async addUnique(task: StoredMonitorTask): Promise<void> {
return this.write((file) => {
const duplicate = file.tasks.some((current) =>
current.courtId === task.courtId &&
current.targetDate === task.targetDate &&
current.startTime === task.startTime &&
current.endTime === task.endTime
)
if (duplicate) throw new Error('相同日期范围和时间段的监控任务已存在')
file.tasks.push(task)
})
}
async update(taskId: string, update: (task: StoredMonitorTask) => void): Promise<StoredMonitorTask> {
let updated: StoredMonitorTask | undefined
await this.write((file) => {
const task = file.tasks.find((candidate) => candidate.id === taskId)
if (!task) throw new Error('监控任务不存在')
update(task)
updated = { ...task, seenAvailableSlotIds: [...task.seenAvailableSlotIds] }
})
return updated!
}
async delete(taskId: string): Promise<void> {
return this.write((file) => {
const nextTasks = file.tasks.filter((task) => task.id !== taskId)
if (nextTasks.length === file.tasks.length) throw new Error('监控任务不存在')
file.tasks = nextTasks
})
}
private async write(update: (file: MonitorFile) => void): Promise<void> {
const operation = this.writeQueue.then(async () => {
const file = await this.read()
update(file)
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.${process.pid}.tmp`
await writeFile(temporaryPath, `${JSON.stringify(file, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600
})
await rename(temporaryPath, this.filePath)
})
this.writeQueue = operation.catch(() => undefined)
return operation
}
private async read(): Promise<MonitorFile> {
try {
const value: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.tasks)) {
throw new Error('本机空场监控文件格式错误')
}
return { version: 1, tasks: value.tasks.map(parseTask) }
} catch (error) {
if (isRecord(error) && error.code === 'ENOENT') return { version: 1, tasks: [] }
throw error
}
}
}