fix(profile): ProfileAssemblyService 漏写 current_customer_id(唯一键失效的另一处)

## 现象(2026-09-12 端到端跑通后查数据时发现)

客户 9102 的画像快照状态**彼此矛盾**:

| version | is_current | current_customer_id |
|---|---|---|
| 2 | `0` | `9102` ← 清旧时没清空 |
| 3 | `1` | `NULL` ← 建新时没写入 |

## 根因

`ProfileAssemblyService._write_snapshot` 把 `current_customer_id` **当成了生成列**:

- 方法 docstring 原文写着"唯一键 `uk_profile_snapshot_current` 建立在**生成列** `current_customer_id` 上"
- 因此两处都不赋值(以为 DB 会自动填)

**但该列不是生成列** —— `alembic/baseline_generated.sql` 与真实库都是**普通可空列 + 唯一键**,
`app/model/profile.py` 的模块 docstring 第 2 条已明确:"当前版本必须由写入方**显式写入**客户 ID
(历史版本写 NULL),才能保证「每个客户最多一条当前快照」"。

后果:唯一键**形同虚设**(多个 NULL 不冲突)⇒ 不变式失效;且旧版本残留的值
一旦与新版本补上的值相同,就会**直接撞唯一键**。

> 这与本线先前修的 `CustomerProfileCandidateService._write_profile_snapshot` 是**同一个缺陷的另一处**
> —— 当时只找到一处,这次是靠真实链路跑出数据后核对才暴露出来。

## 改动

`app/service/profile_assembly_service.py`:

- 旧版本:`is_current = False` 的同时 `current_customer_id = None`
- 新版本:`is_current=True` 的同时 `current_customer_id=customer_id`
- 订正方法 docstring 的错误认知("生成列"→ 普通可空列 + 唯一键),并写明后果

## 已有数据订正

新增 `tools/fix_profile_snapshot_current.py`(**默认 dry-run**、幂等、`--apply` 才提交):

1. 先清空 `is_current=0` 却残留值的行
2. 再补写 `is_current=1` 却是 NULL 的行
3. **顺序要紧**:反过来的话第 2 步会与残留值撞唯一键

本机实测:清空 1 行、补写 1 行,复核两类异常均归零。

## 验证

