docs: 迁入第一迭代过程报告并建立进展看板

- 新增 development/iterations/iteration-1/:15 份角色报告 + 进展看板(已完成/未闭环/下一步),作为双人协作的进度事实来源
- 新增 ADR-006:测试与交付容器化策略(Testcontainers / 交付 Docker 包 / 本机库仅个人联调)
- Git 工作流规范补充:敏感信息只进忽略文件或 sample、测试数据不入库、测试代码限标准测试目录
- 门禁:mkdocs build --strict 通过(零警告)
This commit is contained in:
2026-09-04 10:45:05 +08:00
parent 027876ae00
commit 209021e7d2
19 changed files with 3077 additions and 1 deletions
@@ -0,0 +1,580 @@
# 埋点落地工程规范(身份漏斗 v1)
> 角色:Experiment Tracker
> 日期:2026-09-04
> 前序:`05-experiment-tracking-plan.md`(事件与指标规划)
> 依据:ADR-001~005(`patbond-doc/docs/architecture/decisions.md`)、开发计划第 6 节 API 契约规范、`patbond-doc/docs/database/patbond_postgresql.sql` platform schema 现有风格
> 性质:纯文档草案,供后续开发工单直接引用;DDL/OpenAPI 进 `patbond-doc` 由工单定夺
本文把 05 号报告的规划落到可实现粒度,共五部分:服务端契约(§1)、表 DDL(§2)、Flutter 采集模块(§3)、事件字典终稿 v1(§4)、数据质量验收清单(§5)。
与 05 号报告的差异(均由拍板决策驱动):
1. **ADR-004(仅账号密码)**:删除 `auth_register_failed.failureReason` 中的 `phone_taken`;`identifierType` 枚举 v1 仅保留 `username`(字段保留,为未来手机号/邮箱登录扩展)。
2. **Flutter 存储现实**:当前应用仅有 `shared_preferences`(已核对 `patbond-flutter/pubspec.yaml`),05 号报告建议的 sqflite/追加式文件均不可用(sqflite 未引入,追加文件需 path_provider)。事件队列改为 shared_preferences 分段方案(§3.3),队列上限相应从 1,000 降为 500。
3. **Flyway 基线已定**(ADR-001,Boot 3 + Flyway):DDL 以 Flyway 迁移草案形式给出。
---
## 1. 服务端契约:`POST /api/v1/events`
### 1.1 设计要点
- **批量上限**:单批 1–50 条事件,请求体 ≤ 64 KB。超限整批拒绝(400),客户端不重试、按批丢弃并本地计数。
- **部分失败语义**:合法批次一律响应 `202`,逐条返回结果(`accepted` / `duplicate` / `rejected`)。单条非法事件不拖累整批——这是 at-least-once 客户端能安全删除本地队列的前提:**客户端收到 202 即删除该批全部本地记录**,rejected 条目不重试(schema 错误重试无意义)。
- **幂等**:以每条事件的 `eventId`(客户端 UUIDv7)为去重键,落库 `ON CONFLICT DO NOTHING`,重复条目回 `duplicate`(也计成功)。**本端点不使用 `Idempotency-Key` 请求头**——开发计划 6.1 的幂等键是请求级语义,事件上报需要条目级幂等,`eventId` 已覆盖;避免两套幂等机制叠加。
- **鉴权(登录前事件)**:本端点是 `/api/v1` 下唯一**允许匿名**的写端点。`Authorization: Bearer` 可选:
- 客户端规则:上报时若持有**未过期**的 access token 则附带;过期/缺失则不带,**绝不因埋点触发 token 刷新**(埋点是旁路,不得驱动鉴权流量)。
- 服务端规则:带了 token 就正常校验,无效 token 回 401(客户端收到 401 去掉 Authorization 头重试一次)。已认证请求中,若某条事件 `userId` 非空且 ≠ token subject,该条 rejected(`identity_mismatch`)。
- 匿名请求中事件携带的 `userId` 原样落库(队列可能在退出登录后才冲刷历史事件)——它是分析归因数据,不参与任何权限判断。
- **防滥用**:按 `anonymousId` + 客户端 IP 双维度限流,建议 60 请求 / 5 分钟(正常客户端 30 秒一批,余量 15 倍),超限 429 + `Retry-After`;配合 body 64 KB 上限。429 客户端按退避重试(§3.4)。
- **白名单校验**:事件名不在字典(§4)→ 整条 rejected(`unknown_event_name`);`props` 中字典之外的字段**剥离后仍收下**该事件(丢弃字段计数告警),命中隐私红线字段名(password/token/phone/email 等模式)→ 整条 rejected(`forbidden_field`)。
- **响应结构**:沿用开发计划 6.1 统一信封 `{ "code": 0, "message": "success", "data": ... }`,字段 camelCase。
### 1.2 OpenAPI 3 片段(YAML 草案)
```yaml
paths:
/api/v1/events:
post:
tags: [analytics]
summary: 批量上报产品事件(身份漏斗 v1)
description: >
唯一允许匿名调用的写端点。单批 1-50 条、body <= 64KB。
以事件自身 eventId 幂等去重,不使用 Idempotency-Key 头。
合法批次一律 202 并逐条返回结果;客户端收到 202 即可删除本地队列中该批全部事件。
security:
- {} # 匿名(注册/登录前)
- bearerAuth: [] # 登录后携带未过期 access token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TrackEventsRequest'
responses:
'202':
description: 批次已受理,逐条结果见 data.results
content:
application/json:
schema:
$ref: '#/components/schemas/TrackEventsResponse'
'400':
description: 整批拒绝——JSON 非法、events 为空或超过 50 条、body 超过 64KB(客户端丢弃该批,不重试)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: 携带的 access token 无效或过期(客户端去掉 Authorization 重试一次)
'429':
description: 触发限流(建议 60 请求/5 分钟/anonymousId+IP),响应含 Retry-After;客户端按退避重试
headers:
Retry-After:
schema: { type: integer }
description: 建议等待秒数
components:
schemas:
TrackEventsRequest:
type: object
required: [events]
properties:
events:
type: array
minItems: 1
maxItems: 50
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: 客户端生成的 UUIDv7,服务端幂等去重键
eventName:
type: string
pattern: '^[a-z][a-z0-9_]{1,63}$'
description: 见事件字典 v1;不在字典中的事件名整条拒绝
example: auth_login_succeeded
eventVersion:
type: integer
minimum: 1
description: 事件 schema 版本,字典 v1 全部为 1
anonymousId:
type: string
format: uuid
description: 设备级匿名标识,首次启动生成
userId:
type: string
format: uuid
nullable: true
description: 登录后填充;认证请求中若与 token subject 不一致则该条 rejected
sessionId:
type: string
format: uuid
description: 客户端会话标识(冷启动或后台 30 分钟后重新生成)
clientTs:
type: string
format: date-time
description: 客户端本地时间(ISO 8601 含时区);serverTs 由服务端补写,客户端不发
appVersion:
type: string
maxLength: 32
example: 1.0.0+12
platform:
type: string
enum: [android, ios]
osVersion:
type: string
maxLength: 32
example: android-14
props:
type: object
description: >
事件专有属性,按事件字典 v1 白名单校验:字典外字段剥离并计数,
命中隐私红线模式(password/token/phone/email 等)整条拒绝。
additionalProperties: true
TrackEventsResponse:
type: object
properties:
code: { type: integer, example: 0 }
message: { type: string, example: success }
data:
type: object
required: [accepted, duplicated, rejected, results]
properties:
accepted: { type: integer, description: 新落库条数 }
duplicated: { type: integer, description: eventId 去重命中条数(视为成功) }
rejected: { type: integer, description: 被拒条数 }
results:
type: array
description: 与请求 events 等长、按原顺序对应
items:
type: object
required: [eventId, status]
properties:
eventId: { type: string, format: uuid }
status:
type: string
enum: [accepted, duplicate, rejected]
reason:
type: string
enum:
- unknown_event_name
- schema_invalid
- forbidden_field
- identity_mismatch
- event_too_large
description: 仅 status=rejected 时出现
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
```
落点建议维持 05 号报告结论:第一迭代放在现有服务内(`patbond-user` 或后续网关层),不为埋点单起服务。
---
## 2. 表 DDL:`platform.product_events` Flyway 迁移草案
### 2.1 设计要点
- **风格对齐现有 SQL**:`ck_` 约束前缀、`ix_`/`uq_` 索引前缀、varchar + CHECK 收敛取值、`jsonb_typeof` 校验、表上方英文注释,与 `platform.outbox_events` / `platform.notifications` 一致。
- **`event_id` 直接作主键**:客户端 UUIDv7 天然时间有序,作 PK 插入局部性好,且主键唯一约束就是幂等去重(`ON CONFLICT (event_id) DO NOTHING`)。不再需要 `DEFAULT gen_random_uuid()`——ID 必须来自客户端,服务端生成反而破坏去重。
- **`user_id` 不加外键**:分析事件是 append-only 旁路数据,不应阻塞 `identity.users` 的删除/清理,且乱序到达的事件可能引用尚未可见或已删除的用户。与 `outbox_events.aggregate_id` 不加外键的既有取舍一致。
- **`client_ts` 合理性约束**:允许滞后 30 天(离线队列最长积压)、超前 1 天(时钟漂移),超出即数据异常,宁可插入失败暴露问题。
- **分区(可选,v1 不做)**:见 2.3。
### 2.2 迁移草案
文件名建议 `V2__create_platform_product_events.sql`(假设 `V1__baseline.sql` 为 M0 的 bootstrap 基线;实际版本号以合入时迁移序列为准,`patbond-api` 目前尚无迁移文件)。
```sql
-- Client-side product analytics events (auth funnel, dictionary v1).
-- Append-only side channel: eventId is generated by the client (UUIDv7)
-- and doubles as the idempotency key for at-least-once upload, so the
-- primary key must NOT default to a server-generated uuid. user_id is
-- intentionally not a foreign key: analytics rows may outlive or precede
-- identity.users rows and must never block account lifecycle operations.
CREATE TABLE platform.product_events (
event_id uuid PRIMARY KEY,
event_name varchar(64) NOT NULL,
event_version smallint NOT NULL DEFAULT 1,
anonymous_id uuid NOT NULL,
user_id uuid,
session_id uuid NOT NULL,
client_ts timestamptz NOT NULL,
server_ts timestamptz NOT NULL DEFAULT now(),
app_version varchar(32) NOT NULL,
platform varchar(16) NOT NULL,
os_version varchar(32) NOT NULL,
props jsonb NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT ck_product_events_name CHECK (event_name ~ '^[a-z][a-z0-9_]{1,63}$'),
CONSTRAINT ck_product_events_version CHECK (event_version > 0),
CONSTRAINT ck_product_events_platform CHECK (platform IN ('android', 'ios')),
CONSTRAINT ck_product_events_app_version CHECK (char_length(btrim(app_version)) BETWEEN 1 AND 32),
CONSTRAINT ck_product_events_os_version CHECK (char_length(btrim(os_version)) BETWEEN 1 AND 32),
CONSTRAINT ck_product_events_props CHECK (jsonb_typeof(props) = 'object'),
CONSTRAINT ck_product_events_client_ts CHECK (
client_ts >= server_ts - interval '30 days'
AND client_ts <= server_ts + interval '1 day'
)
);
COMMENT ON TABLE platform.product_events IS
'Client analytics events (auth funnel v1); dedup by client-generated event_id, metrics windows use server_ts';
-- Funnel/metric queries: daily counts per event name.
CREATE INDEX ix_product_events_name_server_ts
ON platform.product_events (event_name, server_ts);
-- Per-subject dedup for conversion metrics (userId after login, anonymousId before).
CREATE INDEX ix_product_events_user_server_ts
ON platform.product_events (user_id, server_ts)
WHERE user_id IS NOT NULL;
CREATE INDEX ix_product_events_anon_server_ts
ON platform.product_events (anonymous_id, server_ts);
```
插入模式(接收端补写 `server_ts` 用列默认值即可,不由客户端传入):
```sql
INSERT INTO platform.product_events
(event_id, event_name, event_version, anonymous_id, user_id, session_id,
client_ts, app_version, platform, os_version, props)
VALUES (...)
ON CONFLICT (event_id) DO NOTHING;
-- 受影响行数 = 0 即 duplicate,计入去重命中率指标
```
### 2.3 分区建议(明确:v1 不分区)
- **不分区的理由**:第一迭代事件量极小;而 PostgreSQL 分区表的主键必须包含分区键,若按 `server_ts` 范围分区,PK 变为 `(event_id, server_ts)`——重试上报的同一事件会带着**不同的** `server_ts` 到达,去重唯一键即告失效,必须再引入应用层近期 eventId 缓存来补,复杂度不值。
- **触发条件**:单表超过约 5,000 万行、或需要按保留期批量清理时再改造。届时优先考虑「不分区 + 保留期删除(如保留 18 个月,按 `server_ts` 批量 DELETE)」;确要分区,同步设计应用层去重缓存(近 N 天 eventId 布隆过滤器/Redis set)兜住跨分区重复。
---
## 3. Flutter 采集模块设计
### 3.1 模块结构
`features/` 平级(埋点是横切基础设施,不是 feature):
```
lib/analytics/
analytics.dart # 门面导出:业务代码只 import 这一个
analytics_client.dart # AnalyticsClient:track()/identify()/reset()/flush()
event_context.dart # EventContext:组装公共属性(anonymousId/sessionId/appVersion/platform/osVersion)
session_tracker.dart # sessionId 生命周期:冷启动或后台超 30 分钟重新生成(WidgetsBindingObserver)
auth_analytics.dart # 11 个 auth_ 事件的类型安全封装(业务侧唯一允许的调用入口,杜绝手拼事件名/属性)
queue/
event_queue.dart # 抽象接口:append/peekBatch/removeBatch/size
prefs_event_queue.dart # shared_preferences 分段实现(v1)
upload/
event_uploader.dart # 批量上报、指数退避、at-least-once
```
关键契约:
- `AnalyticsClient.track(name, props)` **永不抛异常、永不 await 网络**——内部 try/catch 全吞并本地计数,埋点是旁路,任何失败不得影响业务流程。
- `identify(userId)` 在登录/恢复成功后调用,之后的事件自动带 `userId`;`reset()` 在退出后调用,只清 `userId`,**不清** `anonymousId` 与队列。
- `auth_analytics.dart` 提供如 `trackLoginSucceeded({required IdentifierType identifierType, required int durationMs})` 的强类型方法,属性名/枚举值编译期锁死,与字典 v1 一一对应。
依赖决策:`eventId` 需要 UUIDv7,当前 pubspec 无 uuid 能力。建议工单引入 `uuid` 包(^4,支持 v7)——auth 流程的 `Idempotency-Key` 同样需要它,一举两得;若依赖审批不过,退路是自实现 UUIDv7(约 30 行,`Random.secure` + 毫秒时间戳)。
### 3.2 存储选型:为什么是 shared_preferences,存什么
当前应用**只有 `shared_preferences`** 可用(已核对 pubspec.yaml);sqflite 未引入,追加式文件需要 path_provider 定位文档目录,也未引入。第一迭代不为埋点扩依赖面,用 shared_preferences 承载,理由:
- 事件属于**非敏感数据**(隐私红线在采集侧已挡住 password/token/账号原文),存普通本地存储合规。**token 类敏感数据继续禁入 shared_preferences**——它们属于 M1 将引入的安全存储(flutter_secure_storage/Keychain/Keystore),与事件队列物理隔离,本模块任何 key 不得存凭证。
- 事件量小(身份漏斗每会话 < 10 条),500 条 × ~0.5 KB ≈ 250 KB,在 shared_preferences(Android 上是整读整写的 XML/DataStore)可接受范围内。
`EventQueue` 做成接口,M2 起若 sqflite/path_provider 进入依赖集,换实现不动调用方。
### 3.3 分段队列方案(避免整队列重写)
朴素方案「一个 key 存整个 JSON 数组」每次 track 要重写全量字符串,500 条时是 O(n) 放大。改为**分段(segment)**:
| Key | 内容 |
| --- | --- |
| `pb.analytics.anonymousId` | 设备匿名 ID(UUID,首启生成,永不清除) |
| `pb.analytics.lastActiveAt` | 最近活跃时间(ISO 8601),用于 30 分钟会话超时判定 |
| `pb.analytics.segIndex` | JSON 数组:段 ID 有序列表(旧 → 新) |
| `pb.analytics.seg.<segId>` | JSON 数组:该段最多 20 条序列化事件 |
| `pb.analytics.droppedCount` | 本地累计丢弃计数(溢出淘汰 + 4xx 丢批),诊断用 |
- **写入**:track() 先进内存缓冲,追加到当前「开放段」并持久化该段(重写 ≤ 20 条,几 KB);段满 20 条即封段、开新段。
- **上限与淘汰**:总量上限 **500 条**(25 段)。超限时**丢最旧的整段**并累加 `droppedCount`——先到先丢,保住最新行为数据。
- **读取上报**:从最旧段起取事件拼批(单批 ≤ 50 条,即最多 2.5 段);**收到 202 后才删除对应段**(部分消费的段重写剩余部分),这就是 at-least-once——应用在响应到达前被杀,事件还在,重启后重发,服务端靠 `eventId` 去重。
### 3.4 上报时序(批量 / 退避 / at-least-once)
冲刷触发(四选一即触发):缓冲 ≥ 20 条;30 秒定时器;冷启动完成;应用进入后台(`AppLifecycleState.paused`,尽力冲刷不保证完成)。
```
track() ──► 内存缓冲 ──► 持久化到当前段(同步落盘,应用被杀不丢)
触发条件满足 ──────────────►│
取最旧 ≤50 条组批 ──► POST /api/v1/events
│ (有未过期 token 则带,否则匿名;绝不触发刷新)
┌──────────────┼──────────────────┬───────────────┐
▼ ▼ ▼ ▼
202 401(带了失效token) 400(整批被拒) 网络错误/5xx/429
删除该批本地段 去掉 Authorization 丢弃该批+ 保留本地,指数退避:
重置退避; 重试一次(仅一次) droppedCount++ 5s 起 ×2,上限 5min;
统计 rejected (不重试) 429 优先用 Retry-After
条数(不重试)
```
- 同一时刻最多一个在途上报请求(串行),天然保序,避免并发批间重复消费同段。
- 退避状态存内存即可,冷启动重置(冷启动本身会触发一次冲刷)。
### 3.5 与 auth 流程的挂接点
auth 功能页与仓储层是 M1 在建项(当前 `lib/` 尚无 auth feature),下表按开发计划 M1 架构(登录/注册页、ApiClient、AuthRepository、鉴权拦截、安全存储)指明每个事件的触发调用点,供 auth 工单实现时对号入座:
| # | 事件 | 挂接点(类/时机) |
| --- | --- | --- |
| 1 | `auth_register_started` | RegisterPage:任一输入框**首次**产生非空输入(页面 State 持一次性 flag,每次进入注册页的会话记一次);`entryPoint` 由路由来源传入 |
| 2 | `auth_register_succeeded` | AuthRepository.register:收到 code=0 且 token 已写入安全存储**之后**;`durationMs` = started 至此的耗时(started 时间戳由页面传给仓储调用) |
| 3 | `auth_register_failed` | 两处:RegisterPage 本地校验拦截提交时(`validation_error`);AuthRepository.register 异常分类处(按 §4 枚举映射业务码/HTTP 状态/网络异常) |
| 4 | `auth_login_succeeded` | AuthRepository.login:成功且 token 落安全存储后;**紧接着调用 `analytics.identify(userId)`**,顺序不可反(本事件自身要带上 userId) |
| 5 | `auth_login_failed` | AuthRepository.login 异常分类处 |
| 6 | `auth_token_refresh_succeeded` | TokenRefresher(单一刷新入口,被三类调用方使用):轮换成功且新 token 落安全存储后;`trigger` 由调用方传入(`proactive` 预刷新定时器 / `on_401` ApiClient 401 拦截器 / `restore` 启动恢复) |
| 7 | `auth_token_refresh_failed` | TokenRefresher 失败分支,`trigger` 同上 |
| 8 | `auth_logout` | SessionManager.logout:**本地凭证清除后**立即上报(不等服务端结果),`serverRevoked` = `POST /auth/logout` 调用结果;之后调用 `analytics.reset()`(事件本身要带退出前的 userId,顺序不可反) |
| 9 | `auth_session_restore_started` | 应用引导(main.dart bootstrap → SessionRestorer):安全存储中**存在** refresh token 才上报并进入恢复流程;无存量凭证不报任何 restore 事件 |
| 10 | `auth_session_restore_succeeded` | SessionRestorer:拿到有效 access token 且 `GET /api/v1/me` 成功后;随后 `identify(userId)` |
| 11 | `auth_session_restore_failed` | SessionRestorer 失败分支(用户被送回登录页时) |
两条全局规则:被动登出(刷新失败导致跳登录页)不报 `auth_logout`,由事件 7/11 覆盖;所有 track 调用点都不 await、不包裹业务 try/catch 之外的逻辑。
---
## 4. 事件字典终稿(v1)
> **字典版本:v1(2026-09-04)**。所有事件 `eventVersion = 1`。schema 变更须递增事件版本并在本字典追加条目,禁止原地改语义。
> 相对 05 号报告的裁剪(依据 ADR-004 仅账号密码):删除 `phone_taken`;`identifierType` 枚举仅 `username`(字段保留待扩展)。无短信/第三方登录相关枚举残留。
### 4.0 公共属性(所有事件必带,即 §1.2 `TrackedEvent` 顶层字段)
| 字段 | 类型 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | --- |
| `eventId` | UUID | 是 | `01920b7e-…` | 客户端 UUIDv7,去重键 |
| `eventName` | string | 是 | `auth_login_succeeded` | 本字典 11 个之一 |
| `eventVersion` | int | 是 | `1` | v1 固定 1 |
| `anonymousId` | UUID | 是 | `3f8a…` | 设备匿名标识 |
| `userId` | UUID | 否(可 null) | `9c21…` | 登录后填充 |
| `sessionId` | UUID | 是 | `b442…` | 冷启动/后台 30 分钟后重生成 |
| `clientTs` | ISO 8601 | 是 | `2026-09-04T10:12:03.120+08:00` | 客户端时间;`serverTs` 服务端补写,客户端不发 |
| `appVersion` | string | 是 | `1.0.0+12` | |
| `platform` | enum | 是 | `android` | `android` / `ios` |
| `osVersion` | string | 是 | `android-14` | 粗粒度主版本 |
隐私红线(05 号报告 1.3 节)全文有效:密码、凭证、手机号/邮箱/用户名原文、完整 IP、广告标识、原始报文/堆栈,任何事件任何字段禁止携带。
### 4.1 `auth_register_started`(注册)
| 属性 | 类型 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | --- |
| `entryPoint` | enum | 是 | `login_page_link` | `launch` / `login_page_link` |
### 4.2 `auth_register_succeeded`(注册,漏斗事件)
| 属性 | 类型 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | --- |
| `durationMs` | int | 是 | `41250` | started → succeeded 耗时 |
### 4.3 `auth_register_failed`(注册)
| 属性 | 类型 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | --- |
| `failureReason` | enum | 是 | `username_taken` | 见下方枚举 |
| `errorCode` | string | 否 | `A0102` | 业务错误码,本地拦截/网络错误时为空 |
| `httpStatus` | int | 否 | `409` | 无响应时为空 |
| `attemptSeq` | int | 是 | `2` | 本注册会话第几次尝试 |
`failureReason` 枚举(v1):`validation_error``username_taken``weak_password``rate_limited``network_error``server_error`
(~~`phone_taken`~~ 已删除——ADR-004 首版无手机号注册。)
### 4.4 `auth_login_succeeded`(登录,漏斗事件)
| 属性 | 类型 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | --- |
| `identifierType` | enum | 是 | `username` | v1 仅 `username`;字段保留待手机号/邮箱扩展 |
| `durationMs` | int | 是 | `1830` | 提交 → 成功耗时 |
### 4.5 `auth_login_failed`(登录)
| 属性 | 类型 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | --- |
| `identifierType` | enum | 是 | `username` | 同上 |
| `failureReason` | enum | 是 | `invalid_credentials` | 见下方枚举 |
| `errorCode` | string | 否 | `A0201` | |
| `httpStatus` | int | 否 | `401` | |
| `attemptSeq` | int | 是 | `1` | |
`failureReason` 枚举(v1):`invalid_credentials`(不区分账号不存在与密码错,与接口防枚举一致)、`account_locked``rate_limited``validation_error``network_error``server_error`
### 4.6 `auth_token_refresh_succeeded`(会话维持)
| 属性 | 类型 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | --- |
| `trigger` | enum | 是 | `on_401` | `proactive` / `on_401` / `restore` |
### 4.7 `auth_token_refresh_failed`(会话维持)
| 属性 | 类型 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | --- |
| `trigger` | enum | 是 | `restore` | 同上 |
| `failureReason` | enum | 是 | `refresh_revoked` | `refresh_expired` / `refresh_revoked`(含轮换重放被拒,M1 验收观测点)/ `network_error` / `server_error` |
| `errorCode` | string | 否 | `A0301` | |
| `httpStatus` | int | 否 | `401` | |
### 4.8 `auth_logout`(退出)
| 属性 | 类型 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | --- |
| `serverRevoked` | bool | 是 | `true` | `POST /auth/logout` 是否成功;事件在本地凭证清除后上报,不等服务端 |
### 4.9 `auth_session_restore_started`(恢复)
无专有属性(`props` 为空对象)。仅当安全存储存在 refresh token 时上报。
### 4.10 `auth_session_restore_succeeded`(恢复)
| 属性 | 类型 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | --- |
| `durationMs` | int | 是 | `920` | 恢复流程耗时 |
| `usedRefresh` | bool | 是 | `true` | 是否经历了 token 刷新 |
### 4.11 `auth_session_restore_failed`(恢复)
| 属性 | 类型 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | --- |
| `failureReason` | enum | 是 | `refresh_expired` | `refresh_expired` / `refresh_revoked` / `network_error` / `server_error` |
| `errorCode` | string | 否 | `A0301` | |
| `httpStatus` | int | 否 | `401` | |
---
## 5. 数据质量验收清单
事件链路上线时逐项验证;§5.2 的对账 SQL 沉淀为每日巡检(未来实验前置条件「与服务端日志交叉核对」的日常化)。真值来源:`identity.auth_sessions` 的会话族结构(登录开新族、刷新在族内轮换、退出写 `revoke_reason`)与 `identity.users.created_at`
### 5.1 上线验收项(一次性)
| # | 验收项 | 方法 | 通过标准 |
| --- | --- | --- | --- |
| 1 | 幂等去重生效 | 手工重放同一批(同 `eventId`)两次 | 第二次全部 `duplicate`,表内仅一行 |
| 2 | `serverTs` 覆盖率 | `SELECT count(*) FROM platform.product_events WHERE server_ts IS NULL` | 恒为 0(列 NOT NULL DEFAULT 保证,查询作双保险) |
| 3 | 白名单剥离与红线拒绝 | 构造带未知字段 / 带 `password` 字段的事件上报 | 前者字段被剥离且计数告警,后者整条 rejected(`forbidden_field`);库内 props 无红线字段(SQL 见 5.2.4) |
| 4 | at-least-once 不丢 | 飞行模式下操作登录流程 → 杀进程 → 重启联网 | 事件补报到库,无重复 |
| 5 | 匿名上报与 401 降级 | 未登录状态上报;带过期 token 上报 | 前者 202;后者 401 后客户端去头重试成功 |
| 6 | 事件丢失率 | 端上 `droppedCount` 抽样 + 5.2 对账偏差 | 丢失率 < 5%(实验前置条件阈值) |
| 7 | 整批限制 | 51 条 / >64KB 请求 | 400,客户端丢批不重试 |
### 5.2 对账 SQL(每日巡检,偏差 > 5% 告警)
统计窗口均为 UTC 日界(与指标口径一致)。注意:事件经本地队列有分钟级延迟,`server_ts` 与会话创建时刻可能跨日,单日偏差告警建议观察连续 2 日,7 天滚动窗口偏差是更稳的告警口径。
**5.2.1 登录成功对账**:`auth_login_succeeded` 事件数 vs 服务端新建会话族数(登录开新 `token_family_id`;排除注册当场创建的会话族)。
```sql
WITH family_first AS (
SELECT DISTINCT ON (token_family_id) token_family_id, user_id, created_at
FROM identity.auth_sessions
ORDER BY token_family_id, created_at
),
api_logins AS (
SELECT date_trunc('day', ff.created_at AT TIME ZONE 'UTC') AS day, count(*) AS api_cnt
FROM family_first ff
JOIN identity.users u ON u.id = ff.user_id
WHERE ff.created_at - u.created_at > interval '60 seconds' -- 排除注册即建的首个会话族
GROUP BY 1
),
tracked AS (
SELECT date_trunc('day', server_ts AT TIME ZONE 'UTC') AS day, count(*) AS evt_cnt
FROM platform.product_events
WHERE event_name = 'auth_login_succeeded'
GROUP BY 1
)
SELECT coalesce(a.day, t.day) AS day,
coalesce(api_cnt, 0) AS api_cnt,
coalesce(evt_cnt, 0) AS evt_cnt,
round(abs(coalesce(evt_cnt, 0) - coalesce(api_cnt, 0))::numeric
/ greatest(coalesce(api_cnt, 0), 1) * 100, 2) AS diff_pct -- > 5 告警
FROM api_logins a
FULL JOIN tracked t USING (day)
ORDER BY day;
```
**5.2.2 注册成功对账**:`auth_register_succeeded` vs `identity.users` 当日新建数。
```sql
SELECT coalesce(u.day, t.day) AS day, coalesce(api_cnt, 0) AS api_cnt,
coalesce(evt_cnt, 0) AS evt_cnt,
round(abs(coalesce(evt_cnt, 0) - coalesce(api_cnt, 0))::numeric
/ greatest(coalesce(api_cnt, 0), 1) * 100, 2) AS diff_pct
FROM (SELECT date_trunc('day', created_at AT TIME ZONE 'UTC') AS day, count(*) AS api_cnt
FROM identity.users GROUP BY 1) u
FULL JOIN (SELECT date_trunc('day', server_ts AT TIME ZONE 'UTC') AS day, count(*) AS evt_cnt
FROM platform.product_events
WHERE event_name = 'auth_register_succeeded' GROUP BY 1) t USING (day)
ORDER BY day;
```
**5.2.3 刷新成功对账**:`auth_token_refresh_succeeded` vs 会话轮换数(`rotated_at` 落在当日的会话行)。
```sql
SELECT coalesce(s.day, t.day) AS day, coalesce(api_cnt, 0) AS api_cnt,
coalesce(evt_cnt, 0) AS evt_cnt
FROM (SELECT date_trunc('day', rotated_at AT TIME ZONE 'UTC') AS day, count(*) AS api_cnt
FROM identity.auth_sessions WHERE rotated_at IS NOT NULL GROUP BY 1) s
FULL JOIN (SELECT date_trunc('day', server_ts AT TIME ZONE 'UTC') AS day, count(*) AS evt_cnt
FROM platform.product_events
WHERE event_name = 'auth_token_refresh_succeeded' GROUP BY 1) t USING (day)
ORDER BY day;
```
**5.2.4 隐私红线扫描**:props 中不得出现红线字段(每日跑,命中即 P1 处理并清洗)。
```sql
SELECT event_name, k AS prop_key, count(*) AS hits
FROM platform.product_events
CROSS JOIN LATERAL jsonb_object_keys(props) AS k
WHERE server_ts >= now() - interval '1 day'
AND k ~* 'password|token|secret|phone|mobile|email|credential|idfa|gaid'
GROUP BY 1, 2;
-- 期望恒为空集;另将全量 key 分布与字典 v1 白名单比对,发现未知 key 说明服务端剥离逻辑失效
```
**5.2.5 服务端技术指标**(接收端 Micrometer 计数器,上线即带):`/api/v1/events` 请求量、整批拒绝率、逐条 rejected 率(按 reason 分)、去重命中率、白名单剥离字段计数。去重命中率长期 > 10% 提示客户端删除本地队列的时机有 bug。
---
## 附:工单拆分建议
1. **后端**:`V2` 迁移 + `/api/v1/events` 接收端(白名单校验、限流、逐条结果)+ 5.2.5 技术指标 —— 依赖 M0 Flyway 基线。
2. **Flutter**:`lib/analytics/` 模块(队列 + 上报器 + 门面),可先于 auth 功能独立交付并用假事件自测。
3. **Flutter**:auth 工单按 §3.5 挂接 11 个事件(依赖工单 2 与 M1 auth 实现)。
4. **数据**:5.2 对账 SQL 入 `patbond-doc/docs/database/` 参考查询 + 告警巡检接入。