diff --git a/docs/reports/implementation/2026-03-22-lsfx-rule-hit-mode-backend-record.md b/docs/reports/implementation/2026-03-22-lsfx-rule-hit-mode-backend-record.md new file mode 100644 index 00000000..f9e3bba5 --- /dev/null +++ b/docs/reports/implementation/2026-03-22-lsfx-rule-hit-mode-backend-record.md @@ -0,0 +1,40 @@ +# LSFX Mock 规则命中模式后端实施记录 + +## 修改范围 +- `lsfx-mock-server/config/settings.py` +- `lsfx-mock-server/main.py` +- `lsfx-mock-server/dev.py` +- `lsfx-mock-server/services/file_service.py` +- `lsfx-mock-server/tests/test_startup.py` +- `lsfx-mock-server/tests/test_file_service.py` +- `lsfx-mock-server/README.md` + +## 本次改动 +- 为 Mock 服务新增统一配置项 `RULE_HIT_MODE`,默认值为 `subset`。 +- 在 `main.py` 中新增 `parse_args()` 与启动前模式注入逻辑,支持 `--rule-hit-mode subset|all`。 +- 新增项目级热重载入口 `dev.py`,支持 `python dev.py --reload --rule-hit-mode ...`。 +- 在 `FileService` 中将规则命中计划拆分为: + - `subset` 模式:沿用按 `logId` 稳定随机命中子集 + - `all` 模式:返回全部兼容规则命中计划 +- 新增显式互斥组入口 `RULE_CONFLICT_GROUPS`,当前实现默认为空列表,仅预留结构与裁剪逻辑。 + +## 规则命中模式语义 +- 默认模式保持为 `subset`,不传参数时仍按同一 `logId` 生成稳定随机子集。 +- `all` 的准确语义是“全部兼容规则命中”,不是无约束全量命中。 +- 当前四类规则池在 `all` 模式下会返回各自全集,再经过互斥组裁剪。 +- 当前互斥组配置为空列表,因此默认不会额外裁剪任何规则;后续若新增互斥组,将按组内顺序保留首个规则。 + +## 启动入口调整 +- 普通启动入口更新为: + - `python main.py --rule-hit-mode subset` + - `python main.py --rule-hit-mode all` +- 热重载入口统一改为项目脚本: + - `python dev.py --reload --rule-hit-mode subset` + - `python dev.py --reload --rule-hit-mode all` +- README 已同步改为“全部兼容规则命中”口径,不再使用“全部规则命中”。 + +## 实施结果 +- `FileService -> StatementService -> FileRecord 缓存` 主链路保持不变。 +- 默认随机子集行为未回归。 +- `all` 模式已支持通过启动参数显式切换。 +- 热重载启动不再依赖裸 `uvicorn main:app --reload ...` 透传业务参数。 diff --git a/docs/tests/records/2026-03-22-lsfx-rule-hit-mode-backend-verification.md b/docs/tests/records/2026-03-22-lsfx-rule-hit-mode-backend-verification.md new file mode 100644 index 00000000..963f6bed --- /dev/null +++ b/docs/tests/records/2026-03-22-lsfx-rule-hit-mode-backend-verification.md @@ -0,0 +1,41 @@ +# LSFX Mock 规则命中模式后端验证记录 + +## 执行命令 + +```bash +cd lsfx-mock-server +python3 -m pytest tests/test_startup.py tests/test_file_service.py -k "rule_hit_plan or parse_args" -v + +PORT=18000 python3 main.py --rule-hit-mode all > /tmp/lsfx_main_18000.log 2>&1 & +sleep 3 +kill + +PORT=18001 python3 dev.py --reload --rule-hit-mode all > /tmp/lsfx_dev_18001.log 2>&1 & +sleep 5 +kill +``` + +## 测试结果 +- 2026-03-22 执行: + `python3 -m pytest tests/test_startup.py tests/test_file_service.py -k "rule_hit_plan or parse_args" -v` +- 结果:`10 passed, 5 deselected, 1 warning in 0.27s` +- warning 为现有 `pydantic` 弃用提示,本次改动未引入失败或 error。 + +## 启动验证结果 +- 普通启动验证: + - 使用临时端口 `18000` 执行 `python3 main.py --rule-hit-mode all` + - 日志显示 `Uvicorn running on http://0.0.0.0:18000` + - 结束后日志显示正常 shutdown,进程已清理 +- 热重载启动验证: + - 使用临时端口 `18001` 执行 `python3 dev.py --reload --rule-hit-mode all` + - 日志显示 reloader 进程与 server 进程均成功启动 + - 结束后日志显示 server process 与 reloader process 已停止 + +## 进程清理结果 +- 启动验证结束后,针对本次工作树路径再次执行进程扫描。 +- `main.py --rule-hit-mode all` 无残留进程。 +- `dev.py --reload --rule-hit-mode all` 无残留进程。 + +## 说明 +- 原计划默认使用 `8000` 端口进行烟测。 +- 验证时发现本机已有独立 `Python main.py` 进程占用 `8000`,为避免影响现有环境,本次改用临时端口 `18000/18001` 完成等价验证。 diff --git a/lsfx-mock-server/README.md b/lsfx-mock-server/README.md index 2dfcfc0b..58b83c65 100644 --- a/lsfx-mock-server/README.md +++ b/lsfx-mock-server/README.md @@ -22,15 +22,20 @@ pip install -r requirements.txt ### 2. 启动服务 ```bash -python main.py +python main.py --rule-hit-mode subset +python main.py --rule-hit-mode all ``` -或使用 uvicorn(支持热重载): +热重载启动请使用项目脚本入口: ```bash -uvicorn main:app --reload --host 0.0.0.0 --port 8000 +python dev.py --reload --rule-hit-mode subset +python dev.py --reload --rule-hit-mode all ``` +- `subset`:默认模式,按 `logId` 稳定随机命中部分规则 +- `all`:全部兼容规则命中模式,会命中当前可共存的全部规则 + ### 3. 访问 API 文档 - **Swagger UI**: http://localhost:8000/docs @@ -135,6 +140,7 @@ PORT=8000 # 模拟配置 PARSE_DELAY_SECONDS=4 MAX_FILE_SIZE=10485760 +RULE_HIT_MODE=subset ``` ### 响应模板 diff --git a/lsfx-mock-server/config/settings.py b/lsfx-mock-server/config/settings.py index 05b25b31..a2fa65d0 100644 --- a/lsfx-mock-server/config/settings.py +++ b/lsfx-mock-server/config/settings.py @@ -1,6 +1,7 @@ from pydantic_settings import BaseSettings from pathlib import Path import re +from typing import Literal def _load_ruoyi_mysql_defaults() -> dict: @@ -42,6 +43,7 @@ class Settings(BaseSettings): # 模拟配置 PARSE_DELAY_SECONDS: int = 4 # 文件解析延迟秒数 MAX_FILE_SIZE: int = 10485760 # 10MB + RULE_HIT_MODE: Literal["subset", "all"] = "subset" # 测试数据配置 INITIAL_PROJECT_ID: int = 1000 diff --git a/lsfx-mock-server/dev.py b/lsfx-mock-server/dev.py new file mode 100644 index 00000000..89181d98 --- /dev/null +++ b/lsfx-mock-server/dev.py @@ -0,0 +1,29 @@ +import argparse + +import uvicorn + +from config.settings import settings +from main import apply_rule_hit_mode + + +def parse_args(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--reload", action="store_true") + parser.add_argument("--rule-hit-mode", choices=["subset", "all"], default="subset") + return parser.parse_args(argv) + + +def main(): + args = parse_args() + apply_rule_hit_mode(args.rule_hit_mode) + uvicorn.run( + "main:app", + host=settings.HOST, + port=settings.PORT, + log_level="debug" if settings.DEBUG else "info", + reload=args.reload, + ) + + +if __name__ == "__main__": + main() diff --git a/lsfx-mock-server/main.py b/lsfx-mock-server/main.py index 6aa9ac78..779c77d9 100644 --- a/lsfx-mock-server/main.py +++ b/lsfx-mock-server/main.py @@ -3,6 +3,9 @@ 基于 FastAPI 实现的 Mock 服务器,用于模拟流水分析平台的 7 个核心接口 """ +import argparse +import os + from fastapi import FastAPI from routers import api from config.settings import settings @@ -68,9 +71,23 @@ async def health_check(): } +def parse_args(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--rule-hit-mode", choices=["subset", "all"], default="subset") + return parser.parse_args(argv) + + +def apply_rule_hit_mode(rule_hit_mode: str) -> None: + os.environ["RULE_HIT_MODE"] = rule_hit_mode + settings.RULE_HIT_MODE = rule_hit_mode + + if __name__ == "__main__": import uvicorn + args = parse_args() + apply_rule_hit_mode(args.rule_hit_mode) + # 启动服务器 uvicorn.run( app, diff --git a/lsfx-mock-server/services/file_service.py b/lsfx-mock-server/services/file_service.py index 50e3cfdd..f5be2c59 100644 --- a/lsfx-mock-server/services/file_service.py +++ b/lsfx-mock-server/services/file_service.py @@ -48,6 +48,8 @@ PHASE2_BASELINE_RULE_CODES = [ "SUPPLIER_CONCENTRATION", ] +RULE_CONFLICT_GROUPS = [] + @dataclass class FileRecord: @@ -179,8 +181,8 @@ class FileService: selected_codes = set(rng.sample(rule_codes, rng.randint(min_count, max_count))) return [rule_code for rule_code in rule_codes if rule_code in selected_codes] - def _build_rule_hit_plan(self, log_id: int) -> dict: - """基于 logId 生成稳定的规则命中计划。""" + def _build_subset_rule_hit_plan(self, log_id: int) -> dict: + """基于 logId 生成稳定的规则子集命中计划。""" rng = random.Random(f"rule-plan:{log_id}") return { "large_transaction_hit_rules": self._pick_rule_subset( @@ -195,6 +197,41 @@ class FileService: ), } + def _build_all_compatible_rule_hit_plan(self) -> dict: + """生成全部兼容规则命中计划。""" + return { + "large_transaction_hit_rules": list(LARGE_TRANSACTION_RULE_CODES), + "phase1_hit_rules": list(PHASE1_RULE_CODES), + "phase2_statement_hit_rules": list(PHASE2_STATEMENT_RULE_CODES), + "phase2_baseline_hit_rules": list(PHASE2_BASELINE_RULE_CODES), + } + + def _apply_conflict_groups(self, rule_plan: dict) -> dict: + """按显式互斥组裁剪规则计划,同组仅保留固定优先级的首个规则。""" + resolved_plan = {plan_key: list(rule_codes) for plan_key, rule_codes in rule_plan.items()} + for plan_key, rule_codes in resolved_plan.items(): + filtered_codes = list(rule_codes) + for conflict_group in RULE_CONFLICT_GROUPS: + kept_rule_code = next( + (rule_code for rule_code in conflict_group if rule_code in filtered_codes), + None, + ) + if kept_rule_code is None: + continue + filtered_codes = [ + rule_code + for rule_code in filtered_codes + if rule_code == kept_rule_code or rule_code not in conflict_group + ] + resolved_plan[plan_key] = filtered_codes + return resolved_plan + + def _build_rule_hit_plan(self, log_id: int) -> dict: + """按配置模式生成规则命中计划。""" + if settings.RULE_HIT_MODE == "all": + return self._apply_conflict_groups(self._build_all_compatible_rule_hit_plan()) + return self._build_subset_rule_hit_plan(log_id) + def _create_file_record( self, *, diff --git a/lsfx-mock-server/tests/test_file_service.py b/lsfx-mock-server/tests/test_file_service.py index 8a1c1ba4..1d87ea9c 100644 --- a/lsfx-mock-server/tests/test_file_service.py +++ b/lsfx-mock-server/tests/test_file_service.py @@ -8,7 +8,14 @@ import io from fastapi import BackgroundTasks from fastapi.datastructures import UploadFile -from services.file_service import FileRecord, FileService +from services.file_service import ( + LARGE_TRANSACTION_RULE_CODES, + PHASE1_RULE_CODES, + PHASE2_BASELINE_RULE_CODES, + PHASE2_STATEMENT_RULE_CODES, + FileRecord, + FileService, +) class FakeStaffIdentityRepository: @@ -196,6 +203,44 @@ def test_phase2_rule_hit_plan_should_be_deterministic_for_same_log_id(): assert 2 <= len(plan1["phase2_baseline_hit_rules"]) <= 4 +def test_build_rule_hit_plan_should_return_all_compatible_rules_in_all_mode(monkeypatch): + monkeypatch.setattr("services.file_service.settings.RULE_HIT_MODE", "all") + service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) + + plan = service._build_rule_hit_plan(10001) + + assert plan["large_transaction_hit_rules"] == LARGE_TRANSACTION_RULE_CODES + assert plan["phase1_hit_rules"] == PHASE1_RULE_CODES + assert plan["phase2_statement_hit_rules"] == PHASE2_STATEMENT_RULE_CODES + assert plan["phase2_baseline_hit_rules"] == PHASE2_BASELINE_RULE_CODES + + +def test_build_rule_hit_plan_should_keep_subset_mode_as_default(): + service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) + + plan1 = service._build_rule_hit_plan(10001) + plan2 = service._build_rule_hit_plan(10001) + + assert plan1 == plan2 + assert 2 <= len(plan1["large_transaction_hit_rules"]) <= 4 + + +def test_build_rule_hit_plan_should_drop_conflicting_rules_from_all_mode(monkeypatch): + monkeypatch.setattr("services.file_service.settings.RULE_HIT_MODE", "all") + monkeypatch.setattr( + "services.file_service.RULE_CONFLICT_GROUPS", + [["SALARY_QUICK_TRANSFER", "SALARY_UNUSED"]], + ) + service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) + + plan = service._build_rule_hit_plan(10001) + + assert not ( + "SALARY_QUICK_TRANSFER" in plan["phase2_statement_hit_rules"] + and "SALARY_UNUSED" in plan["phase2_statement_hit_rules"] + ) + + def test_fetch_inner_flow_should_persist_rule_hit_plan(monkeypatch): service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) monkeypatch.setattr( diff --git a/lsfx-mock-server/tests/test_startup.py b/lsfx-mock-server/tests/test_startup.py new file mode 100644 index 00000000..0ed36322 --- /dev/null +++ b/lsfx-mock-server/tests/test_startup.py @@ -0,0 +1,19 @@ +import pytest + +from main import parse_args as parse_main_args +from dev import parse_args as parse_dev_args + + +def test_main_parse_args_should_default_to_subset(): + args = parse_main_args([]) + assert args.rule_hit_mode == "subset" + + +def test_main_parse_args_should_accept_all_mode(): + args = parse_main_args(["--rule-hit-mode", "all"]) + assert args.rule_hit_mode == "all" + + +def test_dev_parse_args_should_reject_invalid_mode(): + with pytest.raises(SystemExit): + parse_dev_args(["--rule-hit-mode", "invalid"])