Merge branch 'codex/lsfx-mock-server-abnormal-account-backend' into dev

This commit is contained in:
wkc
2026-03-31 20:54:05 +08:00
8 changed files with 464 additions and 3 deletions

View File

@@ -0,0 +1,78 @@
# LSFX Mock Server 异常账户后端实施记录
## 1. 实施范围
本次改动仅覆盖 `lsfx-mock-server` 后端 Mock 造数主链路,目标是在不新增接口的前提下,为异常账户规则补齐稳定命中能力。
涉及规则:
- `SUDDEN_ACCOUNT_CLOSURE`
- `DORMANT_ACCOUNT_LARGE_ACTIVATION`
## 2. 主要改动
### 2.1 FileRecord 新增异常账户计划与事实
`lsfx-mock-server/services/file_service.py` 中扩展了 `FileRecord`
- 新增 `abnormal_account_hit_rules`
- 新增 `abnormal_accounts`
同时把异常账户规则池并入现有规则命中计划生成逻辑:
- `subset` 模式下按 `logId` 稳定随机命中异常账户规则
- `all` 模式下自动纳入全部异常账户规则
- 在上传链路与 `fetch_inner_flow(...)` 中同步生成最小异常账户事实
最小账户事实字段包括:
- `account_no`
- `owner_id_card`
- `account_name`
- `status`
- `effective_date`
- `invalid_date`
### 2.2 新增两类异常账户样本生成器
`lsfx-mock-server/services/statement_rule_samples.py` 中新增:
- `build_sudden_account_closure_samples(...)`
- `build_dormant_account_large_activation_samples(...)`
口径落实如下:
- `SUDDEN_ACCOUNT_CLOSURE` 的样本流水全部落在销户日前 30 天窗口内
- `DORMANT_ACCOUNT_LARGE_ACTIVATION` 的首笔流水晚于开户满 6 个月
- 休眠激活样本同时满足累计金额阈值与单笔最大金额阈值
### 2.3 接入现有种子流水主链路
未新增平行入口,直接复用现有:
- `FileService -> FileRecord`
- `StatementService._generate_statements(...)`
- `build_seed_statements_for_rule_plan(...)`
接入方式:
- 在统一种子流水构造入口增加 `abnormal_account_hit_rules` 分支
- 根据 `abnormal_accounts` 为每条异常账户规则选择匹配账户事实
- 生成的异常账户样本继续与既有规则样本一起补噪声、编号、打乱和分页
## 3. 测试补充
新增并通过的关键测试包括:
- `test_fetch_inner_flow_should_attach_abnormal_account_rule_plan`
- `test_sudden_account_closure_samples_should_stay_within_30_days_before_invalid_date`
- `test_dormant_account_large_activation_samples_should_exceed_threshold_after_6_months`
- `test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record`
## 4. 联动修正
`all` 模式安全噪声测试中,原有用例只清空了旧规则维度,未同步清空新增的 `abnormal_account_hit_rules`。本次已将该测试夹具补齐,保证它继续只验证“月固定收入 + 安全噪声”的原始语义。
## 5. 结果
异常账户命中计划、最小账户事实、样本生成器和服务层主链路均已落地,现有 Mock 服务可以为同一个 `logId` 稳定提供异常账户命中流水样本。

View File

@@ -0,0 +1,39 @@
# LSFX Mock Server 异常账户后端验证记录
## 1. 验证命令
按实施过程实际执行了以下命令:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_file_service.py::test_fetch_inner_flow_should_attach_abnormal_account_rule_plan -v
python3 -m pytest tests/test_statement_service.py -k "sudden_account_closure or dormant_account_large_activation" -v
python3 -m pytest tests/test_statement_service.py::test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record -v
python3 -m pytest tests/test_file_service.py tests/test_statement_service.py -v
python3 -m pytest tests/ -v
```
README 补充后按计划追加执行:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_statement_service.py -v
```
## 2. 验证结果摘要
- `tests/test_file_service.py::test_fetch_inner_flow_should_attach_abnormal_account_rule_plan`:通过
- `tests/test_statement_service.py -k "sudden_account_closure or dormant_account_large_activation"`:通过
- `tests/test_statement_service.py::test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record`:通过
- `python3 -m pytest tests/test_file_service.py tests/test_statement_service.py -v``43 passed`
- `python3 -m pytest tests/ -v``84 passed`
- `python3 -m pytest tests/test_statement_service.py -v``26 passed`
## 3. 过程说明
- 回归期间发现 `all` 模式安全噪声测试未同步清空新增的异常账户规则维度,导致异常账户样本被计入噪声断言
- 已通过补齐测试夹具方式修正,随后重新执行聚焦回归和全量回归,结果均通过
## 4. 进程清理
本轮只执行了 `pytest` 命令,未启动额外前端、后端或 Mock 服务进程,因此无需清理残留进程。

