浏览趋势图全栈实现:从用户点击到 SVG 渲染的完整数据链路
这是一篇围绕 StaticFlow 浏览趋势功能的全栈实现解析,重点展示“浏览事件如何被可靠采集、去重、聚合并在前端可视化”。问题与目标在个人博客场景下,希望避免第三方统计平台,构建本地优先、隐私友好的浏览统计能力。需要同时支持按天与按小时的趋势分析,并避免用户短时间刷新造成重复计数。方案要求与现有 Rust 全栈(A…
浏览趋势图全栈实现:从用户点击到 SVG 渲染的完整数据链路 代码版本:基于 StaticFlow 当前 master 分支。1. 为什么要自建浏览统计 个人博客接入 Google Analytics 或百度统计,意味着把用户行为数据交给第三方。对于一个本地优先的知识管理系统来说,这不太合适:隐私:不想向第三方暴露读者的 IP 和浏览行为依赖:第三方服务挂了,统计就断了灵活性:想要按天/按小时的细粒度趋势,而不是第三方仪表盘的固定视图 StaticFlow 的技术栈是 Rust 全栈 —— Axum 后端 + Yew WASM 前端 + LanceDB 嵌入式数据库。在这个栈上自建浏览统计,核心挑战是:如何在无 cookie、无登录的场景下做用户去重如何用嵌入式列存数据库(无原生 GROUP BY)做时间序列聚合如何在 WASM 环境中零依赖渲染趋势图1.1 数据流总览 一次完整的浏览追踪 + 趋势展示,经过以下链路:graph LR A["用户打开文章"] --> B["WASM 前端<br/>POST /api/articles/:id/view"] B --> C["远端 Caddy/Nginx :443<br/>TLS 终止 + 路径过滤"] C --> D["pb-mapper<br/>模拟远端 localhost"] D --> E["本地 Axum :3000<br/>指纹生成 + 去重"] E --> F["LanceDB<br/>merge_insert upsert"] F --> G["分桶聚合<br/>day / hour"] G --> H["JSON 响应"] H --> I["ViewTrendChart<br/>纯 SVG 渲染"] classDef user fill:#d4edda,stroke:#28a745,color:#155724 classDef frontend fill:#cce5ff,stroke:#0d6efd,color:#084298 classDef network fill:#fff3cd,stroke:#fd7e14,color:#664d03 classDef backend fill:#e2d9f3,stroke:#6f42c1,color:#432874 classDef database fill:#d1ecf1,stroke:#0dcaf0,color:#055160 classDef render fill:#d4edda,stroke:#198754,color:#0f5132 class A user class B frontend class C,D network class E backend class F,G database class H backend class I render 📌 本文范围:覆盖从后端浏览追踪、去重机制、分桶聚合、运行时配置、部署架构到前端 SVG 渲染的完整链路。不涉及文章内容管理和搜索功能。2. 后端 — 浏览事件追踪与去重 用户打开一篇文章时,前端发送 POST /api/articles/:id/view。后端需要解决两个问题:识别用户和防止重复计数。2.1 客户端指纹生成 在无 cookie、无登录的场景下,我们用 SHA256(IP | User-Agent) 生成客户端指纹。这不是完美的用户标识(同一 NAT 下的不同用户会被合并),但对个人博客场景足够用。 💡 Key Point:为什么不用 cookie 或 localStorage?WASM 前端部署在 GitHub Pages,API 在自有域名,跨域 cookie 受 SameSite 限制localStorage 指纹容易被清除,且无法在服务端验证IP + UA 的方案完全在服务端完成,前端零改动 指纹生成 — backend/src/handlers.rs:509-522:fn build_client_fingerprint(headers: &HeaderMap) -> String { let ip = extract_client_ip(headers); let user_agent = headers .get(header::USER_AGENT) .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("unknown"); let raw = format!("{ip}|{user_agent}"); let mut hasher = Sha256::new(); has…
正在初始化 WebAssembly 引擎…
首次编译原生模块可能需要数秒
就绪后,页面交互将以接近原生的速度运行