# LSFX Mock Server 异常账户基线同步设计文档

**模块**: `lsfx-mock-server`
**日期**: 2026-03-31

## 一、背景

当前 `lsfx-mock-server` 已完成异常账户命中流水的主链路接入：

- `FileService` 可为 `logId` 生成稳定的 `abnormal_account_hit_rules`
- `FileRecord` 内已保存 `abnormal_accounts`
- `StatementService` 已能按异常账户事实拼接 `SUDDEN_ACCOUNT_CLOSURE`、`DORMANT_ACCOUNT_LARGE_ACTIVATION` 命中流水

但现阶段异常账户事实仅存在于 Mock 进程内存中，尚未同步到主项目真实规则依赖的关联表 `ccdi_account_info`。这会导致两个问题：

1. Mock 返回的流水看起来满足异常账户规则，但真实打标 SQL 缺少账户事实，命中不稳定
2. 同一个 `logId` 下，“命中流水”与“真实账户事实”没有形成完整闭环

本次需求要求在生成可以命中异常账户的流水时，同时向关联表插入最小事实数据，保证真实规则命中条件成立。

## 二、目标

- 在 `fetch_inner_flow(...)` / 上传创建 `logId` 时一次性同步异常账户事实到 `ccdi_account_info`
- 保持同一个 `logId` 的异常账户事实、返回流水、真实打标条件三者一致
- 保持现有 `/watson/api/project/getBSByLogId` 接口协议不变
- 保持 `StatementService` 只负责读 `FileRecord` 生成流水，不新增写库副作用
- 对异常账户基线写库失败采用显式失败语义，不返回半成功 `logId`

## 三、非目标

- 不新增异常账户独立接口
- 不修改现有随机规则命中策略
- 不扩展 `ccdi_account_info` 为完整账户域模型
- 不在 `getBSByLogId` 首次查询时补做异常账户写库
- 不新增兜底、补丁或降级链路

## 四、方案对比

### 4.1 方案 A：在创建 `logId` 时同步异常账户基线，推荐

做法：

- `FileService` 生成 `FileRecord` 和 `abnormal_accounts`
- 在保存 `file_records[log_id]` 之前，同步将异常账户事实幂等写入 `ccdi_account_info`
- 后续 `StatementService` 只读内存事实生成流水

优点：

- 触发点单一，同一个 `logId` 只写一次
- 不把写库副作用混进读接口
- “命中前提未建好就不返回 `logId`” 的语义最清晰
- 与现有 `fetch_inner_flow -> getBSByLogId` 主链路最一致

缺点：

- 需要新增一个很小的异常账户基线写库服务

### 4.2 方案 B：在 `getBSByLogId` 首次生成流水时再写库

优点：

- 只有真正查询流水时才落库

缺点：

- 读接口承担写库副作用，职责变重
- 缓存、重试和并发下更容易出现重复写库或半成功状态
- 不符合当前 Mock 服务“先建上传记录，再查流水”的链路习惯

### 4.3 方案 C：继续只保留内存事实，不做运行时写库

优点：

- 实现最省事

缺点：

- 无法保证真实规则稳定命中
- 不满足当前需求

## 五、结论

采用方案 A。

原因如下：

- 最短路径实现真实闭环
- 不破坏现有服务职责边界
- 最容易保证“同一个 `logId` 一次建好全部命中前提”
- 最符合你要求的“生成可以命中的流水时，同时向关联表插入数据”

## 六、总体设计

### 6.1 新增服务边界

新增 `AbnormalAccountBaselineService`，职责仅有一项：

- 将 `FileRecord.abnormal_accounts` 幂等同步到 `ccdi_account_info`

职责划分如下：

- `FileService`
  - 生成 `logId`
  - 选择员工身份
  - 生成异常账户命中计划
  - 生成 `abnormal_accounts`
  - 调用异常账户基线同步服务
- `AbnormalAccountBaselineService`
  - 连接数据库
  - 以 `account_no` 为键执行幂等写入