View File

@@ -37,6 +37,12 @@ python dev.py --reload --rule-hit-mode all
- `subset`:默认模式,按 `logId` 稳定随机命中部分规则
- `all`:全部兼容规则命中模式,会命中当前可共存的全部规则
补充说明:
- `fetch_inner_flow` 与上传链路会在内部生成 `abnormal_account_hit_rules`
- 当前异常账户规则样本包含 `SUDDEN_ACCOUNT_CLOSURE``DORMANT_ACCOUNT_LARGE_ACTIVATION`
- `/watson/api/project/getBSByLogId` 会沿用现有种子流水主链路,自动混入与异常账户事实匹配的命中流水样本
### 3. 访问 API 文档
- **Swagger UI**: http://localhost:8000/docs

View File

@@ -48,6 +48,11 @@ PHASE2_BASELINE_RULE_CODES = [
"SUPPLIER_CONCENTRATION",
]
ABNORMAL_ACCOUNT_RULE_CODES = [
"SUDDEN_ACCOUNT_CLOSURE",
"DORMANT_ACCOUNT_LARGE_ACTIVATION",
]
MONTHLY_FIXED_INCOME_ISOLATED_LARGE_TRANSACTION_RULE_CODES = {
"SINGLE_LARGE_INCOME",
"CUMULATIVE_INCOME",
@@ -127,6 +132,8 @@ class FileRecord:
phase1_hit_rules: List[str] = field(default_factory=list)
phase2_statement_hit_rules: List[str] = field(default_factory=list)
phase2_baseline_hit_rules: List[str] = field(default_factory=list)
abnormal_account_hit_rules: List[str] = field(default_factory=list)
abnormal_accounts: List[dict] = field(default_factory=list)
class FileService:
@@ -213,6 +220,9 @@ class FileService:
"phase2_baseline_hit_rules": self._pick_rule_subset(
rng, PHASE2_BASELINE_RULE_CODES, 2, 4
),
"abnormal_account_hit_rules": self._pick_rule_subset(
rng, ABNORMAL_ACCOUNT_RULE_CODES, 1, len(ABNORMAL_ACCOUNT_RULE_CODES)
),
}
def _build_all_compatible_rule_hit_plan(self) -> dict:
@@ -222,6 +232,7 @@ class FileService:
"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),
"abnormal_account_hit_rules": list(ABNORMAL_ACCOUNT_RULE_CODES),
}
def _build_monthly_fixed_income_isolated_rule_hit_plan(self) -> dict:
@@ -284,6 +295,52 @@ class FileService:
file_record.phase2_baseline_hit_rules = list(
rule_hit_plan.get("phase2_baseline_hit_rules", [])
)
file_record.abnormal_account_hit_rules = list(
rule_hit_plan.get("abnormal_account_hit_rules", [])
)
file_record.abnormal_accounts = self._build_abnormal_accounts(
log_id=file_record.log_id,
staff_id_card=file_record.staff_id_card,
abnormal_account_hit_rules=file_record.abnormal_account_hit_rules,
)
def _build_abnormal_accounts(
self,
*,
log_id: int,
staff_id_card: str,
abnormal_account_hit_rules: List[str],
) -> List[dict]:
"""按命中规则生成最小异常账户事实。"""
if not abnormal_account_hit_rules:
return []
rng = random.Random(f"abnormal-account:{log_id}")
accounts = []
for index, rule_code in enumerate(abnormal_account_hit_rules, start=1):
account_no = f"622200{rng.randint(10**9, 10**10 - 1)}"
account_fact = {
"account_no": account_no,
"owner_id_card": staff_id_card,
"account_name": "测试员工工资卡",
"status": 1,
"effective_date": "2025-01-01",
"invalid_date": None,
"rule_code": rule_code,
}
if rule_code == "SUDDEN_ACCOUNT_CLOSURE":
account_fact["status"] = 2
account_fact["effective_date"] = "2024-01-01"
account_fact["invalid_date"] = "2026-03-20"
elif rule_code == "DORMANT_ACCOUNT_LARGE_ACTIVATION":
account_fact["status"] = 1
account_fact["effective_date"] = "2025-01-01"
account_fact["invalid_date"] = None
account_fact["account_no"] = f"{account_no[:-2]}{index:02d}"
accounts.append(account_fact)
return accounts
def _rebalance_all_mode_group_rule_plans(self, group_id: int) -> None:
"""同项目存在多文件时,隔离月固定收入样本,避免被其他正向流入规则污染。"""
@@ -332,6 +389,8 @@ class FileService:
phase1_hit_rules: List[str] = None,
phase2_statement_hit_rules: List[str] = None,
phase2_baseline_hit_rules: List[str] = None,
abnormal_account_hit_rules: List[str] = None,
abnormal_accounts: List[dict] = None,
parsing: bool = True,
status: int = -5,
) -> FileRecord:
@@ -366,6 +425,8 @@ class FileService:
phase1_hit_rules=list(phase1_hit_rules or []),
phase2_statement_hit_rules=list(phase2_statement_hit_rules or []),
phase2_baseline_hit_rules=list(phase2_baseline_hit_rules or []),
abnormal_account_hit_rules=list(abnormal_account_hit_rules or []),
abnormal_accounts=[dict(account) for account in (abnormal_accounts or [])],
parsing=parsing,
status=status,
)
@@ -444,6 +505,12 @@ class FileService:
phase1_hit_rules=rule_hit_plan.get("phase1_hit_rules", []),
phase2_statement_hit_rules=rule_hit_plan.get("phase2_statement_hit_rules", []),
phase2_baseline_hit_rules=rule_hit_plan.get("phase2_baseline_hit_rules", []),
abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []),
abnormal_accounts=self._build_abnormal_accounts(
log_id=log_id,
staff_id_card=identity_scope["staff_id_card"],
abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []),
),
)
# 存储记录
@@ -775,6 +842,12 @@ class FileService:
phase1_hit_rules=rule_hit_plan.get("phase1_hit_rules", []),
phase2_statement_hit_rules=rule_hit_plan.get("phase2_statement_hit_rules", []),
phase2_baseline_hit_rules=rule_hit_plan.get("phase2_baseline_hit_rules", []),
abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []),
abnormal_accounts=self._build_abnormal_accounts(
log_id=log_id,
staff_id_card=identity_scope["staff_id_card"],
abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []),
),
parsing=False,
)

