mirror of
http://git.pig4cloud.com/pig/pigx.git
synced 2026-11-08 04:39:00 +08:00
feat(upms): support umami analytics
This commit is contained in:
1 parent
83ee6144b3
commit
5931a5d6d7
14 files changed
+687
-180
No files matched your search
+22
-27
@@ -341,6 +341,7 @@ INSERT INTO `sys_dict_item` VALUES (95, 29, '0', '黑名单', 'sensitive_type',
|
||||
INSERT INTO `sys_dict_item` VALUES (96, 29, '1', '白名单', 'sensitive_type', '失败', NULL, 3, 'admin', ' ', '2023-10-27 10:30:47', NULL, NULL, '0', 1);
|
||||
INSERT INTO `sys_dict_item` VALUES (97,2,'MINI','小程序登录','social_type','小程序登录',NULL,3,' ',' ','2022-02-18 13:56:25','2022-02-18 13:56:28',NULL,'0',1);
|
||||
INSERT INTO `sys_dict_item` VALUES (98,2,'clarity','clarity','social_type','clarity',NULL,3,' ',' ','2022-02-18 13:56:25','2022-02-18 13:56:28',NULL,'0',1);
|
||||
INSERT INTO `sys_dict_item` VALUES (99,2,'umami','umami','social_type','umami',NULL,4,' ',' ','2026-06-23 00:00:00','2026-06-23 00:00:00',NULL,'0',1);
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
@@ -923,6 +924,9 @@ INSERT INTO `sys_public_param` VALUES (26, '隐私提示', 'SITE_PRIVACY_TIP', '
|
||||
INSERT INTO `sys_public_param` VALUES (27, '密码过期天数', 'PASSWORD_EXPIRE_DAYS', '90', '0', NULL, 'admin', 'admin', '2026-03-28 00:00:00', NULL, '2', '0', '0', 1);
|
||||
INSERT INTO `sys_public_param` VALUES (28, '登录失败锁定次数', 'LOGIN_ERROR_TIMES', '5', '0', NULL, 'admin', 'admin', '2026-03-28 00:00:00', NULL, '2', '0', '0', 1);
|
||||
INSERT INTO `sys_public_param` VALUES (29, '密码规则预设', 'SITE_PASSWORD_RULE', 'letterNumber', '0', NULL, 'admin', 'admin', '2026-03-28 00:00:00', NULL, '2', '1', '0', 1);
|
||||
INSERT INTO `sys_public_param` VALUES (30, '站点统计来源', 'SITE_ANALYTICS_PROVIDER', 'clarity', '0', NULL, 'admin', 'admin', '2026-06-23 00:00:00', NULL, '2', '1', '0', 1);
|
||||
INSERT INTO `sys_public_param` VALUES (31, 'Umami 实例地址', 'SITE_UMAMI_HOST_URL', '', '0', NULL, 'admin', 'admin', '2026-06-23 00:00:00', NULL, '2', '1', '0', 1);
|
||||
INSERT INTO `sys_public_param` VALUES (32, 'Umami Website ID', 'SITE_UMAMI_WEBSITE_ID', '', '0', NULL, 'admin', 'admin', '2026-06-23 00:00:00', NULL, '2', '1', '0', 1);
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
@@ -1334,37 +1338,28 @@ BEGIN;
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for sys_clarity_data
|
||||
-- Table structure for sys_analytics_data
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `sys_clarity_data`;
|
||||
CREATE TABLE `sys_clarity_data` (
|
||||
`id` bigint(20) NOT NULL COMMENT '主键(ASSIGN_ID,应用层雪花算法生成)',
|
||||
`data_date` date NOT NULL COMMENT '数据日期',
|
||||
`total_sessions` int(11) DEFAULT NULL COMMENT '总会话数(Traffic.totalSessionCount)',
|
||||
`distinct_users` int(11) DEFAULT NULL COMMENT '独立访客数 UV(Traffic.distinctUserCount)',
|
||||
`pages_per_session` decimal(10,2) DEFAULT NULL COMMENT '每会话页面数(Traffic.pagesPerSessionPercentage)',
|
||||
`scroll_depth` decimal(10,2) DEFAULT NULL COMMENT '平均滚动深度 %(ScrollDepth.averageScrollDepth)',
|
||||
`dead_click_rate` decimal(10,2) DEFAULT NULL COMMENT '死点击率 %(DeadClickCount.sessionsWithMetricPercentage)',
|
||||
`rage_click_rate` decimal(10,2) DEFAULT NULL COMMENT '激怒点击率 %(RageClickCount.sessionsWithMetricPercentage)',
|
||||
`device_data` text DEFAULT NULL COMMENT '设备分布 JSON 文本:[{"name":"PC","value":569}]',
|
||||
`top_urls` text DEFAULT NULL COMMENT '热门页面 Top10 JSON 文本:[{"name":"https://...","value":297}]',
|
||||
`num_of_days` tinyint(4) NOT NULL DEFAULT 1 COMMENT '数据天数',
|
||||
`fetch_status` varchar(10) NOT NULL DEFAULT 'pending' COMMENT '拉取状态:pending=占位中, success=成功, failed=失败',
|
||||
`referrer_url_data` text DEFAULT NULL COMMENT '来源页面 JSON 文本:[{"name":"https://...","value":69}]',
|
||||
`page_title_data` text DEFAULT NULL COMMENT '页面标题 JSON 文本:[{"name":"标题","value":24}]',
|
||||
`browser_data` text DEFAULT NULL COMMENT '浏览器分布 JSON 文本:[{"name":"Chrome","value":108}]',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
|
||||
`del_flag` char(1) DEFAULT '0' COMMENT '删除标记:0正常,1已删除',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
`tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID',
|
||||
DROP TABLE IF EXISTS `sys_analytics_data`;
|
||||
CREATE TABLE `sys_analytics_data` (
|
||||
`id` bigint(20) NOT NULL COMMENT '主键(ASSIGN_ID,应用层雪花算法生成)',
|
||||
`provider` varchar(20) NOT NULL COMMENT '统计来源:clarity 或 umami',
|
||||
`data_date` date NOT NULL COMMENT '数据日期',
|
||||
`num_of_days` tinyint(4) NOT NULL DEFAULT 1 COMMENT '数据天数',
|
||||
`fetch_status` varchar(10) NOT NULL DEFAULT 'pending' COMMENT '拉取状态:pending=占位中, success=成功, failed=失败',
|
||||
`data_json` longtext DEFAULT NULL COMMENT '统计展示快照 JSON',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
|
||||
`del_flag` char(1) DEFAULT '0' COMMENT '删除标记:0正常,1已删除',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
`tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_date_days_tenant` (`data_date`, `num_of_days`, `tenant_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Clarity 监控数据缓存表';
|
||||
UNIQUE KEY `uk_provider_date_days_tenant` (`provider`, `data_date`, `num_of_days`, `tenant_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='站点统计缓存表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of sys_clarity_data
|
||||
-- Records of sys_analytics_data
|
||||
-- ----------------------------
|
||||
BEGIN;
|
||||
COMMIT;
|
||||
|
||||
+22
-27
@@ -322,6 +322,7 @@ INSERT INTO `sys_dict_item` VALUES (95, 29, '0', '黑名单', 'sensitive_type',
|
||||
INSERT INTO `sys_dict_item` VALUES (96, 29, '1', '白名单', 'sensitive_type', '失败', NULL, 3, 'admin', ' ', '2023-10-27 10:30:47', NULL, NULL, '0', 1);
|
||||
INSERT INTO `sys_dict_item` VALUES (97,2,'MINI','小程序登录','social_type','小程序登录',NULL,3,' ',' ','2022-02-18 13:56:25','2022-02-18 13:56:28',NULL,'0',1);
|
||||
INSERT INTO `sys_dict_item` VALUES (98,2,'clarity','clarity','social_type','clarity',NULL,3,' ',' ','2022-02-18 13:56:25','2022-02-18 13:56:28',NULL,'0',1);
|
||||
INSERT INTO `sys_dict_item` VALUES (99,2,'umami','umami','social_type','umami',NULL,4,' ',' ','2026-06-23 00:00:00','2026-06-23 00:00:00',NULL,'0',1);
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
@@ -908,6 +909,9 @@ INSERT INTO `sys_public_param` VALUES (26, '隐私提示', 'SITE_PRIVACY_TIP', '
|
||||
INSERT INTO `sys_public_param` VALUES (27, '密码过期天数', 'PASSWORD_EXPIRE_DAYS', '90', '0', NULL, 'admin', 'admin', '2026-03-28 00:00:00', NULL, '2', '0', '0', 1);
|
||||
INSERT INTO `sys_public_param` VALUES (28, '登录失败锁定次数', 'LOGIN_ERROR_TIMES', '5', '0', NULL, 'admin', 'admin', '2026-03-28 00:00:00', NULL, '2', '0', '0', 1);
|
||||
INSERT INTO `sys_public_param` VALUES (29, '密码规则预设', 'SITE_PASSWORD_RULE', 'letterNumber', '0', NULL, 'admin', 'admin', '2026-03-28 00:00:00', NULL, '2', '1', '0', 1);
|
||||
INSERT INTO `sys_public_param` VALUES (30, '站点统计来源', 'SITE_ANALYTICS_PROVIDER', 'clarity', '0', NULL, 'admin', 'admin', '2026-06-23 00:00:00', NULL, '2', '1', '0', 1);
|
||||
INSERT INTO `sys_public_param` VALUES (31, 'Umami 实例地址', 'SITE_UMAMI_HOST_URL', '', '0', NULL, 'admin', 'admin', '2026-06-23 00:00:00', NULL, '2', '1', '0', 1);
|
||||
INSERT INTO `sys_public_param` VALUES (32, 'Umami Website ID', 'SITE_UMAMI_WEBSITE_ID', '', '0', NULL, 'admin', 'admin', '2026-06-23 00:00:00', NULL, '2', '1', '0', 1);
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
@@ -1320,37 +1324,28 @@ BEGIN;
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for sys_clarity_data
|
||||
-- Table structure for sys_analytics_data
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `sys_clarity_data`;
|
||||
CREATE TABLE `sys_clarity_data` (
|
||||
`id` bigint(20) NOT NULL COMMENT '主键(ASSIGN_ID,应用层雪花算法生成)',
|
||||
`data_date` date NOT NULL COMMENT '数据日期',
|
||||
`total_sessions` int(11) DEFAULT NULL COMMENT '总会话数(Traffic.totalSessionCount)',
|
||||
`distinct_users` int(11) DEFAULT NULL COMMENT '独立访客数 UV(Traffic.distinctUserCount)',
|
||||
`pages_per_session` decimal(10,2) DEFAULT NULL COMMENT '每会话页面数(Traffic.pagesPerSessionPercentage)',
|
||||
`scroll_depth` decimal(10,2) DEFAULT NULL COMMENT '平均滚动深度 %(ScrollDepth.averageScrollDepth)',
|
||||
`dead_click_rate` decimal(10,2) DEFAULT NULL COMMENT '死点击率 %(DeadClickCount.sessionsWithMetricPercentage)',
|
||||
`rage_click_rate` decimal(10,2) DEFAULT NULL COMMENT '激怒点击率 %(RageClickCount.sessionsWithMetricPercentage)',
|
||||
`device_data` text DEFAULT NULL COMMENT '设备分布 JSON 文本:[{"name":"PC","value":569}]',
|
||||
`top_urls` text DEFAULT NULL COMMENT '热门页面 Top10 JSON 文本:[{"name":"https://...","value":297}]',
|
||||
`num_of_days` tinyint(4) NOT NULL DEFAULT 1 COMMENT '数据天数',
|
||||
`fetch_status` varchar(10) NOT NULL DEFAULT 'pending' COMMENT '拉取状态:pending=占位中, success=成功, failed=失败',
|
||||
`referrer_url_data` text DEFAULT NULL COMMENT '来源页面 JSON 文本:[{"name":"https://...","value":69}]',
|
||||
`page_title_data` text DEFAULT NULL COMMENT '页面标题 JSON 文本:[{"name":"标题","value":24}]',
|
||||
`browser_data` text DEFAULT NULL COMMENT '浏览器分布 JSON 文本:[{"name":"Chrome","value":108}]',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
|
||||
`del_flag` char(1) DEFAULT '0' COMMENT '删除标记:0正常,1已删除',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
`tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID',
|
||||
DROP TABLE IF EXISTS `sys_analytics_data`;
|
||||
CREATE TABLE `sys_analytics_data` (
|
||||
`id` bigint(20) NOT NULL COMMENT '主键(ASSIGN_ID,应用层雪花算法生成)',
|
||||
`provider` varchar(20) NOT NULL COMMENT '统计来源:clarity 或 umami',
|
||||
`data_date` date NOT NULL COMMENT '数据日期',
|
||||
`num_of_days` tinyint(4) NOT NULL DEFAULT 1 COMMENT '数据天数',
|
||||
`fetch_status` varchar(10) NOT NULL DEFAULT 'pending' COMMENT '拉取状态:pending=占位中, success=成功, failed=失败',
|
||||
`data_json` longtext DEFAULT NULL COMMENT '统计展示快照 JSON',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
|
||||
`del_flag` char(1) DEFAULT '0' COMMENT '删除标记:0正常,1已删除',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
`tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_date_days_tenant` (`data_date`, `num_of_days`, `tenant_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Clarity 监控数据缓存表';
|
||||
UNIQUE KEY `uk_provider_date_days_tenant` (`provider`, `data_date`, `num_of_days`, `tenant_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='站点统计缓存表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of sys_clarity_data
|
||||
-- Records of sys_analytics_data
|
||||
-- ----------------------------
|
||||
BEGIN;
|
||||
COMMIT;
|
||||
|
||||
+20
-21
@@ -7,8 +7,7 @@
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
* notice, this list of conditions and the following disclaimer.
|
||||
* Neither the name of the pig4cloud.com developer nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
@@ -23,34 +22,34 @@ import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Clarity 数据拉取状态枚举
|
||||
* 站点统计数据拉取状态枚举
|
||||
*
|
||||
* @author lengleng
|
||||
* @date 2026-03-31
|
||||
* @date 2026-06-24
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum ClarityFetchStatus {
|
||||
public enum AnalyticsFetchStatus {
|
||||
|
||||
/**
|
||||
* 占位中:已插入占位行,API 调用尚未完成
|
||||
*/
|
||||
PENDING("pending", "占位中"),
|
||||
/**
|
||||
* 占位中:已插入占位行,API 调用尚未完成
|
||||
*/
|
||||
PENDING("pending", "占位中"),
|
||||
|
||||
/**
|
||||
* 成功:API 调用成功,数据已写入
|
||||
*/
|
||||
SUCCESS("success", "成功"),
|
||||
/**
|
||||
* 成功:API 调用成功,数据已写入
|
||||
*/
|
||||
SUCCESS("success", "成功"),
|
||||
|
||||
/**
|
||||
* 失败:API 调用失败,下次调度可重试
|
||||
*/
|
||||
FAILED("failed", "失败");
|
||||
/**
|
||||
* 失败:API 调用失败,下次调度可重试
|
||||
*/
|
||||
FAILED("failed", "失败");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
private final String desc;
|
||||
private final String desc;
|
||||
|
||||
}
|
||||
+9
@@ -30,9 +30,18 @@ import lombok.Data;
|
||||
@Schema(description = "站点配置")
|
||||
public class SiteConfigDTO {
|
||||
|
||||
@Schema(description = "站点统计来源:clarity=Microsoft Clarity, umami=Umami")
|
||||
private String analyticsProvider;
|
||||
|
||||
@Schema(description = "Clarity 统计ID")
|
||||
private String clarityId;
|
||||
|
||||
@Schema(description = "Umami 实例地址")
|
||||
private String umamiHostUrl;
|
||||
|
||||
@Schema(description = "Umami Website ID")
|
||||
private String umamiWebsiteId;
|
||||
|
||||
@Schema(description = "验证码类型:clickWord=点按,blockPuzzle=滑动,math=图形,none=关闭")
|
||||
private String captchaType;
|
||||
|
||||
|
||||
+14
-43
@@ -7,8 +7,7 @@
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
* notice, this list of conditions and the following disclaimer.
|
||||
* Neither the name of the pig4cloud.com developer nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
@@ -19,27 +18,26 @@ package com.pig4cloud.pigx.admin.api.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import com.baomidou.mybatisplus.extension.activerecord.Model;
|
||||
import com.pig4cloud.pigx.admin.api.constant.ClarityFetchStatus;
|
||||
import com.pig4cloud.pigx.admin.api.constant.AnalyticsFetchStatus;
|
||||
import com.pig4cloud.pigx.common.core.util.TenantTable;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Clarity 监控数据缓存
|
||||
* 站点统计缓存
|
||||
*
|
||||
* @author lengleng
|
||||
* @date 2026-03-26
|
||||
* @date 2026-06-24
|
||||
*/
|
||||
@Data
|
||||
@TenantTable
|
||||
@Schema(description = "Clarity 监控数据缓存")
|
||||
@Schema(description = "站点统计缓存")
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class SysClarityData extends Model<SysClarityData> {
|
||||
public class SysAnalyticsData extends Model<SysAnalyticsData> {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -47,47 +45,20 @@ public class SysClarityData extends Model<SysClarityData> {
|
||||
@Schema(description = "主键")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "统计来源:clarity 或 umami")
|
||||
private String provider;
|
||||
|
||||
@Schema(description = "数据日期")
|
||||
private LocalDate dataDate;
|
||||
|
||||
@Schema(description = "总会话数(Traffic.totalSessionCount)")
|
||||
private Integer totalSessions;
|
||||
|
||||
@Schema(description = "独立访客数 UV(Traffic.distinctUserCount)")
|
||||
private Integer distinctUsers;
|
||||
|
||||
@Schema(description = "每会话页面数(Traffic.pagesPerSessionPercentage)")
|
||||
private BigDecimal pagesPerSession;
|
||||
|
||||
@Schema(description = "平均滚动深度 %(ScrollDepth.averageScrollDepth)")
|
||||
private BigDecimal scrollDepth;
|
||||
|
||||
@Schema(description = "死点击率 %(DeadClickCount.sessionsWithMetricPercentage)")
|
||||
private BigDecimal deadClickRate;
|
||||
|
||||
@Schema(description = "激怒点击率 %(RageClickCount.sessionsWithMetricPercentage)")
|
||||
private BigDecimal rageClickRate;
|
||||
|
||||
@Schema(description = "设备分布 JSON 文本,格式:[{name,value}]")
|
||||
private String deviceData;
|
||||
|
||||
@Schema(description = "热门页面 Top10 JSON 文本,格式:[{name,value}]")
|
||||
private String topUrls;
|
||||
|
||||
@Schema(description = "来源页面 Top5 JSON,格式:[{name,value}]")
|
||||
private String referrerUrlData;
|
||||
|
||||
@Schema(description = "页面标题 Top5 JSON,格式:[{name,value}]")
|
||||
private String pageTitleData;
|
||||
|
||||
@Schema(description = "浏览器分布 JSON,格式:[{name,value}]")
|
||||
private String browserData;
|
||||
|
||||
@Schema(description = "Clarity API numOfDays 参数:1=24h, 2=48h, 3=72h")
|
||||
@Schema(description = "统计天数")
|
||||
private Integer numOfDays;
|
||||
|
||||
@Schema(description = "数据拉取状态:pending=占位中, success=成功, failed=失败")
|
||||
private ClarityFetchStatus fetchStatus;
|
||||
private AnalyticsFetchStatus fetchStatus;
|
||||
|
||||
@Schema(description = "统计展示快照 JSON")
|
||||
private String dataJson;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
@Schema(description = "创建人")
|
||||
+11
@@ -47,6 +47,17 @@ public class SysSystemInfoController {
|
||||
return sysSystemInfoService.getClarityData(numOfDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点监控数据
|
||||
*
|
||||
* @return R<?>
|
||||
*/
|
||||
@HasPermission("sys_clarity_view")
|
||||
@GetMapping("/analytics")
|
||||
public R<?> analytics(@RequestParam(defaultValue = "1") Integer numOfDays) {
|
||||
return sysSystemInfoService.getAnalyticsData(numOfDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合配置(i18n + site 配置)- 公开接口
|
||||
*/
|
||||
|
||||
+59
-38
@@ -25,10 +25,10 @@ import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.pig4cloud.pigx.admin.api.constant.ClarityFetchStatus;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysClarityData;
|
||||
import com.pig4cloud.pigx.admin.api.constant.AnalyticsFetchStatus;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysAnalyticsData;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysSocialDetails;
|
||||
import com.pig4cloud.pigx.admin.mapper.SysClarityDataMapper;
|
||||
import com.pig4cloud.pigx.admin.mapper.SysAnalyticsDataMapper;
|
||||
import com.pig4cloud.pigx.admin.mapper.SysSocialDetailsMapper;
|
||||
import com.pig4cloud.pigx.common.data.tenant.TenantBroker;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -38,6 +38,7 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
@@ -58,7 +59,11 @@ public class ClarityFetchTaskHandler {
|
||||
|
||||
private static final String CLARITY_TYPE = "clarity";
|
||||
|
||||
private final SysClarityDataMapper clarityDataMapper;
|
||||
private static final String CLARITY_PROVIDER = "clarity";
|
||||
|
||||
private static final int HTTP_TIMEOUT = 10000;
|
||||
|
||||
private final SysAnalyticsDataMapper analyticsDataMapper;
|
||||
|
||||
private final SysSocialDetailsMapper socialDetailsMapper;
|
||||
|
||||
@@ -76,11 +81,12 @@ public class ClarityFetchTaskHandler {
|
||||
try {
|
||||
TenantBroker.runAs(tenantId, t -> {
|
||||
// 幂等检查:今日已有 PENDING 或 SUCCESS 记录则跳过(FAILED 允许重试)
|
||||
boolean alreadyRunning = clarityDataMapper.exists(Wrappers.<SysClarityData>lambdaQuery()
|
||||
.eq(SysClarityData::getDataDate, LocalDate.now())
|
||||
.eq(SysClarityData::getNumOfDays, numOfDays)
|
||||
.in(SysClarityData::getFetchStatus, ClarityFetchStatus.PENDING, ClarityFetchStatus.SUCCESS));
|
||||
if (alreadyRunning) {
|
||||
SysAnalyticsData today = analyticsDataMapper.selectOne(Wrappers.<SysAnalyticsData>lambdaQuery()
|
||||
.eq(SysAnalyticsData::getProvider, CLARITY_PROVIDER)
|
||||
.eq(SysAnalyticsData::getDataDate, LocalDate.now())
|
||||
.eq(SysAnalyticsData::getNumOfDays, numOfDays));
|
||||
if (today != null && (AnalyticsFetchStatus.PENDING.equals(today.getFetchStatus())
|
||||
|| AnalyticsFetchStatus.SUCCESS.equals(today.getFetchStatus()))) {
|
||||
log.debug("[Clarity] 今日 numOfDays={} 已有进行中或成功记录,跳过", numOfDays);
|
||||
return;
|
||||
}
|
||||
@@ -93,33 +99,46 @@ public class ClarityFetchTaskHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// 插入占位行,防止并发重复调用
|
||||
SysClarityData placeholder = new SysClarityData();
|
||||
placeholder.setDataDate(LocalDate.now());
|
||||
placeholder.setNumOfDays(numOfDays);
|
||||
placeholder.setFetchStatus(ClarityFetchStatus.PENDING);
|
||||
clarityDataMapper.insert(placeholder);
|
||||
log.info("[Clarity] 占位行已插入,id={},numOfDays={}", placeholder.getId(), numOfDays);
|
||||
Long dataId;
|
||||
if (today == null) {
|
||||
// 插入占位行,防止并发重复调用
|
||||
SysAnalyticsData placeholder = new SysAnalyticsData();
|
||||
placeholder.setProvider(CLARITY_PROVIDER);
|
||||
placeholder.setDataDate(LocalDate.now());
|
||||
placeholder.setNumOfDays(numOfDays);
|
||||
placeholder.setFetchStatus(AnalyticsFetchStatus.PENDING);
|
||||
analyticsDataMapper.insert(placeholder);
|
||||
dataId = placeholder.getId();
|
||||
log.info("[Clarity] 占位行已插入,id={},numOfDays={}", dataId, numOfDays);
|
||||
}
|
||||
else {
|
||||
dataId = today.getId();
|
||||
updateStatus(dataId, AnalyticsFetchStatus.PENDING);
|
||||
log.info("[Clarity] 失败记录重新拉取,id={},numOfDays={}", dataId, numOfDays);
|
||||
}
|
||||
|
||||
// 调用 Clarity API
|
||||
HttpResponse response = HttpRequest.get(CLARITY_API_BASE + numOfDays)
|
||||
.header("Authorization", "Bearer " + config.getAppSecret())
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.execute();
|
||||
|
||||
if (!response.isOk()) {
|
||||
log.error("[Clarity] API 调用失败,状态码:{}", response.getStatus());
|
||||
// 标记失败,下次调度可重试
|
||||
updateStatus(placeholder.getId(), ClarityFetchStatus.FAILED);
|
||||
updateStatus(dataId, AnalyticsFetchStatus.FAILED);
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析响应并填充数据
|
||||
JSONArray body = JSONUtil.parseArray(response.body());
|
||||
SysClarityData data = parseResponse(body);
|
||||
data.setId(placeholder.getId());
|
||||
data.setFetchStatus(ClarityFetchStatus.SUCCESS);
|
||||
clarityDataMapper.updateById(data);
|
||||
Map<String, Object> data = parseResponse(body);
|
||||
SysAnalyticsData update = new SysAnalyticsData();
|
||||
update.setId(dataId);
|
||||
update.setFetchStatus(AnalyticsFetchStatus.SUCCESS);
|
||||
update.setDataJson(JSONUtil.toJsonStr(data));
|
||||
analyticsDataMapper.updateById(update);
|
||||
log.info("[Clarity] 数据拉取并存库成功,日期:{},numOfDays:{}", LocalDate.now(), numOfDays);
|
||||
});
|
||||
}
|
||||
@@ -128,11 +147,11 @@ public class ClarityFetchTaskHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStatus(Long id, ClarityFetchStatus status) {
|
||||
SysClarityData update = new SysClarityData();
|
||||
private void updateStatus(Long id, AnalyticsFetchStatus status) {
|
||||
SysAnalyticsData update = new SysAnalyticsData();
|
||||
update.setId(id);
|
||||
update.setFetchStatus(status);
|
||||
clarityDataMapper.updateById(update);
|
||||
analyticsDataMapper.updateById(update);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,8 +159,8 @@ public class ClarityFetchTaskHandler {
|
||||
* 字段映射已通过真实 API 调用确认(2026-03-26)
|
||||
* 所有 count 类字段为 String 类型,使用 Convert.toInt 安全转换(null/空串返回 0)
|
||||
*/
|
||||
private SysClarityData parseResponse(JSONArray responseArray) {
|
||||
SysClarityData data = new SysClarityData();
|
||||
private Map<String, Object> parseResponse(JSONArray responseArray) {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
|
||||
for (Object item : responseArray) {
|
||||
if (!(item instanceof JSONObject metric)) {
|
||||
@@ -156,24 +175,24 @@ public class ClarityFetchTaskHandler {
|
||||
|
||||
switch (metricName) {
|
||||
case "Traffic" -> {
|
||||
data.setTotalSessions(Convert.toInt(first.getStr("totalSessionCount"), 0));
|
||||
data.setDistinctUsers(Convert.toInt(first.getStr("distinctUserCount"), 0));
|
||||
data.setPagesPerSession(first.getBigDecimal("pagesPerSessionPercentage"));
|
||||
data.put("totalSessions", Convert.toInt(first.getStr("totalSessionCount"), 0));
|
||||
data.put("distinctUsers", Convert.toInt(first.getStr("distinctUserCount"), 0));
|
||||
data.put("pagesPerSession", first.getBigDecimal("pagesPerSessionPercentage"));
|
||||
}
|
||||
case "ScrollDepth" -> data.setScrollDepth(first.getBigDecimal("averageScrollDepth"));
|
||||
case "DeadClickCount" -> data.setDeadClickRate(first.getBigDecimal("sessionsWithMetricPercentage"));
|
||||
case "RageClickCount" -> data.setRageClickRate(first.getBigDecimal("sessionsWithMetricPercentage"));
|
||||
case "Device" -> data.setDeviceData(toJsonRanking(info.stream(), dev ->
|
||||
case "ScrollDepth" -> data.put("scrollDepth", first.getBigDecimal("averageScrollDepth"));
|
||||
case "DeadClickCount" -> data.put("deadClickRate", first.getBigDecimal("sessionsWithMetricPercentage"));
|
||||
case "RageClickCount" -> data.put("rageClickRate", first.getBigDecimal("sessionsWithMetricPercentage"));
|
||||
case "Device" -> data.put("deviceData", toJsonRanking(info.stream(), dev ->
|
||||
dev.getStr("name"), dev -> Convert.toInt(dev.getStr("sessionsCount"), 0)));
|
||||
case "PopularPages" -> data.setTopUrls(toJsonRanking(info.stream().limit(5), page ->
|
||||
case "PopularPages" -> data.put("topUrls", toJsonRanking(info.stream().limit(5), page ->
|
||||
page.getStr("url"), page -> Convert.toInt(page.getStr("visitsCount"), 0)));
|
||||
case "ReferrerUrl" -> data.setReferrerUrlData(toJsonRanking(info.stream().limit(5), ref ->
|
||||
case "ReferrerUrl" -> data.put("referrerUrlData", toJsonRanking(info.stream().limit(5), ref ->
|
||||
StrUtil.blankToDefault(ref.getStr("url"), ref.getStr("name", "unknown")),
|
||||
ref -> Convert.toInt(ref.getStr("sessionsCount"), 0)));
|
||||
case "PageTitle" -> data.setPageTitleData(toJsonRanking(info.stream().limit(5), pt ->
|
||||
case "PageTitle" -> data.put("pageTitleData", toJsonRanking(info.stream().limit(5), pt ->
|
||||
StrUtil.blankToDefault(pt.getStr("name"), pt.getStr("title", "unknown")),
|
||||
pt -> Convert.toInt(pt.getStr("sessionsCount"), 0)));
|
||||
case "Browser" -> data.setBrowserData(toJsonRanking(info.stream(), br ->
|
||||
case "Browser" -> data.put("browserData", toJsonRanking(info.stream(), br ->
|
||||
StrUtil.blankToDefault(br.getStr("name"), "unknown"),
|
||||
br -> Convert.toInt(br.getStr("sessionsCount"), 0)));
|
||||
default -> {
|
||||
@@ -193,7 +212,9 @@ public class ClarityFetchTaskHandler {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
stream.forEach(item -> {
|
||||
if (item instanceof JSONObject obj) {
|
||||
list.add(Map.of("name", nameExtractor.apply(obj), "value", valueExtractor.apply(obj)));
|
||||
list.add(Map.of(
|
||||
"name", StrUtil.blankToDefault(nameExtractor.apply(obj), "unknown"),
|
||||
"value", valueExtractor.apply(obj)));
|
||||
}
|
||||
});
|
||||
return JSONUtil.toJsonStr(list);
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright (c) 2018-2025, lengleng All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer.
|
||||
* Neither the name of the pig4cloud.com developer nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
* Author: lengleng (wangiegie@gmail.com)
|
||||
*/
|
||||
|
||||
package com.pig4cloud.pigx.admin.handler;
|
||||
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.pig4cloud.pigx.admin.api.constant.AnalyticsFetchStatus;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysAnalyticsData;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysSocialDetails;
|
||||
import com.pig4cloud.pigx.admin.mapper.SysAnalyticsDataMapper;
|
||||
import com.pig4cloud.pigx.admin.mapper.SysSocialDetailsMapper;
|
||||
import com.pig4cloud.pigx.admin.service.SysPublicParamService;
|
||||
import com.pig4cloud.pigx.common.data.tenant.TenantBroker;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Umami API 异步数据拉取任务
|
||||
*
|
||||
* @author lengleng
|
||||
* @date 2026-06-23
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class UmamiFetchTaskHandler {
|
||||
|
||||
private static final String UMAMI_PROVIDER = "umami";
|
||||
|
||||
private static final String HOST_URL_KEY = "SITE_UMAMI_HOST_URL";
|
||||
|
||||
private static final String WEBSITE_ID_KEY = "SITE_UMAMI_WEBSITE_ID";
|
||||
|
||||
private static final int TOP_LIMIT = 5;
|
||||
|
||||
private static final int HTTP_TIMEOUT = 10000;
|
||||
|
||||
private final SysAnalyticsDataMapper analyticsDataMapper;
|
||||
|
||||
private final SysSocialDetailsMapper socialDetailsMapper;
|
||||
|
||||
private final SysPublicParamService sysPublicParamService;
|
||||
|
||||
/**
|
||||
* 异步从 Umami API 拉取数据并存库
|
||||
*
|
||||
* @param tenantId 调用方当前租户 ID
|
||||
* @param numOfDays 统计天数(1=24h, 2=48h, 3=72h)
|
||||
*/
|
||||
@Async
|
||||
public void fetchAndSave(Long tenantId, int numOfDays) {
|
||||
try {
|
||||
TenantBroker.runAs(tenantId, t -> {
|
||||
SysAnalyticsData today = analyticsDataMapper.selectOne(Wrappers.<SysAnalyticsData>lambdaQuery()
|
||||
.eq(SysAnalyticsData::getProvider, UMAMI_PROVIDER)
|
||||
.eq(SysAnalyticsData::getDataDate, LocalDate.now())
|
||||
.eq(SysAnalyticsData::getNumOfDays, numOfDays));
|
||||
if (today != null && (AnalyticsFetchStatus.PENDING.equals(today.getFetchStatus())
|
||||
|| AnalyticsFetchStatus.SUCCESS.equals(today.getFetchStatus()))) {
|
||||
log.debug("[Umami] 今日 numOfDays={} 已有进行中或成功记录,跳过", numOfDays);
|
||||
return;
|
||||
}
|
||||
|
||||
SysSocialDetails config = socialDetailsMapper
|
||||
.selectOne(Wrappers.<SysSocialDetails>lambdaQuery().eq(SysSocialDetails::getType, UMAMI_PROVIDER));
|
||||
String hostUrl = normalizeHostUrl(sysPublicParamService.getSysPublicParamKeyToValue(HOST_URL_KEY));
|
||||
String websiteId = StrUtil.trim(sysPublicParamService.getSysPublicParamKeyToValue(WEBSITE_ID_KEY));
|
||||
if (config == null || StrUtil.hasBlank(hostUrl, websiteId)) {
|
||||
log.warn("[Umami] 配置不完整,跳过拉取");
|
||||
return;
|
||||
}
|
||||
|
||||
Long dataId;
|
||||
if (today == null) {
|
||||
SysAnalyticsData placeholder = new SysAnalyticsData();
|
||||
placeholder.setProvider(UMAMI_PROVIDER);
|
||||
placeholder.setDataDate(LocalDate.now());
|
||||
placeholder.setNumOfDays(numOfDays);
|
||||
placeholder.setFetchStatus(AnalyticsFetchStatus.PENDING);
|
||||
analyticsDataMapper.insert(placeholder);
|
||||
dataId = placeholder.getId();
|
||||
log.info("[Umami] 占位行已插入,id={},numOfDays={}", dataId, numOfDays);
|
||||
}
|
||||
else {
|
||||
dataId = today.getId();
|
||||
updateStatus(dataId, AnalyticsFetchStatus.PENDING);
|
||||
log.info("[Umami] 失败记录重新拉取,id={},numOfDays={}", dataId, numOfDays);
|
||||
}
|
||||
|
||||
try {
|
||||
String token = login(hostUrl, config);
|
||||
if (StrUtil.isBlank(token)) {
|
||||
updateStatus(dataId, AnalyticsFetchStatus.FAILED);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> data = fetchWebsiteData(hostUrl, websiteId, token, numOfDays);
|
||||
SysAnalyticsData update = new SysAnalyticsData();
|
||||
update.setId(dataId);
|
||||
update.setFetchStatus(AnalyticsFetchStatus.SUCCESS);
|
||||
update.setDataJson(JSONUtil.toJsonStr(data));
|
||||
analyticsDataMapper.updateById(update);
|
||||
log.info("[Umami] 数据拉取并存库成功,日期:{},numOfDays:{}", LocalDate.now(), numOfDays);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("[Umami] API 调用失败", e);
|
||||
updateStatus(dataId, AnalyticsFetchStatus.FAILED);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("[Umami] 数据拉取异常", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeHostUrl(String hostUrl) {
|
||||
return StrUtil.removeSuffix(StrUtil.trim(hostUrl), StrUtil.SLASH);
|
||||
}
|
||||
|
||||
private String login(String hostUrl, SysSocialDetails config) {
|
||||
JSONObject loginBody = JSONUtil.createObj()
|
||||
.set("username", config.getAppId())
|
||||
.set("password", config.getAppSecret());
|
||||
HttpResponse loginResponse = HttpRequest.post(hostUrl + "/api/auth/login")
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.body(loginBody.toString())
|
||||
.execute();
|
||||
|
||||
if (!loginResponse.isOk()) {
|
||||
log.error("[Umami] 登录失败,状态码:{}", loginResponse.getStatus());
|
||||
return StrUtil.EMPTY;
|
||||
}
|
||||
return JSONUtil.parseObj(loginResponse.body()).getStr("token");
|
||||
}
|
||||
|
||||
private Map<String, Object> fetchWebsiteData(String hostUrl, String websiteId, String token, int numOfDays) {
|
||||
long endAt = System.currentTimeMillis();
|
||||
long startAt = endAt - Duration.ofDays(Math.max(numOfDays, 1)).toMillis();
|
||||
String baseQuery = "startAt=%d&endAt=%d".formatted(startAt, endAt);
|
||||
String statsUrl = "%s/api/websites/%s/stats?%s".formatted(hostUrl, websiteId, baseQuery);
|
||||
|
||||
HttpResponse statsResponse = authorizedGet(statsUrl, token);
|
||||
if (!statsResponse.isOk()) {
|
||||
throw new IllegalStateException("Umami stats API 调用失败,状态码:" + statsResponse.getStatus());
|
||||
}
|
||||
|
||||
JSONObject stats = JSONUtil.parseObj(statsResponse.body());
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
Integer pageviews = Convert.toInt(stats.get("pageviews"), 0);
|
||||
Integer visits = Convert.toInt(stats.get("visits"), 0);
|
||||
Integer visitors = Convert.toInt(stats.get("visitors"), 0);
|
||||
Integer bounces = Convert.toInt(stats.get("bounces"), 0);
|
||||
Integer totalTime = Convert.toInt(stats.get("totaltime"), 0);
|
||||
|
||||
data.put("pageviews", pageviews);
|
||||
data.put("visits", visits);
|
||||
data.put("visitors", visitors);
|
||||
data.put("bounces", bounces);
|
||||
data.put("totalTime", totalTime);
|
||||
data.put("pagesPerVisit", divide(pageviews, visits));
|
||||
data.put("bounceRate", divide(bounces * 100L, visits));
|
||||
data.put("avgVisitDuration", divide(totalTime, visits));
|
||||
data.put("deviceData", toJsonRanking(requestMetrics(hostUrl, websiteId, token, baseQuery, "device")));
|
||||
data.put("topUrls", toJsonRanking(requestMetrics(hostUrl, websiteId, token, baseQuery, "path")));
|
||||
data.put("referrerUrlData", toJsonRanking(requestMetrics(hostUrl, websiteId, token, baseQuery, "referrer")));
|
||||
data.put("pageTitleData", toJsonRanking(requestMetrics(hostUrl, websiteId, token, baseQuery, "title")));
|
||||
data.put("browserData", toJsonRanking(requestMetrics(hostUrl, websiteId, token, baseQuery, "browser")));
|
||||
return data;
|
||||
}
|
||||
|
||||
private HttpResponse authorizedGet(String url, String token) {
|
||||
return HttpRequest.get(url)
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.execute();
|
||||
}
|
||||
|
||||
private JSONArray requestMetrics(String hostUrl, String websiteId, String token, String baseQuery, String type) {
|
||||
String metricsUrl = "%s/api/websites/%s/metrics/expanded?%s&type=%s&limit=%d"
|
||||
.formatted(hostUrl, websiteId, baseQuery, type, TOP_LIMIT);
|
||||
HttpResponse response = authorizedGet(metricsUrl, token);
|
||||
if (!response.isOk()) {
|
||||
log.warn("[Umami] metrics type={} 调用失败,状态码:{}", type, response.getStatus());
|
||||
return new JSONArray();
|
||||
}
|
||||
return JSONUtil.parseArray(response.body());
|
||||
}
|
||||
|
||||
private BigDecimal divide(long numerator, long denominator) {
|
||||
if (denominator == 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return BigDecimal.valueOf(numerator).divide(BigDecimal.valueOf(denominator), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Umami metrics/expanded 转换为前端已有的 [{name,value}] 结构
|
||||
*/
|
||||
private String toJsonRanking(JSONArray array) {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
array.forEach(item -> {
|
||||
if (item instanceof JSONObject obj) {
|
||||
list.add(Map.of(
|
||||
"name", StrUtil.blankToDefault(obj.getStr("name"), "unknown"),
|
||||
"value", Convert.toInt(obj.get("visits"), 0)));
|
||||
}
|
||||
});
|
||||
return JSONUtil.toJsonStr(list);
|
||||
}
|
||||
|
||||
private void updateStatus(Long id, AnalyticsFetchStatus status) {
|
||||
SysAnalyticsData update = new SysAnalyticsData();
|
||||
update.setId(id);
|
||||
update.setFetchStatus(status);
|
||||
analyticsDataMapper.updateById(update);
|
||||
}
|
||||
|
||||
}
|
||||
+5
-7
@@ -7,8 +7,7 @@
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
* notice, this list of conditions and the following disclaimer.
|
||||
* Neither the name of the pig4cloud.com developer nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
@@ -17,17 +16,16 @@
|
||||
|
||||
package com.pig4cloud.pigx.admin.mapper;
|
||||
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysClarityData;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysAnalyticsData;
|
||||
import com.pig4cloud.pigx.common.data.datascope.PigxBaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* Clarity 监控数据 Mapper
|
||||
* 站点统计缓存 Mapper
|
||||
*
|
||||
* @author lengleng
|
||||
* @date 2026-03-26
|
||||
* @date 2026-06-24
|
||||
*/
|
||||
@Mapper
|
||||
public interface SysClarityDataMapper extends PigxBaseMapper<SysClarityData> {
|
||||
|
||||
public interface SysAnalyticsDataMapper extends PigxBaseMapper<SysAnalyticsData> {
|
||||
}
|
||||
+9
-2
@@ -18,7 +18,7 @@
|
||||
package com.pig4cloud.pigx.admin.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysClarityData;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysAnalyticsData;
|
||||
import com.pig4cloud.pigx.common.core.util.R;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -29,7 +29,7 @@ import java.util.Map;
|
||||
* @author lengleng
|
||||
* @date 2026-03-26
|
||||
*/
|
||||
public interface SysSystemInfoService extends IService<SysClarityData> {
|
||||
public interface SysSystemInfoService extends IService<SysAnalyticsData> {
|
||||
|
||||
/**
|
||||
* 获取 Redis 缓存监控信息
|
||||
@@ -45,4 +45,11 @@ public interface SysSystemInfoService extends IService<SysClarityData> {
|
||||
*/
|
||||
R<?> getClarityData(int numOfDays);
|
||||
|
||||
/**
|
||||
* 获取当前站点统计来源的数据
|
||||
* @param numOfDays 统计天数
|
||||
* @return R
|
||||
*/
|
||||
R<?> getAnalyticsData(int numOfDays);
|
||||
|
||||
}
|
||||
+3
@@ -66,7 +66,10 @@ public class SysSiteConfigServiceImpl implements SysSiteConfigService {
|
||||
"antiDebugEnable", "syncDingtalkEnabled", "syncWechatEnabled");
|
||||
|
||||
static {
|
||||
KEY_FIELD_MAP.put("SITE_ANALYTICS_PROVIDER", "analyticsProvider");
|
||||
KEY_FIELD_MAP.put("SITE_CLARITY_ID", "clarityId");
|
||||
KEY_FIELD_MAP.put("SITE_UMAMI_HOST_URL", "umamiHostUrl");
|
||||
KEY_FIELD_MAP.put("SITE_UMAMI_WEBSITE_ID", "umamiWebsiteId");
|
||||
KEY_FIELD_MAP.put("SITE_CAPTCHA_TYPE", "captchaType");
|
||||
KEY_FIELD_MAP.put("SITE_PASSWORD_RULE", "passwordRule");
|
||||
KEY_FIELD_MAP.put("SITE_FORCE_RESET_PWD", "forceResetPwd");
|
||||
|
||||
+68
-15
@@ -17,13 +17,18 @@
|
||||
|
||||
package com.pig4cloud.pigx.admin.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysClarityData;
|
||||
import com.pig4cloud.pigx.admin.handler.ClarityFetchTaskHandler;
|
||||
import com.pig4cloud.pigx.admin.mapper.SysClarityDataMapper;
|
||||
import com.pig4cloud.pigx.admin.service.SysSystemInfoService;
|
||||
import com.pig4cloud.pigx.admin.api.constant.AnalyticsFetchStatus;
|
||||
import com.pig4cloud.pigx.admin.api.constant.UpmsErrorCodes;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysAnalyticsData;
|
||||
import com.pig4cloud.pigx.admin.handler.ClarityFetchTaskHandler;
|
||||
import com.pig4cloud.pigx.admin.handler.UmamiFetchTaskHandler;
|
||||
import com.pig4cloud.pigx.admin.mapper.SysAnalyticsDataMapper;
|
||||
import com.pig4cloud.pigx.admin.service.SysPublicParamService;
|
||||
import com.pig4cloud.pigx.admin.service.SysSystemInfoService;
|
||||
import com.pig4cloud.pigx.common.core.util.MsgUtils;
|
||||
import com.pig4cloud.pigx.common.core.util.R;
|
||||
import com.pig4cloud.pigx.common.data.cache.RedisUtils;
|
||||
@@ -47,11 +52,21 @@ import java.util.*;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SysSystemInfoServiceImpl extends ServiceImpl<SysClarityDataMapper, SysClarityData>
|
||||
public class SysSystemInfoServiceImpl extends ServiceImpl<SysAnalyticsDataMapper, SysAnalyticsData>
|
||||
implements SysSystemInfoService {
|
||||
|
||||
private static final String ANALYTICS_PROVIDER_KEY = "SITE_ANALYTICS_PROVIDER";
|
||||
|
||||
private static final String ANALYTICS_PROVIDER_CLARITY = "clarity";
|
||||
|
||||
private static final String ANALYTICS_PROVIDER_UMAMI = "umami";
|
||||
|
||||
private final ClarityFetchTaskHandler clarityFetchTask;
|
||||
|
||||
private final UmamiFetchTaskHandler umamiFetchTask;
|
||||
|
||||
private final SysPublicParamService sysPublicParamService;
|
||||
|
||||
@Override
|
||||
public R<Map<String, Object>> getCacheInfo() {
|
||||
Properties info = RedisUtils.execute((RedisCallback<Properties>) RedisServerCommands::info);
|
||||
@@ -82,23 +97,61 @@ public class SysSystemInfoServiceImpl extends ServiceImpl<SysClarityDataMapper,
|
||||
|
||||
@Override
|
||||
public R<?> getClarityData(int numOfDays) {
|
||||
// 查询今日对应 numOfDays 的缓存数据
|
||||
SysClarityData today = baseMapper.selectOne(Wrappers.<SysClarityData>lambdaQuery()
|
||||
.eq(SysClarityData::getDataDate, LocalDate.now())
|
||||
.eq(SysClarityData::getNumOfDays, numOfDays));
|
||||
return getProviderData(ANALYTICS_PROVIDER_CLARITY, numOfDays);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<?> getAnalyticsData(int numOfDays) {
|
||||
String provider = StrUtil.trim(StrUtil.blankToDefault(
|
||||
sysPublicParamService.getSysPublicParamKeyToValue(ANALYTICS_PROVIDER_KEY), ANALYTICS_PROVIDER_CLARITY))
|
||||
.toLowerCase(Locale.ROOT);
|
||||
return getProviderData(ANALYTICS_PROVIDER_UMAMI.equals(provider) ? ANALYTICS_PROVIDER_UMAMI
|
||||
: ANALYTICS_PROVIDER_CLARITY, numOfDays);
|
||||
}
|
||||
|
||||
private R<?> getProviderData(String provider, int numOfDays) {
|
||||
SysAnalyticsData today = baseMapper.selectOne(Wrappers.<SysAnalyticsData>lambdaQuery()
|
||||
.eq(SysAnalyticsData::getProvider, provider)
|
||||
.eq(SysAnalyticsData::getDataDate, LocalDate.now())
|
||||
.eq(SysAnalyticsData::getNumOfDays, numOfDays));
|
||||
|
||||
if (today != null) {
|
||||
return R.ok(today);
|
||||
if (AnalyticsFetchStatus.SUCCESS.equals(today.getFetchStatus())) {
|
||||
return R.ok(withProvider(today.getDataJson(), provider));
|
||||
}
|
||||
if (AnalyticsFetchStatus.PENDING.equals(today.getFetchStatus())) {
|
||||
return R.ok(loading(provider));
|
||||
}
|
||||
}
|
||||
|
||||
// 无数据:触发异步拉取,立即返回 loading 状态
|
||||
// 提前捕获租户 ID,避免 @Async 新线程丢失 ThreadLocal 上下文
|
||||
clarityFetchTask.fetchAndSave(TenantContextHolder.getTenantId(), numOfDays);
|
||||
fetch(provider, numOfDays);
|
||||
return R.ok(loading(provider));
|
||||
}
|
||||
|
||||
Map<String, Object> loading = new HashMap<>(2);
|
||||
private Map<String, Object> loading(String provider) {
|
||||
Map<String, Object> loading = new HashMap<>(3);
|
||||
loading.put("loading", true);
|
||||
loading.put("message", "数据获取中,请稍后手动刷新");
|
||||
return R.ok(loading);
|
||||
loading.put("provider", provider);
|
||||
return loading;
|
||||
}
|
||||
|
||||
private Map<String, Object> withProvider(String dataJson, String provider) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (StrUtil.isNotBlank(dataJson)) {
|
||||
JSONUtil.parseObj(dataJson).forEach((key, value) -> result.put(String.valueOf(key), value));
|
||||
}
|
||||
result.put("provider", provider);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void fetch(String provider, int numOfDays) {
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
if (ANALYTICS_PROVIDER_UMAMI.equals(provider)) {
|
||||
umamiFetchTask.fetchAndSave(tenantId, numOfDays);
|
||||
return;
|
||||
}
|
||||
clarityFetchTask.fetchAndSave(tenantId, numOfDays);
|
||||
}
|
||||
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2018-2025, lengleng All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer.
|
||||
* Neither the name of the pig4cloud.com developer nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
* Author: lengleng (wangiegie@gmail.com)
|
||||
*/
|
||||
|
||||
package com.pig4cloud.pigx.admin.handler;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.pig4cloud.pigx.admin.api.constant.AnalyticsFetchStatus;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysAnalyticsData;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysSocialDetails;
|
||||
import com.pig4cloud.pigx.admin.mapper.SysAnalyticsDataMapper;
|
||||
import com.pig4cloud.pigx.admin.mapper.SysSocialDetailsMapper;
|
||||
import com.pig4cloud.pigx.admin.service.SysPublicParamService;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class UmamiFetchTaskHandlerTests {
|
||||
|
||||
@Test
|
||||
void failedCacheIsReusedAndExpandedMetricsUseVisits() throws IOException {
|
||||
List<String> requestedPaths = new CopyOnWriteArrayList<>();
|
||||
HttpServer server = startUmamiServer(requestedPaths);
|
||||
try {
|
||||
SysAnalyticsDataMapper analyticsDataMapper = mock(SysAnalyticsDataMapper.class);
|
||||
SysSocialDetailsMapper socialDetailsMapper = mock(SysSocialDetailsMapper.class);
|
||||
SysPublicParamService sysPublicParamService = mock(SysPublicParamService.class);
|
||||
UmamiFetchTaskHandler handler = new UmamiFetchTaskHandler(analyticsDataMapper, socialDetailsMapper,
|
||||
sysPublicParamService);
|
||||
SysAnalyticsData failed = new SysAnalyticsData();
|
||||
failed.setId(99L);
|
||||
failed.setProvider("umami");
|
||||
failed.setFetchStatus(AnalyticsFetchStatus.FAILED);
|
||||
SysSocialDetails config = new SysSocialDetails();
|
||||
config.setAppId("admin");
|
||||
config.setAppSecret("password");
|
||||
|
||||
when(analyticsDataMapper.selectOne(any())).thenReturn(failed);
|
||||
when(socialDetailsMapper.selectOne(any())).thenReturn(config);
|
||||
when(sysPublicParamService.getSysPublicParamKeyToValue("SITE_UMAMI_HOST_URL"))
|
||||
.thenReturn("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
when(sysPublicParamService.getSysPublicParamKeyToValue("SITE_UMAMI_WEBSITE_ID")).thenReturn("site");
|
||||
|
||||
handler.fetchAndSave(1L, 1);
|
||||
|
||||
ArgumentCaptor<SysAnalyticsData> captor = ArgumentCaptor.forClass(SysAnalyticsData.class);
|
||||
verify(analyticsDataMapper, never()).insert(any(SysAnalyticsData.class));
|
||||
verify(analyticsDataMapper, times(2)).updateById(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).getFetchStatus()).isEqualTo(AnalyticsFetchStatus.PENDING);
|
||||
SysAnalyticsData success = captor.getAllValues().get(1);
|
||||
assertThat(success.getId()).isEqualTo(99L);
|
||||
assertThat(success.getFetchStatus()).isEqualTo(AnalyticsFetchStatus.SUCCESS);
|
||||
assertThat(JSONUtil.parseObj(success.getDataJson()).getStr("topUrls")).contains("\"name\":\"path\"",
|
||||
"\"value\":3");
|
||||
assertThat(requestedPaths).contains("/api/websites/site/metrics/expanded");
|
||||
assertThat(requestedPaths).doesNotContain("/api/websites/site/metrics");
|
||||
}
|
||||
finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpServer startUmamiServer(List<String> requestedPaths) throws IOException {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
requestedPaths.add(path);
|
||||
if ("/api/auth/login".equals(path)) {
|
||||
writeJson(exchange, 200, "{\"token\":\"token\"}");
|
||||
}
|
||||
else if ("/api/websites/site/stats".equals(path)) {
|
||||
writeJson(exchange, 200,
|
||||
"{\"pageviews\":10,\"visits\":5,\"visitors\":4,\"bounces\":1,\"totaltime\":25}");
|
||||
}
|
||||
else if ("/api/websites/site/metrics/expanded".equals(path)) {
|
||||
String type = exchange.getRequestURI().getQuery().replaceAll("^.*type=([^&]+).*$", "$1");
|
||||
writeJson(exchange, 200, "[{\"name\":\"" + type
|
||||
+ "\",\"pageviews\":9,\"visitors\":4,\"visits\":3,\"bounces\":1,\"totaltime\":7}]");
|
||||
}
|
||||
else {
|
||||
writeJson(exchange, 404, "{}");
|
||||
}
|
||||
});
|
||||
server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
private void writeJson(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2018-2025, lengleng All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer.
|
||||
* Neither the name of the pig4cloud.com developer nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
* Author: lengleng (wangiegie@gmail.com)
|
||||
*/
|
||||
|
||||
package com.pig4cloud.pigx.admin.service.impl;
|
||||
|
||||
import com.pig4cloud.pigx.admin.api.constant.AnalyticsFetchStatus;
|
||||
import com.pig4cloud.pigx.admin.api.entity.SysAnalyticsData;
|
||||
import com.pig4cloud.pigx.admin.handler.ClarityFetchTaskHandler;
|
||||
import com.pig4cloud.pigx.admin.handler.UmamiFetchTaskHandler;
|
||||
import com.pig4cloud.pigx.admin.mapper.SysAnalyticsDataMapper;
|
||||
import com.pig4cloud.pigx.admin.service.SysPublicParamService;
|
||||
import com.pig4cloud.pigx.common.core.util.R;
|
||||
import com.pig4cloud.pigx.common.data.tenant.TenantContextHolder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SysSystemInfoServiceImplTests {
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void failedUmamiCacheTriggersRetryAndReturnsLoading() {
|
||||
ClarityFetchTaskHandler clarityFetchTask = mock(ClarityFetchTaskHandler.class);
|
||||
UmamiFetchTaskHandler umamiFetchTask = mock(UmamiFetchTaskHandler.class);
|
||||
SysAnalyticsDataMapper analyticsDataMapper = mock(SysAnalyticsDataMapper.class);
|
||||
SysPublicParamService sysPublicParamService = mock(SysPublicParamService.class);
|
||||
SysSystemInfoServiceImpl service = new SysSystemInfoServiceImpl(clarityFetchTask, umamiFetchTask,
|
||||
sysPublicParamService);
|
||||
ReflectionTestUtils.setField(service, "baseMapper", analyticsDataMapper);
|
||||
SysAnalyticsData failed = new SysAnalyticsData();
|
||||
failed.setProvider("umami");
|
||||
failed.setFetchStatus(AnalyticsFetchStatus.FAILED);
|
||||
|
||||
when(sysPublicParamService.getSysPublicParamKeyToValue("SITE_ANALYTICS_PROVIDER")).thenReturn("umami");
|
||||
when(analyticsDataMapper.selectOne(any())).thenReturn(failed);
|
||||
|
||||
TenantContextHolder.setTenantId(1L);
|
||||
try {
|
||||
R<?> result = service.getAnalyticsData(1);
|
||||
|
||||
assertThat(result.isOk()).isTrue();
|
||||
assertThat((Map<String, Object>) result.getData()).containsEntry("loading", true)
|
||||
.containsEntry("provider", "umami");
|
||||
verify(umamiFetchTask).fetchAndSave(1L, 1);
|
||||
}
|
||||
finally {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user