Commit c3556daf by luoqi

merge: feat/user-activities → test(通用行为埋点:PV/UV + 漏斗 + 登录审计)

parents 74425833 1eddc1b6
Pipeline #3588 failed in 0 seconds
-- 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;
......@@ -19,6 +19,7 @@ import { PlanAggregateModule } from './modules/plan-aggregate/plan-aggregate.mod
import { AiModule } from './modules/ai/ai.module';
import { RealtimeCoachModule } from './modules/realtime-coach/realtime-coach.module';
import { AdminModule } from './modules/admin/admin.module';
import { ActivityModule } from './modules/activity/activity.module';
import { McpModule } from './modules/mcp/mcp.module';
import { AssistantModule } from './modules/assistant/assistant.module';
import { WeixinAibotModule } from './modules/weixin-aibot/weixin-aibot.module';
......@@ -48,6 +49,9 @@ import { HealthController } from './health.controller';
// 2026-08-05:实测它在生产上是公网无鉴权可读可写的入口,而平时没人用 → 默认关掉最省事。
...(process.env.PAC_BULL_BOARD === '1' ? [QueuesBullBoardModule] : []),
AlertingModule,
// @Global —— 埋点是横切关注点(auth/plan/assistant 都落),且 AuthModule 换票时就要用,
// 所以必须排在 AuthModule 之前
ActivityModule,
AuthModule,
FactsModule,
// @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 { ZodResponse } from 'nestjs-zod';
import type { AccessTokenPayload } from '@pac/types';
......@@ -43,8 +44,10 @@ export class AuthController {
@Post('exchange-code')
@ZodResponse({ status: 200, type: ExchangeCodeResponseDto })
@ApiOperation({ summary: 'Exchange a one-time code (from iframe URL) for the accessToken' })
exchangeCode(@Body() dto: ExchangeCodeRequestDto) {
return this.auth.exchangeCode(dto.code);
exchangeCode(@Body() dto: ExchangeCodeRequestDto, @Req() req: Request) {
// 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()
......
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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 {
ApiCode,
......@@ -103,6 +105,9 @@ export class AuthService {
private readonly config: ConfigService,
private readonly alert: AlertService,
private readonly orgTree: OrgTreeService,
/// 登录审计 —— PAC 此前**不记录登录**(没有 session/login 表,角色只活在 JWT 里),
/// 于是「谁什么时候以什么角色进来的」事后无从查证。埋点表补上这一环。
private readonly activity: ActivityService,
) {}
/** access token TTL(秒)— 进程内固定,memoize 一次(收口多处 parseDurationToSeconds) */
......@@ -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;
return {
......@@ -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 tag = code.slice(0, 8); // 埋点用前缀(不泄全码),相邻日志可对同一 code 关联
// 用 get(不删)—— code 在 TTL 内可重复换、返回同一票;到点由 TTL 自然失效。
......@@ -196,6 +218,25 @@ export class AuthService {
this.logger.log(`[exchange-code] 重复换(TTL 内,幂等返同一票) code=${tag}…`);
}
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 {
accessToken: parsed.accessToken,
refreshToken: parsed.refreshToken,
......
'use client';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Permission } from '@pac/types';
import { useHasPermission } from '@/hooks/use-permission';
import { SupervisorWorkbench } from '@/components/supervisor/supervisor-workbench';
import { ActivityAction, ActivitySurface } from '@pac/types';
import { track } from '@/lib/track';
/**
* /supervisor — 主管工作台。
......@@ -45,6 +47,17 @@ export default function SupervisorPage() {
if (!canDispatch) router.replace('/plans');
}, [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;
return <SupervisorWorkbench />;
}
......@@ -23,6 +23,8 @@ import { useAssistantStore } from '@/stores/assistant-store';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ConfirmSheetSignals } from './confirm-sheet-signals';
import { ActivityAction, ActivitySurface } from '@pac/types';
import { track } from '@/lib/track';
import { BenefitPopover } from './benefit-popover';
import { Card, CardContent } from '@/components/ui/card';
import {
......@@ -252,6 +254,27 @@ export function AssignmentConfirmSheet({
const [revoked, setRevoked] = useState<RevokeAssignmentResponse | null>(null);
const [revoking, setRevoking] = 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 秒重算一次窗口(到点按钮自己消失,⛔ 别让主管点了才被服务端拒)
const [, setRevokeTick] = useState(0);
useEffect(() => {
......@@ -603,6 +626,16 @@ export function AssignmentConfirmSheet({
// 一调 revoke_assignment 就报「批次不存在」,而卡片上的撤销按钮握着完整 id 一直是好的,
// 两条路测一条就漏了(实测 2026-08-03)。
// ⚠️ 界面这份**不显示 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 产品定)。
*
......
'use client';
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 { plansApi, type PoolMatrix as PoolMatrixData } from '@/components/plans/plans-api';
import { PoolMatrix } from '@/components/plans/pool-matrix';
......@@ -99,6 +101,12 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
const [preview, setPreview] = useState<PickedCell | null>(null);
const pick = useCallback((c: Picked) => {
const { rect: _rect, ...cell } = c;
// 埋点:漏斗第二格。⛔ 别记 rect(那是屏幕坐标,换个分辨率就没意义)
track(ActivitySurface.SUPERVISOR_WORKBENCH, ActivityAction.MATRIX_CELL, {
treatment: cell.treatment,
temperature: cell.temperature,
count: cell.count,
});
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 './common';
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