- `StatementService`
  - 继续只根据 `FileRecord` 生成命中流水
  - 不负责数据库写入

### 6.2 调用顺序

改造后的 `fetch_inner_flow(...)` 主链路如下：

1. 生成 `logId`
2. 生成规则命中计划
3. 创建 `FileRecord`
4. 生成 `record.abnormal_accounts`
5. 调用 `_apply_abnormal_account_baselines(file_record)`
6. 基线写库成功后，再将 `file_record` 放入 `self.file_records`
7. 继续后续现有逻辑并返回响应

这个顺序的关键点是：

- 不把异常账户写库放到 `StatementService`
- 不在基线未落库成功时返回可用 `logId`

### 6.3 失败语义

- 若 `abnormal_account_hit_rules` 为空：直接跳过，不写库
- 若命中了异常账户规则但 `abnormal_accounts` 为空：视为内部状态异常，直接失败
- 若数据库连接失败或 upsert 失败：`fetch_inner_flow(...)` 直接失败，本次 `logId` 不写入内存
- 不做补丁式重试，不返回半成功结果

## 七、数据模型设计

### 7.1 内存事实结构

继续复用当前 `FileRecord.abnormal_accounts` 结构，最小字段为：

- `account_no`
- `owner_id_card`
- `account_name`
- `status`
- `effective_date`
- `invalid_date`
- `rule_code`

说明：

- `rule_code` 仅作为 Mock 内部路由字段使用
- 对外接口不返回这批事实

### 7.2 `ccdi_account_info` 同步字段

本次只同步真实规则命中所需的最小字段：

- `account_no`
- `account_type`
- `account_name`
- `owner_type`
- `owner_id`
- `bank`
- `bank_code`
- `currency`
- `is_self_account`
- `trans_risk_level`
- `status`
- `effective_date`
- `invalid_date`
- `create_by`
- `update_by`

其中字段值约束如下：

- `account_no`
  - 直接使用 `record.abnormal_accounts[*].account_no`
- `account_name`
  - 直接使用 `record.abnormal_accounts[*].account_name`
- `owner_type`
  - 固定写 `EMPLOYEE`
- `owner_id`
  - 写 `owner_id_card`
- `bank`
  - 固定写当前异常账户样本对齐的银行名称
- `bank_code`
  - 固定写当前异常账户样本对齐的银行编码
- `currency`
  - 固定 `CNY`
- `is_self_account`
  - 固定 `1`
- `trans_risk_level`
  - 固定 `HIGH`
- `status`
  - 由规则事实决定
- `effective_date`
  - 由规则事实决定
- `invalid_date`
  - 仅销户规则写值

本次不补充 `monthly_avg_trans_count`、`monthly_avg_trans_amount`、`dr_max_single_amount` 等推导型字段，因为当前两条真实规则命中依赖的是账户状态与流水窗口，不依赖这些预统计字段。

## 八、幂等策略设计

### 8.1 唯一定位键

以 `account_no` 作为异常账户事实的唯一定位键。

原因：

- Mock 内部异常账户事实和异常账户样本流水都以账号为唯一桥梁
- 同一个员工可能存在多个账户，按 `owner_id` 先删后插会扩大影响面
- 账号粒度最符合异常账户明细展示与后续回溯链路

### 8.2 Upsert 规则

对每条异常账户事实执行单条幂等 upsert：

- 若账号不存在：插入
- 若账号已存在：覆盖本次 Mock 负责的核心字段

覆盖范围仅限：

- `account_name`
- `owner_type`
- `owner_id`
- `bank`
- `bank_code`
- `currency`
- `is_self_account`
- `trans_risk_level`
- `status`
- `effective_date`
- `invalid_date`
- `update_by`
- `update_time`

明确不做的事：

- 不按员工先删整批账户
- 不清空其他来源的账户数据
- 不以 `owner_id` 做批量覆盖

## 九、一致性约束

必须同时满足以下约束：

