perf: 优化空场监控
This commit is contained in:
10
AGENTS.md
10
AGENTS.md
@@ -11,9 +11,15 @@
|
||||
- `src/main/`:Electron 主进程、第三方网络请求、凭证和适配器注册。
|
||||
- `src/main/settings/`:按球场保存的本机配置与原子写入逻辑,存储位置使用 Electron `userData` 目录。
|
||||
- `src/main/bookings/`:待支付锁场订单摘要与恢复逻辑;不得持久化支付签名。
|
||||
- `src/main/monitors/`:空场监控任务、10 秒轮询调度、变化去重和系统通知;只允许调用无凭证列表接口。
|
||||
- `src/main/monitors/`:空场监控任务、10 秒轮询调度、整点立即检查、变化去重和系统通知;只允许调用无凭证列表接口。整点检查必须绕过常规轮询冷却,但不得与已有检查并发或在同一整点重复触发。
|
||||
- 带报名活动(如双打活动)的时段统一视为已订场地;不得计入可订数量,也不得触发空场监控提醒。
|
||||
- 空场监控日期可选;未指定日期的任务持续滚动检查主界面支持的未来 7 天数据,不能在跨日时自动失效。
|
||||
- 自动预定是监控任务级、用户显式开启的一次性写操作。开启前必须明确告知“只锁场不支付”并校验球场访客凭证;成功后立即转为待支付且禁止再次下单。服务端明确失败只允许重试一次,第二次失败后自动熔断;结果不确定时禁止重试并立即熔断。
|
||||
- 监控时间段表示“同一天、同一个物理场地完整连续覆盖整个区间”,不是区间内任意单个小时。任一小时缺失、不可订、属于报名活动或来自不同场地时均不命中;提醒、可订数量和自动预定统一以完整连续组合为单位。
|
||||
- 连续组合自动预定必须作为一笔请求提交全部组成时段,下单前逐项重新校验可用性、场地身份、连续性和总价;不得只锁其中一个小时后宣称任务成功。
|
||||
- 同一球场存在 `submitting-uncertain`、`pending-payment` 或 `release-uncertain` 记录时,所有监控任务只能继续读列表,不得发起新的下单请求。自动预定只能消费本轮新出现的可订时段,每个任务每轮最多选择一个时段。
|
||||
- 每个监控任务的累计统计、近 30 天每日汇总和最近 240 条运行日志随任务持久化;删除任务时一并删除。日志禁止记录访客凭证、预约人信息、完整响应或完整请求头。
|
||||
- 10 秒轮询遥测先更新主进程内存,每 5 分钟按所有脏任务批量写入;提醒、暂停、恢复、到期等关键节点立即持久化,正常退出必须等待最终批量刷新,禁止每任务每轮重写整个 JSON。
|
||||
- `src/main/adapters/<court-id>/`:单个球场的接口与预约流程实现。
|
||||
- `src/preload/`:仅暴露经过白名单审核的类型安全 IPC。
|
||||
- `src/renderer/`:React UI,不可使用 Node.js API,不可持有第三方密钥。
|
||||
@@ -42,7 +48,7 @@
|
||||
- 每球场配置以 `courtId` 隔离;标识符在共享模型和持久化文件中使用字符串,实际请求前再按接口契约安全转换。
|
||||
- 预约人昵称和手机号仅可按产品明确要求存在于当次时段查询的内存结果中;不得写日志、落盘、缓存或用于预约以外的用途。
|
||||
- 外部链接只能通过主进程白名单打开;页面不得任意导航或创建窗口。
|
||||
- 写操作前必须在主进程重新验证实时可用性和价格;UI 必须二次确认。不得用真实接口做自动化下单或取消测试。
|
||||
- 写操作前必须在主进程重新验证实时可用性和价格;手动下单必须 UI 二次确认,自动预定必须在任务开关处获得一次明确授权。不得用真实接口做自动化下单或取消测试。
|
||||
|
||||
## 完成标准
|
||||
|
||||
|
||||
@@ -56,6 +56,14 @@ preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandb
|
||||
|
||||
## 空场监控
|
||||
|
||||
监控任务由主进程持久化和调度,应用通过单实例锁避免重复调度。日期可指定为创建时的今日或明日,也可以留空。指定日期的任务到达当天结束时间后自动停用;未指定日期的任务持续滚动检查主界面当前支持的未来 7 天,跨日后自动向后滚动一天且不会过期。轮询间隔固定为 10 秒;今日已经结束的指定日期范围不能创建。相同球场和日期的请求在同一轮中复用,并分别过滤 `available/limited` 时段。
|
||||
监控任务由主进程持久化和调度,应用通过单实例锁避免重复调度。日期可指定为创建时的今日或明日,也可以留空。指定日期的任务到达当天结束时间后自动停用;未指定日期的任务持续滚动检查主界面当前支持的未来 7 天,跨日后自动向后滚动一天且不会过期。轮询间隔固定为 10 秒;本地时间进入新的整点时绕过剩余轮询冷却立即检查一次,以捕获整点释放的新场地。同一整点只触发一次,并复用现有串行调度,不与尚未结束的检查并发。应用启动和休眠后恢复也会立即调度一次。今日已经结束的指定日期范围不能创建。相同球场和日期的请求在同一轮中复用,并分别过滤 `available/limited` 时段。
|
||||
|
||||
任务持久化上一次可订时段 ID 集合,只对新增 ID 通知;同一时段持续可订时不会重复通知,先消失再重新出现则再次通知。暂停和删除会让在途响应失效。提醒同时使用 Electron 系统通知和主进程待消费队列中的应用内提示,渲染器启动较晚或同轮出现多条提醒时也不会覆盖;提醒不自动下单。带 `enrollment` 报名活动的时段属于已订场地,即使上游同时返回可预约标记,也必须从可订统计和监控提醒中排除。
|
||||
任务持久化上一次可订时段 ID 集合,只对新增 ID 通知;同一时段持续可订时不会重复通知,先消失再重新出现则再次通知。暂停和删除会让在途响应失效。提醒同时使用 Electron 系统通知和主进程待消费队列中的应用内提示,渲染器启动较晚或同轮出现多条提醒时也不会覆盖;默认只提醒,只有任务被用户显式授权自动预定时才会下单。带 `enrollment` 报名活动的时段属于已订场地,即使上游同时返回可预约标记,也必须从可订统计和监控提醒中排除。
|
||||
|
||||
监控时间范围采用完整连续区间语义。例如 `19:00–21:00` 只有在同一天同一个 `classroomUid` 的 `19:00–19:59` 与 `20:00–20:59` 均可订时才形成一个命中;只有其中一段、两段来自不同场地、中间有缺口或包含报名活动都不命中。上游以 `xx:59` 表示小时结束时,公共层将其归一为下一小时的排他边界。去重 ID、可订数量、提醒文案和自动预定均以整个连续组合为单位。
|
||||
|
||||
任务可显式开启一次性自动预定。自动预定只消费本轮新出现的完整连续组合,多项命中时按日期、开始时间、总价和场地名稳定选择一个,并复用 `AdapterRegistry.createBooking` 的实时可用性、逐时段价格复核、同场连续性校验、球场级串行锁和待支付订单持久化。组合中的全部时段通过同一笔 `classroomItems` 请求提交,任一组成时段复核失败则整笔不提交。成功后任务转为“待支付”并关闭自动预定,后续轮询只读不写。同一球场已有待支付、提交结果不确定或释放结果不确定记录时,不发起下单,任务持久化为“已阻止”并要求订单处理完成后由用户重新授权。明确失败且本机没有待处理订单时立即重试一次;第二次明确失败后熔断。网络超时等结果不确定场景禁止重试并立即熔断,避免第一次已经锁场时重复提交。进程在 `attempting` 状态退出后,重启时只有确认的 `pending-payment` 恢复为“待支付”,提交或释放结果不确定时恢复为“已阻止”并要求人工核实。
|
||||
|
||||
每个任务在同一持久化记录中维护累计检查、成功、部分失败、完全失败、提醒次数和空场观测次数;同时保留近 30 个自然日每日汇总与最近 240 条检查、异常、提醒及生命周期日志。旧版任务缺少遥测字段时在读取阶段补齐默认值,不改变文件版本。任务详情只通过独立 IPC 按需读取,任务列表仅返回轻量统计摘要。日志只包含时间、日期范围、计数和脱敏错误摘要,不得保存凭证、预约人信息、完整接口响应或请求头。
|
||||
|
||||
为避免 10 秒轮询反复序列化整个任务文件,遥测先在主进程内存实时累计,每 5 分钟将所有变化任务合并为一次原子快照;提醒、暂停、恢复和到期等关键节点立即落盘。正常退出通过 `before-quit` 等待最终快照完成,异常退出最多损失当前 5 分钟的非关键检查统计,任务定义、去重集合和关键事件不受影响。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -24,10 +24,14 @@ const api: TennisBookApi = {
|
||||
getPendingBooking: (courtId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.getPendingBooking, courtId),
|
||||
listMonitorTasks: () => ipcRenderer.invoke(IPC_CHANNELS.listMonitorTasks),
|
||||
getMonitorTaskDetail: (taskId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.getMonitorTaskDetail, taskId),
|
||||
createMonitorTask: (input: CreateMonitorTaskInput) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.createMonitorTask, input),
|
||||
setMonitorTaskEnabled: (taskId: string, enabled: boolean) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.setMonitorTaskEnabled, taskId, enabled),
|
||||
setMonitorTaskAutoBooking: (taskId: string, enabled: boolean) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.setMonitorTaskAutoBooking, taskId, enabled),
|
||||
deleteMonitorTask: (taskId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.deleteMonitorTask, taskId),
|
||||
takeMonitorAlerts: () => ipcRenderer.invoke(IPC_CHANNELS.takeMonitorAlerts),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
CalendarDays,
|
||||
BellRing,
|
||||
ChevronDown,
|
||||
@@ -23,7 +25,9 @@ import {
|
||||
Trash2,
|
||||
UnlockKeyhole,
|
||||
Warehouse,
|
||||
Wifi
|
||||
Wifi,
|
||||
Zap,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
AvailabilityDay,
|
||||
@@ -33,6 +37,7 @@ import type {
|
||||
CourtSummary,
|
||||
MonitorAlert,
|
||||
MonitorTask,
|
||||
MonitorTaskDetail,
|
||||
PendingBooking
|
||||
} from '../../shared/contracts'
|
||||
|
||||
@@ -217,9 +222,14 @@ export function App() {
|
||||
const [monitorDate, setMonitorDate] = useState('')
|
||||
const [monitorStartTime, setMonitorStartTime] = useState('08:00')
|
||||
const [monitorEndTime, setMonitorEndTime] = useState('09:00')
|
||||
const [monitorAutoBooking, setMonitorAutoBooking] = useState(false)
|
||||
const [monitorBusy, setMonitorBusy] = useState(false)
|
||||
const [monitorError, setMonitorError] = useState<string | null>(null)
|
||||
const [monitorAlerts, setMonitorAlerts] = useState<MonitorAlert[]>([])
|
||||
const [monitorDetailTaskId, setMonitorDetailTaskId] = useState<string | null>(null)
|
||||
const [monitorDetail, setMonitorDetail] = useState<MonitorTaskDetail | null>(null)
|
||||
const [monitorDetailLoading, setMonitorDetailLoading] = useState(false)
|
||||
const [monitorDetailError, setMonitorDetailError] = useState<string | null>(null)
|
||||
const settingsRequestId = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -284,11 +294,14 @@ export function App() {
|
||||
if (!tennisBookApi || !selectedCourtId) return
|
||||
let active = true
|
||||
setPendingBooking(null)
|
||||
tennisBookApi.getPendingBooking(selectedCourtId)
|
||||
const load = () => tennisBookApi.getPendingBooking(selectedCourtId)
|
||||
.then((booking) => active && setPendingBooking(booking))
|
||||
.catch(() => active && setBookingError('待支付订单状态读取失败。'))
|
||||
void load()
|
||||
const timer = window.setInterval(() => void load(), 3_000)
|
||||
return () => {
|
||||
active = false
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [selectedCourtId])
|
||||
|
||||
@@ -333,6 +346,35 @@ export function App() {
|
||||
}
|
||||
}, [loadMonitorTasks, takeMonitorAlerts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tennisBookApi || !monitorDetailTaskId) {
|
||||
setMonitorDetail(null)
|
||||
return
|
||||
}
|
||||
setMonitorDetail(null)
|
||||
setMonitorDetailError(null)
|
||||
let active = true
|
||||
const load = async (showLoading = false) => {
|
||||
if (showLoading) setMonitorDetailLoading(true)
|
||||
try {
|
||||
const detail = await tennisBookApi.getMonitorTaskDetail(monitorDetailTaskId)
|
||||
if (!active) return
|
||||
setMonitorDetail(detail)
|
||||
setMonitorDetailError(null)
|
||||
} catch (error) {
|
||||
if (active) setMonitorDetailError(getErrorMessage(error, '监控详情读取失败。'))
|
||||
} finally {
|
||||
if (active && showLoading) setMonitorDetailLoading(false)
|
||||
}
|
||||
}
|
||||
void load(true)
|
||||
const timer = window.setInterval(() => void load(), 3_000)
|
||||
return () => {
|
||||
active = false
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [monitorDetailTaskId])
|
||||
|
||||
useEffect(() => {
|
||||
if (monitorAlerts.length === 0) return
|
||||
const currentAlert = monitorAlerts[0]!
|
||||
@@ -357,7 +399,8 @@ export function App() {
|
||||
courtId: selectedCourtId,
|
||||
...(monitorDate ? { targetDate: monitorDate } : {}),
|
||||
startTime: monitorStartTime,
|
||||
endTime: monitorEndTime
|
||||
endTime: monitorEndTime,
|
||||
autoBookingEnabled: monitorAutoBooking
|
||||
})
|
||||
setMonitorDialogOpen(false)
|
||||
await loadMonitorTasks()
|
||||
@@ -366,7 +409,7 @@ export function App() {
|
||||
} finally {
|
||||
setMonitorBusy(false)
|
||||
}
|
||||
}, [loadMonitorTasks, monitorDate, monitorEndTime, monitorStartTime, selectedCourtId])
|
||||
}, [loadMonitorTasks, monitorAutoBooking, monitorDate, monitorEndTime, monitorStartTime, selectedCourtId])
|
||||
|
||||
const toggleMonitorTask = useCallback(async (task: MonitorTask) => {
|
||||
if (!tennisBookApi) return
|
||||
@@ -379,6 +422,24 @@ export function App() {
|
||||
}
|
||||
}, [loadMonitorTasks])
|
||||
|
||||
const toggleMonitorAutoBooking = useCallback(async (task: MonitorTask) => {
|
||||
if (!tennisBookApi) return
|
||||
setMonitorError(null)
|
||||
try {
|
||||
const updated = await tennisBookApi.setMonitorTaskAutoBooking(
|
||||
task.id,
|
||||
!task.autoBooking.enabled
|
||||
)
|
||||
setMonitorDetail((current) => current?.task.id === updated.id
|
||||
? { ...current, task: updated }
|
||||
: current)
|
||||
await loadMonitorTasks()
|
||||
} catch (error) {
|
||||
setMonitorError(getErrorMessage(error, '自动预定状态更新失败。'))
|
||||
setMonitorDetailError(getErrorMessage(error, '自动预定状态更新失败。'))
|
||||
}
|
||||
}, [loadMonitorTasks])
|
||||
|
||||
const deleteMonitorTask = useCallback(async (taskId: string) => {
|
||||
if (!tennisBookApi) return
|
||||
setMonitorError(null)
|
||||
@@ -593,6 +654,7 @@ export function App() {
|
||||
disabled={!selectedCourtId}
|
||||
onClick={() => {
|
||||
setMonitorDate('')
|
||||
setMonitorAutoBooking(false)
|
||||
setMonitorError(null)
|
||||
setMonitorDialogOpen(true)
|
||||
}}
|
||||
@@ -606,6 +668,7 @@ export function App() {
|
||||
disabled={!selectedCourtId}
|
||||
onClick={() => {
|
||||
setMonitorDate('')
|
||||
setMonitorAutoBooking(false)
|
||||
setMonitorError(null)
|
||||
setMonitorDialogOpen(true)
|
||||
}}
|
||||
@@ -614,25 +677,62 @@ export function App() {
|
||||
) : (
|
||||
<div className="monitor-task-list">
|
||||
{monitorTasks.map((task) => (
|
||||
<article className={`monitor-task ${task.enabled ? 'is-running' : ''}`} key={task.id}>
|
||||
<article
|
||||
aria-label={`查看 ${task.targetDate ?? '不限日期'} ${task.startTime} 到 ${task.endTime} 的监控详情`}
|
||||
className={`monitor-task ${task.enabled ? 'is-running' : ''}`}
|
||||
key={task.id}
|
||||
onClick={() => setMonitorDetailTaskId(task.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setMonitorDetailTaskId(task.id)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="monitor-pulse" />
|
||||
<div>
|
||||
<strong>{task.targetDate ?? '不限日期'} · {task.startTime}–{task.endTime}</strong>
|
||||
<small>{task.enabled
|
||||
? task.lastError
|
||||
? '检查失败,正在重试'
|
||||
: `监控中 · ${task.targetDate ? '当前' : '未来 7 天'} ${task.availableCount} 个空场`
|
||||
: `监控中 · ${task.targetDate ? '当前' : '未来 7 天'} ${task.availableCount} 个连续空场`
|
||||
: '已暂停'}</small>
|
||||
<span className="monitor-task-stats">
|
||||
{task.stats.totalChecks} 次检查 · {task.stats.alertsSent} 次提醒
|
||||
</span>
|
||||
{task.autoBooking.status !== 'disabled' ? (
|
||||
<span className={`monitor-auto-badge is-${task.autoBooking.status}`}>
|
||||
<Zap size={9} />
|
||||
{task.autoBooking.status === 'armed'
|
||||
? '自动预定已就绪'
|
||||
: task.autoBooking.status === 'attempting'
|
||||
? '正在自动锁场'
|
||||
: task.autoBooking.status === 'pending-payment'
|
||||
? '自动锁场成功 · 待支付'
|
||||
: task.autoBooking.status === 'blocked'
|
||||
? '已有订单 · 自动预定已阻止'
|
||||
: '自动预定已熔断'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
aria-label={task.enabled ? '暂停监控' : '继续监控'}
|
||||
onClick={() => void toggleMonitorTask(task)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void toggleMonitorTask(task)
|
||||
}}
|
||||
title={task.enabled ? '暂停' : '继续'}
|
||||
type="button"
|
||||
>{task.enabled ? <Pause size={12} /> : <Play size={12} />}</button>
|
||||
<button
|
||||
aria-label="删除监控"
|
||||
onClick={() => void deleteMonitorTask(task.id)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void deleteMonitorTask(task.id)
|
||||
}}
|
||||
title="删除"
|
||||
type="button"
|
||||
><Trash2 size={12} /></button>
|
||||
@@ -833,6 +933,124 @@ export function App() {
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{monitorDetailTaskId ? (
|
||||
<div
|
||||
className="monitor-detail-backdrop"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) setMonitorDetailTaskId(null)
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<section className="monitor-detail-panel" role="dialog" aria-modal="true">
|
||||
<header className="monitor-detail-header">
|
||||
<div>
|
||||
<span className="monitor-detail-icon"><Activity size={18} /></span>
|
||||
<div><span className="eyebrow">MONITOR INTELLIGENCE</span><h2>任务运行大盘</h2></div>
|
||||
</div>
|
||||
<button aria-label="关闭监控详情" onClick={() => setMonitorDetailTaskId(null)} type="button"><X size={17} /></button>
|
||||
</header>
|
||||
{monitorDetailLoading && !monitorDetail ? (
|
||||
<div className="monitor-detail-empty"><RefreshCw className="is-spinning" size={22} />正在读取监控记录…</div>
|
||||
) : monitorDetail ? (
|
||||
<>
|
||||
<div className="monitor-detail-hero">
|
||||
<div>
|
||||
<span className={`monitor-detail-status ${monitorDetail.task.enabled ? 'is-live' : ''}`}>
|
||||
<i />{monitorDetail.task.enabled ? '实时监控中' : '任务已暂停'}
|
||||
</span>
|
||||
<h3>{monitorDetail.task.targetDate ?? '未来 7 天滚动监控'}</h3>
|
||||
<p>{monitorDetail.task.startTime}–{monitorDetail.task.endTime} · 每 10 秒检查 · 数据仅存储于本机</p>
|
||||
</div>
|
||||
<div className="monitor-detail-current"><strong>{monitorDetail.task.availableCount}</strong><span>完整空场</span></div>
|
||||
</div>
|
||||
<section className={`monitor-auto-control is-${monitorDetail.task.autoBooking.status}`}>
|
||||
<span className="monitor-auto-icon"><Zap size={17} /></span>
|
||||
<div>
|
||||
<strong>自动预定</strong>
|
||||
<small>{monitorDetail.task.autoBooking.status === 'armed'
|
||||
? '已授权:命中新空场后自动锁定一个场地,不会自动支付。'
|
||||
: monitorDetail.task.autoBooking.status === 'attempting'
|
||||
? `正在执行第 ${monitorDetail.task.autoBooking.attemptCount} 次锁场请求…`
|
||||
: monitorDetail.task.autoBooking.status === 'pending-payment'
|
||||
? `已锁定 ${monitorDetail.task.autoBooking.bookedDate ?? ''} ${monitorDetail.task.autoBooking.bookedSlotLabel ?? ''},请及时支付。`
|
||||
: monitorDetail.task.autoBooking.status === 'blocked'
|
||||
? `已阻止:${monitorDetail.task.autoBooking.lastError ?? '请先处理当前球场订单。'}`
|
||||
: monitorDetail.task.autoBooking.status === 'stopped'
|
||||
? `已停止:${monitorDetail.task.autoBooking.lastError ?? '需要手动重新开启。'}`
|
||||
: '关闭状态:发现空场时只发送通知。'}</small>
|
||||
</div>
|
||||
<button
|
||||
aria-checked={monitorDetail.task.autoBooking.enabled}
|
||||
aria-label={monitorDetail.task.autoBooking.enabled ? '关闭自动预定' : '开启自动预定'}
|
||||
className={monitorDetail.task.autoBooking.enabled ? 'is-on' : ''}
|
||||
disabled={monitorDetail.task.autoBooking.status === 'attempting'}
|
||||
onClick={() => void toggleMonitorAutoBooking(monitorDetail.task)}
|
||||
role="switch"
|
||||
type="button"
|
||||
><i /></button>
|
||||
</section>
|
||||
{monitorDetailError ? (
|
||||
<div className="settings-error"><CircleAlert size={15} />{monitorDetailError}</div>
|
||||
) : null}
|
||||
<div className="monitor-metric-grid">
|
||||
<div><span>累计检查</span><strong>{monitorDetail.task.stats.totalChecks}</strong><small>轮询批次</small></div>
|
||||
<div><span>发送提醒</span><strong>{monitorDetail.task.stats.alertsSent}</strong><small>命中日期</small></div>
|
||||
<div><span>稳定率</span><strong>{monitorDetail.task.stats.totalChecks > 0
|
||||
? Math.round((monitorDetail.task.stats.successfulChecks / monitorDetail.task.stats.totalChecks) * 100)
|
||||
: 0}%</strong><small>完全成功</small></div>
|
||||
<div><span>空场观测</span><strong>{monitorDetail.task.stats.availableObservations}</strong><small>累计快照数</small></div>
|
||||
</div>
|
||||
<div className="monitor-detail-grid">
|
||||
<section className="monitor-trend-card">
|
||||
<header><div><BarChart3 size={15} /><strong>近 7 日运行趋势</strong></div><span>检查批次</span></header>
|
||||
{monitorDetail.dailyStats.length > 0 ? (
|
||||
<div className="monitor-bars">
|
||||
{monitorDetail.dailyStats.slice(-7).map((daily) => {
|
||||
const maxChecks = Math.max(...monitorDetail.dailyStats.slice(-7).map((item) => item.checks), 1)
|
||||
return (
|
||||
<div className="monitor-bar-column" key={daily.date} title={`${daily.date}:${daily.checks} 次检查,${daily.alerts} 次提醒`}>
|
||||
<div className="monitor-bar-track">
|
||||
{daily.alerts > 0 ? <b>{daily.alerts}</b> : null}
|
||||
<i style={{ height: `${Math.max(6, Math.round((daily.checks / maxChecks) * 100))}%` }} />
|
||||
</div>
|
||||
<strong>{daily.checks}</strong><span>{daily.date.slice(5)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : <div className="monitor-chart-empty">首轮检查完成后生成趋势</div>}
|
||||
</section>
|
||||
<section className="monitor-health-card">
|
||||
<header><strong>任务健康度</strong><span>累计</span></header>
|
||||
<div><span><i className="success" />完全成功</span><strong>{monitorDetail.task.stats.successfulChecks}</strong></div>
|
||||
<div><span><i className="warning" />部分失败</span><strong>{monitorDetail.task.stats.partialFailureChecks}</strong></div>
|
||||
<div><span><i className="error" />全部失败</span><strong>{monitorDetail.task.stats.failedChecks}</strong></div>
|
||||
<footer>{monitorDetail.task.stats.lastAlertAt
|
||||
? `最近提醒 ${new Date(monitorDetail.task.stats.lastAlertAt).toLocaleString('zh-CN')}`
|
||||
: '尚未触发空场提醒'}</footer>
|
||||
</section>
|
||||
</div>
|
||||
<section className="monitor-log-card">
|
||||
<header><div><Clock3 size={15} /><strong>实时运行日志</strong></div><span>最近 {monitorDetail.logs.length} 条</span></header>
|
||||
<div className="monitor-log-list">
|
||||
{monitorDetail.logs.map((log) => (
|
||||
<article className={`monitor-log-item is-${log.level}`} key={log.id}>
|
||||
<i />
|
||||
<time>{new Date(log.occurredAt).toLocaleString('zh-CN', { hour12: false })}</time>
|
||||
<div><strong>{log.message}</strong>{log.failedDates?.length ? <small>异常日期:{log.failedDates.join('、')}</small> : null}</div>
|
||||
<span>{log.availableCount === undefined ? '—' : `${log.availableCount} 空场`}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<div className="monitor-detail-empty"><CircleAlert size={22} />{monitorDetailError ?? '任务详情暂不可用'}</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{settingsOpen ? (
|
||||
<div
|
||||
className="settings-backdrop"
|
||||
@@ -922,7 +1140,7 @@ export function App() {
|
||||
<span className="settings-icon"><BellRing size={17} /></span>
|
||||
<div><span className="eyebrow">COURT WATCHER</span><h2>新建空场监控</h2></div>
|
||||
</div>
|
||||
<p>应用将在后台每 10 秒刷新一次无凭证球场列表。不限日期会持续滚动检查未来 7 天,新空场出现时发送系统通知。</p>
|
||||
<p>应用将在后台每 10 秒刷新一次无凭证球场列表。只有同一个场地完整连续覆盖所选时间段才会命中;不限日期会持续滚动检查未来 7 天。</p>
|
||||
<fieldset className="monitor-date-options">
|
||||
<legend>监控日期</legend>
|
||||
<button
|
||||
@@ -944,6 +1162,19 @@ export function App() {
|
||||
<i>至</i>
|
||||
<label><span>结束时间</span><input onChange={(event) => setMonitorEndTime(event.target.value)} step="3600" type="time" value={monitorEndTime} /></label>
|
||||
</div>
|
||||
<label className={`monitor-auto-option ${monitorAutoBooking ? 'is-enabled' : ''}`}>
|
||||
<span className="monitor-auto-option-icon"><Zap size={16} /></span>
|
||||
<span>
|
||||
<strong>自动预定一个完整连续场地</strong>
|
||||
<small>全部组成时段会作为一笔待支付订单提交,不会自动支付。失败仅重试一次。</small>
|
||||
</span>
|
||||
<input
|
||||
checked={monitorAutoBooking}
|
||||
onChange={(event) => setMonitorAutoBooking(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<i aria-hidden="true"><b /></i>
|
||||
</label>
|
||||
{monitorError ? <div className="settings-error"><CircleAlert size={15} />{monitorError}</div> : null}
|
||||
<div className="settings-actions">
|
||||
<button disabled={monitorBusy} onClick={() => setMonitorDialogOpen(false)} type="button">取消</button>
|
||||
|
||||
@@ -177,7 +177,16 @@ button { color: inherit; }
|
||||
.monitor-hub-head > button { display: grid; width: 27px; height: 27px; padding: 0; border: 1px solid #d9e2d6; border-radius: 8px; place-items: center; color: var(--green); background: #f8fbf5; cursor: pointer; }
|
||||
.monitor-empty { width: 100%; margin-top: 9px; padding: 10px; border: 1px dashed #cfd9cd; border-radius: 9px; color: #607369; background: #f8faf5; font-size: 9px; line-height: 1.5; cursor: pointer; }
|
||||
.monitor-empty span { color: #2e7955; font-weight: 750; }
|
||||
.monitor-task-list { display: flex; max-height: 154px; margin-top: 8px; overflow-y: auto; flex-direction: column; gap: 6px; }
|
||||
.monitor-task-list { display: flex; max-height: 190px; margin-top: 8px; overflow-y: auto; flex-direction: column; gap: 6px; }
|
||||
.monitor-auto-badge { display: inline-flex; width: fit-content; align-items: center; gap: 4px; margin-top: 2px; padding: 3px 6px; border-radius: 999px; color: #346047; background: #e9f2df; font-size: 7px; font-weight: 800; }
|
||||
.monitor-auto-badge.is-attempting { color: #76571e; background: #fff0c9; }
|
||||
.monitor-auto-badge.is-pending-payment { color: #205f43; background: #dff5df; }
|
||||
.monitor-auto-badge.is-blocked { color: #8b6328; background: #f8ebcc; }
|
||||
.monitor-auto-badge.is-stopped { color: #a14f3f; background: #fbe3dc; }
|
||||
.monitor-task[role='button'] { cursor: pointer; }
|
||||
.monitor-task[role='button']:hover { border-color: #b7cbb9; background: #f8fbf4; }
|
||||
.monitor-task[role='button']:focus-visible { outline: 2px solid #789981; outline-offset: 2px; }
|
||||
.monitor-task-stats { display: block; margin-top: 3px; color: #97a49d; font-size: 8px; font-weight: 700; letter-spacing: .02em; }
|
||||
.monitor-task { display: grid; grid-template-columns: 7px minmax(0,1fr) 25px 25px; align-items: center; gap: 6px; padding: 8px 6px; border: 1px solid #e1e6dd; border-radius: 9px; background: #fafbf7; }
|
||||
.monitor-task > div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.monitor-task strong { overflow: hidden; color: #365247; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -397,6 +406,18 @@ button { color: inherit; }
|
||||
.monitor-time-fields input { height: 43px; padding: 0 11px; border: 1px solid #ccd6ca; border-radius: 10px; outline: 0; color: var(--ink); background: #fff; font-weight: 700; }
|
||||
.monitor-time-fields input:focus { border-color: #789981; box-shadow: 0 0 0 3px rgba(37,116,82,.09); }
|
||||
.monitor-time-fields > i { align-self: center; margin-top: 18px; color: #9aa59e; font-size: 9px; font-style: normal; text-align: center; }
|
||||
.monitor-auto-option { position: relative; display: grid; grid-template-columns: 36px minmax(0,1fr) 38px; align-items: center; gap: 11px; margin-top: 16px; padding: 13px; border: 1px solid #d9e2d6; border-radius: 13px; background: #f8faf5; cursor: pointer; transition: .18s ease; }
|
||||
.monitor-auto-option.is-enabled { border-color: #8bad91; background: #edf5e9; box-shadow: 0 0 0 3px rgba(37,116,82,.06); }
|
||||
.monitor-auto-option-icon { display: grid; width: 36px; height: 36px; border-radius: 10px; place-items: center; color: #607267; background: #e7ece3; }
|
||||
.monitor-auto-option.is-enabled .monitor-auto-option-icon { color: #d8ff63; background: #174f3a; }
|
||||
.monitor-auto-option > span:nth-child(2) { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.monitor-auto-option strong { color: #2b493a; font-size: 10px; }
|
||||
.monitor-auto-option small { color: #7c8b82; font-size: 8px; line-height: 1.45; }
|
||||
.monitor-auto-option input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.monitor-auto-option > i,.monitor-auto-control > button { position: relative; width: 38px; height: 22px; padding: 0; border: 0; border-radius: 999px; background: #cbd4ca; cursor: pointer; transition: .18s ease; }
|
||||
.monitor-auto-option > i b,.monitor-auto-control > button i { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 2px 5px rgba(24,55,40,.18); transition: transform .18s ease; }
|
||||
.monitor-auto-option.is-enabled > i,.monitor-auto-control > button.is-on { background: #2d8058; }
|
||||
.monitor-auto-option.is-enabled > i b,.monitor-auto-control > button.is-on i { transform: translateX(16px); }
|
||||
.monitor-alert-toast { position: fixed; z-index: 60; top: 68px; right: 20px; display: grid; grid-template-columns: 36px minmax(0,1fr) auto; align-items: center; min-width: 350px; max-width: 480px; gap: 11px; padding: 13px; border: 1px solid #cfe0c9; border-radius: 15px; color: #1f4434; text-align: left; background: rgba(250,255,244,.97); box-shadow: 0 18px 52px rgba(22,69,46,.18); backdrop-filter: blur(16px); cursor: pointer; animation: monitorToastIn .28s ease-out both; }
|
||||
.monitor-alert-toast > span { display: grid; width: 36px; height: 36px; border-radius: 11px; place-items: center; color: var(--tennis); background: var(--green); }
|
||||
.monitor-alert-toast > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
@@ -404,6 +425,80 @@ button { color: inherit; }
|
||||
.monitor-alert-toast small { overflow: hidden; color: #698076; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.monitor-alert-toast > i { color: #2b7954; font-size: 9px; font-style: normal; font-weight: 780; }
|
||||
|
||||
.monitor-detail-backdrop { position: fixed; z-index: 75; inset: 0; display: flex; justify-content: flex-end; padding: 14px; background: rgba(26,45,36,.25); backdrop-filter: blur(8px); animation: monitorBackdropIn .18s ease-out both; }
|
||||
.monitor-detail-panel { width: min(760px, calc(100vw - 28px)); height: calc(100vh - 28px); overflow: auto; padding: 22px; border: 1px solid rgba(210,222,208,.9); border-radius: 22px; color: var(--ink); background: #f7f8f3; box-shadow: -24px 20px 80px rgba(22,54,38,.2); animation: monitorPanelIn .28s cubic-bezier(.2,.8,.2,1) both; }
|
||||
.monitor-detail-header { position: sticky; z-index: 2; top: -22px; display: flex; align-items: center; justify-content: space-between; margin: -22px -22px 0; padding: 19px 22px 16px; border-bottom: 1px solid rgba(217,225,214,.9); background: rgba(247,248,243,.92); backdrop-filter: blur(18px); }
|
||||
.monitor-detail-header > div { display: flex; align-items: center; gap: 11px; }
|
||||
.monitor-detail-header h2 { margin: 2px 0 0; font-family: 'Newsreader Variable', serif; font-size: 22px; font-weight: 600; }
|
||||
.monitor-detail-header > button { display: grid; width: 34px; height: 34px; padding: 0; border: 1px solid #d7dfd4; border-radius: 10px; place-items: center; color: #66776d; background: #fff; cursor: pointer; }
|
||||
.monitor-detail-icon { display: grid; width: 38px; height: 38px; border-radius: 12px; place-items: center; color: #d8ff63; background: #174d38; box-shadow: 0 8px 22px rgba(23,77,56,.16); }
|
||||
.monitor-detail-hero { position: relative; display: flex; align-items: center; justify-content: space-between; min-height: 132px; margin-top: 20px; padding: 24px; overflow: hidden; border-radius: 18px; color: #f7fff9; background: linear-gradient(120deg,#153f30 0%,#245e43 62%,#3a7653 100%); box-shadow: 0 16px 36px rgba(24,73,51,.16); }
|
||||
.monitor-detail-hero::after { position: absolute; right: -34px; bottom: -76px; width: 210px; height: 210px; border: 34px solid rgba(216,255,99,.11); border-radius: 50%; content: ''; }
|
||||
.monitor-detail-hero h3 { margin: 13px 0 3px; font-family: 'Newsreader Variable', serif; font-size: 27px; font-weight: 520; }
|
||||
.monitor-detail-hero p { margin: 0; color: rgba(240,255,246,.66); font-size: 9px; }
|
||||
.monitor-detail-status { display: inline-flex; align-items: center; gap: 6px; color: rgba(240,255,246,.72); font-size: 9px; font-weight: 780; letter-spacing: .08em; }
|
||||
.monitor-detail-status i { width: 7px; height: 7px; border-radius: 50%; background: #9bad9f; }
|
||||
.monitor-detail-status.is-live i { background: #d8ff63; box-shadow: 0 0 0 5px rgba(216,255,99,.12); animation: monitorPulse 1.8s ease-in-out infinite; }
|
||||
.monitor-detail-current { position: relative; z-index: 1; display: flex; min-width: 110px; flex-direction: column; align-items: flex-end; }
|
||||
.monitor-detail-current strong { font-family: 'Newsreader Variable', serif; font-size: 50px; font-weight: 500; line-height: .9; }
|
||||
.monitor-detail-current span { margin-top: 9px; color: #d8ff63; font-size: 9px; font-weight: 800; letter-spacing: .12em; }
|
||||
.monitor-auto-control { display: grid; grid-template-columns: 40px minmax(0,1fr) 38px; align-items: center; gap: 12px; margin-top: 12px; padding: 14px 15px; border: 1px solid #dce4d9; border-radius: 14px; background: #fff; box-shadow: 0 5px 18px rgba(31,66,47,.04); }
|
||||
.monitor-auto-control.is-armed { border-color: #b9d1ae; background: linear-gradient(100deg,#f5faed,#fff 70%); }
|
||||
.monitor-auto-control.is-pending-payment { border-color: #a9d2b1; background: #f1faf0; }
|
||||
.monitor-auto-control.is-blocked { border-color: #e4d2a7; background: #fffaf0; }
|
||||
.monitor-auto-control.is-stopped { border-color: #e5c8c0; background: #fff8f5; }
|
||||
.monitor-auto-icon { display: grid; width: 40px; height: 40px; border-radius: 11px; place-items: center; color: #718078; background: #edf0ea; }
|
||||
.monitor-auto-control.is-armed .monitor-auto-icon,.monitor-auto-control.is-attempting .monitor-auto-icon { color: #d8ff63; background: #174f3a; }
|
||||
.monitor-auto-control.is-pending-payment .monitor-auto-icon { color: #28744f; background: #dff1df; }
|
||||
.monitor-auto-control.is-blocked .monitor-auto-icon { color: #8b6328; background: #f7e9c7; }
|
||||
.monitor-auto-control.is-stopped .monitor-auto-icon { color: #aa5a48; background: #f6ded7; }
|
||||
.monitor-auto-control > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.monitor-auto-control strong { color: #294b39; font-size: 11px; }
|
||||
.monitor-auto-control small { overflow: hidden; color: #7c8b82; font-size: 8px; line-height: 1.5; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.monitor-auto-control > button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.monitor-metric-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 10px; margin-top: 12px; }
|
||||
.monitor-metric-grid > div { display: flex; min-width: 0; flex-direction: column; padding: 15px; border: 1px solid #dde4da; border-radius: 14px; background: #fff; box-shadow: 0 5px 18px rgba(31,66,47,.04); }
|
||||
.monitor-metric-grid span { color: #74837a; font-size: 9px; font-weight: 740; }
|
||||
.monitor-metric-grid strong { margin: 8px 0 2px; color: #244936; font-family: 'Newsreader Variable', serif; font-size: 26px; font-weight: 600; }
|
||||
.monitor-metric-grid small { color: #a0aba4; font-size: 8px; }
|
||||
.monitor-detail-grid { display: grid; grid-template-columns: minmax(0,1.7fr) minmax(210px,.8fr); gap: 12px; margin-top: 12px; }
|
||||
.monitor-trend-card,.monitor-health-card,.monitor-log-card { border: 1px solid #dde4da; border-radius: 16px; background: #fff; box-shadow: 0 5px 18px rgba(31,66,47,.04); }
|
||||
.monitor-trend-card,.monitor-health-card { min-height: 235px; padding: 16px; }
|
||||
.monitor-trend-card > header,.monitor-health-card > header,.monitor-log-card > header { display: flex; align-items: center; justify-content: space-between; color: #536a5d; }
|
||||
.monitor-trend-card > header div,.monitor-log-card > header div { display: flex; align-items: center; gap: 7px; }
|
||||
.monitor-trend-card header strong,.monitor-health-card header strong,.monitor-log-card header strong { color: #294b39; font-size: 11px; }
|
||||
.monitor-trend-card header span,.monitor-health-card header span,.monitor-log-card header span { color: #9aa69f; font-size: 8px; }
|
||||
.monitor-bars { display: grid; height: 174px; grid-template-columns: repeat(7,1fr); align-items: end; gap: 8px; margin-top: 16px; }
|
||||
.monitor-bar-column { display: grid; height: 100%; grid-template-rows: 1fr auto auto; align-items: end; gap: 3px; text-align: center; }
|
||||
.monitor-bar-track { position: relative; width: 100%; height: 100%; overflow: hidden; border-radius: 8px; background: #f0f3ed; }
|
||||
.monitor-bar-track i { position: absolute; right: 0; bottom: 0; left: 0; border-radius: 8px 8px 5px 5px; background: linear-gradient(180deg,#9bc46d,#346f4d); }
|
||||
.monitor-bar-track b { position: absolute; z-index: 1; top: 5px; left: 50%; min-width: 16px; padding: 2px 4px; border-radius: 8px; color: #163f2e; background: #d8ff63; font-size: 7px; transform: translateX(-50%); }
|
||||
.monitor-bar-column > strong { color: #496255; font-size: 8px; }
|
||||
.monitor-bar-column > span { color: #9aa69f; font-size: 7px; }
|
||||
.monitor-chart-empty,.monitor-detail-empty { display: flex; min-height: 170px; align-items: center; justify-content: center; gap: 8px; color: #87958d; font-size: 10px; }
|
||||
.monitor-health-card > div { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; padding-bottom: 10px; border-bottom: 1px solid #edf0ea; }
|
||||
.monitor-health-card > div span { display: flex; align-items: center; gap: 7px; color: #64776c; font-size: 9px; }
|
||||
.monitor-health-card > div i { width: 7px; height: 7px; border-radius: 50%; }
|
||||
.monitor-health-card i.success { background: #5d9d70; }
|
||||
.monitor-health-card i.warning { background: #d6a34d; }
|
||||
.monitor-health-card i.error { background: #ca6b5f; }
|
||||
.monitor-health-card > div strong { color: #294b39; font-family: 'Newsreader Variable',serif; font-size: 18px; }
|
||||
.monitor-health-card footer { margin-top: 14px; color: #95a198; font-size: 8px; line-height: 1.5; }
|
||||
.monitor-log-card { margin-top: 12px; padding: 16px; }
|
||||
.monitor-log-list { max-height: 330px; overflow: auto; margin-top: 12px; border-top: 1px solid #edf0ea; }
|
||||
.monitor-log-item { display: grid; grid-template-columns: 8px 130px minmax(0,1fr) 56px; align-items: start; gap: 9px; padding: 10px 2px; border-bottom: 1px solid #edf0ea; }
|
||||
.monitor-log-item > i { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: #8ca091; }
|
||||
.monitor-log-item.is-warning > i { background: #d6a34d; box-shadow: 0 0 0 4px rgba(214,163,77,.1); }
|
||||
.monitor-log-item.is-success > i { background: #66a773; box-shadow: 0 0 0 4px rgba(102,167,115,.1); }
|
||||
.monitor-log-item time { color: #93a097; font-size: 8px; line-height: 1.5; }
|
||||
.monitor-log-item div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.monitor-log-item div strong { overflow: hidden; color: #40594c; font-size: 9px; font-weight: 660; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.monitor-log-item div small { color: #b07a42; font-size: 8px; }
|
||||
.monitor-log-item > span { color: #789083; font-size: 8px; text-align: right; }
|
||||
@keyframes monitorPanelIn { from { opacity: 0; transform: translateX(24px) scale(.99); } to { opacity: 1; transform: translateX(0) scale(1); } }
|
||||
@keyframes monitorBackdropIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@media (max-width: 760px) { .monitor-metric-grid { grid-template-columns: 1fr 1fr; }.monitor-detail-grid { grid-template-columns: 1fr; }.monitor-detail-panel { padding: 16px; }.monitor-log-item { grid-template-columns: 8px 1fr 52px; }.monitor-log-item time { display: none; } }
|
||||
|
||||
@keyframes monitorPulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .55; transform: scale(.78); } }
|
||||
@keyframes monitorToastIn { from { opacity: 0; transform: translateY(-8px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
.settings-loading { display: grid; min-height: 94px; place-items: center; color: #7e8e84; font-size: 10px; }
|
||||
|
||||
@@ -76,8 +76,11 @@ export interface UpdateCourtSettingsInput {
|
||||
export interface CreateBookingInput {
|
||||
courtId: string
|
||||
date: string
|
||||
slotId: string
|
||||
slotId?: string
|
||||
slotIds?: string[]
|
||||
expectedPriceCents: number
|
||||
expectedStartTime?: string
|
||||
expectedEndTime?: string
|
||||
}
|
||||
|
||||
export interface CancelBookingInput {
|
||||
@@ -112,6 +115,64 @@ export interface MonitorTask {
|
||||
lastCheckedAt?: string
|
||||
lastError?: string
|
||||
availableCount: number
|
||||
stats: MonitorTaskStats
|
||||
autoBooking: MonitorAutoBookingState
|
||||
}
|
||||
|
||||
export type MonitorAutoBookingStatus =
|
||||
| 'disabled'
|
||||
| 'armed'
|
||||
| 'attempting'
|
||||
| 'pending-payment'
|
||||
| 'blocked'
|
||||
| 'stopped'
|
||||
|
||||
export interface MonitorAutoBookingState {
|
||||
enabled: boolean
|
||||
status: MonitorAutoBookingStatus
|
||||
attemptCount: number
|
||||
lastAttemptAt?: string
|
||||
lastError?: string
|
||||
orderId?: string
|
||||
bookedAt?: string
|
||||
bookedDate?: string
|
||||
bookedSlotLabel?: string
|
||||
}
|
||||
|
||||
export interface MonitorTaskStats {
|
||||
totalChecks: number
|
||||
successfulChecks: number
|
||||
partialFailureChecks: number
|
||||
failedChecks: number
|
||||
alertsSent: number
|
||||
availableObservations: number
|
||||
lastAlertAt?: string
|
||||
}
|
||||
|
||||
export interface MonitorDailyStats {
|
||||
date: string
|
||||
checks: number
|
||||
failedChecks: number
|
||||
alerts: number
|
||||
availableObservations: number
|
||||
maxAvailable: number
|
||||
}
|
||||
|
||||
export interface MonitorTaskLog {
|
||||
id: string
|
||||
occurredAt: string
|
||||
type: 'check' | 'alert' | 'error' | 'lifecycle' | 'booking'
|
||||
level: 'info' | 'warning' | 'success'
|
||||
message: string
|
||||
availableCount?: number
|
||||
checkedDateCount?: number
|
||||
failedDates?: string[]
|
||||
}
|
||||
|
||||
export interface MonitorTaskDetail {
|
||||
task: MonitorTask
|
||||
dailyStats: MonitorDailyStats[]
|
||||
logs: MonitorTaskLog[]
|
||||
}
|
||||
|
||||
export interface CreateMonitorTaskInput {
|
||||
@@ -119,6 +180,7 @@ export interface CreateMonitorTaskInput {
|
||||
targetDate?: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
autoBookingEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface MonitorAlert {
|
||||
@@ -140,8 +202,10 @@ export interface TennisBookApi {
|
||||
cancelBooking(input: CancelBookingInput): Promise<void>
|
||||
getPendingBooking(courtId: string): Promise<PendingBooking | null>
|
||||
listMonitorTasks(): Promise<MonitorTask[]>
|
||||
getMonitorTaskDetail(taskId: string): Promise<MonitorTaskDetail>
|
||||
createMonitorTask(input: CreateMonitorTaskInput): Promise<MonitorTask>
|
||||
setMonitorTaskEnabled(taskId: string, enabled: boolean): Promise<MonitorTask>
|
||||
setMonitorTaskAutoBooking(taskId: string, enabled: boolean): Promise<MonitorTask>
|
||||
deleteMonitorTask(taskId: string): Promise<void>
|
||||
takeMonitorAlerts(): Promise<MonitorAlert[]>
|
||||
onMonitorAlert(listener: () => void): () => void
|
||||
@@ -156,8 +220,10 @@ export const IPC_CHANNELS = {
|
||||
cancelBooking: 'bookings:cancel',
|
||||
getPendingBooking: 'bookings:pending',
|
||||
listMonitorTasks: 'monitors:list',
|
||||
getMonitorTaskDetail: 'monitors:detail',
|
||||
createMonitorTask: 'monitors:create',
|
||||
setMonitorTaskEnabled: 'monitors:set-enabled',
|
||||
setMonitorTaskAutoBooking: 'monitors:set-auto-booking',
|
||||
deleteMonitorTask: 'monitors:delete',
|
||||
takeMonitorAlerts: 'monitors:take-alerts',
|
||||
monitorAlert: 'monitors:alert'
|
||||
|
||||
Reference in New Issue
Block a user