feat(admin): 时间线业务事件徽章+询盘漏斗卡+付费社交渠道标签(含server时间线安全字段)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-19 00:12:13 +08:00
parent 8d7fc99ef6
commit c9967b6d70
7 changed files with 324 additions and 15 deletions
+18
View File
@@ -57,3 +57,21 @@ export interface OverviewResp {
/** 总览数据(site 传 site_id 可选;range 默认 7d) */ /** 总览数据(site 传 site_id 可选;range 默认 7d) */
export const getOverview = (params: { site?: string; range?: OverviewRange } = {}) => export const getOverview = (params: { site?: string; range?: OverviewRange } = {}) =>
request.get<OverviewResp>({ url: '/admin/dashboard/overview', params }) request.get<OverviewResp>({ url: '/admin/dashboard/overview', params })
/** 漏斗级行(label 为服务端基词;末级「邮件已发」前端追加「(未验证)」限定语——T3 审查备忘) */
export interface FunnelLevel {
/** 级别事件名:page_view/contact_click/form_start/form_submit/form_submit_result */
key: string
label: string
/** 窗口内发生过该事件的 distinct 会话数(五级逐级独立口径,非嵌套) */
count: number
}
/** 询盘漏斗响应(五级,无百分比——前端只做计数条渲染) */
export interface FunnelSummaryResp {
levels: FunnelLevel[]
}
/** 询盘漏斗(days=1 当日 / 7 近 7 天,上海自然日窗口;排除爬虫;site 可选) */
export const getFunnelSummary = (days: number, site?: string) =>
request.get<FunnelSummaryResp>({ url: '/admin/funnel-summary', params: { days, site } })
+20 -3
View File
@@ -59,7 +59,7 @@ export interface SessionSummary {
/** 单条来源识别(visitors.py `_classification`:classify() 结果 + 规则版本/计算时间) */ /** 单条来源识别(visitors.py `_classification`:classify() 结果 + 规则版本/计算时间) */
export interface AttributionInfo { export interface AttributionInfo {
/** CHANNELS 枚举:natural_search/paid_search/ai_referral/social/in_app/referral/direct/unknown */ /** CHANNELS 枚举:natural_search/paid_search/paid_social/ai_referral/social/in_app/referral/direct/unknown */
channel: string channel: string
/** 平台显示名(Google/ChatGPT/微信…;referral 渠道为来源域名) */ /** 平台显示名(Google/ChatGPT/微信…;referral 渠道为来源域名) */
platform: string platform: string
@@ -116,7 +116,8 @@ export interface VisitorDetail extends VisitorRow {
sessions: SessionSummary[] sessions: SessionSummary[]
} }
/** 时间线单事件(`_merge_page_stay` 白名单字段,不透出 properties 原文) */ /** 时间线单事件(`_merge_page_stay` 白名单字段,不透出 properties 原文;
* 业务事件额外携带 `_safe_fields` 安全字段——均为可选,旧数据/无该字段时不出现) */
export interface TimelineEvent { export interface TimelineEvent {
time: string time: string
event_name: string event_name: string
@@ -124,6 +125,22 @@ export interface TimelineEvent {
title: string title: string
/** page_view 合并后为累计毫秒(初始 0);独立成条的 page_stay 为毫秒;其余事件为 null */ /** page_view 合并后为累计毫秒(初始 0);独立成条的 page_stay 为毫秒;其余事件为 null */
active_ms: number | null active_ms: number | null
/** contact_click 咨询渠道:whatsapp / phone / email */
channel?: string
/** form_start / form_submit 表单标识 */
form_id?: string
/** form_submit 提交批次号前 8 位(服务端截断,非全量 ID) */
submission_id?: string
/** form_error 错误码(如 validation_failed) */
error_code?: string
/** form_error 未通过的表单字段数 */
field_count?: number
/** form_submit_result 投递结果:mail_sent / mail_failed */
result_code?: string
/** product_view 产品标识 */
product_id?: string
/** product_view 是否 URL 推导(非埋点显式上报) */
inferred?: boolean
} }
/** 时间线单会话分组(session 为 NULL 的孤立事件并成一组:session_id/started_at 均为 null) */ /** 时间线单会话分组(session 为 NULL 的孤立事件并成一组:session_id/started_at 均为 null) */
@@ -191,7 +208,7 @@ export interface VisitorListParams {
show_bot?: '1' show_bot?: '1'
/** visitor_id / last_ip 前缀模糊匹配 */ /** visitor_id / last_ip 前缀模糊匹配 */
search?: string search?: string
/** 渠道筛选(CHANNELS 枚举:natural_search/paid_search/ai_referral/social/in_app/referral/direct/unknown, /** 渠道筛选(CHANNELS 枚举:natural_search/paid_search/paid_social/ai_referral/social/in_app/referral/direct/unknown,
* 按访客 first_touch 分类过滤;无分类行的历史访客不属于任何渠道桶) */ * 按访客 first_touch 分类过滤;无分类行的历史访客不属于任何渠道桶) */
channel?: string channel?: string
/** YYYY-MM-DD(非法值后端宽松忽略) */ /** YYYY-MM-DD(非法值后端宽松忽略) */
+1
View File
@@ -43,6 +43,7 @@ export const countryFlag = (country?: string | null): string =>
export const CHANNEL_LABELS: Record<string, { label: string; icon: string; color: string }> = { export const CHANNEL_LABELS: Record<string, { label: string; icon: string; color: string }> = {
natural_search: { label: '自然搜索', icon: '🔍', color: '#409EFF' }, natural_search: { label: '自然搜索', icon: '🔍', color: '#409EFF' },
paid_search: { label: '付费广告', icon: '💰', color: '#F56C6C' }, paid_search: { label: '付费广告', icon: '💰', color: '#F56C6C' },
paid_social: { label: '付费社交', icon: '📣', color: '#F759AB' }, // 归因 v2:fbclid / fb 家族+付费 medium
ai_referral: { label: 'AI 引用', icon: '🤖', color: '#9B59B6' }, ai_referral: { label: 'AI 引用', icon: '🤖', color: '#9B59B6' },
social: { label: '社交媒体', icon: '💬', color: '#E6A23C' }, social: { label: '社交媒体', icon: '💬', color: '#E6A23C' },
in_app: { label: '应用内', icon: '📱', color: '#67C23A' }, in_app: { label: '应用内', icon: '📱', color: '#67C23A' },
+125 -1
View File
@@ -86,6 +86,30 @@
</div> </div>
</div> </div>
<!-- 询盘漏斗卡:五级独立会话计数条(无百分比;末级「未验证」限定语前端追加) -->
<div v-loading="funnelLoading" class="vd-card db-funnel-card">
<div class="db-card-head">
<div class="db-card-title">
<ElIcon :size="16"><FilterIcon /></ElIcon>
<span>询盘漏斗</span>
</div>
<ElSelect v-model="funnelDays" style="width: 104px" @change="loadFunnel">
<ElOption label="当日" :value="1" />
<ElOption label="近 7 天" :value="7" />
</ElSelect>
</div>
<div class="db-funnel-body">
<div v-for="lv in funnelLevels" :key="lv.key" class="db-funnel-row">
<span class="db-funnel-label">{{ lv.label }}</span>
<div class="db-funnel-track">
<div class="db-funnel-bar" :style="{ width: `${(lv.count / funnelMax) * 100}%` }" />
</div>
<span class="db-funnel-count">{{ fmtInt(lv.count) }}</span>
</div>
<div v-if="!funnelLevels.length" class="db-funnel-empty">暂无数据</div>
</div>
</div>
<!-- 最近访问表 --> <!-- 最近访问表 -->
<div class="vd-card db-table-card"> <div class="vd-card db-table-card">
<div class="db-card-head"> <div class="db-card-head">
@@ -139,6 +163,7 @@
import { markRaw } from 'vue' import { markRaw } from 'vue'
import { import {
Clock as ClockIcon, Clock as ClockIcon,
Filter as FilterIcon,
Histogram as HistogramIcon, Histogram as HistogramIcon,
Timer as TimerIcon, Timer as TimerIcon,
TrendCharts as TrendChartsIcon, TrendCharts as TrendChartsIcon,
@@ -150,7 +175,9 @@
import { useChart } from '@/hooks/core/useChart' import { useChart } from '@/hooks/core/useChart'
import { getSites, type SiteRow } from '@/api/tracker/sites' import { getSites, type SiteRow } from '@/api/tracker/sites'
import { import {
getFunnelSummary,
getOverview, getOverview,
type FunnelSummaryResp,
type OverviewRange, type OverviewRange,
type OverviewResp type OverviewResp
} from '@/api/tracker/dashboard' } from '@/api/tracker/dashboard'
@@ -209,9 +236,10 @@
} }
} }
// 站点切换:总览与最近访问都按站点过滤,需同时重拉 // 站点切换:总览/漏斗与最近访问都按站点过滤,需同时重拉
const onSiteChange = () => { const onSiteChange = () => {
loadOverview() loadOverview()
loadFunnel()
loadRecentVisitors() loadRecentVisitors()
} }
@@ -383,6 +411,41 @@
const channelLabel = (channel: string): string => const channelLabel = (channel: string): string =>
CHANNEL_LABELS[channel]?.label ?? channel CHANNEL_LABELS[channel]?.label ?? channel
// ---------- 询盘漏斗(五级独立 distinct 会话计数,无百分比;沿周期卡切换模式) ----------
const funnelDays = ref<number>(7) // 1=当日 / 7=近 7 天(上海自然日窗口,与总览同口径)
const funnel = ref<FunnelSummaryResp | null>(null)
const funnelLoading = ref(false)
let funnelSeq = 0
const loadFunnel = async () => {
const seq = ++funnelSeq
funnelLoading.value = true
try {
const resp = await getFunnelSummary(
funnelDays.value,
selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value
)
if (seq === funnelSeq) funnel.value = resp
} catch {
// 失败已由请求层统一 ElMessage 提示;funnel 置空走空态
if (seq === funnelSeq) funnel.value = null
} finally {
if (seq === funnelSeq) funnelLoading.value = false
}
}
/** 漏斗级行:末级追加「(未验证)」限定语——服务端基词只到「邮件已发」,
* CF7 投递结果无回执验证,前端如实标注(T3 审查备忘) */
const funnelLevels = computed(() =>
(funnel.value?.levels ?? []).map((l) => ({
...l,
label: l.key === 'form_submit_result' ? `${l.label}(未验证)` : l.label
}))
)
/** 条长基准:窗口内最大级计数(全零取 1 防除零——条全空,计数照常显示 0) */
const funnelMax = computed(() => Math.max(1, ...funnelLevels.value.map((l) => l.count)))
// 数据到位后重绘两张图(nextTick 等容器就绪;卸载销毁由 useChart 的 onUnmounted 负责) // 数据到位后重绘两张图(nextTick 等容器就绪;卸载销毁由 useChart 的 onUnmounted 负责)
watch(overview, () => { watch(overview, () => {
nextTick(() => { nextTick(() => {
@@ -432,6 +495,7 @@
booted = true booted = true
loadSites() loadSites()
loadOverview() loadOverview()
loadFunnel()
loadRecentVisitors() loadRecentVisitors()
} }
@@ -442,6 +506,7 @@
return return
} }
loadOverview() loadOverview()
loadFunnel()
loadRecentVisitors() loadRecentVisitors()
}) })
</script> </script>
@@ -648,6 +713,65 @@
padding: 12px 16px; padding: 12px 16px;
} }
// 询盘漏斗卡:五级横向计数条(条长按各级计数相对最大级;无百分比文案)
.db-funnel-card {
margin-bottom: 16px;
}
.db-funnel-body {
display: flex;
flex-direction: column;
padding: 18px 20px;
gap: 14px;
}
.db-funnel-row {
display: flex;
align-items: center;
gap: 12px;
}
.db-funnel-label {
width: 128px;
flex: none;
font-size: 13px;
color: $vd-text-2;
text-align: right;
}
.db-funnel-track {
flex: 1;
min-width: 0;
height: 14px;
overflow: hidden;
background: var(--el-fill-color-light);
border-radius: 7px;
}
.db-funnel-bar {
height: 100%;
background: $vd-primary;
border-radius: 7px;
transition: width 0.3s ease;
}
.db-funnel-count {
width: 48px;
flex: none;
font-size: 13px;
font-weight: 700;
color: $vd-text;
text-align: right;
font-variant-numeric: tabular-nums;
}
.db-funnel-empty {
padding: 20px 0;
font-size: 13px;
color: $vd-text-3;
text-align: center;
}
// 最近访问表卡 // 最近访问表卡
.db-table-card { .db-table-card {
padding-bottom: 12px; padding-bottom: 12px;
@@ -1,5 +1,6 @@
<!-- 访客统计 · 事件时间线(详情页访问时间线与会话抽屉共用) --> <!-- 访客统计 · 事件时间线(详情页访问时间线与会话抽屉共用) -->
<!-- 事件行三分支:page_view(浏览+停留)/ page_stay 独立条目 / sdk_loaded 及自定义事件 --> <!-- 事件行三分支:page_view(浏览+停留)/ page_stay 独立条目 / sdk_loaded 及自定义事件; -->
<!-- 阶段B 业务事件(咨询点击/表单状态机/产品查看)带白名单安全字段副行(服务端 _safe_fields) -->
<!-- 视觉:客迹 V1——灰色时间 + 36px 浅蓝图标块 + 粗体标题 + 灰色 path + 停留时长,左侧 2px 连接线贯穿 --> <!-- 视觉:客迹 V1——灰色时间 + 36px 浅蓝图标块 + 粗体标题 + 灰色 path + 停留时长,左侧 2px 连接线贯穿 -->
<template> <template>
<div class="evt-timeline"> <div class="evt-timeline">
@@ -11,14 +12,16 @@
</ElIcon> </ElIcon>
</div> </div>
<div class="evt-content"> <div class="evt-content">
<!-- 标题行:事件名粗体(page_stay 独立条目带时长) --> <!-- 标题行:事件名粗体(page_stay 独立条目带时长;form_submit_result 按 result_code 分支) -->
<div class="evt-title"> <div class="evt-title">
{{ eventMeta(e.event_name).label }} {{ eventLabel(e) }}
<span v-if="e.event_name === 'page_stay'" class="evt-stay"> <span v-if="e.event_name === 'page_stay'" class="evt-stay">
<span class="evt-stay-label">停留</span> <span class="evt-stay-label">停留</span>
{{ formatStay(e.active_ms ?? 0) }} {{ formatStay(e.active_ms ?? 0) }}
</span> </span>
</div> </div>
<!-- 业务事件副行:服务端白名单安全字段(渠道/表单标识/错误码等) -->
<div v-if="eventSubline(e)" class="evt-subline">{{ eventSubline(e) }}</div>
<div v-if="e.path" class="evt-path">{{ e.path }}</div> <div v-if="e.path" class="evt-path">{{ e.path }}</div>
<div v-if="e.title" class="evt-page-title">{{ e.title }}</div> <div v-if="e.title" class="evt-page-title">{{ e.title }}</div>
<!-- page_view 合并停留:下一行「停留」灰字 + 时长深色粗体 --> <!-- page_view 合并停留:下一行「停留」灰字 + 时长深色粗体 -->
@@ -33,7 +36,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { markRaw, type Component } from 'vue' import { markRaw, type Component } from 'vue'
import { Cpu, InfoFilled, Timer, View } from '@element-plus/icons-vue' import {
ChatDotRound,
Cpu,
DocumentChecked,
EditPen,
Goods,
InfoFilled,
Message,
Timer,
View,
WarningFilled
} from '@element-plus/icons-vue'
import type { TimelineEvent } from '@/api/tracker/visitors' import type { TimelineEvent } from '@/api/tracker/visitors'
import { formatStay } from '@/utils/tracker' import { formatStay } from '@/utils/tracker'
@@ -47,14 +61,56 @@
/** ISO(后端上海时间)→ HH:mm */ /** ISO(后端上海时间)→ HH:mm */
const formatTime = (iso?: string | null): string => (iso ? iso.slice(11, 16) : '—') const formatTime = (iso?: string | null): string => (iso ? iso.slice(11, 16) : '—')
/** 事件行图标/标题:page_view=浏览页面、page_stay=页面停留、sdk_loaded=SDK 加载、其余兜底 */ /** 事件行图标/标题:页面三件套 + 阶段B 业务六事件,其余(含未知自定义事件)兜底原事件名 */
const EVENT_META: Record<string, { icon: Component; label: string }> = { const EVENT_META: Record<string, { icon: Component; label: string }> = {
page_view: { icon: markRaw(View), label: '浏览页面' }, page_view: { icon: markRaw(View), label: '浏览页面' },
page_stay: { icon: markRaw(Timer), label: '页面停留' }, page_stay: { icon: markRaw(Timer), label: '页面停留' },
sdk_loaded: { icon: markRaw(Cpu), label: 'SDK 加载' } sdk_loaded: { icon: markRaw(Cpu), label: 'SDK 加载' },
contact_click: { icon: markRaw(ChatDotRound), label: '咨询点击' },
form_start: { icon: markRaw(EditPen), label: '表单开始' },
form_submit: { icon: markRaw(DocumentChecked), label: '表单提交' },
form_error: { icon: markRaw(WarningFilled), label: '表单校验未过' },
form_submit_result: { icon: markRaw(Message), label: '邮件发送结果' },
product_view: { icon: markRaw(Goods), label: '产品查看' }
} }
const eventMeta = (name: string): { icon: Component; label: string } => const eventMeta = (name: string): { icon: Component; label: string } =>
EVENT_META[name] ?? { icon: markRaw(InfoFilled), label: name } EVENT_META[name] ?? { icon: markRaw(InfoFilled), label: name }
/** 事件行标题:form_submit_result 按 result_code 分支(server 只给基词,限定语前端补) */
const eventLabel = (e: TimelineEvent): string => {
if (e.event_name === 'form_submit_result') {
if (e.result_code === 'mail_sent') return '邮件已发(未验证)'
if (e.result_code === 'mail_failed') return '发送失败'
}
return eventMeta(e.event_name).label
}
/** contact_click 咨询渠道中文(未枚举值原样显示) */
const CONTACT_CHANNEL_LABELS: Record<string, string> = {
whatsapp: 'WhatsApp',
phone: '电话',
email: '邮件'
}
/** 业务事件副行:服务端白名单安全字段拼显示串(空串 → 不渲染该行) */
const eventSubline = (e: TimelineEvent): string => {
switch (e.event_name) {
case 'contact_click':
return (e.channel && (CONTACT_CHANNEL_LABELS[e.channel] ?? e.channel)) || ''
case 'form_start':
return e.form_id || ''
case 'form_submit':
return e.submission_id || ''
case 'form_error':
return [e.error_code, e.field_count != null ? `${e.field_count} 个字段` : '']
.filter(Boolean)
.join(' · ')
case 'product_view':
return [e.product_id, e.inferred ? 'URL推导' : ''].filter(Boolean).join(' · ')
default:
return ''
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -135,6 +191,13 @@
color: #86909c; color: #86909c;
} }
// 业务事件副行:白名单安全字段(咨询渠道/表单标识/错误码/产品标识)
.evt-subline {
margin-top: 2px;
font-size: 12px;
color: #86909c;
}
// 停留时长:「停留」灰字 + 时长深色粗体 // 停留时长:「停留」灰字 + 时长深色粗体
.evt-stay { .evt-stay {
display: block; display: block;
+33 -2
View File
@@ -23,6 +23,34 @@ TIMELINE_MAX_EVENTS = 1000
ONLINE_WINDOW = timedelta(minutes=5) # 与 dashboard active_now 同口径 ONLINE_WINDOW = timedelta(minutes=5) # 与 dashboard active_now 同口径
ATTR_SESSION_ENTRIES = 5 # 详情页归因块带出的最近会话入口分类数 ATTR_SESSION_ENTRIES = 5 # 详情页归因块带出的最近会话入口分类数
# 业务事件时间线安全字段白名单(阶段B SDK 0.2.1 事件):按事件名枚举可透出的非 PII 字段
# (OT10 白名单内),不在表内的事件/字段一律不透出 properties 原文——隐私边界与
# _merge_page_stay 的既有白名单口径一致。submission_id 只透出前 8 位(对账够用)。
TIMELINE_SAFE_FIELDS = {
"contact_click": ("channel",),
"form_start": ("form_id",),
"form_submit": ("form_id", "submission_id"),
"form_error": ("error_code", "field_count"),
"form_submit_result": ("result_code",),
"product_view": ("product_id", "inferred"),
}
def _safe_fields(e):
"""事件行安全字段提取:白名单命中才输出,且只出白名单键(缺失键不占位);
form_submit 的 submission_id 截前 8 位。"""
keys = TIMELINE_SAFE_FIELDS.get(e.event_name)
if not keys:
return {}
props = e.properties or {}
out = {}
for k in keys:
v = props.get(k)
if v is None:
continue
out[k] = str(v)[:8] if k == "submission_id" else v
return out
def _source(referrer_domain, utm): def _source(referrer_domain, utm):
"""来源三级兜底:referrer_domain → utm.utm_source → 直接访问(访客档案/会话共用)。""" """来源三级兜底:referrer_domain → utm.utm_source → 直接访问(访客档案/会话共用)。"""
@@ -182,7 +210,8 @@ def _merge_page_stay(events):
最近一条同 path 的 page_view 条目,把 active_ms 累加进去(同页多段隐藏/恢复多次累加); 最近一条同 path 的 page_view 条目,把 active_ms 累加进去(同页多段隐藏/恢复多次累加);
找不到匹配(含 page_stay 早于任何 page_view 出现)则独立成条目(event_name 保持 page_stay)。 找不到匹配(含 page_stay 早于任何 page_view 出现)则独立成条目(event_name 保持 page_stay)。
作用域天然限定在本组事件序列(单会话/时间线单会话桶)——跨会话绝不合并。 作用域天然限定在本组事件序列(单会话/时间线单会话桶)——跨会话绝不合并。
只出 time/event_name/path/title/active_ms 白名单字段,不透出 properties 原文(隐私)。 只出 time/event_name/path/title/active_ms(+业务事件 _safe_fields 白名单字段),
不透出 properties 原文(隐私)。
""" """
items = [] items = []
for e in events: for e in events:
@@ -198,8 +227,10 @@ def _merge_page_stay(events):
else: else:
# page_view 初始 active_ms=0;其余(sdk_loaded/自定义事件)无停留概念 → null # page_view 初始 active_ms=0;其余(sdk_loaded/自定义事件)无停留概念 → null
active = 0 if e.event_name == "page_view" else None active = 0 if e.event_name == "page_view" else None
# 业务事件附带 _safe_fields 白名单字段(其余 properties 键不透出)
items.append({"time": _iso(e.occurred_at), "event_name": e.event_name, items.append({"time": _iso(e.occurred_at), "event_name": e.event_name,
"path": e.path, "title": e.title, "active_ms": active}) "path": e.path, "title": e.title, "active_ms": active,
**_safe_fields(e)})
return items return items
+58 -3
View File
@@ -48,10 +48,12 @@ def _session(site, visitor, sid, at=NOW, referrer_domain="", utm=None,
duration_ms=duration_ms, stay_ms=stay_ms) duration_ms=duration_ms, stay_ms=stay_ms)
def _event(site, visitor, session, eid, name, at, path="", title="", active_ms=None): def _event(site, visitor, session, eid, name, at, path="", title="", active_ms=None,
properties=None):
return Event.objects.create(site=site, visitor=visitor, session=session, return Event.objects.create(site=site, visitor=visitor, session=session,
event_id=eid, event_name=name, occurred_at=at, path=path, title=title, event_id=eid, event_name=name, occurred_at=at, path=path, title=title,
properties={"active_ms": active_ms} if active_ms is not None else {}) properties=properties if properties is not None else (
{"active_ms": active_ms} if active_ms is not None else {}))
def test_list_fields_and_default_bot_excluded(client, member_user): def test_list_fields_and_default_bot_excluded(client, member_user):
@@ -128,7 +130,9 @@ def test_list_filters_and_search(client, member_user):
d = client.get("/api/admin/visitors", {"is_new": "1"}).json()["data"] # first_seen 在今天 d = client.get("/api/admin/visitors", {"is_new": "1"}).json()["data"] # first_seen 在今天
assert set(ids(d)) == {"cn-desktop", "us-mobile"} assert set(ids(d)) == {"cn-desktop", "us-mobile"}
d = client.get("/api/admin/visitors", {"date_to": YESTERDAY.isoformat()}).json()["data"] d = client.get("/api/admin/visitors", {"date_to": YESTERDAY.isoformat()}).json()["data"]
assert ids(d) == ["old-visitor"] # last_seen 落在昨天 # 断言 date_to 的确定边界:昨天者必收、今天者必排除。us-mobile 的 now-2h 在本地
# 00:00-02:00 间跑测试会落到昨天(被一并收入)——属时钟归属,不作断言对象(防 flaky)。
assert "old-visitor" in ids(d) and "cn-desktop" not in ids(d)
def test_list_site_fields_and_site_filter(client, member_user): def test_list_site_fields_and_site_filter(client, member_user):
@@ -268,6 +272,57 @@ def test_timeline_day_grouped_and_stay_merge(client, member_user):
assert y_evs[1]["title"] == "Pricing" assert y_evs[1]["title"] == "Pricing"
def test_timeline_business_event_safe_fields(client, member_user):
"""时间线业务事件安全字段白名单(阶段B T6 Step0):六类新事件行各带映射字段
(form_submit 的 submission_id 只透前 8 位),其余 properties 键一律不泄漏
(含故意塞进去的 PII 键);单会话接口走同一序列化,同样只出白名单。"""
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])
v = _visitor(site, "v1")
s = _session(site, v, "s1")
base = NOW
_event(site, v, s, "b1", "contact_click", base, path="/contact",
properties={"channel": "whatsapp", "label": " WhatsApp 按钮", "href": "https://wa.me/8613800000000"})
_event(site, v, s, "b2", "form_start", base + timedelta(seconds=1),
properties={"form_id": "cf7-12", "page_url": "https://a.com/contact"})
_event(site, v, s, "b3", "form_submit", base + timedelta(seconds=2),
properties={"form_id": "cf7-12", "submission_id": "abcd1234-9999-zzzz",
"fields": {"your-name": "张三", "your-email": "z@x.com"}})
_event(site, v, s, "b4", "form_error", base + timedelta(seconds=3),
properties={"error_code": "validation_failed", "field_count": 2,
"messages": ["邮箱格式不对"]})
_event(site, v, s, "b5", "form_submit_result", base + timedelta(seconds=4),
properties={"result_code": "mail_sent", "smtp_detail": "250 ok"})
_event(site, v, s, "b6", "product_view", base + timedelta(seconds=5),
properties={"product_id": "excavator-x1", "inferred": True,
"url": "https://a.com/product/excavator-x1"})
r = client.get("/api/admin/visitors/v1/timeline")
assert r.status_code == 200
evs = r.json()["data"]["days"][0]["sessions"][0]["events"]
base_keys = {"time", "event_name", "path", "title", "active_ms"}
e1, e2, e3, e4, e5, e6 = evs
assert set(e1) == base_keys | {"channel"} and e1["channel"] == "whatsapp"
assert set(e2) == base_keys | {"form_id"} and e2["form_id"] == "cf7-12"
assert set(e3) == base_keys | {"form_id", "submission_id"}
assert e3["submission_id"] == "abcd1234" # 前 8 位截断,尾部不透出
assert "abcd1234-9999" not in r.content.decode() # 全量 submission_id 不出现在响应
assert set(e4) == base_keys | {"error_code", "field_count"}
assert e4["error_code"] == "validation_failed" and e4["field_count"] == 2
assert set(e5) == base_keys | {"result_code"} and e5["result_code"] == "mail_sent"
assert set(e6) == base_keys | {"product_id", "inferred"}
assert e6["product_id"] == "excavator-x1" and e6["inferred"] is True
body = r.content.decode()
for leak in ("wa.me", "张三", "z@x.com", "邮箱格式不对", "250 ok", "smtp_detail",
"page_url", "fields", "messages", "label"):
assert leak not in body # 无其他 properties 键泄漏
d = client.get("/api/admin/visitors/v1/sessions/s1").json()["data"] # 同一序列化路径
sub = next(e for e in d["events"] if e["event_name"] == "form_submit")
assert sub["submission_id"] == "abcd1234"
assert "fields" not in sub
def test_timeline_days_param_and_404(client, member_user): def test_timeline_days_param_and_404(client, member_user):
_auth(client, member_user) _auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"]) site = Site.objects.create(name="s", domains=["a.com"])