You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

739 lines
28 KiB
C#

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// ============================================================================
// 【文件说明】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;
/// <summary>
/// 网站访问统计分析服务。
/// <para>
/// 【服务职责】
/// 1. 收集访问事件(页面浏览、搜索、下载等)
/// 2. 解析 User-Agent 获取设备信息
/// 3. 聚合生成日报统计
/// 4. 提供仪表盘查询接口
/// </para>
/// <para>
/// 【C# 语法知识点 - ITransient 瞬态服务】
/// ITransient 表示每次请求都创建新实例。
/// 分析服务通常是无状态的,适合瞬态模式。
/// </para>
/// </summary>
public class HwAnalyticsService : ITransient
{
/// <summary>
/// MyBatis 映射器名称。
/// </summary>
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
""";
/// <summary>
/// 允许的事件类型集合。
/// <para>
/// 【C# 语法知识点 - HashSet&lt;T&gt; 哈希集合】
/// HashSet 适合做"是否存在"判断,查找效率通常比 List 更高。
///
/// 为什么用 HashSet
/// - Contains 方法是 O(1) 时间复杂度
/// - List 的 Contains 是 O(n) 时间复杂度
///
/// StringComparer.OrdinalIgnoreCase 参数:
/// - 忽略大小写比较
/// - "page_view" 和 "PAGE_VIEW" 被视为相同
/// </para>
/// <para>
/// 【业务说明】
/// 只允许预定义的事件类型,避免脏数据进入统计表:
/// - page_view页面浏览
/// - page_leave离开页面
/// - search_submit提交搜索
/// - download_click点击下载
/// - contact_submit提交联系表单
/// </para>
/// </summary>
private static readonly HashSet<string> AllowedEvents = new(StringComparer.OrdinalIgnoreCase)
{
"page_view", "page_leave", "search_submit", "download_click", "contact_submit"
};
/// <summary>
/// MyBatis 执行器(依赖注入)。
/// </summary>
private readonly HwPortalMyBatisExecutor _executor;
private readonly ISqlSugarClient _db;
/// <summary>
/// 日志记录器(依赖注入)。
/// <para>
/// 【C# 语法知识点 - ILogger&lt;T&gt; 泛型日志接口】
/// ILogger&lt;HwAnalyticsService&gt; 是带类别的日志器:
/// - 日志会自动包含类名前缀
/// - 便于筛选特定类的日志
///
/// 对比 Java
/// Java: private static final Logger logger = LoggerFactory.getLogger(HwAnalyticsService.class);
/// C#: private readonly ILogger&lt;HwAnalyticsService&gt; _logger;
///
/// C# 通过 DI 注入,更易于测试和配置。
/// </para>
/// </summary>
private readonly ILogger<HwAnalyticsService> _logger;
/// <summary>
/// 构造函数(依赖注入)。
/// </summary>
/// <param name="executor">MyBatis 执行器</param>
/// <param name="logger">日志记录器</param>
public HwAnalyticsService(HwPortalMyBatisExecutor executor, ISqlSugarClient db, ILogger<HwAnalyticsService> logger)
{
_executor = executor;
_db = db;
_logger = logger;
}
/// <summary>
/// 收集访问事件。
/// <para>
/// 【处理流程】
/// 1. 校验事件类型是否合法
/// 2. 归一化字段值(去除空白、截断长度)
/// 3. 解析 User-Agent 获取设备信息
/// 4. 对 IP 地址进行哈希处理
/// 5. 写入明细事件表
/// 6. 刷新日报统计
/// </para>
/// </summary>
/// <param name="request">事件请求</param>
/// <param name="requestIp">请求 IP</param>
/// <param name="requestUserAgent">请求 User-Agent</param>
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);
}
/// <summary>
/// 获取仪表盘统计数据。
/// <para>
/// 【返回数据】
/// - PV页面浏览量
/// - UV独立访客数
/// - IP UV独立 IP 数
/// - 跳出率:只浏览一页的会话占比
/// - 热门页面排行
/// - 搜索关键词排行
/// </para>
/// </summary>
/// <param name="statDate">统计日期</param>
/// <param name="rankLimit">排行条数限制</param>
/// <returns>仪表盘数据</returns>
public async Task<AnalyticsDashboardDTO> 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&lt;T&gt; 查询单条记录。
// 如果查询结果为空,返回 null。
// 回滚到 XML 方案时可直接恢复:
// HwWebVisitDaily daily = await _executor.QuerySingleAsync<HwWebVisitDaily>(Mapper, "selectDailyByDate", new { statDate = targetDate });
HwWebVisitDaily daily = await QuerySingleOrDefaultAsync<HwWebVisitDaily>(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<AnalyticsRankItemDTO>(Mapper, "selectTopEntryPages", new { statDate = targetDate, limit = topN }),
EntryPages = await QueryListAsync<AnalyticsRankItemDTO>(SelectTopEntryPagesSql, new { statDate = targetDate, limit = topN }),
// 回滚到 XML 方案时可直接恢复:
// HotPages = await _executor.QueryListAsync<AnalyticsRankItemDTO>(Mapper, "selectTopHotPages", new { statDate = targetDate, limit = topN }),
HotPages = await QueryListAsync<AnalyticsRankItemDTO>(SelectTopHotPagesSql, new { statDate = targetDate, limit = topN }),
// 回滚到 XML 方案时可直接恢复:
// HotKeywords = await _executor.QueryListAsync<AnalyticsRankItemDTO>(Mapper, "selectTopKeywords", new { statDate = targetDate, limit = topN })
HotKeywords = await QueryListAsync<AnalyticsRankItemDTO>(SelectTopKeywordsSql, new { statDate = targetDate, limit = topN })
};
return dashboard;
}
/// <summary>
/// 刷新日报统计。
/// <para>
/// 【聚合逻辑】
/// 从明细表聚合以下指标:
/// - PV页面浏览次数
/// - UV独立访客数按 visitorId 去重)
/// - IP UV独立 IP 数(按 ipHash 去重)
/// - 平均停留时长
/// - 搜索次数
/// - 下载次数
/// - 跳出率
/// </para>
/// </summary>
/// <param name="statDate">统计日期</param>
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));
}
/// <summary>
/// 查询单个长整数值。
/// </summary>
/// <param name="statementId">SQL 语句 ID</param>
/// <param name="parameter">参数</param>
/// <returns>查询结果(默认 0</returns>
private async Task<long> QueryLong(string statementId, object parameter)
{
// 回滚到 XML 方案时可直接恢复:
// long? value = await _executor.QuerySingleAsync<long?>(Mapper, statementId, parameter);
string sql = statementId switch
{
"countEventByType" => CountEventByTypeSql,
"countDistinctVisitor" => CountDistinctVisitorSql,
"countDistinctIp" => CountDistinctIpSql,
"avgStayMs" => AvgStayMsSql,
"countDistinctSessions" => CountDistinctSessionsSql,
"countSinglePageSessions" => CountSinglePageSessionsSql,
_ => throw Oops.Oh($"不支持的统计语句:{statementId}")
};
List<long?> rows = await _db.Ado.SqlQueryAsync<long?>(sql, BuildParameters(parameter));
return rows.FirstOrDefault() ?? 0L;
}
private async Task<T> QuerySingleOrDefaultAsync<T>(string sql, object parameter)
{
List<T> rows = await _db.Ado.SqlQueryAsync<T>(sql, BuildParameters(parameter));
return rows.FirstOrDefault();
}
private Task<List<T>> QueryListAsync<T>(string sql, object parameter)
{
return _db.Ado.SqlQueryAsync<T>(sql, BuildParameters(parameter));
}
private static SugarParameter[] BuildParameters(object parameter)
{
if (parameter == null)
{
return Array.Empty<SugarParameter>();
}
if (parameter is SugarParameter[] sugarParameters)
{
return sugarParameters;
}
if (parameter is IEnumerable<SugarParameter> sugarParameterEnumerable)
{
return sugarParameterEnumerable.ToArray();
}
List<SugarParameter> 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();
}
/// <summary>
/// 校验并获取必填文本。
/// <para>
/// 【处理逻辑】
/// 1. 调用 NormalizeText 归一化
/// 2. 如果结果为空,抛出异常
/// </para>
/// </summary>
/// <param name="text">输入文本</param>
/// <param name="maxLen">最大长度</param>
/// <param name="emptyMessage">空值错误消息</param>
/// <returns>归一化后的文本</returns>
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;
}
/// <summary>
/// 归一化文本(去除空白、截断长度)。
/// <para>
/// 【C# 语法知识点 - 字符串截取】
/// normalized[..maxLen] 是范围操作符:
// - 从索引 0 开始
// - 到索引 maxLen 结束(不含)
// - 等价于 normalized.Substring(0, maxLen)
/// </para>
/// </summary>
/// <param name="text">输入文本</param>
/// <param name="maxLen">最大长度</param>
/// <returns>归一化后的文本</returns>
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;
}
/// <summary>
/// 归一化停留时长。
/// <para>
/// 【处理逻辑】
/// - 负值转为 0
/// - 超过 7 天的值限制为 7 天(防止异常数据)
/// </para>
/// </summary>
/// <param name="stayMs">停留时长(毫秒)</param>
/// <returns>归一化后的时长</returns>
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);
}
/// <summary>
/// 解析事件时间。
/// <para>
/// 【Unix 时间戳转换】
/// 前端传的是 Unix 毫秒时间戳,这里转换成本地 DateTime。
///
/// DateTimeOffset.FromUnixTimeMilliseconds
/// - 把 Unix 毫秒时间戳转换为 DateTimeOffset
/// - .LocalDateTime 获取本地时间
/// </para>
/// </summary>
/// <param name="timestamp">Unix 毫秒时间戳</param>
/// <returns>本地时间</returns>
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;
}
}
/// <summary>
/// 检测设备类型。
/// <para>
/// 【检测逻辑】
/// - 包含 ipad/tablet平板
/// - 包含 mobile/android/iphone手机
/// - 其他:桌面
/// </para>
/// </summary>
/// <param name="ua">User-Agent 字符串</param>
/// <returns>设备类型</returns>
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";
}
/// <summary>
/// 构建 IP 哈希值。
/// <para>
/// 【隐私保护】
/// Why出于隐私和合规考虑不直接存明文 IP而是落哈希值。
///
/// 哈希算法SHA256
/// 加盐值hw-portal-analytics防止彩虹表攻击
/// </para>
/// <para>
/// 【C# 语法知识点 - using 语句】
/// using 语句确保资源正确释放:
/// - SHA256 实现了 IDisposable 接口
/// - 离开 using 块时自动调用 Dispose()
///
/// 对比 Java
/// Java 用 try-with-resources
/// try (MessageDigest md = MessageDigest.getInstance("SHA-256")) {
/// ...
/// }
/// </para>
/// </summary>
/// <param name="ip">IP 地址</param>
/// <returns>哈希值(十六进制字符串)</returns>
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 返回 intToString("x") 转换为十六进制。
return safeIp.GetHashCode().ToString("x", CultureInfo.InvariantCulture);
}
}
/// <summary>
/// 归一化排行条数限制。
/// <para>
/// 【处理逻辑】
/// - 默认 10 条
/// - 最大 50 条
/// </para>
/// </summary>
/// <param name="rankLimit">排行条数</param>
/// <returns>归一化后的条数</returns>
private static int NormalizeRankLimit(int? rankLimit)
{
if (!rankLimit.HasValue || rankLimit.Value <= 0)
{
return 10;
}
return Math.Min(rankLimit.Value, 50);
}
}