Commit 1bccd47f by luoqi

feat(stats): plan 详情页 PV/UV 埋点 —— plan_event_logs 加 view 事件

背景:生产此前**完全统计不了 PV/UV** —— 前端无任何分析埋点(gtag/umami/plausible
全无),服务端无 request log,生产 ECS 上更没有 nginx(pac.friday.tech 走独立网关
lb1.friday.tech,访问日志不在我们这台机器上)。Sentry 只有 10% tracing 采样,
做 UV 会系统性偏低。

范围限定在 plan 详情页(切合 plan_event_logs 表名):plan_id / patient_id 本就是
该表的必填列,浏览事件天然填得上,不必为它放宽约束或另立新表。

三种入口按用户口径**全收**:直接进/刷新 · 患者列表 rail 切换 · /plans 落地自动跳转。
(自动跳转确实是系统行为,但业务侧确认要收 —— 它也代表这条 plan 被展示过。)

 唯一的雷:byHuman 不是"是不是人做的",是**口径开关**
  HUMAN_TOUCH_EVENTS 靠 filter(byHuman) 派生。view 按字面语义该写 true,但那会让
  「客服处理过哪些患者」静默膨胀成"看一眼也算处理"——刚交付的 65 人统计与整张
  客服明细 Excel 全部失真,**而且不会有任何编译错误或类型报错**。
  故 view 写 byHuman:false,并把该字段注释从"是否人工动作"改写成明确的口径定义;
  spec 里用**精确相等**(非 arrayContaining)锁死 HUMAN_TOUCH_EVENTS 的四个成员,
  以后新增事件必须显式决策要不要计入,改错即红。

改动:
- enums: PlanEventType.VIEW + PLAN_EVENT_META 项(group 联合类型扩 'view')
- controller: POST /pac/v1/plans/:id/view。**刻意不加认领闸**(区别于 recall-feedback)——
  浏览发生在认领之前,池子里任何一条都能点开;加闸则埋点只剩已认领的,PV/UV 直接废掉。
  plan 不存在静默返回 not_found:埋点是旁路,不能因它失败而影响页面。
- 前端: 埋点挂在 use-plan-aggregate 现成的去重守卫里 —— 该守卫原为防 StrictMode 双调,
  其语义恰好等于"每进入一次详情页";refresh() 直接调 load() 走不到这里,故手动刷新不重复计。
  失败 .catch(()=>{}) 吞掉。

验证(本地起真实前后端 + 浏览器实操):
  /plans 自动跳转 → 记 1 条 ✓    手动点刷新 → 仍 1 条(不重复计)✓
  重新进入页面   → 累计 2 条 ✓    未认领查看 → 能记 ✓
  plan 不存在 → not_found 不报错 ✓  无 token → 401 ✓
   只有 view 事件的患者,在「处理过」口径下计 0(全事件口径会误计 2)✓
  640 tests passed,service/web typecheck 通过。