View File

@@ -811,6 +811,117 @@ def build_salary_unused_samples(group_id: int, log_id: int, **kwargs) -> List[Di
]
def build_sudden_account_closure_samples(
group_id: int,
log_id: int,
*,
account_fact: Dict,
le_name: str = "模型测试主体",
) -> List[Dict]:
invalid_date = datetime.strptime(account_fact["invalid_date"], "%Y-%m-%d")
owner_id_card = account_fact["owner_id_card"]
account_no = account_fact["account_no"]
account_name = account_fact["account_name"]
return [
_build_statement(
group_id,
log_id,
trx_datetime=invalid_date - timedelta(days=30, hours=-1),
cret_no=owner_id_card,
customer_name="杭州临时往来款账户",
user_memo=f"{account_name}销户前资金回笼",
cash_type="对私转账",
cr_amount=88000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666610001",
),
_build_statement(
group_id,
log_id,
trx_datetime=invalid_date - timedelta(days=12, hours=2),
cret_no=owner_id_card,
customer_name="杭州消费支付商户",
user_memo=f"{account_name}销户前集中支出",
cash_type="快捷支付",
dr_amount=62000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666610002",
),
_build_statement(
group_id,
log_id,
trx_datetime=invalid_date - timedelta(days=1, hours=3),
cret_no=owner_id_card,
customer_name="浙江异常账户清理专户",
user_memo=f"{account_name}异常账户销户前转出",
cash_type="对私转账",
dr_amount=126000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666610003",
),
]
def build_dormant_account_large_activation_samples(
group_id: int,
log_id: int,
*,
account_fact: Dict,
le_name: str = "模型测试主体",
) -> List[Dict]:
effective_date = datetime.strptime(account_fact["effective_date"], "%Y-%m-%d")
activation_start = datetime(effective_date.year, effective_date.month, effective_date.day) + timedelta(days=181)
owner_id_card = account_fact["owner_id_card"]
account_no = account_fact["account_no"]
account_name = account_fact["account_name"]
return [
_build_statement(
group_id,
log_id,
trx_datetime=activation_start,
cret_no=owner_id_card,
customer_name="浙江存量资产回收账户",
user_memo=f"{account_name}休眠后异常账户激活入账",
cash_type="对公转账",
cr_amount=180000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666620001",
),
_build_statement(
group_id,
log_id,
trx_datetime=activation_start + timedelta(days=9, hours=2),
cret_no=owner_id_card,
customer_name="浙江大额往来备付金专户",
user_memo=f"{account_name}休眠激活后大额转入",
cash_type="对公转账",
cr_amount=260000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666620002",
),
_build_statement(
group_id,
log_id,
trx_datetime=activation_start + timedelta(days=18, hours=1),
cret_no=owner_id_card,
customer_name="杭州临时资金调拨账户",
user_memo=f"{account_name}休眠账户异常账户激活转出",
cash_type="对私转账",
dr_amount=120000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666620003",
),
]
LARGE_TRANSACTION_BUILDERS = {
"HOUSE_OR_CAR_EXPENSE": build_house_or_car_samples,
"TAX_EXPENSE": build_tax_samples,
@@ -842,6 +953,39 @@ PHASE2_STATEMENT_RULE_BUILDERS = {
"SALARY_UNUSED": build_salary_unused_samples,
}
ABNORMAL_ACCOUNT_RULE_BUILDERS = {
"SUDDEN_ACCOUNT_CLOSURE": build_sudden_account_closure_samples,
"DORMANT_ACCOUNT_LARGE_ACTIVATION": build_dormant_account_large_activation_samples,
}
def _resolve_abnormal_account_fact(rule_code: str, abnormal_accounts: List[Dict]) -> Optional[Dict]:
for account_fact in abnormal_accounts:
if account_fact.get("rule_code") == rule_code:
return account_fact
if rule_code == "SUDDEN_ACCOUNT_CLOSURE":
return next(
(
account_fact
for account_fact in abnormal_accounts
if account_fact.get("status") == 2 and account_fact.get("invalid_date")
),
None,
)
if rule_code == "DORMANT_ACCOUNT_LARGE_ACTIVATION":
return next(
(
account_fact
for account_fact in abnormal_accounts
if account_fact.get("status") == 1 and account_fact.get("effective_date")
),
None,
)
return None
def build_seed_statements_for_rule_plan(
group_id: int,
@@ -850,21 +994,36 @@ def build_seed_statements_for_rule_plan(
**kwargs,
) -> List[Dict]:
statements: List[Dict] = []
abnormal_accounts = list(kwargs.get("abnormal_accounts") or [])
common_kwargs = {key: value for key, value in kwargs.items() if key != "abnormal_accounts"}
for rule_code in rule_plan.get("large_transaction_hit_rules", []):
builder = LARGE_TRANSACTION_BUILDERS.get(rule_code)
if builder is not None:
statements.extend(builder(group_id, log_id, **kwargs))
statements.extend(builder(group_id, log_id, **common_kwargs))
for rule_code in rule_plan.get("phase1_hit_rules", []):
builder = PHASE1_RULE_BUILDERS.get(rule_code)
if builder is not None:
statements.extend(builder(group_id, log_id, **kwargs))
statements.extend(builder(group_id, log_id, **common_kwargs))
for rule_code in rule_plan.get("phase2_statement_hit_rules", []):
builder = PHASE2_STATEMENT_RULE_BUILDERS.get(rule_code)
if builder is not None:
statements.extend(builder(group_id, log_id, **kwargs))
statements.extend(builder(group_id, log_id, **common_kwargs))
for rule_code in rule_plan.get("abnormal_account_hit_rules", []):
builder = ABNORMAL_ACCOUNT_RULE_BUILDERS.get(rule_code)
account_fact = _resolve_abnormal_account_fact(rule_code, abnormal_accounts)
if builder is not None and account_fact is not None:
statements.extend(
builder(
group_id,
log_id,
account_fact=account_fact,
le_name=common_kwargs.get("primary_enterprise_name", "模型测试主体"),
)
)
return statements

View File

@@ -166,6 +166,9 @@ class StatementService:
"phase2_statement_hit_rules": (
list(record.phase2_statement_hit_rules) if record is not None else []
),
"abnormal_account_hit_rules": (
list(record.abnormal_account_hit_rules) if record is not None else []
),
}
if record is not None and record.staff_id_card:
allowed_identity_cards = tuple([record.staff_id_card, *record.family_id_cards])
@@ -180,6 +183,7 @@ class StatementService:
primary_account_no=primary_account_no,
staff_id_card=record.staff_id_card if record is not None else None,
family_id_cards=record.family_id_cards if record is not None else None,
abnormal_accounts=record.abnormal_accounts if record is not None else None,
)
safe_all_mode_noise = settings.RULE_HIT_MODE == "all" and record is not None

