diff --git a/README.md b/README.md index 39b0c8a..7355c5f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - [产品与技术规划](docs/scenic-queue-system-plan.md) - [UI/UX 设计范式](docs/scenic-queue-design-paradigm.md) - [登录持久化设计](docs/login-persistence-design.md) +- [游客端小程序对接文档](docs/visitor-mini-program-integration.md) ## 技术架构 diff --git a/docs/visitor-mini-program-integration.md b/docs/visitor-mini-program-integration.md new file mode 100644 index 0000000..aae9c56 --- /dev/null +++ b/docs/visitor-mini-program-integration.md @@ -0,0 +1,554 @@ +# 景区排队叫号系统|游客端小程序对接文档 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | v1.0 | +| 更新时间 | 2026-07-22 | +| 适用范围 | 景区官方微信小程序接入游客取号、排队状态查询 | +| 接口前缀 | `/api/public` | +| 鉴权方式 | 公开接口;取号后使用 `public_token` 查询本人状态 | +| 当前实现依据 | 现有游客端 API 与 H5 页面 | + +> 本文描述的是当前系统已经实现的公开接口。正式联调前,需要由后端补充实际的 `API_BASE_URL`、前端 `WEB_BASE_URL` 和生产域名白名单。 + +## 1. 接入结论 + +推荐由小程序原生页面直接调用公开 API: + +1. 调用项目列表,展示当前可取号的项目。 +2. 用户提交项目、同行人数和手机号,创建排队号码。 +3. 保存接口返回的 `public_token`。 +4. 使用 `public_token` 轮询排队状态,直到号码完成、过号或取消。 + +系统当前不要求小程序传递登录 Cookie、`Authorization` 或员工/管理员账号。小程序只应调用 `/api/public/*`,不要调用 `/api/staff/*`、`/api/admin/*` 或 `/api/events`。 + +快速接入也可以直接使用现有 H5 页面,但推荐仅作为过渡方案: + +- 取号页:`/visitor` +- 指定号码状态页:`/visitor/{public_token}` + +H5 页面的手机号查号能力目前仅用于运营测试,生产环境接口会被关闭,不能作为正式的小程序找回排队状态方案。 + +## 2. 接入前准备 + +小程序方需要配置以下变量: + +```text +API_BASE_URL=https:// +WEB_BASE_URL=https:// +``` + +如果前端和 API 通过同一个域名发布,两个值可以相同。当前生产路由约定为: + +- `/api/*` 转发到 Go API 服务; +- 其他路径转发到游客端前端服务。 + +请求要求: + +- 使用 HTTPS; +- 将 API 域名加入微信小程序“request 合法域名”; +- `API_BASE_URL` 不要以 `/` 结尾; +- POST 请求发送 `Content-Type: application/json`; +- 建议发送 `Accept: application/json`; +- 服务端响应带 `Cache-Control: no-store`,小程序不要缓存项目列表和排队状态; +- 可选发送 `X-Request-ID`,服务端会在响应中返回同名请求 ID,便于联调排查。 + +当前公开接口没有 CORS 或登录 Cookie 依赖。若使用 `web-view`,还需要把 `WEB_BASE_URL` 加入小程序业务域名,并由小程序页面打开 H5 地址。 + +## 3. 游客业务流程 + +```mermaid +sequenceDiagram + participant MP as 小程序 + participant API as 排队 API + participant Visitor as 游客 + + MP->>API: GET /api/public/projects + API-->>MP: 返回 RUNNING 项目及人数范围 + Visitor->>MP: 选择项目、填写同行人数和手机号 + MP->>API: POST /api/public/projects/{id}/tickets + API-->>MP: 201 + ticket + public_token + MP->>MP: 持久化 public_token + loop 页面可见时每 3–5 秒 + MP->>API: GET /api/public/status/{public_token} + API-->>MP: 号码状态、前方人数、预计等待时间 + end + API-->>MP: status=CALLED + MP-->>Visitor: 提示立即前往现场 +``` + +## 4. 通用接口约定 + +### 4.1 URL 与路径参数 + +所有路径参数都需要进行 URL 编码。`project_id` 是项目 UUID,不是项目名称,也不是项目编码。`public_token` 是取号接口返回的原始 token,不能自行截断、转换大小写或重新生成。 + +### 4.2 成功响应 + +成功响应均为 JSON。创建号码首次成功返回 HTTP `201 Created`;使用同一个幂等键重试时,服务端会返回第一次请求保存的同一份响应。 + +### 4.3 错误响应 + +错误统一为以下结构: + +```json +{ + "error": { + "code": "INVALID_PARTY_SIZE", + "message": "本项目每个号码可绑定 1 到 10 人", + "details": { + "min_party_size": 1, + "max_party_size": 10 + } + } +} +``` + +小程序应根据 `error.code` 分支处理,不要根据 `message` 文案做逻辑判断。`details` 不是所有错误都会返回。 + +## 5. 公开 API + +### 5.1 获取可取号项目 + +```http +GET /api/public/projects +``` + +无需请求体和鉴权。 + +响应示例: + +```json +{ + "projects": [ + { + "id": "2c6c3d19-6c6b-4b2d-8dd4-2c5b718c8b80", + "name": "云栖观光车", + "status": "RUNNING", + "visitor_notice": "请您在景区附近等候,注意听从工作人员指引。", + "min_party_size": 1, + "max_party_size": 10 + } + ] +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | string | 项目 UUID;后续取号必须使用此值 | +| `name` | string | 项目名称 | +| `status` | string | 当前只返回 `RUNNING` 项目 | +| `visitor_notice` | string/null | 项目官方提示,可为空 | +| `min_party_size` | integer | 一个排队号码允许绑定的最少人数,包含边界 | +| `max_party_size` | integer | 一个排队号码允许绑定的最多人数,包含边界 | + +接口只返回当前状态为 `RUNNING` 的项目,并按项目名称排序。返回空数组表示当前没有可取号项目。 + +### 5.2 游客取号 + +```http +POST /api/public/projects/{project_id}/tickets +Content-Type: application/json +Idempotency-Key: +``` + +请求示例: + +```json +{ + "phone": "13800138000", + "party_size": 2, + "last_name": "张", + "honorific": "先生", + "allow_duplicate": false +} +``` + +请求字段: + +| 字段 | 必填 | 类型 | 说明 | +| --- | --- | --- | --- | +| `phone` | 是 | string | 取号人手机号。允许数字、空格、短横线、括号和可选的首位 `+`;规范化后必须为 7–15 位数字 | +| `party_size` | 是 | integer | 本号同行人数,必须在项目返回的 `min_party_size` 与 `max_party_size` 范围内 | +| `last_name` | 否 | string | 姓氏,最多 40 个字符;不需要现场叫号称呼时可不传 | +| `honorific` | 否 | string | 只能是 `游客`、`先生`、`女士`;不传或传空时按 `游客` 处理 | +| `allow_duplicate` | 否 | boolean | 默认 `false`。只有用户明确确认后,才可改为 `true` | + +注意:服务端会拒绝未知字段、非法 JSON 和一个请求中包含多个 JSON 对象。小程序只传本文定义的字段。 + +成功响应示例: + +```json +{ + "ticket": { + "id": "4b2b7f54-0b6a-4b2b-9c5d-5fd5f2fc2d41", + "display_number": "00042", + "ticket_number": "00042", + "status": "WAITING", + "party_size": 2, + "phone_last4": "8000", + "created_at": "2026-07-22T08:00:00Z" + }, + "public_token": "", + "public_url": "/visitor/", + "status_path": "/api/public/status/", + "revision": 42 +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `ticket.id` | string | 排队号码记录 ID;一般不需要用于查询 | +| `ticket.ticket_number` | string | 展示给游客的排队号码。请直接展示,不要自行补零或拼接前缀 | +| `ticket.display_number` | string | 与 `ticket_number` 兼容返回;优先使用 `ticket_number` | +| `ticket.status` | string | 新建时为 `WAITING` | +| `ticket.party_size` | integer | 本号绑定人数 | +| `ticket.phone_last4` | string | 手机号尾四位;系统不会在公开响应中返回完整手机号 | +| `ticket.created_at` | string | ISO 8601 时间 | +| `public_token` | string | 私密状态凭证;必须持久化保存 | +| `public_url` | string | H5 状态页相对路径,不是 API 地址 | +| `status_path` | string | 状态查询 API 相对路径 | +| `revision` | integer | 创建后的队列修订号,可用于判断数据是否变化 | + +#### 幂等要求 + +`Idempotency-Key` 必填,长度 8–128 个字符且不能包含空白。推荐每次用户“确认取号”生成一个 UUID,并在请求完成前保存该 key。 + +- 用户重复点击、网络超时、前端未收到响应:使用同一个 key 和完全相同的请求体重试。 +- 同一个 key 不能用于不同请求体,否则返回 `IDEMPOTENCY_KEY_REUSED`。 +- 一旦收到明确的业务错误(重复手机号除外),可以丢弃该 key。 + +#### 重复手机号处理 + +同一个项目中,该手机号已有 `WAITING`、`CALLED` 或 `ARRIVED` 状态号码时,接口返回: + +```http +409 Conflict +``` + +```json +{ + "error": { + "code": "DUPLICATE_PHONE", + "message": "该手机号已有活动号码,请确认后继续" + } +} +``` + +小程序应向用户确认是否继续取号。用户确认后,保持请求体其他字段不变,将 `allow_duplicate` 改为 `true`,并生成新的 `Idempotency-Key` 再提交。 + +#### 取号常见错误 + +| HTTP | `error.code` | 处理建议 | +| --- | --- | --- | +| 400 | `INVALID_ID`、`INVALID_JSON`、`IDEMPOTENCY_KEY_REQUIRED` | 修正请求后再提交 | +| 404 | `PROJECT_NOT_FOUND` | 刷新项目列表,不要继续使用旧项目 ID | +| 409 | `PROJECT_NOT_RUNNING`、`QUEUE_NOT_RUNNING` | 提示项目暂不可取号,重新获取项目列表 | +| 409 | `DUPLICATE_PHONE` | 进入用户确认流程 | +| 422 | `INVALID_PHONE` | 提示手机号格式错误 | +| 422 | `INVALID_PARTY_SIZE` | 使用项目接口返回的最新人数范围 | +| 422 | `INVALID_LAST_NAME`、`INVALID_HONORIFIC` | 修正可选字段 | +| 429 | `PUBLIC_TICKET_RATE_LIMITED` | 读取 `Retry-After` 秒数,等待后再试 | + +当前实现的公开取号限流为同一客户端 IP 每分钟最多 20 次,具体阈值以后端配置和网关策略为准,小程序不要将该阈值写死为业务规则。 + +### 5.3 查询单个号码状态 + +```http +GET /api/public/status/{public_token} +``` + +无需请求体和鉴权。`public_token` 是取号成功时返回的私密凭证,必须进行 URL 编码。 + +响应示例: + +```json +{ + "ticket_number": "00042", + "display_number": "00042", + "project_name": "云栖观光车", + "status": "WAITING", + "party_size": 2, + "phone_last4": "8000", + "tickets_ahead": 6, + "people_ahead": 18, + "queue_position": 7, + "latest_called_number": "00035", + "estimated_wait": { + "available": true, + "estimate_minutes": 20, + "min_minutes": 20, + "max_minutes": 20 + }, + "visitor_notice": "请您在景区附近等候,注意听从工作人员指引。", + "experienced_people": 120, + "called_at": null, + "last_updated_at": "2026-07-22T08:12:00Z", + "project": { + "id": "2c6c3d19-6c6b-4b2d-8dd4-2c5b718c8b80", + "name": "云栖观光车", + "status": "RUNNING" + }, + "ticket": { + "display_number": "00042", + "status": "WAITING", + "party_size": 2, + "joined_at": "2026-07-22T08:00:00Z", + "called_at": null, + "arrived_at": null, + "completed_at": null, + "missed_at": null + }, + "eta": { + "available": true, + "estimate_minutes": 20, + "min_minutes": 20, + "max_minutes": 20 + }, + "revision": 42, + "server_time": "2026-07-22T08:12:01Z" +} +``` + +小程序实现时优先使用顶层字段。当前响应中 `eta` 与 `estimated_wait` 内容一致,建议统一使用 `estimated_wait`;`ticket` 对象用于兼容现有 H5,不建议把它作为唯一数据来源。 + +状态查询字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `ticket_number` | string | 游客排队号码 | +| `display_number` | string | 兼容字段,通常与 `ticket_number` 相同 | +| `project_name` | string | 项目名称 | +| `status` | string | 号码状态,见下表 | +| `party_size` | integer | 本号人数 | +| `phone_last4` | string | 手机号尾四位 | +| `tickets_ahead` | integer/null | 前方等待中的号码数;非 `WAITING` 状态通常为 0 | +| `people_ahead` | integer/null | 前方等待中的总人数 | +| `queue_position` | integer/null | 当前排队位置,从 1 开始;非 `WAITING` 状态为 null | +| `latest_called_number` | string/null | 当前最新已叫号码,可能为空 | +| `estimated_wait` | object | 预计等待时间,见下文 | +| `visitor_notice` | string/null | 项目官方提示 | +| `experienced_people` | integer | 当日累计绑定人数展示指标 | +| `called_at` | string/null | 叫号时间 | +| `last_updated_at` | string | 服务端数据更新时间 | +| `project` | object | 项目 ID、名称和当前状态 | +| `revision` | integer | 队列修订号 | +| `server_time` | string | 服务端当前时间,ISO 8601 | + +时间字段均为 ISO 8601 格式;建议按服务端时间展示,不要假定服务端一定使用小程序设备本地时区。 + +#### 预计等待时间 + +```json +{ + "available": true, + "estimate_minutes": 20, + "min_minutes": 20, + "max_minutes": 20 +} +``` + +当 `available=false` 时,可能返回: + +```json +{ + "available": false, + "estimate_minutes": 0, + "min_minutes": 0, + "max_minutes": 0, + "reason": "queue_not_running" +} +``` + +常见 `reason`: + +- `queue_not_running`:项目或队列暂停/未运行; +- `ticket_not_waiting`:号码已经叫到、到场、完成、过号或取消; +- `missing_interval_configuration`:暂时没有可用的预计时间配置。 + +只有 `available=true` 时才展示预计分钟数;否则展示“暂不可估算”,不要把 `0` 理解为“马上叫到”。 + +状态查询找不到 token 时返回 HTTP `404` 和 `STATUS_NOT_FOUND`。这通常表示 token 未保存、被截断、被错误编码,或该号码不再存在;当前没有公开的 token 刷新接口。 + +### 5.4 手机号查号(当前仅测试环境) + +```http +POST /api/public/status/search +Content-Type: application/json +``` + +请求体: + +```json +{ + "phone": "13800138000" +} +``` + +该接口返回同一手机号当前活动号码的状态列表,但当前代码在 `APP_ENV=production` 时固定返回 `404 STATUS_NOT_FOUND`。它的设计注释也明确要求正式上线前替换为短信验证码或外部身份接口。 + +因此: + +- 生产小程序不得依赖该接口找回号码; +- 不要把手机号作为 URL 参数; +- 取号成功后必须保存 `public_token`; +- 若业务必须支持“换设备/丢失 token 后查号”,需要另行设计 OTP、微信身份绑定或景区会员身份接口。 + +测试环境下该接口被调用过于频繁时会返回 HTTP `429`、错误码 `PUBLIC_QUERY_RATE_LIMITED`,并通过 `Retry-After` 告知等待秒数。 + +## 6. 号码状态与页面展示建议 + +| `status` | 建议展示 | 处理方式 | +| --- | --- | --- | +| `WAITING` | 正在排队、前方号码、前方人数、预计等待时间 | 持续轮询 | +| `CALLED` | 已到号,请前往现场 | 强提醒,可继续短轮询 | +| `ARRIVED` | 已到场,请按现场指引等待 | 可降低轮询频率 | +| `COMPLETED` | 本次排队已完成 | 停止轮询 | +| `MISSED` | 已过号,请联系工作人员 | 停止轮询,展示现场处理提示 | +| `CANCELED` | 排队已取消 | 停止轮询 | + +当 `project.status=PAUSED` 且号码仍为 `WAITING` 时,号码会被保留,但 `estimated_wait.available` 为 `false`。恢复运行后重新查询即可获得新的预计时间。 + +当前游客公开状态接口不保证返回 `entrance` 或 `message` 字段。到号提示应使用小程序自己的通用文案,并展示项目 `visitor_notice`;如后端后续补充现场入口字段,小程序可以按可选字段兼容展示。 + +## 7. 轮询与小程序生命周期 + +当前没有面向游客的公开 SSE 推送接口,`/api/events` 需要员工登录,不能用于小程序游客端。 + +建议策略: + +- 首次打开状态页立即请求一次; +- 页面可见时每 3–5 秒请求一次; +- 小程序进入后台时停止轮询,回到前台后立即刷新; +- 网络错误时保留最近一次成功数据,并提供“重试”; +- 收到 `429` 时按 `Retry-After` 退避,不要连续重试; +- `COMPLETED`、`MISSED`、`CANCELED` 后停止轮询; +- 不要把 `revision` 当作本地状态的唯一来源,始终以最新成功响应为准。 + +## 8. 原生小程序调用示例 + +以下示例只展示接口调用方式,实际项目请补充请求封装、错误提示和小程序生命周期处理。 + +```js +const API_BASE_URL = 'https://'; + +function requestProjects() { + return new Promise((resolve, reject) => { + wx.request({ + url: `${API_BASE_URL}/api/public/projects`, + method: 'GET', + header: { Accept: 'application/json' }, + success: (res) => res.statusCode === 200 ? resolve(res.data) : reject(res.data), + fail: reject, + }); + }); +} + +function createTicket(projectId, payload, idempotencyKey) { + return new Promise((resolve, reject) => { + wx.request({ + url: `${API_BASE_URL}/api/public/projects/${encodeURIComponent(projectId)}/tickets`, + method: 'POST', + header: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + data: payload, + success: (res) => res.statusCode === 201 ? resolve(res.data) : reject(res.data), + fail: reject, + }); + }); +} + +function getTicketStatus(publicToken) { + return new Promise((resolve, reject) => { + wx.request({ + url: `${API_BASE_URL}/api/public/status/${encodeURIComponent(publicToken)}`, + method: 'GET', + header: { Accept: 'application/json' }, + success: (res) => res.statusCode === 200 ? resolve(res.data) : reject(res.data), + fail: reject, + }); + }); +} +``` + +取号成功后的关键处理: + +```js +const result = await createTicket( + projectId, + { phone, party_size: partySize, allow_duplicate: false }, + idempotencyKey, +); + +wx.setStorageSync('visitor_public_token', result.public_token); +// 后续使用 result.public_token 调用 getTicketStatus,不要依赖手机号查号。 +``` + +`idempotencyKey` 应由一次用户取号意图唯一生成,并在网络重试期间复用;用户开启“重复取号”确认后必须生成新 key。 + +## 9. WebView 过渡接入 + +如果小程序方暂时不实现原生页面,可以使用: + +```text +${WEB_BASE_URL}/visitor +${WEB_BASE_URL}/visitor/${encodeURIComponent(public_token)} +``` + +建议流程: + +1. 使用 WebView 打开 `/visitor` 完成取号; +2. 取号成功后由 H5 页面跳转到 `/visitor/{public_token}`; +3. 如由小程序自己拿到 token,则直接拼接第二个地址打开状态页。 + +注意: + +- `/visitor` 当前包含“手机号查号”入口,但生产 API 会禁用该入口; +- `public_url` 是 H5 相对路径,`status_path` 是 API 相对路径,两者用途不同; +- WebView 需要配置业务域名,API 域名需要配置 request 合法域名; +- 不要把 token 发送到统计、客服或日志系统,也不要在页面标题、分享参数中暴露完整手机号。 + +## 10. 安全与隐私要求 + +`public_token` 等价于“查看该号码状态的私密凭证”,虽然不包含完整手机号,但持有 token 的人可以查询对应排队状态。小程序方必须: + +- 只通过 HTTPS 传输; +- 将 token 存入小程序本地存储,避免写入普通业务日志; +- 不在 URL 之外再拼接手机号、姓氏等个人信息; +- 不在埋点、错误上报和客服截图中上传完整 token; +- 不展示或记录公开接口返回之外的个人信息; +- 不把公开接口当作员工操作接口使用; +- token 丢失时不要用手机号直接绕过身份校验,转入后续 OTP/身份绑定方案。 + +## 11. 联调验收清单 + +| 场景 | 预期 | +| --- | --- | +| 项目列表 | 只展示 `RUNNING` 项目;正确展示人数范围和官方提示 | +| 正常取号 | 返回 HTTP 201、号码和 `public_token`,并能查询状态 | +| 重复点击 | 相同请求体 + 相同幂等键不会产生第二个号码 | +| 网络超时 | 使用原幂等键重试,得到原始结果 | +| 重复手机号 | 先收到 `DUPLICATE_PHONE`,用户确认后新 key + `allow_duplicate=true` 才能继续 | +| 人数越界 | 返回 `INVALID_PARTY_SIZE`,按项目列表的范围修正 | +| 项目暂停 | 号码保留,状态仍可查询,预计时间不可用 | +| 叫号 | `status=CALLED`,展示到号强提醒和 `called_at` | +| 终态 | `COMPLETED`、`MISSED`、`CANCELED` 后停止轮询 | +| token 丢失 | 明确提示无法直接通过手机号恢复,不能调用生产手机号查号接口替代 | +| 限流 | 正确处理 HTTP 429 和 `Retry-After`,不死循环重试 | + +## 12. 当前能力边界与待确认事项 + +以下事项不属于当前游客公开 API,正式上线前需要产品、景区方和后端共同确认: + +1. 生产环境的“换设备/丢失 token 后查号”方案:短信 OTP、微信身份绑定或景区会员身份。 +2. 是否需要游客取消排队、重新取号、过号重排等公开操作;当前没有对应的游客 API。 +3. 实际 `API_BASE_URL`、`WEB_BASE_URL`、HTTPS 证书和微信域名白名单。 +4. 项目 UUID 与景区小程序内部项目配置的映射方式。建议每次以 `/api/public/projects` 返回值为准,不在小程序写死 UUID。 +5. 到号后的现场入口、客服电话、导航信息。当前接口只返回项目官方提示,不保证返回 `entrance` 或 `message`。