系列目录(4 篇已发布)

第二季 · 地基 · 第 4 篇

让 AI 设计数据模型

30 个模型不是一次画出来的,是被四条原则逼出来的。

第一版数据模型只有一张表。

applications:公司、岗位、状态、投递时间,再加几个备注字段。

它撑过了第一周。

第二周开始加需求:一个岗位可能要投不同方向;同一个岗位对我是不合适,对别人可能是目标;简历要能针对岗位改;面试要能复盘;岗位来自飞书表格,会更新、会下线。

每加一个需求,我就往那张表上加一列 JSON。

第三周,我想查“哪些投递用的简历版本里包含这段实习”,发现查不了。所有东西都在 JSON 里,数据库帮不上忙。

一、错误的问题

我最初问 AI 的是:

帮我设计一个求职管理系统的数据库。

它给的答案很标准:用户表、岗位表、投递表、简历表,每张表都有 id、created_at、updated_at、deleted_at。

看起来专业,但没有一条回答我的业务问题:

  • 岗位是我私有的,还是所有人共享的?
  • “不合适”是岗位的属性,还是我的判断?
  • 三个月前投出去的简历,今天打开应该是什么样?
  • 我改了素材文案,旧简历要不要跟着变?
  • 同一个岗位重复导入,怎么不产生两条记录?

这些问题不回答,模型就只能按通用套路设计。通用套路没有错,但它不是我的系统。

二、正确的问法

第二次,我换了方式。不给“帮我设计”,而是给它五个必须回答的问题。

问题一:岗位是共享事实,还是用户私有数据?

岗位信息来自公开渠道,对公司、岗位、JD 的描述对所有用户都一样。但“我觉得不合适”只对我成立。

所以拆成两张表:Job 没有 userId,JobDecision 有。

model Job {
  id          String  @id @default(cuid())
  companyId   String
  title       String
  description String?
  offlineAt   DateTime?   // 数据源事实:来源里已经没有了
}

model JobDecision {
  userId String
  jobId  String
  status JobDecisionStatus   // 目前只有 NOT_SUITABLE
  @@unique([userId, jobId])
}

这里有个容易混的点:offlineAt 和 JobDecision 都是“不显示”,但语义完全不同。

offlineAt 是数据源事实。全量同步后,这个来源这次没返回的岗位被标下线,所有人都看不到。

JobDecision 是个人判断。我标了“不合适”,只影响我的列表。

问题二:历史要不要可复现?

要。三个月前投出去的简历,今天必须能原样打开,哪怕素材已经改了十遍。

所以简历版本存整份快照,不存“当前简历”:

model ResumeVersion {
  resumeId String
  name     String
  content  String     // 整份文本快照
  aiDraft  String?    // AI 初稿,和用户确认稿分开
  type     ResumeVersionType   // BASE | TARGETED
}

同样的思路还出现在两个地方:评估运行保存当时的偏好快照,面试记录保存当时的公司名和 JD。

问题三:引用要固定到哪一层?

素材卡有事实,也有文案。事实稳定,文案会针对不同岗位写多版。

如果文档只引用“素材卡”,用户今天新增一版文案,三个月前的文档就会跟着变。

所以引用必须固定到 variantId:

model DocumentCardReference {
  compositionId String
  cardId        String
  variantId     String
  section       String
  sortOrder     Int
  @@unique([compositionId, section, sortOrder])
}

新增变体不影响旧文档。只有用户显式“覆盖同名变体”时,所有引用该版本的文档才会一起变——所以那个操作要求二次确认。

问题四:当前状态够不够?

不够。当前状态回答“现在在哪”,事件回答“怎么走到这里”。

投递需要一条不可变的事件流:

model ApplicationEvent {
  applicationId String
  type          ApplicationEventType  // CREATE | UPDATE | STATUS_CHANGED | DELETE
  occurredAt    DateTime
  payload       Json
  @@index([applicationId, occurredAt])
}

它有三个用途:审计、统计(漏斗需要“到过某阶段”,不是“现在在某阶段”)、区分来源(手动改的,还是面试结果回写的)。

问题五:重复写入怎么防?

三条防线:数据库唯一约束、先查后写、冲突后回查。

model Application {
  userId String
  jobId  String
  @@unique([userId, jobId])
}

一个岗位对每个用户最多一条投递。并发下撞约束,catch 后回查已有记录。

三、四条原则

