修复Mock流水按数据库员工及亲属绑定身份证

This commit is contained in:
wkc
2026-03-19 16:07:28 +08:00
parent 627886f711
commit 0457c8f3a6
12 changed files with 426 additions and 34 deletions

View File

@@ -0,0 +1,54 @@
# LSFX Mock 数据库身份绑定实施计划
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:**`lsfx-mock-server` 在创建 `logId` 时从员工信息库随机选择一个员工,并读取该员工亲属,后续同一 `logId` 的流水仅使用这组数据库身份。
**Architecture:** 在 Mock 服务中新增一个只读身份仓储,负责从 `ccdi_base_staff``ccdi_staff_fmy_relation` 读取员工及亲属。`FileService` 在创建 `FileRecord` 时写入选中的员工/亲属身份,`StatementService` 只复用该记录中的证件号,不再依赖内置固定身份证池。
**Tech Stack:** FastAPI, Python 3.9, PyMySQL, pytest
---
### Task 1: 补数据库身份绑定失败测试
**Files:**
- Modify: `lsfx-mock-server/tests/test_file_service.py`
- Modify: `lsfx-mock-server/tests/test_statement_service.py`
- [ ] Step 1: 写出 `FileService` 需要保存员工与亲属身份的失败测试
- [ ] Step 2: 运行对应测试,确认当前实现失败
- [ ] Step 3: 写出 `StatementService` 只能使用该员工及其亲属证件号的失败测试
- [ ] Step 4: 运行对应测试,确认当前实现失败
### Task 2: 新增只读身份仓储
**Files:**
- Create: `lsfx-mock-server/services/staff_identity_repository.py`
- Modify: `lsfx-mock-server/config/settings.py`
- Modify: `lsfx-mock-server/requirements.txt`
- [ ] Step 1: 定义员工/亲属身份查询结果结构与仓储接口
- [ ] Step 2: 接入 MySQL 只读查询配置
- [ ] Step 3: 实现“随机取一个有身份证号员工 + 读取该员工有效亲属”的最小查询逻辑
- [ ] Step 4: 保持仓储可被测试替身替换
### Task 3: 将 logId 与数据库身份绑定
**Files:**
- Modify: `lsfx-mock-server/services/file_service.py`
- Modify: `lsfx-mock-server/services/statement_service.py`
- Modify: `lsfx-mock-server/routers/api.py`
- [ ] Step 1: 在 `FileRecord` 中增加员工/亲属身份字段
- [ ] Step 2: 在上传文件与拉取行内流水时写入随机员工及其亲属
- [ ] Step 3: 在流水生成阶段只使用 `FileRecord` 中的员工/亲属证件号
- [ ] Step 4: 保留 deterministic fallback但不影响真实记录链路
### Task 4: 回归验证与文档沉淀
**Files:**
- Create: `docs/reports/implementation/2026-03-19-lsfx-mock-db-backed-identity-selection-fix.md`
- [ ] Step 1: 运行 `lsfx-mock-server` 相关测试
- [ ] Step 2: 记录根因、改动点和验证结果

View File

