# 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 生成的异常账户流水不仅“看起来能命中”,而且“真实规则一定具备命中所需的账户事实前提”。