# Seed 用 upsert 演进时的数据漂移陷阱

> **日期**: 2026-04-14
> **场景**: 角色权限 seed 在 v1 → v2 演化时不清理旧关系
> **错误码**: [ERR-20260414-002]

## 问题

v1 seed 给 Sales 角色赋了 `robot-manager:update` 权限。v2 Phase 3 重构时，`Sales.permissionKeys` 数组里移除了这条。重新跑 seed 后：

- **seed 文件**：Sales 应该有 4 条权限
- **PRD 文档**：Sales 应该有 4 条权限
- **数据库实际**：Sales 仍有 5 条权限（含残留的 `update`）

前 3 轮文档 review 都比对 seed 文件 vs PRD 发现"一致"，却一直没发现数据库 drift。直到调 `/users/me` 拿真实权限才暴露出来。

## 根因

seed 里用 `rolePermission.upsert`：

```ts
for (const key of r.permissionKeys) {
  await prisma.rolePermission.upsert({
    where: { roleId_permissionId: { roleId, permissionId } },
    create: { roleId, permissionId },
    update: {},
  });
}
```

**upsert 只保证"这条关系存在"，不保证"只有这些关系存在"**。老的、新 seed 中已移除的关系会永远留在数据库里。

放大现象：4 个非 admin 角色都因此积累了 `robot-manager:update` 残留。Sales/Finance 按 PRD 矩阵本来不应该有 legacy update 权限，因为会绕过 Phase 3 的 section 级权限控制。

## 解决方案

**"声明式"seed**：每次跑都先清空该角色在该命名空间（此例是 robot-manager:*）下的所有关系，再按 `permissionKeys` create。

```ts
// 先把该角色所有 robot-manager:* 权限关系清空
const allRobotPermIds = Array.from(robotPermMap.values());
await prisma.rolePermission.deleteMany({
  where: { roleId: role.id, permissionId: { in: allRobotPermIds } },
});

// 再按最新 permissionKeys 创建
for (const key of r.permissionKeys) {
  await prisma.rolePermission.create({
    data: { roleId, permissionId: permMap.get(key)! },
  });
}
```

注意 delete 必须**按命名空间过滤**（`permissionId IN 本模块所有权限`），避免误删其他模块给该角色的权限。

## 检测方法

> **定律**：当文档需要和 DB 一致时，**ground truth 必须是运行时 DB 状态，不能是 seed 源码**。

review 角色/权限相关文档时：

1. 调 `/users/me` 或直接查 `user_role_rel` + `role_permission` JOIN 表
2. 对比 seed 文件里的 `permissionKeys`
3. 如果两者不一致 → seed 有 drift bug

```bash
# 示范：列出某用户在某模块的真实权限
T=$(curl -s http://.../auth/login -d '...' | jq -r .data.accessToken)
curl -s http://.../users/me -H "Authorization: Bearer $T" | \
  jq '.data.roles[].role.permissions[].permission | select(.resource=="robot-manager")'
```

## 适用范围

任何使用 upsert 维护"多对多关联表"的 seed 场景都有此风险：
- 角色 ↔ 权限
- 用户 ↔ 角色
- 岗位 ↔ 权限
- 产品 ↔ 类别标签
- ...

**例外**：如果关联只会增不会减（例如审计日志、历史记录），upsert 即可。

## 影响

- 前 3 轮 review 的结论"权限矩阵和 seed 一致"实际是**比对了意图而非现实**
- Sales/Finance 角色用 `PUT /robot-manager/:id`（legacy 全量更新）可以**绕过 Phase 3 的 section 级权限**，是个安全漏洞
- 修复后所有 4 非 admin 角色权限数各减 1，与 PRD 一致
