## [ERR-20260427-019] POST endpoint 用「全替换」语义 → 前端误传部分 → 用户角色丢失

**日期**: 2026-04-27
**类别**: API 设计 / IAM / 数据丢失
**严重度**: 严重（生产正在丢用户角色关联）

### 问题描述
「角色详情页 → 添加用户」操作把用户在所有其他角色里的关联清空。例：把 user A 加到「QA 工程师」 → A 在「工程师」「成员」等角色里全没了。

### 根因（双方都写错了）
**后端** `POST /users/:id/roles` 的 `assignRoles()` 实现是「全替换」（PUT 语义）：
```ts
deleteMany({ userId });   // 删该 user 的所有 userRole
createMany([roleId]);     // 只插传入的
```

**前端** `addUsersToRole` 误以为这是 add 增量：
```ts
apiClient.post(`/users/${userId}/roles`, { roleIds: [roleId] });  // 只传 1 个
```

两边都"自洽"但拼起来 = 数据灾难。

### 修复（双管齐下）
1. **后端加增量 endpoint** `POST /users/:id/roles/add`，业务层显式去重
2. **前端 `addUsersToRole` 切到 `/add`**
3. 旧 `POST /:id/roles` 保留全替换语义（已有调用方依赖此行为）

### 关键陷阱：PostgreSQL unique 索引对 NULL 不去重
```sql
@@unique([userId, roleId, organizationId])
```
看似能防 `(user, role, NULL)` 重复，实际 PG 默认 `NULL != NULL`：每个 NULL 都视为不同值。

- `prisma.createMany({ skipDuplicates: true })` 期望去重 → 全局角色（organizationId=null）会重复插入
- 必须**业务层显式去重**（先 findMany 拿 existingKeys，再过滤 toInsert）

### 启示
- **HTTP method 选择必须匹配语义**：「全替换」用 PUT，「增量加」用 POST 到 sub-resource (`/roles/add`)
- **危险 endpoint 的方法名要起警示作用**：`assignRoles` 看着像"分配"（=add），实际是 "set/replace"。应叫 `setRoles` / `replaceRoles`
- **看到 `deleteMany + createMany` 模式必须警惕**：问"调用方是否真的传完整列表"？admin UI 的 PUT 表单 → OK；某个按钮"加一个"→ 灾难
- **PostgreSQL unique + NULL 是经典陷阱**：要让 NULL 参与去重，要么用 `coalesce` partial index，要么业务层先 findMany 后过滤
- **测试覆盖"只传 1 个 ID"的场景**：很多 PR 跑通"传完整列表"但漏掉这种单元素调用，bug 潜伏几个月

### 数据恢复
旧 endpoint 有 `@Auditable()` 装饰器，所有删除都写了 audit log。可以从 `audit_log.changes` JSON 的 `before` / `after` 字段恢复历史 userRole（独立任务）。

---
