diff --git a/UnitTestProject/Modules/DataProviderModule/Fr007FixingCacheTest.cs b/UnitTestProject/Modules/DataProviderModule/Fr007FixingCacheTest.cs new file mode 100644 index 00000000..02eeb6ed --- /dev/null +++ b/UnitTestProject/Modules/DataProviderModule/Fr007FixingCacheTest.cs @@ -0,0 +1,105 @@ +using YLErp.Modules.DataProviderModule; + +namespace YLErp.Modules.DataProviderModule +{ + /// + /// Fr007FixingCache 快照缓存行为契约(纯内存,不连库;Loader/时钟均为注入接缝): + /// ① 首次访问批量预载、命中 O(1);② miss 不进快照(负缓存防线——当日发布前 miss、发布后须能查到); + /// ③ 写侧版本失效:Invalidate 后立即重载取到新值(不等 TTL);④ TTL 过期自动重载(直改库兜底); + /// ⑤ TTL 窗口内无写入不重载(零查询稳态)。 + /// + [TestClass] + public class Fr007FixingCacheTest + { + private static readonly DateTime D1 = new(2026, 7, 6); + private static readonly DateTime D2 = new(2026, 7, 13); + private int _loadCount; + private Dictionary _market; + + [TestInitialize] + public void Init() + { + _loadCount = 0; + _market = new Dictionary { [D1] = 0.0142, [D2] = 0.01425 }; + Fr007FixingCache.ResetForTest(); + Fr007FixingCache.LoadSnapshot = () => { _loadCount++; return new Dictionary(_market); }; + } + + [TestCleanup] + public void Cleanup() => Fr007FixingCache.ResetForTest(); + + private static void AdvanceClock(long ticks) => Fr007FixingCache.NowTicks = () => ticks; + + [TestMethod] + public void 首次访问预载并命中() + { + Fr007FixingCache.NowTicks = () => 1_000_000L; + Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p1), "预载后历史定盘应命中"); + Assert.AreEqual(0.0142, p1, 1e-12); + Assert.AreEqual(1, _loadCount, "首次访问恰好装载一次"); + Assert.IsTrue(Fr007FixingCache.TryGet(D2, out var p2), "同快照内多次命中"); + Assert.AreEqual(0.01425, p2, 1e-12); + Assert.AreEqual(1, _loadCount, "命中不应重复装载"); + } + + [TestMethod] + public void miss不进快照_当日新发布经直查兜底后TTL内可见() + { + Fr007FixingCache.NowTicks = () => 1_000_000L; + var today = new DateTime(2026, 8, 18); + Assert.IsFalse(Fr007FixingCache.TryGet(today, out _), "快照无该行应 miss(调用方直查库兜底)"); + Assert.AreEqual(1, _loadCount); + + // 直查库发现了新发布的当日行 → 写侧失效(SwapFlowService 场景)→ 重载后可见 + _market[today] = 0.0143; + Fr007FixingCache.Invalidate(); + Assert.IsTrue(Fr007FixingCache.TryGet(today, out var p), "写侧失效重载后当日行应可见"); + Assert.AreEqual(0.0143, p, 1e-12); + Assert.AreEqual(2, _loadCount); + } + + [TestMethod] + public void 写侧失效立即重载取到修正值_不等TTL() + { + Fr007FixingCache.NowTicks = () => 1_000_000L; + Fr007FixingCache.TryGet(D1, out _); + Assert.AreEqual(1, _loadCount); + + _market[D1] = 0.0150; // 界面修正错价 + Fr007FixingCache.Invalidate(); + + // 时钟只走了 1 tick(远小于 TTL),仍必须重载 + Fr007FixingCache.NowTicks = () => 1_000_001L; + Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p), "修正后仍应命中"); + Assert.AreEqual(0.0150, p, 1e-12, "TTL 未到也必须看到写侧修正值"); + Assert.AreEqual(2, _loadCount, "版本失效应立即触发重载"); + } + + [TestMethod] + public void TTL过期自动重载_直改库兜底() + { + Fr007FixingCache.NowTicks = () => 1_000_000L; + Fr007FixingCache.TryGet(D1, out _); + Assert.AreEqual(1, _loadCount); + + _market[D1] = 0.0160; // 直改库(无 Invalidate) + Fr007FixingCache.NowTicks = () => 1_000_000L + TimeSpan.FromMinutes(1).Ticks + 1; // TTL+1 tick + + Assert.IsTrue(Fr007FixingCache.TryGet(D1, out var p)); + Assert.AreEqual(0.0160, p, 1e-12, "TTL 过期后应重载并看到直改库的新值"); + Assert.AreEqual(2, _loadCount); + } + + [TestMethod] + public void TTL窗口内无写入零重载() + { + Fr007FixingCache.NowTicks = () => 1_000_000L; + Fr007FixingCache.TryGet(D1, out _); + + Fr007FixingCache.NowTicks = () => 1_000_000L + TimeSpan.FromMinutes(1).Ticks - 1; // TTL-1 tick:稳态窗口 + for (int i = 0; i < 10; i++) Fr007FixingCache.TryGet(D1, out _); + + Assert.AreEqual(1, _loadCount, "稳态窗口内多次读取零重载(零查询)"); + } + } +} diff --git a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs index 7792e5e1..c8a73b43 100644 --- a/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs +++ b/YLErpDAL/Modules/DataProviderModule/EodPriceQueryService.cs @@ -149,6 +149,12 @@ namespace YLErp.Modules.DataProviderModule price = 0; valueDate = valueDate.Date; + + // FR007 走整表快照缓存(预载全历史,O(1) 命中零查询)。miss=行尚不存在(当日未发布/补录), + // 继续直查库兜底——miss 永不进快照(负缓存防线,详见 Fr007FixingCache 注释)。 + if (underlyingCode == Fr007FixingCache.UnderlyingCode && Fr007FixingCache.TryGet(valueDate, out price)) + return true; + using var db = DbContextFactory.GetYLDbContext(); var data = db.eod_commodity_future_price.Where(x => x.ValueDate == valueDate && x.UnderlyingCode == underlyingCode).OrderByDescending(o => o.ValueDate).FirstOrDefault(); diff --git a/YLErpDAL/Modules/DataProviderModule/Fr007FixingCache.cs b/YLErpDAL/Modules/DataProviderModule/Fr007FixingCache.cs new file mode 100644 index 00000000..16d6ef20 --- /dev/null +++ b/YLErpDAL/Modules/DataProviderModule/Fr007FixingCache.cs @@ -0,0 +1,96 @@ +using YLErp.Modules; + +namespace YLErp.Modules.DataProviderModule +{ + /// + /// FR007 定盘快照缓存。定盘是"每工作日一个数、发布后不变"的小表(一年约 250 行,全历史数千行), + /// 故采用整表快照而非逐键缓存: + /// + /// 预载:首次访问一次性 SELECT 全历史进字典,此后读取 O(1)、零查询; + /// miss 不进快照(负缓存防线):当日定盘 ~11:15 后才出现、历史可补录—— + /// 字典查不到必须回退直查库,绝不能把"查不到"缓存住,否则发布后仍沿用旧利率; + /// 写侧版本失效:经应用的 FR007 写入(SwapFlowService 新增/修改/删除)立即失效, + /// 下次访问整表重载一次; + /// TTL 1 分钟兜底:覆盖直改库与其他通用价格写入口(导入/复制/合成等), + /// 可见性窗口 ≤1 分钟,重启无需。 + /// + /// 快照以引用替换发布(Volatile.Write),读侧要么看到旧快照要么看到新快照,无撕裂; + /// 重载加锁去抖,避免并发下重复整表查询。 + /// + public static class Fr007FixingCache + { + public const string UnderlyingCode = "FR007"; + + /// TTL:兜底"直接改库/通用写入口"的可见性窗口(FR007 专属写入口走版本失效,不受此限)。 + internal static TimeSpan Ttl = TimeSpan.FromMinutes(1); + + /// 测试接缝:时钟(默认 UtcNow Ticks)。 + internal static Func NowTicks = () => DateTime.UtcNow.Ticks; + + /// 测试接缝:快照装载器(默认直查库)。返回 日期→ReferencePrice 全量映射。 + internal static Func> LoadSnapshot = LoadFromDb; + + private static Dictionary _snapshot = new(); + private static bool _loaded; // 是否已完成首次装载 + private static long _loadedAt; // 快照装载时刻(NowTicks 口径) + private static long _writeVersion; // 写侧版本(Invalidate 递增) + private static long _snapVersion; // 装载时的写侧版本 + private static readonly object _reloadLock = new(); + + private static Dictionary LoadFromDb() + { + using var db = DbContextFactory.GetYLDbContext(); + return db.eod_commodity_future_price + .Where(x => x.UnderlyingCode == UnderlyingCode && x.ReferencePrice != null) + .Select(x => new { x.ValueDate, Price = x.ReferencePrice!.Value }) + .AsEnumerable() + .GroupBy(x => x.ValueDate.Date) + .ToDictionary(g => g.Key, g => g.First().Price); + } + + /// 命中返回 true。miss 仅代表"快照里没有",调用方须直查库兜底(当日新发布/补录)。 + public static bool TryGet(DateTime valueDate, out double price) + { + EnsureFresh(); + return Volatile.Read(ref _snapshot).TryGetValue(valueDate.Date, out price); + } + + /// 写侧失效:FR007 行经应用新增/修改/删除后调用,下次访问整表重载。 + public static void Invalidate() => Interlocked.Increment(ref _writeVersion); + + /// 重置为未装载状态并恢复默认接缝(仅测试用)。 + internal static void ResetForTest() + { + lock (_reloadLock) + { + _snapshot = new Dictionary(); + _loaded = false; + _loadedAt = 0; + _writeVersion = 0; + _snapVersion = 0; + Ttl = TimeSpan.FromMinutes(1); + NowTicks = () => DateTime.UtcNow.Ticks; + LoadSnapshot = LoadFromDb; + } + } + + private static void EnsureFresh() + { + if (IsFresh()) return; + lock (_reloadLock) + { + if (IsFresh()) return; + var snap = LoadSnapshot(); + Volatile.Write(ref _snapshot, snap); + Volatile.Write(ref _loaded, true); + Volatile.Write(ref _loadedAt, NowTicks()); + Volatile.Write(ref _snapVersion, Volatile.Read(ref _writeVersion)); + } + } + + private static bool IsFresh() + => Volatile.Read(ref _loaded) + && Volatile.Read(ref _snapVersion) == Volatile.Read(ref _writeVersion) + && NowTicks() - Volatile.Read(ref _loadedAt) <= Ttl.Ticks; + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapFlowService.cs b/YLErpDAL/Modules/SwapModule/SwapFlowService.cs index ba2eb4b1..0ab56b30 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFlowService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFlowService.cs @@ -10,6 +10,7 @@ using YLErp.Helpers; using YLErp.Model; using YLErp.Model.Enum; using YLErp.Modules.EodModule; +using YLErp.Modules.DataProviderModule; using YLErp.Modules.SwapModule.Dto; using YLErp.Office; using YLErp.QdpModule; @@ -70,6 +71,7 @@ namespace YLErp.Modules.SwapModule } DbContext.eod_commodity_future_price.Remove(frdata); DbContext.SaveChanges(); + Fr007FixingCache.Invalidate(); // 写侧失效:删除当日定盘后缓存立即重载,防旧值沿用 return true; } /// @@ -115,6 +117,7 @@ namespace YLErp.Modules.SwapModule frdata.OptName = UserInfo.UserName; frdata.OptDate = DateTime.Now; DbContext.SaveChanges(); + Fr007FixingCache.Invalidate(); // 写侧失效:新增/修改定盘后缓存立即重载 return true; } diff --git a/YLErpDAL/Properties/AssemblyInfo.cs b/YLErpDAL/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..92f6e1ed --- /dev/null +++ b/YLErpDAL/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +// 测试工程访问 internal 接缝(如 Fr007FixingCache 的时钟/装载器注入),替代把测试钩子暴露为 public。 +[assembly: InternalsVisibleTo("UnitTestProject")]