智学文枢:数据管理服务

数据管理服务(data_management_service.py)

一句话摘要:data_management_service.py 是"系统设置-数据管理"页的后端大脑:负责统计数据目录占用、清理 AI 调试残留、扫描并删除孤儿文件、按日期回看日志、彻底删除学生(含图片物理清理),并把备份/恢复委托给独立的 backup_manager。

一、引言

桌面应用跑久了,数据目录会悄悄长出三类"赘肉":

  1. AI 调试残留 :每次大模型返回的原始 JSON(raw_response)都完整存进 ai_analysis 表,一条几 KB 到几十 KB,几百道题就能吃掉几十 MB------但它只服务于调试,用户根本不需要。
  2. 孤儿文件:学生被删了、题目被删了、报告被重新生成了,但对应的图片、PDF 文件还在磁盘上,数据库里已经没有引用它们。
  3. 滚动日志与导出文件:按天滚动的日志、导出的报告和备份,时间长了只增不减。

从第一性原理看,数据管理是一个"引用完整性 "问题:数据库是"真相",磁盘文件只是"真相的物化"。只要保证"磁盘上每一个文件都有数据库引用,数据库里每一条记录都有对应文件",数据目录就是干净的。data_management_service.py 把这一原则翻译成三个具体能力:

  • 统计:让用户先看到"哪里占了多少";
  • 扫描与清理:以数据库引用集合为基准,找出磁盘上没有引用的文件并删除;
  • 级联删除:删一个学生时,先收集它引用的图片,删记录后再物理删文件,绝不留"僵尸引用"。

这个服务还有一个工程上的讲究:所有破坏性操作都写审计日志 (audit_logger),谁在什么时候清了什么、删了什么,全部留痕。

二、核心原理

2.1 孤儿文件的判定算法

清理孤儿文件的本质是集合差运算:

复制代码
孤儿文件 = {磁盘上的文件} - {数据库引用的文件}

实现分三步:第一步从 6 张表的图片/PDF 列(students.avatar_path、questions.question_image_path、variants.variant_image_path、papers 的三列、paper_questions.question_image_path)收集全部被引用路径,统一转成绝对路径归一化集合;第二步 os.walk 遍历 images/pdf 两个根目录,逐个文件比对;第三步删除未命中集合的文件,再清理因此产生的空目录。

路径归一化用 _norm(os.path.normcase + realpath),这是整个算法正确性的前提------如果路径大小写、符号链接解析不一致,同一个文件可能被误判成孤儿。

2.2 删除学生的"先收集、后删除"顺序

delete_student 的时序设计很关键:

  1. 先收集该学生所有引用的图片路径(头像、题目图、变式图),存在内存集合里;
  2. 统计将被删除的业务数据量(习题、对话、错题、分科配置);
  3. session.delete(student) + commit,依赖 ORM 级联删除业务记录;
  4. 再物理删除图片文件。