无 DB 迁移(event 是既有 text 列,加的是取值不是列)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 6333137a
...@@ -251,6 +251,35 @@ export class PlansAggregateController { ...@@ -251,6 +251,35 @@ export class PlansAggregateController {
return { ok: true as const, invocationId: script.agentInvocationId, feedback }; return { ok: true as const, invocationId: script.agentInvocationId, feedback };
} }
@Post(':id/view')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({
summary: '上报 plan 详情页浏览(PV/UV 埋点)—— 每进入一次记一条',
})
async reportPlanView(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
) {
// ⚠️ **不加认领闸**(区别于 recall-feedback / 执行结果):浏览发生在认领**之前**,
// 池子里任何一条都能点开看。加了闸这个埋点就只剩已认领的,PV/UV 直接废掉。
const plan = await this.prisma.followupPlan.findFirst({
where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
select: { id: true, hostId: true, tenantId: true, patientId: true },
});
// 静默返回 not_found:埋点是**尽力而为**的旁路,不能因为它失败而影响页面
if (!plan) return { ok: false as const, reason: 'not_found' };
await recordPlanEvent(this.prisma, {
hostId: plan.hostId,
tenantId: plan.tenantId,
planId: plan.id,
patientId: plan.patientId,
event: PlanEventType.VIEW,
actorUserId: user.sub,
});
return { ok: true as const };
}
@Post(':id/recall-feedback') @Post(':id/recall-feedback')
@RequirePermission(Permission.PLAN_VIEW_OWN) @RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ @ApiOperation({
......
...@@ -203,3 +203,36 @@ describe('RecycleSchedulerService — 自动回收默认关闭', () => { ...@@ -203,3 +203,36 @@ describe('RecycleSchedulerService — 自动回收默认关闭', () => {
.toHaveBeenCalled(); .toHaveBeenCalled();
}); });
}); });
// =============================================================
// 2026-07 加 view 事件(plan 详情页 PV/UV)—— 锁死"浏览 ≠ 处理"
// =============================================================
describe('PlanEventType.VIEW — PV/UV 埋点', () => {
it('⭐ 红线:view 绝不能进 HUMAN_TOUCH_EVENTS', () => {
// byHuman 是**口径开关**不是"是不是人做的"。给 view 写 true 会让
// 「客服处理过的患者」静默膨胀成"看一眼也算处理",且**没有任何编译错误**。
expect(PLAN_EVENT_META.view.byHuman).toBe(false);
expect(HUMAN_TOUCH_EVENTS).not.toContain(PlanEventType.VIEW);
});
it('HUMAN_TOUCH_EVENTS 精确等于四个处理类事件(加了新事件要显式决策)', () => {
expect([...HUMAN_TOUCH_EVENTS].sort()).toEqual(
[
PlanEventType.ASSIGN,
PlanEventType.CLAIM,
PlanEventType.FEEDBACK,
PlanEventType.RELEASE,
].sort(),
);
});
it('view 不改变归属:holdsPatient=false,独立 group', () => {
expect(PLAN_EVENT_META.view.holdsPatient).toBe(false);
expect(PLAN_EVENT_META.view.group).toBe('view');
});
it('view 已在枚举登记(元数据完整)', () => {
expect(PlanEventType.VIEW).toBe('view');
expect(PLAN_EVENT_META.view.labelZh.length).toBeGreaterThan(0);
});
});
...@@ -38,6 +38,10 @@ export const plansApi = { ...@@ -38,6 +38,10 @@ export const plansApi = {
getAggregate: (planId: string) => getAggregate: (planId: string) =>
api.get<PlanDetailData>(`/pac/v1/plans/${encodeURIComponent(planId)}/full`), api.get<PlanDetailData>(`/pac/v1/plans/${encodeURIComponent(planId)}/full`),
/** PV/UV 埋点:上报一次 plan 详情页浏览。**尽力而为**,失败不影响页面(调用方吞异常)*/
reportView: (planId: string) =>
api.post<{ ok: boolean }>(`/pac/v1/plans/${encodeURIComponent(planId)}/view`, {}),
/** 回访历史一句话摘要(有则取、无则当场生成;无回访记录 status='empty')*/ /** 回访历史一句话摘要(有则取、无则当场生成;无回访记录 status='empty')*/
getRecallSummary: (planId: string) => getRecallSummary: (planId: string) =>
api.get<{ summary: string | null; status: 'ready' | 'empty'; source?: string }>( api.get<{ summary: string | null; status: 'ready' | 'empty'; source?: string }>(
......
...@@ -35,6 +35,11 @@ export function usePlanAggregate(planId: string) { ...@@ -35,6 +35,11 @@ export function usePlanAggregate(planId: string) {
if (loadedFor.current === planId) return; if (loadedFor.current === planId) return;
loadedFor.current = planId; loadedFor.current = planId;
void load(); void load();
// ⭐ PV/UV 埋点:借这道守卫定义「进入一次详情页」——
// 三种入口全收(直接进/刷新 · 患者列表切换 · /plans 落地自动跳转),
// 而**手动 refresh 不重复计**(refresh 直接调 load(),走不到这里)。
// 尽力而为:失败只吞掉,埋点不能影响页面。
void plansApi.reportView(planId).catch(() => {});
}, [planId, load]); }, [planId, load]);
return { state, refresh: load }; return { state, refresh: load };
......
...@@ -755,26 +755,41 @@ export const PlanEventType = { ...@@ -755,26 +755,41 @@ export const PlanEventType = {
AUTO_RELEASE: 'auto_release', AUTO_RELEASE: 'auto_release',
/// 反馈:召回准不准 👍/👎(reason 列存 up/down) /// 反馈:召回准不准 👍/👎(reason 列存 up/down)
FEEDBACK: 'feedback', FEEDBACK: 'feedback',
/// 浏览:进入 plan 详情页一次(PV/UV 用)。**不是"处理"** —— 见下方 byHuman 说明。
/// 三种入口全收:直接进/刷新 · 患者列表切换 · /plans 落地自动跳转;
/// 同一 planId 每次进入记一次(前端 usePlanAggregate 去重守卫为界),手动刷新不重复计。
VIEW: 'view',
} as const; } as const;
export type PlanEventType = (typeof PlanEventType)[keyof typeof PlanEventType]; export type PlanEventType = (typeof PlanEventType)[keyof typeof PlanEventType];
/** /**
* 事件元数据 —— 报表分组 + "算不算人工处理"的判定源。 * 事件元数据 —— 报表分组 + "算不算人工处理"的判定源。
* *
* group ownership 归属变更 / feedback 质量反馈(后续可加 lifecycle 等) * group ownership 归属变更 / feedback 质量反馈 / view 浏览埋点(后续可加 lifecycle 等)
* byHuman 是否人工动作。⭐ 统计「客服处理过哪些患者」只算 byHuman=true 的, * byHuman ⭐ **口径开关,不是"是不是人做的"** —— 语义是「该事件算不算『客服处理过这个患者』」,
* auto_release 是系统行为,不能算成"客服处理过"。 * 即是否进 [[HUMAN_TOUCH_EVENTS]]。两类为 false,原因不同:
* · auto_release —— 系统超时回收,压根不是人的动作;
* · view —— 是人的动作,但**浏览 ≠ 处理**。若给它 true,「处理过的患者」
* 口径会静默膨胀成"看一眼也算",而 HUMAN_TOUCH_EVENTS 是 filter 派生的,
* 改错**不会有任何编译错误或类型报错**。下方 spec 有断言锁死。
* holdsPatient 该事件之后 plan 是否仍挂在人名下(用于算归属区间) * holdsPatient 该事件之后 plan 是否仍挂在人名下(用于算归属区间)
*/ */
export const PLAN_EVENT_META: Record< export const PLAN_EVENT_META: Record<
PlanEventType, PlanEventType,
{ labelZh: string; group: 'ownership' | 'feedback'; byHuman: boolean; holdsPatient: boolean } {
labelZh: string;
group: 'ownership' | 'feedback' | 'view';
byHuman: boolean;
holdsPatient: boolean;
}
> = { > = {
claim: { labelZh: '认领', group: 'ownership', byHuman: true, holdsPatient: true }, claim: { labelZh: '认领', group: 'ownership', byHuman: true, holdsPatient: true },
assign: { labelZh: '指派', group: 'ownership', byHuman: true, holdsPatient: true }, assign: { labelZh: '指派', group: 'ownership', byHuman: true, holdsPatient: true },
release: { labelZh: '返池', group: 'ownership', byHuman: true, holdsPatient: false }, release: { labelZh: '返池', group: 'ownership', byHuman: true, holdsPatient: false },
auto_release: { labelZh: '超时自动回收', group: 'ownership', byHuman: false, holdsPatient: false }, auto_release: { labelZh: '超时自动回收', group: 'ownership', byHuman: false, holdsPatient: false },
feedback: { labelZh: '召回反馈', group: 'feedback', byHuman: true, holdsPatient: true }, feedback: { labelZh: '召回反馈', group: 'feedback', byHuman: true, holdsPatient: true },
// ⚠️ byHuman: false —— 浏览是人做的,但**不算"处理过"**(见上方字段说明)。别改成 true。
view: { labelZh: '查看详情', group: 'view', byHuman: false, holdsPatient: false },
}; };
/// 「该患者被客服处理过」的事件集合 —— 统计口径收口在这里,避免各处各写一套。 /// 「该患者被客服处理过」的事件集合 —— 统计口径收口在这里,避免各处各写一套。
......
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