diff --git a/db/0pigxx_boot.sql b/db/0pigxx_boot.sql index 570bf6e89..9e3fe24d5 100644 --- a/db/0pigxx_boot.sql +++ b/db/0pigxx_boot.sql @@ -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; diff --git a/db/2pigxx.sql b/db/2pigxx.sql index 200623f7f..4d3d6343f 100644 --- a/db/2pigxx.sql +++ b/db/2pigxx.sql @@ -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; diff --git a/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/constant/ClarityFetchStatus.java b/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/constant/AnalyticsFetchStatus.java similarity index 59% rename from pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/constant/ClarityFetchStatus.java rename to pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/constant/AnalyticsFetchStatus.java index b3680efe4..6bc316533 100644 --- a/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/constant/ClarityFetchStatus.java +++ b/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/constant/AnalyticsFetchStatus.java @@ -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; } diff --git a/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/dto/SiteConfigDTO.java b/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/dto/SiteConfigDTO.java index fa86bfea8..6dfedae22 100644 --- a/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/dto/SiteConfigDTO.java +++ b/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/dto/SiteConfigDTO.java @@ -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; diff --git a/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/entity/SysClarityData.java b/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/entity/SysAnalyticsData.java similarity index 53% rename from pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/entity/SysClarityData.java rename to pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/entity/SysAnalyticsData.java index a9de6fb21..591b6dc53 100644 --- a/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/entity/SysClarityData.java +++ b/pigx-upms/pigx-upms-api/src/main/java/com/pig4cloud/pigx/admin/api/entity/SysAnalyticsData.java @@ -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 { +public class SysAnalyticsData extends Model { private static final long serialVersionUID = 1L; @@ -47,47 +45,20 @@ public class SysClarityData extends Model { @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 = "创建人") diff --git a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/controller/SysSystemInfoController.java b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/controller/SysSystemInfoController.java index 3158a1f70..019dcf04c 100644 --- a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/controller/SysSystemInfoController.java +++ b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/controller/SysSystemInfoController.java @@ -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 配置)- 公开接口 */ diff --git a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/ClarityFetchTaskHandler.java b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/ClarityFetchTaskHandler.java index 5955d1040..b6dd2fee4 100644 --- a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/ClarityFetchTaskHandler.java +++ b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/ClarityFetchTaskHandler.java @@ -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.lambdaQuery() - .eq(SysClarityData::getDataDate, LocalDate.now()) - .eq(SysClarityData::getNumOfDays, numOfDays) - .in(SysClarityData::getFetchStatus, ClarityFetchStatus.PENDING, ClarityFetchStatus.SUCCESS)); - if (alreadyRunning) { + SysAnalyticsData today = analyticsDataMapper.selectOne(Wrappers.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 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 parseResponse(JSONArray responseArray) { + Map 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> 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); diff --git a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/UmamiFetchTaskHandler.java b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/UmamiFetchTaskHandler.java new file mode 100644 index 000000000..c3264855d --- /dev/null +++ b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/handler/UmamiFetchTaskHandler.java @@ -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.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.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 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 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 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> 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); + } + +} diff --git a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/mapper/SysClarityDataMapper.java b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/mapper/SysAnalyticsDataMapper.java similarity index 72% rename from pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/mapper/SysClarityDataMapper.java rename to pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/mapper/SysAnalyticsDataMapper.java index 2cb1d336c..3292fa02d 100644 --- a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/mapper/SysClarityDataMapper.java +++ b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/mapper/SysAnalyticsDataMapper.java @@ -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 { - +public interface SysAnalyticsDataMapper extends PigxBaseMapper { } diff --git a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/SysSystemInfoService.java b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/SysSystemInfoService.java index d56f1b52d..005ac0837 100644 --- a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/SysSystemInfoService.java +++ b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/SysSystemInfoService.java @@ -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 { +public interface SysSystemInfoService extends IService { /** * 获取 Redis 缓存监控信息 @@ -45,4 +45,11 @@ public interface SysSystemInfoService extends IService { */ R getClarityData(int numOfDays); + /** + * 获取当前站点统计来源的数据 + * @param numOfDays 统计天数 + * @return R + */ + R getAnalyticsData(int numOfDays); + } diff --git a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/impl/SysSiteConfigServiceImpl.java b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/impl/SysSiteConfigServiceImpl.java index 1504159e9..79d5344cf 100644 --- a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/impl/SysSiteConfigServiceImpl.java +++ b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/impl/SysSiteConfigServiceImpl.java @@ -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"); diff --git a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/impl/SysSystemInfoServiceImpl.java b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/impl/SysSystemInfoServiceImpl.java index c0c84e426..d550c2041 100644 --- a/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/impl/SysSystemInfoServiceImpl.java +++ b/pigx-upms/pigx-upms-biz/src/main/java/com/pig4cloud/pigx/admin/service/impl/SysSystemInfoServiceImpl.java @@ -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 +public class SysSystemInfoServiceImpl extends ServiceImpl 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> getCacheInfo() { Properties info = RedisUtils.execute((RedisCallback) RedisServerCommands::info); @@ -82,23 +97,61 @@ public class SysSystemInfoServiceImpl extends ServiceImpl getClarityData(int numOfDays) { - // 查询今日对应 numOfDays 的缓存数据 - SysClarityData today = baseMapper.selectOne(Wrappers.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.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 loading = new HashMap<>(2); + private Map loading(String provider) { + Map loading = new HashMap<>(3); loading.put("loading", true); loading.put("message", "数据获取中,请稍后手动刷新"); - return R.ok(loading); + loading.put("provider", provider); + return loading; + } + + private Map withProvider(String dataJson, String provider) { + Map 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); } } diff --git a/pigx-upms/pigx-upms-biz/src/test/java/com/pig4cloud/pigx/admin/handler/UmamiFetchTaskHandlerTests.java b/pigx-upms/pigx-upms-biz/src/test/java/com/pig4cloud/pigx/admin/handler/UmamiFetchTaskHandlerTests.java new file mode 100644 index 000000000..2f31cc24b --- /dev/null +++ b/pigx-upms/pigx-upms-biz/src/test/java/com/pig4cloud/pigx/admin/handler/UmamiFetchTaskHandlerTests.java @@ -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 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 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 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(); + } + +} diff --git a/pigx-upms/pigx-upms-biz/src/test/java/com/pig4cloud/pigx/admin/service/impl/SysSystemInfoServiceImplTests.java b/pigx-upms/pigx-upms-biz/src/test/java/com/pig4cloud/pigx/admin/service/impl/SysSystemInfoServiceImplTests.java new file mode 100644 index 000000000..f98f5e583 --- /dev/null +++ b/pigx-upms/pigx-upms-biz/src/test/java/com/pig4cloud/pigx/admin/service/impl/SysSystemInfoServiceImplTests.java @@ -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) result.getData()).containsEntry("loading", true) + .containsEntry("provider", "umami"); + verify(umamiFetchTask).fetchAndSave(1L, 1); + } + finally { + TenantContextHolder.clear(); + } + } + +}