Merge branch 'codex/lsfx-rule-hit-mode-backend' into dev

This commit is contained in:
wkc
2026-03-22 16:20:58 +08:00
9 changed files with 242 additions and 6 deletions

View File

@@ -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 ...` 透传业务参数。

View File

@@ -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 <main-pid>
PORT=18001 python3 dev.py --reload --rule-hit-mode all > /tmp/lsfx_dev_18001.log 2>&1 &
sleep 5
kill <dev-pid>
```
## 测试结果
- 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` 完成等价验证。

View File

@@ -22,15 +22,20 @@ pip install -r requirements.txt
### 2. 启动服务 ### 2. 启动服务
```bash ```bash
python main.py python main.py --rule-hit-mode subset
python main.py --rule-hit-mode all
``` ```
或使用 uvicorn支持热重载 热重载启动请使用项目脚本入口
```bash ```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 文档 ### 3. 访问 API 文档
- **Swagger UI**: http://localhost:8000/docs - **Swagger UI**: http://localhost:8000/docs
@@ -135,6 +140,7 @@ PORT=8000
# 模拟配置 # 模拟配置
PARSE_DELAY_SECONDS=4 PARSE_DELAY_SECONDS=4
MAX_FILE_SIZE=10485760 MAX_FILE_SIZE=10485760
RULE_HIT_MODE=subset
``` ```
### 响应模板 ### 响应模板

View File

@@ -1,6 +1,7 @@
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from pathlib import Path from pathlib import Path
import re import re
from typing import Literal
def _load_ruoyi_mysql_defaults() -> dict: def _load_ruoyi_mysql_defaults() -> dict:
@@ -42,6 +43,7 @@ class Settings(BaseSettings):
# 模拟配置 # 模拟配置
PARSE_DELAY_SECONDS: int = 4 # 文件解析延迟秒数 PARSE_DELAY_SECONDS: int = 4 # 文件解析延迟秒数
MAX_FILE_SIZE: int = 10485760 # 10MB MAX_FILE_SIZE: int = 10485760 # 10MB
RULE_HIT_MODE: Literal["subset", "all"] = "subset"
# 测试数据配置 # 测试数据配置
INITIAL_PROJECT_ID: int = 1000 INITIAL_PROJECT_ID: int = 1000

29
lsfx-mock-server/dev.py Normal file
View File

@@ -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()

View File

@@ -3,6 +3,9 @@
基于 FastAPI 实现的 Mock 服务器,用于模拟流水分析平台的 7 个核心接口 基于 FastAPI 实现的 Mock 服务器,用于模拟流水分析平台的 7 个核心接口
""" """
import argparse
import os
from fastapi import FastAPI from fastapi import FastAPI
from routers import api from routers import api
from config.settings import settings 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__": if __name__ == "__main__":
import uvicorn import uvicorn
args = parse_args()
apply_rule_hit_mode(args.rule_hit_mode)
# 启动服务器 # 启动服务器
uvicorn.run( uvicorn.run(
app, app,

View File

@@ -48,6 +48,8 @@ PHASE2_BASELINE_RULE_CODES = [
"SUPPLIER_CONCENTRATION", "SUPPLIER_CONCENTRATION",
] ]
RULE_CONFLICT_GROUPS = []
@dataclass @dataclass
class FileRecord: class FileRecord:
@@ -179,8 +181,8 @@ class FileService:
selected_codes = set(rng.sample(rule_codes, rng.randint(min_count, max_count))) 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] 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: def _build_subset_rule_hit_plan(self, log_id: int) -> dict:
"""基于 logId 生成稳定的规则命中计划。""" """基于 logId 生成稳定的规则子集命中计划。"""
rng = random.Random(f"rule-plan:{log_id}") rng = random.Random(f"rule-plan:{log_id}")
return { return {
"large_transaction_hit_rules": self._pick_rule_subset( "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( def _create_file_record(
self, self,
*, *,

View File

@@ -8,7 +8,14 @@ import io
from fastapi import BackgroundTasks from fastapi import BackgroundTasks
from fastapi.datastructures import UploadFile 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: 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 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): def test_fetch_inner_flow_should_persist_rule_hit_plan(monkeypatch):
service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
monkeypatch.setattr( monkeypatch.setattr(

View File

@@ -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"])