From 079b412d38e0e1014c6b136c294d5dd652a1256b Mon Sep 17 00:00:00 2001
From: wkc <978997012@qq.com>
Date: Fri, 20 Mar 2026 16:25:22 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=81=E6=B0=B4=E8=AF=A6?=
=?UTF-8?q?=E6=83=85=E5=8E=9F=E5=A7=8B=E6=96=87=E4=BB=B6=E5=85=B3=E8=81=94?=
=?UTF-8?q?=E4=B8=8EMock=E9=9A=8F=E6=9C=BAlogId?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.DS_Store | Bin 14340 -> 14340 bytes
.../ccdi/project/CcdiBankStatementMapper.xml | 10 +-
.../CcdiBankStatementMapperXmlTest.java | 5 +-
.../sql/CcdiBankTagRuleSqlMetadataTest.java | 48 ++++
...20-bank-tag-new-model-validation-record.md | 15 ++
...ank-tag-phase1-rule-metadata-fix-record.md | 32 +++
...2026-03-20-mock-random-logid-fix-record.md | 28 +++
...k-tag-new-model-validation-verification.md | 218 +++++++++++++++++-
lsfx-mock-server/services/file_service.py | 24 +-
lsfx-mock-server/tests/test_file_service.py | 22 +-
...-20-sync-bank-tag-phase1-rule-metadata.sql | 40 ++++
11 files changed, 427 insertions(+), 15 deletions(-)
create mode 100644 ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java
create mode 100644 docs/reports/implementation/2026-03-20-bank-tag-phase1-rule-metadata-fix-record.md
create mode 100644 docs/reports/implementation/2026-03-20-mock-random-logid-fix-record.md
create mode 100644 sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql
diff --git a/.DS_Store b/.DS_Store
index 28188b4620221c6ee46aaa2c4ac984a044ed08d5..349a95d060d84267b033b023c8d13ced4cef2fa1 100644
GIT binary patch
delta 1393
zcmeH`TTE0(7{}*7D9p(b8DLl7;9dZQfJX%v6i^Tr@B*8z3M$$ZSlDCSxa_Wb77Sj3
z_Xm54$s}r1jq$-t(keD6@e
`2wRhY0e$GcsW0MgMBtoM`dmv$$Vq@VBr8hJbjdX|h8e*c@
zUZg13<`&E^Sy;8SwxQX(w#z@0!*lagn<}1_R6r9bEy+@ynp@!)L}h8!xLToADm=h?
zOj*JDS67!P+*EpYg(AUJM5|Ffj0cs?;lQ8~wnEF*dd9nyt)oWV3`HYWaHZPBa-l1|
zL($=3Ba$$!uv2SgtS=~U>B8+kZ4KiO*miSgAa3~gM2xtV(ynzf*3WZN
z@nlvlS)`QAW$~fEaKT3V&JlxQ)EMKp1h5
zYT_lGq>seN82Ol-BIn5sa+6GvZ^;w#l>AJ7Cx4Q^$V>7Md4*ZXMh-Y~p`jQKII#$|
zsKau2(FGqqfFC{h2z?kp06P#x3~`tk!(klRiDNj9&u|)N@CDA|BCcTq*YOP=U>Xnc
z2tVLCe!;K!8~@TQ%BVu~Xg;;md9;#NQ4d{3J7_2Ewz{B5ck|)fUD2SZSx}6Lv|U_l
z*RAjD^1YK??27ViZqgw6Hv7B+chWWWO|3lc%{w7SNn@(k%EB_->0DUlWpjjGCLNmp5C&8wv(a;RzDmaN3&DwFt9
z-IcXeC3g{a%y|!#)8u>dBY93#Yeoy&(2foXZv!@=I|Z;`;v2*e
z!iY$OyCuYZIDl~+#37u(Cptb&f&4rL@*FPV3ckcwxQZ#4IPa+#4)1}zAKWd^DgOFgC?xdcu5pBh(==!URbyhqb6!%q9z(I3lrBK+IowB!P!jS@0)XS&hvb8u>D|r
z{}4tlpA9J(p*d=YIkl(9R7gIyBrK6Sbt=t{dbOZPI1L4y2S+g_e
zTIMe-S-x^j<%WqwDQSji5b1cPd7f2p`rSTlVuqBIn3^Wb^_@-Lj!ti|TQc!{XBNhT*gZe`E4F{ll5*rSlzDqwdlU3@~qW7-=Obs
z2ej&)el75GNd-r_U9;H#a0OeF@AOj3wKRFXwZ
zq?jxsc2Yqcq>i{E5(%O*8~&C`+-xLf(&k(xURk@YqO$7OKqUP$5LC5x6Qf82bMr!?
zBE>Q{`~-8Io^56^j(Lg1T&to;M7qY%O?C?k6;YbPWDDVFv$BY2C|M$kQVi1`>8&ub
z$VJ1-RcxC{i9Bzzx7fqyHrXxVH}x44emlS*9WWZV7h@SCy8$)W2q$V8T^E|r946ex
z_y(Y3H}){b`xxau9Klia;}}k10H?#G&*3~S;3BSL1UE2>dl`X;4dcvb#rT+HO}@}JJ1bmH&>JJA@r
diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml
index d1a0c1db..1720917d 100644
--- a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml
+++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml
@@ -328,7 +328,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
fur.file_name AS originalFileName,
fur.upload_time AS uploadTime
FROM ccdi_bank_statement bs
- LEFT JOIN ccdi_file_upload_record fur ON fur.log_id = bs.batch_id AND fur.project_id = bs.project_id
+ LEFT JOIN (
+ SELECT latest_record.project_id, latest_record.log_id, latest_record.file_name, latest_record.upload_time
+ FROM ccdi_file_upload_record latest_record
+ INNER JOIN (
+ SELECT project_id, log_id, MAX(id) AS max_id
+ FROM ccdi_file_upload_record
+ GROUP BY project_id, log_id
+ ) latest_meta ON latest_meta.max_id = latest_record.id
+ ) fur ON fur.log_id = bs.batch_id AND fur.project_id = bs.project_id
WHERE bs.bank_statement_id = #{bankStatementId}
diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapperXmlTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapperXmlTest.java
index f1ba30ec..d1dd33ae 100644
--- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapperXmlTest.java
+++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapperXmlTest.java
@@ -121,7 +121,10 @@ class CcdiBankStatementMapperXmlTest {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(
- xml.contains("LEFT JOIN ccdi_file_upload_record fur ON fur.log_id = bs.batch_id AND fur.project_id = bs.project_id"),
+ xml.contains("LEFT JOIN (")
+ && xml.contains("SELECT latest_record.project_id, latest_record.log_id, latest_record.file_name, latest_record.upload_time")
+ && xml.contains("MAX(id) AS max_id")
+ && xml.contains("fur.log_id = bs.batch_id AND fur.project_id = bs.project_id"),
xml
);
assertTrue(xml.contains("fur.file_name AS originalFileName"), xml);
diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java
new file mode 100644
index 00000000..0d8c3906
--- /dev/null
+++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java
@@ -0,0 +1,48 @@
+package com.ruoyi.ccdi.project.sql;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CcdiBankTagRuleSqlMetadataTest {
+
+ @Test
+ void phase1MetadataSql_shouldAlignInitAndMigrationScripts() throws IOException {
+ String initSql = readProjectFile("sql", "2026-03-16-bank-tagging.sql");
+ String migrationSql = readProjectFile("sql", "migration", "2026-03-20-sync-bank-tag-phase1-rule-metadata.sql");
+
+ assertPhase1Metadata(initSql);
+ assertPhase1Metadata(migrationSql);
+ }
+
+ private void assertPhase1Metadata(String sqlContent) {
+ assertAll(
+ () -> assertTrue(sqlContent.contains("'FOREX_BUY_AMT'")
+ && sqlContent.contains("'SINGLE_PURCHASE_AMOUNT'"),
+ "FOREX_BUY_AMT 应使用 SINGLE_PURCHASE_AMOUNT"),
+ () -> assertTrue(sqlContent.contains("'FOREX_SELL_AMT'")
+ && sqlContent.contains("'SINGLE_SETTLEMENT_AMOUNT'"),
+ "FOREX_SELL_AMT 应使用 SINGLE_SETTLEMENT_AMOUNT"),
+ () -> assertTrue(sqlContent.contains("'LARGE_STOCK_TRADING'")
+ && sqlContent.contains("'STOCK_TFR_LARGE'"),
+ "LARGE_STOCK_TRADING 应使用 STOCK_TFR_LARGE"),
+ () -> assertTrue(sqlContent.contains("真实规则:识别单笔购汇金额超过阈值的流水"),
+ "应同步 FOREX_BUY_AMT 的真实规则说明"),
+ () -> assertTrue(sqlContent.contains("真实规则:识别单笔结汇金额超过阈值的流水"),
+ "应同步 FOREX_SELL_AMT 的真实规则说明"),
+ () -> assertTrue(sqlContent.contains("真实规则:识别单笔三方资管交易金额超过阈值的流水"),
+ "应同步 LARGE_STOCK_TRADING 的真实规则说明")
+ );
+ }
+
+ private String readProjectFile(String... parts) throws IOException {
+ Path path = Path.of("..", parts);
+ return Files.readString(path, StandardCharsets.UTF_8);
+ }
+}
diff --git a/docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md b/docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md
index 7057e00e..705a6ac6 100644
--- a/docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md
+++ b/docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md
@@ -38,3 +38,18 @@
## 执行说明
- 验证过程中若任一层失败,立即停在对应层记录证据,不继续给出“验证通过”结论。
- 本次执行基于当前本地开发环境,不额外引入修复或扩展范围。
+
+## 当前进展
+- 2026-03-20 15:21:54 CST 完成阶段 1:已对齐验证范围、读取来源实施记录、选定 `project_id=47`,并创建实施记录与验证记录骨架。
+- 2026-03-20 15:21:54 CST 完成阶段 2:`lsfx-mock-server` 聚焦回归与全量回归全部通过,确认规则命中计划、样本装配、缓存稳定性与集成链路未回退。
+- 2026-03-20 15:23:10 CST 完成阶段 3:`ccdi-project` 第一期真实规则目标测试全部 `BUILD SUCCESS`,规则映射、真实 SQL、规则分发与风险人数刷新链路保持通过。
+- 2026-03-20 15:24 左右执行阶段 4:采购基线脚本成功重跑,`LSFXMOCKPUR001` 基线记录存在且金额满足门槛;但第一期规则元数据查询发现 `indicator_code` 与既有实施记录不一致,判定为“数据基线异常”,按计划停在数据库核验层,不继续执行接口端到端验证。
+- 2026-03-20 15:41:06 CST 完成问题修复与复验:
+ - 已新增第一期规则元数据 SQL 校验测试与增量修复脚本。
+ - 已将修复脚本落库,确认 `FOREX_BUY_AMT`、`FOREX_SELL_AMT`、`LARGE_STOCK_TRADING` 的 `indicator_code` 与 9 条一期真实规则 `remark` 均已对齐。
+ - 已完成项目 `47` 的拉取本行信息、手动重算、任务轮询、命中结果查询与流水详情接口复验。
+ - Mock 与后端验证进程均已关闭。
+- 2026-03-20 16:01 左右完成补充复验:
+ - 重新启动 Mock 与后端服务,复跑项目 `47` 的登录、拉取本行信息、手动重算、任务轮询与详情接口链路。
+ - 自动任务 `id=39` 与手动任务 `id=40` 均执行成功,`hit_count=3636`,`success_rule_count=33`,`failed_rule_count=0`。
+ - 针对之前出现 `selectOne()` 重复结果异常的样例 `bank_statement_id=67279`,详情接口已返回 `code=200`,并正确带出 `GAMBLING_SENSITIVE_KEYWORD` 命中标签与原始文件名。
diff --git a/docs/reports/implementation/2026-03-20-bank-tag-phase1-rule-metadata-fix-record.md b/docs/reports/implementation/2026-03-20-bank-tag-phase1-rule-metadata-fix-record.md
new file mode 100644
index 00000000..2bb1e9cb
--- /dev/null
+++ b/docs/reports/implementation/2026-03-20-bank-tag-phase1-rule-metadata-fix-record.md
@@ -0,0 +1,32 @@
+# 第一期银行流水规则元数据修复实施记录
+
+## 问题背景
+- 2026-03-20 新增模型打标完整验证在数据库核验阶段发现:
+ - `FOREX_BUY_AMT.indicator_code` 仍为 `FOREX_BUY_AMT`
+ - `FOREX_SELL_AMT.indicator_code` 仍为 `FOREX_SELL_AMT`
+ - `LARGE_STOCK_TRADING.indicator_code` 为 `NULL`
+- 同时,第一期已落地真实规则的 `remark` 仍停留在“占位规则,待补充真实SQL”。
+
+## 根因分析
+- 主初始化脚本 [`sql/2026-03-16-bank-tagging.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/2026-03-16-bank-tagging.sql) 已包含第一期真实规则的正确元数据。
+- 老增量脚本 [`sql/migration/2026-03-18-sync-bank-tag-uppercase-and-rules.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/migration/2026-03-18-sync-bank-tag-uppercase-and-rules.sql) 仍写入旧的占位元数据。
+- 已执行过 2026-03-18 增量脚本、但未补后续迁移的环境,会停留在旧的 `indicator_code` 与 `remark` 状态。
+
+## 本次修改
+- 新增 SQL 资产校验测试 [`ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java)
+ - 先以缺失迁移脚本的红灯方式固定问题。
+ - 约束初始化脚本与增量脚本必须同时对齐:
+ - `FOREX_BUY_AMT -> SINGLE_PURCHASE_AMOUNT`
+ - `FOREX_SELL_AMT -> SINGLE_SETTLEMENT_AMOUNT`
+ - `LARGE_STOCK_TRADING -> STOCK_TFR_LARGE`
+ - 三条规则真实说明文案保持一致。
+- 新增增量脚本 [`sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql)
+ - 使用 `INSERT ... ON DUPLICATE KEY UPDATE` 同步第一期 9 条真实规则元数据。
+ - 修复三条规则的 `indicator_code`。
+ - 同步 9 条规则的真实规则 `remark`。
+- 将增量脚本通过 `bin/mysql_utf8_exec.sh` 落到当前验证数据库。
+
+## 实施结果
+- 规则元数据已对齐到第一期真实规则状态。
+- 新增 SQL 校验测试可在仓库层拦住“只改初始化脚本、遗漏增量脚本”的回归。
+- 修复后重新完成接口链路复验,项目 `47` 的自动拉取、手动重算、命中结果查询与详情接口均已通过。
diff --git a/docs/reports/implementation/2026-03-20-mock-random-logid-fix-record.md b/docs/reports/implementation/2026-03-20-mock-random-logid-fix-record.md
new file mode 100644
index 00000000..2dd9c925
--- /dev/null
+++ b/docs/reports/implementation/2026-03-20-mock-random-logid-fix-record.md
@@ -0,0 +1,28 @@
+# Mock 服务随机 logId 实施记录
+
+## 问题背景
+- 2026-03-20 联调过程中,`lsfx-mock-server` 的 `logId` 仍使用进程内递增方式分配。
+- 仓库文档与接口预期要求 Mock 返回随机 `logId`,避免联调时对顺序值形成隐式依赖。
+
+## 根因分析
+- [`lsfx-mock-server/services/file_service.py`](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server/services/file_service.py) 中,`upload_file()` 与 `fetch_inner_flow()` 都直接通过 `self.log_counter += 1` 生成 `logId`。
+- 现有测试只覆盖了 `logId` 落在 `10000-99999` 区间内,没有约束“冲突时需要重试并避让已有记录”。
+
+## 本次修改
+- 在 [`lsfx-mock-server/tests/test_file_service.py`](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server/tests/test_file_service.py) 先新增红灯测试 `test_generate_log_id_should_retry_when_random_value_conflicts`。
+ - 固定随机值第一次命中已存在 `logId` 时必须重试。
+ - 同步把行内流水测试中的旧递增断言改为随机区间断言。
+- 在 [`lsfx-mock-server/services/file_service.py`](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server/services/file_service.py) 新增统一 `_generate_log_id()`。
+ - 在 `10000-99999` 区间内随机生成。
+ - 若命中 `file_records` 中已存在的 `logId`,则继续重试直到拿到未占用值。
+ - `upload_file()` 与 `fetch_inner_flow()` 均切换为调用该方法。
+
+## 验证结果
+- `python3 -m pytest lsfx-mock-server/tests/test_file_service.py -k "fetch_inner_flow_persists_primary_binding_record or generate_log_id_should_retry_when_random_value_conflicts" -v`
+ - 结果:`2 passed`
+- `python3 -m pytest lsfx-mock-server/tests/test_file_service.py lsfx-mock-server/tests/test_statement_service.py lsfx-mock-server/tests/test_api.py lsfx-mock-server/tests/integration/test_full_workflow.py -v`
+ - 结果:`39 passed, 20 warnings`
+
+## 实施结果
+- Mock 服务的新建上传记录与行内流水记录已改为随机 `logId`。
+- 同一 `logId` 下的规则命中计划、流水样本与上传状态复用逻辑保持不变。
diff --git a/docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md b/docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md
index f08cde19..f443c101 100644
--- a/docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md
+++ b/docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md
@@ -1,16 +1,224 @@
# 新增模型打标完整验证记录
## 执行命令
-- 待补充本次实际执行的 pytest、Maven、SQL、curl 与 Python 核验命令。
+```bash
+cd lsfx-mock-server
+python3 -m pytest tests/test_file_service.py -k "rule_hit_plan or persist_rule_hit_plan" -v
+python3 -m pytest tests/test_statement_service.py -k "rule_plan_should_only_include or withdraw_cnt_samples" -v
+python3 -m pytest tests/test_statement_service.py -k "follow_rule_hit_plan or fixed_total_count_200 or cached_result" -v
+python3 -m pytest tests/integration/test_full_workflow.py -k "same_rule_subset or share_same_primary_binding" -v
+python3 -m pytest tests/test_file_service.py tests/test_statement_service.py tests/test_api.py tests/integration/test_full_workflow.py -v
+
+cd ..
+mvn test -pl ccdi-project -Dtest=BankTagRuleConfigResolverTest
+mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest
+mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest
+mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest
+```
+
+## Mock 自动化结果
+- 2026-03-20 15:21:54 CST 完成 Mock 聚焦回归与全量回归。
+- 聚焦回归结果:
+ - `tests/test_file_service.py -k "rule_hit_plan or persist_rule_hit_plan"`: `2 passed, 4 deselected, 1 warning`
+ - `tests/test_statement_service.py -k "rule_plan_should_only_include or withdraw_cnt_samples"`: `2 passed, 11 deselected, 1 warning`
+ - `tests/test_statement_service.py -k "follow_rule_hit_plan or fixed_total_count_200 or cached_result"`: `3 passed, 10 deselected, 1 warning`
+ - `tests/integration/test_full_workflow.py -k "same_rule_subset or share_same_primary_binding"`: `2 passed, 3 deselected, 3 warnings`
+- 全量回归结果:
+ - `python3 -m pytest tests/test_file_service.py tests/test_statement_service.py tests/test_api.py tests/integration/test_full_workflow.py -v`
+ - 摘要:`38 passed, 20 warnings in 4.15s`
+- warning 摘要:
+ - `pydantic` 的 class-based config 弃用提示仍存在。
+ - `httpx` 的 `app` shortcut 弃用提示仍存在。
+ - 两类 warning 与既有 Mock 验证记录一致,本次未新增 failure 或 error。
+
+## 主工程自动化结果
+- 2026-03-20 15:22:27 CST 执行 `mvn test -pl ccdi-project -Dtest=BankTagRuleConfigResolverTest`,结果 `BUILD SUCCESS`,`Tests run: 6, Failures: 0, Errors: 0, Skipped: 0`。
+- 2026-03-20 15:22:47 CST 执行 `mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest`,结果 `BUILD SUCCESS`,`Tests run: 8, Failures: 0, Errors: 0, Skipped: 0`。
+- 2026-03-20 15:22:57 CST 执行 `mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest`,结果 `BUILD SUCCESS`,`Tests run: 19, Failures: 0, Errors: 0, Skipped: 0`。
+- 2026-03-20 15:23:10 CST 执行 `mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest`,结果 `BUILD SUCCESS`,`Tests run: 27, Failures: 0, Errors: 0, Skipped: 0`。
+- 结果归纳:
+ - `BankTagRuleConfigResolverTest` 证明第一期规则参数映射保持通过。
+ - `CcdiBankTagAnalysisMapperXmlTest` 证明真实 SQL 结构保持通过。
+ - `CcdiBankTagServiceImplTest` 证明规则分发和异常路径断言保持通过。
+ - `CcdiBankTagServiceRiskCountRefreshTest` 证明风险人数刷新链路保持通过。
+- 日志说明:
+ - 测试日志中的 `threshold missing` 与 `refresh failed` 为异常路径断言场景产生的预期日志,不代表本轮 Maven 回归失败。
## 数据库核验
-- 待补充采购基线、规则元数据、任务状态与命中结果查询。
+```bash
+bin/mysql_utf8_exec.sh sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql
+
+python3 - <<'PY'
+from pathlib import Path
+import pymysql, re
+
+text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8')
+match = re.search(r"url:\s*jdbc:mysql://(?P[^:/?#]+):(?P\d+)/(?P[^?\n]+).*?\n\s*username:\s*(?P[^\n]+)\n\s*password:\s*(?P[^\n]+)", text, re.S)
+conn = pymysql.connect(
+ host=match.group('host'),
+ port=int(match.group('port')),
+ user=match.group('user').strip(),
+ password=match.group('pwd').strip(),
+ database=match.group('db').strip(),
+ charset='utf8mb4',
+ cursorclass=pymysql.cursors.DictCursor,
+)
+with conn, conn.cursor() as cursor:
+ cursor.execute("""
+ SELECT purchase_id, actual_amount, supplier_name
+ FROM ccdi_purchase_transaction
+ WHERE purchase_id = 'LSFXMOCKPUR001'
+ AND actual_amount > 100000
+ """)
+ print(cursor.fetchone())
+PY
+
+python3 - <<'PY'
+from pathlib import Path
+import pymysql, re
+
+TARGET_RULES = (
+ 'GAMBLING_SENSITIVE_KEYWORD','SPECIAL_AMOUNT_TRANSACTION','SUSPICIOUS_INCOME_KEYWORD',
+ 'FOREX_BUY_AMT','FOREX_SELL_AMT','LARGE_PURCHASE_TRANSACTION',
+ 'STOCK_TFR_LARGE','WITHDRAW_CNT','LARGE_STOCK_TRADING'
+)
+
+text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8')
+match = re.search(r"url:\s*jdbc:mysql://(?P[^:/?#]+):(?P\d+)/(?P[^?\n]+).*?\n\s*username:\s*(?P[^\n]+)\n\s*password:\s*(?P[^\n]+)", text, re.S)
+conn = pymysql.connect(
+ host=match.group('host'),
+ port=int(match.group('port')),
+ user=match.group('user').strip(),
+ password=match.group('pwd').strip(),
+ database=match.group('db').strip(),
+ charset='utf8mb4',
+ cursorclass=pymysql.cursors.DictCursor,
+)
+sql = f"""
+SELECT model_code, rule_code, indicator_code
+FROM ccdi_bank_tag_rule
+WHERE rule_code IN ({','.join(['%s'] * len(TARGET_RULES))})
+ORDER BY model_code, sort_order, rule_code
+"""
+with conn, conn.cursor() as cursor:
+ cursor.execute(sql, TARGET_RULES)
+ for row in cursor.fetchall():
+ print(row)
+PY
+```
+
+- 采购基线脚本执行结果:
+ - `bin/mysql_utf8_exec.sh sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql` 执行成功,无报错、无乱码输出。
+- 采购基线查询结果:
+ - 返回 `{'purchase_id': 'LSFXMOCKPUR001', 'actual_amount': Decimal('186000.00'), 'supplier_name': '兰溪市联调供应链有限公司'}`
+ - 结论:`LSFXMOCKPUR001` 存在,且 `actual_amount > 100000`,采购基线正常。
+- 规则元数据查询结果:
+ - 共查询到 9 条目标规则,`rule_code` 均存在。
+ - 返回摘要:
+ - `STOCK_TFR_LARGE -> indicator_code=STOCK_TFR_LARGE`
+ - `WITHDRAW_CNT -> indicator_code=WITHDRAW_CNT`
+ - `LARGE_STOCK_TRADING -> indicator_code=NULL`
+ - `FOREX_BUY_AMT -> indicator_code=FOREX_BUY_AMT`
+ - `FOREX_SELL_AMT -> indicator_code=FOREX_SELL_AMT`
+ - 其余 4 条规则 `indicator_code=NULL`
+- 异常判定:
+ - 根据既有实施记录,`FOREX_BUY_AMT` 预期应对齐为 `SINGLE_PURCHASE_AMOUNT`。
+ - `FOREX_SELL_AMT` 预期应对齐为 `SINGLE_SETTLEMENT_AMOUNT`。
+ - `LARGE_STOCK_TRADING` 预期应对齐为 `STOCK_TFR_LARGE`,当前查询为 `NULL`。
+ - 首次执行因此在数据库层判定为“数据基线异常”。
+- 修复后复验:
+ - 已执行 `bin/mysql_utf8_exec.sh sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql`
+ - 修复后查询结果:
+ - `FOREX_BUY_AMT -> indicator_code=SINGLE_PURCHASE_AMOUNT`
+ - `FOREX_SELL_AMT -> indicator_code=SINGLE_SETTLEMENT_AMOUNT`
+ - `LARGE_STOCK_TRADING -> indicator_code=STOCK_TFR_LARGE`
+ - 9 条一期真实规则 `remark` 均已同步为真实规则说明
+ - 结论:数据库元数据异常已修复,可继续进入接口端到端验证。
## 接口验证
-- 待补充登录、拉取本行信息、手动重算、流水详情回查与结果摘要。
+```bash
+curl -s http://localhost:62318/login/test \
+ -H 'Content-Type: application/json' \
+ -d '{"username":"admin","password":"admin123"}'
+
+python3 - <<'PY'
+# 读取 3 个有效身份证号并生成 /tmp/bank-tag-pull-request.json
+PY
+
+curl -s http://localhost:62318/ccdi/file-upload/pull-bank-info \
+ -H "Authorization: Bearer $TOKEN" \
+ -H 'Content-Type: application/json' \
+ --data-binary @/tmp/bank-tag-pull-request.json
+
+curl -s http://localhost:62318/ccdi/project/tags/rebuild \
+ -H "Authorization: Bearer $TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d '{"projectId":47,"modelCode":null}'
+
+python3 - <<'PY'
+# 轮询 ccdi_bank_tag_task 并查询目标规则命中结果
+PY
+
+curl -s "http://localhost:62318/ccdi/project/bank-statement/detail/66679" \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+- 登录结果:
+ - 返回 `code=200`,token 非空。
+- 拉取本行信息结果:
+ - 选择身份证号:`558455197203132040`、`523342199111246421`、`38056420050404632X`
+ - 接口返回 `{"msg":"拉取任务已提交","code":200,...}`
+ - 自动触发任务 `id=36`,`trigger_type=AUTO_PULL_BANK_INFO`,状态 `SUCCESS`。
+- 手动重算结果:
+ - 首次调用命中项目级重算锁,返回“当前项目标签正在重算中,请稍后再试”。
+ - 自动拉取任务完成后再次调用,返回 `{"msg":"标签重算任务已提交","code":200}`。
+ - 最新任务 `id=37`,状态 `SUCCESS`,`hit_count=3481`,`success_rule_count=33`,`failed_rule_count=0`。
+- 命中结果查询:
+ - 已查到目标规则命中,包括:
+ - `WITHDRAW_CNT`
+ - `GAMBLING_SENSITIVE_KEYWORD`
+ - `LARGE_PURCHASE_TRANSACTION`
+ - 样例明细:
+ - `rule_code=GAMBLING_SENSITIVE_KEYWORD`
+ - `bank_statement_id=66679`
+ - `reason_detail=摘要/对手命中赌博敏感词,摘要“游戏充值”,对手方“欢乐游戏科技有限公司”,支出金额 6888.00 元`
+- 详情接口回查:
+ - `GET /ccdi/project/bank-statement/detail/66679` 返回 `code=200`
+ - `data.hitTags` 中包含 `GAMBLING_SENSITIVE_KEYWORD`
+
+## 补充复验
+- 2026-03-20 16:01 左右,基于修复后的详情查询 SQL 再次执行项目 `47` 端到端链路验证。
+- 登录结果:
+ - `POST /login/test` 返回 `code=200`,token 非空。
+- 拉取本行信息结果:
+ - 仍使用身份证号 `558455197203132040`、`523342199111246421`、`38056420050404632X`
+ - `POST /ccdi/file-upload/pull-bank-info` 返回 `{"msg":"拉取任务已提交","code":200,...}`
+ - 自动触发任务 `id=39`,`trigger_type=AUTO_PULL_BANK_INFO`,状态 `SUCCESS`
+ - `hit_count=3636`,`success_rule_count=33`,`failed_rule_count=0`
+- 手动重算结果:
+ - `POST /ccdi/project/tags/rebuild` 直接返回 `{"msg":"标签重算任务已提交","code":200}`
+ - 最新任务 `id=40`,`trigger_type=MANUAL`,状态 `SUCCESS`
+ - `hit_count=3636`,`success_rule_count=33`,`failed_rule_count=0`
+- 命中样例回查:
+ - 最新 `GAMBLING_SENSITIVE_KEYWORD` 命中样例为 `bank_statement_id=67279`
+ - `reason_detail=摘要/对手命中赌博敏感词,摘要“游戏充值”,对手方“欢乐游戏科技有限公司”,支出金额 6888.00 元`
+- 详情接口回查:
+ - `GET /ccdi/project/bank-statement/detail/67279` 返回 `code=200`
+ - 返回结果包含 `originalFileName=558455197203132040_10001.csv`
+ - `data.hitTags` 中包含 `GAMBLING_SENSITIVE_KEYWORD`
## 结论
-- 待本次验证全部执行完成后补充。
+- 首次执行在数据库核验阶段发现第一期规则元数据异常,问题已定位并修复。
+- 修复后重新验证结果如下:
+ - Mock 自动化回归通过。
+ - 主工程第一期真实规则自动化回归通过。
+ - 数据库采购基线与第一期规则元数据核验通过。
+ - 项目 `47` 的自动拉取、手动重算、规则命中查询与详情接口回查通过。
+ - 补充复验确认:重复上传记录场景下,流水详情接口已不再出现 `selectOne()` 结果重复异常。
+- 最终结论:本次“新增模型打标完整验证”在修复元数据缺口后已通过。
## 环境清理
-- 待补充本次验证启动的 Mock 与后端进程清理结果。
+- 已停止本次复验启动的 Mock 服务与后端 Jar 服务。
+- 端口复核结果:
+ - `62318` 无监听进程
+ - `8000` 无监听进程
diff --git a/lsfx-mock-server/services/file_service.py b/lsfx-mock-server/services/file_service.py
index ec13b0f3..9b42389c 100644
--- a/lsfx-mock-server/services/file_service.py
+++ b/lsfx-mock-server/services/file_service.py
@@ -94,6 +94,8 @@ class FileService:
"""文件上传和解析服务"""
INNER_FLOW_TOTAL_RECORDS = 200
+ LOG_ID_MIN = settings.INITIAL_LOG_ID
+ LOG_ID_MAX = 99999
def __init__(self, staff_identity_repository=None):
self.file_records: Dict[int, FileRecord] = {} # logId -> FileRecord
@@ -104,6 +106,18 @@ class FileService:
"""按 logId 获取已存在的文件记录。"""
return self.file_records.get(log_id)
+ def _generate_log_id(self) -> int:
+ """生成当前进程内未占用的随机 logId。"""
+ available_capacity = self.LOG_ID_MAX - self.LOG_ID_MIN + 1
+ if len(self.file_records) >= available_capacity:
+ raise RuntimeError("可用 logId 已耗尽")
+
+ while True:
+ candidate = random.randint(self.LOG_ID_MIN, self.LOG_ID_MAX)
+ if candidate not in self.file_records:
+ self.log_counter = candidate
+ return candidate
+
def _infer_bank_name(self, filename: str) -> tuple:
"""根据文件名推断银行名称和模板名称"""
if "支付宝" in filename or "alipay" in filename.lower():
@@ -230,9 +244,8 @@ class FileService:
Returns:
上传响应字典
"""
- # 生成唯一logId
- self.log_counter += 1
- log_id = self.log_counter
+ # 生成唯一 logId
+ log_id = self._generate_log_id()
# 推断银行信息
bank_name, template_name = self._infer_bank_name(file.filename)
@@ -570,9 +583,8 @@ class FileService:
data_start_date_id = request.dataStartDateId
data_end_date_id = request.dataEndDateId
- # 使用递增 logId,确保与上传链路一致
- self.log_counter += 1
- log_id = self.log_counter
+ # 使用随机 logId,确保与上传链路一致且不覆盖现有记录
+ log_id = self._generate_log_id()
rule_hit_plan = self._build_rule_hit_plan(log_id)
primary_enterprise_name, primary_account_no = self._generate_primary_binding()
diff --git a/lsfx-mock-server/tests/test_file_service.py b/lsfx-mock-server/tests/test_file_service.py
index 94bd3893..0adb4b9c 100644
--- a/lsfx-mock-server/tests/test_file_service.py
+++ b/lsfx-mock-server/tests/test_file_service.py
@@ -8,7 +8,7 @@ import io
from fastapi import BackgroundTasks
from fastapi.datastructures import UploadFile
-from services.file_service import FileService
+from services.file_service import FileRecord, FileService
class FakeStaffIdentityRepository:
@@ -139,7 +139,7 @@ def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch):
response = service.fetch_inner_flow(request)
log_id = response["data"][0]
- assert log_id == service.log_counter
+ assert 10000 <= log_id <= 99999
assert log_id in service.file_records
record = service.file_records[log_id]
@@ -156,6 +156,24 @@ def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch):
assert record.total_records == 200
+def test_generate_log_id_should_retry_when_random_value_conflicts(monkeypatch):
+ """随机 logId 命中已存在记录时必须重试并返回未占用值。"""
+ service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
+ service.file_records[34567] = FileRecord(
+ log_id=34567,
+ group_id=1001,
+ file_name="existing.csv",
+ )
+
+ candidate_values = iter([34567, 45678])
+ monkeypatch.setattr(
+ "services.file_service.random.randint",
+ lambda start, end: next(candidate_values),
+ )
+
+ assert service._generate_log_id() == 45678
+
+
def test_build_rule_hit_plan_should_be_deterministic_for_same_log_id():
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
diff --git a/sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql b/sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql
new file mode 100644
index 00000000..ddabaf88
--- /dev/null
+++ b/sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql
@@ -0,0 +1,40 @@
+START TRANSACTION;
+
+INSERT INTO ccdi_bank_tag_rule (
+ model_code,
+ model_name,
+ rule_code,
+ rule_name,
+ indicator_code,
+ result_type,
+ risk_level,
+ business_caliber,
+ enabled,
+ sort_order,
+ create_by,
+ remark
+) VALUES
+('SUSPICIOUS_GAMBLING', '疑似赌博', 'GAMBLING_SENSITIVE_KEYWORD', '疑似敏感交易', NULL, 'STATEMENT', 'HIGH', '备注或交易摘要、对手有“游戏、抖币、体彩、福彩”等字眼。', 1, 20, 'system', '真实规则:识别摘要或对手方命中赌博敏感词的支出流水'),
+('SUSPICIOUS_RELATION', '可疑关系', 'SPECIAL_AMOUNT_TRANSACTION', '特殊金额交易', NULL, 'STATEMENT', NULL, '除与配偶、子女外,发生特殊金额交易,如1314元、520元等具有特殊含义的金额。', 1, 10, 'system', '真实规则:识别与非配偶子女发生的特殊金额交易'),
+('SUSPICIOUS_PART_TIME', '可疑兼职', 'SUSPICIOUS_INCOME_KEYWORD', '疑似兼职', NULL, 'STATEMENT', 'HIGH', '转入资金摘要有“工资”、“分红”、“红利”、“利息(非银行结息)”等收入', 1, 30, 'system', '真实规则:识别非本行工资代发的收入关键词转入流水'),
+('SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易', 'FOREX_BUY_AMT', '可疑外汇交易', 'SINGLE_PURCHASE_AMOUNT', 'STATEMENT', NULL, '单笔购汇金额超限', 1, 10, 'system', '真实规则:识别单笔购汇金额超过阈值的流水'),
+('SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易', 'FOREX_SELL_AMT', '可疑外汇交易', 'SINGLE_SETTLEMENT_AMOUNT', 'STATEMENT', NULL, '单笔结汇金额超限', 1, 20, 'system', '真实规则:识别单笔结汇金额超过阈值的流水'),
+('SUSPICIOUS_PURCHASE', '可疑采购', 'LARGE_PURCHASE_TRANSACTION', '可疑采购', NULL, 'STATEMENT', NULL, '单笔采购金额超过10万元。', 1, 10, 'system', '真实规则:识别单笔采购金额超过10万元的采购事项'),
+('ABNORMAL_BEHAVIOR', '异常行为', 'STOCK_TFR_LARGE', '可疑银证大额转账', 'STOCK_TFR_LARGE', 'STATEMENT', NULL, '家庭老人/非关系人银证大额转账', 1, 10, 'system', '真实规则:识别银证转账金额超过阈值的流水'),
+('ABNORMAL_BEHAVIOR', '异常行为', 'WITHDRAW_CNT', '微信支付宝频繁提现', 'WITHDRAW_CNT', 'OBJECT', NULL, '微信、支付宝单日提现次数超过设置次数', 1, 20, 'system', '真实规则:识别微信支付宝单日提现次数超过阈值的对象'),
+('ABNORMAL_BEHAVIOR', '异常行为', 'LARGE_STOCK_TRADING', '大额炒股', 'STOCK_TFR_LARGE', 'STATEMENT', 'HIGH', '单次三方资管交易金额超过100万元。', 1, 60, 'system', '真实规则:识别单笔三方资管交易金额超过阈值的流水')
+ON DUPLICATE KEY UPDATE
+ model_code = VALUES(model_code),
+ model_name = VALUES(model_name),
+ rule_name = VALUES(rule_name),
+ indicator_code = VALUES(indicator_code),
+ result_type = VALUES(result_type),
+ risk_level = VALUES(risk_level),
+ business_caliber = VALUES(business_caliber),
+ enabled = VALUES(enabled),
+ sort_order = VALUES(sort_order),
+ update_by = 'system',
+ update_time = NOW(),
+ remark = VALUES(remark);
+
+COMMIT;