- 新增 development/iterations/iteration-1/:15 份角色报告 + 进展看板(已完成/未闭环/下一步),作为双人协作的进度事实来源 - 新增 ADR-006:测试与交付容器化策略(Testcontainers / 交付 Docker 包 / 本机库仅个人联调) - Git 工作流规范补充:敏感信息只进忽略文件或 sample、测试数据不入库、测试代码限标准测试目录 - 门禁:mkdocs build --strict 通过(零警告)
31 KiB
埋点落地工程规范(身份漏斗 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.sqlplatform schema 现有风格 性质:纯文档草案,供后续开发工单直接引用;DDL/OpenAPI 进patbond-doc由工单定夺
本文把 05 号报告的规划落到可实现粒度,共五部分:服务端契约(§1)、表 DDL(§2)、Flutter 采集模块(§3)、事件字典终稿 v1(§4)、数据质量验收清单(§5)。
与 05 号报告的差异(均由拍板决策驱动):
- ADR-004(仅账号密码):删除
auth_register_failed.failureReason中的phone_taken;identifierType枚举 v1 仅保留username(字段保留,为未来手机号/邮箱登录扩展)。 - Flutter 存储现实:当前应用仅有
shared_preferences(已核对patbond-flutter/pubspec.yaml),05 号报告建议的 sqflite/追加式文件均不可用(sqflite 未引入,追加文件需 path_provider)。事件队列改为 shared_preferences 分段方案(§3.3),队列上限相应从 1,000 降为 500。 - 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 草案)
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 目前尚无迁移文件)。
-- 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 用列默认值即可,不由客户端传入):
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。
( 已删除——ADR-004 首版无手机号注册。)phone_taken
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;排除注册当场创建的会话族)。
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 当日新建数。
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 落在当日的会话行)。
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 处理并清洗)。
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。
附:工单拆分建议
- 后端:
V2迁移 +/api/v1/events接收端(白名单校验、限流、逐条结果)+ 5.2.5 技术指标 —— 依赖 M0 Flyway 基线。 - Flutter:
lib/analytics/模块(队列 + 上报器 + 门面),可先于 auth 功能独立交付并用假事件自测。 - Flutter:auth 工单按 §3.5 挂接 11 个事件(依赖工单 2 与 M1 auth 实现)。
- 数据:5.2 对账 SQL 入
patbond-doc/docs/database/参考查询 + 告警巡检接入。