@@ -0,0 +1,58 @@
# 2026-03-19 LSFX Mock 数据库身份绑定修复记录
## 背景
- 调整目标:一个 `logId` 需要从员工信息库随机选取一个员工身份证号,并且该员工的亲属证件号也必须从数据库读取。
- 业务口径:同一个 `logId` 的流水证件号范围仅允许使用“该员工本人 + 该员工亲属”。
## 根因
- `lsfx-mock-server` 之前使用内置固定身份证池生成 `cretNo`,没有接入 `ccdi_base_staff``ccdi_staff_fmy_relation`
- 即使前一版已经收敛到单员工域,身份来源仍然是 mock 常量,不满足“从数据库读取员工及亲属”的要求。
## 修改内容
### 1. 新增只读员工身份仓储
- 新增 `lsfx-mock-server/services/staff_identity_repository.py`
- 仓储使用 `PyMySQL` 只读查询:
-`ccdi_base_staff` 随机选取一个有身份证号的在职员工
- 优先选择存在有效亲属记录的员工
-`ccdi_staff_fmy_relation` 读取该员工的有效亲属证件号
### 2. FileRecord 绑定数据库身份
- `lsfx-mock-server/services/file_service.py`
- `FileService` 支持注入身份仓储
- `FileRecord` 新增:
- `staff_name`
- `staff_id_card`
- `family_id_cards`
- 上传文件与拉取行内流水时,创建 `logId` 的同时写入该员工及亲属身份
### 3. 流水生成只复用绑定身份
- `lsfx-mock-server/services/statement_service.py`
-`logId` 已存在真实 `FileRecord`,则流水生成阶段只使用该记录中的 `staff_id_card``family_id_cards`
- `lsfx-mock-server/services/statement_rule_samples.py`
- 固定命中样本支持接收外部传入的员工/亲属证件号,避免继续依赖内置固定身份证
### 4. 配置与测试
- `lsfx-mock-server/config/settings.py`
- 从主项目 `ruoyi-admin/src/main/resources/application-dev.yml` 提取数据库连接默认值
- `lsfx-mock-server/requirements.txt`
- 新增 `PyMySQL`
- 测试层新增假仓储,避免单元/集成测试访问真实数据库
## 验证结果
- 执行:
- `cd lsfx-mock-server && python3 -m pytest tests/test_file_service.py tests/test_statement_service.py tests/integration/test_full_workflow.py -q`
- 结果:
- `17 passed`
## 影响说明
- 本次变更仅作用于 `lsfx-mock-server`
- 真实运行时会按数据库随机绑定员工与亲属;测试环境仍通过假仓储保持可控与稳定。

View File

@@ -0,0 +1,42 @@
# 2026-03-19 LSFX Mock 单员工域修复记录
## 背景
- 现象:新建项目后导入单个流水文件,解析完成后结果总览出现两个员工信息。
- 预期:单个上传文件只应归属到一个员工域,结果总览最多出现该员工本人对应的一条员工信息。
## 根因
- `lsfx-mock-server/services/statement_rule_samples.py` 中的大额交易固定样本同时混入了两名员工及两名家属的证件号。
- `lsfx-mock-server/services/statement_service.py` 中的随机噪声流水继续从四个证件号的全量池随机取值。
- 因此同一个 `logId` 返回的流水天然会覆盖两个员工域,主系统按 `cret_no` 聚合后就会出现两个员工。
## 修改内容
### Mock 造数收敛为单员工域
-`lsfx-mock-server/services/statement_rule_samples.py` 新增 `IDENTITY_SCOPES`
- 新增 `resolve_identity_scope(log_id)``resolve_identity_cards(log_id)`,按 `logId` 稳定选择单个员工域。
- 将固定命中样本从“跨两个员工域混用”改为“只使用当前员工域的员工本人及家属”。
### 随机噪声不再污染其他员工
-`lsfx-mock-server/services/statement_service.py` 中改为按 `logId` 读取允许的证件号范围。
- 随机噪声流水的 `cretNo` 仅能从当前员工域的两张证件号中选择。
### 测试补充
-`lsfx-mock-server/tests/test_statement_service.py` 增加“同一 `logId` 只能落在单一员工域”测试。
- 调整大额交易样本测试,改为基于 `resolve_identity_scope(log_id)` 校验,避免继续依赖固定第二名员工。
## 验证结果
- 执行:
- `cd lsfx-mock-server && python3 -m pytest tests/test_statement_service.py tests/test_file_service.py tests/integration/test_full_workflow.py -q`
- 结果:
- `17 passed`
## 影响说明
- 本次只修改 `lsfx-mock-server` 的 mock 造数逻辑,不影响主系统 Java/Vue 代码。
- 已存在库中的历史测试项目数据不会自动回写;如需验证页面结果,需要重启 mock 服务后重新上传文件生成新流水。

View File

