去年帮朋友所在的一家社区医院搭过一套医疗仪器设备管理系统,说实话,一开始大家以为写个Excel表就能搞定,等真到设备盘点、维修记录、借用登记全都堆在一起的时候,才发现没有一个统一的系统根本没法管。最后我用Python和Flask从零写了一套,断断续续改了三个星期,上线跑了半年,至少把设备科的同事从“翻纸质台账”里救了出来。这篇就围绕这个基于Flask的医院医疗仪器设备管理系统,聊聊我是怎么拆需求、选技术、写功能、踩坑排错的全过程。如果你正在做课程设计、毕业设计,或者医院内部想搞一套轻量级的设备管理系统,这文章基本能给你一条完整路线。
1. 项目概述与需求拆解
1.1 医院设备管理的真实痛点与核心需求
医院里的医疗仪器设备,小到血压计、注射泵,大到彩超、CT机,种类和数量都非常多。以前设备科管理这些设备,靠的是Excel表格加纸质登记本,设备入库就填一行,借出去就记一个名字,还回来再划掉。听起来简单,实际用起来全是问题:设备多的时候找不到哪台在哪儿,维修记录散落各处,设备报废日期没人记得,到了需要统计设备使用率的时候,数据根本拼不出来。
所以我做这套系统时,第一件事不是打开编辑器,而是先把业务方叫到一起聊需求。最终整理下来,核心就几条:
- 设备档案要全:型号、序列号、所属科室、生产厂家、购入时间、价格、报废日期一个都不能少。
- 状态必须实时:设备是在库、借出、维修、报废,得一眼能看到。
- 借还要有记录:谁借的、什么时候借的、什么时候还的、经手人是谁,都要留痕。
- 维修保养要追踪:设备坏了要登记,修完要能查维修历史,还要能提醒定期检定。
- 报表统计要快:按科室、按设备类型、按状态统计,不能再靠人工数。
这些需求听起来不复杂,但对数据关系的要求其实比较细。比如“设备”和“维修记录”是一对多关系,“设备”和“借用记录”也是一对多关系,每台设备还要绑定一个科室和一个设备类型。如果一开始表结构设计错了,后面写功能的时候会越写越痛苦。
1.2 为什么选Python和Flask
确定需求之后,技术选型反而是最顺的那一步。医院内部系统,一般不需要高并发,也不需要复杂的分布式架构,要的就是快速落地、方便维护、业务逻辑清晰。我当时直接锁定了Python加Flask这个组合。
选Python,是因为它语法简洁,开发速度快。设备管理这种业务系统,主要工作是写增删改查、处理表单、渲染页面,Python的标准库加第三方库可以覆盖绝大部分需求。而且医院设备科的人不是专职程序员,后续如果有个简单的脚本需求,或者要改个统计逻辑,Python也方便交接。
选Flask,是因为它足够轻量,又足够灵活。Django虽然功能全,但自带admin、ORM、表单体系,对于一个“设备管理系统”来说有点重,而且Django的约束比我想要的要多。FastAPI这几年很火,性能强、异步支持好,但它更适合写API接口,而不是直接渲染Jinja2模板做传统Web页面。Flask在这两者之间刚好平衡:路由自己定,ORM可以自己选,模板直接用Jinja2,想加功能加插件,不需要的功能一个都不要。
这里也顺带说一句Flask和FastAPI怎么选。如果你的项目是给内部人员用的管理后台,主要靠页面操作,Flask反而更顺手,因为模板渲染、表单提交、session管理都是现成的。FastAPI更适合做纯后端接口,配合Vue这类前端框架用。这两种思路没有高下之分,就看你团队习惯和项目形态。
2. 技术选型与架构设计
2.1 整体技术栈:Flask + SQLite/MySQL + Jinja2 + Bootstrap
这套系统的整体技术栈不算花哨,但很实用:
- 后端框架:Flask 2.x,负责路由、session、视图函数。
- 数据库:开发环境用SQLite,部署环境用MySQL。SQLite零配置,适合本地调试;MySQL更适合多人同时在线的场景。
- ORM:SQLAlchemy,配合Flask-SQLAlchemy插件操作数据库,避免手写SQL。
- 模板引擎:Jinja2,直接在HTML里写循环和判断,不用单独做前后端分离。
- 前端样式:Bootstrap 5,做后台管理界面没有任何违和感,表格、表单、提示框都现成。
- 表单处理:Flask-WTF,负责表单验证和CSRF防护。
- 登录认证:Flask-Login,管理用户会话。
这个组合最直观的好处是,一个人就能把全套写完。不需要单独起一个前端服务,也不用维护两套接口文档,Flask启动以后直接就能在浏览器里访问,对“内部管理系统”这个定位来说非常合适。
2.2 数据库设计与模型关系
数据库是整个系统的地基。我当时画了一遍关系图,最后落地成这几张表:
| 表名 | 主要字段 | 说明 |
|---|---|---|
| users | id, username, password_hash, role | 用户表,role分管理员和普通用户 |
| departments | id, name | 科室表,设备所属科室 |
| equipment_types | id, name | 设备类型表,比如超声类、生命体征监测类 |
| equipment | id, name, model, serial_no, type_id, department_id, status, purchase_date, purchase_price, warranty_expire, calibration_date | 设备主表 |
| borrow_records | id, equipment_id, user_id, borrower_name, borrow_time, return_time, note | 借用/归还记录 |
| repair_records | id, equipment_id, repair_time, description, cost, status | 维修记录 |
关键的关联关系是:设备与科室多对一,设备与类型多对一,设备与借用记录一对多,设备与维修记录一对多。这里最需要注意的坑是,设备的状态字段不能只靠“当前在哪张表里的记录”去推断,否则查询非常复杂。我当时直接在equipment表里建了一个status字段,值为“在库、借出、维修、报废”四个枚举,所有状态查询都先走这个字段,借用记录和维修记录只是用来追溯历史。这样设计虽然有一点冗余,但查询效率高很多,业务逻辑也简单。
2.3 项目目录结构与模块划分
Flask项目如果没有规划好目录结构,很容易写成一个大文件堆积的“微服务大山”。我推荐用蓝图的组织方式,按业务模块划分:
hospital_equipment_system/ ├── app/ │ ├── __init__.py # 创建Flask应用,注册蓝图 │ ├── models.py # 所有数据库模型 │ ├── forms.py # 表单验证类 │ ├── views/ │ │ ├── __init__.py │ │ ├── auth.py # 登录、退出、用户管理 │ │ ├── equipment.py # 设备档案管理 │ │ ├── borrow.py # 借用归还 │ │ ├── repair.py # 维修保养 │ │ └── report.py # 统计报表 │ ├── templates/ # Jinja2模板 │ └── static/ # CSS、JS、图片 ├── config.py # 配置文件 ├── run.py # 启动入口 └── requirements.txt # 依赖清单这样做的好处是,每个蓝图只负责自己的路由和视图,文件之间没有循环引用。比如equipment.py里只写设备增删改查,borrow.py里只写借还流程,想看哪块业务就去哪个文件,不会出现一个文件两千行的恐怖场景。
3. 核心功能实现与实操要点
3.1 设备档案管理:增删改查与条件筛选
设备档案管理是整个系统的首页功能。在设备列表页,我需要看到所有设备的基本信息,并且能够按设备名称、型号、科室、状态来筛选。
这个功能看起来简单,但“条件筛选”的坑不少。我一开始是写死的SQL拼接,结果每次加一个筛选条件就要改一次查询逻辑,后来统一改成了SQLAlchemy的动态条件查询,核心思路是这样的:
- 先构建一个query对象:
Equipment.query - 如果用户传入了关键词,就加一个
eq.filters条件 - 如果用户选择了科室,就加一个
department_id条件 - 状态筛选同理,最后再调用
.paginate()分页
用if判断逐步追加查询条件,比拼SQL字符串安全得多,而且SQLAlchemy能帮你处理参数转义,避免注入风险。这里也提醒一句,绝不要直接用f"SELECT * FROM equipment WHERE name = '{keyword}'"这种写法,我在网上见过太多课程设计代码这么干,放在真实系统里就是送人头。
设备的新增和编辑表单,我用Flask-WTF定义了一个EquipmentForm类,字段包括名称、型号、序列号、类型、科室、购入日期等。表单类里写好了validators,比如序列号必填、购入日期必须是日期格式。表单验证通过后再写入数据库,这样前端和后端都有一层校验,不会出现用户随便填一个非法数据就入库的情况。
3.2 借出与归还流程:状态流转与记录留存
设备借出和归还是在库状态变化最频繁的场景。我在设计时把流程拆成两步操作:
- 借出:用户填写借用单,选择设备、填写借用人、预计归还时间,提交后系统把设备状态从“在库”改为“借出”,同时插入一条borrow_records记录。
- 归还:用户打开借用列表,找到未归还的记录,点击“归还”,系统把设备状态改回“在库”,同时更新borrow_records的return_time字段。
这里有一个很重要的细节:借用记录不能直接删除。哪怕还错了设备,也不能直接把记录删掉重来,正确做法是加一条“归还备注”,在界面上允许管理员修改。这符合设备管理的审计要求,出了问题能够追溯。
为了避免设备已经被借出情况下又被重复借出,我在提交借用单之前加了一个状态检查。如果设备当前状态不是“在库”,就拒绝操作并返回提示。这个检查在高并发场景下可能会有竞争问题,但对一个医院内部系统来说已经够用了,后面我会专门讲一下并发和事务处理。
3.3 维修保养提醒:到期提示与统计报表
设备维修记录维护相对独立,但有一个很头疼的问题:设备什么时候该检修、什么时候过保修期,不能光靠脑子记。
我在equipment表里加了两个日期字段:calibration_date(下次检定日期)和warranty_expire(保修到期日期)。然后写了一个提醒查询:只要当前日期距今30天内的设备,都在首页显示“即将检定”或“即将到期”。这个逻辑不复杂,但效果非常好,设备科同事一登录就能看到近期该做什么。
维修记录本身也是独立的表和独立的管理页面,每条维修记录关联一台设备,记录维修时间、维修单位、故障描述、维修费用和结论。统计报表页面则按设备类型、科室维度展示设备数量、维修次数、维修费用合计。报表页我之前用过图表库,后来发现直接用Bootstrap的表格加几个合计行反而更清晰,因为设备科的人要的是数据,不是花哨的图表。
3.4 用户登录与权限控制
虽然是内部系统,登录和权限还是必须的。我用Flask-Login管理用户会话,用户的密码以哈希形式存到数据库,绝对不存明文密码。
权限方面设置了两类角色:
- 管理员:可以增删改设备、管理用户、查看所有借还和维修记录。
- 普通用户:只能查看设备列表、借用设备和登记维修。
实现方式是在需要权限的视图函数上添加一个装饰器,比如@admin_required,里面判断当前用户的角色。模板里也根据角色显示不同按钮,普通用户看不到“删除设备”“管理用户”这些入口。
这里的坑是,权限控制不能只靠隐藏按钮,后端接口同样要做校验。我在每个修改类的路由里都检查了当前用户是否有管理员权限,不然用户直接构造一个POST请求就能删设备,那就尴尬了。
4. 实操过程与关键代码解析
4.1 环境准备与项目初始化
先说环境,别直接用系统自带的Python,一定要用虚拟环境,这是我在本地开发时最常用的操作:
# 创建虚拟环境 python -m venv venv # 激活虚拟环境,Windows和macOS/Linux命令略有差异 # Windows: venv\Scripts\activate # Linux/macOS: source venv/bin/activate # 安装依赖 pip install flask flask-sqlalchemy flask-login flask-wtf pymysql cryptography在Windows上有时会遇到python不是内部命令的情况,通常是因为没有把Python安装目录加到环境变量。我一般建议直接勾选安装器里的“Add Python to PATH”,省得后面折腾。
装完依赖后,写一个最基本的run.py:
from app import create_app app = create_app() if __name__ == '__main__': app.run(debug=True)create_app放在app/__init__.py里,用来创建Flask应用实例、加载配置、初始化数据库和蓝图。这样写的好处是,测试的时候可以直接导入app,部署的时候也可以直接用WSGI服务器加载,不用改代码。
4.2 数据库模型定义与迁移
我用的模型定义方式比较直白,直接在models.py里写类:
from datetime import datetime from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash db = SQLAlchemy() class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(50), unique=True, nullable=False) password_hash = db.Column(db.String(128), nullable=False) role = db.Column(db.String(20), nullable=False, default='user') def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) class Equipment(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) model = db.Column(db.String(100)) serial_no = db.Column(db.String(100), unique=True, nullable=False) type_id = db.Column(db.Integer, db.ForeignKey('equipment_type.id')) department_id = db.Column(db.Integer, db.ForeignKey('department.id')) status = db.Column(db.String(20), default='available') # available/borrowed/repair/scrapped purchase_date = db.Column(db.Date) purchase_price = db.Column(db.Numeric(10, 2)) warranty_expire = db.Column(db.Date) calibration_date = db.Column(db.Date) created_at = db.Column(db.DateTime, default=datetime.now) type = db.relationship('EquipmentType', backref='equipments') department = db.relationship('Department', backref='equipments')注意外键字段命名,我用的是type_id和department_id,数据库中会自动创建对应外键。status用字符串而不是数字枚举,因为读起来更直观,而且前端模板里判断也简单。
初始化数据库时,我直接在项目的run.py里加了一个建表操作:
with app.app_context(): db.create_all()首次运行会自动建表。后续如果改了模型字段,不要直接删表重建,那就丢数据了。正确做法是用Flask-Migrate管理迁移,或者单独写SQL脚本同步。个人项目图省事,我是在开发阶段反复db.create_all(),上线之后就不再轻易改表结构了,改之前一定先备份。
4.3 路由与视图函数设计
设备列表页的路由是典型的“查询+分页+模板渲染”模式,我贴一下核心代码:
from flask import render_template, request from .models import Equipment, EquipmentType, Department @equipment_bp.route('/equipment') def list_equipment(): page = request.args.get('page', 1, type=int) name = request.args.get('name', '', type=str) status = request.args.get('status', '', type=str) department_id = request.args.get('department_id', 0, type=int) query = Equipment.query if name: query = query.filter(Equipment.name.contains(name)) if status: query = query.filter(Equipment.status == status) if department_id: query = query.filter(Equipment.department_id == department_id) pagination = query.order_by(Equipment.created_at.desc()).paginate( page=page, per_page=10, error_out=False ) equipments = pagination.items return render_template( 'equipment/list.html', equipments=equipments, pagination=pagination, types=EquipmentType.query.all(), departments=Department.query.all(), )这里面有几个容易踩的坑。第一,request.args.get('page', 1, type=int)如果不加type=int,用户直接输入?page=abc会导致页面崩溃。第二,error_out=False意思是当请求的页数超出范围时,不抛404错误,而是返回空列表。第三,模板里需要显示上一页下一页,但分页对象要保留当前的查询参数,不然翻页以后筛选条件就丢了。我的做法是在模板里把查询参数拼到链接上:
<a href="{{ url_for('equipment.list_equipment', page=pagination.prev_num, name=request.args.get('name'), status=request.args.get('status'), department_id=request.args.get('department_id')) }}">上一页</a>用request.args.get来还原查询参数,保住了筛选状态,这块坑我印象很深,一开始没做的时候,点第二页直接变成“所有设备”,找了好久才发现是参数没带上。
4.4 模板渲染与表单交互
Jinja2模板是Flask的一大优势,你可以直接在HTML里写逻辑。比如设备列表页,我用了Bootstrap的表格样式,并且根据状态给对应行加颜色:
{% for item in equipments %} <tr class="{% if item.status == 'repair' %}table-warning{% elif item.status == 'scrapped' %}table-secondary{% endif %}"> <td>{{ item.id }}</td> <td>{{ item.name }}</td> <td>{{ item.model }}</td> <td>{{ item.department.name }}</td> <td> {% if item.status == 'available' %} <span class="badge bg-success">在库</span> {% elif item.status == 'borrowed' %} <span class="badge bg-primary">借出</span> {% elif item.status == 'repair' %} <span class="badge bg-warning text-dark">维修</span> {% else %} <span class="badge bg-secondary">报废</span> {% endif %} </td> </tr> {% endfor %}在模板里直接取关联字段的写法item.department.name,前提是模型定义里配好了relationship,否则这里会拿到None。我建议在模板里先判断{% if item.department %},避免某些设备还没分配到科室时直接报错。
表单提交有个关键点:CSRF防护。Flask-WTF的CSRFProtect插件会给所有POST请求自动检查token,我在模板里每个<form>都加了一个{{ form.hidden_tag() }},否则提交以后会报“CSRF token missing”错误。这个错误在开发模式不致命,但上线前一定得处理,不然所有表单都会提交失败。
4.5 Flask部署要点
开发时用app.run(debug=True)很舒服,但千万不能直接暴露到服务器上。因为Flask自带的开发服务器性能不行,也不安全。我在Linux服务器上用的是Gunicorn加Nginx。
部署步骤大致这样:
# 在服务器上安装gunicorn pip install gunicorn # 启动应用,4个worker进程 gunicorn -w 4 -b 127.0.0.1:8000 run:app然后用Nginx做反向代理,把80端口转发到8000端口。Nginx的配置核心就是一个proxy_pass:
server { listen 80; server_name your_server_ip; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }如果服务器是Windows,Gunicorn安装不了,可以改用waitress:
pip install waitress waitress-serve --port 8000 run:app部署时还有两个坑。一个是静态文件路径,Flask默认帮你托管/static/目录,所以CSS和JS不会丢,但如果用了Nginx直接把静态目录交给Nginx处理会更快,不过这里要小心路径配置,建议先把默认的托管跑通再说。另一个是数据库文件权限,SQLite数据库所在目录必须让运行Web服务的用户有写权限,不然一写数据就报database is locked或者unable to open database file。
5. 常见问题与排查技巧实录
5.1 数据库乱码问题
很多人在Windows上开发,数据库用SQLite,存中文时发现读取出来是乱码。这个我踩过,原因大概率不是Flask编码问题,而是终端编码或者数据库连接字符串没指定编码。
SQLAlchemy连接SQLite一般不需要额外指定编码,UTF-8本来就支持中文,乱码通常发生在用MySQL的时候。用PyMySQL连接MySQL,必须在连接字符串里加上charset=utf8mb4:
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://username:password@host/dbname?charset=utf8mb4'同时在MySQL建表时也要指定表的默认字符集。很多初学者直接在MySQL客户端里建表,默认的latin1字符集存不了中文,这时候连接字符串再怎么写也没用。建表语句里加DEFAULT CHARSET=utf8mb4就可以了。
5.2 搜索、分页、排序组合实现的坑
设备列表页同时支持搜索、分页和状态筛选,之前我提到过翻页时参数丢失的问题,其实还有一个坑是排序。
当用户搜索“温度计”并筛选“维修”状态,然后又点列头按购入日期排序,这里一旦排序字段和方向处理不当,查询结果会变得很诡异。我的做法是把排序字段也作为查询参数传上去:
sort_by = request.args.get('sort_by', 'created_at') sort_order = request.args.get('sort_order', 'desc') if sort_order == 'desc': query = query.order_by(db.desc(getattr(Equipment, sort_by))) else: query = query.order_by(getattr(Equipment, sort_by))这里要注意,getattr(Equipment, sort_by)如果sort_by传进来一个不存在的字段名,会直接报AttributeError。所以必须做一个白名单校验:
allowed_sort_fields = {'name', 'created_at', 'purchase_date', 'id'} if sort_by not in allowed_sort_fields: sort_by = 'created_at'这个白名单思路也适用于其他参数校验,安全不靠运气,靠过滤。
5.3 并发借用冲突与状态更新
医院内部系统并发量不会太高,但也会遇到两个人同时想要借用同一台设备的情况。我在设计时用的是先检查后更新的方式,严格来说不是原子操作,万一两个人同时提交,可能出现两台设备状态错乱。
如果要严谨一点,就用事务加条件更新,SQLAlchemy里可以这样写:
from sqlalchemy import update from app.models import db, Equipment # 原子更新:只有状态是available才更新为borrowed result = db.session.execute( update(Equipment) .where(Equipment.id == equipment_id, Equipment.status == 'available') .values(status='borrowed') ) if result.rowcount == 1: # 插入借用记录 db.session.commit() else: db.session.rollback() # 说明设备已经被占用了rowcount如果等于0,说明更新没有命中,设备已经被别人借走了。这种写法把检查状态和更新状态合并成一个SQL,避免了“先查再改”产生的竞态条件。对设备管理系统这类应用来说,这个强度已经足够。
5.4 调试模式与生产环境的差异
开发阶段我一直开着debug=True,页面报错能看到完整的堆栈信息,改模板也会自动热加载。但有一回我上线之前忘了关,部署以后发现普通用户只要在URL后面加个路径,就能看到Flask的调试器交互页面,这非常危险。后来我在config.py里通过环境变量控制调试模式,默认生产环境关闭调试,部署时也不用手动改代码:
import os class Config: DEBUG = os.environ.get('FLASK_DEBUG', '0') == '1' SECRET_KEY = os.environ.get('SECRET_KEY', 'change-me-in-production')生产环境的SECRET_KEY一定要换成别人猜不到的长随机字符串,否则session容易被伪造。调试模式和生产环境的另一个差异是静态文件,开发时Flask动态加载CSS/JS,改动立刻生效;生产环境如果用了Nginx代理静态目录,改完文件记得清理浏览器缓存或者加版本号参数。
5.5 定时提醒的实现方案
设备到期检定提醒需要每天跑一次吗?其实不用搞那么复杂。我当时的方案是,每次登录后系统自动检查一次,逻辑放在一个公共函数里:
def get_reminders(user): from datetime import datetime, timedelta today = datetime.today().date() soon = today + timedelta(days=30) calibration_soon = Equipment.query.filter( Equipment.calibration_date.isnot(None), Equipment.calibration_date <= soon, Equipment.calibration_date >= today, Equipment.status != 'scrapped' ).count() warranty_expire_soon = Equipment.query.filter( Equipment.warranty_expire.isnot(None), Equipment.warranty_expire <= soon, Equipment.warranty_expire >= today, Equipment.status != 'scrapped' ).count() return calibration_soon, warranty_expire_soon这个函数在首页路由里调用,把数量渲染出来。如果以后要求真正定时跑,可以用APScheduler加一个定时任务,在Flask应用启动时注册BackgroundScheduler。但医院设备管理这个场景,登录时提醒已经完全够用了,没必要为了“技术炫技”引入额外组件。
最后再分享一个我个人的习惯:这类内部管理系统,代码写得再漂亮也不如数据备份重要。我上线后写了一个简单的备份脚本,每天凌晨把SQLite数据库文件复制到备份目录,保留最近7天。后来真有次误操作把一批设备删错了,靠备份恢复,设备科的同事没有抱着电脑绝望。这套系统本身不算复杂,真正值钱的是你对业务流程的理解和每一步操作背后的细节把控。希望这篇基于Flask的医院医疗仪器设备管理系统实战记录,能帮你避开我踩过的那些坑,顺利把你自己的系统搭起来。