Commit 1eddc1b6 by luoqi

feat(埋点): user_activities —— 一张通用行为表(PV/UV + 漏斗 + 登录审计)

主管工作台此前没有任何使用数据可查,而 plan_event_logs 的 plan_id 是 NOT NULL,
工作台没有「单」这个实体可挂 —— 这是「没有表可以落」的由来。

不为工作台单独建表,建一张通用的:后面再有页面要看数据,加两个枚举值即可,不用迁移。

【一张表回答三类问题】
  ① 谁在用    —— 按 (day, surface) 聚合;PV=条数,UV=distinct user_id
  ② 卡在哪一步 —— 同 surface 下 action 逐级衰减(打开 → 点格子 → 出确认单 → 确认)
  ③ 登录审计   —— surface='auth' 的行。PAC 此前**不记录登录**(没有 session/login 表,
     role 只活在 JWT 里),「谁何时以什么角色进来的」事后无从查证,这次补上。

【几条纪律,都写进了 docs/design/user-activities.md】
-  身份不由客户端给:userId/role/orgScope 一律从 JWT 取。上报体是 strictObject,
  前端多传一个 userId 被 Zod 直接拒(已实测:code=10002 unrecognized_keys)。
-  页面 PV 不许塞进 plan_event_logs:那是「单的归属账本」,放空 plan_id 会让所有
  按 plan 聚合的查询悄悄多算,且不报错。
-  surface/action 在库里是 text 不是 PG enum:加埋点不该要迁移,且改枚举不会让
  历史行变非法。role 同理存快照文本 → 读取侧 roles 是 string[] 而非 UserRole[],
  否则会出现「写得进读不出」。
- ️ 埋点是旁路:前端不 await 不抛(微批 400ms + pagehide 冲刷),后端写失败只落日志。
  埋点挂了不能让主管点不了确认。

【已埋 6 个点】
  auth/token_exchange      宿主后端换票(无 Origin —— 服务器间调用)
  auth/code_exchange       浏览器换 code(有 Origin = 从哪个宿主系统进来);只记首次
  supervisor_workbench/open            权限判定之后 —— 被弹走的客服不算一次访问
  supervisor_workbench/matrix_cell     {treatment,temperature,count}; 不记 rect(屏幕坐标换分辨率就没意义)
  supervisor_workbench/proposal_shown  按 requestId 去重,只在 active 记
  supervisor_workbench/assign_confirm  服务端返回之后 —— 点了但失败不算确认

【本地实测(pac-service:3101 + pac-web:3100 + 真浏览器)】
  - 正常上报 accepted=2;伪造 userId 被拒 10002;无 token 拦在 10106(库里无脏行)
  - 真走一次换票+换 code:两条 auth 行落库,Origin 只出现在 code_exchange 上(符合预期)
  - 浏览器打开工作台 + 点格子:open/matrix_cell 落库,payload 正确
  - stats 端点:PV/UV 正确;leader 调被 platform:manage 拦下(10107)

