# 无足鸟（用户，auth）— 对接指南

> 本文档面向 **AI 助手与对接开发者**，是本服务对外可调用接口的**全量说明**。
> 读完本文档即可：正确调用本服务全部公开接口、以第一方或第三方方式接入、
> 正确处理令牌与撤销同步。

---

## 1. 服务定位与边界

- 为无足鸟生态**提供认证、授权、审计**的服务：注册 / 登录 / 微信登录 / 指纹登录 / 邮箱验证码 /
  资料管理 / 账号别名与合并 / 账号删除，以 OIDC 标准方式签发 RS256 令牌。
  合并/删号标记完全收敛在本服务内部（业务项目零合并逻辑、业务库永不改写 user_id，
  详见 9.3）。
- **数据边界**：本服务只持有用户账号数据，不持有任何业务数据（计划、打卡、通知渠道等
  归业务项目）。业务项目需要用户数据（email/openid/角色等）时，由业务项目**调用本服务**
  的 `/internal/*` 接口获取（契约见第 7 节）；本服务**绝不回调业务项目**
  （callback 机制已废弃），业务项目也不得要求本服务直连其业务库。
- 独立部署、独立域名 `auth.wuzuniao.com`。

## 2. 服务地址

| 环境 | Base URL | OIDC Issuer |
|------|----------|-------------|
| 生产 | `https://auth.wuzuniao.com` | `https://auth.wuzuniao.com` |
| 开发 | `http://localhost:11000` | `http://localhost:11000` |

- `ISSUER` 与 Base URL 一致，即 JWT 的 `iss` 声明。接入方验签时必须校验 `iss` 与
  本服务 `ISSUER` **完全一致**，否则视为无效令牌。
- 接入方应以发现文档 `GET {BASE}/.well-known/openid-configuration` 的返回值为准
  获取各端点地址，而不是硬编码。

## 3. 通用调用约定（必读）

### 3.1 响应格式

**业务接口**（`/api/v1/users/*`、`/internal/*`）成功时：

```json
{"code": 0, "msg": "success", "data": { ... }}
```

**失败时**（所有接口一致）：HTTP 状态码为 4xx/5xx，响应体统一为：

```json
{"detail": "错误描述（中文）"}
```

- `422`（参数校验失败）时 `detail` 为数组：`[{"loc": ["body","username"], "msg": "...", "type": "..."}]`。
- **OAuth 标准端点例外**：
  - `POST /oauth/token` 成功直接返回令牌 JSON（无 `code`/`msg` 包裹）；
  - `POST /oauth/revoke` 成功返回 `{}`（RFC 7009）；
  - `GET /oauth/authorize` 成功为 `302` 重定向（无 JSON 体）。

### 3.2 HTTP 状态码语义

| 状态码 | 含义 | 典型场景 |
|--------|------|----------|
| 200 | 成功（`code=0`） | — |
| 302 | 重定向 | `/oauth/authorize` 授权成功 |
| 400 | 业务校验失败 | 验证码错误、用户名已注册、client_id 未注册、PKCE 失败 |
| 401 | 未认证 | 未携带/无效/过期 Bearer 令牌；`X-Service-Token` 错误；指纹凭证无效 |
| 404 | 资源不存在 | 用户不存在（`/users/info`、`/internal/users/{id}`） |
| 422 | 请求参数不合法 | 字段校验失败（`detail` 为数组） |
| 429 | 触发限流 | 见 3.4 |
| 500 | 服务端错误 | 邮件发送失败、微信接口异常等 |

### 3.3 认证方式

| 方式 | 用法 | 适用端点 |
|------|------|----------|
| 用户令牌 | 请求头 `Authorization: Bearer <access_token>` | `/api/v1/users/*` 受保护端点、`GET /oauth/authorize` |
| 服务间令牌 | 请求头 `X-Service-Token: <服务间令牌>` | `/internal/*` 全部端点 |

- access_token 由本服务 RS256 签发（见第 8 节）；未携带或无效返回 401。
- `X-Service-Token` 的值属于对接机密，由服务方在开通接入时提供；缺失或不匹配一律 401。
  仅在服务端存储与使用，切勿写入前端代码或版本库。

### 3.4 限流（触发返回 429）

按**客户端 IP + 接口路径**维度计数；超限返回 429 `{"detail": "请求过于频繁，请 60 秒后重试"}`。

| 端点组 | 限额（滑动窗口） |
|--------|------------------|
| `login` / `wechat-login` / `biometric-login` | 5 次/分钟 |
| `register` | 3 次/分钟 |
| `send-code` 及全部验证码发送端点 | 3 次/分钟 |
| `reset-password` | 3 次/分钟 |
| 登录态接口（Bearer 保护） | 60 次/分钟 |
| `/oauth/token`、`/oauth/revoke` | 10 次/分钟 |

### 3.5 client_id（接入方标识）

- 每个业务项目在 `oauth_clients` 表注册一个公共 client（**无 client_secret**，
  安全由 PKCE 保证），client_id 为公开信息，接入方请求时携带。
- `/users/*` 登录类端点中 `client_id` 为**可选**字段：缺省回落服务端配置的默认 client，
  第三方接入应**始终显式传自己的 client_id**；格式 `^[A-Za-z0-9_-]{1,64}$`；
  未注册的 client_id 返回 400 `{"detail": "client_id 未注册：<id>"}`。

