feat: 支持定时监控

This commit is contained in:
richarjiang
2026-09-23 20:03:13 +08:00
parent d4f55a97dc
commit 0843146d12
11 changed files with 310 additions and 26 deletions

View File

@@ -11,7 +11,7 @@
- `src/main/`:Electron 主进程、第三方网络请求、凭证和适配器注册。
- `src/main/settings/`:按球场保存的本机配置与原子写入逻辑,存储位置使用 Electron `userData` 目录。
- `src/main/bookings/`:待支付锁场订单摘要与恢复逻辑;不得持久化支付签名。
- `src/main/monitors/`:空场监控任务、10 秒轮询调度、整点立即检查、变化去重和系统通知;只允许调用无凭证列表接口。整点检查必须绕过常规轮询冷却,但不得与已有检查并发或在同一整点重复触发。
- `src/main/monitors/`:空场监控任务、10 秒轮询调度、整点立即检查、变化去重和系统通知;范思伯特继续使用无凭证列表;大湾区允许由专属适配器在主进程读取登录凭证查询列表。整点检查必须绕过常规轮询冷却,但不得与已有检查并发或在同一整点重复触发。
- 带报名活动(如双打活动)的时段统一视为已订场地;不得计入可订数量,也不得触发空场监控提醒。
- 空场监控日期可选;未指定日期的任务持续滚动检查主界面支持的未来 7 天数据,不能在跨日时自动失效。
- 自动预定是监控任务级、用户显式开启的一次性写操作。开启前必须明确告知“只锁场不支付”并校验球场访客凭证;成功后立即转为待支付且禁止再次下单。服务端明确失败只允许重试一次,第二次失败后自动熔断;结果不确定时禁止重试并立即熔断。
@@ -67,6 +67,13 @@
## 多凭证球场与外部订单处理
- 通用登录凭证使用 `credential` 按球场存储,旧球场的 `visitorId` 保持兼容;字段标签由适配器元数据提供。凭证仅允许在设置弹窗短暂回填,关闭立即清除。
- 需要登录的列表通过适配器独立的鉴权查询方法调用;后台监控必须使用无凭证查询路径,此类球场不得创建监控任务。
- 需要登录的列表通过适配器独立的鉴权查询方法调用;后台监控按适配器能力选择匿名或主进程鉴权查询;需要鉴权的球场创建或恢复任务前校验已配置凭证。
- 未接入取消或订单查询的球场必须提示用户到原小程序处理。清理本机记录使用独立 IPC 和明确的已处理确认,不得宣称远端取消成功;清理前仍禁止重复下单。
- `src/main/adapters/dawanqu/` 存放大湾区适配器、契约测试和脱敏 fixture;项目、区域及第三方时段参数只在适配器内处理。新增抓包只提取响应 fixture,不保存请求头或用户凭证。
## 放场优先调度
- 大湾区每天北京时间 09:00 开放包含当天在内的第三天(当天 +2 日);放场规则属于球场元数据,不在公共调度器中按球场 ID 分支。
- 放场首分钟优先检查已授权自动锁场任务的目标日期,再检查其他日期。目标日期一旦返回完整连续组合,立即复用原有下单状态机,不等待其他日期响应。
- 整点有独立唤醒;已有检查尚未结束时不得并发重入,完成后立即补查新整点,不能使用整点前的缓存。
- “秒杀任务”指用户开启的一次性自动锁场。接口的优惠/秒杀价格属于另一种请求契约;未证实优惠参数时不得猜测请求体或跳过核价。

View File

