# 数据库规范（入口）

> **目标**: 统一 Schema、约束与迁移规范  
> **最后更新**: 2026-03-13

---

## ✅ 入口与执行

- 数据库与迁移：`database-main`
- 后端功能开发：`backend-main`

---

## 核心规则（摘要）

### Schema 组织
- 使用 Prisma 多文件 schema。
- 每个业务域使用独立 `.prisma` 文件。
- 每个模型必须声明 `@@schema("schema_name")`。

### 迁移规则
- 所有 schema 变更必须通过 Prisma 标准迁移流程。
- 禁止修改已应用或已合并的迁移。
- 新迁移使用 Prisma 默认时间戳命名。

### 安全边界
- 禁止手动修改生产数据库。
- 数据库变更必须经过团队评审，并与代码、文档一起交付。
- 优先采用增量式变更：先加结构，再回填，最后收紧约束。

### 约束策略
- 业务规则主要放在服务层。
- NOT NULL、UNIQUE、FK、CHECK 等数据一致性约束应尽量落数据库。
- 避免复杂触发器或难以推理的“聪明 schema”，除非有明确要求。

### 数据清理安全
- **禁止使用 `TRUNCATE CASCADE` 清理单个模块数据**——CASCADE 会通过外键级联删除其他模块的数据（如绩效表级联删除用户表）。
- 清理模块数据时使用 `DELETE FROM` 并按正确的外键依赖顺序逐表删除。
- 测试环境清理数据优先使用 `testing/backend/helpers/cleanup.helper.ts` 中的辅助函数。

### 种子数据管理
- **所有测试数据通过种子文件管理**，不得只用 SQL 插入。如果紧急用 SQL 创建了数据，必须立即回写到种子文件中。
- **种子文件必须幂等**（使用 `upsert` 或 `ON CONFLICT DO NOTHING`），支持重复执行。
- **种子文件必须遵守业务规则**。例如组织架构要求每个组织自动有同名根部门（`parentId = null`），种子数据也必须创建根部门。在写种子前，先读 service 层的 `create` 方法了解业务约束。
- **种子文件注册到 `prisma/seeds/index.ts`**，确保统一入口可发现。

## Prisma 实战陷阱（2026-04 → 2026-05 累计踩坑 13 次）

> 以下是项目踩过、有真实事故记录的 Prisma 使用陷阱。多数已在代码层修了，本节给后人**事实参考**——下次碰到先查这里。

### 陷阱 1：`where: { x: undefined }` 在标准 query vs raw SQL 语义不一致（⚠️ prod hazard）

**事故记录**: ai-usage dashboard 对 itadmin 显示 825M tokens（跨 org 全量），而 trend / session-stats / daily-user-matrix 显示 0/空——**同一个 bug 引发的诡异表象**，仪表盘部分图有数据部分图无。

**根因**：

```ts
// Prisma 标准 query API：undefined 静默丢过滤
prisma.aiUsageEvent.aggregate({
  where: { organizationId: undefined, ts: { gte, lte } },
});
// 等价于
prisma.aiUsageEvent.aggregate({
  where: { ts: { gte, lte } },     // ← 无 org 过滤，跨 org 全量 ❌
});

// raw SQL $queryRaw：undefined → NULL，`col = NULL::uuid` 永远 FALSE
this.prisma.$queryRaw`
  ... WHERE organization_id = ${scope.organizationId}::uuid AND ...
`;
// → 0 rows ❌
```

**修法**：用 `orgFilterSql` 类 helper 显式构建 `Prisma.Sql` 分支，落地参考 `backend/src/modules/ai-usage/services/dashboard.service.ts`：

```ts
private orgFilterSql(scope: BaseScope, alias = ''): Prisma.Sql {
  if (!scope.organizationId) return Prisma.empty;
  const col = alias ? `${alias}.organization_id` : 'organization_id';
  return Prisma.sql`${Prisma.raw(col)} = ${scope.organizationId}::uuid AND`;
}
```

**前置防御**：公共入口前置 `assertScope`，admin 路径（无 userId）必须有 organizationId，否则拒绝。

参考 learning: `.learnings/2026-05-16-prisma-agg-undefined-org-vs-raw-sql.md`

---

### 陷阱 2：`migrate dev` 在历史迁移 drift 上失败（shadow DB）

**事故**：`npx prisma migrate dev --create-only` 失败，报错指向**不是你刚改的迁移**，而是某条存量历史迁移：

```
Error: P3006
Migration `20260320000731_cleanup_deprecated_models` failed to apply cleanly to the shadow database.
Error: column "assignment_status" of relation "kpi_assignment" does not exist
```

**根因**：Prisma 创建 migration 时起 shadow DB，从零按顺序重放所有历史迁移做 diff。本仓库历史上靠 `db push + migrate resolve --applied` 同步过 schema，存在 drift——shadow 重放就挂，所有人复现，跟你改了什么无关。