### 3.6 CORS

- 仅白名单源可跨域浏览器调用，白名单由服务方配置；接入时向服务方提供你的站点域名。
- 微信小程序 / App / 服务端到服务端调用不携带 `Origin`，不受 CORS 限制。

### 3.7 通用字段校验规则

| 字段 | 规则 |
|------|------|
| `username` | 2-15 位，仅中文 / 英文 / 数字 |
| `password` | 8-20 位，大小写字母、数字、特殊符号至少三种；不做首尾去空格 |
| `code`（验证码） | 6 位数字；有效期 **5 分钟**；一次性（验证通过立即销毁）；连错 5 次销毁需重新获取 |
| `device_id` | 标准 UUID v4 格式 |
| `avatar_url` | ≤500 字符；允许 `http(s)://`、`/` 开头相对路径、短标识符；禁止 `javascript:`/`data:`/`vbscript:`/`file:` |
| `signature` | ≤70 字符 |
| `email` | 标准邮箱格式 |

验证码按用途独立隔离：`register`（注册）、`reset`（找回密码）、`change_old`（换绑-旧邮箱）、
`change_new`（换绑/绑定-新邮箱），互不可混用。

## 4. 端点全量总览

| 分组 | 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|------|
| 系统 | GET | `/` | 无 | 站点首页（浏览器端 HTML 首页 index.html） |
| 系统 | GET | `/health` | 无 | 健康检查，`{"status":"ok"}` |
| 系统 | GET | `/readme` | 无 | 下载 README.md 对接指南（附件响应） |
| OIDC | GET | `/.well-known/openid-configuration` | 无 | 发现文档 |
| OIDC | GET | `/.well-known/jwks.json` | 无 | RS256 公钥集 |
| OIDC | GET | `/oauth/authorize` | Bearer | 授权码入口（PKCE，302） |
| OIDC | POST | `/oauth/token` | 无（PKCE） | 换取 / 刷新令牌 |
| OIDC | POST | `/oauth/token-exchange` | Bearer | 统一交换端点（项目令牌 ↔ 平台令牌双向互换，N25=a） |
| OIDC | POST | `/oauth/revoke` | 无 | 撤销 refresh_token（RFC 7009） |
| 用户 | POST | `/api/v1/users/send-code` | 无 | 发送注册验证码 |
| 用户 | POST | `/api/v1/users/register` | 无 | 注册（返回登录态） |
| 用户 | POST | `/api/v1/users/login` | 无 | 账号密码登录 |
| 用户 | POST | `/api/v1/users/wechat-login` | 无 | 微信一键登录（小程序） |
| 用户 | POST | `/api/v1/users/biometric-login` | 无 | 指纹一键登录 |
| 用户 | POST | `/api/v1/users/send-reset-code` | 无 | 发送找回密码验证码 |
| 用户 | POST | `/api/v1/users/reset-password` | 无 | 重置密码（返回登录态） |
| 用户 | GET | `/api/v1/users/info` | Bearer | 当前用户信息（兼作 OIDC userinfo） |
| 用户 | PUT | `/api/v1/users/update-username` | Bearer | 修改用户名 |
| 用户 | PUT | `/api/v1/users/update-signature` | Bearer | 修改签名 |
| 用户 | PUT | `/api/v1/users/update-avatar` | Bearer | 修改头像 |
| 用户 | PUT | `/api/v1/users/set-password` | Bearer | 无密码用户首次设置密码 |
| 用户 | PUT | `/api/v1/users/change-password` | Bearer | 修改密码（旧令牌全部失效） |
| 用户 | POST | `/api/v1/users/send-change-email-old-code` | Bearer | 发送旧邮箱验证码 |
| 用户 | POST | `/api/v1/users/send-change-email-new-code` | Bearer | 发送新邮箱验证码 |
| 用户 | PUT | `/api/v1/users/change-email` | Bearer | 修改邮箱 |
| 用户 | PUT | `/api/v1/users/bind-email` | Bearer | 绑定邮箱（命中已注册邮箱时 auth 内部自动合并，N15） |
| 用户 | POST | `/api/v1/users/bind-wechat` | Bearer | 绑定微信 |
| 用户 | POST | `/api/v1/users/biometric-revoke` | Bearer | 撤销当前设备指纹凭证 |
| 用户 | POST | `/api/v1/users/schedule-deletion` | Bearer | 发起删号（24h 冷静期；person+全部别名） |
| 用户 | POST | `/api/v1/users/cancel-deletion` | Bearer | 取消删号（别名随主恢复） |
| 用户 | POST | `/api/v1/users/logout` | Bearer | 退出登录（撤销全部令牌） |
| 审计 | GET | `/api/v1/audit/merges` | Bearer（管理员） | 账号合并审计列表 |
| 审计 | GET | `/api/v1/audit/deletions` | Bearer（管理员） | 账号删号审计列表 |
| 审计 | GET | `/api/v1/audit/logins` | Bearer（管理员） | 用户登录/登出审计列表（登录成功与失败均记录，含失败原因） |
| 公告管理 | GET/POST | `/api/v1/admin/announcements` | Bearer（管理员） | 公告列表 / 发布（target_clients 多项目，须已注册） |
| 公告管理 | PUT/DELETE | `/api/v1/admin/announcements/{id}` | Bearer（管理员） | 公告更新 / 删除（删除级联清理已读记录） |
| 公告管理 | GET/POST | `/api/v1/admin/announcements/templates` | Bearer（管理员） | 公告模板列表 / 新建（多条模板，含默认目标项目） |
| 公告管理 | PUT/DELETE | `/api/v1/admin/announcements/templates/{id}` | Bearer（管理员） | 模板更新 / 删除 |
| 公告 | GET | `/api/v1/announcements` | Bearer（业务直连） | 本项目可见公告（分页；每条含 is_read，data 附 unread_count/has_more） |
| 公告 | GET | `/api/v1/announcements/unread-count` | Bearer（业务直连） | 公告未读数（通知组件查询） |
| 公告 | POST | `/api/v1/announcements/{id}/read` | Bearer（业务直连） | 标记单条已读（幂等；响应 data.unread_count 为最新未读数） |
| 公告 | POST | `/api/v1/announcements/read-all` | Bearer（业务直连） | 当前项目全部已读（data.marked 为本次标记条数） |
| 内部 | GET | `/internal/users/{user_id}` | X-Service-Token | 查询用户基本信息（入参 id 别名解析） |
| 内部 | GET | `/internal/users/{user_id}/openid` | X-Service-Token | 查询用户小程序 openid（按别名集合搜索） |
| 内部 | GET | `/internal/revocations` | X-Service-Token | 令牌撤销增量（含 person 全部身份 id 扇出记录） |
| 内部 | POST | `/internal/membership/grant` | X-Service-Token | 会员购买授权（order_no 幂等；续费顺延；计价时长由业务项目服务端下发） |

