
去年帮一所高校做教师职称评定管理系统时技术栈选了 Django Flask 的组合项目从需求梳理到部署上线折腾了两个多月踩了不少坑也沉淀了不少值得分享的经验。这套系统本质上是典型的“信息管理系统 业务流程引擎”混合体核心价值在于把职称申报、材料审核、专家评审、结果公示这条长链路线上化。如果你正在用 Python 做 Web 开发或者手头恰好有类似的内部管理系统需求这篇文章应该能给你一套可以直接抄作业的完整方案。1. 项目全貌与需求拆解职称评定到底在评什么1.1 高校职称评定的业务痛点高校教师职称评定表面上是一个“提交材料、专家打分、出结果”的流程实际跑起来非常重。一线教师需要整理近几年的教学工作量、科研成果、论文、项目、获奖、社会服务等材料每一类都要有佐证文件人事处要核验真实性、查重、对照评审条件做资格审查校外专家要在线看材料、打分、写意见最后还要经过公示、申诉、复议。这些环节如果靠纸质材料加微信群传递效率低不说还容易出错。做这套系统之前我给人事处做过一轮调研他们最头疼的问题有三个材料格式五花八门PDF、Word、Excel、扫描件、压缩包全都有光整理命名规则就要耗费几天。评审专家分散在不同城市线下评审要协调时间、场所成本很高。数据没有沉淀每年申报信息散落在表格里根本没法做趋势分析和辅助决策。所以系统设计的第一原则是“流程全覆盖数据可复用”。从教师申报到最终发文所有环节都要在系统里跑通并且每一年产生的数据都要能对比、能追溯。1.2 用户角色与核心流程系统涉及四类用户申报教师、院系教务员、人事处管理员、评审专家。前三类在同一个 Web 应用里操作评审专家可能来自校外通常只开放一个独立的评审入口。核心流程如下教师填写申报书 → 上传佐证材料 → 提交到院系 → 教务员初审材料是否齐全、格式是否正确 → 退回或通过 → 人事处复审资格审查、量化计分 → 随机分配专家 → 专家在线评审打分 → 系统汇总成绩 → 人事处确认结果 → 公示 → 归档每个状态节点都要有操作日志谁看了、谁改了、什么时候改的都要留痕。这一点在职称评审场景里特别重要因为涉及公平公正的敏感话题系统必须有完整的审计能力。2. 技术选型思路为什么是 Django Flask 双框架2.1 放弃“单框架”的原因很多同事问我为什么不直接用 Django 一个框架搞定坦率说纯 Django 完全能做但做出来的系统会有点“重”。Django 自带 ORM、Admin、Form、模板、中间件非常适合做业务管理系统这种 CRUD 密集型的后端。但职称评审系统里有两个特殊需求用 Flask 实现更轻巧一是独立的专家评审接口二是文件预览服务。专家评审接口需要提供简单的评分提交 API逻辑不复杂用 Django 写也不是不行但这部分通常要和主系统解耦单独部署避免评审期间主系统发布更新影响外部专家访问。Flask 足够轻一个文件就能跑起来部署在独立端口只暴露必要的路由安全边界更清晰。文件预览服务则是为了兼容 Word、PDF、Excel 等格式在线预览。如果用 Django 直接做需要集成一大堆中间件而且预览逻辑和业务逻辑混在一起很容易让主工程变得臃肿。拆成一个 Flask 小服务接收文件路径参数返回 PDF 预览流既隔离了故障也方便后续换组件。2.2 Django 与 Flask 的职责边界这套双框架的结构可以这样描述Django 是“主动脉”管理所有的业务对象、流程状态、权限关系Flask 是两个“毛细血管”分别处理独立的评审 API 和文件预览。职责框架理由用户认证、角色权限、申报流程、审核流转、数据统计DjangoORM、Admin、认证体系成熟适合复杂业务模型专家评分对外 APIFlask逻辑简单独立部署不过多依赖主工程文件在线预览转换Flask容易隔离资源消耗避免预览任务拖垮主应用通信方式很简单Django 需要调用 Flask 接口时通过requests发 HTTP 请求Flask 需要读取数据库时不直接连 Django 的数据库而是通过 Django 提供的内部 API如/internal/document_info获取只读信息。这样两个服务在物理层面是隔离的逻辑层面又保持关联。2.3 为什么不用 FastAPI搜索热词里有人拿 Flask 和 FastAPI 比较我也考虑过 FastAPI。FastAPI 的异步支持和自动文档确实诱人但团队更熟悉 Flask 的生态而且评审 API 本身没有高并发需求——同期评审专家通常就三五十人每人提交几份分数量级很小。用 Flask 足够没必要为了技术亮点引入异步范式的学习成本。如果将来要做大规模并发材料上传可以在 Flask 前面加一层消息队列但不影响本次框架决策。3. 核心模块设计与数据库建模把复杂业务落成表结构3.1 应用拆分与模型设计Django 工程里我按业务域拆分了三个应用user、application、review。user负责用户、角色、院系关系。application负责申报书、佐证材料、审核记录。review负责专家评审、评分汇总、结果公示。模型设计里最核心的是申报实体。职称评定里一个教师在某一年度只能提交一份申报书但下一年的申报书和今年是独立的所以不能简单地把申报信息挂在用户表上。我设计了一张Application表每年每条记录对应一个教师的申报class Application(models.Model): teacher models.ForeignKey(User, on_deletemodels.CASCADE, related_nameapplications) academic_year models.CharField(max_length9) # 如 2024-2025 title models.CharField(max_length50) # 申请职称讲师、副教授、教授 status models.CharField(max_length20, defaultdraft) score_total models.FloatField(default0.0) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) class Meta: unique_together (teacher, academic_year)unique_together保证同一位教师在同一年度只能有一条申报记录从数据库层面堵住了重复提交的可能。这个约束是在实际开发中很容易被忽略的地方很多团队只在表单层做校验结果并发请求下仍然能插入重复数据。3.2 材料表的设计与文件命名规范佐证材料是职称评定系统里最容易出性能问题的部分。一个教授申报书可能带 50 多个附件单个 PDF 几十兆如果全部塞进数据库数据库会很快膨胀。我用的是“数据库存元信息文件系统存实体”的方案class Material(models.Model): application models.ForeignKey(Application, on_deletemodels.CASCADE, related_namematerials) category models.CharField(max_length30) # 论文、项目、获奖... file_name models.CharField(max_length255) file_path models.CharField(max_length500) file_size models.BigIntegerField() uploader models.ForeignKey(User, on_deletemodels.SET_NULL, nullTrue) uploaded_at models.DateTimeField(auto_now_addTrue)file_path是相对路径实际文件存在media/目录下命名规则为academic_year/user_id/uuid_suffix.extension。用 UUID 作为文件主名能避免上传同名文件互相覆盖这在多人上传场景里是必备的。Database 只存字符串路径不会产生 BLOB 垃圾。3.3 评审模型的二元结构评审专家和申报材料的关系是多对多但一个专家对一份申报书只能打一次分所以中间模型设计成独立表class ReviewScore(models.Model): review models.ForeignKey(Review, on_deletemodels.CASCADE) application models.ForeignKey(Application, on_deletemodels.CASCADE) expert models.ForeignKey(User, on_deletemodels.CASCADE) score models.FloatField(nullTrue, blankTrue) comment models.TextField(blankTrue) submitted_at models.DateTimeField(nullTrue, blankTrue) class Meta: unique_together (review, application, expert)这里的review可以理解为一次评审活动比如“2024年度高级职称评审”。每个活动里管理员从专家库里选人系统自动生成若干条ReviewScore记录状态为未提交。专家只能看到分配给自己的材料提交后不可修改。这种建模方式让“分配—评审—汇总”变得非常直观。4. 实操实现与踩坑记录从登录到结果的完整链路4.1 基于 Django 权限框架的访问控制Django 自带的Group和Permission能覆盖大部分权限需求但职称评审的权限粒度更细比如“院系教务员只能看到本院系申报材料”“专家只能看到分配给自己的评审任务”。我选择在视图层手动校验角色而不是完全依赖装饰器def application_list(request): if request.user.role teacher: apps Application.objects.filter(teacherrequest.user) elif request.user.role college: apps Application.objects.filter(teacher__collegerequest.user.college) elif request.user.role hr: apps Application.objects.all() else: apps Application.objects.none() return render(request, application/list.html, {apps: apps})这样写的好处是逻辑直白容易依据学校的具体组织结构调整。坏处是每个视图都要手写一遍角色判断后期统一加日志时容易漏。后来我封装了一个get_visible_applications(user)函数所有视图调它才彻底解决权限判断重复的问题。如果项目规模更大可以考虑用 Django 的ModelBackend自定义权限但高校内部系统用户量不大没必要过度设计。4.2 教师申报材料上传前端分片与后端限制材料上传是稳定性的重灾区。最初版本直接用表单提交结果一位老师上传一个 80 多 MB 的视频材料时Nginx 直接 413。后来做了两件事解决第一前端用plupload或直接在原生 XHR 里做分片上传每片 4MB全部传完再通知后端合并。第二后端在 Django 里加文件大小检查同时设置DATA_UPLOAD_MAX_MEMORY_SIZE# settings.py DATA_UPLOAD_MAX_MEMORY_SIZE 5242880 # 5MB其余走文件流 FILE_UPLOAD_MAX_MEMORY_SIZE 5242880实际做法是分片上传到临时目录后端每收到一片就写入临时文件最后用shutil.copyfileobj合并。这里有个坑如果直接用request.FILES接收所有分片内存占用会很高。我改成接收chunk文件对象写入磁盘再记录分片序号最后校验总大小和分片完整性。分片上传的整体实现不复杂但涉及并发写文件需要给每个上传会话建唯一目录否则不同用户的分片会混在一起。4.3 专家评审 Flask API 与 JWT 鉴权专家评审部分用 Flask 实现接口很简单只有两个获取待评材料、提交评分。对外服务不能使用 Django 的 Session 机制因为专家可能来自不同的终端环境我用 JWT 做无状态鉴权。Flask 端在收到请求头里的Authorization: Bearer token后通过依赖一个共享密钥解码。这个密钥和 Django 端的SECRET_KEY无关是单独生成的存环境变量里。评审分数提交后Flask 直接调 Django 的内部 API 把数据写回主库不行这样两边的耦合度太高了。我的方案是 Flask 只接收数据、暂存到自己的 SQLite 表然后通过 Django 的一个认证回调接口用内部服务账号来拉取。整体延迟可以接受因为评审提交频率不高。如果你希望实时性更好可以在 Django 端开放一个带signature签名校验的写接口Flask 调用它。4.4 文件预览服务的实现细节文件预览用的 Flask 加mammothWord 转 HTML和pdf2htmlPDF 转 HTML。实际经验是不要试图在浏览器里直接渲染 Word兼容性太差。技术方案是上传时用libreoffice在后台把各类文档统一转成 PDF预览时直接输出 PDF 流。这个转换任务一般很耗时我用 Celery 异步队列处理转完后更新材料表里的preview_path字段。很多团队在开发时忽略环境依赖到部署时才意识到服务器上没有装 LibreOffice导致转换任务全部失败。建议开发一开始就在requirements.txt之外把系统级依赖写进部署文档sudo apt install libreoffice。另外定时清理转换产生的临时文件也很重要否则服务器磁盘会被 PDF 占满。5. 部署上线与性能优化内网系统也要考虑并发5.1 Nginx Gunicorn Django 与 Nginx Flask 的混合部署整个系统部署在同一台 8 核 16G 的服务器上使用 Nginx 作为统一入口按路径分发请求/开头的请求走 Django。/api/review/开头的请求走 Flask 评审服务。/preview/开头的请求走 Flask 预览服务。Nginx 配置里最关键的是client_max_body_size。默认值只有 1MB上传材料完全不够必须显式调大server { listen 80; server_name review.example.edu.cn; client_max_body_size 200M; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /api/review/ { proxy_pass http://127.0.0.1:5001; proxy_set_header Host $host; } location /preview/ { proxy_pass http://127.0.0.1:5002; proxy_set_header Host $host; } }注意proxy_pass后面如果带 URI比如http://127.0.0.1:5001/会把原来的路径替换掉不带 URI 则保留完整路径。这里我保留了完整路径Flask 应用里也按/api/review/前缀路由避免前端请求混乱。5.2 Django ORM 查询优化实战N1 问题与预加载评审列表页是典型的慢查询场景。页面需要展示每个申请人的姓名、院系、申请职称、材料数量、平均分如果直接遍历Application再访问关联字段会产生大量 SQL响应时间可能从 200ms 涨到 3 秒以上。解决办法是使用select_related和prefetch_relatedapps (Application.objects .select_related(teacher, teacher__college) .prefetch_related(materials) .filter(statussubmitted))select_related用于 ForeignKey 类型可以 JOIN 查询一次取到teacher和collegeprefetch_related用于反向多对多会先查Application列表再查Material列表做内存关联。这个优化在数据量只有几百条时可能不明显但到几千条、每份材料几十个附件时性能差距是数量级的。另外不要在模板里使用dictsort之类的过滤器来排序得分应该把所有数据在视图里组装成列表使用 Python 的sorted排序。Django 模板过滤器对大数据集的处理效率很低而且逻辑写在模板里不好测试。5.3 定时任务与数据备份策略职称评定有固定的时间窗口比如每年 9 月申报10 月评审。系统运行过程中有很多定时任务夜间备份数据库、每周清理预览缓存、每年自动生成申报批次。我使用 Celery Beat 定时任务管理# tasks.py shared_task def backup_database(): subprocess.run([pg_dump, review_db, -f, f/backup/review_{time.strftime(%Y%m%d_%H%M%S)}.sql])备份时要注意pg_dump输出的转储文件不能保存在应用目录里否则会备份到数据库自己。我单独挂载了一块数据盘到/backup并在 Nginx 中关闭了该目录的访问权限。备份策略是每天全量 每小时增量使用 WAL 归档恢复时先恢复全量再应用归档日志。6. 常见问题与排查技巧实录照着做能少走一半弯路6.1 问题速查表现象可能原因排查/解决上传大文件报 413Nginxclient_max_body_size未调整修改 Nginx 配置并 reload上传成功但预览显示空白LibreOffice 转换失败查看 Celery 日志确认服务器是否安装 libreoffice专家评审提交后分数丢失Flask 与 Django 数据同步失败检查 Django 内部 API 的签名校验逻辑和网络连通性列表页加载缓慢ORM N1 查询使用select_related和prefetch_related同一教师重复申报缺少数据库唯一约束在Application表加unique_together评审结束后专家还能改分状态机控制不严评审活动状态为“已结束”时锁定所有ReviewScore6.2 评审公平性保障的两个细节职称评审系统最敏感的环节是专家打分。系统开发时要保证两个功能一是在评审活动结束前任何人都不能看到其他人的打分情况二是最终的分数汇总规则要明确。我的做法是所有ReviewScore在未提交状态下都只显示“待提交”或“未评”的占位不显示实际分数管理员只有在评审活动结束后才能触发“汇总”功能。汇总时系统去掉一个最高分和一个最低分再计算平均分这个逻辑是在后端完成的不是模板里简单求和。汇总逻辑执行前会生成一份所有专家评分明细的快照供后续审计查看。快照用 JSON 字段存在一张单独的表里避免后期修改数据影响审计记录。6.3 关于系统上线后的一点经验上线第一天人事处提出了一个我们开发时完全没意识到的问题他们需要把系统里的申报数据和学校现有的 OA 系统对接自动同步教师工号和院系信息。当时我们的做法是让用户手工维护结果用户少录了姓名后来全部核对花了两天。如果重新做我会在项目一开始就调研现有统一身份认证系统直接接入 CAS 或 OAuth2而不是每个系统独立账号密码。好在 Django 的第三方认证库django-cas-ng非常成熟后期接入不算太麻烦但前期的数据清洗工作确实痛苦。另外千万不要忽略打印功能。高校职称评审最后需要纸质材料存档。系统里得提供一个“导出申报表 PDF”的功能模板要用 PDF 模板引擎而不是前端浏览器打印。我尝试过用 Django 模板渲染 HTML 再用weasyprint转 PDF效果不错但中文字体的问题需要额外配置。部署服务器上如果没有中文字体转换出的 PDF 会出现中文乱码需要安装fonts-noto-cjk。7. 项目复盘与后续扩展建议整个项目从架构到落地我最深的体会是Django Flask 双框架不是炫技而是对不同复杂度任务的务实拆分。Django 解决了 80% 的常规业务Flask 补齐了 20% 的轻量服务两者通过 HTTP 接口沟通让团队可以并行开发部署时互不干扰。如果你也在做类似的内部管理系统建议在一开始就明确每个框架的边界不要把 Flask 写成半个 Django也不要把 Django 塞进太多工具型接口。后续可扩展的方向很明确一是接入学校统一身份认证省去账号管理成本二是把评审数据可视化用图表展示各院系职称结构、通过率变化趋势这能帮助人事处做政策调整三是增加申报材料的全文检索功能用 Elasticsearch 替换现有的数据库 LIKE 查询方便后续教师查询历史材料。当然这些扩展都要建立在数据规范的基础上前期建模时多花点心思后面会省很多事。