2ceab6b296
CI / docs-build (push) Successful in 1m22s
以 AnalyticsController 实测行为为准补录埋点上报端点:批量 1-50、 202 逐条结果(accepted/duplicate/rejected + 4 种拒绝原因)、eventId 幂等、唯一允许匿名的写端点(带 Bearer 则完整校验 401/40101)、 400/40000 整批拒绝。info.version 1.0.0 -> 1.1.0(纯增量); index.md 端点清单同步为 6 端点。python yaml 解析 + mkdocs build --strict 均通过。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
611 lines
21 KiB
YAML
611 lines
21 KiB
YAML
openapi: 3.0.3
|
||
info:
|
||
title: Patbond API — Auth / Me / Events(公开契约)
|
||
version: 1.1.0
|
||
description: |
|
||
Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差),
|
||
1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。
|
||
|
||
## 通用约定(development-plan 第 6 节)
|
||
- 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。
|
||
- 所有时间字段为 ISO 8601 且带时区偏移(如 `2026-09-04T04:05:06.789Z`)。
|
||
- 统一响应信封 `{"code": 0, "message": "success", "data": …}`;错误同时携带正确的
|
||
HTTP 状态码与稳定业务码,业务码永不复用或改号。
|
||
- `/internal/**` 为服务间接口,不属于本公开契约,需 `X-Internal-Token` 服务凭证,
|
||
未携带或错误一律 401。
|
||
|
||
## 错误码表
|
||
| 业务码 | HTTP | 场景 |
|
||
| --- | --- | --- |
|
||
| 0 | 200 | 成功 |
|
||
| 40000 | 400 | 参数校验失败(含 JSON 不可解析;message 为首个字段错误) |
|
||
| 40100 | 401 | 用户名或密码错误 |
|
||
| 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) |
|
||
| 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) |
|
||
| 40400 | 404 | 资源不存在 |
|
||
| 40900 | 409 | 用户名已存在(大小写不敏感) |
|
||
| 40901 | 409 | 手机号已被使用 |
|
||
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
|
||
| 50000 | 500 | 服务器内部错误 |
|
||
| 50300 | 503 | 依赖服务暂不可用 |
|
||
|
||
## 会话模型(ADR-003,数值均为服务端配置项)
|
||
- access token:JWT(RS256),有效期 15 分钟;由资源服务用公钥本地验签。
|
||
- refresh token:不透明随机串,有效期 30 天;**每次刷新即轮换**,旧值立即失效。
|
||
- 已轮换/已失效的 refresh token 再次被使用时,判定为重用,**整个 token family
|
||
(该登录会话链)全部撤销**,持有者需重新登录。
|
||
- 允许多设备并行会话;退出仅撤销当前会话(由所提交的 refreshToken 标识),
|
||
其他设备不受影响。已签发的 access token 在剩余有效期内仍可用。
|
||
- 登录失败限制:同一账号在 15 分钟窗口内密码错误累计 5 次(配置项),账号锁定
|
||
15 分钟;锁定期间即使密码正确也返回 423/42300;一次成功登录重置计数窗口。
|
||
|
||
servers:
|
||
- url: http://127.0.0.1:8081
|
||
description: patbond-auth(本地开发,/api/v1/auth/**)
|
||
- url: http://127.0.0.1:8082
|
||
description: patbond-user(本地开发,/api/v1/me、/api/v1/events)
|
||
|
||
tags:
|
||
- name: auth
|
||
description: 注册 / 登录 / 刷新 / 退出(patbond-auth)
|
||
- name: user
|
||
description: 当前用户(patbond-user)
|
||
- name: analytics
|
||
description: 产品事件批量上报(patbond-user)
|
||
|
||
paths:
|
||
/api/v1/auth/register:
|
||
post:
|
||
tags: [auth]
|
||
summary: 注册并创建会话
|
||
operationId: register
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/RegisterRequest'
|
||
responses:
|
||
'200':
|
||
description: 注册成功,返回令牌对
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/AuthTokenEnvelope'
|
||
'400':
|
||
$ref: '#/components/responses/ValidationError'
|
||
'409':
|
||
description: 用户名或手机号已被占用(code 40900 / 40901)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ErrorEnvelope'
|
||
examples:
|
||
usernameTaken:
|
||
value: { code: 40900, message: 用户名已存在, data: null }
|
||
phoneTaken:
|
||
value: { code: 40901, message: 手机号已被使用, data: null }
|
||
|
||
/api/v1/auth/login:
|
||
post:
|
||
tags: [auth]
|
||
summary: 登录并创建会话
|
||
description: 多设备并行:每次登录开启独立会话(独立 token family),互不影响。
|
||
operationId: login
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/LoginRequest'
|
||
responses:
|
||
'200':
|
||
description: 登录成功,返回令牌对
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/AuthTokenEnvelope'
|
||
'400':
|
||
$ref: '#/components/responses/ValidationError'
|
||
'401':
|
||
description: 用户名或密码错误(code 40100)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ErrorEnvelope'
|
||
examples:
|
||
invalidCredentials:
|
||
value: { code: 40100, message: 用户名或密码错误, data: null }
|
||
'423':
|
||
description: 登录失败次数过多,账号临时锁定(code 42300)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ErrorEnvelope'
|
||
examples:
|
||
locked:
|
||
value: { code: 42300, message: 登录失败次数过多,账号已临时锁定, data: null }
|
||
|
||
/api/v1/auth/refresh:
|
||
post:
|
||
tags: [auth]
|
||
summary: 轮换 refresh token
|
||
description: |
|
||
成功时返回全新令牌对,旧 refreshToken 立即失效(轮换)。提交已轮换或已失效的
|
||
refreshToken 返回 401/40102,且视为重用攻击:该 token family 的全部会话被撤销。
|
||
operationId: refresh
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/RefreshRequest'
|
||
responses:
|
||
'200':
|
||
description: 轮换成功,返回新的令牌对
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/AuthTokenEnvelope'
|
||
'400':
|
||
$ref: '#/components/responses/ValidationError'
|
||
'401':
|
||
description: refresh token 已失效或被重用(code 40102)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ErrorEnvelope'
|
||
examples:
|
||
invalidated:
|
||
value: { code: 40102, message: refresh token 已失效或被重用, data: null }
|
||
|
||
/api/v1/auth/logout:
|
||
post:
|
||
tags: [auth]
|
||
summary: 退出(撤销当前会话)
|
||
description: |
|
||
撤销 body 中 refreshToken 对应的会话;其他设备的会话不受影响(ADR-003)。
|
||
需携带有效的 access token(从中取用户身份,防止跨账号撤销)。幂等:对已
|
||
失效的 refreshToken 仍返回成功。
|
||
operationId: logout
|
||
security:
|
||
- bearerAuth: []
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/LogoutRequest'
|
||
responses:
|
||
'200':
|
||
description: 已退出
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/VoidEnvelope'
|
||
'400':
|
||
$ref: '#/components/responses/ValidationError'
|
||
'401':
|
||
$ref: '#/components/responses/AccessTokenInvalid'
|
||
|
||
/api/v1/me:
|
||
get:
|
||
tags: [user]
|
||
summary: 当前用户资料
|
||
description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。
|
||
operationId: me
|
||
security:
|
||
- bearerAuth: []
|
||
responses:
|
||
'200':
|
||
description: 当前用户
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/MeEnvelope'
|
||
'401':
|
||
$ref: '#/components/responses/AccessTokenInvalid'
|
||
'404':
|
||
description: 用户不存在(如已注销;code 40400)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ErrorEnvelope'
|
||
|
||
/api/v1/events:
|
||
post:
|
||
tags: [analytics]
|
||
summary: 批量上报产品事件
|
||
description: |
|
||
埋点批量上报(事件字典见迭代报告 13 与第二迭代 06 号报告)。
|
||
`/api/v1` 下唯一允许匿名调用的写端点:`Authorization: Bearer` 可选——
|
||
缺失时按匿名处理放行;**一旦携带则完整校验**,无效 token 仍返回 401/40101。
|
||
|
||
- 单批 1–50 条;条数越界、字段校验失败或 JSON 不可解析时**整批** 400/40000。
|
||
- 通过请求级校验的批次一律返回 **202**,`data.results` 与请求 `events`
|
||
等长且按原顺序逐条给出结果(accepted / duplicate / rejected);
|
||
客户端收到 202 即可删除本地队列中该批全部事件(rejected 条目不重试)。
|
||
- 幂等以每条事件的 `eventId` 去重(落库 ON CONFLICT DO NOTHING),重复条目
|
||
返回 `duplicate`(视为成功);**不使用** `Idempotency-Key` 请求头。
|
||
- 单条拒绝原因:事件名不在字典(`unknown_event_name`);已认证请求中事件
|
||
`userId` 与 token subject 不一致(`identity_mismatch`);props 的键命中
|
||
隐私红线模式 password/token/secret/phone/mobile/email/credential/idfa/gaid
|
||
(`forbidden_field`);落库失败(`schema_invalid`)。
|
||
- props 中字典白名单之外的键**剥离后入库**(事件保留,不拒绝)。
|
||
- 匿名请求中事件携带的 `userId` 原样落库(分析归因数据,不参与权限判断)。
|
||
operationId: trackEvents
|
||
security:
|
||
- {} # 匿名(注册/登录前)
|
||
- bearerAuth: [] # 登录后携带未过期 access token
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/TrackEventsRequest'
|
||
responses:
|
||
'202':
|
||
description: 批次已受理,逐条结果见 data.results(与请求 events 等长、原顺序)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/TrackEventsEnvelope'
|
||
'400':
|
||
description: 整批拒绝——JSON 不可解析、events 为空或超过 50 条、单条事件字段校验失败(code 40000)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ErrorEnvelope'
|
||
examples:
|
||
emptyBatch:
|
||
value: { code: 40000, message: events 长度必须在 1-50 之间, data: null }
|
||
'401':
|
||
description: 携带了 Authorization 头但 access token 无效或过期(code 40101);不携带该头则按匿名放行,不会返回 401
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ErrorEnvelope'
|
||
examples:
|
||
tokenInvalid:
|
||
value: { code: 40101, message: token 无效或过期, data: null }
|
||
|
||
components:
|
||
securitySchemes:
|
||
bearerAuth:
|
||
type: http
|
||
scheme: bearer
|
||
bearerFormat: JWT
|
||
description: 'Authorization: Bearer <accessToken>(RS256 JWT)'
|
||
|
||
responses:
|
||
ValidationError:
|
||
description: 参数校验失败(code 40000)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ErrorEnvelope'
|
||
examples:
|
||
validation:
|
||
value: { code: 40000, message: 参数校验失败, data: null }
|
||
AccessTokenInvalid:
|
||
description: access token 缺失、无效或过期(code 40101)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ErrorEnvelope'
|
||
examples:
|
||
tokenInvalid:
|
||
value: { code: 40101, message: token 无效或过期, data: null }
|
||
|
||
schemas:
|
||
RegisterRequest:
|
||
type: object
|
||
required: [username, password]
|
||
properties:
|
||
username:
|
||
type: string
|
||
minLength: 3
|
||
maxLength: 32
|
||
description: 用户名,大小写不敏感唯一
|
||
example: demo_user
|
||
phone:
|
||
type: string
|
||
pattern: '^\+[1-9][0-9]{7,14}$'
|
||
description: 手机号,E.164 格式;可选,唯一
|
||
example: '+8613800138000'
|
||
password:
|
||
type: string
|
||
format: password
|
||
minLength: 6
|
||
maxLength: 64
|
||
example: secret123
|
||
nickname:
|
||
type: string
|
||
minLength: 1
|
||
maxLength: 32
|
||
description: 昵称;可选(冻结稿之外的可选扩展字段,前端可忽略)
|
||
example: 小柴
|
||
|
||
LoginRequest:
|
||
type: object
|
||
required: [username, password]
|
||
properties:
|
||
username:
|
||
type: string
|
||
example: demo_user
|
||
password:
|
||
type: string
|
||
format: password
|
||
example: secret123
|
||
|
||
RefreshRequest:
|
||
type: object
|
||
required: [refreshToken]
|
||
properties:
|
||
refreshToken:
|
||
type: string
|
||
description: 当前持有的 refresh token(不透明随机串)
|
||
example: Zx3v…43位base64url…Qk
|
||
|
||
LogoutRequest:
|
||
type: object
|
||
required: [refreshToken]
|
||
properties:
|
||
refreshToken:
|
||
type: string
|
||
description: 要撤销的当前会话的 refresh token
|
||
example: Zx3v…43位base64url…Qk
|
||
|
||
AuthTokens:
|
||
type: object
|
||
description: 注册 / 登录 / 刷新共用的令牌对(冻结契约,恰好这 6 个字段)
|
||
required:
|
||
- userId
|
||
- tokenType
|
||
- accessToken
|
||
- accessTokenExpiresAt
|
||
- refreshToken
|
||
- refreshTokenExpiresAt
|
||
properties:
|
||
userId:
|
||
type: string
|
||
format: uuid
|
||
example: 019212aa-0000-7000-8000-000000000001
|
||
tokenType:
|
||
type: string
|
||
enum: [Bearer]
|
||
example: Bearer
|
||
accessToken:
|
||
type: string
|
||
description: RS256 JWT,有效期 15 分钟(配置项)
|
||
example: eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI…
|
||
accessTokenExpiresAt:
|
||
type: string
|
||
format: date-time
|
||
description: ISO 8601 带时区
|
||
example: '2026-09-04T04:20:06.789Z'
|
||
refreshToken:
|
||
type: string
|
||
description: 不透明随机串,有效期 30 天(配置项),每次刷新轮换
|
||
example: Zx3v…43位base64url…Qk
|
||
refreshTokenExpiresAt:
|
||
type: string
|
||
format: date-time
|
||
example: '2026-10-04T04:05:06.789Z'
|
||
|
||
Me:
|
||
type: object
|
||
description: 当前用户资料(冻结契约,恰好这 4 个字段)
|
||
required: [userId, username, createdAt]
|
||
properties:
|
||
userId:
|
||
type: string
|
||
format: uuid
|
||
example: 019212aa-0000-7000-8000-000000000001
|
||
username:
|
||
type: string
|
||
example: demo_user
|
||
phone:
|
||
type: string
|
||
nullable: true
|
||
description: E.164;未绑定时为 null
|
||
example: '+8613800138000'
|
||
createdAt:
|
||
type: string
|
||
format: date-time
|
||
example: '2026-09-04T04:05:06.789Z'
|
||
|
||
AuthTokenEnvelope:
|
||
type: object
|
||
required: [code, message]
|
||
properties:
|
||
code:
|
||
type: integer
|
||
enum: [0]
|
||
message:
|
||
type: string
|
||
example: success
|
||
data:
|
||
$ref: '#/components/schemas/AuthTokens'
|
||
|
||
MeEnvelope:
|
||
type: object
|
||
required: [code, message]
|
||
properties:
|
||
code:
|
||
type: integer
|
||
enum: [0]
|
||
message:
|
||
type: string
|
||
example: success
|
||
data:
|
||
$ref: '#/components/schemas/Me'
|
||
|
||
VoidEnvelope:
|
||
type: object
|
||
required: [code, message]
|
||
properties:
|
||
code:
|
||
type: integer
|
||
enum: [0]
|
||
message:
|
||
type: string
|
||
example: success
|
||
data:
|
||
nullable: true
|
||
example: null
|
||
|
||
ErrorEnvelope:
|
||
type: object
|
||
required: [code, message]
|
||
properties:
|
||
code:
|
||
type: integer
|
||
description: 稳定业务错误码(见顶部错误码表)
|
||
example: 40101
|
||
message:
|
||
type: string
|
||
example: token 无效或过期
|
||
data:
|
||
nullable: true
|
||
example: null
|
||
|
||
TrackEventsRequest:
|
||
type: object
|
||
required: [events]
|
||
properties:
|
||
events:
|
||
type: array
|
||
minItems: 1
|
||
maxItems: 50
|
||
description: 单批 1–50 条;越界整批 400/40000
|
||
items:
|
||
$ref: '#/components/schemas/TrackedEvent'
|
||
|
||
TrackedEvent:
|
||
type: object
|
||
required:
|
||
- eventId
|
||
- eventName
|
||
- eventVersion
|
||
- anonymousId
|
||
- sessionId
|
||
- clientTs
|
||
- appVersion
|
||
- platform
|
||
- osVersion
|
||
properties:
|
||
eventId:
|
||
type: string
|
||
format: uuid
|
||
description: 客户端生成的 UUID(规范要求 v7),服务端幂等去重键
|
||
example: 019212aa-4444-7000-8000-000000000001
|
||
eventName:
|
||
type: string
|
||
pattern: '^[a-z][a-z0-9_]{1,63}$'
|
||
description: 须在服务端事件字典内;不在字典中的事件名整条 rejected(unknown_event_name)
|
||
example: auth_login_succeeded
|
||
eventVersion:
|
||
type: integer
|
||
description: 事件 schema 版本(字典 v1 全部为 1)
|
||
example: 1
|
||
anonymousId:
|
||
type: string
|
||
format: uuid
|
||
description: 设备级匿名标识,首次启动生成
|
||
example: 019212aa-0000-7000-8000-000000000001
|
||
userId:
|
||
type: string
|
||
format: uuid
|
||
nullable: true
|
||
description: |
|
||
登录后填充,可选。已认证请求中若与 token subject 不一致,该条
|
||
rejected(identity_mismatch);匿名请求中原样落库,不做校验。
|
||
example: 019212aa-0000-7000-8000-000000000001
|
||
sessionId:
|
||
type: string
|
||
format: uuid
|
||
description: 客户端会话标识
|
||
example: 019212aa-1111-7000-8000-000000000001
|
||
clientTs:
|
||
type: string
|
||
format: date-time
|
||
description: 客户端本地时间(ISO 8601 带时区);serverTs 由服务端补写,客户端不发
|
||
example: '2026-09-07T04:05:06.789Z'
|
||
appVersion:
|
||
type: string
|
||
minLength: 1
|
||
maxLength: 32
|
||
example: 1.0.0+12
|
||
platform:
|
||
type: string
|
||
enum: [android, ios]
|
||
example: android
|
||
osVersion:
|
||
type: string
|
||
minLength: 1
|
||
maxLength: 32
|
||
example: android-14
|
||
props:
|
||
type: object
|
||
additionalProperties: true
|
||
description: |
|
||
事件专有属性,可选。按事件字典白名单处理:白名单外的键剥离后入库
|
||
(事件保留);键名命中隐私红线模式(password/token/secret/phone/
|
||
mobile/email/credential/idfa/gaid,不区分大小写、子串匹配)则整条
|
||
rejected(forbidden_field)。
|
||
example: { identifierType: username, durationMs: 123 }
|
||
|
||
TrackEventsResult:
|
||
type: object
|
||
description: 批次逐条结果(results 与请求 events 等长、按原顺序对应)
|
||
required: [accepted, duplicated, rejected, results]
|
||
properties:
|
||
accepted:
|
||
type: integer
|
||
description: 新落库条数
|
||
example: 1
|
||
duplicated:
|
||
type: integer
|
||
description: eventId 去重命中条数(视为成功,客户端不必重试)
|
||
example: 0
|
||
rejected:
|
||
type: integer
|
||
description: 被拒条数(客户端不重试)
|
||
example: 0
|
||
results:
|
||
type: array
|
||
items:
|
||
$ref: '#/components/schemas/EventResult'
|
||
|
||
EventResult:
|
||
type: object
|
||
required: [eventId, status]
|
||
properties:
|
||
eventId:
|
||
type: string
|
||
format: uuid
|
||
example: 019212aa-4444-7000-8000-000000000001
|
||
status:
|
||
type: string
|
||
enum: [accepted, duplicate, rejected]
|
||
example: accepted
|
||
reason:
|
||
type: string
|
||
enum: [unknown_event_name, identity_mismatch, forbidden_field, schema_invalid]
|
||
description: 仅 status=rejected 时出现(accepted/duplicate 不含该字段)
|
||
example: unknown_event_name
|
||
|
||
TrackEventsEnvelope:
|
||
type: object
|
||
required: [code, message]
|
||
properties:
|
||
code:
|
||
type: integer
|
||
enum: [0]
|
||
message:
|
||
type: string
|
||
example: success
|
||
data:
|
||
$ref: '#/components/schemas/TrackEventsResult'
|