feat: 支持大湾区球场监控
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,3 +9,7 @@ build/icon.icns
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 技能分发与迭代产物
|
||||
skills/*.{skill,zip}
|
||||
skills/*-workspace/
|
||||
|
||||
@@ -63,3 +63,10 @@
|
||||
- 每次修改至少通过 `npm run typecheck`、`npm run build` 和 `git diff --check`。
|
||||
- 影响交互或布局时必须做一次实际渲染检查。
|
||||
- 接入真实球场时需要覆盖:正常数据、无可用时段、登录失效、限流、接口格式变化和网络失败。
|
||||
|
||||
## 多凭证球场与外部订单处理
|
||||
|
||||
- 通用登录凭证使用 `credential` 按球场存储,旧球场的 `visitorId` 保持兼容;字段标签由适配器元数据提供。凭证仅允许在设置弹窗短暂回填,关闭立即清除。
|
||||
- 需要登录的列表通过适配器独立的鉴权查询方法调用;后台监控必须使用无凭证查询路径,此类球场不得创建监控任务。
|
||||
- 未接入取消或订单查询的球场必须提示用户到原小程序处理。清理本机记录使用独立 IPC 和明确的已处理确认,不得宣称远端取消成功;清理前仍禁止重复下单。
|
||||
- `src/main/adapters/dawanqu/` 存放大湾区适配器、契约测试和脱敏 fixture;项目、区域及第三方时段参数只在适配器内处理。新增抓包只提取响应 fixture,不保存请求头或用户凭证。
|
||||
|
||||
@@ -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 <token>`;不内置抓包凭证。
|
||||
|
||||
先重查列表,再调用 `reserve/price` 核对金额,最后调用 `reserve` 提交完整连续组合。场地与时段联合构成唯一标识,物理场地用通用 `physicalCourtId` 表达。价格或格式变化时阻止提交。提交后的超时、5xx、解析异常均按不确定处理,不自动重试。订单金额取核价结果,订单号取 `orderBusUuid`;不推测支付到期时间。
|
||||
|
||||
当前未提供取消、订单状态、支付接口。用户在原小程序处理后,必须明确确认才清理本机摘要;清理不调用远端接口。适配器没有取消能力时,本机持久化失败不得当作已自动释放。fixture 仅保留列表响应,测试不得调用真实写接口。
|
||||
|
||||
### 大湾区跨日期状态
|
||||
|
||||
以 `isAbleReserve` 区分可订与不可订;`msg` 是原因提示,秒杀/优惠字段描述预约方式,不能覆盖已售状态。已开始时段显示“已开始”;不可订且无其他原因的优惠时段仍显示“已售”。可订但当前适配器未覆盖优惠/原时段转换流程时,保留可订状态,通过通用 `bookingUnavailableReason` 禁止应用内提交并提示在小程序预约。
|
||||
|
||||
矩阵接口业务码 `2000` 配合“当前最多可提前 N 天预定场地”表示未进入开放窗口,返回空时段及统一 `notice`,不是网络失败;只识别该明确响应,不把其他 `2000` 错误吞成空列表。开放窗口以服务端当次结果为准,不在客户端写死日期或推测放场时间。
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
skills/tennis-booking.zip
Normal file
BIN
skills/tennis-booking.zip
Normal file
Binary file not shown.
71
skills/tennis-booking/SKILL.md
Normal file
71
skills/tennis-booking/SKILL.md
Normal file
@@ -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 <visitor-id>` 输出 12 字符 hex。
|
||||
- `evals/evals.json` 中所有断言成立(agent 不绕过 helper 自行拼请求体、不打印 PSPLVISITORID 原值、不在结果不确定时重试)。
|
||||
80
skills/tennis-booking/evals/evals.json
Normal file
80
skills/tennis-booking/evals/evals.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
108
skills/tennis-booking/evals/fixtures/availability-sample.json
Normal file
108
skills/tennis-booking/evals/fixtures/availability-sample.json
Normal file
@@ -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": []
|
||||
}
|
||||
}
|
||||
38
skills/tennis-booking/references/courts/fansibote-fuzhong.md
Normal file
38
skills/tennis-booking/references/courts/fansibote-fuzhong.md
Normal file
@@ -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 **不主动调用**,除非用户明确说"释放我刚锁的那一笔"
|
||||
108
skills/tennis-booking/references/current-logic.md
Normal file
108
skills/tennis-booking/references/current-logic.md
Normal file
@@ -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 <visitor-id>` → 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"两次请求)
|
||||
- ❌ 静默忽略字段缺失
|
||||
152
skills/tennis-booking/references/operations.md
Normal file
152
skills/tennis-booking/references/operations.md
Normal file
@@ -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 <value>`,但**优先用 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 示例。
|
||||
367
skills/tennis-booking/references/protocol.md
Normal file
367
skills/tennis-booking/references/protocol.md
Normal file
@@ -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": "<private-pay-sign>"
|
||||
},
|
||||
"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`
|
||||
|
||||
任一触发 → 停止本球场所有操作,告知用户"接口字段变化,暂停服务",不静默丢时段、不静默重试。
|
||||
506
skills/tennis-booking/scripts/pospal.mjs
Executable file
506
skills/tennis-booking/scripts/pospal.mjs
Executable file
@@ -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 <subcommand> [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> (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 <path> 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)))
|
||||
482
src/cli/index.ts
Normal file
482
src/cli/index.ts
Normal file
@@ -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<string, string | boolean>
|
||||
}
|
||||
|
||||
function parseArgs(values: string[]): ParsedArgs {
|
||||
const positionals: string[] = []
|
||||
const flags = new Map<string, string | boolean>()
|
||||
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<string>((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<string, MonitorTask['autoBooking']['status']>()
|
||||
await new Promise<void>((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
|
||||
})
|
||||
215
src/main/adapters/dawanqu/adapter.test.ts
Normal file
215
src/main/adapters/dawanqu/adapter.test.ts
Normal file
@@ -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<string, unknown>[] }[] }
|
||||
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 })
|
||||
})
|
||||
})
|
||||
212
src/main/adapters/dawanqu/adapter.ts
Normal file
212
src/main/adapters/dawanqu/adapter.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<unknown> {
|
||||
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<string, string>()
|
||||
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<string>()
|
||||
const rows = new Set<string>()
|
||||
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<string, unknown>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
368
src/main/adapters/dawanqu/matrix-dates.fixture.json
Normal file
368
src/main/adapters/dawanqu/matrix-dates.fixture.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1020
src/main/adapters/dawanqu/matrix.fixture.json
Normal file
1020
src/main/adapters/dawanqu/matrix.fixture.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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)) {
|
||||
|
||||
@@ -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<CreateBookingInput, 'slotId' | 'slotIds'> {
|
||||
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,
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface CourtAdapter {
|
||||
readonly court: CourtSummary
|
||||
readonly defaultSettings: CourtRuntimeSettings
|
||||
getAvailability(query: AvailabilityQuery): Promise<AvailabilityDay>
|
||||
getAuthenticatedAvailability?(query: AvailabilityQuery, settings: CourtRuntimeSettings): Promise<AvailabilityDay>
|
||||
preflightWriteSettings?(settings: CourtRuntimeSettings): void
|
||||
createBooking?(
|
||||
slots: BookingSlot[],
|
||||
|
||||
@@ -11,7 +11,7 @@ export function slotEndExclusiveMinutes(slot: Pick<BookingSlot, 'endTime'>) {
|
||||
}
|
||||
|
||||
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[]) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { CourtRuntimeSettings } from './types'
|
||||
|
||||
interface SettingsFile {
|
||||
version: 1
|
||||
courts: Record<string, { visitorId?: string }>
|
||||
courts: Record<string, { visitorId?: string; credential?: string }>
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -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 })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface CourtRuntimeSettings {
|
||||
courtId: string
|
||||
visitorId?: string
|
||||
credential?: string
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -149,7 +149,8 @@ function SlotCell({
|
||||
return (
|
||||
<button
|
||||
className={`slot-cell slot-${slot.status} ${enrollment ? 'has-enrollment' : ''} ${selected ? 'is-selected' : ''}`}
|
||||
disabled={!isAvailable}
|
||||
disabled={!isAvailable || Boolean(slot.bookingUnavailableReason)}
|
||||
title={slot.bookingUnavailableReason}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
@@ -179,11 +180,13 @@ function SlotCell({
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="slot-price">{priceFormatter.format(slot.priceCents / 100)}</span>
|
||||
<span className="slot-price">{slot.priceKnown === false ? '—' : priceFormatter.format(slot.priceCents / 100)}</span>
|
||||
)}
|
||||
{enrollment ? null : (
|
||||
<span className="slot-state">
|
||||
{slot.status === 'available'
|
||||
{slot.bookingUnavailableReason
|
||||
? '可订 · 小程序预约'
|
||||
: slot.status === 'available'
|
||||
? '可订'
|
||||
: slot.status === 'limited'
|
||||
? '紧张'
|
||||
@@ -279,10 +282,10 @@ export function App() {
|
||||
tennisBookApi
|
||||
.getAvailability({ courtId: selectedCourtId, date: selectedDate })
|
||||
.then((result) => active && setAvailability(result))
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
if (!active) return
|
||||
setAvailability(null)
|
||||
setError('可订时段加载失败,请检查登录状态或网络。')
|
||||
setError(getErrorMessage(error, '可订时段加载失败,请检查登录状态或网络。'))
|
||||
})
|
||||
.finally(() => active && setLoading(false))
|
||||
|
||||
@@ -538,7 +541,7 @@ export function App() {
|
||||
const settings = await tennisBookApi.getCourtSettings(selectedCourtId)
|
||||
if (settingsRequestId.current !== requestId) return
|
||||
setCourtSettings(settings)
|
||||
setVisitorIdInput(settings.visitorId ?? '')
|
||||
setVisitorIdInput(settings.credential ?? settings.visitorId ?? '')
|
||||
} catch {
|
||||
if (settingsRequestId.current !== requestId) return
|
||||
setSettingsError('球场配置读取失败,请稍后重试。')
|
||||
@@ -556,7 +559,7 @@ export function App() {
|
||||
const visitorId = visitorIdInput.trim()
|
||||
const saved = await tennisBookApi.updateCourtSettings({
|
||||
courtId: courtSettings.courtId,
|
||||
...(visitorId ? { visitorId } : {})
|
||||
...(visitorId ? selectedCourt?.credentialLabel ? { credential: visitorId } : { visitorId } : {})
|
||||
})
|
||||
setCourtSettings(saved)
|
||||
setVisitorIdInput('')
|
||||
@@ -570,7 +573,7 @@ export function App() {
|
||||
} finally {
|
||||
setSettingsSaving(false)
|
||||
}
|
||||
}, [closeSettings, courtSettings, visitorIdInput])
|
||||
}, [closeSettings, courtSettings, visitorIdInput, selectedCourt])
|
||||
|
||||
const createBooking = useCallback(async () => {
|
||||
if (!tennisBookApi || !selectedSlot || !selectedCourtId) return
|
||||
@@ -605,10 +608,12 @@ export function App() {
|
||||
setBookingBusy(true)
|
||||
setBookingError(null)
|
||||
try {
|
||||
await tennisBookApi.cancelBooking({
|
||||
courtId: pendingBooking.courtId,
|
||||
orderId: pendingBooking.orderId
|
||||
})
|
||||
const input = { courtId: pendingBooking.courtId, orderId: pendingBooking.orderId }
|
||||
if (selectedCourt?.supportsCancellation === false) {
|
||||
await tennisBookApi.acknowledgeBookingHandled({ ...input, confirmed: true })
|
||||
} else {
|
||||
await tennisBookApi.cancelBooking(input)
|
||||
}
|
||||
setPendingBooking(null)
|
||||
setCancelConfirmOpen(false)
|
||||
setRefreshToken((value) => value + 1)
|
||||
@@ -619,7 +624,7 @@ export function App() {
|
||||
} finally {
|
||||
setBookingBusy(false)
|
||||
}
|
||||
}, [pendingBooking])
|
||||
}, [pendingBooking, selectedCourt])
|
||||
|
||||
if (!tennisBookApi) {
|
||||
return (
|
||||
@@ -704,7 +709,7 @@ export function App() {
|
||||
</div>
|
||||
<button
|
||||
aria-label="新建空场监控"
|
||||
disabled={!selectedCourtId}
|
||||
disabled={!selectedCourtId || selectedCourt?.requiresAuthenticatedAvailability}
|
||||
onClick={() => {
|
||||
setMonitorDate('')
|
||||
setMonitorAutoBooking(false)
|
||||
@@ -718,7 +723,7 @@ export function App() {
|
||||
{monitorTasks.length === 0 ? (
|
||||
<button
|
||||
className="monitor-empty"
|
||||
disabled={!selectedCourtId}
|
||||
disabled={!selectedCourtId || selectedCourt?.requiresAuthenticatedAvailability}
|
||||
onClick={() => {
|
||||
setMonitorDate('')
|
||||
setMonitorAutoBooking(false)
|
||||
@@ -726,7 +731,7 @@ export function App() {
|
||||
setMonitorDialogOpen(true)
|
||||
}}
|
||||
type="button"
|
||||
>每 10 秒检查空场<br /><span>点击创建第一个任务</span></button>
|
||||
>{selectedCourt?.requiresAuthenticatedAvailability ? '此球场列表需要登录' : '每 10 秒检查空场'}<br /><span>{selectedCourt?.requiresAuthenticatedAvailability ? '暂不支持无凭证空场监控' : '点击创建第一个任务'}</span></button>
|
||||
) : (
|
||||
<div className="monitor-task-list">
|
||||
{monitorTasks.map((task) => (
|
||||
@@ -872,11 +877,12 @@ export function App() {
|
||||
<button onClick={() => setBookingError(null)} type="button">关闭</button>
|
||||
</div>
|
||||
) : null}
|
||||
{availability?.notice ? <div className="availability-notice" role="status"><CalendarDays size={18} /><span>{availability.notice}</span></div> : null}
|
||||
{loading ? (
|
||||
<div className="loading-grid" aria-label="正在加载时段">
|
||||
{Array.from({ length: 28 }, (_, index) => <i key={index} />)}
|
||||
</div>
|
||||
) : (
|
||||
) : availability?.notice ? null : (
|
||||
<div className="schedule-scroll">
|
||||
<div
|
||||
className="schedule-grid"
|
||||
@@ -917,11 +923,13 @@ export function App() {
|
||||
<strong>
|
||||
{pendingBooking.startTime}–{pendingBooking.endTime} · {pendingBooking.courtLabel}
|
||||
</strong>
|
||||
<small>{pendingBooking.status === 'submitting-uncertain'
|
||||
<small>{selectedCourt?.supportsCancellation === false
|
||||
? `订单 ${pendingBooking.status === 'submitting-uncertain' ? '结果待核实' : pendingBooking.orderId} · 请到原小程序支付、取消或核实状态`
|
||||
: pendingBooking.status === 'submitting-uncertain'
|
||||
? '网络响应不确定,请刷新列表后尝试释放该访客凭证下的锁场'
|
||||
: pendingBooking.status === 'release-uncertain'
|
||||
? '请先到小程序核实是否已释放,再清理本机记录;系统不会自动重试'
|
||||
: `订单 ${pendingBooking.orderId} · 支付有效期至 ${pendingBooking.expiresAt}`}</small>
|
||||
: `订单 ${pendingBooking.orderId}${pendingBooking.expiresAt ? ` · 支付有效期至 ${pendingBooking.expiresAt}` : ' · 请到原小程序处理'}`}</small>
|
||||
</div>
|
||||
<div className="selection-price">
|
||||
{priceFormatter.format(pendingBooking.amountCents / 100)}
|
||||
@@ -931,7 +939,7 @@ export function App() {
|
||||
disabled={bookingBusy}
|
||||
onClick={() => setCancelConfirmOpen(true)}
|
||||
type="button"
|
||||
><UnlockKeyhole size={14} />{pendingBooking.status === 'submitting-uncertain'
|
||||
><UnlockKeyhole size={14} />{selectedCourt?.supportsCancellation === false ? '已处理,清理记录' : pendingBooking.status === 'submitting-uncertain'
|
||||
? '尝试释放'
|
||||
: pendingBooking.status === 'release-uncertain'
|
||||
? '核实后清理'
|
||||
@@ -1140,9 +1148,9 @@ export function App() {
|
||||
) : (
|
||||
<label className="settings-field">
|
||||
<span className="settings-field-title">
|
||||
PSPLVISITORID
|
||||
<i className={courtSettings?.visitorIdConfigured ? 'is-configured' : ''}>
|
||||
{courtSettings?.visitorIdConfigured ? '已配置' : '未配置'}
|
||||
{selectedCourt?.credentialLabel ?? 'PSPLVISITORID'}
|
||||
<i className={(courtSettings?.credentialConfigured ?? courtSettings?.visitorIdConfigured) ? 'is-configured' : ''}>
|
||||
{(courtSettings?.credentialConfigured ?? courtSettings?.visitorIdConfigured) ? '已配置' : '未配置'}
|
||||
</i>
|
||||
</span>
|
||||
<span className="credential-input-wrap">
|
||||
@@ -1151,12 +1159,12 @@ export function App() {
|
||||
autoComplete="off"
|
||||
disabled={settingsSaving || !courtSettings}
|
||||
onChange={(event) => setVisitorIdInput(event.target.value)}
|
||||
placeholder="粘贴该球场的 PSPLVISITORID"
|
||||
placeholder={`粘贴该球场的 ${selectedCourt?.credentialLabel ?? 'PSPLVISITORID'}`}
|
||||
type={visitorIdVisible ? 'text' : 'password'}
|
||||
value={visitorIdInput}
|
||||
/>
|
||||
<button
|
||||
aria-label={visitorIdVisible ? '隐藏 PSPLVISITORID' : '显示 PSPLVISITORID'}
|
||||
aria-label={`${visitorIdVisible ? '隐藏' : '显示'} ${selectedCourt?.credentialLabel ?? 'PSPLVISITORID'}`}
|
||||
disabled={settingsSaving || !visitorIdInput}
|
||||
onClick={() => setVisitorIdVisible((visible) => !visible)}
|
||||
title={visitorIdVisible ? '隐藏凭证' : '显示凭证'}
|
||||
@@ -1251,7 +1259,7 @@ export function App() {
|
||||
<span className="confirm-lock-icon"><LockKeyhole size={20} /></span>
|
||||
<span className="eyebrow">CREATE VENUE ORDER</span>
|
||||
<h2>确认下单并锁定场地?</h2>
|
||||
<p>此操作只创建待支付订单,不会自动支付。下单成功后场地会被临时锁定。</p>
|
||||
<p>{selectedCourt?.bookingNotice ?? '此操作只创建待支付订单,不会自动支付。下单成功后场地会被临时锁定。'}</p>
|
||||
<div className="confirm-booking-summary">
|
||||
<div><span>场地</span><strong>{selectedCourt?.name} · {selectedSlot.courtLabel}</strong></div>
|
||||
<div><span>时间</span><strong>{selectedDate} {selectedSlot.startTime}–{selectedSlot.endTime}</strong></div>
|
||||
@@ -1282,10 +1290,12 @@ export function App() {
|
||||
<section className="booking-confirm-dialog release-dialog" role="dialog" aria-modal="true">
|
||||
<span className="confirm-lock-icon"><UnlockKeyhole size={20} /></span>
|
||||
<span className="eyebrow">RELEASE VENUE HOLD</span>
|
||||
<h2>{pendingBooking.status === 'release-uncertain'
|
||||
<h2>{selectedCourt?.supportsCancellation === false ? '确认已在原小程序处理完成?' : pendingBooking.status === 'release-uncertain'
|
||||
? '确认已经在小程序核实?'
|
||||
: '取消订单并释放场地?'}</h2>
|
||||
<p>{pendingBooking.status === 'release-uncertain'
|
||||
<p>{selectedCourt?.supportsCancellation === false
|
||||
? '请先在原小程序确认订单已支付、已取消或已关闭。此操作只删除本机记录,不会取消远端订单,也不会释放场地。结果不确定时请保留记录。'
|
||||
: pendingBooking.status === 'release-uncertain'
|
||||
? '这里只清理本机的待确认记录,不会再次调用释放接口。请确认场地已经释放或你已自行处理。'
|
||||
: '将调用该球场的释放接口。成功后当前待支付订单不能继续用于支付。'}</p>
|
||||
<div className="confirm-booking-summary">
|
||||
@@ -1311,8 +1321,8 @@ export function App() {
|
||||
type="button"
|
||||
>{bookingBusy
|
||||
? '处理中…'
|
||||
: pendingBooking.status === 'release-uncertain'
|
||||
? '已核实,清理记录'
|
||||
: selectedCourt?.supportsCancellation === false || pendingBooking.status === 'release-uncertain'
|
||||
? '已核实处理完成,清理记录'
|
||||
: '确认取消并释放'}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<CourtSettings>
|
||||
createBooking(input: CreateBookingInput): Promise<PendingBooking>
|
||||
cancelBooking(input: CancelBookingInput): Promise<void>
|
||||
acknowledgeBookingHandled(input: CancelBookingInput & { confirmed: true }): Promise<void>
|
||||
getPendingBooking(courtId: string): Promise<PendingBooking | null>
|
||||
listMonitorTasks(): Promise<MonitorTask[]>
|
||||
getMonitorTaskDetail(taskId: string): Promise<MonitorTaskDetail>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user