Files
zszq-trs/项目文档/互换重收盘误删手动互换资金记录问题分析.md
hjhan 6f9aa5fb45 互换重收盘误删手动互换资金记录: 分析文档+TDD红灯测试
问题:重新收盘后用户手动互换产生的资金记录(ClientCashInCashOut,
Action=系统操作-互换)被误删。根因 ClearSwapPositions:400 按 Action
字符串模糊删除,而该 Action 手动/自动共用,表无来源字段区分。
引入点 commit 44e89726(2026-05-14)。

- 新增决策文档(根因/引入时间/三种修复方案/自测方法)
- 新增 SwapReEodDeleteManualCashRecordTest 4个测试(探查/诊断/录制红灯/离线校验)
- 录制 golden 坐实 bug: trade 1903 手动互换资金记录 id=13032 会被误删
2026-07-01 08:35:58 +08:00

421 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 互换重收盘误删"手动互换"资金记录:问题分析与修复方案
> 本文分析互换交易"重新收盘/重算 EOD"后,**用户手动互换产生的资金记录被错误删除**的问题:根因、引入时间、三种修复方案与自测方法。
> 成文于 2026-07-01,排查范围覆盖 `5f97909f`2026-04-29)至当前 HEAD。
---
## 一、问题概述
互换交易每次"重新收盘"(EOD 重算)时,会先清理旧数据再重算。当前实现会把**用户手动互换**产生的资金记录(`ClientCashInCashOut` 表中 `Action=系统操作-互换` 的记录)**一并删除**,导致历史资金流水丢失。
**复现条件**:一笔互换交易,用户已手动做过互换(EventType=3,生成 `Action=系统操作-互换` 资金记录),随后对该交易日重新收盘 → 该手动互换资金记录消失。
---
## 二、先澄清概念:哪些表是什么
| 表名 | 实体类 | 用途 | 与本问题关系 |
|------|--------|------|-------------|
| `ClientCashInCashOut` | `ClientCashInCashOut.cs:232` | **资金记录/出入金流水**(金额、方向、银行账户)| ⚠️ **被误删的就是这张表** |
| `swap_event` | `SwapEvent.cs` | 互换事件主记录(含 `ClientCashId` 外键)| 删除时按 EventType 过滤,安全 |
| `swap_flow_event` | `SwapFlowEvent.cs:14` | 开平仓事件明细(盈亏/计息),通过 `ClientCashId` 关联资金记录 | EOD 清理时按 EventType 过滤,**未误删** |
| ~~swap_capital_flow / swap_fund_flow~~ | 不存在 | —— | 代码库中无此表 |
**关键**`swap_flow_event` 不是"资金记录",它和 `ClientCashInCashOut` 通过 `ClientCashId` 字段 1:1 关联。本问题中 `swap_flow_event` 没被误删,被误删的是 `ClientCashInCashOut`
---
## 三、根因:删除逻辑按 Action 字符串过滤,无法区分手动/自动
### 问题代码位置
`YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs``ClearSwapPositions` 方法,**第 398409 行**
```csharp
// SwapTradeBaseService.cs:398-409bug 所在)
var swapTradeIds = swapEvents.Select(s => s.SwapTradeId).Distinct().ToList();
var actions = new List<string>() { ClientCashInCashOut.系统操作_预付金返息, ClientCashInCashOut.系统操作_互换 };
var premiumCashRecords = DbContext.ClientCashInCashOut
.Where(x => swapTradeIds.Contains(x.TradeId ?? 0)
&& x.HappenDate >= valueDate
&& actions.Contains(x.Action))
.ToList();
if (premiumCashRecords.Any())
{
DbContext.ClientCashInCashOut.RemoveRange(premiumCashRecords);
}
```
### 为什么会误删
`系统操作_互换` 这个 Action 字符串**手动互换和自动互换共用**,而 `ClientCashInCashOut` 表**没有任何字段区分手动 vs 自动来源**:
| 来源 | 写入位置 | Action 值 |
|------|---------|----------|
| 手动互换 `SwapIncome` | `SwapDealService.cs:1471` | `系统操作-互换` |
| 手动互换 `ApproveSwapTrade`(审批通过)| `SwapDealService.cs:1519` | `系统操作-互换` |
| 自动互换(利息腿)| `SwapEodPositionService.cs:516` | `系统操作-互换` |
| 自动互换(分红)| `SwapEodPositionService.cs:531` | `系统操作-互换` |
`AddClientCashInCashOut``SwapTradeBaseService.cs:285-318`)生成资金记录时**不设任何来源标记**。所以按 `TradeId + HappenDate>=valueDate + Action` 删除,无法过滤掉手动互换的记录。
> 注:第 391396 行按 `swap_event.ClientCashId` 精准删除是**安全**的(只删自动互换 event 自己链接的那条)。**第 398409 行的"按 Action 兜底模糊删除"才是误删元凶。**
---
## 四、引入时间线:是"修每次收盘多出一条"修出来的新 bug
本 bug 是修复"重复收盘导致互换收入重复计入"时,删除范围逐步扩大,最终修过头引入的。三次提交演进:
| 时间 | 提交 | 作者 | 改动 | 性质 |
|------|------|------|------|------|
| **2026-04-29** | `5f97909f` | hjhan | `fix(swap): 修复重复收盘导致互换收入(含预付金利息)重复计入的问题`——在删 `swap_event` 前按 `swap_event.ClientCashId` 精准删除关联资金记录 | ✅ **正确** |
| **2026-05-11** | `ebe711d3` | 吴方海 | `#EQD-5850 预付金返息的发生时间有问题`——自动互换把预付金返息单独拆条记录,并在删除时**新增按 `TradeId + 日期 + Action=预付金返息` 批删** | ⚠️ 首次把删除从"按主键ID"放宽到"按Action字符串" |
| **2026-05-14** | `44e89726` | 吴方海 | `收盘自动互换,需要将当前计息基数给回到持仓名义本金或固定值`——**把 `系统操作_互换` 加进删除 Action 列表** | ❌ **误删手动互换的直接引入点** |
`44e89726` 的 diff(实测验证)只改一行:
```diff
- && x.Action == ClientCashInCashOut.系统操作_预付金返息)
+ var actions = new List<string>() { ClientCashInCashOut.系统操作_预付金返息, ClientCashInCashOut.系统操作_互换 };
+ && actions.Contains(x.Action))
```
加进去的理由是"自动互换的互换主金额也要清掉重算",但没意识到 `系统操作_互换` 跟手动互换共用。
---
## 五、约束分析:为什么不能简单回退到 `5f97909f`
`5f97909f` 的精准删除(按 `swap_event.ClientCashId`)在**当时**是对的,但现在的自动互换逻辑已演进——**一次自动互换会生成多条资金记录**:
`SaveAutoSwapDeal``SwapEodPositionService.cs:506`)按条件最多生成 3 条 `ClientCashInCashOut`
- `:516` 利息腿 `系统操作_互换``SwapCloseAmount != 0` 时)
- `:522` 预付金 `系统操作_预付金返息``SwapMarginRebatePnl != 0` 时)
- `:531` 分红 `系统操作_互换``SwapDividendPnl != 0` 时)
`swap_event.ClientCashId``:535`)**只指向最后一条非 0 的 id**,前面的被覆盖。所以单纯按 `ClientCashId` 删,会**漏删**自动互换的前几条资金记录 → 复发"每次收盘多出一条"老 bug(`5f97909f` 当时修的那个)。
> 手动互换也有同样结构:`SwapIncome:1471` 生成主金额(id 存进 event),`:1474` 预付金返息的 id 被丢弃。手动互换若被精准删除只删主金额那条,预付金返息会成"孤儿"残留——但手动互换**本就不该被重收盘删除**,所以这反而是"正确残留"。
---
## 六、修复方案(三选一,供团队决策)
### 方案 A(推荐,最小改动 + 治本):按 swap_event 链 + EventType 区分
**思路**:删除自动互换资金记录时,**只删与自动互换 swap_event 关联的记录**,不碰手动互换的 swap_eventEventType=3/互换)。
删除条件从"按 Action 字符串"改为"按 `swap_event.ClientCashId` 集合",并扩展 `swap_event` 与资金记录的关联(让被覆盖的前几条也能被找到):
```csharp
// 修复后(SwapTradeBaseService.cs:398-409 替换)
// 注意:本方法入参 eventTypes 调用方只传了 {自动互换}SwapEodPositionService.cs:92),
// 所以 swapEvents 只含自动互换事件,手动互换事件不在内 —— 这就是安全的关键。
var swapEventIds = swapEvents.Select(s => s.id).ToList();
if (swapEventIds.Any())
{
// 已有:按 swap_event.ClientCashId 精准删除主金额那条(保留第391-396行不变)
// 新增:按 swap_flow_event.ClientCashId 反查自动互换生成的全部资金记录
// 自动互换的每条 swap_flow_event 都带 ClientCashId(指向其资金记录)
var autoCashIds = DbContext.swap_flow_event
.Where(x => swapEventIds.Contains(x.EventId ?? 0) && x.ClientCashId > 0)
.Select(x => x.ClientCashId.Value)
.Distinct()
.ToList();
// 合并 swap_event.ClientCashId(主金额)
var eventCashIds = swapEvents.Where(s => s.ClientCashId > 0).Select(s => s.ClientCashId.Value);
autoCashIds = autoCashIds.Union(eventCashIds).ToList();
if (autoCashIds.Any())
{
var autoCashRecords = DbContext.ClientCashInCashOut
.Where(x => autoCashIds.Contains(x.id))
.ToList();
DbContext.ClientCashInCashOut.RemoveRange(autoCashRecords);
}
}
// ❌ 删除原来的"按 Action 字符串模糊删除"整段(第398-409行)
```
**⚠️ 关键前提缺陷(已确认)**:当前 `SaveAutoSwapDeal:538-542` 生成 flow_event 时**只设了 `EventId`,没有给 `ClientCashId` 赋值**
```csharp
// SwapEodPositionService.cs:538-542(现状,缺 ClientCashId 赋值)
flowEvents.ForEach(x =>
{
x.EventId = swapEvent.id;
DbContext.swap_flow_event.Add(x);
});
```
对比平仓路径 `ComposePage:685-686` 是有赋值的(`x.ClientCashId = clientCashId`)。所以**方案 A 必须同时补这个赋值**才能落地,否则反查 `swap_flow_event.ClientCashId` 会漏(复发"每次收盘多出一条"老 bug)。这是方案 A 的硬性前置改动,见第七节"改动 2"。
**优点**
- 删除范围严格限定在自动互换事件链内,手动互换零误伤。
- 不新增字段,不改数据库结构。
- 语义清晰:删哪个 event,就删它关联的资金记录。
**风险**
- 依赖 `swap_flow_event.ClientCashId` 的赋值完整性——当前自动互换分支未设,需补(改动 2)。
- **资金记录与 flow_event 不是 1:1**(已确认):
- `:420` 普通自动互换:生成 2 条资金记录(利息汇总 `:516` + 预付金汇总 `:522`),flow_event 是利息明细腿(多条)。
- `:498` 分红型自动互换:生成 1 条资金记录(分红汇总 `:531`),flow_event 是分红明细腿(多条)。
- 所以一条资金记录会被多条 flow_event 引用——改动 2 给 flow_event 设 ClientCashId 时,让同一笔互换的所有明细腿都指向该次生成的资金记录 id(利息腿指向 `:516` 的 id,预付金腿指向 `:522` 的 id)。删除时 `Distinct()` 去重即可。
- 由于 `clientCashId` 变量在 `SaveAutoSwapDeal:516/522/531` 被多次覆盖,最终 `swap_event.ClientCashId``:535`)只存最后一条。**改动 2 必须在每次 `AddClientCashInCashOut` 后立即把 id 赋给对应的 flow_event 组**,不能依赖最后的 `clientCashId` 值。需配套单测兜底(见第八节)。
---
### 方案 B(保守):保留 Action 过滤 + 加来源标记字段
**思路**:给 `ClientCashInCashOut` 加一个来源字段区分手动/自动,删除时排除手动。
```csharp
// 1. 数据库迁移:ClientCashInCashOut 加列
ALTER TABLE ClientCashInCashOut ADD COLUMN Source VARCHAR(20) DEFAULT '手动';
// 2. AddClientCashInCashOut 增加来源参数(SwapTradeBaseService.cs:285
public int AddClientCashInCashOut(OtcTradeBase td, double amount, string action, DateTime valueDate, string source = "手动")
{
...
ee.Source = source; // 新增
...
}
// 3. 自动互换调用处传 source="自动"SwapEodPositionService.cs:516/522/531
// 4. 删除过滤加排除条件(SwapTradeBaseService.cs:401-404
.Where(x => swapTradeIds.Contains(x.TradeId ?? 0)
&& x.HappenDate >= valueDate
&& actions.Contains(x.Action)
&& x.Source == "自动") // 新增:只删自动生成的
```
**优点**:删除逻辑最直白,改一处过滤条件即可。
**缺点**
- 需要数据库迁移(生产加列),风险高。
- 历史数据 `Source` 为默认值"手动",但历史自动互换记录也是 NULL/默认 → 需数据回填脚本,否则老 bug 数据残留。
- `Source` 字段语义需长期维护。
---
### 方案 C(最长远,工程量大):用 swap_event 作为唯一删除入口
**思路**:所有资金记录都必须挂到某个 `swap_event` 上(通过 `swap_flow_event.ClientCashId` 或新增 `ClientCashInCashOut.SwapEventId`),删除时一律按 event 链,彻底废弃按 Action 字符串删除。
**优点**:语义最清晰,根除"按 Action 字符串删除"这类隐患。
**缺点**:改动面大,需梳理所有生成资金记录的路径,确保都有 event 挂载。适合作为长期重构目标,不适合当下快速止血。
---
### 方案对比
| 维度 | 方案A(推荐)| 方案B | 方案C |
|------|-------------|-------|-------|
| 改动量 | 小(1个方法 + flow_event赋值补全)| 中(加列+迁移+多处调用)| 大(全链路重构)|
| 数据库迁移 | 否 | **是** | 视实现 |
| 治本程度 | 高 | 高 | 最高 |
| 风险 | 需确认 flow_event.ClientCashId 完整性 | 迁移风险+历史数据回填 | 改动面大 |
| 止血速度 | 快 | 慢 | 慢 |
---
## 七、具体小修复代码(方案 A
### 改动 1`SwapTradeBaseService.cs:398-409` 替换删除逻辑
```csharp
// ❌ 删除原第398-409行(按 Action 字符串模糊删除)
// ✅ 替换为:按自动互换 swap_event 链精准删除
// 自动互换一次可能生成多条资金记录(利息腿/预付金/分红),
// 但 swap_event.ClientCashId 只指向最后一条。通过 swap_flow_event.ClientCashId
// 反查自动互换事件关联的全部资金记录,避免误伤手动互换(EventType=3)。
var autoFlowCashIds = DbContext.swap_flow_event
.Where(x => eventIds.Contains(x.EventId ?? 0) && x.ClientCashId > 0)
.Select(x => x.ClientCashId.Value)
.Distinct()
.ToList();
var autoEventCashIds = swapEvents.Where(s => s.ClientCashId > 0).Select(s => s.ClientCashId.Value).ToList();
autoFlowCashIds.AddRange(autoEventCashIds);
autoFlowCashIds = autoFlowCashIds.Distinct().ToList();
if (autoFlowCashIds.Any())
{
var autoCashRecords = DbContext.ClientCashInCashOut
.Where(x => autoFlowCashIds.Contains(x.id))
.ToList();
DbContext.ClientCashInCashOut.RemoveRange(autoCashRecords);
}
```
> 说明:`eventIds` 已在第 377 行算好(`swapEvents.Select(s => s.id)`)。本方法入参 `eventTypes` 在重收盘入口(`SwapEodPositionService.cs:92`)只传 `{自动互换}`,所以 `swapEvents` 只含自动互换事件,手动互换事件天然不在删除范围内 —— 这是安全的根本保证。
### 改动 2(前提):补全 `swap_flow_event.ClientCashId` 赋值
自动互换 `SaveAutoSwapDeal``SwapEodPositionService.cs:506`)当前生成 flow_event 时只设了 `EventId`,没设 `ClientCashId``:538-542``:549-553`)。需补上,让方案 A 反查能找到全部资金记录。
由于资金记录是"汇总"的(利息汇总/预付金汇总/分红汇总各 1 条),而 flow_event 是"明细腿"(多条),赋值规则是:**同一笔互换的明细腿都指向该次生成的资金记录 id**。需要在 `SaveAutoSwapDeal` 内用独立变量保存每条资金记录 id(不能依赖被覆盖的 `clientCashId`):
```csharp
// SwapEodPositionService.cs:512-532 改造(用独立变量分别记录每个资金记录 id)
int interestCashId = 0; // 利息腿资金记录 id
int premiumCashId = 0; // 预付金腿资金记录 id
int dividendCashId = 0; // 分红资金记录 id
if (unwindData.SwapCloseAmount != 0)
{
interestCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapCloseAmount), ClientCashInCashOut.系统操作_互换, cashHappenDate);
}
if (unwindData.SwapMarginRebatePnl != 0)
{
premiumCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapMarginRebatePnl), ClientCashInCashOut.系统操作_预付金返息, unwindData.ValueDate);
}
unwindData.SwapCloseAmount = unwindData.SwapRealizedPnL;
if (unwindData.SwapDividendPnl != 0)
{
var dividendPayDate = (dividendEvents != null && dividendEvents.Count > 0)
? dividendEvents.First().PayDate.Value : unwindData.ValueDate;
dividendCashId = AddClientCashInCashOut(td, Convert.ToDouble(-unwindData.SwapDividendPnl), ClientCashInCashOut.系统操作_互换, dividendPayDate);
}
// 保留 clientCashId 给 swap_event(取最后一条非0,与原行为一致)
int clientCashId = dividendCashId != 0 ? dividendCashId : (premiumCashId != 0 ? premiumCashId : interestCashId);
```
然后在 flow_event 赋值处按腿类型设 `ClientCashId`
```csharp
// SwapEodPositionService.cs:536-555flowEvents 是利息明细腿,指向利息资金记录)
if (flowEvents != null)
{
flowEvents.ForEach(x =>
{
x.EventId = swapEvent.id;
x.ClientCashId = interestCashId; // 补:利息明细腿指向利息汇总资金记录
DbContext.swap_flow_event.Add(x);
});
UpdateInitalPostion(flowEvents, td.id);
}
// dividendEvents 是分红明细腿,指向分红资金记录
if (dividendEvents != null)
{
dividendEvents.ForEach(x =>
{
x.EventId = swapEvent.id;
x.ClientCashId = dividendCashId; // 补:分红明细腿指向分红资金记录
DbContext.swap_flow_event.Add(x);
});
UpdateInitalPostion(dividendEvents, td.id);
}
```
> ⚠️ 预付金资金记录 `premiumCashId` 没有对应的 flow_event 可挂载(预付金是单独汇总的,无明细腿)。它只能靠 `swap_event.ClientCashId`(当它是最后一条时)或方案 A 删除逻辑里单独处理。**实施时需验证:当只有预付金非0时,`swap_event.ClientCashId` 是否指向预付金那条**(按上面 `clientCashId` 三元表达式,`premiumCashId != 0` 时会取它,✓ 覆盖)。
---
## 八、如何自测
### 自测 1:SQL 验证(最快,纯查询)
找一笔**当天有手动互换操作**的互换交易,重收盘前后对比资金记录:
```sql
-- 步骤1:盘前记录手动互换资金记录的 id 快照
SELECT id, TradeId, HappenDate, Action, Money, State, OptName, OptTime
FROM ClientCashInCashOut
WHERE TradeId = <手动互换过的交易ID>
AND Action = '系统操作-互换'
ORDER BY id;
-- 记下这些 id(比如 id 列表 = 1001, 1002
-- 步骤2:对该交易日执行重收盘(前端操作或调 SwapPositionCompose
-- 步骤3:盘后再查同样的 id
SELECT id, TradeId, Action, Money
FROM ClientCashInCashOut
WHERE id IN (1001, 1002); -- 替换为步骤1记下的 id
```
**判断标准**
- **修复前(bug)**:步骤3 查不到记录(被 `RemoveRange` 删除)。
- **修复后(正确)**:步骤3 记录原样存在(id/Money/OptName 不变)。
### 自测 2:守恒校验(验证没修过头 → 自动互换仍能清理)
确保修复后**自动互换的重复资金记录仍被清理**,否则"每次收盘多出一条"老 bug 复发:
```sql
-- 对一笔纯自动互换交易,连续重收盘 3 次,每次收盘后查记录数
SELECT COUNT(*) AS cnt
FROM ClientCashInCashOut
WHERE TradeId = <自动互换交易ID>
AND Action IN ('系统操作-互换', '系统操作-预付金返息')
AND HappenDate >= '<收盘日>';
```
**判断**:连跑 3 次后 `cnt` 应**稳定不变**(每次清掉重算),**不应**线性递增(若递增说明自动互换资金漏删,老 bug 复发)。
### 自测 3:录制测试(复用已有 golden 模式,可进回归)
参考本仓库 `UnitTestProject/Modules/SwapModule/SwapDividendGoldenRecordTest.cs` 的录制模式(连真实测试库 `192.168.2.96/glms_yltrs_ylcms`),骨架:
```csharp
[Ignore] // 有写副作用,标 Ignore 不进 CI;手动跑
[TestCategory("DBRecording")]
public void ReEod_ShouldNotDeleteManualSwapCashRecord()
{
int tradeId = <有手动互换的交易ID>;
DateTime settleDate = <收盘日>;
using var ctx = DbContextFactory.GetYLDbContext();
// 盘前快照:手动互换资金记录 id
var manualCashIds = ctx.ClientCashInCashOut
.Where(x => x.TradeId == tradeId
&& x.Action == ClientCashInCashOut.系统操作_互换
&& x.HappenDate >= settleDate)
.Select(x => x.id).ToList();
Console.WriteLine($"盘前手动互换资金记录数: {manualCashIds.Count}");
// 触发重收盘
var service = new SwapEodPositionService(userInfo);
service.SwapPositionCompose(settleDate, preSettleDate, new List<int>{ clientId });
// 盘后验证:手动互换记录必须原样保留
var survivors = ctx.ClientCashInCashOut
.Where(x => manualCashIds.Contains(x.id)).ToList();
Assert.AreEqual(manualCashIds.Count, survivors.Count,
"手动互换资金记录不应被重收盘删除");
}
```
---
## 九、团队决策问题
1. **选哪个方案?** 推荐方案 A(最小改动+治本),但需先确认 `swap_flow_event.ClientCashId` 在自动互换路径的赋值完整性。方案 B 需数据库迁移,方案 C 适合长期。
2. **历史被误删的数据怎么办?** 若生产已发生"手动互换资金记录被重收盘删除",是否需要从 `clientcashincashout_history``YLContext.cs:127`)或备份恢复?需业务确认影响范围。
3. **是否补单测兜底?** 当前 Swap 模块缺少"重收盘不误删手动数据"的单测,建议补自测 3 的录制测试进回归,防止 `44e89726` 类问题复发。
---
## 附录:关键代码位置索引
| 项 | 文件:行号 |
|----|----------|
| 删除逻辑(bug 所在)| `SwapTradeBaseService.cs:374`ClearSwapPositions),第 398-409 行 |
| 重收盘调用入口 | `SwapEodPositionService.cs:94`SwapPositionCompose`removeEventTyps={自动互换}`, `delAfter=true`|
| 资金记录生成(手动互换)| `SwapDealService.cs:1471`SwapIncome/ `:1519`ApproveSwapTrade|
| 资金记录生成(自动互换)| `SwapEodPositionService.cs:516`(利息腿)/ `:522`(预付金)/ `:531`(分红)|
| `AddClientCashInCashOut`(不设来源标记)| `SwapTradeBaseService.cs:285-318` |
| `ClientCashInCashOut` 实体(无来源字段)| `ClientCashInCashOut.cs:232` |
| 引入 bug 的提交 | `44e89726`2026-05-14,把 `系统操作_互换` 加进删除 Action 列表)|
| 安全的对照基线 | `5f97909f`2026-04-29,按 ClientCashId 精准删除)|