@@ -30,7 +30,7 @@ preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandb
`PSPLVISITORID` 等运行时凭证按 `courtId` 独立存储在 Electron `userData` 目录。范思伯特福中福的场地 ID 固定为适配器私有常量,只在请求边界映射到第三方 `userId` 字段和 `STOREID` 请求头,不经过 IPC,也不在配置界面暴露。设置弹窗通过专用白名单 IPC 读取凭证原值并回填密码框,默认遮挡;关闭弹窗立即清除渲染层中的输入值和设置对象,其他查询及预约接口不返回凭证。
可订列表刷新链路不读取球场凭证,`CourtAdapter.getAvailability()` 也不接收运行时设置,从类型边界保证列表请求不能携带 `PSPLVISITORID`。只有下单和释放写操作在主进程互斥区内读取最新凭证。
匿名可订列表刷新链路不读取球场凭证,`CourtAdapter.getAvailability()` 也不接收运行时设置,从类型边界保证列表请求不能携带 `PSPLVISITORID`。范思伯特只有下单和释放写操作读取凭证;大湾区的列表查询也通过独立鉴权方法读取本机 Authorization。
配置文件使用临时文件加重命名的方式原子替换。共享层始终用字符串表达外部 ID,避免超出 JavaScript 安全整数范围;具体适配器负责校验接口是否接受该范围并转换为请求格式。
@@ -56,7 +56,7 @@ preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandb
## 空场监控
监控任务由主进程持久化和调度,应用通过单实例锁避免重复调度。日期可指定为创建时的今日或明日,也可以留空。指定日期的任务到达当天结束时间后自动停用;未指定日期的任务持续滚动检查主界面当前支持的未来 7 天,跨日后自动向后滚动一天且不会过期。轮询间隔固定为 10 秒;本地时间进入新的整点时绕过剩余轮询冷却立即检查一次,以捕获整点释放的新场地。同一整点只触发一次,并复用现有串行调度,不与尚未结束的检查并发。应用启动和休眠后恢复也会立即调度一次。今日已经结束的指定日期范围不能创建。相同球场和日期的请求在同一轮中复用,并分别过滤 `available/limited` 时段。
监控任务由主进程持久化和调度,应用通过单实例锁避免重复调度。日期可指定为创建时的未来 7 天之一,也可以留空。指定日期的任务到达当天结束时间后自动停用;未指定日期的任务持续滚动检查主界面当前支持的未来 7 天,跨日后自动向后滚动一天且不会过期。轮询间隔固定为 10 秒;本地时间进入新的整点时绕过剩余轮询冷却立即检查一次,以捕获整点释放的新场地。同一整点只触发一次,并复用现有串行调度,不与尚未结束的检查并发。应用启动和休眠后恢复也会立即调度一次。今日已经结束的指定日期范围不能创建。相同球场和日期的请求在同一轮中复用,并分别过滤 `available/limited` 时段。
任务持久化上一次可订时段 ID 集合,只对新增 ID 通知;同一时段持续可订时不会重复通知,先消失再重新出现则再次通知。暂停和删除会让在途响应失效。提醒同时使用 Electron 系统通知和主进程待消费队列中的应用内提示,渲染器启动较晚或同轮出现多条提醒时也不会覆盖;默认只提醒,只有任务被用户显式授权自动预定时才会下单。带 `enrollment` 报名活动的时段属于已订场地,即使上游同时返回可预约标记,也必须从可订统计和监控提醒中排除。
@@ -70,7 +70,7 @@ preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandb
## 大湾区网球场
`dawanqu` 使用华润文体室内网球场矩阵接口。2026-09-23 实测无凭证列表返回业务码 403,因此使用主进程鉴权查询,不开放只允许匿名请求的后台监控。`credential` 保存 Authorization 值,适配器规范为 `Wechat <token>`;不内置抓包凭证。
`dawanqu` 使用华润文体室内网球场矩阵接口。2026-09-23 实测无凭证列表返回业务码 403,因此手动查询和后台监控均使用主进程鉴权查询,凭证不进入任务或日志。`credential` 保存 Authorization 值,适配器规范为 `Wechat <token>`;不内置抓包凭证。
先重查列表,再调用 `reserve/price` 核对金额,最后调用 `reserve` 提交完整连续组合。场地与时段联合构成唯一标识,物理场地用通用 `physicalCourtId` 表达。价格或格式变化时阻止提交。提交后的超时、5xx、解析异常均按不确定处理,不自动重试。订单金额取核价结果,订单号取 `orderBusUuid`;不推测支付到期时间。
@@ -81,3 +81,7 @@ preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandb
以 `isAbleReserve` 区分可订与不可订;`msg` 是原因提示,秒杀/优惠字段描述预约方式,不能覆盖已售状态。已开始时段显示“已开始”;不可订且无其他原因的优惠时段仍显示“已售”。可订但当前适配器未覆盖优惠/原时段转换流程时,保留可订状态,通过通用 `bookingUnavailableReason` 禁止应用内提交并提示在小程序预约。
矩阵接口业务码 `2000` 配合“当前最多可提前 N 天预定场地”表示未进入开放窗口,返回空时段及统一 `notice`,不是网络失败;只识别该明确响应,不把其他 `2000` 错误吞成空列表。开放窗口以服务端当次结果为准,不在客户端写死日期或推测放场时间。
### 放场监控
大湾区支持与已有球场相同的提醒、一次性自动锁场、完整连续时段复核、待支付互斥和不确定结果熔断。任务可选择未来 7 天中的具体日期,或持续滚动。每天北京时间 09:00 的首分钟优先检查当天 +2 日,先服务已启用且自动预定处于 armed 的任务;命中后不等其他日期完成就锁场。此后继续原有 10 秒轮询。独立整点唤醒与串行补查避免冷却漏检,仍不与正在执行的检查重叠;网络、系统休眠和登录有效性影响实际时延。

