// ============================================================================ // 【文件说明】HwAnalyticsService.cs - 网站访问统计分析服务 // ============================================================================ // 这个服务负责收集和分析网站访问数据,类似于 Google Analytics 的功能。 // // 【核心功能】 // 1. 收集访问事件:页面浏览、搜索、下载等 // 2. 解析 User-Agent:获取浏览器、操作系统、设备类型 // 3. 生成日报统计:PV、UV、跳出率等 // 4. 提供仪表盘数据:热门页面、搜索关键词排行 // // 【数据流程】 // 1. 前端上报事件 → Collect 方法接收 // 2. 解析并归一化数据 → 写入明细表 // 3. 聚合计算日报 → 写入日报表 // 4. 查询仪表盘 → 返回统计数据 // // 【隐私保护】 // - IP 地址不直接存储,而是存储哈希值 // - 访客 ID 由前端生成,不包含个人信息 // // 【与 Java Spring Boot 的对比】 // Java 通常用定时任务聚合统计: // @Scheduled(cron = "0 5 0 * * ?") // 每天凌晨 0:05 执行 // public void aggregateDailyStats() { ... } // // C# 这里在写入事件时实时聚合,保证数据即时可用。 // ============================================================================ using UAParser; namespace Admin.NET.Plugin.HwPortal; /// /// 网站访问统计分析服务。 /// /// 【服务职责】 /// 1. 收集访问事件(页面浏览、搜索、下载等) /// 2. 解析 User-Agent 获取设备信息 /// 3. 聚合生成日报统计 /// 4. 提供仪表盘查询接口 /// /// /// 【C# 语法知识点 - ITransient 瞬态服务】 /// ITransient 表示每次请求都创建新实例。 /// 分析服务通常是无状态的,适合瞬态模式。 /// /// public class HwAnalyticsService : ITransient { /// /// MyBatis 映射器名称。 /// private const string Mapper = "HwAnalyticsMapper"; private const string InsertVisitEventSql = """ INSERT INTO hw_web_visit_event ( event_type, visitor_id, session_id, path, referrer, utm_source, utm_medium, utm_campaign, keyword, ip_hash, ua, device, browser, os, stay_ms, event_time, created_at ) VALUES ( @EventType, @VisitorId, @SessionId, @Path, @Referrer, @UtmSource, @UtmMedium, @UtmCampaign, @Keyword, @IpHash, @Ua, @Device, @Browser, @Os, @StayMs, @EventTime, NOW() ) """; private const string CountEventByTypeSql = """ SELECT COUNT(1) FROM hw_web_visit_event WHERE DATE(event_time) = @statDate AND event_type = @eventType """; private const string CountDistinctVisitorSql = """ SELECT COUNT(DISTINCT visitor_id) FROM hw_web_visit_event WHERE DATE(event_time) = @statDate AND event_type = 'page_view' AND visitor_id IS NOT NULL AND visitor_id != '' """; private const string CountDistinctIpSql = """ SELECT COUNT(DISTINCT ip_hash) FROM hw_web_visit_event WHERE DATE(event_time) = @statDate AND event_type = 'page_view' AND ip_hash IS NOT NULL AND ip_hash != '' """; private const string AvgStayMsSql = """ SELECT COALESCE(ROUND(AVG(stay_ms)), 0) FROM hw_web_visit_event WHERE DATE(event_time) = @statDate AND event_type = 'page_leave' AND stay_ms IS NOT NULL AND stay_ms >= 0 """; private const string CountDistinctSessionsSql = """ SELECT COUNT(DISTINCT session_id) FROM hw_web_visit_event WHERE DATE(event_time) = @statDate AND event_type = 'page_view' AND session_id IS NOT NULL AND session_id != '' """; private const string CountSinglePageSessionsSql = """ SELECT COUNT(1) FROM ( SELECT session_id FROM hw_web_visit_event WHERE DATE(event_time) = @statDate AND event_type = 'page_view' AND session_id IS NOT NULL AND session_id != '' GROUP BY session_id HAVING COUNT(1) = 1 ) t """; private const string SelectTopEntryPagesSql = """ SELECT IFNULL(e.path, '/') AS name, COUNT(1) AS value FROM hw_web_visit_event e INNER JOIN ( SELECT session_id, MIN(event_time) AS min_event_time FROM hw_web_visit_event WHERE DATE(event_time) = @statDate AND event_type = 'page_view' AND session_id IS NOT NULL AND session_id != '' GROUP BY session_id ) s ON s.session_id = e.session_id AND s.min_event_time = e.event_time WHERE DATE(e.event_time) = @statDate AND e.event_type = 'page_view' GROUP BY e.path ORDER BY value DESC LIMIT @limit """; private const string SelectTopHotPagesSql = """ SELECT IFNULL(path, '/') AS name, COUNT(1) AS value FROM hw_web_visit_event WHERE DATE(event_time) = @statDate AND event_type = 'page_view' GROUP BY path ORDER BY value DESC LIMIT @limit """; private const string SelectTopKeywordsSql = """ SELECT keyword AS name, COUNT(1) AS value FROM hw_web_visit_event WHERE DATE(event_time) = @statDate AND event_type = 'search_submit' AND keyword IS NOT NULL AND keyword != '' GROUP BY keyword ORDER BY value DESC LIMIT @limit """; private const string UpsertDailySql = """ INSERT INTO hw_web_visit_daily ( stat_date, pv, uv, ip_uv, avg_stay_ms, bounce_rate, search_count, download_count, created_at, updated_at ) VALUES ( @StatDate, @Pv, @Uv, @IpUv, @AvgStayMs, @BounceRate, @SearchCount, @DownloadCount, NOW(), NOW() ) ON DUPLICATE KEY UPDATE pv = VALUES(pv), uv = VALUES(uv), ip_uv = VALUES(ip_uv), avg_stay_ms = VALUES(avg_stay_ms), bounce_rate = VALUES(bounce_rate), search_count = VALUES(search_count), download_count = VALUES(download_count), updated_at = NOW() """; private const string SelectDailyByDateSql = """ SELECT stat_date AS StatDate, pv AS Pv, uv AS Uv, ip_uv AS IpUv, avg_stay_ms AS AvgStayMs, bounce_rate AS BounceRate, search_count AS SearchCount, download_count AS DownloadCount, created_at AS CreatedAt, updated_at AS UpdatedAt FROM hw_web_visit_daily WHERE stat_date = @statDate """; /// /// 允许的事件类型集合。 /// /// 【C# 语法知识点 - HashSet<T> 哈希集合】 /// HashSet 适合做"是否存在"判断,查找效率通常比 List 更高。 /// /// 为什么用 HashSet? /// - Contains 方法是 O(1) 时间复杂度 /// - List 的 Contains 是 O(n) 时间复杂度 /// /// StringComparer.OrdinalIgnoreCase 参数: /// - 忽略大小写比较 /// - "page_view" 和 "PAGE_VIEW" 被视为相同 /// /// /// 【业务说明】 /// 只允许预定义的事件类型,避免脏数据进入统计表: /// - page_view:页面浏览 /// - page_leave:离开页面 /// - search_submit:提交搜索 /// - download_click:点击下载 /// - contact_submit:提交联系表单 /// /// private static readonly HashSet AllowedEvents = new(StringComparer.OrdinalIgnoreCase) { "page_view", "page_leave", "search_submit", "download_click", "contact_submit" }; /// /// MyBatis 执行器(依赖注入)。 /// private readonly HwPortalMyBatisExecutor _executor; private readonly ISqlSugarClient _db; /// /// 日志记录器(依赖注入)。 /// /// 【C# 语法知识点 - ILogger<T> 泛型日志接口】 /// ILogger<HwAnalyticsService> 是带类别的日志器: /// - 日志会自动包含类名前缀 /// - 便于筛选特定类的日志 /// /// 对比 Java: /// Java: private static final Logger logger = LoggerFactory.getLogger(HwAnalyticsService.class); /// C#: private readonly ILogger<HwAnalyticsService> _logger; /// /// C# 通过 DI 注入,更易于测试和配置。 /// /// private readonly ILogger _logger; /// /// 构造函数(依赖注入)。 /// /// MyBatis 执行器 /// 日志记录器 public HwAnalyticsService(HwPortalMyBatisExecutor executor, ISqlSugarClient db, ILogger logger) { _executor = executor; _db = db; _logger = logger; } /// /// 收集访问事件。 /// /// 【处理流程】 /// 1. 校验事件类型是否合法 /// 2. 归一化字段值(去除空白、截断长度) /// 3. 解析 User-Agent 获取设备信息 /// 4. 对 IP 地址进行哈希处理 /// 5. 写入明细事件表 /// 6. 刷新日报统计 /// /// /// 事件请求 /// 请求 IP /// 请求 User-Agent public async Task Collect(AnalyticsCollectRequest request, string requestIp, string requestUserAgent) { if (request == null) { // 【Furion 异常处理】 // Oh.Oh("消息") 是 Furion 框架的异常抛出方式: // - 自动包装成友好的错误响应 // - 支持多语言 // - 自动记录日志 // // 对比 Java: // Java: throw new BusinessException("请求体不能为空"); throw Oops.Oh("请求体不能为空"); } // 先校验事件类型是否合法,避免脏数据进入统计表。 string eventType = NormalizeText(request.EventType, 32); if (!AllowedEvents.Contains(eventType)) { throw Oops.Oh("不支持的事件类型"); } // 【对象初始化器】 // 对象初始化时统一做字段归一化和长度裁剪。 // new HwWebVisitEvent { ... } 是对象初始化器语法。 HwWebVisitEvent entity = new() { EventType = eventType, VisitorId = RequireText(request.VisitorId, 64, "visitorId不能为空"), SessionId = RequireText(request.SessionId, 64, "sessionId不能为空"), Path = RequireText(request.Path, 500, "path不能为空"), Referrer = NormalizeText(request.Referrer, 500), UtmSource = NormalizeText(request.UtmSource, 128), UtmMedium = NormalizeText(request.UtmMedium, 128), UtmCampaign = NormalizeText(request.UtmCampaign, 128), Keyword = NormalizeText(request.Keyword, 128), StayMs = NormalizeStayMs(request.StayMs), EventTime = ResolveEventTime(request.EventTime) }; // Why:优先用前端上报的 ua;如果前端没传,就退回请求头里的 User-Agent。 string ua = NormalizeText(string.IsNullOrWhiteSpace(request.Ua) ? requestUserAgent : request.Ua, 500); // 【User-Agent 解析】 // UAParser 是一个现成库,用来从 User-Agent 字符串里拆出浏览器、系统等信息。 // Parser.GetDefault() 获取默认解析器。 // Parse(ua) 返回 ClientInfo 对象,包含: // - UA:浏览器信息 // - OS:操作系统信息 // - Device:设备信息 ClientInfo client = Parser.GetDefault().Parse(ua ?? string.Empty); entity.Ua = ua; // 【null 合并运算符 ??】 // 如果前端传了 Browser,用前端的值;否则用解析器的值。 entity.Browser = NormalizeText(string.IsNullOrWhiteSpace(request.Browser) ? client.UA.Family : request.Browser, 64); entity.Os = NormalizeText(string.IsNullOrWhiteSpace(request.Os) ? client.OS.Family : request.Os, 64); entity.Device = NormalizeText(string.IsNullOrWhiteSpace(request.Device) ? DetectDevice(ua) : request.Device, 64); entity.IpHash = BuildIpHash(requestIp); // 先写明细事件,再刷新日报统计。 // 回滚到 XML 方案时可直接恢复: // await _executor.InsertReturnIdentityAsync(Mapper, "insertVisitEvent", entity); await _db.Ado.ExecuteCommandAsync(InsertVisitEventSql, BuildParameters(entity)); await RefreshDailyStat(entity.EventTime?.Date ?? DateTime.Now.Date); } /// /// 获取仪表盘统计数据。 /// /// 【返回数据】 /// - PV:页面浏览量 /// - UV:独立访客数 /// - IP UV:独立 IP 数 /// - 跳出率:只浏览一页的会话占比 /// - 热门页面排行 /// - 搜索关键词排行 /// /// /// 统计日期 /// 排行条数限制 /// 仪表盘数据 public async Task GetDashboard(DateTime? statDate, int? rankLimit) { // 【DateTime.Date 属性】 // .Date 会把时间截断到"当天 00:00:00"。 // 例如:2024-01-15 14:30:00 → 2024-01-15 00:00:00 DateTime targetDate = (statDate ?? DateTime.Now).Date; int topN = NormalizeRankLimit(rankLimit); // 先刷新统计,确保数据最新。 await RefreshDailyStat(targetDate); // 【查询日报数据】 // QuerySingleAsync<T> 查询单条记录。 // 如果查询结果为空,返回 null。 // 回滚到 XML 方案时可直接恢复: // HwWebVisitDaily daily = await _executor.QuerySingleAsync(Mapper, "selectDailyByDate", new { statDate = targetDate }); HwWebVisitDaily daily = await QuerySingleOrDefaultAsync(SelectDailyByDateSql, new { statDate = targetDate }); // 【null 合并运算符 ??】 // daily?.Pv ?? 0 表示: // - 如果 daily 为 null,返回 0 // - 如果 daily.Pv 为 null,返回 0 // - 否则返回 daily.Pv AnalyticsDashboardDTO dashboard = new() { StatDate = targetDate.ToString("yyyy-MM-dd"), Pv = daily?.Pv ?? 0, Uv = daily?.Uv ?? 0, IpUv = daily?.IpUv ?? 0, AvgStayMs = daily?.AvgStayMs ?? 0, BounceRate = daily?.BounceRate ?? 0, SearchCount = daily?.SearchCount ?? 0, DownloadCount = daily?.DownloadCount ?? 0, // 回滚到 XML 方案时可直接恢复: // EntryPages = await _executor.QueryListAsync(Mapper, "selectTopEntryPages", new { statDate = targetDate, limit = topN }), EntryPages = await QueryListAsync(SelectTopEntryPagesSql, new { statDate = targetDate, limit = topN }), // 回滚到 XML 方案时可直接恢复: // HotPages = await _executor.QueryListAsync(Mapper, "selectTopHotPages", new { statDate = targetDate, limit = topN }), HotPages = await QueryListAsync(SelectTopHotPagesSql, new { statDate = targetDate, limit = topN }), // 回滚到 XML 方案时可直接恢复: // HotKeywords = await _executor.QueryListAsync(Mapper, "selectTopKeywords", new { statDate = targetDate, limit = topN }) HotKeywords = await QueryListAsync(SelectTopKeywordsSql, new { statDate = targetDate, limit = topN }) }; return dashboard; } /// /// 刷新日报统计。 /// /// 【聚合逻辑】 /// 从明细表聚合以下指标: /// - PV:页面浏览次数 /// - UV:独立访客数(按 visitorId 去重) /// - IP UV:独立 IP 数(按 ipHash 去重) /// - 平均停留时长 /// - 搜索次数 /// - 下载次数 /// - 跳出率 /// /// /// 统计日期 public async Task RefreshDailyStat(DateTime? statDate) { // 日报统计的核心做法: // 先从明细表聚合出 pv / uv / ip_uv / 搜索次数等指标,再 upsert 到日报表。 DateTime targetDate = (statDate ?? DateTime.Now).Date; // 【查询聚合值】 long pv = await QueryLong("countEventByType", new { statDate = targetDate, eventType = "page_view" }); long uv = await QueryLong("countDistinctVisitor", new { statDate = targetDate }); long ipUv = await QueryLong("countDistinctIp", new { statDate = targetDate }); long avgStayMs = await QueryLong("avgStayMs", new { statDate = targetDate }); long searchCount = await QueryLong("countEventByType", new { statDate = targetDate, eventType = "search_submit" }); long downloadCount = await QueryLong("countEventByType", new { statDate = targetDate, eventType = "download_click" }); long sessionCount = await QueryLong("countDistinctSessions", new { statDate = targetDate }); long singlePageSessions = await QueryLong("countSinglePageSessions", new { statDate = targetDate }); // 【跳出率计算】 double bounceRate = 0D; if (sessionCount > 0) { // Why:跳出率 = 单页 session 数 / 总 session 数。 // Math.Round 四舍五入: // - 第一个参数:要舍入的值 // - 第二个参数:保留小数位数 // - 第三个参数:舍入方式(AwayFromZero 表示四舍五入) bounceRate = Math.Round(singlePageSessions * 100.0D / sessionCount, 2, MidpointRounding.AwayFromZero); } HwWebVisitDaily daily = new() { StatDate = targetDate, Pv = pv, Uv = uv, IpUv = ipUv, AvgStayMs = avgStayMs, BounceRate = bounceRate, SearchCount = searchCount, DownloadCount = downloadCount }; // 【Upsert 操作】 // upsert = update + insert // 如果记录存在,更新;不存在,插入。 // 回滚到 XML 方案时可直接恢复: // await _executor.ExecuteAsync(Mapper, "upsertDaily", daily); await _db.Ado.ExecuteCommandAsync(UpsertDailySql, BuildParameters(daily)); } /// /// 查询单个长整数值。 /// /// SQL 语句 ID /// 参数 /// 查询结果(默认 0) private async Task QueryLong(string statementId, object parameter) { // 回滚到 XML 方案时可直接恢复: // long? value = await _executor.QuerySingleAsync(Mapper, statementId, parameter); string sql = statementId switch { "countEventByType" => CountEventByTypeSql, "countDistinctVisitor" => CountDistinctVisitorSql, "countDistinctIp" => CountDistinctIpSql, "avgStayMs" => AvgStayMsSql, "countDistinctSessions" => CountDistinctSessionsSql, "countSinglePageSessions" => CountSinglePageSessionsSql, _ => throw Oops.Oh($"不支持的统计语句:{statementId}") }; List rows = await _db.Ado.SqlQueryAsync(sql, BuildParameters(parameter)); return rows.FirstOrDefault() ?? 0L; } private async Task QuerySingleOrDefaultAsync(string sql, object parameter) { List rows = await _db.Ado.SqlQueryAsync(sql, BuildParameters(parameter)); return rows.FirstOrDefault(); } private Task> QueryListAsync(string sql, object parameter) { return _db.Ado.SqlQueryAsync(sql, BuildParameters(parameter)); } private static SugarParameter[] BuildParameters(object parameter) { if (parameter == null) { return Array.Empty(); } if (parameter is SugarParameter[] sugarParameters) { return sugarParameters; } if (parameter is IEnumerable sugarParameterEnumerable) { return sugarParameterEnumerable.ToArray(); } List parameters = new(); foreach (PropertyInfo property in parameter.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) { object value = property.GetValue(parameter); parameters.Add(new SugarParameter($"@{property.Name}", value ?? DBNull.Value)); } return parameters.ToArray(); } /// /// 校验并获取必填文本。 /// /// 【处理逻辑】 /// 1. 调用 NormalizeText 归一化 /// 2. 如果结果为空,抛出异常 /// /// /// 输入文本 /// 最大长度 /// 空值错误消息 /// 归一化后的文本 private static string RequireText(string text, int maxLen, string emptyMessage) { string normalized = NormalizeText(text, maxLen); if (string.IsNullOrWhiteSpace(normalized)) { throw Oops.Oh(emptyMessage); } return normalized; } /// /// 归一化文本(去除空白、截断长度)。 /// /// 【C# 语法知识点 - 字符串截取】 /// normalized[..maxLen] 是范围操作符: // - 从索引 0 开始 // - 到索引 maxLen 结束(不含) // - 等价于 normalized.Substring(0, maxLen) /// /// /// 输入文本 /// 最大长度 /// 归一化后的文本 private static string NormalizeText(string text, int maxLen) { // Trim() 用于去掉首尾空白。 string normalized = text?.Trim(); if (string.IsNullOrWhiteSpace(normalized)) { return null; } // 【范围操作符】 // normalized[..maxLen] 表示从开始到 maxLen 位置。 // 如果长度超过 maxLen,截断;否则返回原字符串。 return normalized.Length > maxLen ? normalized[..maxLen] : normalized; } /// /// 归一化停留时长。 /// /// 【处理逻辑】 /// - 负值转为 0 /// - 超过 7 天的值限制为 7 天(防止异常数据) /// /// /// 停留时长(毫秒) /// 归一化后的时长 private static long? NormalizeStayMs(long? stayMs) { // 【可空值类型】 // long? 是可空的长整型,可以是 null 或 long 值。 // .HasValue 判断是否有值。 // .Value 获取值(如果有值)。 if (!stayMs.HasValue) { return null; } if (stayMs.Value < 0) { return 0L; } // 【限制最大值】 // 7L * 24 * 3600 * 1000 = 7 天的毫秒数 // Math.Min 取较小值,防止异常数据。 return Math.Min(stayMs.Value, 7L * 24 * 3600 * 1000); } /// /// 解析事件时间。 /// /// 【Unix 时间戳转换】 /// 前端传的是 Unix 毫秒时间戳,这里转换成本地 DateTime。 /// /// DateTimeOffset.FromUnixTimeMilliseconds: /// - 把 Unix 毫秒时间戳转换为 DateTimeOffset /// - .LocalDateTime 获取本地时间 /// /// /// Unix 毫秒时间戳 /// 本地时间 private DateTime ResolveEventTime(long? timestamp) { if (!timestamp.HasValue || timestamp.Value <= 0) { return DateTime.Now; } try { // 前端传的是 Unix 毫秒时间戳,这里转换成本地 DateTime。 return DateTimeOffset.FromUnixTimeMilliseconds(timestamp.Value).LocalDateTime; } catch (Exception ex) { // 【结构化日志】 // _logger.LogWarning(ex, "消息 {属性名}", 属性值) // 属性名会被自动替换为属性值,便于日志查询。 // // 对比 Java: // Java: logger.warn("invalid eventTime: {}", timestamp, ex); _logger.LogWarning(ex, "invalid eventTime: {Timestamp}", timestamp); return DateTime.Now; } } /// /// 检测设备类型。 /// /// 【检测逻辑】 /// - 包含 ipad/tablet:平板 /// - 包含 mobile/android/iphone:手机 /// - 其他:桌面 /// /// /// User-Agent 字符串 /// 设备类型 private static string DetectDevice(string ua) { // ToLowerInvariant() 转换为小写(不依赖区域设置)。 string text = (ua ?? string.Empty).ToLowerInvariant(); if (text.Contains("ipad", StringComparison.Ordinal) || text.Contains("tablet", StringComparison.Ordinal)) { return "Tablet"; } if (text.Contains("mobile", StringComparison.Ordinal) || text.Contains("android", StringComparison.Ordinal) || text.Contains("iphone", StringComparison.Ordinal)) { return "Mobile"; } return "Desktop"; } /// /// 构建 IP 哈希值。 /// /// 【隐私保护】 /// Why:出于隐私和合规考虑,不直接存明文 IP,而是落哈希值。 /// /// 哈希算法:SHA256 /// 加盐值:hw-portal-analytics(防止彩虹表攻击) /// /// /// 【C# 语法知识点 - using 语句】 /// using 语句确保资源正确释放: /// - SHA256 实现了 IDisposable 接口 /// - 离开 using 块时自动调用 Dispose() /// /// 对比 Java: /// Java 用 try-with-resources: /// try (MessageDigest md = MessageDigest.getInstance("SHA-256")) { /// ... /// } /// /// /// IP 地址 /// 哈希值(十六进制字符串) private static string BuildIpHash(string ip) { string safeIp = string.IsNullOrWhiteSpace(ip) ? "unknown" : ip; try { // 【SHA256 哈希】 using System.Security.Cryptography.SHA256 sha256 = System.Security.Cryptography.SHA256.Create(); // ComputeHash 计算字节数组的哈希值。 // Encoding.UTF8.GetBytes 把字符串转换为字节数组。 byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes($"{safeIp}|hw-portal-analytics")); // Convert.ToHexString 把字节数组转换为十六进制字符串。 // ToLowerInvariant() 转换为小写。 return Convert.ToHexString(hash).ToLowerInvariant(); } catch { // 【降级处理】 // 如果 SHA256 失败,使用 GetHashCode 作为降级方案。 // GetHashCode 返回 int,ToString("x") 转换为十六进制。 return safeIp.GetHashCode().ToString("x", CultureInfo.InvariantCulture); } } /// /// 归一化排行条数限制。 /// /// 【处理逻辑】 /// - 默认 10 条 /// - 最大 50 条 /// /// /// 排行条数 /// 归一化后的条数 private static int NormalizeRankLimit(int? rankLimit) { if (!rankLimit.HasValue || rankLimit.Value <= 0) { return 10; } return Math.Min(rankLimit.Value, 50); } }