Merge branch 'codex/lsfx-rule-hit-mode-backend' into dev
This commit is contained in:
@@ -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 ...` 透传业务参数。
|
||||
@@ -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` 完成等价验证。
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### 响应模板
|
||||
|
||||
@@ -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
|
||||
|
||||
29
lsfx-mock-server/dev.py
Normal file
29
lsfx-mock-server/dev.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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(
|
||||
|
||||
19
lsfx-mock-server/tests/test_startup.py
Normal file
19
lsfx-mock-server/tests/test_startup.py
Normal 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"])
|
||||
Reference in New Issue
Block a user