diff --git a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs index 07dee68c..8ea8d558 100644 --- a/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs +++ b/YLErpDAL/Modules/RiskEngine/Helper/RiskCalendarHelper.cs @@ -14,11 +14,33 @@ namespace YLErp.Modules.RiskEngine /// public static class RiskCalendarHelper { + private const string CalendarDateFormat = "yyyy,MM,dd"; + + /// + /// 日历数据异常直接影响平仓日期校验和上一交易日取数,单独使用固定 logger 名称便于线上按模块检索。 + /// + private static readonly IYcLogger Logger = LogFactory.GetLogger("RiskCalendarHelper"); + + /// + /// calendar.HolidayJson 历史主格式是 yyyy,MM,dd;这里额外兼容常见日期格式,避免历史数据被静默当作交易日。 + /// 解析后统一转成 DateTime.Date,后续交易日判断不再依赖原始字符串格式。 + /// + private static readonly string[] SupportedCalendarDateFormats = + { + CalendarDateFormat, + "yyyy/MM/dd", + "yyyy-MM-dd", + "yyyyMMdd", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm:ssK", + "yyyy-MM-ddTHH:mm:ss.fffK" + }; + /// /// 按当前风控执行使用的数据库上下文保存日历缓存,同一次风控检查内复用,数据库上下文释放后不阻止缓存被回收。 /// - private static readonly ConditionalWeakTable>> HolidayCaches = - new ConditionalWeakTable>>(); + private static readonly ConditionalWeakTable>> HolidayCaches = + new ConditionalWeakTable>>(); /// /// 获取指定日期的上一银行间交易日。 @@ -48,8 +70,7 @@ namespace YLErp.Modules.RiskEngine var holidayCache = HolidayCaches.GetOrCreateValue(dbContext); var holidays = GetHolidays(dbContext, date.Year, "IB", "银行间", holidayCache); - var dateText = date.Date.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture); - return !holidays.Contains(dateText); + return !holidays.Contains(date.Date); } public static DateTime GetPreviousExchangeTradingDay(YLContext dbContext, DateTime date) @@ -70,10 +91,9 @@ namespace YLErp.Modules.RiskEngine for (var i = 0; i < 370; i++) { var holidays = GetHolidays(dbContext, currentDate.Year, country, calendarName, holidayCache); - var currentDateText = currentDate.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture); // calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是对应市场的交易日。 - if (!holidays.Contains(currentDateText)) + if (!holidays.Contains(currentDate)) return currentDate; currentDate = currentDate.AddDays(-1); @@ -83,32 +103,51 @@ namespace YLErp.Modules.RiskEngine } /// - /// 获取指定年份的银行间非交易日集合。 + /// 获取指定年份的非交易日集合。 /// /// 当前风控执行使用的数据库上下文。 /// 日历年份。 /// 单次风控检查内按市场和年份共享的非交易日缓存。 - /// 格式为 yyyy,MM,dd 的非交易日集合。 - private static HashSet GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary> holidayCache) + /// 按 Date 归一化后的非交易日集合。 + private static HashSet GetHolidays(YLContext dbContext, int year, string country, string calendarName, Dictionary> holidayCache) { - // 缓存需要同时区分市场和年份,避免银行间与交易所同一年日历相互串用。 - var cacheKey = $"{country.ToUpperInvariant()}:{year}"; + var normalizedCountry = NormalizeCountry(country); + var cacheKey = $"{normalizedCountry}:{year}"; if (holidayCache.TryGetValue(cacheKey, out var holidays)) return holidays; - // 在数据库端同时按年份和市场代码筛选,避免加载同一年份的其他市场日历。 - var normalizedCountry = country.ToUpperInvariant(); - var calendar = dbContext.calendar.FirstOrDefault(c => - c.Year == year - && (c.ValidState == null || c.ValidState != ConsGlobal.InValid) - && c.Country != null - && c.Country.ToUpper() == normalizedCountry); + // 先按年份和有效状态缩小范围,再在内存里做 Trim + ToUpperInvariant 匹配,兼容历史 Country 存在大小写或前后空格的情况。 + // 这里没有在数据库查询里直接 Trim,是为了避免不同 EF/数据库提供方对字符串函数翻译不一致。 + var validCalendars = dbContext.calendar + .Where(c => c.Year == year + && (c.ValidState == null || c.ValidState != ConsGlobal.InValid) + && c.Country != null) + .ToList(); + var matchedCalendars = validCalendars.Where(c => NormalizeCountry(c.Country) == normalizedCountry).ToList(); + var calendar = matchedCalendars.FirstOrDefault(); if (calendar == null) - throw new Exception($"未找到{year}年{calendarName}日历"); + { + var availableCountries = string.Join(",", validCalendars.Select(c => c.Country?.Trim()).Where(c => !string.IsNullOrWhiteSpace(c)).Distinct()); + Logger.Error($"[风控日历] 未找到目标市场日历 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, AvailableCountries:{availableCountries}"); + throw new Exception($"未找到{year}年{calendarName}日历,Country={normalizedCountry},当前可用日历:{availableCountries}"); + } + if (matchedCalendars.Count > 1) + { + // 多条匹配不改变原有“取第一条”的行为,只记录数据质量问题,避免线上突然因历史重复配置中断风控。 + Logger.Info($"[风控日历] 同一年存在多个匹配市场日历,使用第一条 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, MatchedIds:{string.Join(",", matchedCalendars.Select(c => c.id))}"); + } + if (!string.Equals(calendar.Country, normalizedCountry, StringComparison.Ordinal)) + { + // Country 能通过归一化匹配说明历史数据存在大小写或空格差异,只在日历首次加载时记录一次,便于后续清洗数据。 + Logger.Info($"[风控日历] 日历Country已归一化匹配 - Year:{year}, CalendarName:{calendarName}, RawCountry:{calendar.Country}, NormalizedCountry:{normalizedCountry}, CalendarId:{calendar.id}"); + } if (string.IsNullOrWhiteSpace(calendar.HolidayJson)) + { + Logger.Error($"[风控日历] HolidayJson为空 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendar.id}"); throw new Exception($"{year}年{calendarName}日历HolidayJson为空"); + } List holidayList; try @@ -117,12 +156,70 @@ namespace YLErp.Modules.RiskEngine } catch (Exception ex) { + Logger.Error($"[风控日历] HolidayJson解析失败 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendar.id}, Error:{ex.Message}"); throw new Exception($"{year}年{calendarName}日历HolidayJson解析失败", ex); } - holidays = new HashSet(holidayList ?? new List()); + // 日历解析和日志统计只在缓存未命中时执行;同一次风控检查内重复判断交易日不会重复解析 HolidayJson。 + holidays = NormalizeHolidays(holidayList, year, calendarName, normalizedCountry, calendar.id); holidayCache[cacheKey] = holidays; return holidays; } + + private static string NormalizeCountry(string country) + { + return (country ?? string.Empty).Trim().ToUpperInvariant(); + } + + private static HashSet NormalizeHolidays(IEnumerable holidayList, int year, string calendarName, string normalizedCountry, int calendarId) + { + var holidays = new HashSet(); + var formatCounts = new Dictionary(); + var rawCount = 0; + var blankCount = 0; + var duplicateCount = 0; + foreach (var holidayText in holidayList ?? Enumerable.Empty()) + { + rawCount++; + if (string.IsNullOrWhiteSpace(holidayText)) + { + blankCount++; + continue; + } + + // 每条原始日期只在缓存加载阶段解析一次;正常数据只累计格式分布,避免大量节假日逐条写日志。 + var holidayDate = ParseHolidayDate(holidayText, year, calendarName, normalizedCountry, calendarId, out var matchedFormat); + if (!holidays.Add(holidayDate)) + { + duplicateCount++; + } + formatCounts[matchedFormat] = formatCounts.TryGetValue(matchedFormat, out var count) ? count + 1 : 1; + } + Logger.Info($"[风控日历] HolidayJson日期归一化完成 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendarId}, RawCount:{rawCount}, HolidayCount:{holidays.Count}, BlankCount:{blankCount}, DuplicateCount:{duplicateCount}, Formats:{string.Join(",", formatCounts.Select(item => item.Key + ":" + item.Value))}"); + return holidays; + } + + private static DateTime ParseHolidayDate(string holidayText, int year, string calendarName, string normalizedCountry, int calendarId, out string matchedFormat) + { + var normalizedHolidayText = holidayText.Trim(); + foreach (var format in SupportedCalendarDateFormats) + { + if (DateTime.TryParseExact(normalizedHolidayText, format, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var holidayDate)) + { + matchedFormat = format; + return holidayDate.Date; + } + } + + // 支持带时区的历史数据;最终取日期部分用于非交易日集合匹配,避免字符串格式差异导致静默漏判。 + if (DateTimeOffset.TryParse(normalizedHolidayText, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var holidayDateOffset)) + { + matchedFormat = "DateTimeOffset.TryParse"; + return holidayDateOffset.Date; + } + + Logger.Error($"[风控日历] HolidayJson日期格式无法识别 - Year:{year}, CalendarName:{calendarName}, Country:{normalizedCountry}, CalendarId:{calendarId}, RawValue:{holidayText}"); + throw new Exception($"{year}年{calendarName}日历HolidayJson存在无法识别的日期格式:{holidayText}"); + } } }