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#

// ============================================================================
// 【文件说明】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);
}
}