11 KiB
LSFX Mock Server 异常账户基线同步设计文档
模块: lsfx-mock-server
日期: 2026-03-31
一、背景
当前 lsfx-mock-server 已完成异常账户命中流水的主链路接入:
FileService可为logId生成稳定的abnormal_account_hit_rulesFileRecord内已保存abnormal_accountsStatementService已能按异常账户事实拼接SUDDEN_ACCOUNT_CLOSURE、DORMANT_ACCOUNT_LARGE_ACTIVATION命中流水
但现阶段异常账户事实仅存在于 Mock 进程内存中,尚未同步到主项目真实规则依赖的关联表 ccdi_account_info。这会导致两个问题:
- Mock 返回的流水看起来满足异常账户规则,但真实打标 SQL 缺少账户事实,命中不稳定
- 同一个
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(...) 主链路如下:
- 生成
logId - 生成规则命中计划
- 创建
FileRecord - 生成
record.abnormal_accounts - 调用
_apply_abnormal_account_baselines(file_record) - 基线写库成功后,再将
file_record放入self.file_records - 继续后续现有逻辑并返回响应
这个顺序的关键点是:
- 不把异常账户写库放到
StatementService - 不在基线未落库成功时返回可用
logId
6.3 失败语义
- 若
abnormal_account_hit_rules为空:直接跳过,不写库 - 若命中了异常账户规则但
abnormal_accounts为空:视为内部状态异常,直接失败 - 若数据库连接失败或 upsert 失败:
fetch_inner_flow(...)直接失败,本次logId不写入内存 - 不做补丁式重试,不返回半成功结果
七、数据模型设计
7.1 内存事实结构
继续复用当前 FileRecord.abnormal_accounts 结构,最小字段为:
account_noowner_id_cardaccount_namestatuseffective_dateinvalid_daterule_code
说明:
rule_code仅作为 Mock 内部路由字段使用- 对外接口不返回这批事实
7.2 ccdi_account_info 同步字段
本次只同步真实规则命中所需的最小字段:
account_noaccount_typeaccount_nameowner_typeowner_idbankbank_codecurrencyis_self_accounttrans_risk_levelstatuseffective_dateinvalid_datecreate_byupdate_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_nameowner_typeowner_idbankbank_codecurrencyis_self_accounttrans_risk_levelstatuseffective_dateinvalid_dateupdate_byupdate_time
明确不做的事:
- 不按员工先删整批账户
- 不清空其他来源的账户数据
- 不以
owner_id做批量覆盖
九、一致性约束
必须同时满足以下约束:
record.abnormal_accounts[*].account_no必须等于对应异常账户样本流水的accountMaskNorecord.abnormal_accounts[*].owner_id_card必须等于对应异常账户样本流水的cretNo- 同一个
logId下,异常账户事实与异常账户流水必须来自同一份FileRecord 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 入口
建议方法签名:
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幂等更新
十二、验收标准
本次设计实施后,应满足以下验收结果:
- 创建
logId时,命中的异常账户事实会一次性写入ccdi_account_info - 同一个
logId后续查询流水不会再次写库 ccdi_account_info.account_no与异常账户样本流水accountMaskNo完全一致- 写库失败时,不返回半成功
logId - 现有异常账户命中流水生成、分页与缓存语义保持不变
十三、结论
本次采用“创建 logId 时一次性同步异常账户基线”的方式改造 lsfx-mock-server:
- 让异常账户命中样本不再停留在 Mock 进程内存
- 让
ccdi_account_info与返回流水围绕同一个账号闭环 - 保持现有接口不变
- 保持最短路径实现,不引入兼容性和补丁式方案
这能确保 Mock 生成的异常账户流水不仅“看起来能命中”,而且“真实规则一定具备命中所需的账户事实前提”。