diff --git a/docs/api/index.md b/docs/api/index.md index 1424dcb..2b2ebbd 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,5 +1,8 @@ # API 契约 -第一迭代认证域的正式契约见 [openapi.yaml](openapi.yaml)(OpenAPI 3):注册、登录、刷新、退出、当前用户 5 个端点,统一错误信封 `{code, message, data}` 与错误码表(40000/40100/40101/40102/40900/40901/42300),以及会话轮换与登录锁定策略说明。 +正式契约见 [openapi.yaml](openapi.yaml)(OpenAPI 3),当前 6 个端点: + +- 认证域(第一迭代冻结):注册、登录、刷新、退出、当前用户 5 个端点,统一错误信封 `{code, message, data}` 与错误码表(40000/40100/40101/40102/40900/40901/42300),以及会话轮换与登录锁定策略说明。 +- 埋点域(M2 第一波补录):`POST /api/v1/events` 批量上报产品事件——单批 1–50 条、202 逐条结果(accepted/duplicate/rejected)、`eventId` 幂等去重、唯一允许匿名的写端点(携带 Bearer 则完整校验)。 约定:契约变更须先改本文件目录下的 OpenAPI,再改实现(契约先行);错误码只增不改义。 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index daa184b..a6a71f2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,9 +1,10 @@ openapi: 3.0.3 info: - title: Patbond API — Auth & Me(第一批公开接口) - version: 1.0.0 + title: Patbond API — Auth / Me / Events(公开契约) + version: 1.1.0 description: | - Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差)。 + Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), + 1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。 ## 通用约定(development-plan 第 6 节) - 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 @@ -42,13 +43,15 @@ 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) + 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: @@ -209,6 +212,63 @@ paths: 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: @@ -409,3 +469,142 @@ components: 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'