# 图奕统一认证服务 API

本文面向网云穿、远程控制和异地组网的服务端。产品客户端、浏览器和桌面 Native Client 不得持有 Service Client Secret，也不得直接调用本 API。用户登录请看 [OIDC](OIDC.md)，桌面不跳浏览器的边界见 [Native](NATIVE.md)。

> 外部短信是“模板发送服务”，不是“平台登录认证服务”。发送产品提供的六位数字不会创建平台登录挑战，也不能换取用户 Token。不要用该接口替代统一登录。

## 认证模型

用户登录与服务调用使用两套独立客户端：

- OIDC Web/Native Client：只用于用户登录，Scope 为 `openid profile phone offline_access`。
- Confidential Service Client：只部署在产品服务端，Scope 为 `sms:send sms:read payments:create payments:read refunds:create` 的授权子集。

获取 5 分钟服务令牌：

```http
POST /oauth2/token HTTP/1.1
Host: platform.too1.net
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=sms%3Asend%20payments%3Aread
```

成功响应为标准 OAuth JSON。Service Token 是不透明字符串，不包含用户身份、不签发 Refresh Token。产品应在到期前重新获取并只保存在服务端内存中。

Service Client 支持多个并行密钥。轮换后旧密钥默认保留 24 小时；调用方确认新密钥生效后，可在后台立即吊销旧密钥。可为客户端配置来源 CIDR 白名单。

## 通用约定

- 基础地址：`https://platform.too1.net`
- 所有请求与响应使用 UTF-8 JSON。
- 金额使用整数分，首期币种固定为 `CNY`。
- 所有写接口必须携带 `Idempotency-Key`，长度 1–64 字符，并在同一 Service Client 下唯一。
- 推荐 Key 只用 ASCII，添加操作与资源前缀：`sms:订单或操作编号`、`pay:订单号`、`close:平台支付ID`、`refund:退款号`。Key 是该 Client 下跨接口共享的，不是每条路由独立；关闭不同订单不能重复使用同一个 Key。
- 当前摘要按原始 JSON 请求字节计算，空格、字段顺序变化也可能冲突。重试必须复用同一份序列化结果，不能只保证 JSON 语义相同。
- 幂等记录当前保存至少约 24 小时，过期由维护任务清理；不能依赖它永久防重，产品仍需唯一业务单号和可靠记录。
- 同一幂等键和相同请求体会重放首次结果，并返回 `Idempotent-Replayed: true`。
- 同一幂等键和不同请求体返回 HTTP `409 idempotency_conflict`。
- 成功响应：`{"data": ...}`。
- 失败响应：`{"error":{"code":"...","message":"...","request_id":"..."}}`。
- `X-Request-ID` 可用于后台和结构化日志定位，不要在日志中记录 Bearer Token 或请求中的短信参数。
- JSON 请求体上限 64 KiB，仅接受单个 JSON 对象，不接受未定义字段。OIDC Token 端点仍是表单与标准 OAuth JSON，不使用平台 `data` 包装。

## Scope 与 Token 缓存

| Scope | 可调用方法 |
| --- | --- |
| `sms:send` | POST 短信发送 |
| `sms:read` | GET 短信记录 |
| `payments:create` | POST 支付会话、关闭支付 |
| `payments:read` | GET 支付、GET 退款 |
| `refunds:create` | POST 退款申请 |

仅有 `refunds:create` 不代表可以查询退款，读取仍需 `payments:read`。省略 scope 时平台按 Client 授权集合发令牌；建议显式请求最小权限。

产品服务端按 Client 和 Scope 集合缓存 Token，依据 `expires_in` 提前约 30 秒更新；并发请求合并取 Token，避免每条业务请求重新获取。Client Credentials 没有 Refresh Token，到期重新申请。网络请求必须有超时，401 时先检查类型/禁用状态；不要盲目刷新并自动重放具有副作用的业务请求。

无副作用的取令牌示例（curl 会提示输入 Secret；避免把密码写进 shell 历史或 `-v` 日志；输出包含 Token，不要截图外发）：

```bash
curl --fail-with-body --max-time 10 \
  --user wyc-service \
  --data-urlencode grant_type=client_credentials \
  --data-urlencode 'scope=sms:send sms:read' \
  https://platform.too1.net/oauth2/token
```

业务程序应使用 HTTP 客户端和内存缓存，不通过 shell 调用带 Token 的命令。Basic 认证按 OAuth 库编码，不把 Secret 放查询参数。CIDR 限制使用产品服务端真实出口地址，不使用用户电脑 IP。

## 短信

发送单号码事务短信：

```http
POST /api/v1/sms/messages
Authorization: Bearer SERVICE_TOKEN
Idempotency-Key: wyc-login-20260902-000001
Content-Type: application/json

{
  "template_code": "login_code",
  "phone_e164": "+8613812345678",
  "params": {"code": "123456"}
}
```

平台模板代码会在后台映射到联麓 Template ID；产品看不到渠道模板 ID。参数名称、顺序、长度和正则由管理员登记的 Schema 决定。参数值只在本次渠道调用中使用，不落库、不写日志。

