问题:重新收盘后用户手动互换产生的资金记录(ClientCashInCashOut, Action=系统操作-互换)被误删。根因 ClearSwapPositions:400 按 Action 字符串模糊删除,而该 Action 手动/自动共用,表无来源字段区分。 引入点 commit 44e89726(2026-05-14)。 - 新增决策文档(根因/引入时间/三种修复方案/自测方法) - 新增 SwapReEodDeleteManualCashRecordTest 4个测试(探查/诊断/录制红灯/离线校验) - 录制 golden 坐实 bug: trade 1903 手动互换资金记录 id=13032 会被误删
497 lines
26 KiB
C#
497 lines
26 KiB
C#
using Newtonsoft.Json;
|
||
using Newtonsoft.Json.Linq;
|
||
using YLErp.DBModels;
|
||
using YLErp.DBModels.Enums;
|
||
|
||
namespace YLErp.Modules.SwapModule
|
||
{
|
||
/// <summary>
|
||
/// 互换重收盘误删"手动互换"资金记录 - 录制/验证测试(TDD 红灯)
|
||
/// ============================================================================
|
||
/// 背景:
|
||
/// SwapTradeBaseService.ClearSwapPositions 第398-409行按 Action 字符串模糊删除
|
||
/// ClientCashInCashOut,而"系统操作-互换"这个 Action 手动互换和自动互换共用,
|
||
/// 且 ClientCashInCashOut 无来源标记字段 → 重收盘会把用户手动互换的资金记录一并删掉。
|
||
/// 引入点:commit 44e89726(2026-05-14)把 系统操作_互换 加进删除 Action 列表。
|
||
///
|
||
/// TDD 红灯→绿灯:
|
||
/// 红灯(当前):录制一笔"有手动互换"的交易 → 模拟重收盘 → 断言手动资金记录被删(坐实 bug)
|
||
/// 绿灯(修复后):同测试断言通过(手动记录保留,自动互换记录正确清理)
|
||
///
|
||
/// 运行方式:
|
||
/// 全部标 [Ignore]+[TestCategory("DBRecording")],不进 CI。
|
||
/// 手动执行:vstest.console.exe UnitTestProject.dll /TestCaseFilter:"TestCategory=DBRecording"
|
||
/// ============================================================================
|
||
[TestClass]
|
||
public class SwapReEodDeleteManualCashRecordTest
|
||
{
|
||
private static readonly string GoldenDir = Path.Combine(
|
||
AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "SwapReEodDeleteCash");
|
||
|
||
/// <summary>
|
||
/// Step0:探查测试库,列出所有"有手动互换操作"的互换交易,供挑选样本。
|
||
///
|
||
/// 筛选条件(同时满足才是有效复现样本):
|
||
/// 1. swap_event 存在 EventType=互换(3) 且 ClientCashId>0 的记录(手动互换且生成了资金记录)
|
||
/// 2. 该 ClientCashId 在 ClientCashInCashOut 中真实存在(未被删)
|
||
/// 3. 该交易有 eod_swap 记录(已收盘过,才能"重收盘")
|
||
///
|
||
/// 连不上测试库时 Inconclusive(CI 无 DB 环境正常跳过)。
|
||
/// </summary>
|
||
[TestMethod]
|
||
[TestCategory("DBRecording")]
|
||
public void Step0_ListManualSwapTrades()
|
||
{
|
||
YLContext db;
|
||
try { db = DbContextFactory.GetYLDbContext(); }
|
||
catch (Exception ex)
|
||
{
|
||
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// 找所有手动互换事件(EventType=互换=3,且关联了资金记录)
|
||
var manualEvents = db.swap_event
|
||
.Where(x => x.EventType == (int)SwapEventTypeEnum.互换
|
||
&& x.ClientCashId > 0
|
||
&& !x.Invalid)
|
||
.ToList();
|
||
|
||
Console.WriteLine($"=== 手动互换事件(EventType=3, ClientCashId>0)数: {manualEvents.Count} ===\n");
|
||
|
||
// 按交易分组,附加资金记录和 eod 信息
|
||
var byTrade = manualEvents
|
||
.GroupBy(x => x.SwapTradeId)
|
||
.Select(g =>
|
||
{
|
||
var cashIds = g.Select(x => x.ClientCashId).Distinct().ToList();
|
||
var cashRecords = db.ClientCashInCashOut
|
||
.Where(c => cashIds.Contains(c.id)).ToList();
|
||
var eodCount = db.eod_swap.Count(e => e.SwapTradeId == g.Key);
|
||
var autoEvents = db.swap_event.Count(x => x.SwapTradeId == g.Key
|
||
&& x.EventType == (int)SwapEventTypeEnum.自动互换 && !x.Invalid);
|
||
return new
|
||
{
|
||
SwapTradeId = g.Key,
|
||
ManualEventCount = g.Count(),
|
||
CashIds = cashIds,
|
||
CashRecordsFound = cashRecords.Count,
|
||
CashAction = cashRecords.Select(c => c.Action).Distinct().ToList(),
|
||
CashAmounts = cashRecords.Select(c => c.Money).ToList(),
|
||
CashHappenDates = cashRecords.Select(c => c.HappenDate).ToList(),
|
||
EodSwapCount = eodCount,
|
||
AutoSwapEventCount = autoEvents,
|
||
// 关键:事件日期范围(重收盘 valueDate <= 此日期会触发删除)
|
||
MinEventDate = g.Min(x => x.ValueDate),
|
||
MaxEventDate = g.Max(x => x.ValueDate)
|
||
};
|
||
})
|
||
.OrderByDescending(t => t.EodSwapCount > 0) // 优先有eod的(可重收盘)
|
||
.ThenByDescending(t => t.ManualEventCount)
|
||
.ToList();
|
||
|
||
Console.WriteLine($"{"TradeId",8} {"手动事件",8} {"资金记录",8} {"Action",-20} {"金额",14} {"eod",6} {"自动互换",8} {"事件日期范围",-24} {"可复现",6}");
|
||
int reproducible = 0;
|
||
foreach (var t in byTrade.Take(30))
|
||
{
|
||
bool canReproduce = t.EodSwapCount > 0 && t.CashRecordsFound > 0;
|
||
if (canReproduce) reproducible++;
|
||
string actionStr = string.Join("|", t.CashAction);
|
||
string amountStr = t.CashAmounts.Any() ? string.Join("|", t.CashAmounts.Select(m => $"{m:F2}")) : "-";
|
||
string dateRange = $"{t.MinEventDate:yyyy-MM-dd}~{t.MaxEventDate:yyyy-MM-dd}";
|
||
Console.WriteLine($"{t.SwapTradeId,8} {t.ManualEventCount,8} {t.CashRecordsFound,8} {actionStr,-20} {amountStr,14} {t.EodSwapCount,6} {t.AutoSwapEventCount,8} {dateRange,-24} {(canReproduce ? "✓" : "✗"),6}");
|
||
}
|
||
|
||
Console.WriteLine($"\n=== 可复现样本数(有eod+有资金记录): {reproducible} ===");
|
||
if (reproducible == 0)
|
||
{
|
||
Assert.Inconclusive("无可复现样本(需要有 eod + 手动互换资金记录的交易)。请先在测试库构造数据。");
|
||
}
|
||
Assert.IsTrue(reproducible > 0, "应存在可复现样本");
|
||
}
|
||
finally
|
||
{
|
||
db?.Dispose();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Step0b:对单个候选样本做详细诊断,确认"重收盘删除条件"确实会命中手动互换资金记录。
|
||
///
|
||
/// 核心验证(不改任何数据,纯查询):模拟 ClearSwapPositions 第400-404行的删除条件,
|
||
/// 看会命中哪些 ClientCashInCashOut 记录,逐条标注它是"手动互换"还是"自动互换"产生的。
|
||
/// 如果命中列表里有手动互换的记录 → bug 坐实(红灯前置证据)。
|
||
/// </summary>
|
||
[TestMethod]
|
||
[TestCategory("DBRecording")]
|
||
public void Step0b_DiagnoseSingleTradeDeleteCondition()
|
||
{
|
||
// 候选样本(从 Step0 输出中挑选):1903=最新,有自动互换,结构完整
|
||
int tradeId = SampleTradeId;
|
||
|
||
YLContext db;
|
||
try { db = DbContextFactory.GetYLDbContext(); }
|
||
catch (Exception ex)
|
||
{
|
||
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
Console.WriteLine($"===== 诊断 SwapTradeId={tradeId} 的资金记录删除命中情况 =====\n");
|
||
|
||
// 1. 该交易全部资金记录(互换/预付金相关)
|
||
var allCashRecords = db.ClientCashInCashOut
|
||
.Where(x => x.TradeId == tradeId
|
||
&& (x.Action == ClientCashInCashOut.系统操作_互换
|
||
|| x.Action == ClientCashInCashOut.系统操作_预付金返息))
|
||
.OrderBy(x => x.HappenDate).ThenBy(x => x.id)
|
||
.ToList();
|
||
Console.WriteLine($"[1] 该交易全部互换/预付金资金记录: {allCashRecords.Count} 条");
|
||
foreach (var c in allCashRecords)
|
||
{
|
||
Console.WriteLine($" id={c.id} Action={c.Action} Money={c.Money:F2} HappenDate={c.HappenDate:yyyy-MM-dd} OptName={c.OptName} CreateDate={c.CreateDate:yyyy-MM-dd HH:mm}");
|
||
}
|
||
|
||
// 2. 该交易全部 swap_event(区分手动互换 vs 自动互换)
|
||
var allEvents = db.swap_event
|
||
.Where(x => x.SwapTradeId == tradeId && !x.Invalid)
|
||
.OrderBy(x => x.ValueDate).ThenBy(x => x.id)
|
||
.ToList();
|
||
Console.WriteLine($"\n[2] 该交易全部 swap_event: {allEvents.Count} 条");
|
||
foreach (var e in allEvents)
|
||
{
|
||
string etName = ((SwapEventTypeEnum)e.EventType).ToString();
|
||
Console.WriteLine($" id={e.id} EventType={e.EventType}({etName}) ValueDate={e.ValueDate:yyyy-MM-dd} ClientCashId={e.ClientCashId}");
|
||
}
|
||
|
||
// 3. 关键:找出"自动互换"事件,确定重收盘的删除起点 valueDate
|
||
var autoEvents = allEvents.Where(x => x.EventType == (int)SwapEventTypeEnum.自动互换).ToList();
|
||
if (autoEvents.Count == 0)
|
||
{
|
||
Console.WriteLine($"\n⚠ 该交易无自动互换事件,重收盘不会触发 ClearSwapPositions 的资金删除逻辑。");
|
||
Console.WriteLine($" 改用 SwapPositionCompose 的合成持仓路径(delAfter=false)也不删资金。");
|
||
Console.WriteLine($" → 此样本不适合复现,需选有自动互换事件的样本。");
|
||
Assert.Inconclusive("此样本无自动互换事件,请换一个有自动互换的交易。");
|
||
return;
|
||
}
|
||
|
||
// 重收盘时 valueDate 取自动互换事件的最小 ValueDate(重收盘从该日起重算)
|
||
var minAutoDate = autoEvents.Min(x => x.ValueDate);
|
||
Console.WriteLine($"\n[3] 自动互换事件 {autoEvents.Count} 条,最早 ValueDate={minAutoDate:yyyy-MM-dd}(重收盘 valueDate 起点)");
|
||
|
||
// 4. 模拟 ClearSwapPositions 第399-404行的删除条件
|
||
var swapTradeIds = new List<int> { tradeId };
|
||
var actions = new List<string> { ClientCashInCashOut.系统操作_预付金返息, ClientCashInCashOut.系统操作_互换 };
|
||
var wouldDelete = allCashRecords
|
||
.Where(x => x.HappenDate >= minAutoDate && actions.Contains(x.Action))
|
||
.ToList();
|
||
Console.WriteLine($"\n[4] ⚠ 模拟删除条件(HappenDate>={minAutoDate:yyyy-MM-dd} AND Action IN 互换/预付金返息)会命中: {wouldDelete.Count} 条");
|
||
|
||
// 5. 逐条标注命中记录的来源(手动 vs 自动)
|
||
var manualCashIds = allEvents
|
||
.Where(x => x.EventType == (int)SwapEventTypeEnum.互换 && x.ClientCashId > 0)
|
||
.Select(x => x.ClientCashId).ToHashSet();
|
||
var autoCashIds = allEvents
|
||
.Where(x => x.EventType == (int)SwapEventTypeEnum.自动互换 && x.ClientCashId > 0)
|
||
.Select(x => x.ClientCashId).ToHashSet();
|
||
|
||
int manualHit = 0, autoHit = 0, unknownHit = 0;
|
||
Console.WriteLine($" {"id",8} {"Action",-20} {"Money",12} {"HappenDate",-12} {"来源",10} {"⚠误删",6}");
|
||
foreach (var c in wouldDelete)
|
||
{
|
||
string source;
|
||
bool misDelete = false;
|
||
if (manualCashIds.Contains(c.id)) { source = "手动互换"; misDelete = true; manualHit++; }
|
||
else if (autoCashIds.Contains(c.id)) { source = "自动互换"; autoHit++; }
|
||
else { source = "未知(孤儿)"; unknownHit++; }
|
||
Console.WriteLine($" {c.id,8} {c.Action,-20} {c.Money,12:F2} {c.HappenDate?.ToString("yyyy-MM-dd"),-12} {source,-10} {(misDelete ? "✓BUG" : ""),6}");
|
||
}
|
||
|
||
Console.WriteLine($"\n[结论] 删除命中 {wouldDelete.Count} 条 = 手动互换 {manualHit} + 自动互换 {autoHit} + 未知 {unknownHit}");
|
||
if (manualHit > 0)
|
||
{
|
||
Console.WriteLine($"⚠⚠⚠ 坐实 BUG:重收盘会误删 {manualHit} 条手动互换资金记录!");
|
||
}
|
||
Console.WriteLine($"\n(以上为纯查询诊断,未修改任何数据)");
|
||
|
||
Assert.IsTrue(wouldDelete.Count > 0, "删除条件应至少命中1条");
|
||
}
|
||
finally
|
||
{
|
||
db?.Dispose();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 候选样本交易ID。从 Step0 输出中选有自动互换事件 + 有手动互换资金记录的交易。
|
||
/// </summary>
|
||
private int SampleTradeId => 1903;
|
||
|
||
/// <summary>
|
||
/// Step1:录制样本交易快照 + 模拟删除条件,把"会被误删的手动互换资金记录"固化为 golden。
|
||
///
|
||
/// 这是 TDD 红灯的核心产物:
|
||
/// - 录制该交易的 swap_event + ClientCashInCashOut 完整快照
|
||
/// - 模拟 ClearSwapPositions:400-404 的删除条件,算出命中列表
|
||
/// - 标注每条命中记录的来源(手动互换/自动互换/孤儿)
|
||
/// - 断言"命中列表含手动互换记录" → 当前成立(红灯,坐实 bug)
|
||
///
|
||
/// 修复后(绿灯):命中列表应只含自动互换记录,手动互换记录不在内 → 断言失败需更新 golden。
|
||
///
|
||
/// 为何不直接调 SwapPositionCompose:
|
||
/// 那会真删测试库数据且难恢复。录制+模拟条件能等价坐实 bug,又不破坏数据。
|
||
/// </summary>
|
||
[TestMethod]
|
||
[TestCategory("DBRecording")]
|
||
// [Ignore] // 有写文件副作用,手动跑时取消注释
|
||
public void Step1_RecordAndDiagnoseDeleteBug()
|
||
{
|
||
int tradeId = SampleTradeId;
|
||
YLContext db;
|
||
try { db = DbContextFactory.GetYLDbContext(); }
|
||
catch (Exception ex)
|
||
{
|
||
Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}");
|
||
return;
|
||
}
|
||
using (db)
|
||
{
|
||
Directory.CreateDirectory(GoldenDir);
|
||
|
||
Console.WriteLine($"\n========== 录制 SwapTradeId={tradeId}(重收盘误删手动资金记录)==========\n");
|
||
|
||
// 1. 交易主信息
|
||
var trade = db.trade.FirstOrDefault(t => t.id == tradeId);
|
||
Assert.IsNotNull(trade, $"trade {tradeId} 不存在");
|
||
|
||
// 2. swap_event 快照(区分手动互换 vs 自动互换)
|
||
var allEvents = db.swap_event
|
||
.Where(x => x.SwapTradeId == tradeId && !x.Invalid)
|
||
.OrderBy(x => x.ValueDate).ThenBy(x => x.id)
|
||
.ToList();
|
||
|
||
// 3. ClientCashInCashOut 快照(互换/预付金相关,bug 影响范围)
|
||
var allCashRecords = db.ClientCashInCashOut
|
||
.Where(x => x.TradeId == tradeId
|
||
&& (x.Action == ClientCashInCashOut.系统操作_互换
|
||
|| x.Action == ClientCashInCashOut.系统操作_预付金返息))
|
||
.OrderBy(x => x.HappenDate).ThenBy(x => x.id)
|
||
.ToList();
|
||
|
||
// 4. 诊断:模拟删除条件,算出命中列表 + 来源标注
|
||
var diagnosis = DiagnoseDeleteImpact(tradeId, allEvents, allCashRecords);
|
||
Console.WriteLine(diagnosis.Summary);
|
||
|
||
// 5. 序列化 golden
|
||
var golden = new ReEodDeleteCashGoldenModel
|
||
{
|
||
SwapTradeId = tradeId,
|
||
SwapTradeNo = trade.TradeNumber,
|
||
RecordedAt = DateTime.Now,
|
||
SourceDb = "test",
|
||
Purpose = "重收盘误删手动互换资金记录 - TDD红灯证据",
|
||
InputEvents = JArray.FromObject(allEvents, JsonSerializer.Create(JsonSettings)),
|
||
InputCashRecords = JArray.FromObject(allCashRecords, JsonSerializer.Create(JsonSettings)),
|
||
Diagnosis = JObject.FromObject(diagnosis, JsonSerializer.Create(JsonSettings))
|
||
};
|
||
|
||
string filePath = Path.Combine(GoldenDir, $"reeod_delete_trade_{tradeId}.json");
|
||
File.WriteAllText(filePath, JsonConvert.SerializeObject(golden, JsonSettings));
|
||
Console.WriteLine($"\n✅ golden 已保存: {filePath}");
|
||
|
||
// 红灯断言:命中列表必须含手动互换记录(坐实 bug)
|
||
Assert.IsTrue(diagnosis.手动互换误删记录数 > 0,
|
||
$"红灯:重收盘删除条件应误删 {diagnosis.手动互换误删记录数} 条手动互换资金记录 " +
|
||
$"(ids=[{string.Join(",", diagnosis.手动互换误删CashIds)}])。修复后此断言应反转。");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 模拟 ClearSwapPositions:400-404 的删除条件,诊断命中情况。
|
||
/// </summary>
|
||
private DeleteDiagnoseResult DiagnoseDeleteImpact(
|
||
int tradeId,
|
||
List<swap_event> allEvents,
|
||
List<ClientCashInCashOut> allCashRecords)
|
||
{
|
||
var r = new DeleteDiagnoseResult { SwapTradeId = tradeId };
|
||
var lines = new List<string>
|
||
{
|
||
$"--- 重收盘误删诊断 SwapTradeId={tradeId} ---",
|
||
"",
|
||
"[swap_event] 手动互换 vs 自动互换:"
|
||
};
|
||
|
||
foreach (var e in allEvents)
|
||
{
|
||
if (e.EventType == (int)SwapEventTypeEnum.互换 || e.EventType == (int)SwapEventTypeEnum.自动互换)
|
||
{
|
||
lines.Add($" event id={e.id} EventType={((SwapEventTypeEnum)e.EventType).ToString()} " +
|
||
$"ValueDate={e.ValueDate:yyyy-MM-dd} ClientCashId={e.ClientCashId}");
|
||
}
|
||
}
|
||
|
||
// 重收盘删除起点 = 自动互换事件最小 ValueDate(ClearSwapPositions 的 valueDate 入参)
|
||
var autoEvents = allEvents.Where(x => x.EventType == (int)SwapEventTypeEnum.自动互换).ToList();
|
||
r.自动互换事件数 = autoEvents.Count;
|
||
|
||
if (autoEvents.Count == 0)
|
||
{
|
||
r.Summary = string.Join("\n", lines) + "\n\n⚠ 无自动互换事件,删除逻辑不触发。";
|
||
return r;
|
||
}
|
||
|
||
var minAutoDate = autoEvents.Min(x => x.ValueDate);
|
||
r.删除起点ValueDate = minAutoDate;
|
||
|
||
// 来源标注:按 swap_event.ClientCashId 反查
|
||
var manualCashIds = allEvents
|
||
.Where(x => x.EventType == (int)SwapEventTypeEnum.互换 && x.ClientCashId > 0)
|
||
.Select(x => x.ClientCashId).ToHashSet();
|
||
var autoCashIds = autoEvents
|
||
.Where(x => x.ClientCashId > 0)
|
||
.Select(x => x.ClientCashId).ToHashSet();
|
||
|
||
lines.Add("");
|
||
lines.Add($"[删除条件模拟] valueDate={minAutoDate:yyyy-MM-dd} Action IN (系统操作-互换, 系统操作-预付金返息)");
|
||
lines.Add(string.Format(" {0,-8}{1,-22}{2,12}{3,-12}{4,-10}{5,-8}", "id", "Action", "Money", "HappenDate", "来源", "误删?"));
|
||
|
||
// 模拟 ClearSwapPositions:400-404 Where 条件
|
||
var actions = new List<string> { ClientCashInCashOut.系统操作_预付金返息, ClientCashInCashOut.系统操作_互换 };
|
||
var wouldDelete = allCashRecords
|
||
.Where(x => x.HappenDate >= minAutoDate && actions.Contains(x.Action))
|
||
.ToList();
|
||
|
||
r.删除命中总数 = wouldDelete.Count;
|
||
|
||
foreach (var c in wouldDelete)
|
||
{
|
||
string source;
|
||
bool misDelete = false;
|
||
if (manualCashIds.Contains(c.id)) { source = "手动互换"; misDelete = true; r.手动互换误删记录数++; r.手动互换误删CashIds.Add(c.id); }
|
||
else if (autoCashIds.Contains(c.id)) { source = "自动互换"; r.自动互换命中记录数++; }
|
||
else { source = "未知(孤儿)"; r.孤儿命中记录数++; }
|
||
r.命中明细.Add(new DeleteHitItem
|
||
{
|
||
CashId = c.id, Action = c.Action, Money = c.Money ?? 0,
|
||
HappenDate = c.HappenDate, 来源 = source, 会被误删 = misDelete
|
||
});
|
||
lines.Add(string.Format(" {0,-8}{1,-22}{2,12:F2}{3,-12}{4,-10}{5,-8}",
|
||
c.id, c.Action, c.Money, c.HappenDate?.ToString("yyyy-MM-dd"), source, misDelete ? "✓BUG" : ""));
|
||
}
|
||
|
||
lines.Add("");
|
||
lines.Add("[结论]");
|
||
lines.Add($" 删除命中 {r.删除命中总数} 条 = 手动互换 {r.手动互换误删记录数} + 自动互换 {r.自动互换命中记录数} + 孤儿 {r.孤儿命中记录数}");
|
||
if (r.手动互换误删记录数 > 0)
|
||
{
|
||
lines.Add($" ⚠⚠⚠ 坐实 BUG:重收盘会误删 {r.手动互换误删记录数} 条手动互换资金记录 (ids=[{string.Join(",", r.手动互换误删CashIds)}])");
|
||
lines.Add($" 根因:ClearSwapPositions:400 按 Action=系统操作-互换 删除,该 Action 手动/自动共用,无来源字段区分。");
|
||
r.结论 = $"坐实BUG:误删 {r.手动互换误删记录数} 条手动互换资金记录";
|
||
r.BUG成立 = true;
|
||
}
|
||
else
|
||
{
|
||
lines.Add($" 未检测到误删手动互换记录(可能已修复)。");
|
||
r.结论 = "未检测到误删";
|
||
r.BUG成立 = false;
|
||
}
|
||
|
||
r.Summary = string.Join("\n", lines);
|
||
return r;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Step2:离线校验已录制 golden 文件(不连库)。
|
||
/// 确认 json 含完整快照 + 诊断结论能正确反序列化。
|
||
/// 这是唯一不标 [Ignore] 且能进 CI 的测试(纯读文件,无外部依赖)。
|
||
/// </summary>
|
||
[TestMethod]
|
||
[TestCategory("DBRecording")]
|
||
public void Step2_VerifyRecordedGoldenFile()
|
||
{
|
||
if (!Directory.Exists(GoldenDir))
|
||
{
|
||
Assert.Inconclusive($"golden 目录不存在: {GoldenDir}(请先跑 Step1_RecordAndDiagnoseDeleteBug)");
|
||
return;
|
||
}
|
||
var files = Directory.GetFiles(GoldenDir, "reeod_delete_trade_*.json");
|
||
Assert.IsTrue(files.Length > 0, $"应至少有 1 个 golden 文件 in {GoldenDir}");
|
||
|
||
foreach (var file in files)
|
||
{
|
||
var json = File.ReadAllText(file);
|
||
var golden = JsonConvert.DeserializeObject<ReEodDeleteCashGoldenModel>(json);
|
||
|
||
Assert.IsTrue(golden.SwapTradeId > 0, $"{file}: SwapTradeId 无效");
|
||
Assert.IsNotNull(golden.InputEvents, $"{file}: InputEvents 缺失");
|
||
Assert.IsTrue(golden.InputEvents.Count > 0, $"{file}: InputEvents 为空");
|
||
Assert.IsNotNull(golden.InputCashRecords, $"{file}: InputCashRecords 缺失");
|
||
Assert.IsTrue(golden.InputCashRecords.Count > 0, $"{file}: InputCashRecords 为空");
|
||
Assert.IsNotNull(golden.Diagnosis, $"{file}: Diagnosis 缺失");
|
||
|
||
Console.WriteLine($"✅ {Path.GetFileName(file)}: trade={golden.SwapTradeId}, " +
|
||
$"events={golden.InputEvents.Count}条, cash={golden.InputCashRecords.Count}条, " +
|
||
$"BUG成立={golden.Diagnosis?["BUG成立"]?.Value<bool>()}, " +
|
||
$"结论={golden.Diagnosis?["结论"]?.Value<string>()}");
|
||
}
|
||
}
|
||
|
||
private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
|
||
{
|
||
Formatting = Formatting.Indented,
|
||
NullValueHandling = NullValueHandling.Include,
|
||
DateFormatString = "yyyy-MM-ddTHH:mm:ss",
|
||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 重收盘误删 golden 模型:swap_event + ClientCashInCashOut 快照 + 删除命中诊断。
|
||
/// </summary>
|
||
public class ReEodDeleteCashGoldenModel
|
||
{
|
||
public int SwapTradeId { get; set; }
|
||
public string SwapTradeNo { get; set; }
|
||
public DateTime RecordedAt { get; set; }
|
||
public string SourceDb { get; set; }
|
||
public string Purpose { get; set; }
|
||
public JArray InputEvents { get; set; } // swap_event
|
||
public JArray InputCashRecords { get; set; } // ClientCashInCashOut
|
||
public JObject Diagnosis { get; set; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// 重收盘删除命中诊断结果。
|
||
/// </summary>
|
||
public class DeleteDiagnoseResult
|
||
{
|
||
public int SwapTradeId { get; set; }
|
||
public int 自动互换事件数 { get; set; }
|
||
public DateTime 删除起点ValueDate { get; set; }
|
||
public int 删除命中总数 { get; set; }
|
||
public int 手动互换误删记录数 { get; set; }
|
||
public int 自动互换命中记录数 { get; set; }
|
||
public int 孤儿命中记录数 { get; set; }
|
||
public List<long> 手动互换误删CashIds { get; set; } = new List<long>();
|
||
public List<DeleteHitItem> 命中明细 { get; set; } = new List<DeleteHitItem>();
|
||
public bool BUG成立 { get; set; }
|
||
public string 结论 { get; set; }
|
||
public string Summary { get; set; }
|
||
}
|
||
|
||
public class DeleteHitItem
|
||
{
|
||
public long CashId { get; set; }
|
||
public string Action { get; set; }
|
||
public double Money { get; set; }
|
||
public DateTime? HappenDate { get; set; }
|
||
public string 来源 { get; set; }
|
||
public bool 会被误删 { get; set; }
|
||
}
|
||
}
|