8.7 KiB
架构说明
为什么选 Electron
本项目的主要复杂度不是绘制桌面窗口,而是长期维护多个非统一的微信小程序接口:不同签名方式、Cookie、请求头、代理、登录状态和多步骤预约流程。Electron 主进程可以直接复用 Node.js 网络生态,并与 Chromium 渲染层保持清晰隔离,能以最低的适配成本覆盖这些差异。
Tauri 的安装包更小、空闲内存通常更低,但它会把接口适配层拆成 Rust 命令或 sidecar。当前业务没有强约束要求最小包体,却明确要求持续定制多个 JavaScript/HTTP 流程,因此 Electron 的开发与排障成本更低。
运行边界
React renderer
| typed window.tennisBook API
Preload allowlist
| ipcRenderer.invoke
Electron main process
| AdapterRegistry + CourtSettingsStore
CourtAdapter A / CourtAdapter B / ...
| HTTP, cookies, signatures, booking workflow
微信小程序后端 API
渲染层只知道统一的 CourtSummary 和 AvailabilityDay。某个球场的原始响应、鉴权和预约步骤不能穿过 IPC 边界。
AvailabilityDay 可包含界面明确需要展示的预约人昵称和手机号,但适配器必须从第三方原始响应中逐项提取,不能把原始预约对象整体穿过 IPC;这些信息只保留在当前查询的内存状态中,不记录、不缓存、不落盘。
preload 必须构建为 CommonJS .js。项目启用了 Electron renderer sandbox,而 sandboxed preload 没有 ESM 上下文;不能通过关闭沙箱来迁就 .mjs 构建产物。
每球场运行时配置
PSPLVISITORID 等运行时凭证按 courtId 独立存储在 Electron userData 目录。范思伯特福中福的场地 ID 固定为适配器私有常量,只在请求边界映射到第三方 userId 字段和 STOREID 请求头,不经过 IPC,也不在配置界面暴露。设置弹窗通过专用白名单 IPC 读取凭证原值并回填密码框,默认遮挡;关闭弹窗立即清除渲染层中的输入值和设置对象,其他查询及预约接口不返回凭证。
可订列表刷新链路不读取球场凭证,CourtAdapter.getAvailability() 也不接收运行时设置,从类型边界保证列表请求不能携带 PSPLVISITORID。只有下单和释放写操作在主进程互斥区内读取最新凭证。
配置文件使用临时文件加重命名的方式原子替换。共享层始终用字符串表达外部 ID,避免超出 JavaScript 安全整数范围;具体适配器负责校验接口是否接受该范围并转换为请求格式。
应用对外显示名称为 Tennis Book,但 Electron userData 固定为 appData/tennis-book。显示名称与持久化路径解耦,避免品牌名称或大小写变化后配置、待支付订单和监控任务看似丢失。
接入一个新球场
- 在
src/main/adapters/<court-id>/创建适配器。 - 实现
CourtAdapter的court与getAvailability()。 - 在适配器内部完成原始响应到共享模型的转换。
- 将适配器加入
src/main/adapters/registry.ts。 - 为登录失效、空数据和接口字段变化添加契约测试。
预约写操作
公共预约状态机为:可订 -> 二次确认 -> 重新查询校验 -> 结果待确认记录 -> 下单锁场 -> 待支付 -> 取消释放/支付完成。具体请求体、版本头、访客凭证和响应解析只存在于球场适配器中。所有写操作按球场在主进程串行化,待支付或结果不确定期间禁止修改该球场凭证。
下单前由主进程重新查询同一天的数据,并使用重新查询得到的场地 UID、时间和价格构造请求,拒绝已订、排课和活动报名时段。渲染层传入的价格只作为用户确认金额;如与最新价格不同则拒绝下单并要求重新确认。第三方 UID 不接受渲染层输入。
范思伯特福中福的 SaveVenueAppointmentV2 只创建待支付订单并锁场,不代表支付成功。支付脚本不经过 IPC、不记录日志、不落盘;待支付订单的非敏感摘要按球场持久化,以便应用重启后仍能恢复“取消释放”入口。POST 发送前先持久化结果待确认记录,网络超时或响应解析失败时保留“尝试释放”入口,避免生成不可恢复的孤儿锁场。
CrmAutoReleaseVenueAppoint 使用当前球场配置与空 JSON 请求体释放当前访客的锁场,并不能绑定订单号。发送释放请求前先持久化 release-uncertain;若响应超时或进程中断,系统禁止自动重试,用户必须在小程序核实后显式清理本机记录。远端确认释放后先持久化 release-confirmed 标记,再删除摘要,避免清理失败后重复调用全局释放接口。支付脚本的过期时间只用于展示,不作为场地锁已经释放的依据。
空场监控
监控任务由主进程持久化和调度,应用通过单实例锁避免重复调度。日期可指定为创建时的今日或明日,也可以留空。指定日期的任务到达当天结束时间后自动停用;未指定日期的任务持续滚动检查主界面当前支持的未来 7 天,跨日后自动向后滚动一天且不会过期。轮询间隔固定为 10 秒;本地时间进入新的整点时绕过剩余轮询冷却立即检查一次,以捕获整点释放的新场地。同一整点只触发一次,并复用现有串行调度,不与尚未结束的检查并发。应用启动和休眠后恢复也会立即调度一次。今日已经结束的指定日期范围不能创建。相同球场和日期的请求在同一轮中复用,并分别过滤 available/limited 时段。
任务持久化上一次可订时段 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 分钟的非关键检查统计,任务定义、去重集合和关键事件不受影响。