### 请求字段

| 字段 | 类型 | 规则 |
| --- | --- | --- |
| template_code | string | 后台已登记并授权给产品的模板代码，不是联麓模板 ID |
| phone_e164 | string | 当前仅中国大陆 `+86` 手机号，不接受数组 |
| params | object<string,string> | 字段集合与模板 Schema 完全相同，不缺字段、不额外添加字段 |

例如 Schema 规定 `code` 为六位数字，必须传 `{"code":"012345"}`，不能传数字 `12345` 丢掉前导零。参数顺序由后台 Schema 决定；前端页面不能任意选择模板或群发名单。

默认每产品 10 RPS、突发 20、每天 10,000 条；同手机号同模板 60 秒一次、每天 10 次，跨模板每天 20 次。管理员可调整授权额度；以实际限制及返回值为准。没有模板授权默认拒绝。

返回规则：

- `201 accepted`：渠道明确受理。
- `202 unknown`：连接超时或结果未知，平台不会自动重发；产品应查询消息状态并避免立即换幂等键重复发送。
- `502 provider_rejected`：渠道明确拒绝。
- `403 template_not_granted`：产品没有模板授权。
- `429 quota_exceeded` / `rate_limited`：额度或频率限制。

受理返回示例：

```json
{
  "data": {
    "message_id": "00000000-0000-4000-8000-000000000030",
    "template_code": "login_code",
    "phone": "+86****0000",
    "parameter_names": ["code"],
    "status": "accepted",
    "provider": "lianlu",
    "provider_reference": "PROVIDER_TASK_REFERENCE",
    "latency_ms": 180,
    "created_at": "2026-09-03T00:00:00Z",
    "updated_at": "2026-09-03T00:00:00Z"
  }
}
```

示例脱敏格式不应作为解析协议；原样显示即可。可选字段可能省略。accepted 仅代表渠道受理，不代表手机已经收到；unknown 查询也可能仍是 unknown，当前没有向产品承诺最终送达回执。

### 失败与未知结果

同键重试只用于领取已经完成的原请求结果，不代表再次发送。收到明确拒绝需要修正原因后，若用户重新发起一次新的发送，使用新的操作 Key。遇到连接中断、202 unknown 或结果保存异常，应先按已知 message_id 查询；没有 ID 则根据请求编号联系运维，不能自动换 Key 重发。

发送按钮倒计时由产品界面负责，平台不会给产品页面自动加倒计时。建议默认 60 秒，处理 429/503/未知结果并禁用重复点击；不要在失败回调中立即再次发短信。需要产品自行验证的业务验证码应独立保存 HMAC/哈希、用途、过期、次数、单次消费，不能与平台登录验证码混用。

查询发送记录：

```http
GET /api/v1/sms/messages/{message_id}
Authorization: Bearer SERVICE_TOKEN
```

记录仅返回脱敏手机号、模板代码、状态、渠道任务号和耗时，不返回模板参数值。

## 支付与退款

支付 API、收银台、状态机、回调核对和退款规则见 [PAYMENTS.md](PAYMENTS.md)。

## Webhook

平台以至少一次语义投递产品事件。请求头：

- `X-Tuyi-Event-ID`：产品幂等键。
- `X-Tuyi-Timestamp`：Unix 秒时间戳。
- `X-Tuyi-Signature`：`sha256=HEX(HMAC-SHA256(secret, timestamp + "." + raw_body))`。

支付事件包括 `payment.succeeded`、`payment.closed`、`payment.duplicate_suspected`、`refund.pending_approval`、`refund.succeeded`、`refund.failed`。账号事件包括 `user.disabled`、`user.enabled`、`user.phone_changed`、`user.sessions_revoked` 和 `mapping.created`；产品成员事件包括 `product_membership.activated`、`product_membership.suspended`、`product_membership.reactivated`。成员邀请与产品/代理职责边界见[产品成员文档](MANAGED_USERS.md)。

产品必须先验证时间窗口与签名，再按事件 ID 幂等入库，最后交付权益。平台返回支付成功不等于产品权益已交付；Webhook 失败会指数退避并在后台显示积压。

完整消息结构、Go 验签函数、数据库 inbox 幂等及乱序处理见 [Webhook 详细文档](WEBHOOKS.md)。常见 HTTP 与业务错误见[排障文档](TROUBLESHOOTING.md)。

## 最小接入顺序

1. 管理员创建产品并登记精确的 HTTPS 收银台返回地址。
2. 创建 Service Client，仅授予产品需要的 Scope，并保存一次性显示的 Secret。
3. 为产品授权短信模板、绑定支付账户并保持事故开关关闭。
4. 产品服务端实现 Token 缓存、幂等键、Webhook 验签与事件幂等。
5. 在 staging 使用模拟支付和假渠道完成闭环。
6. 分别完成真实短信、微信/支付宝 0.01 元支付、查询、回调和退款后，才开启相应生产开关。
