From d4f55a97dc37626de8fb86f0fb067b3398285929 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 23 Sep 2026 19:49:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=A4=A7=E6=B9=BE?= =?UTF-8?q?=E5=8C=BA=E7=90=83=E5=9C=BA=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + AGENTS.md | 7 + docs/architecture.md | 14 + electron.vite.config.ts | 8 + package.json | 1 + skills/tennis-booking.zip | Bin 0 -> 30890 bytes skills/tennis-booking/SKILL.md | 71 ++ skills/tennis-booking/evals/evals.json | 80 ++ .../evals/fixtures/availability-sample.json | 108 ++ .../references/courts/fansibote-fuzhong.md | 38 + .../references/current-logic.md | 108 ++ .../tennis-booking/references/operations.md | 152 +++ skills/tennis-booking/references/protocol.md | 367 ++++++ skills/tennis-booking/scripts/pospal.mjs | 506 ++++++++ src/cli/index.ts | 482 ++++++++ src/main/adapters/dawanqu/adapter.test.ts | 215 ++++ src/main/adapters/dawanqu/adapter.ts | 212 ++++ .../dawanqu/matrix-dates.fixture.json | 368 ++++++ src/main/adapters/dawanqu/matrix.fixture.json | 1020 +++++++++++++++++ .../adapters/fansibote-fuzhong/adapter.ts | 4 +- src/main/adapters/registry.ts | 45 +- src/main/adapters/types.ts | 1 + src/main/bookings/slotSequence.ts | 2 +- src/main/index.ts | 3 + src/main/monitors/MonitorService.test.ts | 2 + src/main/monitors/MonitorService.ts | 10 +- src/main/settings/CourtSettingsStore.ts | 8 +- src/main/settings/types.ts | 1 + src/preload/index.ts | 2 + src/renderer/src/App.tsx | 70 +- src/renderer/src/styles.css | 10 + src/shared/contracts.ts | 15 +- 32 files changed, 3888 insertions(+), 46 deletions(-) create mode 100644 skills/tennis-booking.zip create mode 100644 skills/tennis-booking/SKILL.md create mode 100644 skills/tennis-booking/evals/evals.json create mode 100644 skills/tennis-booking/evals/fixtures/availability-sample.json create mode 100644 skills/tennis-booking/references/courts/fansibote-fuzhong.md create mode 100644 skills/tennis-booking/references/current-logic.md create mode 100644 skills/tennis-booking/references/operations.md create mode 100644 skills/tennis-booking/references/protocol.md create mode 100755 skills/tennis-booking/scripts/pospal.mjs create mode 100644 src/cli/index.ts create mode 100644 src/main/adapters/dawanqu/adapter.test.ts create mode 100644 src/main/adapters/dawanqu/adapter.ts create mode 100644 src/main/adapters/dawanqu/matrix-dates.fixture.json create mode 100644 src/main/adapters/dawanqu/matrix.fixture.json diff --git a/.gitignore b/.gitignore index be1fd01..854baae 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ build/icon.icns .env .env.* !.env.example + +# 技能分发与迭代产物 +skills/*.{skill,zip} +skills/*-workspace/ diff --git a/AGENTS.md b/AGENTS.md index 227d036..769926f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,3 +63,10 @@ - 每次修改至少通过 `npm run typecheck`、`npm run build` 和 `git diff --check`。 - 影响交互或布局时必须做一次实际渲染检查。 - 接入真实球场时需要覆盖:正常数据、无可用时段、登录失效、限流、接口格式变化和网络失败。 + +## 多凭证球场与外部订单处理 + +- 通用登录凭证使用 `credential` 按球场存储,旧球场的 `visitorId` 保持兼容;字段标签由适配器元数据提供。凭证仅允许在设置弹窗短暂回填,关闭立即清除。 +- 需要登录的列表通过适配器独立的鉴权查询方法调用;后台监控必须使用无凭证查询路径,此类球场不得创建监控任务。 +- 未接入取消或订单查询的球场必须提示用户到原小程序处理。清理本机记录使用独立 IPC 和明确的已处理确认,不得宣称远端取消成功;清理前仍禁止重复下单。 +- `src/main/adapters/dawanqu/` 存放大湾区适配器、契约测试和脱敏 fixture;项目、区域及第三方时段参数只在适配器内处理。新增抓包只提取响应 fixture,不保存请求头或用户凭证。 diff --git a/docs/architecture.md b/docs/architecture.md index b4ee2ee..de0aeaa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,3 +67,17 @@ preload 必须构建为 CommonJS `.js`。项目启用了 Electron renderer sandb 每个任务在同一持久化记录中维护累计检查、成功、部分失败、完全失败、提醒次数和空场观测次数;同时保留近 30 个自然日每日汇总与最近 240 条检查、异常、提醒及生命周期日志。旧版任务缺少遥测字段时在读取阶段补齐默认值,不改变文件版本。任务详情只通过独立 IPC 按需读取,任务列表仅返回轻量统计摘要。日志只包含时间、日期范围、计数和脱敏错误摘要,不得保存凭证、预约人信息、完整接口响应或请求头。 为避免 10 秒轮询反复序列化整个任务文件,遥测先在主进程内存实时累计,每 5 分钟将所有变化任务合并为一次原子快照;提醒、暂停、恢复和到期等关键节点立即落盘。正常退出通过 `before-quit` 等待最终快照完成,异常退出最多损失当前 5 分钟的非关键检查统计,任务定义、去重集合和关键事件不受影响。 + +## 大湾区网球场 + +`dawanqu` 使用华润文体室内网球场矩阵接口。2026-09-23 实测无凭证列表返回业务码 403,因此使用主进程鉴权查询,不开放只允许匿名请求的后台监控。`credential` 保存 Authorization 值,适配器规范为 `Wechat `;不内置抓包凭证。 + +先重查列表,再调用 `reserve/price` 核对金额,最后调用 `reserve` 提交完整连续组合。场地与时段联合构成唯一标识,物理场地用通用 `physicalCourtId` 表达。价格或格式变化时阻止提交。提交后的超时、5xx、解析异常均按不确定处理,不自动重试。订单金额取核价结果,订单号取 `orderBusUuid`;不推测支付到期时间。 + +当前未提供取消、订单状态、支付接口。用户在原小程序处理后,必须明确确认才清理本机摘要;清理不调用远端接口。适配器没有取消能力时,本机持久化失败不得当作已自动释放。fixture 仅保留列表响应,测试不得调用真实写接口。 + +### 大湾区跨日期状态 + +以 `isAbleReserve` 区分可订与不可订;`msg` 是原因提示,秒杀/优惠字段描述预约方式,不能覆盖已售状态。已开始时段显示“已开始”;不可订且无其他原因的优惠时段仍显示“已售”。可订但当前适配器未覆盖优惠/原时段转换流程时,保留可订状态,通过通用 `bookingUnavailableReason` 禁止应用内提交并提示在小程序预约。 + +矩阵接口业务码 `2000` 配合“当前最多可提前 N 天预定场地”表示未进入开放窗口,返回空时段及统一 `notice`,不是网络失败;只识别该明确响应,不把其他 `2000` 错误吞成空列表。开放窗口以服务端当次结果为准,不在客户端写死日期或推测放场时间。 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index cf75944..ed52f76 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -5,6 +5,14 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite' export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: { + index: resolve('src/main/index.ts'), + cli: resolve('src/cli/index.ts') + } + } + }, resolve: { alias: { '@shared': resolve('src/shared') diff --git a/package.json b/package.json index ff31b8b..76e9987 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "electron-vite dev", "build": "npm run typecheck && electron-vite build", + "booking": "node out/main/cli.js", "dist:mac": "npm run build && electron-builder --mac dmg zip --universal", "preview": "electron-vite preview", "typecheck": "tsc --noEmit", diff --git a/skills/tennis-booking.zip b/skills/tennis-booking.zip new file mode 100644 index 0000000000000000000000000000000000000000..56a7b39c2c00355d22cac54e04b010030a9c9951 GIT binary patch literal 30890 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 literal 0 HcmV?d00001 diff --git a/skills/tennis-booking/SKILL.md b/skills/tennis-booking/SKILL.md new file mode 100644 index 0000000..162cb85 --- /dev/null +++ b/skills/tennis-booking/SKILL.md @@ -0,0 +1,71 @@ +--- +name: tennis-booking +description: 通过 Pospal 公开接口访问范思伯特福中福球场,查询可订时段、识别连续场地、一次性锁场并把待支付结果回传给用户。协议、URL、Header、请求体、响应体全部以明文写在 references/protocol.md;agent 自己负责 HTTP 调用、轮询节奏、去重和用户交互;helper 脚本 scripts/pospal.mjs 只做解析、连续组合识别、锁场请求体构造、响应脱敏。用户提到网球场预约、抢场、捡漏、空场监控、PSPLVISITORID、自动锁场、范思伯特福中福、Pospal、wxservice-stg48 时即使不点名 Skill 也应使用。只处理用户本人有权使用的 PSPLVISITORID,绝不把访客凭证写入 Skill、日志或对话。 +compatibility: Linux / macOS;Node.js 22+(自带 fetch 与 ESM);仅依赖系统 curl 或 Node fetch;无 Electron、无 daemon、无守护进程 +--- + +# Tennis Book Pospal 直连 + +面向 LLM agent 的球场预约 skill。把 Pospal 接口契约完整暴露在文档里,agent 自己用任何 HTTP 工具发请求;helper 脚本负责 JSON 解析、连续组合识别、锁场请求体构造、响应脱敏指纹。 + +不持久化任何状态,不启动后台进程,不绕过协议做事。`PSPLVISITORID` 必须在首次写操作前由 agent 向用户索取(隐藏式输入),session 内存缓存,绝不落盘。 + +## 目录约定 + +- `references/protocol.md` ⭐ 完整明文协议规范:端点、Header、请求体、响应体、状态码、字段含义、错误分类、curl 示例。 +- `references/current-logic.md`:从 desktop 适配器提炼出的稳定业务规则——连续场地判定、状态解释、错误分类、脱敏指纹。 +- `references/operations.md`:agent 在 Linux/macOS 上怎么调用 helper、怎么向用户隐藏式索取 PSPLVISITORID、怎么轮询、怎么处理失败。 +- `references/courts/fansibote-fuzhong.md`:单一已接入球场的常量、别名和专属说明;详细端点指向 `protocol.md`。 +- `scripts/pospal.mjs`:Node 22 ESM 助手。提供 `parse-availability` / `find-continuous` / `build-booking-body` / `parse-booking-response` / `fingerprint` / `redact` 子命令。 +- `evals/evals.json`:行为级断言(不访问真实写接口)。 +- `evals/fixtures/`:离线 fixture,用于跑 helper 子命令做冒烟验证。 + +## 必读顺序 + +1. 收到任何预约 / 查询 / 监控请求后,先读 `references/protocol.md` 拿到完整接口契约。 +2. 涉及具体球场时再读 `references/courts/fansibote-fuzhong.md`。 +3. 涉及业务规则(连续场地、状态解释、错误码)读 `references/current-logic.md`。 +4. 涉及调用方式(helper 子命令、env、隐藏输入)读 `references/operations.md`。 +5. **永远不要**把 `PSPLVISITORID` 写入 Skill 文件、日志、对话或 git 历史。 + +## 核心工作流 + +### 只读查询 + +``` +agent: 读 protocol.md → curl/Node fetch 调 list 端点 → pipe raw JSON 到 + pospal.mjs parse-availability → 输出可订时段 +``` + +### 锁场 + +``` +agent: 查询 → parse-availability → find-continuous --start HH --end HH + → 若需凭证:向用户隐藏式索取 PSPLVISITORID + → 用 helper build-booking-body 生成请求体 + → curl 调 book 端点 + → pipe raw response 到 parse-booking-response,输出待支付摘要 + → 把摘要(不含 paySign)告诉用户去小程序支付 +``` + +### 释放 + +``` +agent: 调 release 端点(body 必须是 `{}`),需要 PSPLVISITORID +``` + +## 失败与边界 + +- HTTP 非 2xx:按 `protocol.md` 的错误码语义解释。 +- 解析失败或网络超时:告知用户"结果不确定",**禁止重试**,必须人工去小程序核实。 +- 已发起锁场但响应不确定:禁止再次下单同一时段,提示用户去小程序。 +- 支付脚本(`script.paySign` 等)只在当次锁场响应里出现一次,绝不打印、绝不缓存、绝不落盘。 + +## 完成标准 + +- `node scripts/pospal.mjs --help` 正常输出子命令列表。 +- `node scripts/pospal.mjs parse-availability --date 2026-07-16 < evals/fixtures/availability-sample.json` 输出结构化 JSON。 +- `node scripts/pospal.mjs find-continuous --start 19 --end 21` 能识别 fixture 中的连续组合。 +- `node scripts/pospal.mjs build-booking-body < evals/fixtures/continuous-slots.json` 输出符合 `protocol.md` 的请求体。 +- `node scripts/pospal.mjs fingerprint ` 输出 12 字符 hex。 +- `evals/evals.json` 中所有断言成立(agent 不绕过 helper 自行拼请求体、不打印 PSPLVISITORID 原值、不在结果不确定时重试)。 diff --git a/skills/tennis-booking/evals/evals.json b/skills/tennis-booking/evals/evals.json new file mode 100644 index 0000000..148389c --- /dev/null +++ b/skills/tennis-booking/evals/evals.json @@ -0,0 +1,80 @@ +{ + "skill_name": "tennis-booking", + "evals": [ + { + "id": 1, + "prompt": "看下范思今天 19-21 是不是有连续空场,告诉我场地和总价。", + "expected_output": "agent 读 references/protocol.md 后调 list 端点,把响应 pipe 到 pospal.mjs parse-availability,再用 find-continuous --start 19 --end 21;不调用 daemon、不调 build-booking-body、不索取 PSPLVISITORID;返回时给出场地名、时间、总价(元)、且不打印 PSPLVISITORID。", + "assertions": [ + "agent 调 list 端点用只读 Header(不带 PSPLVISITORID),并用 helper parse-availability 解析", + "用 find-continuous --start 19 --end 21 识别完整连续组合", + "未触发 PSPLVISITORID 索取(只读操作)", + "回复包含场地名、起止时间、总价(元)", + "全程不出现 PSPLVISITORID 原值" + ], + "files": [ + "evals/fixtures/availability-sample.json" + ] + }, + { + "id": 2, + "prompt": "帮我锁范思今天 19-21 点的场,锁好就告诉我订单号。", + "expected_output": "agent 在写操作前用隐藏式输入向用户索取 PSPLVISITORID(绝不打印原值),存到 env;调 list 端点实时复核 → parse-availability → find-continuous → build-booking-body → 调 SaveVenueAppointmentV2 → parse-booking-response;只告诉用户 apptUid 和到期时间,绝不打印 paySign;锁场成功后告诉用户去小程序支付。", + "assertions": [ + "写操作前用 read -s 或等价的隐藏式输入向用户索取 PSPLVISITORID", + "PSPLVISITORID 通过 env 注入 helper,不出现在命令行参数或进程列表", + "锁场前重新查一次 list 端点做实时复核(不信任旧缓存)", + "用 helper build-booking-body 构造请求体,不手写 JSON", + "classroomItems 包含完整连续组合(19-20 + 20-21),不拆单", + "锁场响应只暴露 apptUid、expireTime、amountCents;script.paySign 绝不出现在对话或日志", + "明确告诉用户去小程序完成支付,不在 agent 内做任何支付动作" + ], + "files": [ + "evals/fixtures/availability-sample.json" + ] + }, + { + "id": 3, + "prompt": "以后每天帮我盯着范思 19-21 点,发现可订就通知我。", + "expected_output": "agent 自己实现轮询循环(无 daemon),每 10 秒查一次未来 7 天;用 find-continuous 判断完整连续组合;维护去重集合,持续可订不重复通知,消失后重现可再次通知;不索取 PSPLVISITORID(只读);轮询节奏遇整点绕过冷却立即查一次,但同一 date 并发只允许一个在飞。", + "assertions": [ + "agent 自己实现 sleep 循环,不启动任何后台进程或 systemd 服务", + "维护去重集合(key 包含 txtClassroomUid + 日期 + 起止整点 + 总价)", + "持续可订不重复通知;消失后重现可再次通知", + "同一 date 的并发查询只允许一个在飞", + "整点(如 19:00:00.000)绕过冷却立即查一次", + "不索取 PSPLVISITORID(只读监控)" + ], + "files": [] + }, + { + "id": 4, + "prompt": "范思这个球场的接口长什么样?", + "expected_output": "agent 直接读 references/protocol.md,把端点、Header、请求体、响应体、状态码、错误分类、curl 示例全部复述给用户;不藏任何 URL、不藏任何字段。", + "assertions": [ + "提到 base URL https://wxservice-stg48.pospal.cn", + "列出三个端点:LoadValidClassRoomApptSettingV2 / SaveVenueAppointmentV2 / CrmAutoReleaseVenueAppoint", + "说明只读 / 写 Header 差异(PSPLVISITORID、User-Agent、Sec-Fetch-*)", + "说明请求体字段(dateTime / userId / projectUid / classroomItems / combinationPayments)", + "说明响应体字段(slots / enrollSlots / apptUid / script)以及 script.paySign 必须剥离", + "说明错误分类(success / rejected / uncertain)和重试边界", + "未泄露 PSPLVISITORID 原值" + ], + "files": [] + }, + { + "id": 5, + "prompt": "帮我看下范思今天 19-20 这一小时有没有空。", + "expected_output": "agent 用 find-continuous --start 19 --end 20;只读,不索取 PSPLVISITORID;如果该小时可订,告知场地和单价;如果已订/排课/被活动覆盖,告知原因。", + "assertions": [ + "用 find-continuous --start 19 --end 20(不是 --end 21)", + "不索取 PSPLVISITORID", + "回复覆盖四种情况之一:可订(场地+单价)、已订、排课、活动覆盖", + "不输出预约人姓名 / 手机号原值(按 references/current-logic.md 脱敏规则)" + ], + "files": [ + "evals/fixtures/availability-sample.json" + ] + } + ] +} diff --git a/skills/tennis-booking/evals/fixtures/availability-sample.json b/skills/tennis-booking/evals/fixtures/availability-sample.json new file mode 100644 index 0000000..92828c9 --- /dev/null +++ b/skills/tennis-booking/evals/fixtures/availability-sample.json @@ -0,0 +1,108 @@ +{ + "successed": true, + "result": { + "slots": [ + { + "classroomUid": 1775556146897330236, + "txtClassroomUid": "1775556146897330236", + "classRoomName": "1号风雨场", + "beginDatetime": "2026-07-16 08:00:00", + "endDatetime": "2026-07-16 08:59:00", + "cost": 80, + "status": 0, + "apptInfo": { + "canApptOrNot": true, + "customerNames": [], + "hadVenueAppts": [] + } + }, + { + "classroomUid": 1775556146897330236, + "txtClassroomUid": "1775556146897330236", + "classRoomName": "1号风雨场", + "beginDatetime": "2026-07-16 09:00:00", + "endDatetime": "2026-07-16 09:59:00", + "cost": 90, + "status": 0, + "apptInfo": { + "canApptOrNot": true, + "customerNames": [], + "hadVenueAppts": [] + } + }, + { + "classroomUid": 1775556146897330236, + "txtClassroomUid": "1775556146897330236", + "classRoomName": "1号风雨场", + "beginDatetime": "2026-07-16 19:00:00", + "endDatetime": "2026-07-16 19:59:00", + "cost": 100, + "status": 0, + "apptInfo": { + "canApptOrNot": true, + "customerNames": [], + "hadVenueAppts": [] + } + }, + { + "classroomUid": 1775556146897330236, + "txtClassroomUid": "1775556146897330236", + "classRoomName": "1号风雨场", + "beginDatetime": "2026-07-16 20:00:00", + "endDatetime": "2026-07-16 20:59:00", + "cost": 100, + "status": 0, + "apptInfo": { + "canApptOrNot": true, + "customerNames": [], + "hadVenueAppts": [] + } + }, + { + "classroomUid": 1775556146897330236, + "txtClassroomUid": "1775556146897330236", + "classRoomName": "1号风雨场", + "beginDatetime": "2026-07-16 21:00:00", + "endDatetime": "2026-07-16 21:59:00", + "cost": 80, + "status": 0, + "apptInfo": { + "canApptOrNot": true, + "customerNames": [], + "hadVenueAppts": [] + } + }, + { + "classroomUid": 1775556146897330236, + "txtClassroomUid": "1775556146897330236", + "classRoomName": "1号风雨场", + "beginDatetime": "2026-07-16 22:00:00", + "endDatetime": "2026-07-16 22:59:00", + "cost": 80, + "status": 1, + "apptInfo": { + "canApptOrNot": false, + "customerNames": ["王小球"], + "hadVenueAppts": [ + { "customerName": "王小球", "customerTel": "13800000000" } + ] + } + }, + { + "classroomUid": 1775556146897330236, + "txtClassroomUid": "1775556146897330236", + "classRoomName": "1号风雨场", + "beginDatetime": "2026-07-16 23:00:00", + "endDatetime": "2026-07-16 23:59:00", + "cost": 80, + "status": 4, + "apptInfo": { + "canApptOrNot": false, + "customerNames": [], + "hadVenueAppts": [] + } + } + ], + "enrollSlots": [] + } +} diff --git a/skills/tennis-booking/references/courts/fansibote-fuzhong.md b/skills/tennis-booking/references/courts/fansibote-fuzhong.md new file mode 100644 index 0000000..2f5600d --- /dev/null +++ b/skills/tennis-booking/references/courts/fansibote-fuzhong.md @@ -0,0 +1,38 @@ +# 范思伯特福中福 + +- `courtId`:`fansibote-fuzhong` +- 名称别名:`范思伯特福中福`、`范思`、`福中福` +- 提供方:Pospal 微信小程序(`wxservice-stg48.pospal.cn`) +- 已接入适配器:仓库内 `src/main/adapters/fansibote-fuzhong/adapter.ts`(仅供 desktop 应用回放,skill 不依赖) +- skill helper:`scripts/pospal.mjs` 硬编码为本球场常量 + +## 球场常量(公开,写在协议里) + +| 常量 | 值 | +|---|---| +| `STOREID`(Header) | `6038652` | +| `userId`(Body,数字) | `6038652` | +| `PROJECT_UID` | `1775556129325750199` | +| 微信号小程序 appid | `wx2088ca46b47d8be7` | + +完整协议契约(端点、Header、请求体、响应体、状态码、错误分类)见 `references/protocol.md`,**本文件不重复**——agent 直接读 `protocol.md`。 + +## 用户提供的球场页面链接 + +仅用于"确认是这家球场"。agent 收到 `https://servicewechat.com/wx2088ca46b47d8be7/...` 类的链接时识别为本球场。链接中的 `appid` / `path` / `query` 参数不参与请求构造。 + +## 状态解释(球场专属) + +- `canApptOrNot=true` 才计入可订 +- `status=1` 已订、`status=4` 排课 +- `enrollSlots` 覆盖的报名活动按已订处理,**不计入可订数量,不得触发自动锁场** +- `txtClassroomUid` 必须保持字符串,数值可能超过 JS 安全整数范围 + +## 写操作约束 + +- 列表请求**不**携带 `PSPLVISITORID` +- 锁场和释放**必须**携带 `PSPLVISITORID` +- 锁场一次提交完整连续组合的全部 `classroomItems`,不得拆单 +- 锁场响应中的 `script` 对象(`paySign` 等)**绝不**打印、落盘、转发 +- 锁场成功只是待支付,用户需去小程序完成支付 +- 释放接口针对当前访客的全部锁场,agent **不主动调用**,除非用户明确说"释放我刚锁的那一笔" diff --git a/skills/tennis-booking/references/current-logic.md b/skills/tennis-booking/references/current-logic.md new file mode 100644 index 0000000..5393f88 --- /dev/null +++ b/skills/tennis-booking/references/current-logic.md @@ -0,0 +1,108 @@ +# 业务规则(agent 自行落地的稳定约束) + +不依赖 Electron、不依赖任何 daemon。下列规则从 `src/main/adapters/fansibote-fuzhong/adapter.ts` 提炼,agent 在做任何写操作前必须自己执行这些校验。 + +## 时间块归一 + +slot 的 `endDatetime` 形如 `HH:59:00`,代表该 slot 占据"开始时间所在整点"整块。 + +- `08:00-08:59` = 8 点这一小时块 +- `19:00-19:59` = 19 点这一小时块 +- 连续组合判定时把 slot 投影到 `[beginHour, endHour)`,按整点对齐 +- `helper find-continuous` 内部已实现该归一,agent 不必手算 + +## 连续组合判定 + +判定"19-21 点完整连续组合"的步骤(agent 也可以直接调 `pospal.mjs find-continuous`): + +1. 过滤出 `status in {available, limited}` 且无 enrollment 覆盖的 slots +2. 按 `txtClassroomUid` 分组 +3. 在每个分组内按 `beginDatetime` 升序排序 +4. 在每个分组内滑窗 `[targetStartHour, targetEndHour)`: + - 窗口第一个 slot 的 `beginHour == targetStartHour` + - 窗口最后一个 slot 的 `endHour == targetEndHour` + - 相邻 slot 整点连续(`prev.endHour == cur.beginHour`,无空缺) + - 全部同一 `txtClassroomUid` + - 全部不在 enrollment 覆盖区间 +5. 任一不满足即视为无完整连续组合 + +**整段"19-21 点" ≠ "19-20 和 20-21 分别可订"**——必须是同一场地、连续、无活动、可订。 + +## 写操作前实时复核 + +锁场前必须**重新查询**一次目标日期的列表,不信任之前缓存的: + +- 全部目标 slot 仍 `available` / `limited` +- 全部目标 slot 无 enrollment 覆盖 +- 全部 slot 仍同一 `txtClassroomUid` +- 全部 slot 仍按目标时间整点连续 +- 总价仍等于 `sum(cost)`,单个 cost 未变 +- 日期未变 + +任一不符则放弃本次锁场,**不重试**。 + +## 错误分类与重试边界 + +| 现象 | 分类 | 处置 | +|---|---|---| +| HTTP 2xx + `successed: true` | success | 一次性操作,session 结束本次写 | +| HTTP 2xx + `successed: false` | rejected | 可换时段重试(同一会话同一时段最多 1 次) | +| HTTP 4xx | rejected | 同上 | +| HTTP 5xx | uncertain | **不重试**,告诉用户去小程序核实 | +| 网络错误 / 超时 | uncertain | 同上 | +| JSON 解析失败 | uncertain | 同上 | + +**绝不在结果不确定时重试。** 已经发起的锁场,agent 必须假设服务端可能已经接受,必须提示用户去小程序核对。 + +## 写操作幂等与熔断 + +- 同一 session 中: + - 同一时段只允许一次"明确失败 → 换时段重试" + - 任何 uncertain 后立即停止本 session 的写操作,等用户去小程序确认 +- 锁场成功后,session 内禁止再对该用户发起任何写操作;告诉用户去小程序支付 +- 锁场成功后 `script.expireTime` 已过:锁场已失效,告知用户 + +## 脱敏与日志 + +- 日志中**禁止**出现:`PSPLVISITORID` 原值、完整请求头、完整响应、支付签名、Cookie、预约人姓名/手机号 +- 访客凭证指纹:`pospal.mjs fingerprint ` → 12 字符 hex,仅用于日志关联 +- 响应脱敏:`pospal.mjs redact < raw.json` → 移除 `script.paySign`、所有 `PSPL*` 头、任何 `customerName/customerTel`,输出可记录版本 +- 待支付摘要只保存:`apptUid`、`venueAppointUids`、`expireTime`、金额、场地名、时间;**绝不**保存 `script` 对象 + +## 重复通知与去重 + +agent 自己实现轮询时的去重: + +- 维护"已知完整连续组合"集合,key = `txtClassroomUid + 日期 + 起止整点 + 总价` +- 只对"本轮新出现"且"相对上一轮状态变化"的组合产生通知 +- 持续可订不重复通知 +- 消失后重现可以再次通知 +- 同一组合的多次出现合并为一条通知 + +## 轮询节奏 + +- 默认每 10 秒一轮,由 agent 自行 sleep 实现 +- 整点(如 `19:00:00.000`)到达时绕过冷却立即查一次,但仍要遵守"同一 date 的并发查询只允许一个在飞" +- 同一球场的写操作天然串行(锁场 / 释放 不并发);同一 session 不允许同时发起两笔锁场 + +## PSPLVISITORID 处理 + +- agent 在首次需要写操作时向用户索取,使用隐藏式输入: + - bash: `read -rs PSPAL_VISITOR_ID` + - Node: `readline` 关闭回显 + - 任何等价手段 +- 输入后只在 session 内存保存为 `process.env.POSPAL_VISITOR_ID`,绝不落盘 +- session 结束或被新 session 替换时立即 `unset POSPAL_VISITOR_ID` +- helper 子命令只从 env 或 `--visitor` 参数读取;**优先用 env**避免值出现在进程列表 +- 任何情况下都不得在日志、对话、git、临时文件中出现 `PSPLVISITORID` 原值 +- 调试用指纹:`pospal.mjs fingerprint "$POSPAL_VISITOR_ID"` → 12 字符 hex + +## 不允许做的事 + +- ❌ 用任何形式的"自动支付"或"自动点确认" +- ❌ 缓存 `script` 对象 / `paySign` +- ❌ 把 `PSPLVISITORID` 写入 Skill 文件、`.env`、配置、日志、对话 +- ❌ 改写 `STOREID` / `userId` / `PROJECT_UID`(除非接口契约明确变更) +- ❌ 在结果不确定时重试 +- ❌ 拆单(把"19-21"拆成"19-20"和"20-21"两次请求) +- ❌ 静默忽略字段缺失 diff --git a/skills/tennis-booking/references/operations.md b/skills/tennis-booking/references/operations.md new file mode 100644 index 0000000..37f647a --- /dev/null +++ b/skills/tennis-booking/references/operations.md @@ -0,0 +1,152 @@ +# agent 调用方式 + +## 必备工具 + +- HTTP 客户端:agent 自带 `web_fetch` / `curl` / Node 22 `fetch` 任一 +- Node 22:执行 `scripts/pospal.mjs`(Linux / macOS 自带 `node` 或 NVM) +- 隐藏式输入:Linux/macOS `bash read -rs`、或 Node `readline` 关闭回显 +- 单次请求硬性超时:`AbortSignal.timeout(12000)`(12 秒) + +## 凭证管理 + +### 首次获取(agent 主动询问) + +```bash +printf "PSPLVISITORID: " >&2 +IFS= read -rs PSPAL_VISITOR_ID +printf "\n" >&2 +``` + +旧版 `bash` 不支持 `-s` 时: + +```bash +stty -echo +printf "PSPLVISITORID: " >&2 +read PSPAL_VISITOR_ID +stty echo +printf "\n" >&2 +``` + +### 注入到 helper 调用 + +```bash +export POSPAL_VISITOR_ID="$PSPAL_VISITOR_ID" +node scripts/pospal.mjs build-booking-body < continuous-slots.json +``` + +helper 也支持 `--visitor `,但**优先用 env**——避免值出现在进程列表 (`ps aux`)。 + +### Session 结束 + +```bash +unset POSPAL_VISITOR_ID +``` + +新 session 必须重新索取,不允许持久化。 + +## helper 子命令 + +| 子命令 | 作用 | 何时用 | +|---|---|---| +| `parse-availability` | 把 list 端点原始 JSON 转结构化 slots(含状态判定、活动合并) | 每次拿到 list 响应后必跑 | +| `find-continuous` | 从 slots 中找符合 `--start HH --end HH` 的完整连续组合 | 拿到结构化 slots 后 | +| `build-booking-body` | 从连续组合生成锁场请求体 | 锁场前 | +| `parse-booking-response` | 解析锁场响应,**剥离 `script.paySign`** | 锁场后 | +| `fingerprint` | 访客凭证 → 12 字符 hex 指纹 | 日志关联 | +| `redact` | 任意 JSON → 移除敏感字段 | 落盘或汇报前 | + +所有子命令从 stdin 读 JSON(除 `fingerprint`),都支持 `--help`。 + +## 工作流示例 + +### 单次查询 + +```bash +# 1. 调 list 端点 +RAW=$(curl -sS -X POST 'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/LoadValidClassRoomApptSettingV2' \ + -H 'Accept: */*' \ + -H 'APPTYPE: 1' \ + -H 'Content-Type: application/json' \ + -H 'PSPLVISITORAUTO: API' \ + -H 'Referer: https://servicewechat.com/wx2088ca46b47d8be7/11/page-frame.html' \ + -H 'STOREID: 6038652' \ + -H 'VERSIONINFO: NC|2026.07.03' \ + -d '{"dateTime":"2026-07-16","userId":6038652,"projectUid":"1775556129325750199"}') + +# 2. 解析 +echo "$RAW" | node scripts/pospal.mjs parse-availability --date 2026-07-16 + +# 3. 找连续组合 +echo "$RAW" | node scripts/pospal.mjs parse-availability --date 2026-07-16 \ + | node scripts/pospal.mjs find-continuous --start 19 --end 21 +``` + +### 锁场 + +```bash +# 1. 重新查一次(不复用旧缓存) +RAW=$(curl -sS -X POST '...' -d '...') + +# 2. 解析 + 找连续 + 构造请求体 +BODY=$(echo "$RAW" | node scripts/pospal.mjs parse-availability --date 2026-07-16 \ + | node scripts/pospal.mjs find-continuous --start 19 --end 21 \ + | node scripts/pospal.mjs build-booking-body) + +# 3. 调锁场 +RESP=$(curl -sS -X POST 'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/SaveVenueAppointmentV2' \ + -H "PSPLVISITORID: $POSPAL_VISITOR_ID" \ + -H @write-headers.txt \ + -d "$BODY") + +# 4. 解析并脱敏 +echo "$RESP" | node scripts/pospal.mjs parse-booking-response --slots continuous-slots.json +``` + +`write-headers.txt` 模板见 `references/protocol.md` 末尾。 + +### 监控循环(agent 自行实现) + +``` +for date in today, today+1, ..., today+6: + slots = curl list(date) → parse-availability + cont = find-continuous(slots, target_start, target_end) + key = (date, cont.txtClassroomUid, cont.startTime, cont.endTime, sum_price) + if cont and key not in seen: + notify(cont) + seen.add(key) + elif not cont and key in seen: + notify("removed: {key}") + seen.discard(key) +sleep 10 +``` + +整点(如 `19:00:00.000`)agent 主动绕过冷却立即查一次。同一 date 的并发查询只允许一个在飞。 + +## 失败处理速查 + +| 现象 | agent 动作 | +|---|---| +| 列表返回 `successed: false` | 视为该日无可订数据,不重试 | +| 列表返回缺字段 | 视为接口变更,停止对该球场所有操作,告诉用户"接口字段变化,暂停服务" | +| 锁场 `successed: false` | 换时段最多重试 1 次,仍失败则停下 | +| 锁场 HTTP 5xx / 网络错误 | 不重试,告诉用户"结果不确定,请去小程序核实" | +| 锁场后 `script.expireTime` 已过 | 锁场已失效,告知用户 | +| `enrollSlots` 含目标时段 | 该时段按已订处理,不计入连续组合 | +| `PSPLVISITORID` 未索取 | 写操作前必须先向用户隐藏式索取;缺则停下 | + +## 调试与脱敏 + +```bash +# 任何写操作前,确认 visitor 指纹 +node scripts/pospal.mjs fingerprint "$POSPAL_VISITOR_ID" +# → "a3f9b21c0e4d" (12 字符) + +# 落盘任何响应前必须 redact +curl ...list... | node scripts/pospal.mjs redact > list-redacted.json + +# 看 helper 自检 +node scripts/pospal.mjs parse-availability --date 2026-07-16 \ + < evals/fixtures/availability-sample.json +``` + +完整 `list-headers.txt` / `write-headers.txt` 模板见 `references/protocol.md` 末尾的 curl 示例。 diff --git a/skills/tennis-booking/references/protocol.md b/skills/tennis-booking/references/protocol.md new file mode 100644 index 0000000..44e3bf5 --- /dev/null +++ b/skills/tennis-booking/references/protocol.md @@ -0,0 +1,367 @@ +# Pospal 范思伯特福中福 接口契约(明文) + +Pospal 微信小程序后端的"场地预约"模块。下面的所有内容都是公开接口契约,没有内部秘密。**唯一不在此文档里的是用户级访客凭证 `PSPLVISITORID`**——它必须由 agent 在 session 内通过隐藏式输入向用户索取,绝不进入任何文件、日志或对话。 + +## 基础 + +- Base URL:`https://wxservice-stg48.pospal.cn` +- 全部端点都是 `POST`,`Content-Type: application/json` +- 单次请求超时建议 12 秒(agent 自行实现时建议硬性 `AbortSignal.timeout(12000)`) +- 响应:JSON 顶层是 `{ successed, status?, errorCode?, message?, result? }` + +## 球场常量(写死在协议里) + +| 常量 | 值 | 用途 | +|---|---|---| +| `DEFAULT_VENUE_ID`(数字) | `6038652` | 请求体 `userId` 字段 | +| `STOREID`(Header,字符串) | `"6038652"` | HTTP Header | +| `PROJECT_UID` | `"1775556129325750199"` | 请求体 `projectUid` 字段 | +| 小程序 appid | `wx2088ca46b47d8be7` | 仅用于 `Referer` 识别 | + +这三个是球场/项目级别的公开标识,所有请求都固定。**不允许 agent 改写,也不允许从用户输入派生**。 + +## 通用只读 Header + +```http +Accept: */* +APPTYPE: 1 +Content-Type: application/json +PSPLVISITORAUTO: API +Referer: https://servicewechat.com/wx2088ca46b47d8be7/11/page-frame.html +STOREID: 6038652 +VERSIONINFO: NC|2026.07.03 +``` + +只读请求**不携带** `PSPLVISITORID`、不携带 `User-Agent`、不携带 `Sec-Fetch-*`、`xweb_xhr`。 + +## 通用写 Header + +```http +Accept: */* +Accept-Encoding: gzip, deflate, br +Accept-Language: zh-CN,zh;q=0.9 +APPTYPE: 1 +Content-Type: application/json +PSPLVISITORID: <从用户隐藏式输入> +Referer: https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html +Sec-Fetch-Dest: empty +Sec-Fetch-Mode: cors +Sec-Fetch-Site: cross-site +STOREID: 6038652 +User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.7(0x13080712) UnifiedPCMacWechat(0xf2641b36) XWEB/25193 +VERSIONINFO: NC|2026.07.13 +xweb_xhr: 1 +``` + +写操作(锁场、释放)**必须**携带 `PSPLVISITORID`。User-Agent 是 Pospal 用来识别"来自微信小程序"客户端的,**不要**替换为 `curl/7.x` 之类,否则可能直接被拒。 + +--- + +## 1. 查询可订时段 + +`POST /wxapi/AppointmentVenue/LoadValidClassRoomApptSettingV2` + +### 请求体 + +```json +{ + "dateTime": "YYYY-MM-DD", + "userId": 6038652, + "projectUid": "1775556129325750199" +} +``` + +- `dateTime`:YYYY-MM-DD。agent 一次只查一天;多日要循环。 +- `userId`:数字(与 `STOREID` 同源,但请求体里是 number)。 +- `projectUid`:字符串。 + +### 响应体 + +```json +{ + "successed": true, + "result": { + "slots": [ + { + "classroomUid": 1775556146897330236, + "txtClassroomUid": "1775556146897330236", + "classRoomName": "1号风雨场", + "beginDatetime": "2026-07-16 08:00:00", + "endDatetime": "2026-07-16 08:59:00", + "cost": 80, + "status": 0, + "apptInfo": { + "canApptOrNot": true, + "customerNames": ["王小球"], + "hadVenueAppts": [ + { "customerName": "王小球", "customerTel": "13800000000" } + ] + } + } + ], + "enrollSlots": [ + { + "txtClassroomUid": "1775556146897330236", + "beginDatetime": "2026-07-16 08:00:00", + "endDatetime": "2026-07-16 09:59:00", + "enrollmentRule": { + "uidTxt": "1784027073124705081", + "title": "【2.5双打】福中福8-10", + "beginDate": "2026-07-16 08:00:00", + "endDate": "2026-07-16 10:00:00", + "enrollmentEndDate": "2026-07-16 07:00:00", + "enrollmentFee": 70, + "enrollmentCount": 3, + "enrollmentLimitNum": 4, + "customerModels": [{ "name": "星" }, { "name": "浩哥" }] + } + } + ] + } +} +``` + +### 字段语义 + +#### `result.slots[i]` + +| 字段 | 类型 | 含义 | +|---|---|---| +| `txtClassroomUid` | string | **场地 ID,必须保持字符串**(数值可能超过 JS 安全整数)。后续所有引用都用这个字段。 | +| `classRoomName` | string | 场地展示名,如 `"1号风雨场"` | +| `beginDatetime` | string (`YYYY-MM-DD HH:MM:SS`) | 时段起点 | +| `endDatetime` | string | 时段终点(秒位是 `:59`) | +| `cost` | number(元) | 单价 | +| `status` | number | `0` 可订/不可订看 `canApptOrNot`;`1` 已订;`4` 排课;其他值按不可订处理 | +| `apptInfo.canApptOrNot` | boolean | 仅当 `status=0` 时有定义 | +| `apptInfo.customerNames` | string[] | 已订场地的预约人姓名 | +| `apptInfo.hadVenueAppts` | `[{customerName, customerTel}]` | 已订场地的预约人完整信息 | + +`customerNames` / `hadVenueAppts` 里的姓名/手机号**只在解析时段状态时使用**,不得写入日志、不得转给用户以外的人。 + +#### `result.enrollSlots[i]` + +| 字段 | 类型 | 含义 | +|---|---|---| +| `txtClassroomUid` | string | 与 slot 同一坐标系 | +| `beginDatetime` / `endDatetime` | string | 活动覆盖区间 | +| `enrollmentRule.uidTxt` | string | 活动 ID | +| `enrollmentRule.title` | string | 活动标题 | +| `enrollmentRule.beginDate` / `endDate` | string | 活动完整起止 | +| `enrollmentRule.enrollmentEndDate` | string | 报名截止时间 | +| `enrollmentRule.enrollmentFee` | number(元) | 报名费 | +| `enrollmentRule.enrollmentCount` | number | 已报名数(优先用) | +| `enrollmentRule.ParticipateCount` | number | 已报名数(备用字段,接口两种都出过) | +| `enrollmentRule.enrollmentLimitNum` | number | 名额上限 | +| `enrollmentRule.customerModels` | `[{name}]` | 已报名名单 | + +### 时段状态判定规则 + +``` +enrollSlot 覆盖该 slot(beginDatetime ≤ enroll.endDatetime 且 endDatetime ≥ enroll.beginDatetime) + → status = "booked", unavailableLabel = "已订" +status == 1 + → status = "booked", unavailableLabel = "已订" +status == 4 + → status = "blocked", unavailableLabel = "排课" +status == 0 && apptInfo.canApptOrNot == true + → status = "available" +status == 0 && apptInfo.canApptOrNot == false + → status = "limited"(库存紧张,仍可订;可计入连续组合) +其他 status + → status = "blocked" +``` + +**enrollSlot 永远优先于 slot 自身的 status**。报名活动覆盖的时段按"已订"处理,**不得计入可订数量,也不得触发自动锁场**。 + +### 时间块归一 + +`endDatetime` 的秒是 `:59`,表示该 slot 占据"开始时间所在整点"整块,不延伸到下一小时。 + +- `08:00-08:59` = 8 点这一小时块 +- `19:00-19:59` = 19 点这一小时块 +- 连续组合判定时把 slot 投影到 `[beginHour, endHour)`,按整点对齐 + +### 错误响应 + +```json +{ "successed": false, "status": 400, "errorCode": "...", "message": "..." } +``` + +HTTP 4xx/5xx 都视为明确失败,**不重试**。 + +--- + +## 2. 锁场 + +`POST /wxapi/AppointmentVenue/SaveVenueAppointmentV2` + +### 请求体 + +```json +{ + "userId": 6038652, + "projectType": 0, + "classroomItems": [ + { + "classroomUid": "1775556146897330236", + "beginDatetime": "2026-07-16 19:00:00", + "endDatetime": "2026-07-16 19:59:00", + "peopleNum": 1 + }, + { + "classroomUid": "1775556146897330236", + "beginDatetime": "2026-07-16 20:00:00", + "endDatetime": "2026-07-16 20:59:00", + "peopleNum": 1 + } + ], + "remark": "", + "combinationPayments": [ + { "paymentMethod": 900, "cost": 160 } + ] +} +``` + +要点: + +- `classroomItems` 是**完整连续组合**——所有时段必须: + - 同一 `txtClassroomUid` + - 时段相邻(`prev.endHour == cur.beginHour`) + - 覆盖 `[targetStartHour, targetEndHour)` 整段 + - 全部 `status in {available, limited}` + - 全部不在 `enrollSlot` 覆盖区间 +- `peopleNum` 固定为 `1` +- `remark` 固定为 `""` +- `combinationPayments[0].cost` 是**所有 `classroomItems` 单价之和**,单位**元**(不是分) +- **一次请求提交全部组成时段,不得拆单** + +### 响应体(成功) + +```json +{ + "successed": true, + "result": { + "apptUid": "1784102970857723008", + "script": { + "expireTime": "2026-07-15 16:24:31", + "paySign": "" + }, + "venueAppointUids": ["1784102970857723008"] + } +} +``` + +| 字段 | 含义 | +|---|---| +| `apptUid` | 订单号 | +| `venueAppointUids` | 场地订单号列表 | +| `script.expireTime` | 支付截止 | +| `script.paySign` 及整个 `script` 对象 | **支付签名,只在当次锁场响应里出现一次。不得打印、不得落盘、不得缓存、不得转发。** | + +### 响应体(拒绝) + +```json +{ "successed": false, "status": 400, "errorCode": "...", "message": "..." } +``` + +明确失败,agent 可以换时段重试,但同一会话同一时段**最多重试一次**。 + +### 错误分类 + +| 现象 | 分类 | 处置 | +|---|---|---| +| HTTP 2xx + `successed: true` | success | 一次性操作,session 结束本次写 | +| HTTP 2xx + `successed: false` | rejected | 可换时段重试(同一会话同一时段最多 1 次) | +| HTTP 4xx | rejected | 同上 | +| HTTP 5xx | uncertain | **不重试**,告诉用户结果不确定,必须去小程序核实 | +| 网络错误 / 超时 | uncertain | 同上 | +| JSON 解析失败 | uncertain | 同上 | + +**绝不在结果不确定时重试。** 已经发起的锁场,agent 必须假设服务端可能已经接受,必须提示用户去小程序核对。 + +--- + +## 3. 释放 + +`POST /wxapi/AppointmentVenue/CrmAutoReleaseVenueAppoint` + +**请求体严格是空对象 `{}`,不得携带任何参数。** + +### 响应 + +成功:`{ "successed": true }` +失败:`{ "successed": false, "..." }` + +**注意**:释放接口针对当前访客的全部锁场,**不能仅凭订单号安全限定目标**。除非用户明确说"释放我刚锁的那一笔",否则 agent 不主动调用。 + +--- + +## 完整 curl 示例 + +### 查询 + +```bash +curl -sS -X POST 'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/LoadValidClassRoomApptSettingV2' \ + -H 'Accept: */*' \ + -H 'APPTYPE: 1' \ + -H 'Content-Type: application/json' \ + -H 'PSPLVISITORAUTO: API' \ + -H 'Referer: https://servicewechat.com/wx2088ca46b47d8be7/11/page-frame.html' \ + -H 'STOREID: 6038652' \ + -H 'VERSIONINFO: NC|2026.07.03' \ + -d '{"dateTime":"2026-07-16","userId":6038652,"projectUid":"1775556129325750199"}' +``` + +### 锁场 + +```bash +curl -sS -X POST 'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/SaveVenueAppointmentV2' \ + -H 'Accept: */*' \ + -H 'Accept-Encoding: gzip, deflate, br' \ + -H 'Accept-Language: zh-CN,zh;q=0.9' \ + -H 'APPTYPE: 1' \ + -H 'Content-Type: application/json' \ + -H "PSPLVISITORID: $POSPAL_VISITOR_ID" \ + -H 'Referer: https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html' \ + -H 'Sec-Fetch-Dest: empty' \ + -H 'Sec-Fetch-Mode: cors' \ + -H 'Sec-Fetch-Site: cross-site' \ + -H 'STOREID: 6038652' \ + -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.7(0x13080712) UnifiedPCMacWechat(0xf2641b36) XWEB/25193' \ + -H 'VERSIONINFO: NC|2026.07.13' \ + -H 'xweb_xhr: 1' \ + -d '{ + "userId": 6038652, + "projectType": 0, + "classroomItems": [ + {"classroomUid":"1775556146897330236","beginDatetime":"2026-07-16 19:00:00","endDatetime":"2026-07-16 19:59:00","peopleNum":1}, + {"classroomUid":"1775556146897330236","beginDatetime":"2026-07-16 20:00:00","endDatetime":"2026-07-16 20:59:00","peopleNum":1} + ], + "remark": "", + "combinationPayments": [{"paymentMethod": 900, "cost": 160}] + }' +``` + +### 释放 + +```bash +curl -sS -X POST 'https://wxservice-stg48.pospal.cn/wxapi/AppointmentVenue/CrmAutoReleaseVenueAppoint' \ + -H "PSPLVISITORID: $POSPAL_VISITOR_ID" \ + -H 'Referer: https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html' \ + -H 'STOREID: 6038652' \ + -H 'VERSIONINFO: NC|2026.07.13' \ + -d '{}' +``` + +--- + +## 字段变更检测 + +如果接口返回了上文未列出的关键字段、或字段名变化,按"球场接口响应格式错误"处理,**不静默忽略**。具体触发条件: + +- 列表响应缺少 `result.slots` 数组 +- 列表响应 slot 缺少 `txtClassroomUid` / `classRoomName` / `beginDatetime` / `endDatetime` / `cost` / `status` / `apptInfo.canApptOrNot` 中任一项 +- 锁场响应缺少 `result.apptUid` 或 `result.script.expireTime` 或 `result.venueAppointUids` + +任一触发 → 停止本球场所有操作,告知用户"接口字段变化,暂停服务",不静默丢时段、不静默重试。 diff --git a/skills/tennis-booking/scripts/pospal.mjs b/skills/tennis-booking/scripts/pospal.mjs new file mode 100755 index 0000000..338d025 --- /dev/null +++ b/skills/tennis-booking/scripts/pospal.mjs @@ -0,0 +1,506 @@ +#!/usr/bin/env node +// pospal.mjs — Pospal helper for tennis-booking skill. +// Pure data transformation; never holds network state; never persists secrets. +// Spec: see references/protocol.md in the same skill directory. + +import { createHash } from 'node:crypto' + +// ---------- CLI plumbing ---------- + +function usage() { + process.stderr.write(`Usage: pospal.mjs [options] + +Subcommands: + parse-availability Read raw list JSON from stdin, write structured slots JSON + --date YYYY-MM-DD (required) + --pretty (indent output) + find-continuous Read parse-availability output from stdin, write matching sequence + --start HH (required, e.g. 19) + --end HH (required, e.g. 21) + build-booking-body Read find-continuous (found: true) output from stdin, write request body + --pretty + parse-booking-response Read raw booking response JSON from stdin, write sanitized booking summary + --slots (path to the same find-continuous output used to book) + --pretty + fingerprint Read visitor ID from --visitor or POSPAL_VISITOR_ID env, write 12-char hex + redact Read any JSON from stdin, write redacted JSON (no paySign, no PII, no PSPL*) + --pretty + +All subcommands read JSON from stdin unless noted. +Env: + POSPAL_VISITOR_ID Read by 'fingerprint' if --visitor is not given +`) +} + +function die(msg, code = 1) { + process.stderr.write(`error: ${msg}\n`) + process.exit(code) +} + +function parseArgs(argv) { + const flags = {} + const positional = [] + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a.startsWith('--')) { + const key = a.slice(2) + const next = argv[i + 1] + if (next === undefined || next.startsWith('--')) { + flags[key] = true + } else { + flags[key] = next + i++ + } + } else { + positional.push(a) + } + } + return { flags, positional } +} + +async function readStdin() { + const chunks = [] + for await (const chunk of process.stdin) chunks.push(chunk) + return Buffer.concat(chunks).toString('utf8') +} + +function isRecord(v) { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +function isInt(n) { + return typeof n === 'number' && Number.isFinite(n) +} + +const writeJson = (obj, pretty) => { + process.stdout.write(pretty ? JSON.stringify(obj, null, 2) : JSON.stringify(obj)) + process.stdout.write('\n') +} + +// ---------- shared helpers ---------- + +const TIME_RE = /(\d{2}):(\d{2})/ +const DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}):\d{2}$/ + +function extractTime(value) { + if (typeof value !== 'string') return null + const m = TIME_RE.exec(value) + return m ? m[0] : null +} + +function extractHour(time) { + if (typeof time !== 'string') return null + const m = TIME_RE.exec(time) + return m ? Number(m[1]) : null +} + +function extractDate(dateTime) { + if (typeof dateTime !== 'string') return null + const m = DATE_TIME_RE.exec(dateTime) + return m ? m[1] : null +} + +// ---------- parse-availability ---------- + +function parseEnrollment(raw) { + if (!isRecord(raw) || !isRecord(raw.enrollmentRule)) { + die('球场接口返回了无法识别的活动数据') + } + const rule = raw.enrollmentRule + const startTime = extractTime(rule.beginDate) + const endTime = extractTime(rule.endDate) + const deadline = extractTime(rule.enrollmentEndDate) + if ( + typeof raw.txtClassroomUid !== 'string' || + typeof raw.beginDatetime !== 'string' || + typeof raw.endDatetime !== 'string' || + typeof rule.uidTxt !== 'string' || + typeof rule.title !== 'string' || + startTime === null || + endTime === null || + !isInt(rule.enrollmentFee) || + !isInt(rule.enrollmentLimitNum) + ) { + die('球场接口返回了无法识别的活动数据') + } + const enrolledCount = isInt(rule.enrollmentCount) + ? rule.enrollmentCount + : isInt(rule.ParticipateCount) + ? rule.ParticipateCount + : 0 + const participantNames = [] + if (Array.isArray(rule.customerModels)) { + for (const c of rule.customerModels) { + if (isRecord(c) && typeof c.name === 'string' && c.name.trim()) { + participantNames.push(c.name.trim()) + } + } + } + return { + txtClassroomUid: raw.txtClassroomUid, + beginDatetime: raw.beginDatetime, + endDatetime: raw.endDatetime, + activity: { + id: rule.uidTxt, + title: rule.title, + startTime, + endTime, + feeCents: Math.round(rule.enrollmentFee * 100), + enrolledCount, + capacity: rule.enrollmentLimitNum, + participantNames: [...new Set(participantNames)], + ...(deadline !== null ? { enrollmentDeadline: deadline } : {}) + } + } +} + +function parseSlot(raw, enrollments) { + if (!isRecord(raw)) die('球场接口返回了无法识别的时段数据') + const startTime = extractTime(raw.beginDatetime) + const endTime = extractTime(raw.endDatetime) + const appt = isRecord(raw.apptInfo) ? raw.apptInfo : null + const canBook = appt ? appt.canApptOrNot : undefined + if ( + typeof raw.txtClassroomUid !== 'string' || + raw.txtClassroomUid.trim() === '' || + typeof raw.classRoomName !== 'string' || + raw.classRoomName.trim() === '' || + typeof raw.beginDatetime !== 'string' || + typeof raw.endDatetime !== 'string' || + startTime === null || + endTime === null || + !isInt(raw.cost) || + !isInt(raw.status) || + typeof canBook !== 'boolean' + ) { + die('球场接口返回了无法识别的时段数据') + } + const enrollment = enrollments.find((e) => + e.txtClassroomUid === raw.txtClassroomUid && + raw.beginDatetime <= e.endDatetime && + raw.endDatetime >= e.beginDatetime + ) + let status + let unavailableLabel + if (enrollment) { + status = 'booked' + unavailableLabel = '已订' + } else if (canBook) { + status = 'available' + } else if (raw.status === 1) { + status = 'booked' + unavailableLabel = '已订' + } else { + status = 'blocked' + unavailableLabel = '排课' + } + let bookedBy + if (status === 'booked' && appt) { + const contacts = [] + if (Array.isArray(appt.hadVenueAppts)) { + for (const v of appt.hadVenueAppts) { + if (!isRecord(v)) continue + const name = typeof v.customerName === 'string' ? v.customerName.trim() : '' + const phone = typeof v.customerTel === 'string' ? v.customerTel.trim() : '' + if (name || phone) contacts.push({ name: name || undefined, phone: phone || undefined }) + } + } + if (contacts.length === 0 && Array.isArray(appt.customerNames)) { + for (const v of appt.customerNames) { + if (typeof v === 'string' && v.trim()) contacts.push({ name: v.trim() }) + } + } + const unique = [] + for (const c of contacts) { + if (!unique.some((u) => u.name === c.name && u.phone === c.phone)) unique.push(c) + } + if (unique.length > 0) bookedBy = unique + } + return { + id: `${raw.txtClassroomUid}:${raw.beginDatetime}:${raw.endDatetime}`, + courtLabel: raw.classRoomName, + startTime, + endTime, + priceCents: Math.round(raw.cost * 100), + status, + ...(unavailableLabel ? { unavailableLabel } : {}), + ...(bookedBy ? { bookedBy } : {}), + ...(enrollment ? { enrollment: enrollment.activity } : {}), + providerReference: { + classroomUid: raw.txtClassroomUid, + beginDatetime: raw.beginDatetime, + endDatetime: raw.endDatetime + }, + sourceLabel: '范思伯特实时接口' + } +} + +async function cmdParseAvailability({ flags }) { + const date = flags.date + if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + die('--date YYYY-MM-DD is required') + } + const raw = await readStdin() + let payload + try { payload = JSON.parse(raw) } catch { die('stdin is not valid JSON') } + if (!isRecord(payload)) die('球场接口响应格式错误') + if (payload.successed !== true || !isRecord(payload.result)) { + die('球场接口拒绝了可订时段查询') + } + const slots = payload.result.slots + if (!Array.isArray(slots)) die('球场接口响应缺少时段列表') + const enrollRaw = payload.result.enrollSlots + if (enrollRaw !== undefined && !Array.isArray(enrollRaw)) { + die('球场接口响应中的活动列表格式错误') + } + const enrollments = [] + if (Array.isArray(enrollRaw)) { + for (const e of enrollRaw) enrollments.push(parseEnrollment(e)) + } + const parsedSlots = slots.map((s) => parseSlot(s, enrollments)) + writeJson({ + courtId: 'fansibote-fuzhong', + date, + updatedAt: new Date().toISOString(), + slots: parsedSlots + }, !!flags.pretty) +} + +// ---------- find-continuous ---------- + +function isContinuousSingleCourt(slots) { + if (slots.length === 0) return false + const firstUid = slots[0].providerReference?.classroomUid + if (!firstUid) return false + for (const s of slots) { + if (s.providerReference?.classroomUid !== firstUid) return false + } + for (let i = 1; i < slots.length; i++) { + const prevEnd = extractHour(slots[i - 1].endTime) + const curStart = extractHour(slots[i].startTime) + if (prevEnd === null || curStart === null || prevEnd + 1 !== curStart) return false + } + return true +} + +async function cmdFindContinuous({ flags }) { + const start = flags.start + const end = flags.end + if (typeof start !== 'string' || typeof end !== 'string') { + die('--start HH and --end HH are required') + } + const startHour = Number(start) + const endHour = Number(end) + if (!Number.isInteger(startHour) || !Number.isInteger(endHour) || + startHour < 0 || endHour > 24 || startHour >= endHour) { + die('invalid --start/--end (must be integers, 0 <= start < end <= 24)') + } + const raw = await readStdin() + let payload + try { payload = JSON.parse(raw) } catch { die('stdin is not valid JSON') } + if (!isRecord(payload) || !Array.isArray(payload.slots)) { + die('stdin is not a parse-availability output (missing slots array)') + } + const bookable = payload.slots.filter((s) => + (s.status === 'available' || s.status === 'limited') && + !s.enrollment + ) + const sorted = [...bookable].sort((a, b) => { + if (a.providerReference.classroomUid !== b.providerReference.classroomUid) { + return a.providerReference.classroomUid.localeCompare(b.providerReference.classroomUid) + } + return a.startTime.localeCompare(b.startTime) + }) + const groups = new Map() + for (const s of sorted) { + const uid = s.providerReference.classroomUid + if (!groups.has(uid)) groups.set(uid, []) + groups.get(uid).push(s) + } + const matches = [] + const span = endHour - startHour + for (const [uid, slots] of groups) { + for (let i = 0; i + span <= slots.length; i++) { + const window = slots.slice(i, i + span) + const firstHour = extractHour(window[0].startTime) + const lastEndHour = extractHour(window[window.length - 1].endTime) + if (firstHour !== startHour || lastEndHour + 1 !== endHour) continue + if (!isContinuousSingleCourt(window)) continue + matches.push({ + txtClassroomUid: uid, + courtLabel: window[0].courtLabel, + startTime: window[0].startTime, + endTime: window[window.length - 1].endTime, + slots: window + }) + } + } + if (matches.length === 0) { + writeJson({ found: false }, false) + return + } + matches.sort((a, b) => { + if (a.startTime !== b.startTime) return a.startTime.localeCompare(b.startTime) + const aCost = a.slots.reduce((s, x) => s + x.priceCents, 0) + const bCost = b.slots.reduce((s, x) => s + x.priceCents, 0) + if (aCost !== bCost) return aCost - bCost + return a.courtLabel.localeCompare(b.courtLabel) + }) + writeJson({ + found: true, + match: matches[0], + alternatives: matches.slice(1) + }, false) +} + +// ---------- build-booking-body ---------- + +const DEFAULT_VENUE_ID_NUM = 6038652 + +async function cmdBuildBookingBody({ flags }) { + const raw = await readStdin() + let payload + try { payload = JSON.parse(raw) } catch { die('stdin is not valid JSON') } + if (!isRecord(payload) || payload.found !== true || !isRecord(payload.match)) { + die('stdin is not a find-continuous output (found: true required)') + } + const slots = payload.match.slots + if (!Array.isArray(slots) || slots.length === 0) die('no slots in match') + if (!isContinuousSingleCourt(slots)) die('match is not a single-court continuous sequence') + const body = { + userId: DEFAULT_VENUE_ID_NUM, + projectType: 0, + classroomItems: slots.map((s) => ({ + classroomUid: s.providerReference.classroomUid, + beginDatetime: s.providerReference.beginDatetime, + endDatetime: s.providerReference.endDatetime, + peopleNum: 1 + })), + remark: '', + combinationPayments: [ + { + paymentMethod: 900, + cost: slots.reduce((sum, s) => sum + s.priceCents, 0) / 100 + } + ] + } + writeJson(body, !!flags.pretty) +} + +// ---------- parse-booking-response ---------- + +async function cmdParseBookingResponse({ flags }) { + const slotsPath = flags.slots + if (typeof slotsPath !== 'string') die('--slots is required') + const fs = await import('node:fs/promises') + let slotsDoc + try { + slotsDoc = JSON.parse(await fs.readFile(slotsPath, 'utf8')) + } catch (err) { + die(`cannot read --slots: ${err.message}`) + } + const sl = slotsDoc?.match?.slots ?? slotsDoc?.slots + if (!Array.isArray(sl) || sl.length === 0) { + die('--slots must point to a find-continuous (with .match.slots) or parse-availability (with .slots) output') + } + const respRaw = await readStdin() + let payload + try { payload = JSON.parse(respRaw) } catch { die('stdin is not valid JSON') } + if (!isRecord(payload)) die('球场下单接口响应格式错误') + if (payload.successed !== true) { + const detail = [] + for (const k of ['message', 'msg', 'errorMessage', 'errorMsg', 'status', 'errorCode']) { + const v = payload[k] + if (typeof v === 'string' || typeof v === 'number') { + detail.push(`${k}=${String(v).slice(0, 200)}`) + } + } + die(`球场下单接口拒绝了锁场请求${detail.length ? `(${detail.join(', ')})` : ''}`) + } + if (!isRecord(payload.result)) die('球场下单接口响应格式错误') + const result = payload.result + if ( + typeof result.apptUid !== 'string' || + !isRecord(result.script) || + typeof result.script.expireTime !== 'string' || + !Array.isArray(result.venueAppointUids) || + !result.venueAppointUids.every((v) => typeof v === 'string') + ) { + die('球场下单接口响应格式错误') + } + const sorted = [...sl].sort((a, b) => a.startTime.localeCompare(b.startTime)) + const amountCents = sorted.reduce((s, x) => s + x.priceCents, 0) + const summary = { + courtId: 'fansibote-fuzhong', + orderId: result.apptUid, + venueAppointmentIds: result.venueAppointUids, + amountCents, + courtLabel: sorted[0].courtLabel, + startTime: sorted[0].startTime, + endTime: sorted[sorted.length - 1].endTime, + expiresAt: result.script.expireTime, + status: 'pending-payment', + _note: 'script 对象(paySign 等)已剥离;请到小程序完成支付。' + } + writeJson(summary, !!flags.pretty) +} + +// ---------- fingerprint ---------- + +async function cmdFingerprint({ flags }) { + let visitor = typeof flags.visitor === 'string' ? flags.visitor : '' + if (!visitor) visitor = process.env.POSPAL_VISITOR_ID ?? '' + if (!visitor) die('fingerprint needs --visitor or POSPAL_VISITOR_ID env') + const fp = createHash('sha256').update(visitor).digest('hex').slice(0, 12) + process.stdout.write(fp) + process.stdout.write('\n') +} + +// ---------- redact ---------- + +const SENSITIVE_KEY = /^(?:visitor|paysign|prepay|nonce|package|token|cookie|authorization)$|^(?:pspl|customer|phone|tel)/i + +function redactValue(value, key) { + if (typeof key === 'string' && SENSITIVE_KEY.test(key)) return '[REDACTED]' + if (Array.isArray(value)) return value.map((v) => redactValue(v, '')) + if (isRecord(value)) { + const out = {} + for (const [k, v] of Object.entries(value)) out[k] = redactValue(v, k) + return out + } + return value +} + +async function cmdRedact({ flags }) { + const raw = await readStdin() + let payload + try { payload = JSON.parse(raw) } catch { die('stdin is not valid JSON') } + writeJson(redactValue(payload, ''), !!flags.pretty) +} + +// ---------- main ---------- + +async function main() { + const argv = process.argv.slice(2) + if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { + usage() + process.exit(argv.length === 0 ? 1 : 0) + } + const sub = argv[0] + const parsed = parseArgs(argv.slice(1)) + switch (sub) { + case 'parse-availability': return cmdParseAvailability(parsed) + case 'find-continuous': return cmdFindContinuous(parsed) + case 'build-booking-body': return cmdBuildBookingBody(parsed) + case 'parse-booking-response': return cmdParseBookingResponse(parsed) + case 'fingerprint': return cmdFingerprint(parsed) + case 'redact': return cmdRedact(parsed) + default: { + process.stderr.write(`unknown subcommand: ${sub}\n\n`) + usage() + process.exit(1) + } + } +} + +main().catch((err) => die(err?.stack ?? String(err))) diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..d569225 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,482 @@ +import { spawn } from 'node:child_process' +import { closeSync, constants, openSync } from 'node:fs' +import { + mkdir, + open, + readFile, + unlink +} from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { AdapterRegistry } from '../main/adapters/registry' +import { PendingBookingStore } from '../main/bookings/PendingBookingStore' +import { MonitorService } from '../main/monitors/MonitorService' +import { MonitorTaskStore } from '../main/monitors/MonitorTaskStore' +import { CourtSettingsStore } from '../main/settings/CourtSettingsStore' +import type { MonitorAlert, MonitorTask } from '../shared/contracts' + +const USER_DATA_DIRECTORY = process.env.TENNIS_BOOK_USER_DATA?.trim() || + (process.platform === 'darwin' + ? join(homedir(), 'Library', 'Application Support', 'tennis-book') + : join(process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config'), 'tennis-book')) +const PID_FILE = join(USER_DATA_DIRECTORY, 'booking-daemon.pid') +const LOG_FILE = join(USER_DATA_DIRECTORY, 'booking-daemon.log') +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ +const TIME_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d$/ + +interface ParsedArgs { + positionals: string[] + flags: Map +} + +function parseArgs(values: string[]): ParsedArgs { + const positionals: string[] = [] + const flags = new Map() + for (let index = 0; index < values.length; index += 1) { + const value = values[index]! + if (!value.startsWith('--')) { + positionals.push(value) + continue + } + const [rawName, inlineValue] = value.slice(2).split('=', 2) + if (!rawName) throw new Error(`无法识别参数:${value}`) + if (inlineValue !== undefined) { + flags.set(rawName, inlineValue) + continue + } + const next = values[index + 1] + if (next !== undefined && !next.startsWith('--')) { + flags.set(rawName, next) + index += 1 + } else { + flags.set(rawName, true) + } + } + return { positionals, flags } +} + +function stringFlag(args: ParsedArgs, name: string, required = false) { + const value = args.flags.get(name) + if (value === true) throw new Error(`--${name} 需要参数值`) + if (required && typeof value !== 'string') throw new Error(`缺少 --${name}`) + return typeof value === 'string' ? value : undefined +} + +function booleanFlag(args: ParsedArgs, name: string) { + const value = args.flags.get(name) + if (value === undefined) return false + if (value !== true) throw new Error(`--${name} 不接受参数值`) + return true +} + +function json(value: unknown) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`) +} + +function localDateValue(dayOffset = 0) { + const date = new Date() + date.setDate(date.getDate() + dayOffset) + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ].join('-') +} + +function timeMinutes(value: string) { + const [hour, minute] = value.split(':').map(Number) + return hour! * 60 + minute! +} + +function normalizeTime(value: string) { + const normalized = /^\d{1,2}$/.test(value) + ? `${value.padStart(2, '0')}:00` + : value + if (!TIME_PATTERN.test(normalized)) throw new Error(`时间格式错误:${value}`) + return normalized +} + +function processIsAlive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function daemonPid() { + try { + const pid = Number((await readFile(PID_FILE, 'utf8')).trim()) + if (Number.isSafeInteger(pid) && pid > 0 && processIsAlive(pid)) return pid + await unlink(PID_FILE).catch(() => undefined) + return null + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } +} + +async function assertDaemonStoppedForMutation() { + const pid = await daemonPid() + if (pid) { + throw new Error( + `后台监控正在运行(PID ${pid})。请先执行 daemon stop,修改完成后再 daemon start` + ) + } +} + +function createRuntime(schedulingEnabled = true) { + const settingsStore = new CourtSettingsStore(join(USER_DATA_DIRECTORY, 'court-settings.json')) + const pendingStore = new PendingBookingStore(join(USER_DATA_DIRECTORY, 'pending-bookings.json')) + const registry = new AdapterRegistry(settingsStore, pendingStore) + const taskStore = new MonitorTaskStore(join(USER_DATA_DIRECTORY, 'monitor-tasks.json')) + return { + registry, + service: new MonitorService(registry, taskStore, notifyAvailability, schedulingEnabled) + } +} + +function resolveCourt(input: string, registry: AdapterRegistry) { + const normalized = decodeURIComponent(input.trim()).toLocaleLowerCase('zh-CN') + const court = registry.listCourts().find((candidate) => + [candidate.id, candidate.name, candidate.shortName] + .some((alias) => alias.toLocaleLowerCase('zh-CN') === normalized) + ) + if (!court) { + throw new Error(`未找到球场“${input}”。先执行 courts list 查看已接入的专属适配器`) + } + return court +} + +function appleScriptString(value: string) { + return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` +} + +function showSystemNotification(title: string, body: string) { + if (process.platform !== 'darwin') return + const child = spawn('osascript', [ + '-e', + `display notification ${appleScriptString(body)} with title ${appleScriptString(title)}` + ], { + detached: true, + stdio: 'ignore' + }) + child.unref() +} + +function notifyAvailability(alert: MonitorAlert) { + const title = 'Tennis Book 有空场释放' + const body = `${alert.targetDate} ${alert.slotLabels.join('、')}` + process.stdout.write(`[${alert.occurredAt}] ${title}:${body}\n`) + showSystemNotification(title, body) +} + +async function promptSecret(label: string) { + if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stdin.setRawMode) { + throw new Error('当前终端不能安全读取凭证;请在交互式终端执行此命令') + } + process.stdout.write(label) + process.stdin.setRawMode(true) + process.stdin.resume() + process.stdin.setEncoding('utf8') + return new Promise((resolve, reject) => { + let value = '' + const cleanup = () => { + process.stdin.setRawMode(false) + process.stdin.pause() + process.stdin.removeListener('data', onData) + process.stdout.write('\n') + } + const onData = (chunk: string) => { + for (const character of chunk) { + if (character === '\u0003') { + cleanup() + reject(new Error('已取消')) + return + } + if (character === '\r' || character === '\n') { + cleanup() + resolve(value) + return + } + if (character === '\u007f') { + value = value.slice(0, -1) + continue + } + if (character >= ' ') value += character + } + } + process.stdin.on('data', onData) + }) +} + +function publicTask(task: MonitorTask) { + return { + id: task.id, + courtId: task.courtId, + date: task.targetDate ?? '未来 7 天滚动', + time: `${task.startTime}-${task.endTime}`, + enabled: task.enabled, + availableCount: task.availableCount, + lastCheckedAt: task.lastCheckedAt, + lastError: task.lastError, + autoBooking: task.autoBooking + } +} + +async function handleCourts(args: ParsedArgs) { + if (args.positionals[1] !== 'list') throw new Error('用法:courts list') + const { registry } = createRuntime() + json(registry.listCourts().map((court) => ({ + ...court, + adapter: '专属适配器', + bookingLinkMode: '球场专属接口,不接受任意 URL 替换' + }))) +} + +async function handleSettings(args: ParsedArgs) { + const action = args.positionals[1] + const { registry } = createRuntime() + const court = resolveCourt(stringFlag(args, 'court', true)!, registry) + if (action === 'status') { + const settings = await registry.getCourtSettings(court.id) + json({ courtId: court.id, visitorIdConfigured: settings.credentialConfigured ?? settings.visitorIdConfigured }) + return + } + if (action !== 'set-visitor') { + throw new Error('用法:settings status|set-visitor --court <球场名称或 ID>') + } + await assertDaemonStoppedForMutation() + const visitorId = (await promptSecret(`请输入 ${court.credentialLabel ?? 'PSPLVISITORID'}(输入不会显示):`)).trim() + if (!visitorId) throw new Error('登录凭证不能为空') + const saved = await registry.updateCourtSettings({ courtId: court.id, ...(court.credentialLabel ? { credential: visitorId } : { visitorId }) }) + json({ courtId: saved.courtId, visitorIdConfigured: saved.credentialConfigured ?? saved.visitorIdConfigured }) +} + +async function handleTask(args: ParsedArgs) { + const action = args.positionals[1] + const { registry, service } = createRuntime(false) + if (action === 'list') { + json((await service.listTasks()).map(publicTask)) + return + } + await assertDaemonStoppedForMutation() + if (action === 'add') { + const court = resolveCourt(stringFlag(args, 'court', true)!, registry) + const startTime = normalizeTime(stringFlag(args, 'start', true)!) + const endTime = normalizeTime(stringFlag(args, 'end', true)!) + if (timeMinutes(startTime) >= timeMinutes(endTime)) { + throw new Error('结束时间必须晚于开始时间') + } + const targetDate = stringFlag(args, 'date') + if ( + targetDate && + (!DATE_PATTERN.test(targetDate) || + ![localDateValue(), localDateValue(1)].includes(targetDate)) + ) { + throw new Error('指定日期只支持今天或明天;省略 --date 可持续滚动监控未来 7 天') + } + const autoBookingEnabled = booleanFlag(args, 'auto-book') + if (autoBookingEnabled && !booleanFlag(args, 'confirm-lock-only')) { + throw new Error('自动预定只锁场不支付;确认后同时传入 --confirm-lock-only') + } + const task = await service.createTask({ + courtId: court.id, + ...(targetDate ? { targetDate } : {}), + startTime, + endTime, + ...(autoBookingEnabled ? { autoBookingEnabled: true } : {}) + }) + json(publicTask(task)) + return + } + const taskId = stringFlag(args, 'id', true)! + if (action === 'pause' || action === 'resume') { + json(publicTask(await service.setEnabled(taskId, action === 'resume'))) + return + } + if (action === 'auto') { + const enabled = booleanFlag(args, 'enable') + const disabled = booleanFlag(args, 'disable') + if (enabled === disabled) throw new Error('task auto 必须二选一:--enable 或 --disable') + if (enabled && !booleanFlag(args, 'confirm-lock-only')) { + throw new Error('自动预定只锁场不支付;确认后同时传入 --confirm-lock-only') + } + json(publicTask(await service.setAutoBookingEnabled(taskId, enabled))) + return + } + if (action === 'delete') { + await service.deleteTask(taskId) + json({ deleted: taskId }) + return + } + throw new Error( + '用法:task add|list|pause|resume|auto|delete;修改任务时后台监控必须停止' + ) +} + +async function handlePending(args: ParsedArgs) { + if (args.positionals[1] !== 'list') throw new Error('用法:pending list') + const { registry } = createRuntime() + const bookings = await Promise.all(registry.listCourts().map(async (court) => ({ + court: court.name, + booking: await registry.getPendingBooking(court.id) + }))) + json(bookings.filter(({ booking }) => booking !== null)) +} + +async function acquireDaemonPidFile() { + await mkdir(dirname(PID_FILE), { recursive: true }) + if (await daemonPid()) throw new Error('后台监控已经在运行') + const file = await open(PID_FILE, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) + await file.writeFile(`${process.pid}\n`, 'utf8') + await file.close() +} + +async function runDaemon() { + await acquireDaemonPidFile() + const { service } = createRuntime() + let shuttingDown = false + const shutdown = async () => { + if (shuttingDown) return + shuttingDown = true + await service.stop().catch((error) => { + process.stderr.write(`停止监控时发生错误:${error instanceof Error ? error.message : String(error)}\n`) + }) + await unlink(PID_FILE).catch(() => undefined) + process.exitCode = 0 + } + process.once('SIGINT', () => void shutdown()) + process.once('SIGTERM', () => void shutdown()) + process.once('uncaughtException', (error) => { + process.stderr.write(`${error.stack ?? error.message}\n`) + void shutdown() + }) + process.once('unhandledRejection', (error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`) + void shutdown() + }) + await service.start() + process.stdout.write(`[${new Date().toISOString()}] Tennis Book 后台监控已启动\n`) + const observedAutoBookingStatus = new Map() + await new Promise((resolve) => { + const timer = setInterval(async () => { + if (shuttingDown) { + clearInterval(timer) + resolve() + return + } + const tasks = await service.listTasks().catch(() => []) + for (const task of tasks) { + const previousStatus = observedAutoBookingStatus.get(task.id) + observedAutoBookingStatus.set(task.id, task.autoBooking.status) + if (previousStatus === undefined || previousStatus === task.autoBooking.status) continue + if (task.autoBooking.status === 'pending-payment') { + showSystemNotification( + 'Tennis Book 已锁场,等待支付', + `${task.autoBooking.bookedDate ?? ''} ${task.autoBooking.bookedSlotLabel ?? ''}`.trim() + ) + } else if (task.autoBooking.status === 'blocked' || task.autoBooking.status === 'stopped') { + showSystemNotification( + 'Tennis Book 自动预定已停止', + task.autoBooking.lastError ?? '请检查任务状态后人工处理' + ) + } + } + }, 5_000) + }) +} + +async function startDaemon() { + const existing = await daemonPid() + if (existing) { + json({ running: true, pid: existing, logFile: LOG_FILE }) + return + } + await mkdir(USER_DATA_DIRECTORY, { recursive: true }) + const logFd = openSync(LOG_FILE, 'a', 0o600) + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'daemon', 'run'], { + detached: true, + stdio: ['ignore', logFd, logFd], + env: process.env + }) + child.unref() + closeSync(logFd) + await new Promise((resolve) => setTimeout(resolve, 400)) + const pid = await daemonPid() + if (!pid) throw new Error(`后台监控启动失败,请检查 ${LOG_FILE}`) + json({ running: true, pid, logFile: LOG_FILE }) +} + +async function stopDaemon() { + const pid = await daemonPid() + if (!pid) { + json({ running: false }) + return + } + process.kill(pid, 'SIGTERM') + const deadline = Date.now() + 5_000 + while (processIsAlive(pid) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + if (processIsAlive(pid)) throw new Error(`后台监控(PID ${pid})未能在 5 秒内停止`) + await unlink(PID_FILE).catch(() => undefined) + json({ running: false }) +} + +async function handleDaemon(args: ParsedArgs) { + const action = args.positionals[1] + if (action === 'run') return runDaemon() + if (action === 'start') return startDaemon() + if (action === 'stop') return stopDaemon() + if (action === 'status') { + const pid = await daemonPid() + json({ running: Boolean(pid), pid, logFile: LOG_FILE, userData: USER_DATA_DIRECTORY }) + return + } + throw new Error('用法:daemon start|stop|status|run') +} + +function usage() { + return `Tennis Book 无界面预约工具 + +命令: + courts list + settings status --court <球场名称或 ID> + settings set-visitor --court <球场名称或 ID> + task add --court <球场> --start 19 --end 21 [--date YYYY-MM-DD] + [--auto-book --confirm-lock-only] + task list + task pause|resume|delete --id <任务 ID> + task auto --id <任务 ID> (--enable|--disable) [--confirm-lock-only] + pending list + daemon start|stop|status|run + +说明: + - 省略 --date 时持续滚动监控未来 7 天。 + - 自动预定只锁场、不支付;成功后任务停止并等待用户去小程序支付。 + - 修改凭证或任务前先停止 daemon,修改完再启动。 +` +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const domain = args.positionals[0] + if (!domain || domain === 'help' || booleanFlag(args, 'help')) { + process.stdout.write(usage()) + return + } + if (domain === 'courts') return handleCourts(args) + if (domain === 'settings') return handleSettings(args) + if (domain === 'task') return handleTask(args) + if (domain === 'pending') return handlePending(args) + if (domain === 'daemon') return handleDaemon(args) + throw new Error(`未知命令:${domain}\n\n${usage()}`) +} + +void main().catch((error: unknown) => { + process.stderr.write(`错误:${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 +}) diff --git a/src/main/adapters/dawanqu/adapter.test.ts b/src/main/adapters/dawanqu/adapter.test.ts new file mode 100644 index 0000000..acc2c35 --- /dev/null +++ b/src/main/adapters/dawanqu/adapter.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import fixture from './matrix.fixture.json' +import dateFixtures from './matrix-dates.fixture.json' +import { dawanquAdapter as adapter, parseMatrix } from './adapter' +import { AdapterRegistry } from '../registry' +import { CourtSettingsStore } from '../../settings/CourtSettingsStore' +import { PendingBookingStore } from '../../bookings/PendingBookingStore' +import { MonitorService } from '../../monitors/MonitorService' +import { MonitorTaskStore } from '../../monitors/MonitorTaskStore' + +const query = { courtId: 'dawanqu', date: '2026-09-24' } +const settings = { courtId: 'dawanqu', credential: 'fixture.token.signature' } +const success = (result: unknown) => ({ ok: true, status: 200, json: async () => ({ code: 200, result }) }) +const quote = (amount = 120, count = 1) => success({ total: amount, subTotal: amount, orders: Array.from({ length: count }, () => ({ total: amount / count })) }) +const order = () => success({ orderBusUuid: '2026092319115446374373082', orderPayUuid: '2026092319115450465766477', subTotal: 120 }) +function slots() { return parseMatrix(fixture.result, query).slots.filter((slot) => slot.status === 'available') } +async function runtime() { + const dir = await mkdtemp(join(tmpdir(), 'tennis-dawanqu-')) + const store = new CourtSettingsStore(join(dir, 'settings.json')) + await store.update(settings) + const pending = new PendingBookingStore(join(dir, 'pending.json')) + return { registry: new AdapterRegistry(store, pending), store, pending } +} +const input = () => ({ ...query, slotId: slots()[0]!.id, expectedPriceCents: 12000 }) + +afterEach(() => vi.unstubAllGlobals()) + +describe('大湾区列表与请求边界', () => { + it('解析原始矩阵并以场地和时段联合去重', () => { + const day = parseMatrix(fixture.result, query) + expect(day.slots).toHaveLength(48) + expect(new Set(day.slots.map((slot) => slot.id)).size).toBe(48) + expect(day.slots[0]).toMatchObject({ courtLabel: '网球1号场', startTime: '09:00', endTime: '10:00', priceCents: 12000, status: 'available' }) + expect(day.slots[1]).toMatchObject({ status: 'booked', priceCents: 0 }) + expect(parseMatrix({ fieldList: [], matrix: [] }, query).slots).toEqual([]) + }) + it('无可订时段仍保留已售信息,特殊活动不开放普通锁场', () => { + const result = structuredClone(fixture.result) + for (const row of result.matrix) for (const cell of row.matrix) cell.isAbleReserve = false + expect(parseMatrix(result, query).slots.every((slot) => slot.status === 'booked')).toBe(true) + const special = structuredClone(fixture.result) + special.matrix[0]!.matrix[0]!.isDiscountActivity = true + expect(parseMatrix(special, query).slots[0]).toMatchObject({ status: 'available', bookingUnavailableReason: '优惠场次需在原小程序预约' }) + }) + it('拒绝未知格式、重复 ID、跨日期和可订空价格', () => { + expect(() => parseMatrix({}, query)).toThrow('格式') + const result = structuredClone(fixture.result) + result.matrix[0]!.matrix.push(result.matrix[0]!.matrix[0]!) + expect(() => parseMatrix(result, query)).toThrow('格式') + expect(() => parseMatrix(fixture.result, { ...query, date: '2026-09-25' })).toThrow('格式') + const invalid = structuredClone(fixture.result) + invalid.matrix[0]!.matrix[0]!.price = null + expect(() => parseMatrix(invalid, query)).toThrow('格式') + }) + it('已开始提示不再覆盖成特殊场次,未开始的已售时段仍为已售', () => { + const day = parseMatrix(dateFixtures['2026-09-23'], { ...query, date: '2026-09-23' }) + expect(day.slots[0]).toMatchObject({ status: 'blocked', unavailableLabel: '已开始' }) + expect(day.slots.at(-1)).toMatchObject({ status: 'booked', unavailableLabel: '已售' }) + expect(day.slots.every((slot) => slot.status !== 'available')).toBe(true) + }) + it('9 月 25 日已售优惠时段按真实不可订状态显示,不因秒杀标记变成未知', () => { + const day = parseMatrix(dateFixtures['2026-09-25'], { ...query, date: '2026-09-25' }) + expect(day.slots).toHaveLength(12) + for (const slot of day.slots) { + expect(slot).toMatchObject({ status: 'booked', unavailableLabel: '已售', priceCents: 7000 }) + expect(slot.bookingUnavailableReason).toBeUndefined() + } + }) + it('普通提示不否认可订状态;未开放原因不误报已售', () => { + const result = structuredClone(fixture.result) as unknown as { fieldList: unknown[]; matrix: { matrix: Record[] }[] } + result.matrix[0]!.matrix[0]!.msg = '请合理安排时间' + result.matrix[0]!.matrix[1]!.msg = '尚未开放预约' + const day = parseMatrix(result, query) + expect(day.slots[0]).toMatchObject({ status: 'available' }) + expect(day.slots[0]!.bookingUnavailableReason).toBeUndefined() + expect(day.slots[1]).toMatchObject({ status: 'blocked', unavailableLabel: '尚未开放' }) + }) + it.each(['2026-09-26', '2026-09-27', '2026-09-28', '2026-09-29'])('%s 超出开放窗口返回明确提示,不冒充网络失败', async (date) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ code: 2000, text: '当前最多可提前 3 天预定场地', result: null }) })) + const day = await adapter.getAuthenticatedAvailability!({ ...query, date }, settings) + expect(day).toMatchObject({ date, slots: [], notice: '尚未开放预约:该球场最多可提前 3 天预定,请在开放后刷新。' }) + }) + it('不把其他业务码 2000 误报为尚未开放', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ code: 2000, text: '其他业务异常', result: null }) })) + await expect(adapter.getAuthenticatedAvailability!(query, settings)).rejects.toThrow('未确认成功') + }) + it('可订优惠场次可识别但主进程仍阻止未接入的优惠下单', async () => { + const result = structuredClone(dateFixtures['2026-09-25']) + result.matrix[0]!.matrix[0]!.isAbleReserve = true + const q = { ...query, date: '2026-09-25' } + const slot = parseMatrix(result, q).slots[0]! + expect(slot).toMatchObject({ status: 'available', bookingUnavailableReason: '优惠场次需在原小程序预约' }) + const { registry } = await runtime() + const fetchMock = vi.fn().mockResolvedValue(success(result)) + vi.stubGlobal('fetch', fetchMock) + await expect(registry.createBooking({ ...q, slotId: slot.id, expectedPriceCents: 7000 })).rejects.toThrow('暂不支持应用内预约') + expect(fetchMock).toHaveBeenCalledOnce() + expect(await registry.getPendingBooking(q.courtId)).toBeNull() + await expect(adapter.createBooking!([slot], settings)).rejects.toThrow('已不可预约') + expect(fetchMock).toHaveBeenCalledOnce() + }) + it('主进程查询注入 Authorization,匿名监控不读凭证也不发请求', async () => { + const { registry, store } = await runtime() + const fetchMock = vi.fn().mockResolvedValue(success(fixture.result)) + vi.stubGlobal('fetch', fetchMock) + const day = await registry.getAvailability(query) + expect(fetchMock.mock.calls[0]![1].headers.Authorization).toBe('Wechat fixture.token.signature') + expect(JSON.stringify(day)).not.toContain(settings.credential) + expect(JSON.stringify(registry.listCourts())).not.toContain(settings.credential) + const readSpy = vi.spyOn(store, 'get') + await expect(registry.getAvailability(query, true)).rejects.toThrow('无凭证') + expect(readSpy).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledOnce() + }) + it.each([401, 403, 429, 500])('处理 HTTP %i 且不回传原始响应中的敏感信息', async (status) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status })) + await expect(adapter.getAuthenticatedAvailability!(query, settings)).rejects.toThrow(status === 429 ? '限流' : status === 500 ? '请求失败' : '登录') + }) + it.each([403, 429])('处理业务错误 %i', async (code) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ code, text: settings.credential }) })) + await expect(adapter.getAuthenticatedAvailability!(query, settings)).rejects.toThrow(code === 403 ? '登录' : '限流') + }) + it('网络异常不泄露底层异常或凭证', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error(settings.credential))) + await expect(adapter.getAuthenticatedAvailability!(query, settings)).rejects.toThrow('网络') + await expect(adapter.getAuthenticatedAvailability!(query, settings)).rejects.not.toThrow(settings.credential) + }) +}) + +describe('大湾区核价、锁场与本机恢复', () => { + it('按重查、核价、锁场顺序创建订单且重复下单被拦截', async () => { + const { registry } = await runtime() + const fetchMock = vi.fn().mockResolvedValueOnce(success(fixture.result)).mockResolvedValueOnce(quote()).mockResolvedValueOnce(order()) + vi.stubGlobal('fetch', fetchMock) + const booking = await registry.createBooking(input()) + expect(fetchMock.mock.calls.map((call) => call[0].split('/').slice(-1)[0])).toEqual(['matrix', 'price', 'reserve']) + const body = JSON.parse(fetchMock.mock.calls[2]![1].body) + expect(body).toMatchObject({ payment: 'wxMini', reserveDate: query.date, checkReserveUuid: '', fieldTimeInfos: [{ fieldUuid: fixture.result.fieldList[0]!.fieldUuid, fieldTimeUuid: fixture.result.fieldTimeList[0]!.fieldTimeUuid, wholeFieldUuid: fixture.result.fieldList[0]!.fieldUuid }] }) + expect(booking.status).toBe('pending-payment') + expect(booking.expiresAt).toBeUndefined() + await expect(registry.createBooking(input())).rejects.toThrow('已有待支付') + await expect(registry.cancelBooking({ courtId: query.courtId, orderId: booking.orderId })).rejects.toThrow('尚未接入取消') + await expect(registry.acknowledgeBookingHandled({ courtId: query.courtId, orderId: booking.orderId })).rejects.toThrow('确认') + await registry.acknowledgeBookingHandled({ courtId: query.courtId, orderId: booking.orderId, confirmed: true }) + expect(await registry.getPendingBooking(query.courtId)).toBeNull() + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + it('连续两小时作为同一笔请求提交,禁止跨场拼接', async () => { + const result = structuredClone(fixture.result) + result.matrix[0]!.matrix[1]!.isAbleReserve = true + result.matrix[0]!.matrix[1]!.price = 120 + const selected = parseMatrix(result, query).slots.slice(0, 2) + const fetchMock = vi.fn().mockResolvedValueOnce(quote(240, 2)).mockResolvedValueOnce(success({ orderBusUuid: '123', orderPayUuid: '456', subTotal: 240 })) + vi.stubGlobal('fetch', fetchMock) + await adapter.createBooking!(selected, settings) + expect(JSON.parse(fetchMock.mock.calls[1]![1].body).fieldTimeInfos).toHaveLength(2) + await expect(adapter.createBooking!([selected[0]!, slots()[1]!], settings)).rejects.toThrow('同一场地') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + it('核价改变或网络失败不会锁场,也不残留不确定记录', async () => { + const { registry } = await runtime() + const fetchMock = vi.fn().mockResolvedValueOnce(success(fixture.result)).mockResolvedValueOnce(quote(130)) + vi.stubGlobal('fetch', fetchMock) + await expect(registry.createBooking(input())).rejects.toThrow('价格已变化') + expect(await registry.getPendingBooking(query.courtId)).toBeNull() + expect(fetchMock).toHaveBeenCalledTimes(2) + fetchMock.mockResolvedValueOnce(success(fixture.result)).mockRejectedValueOnce(new Error('network')) + await expect(registry.createBooking(input())).rejects.toThrow('网络') + expect(await registry.getPendingBooking(query.courtId)).toBeNull() + }) + it.each(['network', 'http500', 'malformed', 'business500'])('锁场 %s 保留不确定记录并禁止重试', async (failure) => { + const { registry } = await runtime() + const fetchMock = vi.fn().mockResolvedValueOnce(success(fixture.result)).mockResolvedValueOnce(quote()) + if (failure === 'network') fetchMock.mockRejectedValueOnce(new Error('network')) + if (failure === 'http500') fetchMock.mockResolvedValueOnce({ ok: false, status: 500 }) + if (failure === 'malformed') fetchMock.mockResolvedValueOnce(success({})) + if (failure === 'business500') fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ code: 500 }) }) + vi.stubGlobal('fetch', fetchMock) + await expect(registry.createBooking(input())).rejects.toThrow('结果无法确认') + expect((await registry.getPendingBooking(query.courtId))?.status).toBe('submitting-uncertain') + await expect(registry.createBooking(input())).rejects.toThrow('已有待支付') + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + it('无取消能力且锁场摘要落盘失败时,不伪称自动释放', async () => { + const { registry, pending } = await runtime() + const realSet = pending.set.bind(pending) + vi.spyOn(pending, 'set').mockImplementation(async (booking) => { + if (booking.status === 'pending-payment') throw new Error('disk failure') + return realSet(booking) + }) + const fetchMock = vi.fn().mockResolvedValueOnce(success(fixture.result)).mockResolvedValueOnce(quote()).mockResolvedValueOnce(order()) + vi.stubGlobal('fetch', fetchMock) + await expect(registry.createBooking(input())).rejects.toThrow('立即到小程序检查') + expect(await registry.getPendingBooking(query.courtId)).not.toBeNull() + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + it('拒绝新建需要凭证的后台监控', async () => { + const { registry } = 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([]) + }) + it('新凭证按球场持久化,旧球场 visitorId 保持兼容', async () => { + const { registry, store } = await runtime() + await store.update({ courtId: 'fansibote-fuzhong', visitorId: 'legacy-token' }) + await registry.updateCourtSettings({ courtId: query.courtId, credential: 'Wechat new.token.signature' }) + expect(await registry.getCourtSettings(query.courtId)).toMatchObject({ credentialConfigured: true, credential: 'Wechat new.token.signature' }) + expect(await registry.getCourtSettings('fansibote-fuzhong')).toMatchObject({ visitorId: 'legacy-token', visitorIdConfigured: true }) + }) +}) diff --git a/src/main/adapters/dawanqu/adapter.ts b/src/main/adapters/dawanqu/adapter.ts new file mode 100644 index 0000000..aa522ec --- /dev/null +++ b/src/main/adapters/dawanqu/adapter.ts @@ -0,0 +1,212 @@ +import type { AvailabilityDay, AvailabilityQuery, BookingSlot } from '../../../shared/contracts' +import type { CourtRuntimeSettings } from '../../settings/types' +import { isContinuousSingleCourtSequence, sortSlots } from '../../bookings/slotSequence' +import { ProviderMutationError, type CourtAdapter } from '../types' + +const COURT_ID = 'dawanqu' +const PROJECT_ID = '0ddda9c33d4e11f1b51c2273436a3e4e' +const AREA_ID = 'cec42d973dee11f1b51c2273436a3e4e' +const BASE = 'https://wlhmobile.crland.com.cn/business/client/field/area' +const UUID = /^[a-f0-9]{32}$/i +const FORMAT_ERROR = '大湾区接口格式已变化,请暂停预约并更新适配器' + +class BookingWindowClosedError extends Error {} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} +function uuid(value: unknown): string { + if (typeof value !== 'string' || !UUID.test(value)) throw new Error(FORMAT_ERROR) + return value +} +function cents(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || + !Number.isSafeInteger(Math.round(value * 100)) || Math.abs(value * 100 - Math.round(value * 100)) > 0.00001) { + throw new Error(FORMAT_ERROR) + } + return Math.round(value * 100) +} +function authorization(settings: CourtRuntimeSettings): string { + const raw = settings.credential?.trim().replace(/^Wechat\s+/i, '') + if (!raw || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(raw)) { + throw new Error('请在球场配置中填写有效的 Authorization(Wechat 登录凭证)') + } + return `Wechat ${raw}` +} +function validateQuery(query: AvailabilityQuery) { + if (query.courtId !== COURT_ID || !/^\d{4}-\d{2}-\d{2}$/.test(query.date) || + !Number.isFinite(Date.parse(`${query.date}T00:00:00Z`)) || + new Date(`${query.date}T00:00:00Z`).toISOString().slice(0, 10) !== query.date) { + throw new Error('球场或预约日期格式错误') + } +} + +async function request(path: string, body: unknown, settings: CourtRuntimeSettings, mutation = false): Promise { + const auth = authorization(settings) + let response: Response + try { + response = await fetch(`${BASE}/${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + appId: 'wx020209beec4251e0', + projectUuid: PROJECT_ID, + Authorization: auth + }, + body: JSON.stringify(body), + redirect: 'error', + signal: AbortSignal.timeout(12_000) + }) + } catch { + throw new ProviderMutationError('大湾区网络请求失败或超时,请稍后重试', mutation ? 'uncertain' : 'rejected') + } + const fail = (message: string, definite = false): never => { + throw new ProviderMutationError(message, mutation && !definite ? 'uncertain' : 'rejected') + } + if (response.status === 401 || response.status === 403) fail('大湾区登录已失效,请更新 Authorization', true) + if (response.status === 429) fail('大湾区接口限流,请稍后重试', true) + if (!response.ok) fail(`大湾区请求失败(HTTP ${response.status})`) + let payload: unknown + try { payload = await response.json() } catch { return fail(FORMAT_ERROR) } + if (!record(payload) || typeof payload.code !== 'number') return fail(FORMAT_ERROR) + if (payload.code === 401 || payload.code === 403) fail('大湾区登录已失效,请更新 Authorization', true) + if (payload.code === 429) fail('大湾区接口限流,请稍后重试', true) + // Unknown business errors are not proof that a mutation never happened. + if (path === 'matrix' && payload.code === 2000 && typeof payload.text === 'string') { + const window = /^当前最多可提前\s*(\d{1,3})\s*天预定场地$/.exec(payload.text.trim()) + if (window && Number(window[1]) > 0) { + throw new BookingWindowClosedError(`尚未开放预约:该球场最多可提前 ${Number(window[1])} 天预定,请在开放后刷新。`) + } + } + if (payload.code !== 200) fail('大湾区接口未确认成功,请到原小程序核实') + return payload.result +} + +export function parseMatrix(result: unknown, query: AvailabilityQuery): AvailabilityDay { + validateQuery(query) + if (!record(result) || !Array.isArray(result.fieldList) || !Array.isArray(result.matrix)) throw new Error(FORMAT_ERROR) + const names = new Map() + for (const field of result.fieldList) { + if (!record(field) || typeof field.fieldName !== 'string' || !field.fieldName.trim()) throw new Error(FORMAT_ERROR) + const id = uuid(field.fieldUuid) + if (names.has(id)) throw new Error(FORMAT_ERROR) + names.set(id, field.fieldName) + } + const slots: BookingSlot[] = [] + const ids = new Set() + const rows = new Set() + for (const row of result.matrix) { + if (!record(row) || !Array.isArray(row.matrix)) throw new Error(FORMAT_ERROR) + const fieldId = uuid(row.fieldUuid) + const name = names.get(fieldId) + if (!name || rows.has(fieldId)) throw new Error(FORMAT_ERROR) + rows.add(fieldId) + for (const cell of row.matrix) { + if (!record(cell) || cell.fieldUuid !== fieldId || typeof cell.isAbleReserve !== 'boolean') throw new Error(FORMAT_ERROR) + const timeId = uuid(cell.fieldTimeUuid) + const start = cell.startTime + const end = cell.endTime + if (typeof start !== 'string' || typeof end !== 'string' || + !/^\d{4}-\d{2}-\d{2} ([01]\d|2[0-3]):[0-5]\d:00$/.test(start) || + !/^\d{4}-\d{2}-\d{2} ([01]\d|2[0-3]):[0-5]\d:00$/.test(end) || + start.slice(0, 10) !== query.date || end.slice(0, 10) !== query.date || start >= end) throw new Error(FORMAT_ERROR) + const id = `${query.date}:${fieldId}:${timeId}` + if (ids.has(id)) throw new Error(FORMAT_ERROR) + ids.add(id) + // Availability, unavailability reasons and unsupported booking flows are separate. + const promotional = Boolean(cell.isSeckill || cell.isDiscountActivity || cell.discountSpecUuid) + const unsupported = promotional || Boolean(cell.originFieldTimeUuid || cell.isOriginReserved) + const available = cell.isAbleReserve + const message = typeof cell.msg === 'string' ? cell.msg.trim() : '' + const unavailableLabel = /场次已开始/.test(message) ? '已开始' + : /未开放|未开始|未到.*预[订定约]/.test(message) ? '尚未开放' + : message ? '暂不可订' : '已售' + slots.push({ + id, courtLabel: name, startTime: start.slice(11, 16), endTime: end.slice(11, 16), + priceCents: cell.price === null && !cell.isAbleReserve ? 0 : cents(cell.price), + priceKnown: cell.price !== null, + status: available ? 'available' : message ? 'blocked' : 'booked', + ...(!available ? { unavailableLabel } : {}), + ...(available && unsupported ? { + bookingUnavailableReason: promotional ? '优惠场次需在原小程序预约' : '此场次需在原小程序预约' + } : {}), + providerReference: { physicalCourtId: fieldId, beginDatetime: start, endDatetime: end }, + sourceLabel: '大湾区网球场' + }) + } + } + return { courtId: query.courtId, date: query.date, updatedAt: new Date().toISOString(), slots } +} + +async function getAuthenticatedAvailability(query: AvailabilityQuery, settings: CourtRuntimeSettings) { + validateQuery(query) + try { + return parseMatrix(await request('matrix', { + fieldAreaUuid: AREA_ID, reserveDate: query.date, enterpriseUuid: '', discountSpecUuid: '', projectUuid: PROJECT_ID + }, settings), query) + } catch (error) { + if (!(error instanceof BookingWindowClosedError)) throw error + return { courtId: query.courtId, date: query.date, updatedAt: new Date().toISOString(), slots: [], notice: error.message } + } +} + +export const dawanquAdapter: CourtAdapter = { + court: { + id: COURT_ID, name: '大湾区网球场', shortName: '大湾区', indoor: true, + accent: '#4776d0', status: 'online', credentialLabel: 'Authorization', + requiresAuthenticatedAvailability: true, supportsCancellation: false, + bookingNotice: '只锁场不支付。支付、取消和订单状态请到原小程序查看;本应用暂不支持远端取消。' + }, + defaultSettings: { courtId: COURT_ID }, + async getAvailability() { throw new Error('大湾区列表需要登录,暂不支持无凭证空场监控') }, + getAuthenticatedAvailability, + preflightWriteSettings: authorization, + async createBooking(selected, settings) { + let body: Record + let slots: BookingSlot[] + let total: number + try { + authorization(settings) + slots = sortSlots(selected) + if (!isContinuousSingleCourtSequence(slots)) throw new Error('请选择同一场地的完整连续时段') + const date = slots[0]!.providerReference?.beginDatetime.slice(0, 10) + if (!date) throw new Error(FORMAT_ERROR) + const fieldTimeInfos = slots.map((slot) => { + const parts = slot.id.split(':') + const ref = slot.providerReference + if (parts.length !== 3 || parts[0] !== date || !ref || parts[1] !== ref.physicalCourtId || + ref.beginDatetime.slice(0, 10) !== date || ref.endDatetime.slice(0, 10) !== date || + slot.status !== 'available' || slot.enrollment || slot.bookingUnavailableReason) throw new Error('目标时段已不可预约') + return { fieldUuid: uuid(parts[1]), fieldTimeUuid: uuid(parts[2]), wholeFieldUuid: uuid(parts[1]) } + }) + if (new Set(slots.map((slot) => slot.id)).size !== slots.length) throw new Error('时段重复') + total = slots.reduce((sum, slot) => sum + slot.priceCents, 0) + body = { reserveDate: date, fieldTimeInfos, enterpriseUuid: '', discountSpecUuid: '', couponAssignUuid: '', projectUuid: PROJECT_ID } + const quote = await request('reserve/price', body, settings) + if (!record(quote) || cents(quote.total) !== total || cents(quote.subTotal) !== total || + !Array.isArray(quote.orders) || quote.orders.length !== slots.length || + quote.orders.reduce((sum: number, order: unknown) => { + if (!record(order)) throw new Error(FORMAT_ERROR) + return sum + cents(order.total) + }, 0) !== total) throw new Error('价格已变化或核价格式异常,请刷新列表重新确认') + } catch (error) { + throw new ProviderMutationError(error instanceof Error ? error.message : '核价失败', 'rejected') + } + const result = await request('reserve', { payment: 'wxMini', ...body, checkReserveUuid: '' }, settings, true) + if (!record(result) || typeof result.orderBusUuid !== 'string' || !/^\d+$/.test(result.orderBusUuid) || + typeof result.orderPayUuid !== 'string' || !/^\d+$/.test(result.orderPayUuid)) { + throw new ProviderMutationError('锁场响应格式异常,请到原小程序核实订单', 'uncertain') + } + let amount: number + try { amount = cents(result.subTotal) } catch { throw new ProviderMutationError('锁场金额无法确认', 'uncertain') } + if (amount !== total) throw new ProviderMutationError('锁场返回金额与核价不一致,请到原小程序核实', 'uncertain') + return { + booking: { + courtId: COURT_ID, orderId: result.orderBusUuid, venueAppointmentIds: [], amountCents: amount, + courtLabel: slots[0]!.courtLabel, startTime: slots[0]!.startTime, endTime: slots.at(-1)!.endTime, + status: 'pending-payment' + }, + paymentScript: null + } + } +} diff --git a/src/main/adapters/dawanqu/matrix-dates.fixture.json b/src/main/adapters/dawanqu/matrix-dates.fixture.json new file mode 100644 index 0000000..e6039b0 --- /dev/null +++ b/src/main/adapters/dawanqu/matrix-dates.fixture.json @@ -0,0 +1,368 @@ +{ + "2026-09-23": { + "fieldList": [ + { + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldName": "网球1号场" + } + ], + "matrix": [ + { + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "matrix": [ + { + "isAbleReserve": false, + "msg": "场次已开始,请合理安排时间", + "isSeckill": false, + "isDiscountActivity": false, + "discountSpecUuid": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": null, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86da62b6760c11f19bcb922ac365dbe6", + "startTime": "2026-09-23 09:00:00", + "endTime": "2026-09-23 10:00:00" + }, + { + "isAbleReserve": false, + "msg": "场次已开始,请合理安排时间", + "isSeckill": false, + "isDiscountActivity": false, + "discountSpecUuid": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": null, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86db2608760c11f19bcb922ac365dbe6", + "startTime": "2026-09-23 10:00:00", + "endTime": "2026-09-23 11:00:00" + }, + { + "isAbleReserve": false, + "msg": "场次已开始,请合理安排时间", + "isSeckill": false, + "isDiscountActivity": false, + "discountSpecUuid": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": null, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86dbc24a760c11f19bcb922ac365dbe6", + "startTime": "2026-09-23 11:00:00", + "endTime": "2026-09-23 12:00:00" + }, + { + "isAbleReserve": false, + "msg": "场次已开始,请合理安排时间", + "isSeckill": false, + "isDiscountActivity": false, + "discountSpecUuid": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": null, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86dcacac760c11f19bcb922ac365dbe6", + "startTime": "2026-09-23 12:00:00", + "endTime": "2026-09-23 13:00:00" + }, + { + "isAbleReserve": false, + "msg": "场次已开始,请合理安排时间", + "isSeckill": false, + "isDiscountActivity": false, + "discountSpecUuid": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": null, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86dde52e760c11f19bcb922ac365dbe6", + "startTime": "2026-09-23 13:00:00", + "endTime": "2026-09-23 14:00:00" + }, + { + "isAbleReserve": false, + "msg": "场次已开始,请合理安排时间", + "isSeckill": false, + "isDiscountActivity": false, + "discountSpecUuid": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": null, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86de8170760c11f19bcb922ac365dbe6", + "startTime": "2026-09-23 14:00:00", + "endTime": "2026-09-23 15:00:00" + }, + { + "isAbleReserve": false, + "msg": "场次已开始,请合理安排时间", + "isSeckill": false, + "isDiscountActivity": false, + "discountSpecUuid": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": null, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86df44c2760c11f19bcb922ac365dbe6", + "startTime": "2026-09-23 15:00:00", + "endTime": "2026-09-23 16:00:00" + }, + { + "isAbleReserve": false, + "msg": "场次已开始,请合理安排时间", + "isSeckill": false, + "isDiscountActivity": false, + "discountSpecUuid": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": null, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86dfe104760c11f19bcb922ac365dbe6", + "startTime": "2026-09-23 16:00:00", + "endTime": "2026-09-23 17:00:00" + }, + { + "isAbleReserve": false, + "msg": "场次已开始,请合理安排时间", + "isSeckill": false, + "isDiscountActivity": false, + "discountSpecUuid": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": null, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86e0a456760c11f19bcb922ac365dbe6", + "startTime": "2026-09-23 17:00:00", + "endTime": "2026-09-23 18:00:00" + }, + { + "isAbleReserve": false, + "msg": "场次已开始,请合理安排时间", + "isSeckill": false, + "isDiscountActivity": false, + "discountSpecUuid": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": null, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86e14098760c11f19bcb922ac365dbe6", + "startTime": "2026-09-23 18:00:00", + "endTime": "2026-09-23 19:00:00" + }, + { + "isAbleReserve": false, + "msg": "场次已开始,请合理安排时间", + "isSeckill": false, + "isDiscountActivity": false, + "discountSpecUuid": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": null, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86e22afa760c11f19bcb922ac365dbe6", + "startTime": "2026-09-23 19:00:00", + "endTime": "2026-09-23 20:00:00" + }, + { + "isAbleReserve": false, + "msg": null, + "isSeckill": false, + "isDiscountActivity": false, + "discountSpecUuid": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": null, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86e2c73c760c11f19bcb922ac365dbe6", + "startTime": "2026-09-23 20:00:00", + "endTime": "2026-09-23 21:00:00" + } + ] + } + ] + }, + "2026-09-25": { + "fieldList": [ + { + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldName": "网球1号场" + } + ], + "matrix": [ + { + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "matrix": [ + { + "isAbleReserve": false, + "msg": null, + "isSeckill": true, + "isDiscountActivity": false, + "discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43", + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": 70.0, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86da62b6760c11f19bcb922ac365dbe6", + "startTime": "2026-09-25 09:00:00", + "endTime": "2026-09-25 10:00:00" + }, + { + "isAbleReserve": false, + "msg": null, + "isSeckill": true, + "isDiscountActivity": false, + "discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43", + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": 70.0, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86db2608760c11f19bcb922ac365dbe6", + "startTime": "2026-09-25 10:00:00", + "endTime": "2026-09-25 11:00:00" + }, + { + "isAbleReserve": false, + "msg": null, + "isSeckill": true, + "isDiscountActivity": false, + "discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43", + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": 70.0, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86dbc24a760c11f19bcb922ac365dbe6", + "startTime": "2026-09-25 11:00:00", + "endTime": "2026-09-25 12:00:00" + }, + { + "isAbleReserve": false, + "msg": null, + "isSeckill": true, + "isDiscountActivity": false, + "discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43", + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": 70.0, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86dcacac760c11f19bcb922ac365dbe6", + "startTime": "2026-09-25 12:00:00", + "endTime": "2026-09-25 13:00:00" + }, + { + "isAbleReserve": false, + "msg": null, + "isSeckill": true, + "isDiscountActivity": false, + "discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43", + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": 70.0, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86dde52e760c11f19bcb922ac365dbe6", + "startTime": "2026-09-25 13:00:00", + "endTime": "2026-09-25 14:00:00" + }, + { + "isAbleReserve": false, + "msg": null, + "isSeckill": true, + "isDiscountActivity": false, + "discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43", + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": 70.0, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86de8170760c11f19bcb922ac365dbe6", + "startTime": "2026-09-25 14:00:00", + "endTime": "2026-09-25 15:00:00" + }, + { + "isAbleReserve": false, + "msg": null, + "isSeckill": true, + "isDiscountActivity": false, + "discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43", + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": 70.0, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86df44c2760c11f19bcb922ac365dbe6", + "startTime": "2026-09-25 15:00:00", + "endTime": "2026-09-25 16:00:00" + }, + { + "isAbleReserve": false, + "msg": null, + "isSeckill": true, + "isDiscountActivity": false, + "discountSpecUuid": "70111563b62c11f19fe83a9fa704cf43", + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": 70.0, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86dfe104760c11f19bcb922ac365dbe6", + "startTime": "2026-09-25 16:00:00", + "endTime": "2026-09-25 17:00:00" + }, + { + "isAbleReserve": false, + "msg": null, + "isSeckill": true, + "isDiscountActivity": false, + "discountSpecUuid": "70116384b62c11f19fe83a9fa704cf43", + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": 70.0, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86e0a456760c11f19bcb922ac365dbe6", + "startTime": "2026-09-25 17:00:00", + "endTime": "2026-09-25 18:00:00" + }, + { + "isAbleReserve": false, + "msg": null, + "isSeckill": true, + "isDiscountActivity": false, + "discountSpecUuid": "70116384b62c11f19fe83a9fa704cf43", + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": 70.0, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86e14098760c11f19bcb922ac365dbe6", + "startTime": "2026-09-25 18:00:00", + "endTime": "2026-09-25 19:00:00" + }, + { + "isAbleReserve": false, + "msg": null, + "isSeckill": true, + "isDiscountActivity": false, + "discountSpecUuid": "70116384b62c11f19fe83a9fa704cf43", + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": 70.0, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86e22afa760c11f19bcb922ac365dbe6", + "startTime": "2026-09-25 19:00:00", + "endTime": "2026-09-25 20:00:00" + }, + { + "isAbleReserve": false, + "msg": null, + "isSeckill": true, + "isDiscountActivity": false, + "discountSpecUuid": "70116384b62c11f19fe83a9fa704cf43", + "originFieldTimeUuid": null, + "isOriginReserved": null, + "price": 70.0, + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldTimeUuid": "86e2c73c760c11f19bcb922ac365dbe6", + "startTime": "2026-09-25 20:00:00", + "endTime": "2026-09-25 21:00:00" + } + ] + } + ] + } +} diff --git a/src/main/adapters/dawanqu/matrix.fixture.json b/src/main/adapters/dawanqu/matrix.fixture.json new file mode 100644 index 0000000..1d3bf93 --- /dev/null +++ b/src/main/adapters/dawanqu/matrix.fixture.json @@ -0,0 +1,1020 @@ +{ + "code": 200, + "text": "success", + "result": { + "fieldTimeList": [ + { + "fieldTimeUuid": "86da62b6760c11f19bcb922ac365dbe6", + "fieldTimeStartTime": 540, + "fieldTimeEndTime": 600, + "startTime": "09:00", + "endTime": "10:00", + "colspan": 2, + "originFieldTimeUuid": null + }, + { + "fieldTimeUuid": "86db2608760c11f19bcb922ac365dbe6", + "fieldTimeStartTime": 600, + "fieldTimeEndTime": 660, + "startTime": "10:00", + "endTime": "11:00", + "colspan": 2, + "originFieldTimeUuid": null + }, + { + "fieldTimeUuid": "86dbc24a760c11f19bcb922ac365dbe6", + "fieldTimeStartTime": 660, + "fieldTimeEndTime": 720, + "startTime": "11:00", + "endTime": "12:00", + "colspan": 2, + "originFieldTimeUuid": null + }, + { + "fieldTimeUuid": "86dcacac760c11f19bcb922ac365dbe6", + "fieldTimeStartTime": 720, + "fieldTimeEndTime": 780, + "startTime": "12:00", + "endTime": "13:00", + "colspan": 2, + "originFieldTimeUuid": null + }, + { + "fieldTimeUuid": "86dde52e760c11f19bcb922ac365dbe6", + "fieldTimeStartTime": 780, + "fieldTimeEndTime": 840, + "startTime": "13:00", + "endTime": "14:00", + "colspan": 2, + "originFieldTimeUuid": null + }, + { + "fieldTimeUuid": "86de8170760c11f19bcb922ac365dbe6", + "fieldTimeStartTime": 840, + "fieldTimeEndTime": 900, + "startTime": "14:00", + "endTime": "15:00", + "colspan": 2, + "originFieldTimeUuid": null + }, + { + "fieldTimeUuid": "86df44c2760c11f19bcb922ac365dbe6", + "fieldTimeStartTime": 900, + "fieldTimeEndTime": 960, + "startTime": "15:00", + "endTime": "16:00", + "colspan": 2, + "originFieldTimeUuid": null + }, + { + "fieldTimeUuid": "86dfe104760c11f19bcb922ac365dbe6", + "fieldTimeStartTime": 960, + "fieldTimeEndTime": 1020, + "startTime": "16:00", + "endTime": "17:00", + "colspan": 2, + "originFieldTimeUuid": null + }, + { + "fieldTimeUuid": "86e0a456760c11f19bcb922ac365dbe6", + "fieldTimeStartTime": 1020, + "fieldTimeEndTime": 1080, + "startTime": "17:00", + "endTime": "18:00", + "colspan": 2, + "originFieldTimeUuid": null + }, + { + "fieldTimeUuid": "86e14098760c11f19bcb922ac365dbe6", + "fieldTimeStartTime": 1080, + "fieldTimeEndTime": 1140, + "startTime": "18:00", + "endTime": "19:00", + "colspan": 2, + "originFieldTimeUuid": null + }, + { + "fieldTimeUuid": "86e22afa760c11f19bcb922ac365dbe6", + "fieldTimeStartTime": 1140, + "fieldTimeEndTime": 1200, + "startTime": "19:00", + "endTime": "20:00", + "colspan": 2, + "originFieldTimeUuid": null + }, + { + "fieldTimeUuid": "86e2c73c760c11f19bcb922ac365dbe6", + "fieldTimeStartTime": 1200, + "fieldTimeEndTime": 1260, + "startTime": "20:00", + "endTime": "21:00", + "colspan": 2, + "originFieldTimeUuid": null + } + ], + "fieldList": [ + { + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "fieldName": "网球1号场" + }, + { + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "fieldName": "网球2号场" + }, + { + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "fieldName": "网球3号场" + }, + { + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "fieldName": "网球4号场" + } + ], + "matrix": [ + { + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "matrix": [ + { + "fieldTimeUuid": "86da62b6760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 09:00:00", + "endTime": "2026-09-24 10:00:00", + "isAbleReserve": true, + "colspan": 2, + "msg": null, + "price": 120.0, + "originalPrice": 120.0, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86db2608760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 10:00:00", + "endTime": "2026-09-24 11:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dbc24a760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 11:00:00", + "endTime": "2026-09-24 12:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dcacac760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 12:00:00", + "endTime": "2026-09-24 13:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dde52e760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 13:00:00", + "endTime": "2026-09-24 14:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86de8170760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 14:00:00", + "endTime": "2026-09-24 15:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86df44c2760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 15:00:00", + "endTime": "2026-09-24 16:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dfe104760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 16:00:00", + "endTime": "2026-09-24 17:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e0a456760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 17:00:00", + "endTime": "2026-09-24 18:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e14098760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 18:00:00", + "endTime": "2026-09-24 19:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e22afa760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 19:00:00", + "endTime": "2026-09-24 20:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e2c73c760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2068ee3def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 20:00:00", + "endTime": "2026-09-24 21:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + } + ] + }, + { + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "matrix": [ + { + "fieldTimeUuid": "86da62b6760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 09:00:00", + "endTime": "2026-09-24 10:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86db2608760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 10:00:00", + "endTime": "2026-09-24 11:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dbc24a760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 11:00:00", + "endTime": "2026-09-24 12:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dcacac760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 12:00:00", + "endTime": "2026-09-24 13:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dde52e760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 13:00:00", + "endTime": "2026-09-24 14:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86de8170760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 14:00:00", + "endTime": "2026-09-24 15:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86df44c2760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 15:00:00", + "endTime": "2026-09-24 16:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dfe104760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 16:00:00", + "endTime": "2026-09-24 17:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e0a456760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 17:00:00", + "endTime": "2026-09-24 18:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e14098760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 18:00:00", + "endTime": "2026-09-24 19:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e22afa760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 19:00:00", + "endTime": "2026-09-24 20:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e2c73c760c11f19bcb922ac365dbe6", + "fieldUuid": "8f2301003def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 20:00:00", + "endTime": "2026-09-24 21:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + } + ] + }, + { + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "matrix": [ + { + "fieldTimeUuid": "86da62b6760c11f19bcb922ac365dbe6", + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 09:00:00", + "endTime": "2026-09-24 10:00:00", + "isAbleReserve": true, + "colspan": 2, + "msg": null, + "price": 120.0, + "originalPrice": 120.0, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86db2608760c11f19bcb922ac365dbe6", + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 10:00:00", + "endTime": "2026-09-24 11:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dbc24a760c11f19bcb922ac365dbe6", + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 11:00:00", + "endTime": "2026-09-24 12:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dcacac760c11f19bcb922ac365dbe6", + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 12:00:00", + "endTime": "2026-09-24 13:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dde52e760c11f19bcb922ac365dbe6", + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 13:00:00", + "endTime": "2026-09-24 14:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86de8170760c11f19bcb922ac365dbe6", + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 14:00:00", + "endTime": "2026-09-24 15:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86df44c2760c11f19bcb922ac365dbe6", + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 15:00:00", + "endTime": "2026-09-24 16:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dfe104760c11f19bcb922ac365dbe6", + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 16:00:00", + "endTime": "2026-09-24 17:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e0a456760c11f19bcb922ac365dbe6", + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 17:00:00", + "endTime": "2026-09-24 18:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e14098760c11f19bcb922ac365dbe6", + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 18:00:00", + "endTime": "2026-09-24 19:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e22afa760c11f19bcb922ac365dbe6", + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 19:00:00", + "endTime": "2026-09-24 20:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e2c73c760c11f19bcb922ac365dbe6", + "fieldUuid": "8f23eb623def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 20:00:00", + "endTime": "2026-09-24 21:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + } + ] + }, + { + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "matrix": [ + { + "fieldTimeUuid": "86da62b6760c11f19bcb922ac365dbe6", + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 09:00:00", + "endTime": "2026-09-24 10:00:00", + "isAbleReserve": true, + "colspan": 2, + "msg": null, + "price": 120.0, + "originalPrice": 120.0, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86db2608760c11f19bcb922ac365dbe6", + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 10:00:00", + "endTime": "2026-09-24 11:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dbc24a760c11f19bcb922ac365dbe6", + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 11:00:00", + "endTime": "2026-09-24 12:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dcacac760c11f19bcb922ac365dbe6", + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 12:00:00", + "endTime": "2026-09-24 13:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dde52e760c11f19bcb922ac365dbe6", + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 13:00:00", + "endTime": "2026-09-24 14:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86de8170760c11f19bcb922ac365dbe6", + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 14:00:00", + "endTime": "2026-09-24 15:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86df44c2760c11f19bcb922ac365dbe6", + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 15:00:00", + "endTime": "2026-09-24 16:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86dfe104760c11f19bcb922ac365dbe6", + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 16:00:00", + "endTime": "2026-09-24 17:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e0a456760c11f19bcb922ac365dbe6", + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 17:00:00", + "endTime": "2026-09-24 18:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e14098760c11f19bcb922ac365dbe6", + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 18:00:00", + "endTime": "2026-09-24 19:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e22afa760c11f19bcb922ac365dbe6", + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 19:00:00", + "endTime": "2026-09-24 20:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + }, + { + "fieldTimeUuid": "86e2c73c760c11f19bcb922ac365dbe6", + "fieldUuid": "8f24d5c43def11f1ba41b2d27276c64b", + "startTime": "2026-09-24 20:00:00", + "endTime": "2026-09-24 21:00:00", + "isAbleReserve": false, + "colspan": 2, + "msg": null, + "price": null, + "originalPrice": null, + "originFieldTimeUuid": null, + "isOriginReserved": null, + "isSeckill": false, + "seckillPrice": null, + "discountSpecUuid": null, + "isDiscountActivity": false, + "discountActivityUuid": null + } + ] + } + ] + } +} diff --git a/src/main/adapters/fansibote-fuzhong/adapter.ts b/src/main/adapters/fansibote-fuzhong/adapter.ts index 1eb73c6..06932a3 100644 --- a/src/main/adapters/fansibote-fuzhong/adapter.ts +++ b/src/main/adapters/fansibote-fuzhong/adapter.ts @@ -64,7 +64,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -const SENSITIVE_LOG_KEY = /visitor|paysign|prepay|nonce|package|token|cookie|authorization|customer|phone|tel/i +const SENSITIVE_LOG_KEY = /visitor|paysign|prepay|nonce|package|token|cookie|authorization|customer|phone|tel|pspl/i function redactForLog(value: unknown, key = ''): unknown { if (SENSITIVE_LOG_KEY.test(key)) return '[REDACTED]' @@ -361,7 +361,7 @@ export function buildCreateBookingRequestBody( input: BookingSlot | BookingSlot[] ) { const slots = sortSlots(Array.isArray(input) ? input : [input]) - if (slots.length === 0 || slots.some((slot) => !slot.providerReference)) { + if (slots.length === 0 || slots.some((slot) => !slot.providerReference?.classroomUid)) { throw new Error('时段缺少下单引用信息') } if (!isContinuousSingleCourtSequence(slots)) { diff --git a/src/main/adapters/registry.ts b/src/main/adapters/registry.ts index 79e2b78..42e1ea7 100644 --- a/src/main/adapters/registry.ts +++ b/src/main/adapters/registry.ts @@ -6,6 +6,7 @@ import type { } from '../../shared/contracts' import type { CourtSettingsStore } from '../settings/CourtSettingsStore' import type { PendingBookingStore } from '../bookings/PendingBookingStore' +import { dawanquAdapter } from './dawanqu/adapter' import { fansiboteFuzhongAdapter } from './fansibote-fuzhong/adapter' import { ProviderMutationError, type CourtAdapter } from './types' import { @@ -14,7 +15,7 @@ import { sortSlots } from '../bookings/slotSequence' -const adapters: CourtAdapter[] = [fansiboteFuzhongAdapter] +const adapters: CourtAdapter[] = [fansiboteFuzhongAdapter, dawanquAdapter] interface ParsedCreateBookingInput extends Omit { slotIds: string[] @@ -39,8 +40,12 @@ export class AdapterRegistry { return [...this.adapters.values()].map((adapter) => adapter.court) } - async getAvailability(query: AvailabilityQuery) { + async getAvailability(query: AvailabilityQuery, anonymousOnly = false) { const adapter = this.getAdapter(query.courtId) + if (adapter.getAuthenticatedAvailability && !anonymousOnly) { + const settings = await this.settingsStore.get(adapter.court.id, adapter.defaultSettings) + return adapter.getAuthenticatedAvailability(query, settings) + } return adapter.getAvailability(query) } @@ -49,6 +54,7 @@ export class AdapterRegistry { const settings = await this.settingsStore.get(courtId, adapter.defaultSettings) return { courtId, + ...(adapter.court.credentialLabel ? { credential: settings.credential, credentialConfigured: Boolean(settings.credential) } : {}), visitorId: settings.visitorId, visitorIdConfigured: Boolean(settings.visitorId) } @@ -58,12 +64,14 @@ export class AdapterRegistry { if ( !isRecord(input) || typeof input.courtId !== 'string' || - (input.visitorId !== undefined && typeof input.visitorId !== 'string') + (input.visitorId !== undefined && typeof input.visitorId !== 'string') || + (input.credential !== undefined && typeof input.credential !== 'string') ) { throw new Error('球场配置参数格式错误') } const parsedInput: UpdateCourtSettingsInput = { courtId: input.courtId, + credential: input.credential, visitorId: input.visitorId } this.getAdapter(parsedInput.courtId) @@ -80,12 +88,17 @@ export class AdapterRegistry { if (parsedInput.visitorId !== undefined && !parsedInput.visitorId.trim()) { throw new Error('PSPLVISITORID 不能为空') } + const credential = parsedInput.credential?.trim() ?? current.credential + if (parsedInput.credential !== undefined && !credential) throw new Error('登录凭证不能为空') + if (adapter.court.credentialLabel) adapter.preflightWriteSettings?.({ courtId: parsedInput.courtId, credential }) const saved = await this.settingsStore.update({ courtId: parsedInput.courtId, - visitorId + visitorId, + credential }) return { courtId: saved.courtId, + ...(adapter.court.credentialLabel ? { credential: saved.credential, credentialConfigured: Boolean(saved.credential) } : {}), visitorId: saved.visitorId, visitorIdConfigured: Boolean(saved.visitorId) } @@ -106,6 +119,18 @@ export class AdapterRegistry { ) } + async acknowledgeBookingHandled(input: unknown) { + if (!isRecord(input) || input.confirmed !== true) throw new Error('请先确认已在原小程序处理订单') + const parsed = this.parseCancelBookingInput(input) + return this.runExclusive(parsed.courtId, async () => { + const adapter = this.getAdapter(parsed.courtId) + if (adapter.cancelBooking) throw new Error('当前球场请使用取消释放流程') + const pending = await this.pendingBookingStore.get(parsed.courtId) + if (!pending || pending.orderId !== parsed.orderId) throw new Error('待处理订单已变化,请刷新') + await this.pendingBookingStore.clear(parsed.courtId) + }) + } + async getPendingBooking(courtId: string) { this.getAdapter(courtId) const pending = await this.pendingBookingStore.get(courtId) @@ -127,7 +152,7 @@ export class AdapterRegistry { adapter.court.id, adapter.defaultSettings ) - const availability = await adapter.getAvailability({ + const availability = await this.getAvailability({ courtId: parsedInput.courtId, date: parsedInput.date }) @@ -141,6 +166,9 @@ export class AdapterRegistry { if (slots.some((slot) => slot.status !== 'available' && slot.status !== 'limited')) { throw new Error('目标连续时段已不可预约,请刷新列表') } + if (slots.some((slot) => slot.bookingUnavailableReason)) { + throw new Error('该场次暂不支持应用内预约,请到原小程序处理') + } if (slots.some((slot) => slot.enrollment)) { throw new Error('活动时段不能通过普通场地接口下单') } @@ -192,18 +220,21 @@ export class AdapterRegistry { throw error } throw new Error( - `下单结果无法确认,已保留恢复记录;请刷新列表后使用“尝试释放”。${error instanceof Error ? ` 原因:${error.message}` : ''}` + `下单结果无法确认,已保留恢复记录;请到原小程序核实订单。${error instanceof Error ? ` 原因:${error.message}` : ''}` ) } try { await this.pendingBookingStore.set(result.booking) } catch { + if (!adapter.cancelBooking) { + throw new Error('锁场成功但本机记录失败,当前球场不支持自动释放,请立即到小程序检查订单') + } try { await this.pendingBookingStore.set({ ...uncertainBooking, status: 'release-uncertain' }) - await adapter.cancelBooking?.(settings) + await adapter.cancelBooking(settings) this.releaseConfirmedCourts.add(parsedInput.courtId) await this.pendingBookingStore.set({ ...uncertainBooking, diff --git a/src/main/adapters/types.ts b/src/main/adapters/types.ts index 8f6732d..6b10718 100644 --- a/src/main/adapters/types.ts +++ b/src/main/adapters/types.ts @@ -26,6 +26,7 @@ export interface CourtAdapter { readonly court: CourtSummary readonly defaultSettings: CourtRuntimeSettings getAvailability(query: AvailabilityQuery): Promise + getAuthenticatedAvailability?(query: AvailabilityQuery, settings: CourtRuntimeSettings): Promise preflightWriteSettings?(settings: CourtRuntimeSettings): void createBooking?( slots: BookingSlot[], diff --git a/src/main/bookings/slotSequence.ts b/src/main/bookings/slotSequence.ts index 6c39d39..cb2ca2a 100644 --- a/src/main/bookings/slotSequence.ts +++ b/src/main/bookings/slotSequence.ts @@ -11,7 +11,7 @@ export function slotEndExclusiveMinutes(slot: Pick) { } export function physicalCourtKey(slot: BookingSlot) { - return slot.providerReference?.classroomUid ?? slot.courtLabel + return slot.providerReference?.physicalCourtId ?? slot.providerReference?.classroomUid ?? slot.courtLabel } export function sortSlots(slots: BookingSlot[]) { diff --git a/src/main/index.ts b/src/main/index.ts index 7a2918d..748b115 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -139,6 +139,9 @@ app.whenReady().then(() => { } }) + ipcMain.handle(IPC_CHANNELS.acknowledgeBookingHandled, (_event, input: unknown) => + registry.acknowledgeBookingHandled(input) + ) ipcMain.handle(IPC_CHANNELS.listCourts, () => registry.listCourts()) ipcMain.handle( IPC_CHANNELS.getAvailability, diff --git a/src/main/monitors/MonitorService.test.ts b/src/main/monitors/MonitorService.test.ts index 98f903a..9bab3ea 100644 --- a/src/main/monitors/MonitorService.test.ts +++ b/src/main/monitors/MonitorService.test.ts @@ -565,6 +565,7 @@ describe('MonitorService', () => { it('19至21点同一场连续两小时才算一个空场并整组自动下单', async () => { vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z')) const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-complete-range-')) const store = new MonitorTaskStore(join(directory, 'tasks.json')) await store.add({ @@ -613,6 +614,7 @@ describe('MonitorService', () => { it('旧版已记录的两个单小时升级后迁移为组合去重且不重复提醒下单', async () => { vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z')) const directory = await mkdtemp(join(tmpdir(), 'tennis-book-monitor-sequence-migration-')) const store = new MonitorTaskStore(join(directory, 'tasks.json')) await store.add({ diff --git a/src/main/monitors/MonitorService.ts b/src/main/monitors/MonitorService.ts index ebe424a..6bb333b 100644 --- a/src/main/monitors/MonitorService.ts +++ b/src/main/monitors/MonitorService.ts @@ -174,7 +174,8 @@ export class MonitorService { constructor( private readonly registry: AdapterRegistry, private readonly store: MonitorTaskStore, - private readonly onAlert: (alert: MonitorAlert) => void + private readonly onAlert: (alert: MonitorAlert) => void, + private readonly schedulingEnabled = true ) {} async start() { @@ -255,6 +256,9 @@ 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('此球场列表需要登录,暂不支持无凭证空场监控') + } if (parsed.autoBookingEnabled) await this.assertAutoBookingCanBeEnabled(parsed.courtId) const createdAt = new Date().toISOString() const telemetry = createMonitorTelemetry(createdAt) @@ -505,7 +509,7 @@ export class MonitorService { } private scheduleTick() { - if (this.stopping) return + if (!this.schedulingEnabled || this.stopping) return if (this.scheduledTickPromise) return const operation = this.tick() .then(() => { @@ -570,7 +574,7 @@ export class MonitorService { const key = `${courtId}:${date}` const current = availabilityRequests.get(key) if (current) return current - const request = this.registry.getAvailability({ courtId, date }) + const request = this.registry.getAvailability({ courtId, date }, true) availabilityRequests.set(key, request) return request } diff --git a/src/main/settings/CourtSettingsStore.ts b/src/main/settings/CourtSettingsStore.ts index 6ada4e3..45d3a30 100644 --- a/src/main/settings/CourtSettingsStore.ts +++ b/src/main/settings/CourtSettingsStore.ts @@ -4,7 +4,7 @@ import type { CourtRuntimeSettings } from './types' interface SettingsFile { version: 1 - courts: Record + courts: Record } function isRecord(value: unknown): value is Record { @@ -20,11 +20,13 @@ function parseSettingsFile(value: unknown): SettingsFile { for (const [courtId, settings] of Object.entries(value.courts)) { if ( !isRecord(settings) || - (settings.visitorId !== undefined && typeof settings.visitorId !== 'string') + (settings.visitorId !== undefined && typeof settings.visitorId !== 'string') || + (settings.credential !== undefined && typeof settings.credential !== 'string') ) { throw new Error(`球场 ${courtId} 的本机配置格式错误`) } courts[courtId] = { + credential: settings.credential, visitorId: settings.visitorId } } @@ -44,6 +46,7 @@ export class CourtSettingsStore { const file = await this.read() return { courtId, + credential: file.courts[courtId]?.credential ?? defaults.credential, visitorId: file.courts[courtId]?.visitorId ?? defaults.visitorId } } @@ -52,6 +55,7 @@ export class CourtSettingsStore { const operation = this.writeQueue.then(async () => { const file = await this.read() file.courts[settings.courtId] = { + credential: settings.credential, visitorId: settings.visitorId } await mkdir(dirname(this.filePath), { recursive: true }) diff --git a/src/main/settings/types.ts b/src/main/settings/types.ts index a67f2fe..84ebb40 100644 --- a/src/main/settings/types.ts +++ b/src/main/settings/types.ts @@ -1,4 +1,5 @@ export interface CourtRuntimeSettings { courtId: string visitorId?: string + credential?: string } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7c04185..18625a6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -21,6 +21,8 @@ const api: TennisBookApi = { ipcRenderer.invoke(IPC_CHANNELS.createBooking, input), cancelBooking: (input: CancelBookingInput) => ipcRenderer.invoke(IPC_CHANNELS.cancelBooking, input), + acknowledgeBookingHandled: (input) => + ipcRenderer.invoke(IPC_CHANNELS.acknowledgeBookingHandled, input), getPendingBooking: (courtId: string) => ipcRenderer.invoke(IPC_CHANNELS.getPendingBooking, courtId), listMonitorTasks: () => ipcRenderer.invoke(IPC_CHANNELS.listMonitorTasks), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 00d70f7..7de1f86 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -149,7 +149,8 @@ function SlotCell({ return ( + >{selectedCourt?.requiresAuthenticatedAvailability ? '此球场列表需要登录' : '每 10 秒检查空场'}
{selectedCourt?.requiresAuthenticatedAvailability ? '暂不支持无凭证空场监控' : '点击创建第一个任务'} ) : (
{monitorTasks.map((task) => ( @@ -872,11 +877,12 @@ export function App() {
) : null} + {availability?.notice ?
{availability.notice}
: null} {loading ? (
{Array.from({ length: 28 }, (_, index) => )}
- ) : ( + ) : availability?.notice ? null : (
{pendingBooking.startTime}–{pendingBooking.endTime} · {pendingBooking.courtLabel} - {pendingBooking.status === 'submitting-uncertain' + {selectedCourt?.supportsCancellation === false + ? `订单 ${pendingBooking.status === 'submitting-uncertain' ? '结果待核实' : pendingBooking.orderId} · 请到原小程序支付、取消或核实状态` + : pendingBooking.status === 'submitting-uncertain' ? '网络响应不确定,请刷新列表后尝试释放该访客凭证下的锁场' : pendingBooking.status === 'release-uncertain' ? '请先到小程序核实是否已释放,再清理本机记录;系统不会自动重试' - : `订单 ${pendingBooking.orderId} · 支付有效期至 ${pendingBooking.expiresAt}`} + : `订单 ${pendingBooking.orderId}${pendingBooking.expiresAt ? ` · 支付有效期至 ${pendingBooking.expiresAt}` : ' · 请到原小程序处理'}`}
{priceFormatter.format(pendingBooking.amountCents / 100)} @@ -931,7 +939,7 @@ export function App() { disabled={bookingBusy} onClick={() => setCancelConfirmOpen(true)} type="button" - >{pendingBooking.status === 'submitting-uncertain' + >{selectedCourt?.supportsCancellation === false ? '已处理,清理记录' : pendingBooking.status === 'submitting-uncertain' ? '尝试释放' : pendingBooking.status === 'release-uncertain' ? '核实后清理' @@ -1140,9 +1148,9 @@ export function App() { ) : (
diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index e47050b..bb1c104 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -552,3 +552,13 @@ button { color: inherit; } .date-strip > button { min-width: 68px; padding: 0 9px; } .schedule-grid { grid-template-columns: 68px repeat(var(--court-columns), minmax(150px, 1fr)); } } + +.availability-notice { + display: flex; + align-items: center; + gap: 12px; + padding: 24px 16px; + color: var(--text-secondary, #66756b); + line-height: 1.6; +} +.availability-notice svg { flex-shrink: 0; } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 6917fa0..7395507 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -9,6 +9,10 @@ export interface CourtSummary { distanceKm?: number surface?: string indoor?: boolean + credentialLabel?: string + requiresAuthenticatedAvailability?: boolean + supportsCancellation?: boolean + bookingNotice?: string accent: string status: CourtStatus } @@ -38,12 +42,15 @@ export interface BookingSlot { startTime: string endTime: string priceCents: number + priceKnown?: boolean + bookingUnavailableReason?: string status: SlotStatus unavailableLabel?: string bookedBy?: BookingContact[] enrollment?: EnrollmentActivity providerReference?: { - classroomUid: string + classroomUid?: string + physicalCourtId?: string beginDatetime: string endDatetime: string } @@ -51,6 +58,7 @@ export interface BookingSlot { } export interface AvailabilityDay { + notice?: string courtId: string date: string updatedAt: string @@ -63,12 +71,15 @@ export interface AvailabilityQuery { } export interface CourtSettings { + credential?: string + credentialConfigured?: boolean courtId: string visitorId?: string visitorIdConfigured: boolean } export interface UpdateCourtSettingsInput { + credential?: string courtId: string visitorId?: string } @@ -200,6 +211,7 @@ export interface TennisBookApi { updateCourtSettings(input: UpdateCourtSettingsInput): Promise createBooking(input: CreateBookingInput): Promise cancelBooking(input: CancelBookingInput): Promise + acknowledgeBookingHandled(input: CancelBookingInput & { confirmed: true }): Promise getPendingBooking(courtId: string): Promise listMonitorTasks(): Promise getMonitorTaskDetail(taskId: string): Promise @@ -221,6 +233,7 @@ export const IPC_CHANNELS = { updateCourtSettings: 'court-settings:update', createBooking: 'bookings:create', cancelBooking: 'bookings:cancel', + acknowledgeBookingHandled: 'bookings:acknowledge-handled', getPendingBooking: 'bookings:pending', listMonitorTasks: 'monitors:list', getMonitorTaskDetail: 'monitors:detail',