@@ -1,5 +1,30 @@
from pydantic_settings import BaseSettings
from typing import Optional
from pathlib import Path
import re
def _load_ruoyi_mysql_defaults() -> dict:
"""从主项目开发配置中提取 MySQL 连接默认值。"""
project_root = Path(__file__).resolve().parents[2]
config_path = project_root / "ruoyi-admin" / "src" / "main" / "resources" / "application-dev.yml"
if not config_path.exists():
return {}
text = config_path.read_text(encoding="utf-8")
match = re.search(
r"master:\s*\n"
r"\s*url:\s*jdbc:mysql://(?P<host>[^:/?#]+):(?P<port>\d+)/(?P<database>[^?\s]+)[^\n]*\n"
r"\s*username:\s*(?P<username>[^\n]+)\n"
r"\s*password:\s*(?P<password>[^\n]+)",
text,
)
if not match:
return {}
return {key: value.strip() for key, value in match.groupdict().items()}
MYSQL_DEFAULTS = _load_ruoyi_mysql_defaults()
class Settings(BaseSettings):
@@ -22,6 +47,14 @@ class Settings(BaseSettings):
INITIAL_PROJECT_ID: int = 1000
INITIAL_LOG_ID: int = 10000
# 员工库只读配置
CCDI_DB_HOST: str = MYSQL_DEFAULTS.get("host", "")
CCDI_DB_PORT: int = int(MYSQL_DEFAULTS.get("port", 3306))
CCDI_DB_NAME: str = MYSQL_DEFAULTS.get("database", "")
CCDI_DB_USERNAME: str = MYSQL_DEFAULTS.get("username", "")
CCDI_DB_PASSWORD: str = MYSQL_DEFAULTS.get("password", "")
CCDI_DB_CONNECT_TIMEOUT_SECONDS: int = 5
class Config:
env_file = ".env"
env_file_encoding = "utf-8"

View File

@@ -6,3 +6,4 @@ python-multipart==0.0.6
pytest>=7.0.0
pytest-cov>=4.0.0
httpx>=0.25.0
PyMySQL==1.1.1

View File

@@ -1,6 +1,7 @@
from fastapi import BackgroundTasks, UploadFile
from utils.response_builder import ResponseBuilder
from config.settings import settings
from services.staff_identity_repository import StaffIdentityRepository
from typing import Dict, List, Union
from dataclasses import dataclass, field
import time
@@ -59,13 +60,19 @@ class FileRecord:
trx_date_start_id: int = 20240101
trx_date_end_id: int = 20241231
# 新增字段 - 身份绑定
staff_name: str = ""
staff_id_card: str = ""
family_id_cards: List[str] = field(default_factory=list)
class FileService:
"""文件上传和解析服务"""
def __init__(self):
def __init__(self, staff_identity_repository=None):
self.file_records: Dict[int, FileRecord] = {} # logId -> FileRecord
self.log_counter = settings.INITIAL_LOG_ID
self.staff_identity_repository = staff_identity_repository or StaffIdentityRepository()
def get_file_record(self, log_id: int) -> FileRecord:
"""按 logId 获取已存在的文件记录。"""
@@ -118,6 +125,9 @@ class FileService:
trx_date_end_id: int,
le_id: int,
login_le_id: int,
staff_name: str = "",
staff_id_card: str = "",
family_id_cards: List[str] = None,
parsing: bool = True,
status: int = -5,
) -> FileRecord:
@@ -145,10 +155,17 @@ class FileService:
total_records=total_records,
trx_date_start_id=trx_date_start_id,
trx_date_end_id=trx_date_end_id,
staff_name=staff_name,
staff_id_card=staff_id_card,
family_id_cards=list(family_id_cards or []),
parsing=parsing,
status=status,
)
def _select_staff_identity_scope(self) -> dict:
"""读取一个员工及其亲属身份范围。"""
return self.staff_identity_repository.select_random_staff_with_families()
async def upload_file(
self, group_id: int, file: UploadFile, background_tasks: BackgroundTasks
) -> Dict:
@@ -177,6 +194,7 @@ class FileService:
# 生成单一主绑定
primary_enterprise_name, primary_account_no = self._generate_primary_binding()
identity_scope = self._select_staff_identity_scope()
# 创建完整的文件记录
file_record = self._create_file_record(
@@ -194,6 +212,9 @@ class FileService:
trx_date_end_id=trx_date_end_id,
le_id=10000 + random.randint(0, 9999),
login_le_id=10000 + random.randint(0, 9999),
staff_name=identity_scope["staff_name"],
staff_id_card=identity_scope["staff_id_card"],
family_id_cards=identity_scope["family_id_cards"],
)
# 存储记录
@@ -500,6 +521,7 @@ class FileService:
log_id = self.log_counter
primary_enterprise_name, primary_account_no = self._generate_primary_binding()
identity_scope = self._select_staff_identity_scope()
file_record = self._create_file_record(
log_id=log_id,
group_id=group_id,
@@ -515,6 +537,9 @@ class FileService:
trx_date_end_id=data_end_date_id,
le_id=10000 + random.randint(0, 9999),
login_le_id=10000 + random.randint(0, 9999),
staff_name=identity_scope["staff_name"],
staff_id_card=identity_scope["staff_id_card"],
family_id_cards=identity_scope["family_id_cards"],
parsing=False,
)