- `mypy app` → 0 错 / 245 文件
- `pytest tests`(全量)→ `2 failed, 1418 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
- 端到端:真实对话 → 记忆抽取 → `memory_unit` 落库已实测通过(客户 9102
  `preference:risk_level = "低风险"`,候选态)
This commit is contained in:
2026-09-12 14:20:38 +08:00
parent 58c28ef4a0
commit 451aa4e915
2 changed files with 101 additions and 1 deletions
+13 -1
View File
@@ -226,8 +226,16 @@ class ProfileAssemblyService:
) -> None:
"""写入新版本快照并把旧版本置为非当前。
唯一键 `uk_profile_snapshot_current` 建立在生成列 `current_customer_id` 上,
唯一键 `uk_profile_snapshot_current` 建立在 `current_customer_id` 上,
保证「每个客户最多一条 current」;因此必须先清旧再写新,顺序不能反。
⚠️ **该列不是生成列**(订正 2026-09-12):它由 `alembic/baseline_generated.sql`
与真实库确认为**普通可空列 + 唯一键**,`app/model/profile.py` 的模块 docstring
第 2 条已写明"当前版本必须由写入方**显式写入**客户 ID(历史版本写 NULL)"。
此处原先按"生成列"理解而两处都不赋值,后果实测到:
旧版本 `is_current=0` 却仍留着 `current_customer_id`,新版本 `is_current=1`
却是 NULL —— **唯一键形同虚设**(多个 NULL 不冲突),
而且旧值一旦残留、新值再补写就会直接撞键。
"""
previous = list(await self.session.scalars(
select(ProfileSnapshot).where(
@@ -236,6 +244,8 @@ class ProfileAssemblyService:
))
for row in previous:
row.is_current = False
# 必须同时清空,否则与即将插入的新当前版本撞唯一键(见 docstring)。
row.current_customer_id = None
row.updated_at = now
await self.session.flush()
@@ -252,6 +262,8 @@ class ProfileAssemblyService:
generation_basis=basis,
snapshot_hash=sha256(payload.encode("utf-8")).hexdigest(),
is_current=True,
# 当前版本必须显式写入客户 ID(历史版本为 NULL),见 docstring。
current_customer_id=customer_id,
generated_at=now,
created_at=now,
updated_at=now,
+88
View File
@@ -0,0 +1,88 @@
"""数据订正:`profile_snapshots.current_customer_id` 与 `is_current` 对齐。
**默认 dry-run**,加 `--apply` 才提交。幂等,可重复运行。
### 背景
`current_customer_id` 是**普通可空列 + 唯一键** `uk_profile_snapshot_current`(不是生成列),
契约是「当前版本写客户 ID、历史版本写 NULL」。但写入方曾把它误当生成列,
两处路径都未赋值(`ProfileAssemblyService._write_snapshot`、
`CustomerProfileCandidateService._write_profile_snapshot`),造成两类脏数据:
1. 历史版本 `is_current=0` 却**仍留着** `current_customer_id`;
2. 当前版本 `is_current=1` 却是 NULL。
后果:唯一键形同虚设(多个 NULL 不冲突)⇒「每个客户最多一条 current」失效;
而且 (1) 的值一旦与 (2) 补上的值相同就会**撞唯一键**。
### 顺序要紧
先清 (1) 再补 (2):反过来的话,第 2 步写入时旧值还在,会直接撞键。
用法:
.\\.venv\\Scripts\\python.exe tools\\fix_profile_snapshot_current.py # 先看
.\\.venv\\Scripts\\python.exe tools\\fix_profile_snapshot_current.py --apply # 再改
"""
import asyncio
import sys
from sqlalchemy import text
from app.infrastructure.db import engine
#: (1) 历史版本不该持有该列 → 清空。必须先做。
_CLEAR_STALE = text("""
UPDATE profile_snapshots
SET current_customer_id = NULL
WHERE is_current = 0 AND current_customer_id IS NOT NULL
""")
#: (2) 当前版本必须持有该列 → 补上。放在 (1) 之后,避免撞唯一键。
_FILL_CURRENT = text("""
UPDATE profile_snapshots
SET current_customer_id = customer_id
WHERE is_current = 1 AND current_customer_id IS NULL
""")
_COUNT_STALE = text(
"SELECT COUNT(*) FROM profile_snapshots "
"WHERE is_current = 0 AND current_customer_id IS NOT NULL"
)
_COUNT_MISSING = text(
"SELECT COUNT(*) FROM profile_snapshots "
"WHERE is_current = 1 AND current_customer_id IS NULL"
)
async def main(apply: bool) -> int:
async with engine.connect() as conn:
stale = await conn.scalar(_COUNT_STALE)
missing = await conn.scalar(_COUNT_MISSING)
print(f"待清空(is_current=0 却留着值): {stale}")
print(f"待补写(is_current=1 却是 NULL): {missing}")
if not apply:
print("dry-run:未提交。确认无误后加 --apply 再运行。")
await conn.rollback()
return 0
cleared = (await conn.execute(_CLEAR_STALE)).rowcount
filled = (await conn.execute(_FILL_CURRENT)).rowcount
await conn.commit()
print(f"已清空 {cleared} 行、已补写 {filled} 行")
# 复核:两类异常都应归零
print("复核 待清空:", await conn.scalar(_COUNT_STALE))
print("复核 待补写:", await conn.scalar(_COUNT_MISSING))
print("\n各客户的当前快照:")
for row in (await conn.execute(text(
"SELECT customer_id, version, is_current, current_customer_id "
"FROM profile_snapshots ORDER BY customer_id, version"
))).mappings().all():
print(" ", dict(row))
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main("--apply" in sys.argv)))