## 5. 用户模块接口详解（`/api/v1/users/*`）

### 5.0 登录态响应结构（多接口复用）

`register` / `login` / `wechat-login` / `biometric-login` / `reset-password` / `bind-email`
成功时 `data` 均为以下结构（调用方应持久化 `access_token` 与 `refresh_token`）：

```json
{
  "id": 1,
  "username": "张三",
  "signature": "签名",
  "avatar_url": "/static/avatar.png",
  "email": "user@example.com",
  "has_password": true,
  "status": 1,
  "role": 0,
  "is_wechat_bound": true,
  "access_token": "<RS256 JWT>",
  "refresh_token": "<不透明随机串>",
  "expires_in": 259200,
  "token_type": "Bearer",
  "biometric_token": "<可选：携带 device_id 调用 login/reset-password 时下发>"
}
```

- `status`：`1` 正常；`0` 待删除（冷静期内仍可登录并取消删除）；其他值视为停用
  （受 Bearer 保护的端点一律 401）。
- `role`：`0` 普通用户；`7` 管理员。
- `has_password`：微信注册等无密码账号为 `false`，可调 `/set-password` 补设。
- `is_wechat_bound`：是否已绑定微信小程序账号（绑定后业务项目可向其下发微信通知）。
- `expires_in`：access_token 有效期（秒），默认 3 天 = 259200。

---

### 5.1 POST `/api/v1/users/send-code` — 发送注册验证码

请求体：`{"email": "user@example.com"}`

```http
POST /api/v1/users/send-code HTTP/1.1
Content-Type: application/json

{"email": "user@example.com"}
```

成功：`{"code": 0, "msg": "验证码已发送", "data": null}`
失败：400 邮箱已被注册；422 邮箱格式非法；500 邮件发送失败（可重试）。

### 5.2 POST `/api/v1/users/register` — 注册

请求体：

| 字段 | 必填 | 说明 |
|------|------|------|
| `username` | 是 | 2-15 位中文/英文/数字 |
| `password` | 是 | 8-20 位，三类字符 |
| `email` | 是 | 收到验证码的邮箱 |
| `code` | 是 | 6 位数字（`register` 用途） |
| `client_id` | 否 | 接入方标识，缺省用服务端默认 client |

```http
POST /api/v1/users/register HTTP/1.1
Content-Type: application/json

{"username": "张三", "password": "Abc12345!", "email": "user@example.com", "code": "123456", "client_id": "myapp"}
```

成功：`{"code": 0, "msg": "注册成功", "data": <5.0 登录态结构>}`（注册即登录）。
失败：400 验证码错误或已过期 / 用户名已被注册 / 邮箱已被注册。

### 5.3 POST `/api/v1/users/login` — 账号密码登录

请求体：`username`（**用户名或邮箱均可**）、`password`、可选 `device_id`、`client_id`。

```http
POST /api/v1/users/login HTTP/1.1
Content-Type: application/json

{"username": "user@example.com", "password": "Abc12345!", "device_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "client_id": "myapp"}
```

- 成功：`{"code": 0, "msg": "登录成功", "data": <5.0 登录态结构>}`。
- **携带合法 `device_id` 时**，`data` 额外含 `biometric_token`（14 天有效），供后续指纹登录。
- 失败：400 `{"detail": "用户名或密码错误"}`（用户不存在与密码错误统一文案，防枚举）。
- 待删除账号（status=0）可正常登录，用于取消删除。

### 5.4 POST `/api/v1/users/wechat-login` — 微信一键登录（小程序）

