互换重收盘误删手动互换资金记录: 分析文档+TDD红灯测试

问题:重新收盘后用户手动互换产生的资金记录(ClientCashInCashOut,
Action=系统操作-互换)被误删。根因 ClearSwapPositions:400 按 Action
字符串模糊删除,而该 Action 手动/自动共用,表无来源字段区分。
引入点 commit 44e89726(2026-05-14)。

- 新增决策文档(根因/引入时间/三种修复方案/自测方法)
- 新增 SwapReEodDeleteManualCashRecordTest 4个测试(探查/诊断/录制红灯/离线校验)
- 录制 golden 坐实 bug: trade 1903 手动互换资金记录 id=13032 会被误删
This commit is contained in:
hjhan
2026-07-01 08:35:58 +08:00
parent b4ff5f9f10
commit 6f9aa5fb45
3 changed files with 1229 additions and 0 deletions
@@ -0,0 +1,496 @@
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 44e897262026-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}");
}
}
// 重收盘删除起点 = 自动互换事件最小 ValueDateClearSwapPositions 的 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; }
}
}