顺序不能反:如果先删文件、删记录时抛异常回滚,文件就永久丢了;如果先删记录再收集引用,引用已经没了。这个"先取证、后行刑"的顺序,保证任何一步失败都不会造成数据与文件的永久错位。
#mermaid-svg-LjvgACk4GkKuGp6P{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-LjvgACk4GkKuGp6P .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LjvgACk4GkKuGp6P .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LjvgACk4GkKuGp6P .error-icon{fill:#552222;}#mermaid-svg-LjvgACk4GkKuGp6P .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LjvgACk4GkKuGp6P .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LjvgACk4GkKuGp6P .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LjvgACk4GkKuGp6P .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LjvgACk4GkKuGp6P .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LjvgACk4GkKuGp6P .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LjvgACk4GkKuGp6P .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LjvgACk4GkKuGp6P .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LjvgACk4GkKuGp6P .marker.cross{stroke:#333333;}#mermaid-svg-LjvgACk4GkKuGp6P svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LjvgACk4GkKuGp6P p{margin:0;}#mermaid-svg-LjvgACk4GkKuGp6P .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-LjvgACk4GkKuGp6P .cluster-label text{fill:#333;}#mermaid-svg-LjvgACk4GkKuGp6P .cluster-label span{color:#333;}#mermaid-svg-LjvgACk4GkKuGp6P .cluster-label span p{background-color:transparent;}#mermaid-svg-LjvgACk4GkKuGp6P .label text,#mermaid-svg-LjvgACk4GkKuGp6P span{fill:#333;color:#333;}#mermaid-svg-LjvgACk4GkKuGp6P .node rect,#mermaid-svg-LjvgACk4GkKuGp6P .node circle,#mermaid-svg-LjvgACk4GkKuGp6P .node ellipse,#mermaid-svg-LjvgACk4GkKuGp6P .node polygon,#mermaid-svg-LjvgACk4GkKuGp6P .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LjvgACk4GkKuGp6P .rough-node .label text,#mermaid-svg-LjvgACk4GkKuGp6P .node .label text,#mermaid-svg-LjvgACk4GkKuGp6P .image-shape .label,#mermaid-svg-LjvgACk4GkKuGp6P .icon-shape .label{text-anchor:middle;}#mermaid-svg-LjvgACk4GkKuGp6P .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LjvgACk4GkKuGp6P .rough-node .label,#mermaid-svg-LjvgACk4GkKuGp6P .node .label,#mermaid-svg-LjvgACk4GkKuGp6P .image-shape .label,#mermaid-svg-LjvgACk4GkKuGp6P .icon-shape .label{text-align:center;}#mermaid-svg-LjvgACk4GkKuGp6P .node.clickable{cursor:pointer;}#mermaid-svg-LjvgACk4GkKuGp6P .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LjvgACk4GkKuGp6P .arrowheadPath{fill:#333333;}#mermaid-svg-LjvgACk4GkKuGp6P .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LjvgACk4GkKuGp6P .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LjvgACk4GkKuGp6P .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LjvgACk4GkKuGp6P .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LjvgACk4GkKuGp6P .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LjvgACk4GkKuGp6P .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LjvgACk4GkKuGp6P .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LjvgACk4GkKuGp6P .cluster text{fill:#333;}#mermaid-svg-LjvgACk4GkKuGp6P .cluster span{color:#333;}#mermaid-svg-LjvgACk4GkKuGp6P div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-LjvgACk4GkKuGp6P .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LjvgACk4GkKuGp6P rect.text{fill:none;stroke-width:0;}#mermaid-svg-LjvgACk4GkKuGp6P .icon-shape,#mermaid-svg-LjvgACk4GkKuGp6P .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LjvgACk4GkKuGp6P .icon-shape p,#mermaid-svg-LjvgACk4GkKuGp6P .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LjvgACk4GkKuGp6P .icon-shape .label rect,#mermaid-svg-LjvgACk4GkKuGp6P .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LjvgACk4GkKuGp6P .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LjvgACk4GkKuGp6P .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LjvgACk4GkKuGp6P :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
数据管理页
get_data_overview 统计
scan_orphan_files 扫描孤儿
文件在引用集合?
标记孤儿
跳过
用户确认后 clean_orphan_files
删除文件 + 清理空目录 + 写审计日志
delete_student
收集图片路径
统计业务数据量
ORM级联删记录
物理删图片 + 审计日志

三、代码实现

3.1 数据概览统计

get_data_overview(源码位置:services/data_management_service.py L54-L78)用原生 SQL 快速数清五张主表的记录数,用 _dir_size 递归统计各目录占用:

python 复制代码
def get_data_overview():
    """统计数据目录各分区大小与业务记录数"""
    session = db.get_session()
    try:
        counts = {
            "student": session.scalar(text("SELECT COUNT(*) FROM students")) or 0,
            "question": session.scalar(text("SELECT COUNT(*) FROM questions")) or 0,
            "paper": session.scalar(text("SELECT COUNT(*) FROM papers")) or 0,
            "analysis": session.scalar(text("SELECT COUNT(*) FROM ai_analysis")) or 0,
            "conversation": session.scalar(text("SELECT COUNT(*) FROM conversation_records")) or 0,
        }
    finally:
        session.close()
    return {
        "dirs": {
            "数据库": _dir_size(os.path.join(APP_DATA_DIR, "database")),
            "图片": _dir_size(IMAGES_DIR),
            "PDF": _dir_size(PDF_DIR),
            "报告": _dir_size(REPORTS_DIR),
            "日志": _dir_size(LOGS_DIR),
            "导出": _dir_size(EXPORTS_DIR),
        },
        "counts": counts,
        "data_dir": APP_DATA_DIR,
    }

session.scalar(text(...)) 直接执行聚合 SQL,比 ORM 查询轻得多;finally: session.close() 保证连接必然归还。

3.2 孤儿文件扫描

scan_orphan_files(源码位置:services/data_management_service.py L161-L186):

python 复制代码
def scan_orphan_files(on_progress=None):
    """扫描孤立文件,返回 (引用数, [(abs_path, size), ...])"""
    session = db.get_session()
    try:
        refs = _collect_refs(session)
    finally:
        session.close()

    orphans = []
    total = 0
    for root in _ORPHAN_ROOTS:
        if not os.path.isdir(root):
            continue
        for dirpath, _dirnames, filenames in os.walk(root):
            for fn in filenames:
                fp = os.path.join(dirpath, fn)
                try:
                    size = os.path.getsize(fp)
                except OSError:
                    continue
                total += 1
                if _norm(fp) not in refs:
                    orphans.append((fp, size))
                if on_progress:
                    on_progress(total, 0)
    return len(refs), orphans

_collect_refs 从六列收集引用路径(源码位置:services/data_management_service.py L129-L158),全部经过 _abs_for_ref 转绝对路径。on_progress 回调让 UI 能实时刷新"已扫描 N 个文件"。

3.3 彻底删除学生

delete_student(源码位置:services/data_management_service.py L304-L384)节选:

python 复制代码
def delete_student(student_id, student_name=""):
    """彻底删除一个学生及其全部数据与相关图片文件,返回统计 dict。"""
    session = db.get_session()
    try:
        student = session.get(Student, student_id)
        if not student:
            return {"deleted": False, "reason": "学生不存在"}

        # 收集该学生关联的图片文件(删除记录前)
        img_paths = set()
        if student.avatar_path:
            full = _abs_for_ref(student.avatar_path)
            if full:
                img_paths.add(full)
        q_images = session.execute(text(
            "SELECT question_image_path FROM questions WHERE student_id = :sid"
        ), {"sid": student_id}).all()
        for (p,) in q_images:
            full = _abs_for_ref(p)
            if full:
                img_paths.add(full)
        v_images = session.execute(text(
            "SELECT v.variant_image_path FROM variants v "
            "JOIN questions q ON q.id = v.original_question_id "
            "WHERE q.student_id = :sid"
        ), {"sid": student_id}).all()
        for (p,) in v_images:
            full = _abs_for_ref(p)
            if full:
                img_paths.add(full)

        counts = { ... }  # 统计将删除的业务数据
        session.delete(student)
        session.commit()

        # 物理删除图片
        removed_images = 0; freed = 0
        for fp in img_paths:
            try:
                if os.path.isfile(fp):
                    size = os.path.getsize(fp)
                    os.remove(fp)
                    removed_images += 1
                    freed += size
            except OSError:
                pass
        _audit_warning(f"删除学生「{student_name or student.name}」:习题 {counts['questions']}、...")
        return {"deleted": True, "name": student_name or student.name,
                "counts": counts, "removed_images": removed_images,
                "freed_images_bytes": freed}
    finally:
        session.close()

v_images 那条 SQL 是变式图关联查询:变式表没有直接的 student_id,要通过 original_question_id JOIN 到 questions 才能定位学生的变式图片------这是"跨表取证"的典型场景。

3.4 清理 AI 调试残留

clear_raw_response(源码位置:services/data_management_service.py L107-L124)先统计再清空:

python 复制代码
def clear_raw_response():
    """清空 ai_analysis.raw_response,返回 (条数, 释放字节)"""
    session = db.get_session()
    try:
        rows = session.execute(text(
            "SELECT raw_response FROM ai_analysis "
            "WHERE raw_response IS NOT NULL AND raw_response != ''"
        )).all()
        total = sum(len(r[0].encode("utf-8")) for r in rows)
        cnt = session.execute(text(
            "UPDATE ai_analysis SET raw_response = NULL "
            "WHERE raw_response IS NOT NULL AND raw_response != ''"
        )).rowcount
        session.commit()
        _audit_info(f"清理AI调试残留:{cnt} 条,释放空间 {total} 字节")
        return cnt, total
    finally:
        session.close()

先 SELECT 统计、再 UPDATE 清空、最后写审计日志------用户能看到"清理 N 条、释放 X 字节"的真实反馈。raw_response 只影响调试,清空不破坏任何展示字段(核心概念、分步解析等都另有列)。

四、设计权衡 / 踩坑记录

权衡 1:为什么备份/恢复不在这里实现? create_backup / restore_backup / read_backup_info 全部是委托 给 data.backup_manager(源码位置:services/data_management_service.py L389-L410)。原因是备份逻辑(zip 打包、校验、安全目录)是独立且复杂的领域,放 backup_manager 里可被其他入口复用;数据管理服务只负责加审计日志和 UI 回调转发。一层薄委托比代码复制更符合单一职责。

踩坑 1:路径归一化不一致导致误删。 早期直接拿数据库路径字符串与 os.walk 结果比较,Windows 下 C:\Users\X\... 与 c:\users\x\... 的大小写差异就让同一个文件被判成孤儿,差点误删用户图片。修复方案是 _norm = normcase(realpath()) 双保险,且所有比较统一走 _norm。

踩坑 2:删除学生后图片悬空。 早期版本直接 session.delete(student) 靠外键级联删业务记录,但图片文件没人管。后来加"先收集、后删除"流程。特别注意:题包(papers)是共享资源,删除学生不能连坐题包 ------文档注释里明确写了"保留共享的题包 papers 本身",这也是 delete_student 不用 DELETE FROM papers WHERE student_id=... 的原因。

踩坑 3:孤儿清理后的空目录。 删除图片后,images/2026-08/ 这类按日期建的子目录可能变空。clean_orphan_files 末尾用 os.walk(topdown=False) 自底向上删空目录(源码位置:services/data_management_service.py L206-L213),否则目录垃圾同样堆积。

五、小结

  • 数据管理的本质是引用完整性:磁盘文件必须有数据库引用,数据库记录必须有文件支撑。
  • 孤儿文件 = 磁盘集合 − 引用集合,路径归一化(_norm)是判定正确的生命线。
  • 删除学生采用"先收集图片引用、再删记录、最后物理删文件"的顺序,配合审计日志,破坏性操作全程可追溯。
  • 备份/恢复委托给 backup_manager,数据管理服务专注统计、清理、查看、删除四件事,边界清晰。
相关推荐
日日大王学编程1 小时前
DeepSeekHarness开源Agent底座详解
python·开源·deep learning
Eider19981 小时前
python基础语法之并发基础练习
开发语言·python
长和信泰光伏储能1 小时前
探索绿色能源:光伏发电的渠道与应用
python·能源
郝学胜-神的一滴1 小时前
AI 编程智能体 20:技术栈选型与架构落地全攻略
人工智能·python·架构·langchain·1024程序员节·智能体
云和数据.ChenGuang1 小时前
python和java的try
java·开发语言·python
小小张说故事2 小时前
Python 的 list 为什么能装下任何类型?拆开 PyObject* 数组,以及它让你多付了多少内存
后端·python
chi11302 小时前
【无标题】
前端·javascript·python
编程见习者2 小时前
Deep Agents介绍
人工智能·python·langchain
梦幻精灵_cq2 小时前
《SGR修行之路》——二○二一五二〇,初识终端点红裳
python·生活