低成本埋点统计:按「功能 / 用户 / 部门」统计访问、使用、登录次数
技术栈:Vue2 + Spring Cloud Gateway + Spring Boot + MyBatis-Plus + Redis + RocketMQ(可选)
说明:本文档中的工程名、包名、全局变量名均为通用示例(
myproject),实际落地时替换为你自己的命名即可。
摘要
本方案用**「前端带一次头 + 后端拦一道 + 异步落一条」的方式,低成本统计功能的 访问次数、使用次数、登录次数**,并支持按功能 / 按用户 / 按部门三个维度聚合。
- 前端 :切换菜单时把菜单 id 与批次号记入
sessionStorage,后续请求由 axios 拦截器按需注入Event-Track-*一组请求头; - 后端 :网关全局过滤器读取埋点头,用
clientId + batchNo去重后异步 feign 上报,不阻塞业务请求; - 落库 :一条记录用
IS_VISIT_ / IS_USE_ / IS_LOGIN_三个标记位区分事件类型,统计时直接SUM即得三种次数; - 用户与部门:不由前端上报,全部由服务端从 token 解析,既防伪造、又免去前端改造。
所谓「简化」,落在两道去重 上:批次号未变化时请求不带任何埋点头 (埋点量从"次数级"降到"分钟级"),同一批次后端只落一条(防多标签页、重试、并发)。
交付物 = 单工程 Vue 前端(别名 / 环境变量 / 适配层 / 应用配置)+ 网关埋点过滤器 + 一张 sys_event_track 埋点表。
一、方案概述
1.1 需求
需要低成本地统计三类指标,并支持三个统计维度:
| 指标 | 含义 | 示例问法 |
|---|---|---|
| 访问次数 | 用户进入某个功能的次数 | 这个月「合同管理」被访问了多少次 |
| 使用次数 | 用户实际使用该功能的次数(进入即算一次) | 「合同管理」实际被使用了多少次 |
| 登录次数 | 用户登录系统的次数 | 张三这个月登录了几次 |
| 统计维度 | 需要的字段 | 数据来源 |
|---|---|---|
| 按功能 | 菜单 id / 菜单名称 | 前端切换菜单时带上 |
| 按用户 | 用户 id / 用户名称 / 手机号 | 后端从 token 解析,不由前端上报 |
| 按部门 | 部门 id / 部门名称 | 后端从 token 解析,不由前端上报 |
1.2 核心思路(4 步)
① 前端切菜单 → ② 请求头带埋点参数 → ③ 网关拦截、异步上报 → ④ 服务端落库,按维度聚合
- 前端记录菜单 :切换菜单时把当前
menuId、menuName记到sessionStorage,并生成一个批次号; - 请求头注入 :后续每个请求由 axios 拦截器判断"要不要带埋点头",需要时自动追加
Event-Track-*一组头; - 后端拦截 :网关全局过滤器读埋点头,先做一次批次去重,然后异步 feign 调用埋点接口(不阻塞业务);
- 落库 + 聚合 :服务端从 token 解析出用户 id / 部门 / 手机号,写一条记录;统计时按
菜单 / 用户 / 部门三个维度SUM即可。
1.3 关键设计:为什么叫"简化"埋点
传统埋点是"每个操作都上报一条",写入量大、还要单独的埋点 SDK。本方案做了三处精简:
| 精简点 | 做法 | 效果 |
|---|---|---|
| 按批次上报 | 同一菜单内,每隔 interval 秒才产生一个新批次号;批次号不变时请求不带任何埋点头 |
埋点请求量从"次数级"降到"分钟级",最多每 interval 秒 1 条 |
| 用户信息不从前端传 | 用户 id / 部门 / 手机号全部由服务端从 token 解析 | 前端零改造成本、数据不可伪造 |
| 复用现有管道 | 复用网关全局过滤器 + feign + 现有用户上下文 | 不需要引入埋点 SDK、不需要新增中间件 |
去重是双保险:
- 前端:批次号未变化 → 不带埋点头(省掉 90%+ 的无效上报);
- 后端:
clientId + batchNo记到 Redis,同批次只落库一次(防多标签页、重试、并发)。
1.4 整体架构
#mermaid-svg-5O3uPf2DLqTlPI8Z{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5O3uPf2DLqTlPI8Z .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5O3uPf2DLqTlPI8Z .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5O3uPf2DLqTlPI8Z .error-icon{fill:#552222;}#mermaid-svg-5O3uPf2DLqTlPI8Z .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5O3uPf2DLqTlPI8Z .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5O3uPf2DLqTlPI8Z .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5O3uPf2DLqTlPI8Z .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5O3uPf2DLqTlPI8Z .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5O3uPf2DLqTlPI8Z .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5O3uPf2DLqTlPI8Z .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5O3uPf2DLqTlPI8Z .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5O3uPf2DLqTlPI8Z .marker.cross{stroke:#333333;}#mermaid-svg-5O3uPf2DLqTlPI8Z svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5O3uPf2DLqTlPI8Z p{margin:0;}#mermaid-svg-5O3uPf2DLqTlPI8Z .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5O3uPf2DLqTlPI8Z .cluster-label text{fill:#333;}#mermaid-svg-5O3uPf2DLqTlPI8Z .cluster-label span{color:#333;}#mermaid-svg-5O3uPf2DLqTlPI8Z .cluster-label span p{background-color:transparent;}#mermaid-svg-5O3uPf2DLqTlPI8Z .label text,#mermaid-svg-5O3uPf2DLqTlPI8Z span{fill:#333;color:#333;}#mermaid-svg-5O3uPf2DLqTlPI8Z .node rect,#mermaid-svg-5O3uPf2DLqTlPI8Z .node circle,#mermaid-svg-5O3uPf2DLqTlPI8Z .node ellipse,#mermaid-svg-5O3uPf2DLqTlPI8Z .node polygon,#mermaid-svg-5O3uPf2DLqTlPI8Z .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5O3uPf2DLqTlPI8Z .rough-node .label text,#mermaid-svg-5O3uPf2DLqTlPI8Z .node .label text,#mermaid-svg-5O3uPf2DLqTlPI8Z .image-shape .label,#mermaid-svg-5O3uPf2DLqTlPI8Z .icon-shape .label{text-anchor:middle;}#mermaid-svg-5O3uPf2DLqTlPI8Z .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5O3uPf2DLqTlPI8Z .rough-node .label,#mermaid-svg-5O3uPf2DLqTlPI8Z .node .label,#mermaid-svg-5O3uPf2DLqTlPI8Z .image-shape .label,#mermaid-svg-5O3uPf2DLqTlPI8Z .icon-shape .label{text-align:center;}#mermaid-svg-5O3uPf2DLqTlPI8Z .node.clickable{cursor:pointer;}#mermaid-svg-5O3uPf2DLqTlPI8Z .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5O3uPf2DLqTlPI8Z .arrowheadPath{fill:#333333;}#mermaid-svg-5O3uPf2DLqTlPI8Z .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5O3uPf2DLqTlPI8Z .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5O3uPf2DLqTlPI8Z .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5O3uPf2DLqTlPI8Z .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5O3uPf2DLqTlPI8Z .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5O3uPf2DLqTlPI8Z .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5O3uPf2DLqTlPI8Z .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5O3uPf2DLqTlPI8Z .cluster text{fill:#333;}#mermaid-svg-5O3uPf2DLqTlPI8Z .cluster span{color:#333;}#mermaid-svg-5O3uPf2DLqTlPI8Z div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5O3uPf2DLqTlPI8Z .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5O3uPf2DLqTlPI8Z rect.text{fill:none;stroke-width:0;}#mermaid-svg-5O3uPf2DLqTlPI8Z .icon-shape,#mermaid-svg-5O3uPf2DLqTlPI8Z .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5O3uPf2DLqTlPI8Z .icon-shape p,#mermaid-svg-5O3uPf2DLqTlPI8Z .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5O3uPf2DLqTlPI8Z .icon-shape .label rect,#mermaid-svg-5O3uPf2DLqTlPI8Z .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5O3uPf2DLqTlPI8Z .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5O3uPf2DLqTlPI8Z .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5O3uPf2DLqTlPI8Z :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 请求头 Event-Track-*
异步 feign
Vue 前端
切菜单 → 生成批次号
网关
EventTrackFilter
应用服务
埋点接收接口
业务服务
sys_event_track
统计接口
按功能 / 用户 / 部门
1.5 三种计数的口径(全文最关键的约定)
一条埋点记录用三个整型字段表达三种事件,统计时直接 SUM:
| 计数 | 落库字段 | 触发场景 | 前端上报频率 |
|---|---|---|---|
| 访问次数 | IS_VISIT_ = 1 |
切换到某菜单后的第一个业务请求 | 每次切菜单最多 1 次 |
| 使用次数 | IS_USE_ = 1 |
所有非登录的记录(含切菜单那一条) | 最多每 interval 秒 1 次 |
| 登录次数 | IS_LOGIN_ = 1 |
登录成功后的独立上报 | 每次登录 1 次(localStorage 去重) |
记法:"使用"是"操作了该功能"的总次数,包含首次进入的那一次 。因此
SUM(IS_USE_) ≥ SUM(IS_VISIT_),且凡IS_VISIT_=1的记录必然IS_USE_=1;只有登录记录是IS_USE_=0。
判断规则汇总:
| 场景 | 前端行为 | 请求头 | 落库结果 |
|---|---|---|---|
| 切菜单后首个请求 | 生成本次批次号 | Is-Visit: 1 |
IS_VISIT_=1, IS_USE_=1 |
停留同菜单,距上次超过 interval 秒 |
生成新批次号 | Is-Visit: 0 |
IS_VISIT_=0, IS_USE_=1 |
停留同菜单,距上次不足 interval 秒 |
不带埋点头 | --- | 不落库 |
| 同一批次被重复发送 | 照常发送 | 同批次号 | 后端 Redis 去重,只落库 1 条 |
| 登录成功 | 单独调埋点接口 | 无 | IS_LOGIN_=1, IS_USE_=0 |
| 请求头显式忽略 | 不带埋点头 | Event-Track-Ignore |
不落库 |
二、数据表设计
2.1 建表语句
sql
CREATE TABLE `sys_event_track` (
`ID_` VARCHAR(64) NOT NULL COMMENT '主键',
`MENU_ID_` VARCHAR(64) DEFAULT NULL COMMENT '菜单id(按功能统计维度)',
`MENU_NAME_` VARCHAR(500) DEFAULT NULL COMMENT '菜单名称,包含父级',
`IS_VISIT_` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否访问:0否 1是',
`IS_USE_` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否使用:0否 1是',
`IS_LOGIN_` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否登录:0否 1是',
`APP_ID_` VARCHAR(64) DEFAULT NULL COMMENT '应用id',
`CREATE_BY_NAME_` VARCHAR(255) DEFAULT NULL COMMENT '创建人名称(按用户统计维度)',
`CREATE_DEP_NAME_` VARCHAR(255) DEFAULT NULL COMMENT '创建部门名称(按部门统计维度)',
`MOBILE_` VARCHAR(32) DEFAULT NULL COMMENT '创建人手机号',
-- 以下为基类 BaseExtEntity 提供的公共字段(按实际项目增减)
`CREATE_BY_` VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
`CREATE_DEP_ID_` VARCHAR(64) DEFAULT NULL COMMENT '创建部门id',
`TENANT_ID_` VARCHAR(64) DEFAULT NULL COMMENT '租户id',
`CREATE_TIME_` DATETIME DEFAULT NULL COMMENT '创建时间',
`STATUS_` VARCHAR(32) DEFAULT 'actived' COMMENT '数据状态',
PRIMARY KEY (`ID_`),
KEY `idx_track_time` (`CREATE_TIME_`),
KEY `idx_track_menu` (`MENU_ID_`, `CREATE_TIME_`),
KEY `idx_track_user` (`CREATE_BY_`, `CREATE_TIME_`),
KEY `idx_track_dep` (`CREATE_DEP_ID_`, `CREATE_TIME_`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '埋点记录表';
索引按三个统计维度各建一个联合索引即可。
IS_VISIT_ / IS_USE_ / IS_LOGIN_用TINYINT是为了能直接SUM出来当次数用。部门维度同时存了 id 和名称:历史统计要看名称 (组织架构调整后仍能按当时的部门展示),要按新架构重新归集时再用
CREATE_DEP_ID_关联即可(实体里的isOldDep字段就是为这类"旧部门"场景预留的)。
2.2 实体类
java
package com.myproject.system.core.entity.track;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.myproject.common.base.entity.BaseExtEntity;
import lombok.Data;
/**
* 埋点记录表
*/
@Data
@TableName(value = "sys_event_track")
public class SysEventTrack extends BaseExtEntity<String> {
@JsonCreator
public SysEventTrack() {
}
/** 主键 */
@TableId(value = "ID_", type = IdType.INPUT)
private String id;
/** 菜单id(按功能统计维度) */
@TableField(value = "MENU_ID_")
private String menuId;
/** 菜单名称,包含父级的 */
@TableField(value = "MENU_NAME_")
private String menuName;
/** 是否访问:0否 1是 */
@TableField(value = "IS_VISIT_")
private Integer isVisit;
/** 是否使用:0否 1是 */
@TableField(value = "IS_USE_")
private Integer isUse;
/** 是否登录:0否 1是 */
@TableField(value = "IS_LOGIN_")
private Integer isLogin;
/** 应用id */
@TableField(value = "APP_ID_")
private String appId;
/** 创建部门名称(按部门统计维度) */
@TableField(value = "CREATE_DEP_NAME_")
private String createDepName;
/** 创建人名称(按用户统计维度) */
@TableField(value = "CREATE_BY_NAME_")
private String createByName;
/** 创建人手机号 */
@TableField(value = "MOBILE_")
private String mobile;
// ------------------- 以下为查询统计用的非持久化字段 -------------------
/** 应用编码(入参/出参用,不入库) */
@TableField(exist = false)
private String appCode;
/** 访问次数(统计结果) */
@TableField(exist = false)
private long visitNum;
/** 使用次数(统计结果) */
@TableField(exist = false)
private long useNum;
/** 登录次数(统计结果) */
@TableField(exist = false)
private long loginNum;
/** 是否为旧部门:1为旧部门 */
@TableField(exist = false)
private Integer isOldDep;
@Override
public String getPkId() {
return id;
}
@Override
public void setPkId(String pkId) {
this.id = pkId;
}
}
注意
visitNum / useNum / loginNum都是@TableField(exist = false):它们不是表字段,而是三个维度的聚合结果。前端拿到的列表直接展示这三个数字即可。
三、前端工程结构与配置
3.1 目录结构
前端为单一工程,公共代码统一放在 src/lib/,业务代码按 api / store / views 分层:
myproject-front/
├── .env # 环境变量
├── vue.config.js # 配置 @ 与 @lib 别名
├── package.json
└── src/
├── main.js # 应用入口 + window.myproject 适配层
├── permission.js # 路由守卫 + 登录次数上报
├── lib/ # 公共代码(axios 封装、埋点参数、通用组件等)
│ ├── assets/
│ │ ├── ajax.js # axios 封装(含埋点头注入)
│ │ ├── eventTrack.js # 埋点参数生成
│ │ └── util.js
│ └── components/ # 通用组件、提示框等
├── api/ # 业务接口
├── store/ # vuex
└── views/ # 页面
约定:
src/lib/内部文件之间用相对路径互相引用;- 业务代码一律通过
@lib/xxx引用公共代码,保持"公共能力 / 业务实现"的边界清晰; - 同类能力只保留一份实现(例如
ajax.js只有src/lib/assets/ajax.js一处),避免出现"部分接口不埋点"这类难查的问题。
3.2 配置路径别名
vue.config.js:
js
const path = require('path')
module.exports = {
configureWebpack: {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@lib': path.resolve(__dirname, 'src/lib') // 公共代码目录
}
}
}
}
@lib用于区分公共代码与业务代码:src/lib内部互相引用用相对路径,业务侧统一用@lib/xxx。
3.3 环境变量
bash
# 应用上下文
VUE_APP_API_BASE_URL=/api
VUE_APP_API_CTX_CODE=demo
VUE_APP_API_CTX_PATH=/appPortal
VUE_APP_API_CTX_TITLE=演示平台
# token 的 localStorage key(不配则默认为 Access-Token)
#VUE_APP_API_TOKEN_KEY=demo
# 是否已记录埋点登录次数的 localStorage key
VUE_APP_EVENT_TRACK_LOGIN_KEY=Event-Track-IsLogin
读取方式统一走 src/store/mutation-types.js:
js
export const ACCESS_TOKEN = process.env.VUE_APP_API_TOKEN_KEY
? process.env.VUE_APP_API_TOKEN_KEY + '-Access-Token'
: 'Access-Token';
/** 应用的访问编码 */
export const CODE = process.env.VUE_APP_API_CTX_CODE;
/** 跳转的登录页 url */
const path = process.env.VUE_APP_API_CTX_PATH;
export const PATH = path;
export const LOGIN_URL = path + '/login';
/**
* 是否已记录埋点的登录次数标记的 localStorage 的 key
* 用途:避免同一次登录重复上报登录次数
*/
export const EVENT_TRACK_LOGIN_KEY = process.env.VUE_APP_EVENT_TRACK_LOGIN_KEY;
3.4 应用适配层 window.myproject
工程内挂一个 window.myproject 适配层,公共代码(src/lib)通过它读取应用侧的配置与状态。这样做有两个好处:
- iframe 场景 :业务页面常被 iframe 嵌在其他系统里,此时菜单 id 存在于父窗口 。
ajax.js里的getTarget()会优先读window.parent.myproject,没有这层就拿不到父窗口的菜单 id,埋点会记错菜单; - 解耦:公共代码不直接依赖应用的 vuex 结构,边界更清晰。
main.js 中的适配层:
js
import store from './store/'
import {CODE, ACCESS_TOKEN, LOGIN_URL} from '@/store/mutation-types'
// 应用适配层:公共代码通过 window.myproject 读取应用侧配置
window.myproject = {
// 获取应用code,主要用于 axios 设置 AppCode 请求头
getAppCode: function () {
return CODE
},
// 获取 token 的 key,供公共代码读取应用端设置的 token key
getAccessTokenKey() {
return ACCESS_TOKEN
},
// 获取 menuId,用于 axios 设置 menuId 请求头,控制数据权限
getMenuId: function () {
if (store.state.appSetting.selectMenu) {
return store.state.appSetting.selectMenu.id
}
return null
},
// 登录地址:axios 发现登录过期后跳转
getLoginUrl: function () {
return LOGIN_URL
},
// 点击遮罩是否关闭弹窗
getShadeClose: function () {
return true
},
// 获取埋点配置(开关 + 上报间隔)
getEventTrackConfig: function () {
return {
interval: store.state.appSetting.appConfig ? store.state.appSetting.appConfig.eventTrackInterval : 5,
status: store.state.appSetting.appConfig ? store.state.appSetting.appConfig.eventTrackStatus : 0
}
}
}
3.5 应用配置(开关与上报间隔)
应用配置存放在 vuex:
js
// src/store/modules/appSetting.js
state: {
appConfig: { // 应用配置
eventTrackStatus: 0, // 是否开启埋点(0-否,1-是)
eventTrackInterval: 5, // 埋点请求间隔(秒)
waterMarkerStatus: 0, // 是否开启水印(0-否,1-是)
maxCacheNum: 20 // 页签最大缓存数量
}
},
mutations: {
setAppConfig: (state, appConfig) => {
state.appConfig = {
eventTrackStatus: appConfig.eventTrackStatus,
eventTrackInterval: appConfig.eventTrackInterval,
waterMarkerStatus: appConfig.waterMarkerStatus,
maxCacheNum: 20
};
}
}
appConfig 由登录后获取应用信息的接口下发,开关放在服务端,这样不用发版就能开/关埋点、调整上报频率。
3.6 前端校验清单
| # | 校验项 | 期望结果 |
|---|---|---|
| 1 | 全局搜索 ajax.js / eventTrack.js |
各只有一份(src/lib/assets/ 下),业务侧统一 @lib/assets/xxx 引用 |
| 2 | 前端请求头常量与后端常量对照 | 前缀 Event-Track 及 5 个子键逐字一致 |
| 3 | 启动应用,切菜单后看任意业务请求 | 请求头带 Event-Track-Batch-No / Menu-Id / Is-Visit / Client-Id |
| 4 | 同一菜单内连续点两次查询(间隔 < interval) |
第二次请求不带埋点头 |
| 5 | 等超过 interval 秒再操作 |
重新带上埋点头,且 Batch-No 变化 |
| 6 | 应用被 iframe 嵌入时切菜单 | Menu-Id 为父窗口当前菜单,不是子页面自己的 |
| 7 | 调用 isNeedChangePwd 这类接口 |
请求头带 Event-Track-Ignore,不产生埋点 |
四、前端埋点实现
4.1 埋点参数生成 eventTrack.js
这是整个前端的核心:决定"这个请求要不要带埋点头、带什么值"。
js
var EventTrack = {};
/**
* 埋点请求参数保存在 storage 中的 key,也是请求头参数的前缀。
* 注意:一旦确定不要修改,必须与后端常量保持一致
* @type {string}
*/
const eventTrackKey = "Event-Track"
// 以下是请求头参数 key(必须与后端一致)
const menuIdKey = eventTrackKey + '-Menu-Id' // 菜单id
const menuNameKey = eventTrackKey + '-Menu-Name' // 菜单名称
const batchNoKey = eventTrackKey + '-Batch-No' // 批次号
const isVisitKey = eventTrackKey + '-Is-Visit' // 是否算访问次数
const clientIdKey = eventTrackKey + '-Client-Id' // 客户端标识
/**
* 生成批次号。
* 未到时间间隔则返回空字符串,表示"这个请求不需要上报埋点"。
* @param oldBatchNo 上一个批次号,不传则一定会生成批次号
* @param interval 批次号的时间间隔配置(秒)
* @returns {number|string}
*/
function genBatchNo(oldBatchNo, interval) {
let batchNo = Math.floor(Date.now() / 1000)
if (!oldBatchNo || batchNo - oldBatchNo > interval) {
return batchNo
}
return ""
}
/**
* 生成客户端标识。
* 客户端id + 批次号 作为唯一标识,供后端做同批次去重。
*/
function genClientId() {
var len = 16, radix = 32;
var chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
var uuid = [], i;
radix = radix || chars.length;
if (len) {
for (i = 0; i < len; i++) uuid[i] = chars[0 | Math.random() * radix];
} else {
var r;
uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-';
uuid[14] = '4';
for (i = 0; i < 36; i++) {
if (!uuid[i]) {
r = 0 | Math.random() * 16;
uuid[i] = chars[(i == 19) ? (r & 0x3) | 0x8 : r];
}
}
}
return uuid.join('');
}
/**
* 是否切换了菜单
*/
function isChangeMenu(oldEventTrackParam, menuId, menuName) {
if (menuId != oldEventTrackParam[menuIdKey]) {
return true
}
if (menuName != oldEventTrackParam[menuNameKey]) {
return true
}
return false;
}
/**
* 埋点参数保存在 storage 的 key。
* 按 appCode 隔离,避免同一浏览器打开多个应用时互相干扰。
*/
function getStorageKey(appCode) {
return appCode ? appCode + "_" + eventTrackKey : eventTrackKey
}
/**
* 清除 storage 中的埋点参数(应用启动时调用一次)
*/
EventTrack.clearEventTrackParam = function (appCode) {
let storageKey = getStorageKey(appCode)
sessionStorage.removeItem(storageKey)
}
/**
* 生成埋点请求头参数。
* @param menuId 菜单id
* @param menuName 菜单名称
* @param appCode 应用编码
* @param interval 上报间隔(秒)
* @returns {object|null} null 表示本次请求不需要上报埋点
*/
EventTrack.genEventTrackParams = function (menuId, menuName, appCode, interval) {
// 既没有菜单id也没有菜单名,无法定位功能,直接不上报
if (!menuId && !menuName) return null;
let storageKey = getStorageKey(appCode)
let eventTrackParamStr = sessionStorage.getItem(storageKey)
let eventTrackParam = eventTrackParamStr ? JSON.parse(eventTrackParamStr) : {}
// 客户端标识(同一标签页保持稳定)
if (!eventTrackParam[clientIdKey]) {
eventTrackParam[clientIdKey] = genClientId();
}
if (isChangeMenu(eventTrackParam, menuId, menuName)) {
// ---------- 场景一:切换了菜单 → 算一次「访问」 ----------
eventTrackParam[menuIdKey] = menuId
eventTrackParam[isVisitKey] = 1 // 算访问
eventTrackParam[batchNoKey] = genBatchNo() // 不传旧值 → 必定生成新批次号
if (menuName) {
eventTrackParam[menuNameKey] = menuName
} else {
delete eventTrackParam[menuNameKey]
}
} else {
// ---------- 场景二:未切换菜单 → 满足间隔才再计一次「使用」 ----------
let batchNo = genBatchNo(eventTrackParam[batchNoKey], interval)
// 批次号为空说明还在同一个时间窗口内,本次请求不上报埋点
if (!batchNo) return null
eventTrackParam[isVisitKey] = 0 // 不算访问(后端仍记为使用)
eventTrackParam[batchNoKey] = batchNo
}
// 回写 storage,用于下次判断菜单是否切换、是否同一批次
sessionStorage.setItem(storageKey, JSON.stringify(eventTrackParam))
return eventTrackParam
}
export default EventTrack;
为什么这样能省开销 :假设用户停在一个菜单里点了 100 次查询,interval = 5 秒:
- 传统埋点:100 条记录;
- 本方案:只有"首次访问 1 条 + 每 5 秒最多 1 条使用记录",实际可能只有几条。
4.2 请求头自动注入 ajax.js
在 axios 请求拦截器里统一注入,业务代码零改动:
js
import Vue from 'vue'
import axios from 'axios'
import Util from "./util"
import EventTrack from "./eventTrack"
let baseURL = "/api";
/**
* 获取 target,用于读取应用端的配置。
* 优先取 window.parent.myproject ------ 业务页面被 iframe 嵌入时,
* 菜单、应用编码等配置在父窗口(即主框架)里。
*/
function getTarget() {
let target = window
try { // 加减异常处理,避免 iframe 非同源时报错
target = window.parent && window.parent.myproject ? window.parent : window
} catch (e) {
target = window
}
return target
}
// 获取 menuId(数据权限需要,同时也作为埋点的功能标识)
const getMenuId = function () {
let target = getTarget();
if (target.myproject && target.myproject.getMenuId) {
return target.myproject.getMenuId()
}
return null
}
function getAjax(conf) {
if (conf && conf.baseUrl) {
baseURL = conf.baseUrl;
}
// 应用端可覆盖基础路径
let target = getTarget();
if (target.myproject && target.myproject.getBaseUrl) {
baseURL = target.myproject.getBaseUrl()
}
const $ajax = axios.create({
baseURL: baseURL,
timeout: (conf && conf.timeout) || 600000
});
// ---------------- 请求拦截器:统一注入公共请求头 ----------------
$ajax.interceptors.request.use(config => {
if (window.myproject) {
// 1) 应用编码
if (window.myproject.getAppCode) {
let code = window.myproject.getAppCode()
if (code) {
config.headers['AppCode'] = code;
}
}
// 2) menuId:数据权限使用
if (!config.headers['menuId']) {
let menuId = getMenuId()
if (menuId) {
config.headers['menuId'] = menuId;
}
}
// 3) 埋点参数:开启埋点且接口未显式忽略时才注入
if (window.myproject.getEventTrackConfig) {
let eventTrackConfig = window.myproject.getEventTrackConfig()
if (eventTrackConfig.status == '1' && !config.headers['Event-Track-Ignore']) {
let eventTrackParams = EventTrack.genEventTrackParams(
config.headers['menuId'],
config.headers['Event-Track-Menu-Name'],
window.myproject.getAppCode(),
eventTrackConfig.interval || 5
);
// 返回 null 表示本次不上报(同批次内),因此不会添加任何埋点头
if (eventTrackParams) {
Object.assign(config.headers, eventTrackParams);
}
}
}
// 4) 应用端可指定忽略埋点
if (config.headers['Event-Track-Ignore']) {
delete config.headers['Event-Track-Ignore'] // 该头只在前端判断用,不传给后端
}
}
// 不需要 token 的请求
if (config.headers && !config.headers.needToken_) {
return config;
}
var token = Vue.ls.get(Util.getAccessTokenKey());
if (token) {
config.headers['Authorization'] = 'Bearer ' + token;
}
return config
}, errorHandler);
return $ajax
}
关键点:
genEventTrackParams返回null时,一个埋点头都不加,请求体积和普通请求完全一样。这就是"简化"的落点。
4.3 两种"忽略埋点"的方式
| 方式 | 使用场景 | 实现 |
|---|---|---|
前端请求头 Event-Track-Ignore: true |
个别接口不想统计(如"是否需要修改密码"这类轮询/前置校验接口) | 调用方在 config 里指定 |
| 后端忽略清单 | 整类 url 不统计(如登录、字典、上传下载) | 网关 SecurityProperties.Ignore 配置 |
前端用法示例:
js
// 该接口忽略埋点统计
DemoApi.isNeedChangePwd = function () {
var url = DemoApi.baseUrl + '/isNeedChangePwd';
let config = {
hideErrMsg: true, // 隐藏错误提示
headers: {'Event-Track-Ignore': true} // 忽略埋点统计
}
return rxAjax.get(url, null, config)
}
4.4 登录次数上报 permission.js
登录次数不在业务请求里统计,而是在路由守卫 里单独上报一次,并用 localStorage 去重:
js
import Vue from 'vue'
import router from './router'
import EventTrackApi from "@/api/demo/track/eventTrack";
import {ACCESS_TOKEN, LOGIN_URL, CODE, EVENT_TRACK_LOGIN_KEY} from '@/store/mutation-types';
// 白名单
const whiteList = ['login', 'yzyLogin', 'casLogin'];
router.beforeEach((to, from, next) => {
if (Vue.ls.get(ACCESS_TOKEN)) {
// ---------------- 记录埋点的登录次数 ----------------
let eventTrackConfig = window.myproject.getEventTrackConfig()
// 三个条件:配了 key、开启了埋点、本次登录还没记录过
if (EVENT_TRACK_LOGIN_KEY
&& eventTrackConfig.status == '1'
&& localStorage.getItem(EVENT_TRACK_LOGIN_KEY) != 1) {
EventTrackApi.save({
"menuName": "登录",
"isLogin": 1,
"appCode": CODE // 不传也可以,传了可以区分是哪个应用登录的
})
localStorage.setItem(EVENT_TRACK_LOGIN_KEY, "1")
}
// ... 后续为原有的路由/菜单加载逻辑
} else {
// 没有 token 则清除登录标记,保证下次登录会重新上报
localStorage.removeItem(EVENT_TRACK_LOGIN_KEY)
// ...
}
})
要点:
- 标记写在
localStorage,退出登录时删除,所以"一次登录 = 一次登录次数";刷新页面不会重复计数; - 上报接口自身要忽略埋点(见下),否则会产生"访问"记录。
4.5 埋点上报接口 api/demo/track/eventTrack.js
说明:下文接口路径中的网关路由前缀 与服务内模块路径 均为示例(统一抽象为
api-demo/demo),落地时按实际网关路由与服务路径替换。
js
import rxAjax from '@lib/assets/ajax';
export const EventTrackApi = {};
EventTrackApi.baseUrl = '/api-demo/demo/track/sysEventTrack';
/**
* 保存埋点记录(登录次数)
* 注意:该接口自身必须忽略埋点统计,否则会额外产生一条访问记录
*/
EventTrackApi.save = function (parameter) {
var url = EventTrackApi.baseUrl + '/save';
let config = {
hideSuccessMsg: true,
headers: {'Event-Track-Ignore': true} // 忽略埋点统计
}
return rxAjax.postJson(url, parameter, config);
}
export default EventTrackApi;
4.6 应用启动时清理埋点缓存
main.js:
js
import {CODE} from '@/store/mutation-types';
import EventTrack from '@lib/assets/eventTrack';
// 清除埋点参数:避免上次会话残留的批次号/菜单id 影响本次埋点
EventTrack.clearEventTrackParam(CODE)
五、后端埋点实现
5.1 网关全局过滤器 EventTrackFilter(核心)
放在网关的原因:统一入口、可异步、能拿到原始 token。
java
package com.myproject.gateway.filter;
import com.alibaba.fastjson.JSONObject;
import com.myproject.cache.CacheUtil;
import com.myproject.common.constant.SecurityConstants;
import com.myproject.common.tool.StringUtils;
import com.myproject.gateway.feign.SystemClient;
import com.myproject.oauth2.common.properties.SecurityProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* 埋点过滤器:记录功能访问量、使用量、登录量
*/
@Slf4j
@Component
public class EventTrackFilter implements GlobalFilter, Ordered {
/** 埋点请求头前缀,必须与前端 eventTrack.js 中的 eventTrackKey 一致 */
private static final String TRACK_KEY = "Event-Track";
private static final String TRACK_MENU_ID_KEY = TRACK_KEY + "-Menu-Id";
private static final String TRACK_MENU_NAME_KEY = TRACK_KEY + "-Menu-Name";
private static final String TRACK_IS_VISIT_KEY = TRACK_KEY + "-Is-Visit";
private static final String TRACK_BATCH_NO_KEY = TRACK_KEY + "-Batch-No";
private static final String TRACK_BATCH_CLIENT_ID_KEY = TRACK_KEY + "-Client-Id";
@Autowired
private SecurityProperties securityProperties;
@Autowired
private TaskExecutor taskExecutor;
@Autowired
private SystemClient systemClient;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest serverHttpRequest = exchange.getRequest();
// 1) 没有批次号 → 前端判定本次不需要埋点,直接放行(绝大多数请求走这里)
String batchNo = serverHttpRequest.getHeaders().getFirst(TRACK_BATCH_NO_KEY);
if (StringUtils.isEmpty(batchNo)) {
return chain.filter(exchange);
}
// 2) 忽略清单内的地址不统计
String reqUrl = serverHttpRequest.getPath().value();
if (securityProperties.getIgnore().containsTrackUrl(reqUrl)) {
return chain.filter(exchange);
}
// 3) 客户端标识 + 批次号作为唯一标识,同批次只记录一次
String clientId = serverHttpRequest.getHeaders().getFirst(TRACK_BATCH_CLIENT_ID_KEY);
String trackId = genTrackId(clientId, batchNo);
Object lastTrackId = CacheUtil.get(TRACK_KEY, clientId);
if (trackId.equals(lastTrackId)) {
return chain.filter(exchange);
}
CacheUtil.set(TRACK_KEY, clientId, trackId);
// 4) 组装埋点数据:用户信息不由前端传,服务端从 token 解析
String menuId = serverHttpRequest.getHeaders().getFirst(TRACK_MENU_ID_KEY);
String menuName = serverHttpRequest.getHeaders().getFirst(TRACK_MENU_NAME_KEY);
String isVisit = serverHttpRequest.getHeaders().getFirst(TRACK_IS_VISIT_KEY);
JSONObject ent = new JSONObject();
ent.put("menuId", menuId);
ent.put("menuName", menuName);
ent.put("isVisit", isVisit);
ent.put("appCode", serverHttpRequest.getHeaders().getFirst("AppCode"));
// 5) 异步上报,不阻塞业务请求
taskExecutor.execute(() -> {
try {
systemClient.saveEventTrack(ent,
serverHttpRequest.getHeaders().getFirst(SecurityConstants.Authorization));
} catch (Exception e) {
// 埋点失败绝不能影响业务,只记日志
log.error("saveEventTrack error", e);
}
});
return chain.filter(exchange);
}
private String genTrackId(String clientId, String batchNo) {
return clientId + "_" + batchNo;
}
@Override
public int getOrder() {
// 排在认证过滤器之后,保证能拿到用户上下文;排在业务转发之前
return -91;
}
}
三个设计要点:
| 要点 | 说明 |
|---|---|
| 快速短路 | 没有 Batch-No 的请求在第一行就 return,对绝大多数请求零开销 |
| Redis 去重 | clientId + batchNo 作为唯一键;多标签页、重试、并发同批次只会落库一次 |
| 异步上报 | taskExecutor.execute + feign,业务请求不受埋点影响;异常被吞掉只记日志 |
5.2 feign 客户端
java
/**
* 保存埋点记录
* 注意:透传 Authorization,服务端才能解析出当前用户与部门
*/
@PostMapping("/demo/track/sysEventTrack/save")
JsonResult saveEventTrack(@RequestBody JSONObject ent,
@RequestHeader(SecurityConstants.Authorization) String authorization);
也可以像日志模块那样走 MQ(Spring Cloud Stream)异步落库,把"埋点写入"和"业务服务"彻底解耦。本方案用 feign + 线程池已经足够,量级大时再换 MQ。
5.3 忽略清单配置
yaml
# 网关安全配置(示意,键名以实际 SecurityProperties 为准)
myproject:
security:
ignore:
# 这些地址不做埋点统计
track-urls:
- /demo/track/sysEventTrack/**
- /demo/file/**
- /demo/login
- /demo/logout
六、关键流程
6.1 一次带埋点的请求
sys_event_track 应用服务 Redis 网关 EventTrackFilter 前端(axios) 用户 sys_event_track 应用服务 Redis 网关 EventTrackFilter 前端(axios) 用户 #mermaid-svg-3b2GeDU1KWypeGOp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3b2GeDU1KWypeGOp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3b2GeDU1KWypeGOp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3b2GeDU1KWypeGOp .error-icon{fill:#552222;}#mermaid-svg-3b2GeDU1KWypeGOp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3b2GeDU1KWypeGOp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3b2GeDU1KWypeGOp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3b2GeDU1KWypeGOp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3b2GeDU1KWypeGOp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3b2GeDU1KWypeGOp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3b2GeDU1KWypeGOp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3b2GeDU1KWypeGOp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3b2GeDU1KWypeGOp .marker.cross{stroke:#333333;}#mermaid-svg-3b2GeDU1KWypeGOp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3b2GeDU1KWypeGOp p{margin:0;}#mermaid-svg-3b2GeDU1KWypeGOp .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3b2GeDU1KWypeGOp text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3b2GeDU1KWypeGOp .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3b2GeDU1KWypeGOp .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3b2GeDU1KWypeGOp .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3b2GeDU1KWypeGOp .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3b2GeDU1KWypeGOp #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3b2GeDU1KWypeGOp .sequenceNumber{fill:white;}#mermaid-svg-3b2GeDU1KWypeGOp #sequencenumber{fill:#333;}#mermaid-svg-3b2GeDU1KWypeGOp #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3b2GeDU1KWypeGOp .messageText{fill:#333;stroke:none;}#mermaid-svg-3b2GeDU1KWypeGOp .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3b2GeDU1KWypeGOp .labelText,#mermaid-svg-3b2GeDU1KWypeGOp .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3b2GeDU1KWypeGOp .loopText,#mermaid-svg-3b2GeDU1KWypeGOp .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3b2GeDU1KWypeGOp .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3b2GeDU1KWypeGOp .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3b2GeDU1KWypeGOp .noteText,#mermaid-svg-3b2GeDU1KWypeGOp .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3b2GeDU1KWypeGOp .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3b2GeDU1KWypeGOp .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3b2GeDU1KWypeGOp .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3b2GeDU1KWypeGOp .actorPopupMenu{position:absolute;}#mermaid-svg-3b2GeDU1KWypeGOp .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-3b2GeDU1KWypeGOp .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3b2GeDU1KWypeGOp .actor-man circle,#mermaid-svg-3b2GeDU1KWypeGOp line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3b2GeDU1KWypeGOp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt同批次已记录首次 切换菜单1genEventTrackParams()切换菜单 → 新批次号, isVisit=12发起业务请求3注入 Event-Track-* 请求头4业务请求5读取 Batch-No(为空则直接放行)6get/set trackId = clientId_batchNo7跳过埋点8异步 feign saveEventTrack()9从 token 解析用户/部门10insert(isVisit=1 / isUse=1)11转发业务请求12业务响应13
6.2 时间轴示例(interval = 5 秒)
| 时刻 | 用户动作 | 前端 | 落库(IS_VISIT_ / IS_USE_) |
累计 |
|---|---|---|---|---|
| 10:00:00 | 进入「合同管理」菜单 | 新批次号 A,isVisit=1 |
1 / 1 |
访问 1、使用 1 |
| 10:00:02 | 点查询 | 批次 A 未过期 → 不带埋点头 | --- | --- |
| 10:00:04 | 再点查询 | 不带埋点头 | --- | --- |
| 10:00:07 | 点查询 | 距批次 A 超过 5s → 批次 B,isVisit=0 |
0 / 1 |
使用 2 |
| 10:00:09 | 点查询 | 不带埋点头 | --- | --- |
| 10:01:00 | 切到「客户管理」 | 新批次号 C,isVisit=1 |
1 / 1 |
访问 2、使用 3 |
结果:1 分钟的活跃操作只产生 3 条 记录(传统埋点会是几十条),统计出来是 访问 2 次、使用 3 次------使用次数把首次进入那次也算在内,所以比访问次数多。
6.3 登录次数的流程
sys_event_track 应用服务 前端路由守卫 用户 sys_event_track 应用服务 前端路由守卫 用户 #mermaid-svg-RE0Ss6EqymUKL3pf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RE0Ss6EqymUKL3pf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RE0Ss6EqymUKL3pf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RE0Ss6EqymUKL3pf .error-icon{fill:#552222;}#mermaid-svg-RE0Ss6EqymUKL3pf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RE0Ss6EqymUKL3pf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RE0Ss6EqymUKL3pf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RE0Ss6EqymUKL3pf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RE0Ss6EqymUKL3pf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RE0Ss6EqymUKL3pf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RE0Ss6EqymUKL3pf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RE0Ss6EqymUKL3pf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RE0Ss6EqymUKL3pf .marker.cross{stroke:#333333;}#mermaid-svg-RE0Ss6EqymUKL3pf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RE0Ss6EqymUKL3pf p{margin:0;}#mermaid-svg-RE0Ss6EqymUKL3pf .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RE0Ss6EqymUKL3pf text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-RE0Ss6EqymUKL3pf .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-RE0Ss6EqymUKL3pf .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-RE0Ss6EqymUKL3pf .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-RE0Ss6EqymUKL3pf .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-RE0Ss6EqymUKL3pf #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-RE0Ss6EqymUKL3pf .sequenceNumber{fill:white;}#mermaid-svg-RE0Ss6EqymUKL3pf #sequencenumber{fill:#333;}#mermaid-svg-RE0Ss6EqymUKL3pf #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-RE0Ss6EqymUKL3pf .messageText{fill:#333;stroke:none;}#mermaid-svg-RE0Ss6EqymUKL3pf .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RE0Ss6EqymUKL3pf .labelText,#mermaid-svg-RE0Ss6EqymUKL3pf .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-RE0Ss6EqymUKL3pf .loopText,#mermaid-svg-RE0Ss6EqymUKL3pf .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-RE0Ss6EqymUKL3pf .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-RE0Ss6EqymUKL3pf .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-RE0Ss6EqymUKL3pf .noteText,#mermaid-svg-RE0Ss6EqymUKL3pf .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-RE0Ss6EqymUKL3pf .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RE0Ss6EqymUKL3pf .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RE0Ss6EqymUKL3pf .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RE0Ss6EqymUKL3pf .actorPopupMenu{position:absolute;}#mermaid-svg-RE0Ss6EqymUKL3pf .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-RE0Ss6EqymUKL3pf .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RE0Ss6EqymUKL3pf .actor-man circle,#mermaid-svg-RE0Ss6EqymUKL3pf line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-RE0Ss6EqymUKL3pf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt本次登录未上报过 且 埋点已开启已上报过 登录成功,进入系统1读 localStorageEVENT_TRACK_LOGIN_KEY2save({menuName:"登录", isLogin:1, appCode})3insert(isLogin=1)4localStorage.setItem(key, "1")5跳过(刷新页面不会重复计数)6退出登录7localStorage.removeItem(key) ← 下次登录重新计数8
七、注意事项与调优
7.1 必须保证一致的约定
| # | 约定 | 风险 |
|---|---|---|
| 1 | 请求头前缀 Event-Track 及 5 个子键,前端常量与后端常量必须逐字一致 |
不一致时埋点静默失效,且不报错,很难查 |
| 2 | isVisit 语义:1 = 这次是"进入功能",0 = 仅"继续使用";而后端对所有非登录记录 都置 IS_USE_=1 |
isVisit 传反会让"访问次数"失真(使用次数不受影响,它恒 ≥ 访问次数) |
| 3 | 统计口径以"计数位求和"为准(SUM(IS_VISIT_)、SUM(IS_USE_)) |
不要用 COUNT(*) 当访问次数(会把两类记录一起算进去) |
| 4 | 用户/部门信息只由服务端从 token 解析 | 前端传用户信息既有伪造风险,也增加改造量 |
建议把 header 名在前后端各集中定义一处(前端
eventTrack.js顶部常量、后端EventTrackFilter顶部常量),禁止散落在业务代码里。
7.2 常见问题排查
| 现象 | 排查方向 |
|---|---|
| 埋点完全没有数据 | ① 应用配置 eventTrackStatus 是否 = 1;② 请求头是否已注入;③ 网关过滤器 order 是否被其他过滤器短路;④ 忽略清单是否把该 url 配进去了 |
| 使用次数小于访问次数 | 不符合口径(应恒 ≥ 访问次数)。排查是否有记录绕过了 beforeSave 钩子:旧版本写入的历史数据、手工导入的数据、直连 mapper 的写入 |
| 使用次数只有访问次数那么多 | interval 配置过大(比如 3600),用户停留期间没触发新的批次 |
| 同一菜单重复记多条访问 | 前端 menuName 不稳定(如带上了动态标题),导致 isChangeMenu 判为切换 |
| iframe 嵌入后菜单记错 | getTarget() 没读到 window.parent.myproject(父窗口未挂载适配层或跨域) |
| 登录次数偏多 | localStorage 标记的 key(VUE_APP_EVENT_TRACK_LOGIN_KEY)未配,或退出登录时没有清理/未清理干净 |
| 埋点表增长过快 | 调大 interval;或按时间分区/定期归档(该表只用于统计,历史明细可定期清理) |
7.3 性能与容量
| 项 | 表现 | 说明 |
|---|---|---|
| 对业务请求的影响 | 极小 | 无批次头的请求在网关第一行即返回;同批次请求零额外开销 |
| 落库频率 | 最多每 interval 秒 / 客户端 1 条 |
1 万活跃用户、interval=60 时约 1 万条/分钟上限,实际远低于此 |
| 存储 | 单行很小(十几个字段,其中 8 个是 id/时间) | 按 interval + 归档策略控制,无需分库分表 |
7.4 可选增强
- 异步化升级 :埋点数据量大时,把 §5.1 的
taskExecutor + feign换成 Spring Cloud Stream(RocketMQ)异步落库,与业务服务解耦; - 写入削峰 :网关侧先聚合再批量发送(同一
clientId的多个批次合并),可进一步降低写入次数; - "使用"打点更精确 :若需要精确统计"点了保存/提交"这类动作,可在需要统计的 Controller 方法上加一个自定义注解(如
@TrackUse),由 AOP 在方法成功后写入IS_USE_=1,与当前"只要有埋点记录即算使用"的口径互补; - 保留期策略:埋点明细建议保留 6~12 个月,超期归档到统计结果表(按天预聚合),统计查询会更快。
八、落地清单
| 步骤 | 内容 | 产出 |
|---|---|---|
| 1 | 建表 sys_event_track + 实体类 |
§2.1、§2.2 |
| 2 | 前端工程结构与配置(别名、环境变量、适配层、应用配置) | §3.1 ~ §3.6 |
| 3 | 前端埋点:eventTrack.js + ajax.js 注入 + 登录上报 |
§4.1、§4.2、§4.4 |
| 4 | 网关过滤器 + feign + 忽略清单 | §5.1 ~ §5.3 |
| 5 | 应用配置下发(eventTrackStatus / eventTrackInterval) |
§3.5 |
| 6 | 按 §3.6 校验清单逐项验证 | --- |
九、落地效果
