带“高校教材征订系统”这个项目毕业设计我是从一位教务处老师的朋友圈吐槽开始的——每学期初教材科的人捧着几个Excel表格来回传学生下单靠手填班级汇总靠复制粘贴最后总有几十个人订错版本。我接这个项目的时候就想与其继续修修补补Excel不如正经做一个能支撑完整业务链的系统。项目最终落地的技术形态用标题里那句“django-flask基于python”来形容最贴切不过主体业务用Django实现辅助的轻量查询服务用Flask承接。这篇文章把我从需求梳理、选型、数据库建模到双框架协作、部署上线的全过程拆开来讲重点是我在真实开发中踩过的坑和最终保留的关键设计适合正在做类似管理系统、或者纠结“Django和Flask到底怎么选”的朋友参考。1. 为什么把征订系统拆成了Django和Flask两套服务1.1 最初设想大多数人的第一反应是既然都选了Python那就老老实实用Django全家桶Flask也就不用出现了。我最初也是这样想的甚至在需求文档里直接写“基于Django的教材征订系统”。但等我把角色、流程细化到第二版时发现有两个东西很别扭。第一个别扭点是报表导出。教材科和任课老师需要大量按班级、按教材、按征订批次导出的Excel汇总表一个批次七八个专业同时征订导出的组合非常多。这些操作如果全部走Django的MTV模式意味着我要写堆积如山的视图函数和模板页面而且每次报表参数不同都要新开页面。第二个别扭点是查询接口。学生端除了在网页上下单还要在手机浏览器里查订单状态、查教材版本、查退订结果。这些接口都是典型的“只读、高频率、低复杂度”场景并不需要Django的Admin、Form、Signal等重量级能力。于是我把系统拆成了两部分Django负责主业务闭环Flask负责轻查询和报表服务。一个系统里两个框架并行不是炫技而是让合适的工具处理合适的问题。Django的ORM、Admin后台、认证机制对管理类系统太合适了而Flask的轻量路由让我写报表接口时没有额外心理负担。1.2 真正的边界划分拆分的核心不是“一半用Django一半用Flask”而是把服务边界划干净。我最终定的边界如下服务框架负责内容主站Django 3.2用户认证、教材档案管理、征订批次管理、学生下单退订、班级汇总、采购订单、入库管理、发放管理辅助站Flask 2.0订单状态查询接口、教材库存同步接口、Excel汇总报表生成、手机端轻量查询Django侧保留完整的后台管理界面方便教材科老师直接录入教材、设置征订批次、查看班级汇总Flask侧不提供任何HTML页面只暴露JSON接口和文件下载接口。这里有一条重要的经验两个框架之间不共享模板层也不共享视图层唯一共享的就是数据库。谁有写权限谁只有读权限必须在代码层面明确。我让Flask侧只连数据库做查询所有写操作必须走Django主站的API这样后续排查问题的时候不用在两个框架里来回找写入逻辑。1.3 为什么不用微服务框架有人可能会问既然要拆为什么不干脆上微服务用gRPC或者消息队列我用一套师生规模中等的高校场景来反驳了这点教材征订系统的并发量不高核心瓶颈是业务复杂度而不是流量峰值。全校一次性并发征订也就几千人同时在线提交这对一台云服务器毫无压力。若引入微服务架构光服务注册、配置中心、链路追踪就会让这个项目复杂度翻倍得不偿失。所以我的结论是“双框架但不拆服务”是最适合这类管理系统的折中方案——代码仓库仍是一个部署时起两个进程即可复杂度可控又能享受Flask轻量接口的便利。2. 动手前先把业务链完整捋了一遍2.1 用户的四个角色任何管理系统先分清角色才能定权限。高校教材征订系统的用户只有四类但每一类对系统的诉求完全不同系统管理员管理用户账号、部门、角色权限日常维护系统参数。教材科老师维护教材档案、开启/关闭征订批次、查看汇总、生成采购单、入库、发放。任课教师查看指定课程的教材信息确认是否采用指定教材有疑问时发起修改申请。学生根据所在班级和专业查看本班征订计划提交征订、退订、查看订单状态。这里有一个经常被忽略的点学生并不是自己选任何教材而是只能选自己班级教学计划内规定的教材。所以教材征订系统不能做成“商城式”的随便选购必须先建立“班级—课程—教材”的关联关系。2.2 一轮征订从启动到教材到手要经过多少步我把完整流程画成了一张表格不是流程图因为用文字表格更直观在开发前贴在项目文档第一页阶段具体动作执行角色准备教材科导入教材档案维护班级-课程-教材关系教材科启动发布新一期征订批次包含学年学期、起止时间、征订说明教材科征订学生在规定时间内登录系统按班级课程下单学生催订系统自动统计未征订学生名单短信/站内信提醒系统汇总按班级、教材维度汇总征订数量导出确认表教材科采购根据汇总数量生成采购订单联系书商入库教材科入库教材到货后录入库存系统关联到原征订订单教材科发放学生按订单到教材科领取系统核销教材科结算按专业/班级结算教材费用导出结算明细教材科我把这个流程表直接同步给了数据库设计文档因为每张核心表几乎都对应流程中的一个“动作”。比如征订批次对应计划主体订单对应学生动作采购单对应教材科动作库存表对应入库动作。2.3 哪些业务做进系统哪些明确不做需求访谈阶段最容易踩的坑就是甲方觉得什么都能做。我一开始就明确了三个不做第一不做在线支付。教材费通常学期末结算且涉及折扣和退书在线支付反而不方便系统只需要记录“应交”和“实领”状态。第二不做物流跟踪。教材入库后由学校组织统一发放不存在逐单快递发货环节只需要有入库记录即可。第三不做复杂的库存预警算法。征订场景是“先订后采”理论上库存几乎不需要预测只要按订单采购即可。库存表的唯一作用就是记录到货情况和剩余量不需要像零售系统那样算安全库存。这些“不做”帮我省下了大量时间也让系统始终围绕核心征订业务展开。3. 数据库设计是最值得反复打磨的部分3.1 用户与角色直接继承Django的User模型用户这块我直接继承了Django内置的AbstractUser没有自己从零写用户表。原因很简单Django自带的认证体系、密码加密、会话管理全是经过生产验证的自己再造一遍轮子只会增加安全风险。角色按一对一外键拆成三张扩展表from django.db import models from django.contrib.auth.models import AbstractUser class User(AbstractUser): ROLE_CHOICES ( (admin, 系统管理员), (textbook_office, 教材科老师), (teacher, 任课教师), (student, 学生), ) role models.CharField(角色, max_length20, choicesROLE_CHOICES, defaultstudent) class StudentProfile(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, related_namestudent_profile) student_no models.CharField(学号, max_length20, uniqueTrue) major models.ForeignKey(Major, on_deletemodels.PROTECT, verbose_name专业) class_name models.ForeignKey(ClassGroup, on_deletemodels.PROTECT, verbose_name班级) grade models.CharField(年级, max_length4) class TeacherProfile(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, related_nameteacher_profile) teacher_no models.CharField(工号, max_length20, uniqueTrue) department models.ForeignKey(Department, on_deletemodels.PROTECT, verbose_name学院/部门)用PROTECT外键而不是CASCADE是我从学生退学和专业调整场景里学来的——一个专业如果被误删底下所有学生的征订记录都会变成孤儿数据。PROTECT会阻止删除强制你先处理关联数据这个保护机制在管理类系统里非常有必要。3.2 教材档案、专业课程与班级的关系建模教材档案表设计了这些字段教材名称、ISBN、出版社、作者、版次、单价、适用课程、状态。很多人会把“适用课程”和“适用专业”挤在一起我实际建模时拆成了三张关联表Major专业表ClassGroup班级表外键指向专业CourseTextbook课程教材关系表记录“某专业的某门课用哪本教材”class CourseTextbook(models.Model): major models.ForeignKey(Major, on_deletemodels.CASCADE, verbose_name专业) course_name models.CharField(课程名称, max_length100) textbook models.ForeignKey(TextBook, on_deletemodels.PROTECT, verbose_name教材) semester models.CharField(开设学期, max_length20) is_current models.BooleanField(当前有效, defaultTrue)为什么要单独建关系表而不是在教材表上加“适用专业”字段因为同一本教材可能被多个专业的不同课程使用而同一个专业在不同学期也可能更换教材版本。把关系单独建表的好处是学生端查询“本班需要订什么书”时只需要根据专业和当前学期去关联表里找教材非常快。3.3 征订批次、订单、采购、库存四张核心单据这四张表是整个系统的骨架我在写代码前对它们的字段反复推敲了很久。征订批次表EnrollBatch核心字段是academic_year例如2024-2025、semester1或2、start_time、end_time、status。这里有个小细节状态字段用字符串还是整数我选了字符串class EnrollBatch(models.Model): STATUS_CHOICES ( (draft, 草稿), (active, 征订中), (finished, 已截止), (cancelled, 已取消), ) name models.CharField(批次名称, max_length50) academic_year models.CharField(学年, max_length9) semester models.CharField(学期, max_length1, choices((1, 第一学期), (2, 第二学期))) start_time models.DateTimeField(开始时间) end_time models.DateTimeField(截止时间) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultdraft)字符串比布尔值好扩展比整数可读性强这是我在项目里多次改字段后的体感结论。学生订单表StudentOrder这是写入频率最高的表字段包括学号、批次、教材、数量、状态、下单时间、退订时间等。订单状态我设计了四档pending待确认、confirmed已确认、cancelled已退订、issued已发放。为什么需要“已确认”因为学生下单后教材科还要在汇总时逐个班级核账确认过的订单才能进入采购汇总这个中间态非常关键。采购单表PurchaseOrder按教材维度聚合字段包括教材、批次、订购数量、供应商、采购状态。采购单不是纯手工录入而是系统根据学生订单状态自动生成候选数据教材科确认后落库。库存表Inventory字段包括教材、入库批次、采购数量、已发放数量、剩余数量。入库时从采购单关联带出数量发放时每核销一笔订单就增加已发放数量。剩余数量通过已入库-已发放计算不额外存储冗余字段除非性能有问题再考虑冗余这是后话。3.4 学年学期字段为什么会出现在一半表里做过高校系统的朋友一定懂高校的一切业务都有学年学期属性。征订批次要按学期分教材版本要按学期替换统计报表要按学期汇总。我在几乎所有核心表里都加了academic_year和semester字段导致初看列表时觉得很罗嗦。但实际用下来这个设计让我在后端写统计接口时非常顺手按学年过滤、按学期过滤都不需要额外联表只要在查询条件里加上两个等值条件即可。代价是录入数据时要多选两次但通过Django Admin的下拉框这种成本几乎可以忽略。4. Django与Flask在同一项目里的协作细节4.1 工程目录怎么组织一个项目里同时有Django和Flask最容易乱的就是目录。我最终采用的结构是这样的textbook_order/ # 项目仓库根目录 ├── manage.py ├── config/ # Django主配置 │ ├── settings/ │ ├── urls.py │ ├── wsgi.py ├── apps/ │ ├── enroll/ # 征订模块 │ ├── textbook/ # 教材模块 │ ├── stats/ # 统计模块 ├── flask_app/ # Flask辅助服务 │ ├── run.py │ ├── query_api.py # 查询类接口 │ ├── report_api.py # 报表类接口 │ ├── config.pyDjango和Flask代码分目录存放但共享同一个虚拟环境和同一个MySQL数据库。Flask侧不创建新的项目结构而是直接引用Django的配置来初始化数据库连接。有个关键点需要提醒两个框架不要写自己的数据库连接配置而是统一读取Django的settings.py里的数据库配置。我用了一个小工具函数# flask_app/db_helper.py import pymysql from config.settings import DATABASES mysql_config DATABASES[default] def get_connection(): return pymysql.connect( hostmysql_config[HOST], portint(mysql_config.get(PORT, 3306)), usermysql_config[USER], passwordmysql_config[PASSWORD], databasemysql_config[NAME], charsetutf8mb4, )这样数据库密码只维护一份两个服务启动时读同一份配置不会出现“Django连的是A库Flask连的是B库”的经典事故。4.2 认证Session打通按理说Django和Flask各自实现Session就能工作但学生买书的时候通常是从手机端调查询接口需要验证“这个请求是不是合法学生本人”。如果两套Session体系各自为政就会出现“Django登录成功Flask查询却说未登录”的情况。我的做法很务实Flask侧不维护独立登录态只做Token校验。学生登录必须走Django主站登录成功后Django生成一个签名token存到django_session表同时返回给前端。Flask在收到请求头里的token后去同一张Session表里查是否存在且未过期# flask_app/auth.py from itsdangerous import TimestampSigner from config.settings import SECRET_KEY signer TimestampSigner(SECRET_KEY) def verify_token(token: str): 校验token是否由Django签发且未过期过期时间按session有效期处理 try: data signer.unsign(token, max_age24 * 3600) # 24小时有效期 user_id data.decode(utf-8) return int(user_id) except Exception: return None这个方案的好处是我没有在Flask里再造一套用户表也没有引入额外的Session存储中间件。Django负责签发Flask负责验证共享的只有SECRET_KEY和访问同一个数据库。部署时两个服务用同一个SECRET_KEY即可。提示Django的SECRET_KEY严禁硬编码在Git里。我上线前把它移到了环境变量Flask侧也从环境变量读取避免密钥泄露导致Token被伪造。4.3 ORM边界谁负责查数据谁能改数据双框架协作最容易出的问题就是“两边都写了数据库”。我定了一条铁律Django是唯一的数据写入方Flask只做只读查询。Flask侧所有查询都通过pymysql直连MySQL执行简单的SELECT语句不允许出现INSERT、UPDATE、DELETE。报表和查询接口天然就是只读的这条约束执行起来完全无障碍。这样做的好处显而易见一旦数据异常不需要在两个框架里同时排查写入逻辑因为Flask侧根本没有写入口。如果未来Flask也需要写数据比如同步某些状态我会建议通过HTTP接口调Django的服务而不是让Flask直接操作表。这个原则从架构上杜绝了“脑裂”。5. 核心功能实现解析5.1 征订批次的启停控制征订批次是学生下单的前提所以状态控制非常重要。我拒绝在视图函数里手写“当前时间是否在开始和截止时间之间”这种判断因为一旦批次很多逻辑容易漏。Django的信号机制帮我解决了这个困扰。我把批次状态和征订时间统一通过批次表的status字段管理同时写了一个校验函数# apps/enroll/services.py from django.utils import timezone from django.core.exceptions import PermissionDenied def ensure_enroll_open(batch): now timezone.now() if batch.status ! active: raise PermissionDenied(当前批次未开启) if now batch.start_time or now batch.end_time: raise PermissionDenied(当前不在征订时间内) return True学生端的下单视图、退订视图、改订视图在入口处都先调用这个函数。这样做的好处是我只需要维护一个入口而不是在每个视图函数里复制时间判断逻辑。5.2 学生端一键订书学生端的核心体验是“进了系统看到本班该订的书一键提交全部订单”。我做了两步设计第一步根据学生的major字段和CourseTextbook表按照当前征订批次对应的学期查到该生本学期需要订购的教材清单。第二步前端页面把所有教材列成勾选列表默认全选学生去掉不需要的教材后点击“提交征订”后端批量创建订单记录。# apps/enroll/views.py from django.views import View from django.http import JsonResponse from django.db import transaction from .models import StudentOrder, EnrollBatch class SubmitOrderView(View): staticmethod def post(request): batch_id request.POST.get(batch_id) textbook_ids request.POST.getlist(textbook_ids) batch EnrollBatch.objects.get(idbatch_id) ensure_enroll_open(batch) student request.user.student_profile with transaction.atomic(): for tid in textbook_ids: StudentOrder.objects.create( studentstudent, batchbatch, textbook_idtid, statuspending ) return JsonResponse({code: 0, msg: 提交成功})批量创建时我用transaction.atomic()包住整个循环避免提交一半、失败一半的脏数据。学生下单这种操作要么全部成功要么全部失败不能出现“订了5本只写进去3本”的情况。5.3 教师侧班级汇总与Excel导出这应该是教材科最看重的功能。按班级汇总需求是某个批次下某本教材在某班级有多少学生订购合计多少本。我起初用Django ORM的values().annotate()做聚合后来发现有些场景是“按专业汇总”“按班级汇总”“按教材汇总”三种组合干脆在Flask报表服务里用原生SQL写视图再用openpyxl生成Excel。# flask_app/report_api.py def batch_class_summary(batch_id): sql SELECT c.name AS class_name, t.name AS textbook_name, COUNT(DISTINCT so.student_id) AS student_count, SUM(so.quantity) AS total_quantity FROM enroll_studentorder so JOIN enroll_classgroup c ON so.class_id c.id JOIN enroll_textbook t ON so.textbook_id t.id WHERE so.batch_id %s AND so.status IN (pending, confirmed, issued) GROUP BY c.name, t.name ORDER BY c.name, t.name rows execute_query(sql, (batch_id,)) return generate_excel(rows)我在统计接口里特意把订单状态限定为pending、confirmed、issued三个有效状态剔除cancelled这样汇总数量不会因为退订数据而虚高。Excel导出除了生成文件还要给教材科老师一个下载入口。Flask端返回send_file文件名用URL里的参数动态生成同时在响应头加上Content-Disposition中文文件名需要做URL编码否则部分浏览器会乱码。5.4 订单超期与退订处理征订截止后学生不能再提交退订请求。但这个规则不能只靠前端隐藏按钮后端必须强制校验。我在Django的退订接口里加了如下判断# apps/enroll/views.py def cancel_order(request, order_id): order StudentOrder.objects.get(idorder_id) ensure_enroll_open(order.batch) if order.status in (issued, cancelled): raise PermissionDenied(当前状态不可退订) if request.user.id ! order.student.user_id: raise PermissionDenied(无权操作该订单) order.status cancelled order.save()这个接口看起来简单但最容易漏的是学生只能退自己的单不能帮别人退。当前登录用户的id必须和订单实例里的student.user_id一致否则直接拒绝。这是很多管理系统里越权漏洞的源头多写一行校验就多一层保障。6. 双框架协作的坑我逐个记录了一遍6.1 坑一Flask侧读到的数据总是旧的上线测试时学生端提交订单后手机端Flask查询接口总是查不到最新订单。排查后发现不是数据没写入而是我把pymysql的连接放在了全局变量里线程之间复用了同一个连接。Flask的同步模式下每次请求如果复用旧连接事务隔离级别和会话未刷新就会读到旧快照。解决办法是每次请求都创建新连接用完即关from flask import Flask, g app Flask(__name__) app.teardown_appcontext def close_db(exc): if db in g: g.db.close() def get_db(): if db not in g: g.db get_connection() return g.db用g对象管理连接不仅仅解决“脏连接”问题也让每个请求携带独立的数据库会话互不干扰。这个改动上了生产之后查询延迟基本稳定在个位数毫秒旧数据问题彻底消失。6.2 坑二跨模块报表里的中文乱码Flask生成Excel时我最初给Content-Type设了application/vnd.ms-excel在部分浏览器里中文文件名变成了乱码。换成下面这个写法解决from urllib.parse import quote from flask import send_file filename 班级汇总表.xlsx # 对文件名做URL编码兼容常见浏览器 response send_file(excel_temp_path, mimetypeapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet) response.headers[Content-Disposition] fattachment; filename*UTF-8{quote(filename)}这个坑的根源是HTTP响应头只支持ASCII字符中文字符文件名必须编码。类似问题在Django的FileResponse里也存在建议统一用filename*UTF-8格式。6.3 坑三两个框架的日志混在一起前期开发时Django和Flask都往终端输出日志部署后问题来了线上报错根本分不清是哪个服务出的。后来我把两套日志分别写到不同目录Django的日志进logs/django.logFlask的进logs/flask.log且每条日志都带请求路径和耗时。# settings.py 里配置相对路径 import os LOG_BASE_DIR os.path.join(BASE_DIR, logs)两个框架各自按天切分日志查找问题时用grep按业务关键字跨文件搜。后来某次故障我靠Flask日志里的慢查询SQL直接定位到报表接口缺索引很快就修复了。双框架系统的日志隔离直接影响线上排障效率绝对不要省。6.4 坑四Django Admin默认行为对征订业务的干扰Django Admin确实好用但默认权限太“口语化”——操作一个订单、一个批次没有操作留痕。教材科老师误删了一条订单后我们花了半天才恢复。后来我引入了一个简单方案在核心表上重写delete方法在删除前把记录写入操作日志表。class StudentOrder(models.Model): # 字段略 def delete(self, *args, **kwargs): OperationLog.objects.create( userself.student.user, actiondelete, contentf订单 {self.id} 被删除 ) super().delete(*args, **kwargs)这种简易操作留痕配合Admin的list_display至少能追溯到谁在什么时间删了什么数据。如果项目后续变大可以再上django-simple-history之类的库但初期这层手工记录已经够用。7. 部署上线与后续建议7.1 Nginx uWSGI Gunicorn的部署组合Django和Flask同在一个仓库但它们是两个独立进程部署时我用了两套方案Django主站用uWSGI启动监听127.0.0.1:8001。Flask辅助服务用Gunicorn启动监听127.0.0.1:8002。Nginx作为统一入口根据URL前缀分流到两个后端服务。server { listen 80; server_name textbook.example.edu.cn; location /api/ { proxy_pass http://127.0.0.1:8002; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { proxy_pass http://127.0.0.1:8001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里把Flask的查询/报表接口统一放在/api/前缀下Nginx直接分流。Django主站的URL路由里不出现/api/这样前后端分离比较清爽。7.2 启动脚本与批量导入教学计划教材科最头疼的其实是每学期导入教学计划。教材档案、班级-课程-教材关系如果全靠手工录入会疯掉。这套系统我预置了Excel批量导入模板专业、班级、课程名称、ISBN、出版社、版次、作者、单价、开设学期一列一列对应好。导入逻辑放在Django的管理命令中# apps/textbook/management/commands/import_plan.py from django.core.management.base import BaseCommand class Command(BaseCommand): help 批量导入班级-课程-教材关系 def handle(self, *args, **options): # 读取Excel逐行创建/更新关系记录 ...这个命令配合cron定时任务可以做到定时同步。不过我在实际部署中建议人工触发因为教材科每学期开学初才需要跑一次完全不需要自动化调度。7.3 上线后最值得做的一次回归测试上线前我组织了一次模拟征订演练用脚本造了2000个学生账号提前导入一批教材关系然后让所有真实账号在测试环境跑一轮“选订—退订—汇总—导出—采购—入库—发放”全流程。结果发现几个只有全流程才暴露的问题汇总报表里退订的数据没有被过滤干净一度导致采购数量虚高Excel导出在并发访问时会偶发文件路径冲突学生端页面在低版本手机上提交订单时缺少Loading状态导致重复点击重复下单。这些问题单个拉出来都不算大但如果不做全流程演练上线后每一个都会变成教材科和学生一起抱怨的焦点。我的建议是部署不是终点把全业务链跑通一遍再宣布上线。这个项目完成之后我最大的体会是管理系统项目的核心并不全在代码而在于把业务链拆清楚再决定每个环节用什么技术实现。Django和Flask的组合在这里没有产生额外负担反而让主业务和轻量服务各得其所。最后再分享一个小技巧数据库里的每个状态字段、每条业务规则最好在开发前就和实际使用系统的教材科老师确认一遍。他们才是最懂“征订”这两个字的人。
阅读完成 · 觉得有帮助?