feat(upms): support umami analytics

This commit is contained in:
PIGCLOUD committed 2026-08-01 13:31:35 +08:00
1 parent 83ee6144b3
commit 5931a5d6d7
14 files changed
+687 -180

No files matched your search

+22 -27
View File
@@ -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
View File
@@ -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;
@@ -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;
}
@@ -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;
@@ -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 = "创建人")
@@ -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 配置)- 公开接口
*/
@@ -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);
@@ -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);
}
}
@@ -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> {
}
@@ -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);
}
@@ -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");
@@ -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);
}
}
@@ -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();
}
}
@@ -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();
}
}
}