**修法（绕行，已是项目约定）**：

1. **开发库同步**：`npm run db:push`（底层 `prisma db push`），不经 shadow，不生成 migration
2. **手写 migration SQL**：自己在 `backend/prisma/migrations/<timestamp>_<name>/migration.sql` 写建表/改列 SQL
   - 命名：`YYYYMMDDHHMMSS_<descriptive>`
   - schema 用双引号前缀（multi-schema 模式）
   - 列名 snake_case 跟 Prisma `@map` 对齐
   - 索引名跟 Prisma 约定：`<table>_<col>_key` (唯一) / `<table>_<col1>_<col2>_idx` (普通)

**禁止**修存量迁移文件（会破坏生产部署）。

参考 learning: `.learnings/2026-04-15-prisma-migrate-dev-shadow-db-drift.md` / `2026-04-02-schema-drift-fix.md`

---

### 陷阱 3：`upsert.update: {}` 静默保留旧字段 + 漏副作用

**事故 A（seed 字段漂移）**：v1 seed 给 Sales 角色赋了 `robot-manager:update`，v2 移除后跑 seed，DB 仍有 5 条权限（含残留 update）。前 3 轮文档 review 比 seed vs PRD "一致"，调 `/users/me` 才暴露。

```ts
// ❌ upsert 只保证"关系存在"，不保证"只有这些关系存在"
for (const key of r.permissionKeys) {
  await prisma.rolePermission.upsert({
    where: { roleId_permissionId: { roleId, permissionId } },
    create: { roleId, permissionId },
    update: {},
  });
}
```

**事故 B（FK 漂移）**：issueToken 失败 `Foreign key constraint violated`：

```ts
// 早期手工 SQL 建 binding，slug='lijian'
// 后来调 issueToken({ userId, mailNickname: 'itadmin', ... })
const employeeSlug = this.slugSvc.normalizeEmployeeSlug(mailNickname); // = 'itadmin'

await tx.employeeSlugBinding.upsert({
  where: { userId },
  create: { ..., employeeSlug, ... },   // 创建路径用 'itadmin'
  update: {},                            // ❌ 已有路径不动
});

await tx.internalAppEmployeeToken.create({
  data: { employeeSlug, ... },           // 用本地变量 'itadmin'
});
// ↑ DB 真实 slug='lijian'，FK 找不到 'itadmin' → 500
```

**事故 C（副作用不对称）**：`upsert` 的两个分支不对称——副作用只挂在 `else` 新建侧，老员工每次同步只更新基础字段，**新加的副作用对老数据从未生效**。

**修法**：

1. **声明式 seed**：每次跑先清空该角色在该命名空间下的所有关系，再按 `permissionKeys` create
2. **upsert 字段同步**：existing 分支显式更新可能变化的字段（如 `update: { employeeSlug }`），不要 `update: {}`
3. **幂等副作用对称**：每次新增副作用时同时检查 `if (existing)` 分支，老数据也要触发

参考 learning: `2026-04-14-seed-upsert-drift.md` / `2026-04-30-upsert-existing-branch-side-effect-gap.md` / `2026-05-14-prisma-upsert-empty-update-stale-fields.md`

---

### 陷阱 4：`createMany` 与 `GENERATED ALWAYS AS` 列冲突

**事故**：`ai_usage_events` 表 `total_tokens` 是 `GENERATED ALWAYS AS (input + output + cache_creation + cache_read) STORED`。批量 ingestion `prisma.aiUsageEvent.createMany({ data: rows })` PG 抛错：

```
cannot insert a non-DEFAULT value into column "total_tokens"
```

**根因**：Prisma 把 `data` 字段全部列在 INSERT 字段清单里（包括 `totalTokens`），PG 对 GENERATED ALWAYS 列拒绝任何显式赋值。

**不工作的方案**：
- ❌ schema 标 `@ignore` → Prisma 不生成字段，select 时拿不到值
- ❌ `@default(dbgenerated(...))` → createMany 仍把字段列在 INSERT
- ❌ data 里省略字段 → Prisma 仍按 schema 全列上

**工作方案**：raw SQL INSERT 不列 generated column：

```ts
await this.prisma.$queryRawUnsafe(
  `INSERT INTO platform_ai_usage.ai_usage_events
     (id, raw_message_id, ..., input_tokens, output_tokens,
      cache_creation_tokens, cache_read_tokens, estimated_cost_usd, ...)
   VALUES (gen_random_uuid(), $1, ..., $11, $12, $13, $14, $15, ...)
   ON CONFLICT (raw_message_id) DO NOTHING
   RETURNING 1`,
   ...
);
```