【过程中修掉的三个自己的坑】
  - track 路径漏了 /pac/v1 前缀(api-client 用 new URL(path,base) 不自动补)→ 请求打到
    /activities,envelope 里其实是 404 但 HTTP 仍 200(项目约定),网络面板一看才发现
  - open 记了两条:React 18 StrictMode 双挂载 + canDispatch 翻转 → 加 ref 守卫
  - session_id 恒空:access token 没有 jti(只有 refresh token 有)→ 改用 sub:iat,
    同一次登录内所有行为共享,漏斗能串起来

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent b7132fae
-- UserActivity —— 通用行为埋点表(PV/UV + 漏斗 + 登录审计,见 schema.prisma 头注)
CREATE TABLE "user_activities" (
"id" UUID NOT NULL,
"host_id" UUID NOT NULL,
"tenant_id" TEXT NOT NULL,
"surface" TEXT NOT NULL,
"action" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"role" TEXT NOT NULL,
"org_scope" TEXT[],
"host_origin" TEXT,
"session_id" TEXT,
"payload" JSONB,
"occurred_at" TIMESTAMPTZ(3),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_activities_pkey" PRIMARY KEY ("id")
);
-- PV/UV 主查询:按 host+tenant 圈定后按天扫
CREATE INDEX "user_activities_host_id_tenant_id_created_at_idx"
ON "user_activities"("host_id", "tenant_id", "created_at");
-- 漏斗:按 (surface, action) 聚合不回表
CREATE INDEX "user_activities_host_id_tenant_id_surface_action_created_at_idx"
ON "user_activities"("host_id", "tenant_id", "surface", "action", "created_at");
-- 单人行为回放 / 登录审计按人查
CREATE INDEX "user_activities_user_id_created_at_idx"
ON "user_activities"("user_id", "created_at");
ALTER TABLE "user_activities" ADD CONSTRAINT "user_activities_host_id_fkey"
FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
...@@ -113,30 +113,30 @@ model Host { ...@@ -113,30 +113,30 @@ model Host {
/// 动作跳转 URL 模板(VIEW_PATIENTCREATE_APPOINTMENT ) /// 动作跳转 URL 模板(VIEW_PATIENTCREATE_APPOINTMENT )
/// 形状: { "VIEW_PATIENT": "https://.../patient/{patientId}", ... };null/缺失 = 前端不渲染该按钮 /// 形状: { "VIEW_PATIENT": "https://.../patient/{patientId}", ... };null/缺失 = 前端不渲染该按钮
actionUrls Json @default("{}") @map("action_urls") actionUrls Json @default("{}") @map("action_urls")
/// 组织节点别名:PAC 内部 org 树节点 id 宿主契约里的组织 id(orgScope 传的那套) /// 组织节点别名:PAC 内部 org 树节点 id 宿主契约里的组织 id(orgScope 传的那套)
/// 用于"PAC 节点 id ≠ 宿主契约 id"的层 —— 典型:品牌层 PAC 用名字(source_unit,"瑞尔"), /// 用于"PAC 节点 id ≠ 宿主契约 id"的层 —— 典型:品牌层 PAC 用名字(source_unit,"瑞尔"),
/// 宿主 orgScope 按契约传品牌 GUID(`ba67e6cf`)。挂进 org 树品牌节点 aliases, /// 宿主 orgScope 按契约传品牌 GUID(`ba67e6cf`)。挂进 org 树品牌节点 aliases,
/// 让品牌名 / 品牌 GUID 两种传法都能命中,不改 source_unit、不影响诊所层。 /// 让品牌名 / 品牌 GUID 两种传法都能命中,不改 source_unit、不影响诊所层。
/// 形状: { "瑞尔": "ba67e6cf30dc4f9c9c46adef188bbd04", "瑞泰": "77057aed269f4a14957ae0ad0eff359a" } /// 形状: { "瑞尔": "ba67e6cf30dc4f9c9c46adef188bbd04", "瑞泰": "77057aed269f4a14957ae0ad0eff359a" }
orgAliases Json @default("{}") @map("org_aliases") orgAliases Json @default("{}") @map("org_aliases")
/// 诊所名字典:诊所 id 中文名。**从摄入数据派生**(DW organization_name ),服务端维护, /// 诊所名字典:诊所 id 中文名。**从摄入数据派生**(DW organization_name ),服务端维护,
/// /auth/session 合并进 dictionary.clinics 下发 —— 前端显示名字不再依赖宿主换票时传 dictionary /// /auth/session 合并进 dictionary.clinics 下发 —— 前端显示名字不再依赖宿主换票时传 dictionary
/// 宿主换票仍可传 dictionary.clinics 作覆盖(登录传的优先,服务端补齐其余) /// 宿主换票仍可传 dictionary.clinics 作覆盖(登录传的优先,服务端补齐其余)
/// 形状: { "66701845dd2342e19f9e9f576c4ffe9c": "北京朝阳公园诊所", ... } /// 形状: { "66701845dd2342e19f9e9f576c4ffe9c": "北京朝阳公园诊所", ... }
clinicNames Json @default("{}") @map("clinic_names") clinicNames Json @default("{}") @map("clinic_names")
/// 执行结果回执地址(宿主接收 POST HTTPS endpoint);null = 不启用回执(opt-in) /// 执行结果回执地址(宿主接收 POST HTTPS endpoint);null = 不启用回执(opt-in)
callbackUrl String? @map("callback_url") callbackUrl String? @map("callback_url")
/// 回执验签用的 HMAC 共享密钥;PAC **签名**,故存**原文**(不同于 appSecret 存哈希) /// 回执验签用的 HMAC 共享密钥;PAC **签名**,故存**原文**(不同于 appSecret 存哈希)
/// 数组前插支持平滑轮换:索引 0 = current(签名用它),后置为宿主切换期仍在用的旧 key /// 数组前插支持平滑轮换:索引 0 = current(签名用它),后置为宿主切换期仍在用的旧 key
/// push 密钥相互独立(不同方向、不同宿主子系统,各管一把) /// push 密钥相互独立(不同方向、不同宿主子系统,各管一把)
callbackSecrets String[] @default([]) @map("callback_secrets") callbackSecrets String[] @default([]) @map("callback_secrets")
/// 软停用开关,false 时换票直接 401 /// 软停用开关,false 时换票直接 401
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
...@@ -155,6 +155,7 @@ model Host { ...@@ -155,6 +155,7 @@ model Host {
planEventLogs PlanEventLog[] planEventLogs PlanEventLog[]
planAssignments PlanAssignment[] planAssignments PlanAssignment[]
agentInvocations AgentInvocation[] agentInvocations AgentInvocation[]
userActivities UserActivity[]
@@map("hosts") @@map("hosts")
} }
...@@ -331,16 +332,15 @@ model PatientProfile { ...@@ -331,16 +332,15 @@ model PatientProfile {
/// DW fact_client_out.favor_doctor_id 才是真值,PAC 尚未摄入;接入后换值即可,消费方不用改。 /// DW fact_client_out.favor_doctor_id 才是真值,PAC 尚未摄入;接入后换值即可,消费方不用改。
preferredDoctor String? @map("preferred_doctor") preferredDoctor String? @map("preferred_doctor")
/// 筛选索引:上次时间走区间、两个医生走等值(前端在名单上做模糊,选中发精确姓名)
@@index([lastVisitAt])
@@index([lastVisitDoctor])
@@index([preferredDoctor])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
/// 筛选索引:上次时间走区间、两个医生走等值(前端在名单上做模糊,选中发精确姓名)
@@index([lastVisitAt])
@@index([lastVisitDoctor])
@@index([preferredDoctor])
@@index([doNotContact]) @@index([doNotContact])
@@index([deceased]) @@index([deceased])
@@index([hostFollowUpActive]) @@index([hostFollowUpActive])
...@@ -554,11 +554,11 @@ model PatientTransaction { ...@@ -554,11 +554,11 @@ model PatientTransaction {
/// 设想用途都没有落地成代码, reparse 走的是本列(rawPayload)从头重跑。实测它占 patient_transactions /// 设想用途都没有落地成代码, reparse 走的是本列(rawPayload)从头重跑。实测它占 patient_transactions
/// 7.9 GB(测试服),故删除。真需要 canonical 时从 rawPayload 跑一遍 field-mapping 即得 —— 那正是 /// 7.9 GB(测试服),故删除。真需要 canonical 时从 rawPayload 跑一遍 field-mapping 即得 —— 那正是
/// reparse 做的事,所以它是可推导的冗余,不是丢失的证据。 /// reparse 做的事,所以它是可推导的冗余,不是丢失的证据。
rawPayload Json @map("raw_payload") rawPayload Json @map("raw_payload")
/// payload 指纹(sha256 of rawRow);完整性 / 兜底校验用的独立字段。 /// payload 指纹(sha256 of rawRow);完整性 / 兜底校验用的独立字段。
/// :source_event_id TransactionSynthesizer `来源:资源:source_unit:subjectId:updatedAt` 合成, /// :source_event_id TransactionSynthesizer `来源:资源:source_unit:subjectId:updatedAt` 合成,
/// **不含本字段**(早期注释说"也作为 source_event_id 输入"已与代码不符,已更正) /// **不含本字段**(早期注释说"也作为 source_event_id 输入"已与代码不符,已更正)
payloadHash String @map("payload_hash") payloadHash String @map("payload_hash")
/// 宿主侧实际发生时间(adapter platform.pullConfig.timezone 解析无时区字符串后转 UTC) /// 宿主侧实际发生时间(adapter platform.pullConfig.timezone 解析无时区字符串后转 UTC)
/// patient_facts.occurredAt 字段名一致(同一语义在事件账本和事实视图复用) /// patient_facts.occurredAt 字段名一致(同一语义在事件账本和事实视图复用)
...@@ -640,10 +640,10 @@ model PatientTransaction { ...@@ -640,10 +640,10 @@ model PatientTransaction {
/// BullMQ `jobName = subject_id` 实现同 subject 串行化;事务内可用 /// BullMQ `jobName = subject_id` 实现同 subject 串行化;事务内可用
/// `SELECT ... FOR UPDATE` 锁住 active 行再 supersede + insert 新版本。 /// `SELECT ... FOR UPDATE` 锁住 active 行再 supersede + insert 新版本。
model PatientFact { model PatientFact {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
hostId String @map("host_id") @db.Uuid hostId String @map("host_id") @db.Uuid
tenantId String @map("tenant_id") tenantId String @map("tenant_id")
patientId String @map("patient_id") @db.Uuid patientId String @map("patient_id") @db.Uuid
/// 源组织命名空间(= 该患者的 source_unit)。集团内 subject_id 品牌级会撞 /// 源组织命名空间(= 该患者的 source_unit)。集团内 subject_id 品牌级会撞
/// (encounter_record:1011032 在瑞尔/瑞泰是两条不同就诊),进键消歧。默认 '' /// (encounter_record:1011032 在瑞尔/瑞泰是两条不同就诊),进键消歧。默认 ''
sourceUnit String @default("") @map("source_unit") sourceUnit String @default("") @map("source_unit")
...@@ -705,17 +705,17 @@ model PatientFact { ...@@ -705,17 +705,17 @@ model PatientFact {
clinicId String? @map("clinic_id") clinicId String? @map("clinic_id")
/// actual 应填 事实在宿主侧实际发生时间 /// actual 应填 事实在宿主侧实际发生时间
occurredAt DateTime? @map("occurred_at") @db.Timestamptz(3) occurredAt DateTime? @map("occurred_at") @db.Timestamptz(3)
/// 源记录末次修改时间(= canonicalRow.updatedAt,宿主侧口径)。乱序防护用: /// 源记录末次修改时间(= canonicalRow.updatedAt,宿主侧口径)。乱序防护用:
/// 携带更旧 updatedAt 的版本晚到(补推快照 vs 实时推送并发) fact-writer 按此裁决跳过,不旧覆新。 /// 携带更旧 updatedAt 的版本晚到(补推快照 vs 实时推送并发) fact-writer 按此裁决跳过,不旧覆新。
/// null = 源没给 updatedAt 退回"到达顺序"语义(向后兼容,老数据/无该列的 host 不受影响) /// null = 源没给 updatedAt 退回"到达顺序"语义(向后兼容,老数据/无该列的 host 不受影响)
sourceUpdatedAt DateTime? @map("source_updated_at") @db.Timestamptz(3) sourceUpdatedAt DateTime? @map("source_updated_at") @db.Timestamptz(3)
/// planned 推荐填 未来安排的目标时间(预约时间 / 复查时间 / 计划治疗时间) /// planned 推荐填 未来安排的目标时间(预约时间 / 复查时间 / 计划治疗时间)
plannedFor DateTime? @map("planned_for") @db.Timestamptz(3) plannedFor DateTime? @map("planned_for") @db.Timestamptz(3)
/// planned 时间窗起;部分场景(医嘱"1-2 周内复查")需要区间 /// planned 时间窗起;部分场景(医嘱"1-2 周内复查")需要区间
validFrom DateTime? @map("valid_from") @db.Timestamptz(3) validFrom DateTime? @map("valid_from") @db.Timestamptz(3)
/// planned 时间窗止;过期 cron 用此扫描置 status='expired' /// planned 时间窗止;过期 cron 用此扫描置 status='expired'
validUntil DateTime? @map("valid_until") @db.Timestamptz(3) validUntil DateTime? @map("valid_until") @db.Timestamptz(3)
/// 简短标题(UI 展示用,"复查 11 号牙(2026-05-20)") /// 简短标题(UI 展示用,"复查 11 号牙(2026-05-20)")
title String? title String?
...@@ -1258,18 +1258,15 @@ model PlanReason { ...@@ -1258,18 +1258,15 @@ model PlanReason {
/** /**
* 这条依据能推出哪几个**潜在治疗标签**implant / ortho / extraction …)—— 预先算好。 * 这条依据能推出哪几个**潜在治疗标签**implant / ortho / extraction …)—— 预先算好。
*
* ═══ 为什么落这一列 ═══════════════════════════════════════════════ * ═══ 为什么落这一列 ═══════════════════════════════════════════════
* 初选矩阵此前每次开页面都现推:`plan_reasons` 展开 `evidence.factIds` * 初选矩阵此前每次开页面都现推:`plan_reasons` 展开 `evidence.factIds`
* 回查 `patient_facts`1567 万行 / 18 GB)。最忙的诊所一次要摊三万多次随机查、 * 回查 `patient_facts`1567 万行 / 18 GB)。最忙的诊所一次要摊三万多次随机查、
* 372 MB I/O —— **而这一切的唯一产出就是这个字符串** * 372 MB I/O —— **而这一切的唯一产出就是这个字符串**
* 🔴 本地实测(15,884 plan 的诊所):958 ms 110 ms**磁盘读 119,861 0** * 🔴 本地实测(15,884 plan 的诊所):958 ms 110 ms**磁盘读 119,861 0**
* 24 格的数逐字未变(见 `scripts/bench-matrix.ts` 的结果快照)。 * 24 格的数逐字未变(见 `scripts/bench-matrix.ts` 的结果快照)。
*
* ═══ 为什么是数组 ═══════════════════════════════════════════════ * ═══ 为什么是数组 ═══════════════════════════════════════════════
* 一条依据可以挂多个 fact93.7% 只有一个,但** 3.7%(本地 1,391 条) * 一条依据可以挂多个 fact93.7% 只有一个,但** 3.7%(本地 1,391 条)
* 会推出不止一个 code** 存单值会把这批人算少,而那正是最不能出的错。 * 会推出不止一个 code** 存单值会把这批人算少,而那正是最不能出的错。
*
* ═══ ⚠️ 年龄被冻结在算的那一刻 ═══════════════════════════════════ * ═══ ⚠️ 年龄被冻结在算的那一刻 ═══════════════════════════════════
* 标签规则里有三条带年龄(K08>18 / K07 3~12 / K07 13~40),所以它不是纯函数。 * 标签规则里有三条带年龄(K08>18 / K07 3~12 / K07 13~40),所以它不是纯函数。
* 实测本地受年龄规则影响 5,551 人,**明天跨档 0 人、30 天内 16 **(≈0.5 /天)—— * 实测本地受年龄规则影响 5,551 人,**明天跨档 0 人、30 天内 16 **(≈0.5 /天)——
...@@ -1313,13 +1310,12 @@ model PlanScript { ...@@ -1313,13 +1310,12 @@ model PlanScript {
/** /**
* 话术**渠道**:`phone`(电话,分段) | `wecom`(企微,一次性单块) * 话术**渠道**:`phone`(电话,分段) | `wecom`(企微,一次性单块)
*
* ⚠️ 原来是 `planId @unique`( plan 一条话术)。加渠道后改成 `@@unique([planId, channel])` —— * ⚠️ 原来是 `planId @unique`( plan 一条话术)。加渠道后改成 `@@unique([planId, channel])` ——
* 同一个 plan 现在可以同时有电话稿和企微稿,两者**独立生成、独立状态机** * 同一个 plan 现在可以同时有电话稿和企微稿,两者**独立生成、独立状态机**
* ⚠️ 存量行默认 `phone`:上线前的话术都是电话稿,这不是"未知" * ⚠️ 存量行默认 `phone`:上线前的话术都是电话稿,这不是"未知"
* 别为企微另立一张表 —— 生成状态机(pending/ready/failed)source * 别为企微另立一张表 —— 生成状态机(pending/ready/failed)source
* agentInvocationId、流式接口、聚合查询全都一样,只有正文形态不同。 * agentInvocationId、流式接口、聚合查询全都一样,只有正文形态不同。
* 两张表意味着这套东西各维护一份,而它们一定会漂。 * 两张表意味着这套东西各维护一份,而它们一定会漂。
*/ */
channel String @default("phone") channel String @default("phone")
...@@ -1449,20 +1445,18 @@ model PlanExecution { ...@@ -1449,20 +1445,18 @@ model PlanExecution {
/** /**
* 这次执行发生在**哪一批的在办期内**null = 自助认领时做的,不属于任何批次。 * 这次执行发生在**哪一批的在办期内**null = 自助认领时做的,不属于任何批次。
*
* ── 为什么要立这一列 ── * ── 为什么要立这一列 ──
* 主管要的「本批成功几个 / 不成功几个 / 都因为什么」只能从 plan_executions , * 主管要的「本批成功几个 / 不成功几个 / 都因为什么」只能从 plan_executions ,
* 而这张表原本只有 `plan_id`。想靠 `followup_plans.assignment_id` 反查是**不行**: * 而这张表原本只有 `plan_id`。想靠 `followup_plans.assignment_id` 反查是**不行**:
* 那一列会被下一次分配覆盖(退回/到期回池后被别的批次挑走就改指了), * 那一列会被下一次分配覆盖(退回/到期回池后被别的批次挑走就改指了),
* plan_event_logs assignment_id 是同一个坑、同一个理由。 * plan_event_logs assignment_id 是同一个坑、同一个理由。
* ⚠️ **不能**"时间窗"去套(某批 assign 之后、release 之前的执行)—— * ⚠️ **不能**"时间窗"去套(某批 assign 之后、release 之前的执行)——
* 那是启发式不是键,两批前后脚就糊了。 * 那是启发式不是键,两批前后脚就糊了。
*
* ── 什么时候写 ── * ── 什么时候写 ──
* 判据是 `followup_plans.assignment_expires_at != null` —— * 判据是 `followup_plans.assignment_expires_at != null` ——
* 那一列的自洽性就是「非空 有一次在办的分配」(退回/到期/撤销都会把它清空) * 那一列的自洽性就是「非空 有一次在办的分配」(退回/到期/撤销都会把它清空)
* **不能只看 plan.assignment_id 非空**:它在退回后**仍然留着**(那是退回率的分母), * **不能只看 plan.assignment_id 非空**:它在退回后**仍然留着**(那是退回率的分母),
* 此时客服自己从池子里捞回来打的电话,会被算进一个早就结束的批次。 * 此时客服自己从池子里捞回来打的电话,会被算进一个早就结束的批次。
*/ */
assignmentId String? @map("assignment_id") @db.Uuid assignmentId String? @map("assignment_id") @db.Uuid
...@@ -1626,46 +1620,39 @@ model PlanEventLog { ...@@ -1626,46 +1620,39 @@ model PlanEventLog {
/** /**
* 这条事件**发生在哪一批的执行过程中** * 这条事件**发生在哪一批的执行过程中**
*
* ── 为什么必须立柱(而不是塞 details Json)── * ── 为什么必须立柱(而不是塞 details Json)──
* 本表对 details 的规矩是「只放**查询不按它过滤**的内容;要按它筛就该立柱」。 * 本表对 details 的规矩是「只放**查询不按它过滤**的内容;要按它筛就该立柱」。
* 批次报表要 `groupBy(assignment_id)` 出数 —— 按自己的规矩就得是列。 * 批次报表要 `groupBy(assignment_id)` 出数 —— 按自己的规矩就得是列。
*
* ── 为什么必须有(而不是继续从 followup_plans.assignment_id 现算)── * ── 为什么必须有(而不是继续从 followup_plans.assignment_id 现算)──
* 🔴 `followup_plans.assignment_id` **会被下一次分配覆盖**:一条单退回/到期回池后 * 🔴 `followup_plans.assignment_id` **会被下一次分配覆盖**:一条单退回/到期回池后
* 再被分进新批次,旧批次就"少了一个人"——planned/agents 静默缩水,**批次跑得越久 * 再被分进新批次,旧批次就"少了一个人"——planned/agents 静默缩水,**批次跑得越久
* (退回重分越多)缩得越厉害**,且不报任何错。与 supersededAt 那个坑同构。 * (退回重分越多)缩得越厉害**,且不报任何错。与 supersededAt 那个坑同构。
* 实测:c02e1b80 分过 9 ,重分后 `planned` 显示 **0** * 实测:c02e1b80 分过 9 ,重分后 `planned` 显示 **0**
* ⚠️ 曾想用「时间窗」从账本里圈出一批(created_at BETWEEN )—— **那是启发式不是键**, * ⚠️ 曾想用「时间窗」从账本里圈出一批(created_at BETWEEN )—— **那是启发式不是键**,
* 同一主管连着分两批、两个主管同时分,就会糊在一起。归因指标不能这么算。 * 同一主管连着分两批、两个主管同时分,就会糊在一起。归因指标不能这么算。
*
* ── 什么时候写、什么时候留 null ── * ── 什么时候写、什么时候留 null ──
* assign(批次分配) 本批 id * assign(批次分配) 本批 id
* release(客服退回) 退的那一刻它归哪一批 * release(客服退回) 退的那一刻它归哪一批
* auto_release(到期/撤销) 同上 * auto_release(到期/撤销) 同上
* claim / view / feedback **null**。自认领是从池子里自己捞的, * claim / view / feedback **null**。自认领是从池子里自己捞的,
* 绝不能拿 plan 上那个**陈旧的** assignment_id 顶上 —— 那会把"自己捞的" * 绝不能拿 plan 上那个**陈旧的** assignment_id 顶上 —— 那会把"自己捞的"
* 算成"某批分的",批次报表凭空多出人。 * 算成"某批分的",批次报表凭空多出人。
*
* ⚠️ 稀疏很正常:本表 heldSeconds / reason / assigneeUserId 也都只对部分事件有值。 * ⚠️ 稀疏很正常:本表 heldSeconds / reason / assigneeUserId 也都只对部分事件有值。
*/ */
assignmentId String? @map("assignment_id") @db.Uuid assignmentId String? @map("assignment_id") @db.Uuid
/** /**
* 这条事件属于**哪一次主管重排**(调整各客服在手的单) * 这条事件属于**哪一次主管重排**(调整各客服在手的单)
*
* ── 为什么必须立柱,而不是塞 details ── * ── 为什么必须立柱,而不是塞 details ──
* 本表的规矩是「只放**查询不按它过滤**的内容;要按它筛就该立柱」。 * 本表的规矩是「只放**查询不按它过滤**的内容;要按它筛就该立柱」。
* "这次重排动了什么" / 提交幂等 / 将来撤销一次重排,三条路都要按它筛。 * "这次重排动了什么" / 提交幂等 / 将来撤销一次重排,三条路都要按它筛。
*
* ── 为什么⛔ 不复用 `assignment_id` ── * ── 为什么⛔ 不复用 `assignment_id` ──
* 那一列**没有外键**( uuid),物理上塞得进重排 id —— * 那一列**没有外键**( uuid),物理上塞得进重排 id ——
* `ledgerStatsByAssignment` / `handledByAssignment` 都在 `WHERE assignment_id IN ()` * `ledgerStatsByAssignment` / `handledByAssignment` 都在 `WHERE assignment_id IN ()`
* 上出数。两个 id 空间混一列,迟早串台,且不报错。 * 上出数。两个 id 空间混一列,迟早串台,且不报错。
*
* ⚠️ 重排**不建批次**:重排事件的 `assignment_id` 仍是这条单**原来那一批** * ⚠️ 重排**不建批次**:重排事件的 `assignment_id` 仍是这条单**原来那一批**
* (归属不动,批次报表的分母才不塌)。两列同时有值是正常的。 * (归属不动,批次报表的分母才不塌)。两列同时有值是正常的。
* claim / view / feedback 这类非重排事件此列为 null * claim / view / feedback 这类非重排事件此列为 null
*/ */
operationId String? @map("operation_id") @db.Uuid operationId String? @map("operation_id") @db.Uuid
...@@ -2002,3 +1989,62 @@ model SyncLog { ...@@ -2002,3 +1989,62 @@ model SyncLog {
/// Prisma 不支持声明式 partial UNIQUE,migration.sql 是单一真理源。 /// Prisma 不支持声明式 partial UNIQUE,migration.sql 是单一真理源。
@@map("sync_logs") @@map("sync_logs")
} }
/// UserActivity —— **通用行为埋点**(append-only)
///
/// 一张表回答三类问题,别再为每个新页面建表:
/// 谁在用 —— PV/UV( surface 分组、按天 distinct user_id)
/// 卡在哪一步 —— 漏斗(同一 surface action 逐级衰减)
/// 登录审计 —— surface='auth' 的行(谁、何时、什么角色、什么数据范围)
///
/// **身份来自 JWT,不来自请求体**。前端上报接口里根本没有 userId/role/orgScope 字段,
/// 伪造无入口。surface='auth' 那两个 action 由服务端在换票时自己落。
/// **surface/action text 不是 PG enum** —— 加埋点不该要迁移;枚举只在 @pac/types
/// (ActivitySurface / ActivityAction)约束。老数据里的历史值不会因为改枚举变非法。
/// ⚠️ [PlanEventLog] 分工:那张是**单的归属账本**(plan_id NOT NULL,记这一单归谁、
/// 被谁看过),本表是**人的行为流水**(跟具体单无关)。⛔ 别把页面 PV 塞进 PlanEventLog
/// (plan_id 放空会让所有按 plan 聚合的查询悄悄多算),也别把归属事件塞进本表。
/// ⚠️ 保留:本表按天线性增长但基数小(主管几十人量级,对比 plan_generation_logs
/// 每轮每患者一行)。真涨起来按 created_at 删旧即可 —— 索引已按 (host,tenant,created_at) 建好。
model UserActivity {
id String @id @default(uuid()) @db.Uuid
hostId String @map("host_id") @db.Uuid
tenantId String @map("tenant_id")
/// 埋点面(页面/模块), ActivitySurface
surface String
/// 行为, ActivityAction。⚠️ 漏斗永远按 (surface, action) 这一对看,别只按 action 聚合
action String
/// 操作人 —— JWT.sub
userId String @map("user_id")
/// 当时的角色。⭐ 存快照而非关联:角色由宿主换票时下发、随时会变,
/// 事后想知道"他当时是以什么身份点的"只能靠这一列。
role String
/// 当时的数据权限(JWT.orgScope 原样)。同样是快照,理由同上。
orgScope String[] @map("org_scope")
/// 宿主来源域(iframe 嵌入方), Origin/Referer 取。判断是从哪个宿主系统进来的
hostOrigin String? @map("host_origin")
/// 会话标识(JWT jti,有则记)—— 把同一次登录内的行为串成一条漏斗
sessionId String? @map("session_id")
/// 这次行为的参数(格子坐标、批次人数…)。⛔ 别塞业务实体快照(会变成对不上的影子数据)
payload Json?
/// 客户端声称的发生时刻。⚠️ 客户端时钟不可信 —— **统计一律用 createdAt**,
/// 本列只在排查前端问题时看(比如"用户说点了但没上报")
occurredAt DateTime? @map("occurred_at") @db.Timestamptz(3)
/// 服务端落库时刻 —— 所有统计的时间轴
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
host Host @relation(fields: [hostId], references: [id])
/// PV/UV 主查询: host+tenant 圈定后按天扫
@@index([hostId, tenantId, createdAt])
/// 漏斗: (surface, action) 聚合不回表
@@index([hostId, tenantId, surface, action, createdAt])
/// 单人行为回放 / 登录审计按人查
@@index([userId, createdAt])
@@map("user_activities")
}
...@@ -19,6 +19,7 @@ import { PlanAggregateModule } from './modules/plan-aggregate/plan-aggregate.mod ...@@ -19,6 +19,7 @@ import { PlanAggregateModule } from './modules/plan-aggregate/plan-aggregate.mod
import { AiModule } from './modules/ai/ai.module'; import { AiModule } from './modules/ai/ai.module';
import { RealtimeCoachModule } from './modules/realtime-coach/realtime-coach.module'; import { RealtimeCoachModule } from './modules/realtime-coach/realtime-coach.module';
import { AdminModule } from './modules/admin/admin.module'; import { AdminModule } from './modules/admin/admin.module';
import { ActivityModule } from './modules/activity/activity.module';
import { McpModule } from './modules/mcp/mcp.module'; import { McpModule } from './modules/mcp/mcp.module';
import { AssistantModule } from './modules/assistant/assistant.module'; import { AssistantModule } from './modules/assistant/assistant.module';
import { WeixinAibotModule } from './modules/weixin-aibot/weixin-aibot.module'; import { WeixinAibotModule } from './modules/weixin-aibot/weixin-aibot.module';
...@@ -48,6 +49,9 @@ import { HealthController } from './health.controller'; ...@@ -48,6 +49,9 @@ import { HealthController } from './health.controller';
// 2026-08-05:实测它在生产上是公网无鉴权可读可写的入口,而平时没人用 → 默认关掉最省事。 // 2026-08-05:实测它在生产上是公网无鉴权可读可写的入口,而平时没人用 → 默认关掉最省事。
...(process.env.PAC_BULL_BOARD === '1' ? [QueuesBullBoardModule] : []), ...(process.env.PAC_BULL_BOARD === '1' ? [QueuesBullBoardModule] : []),
AlertingModule, AlertingModule,
// @Global —— 埋点是横切关注点(auth/plan/assistant 都落),且 AuthModule 换票时就要用,
// 所以必须排在 AuthModule 之前
ActivityModule,
AuthModule, AuthModule,
FactsModule, FactsModule,
// @Global —— 摄入管线 / 画像特征 / 重扫 CLI 三处都要,放前面保证 SyncModule 解析时已就绪 // @Global —— 摄入管线 / 画像特征 / 重扫 CLI 三处都要,放前面保证 SyncModule 解析时已就绪
......
import { Body, Controller, Get, Post, Query, Req } from '@nestjs/common';
import type { Request } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod';
import { Permission } from '@pac/types';
import type { AccessTokenPayload } from '@pac/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import {
TenantScope,
TenantScopeContext,
} from '../../common/decorators/tenant-scope.decorator';
import { ActivityService } from './activity.service';
import {
ActivityStatsResponseDto,
TrackActivityRequestDto,
TrackActivityResponseDto,
} from './dto/activity.dto';
/**
* 行为埋点 —— POST /pac/v1/activities(上报) / GET /pac/v1/activities/stats(看数)
*
* ⛔ 上报接口**不接受身份字段**:userId / role / orgScope 全部从 JWT 取(见 service 注释)。
* 请求体是 strictObject,前端多传一个 userId 会被 Zod 直接拒掉 —— 这是故意的。
* ⚠️ 上报**不挂权限**:任何登录用户都能给自己记行为(记的就是他自己)。
* 看数才要 PLATFORM_MANAGE。
*/
@ApiTags('activity')
@ApiBearerAuth('accessToken')
@Controller('activities')
export class ActivityController {
constructor(private readonly svc: ActivityService) {}
@Post()
@ZodResponse({ status: 200, type: TrackActivityResponseDto })
@ApiOperation({ summary: '上报行为埋点(批量,身份取自 token)' })
async track(
@CurrentUser() user: AccessTokenPayload,
@Body() dto: TrackActivityRequestDto,
@Req() req: Request,
) {
const accepted = await this.svc.track(user, dto.items, {
hostOrigin: originOf(req),
// 会话标识 = `sub:iat`。⚠️ access token **没有 jti**(只有 refresh token 有,
// 见 auth.service 的 Redis 白名单),拿 jti 会得到一列恒空的死数据。
// `iat` 是这张票的签发秒 —— 同一次登录内所有行为共享它,正好把漏斗串起来。
sessionId: user.iat ? `${user.sub}:${user.iat}` : null,
});
return { accepted };
}
@Get('stats')
@RequirePermission(Permission.PLATFORM_MANAGE)
@ZodResponse({ status: 200, type: ActivityStatsResponseDto })
@ApiOperation({ summary: 'PV/UV + 漏斗:按 (天, surface, action) 聚合;默认近 30 天' })
stats(
@TenantScope() scope: TenantScopeContext,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('surface') surface?: string,
) {
return this.svc.stats(scope, { from, to, surface });
}
}
/// 宿主来源域:iframe 嵌入时浏览器必带 Origin;直开页面退到 Referer 的 origin。
/// 都没有就是 null(比如服务端脚本调的)。
function originOf(req: Request): string | null {
const o = req.headers.origin;
if (typeof o === 'string' && o) return o.slice(0, 200);
const r = req.headers.referer;
if (typeof r === 'string' && r) {
try {
return new URL(r).origin.slice(0, 200);
} catch {
return null;
}
}
return null;
}
import { Global, Module } from '@nestjs/common';
import { ActivityController } from './activity.controller';
import { ActivityService } from './activity.service';
/**
* 埋点模块。
*
* ⭐ `@Global` 是刻意的:埋点是**横切**关注点,auth / plan / assistant 都要落,
* 每个模块各自 import 一次只会让依赖图更难读。它无对外状态,全局暴露无副作用。
*/
@Global()
@Module({
controllers: [ActivityController],
providers: [ActivityService],
exports: [ActivityService],
})
export class ActivityModule {}
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { ActivityItem, ActivityStatsQuery, ActivityStatsResponse } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { AccessTokenPayload } from '@pac/types';
/// 单条 payload 体积上限。超了截断而不是拒收 —— 埋点是旁路,不该因为一个大字段
/// 让前端拿到 4xx 然后(多半)默默吞掉,那样丢的是整条行为记录。
const PAYLOAD_MAX_BYTES = 8 * 1024;
@Injectable()
export class ActivityService {
private readonly logger = new Logger(ActivityService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* 落埋点。**永不抛** —— 调用方一律 fire-and-forget。
*
* ⚠️ 身份(userId/role/orgScope/host/tenant)全部取自 `user`(JWT 解出来的),
* `items` 里只有 surface/action/payload。前端伪造身份没有入口。
*/
async track(
user: AccessTokenPayload,
items: ActivityItem[],
ctx?: { hostOrigin?: string | null; sessionId?: string | null },
): Promise<number> {
if (!items.length) return 0;
try {
const rows: Prisma.UserActivityCreateManyInput[] = items.map((it) => ({
hostId: user.hostId,
tenantId: user.tenantId,
surface: it.surface,
action: it.action,
userId: user.sub,
role: user.role,
orgScope: user.orgScope ?? [],
hostOrigin: ctx?.hostOrigin ?? null,
sessionId: ctx?.sessionId ?? null,
payload: this.clampPayload(it.payload),
occurredAt: it.occurredAt ? new Date(it.occurredAt) : null,
}));
const r = await this.prisma.userActivity.createMany({ data: rows });
return r.count;
} catch (e) {
// 埋点挂了不能影响主流程(主管点不了确认比少一条 PV 严重得多)
this.logger.warn(`[activity] 落库失败(已吞): ${e instanceof Error ? e.message : String(e)}`);
return 0;
}
}
/**
* 服务端自己落的埋点(登录审计等)—— 没有 JWT 上下文,身份是刚签发出去的那份。
* 同样永不抛。
*/
async trackServerSide(input: {
hostId: string;
tenantId: string;
surface: string;
action: string;
userId: string;
role: string;
orgScope: string[];
hostOrigin?: string | null;
payload?: Record<string, unknown>;
}): Promise<void> {
try {
await this.prisma.userActivity.create({
data: {
hostId: input.hostId,
tenantId: input.tenantId,
surface: input.surface,
action: input.action,
userId: input.userId,
role: input.role,
orgScope: input.orgScope ?? [],
hostOrigin: input.hostOrigin ?? null,
payload: this.clampPayload(input.payload),
},
});
} catch (e) {
this.logger.warn(`[activity] 服务端埋点失败(已吞): ${e instanceof Error ? e.message : String(e)}`);
}
}
/**
* PV/UV + 漏斗。按 (day, surface, action) 聚合。
* ⚠️ 时间轴用 `created_at`(服务端时刻),⛔ 不用 occurred_at —— 客户端时钟不可信。
*/
async stats(
scope: { hostId: string; tenantId: string },
q: ActivityStatsQuery,
): Promise<ActivityStatsResponse> {
const from = q.from ? new Date(`${q.from}T00:00:00+08:00`) : new Date(Date.now() - 30 * 864e5);
const to = q.to ? new Date(`${q.to}T23:59:59+08:00`) : new Date();
const rows = await this.prisma.$queryRaw<
Array<{ day: string; surface: string; action: string; pv: number; uv: number; roles: string[] }>
>`
SELECT to_char(a.created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS "day",
a.surface AS "surface",
a.action AS "action",
COUNT(*)::int AS "pv",
COUNT(DISTINCT a.user_id)::int AS "uv",
array_agg(DISTINCT a.role) AS "roles"
FROM user_activities a
WHERE a.host_id = ${scope.hostId}::uuid
AND a.tenant_id = ${scope.tenantId}
AND a.created_at >= ${from}
AND a.created_at <= ${to}
${q.surface ? Prisma.sql`AND a.surface = ${q.surface}` : Prisma.empty}
GROUP BY 1, 2, 3
ORDER BY 1 DESC, 2, 3
`;
return { rows };
}
/// payload 超限就换成一条自述的占位,别把原值塞进去撑爆行
private clampPayload(p: unknown): Prisma.InputJsonValue | undefined {
if (p == null) return undefined;
const s = JSON.stringify(p);
if (s.length <= PAYLOAD_MAX_BYTES) return p as Prisma.InputJsonValue;
return { _truncated: true, _bytes: s.length } as Prisma.InputJsonValue;
}
}
import { createZodDto } from 'nestjs-zod';
import {
ActivityStatsResponseSchema,
TrackActivityRequestSchema,
TrackActivityResponseSchema,
} from '@pac/types';
export class TrackActivityRequestDto extends createZodDto(TrackActivityRequestSchema) {}
export class TrackActivityResponseDto extends createZodDto(TrackActivityResponseSchema) {}
export class ActivityStatsResponseDto extends createZodDto(ActivityStatsResponseSchema) {}
import { Body, Controller, Get, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Post, Query, Req } from '@nestjs/common';
import type { Request } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod'; import { ZodResponse } from 'nestjs-zod';
import type { AccessTokenPayload } from '@pac/types'; import type { AccessTokenPayload } from '@pac/types';
...@@ -43,8 +44,10 @@ export class AuthController { ...@@ -43,8 +44,10 @@ export class AuthController {
@Post('exchange-code') @Post('exchange-code')
@ZodResponse({ status: 200, type: ExchangeCodeResponseDto }) @ZodResponse({ status: 200, type: ExchangeCodeResponseDto })
@ApiOperation({ summary: 'Exchange a one-time code (from iframe URL) for the accessToken' }) @ApiOperation({ summary: 'Exchange a one-time code (from iframe URL) for the accessToken' })
exchangeCode(@Body() dto: ExchangeCodeRequestDto) { exchangeCode(@Body() dto: ExchangeCodeRequestDto, @Req() req: Request) {
return this.auth.exchangeCode(dto.code); // Origin 只在这一步有:换 code 是浏览器发的,换票是宿主后端发的(见 service 注释)
const origin = typeof req.headers.origin === 'string' ? req.headers.origin.slice(0, 200) : null;
return this.auth.exchangeCode(dto.code, origin);
} }
@Public() @Public()
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ActivityAction, ActivitySurface } from '@pac/types';
import { ActivityService } from '../activity/activity.service';
import { JVS_DW_PRESETS } from './jvs-dw-preset'; import { JVS_DW_PRESETS } from './jvs-dw-preset';
import { import {
ApiCode, ApiCode,
...@@ -103,6 +105,9 @@ export class AuthService { ...@@ -103,6 +105,9 @@ export class AuthService {
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly alert: AlertService, private readonly alert: AlertService,
private readonly orgTree: OrgTreeService, private readonly orgTree: OrgTreeService,
/// 登录审计 —— PAC 此前**不记录登录**(没有 session/login 表,角色只活在 JWT 里),
/// 于是「谁什么时候以什么角色进来的」事后无从查证。埋点表补上这一环。
private readonly activity: ActivityService,
) {} ) {}
/** access token TTL(秒)— 进程内固定,memoize 一次(收口多处 parseDurationToSeconds) */ /** access token TTL(秒)— 进程内固定,memoize 一次(收口多处 parseDurationToSeconds) */
...@@ -157,6 +162,18 @@ export class AuthService { ...@@ -157,6 +162,18 @@ export class AuthService {
}, },
); );
// 登录审计 —— 宿主换票 = 一次登录。⚠️ fire-and-forget,埋点挂了不能让宿主换不到票。
void this.activity.trackServerSide({
hostId: host.id,
tenantId: req.user.tenantId,
surface: ActivitySurface.AUTH,
action: ActivityAction.TOKEN_EXCHANGE,
userId: req.user.userId,
role: req.user.role,
orgScope: req.user.orgScope ?? [],
payload: { appId: req.appId },
});
const expiresIn = this.accessExpiresInSeconds; const expiresIn = this.accessExpiresInSeconds;
return { return {
...@@ -168,7 +185,12 @@ export class AuthService { ...@@ -168,7 +185,12 @@ export class AuthService {
}; };
} }
async exchangeCode(code: string): Promise<ExchangeCodeResponse> { /**
* @param hostOrigin 浏览器带的 Origin —— **只有换 code 这一步有意义**:
* `exchangeToken` 是宿主**后端**发起的服务器间调用,没有浏览器 Origin;
* 换 code 是浏览器在 iframe 里发的,这个值才是"用户从哪个宿主系统进来的"。
*/
async exchangeCode(code: string, hostOrigin?: string | null): Promise<ExchangeCodeResponse> {
const expiresIn = this.accessExpiresInSeconds; const expiresIn = this.accessExpiresInSeconds;
const tag = code.slice(0, 8); // 埋点用前缀(不泄全码),相邻日志可对同一 code 关联 const tag = code.slice(0, 8); // 埋点用前缀(不泄全码),相邻日志可对同一 code 关联
// 用 get(不删)—— code 在 TTL 内可重复换、返回同一票;到点由 TTL 自然失效。 // 用 get(不删)—— code 在 TTL 内可重复换、返回同一票;到点由 TTL 自然失效。
...@@ -196,6 +218,25 @@ export class AuthService { ...@@ -196,6 +218,25 @@ export class AuthService {
this.logger.log(`[exchange-code] 重复换(TTL 内,幂等返同一票) code=${tag}…`); this.logger.log(`[exchange-code] 重复换(TTL 内,幂等返同一票) code=${tag}…`);
} }
const parsed = JSON.parse(raw) as { accessToken: string; refreshToken: string }; const parsed = JSON.parse(raw) as { accessToken: string; refreshToken: string };
// 审计 —— 换票(exchangeToken)是宿主后端发起的,**用户不一定真打开了页面**;
// 换 code 才是浏览器真的加载了 PAC。两个都记,漏斗第一格才准。
// ⚠️ 只记首次,TTL 内重复换是同一次打开(宿主重载/双次加载),记两条会虚高 PV。
if (firstTime) {
const p = this.jwt.decode(parsed.accessToken) as AccessTokenPayload | null;
if (p?.sub) {
void this.activity.trackServerSide({
hostId: p.hostId,
tenantId: p.tenantId,
surface: ActivitySurface.AUTH,
action: ActivityAction.CODE_EXCHANGE,
userId: p.sub,
role: p.role,
orgScope: p.orgScope ?? [],
hostOrigin: hostOrigin ?? null,
payload: { codeTag: tag },
});
}
}
return { return {
accessToken: parsed.accessToken, accessToken: parsed.accessToken,
refreshToken: parsed.refreshToken, refreshToken: parsed.refreshToken,
......
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Permission } from '@pac/types'; import { Permission } from '@pac/types';
import { useHasPermission } from '@/hooks/use-permission'; import { useHasPermission } from '@/hooks/use-permission';
import { SupervisorWorkbench } from '@/components/supervisor/supervisor-workbench'; import { SupervisorWorkbench } from '@/components/supervisor/supervisor-workbench';
import { ActivityAction, ActivitySurface } from '@pac/types';
import { track } from '@/lib/track';
/** /**
* /supervisor — 主管工作台。 * /supervisor — 主管工作台。
...@@ -45,6 +47,17 @@ export default function SupervisorPage() { ...@@ -45,6 +47,17 @@ export default function SupervisorPage() {
if (!canDispatch) router.replace('/plans'); if (!canDispatch) router.replace('/plans');
}, [canDispatch, router]); }, [canDispatch, router]);
/// 埋点:漏斗第一格「有没有人打开工作台」。⚠️ 放在权限判定**之后** —— 被弹走的客服
/// 不算一次工作台访问,否则 UV 里会混进一批根本没看见这页的人。
const trackedRef = useRef(false);
useEffect(() => {
// ⚠️ ref 守卫不是多余的:React 18 StrictMode 开发态会把 effect 跑两遍,
// 而 `canDispatch` 也可能 false→true 翻一次 —— 两条路都会让一次访问记成两条 PV。
if (!canDispatch || trackedRef.current) return;
trackedRef.current = true;
track(ActivitySurface.SUPERVISOR_WORKBENCH, ActivityAction.OPEN);
}, [canDispatch]);
if (!canDispatch) return null; if (!canDispatch) return null;
return <SupervisorWorkbench />; return <SupervisorWorkbench />;
} }
...@@ -23,6 +23,8 @@ import { useAssistantStore } from '@/stores/assistant-store'; ...@@ -23,6 +23,8 @@ import { useAssistantStore } from '@/stores/assistant-store';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ConfirmSheetSignals } from './confirm-sheet-signals'; import { ConfirmSheetSignals } from './confirm-sheet-signals';
import { ActivityAction, ActivitySurface } from '@pac/types';
import { track } from '@/lib/track';
import { BenefitPopover } from './benefit-popover'; import { BenefitPopover } from './benefit-popover';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { import {
...@@ -252,6 +254,27 @@ export function AssignmentConfirmSheet({ ...@@ -252,6 +254,27 @@ export function AssignmentConfirmSheet({
const [revoked, setRevoked] = useState<RevokeAssignmentResponse | null>(null); const [revoked, setRevoked] = useState<RevokeAssignmentResponse | null>(null);
const [revoking, setRevoking] = useState(false); const [revoking, setRevoking] = useState(false);
const [confirmRevoke, setConfirmRevoke] = useState(false); const [confirmRevoke, setConfirmRevoke] = useState(false);
/**
* 埋点:漏斗第三格「确认单真的摆出来了」。
*
* ⚠️ 按 `requestId` 去重 —— 这张卡片会因为流式渲染 / 编辑 / 重排重渲染很多次,
* 不去重的话一次提案能记出十几条 PV,漏斗中段直接失真。
* ⚠️ 只在 `active` 记:`superseded`(被新版顶掉)是同一次提案的另一版本,
* `confirmed` / `cancelled` 是终态回显,都不是"新摆了一张单"。
*/
const shownRef = useRef<string | null>(null);
useEffect(() => {
if (state !== 'active' || shownRef.current === requestId) return;
shownRef.current = requestId;
track(ActivitySurface.SUPERVISOR_WORKBENCH, ActivityAction.PROPOSAL_SHOWN, {
requestId,
candidateTotal: sheet.candidateTotal,
batchSize: sheet.batchSize,
potentialTreatment: sheet.potentialTreatment,
temperature: sheet.temperature,
});
}, [state, requestId, sheet.candidateTotal, sheet.batchSize, sheet.potentialTreatment, sheet.temperature]);
/// 每 30 秒重算一次窗口(到点按钮自己消失,⛔ 别让主管点了才被服务端拒) /// 每 30 秒重算一次窗口(到点按钮自己消失,⛔ 别让主管点了才被服务端拒)
const [, setRevokeTick] = useState(0); const [, setRevokeTick] = useState(0);
useEffect(() => { useEffect(() => {
...@@ -603,6 +626,16 @@ export function AssignmentConfirmSheet({ ...@@ -603,6 +626,16 @@ export function AssignmentConfirmSheet({
// 一调 revoke_assignment 就报「批次不存在」,而卡片上的撤销按钮握着完整 id 一直是好的, // 一调 revoke_assignment 就报「批次不存在」,而卡片上的撤销按钮握着完整 id 一直是好的,
// 两条路测一条就漏了(实测 2026-08-03)。 // 两条路测一条就漏了(实测 2026-08-03)。
// ⚠️ 界面这份**不显示 id**:uuid 对主管没有意义,短号则会诱导他把短号念给助手。 // ⚠️ 界面这份**不显示 id**:uuid 对主管没有意义,短号则会诱导他把短号念给助手。
// 埋点:漏斗最后一格。⚠️ 落在**服务端返回之后** —— 点了按钮但请求失败不算确认,
// 否则漏斗末格会比 plan_assignments 的真实条数多,而多出来的那部分正是要查的失败。
track(ActivitySurface.SUPERVISOR_WORKBENCH, ActivityAction.ASSIGN_CONFIRM, {
assignmentId: res.assignmentId,
assigned: res.assigned,
agents: groups.length,
skipped: res.skipped.length,
potentialTreatment: sheet.potentialTreatment,
temperature: sheet.temperature,
});
/** /**
* 🔴 **确认之后的福利提醒不再由卡片摆**(2026-08-15 产品定)。 * 🔴 **确认之后的福利提醒不再由卡片摆**(2026-08-15 产品定)。
* *
......
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { ActivityAction, ActivitySurface } from '@pac/types';
import { track } from '@/lib/track';
import { TEMPERATURE_AXIS_ZH, TEMPERATURE_META, type TemperatureValue } from '@pac/types'; import { TEMPERATURE_AXIS_ZH, TEMPERATURE_META, type TemperatureValue } from '@pac/types';
import { plansApi, type PoolMatrix as PoolMatrixData } from '@/components/plans/plans-api'; import { plansApi, type PoolMatrix as PoolMatrixData } from '@/components/plans/plans-api';
import { PoolMatrix } from '@/components/plans/pool-matrix'; import { PoolMatrix } from '@/components/plans/pool-matrix';
...@@ -99,6 +101,12 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) { ...@@ -99,6 +101,12 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
const [preview, setPreview] = useState<PickedCell | null>(null); const [preview, setPreview] = useState<PickedCell | null>(null);
const pick = useCallback((c: Picked) => { const pick = useCallback((c: Picked) => {
const { rect: _rect, ...cell } = c; const { rect: _rect, ...cell } = c;
// 埋点:漏斗第二格。⛔ 别记 rect(那是屏幕坐标,换个分辨率就没意义)
track(ActivitySurface.SUPERVISOR_WORKBENCH, ActivityAction.MATRIX_CELL, {
treatment: cell.treatment,
temperature: cell.temperature,
count: cell.count,
});
setPreview(cell); setPreview(cell);
}, []); }, []);
......
'use client';
import type { ActivityItem } from '@pac/types';
import { api } from './api-client';
/**
* 行为埋点(通用)—— `track(surface, action, payload?)`。
*
* ── 三条纪律 ──────────────────────────────────────────────
* ⛔ **不传身份**。userId / role / orgScope 由服务端从 JWT 取;上报接口是 strictObject,
* 多传一个字段会被直接拒掉。想在 payload 里塞 userId 也别塞 —— 那只会得到一份
* 和 token 不一致时无从分辨的脏数据。
* ⚠️ **绝不 await、绝不 throw**。埋点是旁路:网络挂了、后端 500 了,用户该点的照样点。
* 调用方写 `track(...)` 不用 `void`,函数本身返回 void。
* ⚠️ **合并发送**:同一次交互可能连打几条(open + matrix_cell),用微批把它们并成一个请求,
* 否则主管点一下矩阵能出三个 XHR,既吵又抢主流程的连接。
*
* 未登录时静默丢弃 —— 没有 token 的上报服务端也认不出是谁,发了也是 401 噪音。
*/
const FLUSH_DELAY_MS = 400;
/// 一批最多 50 条,与服务端 TrackActivityRequestSchema 的上限对齐
const MAX_BATCH = 50;
let queue: ActivityItem[] = [];
let timer: ReturnType<typeof setTimeout> | null = null;
function flush() {
timer = null;
if (!queue.length) return;
const items = queue.splice(0, MAX_BATCH);
// 失败就算了:重试会在后端抖动时把队列越堆越长,而埋点丢几条无所谓
// ⚠️ 路径要带全局前缀 —— api-client 用 `new URL(path, base)` 拼,不会自动补 /pac/v1
void api.post('/pac/v1/activities', { items }).catch(() => {});
if (queue.length) schedule();
}
function schedule() {
if (timer) return;
timer = setTimeout(flush, FLUSH_DELAY_MS);
}
export function track(
surface: string,
action: string,
payload?: Record<string, unknown>,
): void {
try {
queue.push({ surface, action, payload, occurredAt: new Date().toISOString() });
if (queue.length >= MAX_BATCH) flush();
else schedule();
} catch {
/* 埋点永不影响调用方 */
}
}
/// 页面卸载前把队列冲掉 —— 否则「打开了工作台就关掉」这条最关键的漏斗首格会丢。
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
flush();
});
}
# 行为埋点(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)拖到不得不停服才处理。
import { z } from 'zod';
// =============================================================
// User Activity — 通用行为埋点 (POST /pac/v1/activities)
// =============================================================
//
// 一张表回答三类问题,别再为每个新页面建表:
// ① 谁在用 —— PV/UV(按 surface 分组、按天 distinct userId)
// ② 卡在哪一步 —— 漏斗(同一 surface 下 action 的逐级衰减)
// ③ 谁什么时候以什么角色进来的 —— 登录审计(surface=auth)
//
// ── 设计纪律(改之前先读) ────────────────────────────────────
// ⛔ **身份一律从 JWT 取,不信客户端**。请求体里没有 userId / role / orgScope 字段,
// 前端想伪造也没有入口。唯一例外是 surface=auth 的换票,那是服务端自己落的。
// ⛔ **surface / action 在库里是 text,不是 PG enum**。加一个新埋点不该要一次迁移;
// 枚举只在这里(TS 侧)约束,库里保持宽松 —— 老数据里的历史值也不会因为改枚举变非法。
// ⚠️ **payload 只放"这次行为的参数",不放业务实体快照**。放快照会让这张表变成一份
// 永远对不上的影子数据(业务表改了它不改),而且体积失控。
// ⚠️ 埋点是**旁路**:后端 fire-and-forget,写失败只落日志不影响主流程 —— 埋点挂了
// 不能让主管点不了确认。
/// 埋点面(页面 / 模块)。加新页面就加一个值,不用迁移。
export const ActivitySurface = {
/// 主管工作台(矩阵 + 分配 + 团队跟踪)
SUPERVISOR_WORKBENCH: 'supervisor_workbench',
/// 客服执行页(单详情)—— 注:单维度的"看过没"仍走 plan_event_logs.view,
/// 这里只记页面级进入,两者口径不同别混用。
STAFF_EXECUTION: 'staff_execution',
/// 召回池列表
PLAN_POOL: 'plan_pool',
/// 认证(换票 / 换 code)—— 服务端落,登录审计就是这一类
AUTH: 'auth',
} as const;
export type ActivitySurface = (typeof ActivitySurface)[keyof typeof ActivitySurface];
/// 行为。**同一个 action 可以出现在不同 surface 下**(open / view 都是通用动作),
/// 漏斗永远按 (surface, action) 这一对来看,别只按 action 聚合。
export const ActivityAction = {
/// 进入该面(PV 的分子)
OPEN: 'open',
/// 主管点了矩阵格子(payload: { category, bucket, count })
MATRIX_CELL: 'matrix_cell',
/// 助手把确认单摆出来了(payload: { candidateTotal, batchSize })
PROPOSAL_SHOWN: 'proposal_shown',
/// 主管点了确认,分配真的落库(payload: { assignmentId, items })
ASSIGN_CONFIRM: 'assign_confirm',
/// 摆出来了但放弃(payload: { reason? })
PROPOSAL_ABANDON: 'proposal_abandon',
/// 宿主换票(surface=auth,服务端落)
TOKEN_EXCHANGE: 'token_exchange',
/// 一次性 code 换票成功(surface=auth,服务端落)
CODE_EXCHANGE: 'code_exchange',
} as const;
export type ActivityAction = (typeof ActivityAction)[keyof typeof ActivityAction];
/// 单条埋点。⛔ 没有身份字段 —— 见文件头注释。
export const ActivityItemSchema = z.strictObject({
surface: z.string().min(1).max(64),
action: z.string().min(1).max(64),
/// 这次行为的参数。⛔ 别塞业务实体快照;单条 8KB 上限由后端兜。
payload: z.record(z.string(), z.unknown()).optional(),
/// 客户端发生时刻(ISO)。缺省用服务端收到的时刻 —— 客户端时钟不可信,
/// 所以**两个时刻都存**:排序统计用服务端的,排查前端问题看客户端的。
occurredAt: z.string().datetime().optional(),
});
export type ActivityItem = z.infer<typeof ActivityItemSchema>;
/// 批量上报 —— 前端一次交互可能产生好几条,合并成一个请求少打扰主流程。
export const TrackActivityRequestSchema = z.strictObject({
items: z.array(ActivityItemSchema).min(1).max(50),
});
export type TrackActivityRequest = z.infer<typeof TrackActivityRequestSchema>;
export const TrackActivityResponseSchema = z.object({
accepted: z.number().int(),
});
export type TrackActivityResponse = z.infer<typeof TrackActivityResponseSchema>;
// ── 查询侧(admin)──────────────────────────────────────────
export const ActivityStatsQuerySchema = z.strictObject({
/// 起始日(含),YYYY-MM-DD;缺省 = 近 30 天
from: z.string().optional(),
to: z.string().optional(),
surface: z.string().optional(),
});
export type ActivityStatsQuery = z.infer<typeof ActivityStatsQuerySchema>;
export const ActivityStatRowSchema = z.object({
day: z.string(),
surface: z.string(),
action: z.string(),
/// PV = 事件条数
pv: z.number().int(),
/// UV = 去重用户数
uv: z.number().int(),
/// 出现过的角色。⚠️ 是 `string[]` 不是 UserRole[] —— 库里 role 存的是**快照文本**,
/// 角色由宿主换票时下发,将来宿主多一个角色、或 PAC 改了枚举,老行仍是老值。
/// 拿枚举去卡会让历史数据在读取时校验失败(写得进读不出,最难查的一类 bug)。
roles: z.array(z.string()),
});
export type ActivityStatRow = z.infer<typeof ActivityStatRowSchema>;
export const ActivityStatsResponseSchema = z.object({
rows: z.array(ActivityStatRowSchema),
});
export type ActivityStatsResponse = z.infer<typeof ActivityStatsResponseSchema>;
export * from './activity';
export * from './wrap'; export * from './wrap';
export * from './common'; export * from './common';
export * from './auth'; export * from './auth';
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment