实体模型层:七张核心业务表的 ORM 设计
一句话摘要:
models.py用 SQLAlchemy 2.0 的 DeclarativeBase 声明式建模,覆盖学生、题目、变式、分析、对话、题包等十张表,用字符串 UUID 主键、JSON 列、父子自关联与级联删除支撑整套业务。
一、引言
第一性原理:业务代码关心的不是「表结构」,而是「对象」。如果让 UI 层直接写 SELECT ... JOIN ...,那么每个页面都要重复处理字段拼装、类型转换、关系遍历,代码会迅速腐化。ORM 的本质是把关系型数据映射为对象图 ,让开发者以 student.questions 这种自然方式访问数据。
但 ORM 不是银弹:建模时对「主键类型、级联策略、唯一约束、索引设计」的每一个选择,都直接决定上层业务(如批改同步、组合题、变式生成)的实现复杂度。本文拆解 models.py 的十张表设计。
二、核心原理
2.1 声明式基类与工具函数
模型层只有约 370 行,却要支撑 13 个页面、5 个 AI 引擎的数据读写,靠的是三个基础约定:
| 约定 | 实现 | 说明 |
|---|---|---|
| 声明式基类 | class Base(DeclarativeBase) |
SQLAlchemy 2.0 新式声明,替代旧 declarative_base() |
| 全局 UUID 主键 | gen_uuid() |
所有表 id = Column(String, primary_key=True, default=gen_uuid) |
| Unix 时间戳 | now_ts() |
created_at / updated_at 统一用整数秒,排序比较零成本 |
python
class Base(DeclarativeBase):
"""ORM基类"""
pass
def gen_uuid() -> str:
"""生成UUID字符串"""
return str(uuid.uuid4())
def now_ts() -> int:
"""当前Unix时间戳"""
return int(time.time())
源码位置:data/models.py L12-L24
2.2 为什么是字符串 UUID 而不是自增整数
SQLite 的 INTEGER PRIMARY KEY 自带 rowid 优化,性能更好,但本项目坚持 UUID,理由有三:
- 跨库迁移友好 :备份恢复、学生数据转移(
student_transfer_service.py)时,UUID 永不冲突,无需处理自增序列对齐。 - 业务表在 UI 层流通:ID 会被塞进对话框、拖拽数据、临时 JSON,字符串比整数更不容易被误当数值计算。
- 组合题需要「先有子后有父」:插入子题时父题 ID 已由客户端生成,无需等待数据库返回。
代价是索引体积略大、无顺序语义,但桌面单机数据量(万级)下完全可忽略。
2.3 级联删除:外键 + relationship 双管齐下
以 Student 为例,删学生必须连坐删掉他的所有从属数据:
python
subject_configs = relationship("SubjectConfig", back_populates="student", cascade="all, delete-orphan")
questions = relationship("Question", back_populates="student", cascade="all, delete-orphan")
learning_progress = relationship("LearningProgress", back_populates="student", cascade="all, delete-orphan")
student_paper_errors = relationship("StudentPaperError", back_populates="student", cascade="all, delete-orphan")
源码位置:data/models.py L44-L47
这里的「双保险」指:数据库层 ForeignKey("students.id", ondelete="CASCADE")(依赖第 10 篇的 PRAGMA 外键开关)负责物理级联;ORM 层 cascade="all, delete-orphan" 负责对象图级联。二者缺一不可------只靠 ORM 级联,绕过 ORM 的 SQL 删除会留下孤儿数据;只靠外键级联,session.delete(student) 后内存中的子对象仍是脏状态。
三、代码实现
3.1 学生与学科配置
python
class Student(Base):
"""学生档案"""
__tablename__ = "students"
id = Column(String, primary_key=True, default=gen_uuid)
name = Column(String, nullable=False)
grade = Column(String) # 初一/高一/高三
class_name = Column(String) # 班级名称
school = Column(String)
parent_contact = Column(String)
notes = Column(Text) # 私教备注
avatar_path = Column(String) # 头像本地路径
ai_preferences = Column(JSON, default=dict) # AI偏好配置
created_at = Column(Integer, nullable=False, default=now_ts)
updated_at = Column(Integer)
__table_args__ = (
Index("idx_students_name", "name"),
Index("idx_students_grade", "grade"),
Index("idx_students_class", "class_name"),
)
源码位置:data/models.py L27-L53
三个索引对应学生列表页的三个高频筛选维度:按姓名搜、按年级筛、按班级筛。ai_preferences 用 JSON 列存 {tone, example_preference, custom_notes},SQLite 底层是 TEXT,应用层自动序列化/反序列化。
SubjectConfig(表名 student_subject)与之形成一对多:一个学生六个学科(数理化政史地)各一份配置,用 UniqueConstraint("student_id", "subject") 保证「同一学生同一学科只有一条配置」,这是第 13 篇分科配置读写的唯一性基础。
3.2 习题表:组合题的父子自关联
Question 是业务核心表,两个设计亮点值得展开:
python
paper_question_id = Column(String, ForeignKey("paper_questions.id", ondelete="SET NULL"), nullable=True)
parent_question_id = Column(String, ForeignKey("questions.id", ondelete="CASCADE"), nullable=True)
parent = relationship("Question", remote_side=[id],
foreign_keys=[parent_question_id],
back_populates="children")
children = relationship("Question", foreign_keys=[parent_question_id],
back_populates="parent", cascade="all, delete-orphan")
源码位置:data/models.py L101-L116
parent_question_id自关联 :支持「组合题」------一道题干挂多道子题(如大题的 (1)(2)(3) 小问)。自关联必须显式声明remote_side=[id]告诉 SQLAlchemy 哪边是「一」。paper_question_id外联题包母题 :批改同步时,同一母题同一学生只保留一条副本(见 3.4 的唯一索引),ondelete="SET NULL"保证删除题包时复制题不误删。
3.3 AI 分析:一对一 + 教学讲解扩展
python
class AIAnalysis(Base):
"""AI分析结果表(一对一,兼容单题/题包模式)"""
__tablename__ = "ai_analysis"
id = Column(String, primary_key=True, default=gen_uuid)
question_id = Column(String, ForeignKey("questions.id", ondelete="CASCADE"), nullable=True, unique=True)
analysis_type = Column(String) # science/humanities
raw_response = Column(Text) # AI原始返回(调试用)
step_by_step = Column(JSON) # 分步解析 JSON数组
core_concept = Column(Text) # 理科:核心公式;文科:核心概念
...
has_teaching_talk = Column(Integer, default=0) # 是否已生成讲解词
teaching_talk = Column(Text) # 讲解词(自由文本)
teaching_talk_model = Column(String) # 生成讲解词所用模型
teaching_talk_generated_at = Column(Integer) # 生成时间戳
源码位置:data/models.py L149-L175
question_id 带 unique=True 实现逻辑一对一;question_id 可空(nullable=True)是为了兼容「只有题包母题、没有单题副本」的分析场景。raw_response 保留 AI 原始返回用于调试,业务字段(core_concept / knowledge_gap 等)则是从原始返回解析出的结构化结果。讲解词(teaching_talk)设计为独立字段而非复用 raw_response,是「方案 B:AI 分析之外独立生成讲解」的落地。
3.4 题包三表:Paper / PaperQuestion / StudentPaperError
题包模块是三张表的协作:
#mermaid-svg-9I2Vqw8yDMWKQssQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9I2Vqw8yDMWKQssQ .error-icon{fill:#552222;}#mermaid-svg-9I2Vqw8yDMWKQssQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9I2Vqw8yDMWKQssQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9I2Vqw8yDMWKQssQ .marker.cross{stroke:#333333;}#mermaid-svg-9I2Vqw8yDMWKQssQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9I2Vqw8yDMWKQssQ p{margin:0;}#mermaid-svg-9I2Vqw8yDMWKQssQ .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-9I2Vqw8yDMWKQssQ .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-9I2Vqw8yDMWKQssQ .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-9I2Vqw8yDMWKQssQ .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-9I2Vqw8yDMWKQssQ .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-9I2Vqw8yDMWKQssQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-9I2Vqw8yDMWKQssQ .node rect,#mermaid-svg-9I2Vqw8yDMWKQssQ .node circle,#mermaid-svg-9I2Vqw8yDMWKQssQ .node ellipse,#mermaid-svg-9I2Vqw8yDMWKQssQ .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9I2Vqw8yDMWKQssQ .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-9I2Vqw8yDMWKQssQ .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-9I2Vqw8yDMWKQssQ .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9I2Vqw8yDMWKQssQ .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-9I2Vqw8yDMWKQssQ .edgeLabel .label text{fill:#333;}#mermaid-svg-9I2Vqw8yDMWKQssQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 批改
包含
被批改
父子(组合题)
STUDENT
PAPER_ERROR
string
student_id
FK
string
paper_id
FK
string
paper_question_id
FK
string
analysis_id
FK
int
is_mistake
int
reviewed
PAPER
PAPER_QUESTION
StudentPaperError 是核心多对多关联表,唯一约束是防重复批改的关键:
python
__table_args__ = (
Index("idx_spe_student", "student_id"),
Index("idx_spe_paper", "paper_id"),
Index("idx_spe_mistake", "is_mistake"),
# 同一学生同一题包同一母题只允许一条批改记录,防止重复批改累积
Index("uq_spe_student_paper_pq", "student_id", "paper_id", "paper_question_id", unique=True),
)
源码位置:data/models.py L363-L368
这个唯一索引直接呼应第 10 篇 _migrate_schema 里的兜底逻辑:旧库若已存在重复批改数据,建唯一索引会失败,系统记录 warning 并交由专门清理脚本(tests/cleanup_duplicate_paper_errors.py)处理。
3.5 其余三张轻量表
- Variant(变式题) :
difficulty_delta用字符串存-1/0/+1(降/平/升难度),solution_steps用 JSON 存步骤数组,hint字段仅降难度时使用。 - ConversationRecord(追问对话) :每道题独立对话线程,
order_index整数保证消息严格有序,配合(question_id, student_id)复合索引。 - QuestionEngineConfig(追问引擎配置) :通过
config_type(global / subject / student_subject)+target_id实现三级配置继承。
四、设计权衡 / 踩坑记录
1. 组合题与唯一索引的冲突
批改同步唯一键是 (student_id, paper_question_id),但组合题的子题共享同一母题 ID?不会------母题是「题干记录」,子题绑定的是各自 paper_question_id,题干记录本身不参与批改计分(paper_repo 的 get_by_paper_leaves 专门过滤题干,见第 15 篇)。
2. 一处值得注意的隐患:重复的 __table_args__
源码中 PaperQuestion 类体内出现了两次 __table_args__(models.py L324-L328 与 L334-L337)。Python 类定义中同名属性后定义者覆盖先定义者,因此实际生效的是第二个------意味着 idx_pq_parent 索引(组合题父题查询加速)不会真正创建。当前数据量下不影响正确性,但若组合题量大,应按最终生效版本补齐该索引。
3. uselist=False 的取舍
Question.ai_analysis 声明为 uselist=False(一对一)。分析结果在模型层是唯一键约束,ORM 层一对一声明后,question.ai_analysis 直接返回对象而非列表,UI 层少一层 [0] 解包。
4. JSON 列的默认值陷阱
Column(JSON, default=dict) 传入的是工厂 而非实例,保证每个新对象拿到独立字典;若误写 default={},所有新对象的默认值将共享同一引用,改一个全变。
五、小结
- 十张表统一采用「字符串 UUID 主键 + Unix 时间戳 + JSON 灵活列」的建模约定,兼顾跨库迁移与灵活扩展。
- 级联删除由「外键 ondelete + ORM cascade」双保险实现,防止孤儿数据。
- 组合题通过
Question.parent_question_id自关联建模,题包-学生关联由StudentPaperError承载并加唯一索引防重复。 - 分析表通过
question_id UNIQUE实现一对一,并用paper_question_id兼容题包模式。 - 建模时的每个唯一约束(学科配置、知识点进度、批改记录)都在为上层去重逻辑兜底。