View File

@@ -163,6 +163,29 @@ def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch):
assert record.total_records == 200
def test_fetch_inner_flow_should_attach_abnormal_account_rule_plan():
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
response = service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_account",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = service.file_records[log_id]
assert hasattr(record, "abnormal_account_hit_rules")
assert hasattr(record, "abnormal_accounts")
assert isinstance(record.abnormal_account_hit_rules, list)
assert isinstance(record.abnormal_accounts, list)
def test_generate_log_id_should_retry_when_random_value_conflicts(monkeypatch):
"""随机 logId 命中已存在记录时必须重试并返回未占用值。"""
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())

View File

@@ -4,6 +4,7 @@ StatementService 主绑定注入测试
from collections import Counter, defaultdict
import services.statement_rule_samples as statement_rule_samples
from services.file_service import FileService
from services.statement_service import StatementService
from services.statement_rule_samples import (
@@ -234,6 +235,82 @@ def test_generate_statements_should_follow_rule_hit_plan_from_file_record():
assert not any("购汇" in item["userMemo"] for item in statements)
def test_sudden_account_closure_samples_should_stay_within_30_days_before_invalid_date():
statements = statement_rule_samples.build_sudden_account_closure_samples(
group_id=1000,
log_id=20001,
account_fact={
"account_no": "6222000000000001",
"owner_id_card": "320101199001010030",
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
},
le_name="测试主体",
)
assert statements
assert all("6222000000000001" == item["accountMaskNo"] for item in statements)
assert all("2026-02-18" <= item["trxDate"][:10] < "2026-03-20" for item in statements)
def test_dormant_account_large_activation_samples_should_exceed_threshold_after_6_months():
statements = statement_rule_samples.build_dormant_account_large_activation_samples(
group_id=1000,
log_id=20001,
account_fact={
"account_no": "6222000000000002",
"owner_id_card": "320101199001010030",
"account_name": "测试员工工资卡",
"status": 1,
"effective_date": "2025-01-01",
"invalid_date": None,
},
le_name="测试主体",
)
assert statements
assert min(item["trxDate"][:10] for item in statements) >= "2025-07-01"
assert sum(item["drAmount"] + item["crAmount"] for item in statements) >= 500000
assert max(item["drAmount"] + item["crAmount"] for item in statements) >= 100000
def test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record():
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_rule_plan",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.abnormal_account_hit_rules = ["SUDDEN_ACCOUNT_CLOSURE"]
record.abnormal_accounts = [
{
"account_no": "6222000000000001",
"owner_id_card": record.staff_id_card,
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
}
]
statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=80)
assert any(item["accountMaskNo"] == "6222000000000001" for item in statements)
assert any("销户" in item["userMemo"] or "异常账户" in item["userMemo"] for item in statements)
def test_generate_statements_should_stay_within_single_employee_scope_per_log_id():
"""同一 logId 的流水只能落在 FileRecord 绑定的员工及亲属身份证内。"""
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
@@ -572,6 +649,8 @@ def test_generate_statements_should_keep_all_mode_noise_as_safe_debits(monkeypat
"MONTHLY_FIXED_INCOME",
"FIXED_COUNTERPARTY_TRANSFER",
]
record.abnormal_account_hit_rules = []
record.abnormal_accounts = []
statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=30)
noise_statements = [