英语学习平台:数据库表关系与字段说明#
数据库:PostgreSQL
ORM:Prisma
模型来源:server/prisma/schema.prisma
行号基准:Git 提交82883ee1097876d3159ffb5b50fae0ecd93542ec
生成日期:2026-10-03
1. 先理解四类存储#
本项目不只有 PostgreSQL。面试时要先把四类存储的职责分清:
| 存储 | 是否是业务事实来源 | 保存内容 |
|---|---|---|
| PostgreSQL | 是 | 用户、课程、订单、学习记录、知识库元数据、Agent 会话/运行/Action、学习计划、测验、LangGraph checkpoint。 |
| MinIO | 否 | 用户上传的原始 PDF/MD/TXT、头像、课程静态文件。 |
| Chroma | 否,可重建 | 私人文档分片、Embedding 向量、页码和用户/文档过滤元数据。 |
| Redis | 否,短期协调 | Celery 队列、文档锁、限流计数、HMAC nonce、Worker 心跳、删除 tombstone。 |
本文以 PostgreSQL 为主,并在 8.2 节单独列出 Chroma 向量库的 Collection、记录字段、metadata 字段以及它们和 PostgreSQL 的逻辑关系。知识库正文和向量不在 KnowledgeDocument 表中;原文在 MinIO,分片与向量在 Chroma。
2. Prisma 类型速查#
| Prisma 类型/标记 | PostgreSQL 含义 |
|---|---|
String |
通常是 TEXT;本项目主键多为 cuid() 字符串。 |
String? |
可空字符串。 |
Int |
32 位整数。 |
Float |
双精度浮点数。 |
Decimal |
精确小数,适合金额/成本。 |
Boolean |
布尔值。 |
DateTime |
时间戳。 |
Json |
PostgreSQL JSONB。 |
String[] |
PostgreSQL 字符串数组。 |
@id |
主键。 |
@unique |
唯一约束。 |
@default(...) |
默认值。 |
@updatedAt |
Prisma 更新记录时自动刷新。 |
@@index([...]) |
普通索引。 |
@@unique([...]) |
组合唯一约束。 |
onDelete: Cascade |
删除父记录时,数据库自动删除子记录。 |
onDelete: SetNull |
删除父记录时保留子记录,但把外键设为 NULL。 |
onDelete: Restrict |
有子记录引用时禁止删除父记录。 |
Prisma 模型中的 xxx[] 和关系对象字段只是 ORM 关系导航字段,不会额外生成数据库列。真正落库的是 userId、conversationId 等外键字段。
3. 全局关系概览#
系统共 18 个 Prisma 业务模型,可以分为五组:
| 领域 | 表 |
|---|---|
| 用户与词汇 | User、WordBook、WordBookRecord |
| 课程与支付 | Course、CourseRecord、PaymentRecord |
| 前端埋点 | Visitor、PageView、TrackEvent、PerformanceEntry、ErrorEntry |
| Agent 与知识库 | KnowledgeDocument、AiConversation、AiMessage、AiRun、AgentAction |
| 学习计划与测验 | StudyPlan、StudyTask、QuizSession、QuizAttempt |
3.1 用户、词汇、课程关系#
erDiagram
User ||--o{ WordBookRecord : learns
WordBook ||--o{ WordBookRecord : recorded_by
User ||--o{ PaymentRecord : pays
User ||--o{ CourseRecord : owns
Course ||--o{ CourseRecord : purchased_as
PaymentRecord ||--o{ CourseRecord : grants
User与WordBook是多对多,通过WordBookRecord保存用户对每个单词的掌握状态。User与Course是多对多,通过CourseRecord保存购买关系。- 一笔
PaymentRecord可以关联一个或多个CourseRecord;CourseRecord.paymentRecordId允许为空,便于先创建关系或兼容非支付来源。
3.2 埋点关系#
erDiagram
User o|--o{ Visitor : may_bind
Visitor ||--o{ PageView : has
Visitor ||--o{ TrackEvent : has
Visitor ||--o{ PerformanceEntry : has
Visitor ||--o{ ErrorEntry : has
Visitor用anonymousId标识浏览器访客,登录后可选地绑定User。- PV、行为、性能、错误都挂在
Visitor下,因此未登录访问也能统计。
3.3 Agent、知识库和学习关系#
erDiagram
User ||--o{ KnowledgeDocument : uploads
User ||--o{ AiConversation : owns
User ||--o{ AiRun : starts
User ||--o{ AgentAction : confirms
User ||--o{ StudyPlan : has
User ||--o{ StudyTask : has
User ||--o{ QuizSession : takes
User ||--o{ QuizAttempt : submits
AiConversation ||--o{ AiMessage : contains
AiConversation ||--o{ AiRun : executes
AiConversation ||--o{ AgentAction : requests
AiConversation o|--o{ QuizSession : generates
AiConversation o|--o{ QuizAttempt : records
AiRun o|--o{ AiMessage : produces
AiRun o|--o{ AgentAction : creates
StudyPlan ||--o{ StudyTask : contains
QuizSession ||--o| QuizAttempt : submitted_as
关键设计:
AiConversation是长期会话;AiRun是一次用户消息触发的一次执行。AiMessage属于会话,也可以属于某次 Run;Action 回执等消息可能不绑定原 Run。AgentAction把“模型建议执行”与“真实业务写入”隔开。StudyPlan是计划头,StudyTask是按日期展开的具体任务。QuizSession保存尚未提交的服务端题目快照;QuizAttempt保存最终答案与评分,两者是一对零或一。KnowledgeDocument与 Chroma 没有数据库外键,但通过KnowledgeDocument.id = metadata.document_id、User.id = metadata.owner_user_id形成跨存储的一对多逻辑关系。
4. 状态枚举#
枚举定义位于 server/prisma/schema.prisma:17-98。
4.1 TradeStatus#
| 值 | 含义 |
|---|---|
NOT_PAY |
本地订单已创建但尚未支付。 |
WAIT_BUYER_PAY |
支付宝等待买家付款。 |
TRADE_CLOSED |
交易关闭。 |
TRADE_SUCCESS |
支付成功,通常仍处于可退款期。 |
TRADE_FINISHED |
交易完成,不再允许退款。 |
4.2 KnowledgeDocumentStatus#
UPLOADED → QUEUED → PROCESSING → READY
└──────→ FAILED
READY / FAILED / 其他状态 → DELETING → 软删除
| 值 | 含义 |
|---|---|
UPLOADED |
PostgreSQL 和 MinIO 已接收文件,尚未成功入队。 |
QUEUED |
FastAPI 已返回 Celery jobId,等待 Worker。 |
PROCESSING |
Worker 正在解析、分片、向量化或写 Chroma。 |
READY |
向量完整写入并通过数量校验,可以检索。 |
FAILED |
入库失败,可由用户或恢复任务重试。 |
DELETING |
正在清理 Chroma、MinIO 和业务记录。 |
4.3 Agent 相关枚举#
| 枚举 | 值 | 含义 |
|---|---|---|
AiConversationStatus |
ACTIVE、ARCHIVED |
活跃会话或已归档会话。 |
AiConversationScene |
learning_coach、knowledge_tutor、writing_coach |
学习教练、私人资料辅导、写作教练。 |
AiMessageRole |
USER、ASSISTANT、SYSTEM、TOOL |
消息角色。 |
AiRunStatus |
RUNNING、COMPLETED、FAILED、CANCELLED、TIMED_OUT |
一次 Agent 执行的生命周期。 |
AgentActionType |
CREATE_STUDY_PLAN、COMPLETE_STUDY_TASK |
当前支持的高影响写操作。 |
AgentActionStatus |
PENDING、CONFIRMED、REJECTED、EXPIRED、EXECUTED、FAILED |
待确认、已确认、拒绝、过期、已执行、执行失败。 |
典型 Action 状态流:
PENDING → CONFIRMED → EXECUTED
├────→ REJECTED
└────→ EXPIRED
CONFIRMED → FAILED(达到重试上限或永久失败)
4.4 学习相关枚举#
| 枚举 | 值 | 含义 |
|---|---|---|
StudyPlanStatus |
PENDING_CONFIRMATION、ACTIVE、COMPLETED、CANCELLED |
计划等待确认、执行中、已完成、已取消。 |
StudyTaskType |
VOCABULARY、READING、WRITING、QUIZ、REVIEW |
词汇、阅读、写作、测验、复习任务。 |
StudyTaskStatus |
PENDING、COMPLETED、SKIPPED |
待执行、已完成、已跳过。 |
QuizMode |
DUE_REVIEW、WEAK_WORDS |
从到期复习词或薄弱词生成测验。 |
5. 用户与词汇表#
5.1 User - 用户主表#
源码:server/prisma/schema.prisma:100。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK,cuid() |
用户唯一 ID,也是其他业务表最主要的租户隔离键。 |
name |
String |
用户昵称/姓名。 |
email |
String?,unique |
可选邮箱;非空时全局唯一。PostgreSQL 唯一索引允许多个 NULL。 |
phone |
String,unique |
登录手机号,全局唯一。 |
address |
String? |
用户地址。 |
password |
String |
登录密码字段。当前代码以明文保存/比较,必须改为 Argon2/bcrypt 哈希。 |
avatar |
String? |
头像对象路径/访问路径。 |
bio |
String? |
个性签名。 |
isTimingTask |
Boolean,默认 false |
是否启用定时学习提醒/任务。 |
timingTaskTime |
String,默认 00:00:00 |
定时任务时间;当前是字符串而非数据库 time 类型。 |
wordNumber |
Int,默认 0 |
已掌握词数量的冗余计数,用于快速展示。 |
dayNumber |
Int,默认 0 |
学习/打卡天数的冗余计数。 |
createdAt |
DateTime,默认 now |
注册时间。 |
updatedAt |
DateTime,自动更新 |
用户记录最后修改时间。 |
lastLoginAt |
DateTime? |
最近登录时间。 |
关系数组 wordBookRecords、paymentRecords、courseRecords、visitors、knowledgeDocuments、aiConversations、aiRuns、agentActions、studyPlans、studyTasks、quizAttempts、quizSessions 都是 Prisma 导航字段,不是物理列。
关联表#
| 关联表 | 关系 | 关联字段 | 删除 User 时 |
|---|---|---|---|
WordBookRecord |
一对多;一个用户有多条单词掌握记录 | WordBookRecord.userId → User.id |
Cascade |
PaymentRecord |
一对多;一个用户有多笔支付订单 | PaymentRecord.userId → User.id |
Cascade |
CourseRecord |
一对多;一个用户有多条课程购买关系 | CourseRecord.userId → User.id |
Cascade |
Visitor |
一对多且外键可空;匿名访客登录后可绑定用户 | Visitor.userId → User.id |
Cascade |
KnowledgeDocument |
一对多;一个用户有多份私人文档 | KnowledgeDocument.userId → User.id |
Cascade |
AiConversation |
一对多;一个用户有多个 Agent 会话 | AiConversation.userId → User.id |
Cascade |
AiRun |
一对多;冗余保存发起用户,便于限流和恢复 | AiRun.userId → User.id |
Cascade |
AgentAction |
一对多;只有所属用户可确认操作 | AgentAction.userId → User.id |
Cascade |
StudyPlan |
一对多;一个用户有多份历史计划 | StudyPlan.userId → User.id |
Cascade |
StudyTask |
一对多;便于不联表直接查询用户任务 | StudyTask.userId → User.id |
Cascade |
QuizSession |
一对多;用户生成的待提交测验 | QuizSession.userId → User.id |
Cascade |
QuizAttempt |
一对多;用户已经提交的测验结果 | QuizAttempt.userId → User.id |
Cascade |
User 还会通过 WordBookRecord 间接关联 WordBook,通过 CourseRecord 间接关联 Course 和 PaymentRecord。这些属于中间表关系,不是 User 表上的直接外键。
5.2 WordBook - 单词主数据表#
源码:server/prisma/schema.prisma:156。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK,cuid() |
单词记录 ID。 |
word |
String |
单词文本。 |
phonetic |
String? |
音标。 |
definition |
String? |
英文释义。 |
translation |
String? |
中文翻译。 |
pos |
String? |
词性。 |
collins |
String? |
柯林斯等级/标记。 |
oxford |
String? |
牛津词典相关标记。 |
tag |
String? |
业务标签。 |
bnc |
String? |
BNC 语料词频/排名。 |
frq |
String? |
通用词频字段;当前是字符串,排序时要注意字典序与数值序差异。 |
exchange |
String? |
词形变化、同义/交换信息。 |
gk |
Boolean? |
是否属于高考词库。 |
zk |
Boolean? |
是否属于中考词库。 |
gre |
Boolean? |
是否属于 GRE 词库。 |
toefl |
Boolean? |
是否属于 TOEFL 词库。 |
ielts |
Boolean? |
是否属于 IELTS 词库。 |
cet6 |
Boolean? |
是否属于 CET-6 词库。 |
cet4 |
Boolean? |
是否属于 CET-4 词库。 |
ky |
Boolean? |
是否属于考研词库。 |
createdAt |
DateTime |
创建时间。 |
updatedAt |
DateTime |
修改时间。 |
索引:word、tag、word + tag。这些索引服务单词搜索与标签筛选。考试标签使用多个布尔列,适合固定标签集;若标签不断增长,可改为标签表和多对多关系。
关联表#
| 关联表 | 关系 | 关联字段 | 删除 WordBook 时 |
|---|---|---|---|
WordBookRecord |
一对多;一个单词可被多个用户学习 | WordBookRecord.wordId → WordBook.id |
Cascade |
WordBook 通过 WordBookRecord 与 User 构成多对多关系。QuizAttempt.wordIds 也会保存单词 ID 数组用于测验统计,但它不是数据库外键,只是逻辑关联。
5.3 WordBookRecord - 用户单词掌握关系表#
源码:server/prisma/schema.prisma:131。
这是 User 与 WordBook 的多对多中间表,但不仅保存关系,还保存间隔复习状态。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
用户单词记录 ID。 |
userId |
String,FK |
所属用户。 |
wordId |
String,FK |
对应单词。 |
isMaster |
Boolean,默认 false |
当前是否达到“已掌握”业务阈值。 |
masteryScore |
Float,默认 0 |
0-100 的掌握度分数;Migration 有 CHECK 约束。 |
correctCount |
Int,默认 0 |
历史答对次数。 |
wrongCount |
Int,默认 0 |
历史答错次数。 |
reviewCount |
Int,默认 0 |
总复习次数。 |
lastAnswerCorrect |
Boolean? |
最近一次回答是否正确;与历史累计计数分开,便于立即判定薄弱词。 |
lastReviewedAt |
DateTime? |
最近复习时间。 |
nextReviewAt |
DateTime? |
下一次建议复习时间。 |
intervalDays |
Int,默认 1 |
当前复习间隔天数,数据库限制 1-365。 |
easeFactor |
Float,默认 2.5 |
间隔复习难度/增长系数,数据库限制 1.3-3.0。 |
createdAt |
DateTime |
第一次创建记录时间。 |
updatedAt |
DateTime |
最后更新掌握状态时间。 |
约束与索引:
unique(userId, wordId):一个用户对一个单词最多一条记录。index(userId, nextReviewAt):查询“当前用户到期复习词”。index(userId, masteryScore):查询“当前用户薄弱词”。- 删除用户或单词时,该关系记录级联删除。
关联表#
| 关联表 | 关系 | 关联字段 | 上游删除策略 |
|---|---|---|---|
User |
多对一;每条掌握记录属于一个用户 | WordBookRecord.userId → User.id |
删除 User 时 Cascade |
WordBook |
多对一;每条掌握记录对应一个单词 | WordBookRecord.wordId → WordBook.id |
删除 WordBook 时 Cascade |
QuizAttempt 提交后会按 wordIds 更新这张表,但二者没有外键;这是业务流程关联,不是数据库结构关联。
6. 课程与支付表#
6.1 Course - 课程主表#
源码:server/prisma/schema.prisma:219。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
课程 ID。 |
name |
String |
课程名称。 |
value |
String |
课程业务值/标识,用于关联词库或前端选择。 |
description |
String? |
课程描述。 |
teacher |
String |
教师名称。 |
url |
String |
课程封面或资源 URL。 |
price |
Decimal |
课程价格,使用精确小数。 |
createdAt |
DateTime |
创建时间。 |
updatedAt |
DateTime |
修改时间。 |
关联表#
| 关联表 | 关系 | 关联字段 | 删除 Course 时 |
|---|---|---|---|
CourseRecord |
一对多;课程可被多个用户购买 | CourseRecord.courseId → Course.id |
Cascade |
Course 通过 CourseRecord 间接关联 User 和 PaymentRecord,自身没有直接指向用户或订单的外键。
6.2 PaymentRecord - 支付订单表#
源码:server/prisma/schema.prisma:187。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
内部支付记录 ID。 |
userId |
String,FK |
下单用户。 |
tradeNo |
String?,index |
支付宝交易号,支付完成后由第三方返回。 |
outTradeNo |
String,unique |
商户侧订单号,用于回调幂等和对账。 |
amount |
Decimal |
实际订单金额。 |
subject |
String |
支付标题。 |
body |
String |
支付说明。 |
tradeStatus |
TradeStatus,默认 NOT_PAY |
订单状态。 |
sendPayTime |
DateTime? |
支付成功/发送支付的业务时间。 |
createdAt |
DateTime |
下单时间。 |
updatedAt |
DateTime |
状态最后更新时间。 |
关联表#
| 关联表 | 关系 | 关联字段 | 删除 PaymentRecord 时 |
|---|---|---|---|
User |
多对一;每笔支付属于一个用户 | PaymentRecord.userId → User.id |
删除 User 时 PaymentRecord Cascade |
CourseRecord |
一对多;订单可关联课程购买记录,外键可空 | CourseRecord.paymentRecordId → PaymentRecord.id |
CourseRecord Cascade |
支付订单与 Course 没有直接外键,而是通过 CourseRecord 关联,因此一笔订单对应哪些课程要查询购买关系表。
6.3 CourseRecord - 用户课程购买关系表#
源码:server/prisma/schema.prisma:204。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
购买关系 ID。 |
userId |
String,FK |
课程所属用户。 |
courseId |
String,FK |
被购买课程。 |
isPurchased |
Boolean,默认 false |
是否已完成购买授权。 |
paymentRecordId |
String?,FK |
对应支付订单;可空。 |
createdAt |
DateTime |
关系创建时间。 |
updatedAt |
DateTime |
购买状态修改时间。 |
约束:unique(userId, courseId) 防止同一用户重复生成同一课程关系。删除用户、课程或关联支付记录时会级联删除购买关系。
关联表#
| 关联表 | 关系 | 关联字段 | 上游删除策略 |
|---|---|---|---|
User |
多对一;购买关系属于一个用户 | CourseRecord.userId → User.id |
删除 User 时 Cascade |
Course |
多对一;购买关系对应一个课程 | CourseRecord.courseId → Course.id |
删除 Course 时 Cascade |
PaymentRecord |
多对一且可空;可记录授权来自哪笔订单 | CourseRecord.paymentRecordId → PaymentRecord.id |
删除 PaymentRecord 时 Cascade |
7. 前端监控与埋点表#
7.1 Visitor - 访客表#
源码:server/prisma/schema.prisma:233。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
访客 ID。 |
anonymousId |
String,unique |
浏览器/设备匿名标识,登录前也可统计。 |
userId |
String?,FK |
登录后可绑定用户;未登录时为空。 |
browser |
String? |
浏览器信息。 |
os |
String? |
操作系统信息。 |
device |
String? |
设备类型。 |
createdAt |
DateTime |
首次记录时间。 |
updatedAt |
DateTime |
最近更新访客信息时间。 |
索引:userId 和 anonymousId。anonymousId 已是 unique,额外普通索引通常是冗余的,可在优化时评估删除。
关联表#
| 关联表 | 关系 | 关联字段 | 删除 Visitor 时 |
|---|---|---|---|
User |
多对一且可空;未登录访客没有用户 | Visitor.userId → User.id |
删除 User 时 Visitor Cascade |
PageView |
一对多;访客的页面访问记录 | PageView.visitorId → Visitor.id |
Cascade |
TrackEvent |
一对多;访客的自定义行为记录 | TrackEvent.visitorId → Visitor.id |
Cascade |
PerformanceEntry |
一对多;访客上报的性能指标 | PerformanceEntry.visitorId → Visitor.id |
Cascade |
ErrorEntry |
一对多;访客上报的前端错误 | ErrorEntry.visitorId → Visitor.id |
Cascade |
7.2 PageView - 页面访问表#
源码:server/prisma/schema.prisma:253。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
PV 记录 ID。 |
visitorId |
String,FK |
访问者。 |
url |
String |
完整页面 URL。 |
referrer |
String? |
来源页面。 |
path |
String |
路由路径,便于按页面聚合。 |
createdAt |
DateTime |
访问发生时间。 |
updatedAt |
DateTime |
记录更新时间。 |
索引:visitorId + createdAt 查某访客访问轨迹;path + createdAt 查某页面时序流量。
关联表#
| 关联表 | 关系 | 关联字段 | 删除策略 |
|---|---|---|---|
Visitor |
多对一;每条 PV 属于一个访客 | PageView.visitorId → Visitor.id |
删除 Visitor 时 Cascade |
通过 Visitor.userId 可间接关联 User;PageView 自身没有 userId 外键,因此匿名访问也能保存。
7.3 TrackEvent - 自定义行为事件表#
源码:server/prisma/schema.prisma:268。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
事件 ID。 |
visitorId |
String,FK |
触发事件的访客。 |
event |
String |
事件名,如按钮点击、搜索、购买入口。 |
payload |
Json? |
事件扩展参数。 |
url |
String? |
事件发生页面。 |
createdAt |
DateTime |
发生时间。 |
updatedAt |
DateTime |
更新时间。 |
索引:visitorId + createdAt、event + createdAt。
关联表#
| 关联表 | 关系 | 关联字段 | 删除策略 |
|---|---|---|---|
Visitor |
多对一;每条行为事件属于一个访客 | TrackEvent.visitorId → Visitor.id |
删除 Visitor 时 Cascade |
通过 Visitor 可间接找到 User,但 payload 中即使包含业务 ID 也只是事件数据,不受数据库外键约束。
7.4 PerformanceEntry - Web 性能表#
源码:server/prisma/schema.prisma:283。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
性能记录 ID。 |
visitorId |
String,FK |
对应访客。 |
fp |
Float? |
First Paint,首次绘制。 |
fcp |
Float? |
First Contentful Paint,首次内容绘制。 |
lcp |
Float? |
Largest Contentful Paint,最大内容绘制。 |
inp |
Float? |
Interaction to Next Paint,交互响应指标。 |
cls |
Float? |
Cumulative Layout Shift,累计布局偏移。 |
createdAt |
DateTime |
采集时间。 |
updatedAt |
DateTime |
更新时间。 |
当前为每个指标分别建索引,并额外建五指标加时间的组合索引。组合索引列很多,实际是否命中要根据查询和 EXPLAIN ANALYZE 验证。
关联表#
| 关联表 | 关系 | 关联字段 | 删除策略 |
|---|---|---|---|
Visitor |
多对一;每条性能数据属于一个访客 | PerformanceEntry.visitorId → Visitor.id |
删除 Visitor 时 Cascade |
通过 Visitor.userId 可间接关联登录用户;未登录访客仍可产生性能记录。
7.5 ErrorEntry - 前端错误表#
源码:server/prisma/schema.prisma:303。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
错误记录 ID。 |
visitorId |
String,FK |
发生错误的访客。 |
error |
String |
错误类别,如 js、promise。 |
message |
String? |
错误消息。 |
stack |
String? |
堆栈。需注意隐私与敏感信息脱敏。 |
url |
String? |
错误页面。 |
createdAt |
DateTime |
错误时间。 |
updatedAt |
DateTime |
更新时间。 |
删除 Visitor 会级联删除四类埋点子表;删除绑定用户也会级联删除该用户关联的 Visitor 及其明细。
关联表#
| 关联表 | 关系 | 关联字段 | 删除策略 |
|---|---|---|---|
Visitor |
多对一;每条错误属于一个访客 | ErrorEntry.visitorId → Visitor.id |
删除 Visitor 时 Cascade |
通过 Visitor 可间接关联 User。url、message 和 stack 只是错误上下文字段,不会与其他业务表建立外键。
8. 知识库表#
8.1 KnowledgeDocument - 私人文档元数据表#
源码:server/prisma/schema.prisma:318。
这张表只保存“业务事实和索引状态”,不保存 PDF 文件本体,也不保存向量。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
文档 ID,也是跨 NestJS、FastAPI、Celery、Chroma 的关联 ID。 |
userId |
String,FK |
文档所有者,是租户隔离关键字段。 |
title |
String |
用户可见标题。 |
fileName |
String |
原始文件名。 |
mimeType |
String |
经服务端验证的 MIME。 |
size |
Int,CHECK > 0 |
原始文件字节数。 |
objectKey |
String,unique |
MinIO 私有对象 Key,一般包含 userId/documentId。 |
status |
KnowledgeDocumentStatus |
文档状态机。 |
jobId |
String? |
Celery 任务 ID。 |
ingestionTraceId |
String? |
当前入库链路 Trace ID,用于拒绝旧任务的晚到回调。 |
chunkCount |
Int,默认 0 |
已完整写入 Chroma 的分片数,数据库限制 0-20000。 |
embeddingProvider |
String? |
入库使用的向量服务类型。 |
embeddingModel |
String? |
入库使用的 Embedding 模型。 |
indexVersion |
String? |
分片/向量索引版本。 |
contentHash |
String |
原文件 SHA-256,用于完整性校验、幂等和去重。 |
activeContentKey |
String?,unique |
活跃文档去重键,值为 userId:contentHash;删除时设 NULL,允许以后重新上传。 |
errorCode |
String? |
机器可读失败码。 |
errorMessage |
String? |
脱敏后的用户可见/运维错误信息。 |
lastIntegrityCheckAt |
DateTime? |
最近一次 PostgreSQL/MinIO/Chroma 一致性核对时间。 |
createdAt |
DateTime |
上传记录创建时间。 |
updatedAt |
DateTime |
状态更新时间。 |
deletedAt |
DateTime? |
软删除时间。非空表示业务上已删除。 |
索引:
userId + status + createdAt:用户文档列表和 READY 白名单。userId + contentHash:用户范围内容查重。status + updatedAt:恢复任务扫描卡住的 QUEUED/PROCESSING/DELETING。objectKey唯一:防止两个业务记录指向同一 MinIO 对象。activeContentKey唯一:数据库层防并发重复上传。
关联表与外部存储#
| 关联对象 | 关系 | 关联字段 | 删除策略/说明 |
|---|---|---|---|
User |
多对一;每份文档属于一个用户 | KnowledgeDocument.userId → User.id |
删除 User 时 Cascade |
| MinIO 对象 | 一对一逻辑关联;保存原始文件 | objectKey |
无数据库外键,由文档删除流程清理 |
| Chroma chunks | 一对多逻辑关联;保存文档分片与向量 | documentId = KnowledgeDocument.id,同时带 userId/indexVersion |
无数据库外键,由索引/删除/对账流程维护 |
这张表与 AiConversation 没有直接外键。每次 Agent 请求携带并校验 documentIds,形成“会话本轮使用哪些文档”的临时业务关联。
8.2 Chroma 向量库:Collection、记录与全部字段#
源码位置:
- Collection 命名:
server-py/app/config.py:233-250的Settings.chroma_collection_name。 - Collection 创建与元数据校验:
server-py/app/vectorstore/chroma_store.py:58-108的validate_collection()、_collection()、_existing_collection()。 - 正式分片写入:
server-py/app/vectorstore/chroma_store.py:169-233的ChromaStore.upsert_document()。 - 私有检索:
server-py/app/vectorstore/chroma_store.py:432-508的ChromaStore.search_private()。
先回答“向量库有哪些表”#
Chroma 对业务代码暴露的概念是 Collection(集合),不是 PostgreSQL 那种由本项目定义的关系表。不要把 Chroma 自己底层的内部系统表当成本项目业务表;内部表结构由 Chroma 1.5.9 管理,升级版本可能改变,本项目也没有直接查询它们。
本项目可见的向量存储对象如下:
| Chroma 对象 | 数量/命名 | 保存内容 | 是否长期保留 |
|---|---|---|---|
| 当前知识库 Collection | 当前配置对应 1 个;名称动态生成 | 所有用户的正式知识分片、Embedding、过滤 metadata,以及短暂的探针记录 | 是;是当前检索目标 |
| 历史受管 Collection | 可能有 0 个或多个;名称使用相同前缀 | 旧 Embedding 模型或旧 indexVersion 产生的历史向量 |
迁移/重建期间可能保留;代码会跨 Collection 清理旧文档向量 |
knowledge_chunk 逻辑记录 |
每个文档有多条 | 正式文档分片和对应向量 | 是,直到文档删除或重新索引 |
stage1_probe 逻辑记录 |
健康探测时临时 1 条 | 用来验证写入、查询、删除和向量维度 | 否,探针结束后删除 |
这里的 knowledge_chunk 和 stage1_probe 是同一个 Collection 中由 metadata.kind 区分的两种记录,不是两个物理表,也不是两个 Collection。
Collection 名称是怎么生成的#
命名公式:
{CHROMA_COLLECTION_PREFIX}_{embedding_provider}_{model_slug}_{model_sha256前12位}_{RAG_INDEX_VERSION}
例如开发环境默认配置大致生成:
english_knowledge_dev_ollama_mxbai_embed_large_latest_c55f6eb80700_v1
| 名称部分 | 来源 | 用途 |
|---|---|---|
CHROMA_COLLECTION_PREFIX |
开发默认 english_knowledge_dev;生产默认 english_knowledge_prod |
区分环境,并让代码能识别哪些 Collection 属于本项目。 |
embedding_provider |
如 ollama、openai_compatible |
不同 Provider 的向量不能混用。 |
model_slug |
Embedding 模型名清洗后的可读部分 | 方便运维人员看出使用的模型。 |
model_sha256前12位 |
原始模型名的稳定摘要 | 防止两个模型名清洗后碰巧得到相同 slug。 |
RAG_INDEX_VERSION |
默认 v1 |
分片规则、metadata 结构或索引方案升级时创建新版本。 |
这套设计意味着:不是每个用户一个 Collection,也不是每个文档一个 Collection。当前模型和索引版本下,所有用户文档共用一个 Collection,租户隔离依赖每条记录的 metadata 和服务端白名单。
Collection 自身的 metadata 字段#
创建 Collection 时,ChromaStore._collection() 会写入并在每次使用时核对以下 metadata。只要实际值和当前配置不一致就直接报错,不会拿错误维度或错误模型继续检索。
| 字段 | 类型 | 含义 | 为什么需要 |
|---|---|---|---|
embedding_provider |
String |
生成向量的 Provider。 | 防止 Ollama 和 OpenAI-compatible 等不同来源混用。 |
embedding_model |
String |
生成向量的模型名。 | 查询向量必须与文档向量使用相同模型。 |
embedding_dimension |
Int |
每个向量应有多少维。 | 写入和查询前校验维度,防止错模型得到无意义相似度。 |
index_version |
String |
当前 RAG 索引版本。 | 分片算法或 metadata 升级后隔离新旧索引。 |
hnsw:space |
String,固定 cosine |
HNSW 使用余弦距离。 | 与代码里的 score = 1 - distance 保持一致。 |
每条 Chroma 记录的顶层字段#
Chroma 的每条记录不是一行 Prisma Model,而是由下面四组数据一起组成:
| 顶层字段 | 类型 | 正式知识分片中的值 | 含义 |
|---|---|---|---|
id / ids[] |
String |
{document_id}:{chunk_index}:{content_hash前12位} |
分片唯一 ID。内容或分片位置变化后 ID 会变化;相同输入重试会得到同一 ID,便于 upsert 幂等。 |
embedding / embeddings[] |
Float[] |
Embedding Provider 对该分片正文生成的向量 | 用于余弦相似度检索;长度必须等于 Collection 的 embedding_dimension。 |
document / documents[] |
String |
DocumentChunk.text |
真正交给模型作为资料上下文的分片正文。 |
metadata / metadatas[] |
标量键值对象 | 见下一张表 | 保存权限、文档归属、页码、哈希、版本等过滤和审计字段。 |
distance 不是持久化字段。它是 collection.query() 查询时由 Chroma 计算并返回的距离;项目把它换算为 score = 1 - distance,再应用最低分阈值。score 也不会写回向量库。
knowledge_chunk 正式分片的全部 metadata 字段#
| metadata 字段 | 类型 | 来源 | 含义/用途 |
|---|---|---|---|
kind |
String,固定 knowledge_chunk |
写入时固定 | 区分正式知识分片与健康探针;查询、统计、删除都会带此条件。 |
document_id |
String |
KnowledgeDocument.id |
关联 PostgreSQL 文档;一份文档对应多条分片记录。 |
owner_user_id |
String |
KnowledgeDocument.userId / 当前用户 |
租户隔离字段;检索、统计和删除必须与当前用户一致。 |
visibility |
String,固定 PRIVATE |
写入时固定 | 明确私人数据属性;不是 PRIVATE 的记录不会进入私人检索结果。 |
file_name |
String |
KnowledgeDocument.fileName |
原文件名快照,返回引用时展示。 |
title |
String |
KnowledgeDocument.title |
文档标题快照,返回引用时展示。 |
page |
Int |
解析器得到的 DocumentChunk.page |
PDF 页码;没有页码时保存 -1,返回前转换为 null。 |
chunk_index |
Int,从 0 开始 |
DocumentChunk.index |
分片在整份文档中的顺序,用于完整性检查。 |
document_chunk_count |
Int |
len(chunks) |
该文档本次入库应有的总分片数;每条分片都保存一份,用于核对是否缺片。 |
content_hash |
String,SHA-256 |
DocumentChunk.content_hash |
当前分片正文哈希;用于精确去重和构造确定性分片 ID。 |
document_content_hash |
String,SHA-256 |
KnowledgeDocument.contentHash |
整份原文件哈希;用于确认 Chroma 分片对应的确实是当前 MinIO/PostgreSQL 版本。 |
ingestion_trace_id |
String |
KnowledgeDocument.ingestionTraceId / 本次任务 trace |
区分不同入库批次;迟到的旧 Worker 结果不能冒充当前索引。 |
index_version |
String |
RAG_INDEX_VERSION |
记录级索引版本快照;完整性检查要求与当前配置一致。 |
embedding_model |
String |
实际 Embedding probe 的模型名 | 记录该批向量由什么模型生成,便于审计和排障。 |
注意:embedding_provider 和 embedding_dimension 保存在 Collection metadata,不在每条 knowledge_chunk metadata 中重复保存。
stage1_probe 健康探针记录字段#
ChromaStore.probe() 会临时写一条记录验证 Chroma 真的能写、能查、能删。它和正式分片共用顶层字段,但 metadata 只有三项:
| 字段 | 值/类型 | 含义 |
|---|---|---|
顶层 id |
调用方传入的 probe_id |
本次探针 ID。 |
顶层 embedding |
Float[] |
对探针文字生成的真实向量。 |
顶层 document |
String |
探针文字。 |
metadata.kind |
固定 stage1_probe |
让读探针只查询探针,不混入正式知识结果。 |
metadata.embedding_model |
String |
本次探针使用的模型。 |
metadata.index_version |
String |
本次探针使用的索引版本。 |
探针完成 delete 后还会再次 get,确认 ID 已不存在。它不是用户文档,不关联 KnowledgeDocument。
与 PostgreSQL、用户和文件的逻辑关系#
| 左侧对象/字段 | 关系 | Chroma 字段 | 数据库外键 | 一致性维护方式 |
|---|---|---|---|---|
KnowledgeDocument.id |
一对多:一份文档拆成多个 chunk | metadata.document_id |
无 | 确定性 ID、入库数量校验、删除/恢复任务。 |
User.id |
一对多:一个用户拥有多份文档的多条 chunk | metadata.owner_user_id |
无 | Business 先构造 READY 文档白名单,Chroma 再按 owner 过滤并在返回后复核。 |
KnowledgeDocument.contentHash |
一对多快照 | metadata.document_content_hash |
无 | Worker 下载 MinIO 后重算 SHA-256,完整性检查再次比对。 |
KnowledgeDocument.ingestionTraceId |
一对多:本次入库批次 | metadata.ingestion_trace_id |
无 | READY 前要求所有分片 trace 与当前任务一致,拒绝旧任务残留。 |
KnowledgeDocument.chunkCount |
应等于实际记录数 | metadata.document_chunk_count + 实际 ids 数量 |
无 | 写入后核对 ID 集合;后台对 READY 文档再次 count。 |
KnowledgeDocument.indexVersion |
应等于当前索引版本 | Collection metadata.index_version + record metadata.index_version |
无 | 创建、读取、READY 回调和后台对账多层校验。 |
KnowledgeDocument.fileName/title |
一对多展示快照 | metadata.file_name / title |
无 | 写入时复制,用于引用结果展示;PostgreSQL 仍是业务事实来源。 |
私有检索实际使用哪些字段#
ChromaStore.search_private() 不会只按向量相似度裸搜,而是先使用以下 metadata 过滤:
kind = knowledge_chunk
AND owner_user_id = 当前登录用户
AND visibility = PRIVATE
AND document_id IN 服务端校验后的 READY 文档白名单
Chroma 返回后,代码还会逐条再次检查 owner_user_id、visibility、kind 和 document_id,然后按 content_hash 去重、按文本相似度去近重复、按 min_score 丢掉低分结果。这样即使向量库过滤出现异常,也不会直接把其他用户或未选择文档的内容交给模型。
写入、重建和删除时怎么保证一致性#
| 操作 | 使用的方法 | 做法 |
|---|---|---|
| 写入/重试 | ChromaStore.upsert_document() |
使用确定性 chunk ID 批量 upsert;结束后重新 get,要求实际 ID 数量和集合完全一致。 |
| 确认完整入库 | completed_ingestion_count() |
核对 owner、PRIVATE、整文档哈希、trace、版本、总分片数以及 chunk_index = 0..N-1。 |
| 当前 Collection 删除 | delete_document() |
删除前先核对 owner;where 同时包含 kind、document_id、owner、PRIVATE;删除后再查 remaining。 |
| 跨版本删除 | delete_document_everywhere() |
遍历所有同项目前缀的历史 Collection,防止旧版本向量残留。 |
| 重建后清旧版本 | delete_document_other_collections() |
保留当前 Collection,清理其他受管 Collection 中同一文档的向量。 |
| READY 对账 | Business reconcileReadyDocuments() + RAG count_document() |
周期性核对 MinIO 原文件 SHA-256、PostgreSQL chunkCount 与 Chroma 实际数量。 |
最重要的面试表述:PostgreSQL 的 KnowledgeDocument 是业务事实来源;Chroma 的向量、正文分片和 metadata 都是可根据 MinIO 原文件重新生成的派生数据,所以二者采用“逻辑关联 + 状态机 + 对账/恢复”,而不是跨数据库外键或分布式事务。
9. Agent 会话表#
9.1 AiConversation - Agent 会话表#
源码:server/prisma/schema.prisma:348。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
会话 ID,同时作为 LangGraph thread_id。 |
userId |
String,FK |
会话所有者。 |
title |
String |
会话标题,初始可为“新对话”,首条消息后自动更新。 |
scene |
AiConversationScene |
决定系统 Prompt 和可用工具范围。 |
status |
AiConversationStatus |
ACTIVE 或 ARCHIVED。 |
lastMessageAt |
DateTime? |
最近消息时间,用于会话排序。 |
checkpointDeletedAt |
DateTime? |
归档后 LangGraph checkpoint 成功清理的时间;NULL 表示仍需恢复任务重试。 |
createdAt |
DateTime |
会话创建时间。 |
updatedAt |
DateTime |
会话更新时间。 |
索引:userId + status + lastMessageAt 用于用户会话列表;status + checkpointDeletedAt 用于扫描已归档但 checkpoint 未清理的会话。
关联表#
| 关联表 | 关系 | 关联字段 | 删除 AiConversation 时 |
|---|---|---|---|
User |
多对一;会话属于一个用户 | AiConversation.userId → User.id |
删除 User 时 Conversation Cascade |
AiMessage |
一对多;会话中的消息历史 | AiMessage.conversationId → AiConversation.id |
Cascade |
AiRun |
一对多;会话中的多次 Agent 执行 | AiRun.conversationId → AiConversation.id |
Cascade |
AgentAction |
一对多;会话产生的待确认操作 | AgentAction.conversationId → AiConversation.id |
Cascade |
QuizSession |
一对多且子表外键可空 | QuizSession.conversationId → AiConversation.id |
SetNull |
QuizAttempt |
一对多且子表外键可空 | QuizAttempt.conversationId → AiConversation.id |
SetNull |
langgraph.* |
逻辑一对多;图执行检查点 | thread_id = AiConversation.id |
无外键,归档后由 CheckpointService 清理 |
9.2 AiMessage - 会话消息表#
源码:server/prisma/schema.prisma:369。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
消息 ID。 |
conversationId |
String,FK |
所属会话。 |
runId |
String?,FK |
产生该消息的 Run;Action 回执等消息可不绑定 Run。 |
role |
AiMessageRole |
USER、ASSISTANT、SYSTEM 或 TOOL。 |
content |
String |
用户/助手/工具消息正文。 |
citations |
Json? |
最终回答实际使用的可信引用数组。 |
toolCalls |
Json? |
本次回答的工具审计摘要。 |
createdAt |
DateTime |
消息时间。 |
关系策略:删会话时消息 Cascade;删 Run 时只把 runId SetNull,消息仍保留在会话历史中。
索引:conversationId + createdAt 用于按时间加载历史;runId 用于追踪一次执行产生的消息。
关联表#
| 关联表 | 关系 | 关联字段 | 删除策略 |
|---|---|---|---|
AiConversation |
多对一;消息属于一个会话 | AiMessage.conversationId → AiConversation.id |
删除 Conversation 时 Cascade |
AiRun |
多对一且可空;消息可由某次 Run 产生 | AiMessage.runId → AiRun.id |
删除 Run 时 SetNull,消息保留 |
AgentAction |
逻辑关联;执行/拒绝回执消息记录 Action ID | AiMessage.toolCalls.actionId = AgentAction.id |
无数据库外键;靠回执幂等逻辑维护 |
9.3 AiRun - 单次 Agent 执行表#
源码:server/prisma/schema.prisma:385。
一条用户消息通常对应一个 AiRun。Conversation 是长期容器,Run 是一次执行审计记录。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
Run ID。 |
conversationId |
String,FK |
所属会话。 |
userId |
String,FK |
发起用户;虽然可从 Conversation 推导,但冗余后方便限额和恢复扫描。 |
traceId |
String,index |
跨前端、NestJS、FastAPI、Worker 的链路追踪 ID。 |
provider |
String |
Chat 模型 Provider。 |
model |
String |
实际模型名。 |
status |
AiRunStatus |
运行状态。 |
startedAt |
DateTime |
开始时间。 |
completedAt |
DateTime? |
结束时间。 |
durationMs |
Int?,CHECK >= 0 |
总耗时毫秒。 |
inputTokens |
Int?,CHECK >= 0 |
Provider 实际返回的输入 Token。 |
outputTokens |
Int?,CHECK >= 0 |
输出 Token。 |
totalTokens |
Int?,CHECK >= 0 |
总 Token。 |
cost |
Decimal?,CHECK >= 0 |
根据真实 Token 和配置单价计算的成本;未知时为 NULL。 |
toolStepCount |
Int,默认 0,CHECK >= 0 |
本轮完成/失败的工具步骤数。 |
errorCode |
String? |
机器可读错误码。 |
errorMessage |
String? |
脱敏错误说明。 |
createdAt |
DateTime |
记录创建时间。 |
updatedAt |
DateTime |
状态更新时间。 |
索引:
conversationId + createdAt:查看会话执行历史。userId + status + createdAt:限制用户并发 Run、查用户失败记录。status + startedAt:恢复长时间 RUNNING 的 stale Run。traceId:运维链路查询。
关联表#
| 关联表 | 关系 | 关联字段 | 删除 AiRun 时 |
|---|---|---|---|
AiConversation |
多对一;Run 属于一个会话 | AiRun.conversationId → AiConversation.id |
删除 Conversation 时 Run Cascade |
User |
多对一;冗余保存 Run 发起用户 | AiRun.userId → User.id |
删除 User 时 Run Cascade |
AiMessage |
一对多;Run 产生的消息,外键可空 | AiMessage.runId → AiRun.id |
AiMessage.runId SetNull |
AgentAction |
一对多;Run 产生的待确认操作,外键可空 | AgentAction.runId → AiRun.id |
AgentAction.runId SetNull |
traceId 还会逻辑关联 FastAPI、Celery 和回调日志,但它不是这些系统之间的数据库外键。
9.4 AgentAction - 高影响操作确认表#
源码:server/prisma/schema.prisma:416。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
Action ID。 |
conversationId |
String,FK |
Action 所属会话。 |
runId |
String?,FK |
创建 Action 的 Run;Run 删除时保留 Action 并置 NULL。 |
userId |
String,FK |
只有该用户能确认或拒绝。 |
type |
AgentActionType |
创建计划或完成任务。 |
payload |
Json |
待执行参数和关联业务 ID。 |
summary |
String |
给用户展示的确认摘要。 |
status |
AgentActionStatus |
Action 生命周期。 |
idempotencyKey |
String,unique |
防止模型重复调用、用户重复确认和服务重试造成重复写入。 |
expiresAt |
DateTime |
PENDING 确认截止时间。 |
confirmedAt |
DateTime? |
用户确认时间。 |
executedAt |
DateTime? |
业务写入真正完成时间。 |
compensatedAt |
DateTime? |
对关联计划等资源完成补偿/取消的时间。 |
executionAttempts |
Int,默认 0 |
执行尝试次数,数据库要求非负。 |
lastAttemptAt |
DateTime? |
最近一次抢占执行租约的时间;恢复任务用它判断是否可重试。 |
result |
Json? |
执行成功后的结构化结果。 |
errorCode |
String? |
失败码。 |
errorMessage |
String? |
脱敏失败说明。 |
createdAt |
DateTime |
创建时间。 |
updatedAt |
DateTime |
状态更新时间。 |
索引分别服务:用户待确认列表、会话 Action 历史、已确认但未执行恢复、终态 Action 补偿扫描。
关联表#
| 关联表 | 关系 | 关联字段 | 删除策略/说明 |
|---|---|---|---|
User |
多对一;Action 属于一个用户 | AgentAction.userId → User.id |
删除 User 时 Cascade |
AiConversation |
多对一;Action 属于一个会话 | AgentAction.conversationId → AiConversation.id |
删除 Conversation 时 Cascade |
AiRun |
多对一且可空;记录由哪次 Run 创建 | AgentAction.runId → AiRun.id |
删除 Run 时 SetNull |
StudyPlan |
逻辑关联;创建计划 Action 保存计划 ID | AgentAction.payload.planId = StudyPlan.id |
无外键;拒绝/过期/失败时做补偿取消 |
StudyTask |
逻辑关联;完成任务 Action 保存任务 ID | AgentAction.payload.taskId = StudyTask.id |
无外键;执行时重新校验所有权与状态 |
AiMessage |
逻辑关联;Action 结果回执 | AiMessage.toolCalls.actionId = AgentAction.id |
无外键;按 actionId 防止重复回执 |
10. 学习计划与任务表#
10.1 StudyPlan - 学习计划表#
源码:server/prisma/schema.prisma:447。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
计划 ID。 |
userId |
String,FK |
计划所属用户。 |
goal |
String |
用户学习目标。 |
startDate |
DateTime |
计划开始日期。 |
endDate |
DateTime |
计划结束日期,CHECK 保证不早于开始日期。 |
status |
StudyPlanStatus |
等待确认、活动、完成或取消。 |
plan |
Json |
Agent/服务端生成的完整计划快照和展示结构。 |
confirmedAt |
DateTime? |
用户确认并激活时间。 |
completedAt |
DateTime? |
全部任务完成时间。 |
createdAt |
DateTime |
创建时间。 |
updatedAt |
DateTime |
修改时间。 |
普通索引 userId + status + createdAt 支持用户计划列表。Migration 还建立了部分唯一索引 StudyPlan_one_active_per_user_idx:只对 status = ACTIVE 生效,保证一个用户同时最多一个活动计划。这个部分索引无法完整表示在 Prisma Schema 中,因此看 Schema 时还必须看 Migration。
关联表#
| 关联表 | 关系 | 关联字段 | 删除 StudyPlan 时 |
|---|---|---|---|
User |
多对一;计划属于一个用户 | StudyPlan.userId → User.id |
删除 User 时 Plan Cascade |
StudyTask |
一对多;计划激活后生成具体任务 | StudyTask.planId → StudyPlan.id |
Cascade |
AgentAction |
逻辑关联;待确认 Action 指向计划 | AgentAction.payload.planId = StudyPlan.id |
无外键;Action 补偿流程负责取消计划 |
10.2 StudyTask - 计划任务表#
源码:server/prisma/schema.prisma:465。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
任务 ID。 |
planId |
String,FK |
所属学习计划。 |
userId |
String,FK |
所属用户;虽然可从 Plan 推导,但便于按用户/日期直接查任务。 |
scheduledFor |
DateTime |
计划执行日期。 |
type |
StudyTaskType |
任务类别。 |
title |
String |
任务标题。 |
description |
String? |
任务说明。 |
payload |
Json? |
与任务类型相关的结构化参数,如词汇列表、测验配置。 |
order |
Int,默认 0,CHECK >= 0 |
同一天任务排序。 |
status |
StudyTaskStatus |
待完成、已完成或跳过。 |
completedAt |
DateTime? |
完成时间。 |
createdAt |
DateTime |
创建时间。 |
updatedAt |
DateTime |
修改时间。 |
约束:unique(planId, scheduledFor, order) 防止同一计划同一天出现重复顺序;index(userId, status, scheduledFor) 支持查询某用户待办和到期任务。删除 Plan 会级联删除 Task。
关联表#
| 关联表 | 关系 | 关联字段 | 删除策略/说明 |
|---|---|---|---|
StudyPlan |
多对一;任务属于一个计划 | StudyTask.planId → StudyPlan.id |
删除 Plan 时 Cascade |
User |
多对一;冗余保存任务所属用户 | StudyTask.userId → User.id |
删除 User 时 Cascade |
AgentAction |
逻辑关联;完成任务 Action 指向任务 | AgentAction.payload.taskId = StudyTask.id |
无外键;执行时按 userId 和状态复核 |
11. 测验表#
11.1 QuizSession - 未提交测验会话#
源码:server/prisma/schema.prisma:509。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
测验会话 ID。 |
userId |
String,FK |
测验所属用户。 |
conversationId |
String?,FK |
若由 Agent 生成,记录来源会话;普通 Study 页面生成时可为空。 |
mode |
QuizMode,默认 WEAK_WORDS |
按薄弱词或到期复习词出题。 |
questions |
Json |
服务端完整题目快照,包含评分所需信息;返回前端时会移除答案。 |
expiresAt |
DateTime |
会话失效时间。 |
submittedAt |
DateTime? |
成功提交时间;非空后不可再次提交。 |
createdAt |
DateTime |
生成时间。 |
索引:userId + expiresAt、userId + mode + expiresAt 查某模式未过期测验;conversationId 查 Agent 会话产生的测验。
关联表#
| 关联表 | 关系 | 关联字段 | 删除策略 |
|---|---|---|---|
User |
多对一;测验会话属于一个用户 | QuizSession.userId → User.id |
删除 User 时 Cascade |
AiConversation |
多对一且可空;记录由哪个 Agent 会话生成 | QuizSession.conversationId → AiConversation.id |
删除 Conversation 时 SetNull |
QuizAttempt |
一对一;一个 Session 最多生成一个 Attempt | QuizAttempt.sessionId → QuizSession.id,且 sessionId unique |
已有 Attempt 时删除 Session 被 Restrict |
11.2 QuizAttempt - 已提交测验结果#
源码:server/prisma/schema.prisma:486。
| 字段 | 类型/约束 | 含义 |
|---|---|---|
id |
String,PK |
测验结果 ID。 |
userId |
String,FK |
答题用户。 |
sessionId |
String,unique,FK |
对应 QuizSession;唯一约束保证一个 Session 只能生成一个 Attempt。 |
conversationId |
String?,FK |
来源 Agent 会话;删除会话时 SetNull,测验历史仍保留。 |
mode |
QuizMode |
生成测验时的模式快照。 |
questions |
Json |
提交时的题目快照,防止以后题库变化影响历史解释。 |
answers |
Json |
用户答案。 |
score |
Float,CHECK 0-100 |
总分。 |
feedback |
Json? |
逐题正确性、正确答案和反馈。 |
wordIds |
String[] |
本次涉及的单词 ID 列表,便于统计与掌握度更新。 |
idempotencyKey |
String,unique |
防止重复提交生成两份结果。 |
submittedAt |
DateTime |
实际提交时间。 |
createdAt |
DateTime |
结果记录创建时间。 |
删除策略:QuizAttempt.sessionId 对 Session 使用 Restrict,已有答题结果时不能删除原 Session;对 Conversation 使用 SetNull;对 User 使用 Cascade。
索引:userId + submittedAt、userId + mode + submittedAt 查近期测验;conversationId 查会话关联测验。
关联表#
| 关联表 | 关系 | 关联字段 | 删除策略/说明 |
|---|---|---|---|
User |
多对一;答题结果属于一个用户 | QuizAttempt.userId → User.id |
删除 User 时 Cascade |
QuizSession |
一对一;Attempt 必须来自一个 Session | QuizAttempt.sessionId → QuizSession.id |
删除 Session 时 Restrict |
AiConversation |
多对一且可空;记录 Agent 来源会话 | QuizAttempt.conversationId → AiConversation.id |
删除 Conversation 时 SetNull |
WordBook |
逻辑多对多;保存本次涉及的单词 ID | QuizAttempt.wordIds[] 包含 WordBook.id |
不是外键,不自动级联 |
WordBookRecord |
业务流程关联;提交后更新掌握度和复习数据 | 通过 userId + wordIds[] 定位 |
不是外键,由提交事务更新 |
12. 外键与删除策略汇总#
| 父表 | 子表 | 外键 | 删除父表时 |
|---|---|---|---|
User |
WordBookRecord |
userId |
Cascade |
WordBook |
WordBookRecord |
wordId |
Cascade |
User |
PaymentRecord |
userId |
Cascade |
User |
CourseRecord |
userId |
Cascade |
Course |
CourseRecord |
courseId |
Cascade |
PaymentRecord |
CourseRecord |
paymentRecordId |
Cascade |
User |
Visitor |
userId |
Cascade |
Visitor |
四类埋点明细 | visitorId |
Cascade |
User |
KnowledgeDocument |
userId |
Cascade |
User |
AiConversation |
userId |
Cascade |
AiConversation |
AiMessage |
conversationId |
Cascade |
AiConversation |
AiRun |
conversationId |
Cascade |
AiRun |
AiMessage |
runId |
SetNull |
AiConversation |
AgentAction |
conversationId |
Cascade |
AiRun |
AgentAction |
runId |
SetNull |
User |
StudyPlan |
userId |
Cascade |
StudyPlan |
StudyTask |
planId |
Cascade |
QuizSession |
QuizAttempt |
sessionId |
Restrict |
AiConversation |
QuizSession/QuizAttempt |
conversationId |
SetNull |
理解原则:
- 强归属数据随父记录删除,使用 Cascade。
- 有独立审计价值的数据在上游执行记录删除后仍保留,使用 SetNull。
- 已提交测验必须保留题目来源,Session → Attempt 使用 Restrict。
- Chroma metadata.
document_id/owner_user_id与 PostgreSQL 只是逻辑关联,没有外键和 Cascade;删除必须由应用显式清理,并由恢复任务核对。
13. 数据库级 CHECK 与特殊索引#
这些约束定义在 Migration,Prisma Schema 不能完整表达,面试时这是一个加分点。
来源:server/prisma/migrations/20260722190000_agent_rag_learning/migration.sql:192-248。
| 对象 | 约束 |
|---|---|
WordBookRecord.masteryScore |
0-100。 |
correctCount/wrongCount/reviewCount |
不得小于 0。 |
intervalDays |
1-365。 |
easeFactor |
1.3-3.0。 |
KnowledgeDocument.size |
必须大于 0。 |
KnowledgeDocument.chunkCount |
0-20000。 |
AiRun.durationMs |
NULL 或非负。 |
AiRun Token 三字段 |
NULL 或非负。 |
AiRun.toolStepCount |
非负。 |
AiRun.cost |
NULL 或非负。 |
AgentAction.executionAttempts |
非负。 |
StudyPlan |
endDate >= startDate。 |
StudyTask.order |
非负。 |
QuizAttempt.score |
0-100。 |
StudyPlan_one_active_per_user_idx |
WHERE status = 'ACTIVE' 的部分唯一索引,一个用户最多一个活动计划。 |
服务端校验改善用户体验,数据库约束负责最后一道一致性防线。二者应同时存在。
14. LangGraph 独立 Schema#
Prisma Migration server/prisma/migrations/20260722122000_add_langgraph_schema/migration.sql:1 只创建 langgraph schema。Agent 启动时,server/apps/ai/src/llm/llm.config.ts:31 的 createCheckpoint() 调用 PostgresSaver.setup() 自动创建以下表:
| 表 | 作用 | 关键字段 | 关联表/对象 |
|---|---|---|---|
langgraph.checkpoint_migrations |
LangGraph 自己的表结构版本。 | v。 |
不关联业务表,只供 PostgresSaver 判断内部 Migration 版本。 |
langgraph.checkpoints |
每个 thread 的 checkpoint 主记录。 | thread_id、checkpoint_ns、checkpoint_id、parent_checkpoint_id、checkpoint JSONB、metadata JSONB。 |
thread_id 逻辑关联 AiConversation.id;parent_checkpoint_id 逻辑关联同一 thread/namespace 的上一条 checkpoints.checkpoint_id,但没有业务外键。 |
langgraph.checkpoint_blobs |
各 Channel 各版本的序列化状态 Blob。 | thread_id、checkpoint_ns、channel、version、type、blob BYTEA。 |
通过 thread_id + checkpoint_ns 与 checkpoints 属于同一执行线程;具体版本由 checkpoint JSON 中的 channel version 引用,无 Prisma 关系。 |
langgraph.checkpoint_writes |
图节点执行期间的中间写入。 | thread_id、checkpoint_ns、checkpoint_id、task_id、idx、channel、type、blob。 |
thread_id + checkpoint_ns + checkpoint_id 逻辑关联 checkpoints;thread_id 同时逻辑关联 AiConversation.id,无 Prisma 外键。 |
项目把 AiConversation.id 作为 LangGraph thread_id。业务会话归档后,CheckpointService.deleteThread() 删除对应 checkpoint、blob 和 pending writes;checkpointDeletedAt 用来记录是否清理成功。
这四张表由第三方库管理,不应加入 Prisma Model,也不应手工修改字段。
15. 三条核心数据流#
15.1 用户完成测验#
QuizSession(题目快照)
→ QuizAttempt(答案、分数、反馈,sessionId 唯一)
→ WordBookRecord(正误计数、掌握度、间隔、下次复习)
→ User.wordNumber(已掌握词冗余计数)
事务和 Advisory Lock 保证重复提交不会重复累计,入口在 study-domain.service.ts:292。
15.2 Agent 创建学习计划#
AiConversation
→ AiRun
→ StudyPlan(PENDING_CONFIRMATION) + StudyTask[]
→ AgentAction(PENDING,payload 内含 planId)
→ 用户确认
→ AgentAction(EXECUTED) + StudyPlan(ACTIVE)
→ AiMessage(Action 执行回执)
AgentAction.idempotencyKey、事务、Advisory Lock 和部分唯一索引共同防止计划重复激活。
15.3 文档上传并入库#
KnowledgeDocument(UPLOADED)
→ MinIO 原始对象
→ KnowledgeDocument(QUEUED, jobId, ingestionTraceId)
→ Worker PROCESSING 回调
→ Chroma 当前 Collection
└─ ids + embeddings + documents + metadatas
└─ kind/document_id/owner_user_id/visibility/page/hash/trace/version...
→ Worker READY 回调
→ KnowledgeDocument(READY, chunkCount, model, indexVersion)
PostgreSQL 决定文档归属和是否可检索;MinIO 与 Chroma 不通过数据库外键连接,而是共同使用 documentId/userId/contentHash 做跨存储关联。
16. 常用查询为什么能命中索引#
| 业务查询 | 对应索引 |
|---|---|
| 查询用户到期复习词 | WordBookRecord(userId, nextReviewAt) |
| 查询用户薄弱词 | WordBookRecord(userId, masteryScore) |
| 查询用户 READY 文档 | KnowledgeDocument(userId, status, createdAt) |
| 扫描卡住的索引任务 | KnowledgeDocument(status, updatedAt) |
| 加载会话历史 | AiMessage(conversationId, createdAt) |
| 限制用户并发 Run | AiRun(userId, status, createdAt) |
| 扫描 stale Run | AiRun(status, startedAt) |
| 查询待确认 Action | AgentAction(userId, status, expiresAt) |
| 恢复已确认 Action | AgentAction(status, confirmedAt) |
| 查某日未完成任务 | StudyTask(userId, status, scheduledFor) |
| 查近期某模式测验 | QuizAttempt(userId, mode, submittedAt) |
| 查页面时序流量 | PageView(path, createdAt) |
组合索引遵循左前缀原则。例如 (userId, status, createdAt) 可以高效支持 userId 或 userId + status 开头的条件,但通常不能单独高效支持只查 createdAt。
17. 面试时容易被追问的设计点#
- 为什么
AiRun、AgentAction、StudyTask都冗余保存userId?答题重点:租户过滤、限额、恢复扫描和索引效率,同时必须用事务保证与父对象用户一致。 - 为什么
StudyPlan.plan已有 JSON,还需要StudyTask表?答题重点:JSON 是完整快照/展示结构,Task 是可查询、可更新、可约束的执行实体。 - 为什么
QuizSession.questions和QuizAttempt.questions都保存?答题重点:Session 用于服务端防篡改评分,Attempt 用于不可变历史审计。 - 为什么
AiMessage.runId与AgentAction.runId是可空并使用 SetNull?答题重点:会话历史和 Action 审计生命周期长于某个 Run。 - 为什么不用 PostgreSQL 存向量?当前选择是 Chroma 职责隔离;但 pgvector 可以减少组件数,需要按规模、检索能力、运维成本权衡。
- 为什么软删除
KnowledgeDocument?跨 PostgreSQL/MinIO/Chroma 删除无法用单库事务,需要DELETING + deletedAt + 恢复任务实现最终一致性。 - 为什么不是“每个用户一个 Chroma Collection”?共用按模型/版本生成的 Collection 可以减少 Collection 管理成本;租户隔离靠服务端 READY 白名单和
owner_user_id + visibility + document_id多层过滤,但必须做好返回后二次校验。 - Chroma 记录为什么同时存
content_hash和document_content_hash?前者标识单个分片并去重,后者确认整份文档版本,解决的问题不同。 activeContentKey为什么比“先查再插”更可靠?唯一约束能挡住并发请求的竞态。- Prisma Schema 为什么不是数据库真相的全部?CHECK、部分索引、LangGraph 自动表都只在 Migration/第三方 setup 中体现;Chroma Collection schema 更不在 Prisma 中。
- 哪些字段是冗余数据?
User.wordNumber、AiRun.userId、StudyTask.userId、模型/索引快照、Chroma 的 title/fileName 等;冗余换查询效率,但要设计一致性修复。 - 当前最严重的数据库安全问题是什么?
User.password尚未哈希。
18. 快速记忆总结#
User是租户根。WordBookRecord是“用户 × 单词”的学习状态。CourseRecord是“用户 × 课程”的购买状态。Visitor是埋点根,四张明细表记录 PV、行为、性能和错误。KnowledgeDocument是私人文档的业务元数据与状态机,不存文件和向量。- Chroma 没有本项目定义的关系表;当前模型/版本对应一个 Collection,正式记录由
ids + embeddings + documents + metadatas组成,knowledge_chunk与stage1_probe用 metadata.kind区分。 - Chroma 通过 metadata.
document_id逻辑关联KnowledgeDocument.id,通过owner_user_id逻辑关联User.id;没有数据库外键。 AiConversation是长期对话,AiRun是单次执行,AiMessage是消息历史。AgentAction是模型建议与真实写操作之间的安全闸门。StudyPlan是计划头,StudyTask是可执行任务。QuizSession是待提交题目,QuizAttempt是不可重复的最终答题结果。langgraph.*四张表保存 Agent checkpoint,由 LangGraph 库管理。