管理员老周微信上找我那天,正好赶上课题组服务器宕机,他发来一张Excel截图:设备借用记录停留在三个月前,一台高速离心机被借走没人登记,两台示波器的预约时间撞了车,耗材柜里的移液器吸头又对不上账。这不是某个实验员马虎的问题,是整个实验室的管理方式还停在“纸质台账+Excel”阶段。后来我花了三周时间,用Python给他从零搭了一套高校实验室管理系统,把设备预约、借用归还、耗材领用、人员权限这些事全部搬进了Web页面里。这篇文章就把这整套系统的设计思路、建表逻辑、核心代码和上线后踩过的坑完整梳理一遍,给同样被实验室管理折磨的同学们一个可以直接上手的参考。
这套系统适用的情况很明确:学院或课题组级别的实验室,设备几十台到上百台,用户几十号人,需要设备预约、借用登记、耗材管理、基础数据统计。它不追求大而全的商业资产管理方案,而是解决“谁在用设备、准备用多久、用完归没归还、耗材还剩多少”这些最头疼的日常问题。全文核心代码基于Python 3.10 + Django 4.2,数据库用SQLite起步,后续可平滑换到MySQL,前端采用服务端渲染加Bootstrap和ECharts,整体难度对有一定Python基础、想快速落地的同学非常友好。
1. 实验室日常的真实痛点:台账、排期与“东西去哪了”
1.1 松散管理下的三类麻烦:借用无迹、预约冲突、耗材黑洞
在动手写第一行代码之前,我花了两天时间泡在实验室里观察管理员老周的工作流程,也翻了他们过往几年的管理记录。最后把痛点归结为三类,这三类问题直接决定了系统功能模块的优先级。
第一类是设备借用的去向问题。实验室里贵重设备不像办公室的订书机,不会有人真的“拿走不还”,但经常出现“借了忘登记”“还了忘销账”“别人要用时不知道设备在谁那儿”的情况。最典型的就是那台高速离心机,A课题组拿去做超速离心,用完了直接放在自己实验室的台面上,B课题组要用时满楼层找,最后在群里喊了一下午才找到。纸质台账解决不了这个问题,因为登记这件事完全依赖人的自觉性,而人的自觉性在赶实验进度的时候是最不可靠的。
第二类是预约排期冲突。实验室的示波器、荧光定量PCR仪、酶标仪这些设备,到了期末或者论文季,使用频率极高。以前的做法是一个Excel在线文档,大家自己往里填时间格子,但经常出现两个人填了同一个时间段,或者someone填了时段却临时不来,浪费了时间窗口。更麻烦的是,Excel文档被某个人锁定编辑时,其他人想填都填不了。
第三类是耗材管理的“黑洞效应”。移液器吸头、PCR管、培养皿、手套这些低值耗材,单价不高但用量巨大,不登记觉得没必要,真到补货的时候又说不清楚一个月究竟消耗了多少。老周的做法是隔一段时间去储藏室人工盘点一次,每次都对不上数,损耗率成了一个说不清的数字。
1.2 为什么选择自己开发而不是采购商业系统
调研了一圈市面上的实验室管理系统,结论很现实:要么是面向大型三甲医院或CRO公司设计的LIMS系统,功能极其庞大,授权费按设备台数计算,一套下来十几万起步,对学院课题组来说完全没有必要;要么是SaaS模式的轻量工具,数据存在云端,但是实验室的设备数据、人员数据属于学校内部信息,走云服务在合规和安全性上不好交代。
自己用Python开发一套,成本只有一台内网服务器和几周的开发时间,数据全部掌握在自己手里,功能可以完全按实验室的实际业务流程定制。更重要的一点是,实验室管理系统的逻辑并不复杂,核心就是“人-设备-时间”三个维度的关系,用Django这类成熟的Web框架,开发难度远没有想象中高。这套系统做出来后,老周从“每天追着别人登记”变成了“在后台看报表”,这个转变让我确信当初的判断是对的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:Django撑起一套完整系统的思路
2.1 Web框架选择:Django vs Flask vs FastAPI
技术选型阶段,我在Django和Flask之间犹豫过一阵。Flask轻量灵活,自由度极高,很多Python开发者对它有天然好感;FastAPI性能好,适合高并发接口服务。但认真评估之后,我最终选择了Django,理由非常现实:
Django自带Admin后台、ORM、表单校验、认证系统和权限框架。对于一个需要管理后台、需要多角色登录、需要处理大量数据库操作的管理系统来说,这几项功能如果全部用Flask自己实现,开发周期至少要翻一倍。Django的ORM让数据库表结构的修改变得极其方便,makemigrations和migrate两条命令就能完成表结构变更,不需要手动维护SQL脚本。
实验室管理系统的用户规模也就几十到几百人,并发量非常低,性能瓶颈根本不在Web框架层面,Django的同步模型完全够用。FastAPI的优势在这种低并发、重业务逻辑的场景下发挥不出来,反而会因为生态相对年轻、需要自行组合太多组件而增加开发负担。
2.2 数据库选型与部署形态
数据库的选择遵循了“先跑起来,再谈规模”的原则。开发阶段直接用Django默认的SQLite,零配置、单文件、不需要单独安装数据库服务,非常适合快速验证功能。系统上线稳定运行一段时间、数据量明显增长之后,再平滑迁移到MySQL。Django的ORM屏蔽了底层数据库差异,迁移成本很低,主要是改一下settings配置并执行数据导出导入。
部署形态上,我选择把系统跑在实验室内部的一台Windows/Linux服务器上,通过校园内网访问。服务器配置不需要很高,2核4G内存的旧机器就够了。这样做的最大好处是数据不出内网,敏感的实验数据不会被传到外部云端,也避免了校园网对公网访问的各种限制。
2.3 前端方案:不搞前后端分离,降低整体复杂度
前端选型上我做了个务实的选择:不做前后端分离。现在的技术社区里“前后端分离”几乎成了一个默认选项,但这件事要分场景。实验室管理系统页面交互复杂度不高,主要就是表格、表单、弹窗、图表,用Django模板加Bootstrap就能实现得很体面,不需要Vue或React引入一整套Node.js构建工具链。
数据可视化部分用ECharts,通过Django视图返回JSON数据,前端用Ajax拉取后渲染图表,既能满足趋势分析的可视化需求,又不需要引入重型前端框架。整个项目的前端代码量不大,一个基础模板加几个页面模板,维护成本极低。这个选择让我在后面部署时省了非常多事——不需要在服务器上装Node.js,不需要处理前端构建产物,Django直接就能把页面渲染出来。
2.4 项目目录设计与应用划分
Django项目我在创建时就按功能拆分了应用,避免所有模型堆在同一个models.py里变得不可维护。最终目录结构如下:
text复制lab_manager/
├── manage.py
├── config/
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── accounts/ # 用户认证与角色管理
├── equipment/ # 设备管理与预约
├── consumable/ # 耗材管理与领用
└── templates/
├── base.html
├── equipment/
├── accounts/
└── consumable/
accounts应用负责用户扩展信息和登录认证,equipment应用处理设备台账、预约、借用、归还,consumable应用管理耗材库存与领用记录。三个应用边界清晰,各自维护自己的模型、视图和URL路由,后期如果要在设备上增加维护记录功能,直接在equipment应用里扩展就行。这种模块化设计是Django项目保持长期可维护性的关键。
3. 数据库建模:把实验室的事务抽象成六张核心表
3.1 用户体系:扩展Django自带的User模型
Django自带的User模型包含用户名、密码、邮箱、姓名等基础字段,但实验室管理还需要“所属课题组”“身份角色”“联系方式”这些信息。我不建议直接修改Django源码或新建一张独立的用户表替代它,标准做法是使用OneToOneField扩展用户表,新建一个Profile模型存附加信息。
python复制from django.db import models
from django.contrib.auth.models import User
class Role(models.TextChoices):
ADMIN = "admin", "管理员"
TEACHER = "teacher", "教师"
STUDENT = "student", "学生"
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
role = models.CharField("角色", max_length=20, choices=Role.choices, default=Role.STUDENT)
group_name = models.CharField("所属课题组", max_length=100, blank=True)
phone = models.CharField("联系电话", max_length=20, blank=True)
def __str__(self):
return f"{self.user.username} ({self.get_role_display()})"
这样设计的优势是复用Django认证系统全部能力,登录验证、会话管理、密码重置这些安全性要求高的功能不用自己造轮子。角色字段虽然是冗余存储在Profile里,但查询时非常方便,一套ORM查询就能拿到某个角色的所有用户。
3.2 设备表:字段设计决定后续功能的扩展边界
设备表是整个系统的核心,字段设计的好坏直接决定后续预约、借用、统计功能的实现复杂度。我在设计时把字段分成了三组:基础信息、状态信息和业务配置。
基础信息包括设备名称、设备编号、型号、存放地点、购置日期、设备管理员等。状态信息包括当前状态(空闲/借出/维修/报废)、当前借用人、当前借用开始时间等。业务配置信息包括是否允许预约、单次最长使用时长、是否需要审批等。核心模型代码如下:
python复制class Equipment(models.Model):
class Status(models.TextChoices):
AVAILABLE = "available", "空闲"
BORROWED = "borrowed", "借出"
MAINTENANCE = "maintenance", "维修中"
RETIRED = "retired", "已报废"
name = models.CharField("设备名称", max_length=100)
code = models.CharField("设备编号", max_length=50, unique=True)
model_no = models.CharField("型号", max_length=100, blank=True)
location = models.CharField("存放地点", max_length=100)
purchase_date = models.DateField("购置日期", null=True, blank=True)
status = models.CharField("当前状态", max_length=20, choices=Status.choices, default=Status.AVAILABLE)
current_user = models.ForeignKey(
User, null=True, blank=True, on_delete=models.SET_NULL,
related_name="borrowed_equipment", verbose_name="当前借用人"
)
need_approval = models.BooleanField("借用需审批", default=False)
max_borrow_days = models.PositiveIntegerField("单次最长借用天数", default=7)
description = models.TextField("设备描述", blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.code})"
这里特别要注意current_user字段用ForeignKey关联到User,并设置了on_delete=models.SET_NULL——当用户从系统删除时,设备记录不能被级联删除,否则会丢失设备的历史归属信息。这个细节在线上运行中很重要,一旦有离职或毕业的学生账号被清理,错误的级联策略会把设备状态历史也带走。
3.3 预约表:时间冲突检测的基石
预约表是系统最核心的模型,它记录了每一次预约“从哪到哪、谁预约、状态是什么”。关键字段包括预约人、设备、开始时间、结束时间、用途说明、状态(待审批/已通过/已拒绝/已取消/已完成)。创建预约时必须做时间冲突检测,这个逻辑我在后面的章节单独展开,这里先看模型定义:
python复制class Reservation(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "待审批"
APPROVED = "approved", "已通过"
REJECTED = "rejected", "已拒绝"
CANCELLED = "cancelled", "已取消"
COMPLETED = "completed", "已完成"
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reservations")
equipment = models.ForeignKey(Equipment, on_delete=models.CASCADE, related_name="reservations")
start_time = models.DateTimeField("开始时间")
end_time = models.DateTimeField("结束时间")
purpose = models.CharField("使用用途", max_length=255)
status = models.CharField("状态", max_length=20, choices=Status.choices, default=Status.PENDING)
created_at = models.DateTimeField("提交时间", auto_now_add=True)
reviewed_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="reviewed_reservations")
reviewed_at = models.DateTimeField("审批时间", null=True, blank=True)
comment = models.TextField("备注", blank=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["equipment", "start_time", "end_time"]),
models.Index(fields=["user", "status"]),
]
Meta里的索引设置容易被新手忽略,但它直接决定了时间冲突检测和大规模数据下查询的效率。如果预约记录达到几万条之后,没有联合索引查询就会出现明显的卡顿。on_delete=models.CASCADE用在预约表上是合理的,因为预约是用户的操作行为记录,用户注销时其预约记录没有保留的必要。
3.4 耗材表与领用记录:库存与流水分离
耗材管理采用了“库存表+流水表”的经典设计,库存表只记录当前剩余数量,流水表记录每一次出库入库的操作痕迹。这样设计的好处是:统计“这个月吸头用了多少”时,直接聚合流水表就能得到准确数字,不需要依赖库存表的差值计算。
python复制class Consumable(models.Model):
name = models.CharField("耗材名称", max_length=100)
spec = models.CharField("规格型号", max_length=100, blank=True)
unit = models.CharField("单位", max_length=20, default="包")
total_quantity = models.PositiveIntegerField("入库总量", default=0)
current_stock = models.PositiveIntegerField("当前库存", default=0)
threshold = models.PositiveIntegerField("低库存预警阈值", default=10)
updated_at = models.DateTimeField("更新时间", auto_now=True)
def is_low_stock(self):
return self.current_stock <= self.threshold
class ConsumableLog(models.Model):
class Action(models.TextChoices):
IN = "in", "入库"
OUT = "out", "领用"
consumable = models.ForeignKey(Consumable, on_delete=models.CASCADE, related_name="logs")
action = models.CharField("操作类型", max_length=10, choices=Action.choices)
quantity = models.PositiveIntegerField("数量")
operator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="操作人")
note = models.CharField("备注", max_length=255, blank=True)
created_at = models.DateTimeField("操作时间", auto_now_add=True)
设计时一定不要把“当前库存”直接和“入库量减去领用量”做动态计算,而要在每次入库/领用时同步更新current_stock字段。这个做法看似冗余,但它保证了列表页显示库存时不需要执行复杂的聚合查询,页面加载速度快很多。领用操作必须包在事务里:先更新current_stock,再写入ConsumableLog,两步操作要么全部成功要么全部回滚,避免出现流水记录了但库存没扣减的数据不一致问题。
3.5 操作记录表:追责和数据回溯的底气
除了上述核心业务表,我还加了一张通用的OperationLog表,记录所有关键操作行为。这个表在开发阶段看起来“没什么用”,但当线上出现“某台设备被谁借走了”“某笔耗材领用是谁操作的”这类问题的时候,它就是唯一可靠的答案来源。
python复制class OperationLog(models.Model):
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="操作用户")
action = models.CharField("操作动作", max_length=50)
target_type = models.CharField("操作对象类型", max_length=50)
target_id = models.PositiveIntegerField("操作对象ID")
detail = models.TextField("详细内容", blank=True)
ip_address = models.GenericIPAddressField("IP地址", null=True, blank=True)
created_at = models.DateTimeField("操作时间", auto_now_add=True)
class Meta:
ordering = ["-created_at"]
操作日志的写入可以放在Django的post_save信号里,也可以在各视图函数中手动调用,我更推荐后者。信号的方式虽然省代码,但调试时容易因为信号抛出异常而影响主流程,手动调用虽然多写几行但意图清晰。
4. 核心业务逻辑:设备预约的时间冲突检测
4.1 冲突检测的查询思路:区间重叠的四种情况
设备预约系统最核心的算法是时间冲突检测。判断两个时间段是否冲突,站在数据库查询的角度,本质是判断“当前预约时间段”和“数据库中已有预约时间段”是否存在重叠。两个区间重叠的条件,用逻辑表达就是:
已有预约的开始时间 < 新预约的结束时间,且已有预约的结束时间 > 新预约的开始时间。
这个判断涵盖了一种特殊情况:完全包含。比如已有预约是早上8点到下午6点,新预约是上午9点到10点,两者显然冲突,上面的条件也能正确捕获。很多新手一上来就想着“怎么排除包含关系”,其实根本不用排除,直接用这个条件就能覆盖四种重叠形态:新预约在已有预约内部、已有预约在新预约内部、新预约前半段重叠、新预约后半段重叠。
4.2 完整实现代码与事务处理
冲突检测的ORM查询写法如下:
python复制from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from datetime import timedelta
def check_conflict(equipment, start_time, end_time, exclude_id=None):
"""检查设备在指定时间段内是否已有冲突预约"""
queryset = Reservation.objects.filter(
equipment=equipment,
status__in=[Reservation.Status.APPROVED, Reservation.Status.PENDING],
start_time__lt=end_time,
end_time__gt=start_time,
)
if exclude_id:
queryset = queryset.exclude(id=exclude_id)
return queryset.exists()
def create_reservation(equipment_id, user, start_time, end_time, purpose):
"""创建预约,包含冲突检测事务保护"""
now = timezone.now()
if start_time < now:
raise ValueError("预约开始时间不能早于当前时间")
if end_time <= start_time:
raise ValueError("预约结束时间必须晚于开始时间")
# 最长使用时长限制
max_days = Equipment.objects.get(id=equipment_id).max_borrow_days
if (end_time - start_time) > timedelta(days=max_days):
raise ValueError(f"单次预约时长不能超过{max_days}天")
with transaction.atomic():
if check_conflict(equipment_id, start_time, end_time):
raise ValueError("该时间段已有预约,请选择其他时间段")
reservation = Reservation.objects.create(
user=user,
equipment_id=equipment_id,
start_time=start_time,
end_time=end_time,
purpose=purpose,
status=Reservation.Status.PENDING
)
OperationLog.objects.create(
user=user,
action="create_reservation",
target_type="reservation",
target_id=reservation.id,
detail=f"预约设备ID={equipment_id}, 时间: {start_time} ~ {end_time}"
)
return reservation
这里需要注意几个边界条件的设计。第一个是status__in=[APPROVED, PENDING]——待审批的预约也必须参与冲突检测,否则会出现两个人都提交了待审批申请,管理员还没来得及审批就占用同一个时间段的情况,先审批的人通过后,后审批的人再拒绝,会造成极差的用户体验。第二个是事务保护——冲突检测和预约创建必须放在同一个transaction.atomic()块里,否则在检测通过之后、创建记录之前,另一个请求插进来创建了预约,就会产生数据竞争,导致“超卖”的情况。
4.3 并发场景下的“超卖”问题与数据库锁
上面的事务处理解决不了高并发下的竞态问题。当两个请求几乎同时提交冲突的预约,两个进程都执行了check_conflict,发现没有冲突,然后都创建了记录,系统里就出现了重叠的预约。解决这个问题的标准方案是使用数据库的select_for_update()行级锁:
python复制with transaction.atomic():
# 对设备行加锁,锁住之后其他请求在读取同一设备行时会等待
equipment = Equipment.objects.select_for_update().get(id=equipment_id)
if check_conflict(equipment_id, start_time, end_time):
raise ValueError("该时间段已有预约,请选择其他时间段")
Reservation.objects.create(...)
select_for_update()会在数据库层面锁住这条设备记录,第二个请求执行到这一步时会阻塞,直到第一个请求的事务提交或回滚。这样就从根源上杜绝了并发预约冲突。对于实验室内部系统,并发量通常不大,但写代码时养成加锁的习惯能避免未来极端场景下的严重bug。
5. 权限与审批:不同角色看到的世界完全不同
5.1 Django内置权限体系的组合用法
实验室系统的角色需求并不复杂:学生能浏览设备、提交预约、领用耗材;教师能审批预约、查看课题组使用记录;管理员拥有全部权限,还能维护设备台账、耗材库存、管理用户账号。
Django的auth框架本身就提供Group和Permission两个模型,但直接操作权限对象比较繁琐。我在实际开发中采用“角色字段+自定义装饰器”的方式,比用内置权限系统更直观好维护。先定义一个装饰器:
python复制from functools import wraps
from django.core.exceptions import PermissionDenied
def role_required(*allowed_roles):
def decorator(view_func):
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
if not request.user.is_authenticated:
from django.contrib.auth.views import redirect_to_login
return redirect_to_login(request.get_full_path())
profile = request.user.profile
if profile.role not in allowed_roles:
raise PermissionDenied("您没有权限执行此操作")
return view_func(request, *args, **kwargs)
return _wrapped_view
return decorator
用法就是在视图函数上加装饰器:
python复制@login_required
@role_required("teacher", "admin")
def approve_reservation(request, reservation_id):
...
这种自定义装饰器的方式比散落在视图函数里挨个判断if profile.role != "admin"要优雅得多,权限逻辑集中管理,一目了然。
5.2 审批流程的状态机设计
预约审批流程本质上是一个状态机,核心状态是PENDING到APPROVED或REJECTED,用户取消预约时从PENDING变为CANCELLED,设备归还后从APPROVED变为COMPLETED。为防止状态跳转混乱,我在视图层做了严格的合法性校验:
python复制def approve_reservation(request, reservation_id):
reservation = get_object_or_404(
Reservation.objects.select_related("equipment", "user"),
id=reservation_id
)
if reservation.status != Reservation.Status.PENDING:
messages.error(request, "该预约已处理,不能重复审批")
return redirect("equipment:reservation_detail", reservation_id=reservation.id)
if request.method == "POST":
action = request.POST.get("action")
with transaction.atomic():
if action == "approve":
reservation.status = Reservation.Status.APPROVED
reservation.reviewed_by = request.user
reservation.reviewed_at = timezone.now()
reservation.save()
elif action == "reject":
reservation.status = Reservation.Status.REJECTED
reservation.reviewed_by = request.user
reservation.reviewed_at = timezone.now()
reservation.comment = request.POST.get("comment", "")
reservation.save()
return redirect("equipment:reservation_detail", reservation_id=reservation.id)
审批通过后,如果设备当前状态是“空闲”,可以把设备状态改为“借出”,记录当前借用人。我采用的是预约通过后设备状态不变,等实际借出时再点击“确认借出”按钮更新设备状态。这样设计的理由是:学生可能提前几天预约,预约通过时设备还在别人手里用,不能提前把设备状态改成“借出”。
5.3 视图层面的越权访问防护
权限控制最容易漏掉的是“水平越权”——普通用户A能不能查看或修改普通用户B的预约记录?Django的get_object_or_404默认按主键查询,如果用户A手动构造URL把reservation_id改成用户B的预约ID,就能看到别人的预约信息。修复方式是在查询时强制加上用户过滤条件:
python复制# 学生只能查看自己的预约
reservation = get_object_or_404(
Reservation,
id=reservation_id,
user=request.user
)
# 教师可以查看自己课题组的预约
reservation = get_object_or_404(
Reservation,
id=reservation_id,
user__profile__group_name=request.user.profile.group_name
)
这个细节在上线后的安全测试中帮我发现了多个类似的越权漏洞。开发阶段如果只关注“功能能不能跑通”而忽略访问控制,系统上线就相当于把大门敞开着,任何人都能通过篡改URL访问到别人的数据。
6. 数据可视化:设备利用率与耗材消耗趋势
6.1 Excel表格做不到的“趋势认知”
老周用了三年Excel管实验室,最头疼的不是登记,而是“回头看”。设备买回来一年到底用了多少次?哪些设备闲置率高?暑假期间耗材消耗是不是明显下降?这些问题在Excel里需要手动做透视表,如果数据量大了还要写公式,实在不友好。数据可视化的目的不是“好看”,而是让管理决策变得有依据:买新设备前先看旧设备利用率,补耗材时看历史消耗趋势,做到有的放矢。
6.2 后端接口:从ORM聚合到JSON输出
我设计了一个统计视图,通过ORM的聚合查询计算每台设备的月度使用次数和总时长:
python复制from django.db.models.functions import TruncMonth
from django.db.models import Count, Sum, F, ExpressionWrapper, DurationField
def equipment_usage_stats(request):
stats = (
Reservation.objects
.filter(status=Reservation.Status.COMPLETED)
.annotate(month=TruncMonth("created_at"))
.values("equipment__name", "month")
.annotate(
use_count=Count("id"),
total_duration=Sum(
ExpressionWrapper(
F("end_time") - F("start_time"),
output_field=DurationField()
)
)
)
.order_by("equipment__name", "month")
)
return JsonResponse(list(stats), safe=False)
这里用TruncMonth把时间戳截断到月份,Count统计使用次数,ExpressionWrapper加DurationField计算总使用时长。前端ECharts拿到JSON后渲染柱状图和折线图。设备利用率的计算我采用了更直观的口径:某台设备某月的实际预约小时数除以该月总可用小时数。这样算出来的数据能直接反映设备是否被充分使用。
6.3 前端ECharts图表渲染
前端页面在Django模板中引入ECharts的CDN资源,用Ajax请求后端接口:
html复制<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<div id="usageChart" style="width: 100%; height: 400px;"></div>
<script>
fetch("/equipment/api/usage-stats/")
.then(response => response.json())
.then(data => {
const months = [...new Set(data.map(item => item.month))];
const equipmentNames = [...new Set(data.map(item => item.equipment__name))];
const chart = echarts.init(document.getElementById("usageChart"));
const series = equipmentNames.map(name => ({
name: name,
type: "bar",
data: months.map(month => {
const record = data.find(d => d.equipment__name === name && d.month === month);
return record ? record.use_count : 0;
})
}));
chart.setOption({
title: { text: "设备月度使用次数" },
tooltip: {},
legend: { data: equipmentNames },
xAxis: { type: "category", data: months },
yAxis: { type: "value", minInterval: 1 },
series: series
});
});
</script>
这套方案的好处在于Django后端只负责提供干净的数据接口,前端ECharts负责渲染,两者互不干扰。后续如果要把统计维度扩大到“按课题组统计”“按设备类型统计”,只需要在后端增加新的API视图或在接口中增加过滤条件。
6.4 低库存预警:自动变成消息而不是人肉盯数据
耗材低库存预警是我做进系统里最有“实操感”的功能。管理员不需要每天逐个看耗材表的“当前库存”列,系统会定期扫描所有耗材,把current_stock <= threshold的记录自动汇总并发送到管理员的系统通知里。实现方式很简单,写一个函数在管理员登录后的首页顶部展示:
python复制def low_stock_list():
return Consumable.objects.filter(current_stock__lte=F("threshold")).order_by("current_stock")
在管理员首页模板中:
html复制{% if low_stock_items %}
<div class="alert alert-warning">
<strong>低库存预警:</strong>
{% for item in low_stock_items %}
{{ item.name }}(剩余{{ item.current_stock }}{{ item.unit }})
{% endfor %}
<a href="{% url 'consumable:list' %}">去补货</a>
</div>
{% endif %}
如果想把预警推到更主动的渠道,比如邮件或企业微信机器人,只需在Django的management command里定时执行同样的查询并调用webhook接口即可。我后来把这个逻辑做成了一个cron任务,每天上午给老周推送一次预警汇总,他再也不用自己翻表格了。
7. 部署、环境配置与上线后的真实“踩坑”记录
7.1 Python版本与虚拟环境:第一步就没得商量
开发环境使用的是Python 3.10,部署时最怕服务器上已经装了其他版本的Python。我强烈建议在所有项目中一律使用虚拟环境,这是Python开发最基础也是最重要的习惯。具体做法:
bash复制python -m venv venv
source venv/bin/activate # Windows上执行 venv\Scripts\activate
pip install django==4.2.0
pip install mysqlclient # 如果使用MySQL
pip install gunicorn # Linux部署时用
pip freeze > requirements.txt
requirements.txt锁死依赖版本号,部署到新环境时一条pip install -r requirements.txt就能复现环境。我在上线时遇到过一次“为什么我在服务器上新装的Django版本和开发时不一样”的困惑,就是因为没有用requirements锁版本,后来用pip freeze重新生成了依赖清单才发现部分包版本被pip自动升级到了不兼容的新版。
7.2 时区问题:预约时间整体错位8小时
这是上线后遇到最隐蔽也最影响使用的bug。Django默认的USE_TZ=True时,所有时间以UTC存储,渲染到模板时Django会自动转换到TIME_ZONE设置的时区。如果settings.py的TIME_ZONE没有设置成Asia/Shanghai,用户在前台选择的时间会被当成UTC时间存入数据库,展示时再被转换回来,就出现了“预约早上9点变成了下午5点”的诡异现象。
解决方案是在settings.py里明确配置:
python复制TIME_ZONE = "Asia/Shanghai"
USE_TZ = True
USE_TZ=True保持开启,因为这是Django处理时间的最佳实践,数据库里统一存UTC时间,展示层自动转为本地时间,后续做跨时区统计分析时不会被历史数据坑。但必须配合正确的TIME_ZONE设置,否则就会出上面的错位问题。
7.3 静态文件404:DEBUG关闭后的经典坑
开发模式下Django会自动处理静态文件,一切看起来都很美好。上线时如果把DEBUG设为False,所有CSS、JS、图片全部404,页面变成纯HTML裸样式。原因是生产模式下Django默认不提供静态文件服务,需要额外配置:
python复制DEBUG = False
ALLOWED_HOSTS = ["lab.example.edu.cn", "192.168.1.100"]
# 静态文件收集
STATIC_ROOT = BASE_DIR / "staticfiles"
然后执行:
bash复制python manage.py collectstatic
如果不想引入Nginx配置反向代理,最简单的方式是让Django临时处理一下静态文件,在urls.py里加:
python复制from django.conf import settings
from django.conf.urls.static import static
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
严格来说这只是过渡方案,真正的生产部署应该用Nginx作为反向代理,静态文件交给Nginx直接返回,Django只处理动态请求。但考虑到实验室内网系统的访问量,上面这个过渡方案已经能稳定运行。
7.4 数据库备份:一条cron命令保平安
系统运行一段时间后,数据就是最有价值的资产。设备台账、预约记录、耗材流水,丢了以后不可能再恢复。SQLite的备份非常简单,直接拷贝数据库文件就行,写进cron定时任务:
bash复制0 2 * * * cp /path/to/db.sqlite3 /backup/db_$(date +\%Y\%m\%d).sqlite3
find /backup -type f -name "*.sqlite3" -mtime +30 -delete
第一行每天凌晨2点备份数据库文件到指定目录,第二行自动删除30天前的旧备份,防止备份文件堆积占用磁盘。如果换成MySQL,备份命令换成mysqldump即可。这条cron任务是我上线后第一时间设好的,后来真的有次误操作把设备表清空了,靠前一天的备份恢复了数据,这一刻才真正理解了“备份是系统开发的一部分”这句话的分量。
7.5 从SQLite迁移到MySQL的完备路径
实验室系统跑了一段时间后,如果数据量增长明显,或者需要支持多人同时写入导致SQLite出现database is locked错误,就该考虑迁移到MySQL了。迁移步骤不复杂:
- 在MySQL中创建数据库和用户,分配权限;
- 修改Django的
settings.py数据库配置为MySQL连接信息; - 如果使用了Django内置的部分模型,先执行
python manage.py migrate在MySQL中创建表结构; - 编写数据迁移脚本,从SQLite读取数据后写入MySQL,注意自增ID要保持一致,否则外键关系会错乱;
- 执行
python manage.py runserver测试验证,确认后再切换线上配置。
Django的ORM让这套迁移过程远没有想象中复杂,核心风险点主要在数据导入时的外键关联和自增主键一致性,提前写个脚本做一次全量对比校验就能规避。
8. 还没结束的后续:从“能用”到“好用”的迭代方向
系统上线后的第一周,老周给我的反馈是“终于不用每天追着人登记了”。但作为一个实际使用过这套系统的人,我清楚它距离“好用”还有不少路要走。目前已经在规划或已经着手开发的迭代方向有三个。
第一个方向是移动端适配。现在系统在手机上打开虽然能看,但按钮偏小、表格需要横向滑动,体验一般。考虑到现在学生更习惯用手机查看预约情况和接收通知,我计划用Bootstrap的响应式布局把几个核心页面(设备查询、预约提交、我的预约)优化成更接近移动端的交互形态,再结合企业微信的H5页面嵌入口,让用户在微信里直接打开就能用,不需要额外安装App。
第二个方向是设备维护记录模块。设备不是“借出-归还”就结束了,还有定期校准、故障维修、保养记录等信息需要沉淀。目前这些信息还是靠维修师傅的纸质工单管理,我计划在equipment应用里增加MaintenanceRecord模型,记录每次维修的时间、内容、费用和更换配件,并与设备详情页关联展示,让每一台设备的“病历”变得可视化。
第三个方向是引入更细粒度的权限模型。目前的角色只有学生、教师、管理员三种,满足当前实验室需求,但如果未来要扩展到院级平台,就需要增加“设备管理员”这种针对单一设备的负责人角色,设备管理员可以审批自己负责设备的预约、管理该设备的维护记录,但无权操作其他设备。Django的权限框架对这一类需求也有成熟的扩展方案,在现有代码结构上增加一层关联关系控制即可。
对于一个学院实验室的管理需求,Python加Django这套组合方案的开发成本、维护成本、扩展能力都达到了一个很好的平衡。我现在的体会是,这类系统真正的难度不在技术实现,而在你愿不愿意沉下心去理解“使用者到底被什么问题困住”。把老周的Excel换成Web系统只是表象,真正有价值的是让信息从人的记忆和纸质本子上,变成一套可查询、可统计、可追溯的数据资产。花三周时间写代码是值得的,因为它省下来的是之后每一天的重复沟通与低效查找。
