医院设备科的报修群,每天从早响到晚。呼吸机的光耦烧了、心电监护仪的导联线断了、CT机房的温度报警又触发了……报修信息零散地堆在微信里,维修工程师背着工具包满楼跑,事后的记录要么写在纸质本子上,要么压根没写。等到年底盘点设备完好率,统计报表只能靠人工翻聊天记录拼凑。我最早接触“python-django医院医疗设备维修管理系统vue3”这个项目,就是在这样的背景下——设备科需要一个能替代微信群报修、把工单流转、设备台账、维修历史、统计报表全部线上化的系统,后端用Django提供稳定的API服务,前端用Vue3搭建操作界面。这篇博文会把整个项目的需求拆解、技术选型、数据库设计、实际开发步骤和踩坑经验完整讲一遍,适合正在做医疗信息化、设备管理系统,或者打算用Django+Vue3做业务管理系统的人参考。
1. 项目整体设计与技术选型拆解
1.1 标题背后的真实需求是什么
只看“医院医疗设备维修管理系统”这几个字,很容易做成一个普通的“报修工单系统”。但真正在一家综合医院里跑过设备管理的人会告诉你,核心痛点远不止“报修、派单、完单”这么简单。
医院设备大致分三类:大型影像设备(CT、MRI、DR)、生命支持设备(呼吸机、麻醉机、监护仪)、通用后勤设备(消毒机、血压计、输液泵)。每一类设备的生命周期管理要求完全不同,比如呼吸机这类急救设备,维修响应时间按分钟算,验收记录要能追溯到具体维修人员;CT这类大型设备,除了维修单,还需要关联巡检计划、保养记录、原厂服务合同。系统如果只做“报修单”这一个对象,后续十有八九要返工。
我把标题拆开看,真正的需求应该包含四块:一是设备台账数字化,给每台设备一个独立ID,沉淀购置信息、科室归属、使用状态;二是维修工单全流程流转,从报修、派单、接单、维修、验收、归档,每一步有记录、有人负责;三是维修历史与费用统计,能够查询某台设备修过几次、换过什么配件、花了多少钱;四是直观的数据看板,设备完好率、科室报修排行、维修及时率这些指标,管理员打开系统就能看到。
这套逻辑放在Django项目里,对应的是设备管理、工单管理、备件管理、统计报表、用户权限五大模块。我建议第一版就按这个边界划分app,而不是把所有表都塞进一个app里,后面扩展时你会感谢这个决定。
1.2 为什么是Django + Vue3,而不是其他方案
技术选型这块,我在做方案评审时反复比过。后端如果图省事,可以直接用Django的模板系统加Admin,十天就能出一个能用的内部系统。但考虑到医院信息科未来大概率要对接HIS、LIS或者物资系统,接口必须标准化,前后端分离是更稳的选择。
Django作为后端框架在医疗信息类项目里有几个天然优势。ORM足够成熟,不用手写SQL就能做多表联查;自带Admin后台,开发阶段给设备科做基础数据维护界面几乎零成本;Django REST Framework(DRF)把序列化、分页、认证、权限这些API开发必备的东西全封装好了,开发者能集中精力写业务逻辑。另外Python生态里有大量医疗数据处理相关的库,哪怕以后要做设备数据采集分析,后端语言保持不变也能平滑扩展。
前端选Vue3,一方面是因为Element Plus提供了一整套中后台管理组件,表格、表单、弹窗、日期选择器开箱即用,很适合设备管理这类功能密集的管理系统;另一方面,Vue3的Composition API在维护复杂页面状态时比Vue2的Options API更顺手,一个维修工单详情页要同时展示设备信息、维修步骤时间线、配件清单、操作按钮,这种多状态联动的场景非常适合用Composition API组织逻辑。
另一点容易被忽略的是团队招聘成本。国内做医疗信息化的技术团队,大部分人熟悉Python和Vue,招人比找Rails或者Angular的人容易得多。技术选型不是越新越好,而是要考虑团队能长期维护。这一点在医疗项目里尤为重要,系统交付后往往要维护五六年。
1.3 系统架构与核心模块设计
整个系统采用前后端分离的单体架构,Django提供REST API,Vue3打包成静态文件后由Nginx托管,两边的数据交互走JSON。医院内网环境下,单体架构比微服务更容易部署和排查问题,一台内网服务器就能跑起来。
业务模块划分如下:
- 设备管理:设备台账、设备分类、设备档案详情、设备保养计划
- 工单管理:报修单创建、待派单列表、维修记录填报、验收/关闭工单
- 备件管理:备件出入库、备件与维修工单关联
- 统计报表:设备完好率、科室报修排行、工程师工作量、维修费用统计
- 系统管理:用户管理、角色权限、数据字典、操作日志
工单是贯穿这套系统的核心对象。一台设备从报修到归档,数据链条是这样的:报修人从设备台账里选择故障设备,创建报修单;设备科管理员在待派单列表里指派工程师;工程师接单后填写故障原因、维修措施、更换配件;最后报修人或者管理员验收并关闭工单。每一步状态变化,系统都要写一条操作记录。
这套设计对医院场景有一个很关键的好处:任何一台设备出事,都能查清楚“谁报修的、谁修的、换了什么、修了多久、花了多少钱”。一旦出现医疗设备相关的事故纠纷,这套审计链条是设备科最有力的说明依据。
2. 核心细节解析与关键模型设计
2.1 设备台账模型:主数据是一切的基础
设备台账是这套系统里最不能糊弄的部分,因为所有工单、报表都挂在设备ID下面。如果设备编码混乱、科室归属不清,后续的统计全都会失真。
我在设计Device模型时,核心字段分了四类。身份标识类包含设备编码、设备名称、出厂编号、资产编号;分类属性类包含设备分类外键、型号、生产厂家、供应商;位置与状态类包含所属科室、安装位置、设备状态、启用日期;合同与财务类包含购置日期、购入金额、保修截止日期、折旧状态。设备分类我单独建了一张自关联的DeviceCategory表,支持二级分类,比如“生命支持设备-呼吸机-有创呼吸机”,这样后续统计可以按大类或者小类分别聚合。
字段类型上有几个细节值得展开说。设备编码设置unique=True,同时用Django的db_index=True建索引,因为设备编码会高频出现在查询条件和报表分组里。生产厂家和供应商这类字段,看起来像是字符串,但建议单独建表存,避免出现“某公司”和“某公司 ”(带空格)这种脏数据。设备的扩展属性,比如CT的球管使用次数、核磁共振的液氦剩余量,不同设备差别很大,用JSONField存灵活性最高,不必为每种设备单独建字段。
注意:设备编码规则要在开发前跟设备科确认好。我见过一个项目,设备科报上来的编码规则和资产科的系统对不上,导致系统上线前要重刷几千条数据。最稳妥的做法是把设备编码设计成“科室缩写+设备类型+流水号”,并且保证全系统唯一。
关键要理解为什么台账要独立成模块而不是塞在维修工单里。工单系统关注的是“某一次维修过程”,而台账关注的是“这台设备的全生命周期”。把两者分开,设备每次维修后台账里的设备状态(正常/维修中/待报废)由工单完成事件驱动更新,这样数据职责清晰,不会出现多个人改同一个字段引起的数据不一致。
2.2 维修工单的状态机与历史追踪
维修工单是整个系统的核心流程载体。我见过不少初版设计把状态放在一个简单的CharField里,然后视图函数里自由赋值,结果线上跑一段时间后,一个工单出现了“已验收”和“维修中”同时存在的混乱。原因就是没有在模型层约束状态流转。
我的做法是在模型里定义状态常量,同时写一个transition方法做状态流转校验。RepairOrder模型的核心字段包括:工单号(唯一)、设备外键、报修人外键、故障描述、紧急程度、当前状态、派单时间、指派工程师、预计完成时间、实际完成时间、验收意见、故障分类。
状态被严格定义为五个:
| 当前状态 | 允许的操作 | 目标状态 | 操作角色 |
|---|---|---|---|
| pending(待派单) | 指派工程师 | processing(维修中) | 设备科管理员 |
| processing(维修中) | 填写维修记录 | accepting(待验收) | 维修工程师 |
| accepting(待验收) | 验收通过 | done(已完成) | 报修人/管理员 |
| accepting(待验收) | 验收驳回 | processing(维修中) | 报修人/管理员 |
| done(已完成) | 关闭归档 | closed(已关闭) | 设备科管理员 |
在transition方法里,每次状态变更都会写入一条OperationLog记录,包含操作人、操作时间、旧状态、新状态、备注。这么做以后,设备科领导追问“上个月CT机维修为什么拖了三天”,只需要查询这台设备的工单流转记录就能还原整个过程,而不是听维修工程师口头解释。
除了主工单表,我还设计了RepairLog(维修日志)子表。工程师在维修过程中可能多次进场,每次进场换过什么配件、做过什么测试、耗了多少工时,都作为独立记录追加到工单下。这样工单表本身保持精简,只存流程状态,而维修日志完整记录业务细节。查询历史时,OrderLog负责“状态怎么变的”,RepairLog负责“维修时做了什么”,两条线互不干扰。
2.3 角色权限与操作边界设计
医院场景下的角色划分比较固定,我按用户身份分成了四类:科室报修人、设备科管理员、维修工程师、院方领导(只读统计)。不在这套系统里做超级复杂的RBAC矩阵,因为一复杂,现场维护人员就搞混了。
具体权限用Django的Group加自建角色字段实现。报修人可以创建设备报修单,查看自己发起的工单;工程师可以查看被指派给自己的工单,填写维修日志;管理员拥有设备台账、工单派单、人员管理、报表查看的全部权限;领导角色的代码里直接强制只读,写操作一律拒绝。
一个容易踩坑的地方是对象级权限。只有查询接口做了“用户只能看到自己的报修单”还不够,如果维修接口没做同样的过滤,工程师把工单号改成别人的单号就能越权操作。我后来统一在ViewSet的get_queryset里根据当前用户角色过滤数据源,同时写了一个IsOwnerOrAdmin权限类,放在写操作的permission_classes里,双重校验。
另外,医院里有时候会有一个维修工程师处理多个科室设备的情况,所以角色和科室之间做了多对多关联。一个工程师可以绑定“内科片区”和“外科片区”,派单时系统会优先匹配同时满足设备类型和科室归属的工程师,这个逻辑在后端接口里用一次内存筛选完成,不需要引入复杂的调度引擎。
2.4 数据库设计的避坑指南
第一版设计最容易出现的四类问题,我提前列出来供参考。
第一,忽略外键的级联策略。设备被删除时,如果直接级联删除关联的维修工单,历史数据会全部丢失。维修工单是医院设备管理的凭证性数据,绝对不能物理删除。所以设备表和工单表统一用软删除,加is_deleted字段,查询时默认过滤掉已删除记录,但原始数据永远留在库里。
第二,日期字段时区混乱。Django开启USE_TZ=True后,数据库中存的是UTC时间,前端拿到的JSON是带时区的ISO格式。如果前端组件不做处理直接显示,会出现报修时间比本地时间早8小时的问题。我在所有日期字段进入序列化器时统一转成Asia/Shanghai时区格式的字符串,前端拿到的永远是本地化时间,不用再做时区换算。
第三,备注和文本字段滥用TextField。故障描述、维修措施这类字段确实是TextField,但“设备状态”“紧急程度”这类有固定枚举值的字段一定不要用TextField随便填。我见过有人把设备状态存成“正常”、“正常运行”、“好的”,三种说法一个意思,统计时却会被分到三个组里。所有枚举字段统一用IntegerField加choices映射,输出时由序列化器翻译成中文标签。
第四,查询性能的隐性问题。工单列表页默认会带出设备名称、报修人姓名、工程师姓名,三层关联如果不做select_related,每展示一行工单就多出几次SQL查询,列表70条数据可能要触发两百多次数据库查询。我所有list接口都会用select_related把设备、报修人、维修人的外键关系预加载出来,这个优化在数据量上来后效果极其明显。
3. 实操过程——完整搭建一套可运行的维修管理系统
3.1 开发环境准备与项目初始化
先说环境版本。一套相对稳妥的组合是Python 3.10、Django 4.2、Django REST Framework 3.14、Vue 3.3、Vite 5,数据库开发环境用SQLite零配置跑通,生产环境切换MySQL。这套组合的API完全兼容,不用改业务代码。
创建后端项目:
mkdir med-device-manager && cd med-device-manager python -m venv venv source venv/bin/activate # Windows环境改执行venv/Scripts/activate pip install django djangorestframework django-cors-headers djangorestframework-simplejwt django-filter django-admin startproject config . python manage.py startapp devices python manage.py startapp workorders python manage.py startapp users python manage.py startapp commonINSTALLED_APPS里需要重点说明的是,rest_framework、corsheaders、rest_framework_simplejwt这三个一定要放在新增业务app之前注册,顺序错了中间件和认证配置不会生效。
3.2 核心配置文件解读
settings.py里有两个容易出错的地方。一个是数据库配置,生产环境用MySQL时,由于医院内网服务器通常没有外网,额外安装mysqlclient需要提前准备离线包。另一个是DRF全局配置,我建议这样写:
REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication.JWTAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', ), 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', 'rest_framework.filters.SearchFilter', 'rest_framework.filters.OrderingFilter', ), 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 20, 'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S', }这里把全局默认权限设为IsAuthenticated,意味着所有未登录请求都会返回401,避免哪个视图漏写权限装饰器导致接口裸奔。JWT的过期时间我设置为8小时,医院内网系统里这个时间足够一个白班使用,也避免了频繁重新登录的麻烦。
CORS配置用django-cors-headers解决。前后端分离后,本地开发时Vite端口是5173,Django端口是8000,不配CORS,前端调接口会直接被浏览器拦截。配置时需要注意,在DEBUG模式外生产环境不要轻易用CORS_ALLOW_ALL_ORIGINS=True,而是指定具体的内网域名,这是从安全角度考虑的底线。
3.3 设备与工单的模型代码实现
设备模型的核心代码:
class Device(models.Model): code = models.CharField('设备编码', max_length=64, unique=True, db_index=True) name = models.CharField('设备名称', max_length=128) category = models.ForeignKey(DeviceCategory, on_delete=models.PROTECT, verbose_name='设备分类') model_name = models.CharField('型号', max_length=128, blank=True) manufacturer = models.CharField('生产厂家', max_length=128) asset_no = models.CharField('资产编号', max_length=64, blank=True) serial_no = models.CharField('出厂编号', max_length=64, blank=True) department = models.ForeignKey(Department, on_delete=models.PROTECT, verbose_name='所属科室') location = models.CharField('安装位置', max_length=255, blank=True) status = models.CharField('设备状态', max_length=16, choices=DEVICE_STATUS, default='normal') purchase_date = models.DateField('购置日期', null=True, blank=True) purchase_price = models.DecimalField('购置金额', max_digits=12, decimal_places=2, null=True, blank=True) warranty_date = models.DateField('保修截止日期', null=True, blank=True) is_deleted = models.BooleanField('已删除', default=False) created_at = models.DateTimeField('创建时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) class Meta: db_table = 'device' ordering = ['-created_at']几个设计细节的解释。分类外键用on_delete=models.PROTECT而不是CASCADE,因为设备分类下面挂着设备,分类一旦被引用就不允许删除,能防止用户误删分类导致整组设备失效。设备状态是CharField枚举值,不直接用IntegerField,原因是直接读库时字符串更直观,排查问题时省一步转换。
维修工单模型的关键实现:
class RepairOrder(models.Model): STATUS_CHOICES = [ ('pending', '待派单'), ('processing', '维修中'), ('accepting', '待验收'), ('done', '已完成'), ('closed', '已关闭'), ] order_no = models.CharField('工单号', max_length=32, unique=True) device = models.ForeignKey(Device, on_delete=models.PROTECT, verbose_name='设备') reporter = models.ForeignKey(User, on_delete=models.PROTECT, related_name='reported_orders', verbose_name='报修人') assignee = models.ForeignKey(User, on_delete=models.PROTECT, related_name='assigned_orders', null=True, blank=True, verbose_name='维修工程师') description = models.TextField('故障描述') fault_type = models.CharField('故障分类', max_length=32, choices=FAULT_TYPE_CHOICES, default='other') priority = models.CharField('紧急程度', max_length=16, choices=PRIORITY_CHOICES, default='normal') status = models.CharField('当前状态', max_length=16, choices=STATUS_CHOICES, default='pending') complete_time = models.DateTimeField('完成时间', null=True, blank=True) created_at = models.DateTimeField('报修时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True)工单号生成不能依赖数据库自增主键直接拼接,因为多进程下会重复。我封装了一个函数,用“日期+当日流水号”生成,流水号通过Redis自增计数或者Django的get_or_create+锁保证唯一。没有Redis的环境,用数据库表级别的一个Sequence记录也可以,但要注意并发下的事务隔离。
状态流转方法示例:
def assign(self, engineer): if self.status != 'pending': raise ValidationError('只有待派单状态才能指派工程师') self.assignee = engineer self.status = 'processing' self.save()每次状态变更前做合法性检查,非法跳转会抛出ValidationError。API层再配合Permission校验,前端几乎不可能把系统状态弄乱。
3.4 序列化器与视图的实现要点
写Serializer时我把输出字段分成两层,基础字段直接用ModelSerializer自动生成,额外的展示字段一对一手动声明。设备外的维修工单列表,通常需要在表格里直接看到设备名称和科室,而不是等用户点进详情才看到。所以Serializer上要加两个只读字段:
class RepairOrderListSerializer(serializers.ModelSerializer): device_name = serializers.CharField(source='device.name', read_only=True) device_code = serializers.CharField(source='device.code', read_only=True) department_name = serializers.CharField(source='device.department.name', read_only=True) reporter_name = serializers.CharField(source='reporter.username', read_only=True) assignee_name = serializers.CharField(source='assignee.username', read_only=True, default=None) status_display = serializers.CharField(source='get_status_display', read_only=True) class Meta: model = RepairOrder fields = ('id', 'order_no', 'device_name', 'device_code', 'department_name', 'reporter_name', 'assignee_name', 'priority', 'status', 'status_display', 'created_at', 'complete_time')这里有个细节:source里连续跨了两层关联,比如source='device.department.name',DRF会自动帮你完成链式取值。但前提是列表查询时已经用select_related('device__department', 'reporter', 'assignee')预加载了,否则这条链式取值会导致多查询数据库。写source的时候记得检查对应的ViewSet是否预加载了全部需要的关联。
ViewSet选择ModelViewSet能少写很多重复代码,但create和update方法需要覆盖,因为创建工单时要自动生成order_no,更新工单时要校验状态流转。代码大致如下:
class RepairOrderViewSet(viewsets.ModelViewSet): queryset = RepairOrder.objects.select_related('device__department', 'reporter', 'assignee').all() def perform_create(self, serializer): serializer.save( reporter=self.request.user, order_no=generate_order_no() )细心的读者会发现,create时把reporter直接取当前登录用户,防止用户在请求体里伪造报修人身份。这种赋值逻辑放在perform_create里,而不是从request.data里读取,安全性和数据一致性都会好很多。
3.5 Vue3前端工程搭建与接口对接
前端Vue3项目我使用Vite构建,组件库用Element Plus,状态管理用Pinia,路由用Vue Router 4。具体搭建过程不重复,直接说核心文件。
创建项目后,安装依赖:
npm create vite@latest frontend -- --template vue cd frontend npm install element-plus @element-plus/icons-vue npm install vue-router@4 pinia axiosaxios封装是前后端对接最关键的环节。我给出一个可直接使用的封装:
import axios from 'axios' import { ElMessage } from 'element-plus' const service = axios.create({ baseURL: '/api', timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('access_token') if (token) { config.headers.Authorization = 'Bearer ' + token } return config }) service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('access_token') window.location.href = '/login' } else { ElMessage.error(error.response?.data?.detail || '请求失败') } return Promise.reject(error) } ) export default serviceVite的代理配置注意这样写:
server: { proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } } }这样开发环境里前端请求/api/device,会被代理转发到Django的8000端口,生产环境则由Nginx负责同样的转发规则,前后端联调时不需要改任何业务代码。
前端路由分成两块:登录页面单独一个layout,登录后的首页、设备管理、工单管理、统计报表放在主layout下。路由守卫里检查本地token是否存在,不存在就跳登录页。注意路由守卫只是体验优化,真正的权限校验必须依赖后端接口判断,不能把前端隐藏按钮当成安全手段。
工单管理页面的核心交互是工单列表和详情抽屉。列表用el-table绑定工单数据,状态列用el-tag按颜色区分;详情用el-drawer展示工单完整信息,包含维修日志timeline。Vue3的Composition API在这个页面尤其好用,工单列表、筛选条件、分页参数、详情数据各自用ref管理,互不干扰:
import { ref, onMounted } from 'vue' import { getRepairOrders } from '@/api/workorder' const orders = ref([]) const total = ref(0) const queryParams = ref({ page: 1, page_size: 20, status: '', keyword: '' }) const fetchOrders = async () => { const res = await getRepairOrders(queryParams.value) orders.value = res.results total.value = res.count } onMounted(fetchOrders)3.6 一个完整工单的端到端流转演练
整个系统做好后,我用一个模拟场景验证全流程。设备“某医院内科ICU-002号呼吸机”出现故障报警,值班护士登录系统,进入设备台账找到这台呼吸机,点击“报修”按钮,填写故障描述“开机自检失败,氧浓度模块报警”,紧急程度选择“高”,提交工单。
设备科管理员在系统首页看到待派单列表多了一条工单,点开后能看到设备全信息和故障描述,指派给负责ICU片区的维修工程师。工程师手机端收到提醒,点击接单,在维修日志里填写第一次进场记录,检查发现氧浓度传感器老化,更换备件后重新自检通过,然后提交待验收。护士确认设备恢复正常,点验收通过,工单状态变为已完成,台账里这台设备的“最近维修”字段自动更新。
整个过程没有电话、没有纸质单据、没有微信语音。医院设备科要看的报表数据,比如本月呼吸机维修次数、平均维修时长、备件消耗排名,全部自动生成。这就是这类系统的核心价值——把隐性的维修工作变成可管理、可追溯、可统计的数字资产。
4. 常见问题与排查技巧实录
4.1 接口跨域和CORS配置踩坑
前后端分离项目,开发环境最常见的报错就是浏览器控制台出现“CORS policy: No 'Access-Control-Allow-Origin'”。配置django-cors-headers后,大部分情况能解决,但有一个隐蔽问题:当接口需要携带Cookie或自定义Header时,前端axios必须配置withCredentials,同时后端CORS配置不能使用通配符*。通配符与Credentials组合是不允许的,必须显式指定允许的来源,否则浏览器依然会拦截响应。
我还遇到过一种情况,接口能通,但前端发起OPTIONS预检请求时Django直接返回404。原因是URL路由里没有匹配到OPTIONS请求对应的视图。django-cors-headers中间件会处理OPTIONS预检,前提是它被正确加入MIDDLEWARE,并且放在CommonMiddleware之前。
4.2 文件上传与静态文件服务问题
维修记录常需要上传设备故障照片。开发环境,Django默认能处理本地文件上传,但如果没配置MEDIA_URL和MEDIA_ROOT,上传后的文件无法通过URL访问。我在settings里的配置:
MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media'同时在config/urls.py里,DEV模式加一条:
from django.conf.urls.static import static urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)生产环境务必把/media/路径交给Nginx或者单独的对象存储服务,不要让Django进程处理文件读写,否则高并发上传时会拖垮API服务。
上传文件大小默认限制为2.5MB,医院现场拍照照片动辄四五MB。我调大了两个参数:DATA_UPLOAD_MAX_MEMORY_SIZE设为10MB,前端组件同时限制文件数量为3张。超过10MB的照片提示压缩后再传,避免数据库和存储被大文件拖垮。
4.3 工单查询性能优化的进阶操作
设备数量超过两千台、工单超过五万条后,列表接口会明显变慢。排查时先看数据库慢查询日志,通常问题集中在两类。
一类是列表页的排序字段没用索引。RepairOrder表按created_at排序,需要在字段上建索引。另一类是搜索关键词的模糊查询。设备名称包含搜索在数据量大的时候会全表扫描,我用了Django的SearchFilter配合MySQL的全文索引,或者退而求其次,限制搜关键字最短长度,避免用户输入一个“a”就触发全表扫描。
工单列表带出关联字段,前面提到用select_related。如果工程师绑定科室的多对多字段也要展示,还需要prefetch_related。这两者的区别是:select_related用于外键单值关联,生成SQL JOIN;prefetch_related用于多对多或反向关联,执行额外查询后内存拼接。用错了会导致N+1查询问题,这是DRF列表接口性能杀手。
4.4 权限校验的隐性漏洞
普通的越权测试很好防,比如工单详情接口,如果直接传别人的工单ID就能看到数据,那是queryset过滤没做好。但有一个隐蔽场景容易被忽略:工程师A被派单后,如果他把自己负责工单里的“维修日志”删除,系统是否允许?
我的方案是对维修日志的删除操作做权限控制,只允许创建设备科管理员或者维修工单结束前由管理员操作,工程师自己只能新增日志不能删除已有日志。这个细节如果不处理,一旦发生纠纷,维修记录被工程师自行删除,审计链条就断掉了。后来在操作日志表里增加了“删除原因”字段,任何删除都要求填写原因,虽然增加了使用成本,但在医疗场景下这是必要的约束。
JWT安全也是重点。我把JWT的过期时间设为8小时,但存在一个隐患:token被截获后,8小时内都能调用接口。为了解决这个问题,我在每次变更敏感状态(删除设备、修改权限、关闭工单)时,检查用户的token签发时间,如果用户密码已修改,则旧token立即失效。代码逻辑虽简单,但现场实测下来,安全性和用户体验能达到平衡。
4.5 数据时区与日期筛选的兼容处理
医院业务特征是跨天工作。比如工单要求按“报修日期”筛选,前端选的是“2025-03-10”,后端用DateTimeField直接精确等于是查不到数据的。我在Serializer里输出日期字段时全部格式化为“YYYY-MM-DD HH:mm:ss”,但查询接口接收的是“YYYY-MM-DD”字符串,所以需要做一次日期范围转换:把单日查询自动扩展为当天00:00:00到23:59:59。
还有时区问题。如果Django配置USE_TZ=True,库里存的是UTC,查询“2025-03-10”时先转换成UTC再比较,就会丢掉8小时。我常用的处理方式是重写filter后端,或者干脆在项目里全局使用本地时区时间。内部管理系统对国际时间一致性没有要求,统一用Asia/Shanghai反而更简洁,也能避免前端显示时间落后8小时这种尴尬。
5. 实操总结与我的几点体会
这个项目做完之后,有几点体会对我后面的工作影响很大。第一,这类系统的价值不取决于技术复杂度,而取决于对设备科业务流程的还原程度。把工单状态定清楚、把设备台账弄干净,比把页面做得炫花哨重要得多。第二,权限控制一定要在起步阶段就设计好,等上线后再补,几乎所有操作日志和数据审计都需要返工。第三,医院内网环境里部署经常遇到网络隔离、离线依赖的问题,开发阶段就要准备好依赖包的本地缓存,避免现场装到一半发现缺包。
最后一个小技巧:给工单详情页加一个“打印维修单”按钮,输出为A4格式的PDF。医院设备科在设备报废申请、科室对账、审计检查时都需要纸质单据,这个功能看着不起眼,但现场使用频率很高,也让我在几个关键节点上少挨了不少催。后续这套系统还能继续扩展,比如接入HIS系统获取科室床位占用数据从而判断设备使用压力,或者增加设备巡检计划模块把定期保养也纳入工单管理体系,无论朝哪个方向走,现有的设备台账和工单数据底座都不会白做。