77 lines
3.7 KiB
Python
77 lines
3.7 KiB
Python
"""画像快照**字段完整性**的真机回归(守 `ProfileAssemblyService` 不得写残片快照)。
|
||||
|
|
|
|||
|
|
## 守的是什么
|
|||
|
|
|
|||
|
|
`profile_snapshots` 的当前版本有多个写入方,其中两个会把它置为 `is_current=1`:
|
|||
|
|
|
|||
|
|
1. `ProfileGenerationService` —— 走 `build_snapshot()`,覆盖读取侧白名单全部字段;
|
|||
|
|
2. `ProfileAssemblyService.rebuild_profile()` —— 记忆 → 画像重建。
|
|||
|
|
|
|||
|
|
第 2 个此前**就地拼一个只含 `PROFILE_OWNED_FIELDS + generated_at` 的四字段残片**。
|
|||
|
|
后果(2026-09-19 连库实测,客户 9001 的当前快照停在 `{investor_type, risk_tags,
|
|||
|
|
generated_at, ...}`):
|
|||
|
|
|
|||
|
|
- 种子里明明写了 `customer_tier: gold`,被下一次重建覆盖后**分层整个消失**
|
|||
|
|
⇒ 客服「我够哪一档?」只能拿风险等级顶包;
|
|||
|
|
- `assessment_valid_until` / `assessment_expired` 一起消失 ⇒ 画像读取侧再也判断不出
|
|||
|
|
「测评是否过期」,与 `SuitabilityService` 的 `ASSESSMENT_EXPIRED` 失败关闭口径**分叉**;
|
|||
|
|
- `total_asset` / `behavior_score` 消失,画像与「账户看板」无法对照。
|
|||
|
|
|
|||
|
|
所以用例按真实路径重建一次,断言当前快照覆盖 `REQUIRED_SNAPSHOT_FIELDS` 全量。
|
|||
|
|
`9001` 有有效期内的测评行,因此「测评有效期 / 是否过期」两个字段也应齐全。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
from sqlalchemy import select
|
|||
|
|
|
|||
|
|
from app.core.profile_projection import project_profile
|
|||
|
|
from app.infrastructure.db import SessionFactory
|
|||
|
|
from app.model.profile import ProfileSnapshot
|
|||
|
|
from app.service.profile_assembly_service import ProfileAssemblyService
|
|||
|
|
from app.service.profile_generation_service import REQUIRED_SNAPSHOT_FIELDS
|
|||
|
|
|
|||
|
|
CUSTOMER_ID = 9001
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _rebuild_and_read_current() -> dict[str, object]:
|
|||
|
|
async with SessionFactory() as session, session.begin():
|
|||
|
|
await ProfileAssemblyService(session).rebuild(CUSTOMER_ID)
|
|||
|
|
async with SessionFactory() as session:
|
|||
|
|
row = await session.scalar(
|
|||
|
|
select(ProfileSnapshot).where(
|
|||
|
|
ProfileSnapshot.customer_id == CUSTOMER_ID,
|
|||
|
|
ProfileSnapshot.is_current.is_(True),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
assert row is not None, "重建后必须存在一条当前版本快照"
|
|||
|
|
snapshot = row.snapshot
|
|||
|
|
if isinstance(snapshot, (bytes, bytearray)):
|
|||
|
|
snapshot = snapshot.decode("utf-8")
|
|||
|
|
return snapshot if isinstance(snapshot, dict) else {}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.integration
|
|||
|
|
def test_rebuild_writes_a_complete_snapshot_not_a_partial_fragment() -> None:
|
|||
|
|
"""重建画像后,当前快照必须覆盖读取侧白名单的全部字段(不得是残片)。"""
|
|||
|
|
snapshot = asyncio.run(_rebuild_and_read_current())
|
|||
|
|
|
|||
|
|
missing = [field for field in REQUIRED_SNAPSHOT_FIELDS if field not in snapshot]
|
|||
|
|
assert not missing, (
|
|||
|
|
f"重建写出的快照缺少读取侧字段:{missing}(实际快照={snapshot})"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
projected = project_profile(snapshot)
|
|||
|
|
assert projected.get("investor_type"), "重建后画像必须仍可投影出风险等级"
|
|||
|
|
assert "total_asset" in projected, "交易侧客观字段不能被记忆重建抹掉"
|
|||
|
|
assert "assessment_expired" in projected, "测评有效期判据必须保留在画像里"
|
|||
|
|
|
|||
|
|
# JSON 列字段不得被二次编码:正确形状是 `["money_fund"]`,不是 `['"[\\"money_fund\\"]"']`。
|
|||
|
|
# 后者会被读取侧 `_localized` 当成未知值静默丢弃,等价于「偏好资产类别」整条消失。
|
|||
|
|
for field in ("preferred_asset_class", "risk_tags"):
|
|||
|
|
value = snapshot.get(field)
|
|||
|
|
assert isinstance(value, list), f"{field} 必须是数组,实得 {value!r}"
|
|||
|
|
nested = [item for item in value if isinstance(item, str) and item.strip().startswith("[")]
|
|||
|
|
assert not nested, f"{field} 出现二次编码的元素:{nested}"
|