diff --git a/AGENTS.md b/AGENTS.md index 769926f..2151b1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 分支。 +- 放场首分钟优先检查已授权自动锁场任务的目标日期,再检查其他日期。目标日期一旦返回完整连续组合,立即复用原有下单状态机,不等待其他日期响应。 +- 整点有独立唤醒;已有检查尚未结束时不得并发重入,完成后立即补查新整点,不能使用整点前的缓存。 +- “秒杀任务”指用户开启的一次性自动锁场。接口的优惠/秒杀价格属于另一种请求契约;未证实优惠参数时不得猜测请求体或跳过核价。 diff --git a/docs/architecture.md b/docs/architecture.md index de0aeaa..d702066 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 `;不内置抓包凭证。 +`dawanqu` 使用华润文体室内网球场矩阵接口。2026-09-23 实测无凭证列表返回业务码 403,因此手动查询和后台监控均使用主进程鉴权查询,凭证不进入任务或日志。`credential` 保存 Authorization 值,适配器规范为 `Wechat `;不内置抓包凭证。 先重查列表,再调用 `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 秒轮询。独立整点唤醒与串行补查避免冷却漏检,仍不与正在执行的检查重叠;网络、系统休眠和登录有效性影响实际时延。 diff --git a/src/main/adapters/dawanqu/adapter.test.ts b/src/main/adapters/dawanqu/adapter.test.ts index acc2c35..c8a97c7 100644 --- a/src/main/adapters/dawanqu/adapter.test.ts +++ b/src/main/adapters/dawanqu/adapter.test.ts @@ -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() diff --git a/src/main/adapters/dawanqu/adapter.ts b/src/main/adapters/dawanqu/adapter.ts index aa522ec..89d28ed 100644 --- a/src/main/adapters/dawanqu/adapter.ts +++ b/src/main/adapters/dawanqu/adapter.ts @@ -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 }, diff --git a/src/main/monitors/MonitorRelease.test.ts b/src/main/monitors/MonitorRelease.test.ts new file mode 100644 index 0000000..54dc6d7 --- /dev/null +++ b/src/main/monitors/MonitorRelease.test.ts @@ -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((done) => { resolve = done }) + return { promise, resolve } +} +async function setup(getAvailability: (query: AvailabilityQuery) => Promise, 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') + }) +}) diff --git a/src/main/monitors/MonitorService.ts b/src/main/monitors/MonitorService.ts index 6bb333b..a3bd23f 100644 --- a/src/main/monitors/MonitorService.ts +++ b/src/main/monitors/MonitorService.ts @@ -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() private readonly autoBookingOverrides = new Map() private timer: NodeJS.Timeout | null = null + private hourTimer: NodeJS.Timeout | null = null private telemetryTimer: NodeJS.Timeout | null = null private scheduledTickPromise: Promise | 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()) diff --git a/src/main/monitors/releaseSchedule.test.ts b/src/main/monitors/releaseSchedule.test.ts new file mode 100644 index 0000000..baa8bb3 --- /dev/null +++ b/src/main/monitors/releaseSchedule.test.ts @@ -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() + }) +}) diff --git a/src/main/monitors/releaseSchedule.ts b/src/main/monitors/releaseSchedule.ts new file mode 100644 index 0000000..0f574ba --- /dev/null +++ b/src/main/monitors/releaseSchedule.ts @@ -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) +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7de1f86..e7fa492 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -709,7 +709,7 @@ export function App() { + >每 10 秒检查空场
{selectedCourt?.releaseSchedule ? `${selectedCourt.releaseSchedule.time} 优先检查新放场日期` : '点击创建第一个任务'} ) : (
{monitorTasks.map((task) => ( @@ -1207,7 +1207,10 @@ export function App() {
COURT WATCHER

新建空场监控

-

应用将在后台每 10 秒刷新一次无凭证球场列表。只有同一个场地完整连续覆盖所选时间段才会命中;不限日期会持续滚动检查未来 7 天。

+

应用将在后台每 10 秒刷新一次球场列表;需要登录的球场使用本机保存的凭证。只有同一个场地完整连续覆盖所选时间段才会命中;不限日期会持续滚动检查未来 7 天。

+ {selectedCourt?.releaseSchedule ? ( +

{selectedCourt.releaseSchedule.time}(球场当地时间)优先检查含当天在内的第 {selectedCourt.releaseSchedule.dayOffset + 1} 天的场地;已开启自动预定的任务命中后立即尝试锁场,只锁场不支付。

+ ) : null}
监控日期 - {dateOptions.slice(0, 2).map((option) => ( + {dateOptions.map((option) => ( + >{option.relativeLabel ?? weekdayFormatter.format(option.date)}{dateFormatter.format(option.date)} ))}
diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index bb1c104..ca3e5c9 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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; } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 7395507..1cf6b34 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -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