请求体：`code`（前端 `wx.login()` 获取，**与发码小程序 appid 绑定**）、**必填** `app_id`
（发码小程序的 appid，格式 `wx` + 16 位十六进制）、可选 `client_id`。

```http
POST /api/v1/users/wechat-login HTTP/1.1
Content-Type: application/json

{"code": "<wx.login code>", "client_id": "myapp", "app_id": "wx1234567890abcdef"}
```

- **多小程序接入**：每个业务项目小程序各有自己的 appid/secret，`code` 仅对其发码
  appid 有效——接入方**必须传自己的 `app_id`**（`wx.login()` 的 code 换 openid 时
  appid/secret 须与发码小程序一致，否则微信返回 invalid code）；服务端按 `app_id`
  解析对应密钥（`.env` 按小程序成对登记：`WX_APPID_<项目>`/`WX_APP_SECRET_<项目>`，
  如 `_YAO`/`_BAI`/`_JIN`）。
- 服务端校验 code 并换取 openid：已绑定则直接登录；未绑定则**静默创建账号**（无密码、
  无邮箱，服务端生成默认资料）。
- `session_key` 由服务端安全保管，不下发。
- 成功：`{"code": 0, "msg": "登录成功", "data": <5.0 登录态结构>}`。
- 失败：400 code 无效 / appid 未接入；422 app_id 缺失或格式不合法；500 微信服务异常（通用文案，细节仅入日志）。

### 5.5 POST `/api/v1/users/biometric-login` — 指纹一键登录

请求体：`token`（此前 `/login` 下发的 `biometric_token`）、`device_id`（UUID v4，须与凭证
绑定的设备一致）、可选 `client_id`。

```http
POST /api/v1/users/biometric-login HTTP/1.1
Content-Type: application/json

{"token": "<biometric_token>", "device_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "client_id": "myapp"}
```

- 生物特征验证由端侧系统层（Touch ID / Windows Hello / SOTER 等）完成，本服务只校验
  凭证有效性 + 设备绑定。
- 成功：`{"code": 0, "msg": "登录成功", "data": <5.0 登录态结构>}`。
- 失败：401 `{"detail": "指纹登录凭证无效或已失效，请使用账号密码登录"}`；停用账号同样返回 401。

### 5.6 找回密码（两步）

**第一步** `POST /send-reset-code`：`{"email": "user@example.com"}`（仅允许已注册邮箱；
400 表示邮箱未注册——此端点不防枚举，调用方可自行决定提示策略）。

**第二步** `POST /reset-password`：

```json
{"email": "user@example.com", "code": "123456", "new_password": "New12345!", "device_id": "<可选 UUID v4>", "client_id": "myapp"}
```

- 成功：`{"code": 0, "msg": "密码重置成功", "data": <5.0 登录态结构>}`（等效重新认证，
  携带 device_id 时同样下发 `biometric_token`）。
- 全部旧令牌（access/refresh）撤销，其他端需重新登录。

### 5.7 GET `/api/v1/users/info` — 当前用户信息

```
GET /api/v1/users/info HTTP/1.1
Authorization: Bearer <access_token>
```

成功 `data` 为 5.0 结构中的资料字段（`id/username/signature/avatar_url/email/
has_password/status/role/is_wechat_bound`，不含任何令牌字段）：

```json
{"code": 0, "msg": "success", "data": {"id": 1, "username": "张三", "signature": "", "avatar_url": "", "email": "user@example.com", "has_password": true, "status": 1, "role": 0, "is_wechat_bound": false}}
```

- **兼作 OIDC userinfo 端点**（发现文档 `userinfo_endpoint` 指向此处）。
- 404 表示账号已删除，调用方应清除本地登录态。

### 5.8 资料与密码修改（均需 Bearer，均限流 60/min）

| 端点 | 请求体 | 成功 data | 失败示例 |
|------|--------|-----------|----------|
| `PUT /update-username` | `{"new_username": "李四"}` | `{id, username}` | 400 已被占用；422 格式非法 |
| `PUT /update-signature` | `{"signature": "签名"}` | `{id, username, signature}` | 422 超 70 字符 |
| `PUT /update-avatar` | `{"avatar_url": "https://cdn.example.com/a.png"}` | `{id, username, avatar_url}` | 422 协议不合法 |
| `PUT /set-password` | `{"new_password": "Abc12345!"}` | `{id, username}` | 400 已设密码；422 格式非法 |
| `PUT /change-password` | `{"old_password": "...", "new_password": "..."}` | `{id, username}` | 400 旧密码错误；422 格式非法 |

- **`/change-password` 语义**：成功后该用户全部旧令牌撤销（本服务侧立即失效；业务项目经
  撤销同步约 5 分钟内失效），请求方需重新登录获取新令牌。
- **`/set-password`**：仅供无密码账号（`has_password=false`）首次设置密码，已设密码的账号
  调用返回 400。

### 5.9 邮箱换绑 / 绑定（均需 Bearer）

三步流程（修改邮箱，要求账号已有邮箱）与两步流程（无邮箱用户绑定）：

```text
1. POST /send-change-email-old-code        body: {}        （向当前邮箱发码，仅修改邮箱场景需要）
2. POST /send-change-email-new-code        body: {"new_email": "new@example.com", "allow_existing": false}
3. PUT  /change-email  body: {"old_code": "111111", "new_email": "new@example.com", "new_code": "222222"}
```

