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