student_store / question_store:学生与习题的状态中枢
一句话摘要:学生是档案主体、习题是分析对象,
StudentStore与QuestionStore各自管好"内存缓存 + CRUD + Signal 广播",一个专注档案与分科配置,一个连图片清理、组合题、知识点替换这些细节都包办了,是整个 AI 工作台的数据源头。
一、引言
智学文枢的业务模型有一个清晰的"主从结构":学生(Student)是档案主体,习题(Question)挂靠在学生名下。几乎每个页面都在做同一件事——“选一个学生,看他的习题,对某道题做分析/讲解/变式/报告”。
所以第一性问题来了:这么多页面共享的学生和习题数据,该由谁来"说了算"?
如果把读库逻辑散落在十几个页面里,任何一个页面新增学生后,其他页面都得被"手动通知",漏一个就出现幽灵数据;更糟的是,习题还涉及图片文件、组合题父子关系、知识点标签这些"连带资产",删除时漏清理一项,数据目录里就会堆满孤儿文件。
答案是收敛:StudentStore和QuestionStore作为两个全局单例,成为学生与习题的唯一数据入口。所有页面只做三件事——调用 Store 的方法、订阅 Store 的信号、读取 Store 的缓存。
二、核心原理
2.1 职责边界
| Store | 数据主体 | 连带资产 | 典型页面 |
|---|---|---|---|
| StudentStore | 学生 + 分科配置(SubjectConfig) | 头像图片文件 | 学生档案页、看板、学生弹窗 |
| QuestionStore | 习题 + 知识点(QuestionKnowledge) | 习题图片文件、组合题子题 | 习题管理页、AI 分析页等 8 个页面 |
2.2 两条筛选策略:SQL 过滤 vs 内存过滤
QuestionStore.filter_questions展示了两种过滤策略的混用:
- 有 SQL 捷径(按学科):调
repo.get_by_subject(student_id, subject),让 SQLite 干活; - 无 SQL 捷径(错题标记、学科+错题组合):先
repo.get_by_student全量拉取,再在内存里用列表推导过滤。
取舍逻辑很简单:题目表数据量在桌面应用场景下通常几百到几千行,内存过滤毫秒级完成,换来的是 Repository 接口更精简、不用为每个组合条件写 SQL。
2.3 组合题:一道父题 + N 道子题
组合题(题干 + 多子问)是习题模型里最复杂的结构。create_group_question一次写入父题和全部子题,子题共用题干图片、持有parent_question_id;删除父题时,缓存过滤会连同parent_question_id == question_id的子题一起清掉。这是"连带资产"处理的第一个实例。
三、代码实现
3.1 删除学生:连头像图片一起清理
delete_student是 Store 处理"连带资产"的示范——先删数据库记录,再清缓存、清头像文件、广播:
defdelete_student(self,student_id:str)->bool:"""删除学生(含头像图片清理与删除广播) 仅删除数据库记录并广播;由界面侧随后调用 load_students() 触发全局刷新。 """repo=StudentRepository()try:student=repo.get_by_id(student_id)success=repo.delete(student_id)ifsuccess:self._cleanup_avatar(student)self._remove_from_cache(student_id)self.student_deleted.emit(student_id)self.data_changed.emit()returnsuccessfinally:repo.close()源码位置:ui/stores/student_store.py L107-L122
注意 docstring 里的约定:Store 只删库并广播,界面侧随后主动调load_students()做全局刷新。这是信号驱动架构里的一个默契——Store 负责"数据权威",页面负责"刷新编排"。
3.2 外部删除同步:notify_deleted
数据管理页可以直接操作数据库删除学生,此时 Store 不能假装不知道。notify_deleted是外部入口:
defnotify_deleted(self,student_id:str):"""外部入口(如数据管理页)删除学生后,同步缓存并统一广播删除事件。 数据已由外部删除,此处不再操作数据库与图片。 """self._remove_from_cache(student_id)self.student_deleted.emit(student_id)self.data_changed.emit()源码位置:ui/stores/student_store.py L124-L130
它的存在说明:Store 的"唯一入口"是设计目标,但现实中总有绕过它的路径(批量导入、数据管理页)。notify_deleted提供了一条事后同步的后门——不动库,只同步内存缓存并广播,把"数据已经变了"的通知职责补回来。
3.3 习题筛选:SQL + 内存混合
deffilter_questions(self,student_id:str,subject:str=None,is_mistake:int=None):"""按条件筛选习题"""self._current_filters["student_id"]=student_id self._current_filters["subject"]=subject self._current_filters["is_mistake"]=is_mistake repo=QuestionRepository()try:ifsubjectandis_mistakeisnotNone:# 按学科+错题筛选all_q=repo.get_by_student(student_id)self._questions=[qforqinall_qifq.subject==subjectandq.is_mistake==is_mistake]elifsubject:self._questions=repo.get_by_subject(student_id,subject)elifis_mistakeisnotNone:all_q=repo.get_by_student(student_id)self._questions=[qforqinall_qifq.is_mistake==is_mistake]else:self._questions=repo.get_by_student(student_id)self.questions_loaded.emit(self._questions)finally:repo.close()源码位置:ui/stores/question_store.py L45-L70
四个分支对应四种筛选组合,逻辑直接、无 SQL 拼接注入风险;_current_filters记录当前筛选状态,便于后续"增删后保持筛选结果一致"。
3.4 知识点整体替换:靠 ORM 级联
知识点不写"删旧加新"的循环,而是直接重赋值集合,靠 ORM 的cascade=delete-orphan自动完成替换:
defsave_knowledge_points(self,question_id:str,knowledge_points:List[str]):"""整体替换某习题的知识点标签(先删旧再写新)。 知识点存于 QuestionKnowledge 关联表,利用 Question.knowledge_points 的 cascade=delete-orphan 关系,重赋值即实现替换;空串/None 忽略。 """valid=[(kor"").strip()forkin(knowledge_pointsor[])if(kor"").strip()]repo=QuestionRepository()try:q=repo.session.get(Question,question_id)ifnotq:returnq.knowledge_points=[QuestionKnowledge(question_id=question_id,knowledge_point=k,confidence=0.8,is_primary=1,)forkinvalid]repo.session.commit()# 空列表也要提交,以删除旧知识点finally:repo.close()源码位置:ui/stores/question_store.py L289-L312
注释里特别强调"空列表也要提交"——否则"清空全部知识点"的操作会被 ORM 当成"没改"而跳过,旧标签残留。
四、设计权衡 / 踩坑记录
4.1 坑:删除习题忘删图片 → 孤儿文件
习题图片存在数据目录,数据库字段只是路径。若删除只清行不删文件,日积月累目录全是无用图片。delete_question的解法是"先删文件再删行":
ifquestion.question_image_path:delete_image(question.question_image_path)success=repo.delete(question_id)源码位置:ui/stores/question_store.py L235-L237
与头像清理(_cleanup_avatar)同一思路:连带资产与主记录同生命周期。
4.2 权衡:create 后同步插入缓存头部
create_question里self._questions.insert(0, question),新题排在最前,符合"最新录入在最上面"的产品直觉,也避免了一次全量重查。代价是当列表处于筛选状态时,新插入的题可能"不符合当前筛选"却仍然显示——但题目流水的场景中,录入即处理,这个瑕疵被产品流程天然掩盖了。
4.3 权衡:update 走"原地替换"而非全量刷新
update_question更新后只替换内存中对应下标元素:
fori,qinenumerate(self._questions):ifq.id==question_id:self._questions[i]=questionbreak源码位置:ui/stores/question_store.py L218-L221
比全量重查省一次 IO,但必须保证"内存对象与库中对象一致"——如果别处(如阶段推进)也改了同一条记录,这里替换的可能是旧版本。所以refresh_question_status特意重查再替换,两个方法各司其职:普通编辑用原地替换,阶段推进用重查刷新。
五、小结
StudentStore管档案 + 分科配置,QuestionStore管习题 + 知识点,二者都是"内存缓存 + Repository 代理 + Signal 广播"的标准 Store;- 删除操作必须处理连带资产:头像文件、习题图片、组合题子题,避免孤儿文件与幽灵数据;
- 筛选策略按需选择 SQL 过滤或内存过滤,数据量小的桌面应用完全够用;
- 知识点替换依赖 ORM 级联,注意"空列表也要提交"的细节;
notify_deleted证明:即使有外部绕行路径,Store 也要提供事后同步入口,把"通知"职责补全。