- `allow_existing` 默认 `false`：新邮箱已被注册时发送端点返回 400；传 `true` 仅用于
  「绑定邮箱触发账号合并」场景（新邮箱允许已被注册）。
- `PUT /bind-email`（仅限**无邮箱账号**首次绑定，已有邮箱账号调用返回 400）：
  `{"new_email": "new@example.com", "new_code": "222222", "client_id": "myapp"}`
  - **若该邮箱已被注册 → auth 内部单事务自动合并**（N15，无 need_merge 协同、无业务项目
    上报）：邮箱属主为主账号（字段冲突属主优先、属主缺失从账号填充），当前登录者降为别名行
    （merged_into 指向属主，永不物理删除）；**不撤销任何令牌**（N8，合并前双方令牌均继续有效）；
    绑定自己 person 已有邮箱时验证码通过后幂等成功（N45）。
  - 响应 = 合并后该项目认识的 identity id + 属主资料 + **全新令牌组**（5.0 结构），
    调用方必须整体替换本地登录态。
- `PUT /change-email` 成功 data：`{id, username, email}`（不换发令牌）；
  失败均 400（旧/新邮箱验证码错误或已过期、新邮箱已被注册）。

### 5.10 POST `/api/v1/users/bind-wechat` — 绑定微信

需 Bearer；请求体 `{"code": "<wx.login code>"}`。

- 将 openid 关联到当前用户（供业务项目下发微信通知）。
- 成功：`{"code": 0, "msg": "微信绑定成功", "data": {"id": 1, "username": "张三", "is_wechat_bound": true}}`。
- 幂等：同一微信重复绑定到本人账号视为成功。
- 失败：400 该微信已绑定其他用户；500 服务异常。

### 5.11 指纹凭证管理

| 操作 | 端点 | 说明 |
|------|------|------|
| 下发凭证 | `/login` 或 `/reset-password` 携带 `device_id` | 响应含 `biometric_token`，14 天有效 |
| 续期凭证 | `POST /oauth/token`（refresh grant）携带 `device_id` | 顺延 14 天 |
| 使用凭证 | `POST /biometric-login` | 见 5.5 |
| 撤销凭证 | `POST /biometric-revoke`（Bearer） | body `{"device_id": "..."}`，仅撤销该设备 |

- 修改密码 / 预约删除账号会作废该用户**全部**指纹凭证；退出登录**不**作废（便于下次免密）。

### 5.12 账号生命周期（均需 Bearer）

| 端点 | 请求体 | 行为 |
|------|--------|------|
| `POST /schedule-deletion` | `{}`（可空） | `status→0`（24h 冷静期）；**撤销全部令牌与指纹凭证** |
| `POST /cancel-deletion` | `{}`（可空） | `status→1`，取消删除（仅冷静期内可用） |
| `POST /logout` | `{}`（可空） | 写撤销日志 + 撤销全部 refresh_token + 销毁 SSO 会话；**不**作废指纹凭证 |

- 删除执行方式（完全收敛本服务内部，2026-10-01 起）：冷静期届满后由本服务定时扫描
  （60 秒）标记 `status=2`（不可登录）并清理 auth 自有数据（绑定行/refresh_token/
  指纹凭证/SSO 会话/授权码）+ 按全部身份 id 扇出撤销——业务项目零上报、零轮询；
  账号主记录保留以支持「忘记密码」找回，业务数据残留永久接受（详见 9.3）。

## 6. OIDC 标准端点

### 6.1 GET `/.well-known/openid-configuration` — 发现文档

```json
{
  "issuer": "https://auth.wuzuniao.com",
  "authorization_endpoint": "https://auth.wuzuniao.com/oauth/authorize",
  "token_endpoint": "https://auth.wuzuniao.com/oauth/token",
  "revocation_endpoint": "https://auth.wuzuniao.com/oauth/revoke",
  "userinfo_endpoint": "https://auth.wuzuniao.com/api/v1/users/info",
  "jwks_uri": "https://auth.wuzuniao.com/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "code_challenge_methods_supported": ["S256"],
  "subject_types_supported": ["public"],
  "scopes_supported": ["openid"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_basic"]
}
```

### 6.2 GET `/.well-known/jwks.json` — 公钥集

返回 JWKS（RS256 公钥集）。业务项目**启动时拉取一次并缓存**，按 JWT header 中的
`kid` 匹配公钥本地验签；遇到未知 `kid` 自动重拉（支持密钥轮换）。日常请求零网络调用。

### 6.3 GET `/oauth/authorize` — 授权码入口（授权码 + PKCE）

**鉴权：`Authorization: Bearer <access_token>`**（SPA 场景：接入方前端持本服务签发的
access_token 调用；未登录返回 401，由接入方引导登录后重试）。

Query 参数：

| 参数 | 必填 | 说明 |
|------|------|------|
| `response_type` | 是 | 固定 `code` |
| `client_id` | 是 | 已注册的接入方 |
| `redirect_uri` | 是 | 必须在 `oauth_clients.redirect_uris` 白名单（换行分隔，逐条精确匹配） |
| `scope` | 否 | 默认 `openid`（当前仅支持此 scope） |
| `state` | 建议 | 原样回传（防 CSRF） |
| `code_challenge` | 是 | `BASE64URL(SHA256(ASCII(code_verifier)))` |
| `code_challenge_method` | 是 | 固定 `S256` |

