Files
banban/docs/设备直接绑定接口联调文档(2).md
T

565 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设备直接绑定接口联调文档
## 1. 适用范围
本文档用于 Banban 小程序与 4G 管理后端之间的“设备无法获取绑定码时直接绑定”联调。
2026-09-23 更新:新增按 `sourceBindingId` 查询原绑定关系、关闭 handoff 关联的服务端接口,见第 11、12 节。伴伴解绑必须以 `canUnbind=true` 为放行条件,不能只看 ICCID 当前状态或把查不到当成已经解绑。
生产环境推荐使用 `handoffToken` 两段式流程:
1. Banban 后端使用服务凭证,向 4G 管理后端申请一次性 `handoffToken`。
2. 4G 小程序用户完成微信登录后,携带程序用户 Token 和 `handoffToken` 完成设备绑定。
该流程仍然复用普通绑定码绑定的事务逻辑,成功后会:
- 创建当前 4G 小程序用户与设备的有效绑定关系;
- 将 `sim_card.binding_status` 更新为 `bound`;
- 作废该设备尚未使用的绑定验证码;
- 清理绑定验证码缓存;
- 写入绑定审计记录。
handoff 申请接口会在服务 Token 校验通过后,按 ICCID 幂等创建或复用 4G 后端的 `sim_card` 档案。新建档案初始状态为 `active`、`unbound`、`sync_status=pending`;消费 handoffToken 后才会创建用户绑定并将绑定状态更新为 `bound`。因此 Banban 不需要提前调用 4G 后端的设备注册接口。
该自动建档能力只对已认证的 Banban 服务接口开放,不代表可以通过公开接口任意写入 ICCID。ICCID 格式仍必须合法;如果已有档案状态为非 `active`,申请会被拒绝。
## 2. 联调环境与配置
当前生产环境通过 Nginx 的 `/traffic-4g` 前缀访问,以下 `BASE_URL` 必须包含此前缀:
```text
BASE_URL=https://role.api.talkingq.com/traffic-4g
```
4G 后端需要配置并重启后才会生效:
```bash
HANDOFF_ENABLED=true
HANDOFF_TOKEN_TTL=5m
HANDOFF_TOKEN_PEPPER=<独立的高强度随机值>
BANBAN_HANDOFF_SERVICE_TOKEN=<独立的高强度随机值>
WECHAT_APP_ID=<4G 小程序 AppID>
DIRECT_ICCID_BINDING_ENABLED=false
```
说明:
- `BANBAN_HANDOFF_SERVICE_TOKEN` 只允许保存在 Banban 后端服务端,不能下发到小程序或前端页面。
- `HANDOFF_TOKEN_PEPPER` 只允许保存在 4G 后端服务端,不能与服务 Token 共用。
- `HANDOFF_TOKEN_TTL=5m` 表示凭证默认有效 5 分钟,具体有效期以申请接口返回的 `expiresIn` 为准。
- 当前环境的 `BINDING_ALLOWED_ORIGINS` 可以继续保留 `https://banban.api.talkingq.com,http://192.168.101.151`,但 handoff 流程不依赖 Origin 证明设备归属。
- 新增关系查询、关闭接口继续使用 `BANBAN_HANDOFF_SERVICE_TOKEN`,不需要新增密钥或修改 `.env`。即使 `HANDOFF_ENABLED=false`,仍可使用正确服务凭证查询、关闭已有关系。
## 3. 完整调用时序
```text
Banban 后端 4G 管理后端 4G 小程序
| | |
|-- X-Service-Token + ICCID -------->| |
|<--------- handoffToken ------------| |
| | |
| |<-- wx.login code --------|
| |---- accessToken -------->|
| | |
| |<-- accessToken + token --|
| |---- 完成设备绑定 -------->|
```
Banban 后端申请凭证的接口只能由服务端调用;4G 小程序消费凭证的接口只能由登录后的 `PROGRAM_USER` 调用。
## 4. 接口一:Banban 后端申请一次性 handoffToken
### 4.1 请求地址
```http
POST /internal/v1/handoff-tokens
```
完整地址:
```text
https://role.api.talkingq.com/traffic-4g/internal/v1/handoff-tokens
```
必须使用 HTTPS。该接口不使用小程序 JWT,而是使用服务间 Token。
### 4.2 请求头
```http
X-Service-Token: <BANBAN_HANDOFF_SERVICE_TOKEN>
Content-Type: application/json
X-Request-Id: banban-handoff-20260922-0001
```
`X-Service-Token` 的值必须与 4G 后端环境变量 `BANBAN_HANDOFF_SERVICE_TOKEN` 完全一致。
`X-Request-Id` 仅用于 HTTP 请求追踪,建议使用 8 至 64 位的字母、数字、点、下划线或短横线。真正的业务幂等键是请求体中的 `requestId`。
### 4.3 请求体
```json
{
"iccid": "898604E91123D1334901",
"sourceBindingId": "banban-binding-100001",
"requestId": "banban-handoff-20260922-0001"
}
```
| 字段 | 类型 | 必填 | 约束与说明 |
| --- | --- | --- | --- |
| `iccid` | string | 是 | 18 至 22 位字母或数字,后端会统一转为大写;已登记的 active 档案会复用,未登记时由 4G 后端自动建档 |
| `sourceBindingId` | string | 是 | Banban 侧绑定关系的稳定唯一 ID,最长 128 个字符;同一绑定关系保持不变,且必须始终对应同一 ICCID。新接入使用 UUID 或字母、数字、短横线、下划线,便于作为查询路径参数 |
| `requestId` | string | 是 | 本次申请的幂等请求号,最长 64 个字符;网络超时重试必须保持不变 |
`sourceBindingId` 不要使用每次随机生成的值,否则无法实现申请接口幂等。`requestId` 建议由 Banban 后端生成,例如 `banban-handoff-20260922-0001`。
即使换了 `requestId`,同一 `sourceBindingId` 也不能切换 ICCID,否则返回 `IDEMPOTENCY_CONFLICT`。伴伴关系进入 `closing` 后不得再申请或消费 handoff;完成解绑并重新建立伴伴绑定时,必须使用新关系的唯一 ID,不能复用旧 ID 绕过关闭状态。
### 4.4 curl 示例
以下命令只能在 Banban 后端服务器或安全的服务端环境执行:
```bash
export BASE_URL="https://role.api.talkingq.com/traffic-4g"
export BANBAN_HANDOFF_SERVICE_TOKEN="<服务端密钥>"
curl -i -sS -X POST "$BASE_URL/internal/v1/handoff-tokens" \
-H "X-Service-Token: $BANBAN_HANDOFF_SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Request-Id: banban-handoff-20260922-0001" \
--data '{
"iccid": "898604E91123D1334901",
"sourceBindingId": "banban-binding-100001",
"requestId": "banban-handoff-20260922-0001"
}'
```
### 4.5 成功响应
HTTP 状态码为 `200`:
```json
{
"code": "OK",
"message": "success",
"data": {
"targetAppId": "<4G 小程序 AppID>",
"handoffToken": "<一次性凭证>",
"expiresIn": 300
},
"requestId": "banban-handoff-20260922-0001",
"timestamp": "2026-09-22T03:30:00Z"
}
```
| 字段 | 说明 |
| --- | --- |
| `data.targetAppId` | 允许消费该凭证的目标小程序 AppID,必须与 4G 小程序登录身份一致 |
| `data.handoffToken` | 一次性绑定凭证,只能传给 4G 小程序的绑定接口,不要写入日志或返回给不可信客户端 |
| `data.expiresIn` | 剩余有效秒数,消费时以服务端校验结果为准 |
相同的 `sourceBindingId + requestId` 重试时:
- 凭证仍为 `active` 且未过期:返回同一凭证,不会重复创建;
- 对应 ICCID 发生变化:返回 `IDEMPOTENCY_CONFLICT`;
- 凭证已消费:返回 `HANDOFF_TOKEN_USED`;
- 凭证已过期:返回 `HANDOFF_TOKEN_EXPIRED`。
## 5. 接口二:4G 小程序消费 handoffToken 完成绑定
### 5.1 先获取 4G 小程序 accessToken
4G 小程序先调用微信 `wx.login()`,再把返回的临时 `code` 交给 4G 后端:
```http
POST /api/v1/program/auth/login
```
请求:
```http
Content-Type: application/json
X-Request-Id: program-login-20260922-0001
```
```json
{
"code": "<4G 小程序 wx.login 返回的 code>"
}
```
curl 示例:
```bash
curl -i -sS -X POST "$BASE_URL/api/v1/program/auth/login" \
-H "Content-Type: application/json" \
-H "X-Request-Id: program-login-20260922-0001" \
--data '{"code":"<4G 小程序 wx.login 返回的 code>"}'
```
成功响应中的 `data.accessToken` 是后续绑定接口需要的程序用户 Token:
```json
{
"code": "OK",
"message": "success",
"data": {
"accessToken": "<PROGRAM_USER_ACCESS_TOKEN>",
"expiresAt": "2026-09-22T15:30:00Z",
"user": {
"userId": 10001,
"status": "active"
}
},
"requestId": "program-login-20260922-0001",
"timestamp": "2026-09-22T03:31:00Z"
}
```
必须使用 4G 小程序自己的 `wx.login()` code,不能使用 Banban 小程序的登录 code,也不能使用 `SUPER_ADMIN`、`FACTORY_PROVISIONER` 或设备 Token。
### 5.2 请求地址
```http
POST /api/v1/program/bindings/handoff
```
完整地址:
```text
https://role.api.talkingq.com/traffic-4g/api/v1/program/bindings/handoff
```
### 5.3 请求头
```http
Authorization: Bearer <PROGRAM_USER_ACCESS_TOKEN>
Content-Type: application/json
X-Request-Id: program-handoff-20260922-0001
```
该接口不要求 `Origin` 白名单。权限来自程序用户 JWT、目标小程序 AppID 校验和一次性 `handoffToken`。
### 5.4 请求体
```json
{
"handoffToken": "<接口一返回的一次性凭证>",
"requestId": "program-handoff-20260922-0001"
}
```
| 字段 | 类型 | 必填 | 约束与说明 |
| --- | --- | --- | --- |
| `handoffToken` | string | 是 | 接口一返回的一次性凭证,最长 256 个字符;不要自行修改或解码 |
| `requestId` | string | 是 | 本次绑定的幂等请求号,最长 64 个字符;网络超时重试时保持不变 |
### 5.5 curl 示例
```bash
export PROGRAM_ACCESS_TOKEN="<4G 小程序登录返回的 accessToken>"
export HANDOFF_TOKEN="<接口一返回的 handoffToken>"
curl -i -sS -X POST "$BASE_URL/api/v1/program/bindings/handoff" \
-H "Authorization: Bearer $PROGRAM_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Request-Id: program-handoff-20260922-0001" \
--data "{\"handoffToken\":\"$HANDOFF_TOKEN\",\"requestId\":\"program-handoff-20260922-0001\"}"
```
### 5.6 成功响应
HTTP 状态码为 `200`:
```json
{
"code": "OK",
"message": "success",
"data": {
"iccid": "898604E91123D1334901",
"alreadyBound": false
},
"requestId": "program-handoff-20260922-0001",
"timestamp": "2026-09-22T03:32:00Z"
}
```
| 字段 | 说明 |
| --- | --- |
| `data.iccid` | 实际绑定成功的设备 ICCID |
| `data.alreadyBound` | `false` 表示本次新建绑定;`true` 表示重复请求返回已有绑定结果 |
绑定、作废旧验证码、更新 SIM 绑定状态、记录 handoff 对应的具体绑定记录、消费凭证和审计记录在同一个数据库事务中完成。即使设备原本已绑定当前用户,也会记录复用的绑定记录 ID。接口成功后,可以调用 `GET /api/v1/program/devices` 验证当前用户的设备列表。
## 6. 幂等与重试规则
### 6.1 申请凭证重试
Banban 后端申请接口超时或未收到响应时,使用相同的 `sourceBindingId` 和 `requestId` 重试,不要重新生成请求号。这样可以复用原有有效凭证,避免创建多条 handoff 记录。
### 6.2 消费凭证重试
4G 小程序消费接口超时或网络断开时,使用相同的 `handoffToken` 和 `requestId` 重试:
- 如果事务已经成功提交,原绑定仍有效且伴伴关系未关闭,原消费用户重试返回 `alreadyBound=true`;
- 如果凭证已被其他用户消费,或对应的原绑定已解绑,返回 `HANDOFF_TOKEN_USED`;设备后来重新绑定给同一用户,也不能复用旧凭证;
- 如果伴伴已关闭此关系,返回 `HANDOFF_RELATION_CLOSED`;
- 不要因为超时就重新向 Banban 申请新的 `sourceBindingId`,应先使用原请求参数重试或查询绑定结果。
### 6.3 凭证安全规则
- `handoffToken` 只能在服务端申请、在 4G 小程序绑定流程中消费一次;
- 不要把 `BANBAN_HANDOFF_SERVICE_TOKEN` 放在小程序、浏览器代码、页面参数或日志中;
- 不要把完整 `handoffToken` 写入业务日志、埋点或群聊;
- `handoffToken` 过期、篡改或消费后不可恢复,需按原绑定关系重新申请。
## 7. 错误码与处理建议
统一错误响应结构如下,`data` 为 `null`:
```json
{
"code": "HANDOFF_TOKEN_EXPIRED",
"message": "跨系统绑定凭证已过期",
"data": null,
"requestId": "program-handoff-20260922-0001",
"timestamp": "2026-09-22T03:40:00Z"
}
```
### 7.1 申请凭证接口
| HTTP 状态 | `code` | 含义与处理 |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | ICCID、`sourceBindingId` 或 `requestId` 格式不符合约束 |
| 401 | `HANDOFF_SERVICE_UNAUTHORIZED` | 缺少或错误的 `X-Service-Token`;确认服务端密钥配置一致 |
| 403 | `DEVICE_DISABLED` | 设备状态不是 `active`;先恢复设备状态 |
| 409 | `IDEMPOTENCY_CONFLICT` | 相同幂等键对应了不同 ICCID;检查 `sourceBindingId` 和 `requestId` |
| 409 | `HANDOFF_TOKEN_USED` | 原凭证已经消费;按当前绑定结果处理或重新发起新业务请求 |
| 409 | `HANDOFF_RELATION_CLOSED` | 此伴伴关系已停止新关联;不能重新申请凭证 |
| 409 | `HANDOFF_RELATION_UNKNOWN` | 历史记录无法准确关联,需要核查,不能绕过检查重新申请 |
| 410 | `HANDOFF_TOKEN_EXPIRED` | 原凭证已过期;使用相同业务关系重新申请凭证 |
| 429 | `RATE_LIMITED` | 触发申请频率限制;降低重试频率 |
| 503 | `HANDOFF_DISABLED` | 后端未启用 handoff 流程;检查 `HANDOFF_ENABLED` |
| 503 | `CONFIGURATION_ERROR` | handoff 密钥、4G AppID 或 TTL 配置缺失/不合法 |
| 500 | `INTERNAL_ERROR` | 自动建档后无法读取 SIM 档案;需要联系后端排查数据库或事务状态 |
### 7.2 消费凭证接口
| HTTP 状态 | `code` | 含义与处理 |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | 请求体字段缺失或超过长度限制 |
| 400 | `HANDOFF_TOKEN_INVALID` | 凭证不存在、被篡改或已失效 |
| 401 | `UNAUTHORIZED` | 未登录、Token 过期或 Token 不是程序用户 Token |
| 403 | `HANDOFF_TARGET_APP_MISMATCH` | 当前登录的小程序不是接口一返回的 `targetAppId` |
| 403 | `DEVICE_DISABLED` | 设备当前被禁用 |
| 409 | `HANDOFF_TOKEN_USED` | 凭证已使用且无法匹配当前用户的重试 |
| 409 | `DEVICE_ALREADY_BOUND` | 设备已经绑定其他程序用户 |
| 409 | `IDEMPOTENCY_CONFLICT` | 相同 `requestId` 对应了不同设备或不同绑定操作 |
| 409 | `HANDOFF_RELATION_CLOSED` | 伴伴关系已关闭;已有未消费凭证不能再绑定 |
| 409 | `HANDOFF_RELATION_UNKNOWN` | 无法确认此凭证对应的原关系,需联系后端核查 |
| 410 | `HANDOFF_TOKEN_EXPIRED` | 凭证已经过期 |
| 429 | `RATE_LIMITED` | 用户、IP 或凭证消费频率过高 |
| 503 | `HANDOFF_DISABLED` | 后端未启用 handoff 流程 |
## 8. 兼容接口:裸 ICCID 直接绑定(逐步下线)
旧接口地址:
```http
POST /api/v1/program/bindings/by-iccid
```
该接口要求:
- 使用 `PROGRAM_USER` 的 `Authorization: Bearer` Token;
- 携带与页面完全一致的 `Origin`;
- `Origin` 必须存在于 `BINDING_ALLOWED_ORIGINS`;
- 后端配置 `DIRECT_ICCID_BINDING_ENABLED=true`。
当前环境已配置:
```bash
DIRECT_ICCID_BINDING_ENABLED=false
```
因此当前调用该接口会返回 HTTP `410` 和 `DIRECT_BINDING_DISABLED`。只有临时兼容旧版本联调时,才允许在服务端显式开启:
```bash
DIRECT_ICCID_BINDING_ENABLED=true
BINDING_ALLOWED_ORIGINS=https://banban.api.talkingq.com,http://192.168.101.151
```
旧接口请求示例:
```http
POST /api/v1/program/bindings/by-iccid
Origin: https://banban.api.talkingq.com
Authorization: Bearer <PROGRAM_USER_ACCESS_TOKEN>
Content-Type: application/json
X-Request-Id: direct-bind-20260922-0001
```
```json
{
"iccid": "898604E91123D1334901",
"requestId": "direct-bind-20260922-0001"
}
```
该接口无法证明 Banban 后端实际拥有该 ICCID,不应作为新的跨系统正式方案。新接入方必须使用本文档第 4、5 节的 handoffToken 流程。
## 9. 联调验收清单
建议按以下顺序验收:
1. 使用尚未登记的测试 ICCID,申请接口返回 `code=OK`、`targetAppId`、`handoffToken` 和正数 `expiresIn`。
2. 查询数据库,确认该 ICCID 已自动写入 `sim_card`,初始为 `status=active`、`binding_status=unbound`、`sync_status=pending`。
3. 使用 4G 小程序的 `wx.login()` code 登录,确认返回 `PROGRAM_USER` 的 `accessToken`。
4. 使用该 `accessToken` 消费 handoffToken,返回 `code=OK`、正确 ICCID、`alreadyBound=false`。
5. 查询 `GET /api/v1/program/devices`,确认设备出现在当前 4G 小程序用户列表中。
6. 使用相同 `sourceBindingId + requestId` 重试申请接口,确认不会重复创建 SIM 档案或凭证。
7. 使用相同用户、相同 handoffToken 和相同绑定 `requestId` 重试消费接口,确认返回幂等结果或可确认已绑定。
8. 绑定成功后调用流量详情接口,确认设备可以进入 `pending` 或同步后的流量状态,而不是 `DEVICE_NOT_FOUND`。
9. 使用其他小程序身份消费,确认返回 `HANDOFF_TARGET_APP_MISMATCH`。
10. 使用过期或篡改后的凭证,确认分别返回 `HANDOFF_TOKEN_EXPIRED` 或 `HANDOFF_TOKEN_INVALID`。
11. 使用已绑定到其他用户的设备,确认返回 `DEVICE_ALREADY_BOUND`,不会覆盖原绑定关系。
12. 确认数据库中该设备的 `sim_card.binding_status` 为 `bound`,且 `user_device_binding.status` 为 `active`。
13. 确认旧 `/bindings/by-iccid` 接口在 `DIRECT_ICCID_BINDING_ENABLED=false` 时返回 `DIRECT_BINDING_DISABLED`。
## 10. 联调排查信息
请保留以下信息用于后端排查,但不要提交 Token 或完整 ICCID:
- 请求路径和 HTTP 状态码;
- 响应中的 `code`、`message`、`requestId`;
- 请求头中的 `X-Request-Id`;
- Banban 的 `sourceBindingId` 和脱敏后的 ICCID;
- 调用时间(含时区);
- 是否使用了相同幂等参数重试。
不要在工单、群聊、代码仓库或日志中粘贴 `X-Service-Token`、`PROGRAM_USER_ACCESS_TOKEN` 或完整 `handoffToken`。
## 11. 接口三:查询这次伴伴绑定对应的 4G 关系
```http
GET /internal/v1/binding-relations/{sourceBindingId}
X-Service-Token: <BANBAN_HANDOFF_SERVICE_TOKEN>
```
查询的是 `sourceBindingId` 实际关联的具体 4G 绑定记录。原绑定解绑后,即使同一 ICCID 后来被其他用户或原用户通过其他流程重新绑定,原关系仍返回 `unbound`。若未关闭的同一伴伴关系又成功消费了新 handoff,则查询追踪这次新关联。
```bash
export BASE_URL="https://role.api.talkingq.com/traffic-4g"
export SOURCE_BINDING_ID="banban-binding-100001"
curl -i -sS "$BASE_URL/internal/v1/binding-relations/$SOURCE_BINDING_ID" \
-H "X-Service-Token: $BANBAN_HANDOFF_SERVICE_TOKEN" \
-H "X-Request-Id: relation-query-20260923-0001"
```
成功响应示例(四种业务状态均返回 HTTP 200):
```json
{
"code": "OK",
"message": "success",
"data": {
"sourceBindingId": "banban-binding-100001",
"status": "never_bound",
"pendingHandoff": true,
"acceptingHandoff": true,
"relationState": "open",
"canUnbind": false,
"reason": "NO_COMPLETED_BINDING",
"checkedAt": "2026-09-23T08:00:00Z"
},
"requestId": "relation-query-20260923-0001",
"timestamp": "2026-09-23T08:00:00Z"
}
```
| `status` | 含义 | 伴伴处理 |
| --- | --- | --- |
| `bound` | 本次伴伴关系关联的 4G 绑定仍有效 | 提示先完成 4G 解绑 |
| `unbound` | 关联的原 4G 绑定已由用户或管理员解除 | 完成关闭后,仅 `canUnbind=true` 才能解绑伴伴 |
| `never_bound` | 有关系记录,且能证明尚未完成过 handoff 绑定 | 完成关闭后,仅 `canUnbind=true` 才能解绑伴伴 |
| `unknown` | 无记录、历史无法准确关联或数据不一致 | 提示未确认,保留伴伴绑定并核查 |
| 字段 | 说明 |
| --- | --- |
| `pendingHandoff` | 是否存在 `active` 且未过期的凭证;数据库状态尚未改为 `expired` 的过期凭证不计入 |
| `acceptingHandoff` | 此关系是否仍允许申请或消费新 handoff;还需满足功能开关、身份、设备状态等原有检查 |
| `relationState` | `open` 允许关联;`closing` 已停止关联;`unknown` 表示没有关系记录 |
| `canUnbind` | 仅当关系已 `closing`、无待处理凭证,且 `status` 为 `unbound/never_bound` 时为 `true` |
| `reason` | 状态原因,例如 `NO_RELATION`、`AMBIGUOUS_HISTORY`、`ORIGINAL_BINDING_ACTIVE`、`ORIGINAL_BINDING_RELEASED`、`NO_COMPLETED_BINDING` |
| `checkedAt` | 查询时间,UTC、带 `Z` 时区;只是本次快照时间,不是后续请求的锁或授权凭据 |
该 GET 不会撤销凭证或关闭关系,响应禁止缓存。即使查到 `never_bound + pendingHandoff=false`,只要仍为 `open`,`canUnbind` 就是 `false`。伴伴不能根据一次普通 GET 直接删除绑定,必须进入下一节关闭流程。
## 12. 接口四:关闭关联并确认是否可以解绑伴伴
```http
POST /internal/v1/binding-relations/{sourceBindingId}/close
X-Service-Token: <BANBAN_HANDOFF_SERVICE_TOKEN>
Content-Type: application/json
{"requestId":"banban-close-20260923-0001"}
```
`requestId` 必填,最长 64 字符,用于审计及重试追踪;关闭操作按 `sourceBindingId` 幂等,重复调用不会恢复关系,也不会重复解绑设备。返回结构与第 11 节一致。
```bash
curl -i -sS -X POST "$BASE_URL/internal/v1/binding-relations/$SOURCE_BINDING_ID/close" \
-H "X-Service-Token: $BANBAN_HANDOFF_SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Request-Id: banban-close-20260923-0001" \
--data '{"requestId":"banban-close-20260923-0001"}'
```
此操作会锁定关系,将其设置为 `closing`,撤销所有尚未消费的凭证,再判断原绑定是否有效。不会自动解除 4G 用户绑定,也不会删除 SIM 档案或影响已有流量、充值记录。
如果 `sourceBindingId` 完全没有记录,关闭操作会创建一条拒绝后续关联的记录,但仍返回 `unknown + canUnbind=false`,不会把“没有记录”变成“证明从未绑定”。
伴伴后端的解绑顺序:
1. 用户确认解绑后,先将本地伴伴关系标记为解绑中;该状态必须持久化,并阻止所有入口发起新的 handoff。
2. 调用上述 `close`;超时、网络失败时保持解绑中并重试,不删除本地绑定。
3. 返回 `bound`:提示用户先到 4G 小程序解绑。此时 handoff 已被关闭,但原 4G 绑定继续有效,流量查询和充值仍按原权限工作。
4. 用户完成 4G 解绑后,再调用 GET 或重试 `close`;只有 `canUnbind=true` 时才能删除伴伴绑定。
5. 返回 `unknown`,或 HTTP 401/429/5xx:保留伴伴绑定,提示未确认或稍后重试。不能把异常兜底成 `unbound`。
并发结果:消费事务先获得关系锁并完成绑定时,`close` 等待后返回 `bound`;`close` 先获得锁时,正在途中的签发、消费请求等待后返回 `HANDOFF_RELATION_CLOSED`。`pendingHandoff` 是已提交凭证的快照;正在进行的消费由 `close` 的事务锁处理。
`closing` 是持续生效的关闭状态,本版本没有恢复/取消关闭接口。即使用户取消了伴伴解绑,也不会自动恢复此 ID 的 handoff 能力;已有 4G 绑定可以继续使用。只有伴伴真正建立新的绑定关系时才使用新的 `sourceBindingId`。
查询、关闭接口错误码:
| HTTP 状态 | `code` | 处理 |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | 路径 ID 为空/超长,或关闭请求缺少合法 `requestId` |
| 401 | `HANDOFF_SERVICE_UNAUTHORIZED` | 缺失或错误的服务凭证 |
| 429 | `RATE_LIMITED` | 同一关系查询和关闭合计超过每分钟 60 次;降低频率 |
| 500 | `INTERNAL_ERROR` | 数据库、事务或其他内部失败;保留伴伴关系并重试/排查 |
## 13. 本次升级部署及验收
本次新增 Flyway `V13__add_handoff_binding_relations.sql`,启动时自动建表、关联凭证并回填可明确识别的历史绑定,不修改已有 V12 迁移文件。历史凭证能通过消费用户、SIM、消费请求号唯一关联原绑定时会回填;同用户复用旧绑定但请求号不同等无法确认的情况保留为 `unknown`,由后端核查。
发布时同步更新 JAR 与 `nginx/locations.conf`,让新增 `/traffic-4g/internal/v1/binding-relations/` 路径生效;在实际承载域名的 Nginx 进程/容器中检查配置并重载。只替换 JAR 不会使 Nginx 自动开放新路径。迁移期间停止旧后端实例写入,完成迁移后再由新实例提供 handoff 服务。
新增验收场景:
1. 不带或带错误 `X-Service-Token` 调用查询、关闭,均返回 HTTP 401。
2. 查询未出现过的 `sourceBindingId`,返回 HTTP 200、`unknown`、`canUnbind=false`。
3. 申请未消费凭证后,查询返回 `never_bound`、`pendingHandoff=true`、`canUnbind=false`。
4. 同一关系申请多张凭证后关闭,全部不能再消费,返回 `never_bound`、`pendingHandoff=false`、`canUnbind=true`。
5. 成功消费后关闭,返回 `bound`、`canUnbind=false`;用户或管理员完成 4G 解绑后,查询返回 `unbound`、`canUnbind=true`。
6. 原绑定解绑后,同一 ICCID 被其他用户或原用户通过其他流程绑定,旧关系查询仍为 `unbound`;旧已消费凭证不能变成新绑定的重试凭证。
7. 同一 `sourceBindingId` 使用不同 `requestId` 换 ICCID,返回 `IDEMPOTENCY_CONFLICT`。
8. 并发执行关闭与签发/消费,结果只能是“先绑定成功,关闭返回 bound”或“关闭成功,后续关联失败”。
9. 重复关闭结果保持关闭状态;没有历史的关系关闭后仍为 `unknown`。
10. 历史不明确的数据保持 `unknown`,不得通过换申请请求号绕过。
自动化验证:普通单元和接口测试执行 `mvn test`;真实 MySQL 事务测试为 `HandoffBindingRelationIntegrationTest`,需要显式配置 `HANDOFF_TEST_JDBC_URL`、`HANDOFF_TEST_DB_PASSWORD`(可选 `HANDOFF_TEST_DB_USERNAME`,默认 root),指向全新的空测试库后执行 `mvn -Dtest=HandoffBindingRelationIntegrationTest test`。该测试只使用指定测试库,不读取生产 `.env`;数据库非空时会拒绝执行。