# 行为埋点(user_activities)

一张表回答三类问题,**别再为每个新页面建表**。

| 问题 | 怎么查 |
|---|---|
| 谁在用（PV/UV） | 按 `(day, surface)` 聚合;PV = 条数,UV = `count(distinct user_id)` |
| 卡在哪一步（漏斗） | 同一 `surface` 下按 `action` 逐级看衰减 |
| 谁何时以什么角色进来（登录审计） | `surface='auth'` 的行 |

## 加一个新埋点

1. 在 `@pac/types` 的 `ActivitySurface` / `ActivityAction` 里加值(**不用迁移** —— 库里是 text)
2. 前端 `track(surface, action, payload?)`;服务端 `activityService.trackServerSide({...})`

## 纪律

- ⛔ **身份不由客户端给**。`userId` / `role` / `orgScope` 一律从 JWT 取。上报体是 `strictObject`,
  前端多传一个 `userId` 会被 Zod 直接拒掉 —— 这是故意的,不是漏配。
- ⛔ **别把页面 PV 塞进 `plan_event_logs`**。那张是「单的归属账本」(`plan_id` NOT NULL,记这一单
  归谁、被谁看过);放空 `plan_id` 会让所有按 plan 聚合的查询悄悄多算,而且不报错。
- ⚠️ **埋点是旁路**。前端不 await 不抛,后端写失败只落日志 —— 埋点挂了不能让主管点不了确认。
- ⚠️ **`role` / `org_scope` 存的是快照**。角色由宿主换票时下发、随时会变,事后想知道
  「他当时是以什么身份点的」只能靠这两列。故读取侧 `roles` 是 `string[]` 而不是 `UserRole[]`:
  拿当下的枚举去卡历史行,会出现「写得进读不出」。
- ⚠️ **统计一律用 `created_at`(服务端时刻)**,`occurred_at`(客户端声称的)只在排查前端问题时看。
- ⚠️ **前端埋点要防重复触发**。React 18 StrictMode 开发态会把 effect 跑两遍;流式渲染的卡片
  会重渲染十几次。一次访问记成十条 PV 会让漏斗中段直接失真 —— 用 ref 或业务键去重
  (见 `supervisor/page.tsx` 的 `trackedRef`、`assignment-confirm-sheet.tsx` 的 `shownRef`)。

## 已埋的点

| surface | action | 落点 |
|---|---|---|
| `auth` | `token_exchange` | 宿主后端换票(**无** Origin —— 服务器间调用) |
| `auth` | `code_exchange` | 浏览器换 code(**有** Origin = 从哪个宿主系统进来的);只记首次,TTL 内重复换是同一次打开 |
| `supervisor_workbench` | `open` | 主管工作台打开(权限判定**之后** —— 被弹走的客服不算) |
| `supervisor_workbench` | `matrix_cell` | 点矩阵格子(`{treatment, temperature, count}`;⛔ 不记 rect) |
| `supervisor_workbench` | `proposal_shown` | 确认单摆出来(按 `requestId` 去重,只在 `active` 记) |
| `supervisor_workbench` | `assign_confirm` | 确认成功(**服务端返回之后** —— 点了但失败不算) |

## 会话串联

`session_id = ${sub}:${iat}`。⚠️ access token **没有 `jti`**(只有 refresh token 有,见
`auth.service` 的 Redis 白名单),用 `jti` 会得到一列恒空的死数据。`iat` 是这张票的签发秒,
同一次登录内所有行为共享它。

## 保留

按天线性增长但基数小(主管几十人量级)。真涨起来按 `created_at` 删旧即可 —— 索引已按
`(host_id, tenant_id, created_at)` 建好。⚠️ 别学 `plan_generation_logs`(每轮每患者一行,
测试服 5,350 万行 / 13 GB)拖到不得不停服才处理。
