# T3 · 客户端埋点批量上报 > 对应任务:`T-P1-10`(埋点事件字典/上报通道的服务端部分)| 需求:`FR-SYS-06`、架构 §9.3 | 分支:`task/t3-events` | worktree:`/home/xsl/meida_backend-t3` > 先读《README.md(协作规程)》,遵守全部纪律。 ## 1. 目标 实现 `POST /events/batch`:客户端批量上报埋点事件,落 `ob_event` 表。这是 V1 全链路埋点(FR-SYS-06)与后续三张看板(TD-24)的数据入口。 ## 2. 已就绪的契约(协调提交已提供) ### 表(迁移 `0004` 已建,ORM 已加,**你不需要碰 models.py / migrations**) `ob_event`(架构 §6.2): - `event_id` bigint PK(雪花)、`user_id` bigint NOT NULL、`event_name` varchar(64)、`props` JSONB、`client_ts` timestamptz NULL、`server_ts` timestamptz NOT NULL default now()、 - 索引:`(event_name, server_ts)`、`(user_id, server_ts)`。 - 架构原文"按月分区"——**V1 先普通表**(与 ob_ai_call 同样处理),代码注释写明"分区在数据量上来后再做,先不预支复杂度"。 ### 配置(`config.py` 已加字段) ```python events_max_batch: int = 50 # env: EVENTS_MAX_BATCH events_props_max_bytes: int = 8192 # env: EVENTS_PROPS_MAX_BYTES ``` ### 错误码(`errors.py` 已加) - `EVENTS_BATCH_REJECTED = 40020`:400「埋点数据格式有误」,hint=null(对客户端静默降级即可,不打扰用户)。 ### 骨架(协调提交已建,直接填充) - `packages/analytics/__init__.py`、`packages/analytics/README.md`、`packages/analytics/service.py`(空骨架) - `apps/api/routers/events.py`(空骨架 router,**已注册进 main.py**,prefix `/events`) - `apps/api/deps.py` 已有 `AnalyticsServiceDep`(构造 `AnalyticsService(session)`)——**deps.py 禁改,签名如不满足需要,用路由函数内自建服务对象的方式,不改 deps**。 ### 分层位置 `packages.analytics` 被协调提交放进了 import-linter 的"共享能力层"(与 identity/billing/task 同层)——它只能 import `packages.shared`/`packages.domain`,**不得** import 其它业务包或 llm。 ## 3. 实现要点 允许改动:`packages/analytics/**`、`apps/api/routers/events.py`、测试文件。 1. **`packages/analytics/schemas.py`**(新建): - `EventIn`:`event_name: str`(`pattern=r"^[a-z][a-z0-9_.]{1,63}$"`)、`props: dict[str, Any] = {}`、`client_ts: datetime | None = None`; - `EventBatchRequest`:`events: list[EventIn]`(`min_length=1`)、 - `EventBatchResponse`:`accepted: int`。 - **事件字典(event dictionary)**:V1 不做服务端枚举校验(客户端事件名集合由 TD-23 埋点字典维护,尚未定稿)——用正则约束形状即可,代码注释写明"字典校验等 TD-23 定稿后再收紧"。 2. **`packages/analytics/service.py`**: - `AnalyticsService(session)`:`async def record(user_id: int, batch: list[EventIn]) -> int`; - 校验(fail-close,任一不过 → 整批拒绝 `AppError(EVENTS_BATCH_REJECTED)`,**不部分落库**):批量 ≤ `events_max_batch`;每条 props 序列化后 ≤ `events_props_max_bytes`(`len(json.dumps(props, ensure_ascii=False).encode())`);`client_ts` 若给出:不晚于 server_now+5min、不早于 7 天前; - 组装 `ObEvent(event_id=next_id(), user_id=..., server_ts=datetime.now(UTC))` 逐条 add,`flush` 一次,返回条数;(`from packages.domain.ids import next_id`,参考 identity/service.py 用法。) - **不含 PII 清洗**:props 里若有 `phone`/`token`/`authorization` 键 → 直接删除该键并 `logger.warning("events_pii_dropped", ...)`(NFR-SEC-03 日志脱敏的同族要求)。 3. **`apps/api/routers/events.py`**: - `POST /batch`,`user_id: CurrentUserId`,`analytics: AnalyticsServiceDep`(若 Dep 不可用则函数内 `AnalyticsService(session)` with `SessionDep`); - `ok(EventBatchResponse(...))`;服务层抛的 `AppError` 自然走统一异常处理,不需要额外 catch。 ## 4. 测试要求 - 单测 `tests/unit/test_events_validation.py`(纯校验逻辑,不连库——把校验拆成模块级纯函数 `validate_batch(events, *, max_batch, max_bytes, now) -> list[ObEvent-like]` 便于无 DB 测试): - 合法批量通过;超批量拒;props 超字节拒;event_name 正则(大写/数字开头/超长拒);client_ts 未来>5min 拒、7 天前拒;PII 键被剔除。 - 集成测试 `tests/integration/test_events.py`(新文件,允许跑):dev-token → POST /v1/events/batch 两条事件 → 200 accepted=2;查库两行 `ob_event`;再 POST 非法批(event_name 大写)→ 40020 且**库里没有半条**。 ## 5. 禁改清单(通用禁改之外) `packages/llm/**`、`packages/billing/**`、`packages/identity/**`、`packages/storage/**`、`packages/agent/**`、`apps/api/routers/{agent,auth,quota,tasks,uploads}.py`、`tests/integration/test_vertical_slice.py`。 ## 6. 验收标准 1. `POST /api/v1/events/batch`(及 `/v1` 别名)可用,认证必需(无 token → 40100); 2. 整批原子:要么全落、要么全拒; 3. 校验规则全部有测试覆盖; 4. 全部检查绿(mypy 范围同 T2 列表)。 ## 7. 提交信息 ``` feat(analytics): POST /events/batch with fail-close validation ob_event 落库通道:形状校验 + props 字节上限 + client_ts 合理性 + PII 键剔除,整批原子(FR-SYS-06 服务端部分)。 偏差说明:<如有写在这里,无则写"无"> ```