Files
lixi 209021e7d2 docs: 迁入第一迭代过程报告并建立进展看板
- 新增 development/iterations/iteration-1/:15 份角色报告 + 进展看板(已完成/未闭环/下一步),作为双人协作的进度事实来源
- 新增 ADR-006:测试与交付容器化策略(Testcontainers / 交付 Docker 包 / 本机库仅个人联调)
- Git 工作流规范补充:敏感信息只进忽略文件或 sample、测试数据不入库、测试代码限标准测试目录
- 门禁:mkdocs build --strict 通过(零警告)
2026-09-04 10:45:05 +08:00

581 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 埋点落地工程规范(身份漏斗 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/` 参考查询 + 告警巡检接入。