成功：`302` 重定向到 `{redirect_uri}?code=<授权码>&state=<原样回传>`。
失败：400（client 未注册 / response_type 错 / redirect_uri 不在白名单 / 缺 PKCE / scope 不支持）。
授权码 **5 分钟有效、一次性**。

### 6.4 POST `/oauth/token` — 换取 / 刷新令牌

请求体兼容 `application/x-www-form-urlencoded` 与 `application/json`。
成功直接返回令牌 JSON（**无 code/msg 包裹**）：

```json
{"access_token": "<RS256 JWT>", "refresh_token": "<不透明串>", "expires_in": 259200, "token_type": "Bearer"}
```

**grant_type=authorization_code（第三方 Web 换码）：**

```http
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=<授权码>&redirect_uri=<与授权时完全一致>&client_id=myapp&code_verifier=<原始 verifier>
```

- 校验：授权码一次性 / 未过期 / client 匹配 / redirect_uri 与授权时一致 / PKCE S256。
- 失败：400 `{"detail": "缺少授权码换令牌必需参数"}` / `"授权码无效或已被使用"` / `"授权码已过期"` / `"回调地址与授权时不一致"` / `"PKCE 校验失败"` 等。

**grant_type=refresh_token（刷新，第一方与第三方统一走此端点）：**

```http
POST /oauth/token HTTP/1.1
Content-Type: application/json

{"grant_type": "refresh_token", "refresh_token": "<当前 refresh_token>", "client_id": "myapp", "device_id": "<可选 UUID v4，App 静默续期指纹凭证>"}
```

- **轮换制**：旧 refresh_token 立即撤销，返回全新令牌对。调用方必须覆盖保存新对。
- **重放检测**：已撤销的 refresh_token 被再次使用 → 撤销该用户该 client 全部令牌
  （疑似泄露，强制全端重新登录）。
- 失败：400 `{"detail": "缺少刷新令牌必需参数"}`；401 `{"detail": "刷新令牌无效，请重新登录"}` / `"刷新令牌已失效，请重新登录"`（重放）
  / `"刷新令牌已过期，请重新登录"` / `"刷新令牌与 client 不匹配"`。

### 6.5 POST `/oauth/revoke` — 撤销 refresh_token（RFC 7009）

请求体（form 或 JSON）：`{"token": "<refresh_token>"}`。
缺 `token` 参数返回 400；幂等：令牌不存在或已撤销同样返回 `200 {}`。
仅撤销单个 refresh_token；**完整登出（含撤销日志同步）请用 `POST /api/v1/users/logout`**。

## 7. 服务间内部接口（`/internal/*`，业务项目服务端调用）

- 鉴权：请求头 `X-Service-Token: <服务间令牌>`（见 3.3），缺失或错误返回 401。
- 响应为统一格式 `{"code": 0, "msg": "success", "data": ...}`。

### 7.1 GET `/internal/users/{user_id}` — 查询用户基本信息

```json
{"code": 0, "msg": "success", "data": {"id": 1, "username": "张三", "email": "user@example.com", "status": 1, "role": 0}}
```

404 = 用户不存在（业务项目自行决定降级行为）。典型场景：发邮件通知需要 email。

### 7.2 GET `/internal/users/{user_id}/openid?app_id={AppID}` — 查询小程序 openid

```json
{"code": 0, "msg": "success", "data": {"openid": "oXyz..."}}
```

`openid` 为 `null` 表示该用户未绑定此小程序，业务项目跳过发送。典型场景：微信订阅消息。

### 7.3 GET `/internal/revocations?since={水位}` — 令牌撤销增量（核心）

- `since`：上次拉取的水位（ISO 格式，如 `2026-09-07T12:00:00`；带 `Z`/`+08:00` 后缀亦可，
  无时区按上海时区解释）。首次接入从当前时间开始。
- 返回 `revoked_at` **晚于**水位的全部撤销记录（升序），含已删除用户的补记：

```json
{
  "code": 0, "msg": "success",
  "data": {
    "items": [{"user_id": 1, "revoked_at": "2026-09-07T12:03:45"}],
    "watermark": "2026-09-07T12:05:00.123456"
  }
}
```

- **业务项目侧约定**：
  1. 每 **5 分钟** 定时拉取一次；
  2. 以响应中的 `watermark` 作为下一轮的 `since`（持久化到本地）；
  3. 本地校验规则：**当 `token.iat < revoked_at`（严格小于，秒精度）时拒绝该用户旧令牌（401）**；
     `iat == revoked_at` 同秒签发**不**视为已撤销；
  4. 撤销日志仅保留 7 天，务必按上述频率消费，勿长期停摆。

### 7.4 POST `/internal/membership/grant` — 会员购买授权（2026-10-13）

业务项目支付成功后服务间落账（如 bai 微信小程序端金币购买黑铁会员月卡）。
计价与时长由**业务项目服务端下发**（前端不可传价格），本服务不做支付验证。

请求体：

