Files
patbond-doc/docs/development/iterations/iteration-1/13-tracking-implementation-spec.md
T
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

31 KiB
Raw Blame History

埋点落地工程规范(身份漏斗 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 草案)

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_errorusername_takenweak_passwordrate_limitednetwork_errorserver_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_lockedrate_limitedvalidation_errornetwork_errorserver_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。


附:工单拆分建议

  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/ 参考查询 + 告警巡检接入。