View File

@@ -26,7 +26,7 @@ async function runtime() {
}
const input = () => ({ ...query, slotId: slots()[0]!.id, expectedPriceCents: 12000 })
afterEach(() => vi.unstubAllGlobals())
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() })
describe('大湾区列表与请求边界', () => {
it('解析原始矩阵并以场地和时段联合去重', () => {
@@ -197,13 +197,43 @@ describe('大湾区核价、锁场与本机恢复', () => {
expect(await registry.getPendingBooking(query.courtId)).not.toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('拒绝新建需要凭证的后台监控', async () => {
const { registry } = await runtime()
it('大湾区监控和自动预定使用 Authorization,并拒绝缺失凭证', async () => {
const { registry, store: settingsStore } = await runtime()
const dir = await mkdtemp(join(tmpdir(), 'tennis-dawanqu-monitor-'))
const store = new MonitorTaskStore(join(dir, 'tasks.json'))
const service = new MonitorService(registry, store, vi.fn())
await expect(service.createTask({ courtId: query.courtId, startTime: '09:00', endTime: '10:00' })).rejects.toThrow('无凭证空场监控')
expect(await store.list()).toEqual([])
const service = new MonitorService(registry, store, vi.fn(), false)
const task = await service.createTask({ courtId: query.courtId, startTime: '09:00', endTime: '10:00', autoBookingEnabled: true })
expect(task.autoBooking.status).toBe('armed')
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await settingsStore.update({ courtId: query.courtId })
await expect(service.createTask({ courtId: query.courtId, startTime: '11:00', endTime: '12:00' })).rejects.toThrow('Authorization')
await service.setEnabled(task.id, false)
await expect(service.setEnabled(task.id, true)).rejects.toThrow('Authorization')
expect(fetchMock).not.toHaveBeenCalled()
expect(await store.list()).toHaveLength(1)
})
it('监控通过真实注册表鉴权查询、重查核价并只锁一次场(全部请求为 mock)', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-23T11:00:00Z'))
const { registry } = await runtime()
const dir = await mkdtemp(join(tmpdir(), 'tennis-dawanqu-monitor-booking-'))
const store = new MonitorTaskStore(join(dir, 'tasks.json'))
const service = new MonitorService(registry, store, vi.fn(), false)
const task = await service.createTask({ courtId: query.courtId, targetDate: query.date, startTime: '09:00', endTime: '10:00', autoBookingEnabled: true })
const fetchMock = vi.fn()
.mockResolvedValueOnce(success(fixture.result))
.mockResolvedValueOnce(success(fixture.result))
.mockResolvedValueOnce(quote())
.mockResolvedValueOnce(order())
vi.stubGlobal('fetch', fetchMock)
await service.runOnceForTest()
expect(fetchMock.mock.calls.map(([url]) => url.split('/').at(-1))).toEqual(['matrix', 'matrix', 'price', 'reserve'])
for (const [, request] of fetchMock.mock.calls) expect(request.headers.Authorization).toBe('Wechat fixture.token.signature')
expect((await store.list()).find((candidate) => candidate.id === task.id)?.autoBooking.status).toBe('pending-payment')
expect((await registry.getPendingBooking(query.courtId))?.status).toBe('pending-payment')
await service.runOnceForTest()
expect(fetchMock).toHaveBeenCalledTimes(4)
})
it('新凭证按球场持久化,旧球场 visitorId 保持兼容', async () => {
const { registry, store } = await runtime()

View File

@@ -155,6 +155,7 @@ export const dawanquAdapter: CourtAdapter = {
id: COURT_ID, name: '大湾区网球场', shortName: '大湾区', indoor: true,
accent: '#4776d0', status: 'online', credentialLabel: 'Authorization',
requiresAuthenticatedAvailability: true, supportsCancellation: false,
releaseSchedule: { time: '09:00', dayOffset: 2, timeZone: 'Asia/Shanghai' },
bookingNotice: '只锁场不支付。支付、取消和订单状态请到原小程序查看;本应用暂不支持远端取消。'
},
defaultSettings: { courtId: COURT_ID },

View File

@@ -0,0 +1,147 @@
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AvailabilityDay, AvailabilityQuery, PendingBooking } from '../../shared/contracts'
import type { AdapterRegistry } from '../adapters/registry'
import { dawanquAdapter } from '../adapters/dawanqu/adapter'
import { MonitorService } from './MonitorService'
import { createAutoBookingState, MonitorTaskStore } from './MonitorTaskStore'
const releasedDate = '2026-09-26'
function availability(date: string, available = false): AvailabilityDay {
return {
courtId: 'dawanqu', date, updatedAt: new Date().toISOString(),
slots: available ? [{ id: `${date}:field:09`, courtLabel: '网球1号场', startTime: '09:00', endTime: '10:00', priceCents: 12000, status: 'available' }] : []
}
}
function deferred() {
let resolve!: () => void
const promise = new Promise<void>((done) => { resolve = done })
return { promise, resolve }
}
async function setup(getAvailability: (query: AvailabilityQuery) => Promise<AvailabilityDay>, count = 1) {
const dir = await mkdtemp(join(tmpdir(), 'tennis-release-test-'))
const store = new MonitorTaskStore(join(dir, 'tasks.json'))
for (let i = 0; i < count; i++) {
await store.add({
id: `task-${i}`, courtId: 'dawanqu', startTime: '09:00', endTime: '10:00', enabled: true,
createdAt: new Date().toISOString(), seenAvailableSlotIds: [], autoBooking: createAutoBookingState(true)
})
}
let pending: PendingBooking | null = null
const registry = {
listCourts: () => [dawanquAdapter.court],
getAvailability: vi.fn(getAvailability),
getCourtSettings: vi.fn(async () => ({ credentialConfigured: true, visitorIdConfigured: false })),
getPendingBooking: vi.fn(async () => pending),
createBooking: vi.fn(async () => {
pending = { courtId: 'dawanqu', orderId: 'fixture-order', venueAppointmentIds: [], amountCents: 12000, courtLabel: '网球1号场', startTime: '09:00', endTime: '10:00', status: 'pending-payment' }
return pending
})
}
return { registry, store, service: new MonitorService(registry as unknown as AdapterRegistry, store, vi.fn()) }
}
afterEach(() => vi.useRealTimers())
describe('大湾区九点优先锁场', () => {
it('先查询第三天并完成锁场,不等待其他六个日期的慢响应', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-24T01:00:00Z'))
const slow = deferred()
const { registry, service, store } = await setup(async ({ date }) => {
if (date !== releasedDate) await slow.promise
return availability(date, date === releasedDate)
})
const tick = service.runOnceForTest()
await vi.waitFor(() => expect(registry.createBooking).toHaveBeenCalledOnce())
expect(registry.getAvailability.mock.calls[0]![0].date).toBe(releasedDate)
expect(registry.createBooking).toHaveBeenCalledWith(expect.objectContaining({ date: releasedDate, expectedStartTime: '09:00', expectedEndTime: '10:00', expectedPriceCents: 12000 }))
slow.resolve()
await tick
expect(registry.getAvailability.mock.calls.filter(([q]) => q.date === releasedDate)).toHaveLength(1)
expect((await store.list())[0]!.autoBooking.status).toBe('pending-payment')
})
it('绕过整点前冷却,多个任务共用查询但同一球场只生成一个订单', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-24T00:59:55Z'))
let opened = false
const { registry, service, store } = await setup(async ({ date }) => availability(date, opened && date === releasedDate), 2)
await service.runOnceForTest()
expect(registry.createBooking).not.toHaveBeenCalled()
opened = true
vi.setSystemTime(new Date('2026-09-24T01:00:00Z'))
await service.runOnceForTest()
expect(registry.createBooking).toHaveBeenCalledOnce()
expect(registry.getAvailability.mock.calls.filter(([q]) => q.date === releasedDate)).toHaveLength(2)
expect((await store.list()).map((task) => task.autoBooking.status)).toEqual(['pending-payment', 'blocked'])
const calls = registry.getAvailability.mock.calls.length
vi.setSystemTime(new Date('2026-09-24T01:00:01Z'))
await service.runOnceForTest()
expect(registry.getAvailability).toHaveBeenCalledTimes(calls)
expect(registry.createBooking).toHaveBeenCalledOnce()
})
it('整点前请求跨过九点时不重叠,完成后立即补查新放场数据', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-24T00:59:59Z'))
const slow = deferred()
let firstSweep = true
const { registry, service } = await setup(async ({ date }) => {
if (firstSweep) { await slow.promise; return availability(date, date !== releasedDate) }
return availability(date, date === releasedDate)
})
await service.start()
await vi.waitFor(() => expect(registry.getAvailability).toHaveBeenCalledTimes(7))
await vi.advanceTimersByTimeAsync(1000)
expect(registry.getAvailability).toHaveBeenCalledTimes(7)
firstSweep = false
slow.resolve()
await vi.waitFor(() => expect(registry.createBooking).toHaveBeenCalledOnce())
expect(registry.getAvailability.mock.calls[7]![0].date).toBe(releasedDate)
await service.stop()
})
it('独立整点唤醒不等待未对齐的每秒心跳', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-24T00:59:58.750Z'))
const readTimes: number[] = []
const { registry, service } = await setup(async ({ date }) => {
readTimes.push(Date.now())
return availability(date, Date.now() >= Date.parse('2026-09-24T01:00:00Z') && date === releasedDate)
})
await service.start()
await vi.waitFor(() => expect(registry.getAvailability).toHaveBeenCalledTimes(7), { interval: 1 })
await vi.advanceTimersByTimeAsync(Date.parse('2026-09-24T01:00:00Z') - Date.now())
await vi.waitFor(() => expect(registry.createBooking).toHaveBeenCalledOnce(), { interval: 1 })
expect(readTimes[7]).toBeLessThan(Date.parse('2026-09-24T01:00:00.750Z'))
await service.stop()
})
it('支持提前建立第三天的自动预定任务,不需要等到前一天', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-24T00:00:00Z'))
const { registry, store } = await setup(async ({ date }) => availability(date), 0)
const service = new MonitorService(registry as unknown as AdapterRegistry, store, vi.fn(), false)
const task = await service.createTask({ courtId: 'dawanqu', targetDate: releasedDate, startTime: '19:00', endTime: '21:00', autoBookingEnabled: true })
expect(task.targetDate).toBe(releasedDate)
expect(task.autoBooking.status).toBe('armed')
await expect(service.createTask({ courtId: 'dawanqu', targetDate: '2026-10-10', startTime: '19:00', endTime: '21:00' })).rejects.toThrow('参数格式错误')
expect(registry.createBooking).not.toHaveBeenCalled()
})
it('未支持的优惠流程只提醒,不消耗自动锁场重试次数', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-24T01:00:00Z'))
const { registry, service, store } = await setup(async ({ date }) => {
const day = availability(date, date === releasedDate)
day.slots.forEach((slot) => { slot.bookingUnavailableReason = '优惠场次需在原小程序预约' })
return day
})
await service.runOnceForTest()
expect(registry.createBooking).not.toHaveBeenCalled()
expect((await store.list())[0]!.autoBooking.status).toBe('armed')
})
})

View File

@@ -8,6 +8,7 @@ import type {
MonitorTask
} from '../../shared/contracts'
import type { AdapterRegistry } from '../adapters/registry'
import { priorityReleaseDate } from './releaseSchedule'
import {
findBestCompleteContinuousSequence,
physicalCourtKey,
@@ -165,6 +166,7 @@ export class MonitorService {
private readonly criticalTelemetryWrites = new Set<string>()
private readonly autoBookingOverrides = new Map<string, MonitorAutoBookingState>()
private timer: NodeJS.Timeout | null = null
private hourTimer: NodeJS.Timeout | null = null
private telemetryTimer: NodeJS.Timeout | null = null
private scheduledTickPromise: Promise<void> | null = null
private lastObservedHourKey: string | null = null
@@ -191,6 +193,7 @@ export class MonitorService {
}
}
this.timer = setInterval(() => this.scheduleTick(), 1_000)
this.scheduleHourWake()
this.telemetryTimer = setInterval(
() => void this.flushTelemetry().catch((error) => {
console.error('[tennis-book][monitor-telemetry]', error)
@@ -203,6 +206,8 @@ export class MonitorService {
async stop() {
this.stopping = true
if (this.timer) clearInterval(this.timer)
if (this.hourTimer) clearTimeout(this.hourTimer)
this.hourTimer = null
if (this.telemetryTimer) clearInterval(this.telemetryTimer)
this.timer = null
this.telemetryTimer = null
@@ -256,9 +261,7 @@ export class MonitorService {
const parsed = this.parseCreateInput(input)
this.registry.listCourts().find((court) => court.id === parsed.courtId) ??
(() => { throw new Error('球场不存在') })()
if (this.registry.listCourts().find((court) => court.id === parsed.courtId)?.requiresAuthenticatedAvailability) {
throw new Error('此球场列表需要登录,暂不支持无凭证空场监控')
}
await this.assertMonitorCredentials(parsed.courtId)
if (parsed.autoBookingEnabled) await this.assertAutoBookingCanBeEnabled(parsed.courtId)
const createdAt = new Date().toISOString()
const telemetry = createMonitorTelemetry(createdAt)
@@ -295,6 +298,11 @@ export class MonitorService {
if (typeof taskId !== 'string' || typeof enabled !== 'boolean') {
throw new Error('监控任务状态参数错误')
}
if (enabled) {
const task = (await this.store.list()).find((candidate) => candidate.id === taskId)
if (!task) throw new Error('监控任务不存在')
await this.assertMonitorCredentials(task.courtId)
}
const runtime = this.ensureRuntime(taskId, enabled)
const previousEnabled = runtime.enabled
runtime.generation += 1
@@ -400,10 +408,19 @@ export class MonitorService {
return task.telemetry
}
private async assertMonitorCredentials(courtId: string) {
const court = this.registry.listCourts().find((candidate) => candidate.id === courtId)
if (!court) throw new Error('球场不存在')
if (!court.requiresAuthenticatedAvailability) return
const settings = await this.registry.getCourtSettings(courtId)
if (!settings.credentialConfigured) throw new Error(`请先配置当前球场的 ${court.credentialLabel ?? '登录凭证'} 再开启监控`)
}
private async assertAutoBookingCanBeEnabled(courtId: string) {
const settings = await this.registry.getCourtSettings(courtId)
if (!settings.visitorIdConfigured) {
throw new Error('开启自动预定前,请先配置当前球场的 PSPLVISITORID')
if (!(settings.credentialConfigured ?? settings.visitorIdConfigured)) {
const label = this.registry.listCourts().find((court) => court.id === courtId)?.credentialLabel ?? 'PSPLVISITORID'
throw new Error(`开启自动预定前,请先配置当前球场的 ${label}`)
}
if (await this.registry.getPendingBooking(courtId)) {
throw new Error('当前球场已有待处理订单,请先支付或释放后再开启自动预定')
@@ -508,9 +525,19 @@ export class MonitorService {
}
}
private scheduleHourWake() {
if (!this.schedulingEnabled || this.stopping) return
const delay = 3_600_000 - Date.now() % 3_600_000
this.hourTimer = setTimeout(() => {
this.scheduleTick()
this.scheduleHourWake()
}, delay)
}
private scheduleTick() {
if (!this.schedulingEnabled || this.stopping) return
if (this.scheduledTickPromise) return
const hourAtStart = localHourKey(Date.now())
const operation = this.tick()
.then(() => {
this.serviceError = null
@@ -522,6 +549,8 @@ export class MonitorService {
this.scheduledTickPromise = operation
void operation.finally(() => {
if (this.scheduledTickPromise === operation) this.scheduledTickPromise = null
// If a request crossed the hour, immediately start a fresh check after it drains.
if (!this.stopping && hourAtStart !== localHourKey(Date.now())) this.scheduleTick()
})
}
@@ -574,10 +603,33 @@ export class MonitorService {
const key = `${courtId}:${date}`
const current = availabilityRequests.get(key)
if (current) return current
const request = this.registry.getAvailability({ courtId, date }, true)
const request = this.registry.getAvailability({ courtId, date })
availabilityRequests.set(key, request)
return request
}
// Process release-day armed tasks before starting unrelated date requests.
// The per-tick request cache is shared with the normal sweep below.
const priorityTasks = [...groups.values()].flat().filter((task) => {
const date = priorityReleaseDate(this.registry.listCourts().find((court) => court.id === task.courtId), now)
return !this.runningGroups.has(groupKey(task)) && date && (!task.targetDate || task.targetDate === date) &&
task.autoBooking.enabled && task.autoBooking.status === 'armed' &&
(isNewHour || (this.groupNextCheckAt.get(groupKey(task)) ?? 0) <= now)
}).sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id))
for (const task of priorityTasks) {
const runtime = this.ensureRuntime(task.id, task.enabled)
const generation = runtime.generation
const date = priorityReleaseDate(this.registry.listCourts().find((court) => court.id === task.courtId), now)!
try {
const day = await getAvailability(task.courtId, date)
const previous = new Set(task.seenAvailableSlotIds)
const newlyAvailable = continuousAvailabilityMatches(task, [day]).filter(({ trackingId, legacyTrackingIds }) =>
!previous.has(trackingId) && !legacyTrackingIds.every((id) => previous.has(id))
)
await this.maybeAutoBook(task, runtime, generation, newlyAvailable)
} catch {
// Preserve the normal error accounting below; never turn a failed read into a booking.
}
}
await Promise.all([...groups.entries()].map(async ([key, groupTasks]) => {
if (
this.runningGroups.has(key) ||
@@ -602,6 +654,12 @@ export class MonitorService {
return { date, error }
}
}))
const court = this.registry.listCourts().find((candidate) => candidate.id === first.courtId)
const newlyReleasedDate = priorityReleaseDate(court, Date.now())
if (newlyReleasedDate && !priorityReleaseDate(court, now) && checks.some(({ task }) =>
task.autoBooking.enabled && task.autoBooking.status === 'armed' &&
(!task.targetDate || task.targetDate === newlyReleasedDate)
)) return // Discard pre-release snapshots; the queued hour check will fetch again.
const availability = dateResults.flatMap((result) =>
result.availability ? [result.availability] : []
)
@@ -788,7 +846,11 @@ export class MonitorService {
return
}
const candidate = [...newlyAvailable].sort((left, right) =>
const releaseDate = priorityReleaseDate(this.registry.listCourts().find((court) => court.id === task.courtId), Date.now())
const bookable = newlyAvailable.filter((match) => match.slots.every((slot) => !slot.bookingUnavailableReason))
if (bookable.length === 0) return
const candidate = [...bookable].sort((left, right) =>
Number(right.date === releaseDate) - Number(left.date === releaseDate) ||
left.date.localeCompare(right.date) ||
left.totalPriceCents - right.totalPriceCents ||
left.courtLabel.localeCompare(right.courtLabel, 'zh-CN')
@@ -967,7 +1029,7 @@ export class MonitorService {
(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)) ||
(typeof targetDate === 'string' && !Array.from({ length: ROLLING_DATE_COUNT }, (_, index) => localDateValue(index)).includes(targetDate)) ||
timeMinutes(input.startTime) >= timeMinutes(input.endTime) ||
(typeof targetDate === 'string' &&
new Date(`${targetDate}T${input.endTime}:00`).getTime() <= Date.now())

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { dawanquAdapter } from '../adapters/dawanqu/adapter'
import { priorityReleaseDate } from './releaseSchedule'
describe('放场日期与时区', () => {
it('北京时间九点关注包含当天在内的第三天,跨月跨年也正确', () => {
expect(priorityReleaseDate(dawanquAdapter.court, Date.parse('2026-09-24T01:00:00Z'))).toBe('2026-09-26')
expect(priorityReleaseDate(dawanquAdapter.court, Date.parse('2026-12-31T01:00:59Z'))).toBe('2027-01-02')
expect(priorityReleaseDate(dawanquAdapter.court, Date.parse('2026-09-24T00:59:59Z'))).toBeUndefined()
expect(priorityReleaseDate(dawanquAdapter.court, Date.parse('2026-09-24T01:01:00Z'))).toBeUndefined()
expect(priorityReleaseDate(undefined, Date.now())).toBeUndefined()
})
})

View File

@@ -0,0 +1,16 @@
import type { CourtSummary } from '../../shared/contracts'
/** Release priority is based on the venue timezone, independent of the Mac timezone. */
export function priorityReleaseDate(court: CourtSummary | undefined, timestamp: number): string | undefined {
const schedule = court?.releaseSchedule
if (!schedule) return undefined
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: schedule.timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
}).formatToParts(timestamp)
const value = (type: string) => parts.find((part) => part.type === type)!.value
if (`${value('hour')}:${value('minute')}` !== schedule.time) return undefined
const date = new Date(`${value('year')}-${value('month')}-${value('day')}T00:00:00Z`)
date.setUTCDate(date.getUTCDate() + schedule.dayOffset)
return date.toISOString().slice(0, 10)
}

View File

@@ -709,7 +709,7 @@ export function App() {
</div>
<button
aria-label="新建空场监控"
disabled={!selectedCourtId || selectedCourt?.requiresAuthenticatedAvailability}
disabled={!selectedCourtId}
onClick={() => {
setMonitorDate('')
setMonitorAutoBooking(false)
@@ -723,7 +723,7 @@ export function App() {
{monitorTasks.length === 0 ? (
<button
className="monitor-empty"
disabled={!selectedCourtId || selectedCourt?.requiresAuthenticatedAvailability}
disabled={!selectedCourtId}
onClick={() => {
setMonitorDate('')
setMonitorAutoBooking(false)
@@ -731,7 +731,7 @@ export function App() {
setMonitorDialogOpen(true)
}}
type="button"
>{selectedCourt?.requiresAuthenticatedAvailability ? '此球场列表需要登录' : '每 10 秒检查空场'}<br /><span>{selectedCourt?.requiresAuthenticatedAvailability ? '暂不支持无凭证空场监控' : '点击创建第一个任务'}</span></button>
>每 10 秒检查空场<br /><span>{selectedCourt?.releaseSchedule ? `${selectedCourt.releaseSchedule.time} 优先检查新放场日期` : '点击创建第一个任务'}</span></button>
) : (
<div className="monitor-task-list">
{monitorTasks.map((task) => (
@@ -1207,7 +1207,10 @@ 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>
{selectedCourt?.releaseSchedule ? (
<p>{selectedCourt.releaseSchedule.time}(球场当地时间)优先检查含当天在内的第 {selectedCourt.releaseSchedule.dayOffset + 1} 天的场地;已开启自动预定的任务命中后立即尝试锁场,只锁场不支付。</p>
) : null}
<fieldset className="monitor-date-options">
<legend>监控日期</legend>
<button
@@ -1215,13 +1218,13 @@ export function App() {
onClick={() => setMonitorDate('')}
type="button"
><span>持续</span><strong>不限日期</strong></button>
{dateOptions.slice(0, 2).map((option) => (
{dateOptions.map((option) => (
<button
className={monitorDate === option.value ? 'is-selected' : ''}
key={option.value}
onClick={() => setMonitorDate(option.value)}
type="button"
><span>{option.relativeLabel}</span><strong>{dateFormatter.format(option.date)}</strong></button>
><span>{option.relativeLabel ?? weekdayFormatter.format(option.date)}</span><strong>{dateFormatter.format(option.date)}</strong></button>
))}
</fieldset>
<div className="monitor-time-fields">

View File

@@ -408,7 +408,7 @@ button { color: inherit; }
.credential-input-wrap button:focus-visible { outline: 2px solid #789981; outline-offset: 1px; }
.credential-input-wrap button:disabled { cursor: default; opacity: .35; }
.monitor-dialog { width: min(430px, calc(100vw - 40px)); padding: 24px; border: 1px solid #dce3d9; border-radius: 18px; background: var(--paper); box-shadow: 0 28px 80px rgba(22,58,42,.2); }
.monitor-dialog { max-height: calc(100vh - 40px); overflow-y: auto; width: min(430px, calc(100vw - 40px)); padding: 24px; border: 1px solid #dce3d9; border-radius: 18px; background: var(--paper); box-shadow: 0 28px 80px rgba(22,58,42,.2); }
.monitor-dialog > p { margin: 16px 0 0; color: #718178; font-size: 10px; line-height: 1.65; }
.monitor-date-options { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 20px 0 0; padding: 0; border: 0; }
.monitor-date-options legend { grid-column: 1 / -1; margin-bottom: 7px; color: #40594d; font-size: 10px; font-weight: 760; }

View File

@@ -11,6 +11,7 @@ export interface CourtSummary {
indoor?: boolean
credentialLabel?: string
requiresAuthenticatedAvailability?: boolean
releaseSchedule?: { time: string; dayOffset: number; timeZone: string }
supportsCancellation?: boolean
bookingNotice?: string
accent: string