View File

@@ -0,0 +1,87 @@
from typing import Dict, List
from config.settings import settings
class StaffIdentityRepository:
"""从员工信息库中读取员工及亲属身份证信息。"""
def _connect(self):
try:
import pymysql
except ImportError as exc:
raise RuntimeError("缺少 PyMySQL 依赖,无法读取员工信息库") from exc
return pymysql.connect(
host=settings.CCDI_DB_HOST,
port=settings.CCDI_DB_PORT,
user=settings.CCDI_DB_USERNAME,
password=settings.CCDI_DB_PASSWORD,
database=settings.CCDI_DB_NAME,
charset="utf8mb4",
connect_timeout=settings.CCDI_DB_CONNECT_TIMEOUT_SECONDS,
cursorclass=pymysql.cursors.DictCursor,
)
def select_random_staff_with_families(self) -> Dict[str, object]:
"""随机选择一个员工,并读取其有效亲属证件号。"""
with self._connect() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT s.name AS staff_name, s.id_card AS staff_id_card
FROM ccdi_base_staff s
WHERE COALESCE(TRIM(s.id_card), '') <> ''
AND s.status = '0'
AND EXISTS (
SELECT 1
FROM ccdi_staff_fmy_relation r
WHERE r.person_id = s.id_card
AND r.status = 1
AND COALESCE(TRIM(r.relation_cert_no), '') <> ''
)
ORDER BY RAND()
LIMIT 1
"""
)
staff = cursor.fetchone()
if not staff:
cursor.execute(
"""
SELECT s.name AS staff_name, s.id_card AS staff_id_card
FROM ccdi_base_staff s
WHERE COALESCE(TRIM(s.id_card), '') <> ''
AND s.status = '0'
ORDER BY RAND()
LIMIT 1
"""
)
staff = cursor.fetchone()
if not staff:
raise RuntimeError("员工信息库中不存在可用身份证号")
cursor.execute(
"""
SELECT DISTINCT relation_cert_no
FROM ccdi_staff_fmy_relation
WHERE person_id = %s
AND status = 1
AND COALESCE(TRIM(relation_cert_no), '') <> ''
ORDER BY relation_cert_no
""",
(staff["staff_id_card"],),
)
family_rows = cursor.fetchall()
family_id_cards: List[str] = [
row["relation_cert_no"]
for row in family_rows
if row["relation_cert_no"] != staff["staff_id_card"]
]
return {
"staff_name": staff["staff_name"],
"staff_id_card": staff["staff_id_card"],
"family_id_cards": family_id_cards,
}

View File

@@ -34,11 +34,33 @@ IDENTITY_POOL = {
},
}
IDENTITY_SCOPES = {
"primary": {
"staff": IDENTITY_POOL["staff_primary"],
"family": IDENTITY_POOL["family_primary"],
},
"secondary": {
"staff": IDENTITY_POOL["staff_secondary"],
"family": IDENTITY_POOL["family_secondary"],
},
}
IDENTITY_CARD_POOL = tuple(identity["id_card"] for identity in IDENTITY_POOL.values())
REFERENCE_NOW = datetime(2026, 3, 18, 9, 0, 0)
def resolve_identity_scope(log_id: int) -> Dict[str, Dict[str, str]]:
"""按 logId 稳定选择单个员工域。"""
return IDENTITY_SCOPES["primary"] if log_id % 2 == 1 else IDENTITY_SCOPES["secondary"]
def resolve_identity_cards(log_id: int) -> tuple:
"""返回指定 logId 允许出现的证件号集合(员工本人及家属)。"""
identity_scope = resolve_identity_scope(log_id)
return tuple(identity["id_card"] for identity in identity_scope.values())
def _format_datetime(value: datetime) -> str:
return value.strftime("%Y-%m-%d %H:%M:%S")
@@ -124,9 +146,17 @@ def build_large_transaction_seed_statements(
log_id: int,
primary_enterprise_name: Optional[str] = None,
primary_account_no: Optional[str] = None,
staff_id_card: Optional[str] = None,
family_id_cards: Optional[List[str]] = None,
) -> List[Dict]:
le_name = primary_enterprise_name or "模型测试主体"
account_no = primary_account_no or "6222024999999999"
identity_scope = resolve_identity_scope(log_id)
staff_identity = identity_scope["staff"]
family_identity = identity_scope["family"]
selected_staff_id_card = staff_id_card or staff_identity["id_card"]
selected_family_id_cards = list(family_id_cards or [family_identity["id_card"]])
primary_family_id_card = selected_family_id_cards[0] if selected_family_id_cards else selected_staff_id_card
statements: List[Dict] = []
@@ -135,7 +165,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=9, hours=1),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="杭州贝壳房地产经纪有限公司",
user_memo="购买房产首付款",
cash_type="对公转账",
@@ -148,7 +178,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=8, hours=2),
cret_no=IDENTITY_POOL["family_primary"]["id_card"],
cret_no=primary_family_id_card,
customer_name="兰溪星耀汽车销售服务有限公司",
user_memo="购车首付款",
cash_type="对公转账",
@@ -161,7 +191,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=7, hours=1),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="国家金库兰溪市中心支库",
user_memo="个人所得税税款",
cash_type="税务缴款",
@@ -174,7 +204,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=6, hours=3),
cret_no=IDENTITY_POOL["family_secondary"]["id_card"],
cret_no=primary_family_id_card,
customer_name="兰溪市税务局",
user_memo="房产税务缴税",
cash_type="税务缴款",
@@ -187,7 +217,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=5, hours=2),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="浙江远望贸易有限公司",
user_memo="经营往来收入",
cash_type="对公转账",
@@ -200,7 +230,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=5, hours=1),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="浙江远望贸易有限公司",
user_memo="项目回款收入",
cash_type="对公转账",
@@ -213,7 +243,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=4, hours=4),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="浙江远望贸易有限公司",
user_memo="业务合作收入",
cash_type="对公转账",
@@ -226,7 +256,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=datetime(2026, 3, 10, 9, 0, 0),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="",
user_memo="现金存款",
cash_type="现金存款",
@@ -238,7 +268,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=datetime(2026, 3, 10, 9, 30, 0),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="",
user_memo="ATM现金存款",
cash_type="现金存款",
@@ -250,7 +280,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=datetime(2026, 3, 10, 10, 0, 0),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="",
user_memo="自助存款现金存入",
cash_type="现金存款",
@@ -262,7 +292,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=datetime(2026, 3, 10, 10, 30, 0),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="",
user_memo="CRS存款",
cash_type="现金存款",
@@ -274,7 +304,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=datetime(2026, 3, 10, 11, 0, 0),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="",
user_memo="本行ATM存款",
cash_type="现金存款",
@@ -286,7 +316,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=datetime(2026, 3, 10, 11, 30, 0),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="",
user_memo="柜面现金存款",
cash_type="现金存款",
@@ -298,7 +328,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=3, hours=1),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="异地转账平台",
user_memo="手机银行转账",
cash_type="转账支出",
@@ -311,7 +341,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=3, hours=2),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="跨行转账中心",
user_memo="对外转账",
cash_type="转账支出",
@@ -324,7 +354,7 @@ def build_large_transaction_seed_statements(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=2, hours=5),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
cret_no=selected_staff_id_card,
customer_name="跨境转账服务平台",
user_memo="网银转账",
cash_type="转账支出",

View File

@@ -5,8 +5,8 @@ import uuid
from datetime import datetime, timedelta
from services.statement_rule_samples import (
IDENTITY_CARD_POOL,
build_large_transaction_seed_statements,
resolve_identity_cards,
)
# 配置日志
@@ -39,6 +39,7 @@ class StatementService:
log_id: int,
primary_enterprise_name: str,
primary_account_no: str,
allowed_identity_cards: tuple,
rng: random.Random,
) -> Dict:
"""生成单条随机噪声流水记录。"""
@@ -87,7 +88,7 @@ class StatementService:
"crAmount": cr_amount,
"createDate": reference_now.strftime("%Y-%m-%d %H:%M:%S"),
"createdBy": "902001",
"cretNo": rng.choice(IDENTITY_CARD_POOL),
"cretNo": rng.choice(allowed_identity_cards),
"currency": "CNY",
"customerAccountMaskNo": str(rng.randint(100000000, 999999999)),
"customerBank": "",
@@ -140,12 +141,19 @@ class StatementService:
def _generate_statements(self, group_id: int, log_id: int, count: int) -> List[Dict]:
"""生成指定数量的流水记录。"""
primary_enterprise_name, primary_account_no = self._resolve_primary_binding(log_id)
record = self.file_service.get_file_record(log_id) if self.file_service is not None else None
if record is not None and record.staff_id_card:
allowed_identity_cards = tuple([record.staff_id_card, *record.family_id_cards])
else:
allowed_identity_cards = resolve_identity_cards(log_id)
rng = random.Random(f"statement:{log_id}")
seeded_statements = build_large_transaction_seed_statements(
group_id=group_id,
log_id=log_id,
primary_enterprise_name=primary_enterprise_name,
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,
)
total_count = max(count, len(seeded_statements))
@@ -157,6 +165,7 @@ class StatementService:
log_id,
primary_enterprise_name,
primary_account_no,
allowed_identity_cards,
rng,
)
)

View File

@@ -14,11 +14,21 @@ from config.settings import settings
from routers.api import file_service
class FakeStaffIdentityRepository:
def select_random_staff_with_families(self):
return {
"staff_name": "测试员工",
"staff_id_card": "320101199001010030",
"family_id_cards": ["320101199201010051"],
}
@pytest.fixture(autouse=True)
def reset_file_service_state():
"""避免 file_service 单例状态影响测试顺序。"""
file_service.file_records.clear()
file_service.log_counter = settings.INITIAL_LOG_ID
file_service.staff_identity_repository = FakeStaffIdentityRepository()
yield
file_service.file_records.clear()
file_service.log_counter = settings.INITIAL_LOG_ID

View File

@@ -11,9 +11,18 @@ from fastapi.datastructures import UploadFile
from services.file_service import FileService
class FakeStaffIdentityRepository:
def select_random_staff_with_families(self):
return {
"staff_name": "数据库员工",
"staff_id_card": "320101199001010030",
"family_id_cards": ["320101199201010051", "320101199301010052"],
}
def test_upload_file_primary_binding_response(monkeypatch):
"""同一 logId 的主绑定必须稳定且只保留一组主体/账号信息。"""
service = FileService()
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
monkeypatch.setattr(
service,
@@ -42,7 +51,7 @@ def test_upload_file_primary_binding_response(monkeypatch):
def test_upload_file_total_records_range(monkeypatch):
"""上传文件返回的流水条数必须限制在 150-200 条。"""
service = FileService()
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
monkeypatch.setattr(
service,
@@ -69,7 +78,7 @@ def test_upload_file_total_records_range(monkeypatch):
def test_upload_file_then_upload_status_reads_same_record(monkeypatch):
"""上传后再查状态时,上传状态接口必须读取同一条真实记录。"""
service = FileService()
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
monkeypatch.setattr(
service,
@@ -108,8 +117,8 @@ def test_upload_file_then_upload_status_reads_same_record(monkeypatch):
def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch):
"""拉取行内流水必须创建并保存绑定记录"""
service = FileService()
"""拉取行内流水必须创建并保存数据库员工及亲属身份"""
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
monkeypatch.setattr(
service,
@@ -137,6 +146,9 @@ def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch):
assert record.parsing is False
assert record.primary_enterprise_name
assert record.primary_account_no
assert record.staff_name == "数据库员工"
assert record.staff_id_card == "320101199001010030"
assert record.family_id_cards == ["320101199201010051", "320101199301010052"]
assert record.primary_enterprise_name == "行内主体"
assert record.primary_account_no == "6210987654321098"
assert record.enterprise_name_list == ["行内主体"]

View File

@@ -12,6 +12,15 @@ from services.statement_rule_samples import (
)
class FakeStaffIdentityRepository:
def select_random_staff_with_families(self):
return {
"staff_name": "数据库员工",
"staff_id_card": "320101199001010030",
"family_id_cards": ["320101199201010051", "320101199301010052"],
}
def test_generate_statements_should_include_seeded_samples_before_noise():
"""生成流水时必须先混入固定命中样本,而不是纯随机噪声。"""
service = StatementService()
@@ -89,6 +98,30 @@ def test_generate_statements_should_fill_noise_up_to_requested_count():
assert len(statements) == 80
def test_generate_statements_should_stay_within_single_employee_scope_per_log_id():
"""同一 logId 的流水只能落在 FileRecord 绑定的员工及亲属身份证内。"""
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_scope",
"dataChannelCode": "test",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
allowed_id_cards = {record.staff_id_card, *record.family_id_cards}
statements = service._generate_statements(group_id=1000, log_id=log_id, count=1600)
assert {item["cretNo"] for item in statements}.issubset(allowed_id_cards)
def test_generate_statements_should_only_use_recognizable_identity_cards():
"""命中样本和随机噪声都只能使用现库可识别的身份证号。"""
service = StatementService()
@@ -121,7 +154,7 @@ def test_get_bank_statement_should_keep_same_cached_result_for_same_log_id():
def test_get_bank_statement_uses_primary_binding_from_file_service(monkeypatch):
"""同一 logId 的流水记录必须复用 FileService 中的主体与账号绑定。"""
file_service = FileService()
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
monkeypatch.setattr(
@@ -161,7 +194,7 @@ def test_get_bank_statement_uses_primary_binding_from_file_service(monkeypatch):
def test_get_bank_statement_contains_large_transaction_hit_samples(monkeypatch):
"""流水 Mock 首次生成时必须稳定包含可命中大额交易规则的样本簇。"""
file_service = FileService()
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
monkeypatch.setattr(
@@ -182,6 +215,9 @@ def test_get_bank_statement_contains_large_transaction_hit_samples(monkeypatch):
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
staff_id_card = record.staff_id_card
family_id_card = record.family_id_cards[0]
statement_response = statement_service.get_bank_statement(
{
@@ -195,12 +231,7 @@ def test_get_bank_statement_contains_large_transaction_hit_samples(monkeypatch):
assert statements
assert any(
item["cretNo"] in {
"330101198801010011",
"330101199001010022",
"330101198802020033",
"330101199202020044",
}
item["cretNo"] in {staff_id_card, family_id_card}
for item in statements
)
assert any("房产首付款" in item["userMemo"] for item in statements)
@@ -212,7 +243,7 @@ def test_get_bank_statement_contains_large_transaction_hit_samples(monkeypatch):
for item in statements:
if (
item["cretNo"] == "330101198802020033"
item["cretNo"] == staff_id_card
and item["customerName"] == "浙江远望贸易有限公司"
and item["crAmount"] > 0
):