
学校实验室的预约在很多高校长期靠着一份Excel表加一个微信群在支撑。每到期末群里消息翻几百条才能找到空闲时段管理人员要人工核对有没有时间冲突学生预约了不去也不会被记录更别说统计哪个实验室使用率高了。我待的学院就是这种状态忍了一学期之后我实在坐不住了决定用自己最熟的 Python 配合 Django 框架自己动手搭一套高校实验室租赁管理系统。开发过程大概用了三周上线后在我们学院平稳跑了两个学期从提交申请、管理员审批、到点签入、到期归还的整条流程都稳定运转后来其他几个学院的实验室也陆续接进来用。这篇文章不是来发源码的而是想把我从业务梳理、数据建模到部署上线的关键决策、还有踩过的那些坑原原本本写出来给正在做同类管理系统的朋友一个参考。1. 为什么选Django从需求反推技术选型1.1 实验室租赁管理到底在管什么动手写代码之前我花了两天时间把线下实验室管理的实际流程完整捋了一遍。这里面的核心业务其实是一条订单链路学生提交预约申请管理员审批通过到了预约时间学生签入使用用完归还释放实验室。除了订单流转系统还需要维护实验室的基础信息、设备清单、当前状态和开放时段。管理员要能随时知道每个实验室的位置、可容纳人数、里面有什么设备、现在被谁占用期末还要统计使用率、空置时段这些数据。把这些需求剥开之后我很快意识到这个业务本质上和常见的信息管理系统没有太大区别核心就是三个实体之间的关系用户、实验室资源、租赁订单。其复杂度天花板并不高但有三条线必须抓牢订单状态流转要准确时间冲突判断要可靠权限边界要清晰。这三条做不好系统人一多就会出乱子。所以我在技术选型时没有纠结太久。市面上能选的方案无非是 PHP 系、Java 系和 Python 系。Python 生态里做 Web 应用Django 和 Flask 是两大主流。但 Flask 太灵活了数据库要自己配 SQLAlchemy用户认证要装 Flask-Login表单要写 WTForms权限管理也没有现成方案开发效率明显不如 Django。对于实验室租赁这种“表单列表状态流转”的系统Django 的内置能力几乎覆盖了全部需求。1.2 MTV模式为什么刚好匹配这类业务Django 的 MTV 模式就是 Model-Template-View。这个结构在刚学的时候觉得绕但真正做业务系统时就会体会到它的省心Model 对应数据库表View 写业务逻辑Template 做页面渲染职责边界非常清楚。实验室、订单、用户这些业务实体天然映射为 Model时间冲突检测、状态审批、权限校验这些逻辑全部放进 View学生和老师看到的页面交给 Template。我拿之前用 Flask 做过的几个小工具做对比Flask 最大的问题是“什么都行”意味着“什么都要自己搭”。而 Django 自带 Admin 后台、用户认证、CSRF 防护、ORM 数据库映射这些在管理系统里全是刚需。尤其是 Admin 后台在项目初期没有写任何管理页面之前我全靠它来维护实验室数据和调整订单状态节省了至少一周的开发量。提示如果团队有 Python 基础但没接触过 Django建议先用官方教程做一个投票应用把 MTV 的请求路径和 ORM 的用法跑通再动手做管理系统整体会顺畅很多。1.3 对比其他方案Django具体赢在哪里这里补充一下我做选型时列过的对比给大家一个直观参考。同样是做一个实验室租赁管理系统我大致估算过不同方案的开发量功能需求DjangoFlaskSpring Boot用户登录与角色管理自带的User模型扩展表半天搞定需要Flask-Login额外配置需要Spring Security学习成本高后台管理界面Admin直接生成零成本需要集成Flask-Admin需要若依等脚手架或手写ORM与数据库迁移自带ORM和migration简单直观需要SQLAlchemymodel与迁移分离JPA/Hibernate概念复杂CSRF与安全防护中间件默认开启需要手动配置Spring Security生态配置繁琐适合人群Python开发者上手快Python开发者但需自己组装Java团队可选做这个项目的时候团队就我一个人后面交给学弟学妹维护他们也都是 Python 背景。选 Django 是最务实的选择。2. 数据库模型设计把现实关系变成一张张表2.1 用户角色与实验室信息建模这套系统的使用角色有三种学生、教师、管理员。Django 自带的 User 模型只有用户名、密码、邮箱这类基础字段没法直接表达“这个人是什么角色”所以我建了一个 UserProfile 扩展表用 OneToOne 关联到 User。这个做法是 Django 社区最通用的扩展方式不会改动作为核心认证组件的 User 表也方便后续加字段。from django.contrib.auth.models import User from django.db import models class UserProfile(models.Model): USER_TYPES ( (student, 学生), (teacher, 教师), (admin, 管理员), ) user models.OneToOneField(User, on_deletemodels.CASCADE, related_nameprofile) user_type models.CharField(max_length10, choicesUSER_TYPES, defaultstudent) student_id models.CharField(学号/工号, max_length20, blankTrue) college models.CharField(所属学院, max_length50, blankTrue) phone models.CharField(联系电话, max_length20, blankTrue) def __str__(self): return f{self.user.username}-{self.get_user_type_display()}这里有个设计细节想提醒大家on_deletemodels.CASCADE表示删除 User 时级联删除 UserProfile。我一开始犹豫过要不要用PROTECT防止误删但实际里 User 和 UserProfile 的生命周期本来就绑定级联删除不会误伤业务数据反而避免了孤儿记录的产生。实验室信息表就更加直接了。名称、位置、容量、设备清单都是必填项。状态字段这里我用了 choices 枚举而不是自由字符串这样后续做筛选和下拉展示时非常可控不会出现有些人填“空闲”有些人填“空”这种脏数据class LabRoom(models.Model): STATUS_CHOICES ( (available, 空闲), (in_use, 使用中), (maintenance, 维护中), ) name models.CharField(实验室名称, max_length100, uniqueTrue) location models.CharField(所在位置, max_length100) capacity models.IntegerField(容纳人数) equipment models.TextField(设备清单, help_text使用分号分隔设备名称) status models.CharField(当前状态, max_length20, choicesSTATUS_CHOICES, defaultavailable) image models.ImageField(实验室照片, upload_tolab_images/, blankTrue, nullTrue) desc models.TextField(使用说明, blankTrue) created_at models.DateTimeField(创建时间, auto_now_addTrue) class Meta: ordering [name]这里我想解释一个很多人一开始想不通的点实验室表里还有一个status字段看起来和订单状态有冗余。但这个冗余非常必要因为管理员在首页展示“当前有几个空闲实验室”时不可能每刷新一次就去聚合查询所有订单算一遍。直接查LabRoom.objects.filter(statusavailable)效率高得多。代价是需要在业务逻辑里保证两张表状态同步这个我到下一章讲签入签出时会详细说。2.2 租赁订单状态机为什么用字符串状态而不是布尔值订单表是整个系统的核心。我最初设计时只打算用一个is_approved布尔值记录是否通过后来很快发现完全不够。租赁订单会经历提交、审批、签到、归还、取消、驳回每种状态都要记录管理员还要知道驳回原因。所以状态字段必须用字符类型配合 choices 限制取值范围class RentalOrder(models.Model): STATUS_CHOICES ( (pending, 待审批), (approved, 已通过), (rejected, 已驳回), (in_use, 使用中), (completed, 已完成), (cancelled, 已取消), ) lab models.ForeignKey(LabRoom, on_deletemodels.CASCADE, related_nameorders, verbose_name实验室) user models.ForeignKey(User, on_deletemodels.CASCADE, related_namerental_orders, verbose_name租用人) purpose models.CharField(使用用途, max_length200) start_time models.DateTimeField(开始时间) end_time models.DateTimeField(结束时间) status models.CharField(状态, max_length20, choicesSTATUS_CHOICES, defaultpending) reject_reason models.TextField(驳回原因, blankTrue) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) class Meta: ordering [-created_at] indexes [ models.Index(fields[lab, status]), models.Index(fields[start_time, end_time]), ]状态机的流转路径是这样的学生提交后进入pending待审批态管理员点通过后变approved学生取消时变cancelled管理员驳回时变rejected到了预约时间学生签到进入in_use使用完成归还后变成completed。这个流转看起来简单真正的难点是“哪种角色在哪个状态下能触发哪个转换”我放到了第4章专门讲。订单表里两个索引字段非常关键。时间冲突检测的查询就是按lab start_time/end_time的过滤条件走不建索引的话数据量到了几百条可能还有感觉突破几千条以后查询时间会明显变长。在建模时还有一个特别容易被忽略的坑时间冲突是这类系统最常见的并发问题。两个学生几乎同时提交同一个实验室同一时段的申请如果代码只是先查询再插入有概率在极端情况下两边都通过校验导致同一实验室在同一时间被批准两次。我最后在提交接口里加了事务和select_for_update行锁先把该校验的订单锁住再做冲突判断这样并发条件下也不会出现重复批准。高校场景的访问量不算大但养成这个习惯能避免很多线上事故。3. 核心租赁流程预约、审批、签到、归还的落地3.1 可用时段检索与时间冲突判断学生要预约实验室第一步是选一个可用时段。这个功能的灵魂在时间冲突判断函数。两个时间段[s1, e1]和[s2, e2]重叠的判断条件很简单s1 e2 and s2 e1。放到 Django ORM 里就是这样def has_conflict(lab, start_time, end_time, exclude_order_idNone): qs RentalOrder.objects.filter( lablab, status__in[pending, approved, in_use], start_time__ltend_time, end_time__gtstart_time, ) if exclude_order_id: qs qs.exclude(pkexclude_order_id) return qs.exists()注意这两个过滤条件缺一不可。开始时间等于已有订单结束时间的边界情况不应该算冲突比如前一单到 10:00 结束后一单从 10:00 开始这是合法的。但结束时间只要晚于已有订单的开始时间就必然重叠。我第一次做这个功能时少写了一个条件结果出现了所有相邻时段全被判为冲突的 bug排查了很久才意识到问题。前端交互上为了减少无效操作我会在页面加载时拉取目标实验室未来七天的订单把已经被占用的时间段直接标红学生只能选择白色空闲区间。这个交互设计非常实用用户不需要自己算“能不能借”系统直接告诉你哪里是空的。后台再配合上面的冲突检测做二次校验杜绝了前端被绕过的情况。3.2 审批与签入签出一个视图函数控制状态流转审批环节我用了一个统一的视图函数处理所有状态动作通过 POST 参数区分操作类型。学生能操作的动作只有“取消”管理员和教师可以“通过”“驳回”“签入”“归还”。代码结构上把角色判断抽出来避免在每个函数里重复写from django.contrib.auth.decorators import login_required from django.shortcuts import get_object_or_404, redirect def _check_role(user, allowed_types): return hasattr(user, profile) and user.profile.user_type in allowed_types login_required def handle_order_action(request, order_id): order get_object_or_404(RentalOrder, pkorder_id) action request.POST.get(action) now timezone.localtime() if action cancel and request.user order.user and order.status pending: order.status cancelled elif action approve and _check_role(request.user, [admin, teacher]) and order.status pending: order.status approved elif action reject and _check_role(request.user, [admin, teacher]) and order.status pending: order.status rejected order.reject_reason request.POST.get(reason, ) elif action checkin and _check_role(request.user, [admin, teacher]) and order.status approved and order.start_time now: order.status in_use order.lab.status in_use order.lab.save() elif action checkout and _check_role(request.user, [admin, teacher]) and order.status in_use: order.status completed order.lab.status available order.lab.save() else: return redirect(order_detail, order_idorder.id) order.save() return redirect(order_detail, order_idorder.id)这个函数里最关键的是状态前置条件判断。比如checkin必须在approved状态下执行而且当前时间已经到达订单开始时间否则允许管理员提前签入会导致学生还没用就被占用。cancel动作只允许订单的创建者本人在pending状态下执行审批通过之后就不能随意取消了。这里再补充一个我踩过的坑签入和签出操作同时改了订单和实验室两张表刚开始不是事务结果有次归还的时候实验室状态更新失败出现了“订单已完成但实验室还是使用中”的脏数据。后面我把这两个 save 操作包进了事务里才彻底解决。3.3 图片附件上传从配置到页面显示实验室照片和学生的实验报告附件都会用到文件上传。Django 的ImageField和FileField配置不复杂但有几个细节会耽误不少时间。# settings.py MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media开发环境下要在主路由里加上静态服务from django.conf import settings from django.conf.urls.static import static urlpatterns [ # ...你自己应用的路由 ] static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)模板里显示图片的写法是{{ lab.image.url }}而不是{{ lab.image }}。这两个看起来差不多但lab.image拿到的是文件名字符串浏览器渲染不出图片lab.image.url才会拼出完整的/media/lab_images/xxx.jpg访问路径。这个错误我见过很多新同学踩代码检查半天也找不到原因。上传文件的 form 还有一个必备属性enctypemultipart/form-data。很多人在后端request.FILES.get(image)里拿到 None排查好久最后发现只是 HTML 标签里少了这个属性。这两个坑属于基础但高频写下来给大家提个醒。4. 权限控制与角色边界谁在什么状态下能做什么4.1 三个角色的权限清单设计权限设计我是这样定义的学生浏览实验室列表和详情提交预约申请查看自己的订单列表取消状态为“待审批”的订单。教师拥有学生的大部分功能另外可以审批和处理名下实验室的订单包括通过、驳回、签入、签出。管理员全部权限包括实验室信息的增删改、用户管理、全部订单的查看和修正。这个权限清单里最需要注意的边界是学生不应该能看到别人的订单内容管理员才能看全部。所以列表查询的 queryset 必须根据当前用户角色区分而不是把所有订单查出来再在模板里过滤def get_order_queryset(user): if user.profile.user_type admin: return RentalOrder.objects.all() if user.profile.user_type teacher: return RentalOrder.objects.filter(lab__manageruser) return RentalOrder.objects.filter(useruser)这里lab__manageruser是 Django ORM 的跨外键过滤写法会自动 JOIN 实验室表来匹配负责人字段。前提是 LabRoom 模型里得有一个manager外键指向 User。这样的好处是数据用权限直接约束在模型层面即使之后不小心在模板里显示了不该显示的数据查询结果本来就过滤掉了不会泄露他人隐私。4.2 视图层拦截与模板层隐藏的正确做法权限校验不能指望前端。前端隐藏按钮只是为了让界面简洁真正的权限校验必须放在后端视图里做。比如from django.contrib.auth.decorators import user_passes_test def admin_required(view_func): return user_passes_test( lambda u: hasattr(u, profile) and u.profile.user_type admin, login_url/login/ )(view_func) admin_required def lab_create(request): # 只有管理员能新增实验室 ...模板层再根据角色控制入口显示{% if user.profile.user_type admin %} a classbtn href{% url lab_create %}新增实验室/a {% endif %}这里要特别强调模板里的{% if %}只是为了用户界面的友好性它在安全层面一文不值。因为攻击者完全可以手动构造请求直接访问某个 URL。所以任何关键操作在后端都必须有对应的角色判断这就是“纵深防御”的思想。既然说到安全Django 自带的 CSRF 中间件是默认开启的它对表单类系统很重要。每个表单里都要加{% csrf_token %}这是基础中的基础。如果用了 AJAX 方式提交也要在请求头带上 CSRF token后面第6章我会展开讲这个。4.3 Admin后台和自定义页面的分工这个项目早期我完全依靠 Django Admin 完成了所有管理操作。在 admin.py 里把模型注册进去实验室增删改、订单手动调状态都可以在后台直接点鼠标完成开发效率极其夸张。可以毫不夸张地说Django 的 Admin 是我选择这个框架的直接原因之一。但项目跑起来之后我发现Admin 后台更适合超级管理员做冷门操作和数据修正不适合教工学生日常使用。一方面Admin 界面是给技术人员设计的学生在里面容易点错另一方面Admin 里的状态修改不受业务约束管理员可以直接把订单从“已完成”改成“待审批”这在业务上是不可接受的。所以最终方案是自定义页面承担学生和教师的日常流程Admin 只保留给超管做数据查看和异常订正。5. 部署上线的关键一步WaitressNginx组合5.1 为什么不能把 runserver 当生产服务器很多新手在项目快做完时会直接在服务器上执行python manage.py runserver 0.0.0.0:8000就以为上线了。我必须说这个做法在真实环境里会出大问题。Django 自带的 runserver 是一个用于开发调试的服务器单进程单线程没有并发处理能力。一旦多人同时访问或者有人上传大文件其他请求就会卡住体验非常糟糕。更严重的是runserver 在发生异常时会返回调试模式的详细报错页面包含文件路径、环境变量、甚至数据库配置信息这是生产环境最典型的敏感信息泄露漏洞。生产环境必须用独立的 WSGI 服务器。Linux 上主流选择是 Gunicorn但不少高校信息化中心用的服务器是 Windows ServerGunicorn 在 Windows 上运行不稳定。这时 Waitress 就是最佳选择——它是纯 Python 实现不依赖 C 扩展Windows 直接安装就能跑pip install waitress waitress-serve --listen0.0.0.0:8000 myproject.wsgi:application如果你不想记命令行参数可以在项目根目录写一个start.pyfrom waitress import serve from myproject.wsgi import application if __name__ __main__: serve(application, host0.0.0.0, port8000)然后python start.py就能启动适合不太熟悉命令行的运维同学。装个 NSSM 之类的工具还能把它注册成 Windows 服务开机自启动。5.2 Nginx 反代、静态文件收集与 HTTPS 的 CSRF 坑Waitress 只负责处理 Django 的动态请求静态文件和媒体文件不应该让 WSGI 服务器去管——这是性能瓶颈也是不必要的负担。正确做法是把静态文件交给 Nginx 处理Django 后端只保留动态逻辑。Nginx 关键配置如下server { listen 80; server_name lab.yourcollege.edu.cn; location /static/ { alias D:/projects/labrental/static/; } location /media/ { alias D:/projects/labrental/media/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }配置里proxy_set_header那几行一个都不能少。尤其是X-Forwarded-For和X-Forwarded-Proto缺失的话 Django 里记录不到真实客户端 IP还会影响 HTTPS 的判断逻辑。启动之前别忘了修改settings.pyDEBUG False ALLOWED_HOSTS [lab.yourcollege.edu.cn, 127.0.0.1] SECURE_PROXY_SSL_HEADER (HTTP_X_FORWARDED_PROTO, https) CSRF_TRUSTED_ORIGINS [https://lab.yourcollege.edu.cn]DEBUGFalse之后必须提前执行python manage.py collectstatic把所有 app 里的静态文件收集到项目根目录的 static 文件夹Nginx 才能指向这些文件。这个步骤经常被忽略部署完打开页面发现没有样式多半就是没做静态文件收集。如果你要上 HTTPSCSRF_TRUSTED_ORIGINS一定要填对否则在 HTTPS 环境下提交表单时Django 会因为 Origin 不匹配直接拒绝请求报 403。这个坑我自己第一次配 HTTPS 时踩过当时一提交表单就 403排查了整整一个下午最后才发现是 CSRF 的白名单没加导致的。6. 开发调试里的坑与提效经验6.1 时区问题预约系统最容易出错的细节如果 settings.py 里设置了USE_TZ TrueDjango 3.0 后默认开启数据库里存的时间是 UTC而中国用户看到的时间是 UTC8。这个差别在普通内容管理系统里无伤大雅但在预约租赁系统里非常致命。我第一次联调时就发现存进数据库的开始时间比页面上选的时间整整少了8小时列表展示出来的时间跟学生实际预约的时间完全对不上。解决办法是统一使用 Django 的时区工具从前端到后端一致处理from django.utils import timezone from django.utils.dateparse import parse_datetime # 把页面提交的字符串转为aware datetime dt parse_datetime(request.POST.get(start_time)) if dt: dt timezone.make_aware(dt, timezone.get_current_timezone()) # 展示时 display_time timezone.localtime(order.start_time)前端时间选择器传过来的是一个没有时区信息的 naive datetime必须用timezone.make_aware给它加上当前时区再存进数据库展示时用timezone.localtime()转成本地时间字符串。这样前后端才完全一致。注意千万不要手动给时间加8小时来“修正”时区问题那样系统到每年夏时制或其他时区切换时会彻底混乱。所有环节统一用带时区的时间问题自然消失。6.2 Ajax 提交踩到的 CSRF 坑学生端的预约表单我一开始是整个表单页面刷新提交后来为了展示可用时段和剩余名额改成了局部异步刷新。结果所有 AJAX POST 请求全部返回 403 Forbidden查日志才发现是 CSRF 验证失败。原因是 Django 的 CSRF 机制默认检查 POST 请求里的csrftokencookie 和表单/请求头中的 token 是否匹配。普通 form 提交可以靠{% csrf_token %}自动带上但 AJAX 请求不会自动加。解决办法是在 JS 里读取 cookie 中的 csrftoken然后放到自定义请求头function getCookie(name) { let cookieValue null; if (document.cookie document.cookie ! ) { const cookies document.cookie.split(;); for (let i 0; i cookies.length; i) { const cookie cookies[i].trim(); if (cookie.substring(0, name.length 1) (name )) { cookieValue decodeURIComponent(cookie.substring(name.length 1)); break; } } } return cookieValue; } $.ajaxSetup({ beforeSend: function(xhr, settings) { if (!(/^(GET|HEAD|OPTIONS|TRACE)$/.test(settings.type))) { xhr.setRequestHeader(X-CSRFToken, getCookie(csrftoken)); } } });加完这段之后项目里所有 AJAX POST 请求都自动带上 CSRF token一劳永逸。这个坑当时花了我大半个晚上写在这里希望后来的朋友能直接跳过。6.3 分页、Excel导出这类高频需求怎么做效率最高列表页数据量一涨分页就是刚需。Django 内置的 Paginator 用起来非常简单from django.core.paginator import Paginator page_obj Paginator(orders, 20).get_page(request.GET.get(page))模板里直接{% for order in page_obj %}遍历翻页链接用?page{{ page_obj.next_page_number }}没有任何额外依赖。注意不要自己用切片去手工分页Paginator 已经处理了边界情况比如当前页超出总页数时会自动返回最后一页比你手写判断少很多坑。期末管理员经常需要统计实验室使用记录我直接用 openpyxl 生成 Excel 导出。核心思路是查出符合条件的订单列表逐行写入 Workbook然后通过 HttpResponse 返回文件流from openpyxl import Workbook from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt def export_orders_excel(request): orders get_order_queryset(request.user) wb Workbook() ws wb.active ws.title 实验室使用记录 ws.append([实验室, 租用人, 开始时间, 结束时间, 状态]) for o in orders: ws.append([ o.lab.name, o.user.username, o.start_time.strftime(%Y-%m-%d %H:%M), o.end_time.strftime(%Y-%m-%d %H:%M), o.get_status_display() ]) response HttpResponse( content_typeapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet ) response[Content-Disposition] attachment; filenameorders.xlsx wb.save(response) return response这里Content-Disposition头的作用是告诉浏览器这是一个需要下载的附件而不是要在页面里打开的文档。如果文件名里有中文需要做 URL 编码否则某些浏览器下载后文件名是乱码。这个导出接口在期末统计时帮了大忙以前管理员手工汇总要一天现在点一下按钮就导出全量数据。整个系统从需求调研到上线大概用了三周时间第一版的功能其实很朴素真正让它稳定运行下来的是迭代过程中反复修正的这些细节。回头复盘我最深的体会是开发这类管理系统最花时间的往往不是写代码而是把状态流转、权限边界和时间冲突这些业务规则想清楚。如果你也在做类似的项目建议先把状态机画清楚再动手建模型能少走很多弯路。后续我还想把实验室的耗材库存和借用记录也接入进来让系统从一个实验预约工具扩展成完整的实验室资源管理平台这又是另一个值得继续折腾的方向了。