参考 learning: `2026-05-15-prisma-generated-column-createmany.md`

---

### 陷阱 5：`schema/` 旁的 `.md` 是事实源（权威性高于模块 docs）

**事故**：某次调研把 "FormDefinition 没有 regionId 但 docs/modules/form-management/07-api.md 当作有" 判定为 P0 数据模型 drift，建议加 regionId 列。实际上 `backend/prisma/schema/FORM_ORGANIZATION_ARCHITECTURE.md` 是 v2.0 架构清理的已落地决策，**明文规定彻底去除 FormDefinition 的 regionId**——调研 agent 没读这份 .md，反向建议错。

**事实源优先级**：

```
1. schema 本身（*.prisma）           — 代码即事实
2. schema 目录下的 .md              — 已落地的工程决策（README / ARCHITECTURE / DECISIONS / MIGRATION）
3. docs/standards/                   — 项目级规则
4. docs/modules/{module}/            — 模块文档（容易和 schema 漂移）
```

**规则（调研类任务必跟）**：

读完模块文档后，**必须扫一遍 `backend/prisma/schema/` 下所有非 `.prisma` 文件**。当 schema 实际状态与 `docs/modules/{module}/` 文档冲突时：

- 先在 `prisma/schema/` 找有没有解释这次"删除/重构"的 .md
- 找到 → 按"文档过时"处置，**不要建议反向回滚 schema**
- 找不到 → 才升级为契约 drift 让用户决策

参考 learning: `2026-04-30-schema-side-decision-docs-as-truth-source.md`

---

## Prisma 其它小坑（独立成节避免膨胀）

### `prisma format` 重格所有 schema 文件

执行 `npx prisma format` 后所有 `.prisma` 文件都 modified（21 个文件、~5000+ 行 whitespace diff）。multi-file schema 模式下，format 会统一整个 `prisma/schema/` 目录的格式化规则——历史上没格过的文件被一次性矫正。

**规则**：

1. 改完 schema 必须跑 format（确保新文件符合规范、generate 不挂）
2. **提交前必须撤无关文件**：`git checkout -- <无关>.prisma`
3. 验证差异只剩你的语义改动：`git diff --stat backend/prisma/schema/` 应在几十行以内

参考 learning: `2026-05-13-prisma-format-touches-all-schemas.md`

### Bytes 字段类型 vs Node Buffer

Prisma 6 生成的 Bytes 字段类型是 `Uint8Array<ArrayBuffer>`，不接受 `Buffer`。运行时 Buffer 继承 Uint8Array 但 TS 泛型不一致就拒。

```ts
// 写入 Prisma：显式转 Uint8Array
await prisma.foo.create({
  data: { encrypted: new Uint8Array(cipherBuffer) },
});

// 读出再用 Buffer API：用 Buffer.from
const plaintext = decipher.update(Buffer.from(row.encrypted));
```

❌ `as Buffer` 强转 / `skipLibCheck` 关 ——都不要。

参考 learning: `2026-05-13-prisma-bytes-vs-node-buffer.md`

### PG `min(uuid)` / `max(uuid)` 不存在

UUID 没有内建 min/max 聚合（v4 是随机的，"大小"无业务意义）。`GROUP BY` 时想顺便带 uuid 列回来，cast text：

```sql
-- ❌
SELECT MIN(user_id) FROM ... GROUP BY session_id

-- ✅
SELECT MIN(user_id::text) FROM ... GROUP BY session_id

-- 更"语义化"的替代
SELECT (array_agg(user_id))[1] FROM ... GROUP BY session_id
```

参考 learning: `2026-05-16-pg-no-min-uuid.md`

### `db:seed` 不创建 itadmin

`backend/prisma/seed.ts`（`db:seed` 入口）**只**做：IAM / Positions / PartGroups / DingTalk Sync / SyncBot / Robot FieldDefs / AI Tool Grants。**不创建 itadmin**。

itadmin 只能由 `npm run init:itadmin`（`scripts/backend/init/init-itadmin.ts`）创建。dev 环境每周自动 reset + `db:seed` 后，itadmin 就丢了——必须额外跑 `init:itadmin` 才能登录。

详见 CLAUDE.md "本地启动约定"段（已标第 5 步）。

参考 learning: `2026-05-03-dev-db-missing-itadmin.md`

---

## 参考入口

- [数据库架构](docs/standards/04-database-architecture.md)
- [后端规范](../../backend-main/references/backend-standards.md)
- [NestJS 全局组件 pitfalls](../../backend-main/references/nestjs-global-pitfalls.md)（含 `where: undefined` 与 raw SQL 交叉引用）
- `.agents/skills/database-main/SKILL.md`

---

**维护者**: FFOA 后端团队 & DBA  
**最后更新**: 2026-05-16（Prisma 实战陷阱节追加）