```json
{
  "user_id": 631,            // 业务项目认识的 identity id（自动别名解析属主）
  "role_code": "iron",       // 购买的角色编码（固定 6 预设之一，如 iron=黑铁会员）
  "duration_days": 30,       // 购买时长（天，1~3650）
  "client_id": "bai",        // 发起购买的项目
  "order_no": "bai-xxxx",    // 业务项目订单号（幂等键）
  "amount": 1,               // 支付金额（项目计价数值，落账留痕）
  "currency": "gold"         // 支付货币说明（如 gold=项目金币）
}
```

响应（`data.expires_at` 为本次顺延后的角色到期时间，ISO 无时区后缀即东八区）：

```json
{"code": 0, "msg": "success", "data": {"duplicated": false, "role_code": "iron", "expires_at": "2026-11-12T15:30:00"}}
```

- **幂等**：同 `(client_id, order_no)` 重复调用返回首次落账结果（`duplicated: true`），不重复顺延。
- **续费顺延**：已持有同角色且未过期 → 从现到期时间续延；已过期或未持有 → 从当前时间起算。
- **角色生效**：授权后按 person 全部身份 id 扇出令牌撤销——业务项目拉取撤销增量后
  旧令牌 401，客户端刷新令牌即重新签发携带新角色（`roles` 每次签发实时查库）。
- **到期失效**：`user_roles.expires_at`（NULL=永久）到期后由后台扫描（60 秒）回收
  并扇出撤销，客户端刷新令牌即降回普通角色；`get_user_role_codes` 及管理页用户
  列表均已过滤过期角色行。管理员手动授予的角色（admin 端点）不带有效期，不受影响。
- 购买记录表 `user_membership_purchases`（每次购买一行，含金额/货币/订单号快照）。

## 8. 令牌模型（对接方必须理解）

### 8.1 access_token（RS256 JWT）

Header：`{"alg": "RS256", "kid": "<密钥标识>"}`。Payload：

```json
{
  "iss": "https://auth.wuzuniao.com",
  "sub": "1",
  "role": 0,
  "azp": "myapp",
  "jti": "<uuid hex>",
  "iat": 1789000000,
  "exp": 1789259200
}
```

- `sub` 为**字符串**形式的用户 ID；`role`：0 普通用户 / 7 管理员；`azp` 为签发目标 client_id。
- 有效期默认 **3 天**（`expires_in` 秒数随响应下发；接入时可与服务方协商定制）。
- 业务项目**本地验签**：JWKS 公钥 + 校验 `iss` 与 `exp`；结合撤销增量比对 `iat`
  （见 7.3）。`iat` 与服务端撤销时间均为**秒精度**，比对规则为严格小于。

### 8.2 refresh_token

- 不透明高熵随机串（非 JWT，不可解析、无自包含信息）。
- 有效期 14 天；每次刷新轮换（旧令牌立即失效且新令牌重新计 14 天）；重放旧令牌触发全端撤销（见 6.4）。

### 8.3 撤销触发点（写入撤销日志，业务项目据此拒绝旧令牌）

修改密码、重置密码、退出登录、发起删号（预约删除）、删号冷静期到期标记完成。
撤销记录**按 person 全部身份 id 扇出**（主账号+全部别名各一条）——业务项目按各自认识的
identity id 比对即可命中；**合并本身不撤销**（N8，合并前双方令牌都属于本人，继续有效更平滑）。

## 9. 业务项目接入指南

### 9.1 两种接入形态

| 形态 | 适用 | 登录方式 | 前置要求 |
|------|------|----------|----------|
| 第一方（小程序 / App / 内部 H5） | 无足鸟系列项目 | 直接调 `/api/v1/users/*` 登录端点拿令牌三件套 | oauth_clients 注册一行，`is_first_party=1` |
| Web（跨顶级域如 wuzuniao.cn / 第三方 SPA） | 任意域名项目 | 浏览器跳转 `/oauth/authorize`（授权码 + PKCE；未登录 302 登录页，登录后回跳自动发码；14 天内登录过任一接入项目则免登录直接发码） | 注册 client 并登记 `redirect_uris` 白名单 |

### 9.1.1 跨顶级域单点登录（认证中心会话）

登录类端点（login / register / wechat-login / biometric-login）成功后自动下发
**HttpOnly 会话 Cookie**（auth 域，`sso_session`，14 天有效，DB 仅存 SHA-256 哈希）。
浏览器跳转 `/oauth/authorize` 时自动携带：

- 会话有效（或携带有效 Bearer）：免登录直接 302 发码到业务项目回调地址；
- 无会话：302 到 `pages/authentication/login.html?redirect=<本端点原始 URL>`，
  用户在 auth 登录页完成登录后自动回跳续走发码流程。

「一处登录，处处通行」由此对**任意顶级域**成立（wuzuniao.cn 与 wuzuniao.com
因浏览器安全模型无法共享 Cookie/localStorage，跨注册域 SSO 只能走重定向授权）。
会话失效联动：修改/重置密码、退出登录、注销删除标记时全量销毁；过期会话由后台循环清理。

### 9.2 注册 client（oauth_clients 表）

```sql
INSERT INTO oauth_clients
  (client_id, client_name, redirect_uris, is_first_party)
VALUES
  ('myapp', '接入方名称', 'https://myapp.example.com/callback', 1)
ON DUPLICATE KEY UPDATE
  client_name = VALUES(client_name),
  redirect_uris = VALUES(redirect_uris),
  is_first_party = VALUES(is_first_party);
```

