Compare commits

...

10 Commits

Author SHA1 Message Date
浅念 3e42697038 chore: 入库设计稿与品牌logo资产,gitignore部署日志与临时页
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 17:07:21 +08:00
浅念 4500c12d3e docs: 计划敏感扫描assets改用find(未add前ls-files为空)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:56:32 +08:00
浅念 76bccd55bd docs: 部署计划吸收审查4项修正——tar排除.venv(97MB)/敏感扫描前置/mkdir先于参数解析/脏注入具体化/用户背景模板提醒
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:52:10 +08:00
浅念 e11b7c4b49 docs: Git托管+一键部署实现计划(6任务:卫生/推送/deploy.sh/CLAUDE.md/验证/收尾)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:36:45 +08:00
浅念 ea975214e2 docs: 部署spec吸收审查建议——tar真实路径直落挂载点/单目标语义表/CLAUDE.md实际不存在改为新建/logs-gitignore前置
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:19:00 +08:00
浅念 785260be4f docs: Git托管接入+一键部署方案设计文档
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:11:06 +08:00
浅念 bfd2040496 docs: 阶段B验收台账——D02观察窗通过/归因v2口径/线上链路验证/交付清单
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 17:36:25 +08:00
浅念 c9967b6d70 feat(admin): 时间线业务事件徽章+询盘漏斗卡+付费社交渠道标签(含server时间线安全字段)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 00:12:13 +08:00
浅念 8d7fc99ef6 test(sdk): track宿主伪造归属键被快照覆盖的权威性回归
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 23:57:39 +08:00
浅念 fea19c15cd feat(sdk): 0.2.1——track补归属+business接线
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 23:47:44 +08:00
32 changed files with 1088 additions and 32 deletions
+5
View File
@@ -20,3 +20,8 @@ _artifact_build_complete/
gpt/
/index.html
exports/
# 部署脚本日志(首部署前必须忽略——防日志污染工作区卡住下次门禁)
deploy/logs/
# 根目录临时测试页
/模拟访客测试页.html
+18
View File
@@ -57,3 +57,21 @@ export interface OverviewResp {
/** 总览数据(site 传 site_id 可选;range 默认 7d) */
export const getOverview = (params: { site?: string; range?: OverviewRange } = {}) =>
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() 结果 + 规则版本/计算时间) */
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
/** 平台显示名(Google/ChatGPT/微信…;referral 渠道为来源域名) */
platform: string
@@ -116,7 +116,8 @@ export interface VisitorDetail extends VisitorRow {
sessions: SessionSummary[]
}
/** 时间线单事件(`_merge_page_stay` 白名单字段,不透出 properties 原文) */
/** 时间线单事件(`_merge_page_stay` 白名单字段,不透出 properties 原文;
* 业务事件额外携带 `_safe_fields` 安全字段——均为可选,旧数据/无该字段时不出现) */
export interface TimelineEvent {
time: string
event_name: string
@@ -124,6 +125,22 @@ export interface TimelineEvent {
title: string
/** page_view 合并后为累计毫秒(初始 0);独立成条的 page_stay 为毫秒;其余事件为 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) */
@@ -191,7 +208,7 @@ export interface VisitorListParams {
show_bot?: '1'
/** visitor_id / last_ip 前缀模糊匹配 */
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 分类过滤;无分类行的历史访客不属于任何渠道桶) */
channel?: string
/** YYYY-MM-DD(非法值后端宽松忽略) */
Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

@@ -1,7 +1,7 @@
<!-- 系统logo -->
<template>
<div class="flex-cc">
<img :style="logoStyle" src="@imgs/common/keji-mark.png" alt="logo" class="h-full" />
<img :style="logoStyle" src="@imgs/common/logo.png" alt="logo" class="h-full" />
</div>
</template>
@@ -38,7 +38,7 @@
z-index: 10;
display: block;
width: auto;
height: 52px;
height: 80px;
}
.text-wrap {
+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 }> = {
natural_search: { label: '自然搜索', icon: '🔍', color: '#409EFF' },
paid_search: { label: '付费广告', icon: '💰', color: '#F56C6C' },
paid_social: { label: '付费社交', icon: '📣', color: '#F759AB' }, // 归因 v2:fbclid / fb 家族+付费 medium
ai_referral: { label: 'AI 引用', icon: '🤖', color: '#9B59B6' },
social: { label: '社交媒体', icon: '💬', color: '#E6A23C' },
in_app: { label: '应用内', icon: '📱', color: '#67C23A' },
+125 -1
View File
@@ -86,6 +86,30 @@
</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="db-card-head">
@@ -139,6 +163,7 @@
import { markRaw } from 'vue'
import {
Clock as ClockIcon,
Filter as FilterIcon,
Histogram as HistogramIcon,
Timer as TimerIcon,
TrendCharts as TrendChartsIcon,
@@ -150,7 +175,9 @@
import { useChart } from '@/hooks/core/useChart'
import { getSites, type SiteRow } from '@/api/tracker/sites'
import {
getFunnelSummary,
getOverview,
type FunnelSummaryResp,
type OverviewRange,
type OverviewResp
} from '@/api/tracker/dashboard'
@@ -209,9 +236,10 @@
}
}
// 站点切换:总览与最近访问都按站点过滤,需同时重拉
// 站点切换:总览/漏斗与最近访问都按站点过滤,需同时重拉
const onSiteChange = () => {
loadOverview()
loadFunnel()
loadRecentVisitors()
}
@@ -383,6 +411,41 @@
const channelLabel = (channel: string): string =>
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 负责)
watch(overview, () => {
nextTick(() => {
@@ -432,6 +495,7 @@
booted = true
loadSites()
loadOverview()
loadFunnel()
loadRecentVisitors()
}
@@ -442,6 +506,7 @@
return
}
loadOverview()
loadFunnel()
loadRecentVisitors()
})
</script>
@@ -648,6 +713,65 @@
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 {
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 连接线贯穿 -->
<template>
<div class="evt-timeline">
@@ -11,14 +12,16 @@
</ElIcon>
</div>
<div class="evt-content">
<!-- 标题行:事件名粗体(page_stay 独立条目带时长) -->
<!-- 标题行:事件名粗体(page_stay 独立条目带时长;form_submit_result 按 result_code 分支) -->
<div class="evt-title">
{{ eventMeta(e.event_name).label }}
{{ eventLabel(e) }}
<span v-if="e.event_name === 'page_stay'" class="evt-stay">
<span class="evt-stay-label">停留</span>
{{ formatStay(e.active_ms ?? 0) }}
</span>
</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.title" class="evt-page-title">{{ e.title }}</div>
<!-- page_view 合并停留:下一行「停留」灰字 + 时长深色粗体 -->
@@ -33,7 +36,18 @@
<script setup lang="ts">
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 { formatStay } from '@/utils/tracker'
@@ -47,14 +61,56 @@
/** ISO(后端上海时间)→ HH:mm */
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 }> = {
page_view: { icon: markRaw(View), 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 } =>
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>
<style lang="scss" scoped>
@@ -135,6 +191,13 @@
color: #86909c;
}
// 业务事件副行:白名单安全字段(咨询渠道/表单标识/错误码/产品标识)
.evt-subline {
margin-top: 2px;
font-size: 12px;
color: #86909c;
}
// 停留时长:「停留」灰字 + 时长深色粗体
.evt-stay {
display: block;
+13
View File
@@ -0,0 +1,13 @@
# 客迹 Logo V1
生成日期:2026-09-16
生成方式:内置图像生成工具(image_gen),使用 imagegen 技能。
图像文件:[keji-logo-v1.png](keji-logo-v1.png)
设计方向:以 K 字母、访问路径与触点构成图形,搭配“客迹”与“KEJI ANALYTICS”字标。输出为 PNG 位图概念稿,不是可编辑矢量源文件。
## 最终生成提示词
Use case: logo-brand. Create one polished original logo for the Chinese software brand “客迹”, Keji, a customer journey and website behavior analytics platform. Asset type: usable primary brand logo, not a mockup or presentation sheet. Minimal, precise, contemporary flat vector-style geometry with strong small-size silhouette and balanced negative space. Develop a compact distinctive symbol from a continuous visitor journey path with a few purposeful circular touchpoints, subtly suggesting a K monogram and connection, not a literal shoe footprint, eye, radar, or surveillance camera. Pair the symbol with beautifully typeset, clearly legible Chinese wordmark “客迹” and a smaller English descriptor “KEJI ANALYTICS”. Exact text only: “客迹” and “KEJI ANALYTICS”. Chinese characters must be correct; modern confident sans-serif typography with carefully balanced spacing. Single centered horizontal brand lockup, icon and wordmark optically balanced, generous clean margin, suitable for a website header and independent admin dashboard. Restrained high-contrast professional color treatment, flat fills, no gradients, no glow, no shadow, no texture, no 3D, no stationery or product scene, no decorative captions, no watermark. Genuinely transparent background, not a checkerboard drawn into the image. Crisp high-resolution edges.
Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

@@ -0,0 +1,9 @@
# 白色字标透明Logo:未通过质量检查
2026-09-17。使用imagegen技能及内置image_gen编辑,未使用CLI。原图keji-logo-v1.png未改动。
输出keji-logo-white-text-draft.png仅为失败草稿,不建议用于上线:白色字标间有杂点及透明边缘缺陷。多次生成未解决,不宣称图标像素完全一致。建议获用户同意后以确定性像素替色处理原始图,保留原始alpha及图标像素。
## 草稿提示词
Use case: precise-object-edit. Input image 1 is the exact logo edit target. Make one narrowly scoped change: recolor ONLY the dark navy Chinese wordmark 客迹 and the English line KEJI ANALYTICS to solid pure white #FFFFFF. Preserve their exact lettering, typography, sizes, positioning, spacing and horizontal lockup. Preserve the left K-shaped path icon unchanged in its original vivid blue and cyan colors, including its white ring and small white circular touchpoint, silhouette and geometry. Do NOT redesign or recolor the icon. Preserve overall aspect ratio and composition. Output a genuinely transparent-background PNG with alpha: all surrounding background and negative space transparent, NOT a black, white or checkerboard painted backdrop. Crisp antialiased logo edges, opaque white lettering, no shadow, no glow, no outline, no extra text, no watermark. This is a white-text variant of the same logo for dark backgrounds.
Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -0,0 +1,10 @@
# 登录页左侧纯背景
使用 imagegen 技能、内置 image_gen 编辑生成。原图:03-login.png。输出:05-login-left-background.png。
仅保留深蓝品牌区的路径与触点;移除文字、Logo和右侧表单。为AI去字重建版本,不保证与原稿逐像素一致。未替换页面代码。
## 最终提示词
Use case: precise-object-edit. Input image 1 is the edit target: a login screen. Extract ONLY its LEFT deep-blue brand panel, which occupies x=0 to approximately 57% of input width, as a standalone clean background asset. Output portrait-ish aspect ratio approximately 10:11 matching that panel, full bleed. Remove the entire white right-hand login form panel. Remove ALL text, Chinese and English lettering, brand logo at upper left, short cyan underline below heading, slogans and footer text from the blue panel, reconstructing the underlying smooth navy gradient naturally. Preserve as closely as possible the original blue panel's actual artwork and composition: quiet dark navy upper half, brighter cobalt toward upper right, broad translucent blue/cyan flowing ribbons in the lower half, three distinct circular touchpoints (small filled pale cyan lower left, white ring center lower-middle, filled cyan upper-right of ribbon cluster). Preserve original ribbon shapes, positions, lighting, colors and clean dimensional flow; do not replace with different abstract art, do not add nodes or floor reflections. This is the background from THIS login concept, not a new alternative. No UI, no logo, no text, no watermark, no white border. High resolution, smooth gradients, opaque background suitable for left brand section of real login page.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+58
View File
@@ -0,0 +1,58 @@
# 客迹后台视觉概念 V1
日期:2026-09-16。使用 imagegen 技能及内置 image_gen 生成,未使用 CLI/API 回退。交付为 PNG 效果图与背景素材,不是已实现页面、可编辑设计源文件或真实业务数据。
## 文件
- [数据概览](01-dashboard.png):浅色数据工作台。
- [访客轨迹](02-visitor-journey.png):跨天时间线和来源证据。
- [登录页](03-login.png):深蓝品牌区与浅色登录表单。
- [独立背景](04-login-background.png):无文字、无表单,适合后续页面叠加。
## 设计与落地
沿用 assets/brand/keji-logo-v1.png 的蓝青 K 路径品牌。主色 #2563EB,辅助 #06B6D4,正文 #12233F,后台底色 #F5F7FB,登录背景 #0B1630。适配现有 Vue3、Element Plus、Art Design Pro 与 ECharts。
后台建议240px侧栏、64px顶栏、24px内容边距、12px卡片圆角。数据表使用真实 HTML 和组件,图表用 ECharts,不把效果图整体当作后台页面。登录页桌面采用品牌区与表单区双栏;小屏隐藏或弱化品牌区,表单单栏,按钮与文字保持可访问对比度。背景作为装饰,文字和Logo使用独立前端元素,不从效果图截取表单。
背景图右侧留白可放白色表单卡片;它与登录效果图属于同一视觉方向,但不是效果图左侧插画的逐像素提取版。应用时根据容器裁切检查焦点,移动端使用纯色降级即可。
## 生成稿校对及实现约束
AI 图像用于选定视觉方向,局部自动生成的菜单、年份、数值、文案不作为需求变更或业务契约:
1. 统一采用项目真实菜单;访客详情稿附加的实时访客、转化分析、专业版等入口不意味着本期建设或商业版本划分。
2. 概览表中的“直接访问”实现时改为“直接或未知”;ChatGPT仅标签时明确显示“ChatGPT标签”。来源证据详见 gpt/12、13。
3. 日期使用实际数据;示例图中的2024年份不是当前项目日期。统计值仅用于布局演示,不能作为图表与明细对账样例。
4. 时间线的正序/倒序按钮必须与实际排序一致;首次来源未知时,不添加“通过搜索进入”的确定叙述。兴趣或转化意向不得仅靠浏览行为直接判定为事实。
5. 登录页“安全验证”“忘记密码”仅在真实接入对应能力后开放;前端滑块本身不等于服务端安全验证。保持登录不应保存明文密码。隐私政策链接须指向真实文档。
6. 品牌主标正式实现复用原始Logo,不使用AI重绘字标替换。英文与中文标签均以实际组件字体排版并做人工校对。
本次未修改 admin 中的页面代码,也未替换任何既有品牌文件。
## 最终生成提示词
### 01-dashboard
```text
Use case: ui-mockup. Create a polished high-fidelity desktop web UI design image for 客迹 / KEJI ANALYTICS, an independent website visitor behavior analytics admin, compatible with Vue3 Element Plus Art Design Pro. Landscape 16:10, sharp readable simplified Chinese typography, practical implementable UI, front-on full screen screenshot without laptop/browser mockup framing. Reference image 1 is the existing brand logo only: retain the blue/cyan K path motif and 客迹 name, do not reproduce its large whitespace. Cohesive palette cobalt #2563EB, cyan #06B6D4, ink #12233F, pale canvas #F5F7FB, white panels, subtle borders, restrained shadows, 12px corners. Small unobtrusive label 演示数据. No giant decorative background behind business tables, no surveillance eyes, no futuristic HUD, no fake real personal information. Asset type: admin overview screen. Left narrow white sidebar with small brand lockup top and navigation 数据概览 selected, 访客管理, 客户档案, 来源分析, 站点管理, 系统设置. Top bar breadcrumb 工作台 / 数据概览, site selector 官网主站, date 最近7天 and small user avatar. Main heading 数据概览 and subtitle 了解每一次访问,连接每一个触点. Four restrained metric panels: 访客数 1,284; 访问次数 1,936; 平均有效停留 2分18秒; 回访访客 326. Main middle broad card 访问趋势 with clean blue time-series chart dates09/10 to09/16, adjacent smaller 来源分布 horizontal bars 自然搜索 42%, AI推荐 23%, 直接或未知 20%, 其他 15%. Bottom generous table 最近访问, columns 访客, 本次来源, 浏览页面, 有效停留, 最近访问. Four anonymous rows V-1024 / ChatGPT标签 / 产品介绍 / 03:24 / 14:32 and other varied rows. Show 来源标签 badge explicitly, no claim of exact third-party prompt tracking. Excellent hierarchy, spacious coherent grid, polished B2B SaaS visual.
```
### 02-visitor-journey
```text
Use case: ui-mockup. Create a polished high-fidelity desktop web UI design image for 客迹 / KEJI ANALYTICS, an independent website visitor behavior analytics admin, compatible with Vue3 Element Plus Art Design Pro. Landscape 16:10, sharp readable simplified Chinese typography, practical implementable UI, front-on full screen screenshot without laptop/browser mockup framing. Reference image 1 is the existing brand logo only: retain the blue/cyan K path motif and 客迹 name, do not reproduce its large whitespace. Cohesive palette cobalt #2563EB, cyan #06B6D4, ink #12233F, pale canvas #F5F7FB, white panels, subtle borders, restrained shadows, 12px corners. Small unobtrusive label 演示数据. No giant decorative background behind business tables, no surveillance eyes, no futuristic HUD, no fake real personal information. Asset type: visitor journey detail screen in same design system. Sidebar 访客管理 selected. Top breadcrumb 访客管理 / 访客详情, site 官网主站. Heading 访客 V-1024 with pill 匿名访客, subtitle 当前浏览器的可观测轨迹. Four compact summary stats 访问次数 3, 活跃天数 3, 浏览页面 8, 有效停留 08:42. Main wide left section 访问时间线 with elegantly spaced vertical timeline grouped 09月16日 今天 / 09月15日 / 09月14日. Events show 14:32 进入产品页, 14:34 查看价格页, 14:35 开始咨询表单, small paths and duration, discrete outlined blue event icons. Right column 来源证据 contains three clearly separated rows 首次可观测来源 未知; 首次可识别来源 ChatGPT标签; 本次会话来源 邮件活动. Below compact evidence detail 标记来源 utm_source, 具体智能体 未提供, 原始提问 未提供. Additional panel 访问环境: Chrome / Windows / 桌面设备. No customer real names or emails, no personal avatar portrait, no charts implying lifetime omniscience. Make timeline beautiful and evidence highly readable.
```
### 03-login
```text
Use case: ui-mockup. Create a polished high-fidelity desktop web UI design image for 客迹 / KEJI ANALYTICS, an independent website visitor behavior analytics admin, compatible with Vue3 Element Plus Art Design Pro. Landscape 16:10, sharp readable simplified Chinese typography, practical implementable UI, front-on full screen screenshot without laptop/browser mockup framing. Reference image 1 is the existing brand logo only: retain the blue/cyan K path motif and 客迹 name, do not reproduce its large whitespace. Cohesive palette cobalt #2563EB, cyan #06B6D4, ink #12233F, pale canvas #F5F7FB, white panels, subtle borders, restrained shadows, 12px corners. Small unobtrusive label 演示数据. No giant decorative background behind business tables, no surveillance eyes, no futuristic HUD, no fake real personal information. Asset type: full login page concept, no dashboard sidebar. Two-column 55/45 composition. Left atmospheric deep ink-blue brand illustration panel with elegant flowing blue-cyan translucent ribbons and a few precise bright round touchpoints subtly forming the K journey motif, visual concentrated in middle and lower left; premium soft dimensional lighting, no busy circuitry. Small white 客迹 KEJI ANALYTICS brand top-left. Large legible white headline 看见来访,读懂客迹 with small subtitle 网站访问与客户旅程分析. Right clean warm-white panel with centered 380px-wide form. Heading 欢迎登录客迹, subtitle 登录您的独立数据工作台. Field labels 账号 and 密码, placeholders 请输入账号 and 请输入密码, outline icons, password eye; optional security verification horizontal slot 安全验证; checkbox 保持登录, link 忘记密码; large cobalt 登录 button. Small footer 账号由管理员分配 and 隐私政策 · 使用条款. No signup button, no third-party auth buttons, no example credentials, no giant empty top space. Brand-conscious premium enterprise SaaS aesthetic.
```
### 04-login-background
```text
Use case: stylized-concept. Asset type: production-ready text-free background artwork for 客迹 KEJI ANALYTICS desktop login page. Wide landscape 16:9 high-resolution image. Theme website customer journeys, repeat visits and connected touchpoints expressed abstractly through elegant smooth cobalt and cyan translucent glass ribbons with just a few luminous circular nodes, paths subtly suggesting a K without any explicit letter or logo. Deep midnight navy #0B1630 background with rich quiet tonal gradients and premium soft studio highlights. Artwork concentrated within left 55 percent and lower-left, allowing light headline overlay upper-left; right 45 percent must be very quiet dark navy negative space for an HTML white login card. Clean sophisticated SaaS brand art, dimensional but restrained, carefully balanced curves, fine soft depth, no charts, no dashboard, no interface, no text, no numbers, no watermark, no people, no eye, no surveillance camera, no globe, no busy circuitry. Do not embed login fields or logo into the background. Complement an existing blue/cyan K path brand mark.
```
@@ -0,0 +1,359 @@
# Git 托管接入 + 一键部署 — 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 项目推送至自建 git 远程仓 + 固化 `deploy/deploy.sh` 一键部署链(tar 真实路径直落挂载点/失败即停/哈希验证/部署日志),新建 CLAUDE.md 部署约定。
**Architecture:** Part1 git 卫生与推送(零线上影响)→ Part2 bash 部署脚本(固化既有手工链,验证驱动而非单测——脚本的本性是编排)→ Part3 约定落地 → 验证(admin 单目标 → 失败注入 → all 完整,用户已选完整版)。
**Tech Stack:** bash(Git Bash on Windows + ssh/scp 经跳板)、git、pnpm、pytest/vitest 既有套件。
**Spec:** docs/superpowers/specs/2026-09-29-git-and-deploy-automation-design.md
**红线:** 严格不触碰服务器上其他项目(1Panel/OpenResty/其他容器);服务器操作仅限 /opt/cms-cj 与 docker compose tracker-web。
**现状锚点(2026-09-29 实测):**
- 工作区:`M admin/src/assets/images/logo-w.png`、`M admin/src/components/core/base/art-logo/index.vue`、`M admin/src/components/core/views/login/LoginLeftView.vue`、`?? admin/src/assets/images/common/logo.png`、`?? admin/src/assets/images/logo.png`(**一组用户手工 logo 改动,线上已在跑此状态**——此前 admin 部署构建自该工作区)、`?? assets/`(设计稿)、`?? 模拟访客测试页.html`(杂物)
- 分支:m1(当前)/ main;**无 remote**;.gitignore 已含 node_modules/sdk/dist/deploy/data/exports/gpt/ 等;admin/.gitignore(模板自带)已含 dist
- 生产:nginx 挂载 `/opt/cms-cj/admin/dist`、`/opt/cms-cj/sdk/dist`(bind mount);server 重建 `cd /opt/cms-cj/deploy && docker compose up -d --build web`(自动 migrate)
---
### Task 1: Git 卫生处理 + 工作区对齐提交
**Files:**
- Modify: `.gitignore`
- Commit: 上述 5 个 logo 相关文件 + `assets/`
- [ ] **Step 1: .gitignore 追加**(根 .gitignore 尾部):
```gitignore
# 部署脚本日志(首部署前必须忽略——防日志污染工作区卡住下次门禁)
deploy/logs/
# 根目录临时测试页
/模拟访客测试页.html
```
- [ ] **Step 2: 敏感文件扫描**(spec Part 1 要求,推送前必做——m1+main 全历史将进新远程仓):
```bash
git ls-files | grep -iE '\.env$|\.pem$|id_rsa|secret|password' ; echo "exit=$?" # 预期仅 .env.example 类
find assets -type f | head -20 # 目检设计稿清单(此时尚未 add,ls-files 为空——用 find;确认无意外内容)
```
- [ ] **Step 3: 核实 logo 改动组内聚后提交**(5 文件是一组:art-logo 改用 common/logo.png、LoginLeftView 引用调整、两个 logo 文件、logo-w 替换——`git diff` 快速确认无其他意外改动混入;**提交前跑 `cd admin && npx vue-tsc --noEmit` 确认引用可解析**):
```bash
git add .gitignore assets/ admin/src/assets/images/ admin/src/components/core/base/art-logo/index.vue admin/src/components/core/views/login/LoginLeftView.vue
git commit -m "chore: 入库设计稿与品牌logo资产,gitignore部署日志与临时页
Co-Authored-By: Claude Code <noreply@anthropic.com>"
```
- [ ] **Step 4: 确认 `git status --porcelain` 为空**(工作区干净——后续推送与部署门禁的前提)
### Task 2: 接入远程仓并推送
- [ ] **Step 1: 添加 remote**:
```bash
git remote add origin http://219.157.255.213:25311/xinyouhe/fangke.git
git remote -v # 核对
```
- [ ] **Step 2: 推送 m1(带 `-u` 建立跟踪)**。**用 `GIT_TERMINAL_PROMPT=0` 防凭证缺失时挂死**:
```bash
GIT_TERMINAL_PROMPT=0 git push -u origin m1
```
Expected: 推送成功。若报认证失败(`terminal prompts disabled` / 401):**停下告知用户**——需用户在本机 Git Credential Manager 配好该服务器凭证(一次性),配好后重试。
- [ ] **Step 3: 推送 main**:`git push origin main`
- [ ] **Step 4: 远程核对**(若 git 服务器有 web 界面让用户看一眼;CLI 侧 `git ls-remote origin | head -5` 确认两分支与最新哈希一致)
---
### Task 3: deploy/deploy.sh(核心交付)
**Files:**
- Create: `deploy/deploy.sh`
- Create: `deploy/logs/.gitkeep`(目录占位,logs 本身被 gitignore——.gitkeep 也忽略则无需,直接 mkdir 即可,脚本自建)
- [ ] **Step 1: 写入完整脚本**:
```bash
#!/usr/bin/env bash
# 客迹一键部署——固化标准部署链(spec: docs/superpowers/specs/2026-09-29-git-and-deploy-automation-design.md)
# 用法: bash deploy/deploy.sh <server|admin|sdk|all> [--skip-tests] [--allow-dirty]
# 红线: 只操作 /opt/cms-cj 与 tracker-web 容器,绝不触碰服务器其他项目
set -euo pipefail
SECONDS=0
# ---- 配置(环境变量可覆盖)----
JUMP_HOST="${JUMP_HOST:-root@8.152.213.143}"
TARGET_HOST="${TARGET_HOST:-root@47.254.176.10}"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519}"
REMOTE_DIR="${REMOTE_DIR:-/opt/cms-cj}"
SITE_URL="${SITE_URL:-https://tracker.12418113.xyz}"
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
LOG_DIR="$REPO_DIR/deploy/logs"
STAMP="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/deploy-$STAMP.log"
SSH_OPTS=(-i "$SSH_KEY" -o ConnectTimeout=15 -o BatchMode=yes)
SSH=(ssh "${SSH_OPTS[@]}" -J "$JUMP_HOST" "$TARGET_HOST")
SCP=(scp "${SSH_OPTS[@]}" -J "$JUMP_HOST")
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
die() { log "✗ 失败: $*(部署中止,此前已完成的目标保持已部署状态)"; exit 1; }
ok() { log "✓ $*"; }
mkdir -p "$LOG_DIR" # 必须先于参数解析——早期 die() 也要能写日志
# ---- 参数解析 ----
TARGET_CURRENT="preflight"; STEP="-"
[ $# -ge 1 ] || die "用法: bash deploy/deploy.sh <server|admin|sdk|all> [--skip-tests] [--allow-dirty]"
DEPLOY_TARGET="$1"; shift || true
SKIP_TESTS=0; ALLOW_DIRTY=0
for arg in "$@"; do
case "$arg" in
--skip-tests) SKIP_TESTS=1 ;;
--allow-dirty) ALLOW_DIRTY=1 ;;
*) die "未知参数: $arg" ;;
esac
done
case "$DEPLOY_TARGET" in
server|admin|sdk|all) ;;
*) die "目标必须是 server|admin|sdk|all,收到: $DEPLOY_TARGET" ;;
esac
GIT_HASH="$(git -C "$REPO_DIR" rev-parse --short HEAD)"
log "===== 部署开始 target=$DEPLOY_TARGET git=$GIT_HASH ====="
# ---- 前置检查(所有目标必经)----
preflight() {
STEP="前置检查"
if [ "$ALLOW_DIRTY" -eq 0 ] && [ -n "$(git -C "$REPO_DIR" status --porcelain)" ]; then
die "git 工作区不干净(--allow-dirty 可跳过;强烈建议先提交)"
fi
"${SSH[@]}" 'echo ok' >/dev/null 2>&1 || die "跳板/目标服务器 SSH 不可达($JUMP_HOST → $TARGET_HOST)"
ok "工作区干净,SSH 链路可达"
}
# ---- 各目标实现 ----
server_tests() {
[ "$SKIP_TESTS" -eq 1 ] && return 0
STEP="server 测试"
(cd "$REPO_DIR/server" && python -m pytest tracker/tests/ -q >>"$LOG_FILE" 2>&1) \
|| die "pytest 未全过(详见 $LOG_FILE)"
ok "pytest 全过"
}
sdk_tests() {
[ "$SKIP_TESTS" -eq 1 ] && return 0
STEP="sdk 测试"
(cd "$REPO_DIR/sdk" && npx vitest run >>"$LOG_FILE" 2>&1) || die "vitest 未全过"
(cd "$REPO_DIR/sdk" && npx tsc --noEmit >>"$LOG_FILE" 2>&1) || die "tsc 报错"
ok "sdk vitest+tsc 全过"
}
build_admin() {
STEP="admin 构建"
(cd "$REPO_DIR/admin" && NODE_OPTIONS=--max-old-space-size=6144 pnpm build >>"$LOG_FILE" 2>&1) \
|| die "admin 构建失败(本机 OOM 则确认 NODE_OPTIONS 已带)"
ok "admin 构建完成: $(grep -oE 'index-[A-Za-z0-9_-]+\.js' "$REPO_DIR/admin/dist/index.html" | head -1)"
}
build_sdk() {
STEP="sdk 构建"
(cd "$REPO_DIR/sdk" && node build.mjs >>"$LOG_FILE" 2>&1) || die "sdk 构建失败"
local gz; gz=$(gzip -c "$REPO_DIR/sdk/dist/v1.js" | wc -c)
ok "sdk 构建完成: build_id=$(grep -oE '[0-9a-f]{7,9}-2026[0-9]{8}' "$REPO_DIR/sdk/dist/v1.js" | head -1) gzip=${gz}B"
}
transfer() { # $1=包内路径列表(空格分隔,相对 REPO_DIR) $2=远端包名
STEP="传输与落位"
local tarball="/tmp/$2"
(cd "$REPO_DIR" && tar -czf "$tarball" $1) || die "tar 打包失败: $1"
"${SCP[@]}" "$tarball" "$TARGET_HOST:/tmp/" >/dev/null 2>&1 || die "scp 传输失败(经 $JUMP_HOST)"
rm -f "$tarball"
# tar 按真实路径解压——直接落位挂载点(原地合并,无 cp 无暂存)
"${SSH[@]}" "cd $REMOTE_DIR && tar -xzf /tmp/$2 && rm -f /tmp/$2" >/dev/null 2>&1 \
|| die "远端解压失败($REMOTE_DIR)"
ok "已落位: $1"
}
rebuild_web() {
STEP="server 容器重建"
"${SSH[@]}" "cd $REMOTE_DIR/deploy && docker compose up -d --build web" >>"$LOG_FILE" 2>&1 \
|| die "docker compose 重建失败"
sleep 5
local status; status="$("${SSH[@]}" "docker ps --filter name=tracker-web --format '{{.Status}}'")" \
|| die "容器状态查询失败"
echo "$status" | grep -q "^Up" || die "tracker-web 容器状态异常: $status"
ok "tracker-web: $status"
}
verify_site_up() {
STEP="站点探活"
curl -sf -o /dev/null --max-time 20 "$SITE_URL" || die "$SITE_URL 探活失败"
ok "站点 200"
}
verify_admin_hash() {
STEP="admin 哈希验证"
local local_h online_h
local_h=$(sha256sum "$REPO_DIR/admin/dist/index.html" | cut -d' ' -f1)
online_h=$(curl -sf --max-time 20 "$SITE_URL/" | sha256sum | cut -d' ' -f1) \
|| die "线上 index.html 拉取失败"
[ "$local_h" = "$online_h" ] || die "admin index.html 线上哈希与本机产物不一致(部署未生效或缓存)"
ok "admin 哈希一致"
}
verify_sdk_bid() {
STEP="sdk build_id 验证"
local bid; bid="$(grep -oE '[0-9a-f]{7,9}-2026[0-9]{8}' "$REPO_DIR/sdk/dist/v1.js" | head -1)"
[ -n "$bid" ] || die "本机产物无 build_id"
curl -sf --max-time 20 "$SITE_URL/sdk/v1.js" | grep -q "$bid" \
|| die "线上 v1.js 不含新 build_id $bid"
ok "sdk 线上已含 build_id $bid"
}
deploy_server() {
TARGET_CURRENT=server
server_tests
STEP="server 打包"
# 排除清单镜像 server/.dockerignore 本意:Windows venv(97MB)/缓存/本地xdb 绝不上服务器
(cd "$REPO_DIR" && tar -czf /tmp/keji-server.tgz \
--exclude='__pycache__' --exclude='.pytest_cache' --exclude='db.sqlite3' \
--exclude='.venv' --exclude='.cache' --exclude='tracker/data/*.xdb' server/) \
|| die "server 打包失败"
"${SCP[@]}" /tmp/keji-server.tgz "$TARGET_HOST:/tmp/" >/dev/null 2>&1 || die "scp 失败"
rm -f /tmp/keji-server.tgz
"${SSH[@]}" "cd $REMOTE_DIR && tar -xzf /tmp/keji-server.tgz && rm -f /tmp/keji-server.tgz" >/dev/null 2>&1 \
|| die "远端解压失败"
ok "server 源码落位"
rebuild_web
verify_site_up
}
deploy_admin() {
TARGET_CURRENT=admin
build_admin
transfer "admin/dist" "keji-admin.tgz"
verify_admin_hash
}
deploy_sdk() {
TARGET_CURRENT=sdk
sdk_tests
build_sdk
transfer "sdk/dist" "keji-sdk.tgz"
verify_sdk_bid
}
# ---- 编排(all 按契约依赖序: server → admin → sdk)----
preflight
case "$DEPLOY_TARGET" in
server) deploy_server ;;
admin) deploy_admin ;;
sdk) deploy_sdk ;;
all) deploy_server; deploy_admin; deploy_sdk ;;
esac
log "===== 部署完成 target=$DEPLOY_TARGET git=$GIT_HASH 用时 $SECONDS 秒 ====="
```
**实现者注意**:落盘即上方最终形态(含 SECONDS=0/mkdir 前置/tar 全排除清单/die 包装),勿再引入管道判断测试退避的写法(pytest/vitest 退出码即真相,输出重定向进日志)。
- [ ] **Step 2: 语法检查**:`bash -n deploy/deploy.sh`(无输出即通过)
- [ ] **Step 3: 本地无副作用的参数面测试**:`bash deploy/deploy.sh` (无参 → 应打用法并退出 1);`bash deploy/deploy.sh foo` (非法目标 → 退出 1);`bash deploy/deploy.sh admin --bad-flag`(未知参数 → 退出 1)——三条都不应触碰网络
- [ ] **Step 4: 提交**:
```bash
git add deploy/deploy.sh
git commit -m "feat(deploy): 一键部署脚本——tar真实路径直落挂载点/失败即停/哈希验证/部署日志
Co-Authored-By: Claude Code <noreply@anthropic.com>"
```
---
### Task 4: CLAUDE.md 新建 + 记忆更新
**Files:**
- Create: `CLAUDE.md`(仓库根)
- Modify: 我的自动记忆(会话外文件,非仓库)
- [ ] **Step 1: CLAUDE.md**(简短,只放会话必需约定;项目背景大块由用户自己的模板提供):
```markdown
# CLAUDE.md — 客迹(KEJI)项目约定
## 部署(唯一入口)
- 一键部署:`bash deploy/deploy.sh <server|admin|sdk|all> [--skip-tests] [--allow-dirty]`
- 用户说"部署到线上" = 执行上述命令(按改动范围选目标)并汇报验证结果与版本标识
- `all` 顺序固定 server → admin → sdk(SDK 新事件依赖 server 白名单先上线)
- 部署日志:deploy/logs/(gitignore);线上真挂载点 /opt/cms-cj/{admin/dist, sdk/dist}(tar 真实路径直落,禁止 cp/暂存方案)
- 严格不触碰服务器其他项目(1Panel/OpenResty/其他容器)
## 红线(沿袭 spec §4.5 等)
- SDK 任何回调异常不得冒泡宿主页;业务代码禁 v-html(admin README-dev.md)
- 一切改动走 TDD;用中文交流
```
- [ ] **Step 2: 更新自动记忆**(`C:\Users\30997\.claude\projects\d--project-cms-cj\memory\frontend-deploy-target-dir.md`):正文改为指向 `deploy/deploy.sh` 为唯一部署入口 + 保留历史陷阱教训(为何 tar 真实路径直落);`MEMORY.md` 索引行同步改钩子文案。
- [ ] **Step 3: 提交 CLAUDE.md**:
```bash
git add CLAUDE.md
git commit -m "docs: 新建CLAUDE.md——部署唯一入口与红线约定
Co-Authored-By: Claude Code <noreply@anthropic.com>"
```
---
### Task 5: 验证(用户已选完整版;按风险递增序)
- [ ] **Step 1: admin 单目标首验**(零停机路径先通全链):
```bash
bash deploy/deploy.sh admin
```
Expected: 构建→传输→落位→哈希一致四步日志全 ✓,退出码 0;`deploy/logs/` 生成日志文件。
- [ ] **Step 2: 失败注入**(不碰线上——用环境变量覆盖跳板为不可达地址):
```bash
JUMP_HOST=nobody@127.0.0.1 bash deploy/deploy.sh admin; echo "exit=$?"
```
Expected: 前置检查 ✗ SSH 不可达 → 退出码非 0,日志留档,**未发生任何传输**。
再注入工作区脏检查(具体方法):
```bash
echo "" >> CLAUDE.md # 制造未提交改动
bash deploy/deploy.sh admin; echo "exit=$?" # 预期: 工作区不干净 → 拒绝,退出 1,未触碰网络(脏检查先于 SSH 检查)
git checkout -- CLAUDE.md # 还原
```
- [ ] **Step 3: all 完整验证**(含 server 容器秒级重启,用户已确认接受):
```bash
bash deploy/deploy.sh all
```
Expected: pytest→打包→容器 Up→探活→admin 构建/哈希→sdk 测试/build_id 五段全 ✓;线上验证(容器 Up、站点 200、admin 哈希一致、sdk build_id 在线)。
- [ ] **Step 4: 事后观察**:容器日志无异常(`docker logs tracker-web --since 5m` 无 5xx 报错堆栈)、hesucar 采集照常(几分钟后新事件入库抽查)。
---
### Task 6: 收尾推送
- [ ] **Step 1: 推送全部新提交:`git push origin m1`**(deploy.sh/CLAUDE.md/gitignore 等)
- [ ] **Step 2: 汇报**:远程仓两分支状态、脚本用法示例、验证结果摘要(admin 首验/失败注入/all)、部署日志位置;**提醒用户**:其会话粘贴的"项目背景"模板中"部署策略"段更新为指向 `bash deploy/deploy.sh`(spec Part 3 第 2 条,用户侧自行更新)
---
## 风险提示(执行者必读)
1. **Task 2 凭证**:http 远程首次 push 可能需用户侧 Git Credential Manager 配凭证——`GIT_TERMINAL_PROMPT=0` 会快速失败而非挂死,失败即停并告知用户,不得绕过。
2. **Task 5 Step 3 的 server 重启窗口**:秒级采集 API 中断为已知且用户已接受;SDK 重试吸收;若重建后容器异常(非 Up),立即 `docker logs tracker-web` 取因并汇报,不盲目重试。
3. **tar 落位只增不删**:admin/dist 旧哈希文件会累积(index.html 只指向新哈希,无害);不做的清理不要顺手加。
4. 脚本以 `bash deploy/deploy.sh` 调用(Windows 下不依赖 exec bit);服务器侧命令全部经既有跳板链,不新增任何端口暴露。
@@ -0,0 +1,99 @@
# Git 托管接入 + 一键部署方案 — 设计文档
日期:2026-09-29
分支:m1
状态:设计已获用户批准
远程仓:http://219.157.255.213:25311/xinyouhe/fangke.git(用户自建)
## 背景与目标
当前部署是"约定驱动的手工链":流程存在于对话上下文与一条记忆中,每次由 AI 手工执行 6-8 步(构建→scp 经跳板→解压→cp 到正确挂载点→docker 重建→探活→哈希核对)。已两次踩中"解压到错误目录"同类坑(/opt/cms-cj/dist 而非 admin/dist)。仓库无 git remote,无任何部署脚本。
目标:
1. 项目托管到用户提供的自建 git 远程仓(版本化/备份)。
2. 部署固化为仓库内一键脚本 `deploy/deploy.sh`——用户说"部署到线上"时 AI 执行该命令;日常任务完成后的标准部署走同一条命令。
3. 消灭手工多步流程的错误类(错误目录/静默半部署/漏验证)。
## Part 1:Git 远程仓初始化
1. `git remote add origin http://219.157.255.213:25311/xinyouhe/fangke.git`
2. **推送前卫生处理**(实现时以当时 `git status` 实测为准,按以下原则逐文件决策):
- 被代码引用的资产(如 LoginLeftView 引用的图片)→ 提交入库
- 设计文档/设计稿(assets/design 等)→ 提交入库(团队知识)
- 临时验证产物(根目录截图、测试页 html、.playwright-mcp/、.tmp-*)→ .gitignore
- **.gitignore 必须在本期初始提交中即包含 `deploy/logs/`**——否则首次部署产生的日志污染工作区,下一次部署被自身的"工作区干净"前置门禁拦住
- 敏感文件扫描:确认无 .env/私钥/明文密码入库(.env.example 除外);deploy/.env 不存在于本机(仅服务器有)
- 首次 push 到 http 远程会弹凭证(Windows Git Credential Manager 交互一次后缓存)——一次性用户侧操作,AI 执行 push 前需用户确认凭证已配好
3. 推送 `m1` 与 `main` 两分支(m1 为当前功能分支)。
4. 后续提交按既有节奏正常 push(不做 push 即部署——见"明确不做")。
## Part 2:deploy/deploy.sh 一键部署脚本
### 用法
```
bash deploy/deploy.sh <server|admin|sdk|all> [--skip-tests] [--allow-dirty]
```
- 单目标只走对应子集(如仅改 admin 时 30 秒级完成)
- `all` 按依赖序执行:**server → admin → sdk**(SDK 新事件依赖 server 白名单先上线)
### 脚本顶部配置(环境变量可覆盖)
```
JUMP_HOST=root@8.152.213.143 # 跳板
TARGET=root@47.254.176.10 # 生产服务器
SSH_KEY=~/.ssh/id_ed25519
REMOTE_DIR=/opt/cms-cj
SITE_URL=https://tracker.12418113.xyz
```
### all 完整流程(固化现手工链 + 全部已知陷阱防御)
| 步骤 | 内容 | 防的坑 |
|---|---|---|
| 1 前置 | git 工作区干净(`--allow-dirty` 逃生舱);跳板 SSH 可达 | 部署未提交状态 |
| 2 测试门禁 | server: pytest tracker/tests(188 基线);sdk: vitest run + tsc;admin: vue-tsc(build 内含) | 带病上线 |
| 3 构建 | admin: `NODE_OPTIONS=--max-old-space-size=6144 pnpm build`;sdk: build + gzip 字节数记录 | 本机 OOM |
| 4 打包与传输 | **tar 按真实路径打包**(非暂存 dist/ 方案):admin/sdk 目标打 `admin/dist sdk/dist`;server 目标打 `server/`(排除 __pycache__/db.sqlite3/.pytest_cache;**绝不打包 node_modules/.git/docs**)→ scp 经跳板 → /tmp | 根目录 tar 连 node_modules 一起传 |
| 5 服务器侧 | 在 $REMOTE_DIR 解压——**tar 真实路径直接落位挂载点**(admin/dist、sdk/dist 原地合并覆盖,无 cp、无暂存、无清理步骤——彻底消灭"解压到错误目录"路径);server: `docker compose up -d --build web`(Dockerfile 内自动 migrate) | 错误挂载点目录(×2 历史) |
| 6 验证 | tracker-web 容器 Up;`curl -f $SITE_URL` → 200;admin:`sha256(curl $SITE_URL 的 index.html) === sha256(本机 admin/dist/index.html)`;sdk:`curl $SITE_URL/sdk/v1.js` 含新 build_id | 部署无效/缓存误判 |
| 7 记录 | deploy/logs/deploy-<时间戳>.log:git 哈希/部署目标/版本标识/验证结果/耗时(logs 目录 gitignore,已在 Part 1 落地) | 无部署历史可追溯 |
### 单目标语义(每个目标都完整走:前置检查 + 自己的测试 + 自己的验证)
| 目标 | 子集 |
|---|---|
| server | pytest → tar server/ → 解压 $REMOTE_DIR → docker compose up -d --build web → 容器 Up + `curl -f $SITE_URL` 200(无 dist 传输) |
| admin | vue-tsc/build → tar admin/dist → 解压落位 → index.html sha256 线上比对(无 docker、无 pytest) |
| sdk | vitest + tsc → build → tar sdk/dist → 解压落位 → 线上 v1.js 含 build_id(无 docker) |
### 失败语义
`set -euo pipefail`;任一步失败立即中止并输出失败步骤与原因——**杜绝静默半部署**。单目标失败时 `all` 序列停止(不做部分继续)。
### 平台约束
- 脚本为 bash(开发机 Git Bash 执行;服务器侧命令经 ssh 执行,无 Windows 依赖)
- 服务器现有 1Panel/OpenResty 等其他项目**严格不触碰**(既有红线)
## Part 3:使用约定(新建 CLAUDE.md + 更新会话背景与记忆)
现状核实:仓库与用户级**均无 CLAUDE.md**——部署约定目前活在用户每会话粘贴的"项目背景"块与 AI 自动记忆中。落地:
1. **新建仓库 CLAUDE.md**(部署约定段):用户说"部署到线上" → AI 执行 `bash deploy/deploy.sh all`(或按改动范围选单目标)并汇报验证结果与版本标识;任务完成后的标准部署 = 同一条命令;部署日志位于 deploy/logs/。
2. **用户侧**:其后会话粘贴的"项目背景"块中"部署策略"段改为指向本脚本(由用户自行更新其模板,spec 不代写)。
3. **AI 自动记忆**:更新既有"前端部署目标目录"记忆条目——手工链描述作废,指向 deploy/deploy.sh(历史陷阱教训保留)。
## 明确不做(本期)
- push 触发自动部署(webhook/CI):git 服务器无暴露接收端口必要;未来升级时 CI 可直接调用本脚本(接口即命令行)
- 服务器侧构建:构建留在开发机,服务器保持纯 Docker 运行态
- 自动回滚机制:出问题走"再改再发"(单管理员低频部署,YAGNI;部署日志保留上一版哈希可供人工对照)
## 验证计划
1. 脚本干跑:单目标 admin 部署一次(近期 admin 已是最新,重部署无损)验证全流程
2. `all` 全量部署一次(当前三端均为最新代码,等价幂等重部署)验证顺序与哈希核对
3. 故意注入失败(如断开跳板模拟)验证"失败即停"语义
4. git push 后远程仓确认两分支与提交历史完整
+38
View File
@@ -0,0 +1,38 @@
# 采集阶段B(业务事件体系)验收台账
日期:2026-09-20 · 分支 m1 · SDK 0.2.1 · 服务端归因 v2
部署:server(tracker-web 重建 + recompute_attribution v2:3465 first_touch + 3655 session_entry)→ admin(index-BXQ9nTvO.js)→ SDK(build_id c9967b6-202609190012,gzip 5781B)
## 前置:阶段A 48h 观察窗(D02)验收
- 近 48h:1913 会话,v2 事件占比 93%(11402/12215)
- 孤立 page_stay 会话 16 个(1.9%→0.84%),逐一分解:10 个含 session_resume(恢复会话天然无 PV——设计内,其中 7 个实证 resume_of 原会话有同 page_id 的 PV,归属链正确);1 个 v2 前序会话有同 page_id PV(归属正确);~5 个乱序/丢批网络层边缘 + 3 个纯 v1 旧缓存(旧机制,随缓存淘汰)
- **结论:无归属漂移证据,D02 机制验收通过**;"孤立无 PV"指标在 v2 恢复语义下存在检测误报(已知局限,口径见阶段A台账)
## 归因 v2 口径变化(预期内,非 bug)
recompute 后渠道分布:natural_search 2996 / social 968 / direct 2504 / paid_search 297 / **paid_social 286**(自 paid_search 分离——fbclid 与 fb 家族+付费 medium 流量)/ referral 36 / ai_referral 35。
## 线上验证(2026-09-20)
| 项 | 方法 | 结果 |
|---|---|---|
| 服务端业务契约 | 生产 collect POST contact_click v2 批 | accepted=1,回归数据已清理 |
| 归因重算 | recompute --site + 分布核对 | v2 全量重算成功 |
| SDK 0.2.1 产物 | curl 线上 v1.js(14778B,版本串 0.2.1) | ✅ |
| 业务事件链路 | happy-dom 加载**线上产物**真实执行:产品页 init→点击 wa.me→focusin→原生 submit→wpcf7statuschanged(mail_sent) | product_view(inferred)/contact_click(whatsapp)/form_submit+result(同 submission_id `7b6ac24e` 贯穿)/_ctk_sid 字段值=submission_id(**capture submit 时序实证**);form_start 未触发=isTrusted 守卫正确拒绝合成事件 |
| mailsent 真实闭环 | **待首封真实询盘**(或手动提交 TEST-KEJI 标记询盘一次)验证 form_submit_result 落库与漏斗末级计数 | 待办 |
## 交付清单(阶段B,7 实现 commit + 2 审查修复)
3b9d8ec 契约+6事件 → be0b53b 正则锚点闭合 → 52a8a72 归因 v2 → 0ef01b0 漏斗 API → 7b670f2 business.ts → fea19c1 0.2.1 接线 → 8d7fc99 track 权威性回归 → c9967b6 admin(时间线徽章/漏斗卡/付费社交标签 + server 时间线安全字段白名单含 PII 诱饵测试)
测试基线:server 161→188,sdk 100→122。全任务子代理实现 + 两阶段审查。
## 遗留
- mailsent 真实询盘闭环(上表待办)——下次真实询盘到达时在漏斗末级/时间线核对,或手动 TEST-KEJI 提交一次
- admin 视觉验收(漏斗卡/时间线新徽章渲染)——用户刷新后台即视
- `_canon` 子域归一局限(m.facebook.com 不入 fb 家族)——规则 v3 候选
- CF7 `wpcf7statuschanged` 依赖 CF7 6.x 行为——网站插件大版本升级时复核(源码核验法见阶段A/B spec)
- 阶段C:服务端确认通道(spec §5 契约已预留:_ctk_sid 字段已随 POST 传输实证)/ sdk_diag / capabilities / 次批事件
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ctk-sdk",
"version": "0.2.0",
"version": "0.2.1",
"private": true,
"type": "module",
"scripts": {
+12 -2
View File
@@ -1,8 +1,9 @@
import { uuid } from "./util";
import { mergeConfig } from "./config";
import { Transport } from "./transport";
import { startPageSession } from "./page";
import { startPageSession, getPageSession } from "./page";
import { startStayTracking } from "./stay";
import { startBusinessTracking } from "./business";
import { buildEnvContext, collectHighEntropyHints, pageContext } from "./context";
import { SDK_VERSION, BUILD_ID, SCHEMA_VERSION, CONFIG_VERSION } from "./types";
import type { CtkConfig } from "./config";
@@ -65,8 +66,17 @@ function dispatch(cmd: string, arg?: unknown) {
...ps.attribution(),
});
startStayTracking(transport, ps);
// 0.2.1 接线(T5):业务事件自动监听(contact_click / form_* / product_view)。
// 必须在 startPageSession 之后——emit 经 getPageSession() 取归属,无会话时
// 守卫静默丢弃(business.test.ts 首例覆盖异常顺序)。幂等性依赖调用方守卫:
// init 的 !transport 条件已保证只调一次。product_view 的 init 判定读挂载时
// 同步的 location.pathname——置于 startStayTracking 之后无时序敏感(首帧判定)。
startBusinessTracking(transport);
} else if (cmd === "track" && transport) {
transport.push(arg as object);
// track 补当前段归属(T5):宿主字段展开在前、归属在后——归属键权威(宿主
// 伪造的 site_id 等被覆盖),其余宿主字段原样保留。PageSession 未启动时
// attribution() 为 undefined,展开为无操作——保持原样走兜底组路径。
transport.push({ ...(arg as object), ...getPageSession()?.attribution() });
} else if (cmd === "flush" && transport) {
transport.flush();
}
+1 -1
View File
@@ -9,7 +9,7 @@ export interface Attribution {
epoch?: null; // V1.1 预留
grant_ref?: null; // V1.1 预留
}
export const SDK_VERSION = "0.2.0"; // 与 package.json 同步手维护
export const SDK_VERSION = "0.2.1"; // 与 package.json 同步手维护
export const SCHEMA_VERSION = 2;
export const CONFIG_VERSION = "1";
+13
View File
@@ -427,6 +427,19 @@ describe("business / startBusinessTracking", () => {
expect(ids).toEqual(["cf7-156", "cf7-42", "cf7-0"]);
});
it("form_id 回归(T4 审查遗留):11 位 ID 不截断匹配 → cf7-0(服务端 cf7-N 上限 10 位)", () => {
setUrl("/fid11");
const t = mkTransport("biz-fid11");
startPageSession(t);
startBusinessTracking(t);
const form = mkForm({ action: "#wpcf7-f12345678901" });
trusted("focusin", form.querySelector("input")!);
t.flush();
const fss = fetchedEvents("biz-fid11").filter((e) => e.event_name === "form_start");
expect(fss).toHaveLength(1);
expect(fss[0].properties.form_id).toBe("cf7-0"); // (?!\d) 拒绝截断——按解析失败归 cf7-0
});
// ---- product_view(spec §1.3:非锚定段匹配 + onSegmentChange 切段) ----
it("product_view:init 时 /product/slug/ 判定 → inferred=true rule_version=v1 + 顶层归属", () => {
+144 -11
View File
@@ -2,9 +2,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// index.ts 是 IIFE 打包入口:模块加载即读取全局队列、替换 g.ctk 并重放。
// 隔离手段:vi.resetModules() + 动态 import,每用例拿到全新模块级 transport 状态。
// startPageSession/startStayTracking 注册的 document/window 监听跨用例存留,
// 用独立 siteId 过滤批次。0.2.0 接线(T10):初始 page_view 由 startPageSession
// 发(seq=1),sdk_loaded 随后入队(seq=2)——队列顺序 [page_view, sdk_loaded]。
// startPageSession/startStayTracking/startBusinessTracking 注册的 document/window
// 监听跨用例存留,用独立 siteId 过滤批次。0.2.0 接线(T10):初始 page_view 由
// startPageSession 发(seq=1),sdk_loaded 随后入队(seq=2)。0.2.1 接线(T5):
// startBusinessTracking 挂业务监听(产品页路径下 init 队列追加 product_view)、
// track 补当前段归属(不再走兜底组)。
type Cmd = [string, unknown?];
type CtkFn = ((cmd: string, arg?: unknown) => void) & { q?: Cmd[] };
@@ -33,6 +35,26 @@ function bodiesOf(siteId: string) {
.filter((b: { site_id: string }) => b.site_id === siteId);
}
/** URL 重置:走原型方法绕开历史用例的 page.ts pushState 包装——不触发旧模块
* 切段杂事件(同 business.test.ts setUrl 模式) */
function setUrl(url: string) {
const proto = Object.getPrototypeOf(window.history) as {
replaceState: (data: unknown, unused: string, url?: string | URL | null) => void;
};
proto.replaceState.call(window.history, {}, "", url);
}
/** beacon 路径(contact_click 的 flush(true))批次汇总(按信封 site_id 过滤) */
async function beaconBodiesOf(siteId: string) {
const out: Record<string, any>[] = [];
const beacon = (navigator as unknown as { sendBeacon: ReturnType<typeof vi.fn> }).sendBeacon;
for (const [, blob] of beacon.mock.calls as unknown as [string, Blob][]) {
const body = JSON.parse(await blob.text());
if (body.site_id === siteId) out.push(body);
}
return out;
}
describe("index 入口", () => {
beforeEach(() => {
localStorage.clear();
@@ -64,7 +86,7 @@ describe("index 入口", () => {
expect(bodies).toHaveLength(1);
const loaded = bodies[0].events.find((e: { event_name: string }) => e.event_name === "sdk_loaded");
expect(loaded).toBeTruthy();
expect(loaded.properties.sdk_version).toBe("0.2.0");
expect(loaded.properties.sdk_version).toBe("0.2.1");
// build_id 由 vitest.config define 注入 "vitest";无 define 环境兜底 "unknown"
expect(loaded.properties.build_id).toBe("vitest");
expect(loaded.properties.schema_version).toBe(2);
@@ -95,6 +117,115 @@ describe("index 入口", () => {
expect(evs[0].page_id).toBe(evs[1].page_id); // 同段归属
});
it("track 补归属(T5):init 后 track 事件信封带顶层归属(四元组+seq+V1.1 预留位),不再走兜底组", async () => {
await loadSdk([["init", { siteId: "site-track", endpoint: "/e", flushMs: 60000 }]]);
g.ctk!("track", {
event_id: "e-track-1",
event_name: "cta_click",
occurred_at: new Date().toISOString(),
path: "/anywhere",
title: "t",
properties: { k: 1 },
});
g.ctk!("flush");
const bodies = bodiesOf("site-track");
// track 补当前段归属 → 与 page_view/sdk_loaded 同四元组,单组子批
// (兜底组路径只剩 PageSession 未启动的异常接线场景)
expect(bodies).toHaveLength(1);
const evs = bodies[0].events;
expect(evs.map((e: { event_name: string }) => e.event_name)).toEqual([
"page_view",
"sdk_loaded",
"cta_click",
]);
const ev = evs[2] as Record<string, unknown>;
expect(ev.event_id).toBe("e-track-1"); // 宿主字段原样保留(归属展开在后只补/覆盖归属键)
expect(ev.properties).toEqual({ k: 1 });
expect(ev.site_id).toBe("site-track");
expect(ev.visitor_id).toMatch(UUID_RE);
expect(ev.session_id).toMatch(UUID_RE);
expect(ev.page_id).toBe(evs[0].page_id); // 与初始 page_view 同段
expect(ev.seq).toBe(3); // page_view=1、sdk_loaded=2 之后
expect(ev.purpose).toBe(null); // V1.1 预留位从快照展开
expect(ev.epoch).toBe(null);
expect(ev.grant_ref).toBe(null);
});
it("track 归属权威性回归(T5 审查遗留):宿主伪造归属键被快照覆盖,业务字段原样保留", async () => {
await loadSdk([["init", { siteId: "site-auth", endpoint: "/e", flushMs: 60000 }]]);
g.ctk!("track", {
event_id: "e-evil",
event_name: "page_stay",
occurred_at: new Date().toISOString(),
path: "/x",
title: "t",
properties: {},
site_id: "evil-site", // 宿主伪造归属键:spread 宿主在前、归属在后(index.ts track 分支)
session_id: "evil-sess",
});
g.ctk!("flush");
// 信封 site_id 恒取 init 配置(transport.envelope 读 cfg.siteId)——
// 伪造的 evil-site 批次根本不存在
expect(bodiesOf("evil-site")).toHaveLength(0);
const bodies = bodiesOf("site-auth");
expect(bodies).toHaveLength(1); // 伪造键被覆盖 → 与 page_view/sdk_loaded 同四元组,单组子批
const evs = bodies[0].events;
expect(evs.map((e: { event_name: string }) => e.event_name)).toEqual([
"page_view",
"sdk_loaded",
"page_stay",
]);
const ev = evs[2] as Record<string, unknown>;
// 事件归属四元组来自 PageSession 快照(非宿主伪造值)
expect(ev.site_id).toBe("site-auth");
expect(ev.session_id).toMatch(UUID_RE);
expect(ev.session_id).toBe(evs[0].session_id); // 与初始 page_view 同会话(非 evil-sess)
expect(ev.visitor_id).toBe(evs[0].visitor_id);
expect(ev.page_id).toBe(evs[0].page_id);
// 信封归属同取分组键(事件顶层快照字段):亦非伪造值
expect(bodies[0].session_id).toBe(evs[0].session_id);
// 宿主业务字段不被归属展开覆盖
expect(ev.event_id).toBe("e-evil");
expect(ev.properties).toEqual({});
});
it("init 接线 business(T5):产品页路径下队列 [page_view,sdk_loaded,product_view];wa.me 点击 → contact_click beacon 冒烟", async () => {
setUrl("/product/hydraulic-press-100t/");
await loadSdk([["init", { siteId: "site-biz", endpoint: "/e", flushMs: 60000 }]]);
g.ctk!("flush");
const bodies = bodiesOf("site-biz");
expect(bodies).toHaveLength(1); // 三事件同四元组 → 单组子批
const evs = bodies[0].events;
expect(evs.map((e: { event_name: string }) => e.event_name)).toEqual([
"page_view",
"sdk_loaded",
"product_view",
]);
const pv = evs[2] as Record<string, unknown>;
expect(pv.properties).toEqual({ product_id: "hydraulic-press-100t", inferred: true, rule_version: "v1" });
expect(pv.page_id).toBe(evs[0].page_id); // init 同步判定归当前段
// business 监听已装:点击 wa.me 链接 → contact_click 入队并 flush(true) beacon 送达
const a = document.createElement("a");
a.href = "https://wa.me/8613800138000";
document.body.appendChild(a);
try {
a.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
} finally {
a.remove();
}
const beacons = await beaconBodiesOf("site-biz");
expect(beacons).toHaveLength(1);
const cc = beacons[0].events[0];
expect(cc.event_name).toBe("contact_click");
expect(cc.properties).toEqual({ channel: "whatsapp" });
expect(cc.page_id).toBe(pv.page_id); // 归属同段
expect(cc.site_id).toBe("site-biz");
});
it("批次 context 含设备环境 + referrer_domain/utm/path(首触归因链路)", async () => {
Object.defineProperty(document, "referrer", {
value: "https://news.example/a",
@@ -224,21 +355,23 @@ describe("index 入口", () => {
expect(g.ctk!.q).toBeUndefined(); // 桩(带 q)已被 dispatch 替换
g.ctk!("flush");
let bodies = bodiesOf("site-8");
// T8 分组拆批:归属组(page_view+sdk_loaded 同四元组)+ 兜底组(track 宿主
// 对象无归属顶层字段,信封走 identity 旧路径)——一次 flush 两个子批
expect(bodies).toHaveLength(2);
// T5 起 track 补当前段归属:cta_click 与 page_view/sdk_loaded 同四元组 →
// 单组子批(兜底组只剩 PageSession 未启动的异常接线场景)
expect(bodies).toHaveLength(1);
expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([
"page_view",
"sdk_loaded",
"cta_click",
]);
expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click"]);
expect(bodies[1].events[0].properties).toEqual({ k: 1 });
expect(bodies[0].events[2].properties).toEqual({ k: 1 });
expect(bodies[0].events[2].event_id).toBe("e-custom"); // 宿主字段原样保留
// 重放结束后宿主新调用直接进 dispatch(不再排队丢失)
g.ctk!("track", { ...trackEv, event_id: "e-2", event_name: "cta_click2" });
g.ctk!("flush");
bodies = bodiesOf("site-8");
expect(bodies).toHaveLength(3);
expect(bodies[2].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]);
expect(bodies).toHaveLength(2);
expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]);
expect(bodies[1].events[0].site_id).toBe("site-8"); // dispatch 直调同补归属
});
});
+33 -2
View File
@@ -23,6 +23,34 @@ TIMELINE_MAX_EVENTS = 1000
ONLINE_WINDOW = timedelta(minutes=5) # 与 dashboard active_now 同口径
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):
"""来源三级兜底:referrer_domain → utm.utm_source → 直接访问(访客档案/会话共用)。"""
@@ -182,7 +210,8 @@ def _merge_page_stay(events):
最近一条同 path 的 page_view 条目,把 active_ms 累加进去(同页多段隐藏/恢复多次累加);
找不到匹配(含 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 = []
for e in events:
@@ -198,8 +227,10 @@ def _merge_page_stay(events):
else:
# page_view 初始 active_ms=0;其余(sdk_loaded/自定义事件)无停留概念 → null
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,
"path": e.path, "title": e.title, "active_ms": active})
"path": e.path, "title": e.title, "active_ms": active,
**_safe_fields(e)})
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)
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,
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):
@@ -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 在今天
assert set(ids(d)) == {"cn-desktop", "us-mobile"}
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):
@@ -268,6 +272,57 @@ def test_timeline_day_grouped_and_stay_merge(client, member_user):
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):
_auth(client, member_user)
site = Site.objects.create(name="s", domains=["a.com"])