五个问题最后收敛成四条原则:

  1. 事实与判断分离:客观数据全局共享,个人判断按用户隔离。
  2. 快照优先于引用:需要复现的历史,存整份快照。
  3. 引用固定到版本:引用文案要指向具体版本,不是素材本身。
  4. 事件不可变:当前状态 + 事件流,才回答得了“怎么走到这里”。

30 个模型基本都能放进这四条里。

第一版 schema,我改了三处

第一处,删掉 Job.userId。

AI 的第一版给每张表都加了 userId,这是多租户系统的标准做法。但岗位是公开事实,加 userId 会让同一家公司被存 N 份。

删掉之后,“不合适”改用 JobDecision 表达。

第二处,给 Application 加唯一约束。

第一版只有代码里的“先查一下有没有”,没有数据库约束。并发下两个请求会同时通过检查。

加上 @@unique([userId, jobId]) 之后,重复写入由数据库兜底。

第三处,引用从 cardId 改成 variantId。

第一版文档引用素材卡,不引用文案版本。这样用户改一次文案,所有历史文档都会跟着变。

加上 variantId 之后,历史才稳定。

四、让 AI 写迁移,不只是 schema

Schema 只说明“现在长什么样”,迁移才说明“怎么从上一版变过来”。

这两件事的难度差很多。Schema 是声明式的,AI 很擅长;迁移要考虑存量数据、锁表、回滚,AI 经常忽略。

所以我把迁移也写进验收标准。

一个真实的例子:新增岗位评估功能时,任务里写的是:

扩展 Prisma Schema:新增 JobPreference、AgentRun、Application 关联、枚举与索引;
新增 migration,并更新 local user seed 的默认求职偏好。

验证:空 PostgreSQL 可 migrate deploy;重复 db:seed 后仅有一条默认偏好;
      Application、JobPreference、AgentRun 关联与索引存在。

“重复 seed 后仅有一条”这句话,是我后来加的。

因为第一次跑 seed,它用了 create,跑两次就有两条默认偏好。改成 upsert 才幂等。

这类问题,不写进验证条件,AI 不会主动想。

还有一类更麻烦的:PostgreSQL 不支持删除枚举值。

删掉一个状态时,Prisma 会生成“建新类型、换列类型、改名、删旧类型”的整段迁移,但它不会搬数据。存量里只要有一条旧值,换类型时就会报错。

所以迁移里必须手写一句 UPDATE,把旧值归到新值,放在换类型之前。

这件事 AI 不会主动做,除非你在任务里明确写“存量数据怎么办”。

五、AI 在数据模型上常犯的三个错

第一,过度规范化。

你说“素材有多种类型”,它可能给你 10 张表,每种类型一张。实际上这些类型的字段差异很小,一张表加 type 和 JSON facts 就够了。

判断标准不是“范式对不对”,而是“这些类型会不会有完全不同的字段和查询方式”。不会,就别拆。

第二,忘记唯一约束。

AI 会把“不重复”写在服务层代码里,但忘了数据库约束。并发一来,代码里的检查就失效。

凡是业务上“只能有一条”的东西,都要在数据库里有唯一约束:一个用户一个岗位一条投递、一个用户一个同步源、一个幂等键一条事件。

第三,JSON 万能。

JSON 写起来方便,查起来痛苦。凡是需要筛选、排序、统计的字段,都应该单独成列。

我的 facts、blocks、sections 用 JSON,是因为它们的结构随类型变化,而且只按整块读写。手机号、状态、时间这些从来不进 JSON。

六、验收一个模型,问三个问题

第一,能不能回答历史?

三个月前的简历长什么样,当时用的哪版偏好,面试时 JD 是什么。答不了,说明缺快照。

第二,能不能防重复?

并发、重试、同一个来源同步两次。答不了,说明缺唯一约束或幂等键。

第三,删的时候会不会炸?

外键是 Restrict 还是 Cascade?删一张素材卡,历史文档还能不能打开?

我的答案是:素材卡归档而不是删除,历史文档继续能解析;简历版本删除时先删组合引用。这些都要在删之前想清楚。

七、小结

让 AI 设计数据模型,关键不是 prompt 写得多好,而是你能不能把业务问题翻译成约束。

你给它五个必须回答的问题,它就能给你一个像样的模型。你只说“帮我设计”,它就只能给你一个通用模板。

下一篇讲契约:数据模型只是其中一份,接口、插件、Agent 的边界也都要写下来。


上一篇:先立规矩,再让 AI 写代码

下一篇:用契约文件给 AI 画边界(待发布)