- 第三方项目：`redirect_uris` 填白名单，`is_first_party=0`。
- 第一方项目：`is_first_party=1`，自动获得登录全链路。
- 令牌有效期可按 client 定制（接入时与服务方协商）。
- **auth 平台自身 client**：`client_id=auth`（DEFAULT_CLIENT_ID），供 auth 前端页面 /
  SSO Cookie / 令牌交换使用（P24：全部项目前端请求显式携带 client_id）。

### 9.3 账号别名与合并（2026-10-01 起，合并/删号完全收敛 auth 内部）

**核心约定（业务项目必须遵守，P12）**：

1. **同一自然人在不同项目的 id 不同**。每个项目对每个人恒看到一个 identity id
   （= 令牌 sub = 业务库关联键），由 `user_client_identity` 映射保证，终身不变；
2. **合并后历史 id 全部有效同属一人**：绑定已注册邮箱触发合并时，被合并账号 id 降为
   别名（merged_into 指向属主），identity id 不变、微信绑定行/令牌行不动，业务库
   **永不改写 user_id**、残留数据永久接受；
3. **禁止假设邮箱=同一 user_id**：跨项目关联用户一律以业务库自存的 user_id 为准；
4. **业务项目零合并逻辑**：合并由 auth 在 `bind-email` 内部单事务完成（原 purge 上报 /
   merge-tasks / merges/confirm 协同端点已全部下线，业务项目无需也不会收到合并任务）。

**删号流程**：用户发起（person+全部别名 status=0，撤销按全部身份 id 扇出）→
24h 冷静期（可取消，别名随主恢复）→ auth 定时扫描标记 status=2 并清理 auth 自有数据
（绑定行/refresh_token/指纹凭证/SSO 会话/授权码）。业务项目无需轮询上报；
撤销增量（`/internal/revocations`）按各自认识的 id 比对即命中。

**平台令牌与交换**：业务项目浏览器端恢复登录态（跨子域 SSO Cookie 持平台令牌）或
需要跨 client 互通时，经 `POST /oauth/token-exchange`（Bearer=任意已登记 client 有效
令牌 + body `{"target_client_id": "myapp"}`，限流 60 次/分钟）换取得本项目 identity
令牌（响应与 /oauth/token 同款裸格式，无 code/msg 包裹；幂等：target=源 client 返回
自身 identity 令牌组；未登记 target 400）。平台令牌（client=auth，sub=主账号 id）
不直接用于业务数据接口（N23）。

### 9.4 接入 checklist

1. 注册 client（9.2），确认服务间令牌值与业务项目侧调用头一致；
2. 业务项目侧配置本服务 `ISSUER` 用于验签（校验 `iss` 必需）；
3. 启动时拉取 JWKS 缓存，按 `kid` 验签，未知 `kid` 自动重拉；
4. 每 5 分钟拉取撤销增量并持久化水位（7.3），本地比对 `iat < revoked_at` 拒绝旧令牌；
5. 前端调 `/users/*` 登录（显式携带 client_id）并持久化令牌；access_token 过期前用
   refresh_token 刷新（6.4）；恢复登录态经 `/oauth/token-exchange` 交换本项目令牌；
6. 第三方：实现 redirect_uri 回调页接收 `code`/`state`，后端或前端用 code_verifier 换令牌。

### 9.5 公告接入（2026-10-02 起，公告管理收敛 auth）

公告内容与已读状态全部存于本服务（announcements / announcement_templates / announcement_reads
三表），**业务项目零公告存储**：管理端经本服务前端「管理 → 公告管理」维护
（公告/模板 CRUD、目标项目多选），业务前端直连查询展示：

- **项目归属判定**：业务前端携本项目令牌（azp）调用，无需显式传 client_id；
  公告 `target_clients` 含当前 azp 即对本项目可见；
- **未读模型**：已读记录按 (公告, user_id=令牌 sub, client_id) 唯一——同一人在
  不同项目未读独立维护（合并后各身份历史已读互不影响）；
- **站内信集成建议**：页面打开时 `GET /api/v1/announcements?=&page_size=20`
  （懒加载触底翻页，`has_more` 翻页、`unread_count` 驱动通知图标）；
  点击未读卡片调 `POST /api/v1/announcements/{id}/read`（幂等）；「全部已读」
  调 `POST /api/v1/announcements/read-all`；通知组件可单独调 `unread-count`；
- **约束**：公告标题 ≤200 字、内容 ≤5000 字；接口限流 60 次/分钟（按 IP）；
  删除公告即级联清理全部已读记录，各项目站内信同步消失。

## 10. 对接相关约束

- **信任模型**：RSA 私钥仅本服务持有，绝不对外分发；业务项目仅持公钥（经 JWKS 下发）本地验签。
- **上线顺序**：先本服务 → 再业务项目后端 → 最后业务项目前端；切换后旧登录态全部失效，
  全体用户需重新登录一次。
- **健康检查**：`GET /health` 返回 `{"status": "ok"}`。

## 11. 许可

本项目为**闭源专有软件**：不对外提供源代码；未经书面授权，任何单位或个人不得复制、修改、
分发本软件或其衍生作品，不得进行反向工程、反向汇编、反编译，或移除版权与技术保护措施。
详见 [LICENSE](LICENSE)。
