【低成本埋点统计】Spring Boot+vue2前后端分离项目统计系统访问、使用、登录次数

低成本埋点统计:按「功能 / 用户 / 部门」统计访问、使用、登录次数

技术栈: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 步)

复制代码
① 前端切菜单  →  ② 请求头带埋点参数  →  ③ 网关拦截、异步上报  →  ④ 服务端落库,按维度聚合
  1. 前端记录菜单 :切换菜单时把当前 menuId、menuName 记到 sessionStorage,并生成一个批次号;
  2. 请求头注入 :后续每个请求由 axios 拦截器判断"要不要带埋点头",需要时自动追加 Event-Track-* 一组头;
  3. 后端拦截 :网关全局过滤器读埋点头,先做一次批次去重,然后异步 feign 调用埋点接口(不阻塞业务);
  4. 落库 + 聚合 :服务端从 token 解析出用户 id / 部门 / 手机号,写一条记录;统计时按 菜单 / 用户 / 部门 三个维度 SUM 即可。

1.3 关键设计:为什么叫"简化"埋点

传统埋点是"每个操作都上报一条",写入量大、还要单独的埋点 SDK。本方案做了三处精简:

精简点 做法 效果
按批次上报 同一菜单内,每隔 interval 秒才产生一个新批次号;批次号不变时请求不带任何埋点头 埋点请求量从"次数级"降到"分钟级",最多每 interval 秒 1 条
用户信息不从前端传 用户 id / 部门 / 手机号全部由服务端从 token 解析 前端零改造成本、数据不可伪造
复用现有管道 复用网关全局过滤器 + feign + 现有用户上下文 不需要引入埋点 SDK、不需要新增中间件

去重是双保险:

  1. 前端:批次号未变化 → 不带埋点头(省掉 90%+ 的无效上报);
  2. 后端: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)通过它读取应用侧的配置与状态。这样做有两个好处:

  1. iframe 场景 :业务页面常被 iframe 嵌在其他系统里,此时菜单 id 存在于父窗口 。ajax.js 里的 getTarget() 会优先读 window.parent.myproject,没有这层就拿不到父窗口的菜单 id,埋点会记错菜单;
  2. 解耦:公共代码不直接依赖应用的 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 可选增强

  1. 异步化升级 :埋点数据量大时,把 §5.1 的 taskExecutor + feign 换成 Spring Cloud Stream(RocketMQ)异步落库,与业务服务解耦;
  2. 写入削峰 :网关侧先聚合再批量发送(同一 clientId 的多个批次合并),可进一步降低写入次数;
  3. "使用"打点更精确 :若需要精确统计"点了保存/提交"这类动作,可在需要统计的 Controller 方法上加一个自定义注解(如 @TrackUse),由 AOP 在方法成功后写入 IS_USE_=1,与当前"只要有埋点记录即算使用"的口径互补;
  4. 保留期策略:埋点明细建议保留 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 校验清单逐项验证 ---

九、落地效果

相关推荐
codigger1 小时前
Java 26 深度解析:一次“性能兑现 + 契约收紧”的版本
java·后端·编程·编程语言
高级程序源1 小时前
springboot荆州旅游推介平台24175-计算机课程设计、毕业设计
vue.js·spring boot·python·mysql·django·课程设计
盖世英雄酱581361 小时前
格式化成日期为什么提前一天呢,同样的时间点还是非必现???
java·后端
威哥爱编程1 小时前
【AI全栈12-05】Spring Boot 用 Tool Calling 让 AI 查实时订单与库存
java·spring boot·后端
用户298698530141 小时前
Python 实战:将 HTML 内容保存为图像文件
后端·python·html
挖掘狂人1 小时前
Java 26 深度解析:一次“性能兑现 + 契约收紧”的版本
javascript·后端·性能优化
威哥爱编程1 小时前
【AI全栈12-06】Spring Boot 用 RAG 搭企业私有知识库问答
java·spring boot·后端
小蒜学长1 小时前
基于微信小程序的医院医疗废弃物追溯管理系统(代码+数据库+LW)
java·spring boot·后端·微信小程序·医疗废弃物
掘金者阿豪2 小时前
SQL 编辑器技术实践:从写 SQL 到数据库治理,聊聊电科金仓 KSQL Developer 的实际价值
后端