484 lines
15 KiB
Markdown
484 lines
15 KiB
Markdown
# LSFX Mock LogId Primary Binding Backend Implementation Plan
|
||
|
||
> **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` 绑定唯一且稳定的本方主体与本方账号,并在上传响应、上传状态和银行流水查询中保持一致。
|
||
|
||
**Architecture:** 在 `FileService` 中统一生成并维护 `logId -> 主体账号绑定`,以 `FileRecord` 作为单一事实来源;上传文件与拉取本行信息两条链路都创建完整记录;`StatementService` 通过只读查询能力按 `logId` 取回绑定并写入每条流水的 `leName/accountMaskNo`,不再自行随机本方主体或本方账号。
|
||
|
||
**Tech Stack:** Python 3, FastAPI, pytest, httpx TestClient
|
||
|
||
## 执行结果
|
||
|
||
- 2026-03-18 已按计划完成 Task 1 至 Task 5,功能实现、实施记录与最终验证均已落地。
|
||
- 代码提交顺序:
|
||
- `0120d09` `收敛Mock文件记录主体账号绑定模型`
|
||
- `6fb7287` `让拉取本行信息链路复用Mock主体账号绑定`
|
||
- `0a85c09` `统一Mock上传状态主体账号绑定优先级`
|
||
- `5195617` `让Mock流水查询复用logId主体账号绑定`
|
||
- 最终验证已通过:
|
||
- `python3 -m pytest tests/test_file_service.py -v`
|
||
- `python3 -m pytest tests/test_statement_service.py -v`
|
||
- `python3 -m pytest tests/test_api.py -v`
|
||
- `python3 -m pytest tests/integration/test_full_workflow.py -v`
|
||
- `python3 verify_implementation.py`
|
||
- 实施细节见 `docs/reports/implementation/2026-03-18-lsfx-logid-primary-binding-implementation.md`
|
||
|
||
---
|
||
|
||
### Task 1: 收敛 FileRecord 为单一主体账号绑定模型
|
||
|
||
**Files:**
|
||
- Modify: `lsfx-mock-server/services/file_service.py`
|
||
- Create: `lsfx-mock-server/tests/test_file_service.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
先新增 `test_file_service.py`,锁定 `FileRecord` 和上传响应的最小语义:一个 `logId` 只对应一个主体与一个账号。
|
||
|
||
```python
|
||
import pytest
|
||
from fastapi import BackgroundTasks, UploadFile
|
||
from io import BytesIO
|
||
|
||
from services.file_service import FileService
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_upload_file_should_create_single_primary_binding():
|
||
service = FileService()
|
||
upload = UploadFile(filename="测试流水.csv", file=BytesIO(b"demo"))
|
||
|
||
response = await service.upload_file(1000, upload, BackgroundTasks())
|
||
|
||
log = response["data"]["uploadLogList"][0]
|
||
assert len(log["enterpriseNameList"]) == 1
|
||
assert len(log["accountNoList"]) == 1
|
||
assert response["data"]["accountsOfLog"][str(log["logId"])][0]["accountName"] == log["enterpriseNameList"][0]
|
||
assert response["data"]["accountsOfLog"][str(log["logId"])][0]["accountNo"] == log["accountNoList"][0]
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd lsfx-mock-server
|
||
pytest tests/test_file_service.py -k primary_binding -v
|
||
```
|
||
|
||
Expected:
|
||
|
||
- `FAIL`
|
||
- 原因是 `tests/test_file_service.py` 尚不存在,且 `FileRecord` 还没有明确的单值主体账号语义
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
在 `file_service.py` 中增加单值字段和统一生成方法,例如:
|
||
|
||
```python
|
||
@dataclass
|
||
class FileRecord:
|
||
primary_enterprise_name: str = ""
|
||
primary_account_no: str = ""
|
||
```
|
||
|
||
```python
|
||
PRIMARY_ENTERPRISE_POOL = [
|
||
"测试主体A",
|
||
"测试主体B",
|
||
"测试主体C",
|
||
"兰溪测试主体一部",
|
||
"兰溪测试主体二部",
|
||
]
|
||
|
||
|
||
def _generate_primary_account_binding(self) -> tuple[str, str]:
|
||
enterprise_name = random.choice(PRIMARY_ENTERPRISE_POOL)
|
||
account_no = f"{random.randint(100000000000000, 999999999999999)}"
|
||
return enterprise_name, account_no
|
||
```
|
||
|
||
并在 `upload_file()` 中回填:
|
||
|
||
```python
|
||
enterprise_name, account_no = self._generate_primary_account_binding()
|
||
file_record.primary_enterprise_name = enterprise_name
|
||
file_record.primary_account_no = account_no
|
||
file_record.enterprise_name_list = [enterprise_name]
|
||
file_record.account_no_list = [account_no]
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd lsfx-mock-server
|
||
pytest tests/test_file_service.py -k primary_binding -v
|
||
```
|
||
|
||
Expected:
|
||
|
||
- `PASS`
|
||
- 说明上传接口返回已经围绕单一主体账号绑定工作
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lsfx-mock-server/services/file_service.py lsfx-mock-server/tests/test_file_service.py
|
||
git commit -m "收敛Mock文件记录主体账号绑定模型"
|
||
```
|
||
|
||
### Task 2: 让拉取本行信息链路也创建并保存绑定记录
|
||
|
||
**Files:**
|
||
- Modify: `lsfx-mock-server/services/file_service.py`
|
||
- Modify: `lsfx-mock-server/tests/test_file_service.py`
|
||
- Modify: `lsfx-mock-server/tests/test_api.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
补两个测试,一个测服务层,一个测 API 层:
|
||
|
||
```python
|
||
def test_fetch_inner_flow_should_persist_file_record_for_returned_log_id():
|
||
service = FileService()
|
||
|
||
response = service.fetch_inner_flow({"groupId": 1000})
|
||
log_id = response["data"][0]
|
||
|
||
assert log_id in service.file_records
|
||
record = service.file_records[log_id]
|
||
assert record.primary_enterprise_name
|
||
assert record.primary_account_no
|
||
```
|
||
|
||
```python
|
||
def test_fetch_inner_flow_followed_by_upload_status_should_keep_same_binding(client, sample_inner_flow_request):
|
||
flow_response = client.post("/watson/api/project/getJZFileOrZjrcuFile", data=sample_inner_flow_request)
|
||
log_id = flow_response.json()["data"][0]
|
||
|
||
status_response = client.get(f"/watson/api/project/bs/upload?groupId=1000&logId={log_id}")
|
||
log = status_response.json()["data"]["logs"][0]
|
||
|
||
assert len(log["enterpriseNameList"]) == 1
|
||
assert len(log["accountNoList"]) == 1
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd lsfx-mock-server
|
||
pytest tests/test_file_service.py -k fetch_inner_flow -v
|
||
pytest tests/test_api.py -k "fetch_inner_flow_followed_by_upload_status" -v
|
||
```
|
||
|
||
Expected:
|
||
|
||
- `FAIL`
|
||
- 原因是 `fetch_inner_flow()` 当前只返回随机 `logId`,不会创建 `FileRecord`
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
把 `fetch_inner_flow()` 改成复用和上传相同的记录创建逻辑:
|
||
|
||
```python
|
||
def _create_file_record(self, group_id: int, file_name: str, bank_name: str, template_name: str) -> FileRecord:
|
||
...
|
||
```
|
||
|
||
```python
|
||
def fetch_inner_flow(self, request: Union[Dict, object]) -> Dict:
|
||
group_id = request.get("groupId", 1000) if isinstance(request, dict) else request.groupId
|
||
self.log_counter += 1
|
||
log_id = self.log_counter
|
||
file_record = self._create_file_record(group_id, f"inner_flow_{log_id}.csv", "ZJRCU", "ZJRCU_T251114")
|
||
self.file_records[log_id] = file_record
|
||
return {"code": "200", "data": [log_id], "status": "200", "successResponse": True}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd lsfx-mock-server
|
||
pytest tests/test_file_service.py -k fetch_inner_flow -v
|
||
pytest tests/test_api.py -k "fetch_inner_flow_followed_by_upload_status" -v
|
||
```
|
||
|
||
Expected:
|
||
|
||
- `PASS`
|
||
- 说明拉取本行信息返回的 `logId` 已经能在后续链路中复用同一组主体账号
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lsfx-mock-server/services/file_service.py lsfx-mock-server/tests/test_file_service.py lsfx-mock-server/tests/test_api.py
|
||
git commit -m "让拉取本行信息链路复用Mock主体账号绑定"
|
||
```
|
||
|
||
### Task 3: 统一上传状态接口优先读取真实记录
|
||
|
||
**Files:**
|
||
- Modify: `lsfx-mock-server/services/file_service.py`
|
||
- Modify: `lsfx-mock-server/tests/test_file_service.py`
|
||
- Modify: `lsfx-mock-server/tests/test_api.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
补一组“真实记录优先级”测试,防止 `get_upload_status()` 继续走即时随机分支覆盖真实绑定:
|
||
|
||
```python
|
||
import pytest
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_upload_status_should_prefer_existing_file_record_binding():
|
||
service = FileService()
|
||
upload = UploadFile(filename="测试流水.csv", file=BytesIO(b"demo"))
|
||
response = await service.upload_file(1000, upload, BackgroundTasks())
|
||
log_id = response["data"]["uploadLogList"][0]["logId"]
|
||
|
||
status = service.get_upload_status(1000, log_id)
|
||
log = status["data"]["logs"][0]
|
||
|
||
assert log["enterpriseNameList"] == response["data"]["uploadLogList"][0]["enterpriseNameList"]
|
||
assert log["accountNoList"] == response["data"]["uploadLogList"][0]["accountNoList"]
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd lsfx-mock-server
|
||
pytest tests/test_file_service.py -k "prefer_existing_file_record" -v
|
||
```
|
||
|
||
Expected:
|
||
|
||
- `FAIL`
|
||
- 原因是 `get_upload_status()` 当前只按 `logId` 做即时随机生成,不会优先读取 `self.file_records`
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
把 `get_upload_status()` 改成:
|
||
|
||
```python
|
||
def get_upload_status(self, group_id: int, log_id: int = None) -> dict:
|
||
logs = []
|
||
if log_id:
|
||
if log_id in self.file_records:
|
||
logs.append(self._build_log_detail(self.file_records[log_id]))
|
||
else:
|
||
random.seed(log_id)
|
||
logs.append(self._generate_deterministic_record(log_id, group_id))
|
||
```
|
||
|
||
同时确保 `_generate_deterministic_record()` 也遵循单一绑定语义:
|
||
|
||
- `enterpriseNameList` 长度为 1
|
||
- `accountNoList` 长度为 1
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd lsfx-mock-server
|
||
pytest tests/test_file_service.py -v
|
||
pytest tests/test_api.py -k "upload_status or deterministic_data_generation" -v
|
||
```
|
||
|
||
Expected:
|
||
|
||
- `PASS`
|
||
- 已有上传状态接口的确定性测试仍然成立
|
||
- 对真实记录会优先返回真实绑定
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lsfx-mock-server/services/file_service.py lsfx-mock-server/tests/test_file_service.py lsfx-mock-server/tests/test_api.py
|
||
git commit -m "统一Mock上传状态主体账号绑定优先级"
|
||
```
|
||
|
||
### Task 4: 让 StatementService 按 logId 注入本方主体和账号
|
||
|
||
**Files:**
|
||
- Modify: `lsfx-mock-server/services/statement_service.py`
|
||
- Modify: `lsfx-mock-server/services/file_service.py`
|
||
- Modify: `lsfx-mock-server/routers/api.py`
|
||
- Create: `lsfx-mock-server/tests/test_statement_service.py`
|
||
- Modify: `lsfx-mock-server/tests/integration/test_full_workflow.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
新增服务层测试,锁定同一 `logId` 的所有流水都要复用同一组 `leName/accountMaskNo`:
|
||
|
||
```python
|
||
from services.file_service import FileService
|
||
from services.statement_service import StatementService
|
||
|
||
|
||
def test_bank_statement_should_reuse_file_record_primary_binding():
|
||
file_service = FileService()
|
||
log_id = file_service.fetch_inner_flow({"groupId": 1000})["data"][0]
|
||
record = file_service.file_records[log_id]
|
||
|
||
service = StatementService(file_service=file_service)
|
||
result = service.get_bank_statement({"groupId": 1000, "logId": log_id, "pageNow": 1, "pageSize": 20})
|
||
|
||
statements = result["data"]["bankStatementList"]
|
||
assert all(item["leName"] == record.primary_enterprise_name for item in statements)
|
||
assert all(item["accountMaskNo"] == record.primary_account_no for item in statements)
|
||
```
|
||
|
||
再补一个集成断言:
|
||
|
||
```python
|
||
def test_complete_workflow_should_keep_same_primary_binding_between_status_and_statement(client):
|
||
...
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd lsfx-mock-server
|
||
pytest tests/test_statement_service.py -v
|
||
pytest tests/integration/test_full_workflow.py -k primary_binding -v
|
||
```
|
||
|
||
Expected:
|
||
|
||
- `FAIL`
|
||
- 原因是 `StatementService` 目前仍写死 `leName = 张传伟` 并独立随机 `accountMaskNo`
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
给 `StatementService` 增加对 `FileService` 的只读依赖:
|
||
|
||
```python
|
||
class StatementService:
|
||
def __init__(self, file_service=None):
|
||
self.file_service = file_service
|
||
self._cache = {}
|
||
```
|
||
|
||
```python
|
||
def _resolve_primary_binding(self, group_id: int, log_id: int) -> tuple[str, str]:
|
||
if self.file_service and log_id in self.file_service.file_records:
|
||
record = self.file_service.file_records[log_id]
|
||
return record.primary_enterprise_name, record.primary_account_no
|
||
return "测试主体A", f"{random.randint(100000000000000, 999999999999999)}"
|
||
```
|
||
|
||
在 `_generate_random_statement()` 中替换:
|
||
|
||
```python
|
||
enterprise_name, account_no = self._resolve_primary_binding(group_id, log_id)
|
||
...
|
||
"accountMaskNo": account_no,
|
||
...
|
||
"leName": enterprise_name,
|
||
```
|
||
|
||
并在 [`lsfx-mock-server/routers/api.py`](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server/routers/api.py) 中把服务实例装配改成共享同一份 `file_service`:
|
||
|
||
```python
|
||
file_service = FileService()
|
||
statement_service = StatementService(file_service=file_service)
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd lsfx-mock-server
|
||
pytest tests/test_statement_service.py -v
|
||
pytest tests/integration/test_full_workflow.py -v
|
||
```
|
||
|
||
Expected:
|
||
|
||
- `PASS`
|
||
- 同一 `logId` 的分页流水在 `leName/accountMaskNo` 上保持一致
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add lsfx-mock-server/services/statement_service.py lsfx-mock-server/services/file_service.py lsfx-mock-server/tests/test_statement_service.py lsfx-mock-server/tests/integration/test_full_workflow.py
|
||
git commit -m "让Mock流水查询复用logId主体账号绑定"
|
||
```
|
||
|
||
### Task 5: 补实施记录并完成最终验证
|
||
|
||
**Files:**
|
||
- Create: `docs/reports/implementation/2026-03-18-lsfx-logid-primary-binding-implementation.md`
|
||
- Modify: `docs/plans/backend/2026-03-18-lsfx-logid-primary-binding-backend-implementation.md`
|
||
|
||
- [ ] **Step 1: Write the implementation report skeleton**
|
||
|
||
记录以下内容:
|
||
|
||
- `FileRecord` 新增的绑定字段
|
||
- 上传文件/拉取本行信息/上传状态/查流水四处的联动
|
||
- 实际执行过的 pytest 命令
|
||
- 兼容逻辑优先级说明
|
||
|
||
建议骨架:
|
||
|
||
```markdown
|
||
# LSFX Mock LogId 主体账号绑定实施记录
|
||
|
||
## 变更概述
|
||
- ...
|
||
|
||
## 验证记录
|
||
- `cd lsfx-mock-server && pytest tests/test_file_service.py -v`
|
||
- `cd lsfx-mock-server && pytest tests/test_statement_service.py -v`
|
||
- `cd lsfx-mock-server && pytest tests/test_api.py -v`
|
||
```
|
||
|
||
- [ ] **Step 2: Run final verification**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd lsfx-mock-server
|
||
pytest tests/test_file_service.py -v
|
||
pytest tests/test_statement_service.py -v
|
||
pytest tests/test_api.py -v
|
||
pytest tests/integration/test_full_workflow.py -v
|
||
python verify_implementation.py
|
||
```
|
||
|
||
Expected:
|
||
|
||
- 所有测试通过
|
||
- 接口字段完整性检查仍然通过
|
||
|
||
- [ ] **Step 3: Stop any started services**
|
||
|
||
如果验证过程中启动了:
|
||
|
||
- `python main.py`
|
||
- `uvicorn main:app --reload --host 0.0.0.0 --port 8000`
|
||
|
||
结束时必须主动关闭进程,避免残留端口占用。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add docs/reports/implementation/2026-03-18-lsfx-logid-primary-binding-implementation.md docs/plans/backend/2026-03-18-lsfx-logid-primary-binding-backend-implementation.md
|
||
git commit -m "补充Mock主体账号绑定实施记录"
|
||
```
|