1. `record.abnormal_accounts[*].account_no` 必须等于对应异常账户样本流水的 `accountMaskNo`
2. `record.abnormal_accounts[*].owner_id_card` 必须等于对应异常账户样本流水的 `cretNo`
3. 同一个 `logId` 下，异常账户事实与异常账户流水必须来自同一份 `FileRecord`
4. `StatementService` 返回流水时不得覆盖已存在的异常账户样本账号

这意味着“内存事实 -> 返回流水 -> 数据库账户事实”三者会围绕同一个 `account_no` 对齐，后端真实 SQL 与结果回溯链路不会漂移。

## 十、模块改动设计

### 10.1 `lsfx-mock-server/services/file_service.py`

改动点：

- 注入新的 `abnormal_account_baseline_service`
- 新增 `_apply_abnormal_account_baselines(file_record)` 封装方法
- 在 `fetch_inner_flow(...)` 与上传建档链路中，于 `self.file_records[log_id] = file_record` 之前调用该方法

职责保持：

- 仍是异常账户规则计划和事实的唯一生成入口
- 不直接拼装 SQL 字符串，数据库写入交给独立服务

### 10.2 `lsfx-mock-server/services/abnormal_account_baseline_service.py`

新增文件，提供：

- 数据库连接
- 输入校验
- 单条异常账户事实 upsert
- 批量 apply 入口

建议方法签名：

```python
def apply(self, staff_id_card: str, abnormal_accounts: List[dict]) -> None:
    ...
```

说明：

- `staff_id_card` 用于做最小一致性校验
- `abnormal_accounts` 为当前 `logId` 已生成好的异常账户事实列表

### 10.3 `lsfx-mock-server/services/statement_service.py`

本次不新增写库逻辑，仅维持现有一致性保证：

- 继续从 `FileRecord` 读取 `abnormal_accounts`
- 继续根据 `rule_code` 选择异常账户样本构造器
- 保持 `_apply_primary_binding(...)` 只兜底缺失账号，不覆盖异常账户样本账号

## 十一、测试设计

### 11.1 `tests/test_file_service.py`

补充断言：

- 命中异常账户规则时，`fetch_inner_flow(...)` 会调用异常账户基线同步服务
- 同步服务收到的账号、员工身份证、状态、生效日、销户日与 `record.abnormal_accounts` 完全一致
- 基线同步失败时，`file_records` 中不会残留该 `logId`

这里优先使用 fake service / stub 断言调用参数，不直接依赖真实数据库。

### 11.2 `tests/test_statement_service.py`

保留现有异常账户流水样本测试，再补充链路一致性断言：

- 同一个 `logId` 下，异常账户样本流水中的 `accountMaskNo` 必须全部来自 `record.abnormal_accounts`
- `StatementService` 不会因本次改造新增数据库写入副作用

### 11.3 `tests/test_abnormal_account_baseline_service.py`

新增服务层单测，覆盖：

- 空异常账户列表直接跳过
- 命中规则但事实为空时报错
- 新账号插入
- 已有账号按 `account_no` 幂等更新

## 十二、验收标准

本次设计实施后，应满足以下验收结果：

1. 创建 `logId` 时，命中的异常账户事实会一次性写入 `ccdi_account_info`
2. 同一个 `logId` 后续查询流水不会再次写库
3. `ccdi_account_info.account_no` 与异常账户样本流水 `accountMaskNo` 完全一致
4. 写库失败时，不返回半成功 `logId`
5. 现有异常账户命中流水生成、分页与缓存语义保持不变

## 十三、结论

本次采用“创建 `logId` 时一次性同步异常账户基线”的方式改造 `lsfx-mock-server`：

- 让异常账户命中样本不再停留在 Mock 进程内存
- 让 `ccdi_account_info` 与返回流水围绕同一个账号闭环
- 保持现有接口不变
- 保持最短路径实现，不引入兼容性和补丁式方案

这能确保 Mock 生成的异常账户流水不仅“看起来能命中”，而且“真实规则一定具备命中所需的账户事实前提”。
