简介:这是一套面向高校计算机相关专业学生的医院挂号诊疗管理系统完整项目源码,采用Python+Django+MySql技术栈开发,适合作为毕业设计、期末大作业或课程设计的参考方案,也适合想通过实战项目熟悉Web开发流程的初学者。压缩包共包含2000个文件,以1633个JavaScript脚本、249个HTML页面、53个CSS样式表及33个HTM文件为主,另有JSON配置、Python后端代码、XML与Markdown说明文档等,整体约6.25MB,前端页面与后端逻辑分层清晰。项目代码附有注释,新手也能看懂,下载后简单部署即可运行,可帮助读者快速理解挂号、诊疗等核心业务模块的实现思路,掌握Django与MySQL的配合方式,并在此基础上进行二次开发或功能扩展。目前已有331人学习下载,可作为高分毕设的可靠参考。
1. 从一份能跑起来的挂号系统源码说起:Python+Django+MySql 到底能做出什么
很多计算机专业的同学在选题阶段都会卡在同一个地方:想做一个有业务深度、能写进简历、答辩时又不容易被问穿的系统,但翻遍网上的开源项目,要么是纯 CRUD 的博客后台,要么是缺数据库、缺文档、跑不起来的半成品。医院挂号诊疗管理系统恰好落在一个很舒服的位置上——它天然包含排班、号源、挂号、缴费、就诊、处方这几条真实业务链,用 Python + Django + MySql 实现,既能体现后端建模能力,又不会像微服务那样把新手劝退。这篇文章不讲空泛的“系统概述”,而是把一套可复现的挂号诊疗系统从环境搭建、数据建模、核心业务实现到部署排错完整走一遍,重点放在号源并发、排班冲突、状态流转这些真正会翻车的地方。适合正在做毕设、需要一套能讲清楚设计取舍的完整项目的同学,也适合想用 Django 练手真实业务的后端入门者。
2. 环境搭建与项目骨架:把 Django 和 MySql 先跑通
2.1 版本选型与依赖清单
做毕设最怕的就是环境装到一半报错,所以第一步不是写代码,而是把版本锁死。Django 和 MySql 的兼容性在近几年已经比较稳定,但 Python 版本、mysqlclient 驱动、MySql 服务端三者之间仍有坑。我一般会选 Python 3.10 + Django 4.2 LTS + MySql 8.0 这个组合,原因是 Django 4.2 是长期支持版,官方文档全,遇到问题搜索到的答案最多;MySql 8.0 对窗口函数和 CTE 支持好,后面做排班统计会用到。
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| Python | 3.10.x | 3.12 部分驱动轮子还不全,新手容易卡在编译 |
| Django | 4.2 LTS | 长期支持,教程和第三方包兼容性最好 |
| MySql | 8.0.x | 支持窗口函数,字符集用 utf8mb4 |
| mysqlclient | 2.2.x | Django 官方推荐驱动,比 pymysql 性能好 |
| redis | 7.x | 可选,用于号源缓存和会话 |
安装命令按顺序执行,不要跳步:
# 创建虚拟环境,避免污染全局 Python python -m venv venv # Windows 激活 venv\Scripts\activate # Linux/Mac 激活 source venv/bin/activate # 安装核心依赖 pip install django==4.2.11 pip install mysqlclient==2.2.4 pip install redis==5.0.3这里要说明一下为什么用 mysqlclient 而不是 pymysql。pymysql 是纯 Python 实现,安装简单,但在高并发查询下性能明显不如 C 扩展的 mysqlclient。毕设答辩时如果老师问到数据库驱动选型,这就是一个能加分的点。mysqlclient 在 Windows 上安装偶尔会报缺少 Visual C++ Build Tools,解决办法是去装一个 Microsoft C++ Build Tools,或者直接用 pip 安装预编译 wheel 包。
2.2 创建 Django 项目与数据库配置
环境好了之后,创建项目和 app。挂号系统我一般拆成三个 app:accounts管用户和权限,registration管挂号排班,clinic管就诊和处方。拆分的理由是职责清晰,后面写论文的模块设计章节也好对应。
django-admin startproject hospital_system cd hospital_system python manage.py startapp accounts python manage.py startapp registration python manage.py startapp clinic接着改settings.py里的数据库配置。这里有个高频翻车点:MySql 8.0 默认认证插件是caching_sha2_password,而老版本 mysqlclient 可能不兼容,报错Authentication plugin 'caching_sha2_password' cannot be loaded。解决办法是在 MySql 里把用户认证方式改成mysql_native_password,或者升级 mysqlclient 到 2.2 以上。
# hospital_system/settings.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'hospital_db', 'USER': 'hospital_user', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'charset': 'utf8mb4', 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", }, 'CONN_MAX_AGE': 60, # 连接复用,减少频繁建连开销 } }CONN_MAX_AGE这个参数值得单独说。Django 默认每个请求结束就关闭数据库连接,挂号系统在高峰期会有大量短查询,频繁建连会拖慢响应。设置成 60 秒表示连接在请求结束后保留 60 秒供复用。但要注意,如果你的部署环境用了多线程或异步,连接复用需要配合连接池,否则可能出现连接泄漏。毕设规模下 60 秒足够,生产环境建议上django-db-connection-pool。
数据库建好之后执行迁移:
# 先在 MySql 里建库 mysql -u root -p -e "CREATE DATABASE hospital_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" python manage.py makemigrations python manage.py migrate python manage.py createsuperuserutf8mb4而不是utf8的原因很简单:MySql 的utf8其实是阉割版,最多存 3 字节,存不了 emoji 和部分生僻字。患者姓名里如果有生僻字,用utf8会直接报错或存成问号。这个细节写进论文的数据库设计章节,是实打实的工程考量。
2.3 用 Django 自带 ORM 还是手写 SQL
这是新手最容易纠结的问题。我的建议是:核心业务用 ORM,复杂统计查询用手写 SQL 或raw()。挂号系统的号源扣减、状态流转用 ORM 配合事务完全够用,而且可读性好、防 SQL 注入。但像“统计某科室近 30 天每天挂号量”这种带日期分组和窗口函数的查询,ORM 写起来又长又难维护,直接上原生 SQL 更清晰。
# registration/models.py 片段 from django.db import models class Department(models.Model): name = models.CharField(max_length=50, unique=True, verbose_name='科室名称') location = models.CharField(max_length=100, verbose_name='位置') is_active = models.BooleanField(default=True) class Meta: db_table = 'department' class Doctor(models.Model): name = models.CharField(max_length=30) department = models.ForeignKey(Department, on_delete=models.PROTECT) title = models.CharField(max_length=20) # 主任医师、副主任医师等 daily_quota = models.PositiveIntegerField(default=30, verbose_name='每日号源上限') class Meta: db_table = 'doctor'on_delete=models.PROTECT而不是CASCADE,是因为医生关联着历史挂号记录,删医生不能把患者的就诊记录一起删掉,这是医疗数据的基本要求。答辩时老师如果问数据完整性怎么保证,这就是答案。
3. 号源、排班与挂号:核心业务表设计与状态流转
3.1 排班表与号源表的拆分逻辑
很多同学做挂号系统时会把排班和号源合成一张表,结果做到后面发现号源要支持退号、改约、加号,状态越来越复杂,表字段越加越多。正确的做法是拆成两张表:schedule记录医生某天上午/下午出诊,quota记录这个排班下每个时间段的号源数量和剩余量。
class Schedule(models.Model): doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE) work_date = models.DateField() period = models.CharField(max_length=10, choices=[('AM', '上午'), ('PM', '下午')]) total_quota = models.PositiveIntegerField(default=30) remaining_quota = models.PositiveIntegerField(default=30) version = models.IntegerField(default=0) # 乐观锁版本号 class Meta: db_table = 'schedule' unique_together = ('doctor', 'work_date', 'period') indexes = [models.Index(fields=['work_date', 'period'])]unique_together保证同一个医生同一天同一时段只能有一条排班,从数据库层面杜绝重复排班。version字段是乐观锁,后面讲并发扣减时会用到。indexes加在日期和时段上,因为患者查号时最常用的条件就是“某天某时段有哪些医生”。
号源扣减是整个系统最容易出问题的地方。假设两个患者同时点“确认挂号”,如果代码写成先查再减,就会出现超卖。看下面这段错误示范:
# 错误示范:先查后减,并发下必然超卖 schedule = Schedule.objects.get(id=schedule_id) if schedule.remaining_quota > 0: schedule.remaining_quota -= 1 schedule.save() # 创建挂号记录...问题在于get和save之间有时间窗口,两个请求可能都读到remaining_quota=1,然后都减到 0,结果卖出两个号。正确做法是用数据库的原子更新:
from django.db import transaction from django.db.models import F @transaction.atomic def grab_schedule(schedule_id, patient): # 原子扣减:只有 remaining_quota > 0 时才会更新成功 updated = Schedule.objects.filter( id=schedule_id, remaining_quota__gt=0 ).update(remaining_quota=F('remaining_quota') - 1) if updated == 0: raise ValueError('号源已满') schedule = Schedule.objects.get(id=schedule_id) # 创建挂号记录 Registration.objects.create( schedule=schedule, patient=patient, status='booked' )F('remaining_quota') - 1让数据库在服务端完成减法,而不是先读到 Python 再写回,避免了竞态。filter(remaining_quota__gt=0)保证只有还有号时才更新,updated == 0说明号已满。整个操作包在transaction.atomic里,扣减和创建记录要么都成功要么都回滚。这套写法在毕设答辩时是绝对的亮点,因为它体现了对并发问题的真实理解。
3.2 挂号状态机与退号处理
挂号记录不能只有“已挂号”和“已取消”两个状态,真实业务里至少要有:已预约、已就诊、已取消、爽约。状态流转必须有明确的规则,不能随便改。
| 当前状态 | 允许操作 | 目标状态 | 约束条件 |
|---|---|---|---|
| 已预约 | 就诊 | 已就诊 | 就诊日期当天 |
| 已预约 | 取消 | 已取消 | 就诊日期前一天 24 点前 |
| 已预约 | 爽约 | 爽约 | 就诊日期已过且未就诊 |
| 已取消 | 无 | - | 不可恢复 |
| 已就诊 | 无 | - | 不可逆 |
退号时要把号源还回去,同样用原子操作:
@transaction.atomic def cancel_registration(reg_id): reg = Registration.objects.select_for_update().get(id=reg_id) if reg.status != 'booked': raise ValueError('当前状态不可取消') if reg.schedule.work_date <= timezone.now().date(): raise ValueError('就诊当天不可取消') reg.status = 'cancelled' reg.save() # 号源加回 Schedule.objects.filter(id=reg.schedule_id).update( remaining_quota=F('remaining_quota') + 1 )select_for_update()会在事务里给这行记录加行锁,防止同一个挂号记录被并发取消两次。注意它必须在事务内使用,否则锁会立即释放,等于没加。
3.3 用 Django 表单和权限控制挂号入口
挂号入口不能谁都能调,患者只能给自己挂号,医生只能看自己的排班。Django 的权限系统配合自定义装饰器就能搞定。
from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied def patient_required(view_func): @login_required def wrapper(request, *args, **kwargs): if request.user.role != 'patient': raise PermissionDenied('仅患者可挂号') return view_func(request, *args, **kwargs) return wrapper @patient_required def book_view(request, schedule_id): if request.method == 'POST': try: grab_schedule(schedule_id, request.user.patient_profile) return JsonResponse({'code': 0, 'msg': '挂号成功'}) except ValueError as e: return JsonResponse({'code': 1, 'msg': str(e)})role字段是在用户扩展表里定义的,Django 默认 User 模型没有角色概念,需要建一个UserProfile一对一关联。这里不要直接改 User 模型,除非项目一开始就用了自定义用户模型,中途改会非常痛苦。
4. 就诊、处方与统计:把业务闭环补完整
4.1 就诊记录与处方的关联设计
挂号之后就是就诊。医生看到自己的候诊列表,点进去写病历、开处方。这里的关键是就诊记录和挂号记录要一对一关联,处方和就诊记录一对多关联。
class MedicalRecord(models.Model): registration = models.OneToOneField(Registration, on_delete=models.PROTECT) doctor = models.ForeignKey(Doctor, on_delete=models.PROTECT) chief_complaint = models.TextField(verbose_name='主诉') diagnosis = models.TextField(verbose_name='诊断') created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'medical_record' class Prescription(models.Model): record = models.ForeignKey(MedicalRecord, on_delete=models.CASCADE) drug_name = models.CharField(max_length=100) dosage = models.CharField(max_length=50) quantity = models.PositiveIntegerField() price = models.DecimalField(max_digits=10, decimal_places=2) class Meta: db_table = 'prescription'OneToOneField保证一次挂号只对应一份病历,on_delete=models.PROTECT防止误删挂号记录导致病历丢失。处方用CASCADE是因为处方依附于病历,病历删了处方没有存在意义。价格用DecimalField而不是FloatField,因为浮点数算钱会出现0.1 + 0.2 = 0.30000000000000004这种问题,涉及金额必须用定点数。
4.2 医生排班冲突检测
排班冲突是另一个高频 bug。医生同一天同一时段不能有两个排班,虽然数据库有unique_together兜底,但前端提交时应该先检测并给出友好提示,而不是让用户看到数据库报错。
def create_schedule(doctor_id, work_date, period, total_quota): exists = Schedule.objects.filter( doctor_id=doctor_id, work_date=work_date, period=period ).exists() if exists: raise ValueError(f'{work_date} {period} 该医生已有排班') # 检查医生当天是否已排满两个时段 day_count = Schedule.objects.filter( doctor_id=doctor_id, work_date=work_date ).count() if day_count >= 2: raise ValueError('该医生当天排班已满') return Schedule.objects.create( doctor_id=doctor_id, work_date=work_date, period=period, total_quota=total_quota, remaining_quota=total_quota )这里除了唯一性检测,还加了“一天最多两个时段”的业务规则。规则写在代码里而不是数据库约束里,是因为业务规则可能变,改代码比改表结构灵活。
4.3 挂号量统计与 MySql 窗口函数
论文里通常需要一张“各科室每日挂号量趋势图”,这个查询用 ORM 写会很别扭,直接上 SQL。
from django.db import connection def dept_daily_stats(start_date, end_date): sql = """ SELECT d.name AS dept_name, s.work_date, COUNT(r.id) AS reg_count, SUM(COUNT(r.id)) OVER (PARTITION BY d.name ORDER BY s.work_date) AS cumulative FROM registration r JOIN schedule s ON r.schedule_id = s.id JOIN doctor doc ON s.doctor_id = doc.id JOIN department d ON doc.department_id = d.id WHERE s.work_date BETWEEN %s AND %s AND r.status != 'cancelled' GROUP BY d.name, s.work_date ORDER BY d.name, s.work_date """ with connection.cursor() as cursor: cursor.execute(sql, [start_date, end_date]) columns = [col[0] for col in cursor.description] return [dict(zip(columns, row)) for row in cursor.fetchall()]SUM(COUNT(r.id)) OVER (PARTITION BY d.name ORDER BY s.work_date)是窗口函数,按科室分组、按日期累加,一次查询就能拿到每日量和累计量。如果用 ORM 得查两次再在 Python 里拼,数据量大时性能差很多。注意%s占位符是 Django 的参数化查询写法,不要用字符串拼接,否则有 SQL 注入风险。
5. 避坑与排查:那些让系统跑不起来的真实问题
5.1 坑一:MySql 连接报错 2002 或 1045
现象:运行python manage.py migrate时报error 2002 (hy000): can't connect to local mysql server through socket或Access denied for user。
原因:2002 通常是 MySql 服务没启动,或者HOST配成了localhost而 MySql 走的是 socket 连接但 socket 路径不对;1045 是用户名密码错误或该用户没有远程访问权限。
解决:先确认服务在跑,systemctl status mysql或 Windows 服务面板查看。然后把HOST改成127.0.0.1强制走 TCP,避免 socket 路径问题。权限问题执行GRANT ALL PRIVILEGES ON hospital_db.* TO 'hospital_user'@'%' IDENTIFIED BY 'password'; FLUSH PRIVILEGES;。注意@'%'允许任意主机,生产环境要收紧到具体 IP。
5.2 坑二:Django 静态文件在开发环境正常,部署后 404
现象:DEBUG=True时 CSS、JS 都能加载,改成DEBUG=False后页面样式全丢,控制台报 404。
原因:Django 在DEBUG=False时不再自动服务静态文件,需要collectstatic把文件收集到STATIC_ROOT,再由 Nginx 之类的服务器托管。
解决:在settings.py里配好STATIC_ROOT = BASE_DIR / 'staticfiles',执行python manage.py collectstatic,然后 Nginx 配置location /static/ { alias /path/to/staticfiles/; }。开发阶段如果不想折腾,可以临时用python manage.py runserver --insecure,但这只是权宜之计。
5.3 坑三:号源扣减在压测下仍然超卖
现象:用 JMeter 或 locust 模拟 100 并发抢 10 个号,结果卖出 12 个。
原因:虽然用了F()表达式,但如果没有包在事务里,或者数据库隔离级别是READ COMMITTED且更新语句没有正确加锁,仍可能出问题。更隐蔽的原因是代码里在扣减之后又做了一次save(),把内存里的旧值写回去了。
解决:确保扣减和创建记录在同一个transaction.atomic块里,扣减只用update()不要再用save()。压测时把 MySql 的innodb_flush_log_at_trx_commit临时设为 2 可以提升性能,但生产环境要设回 1 保证不丢数据。
5.4 坑四:时区问题导致排班日期错乱
现象:明明排的是今天的班,患者端显示成昨天,或者退号时提示“就诊当天不可取消”但实际还没到就诊日。
原因:Django 默认用 UTC 存储时间,USE_TZ=True时timezone.now()返回 UTC 时间,而work_date是DateField存的是本地日期,两者比较时差 8 小时。
解决:在settings.py里设TIME_ZONE = 'Asia/Shanghai'和USE_TZ = True,所有时间比较用timezone.localtime(timezone.now()).date()转成本地日期。不要图省事把USE_TZ设成False,那样跨时区部署会出更大问题。
5.5 坑五:mysqlclient 安装失败提示缺少 mysql_config
现象:pip install mysqlclient报EnvironmentError: mysql_config not found。
原因:mysqlclient 编译时需要 MySql 的开发库头文件,系统里只装了 MySql 服务端没装开发包。
解决:Ubuntu/Debian 执行sudo apt-get install default-libmysqlclient-dev build-essential,CentOS 执行sudo yum install mysql-devel gcc,Windows 则装 Visual C++ Build Tools。装完再pip install mysqlclient即可。
6. 让这套系统在答辩和简历里真正立住的两个技巧
第一个技巧是把号源并发这件事讲透。大部分毕设系统都停留在“能跑就行”,但如果你能在论文里画出一张并发扣减的时序图,说明先查后减为什么会超卖、F()表达式和事务是怎么解决的、压测数据是多少,老师会立刻意识到你不是抄的。具体做法是写一个简单的压测脚本,用 Python 的threading模拟 50 个并发请求抢 10 个号,对比错误写法和正确写法的结果。
import threading import requests results = [] def grab(i): r = requests.post('http://127.0.0.1:8000/api/book/', json={'schedule_id': 1}) results.append(r.json()['code']) threads = [threading.Thread(target=grab, args=(i,)) for i in range(50)] for t in threads: t.start() for t in threads: t.join() success = results.count(0) print(f'成功挂号数: {success}') # 正确实现应该正好等于号源总数跑出来如果成功数等于号源数,说明并发控制到位;如果大于,说明还有竞态。这个脚本可以直接放进论文的测试章节,比截图几个页面有说服力得多。
第二个技巧是给系统加一个简单的操作日志。不用上复杂的审计框架,建一张operation_log表,记录谁在什么时间对哪条挂号记录做了什么操作。答辩时如果老师问“怎么追溯误操作”,这就是现成的答案。实现上可以用 Django 的signals,在Registration保存后自动写日志,代码量不超过 20 行,但体现的是工程意识。
from django.db.models.signals import post_save from django.dispatch import receiver @receiver(post_save, sender=Registration) def log_registration_change(sender, instance, created, **kwargs): OperationLog.objects.create( user=instance.patient.user, action='create' if created else 'update', target_id=instance.id, detail=f'status={instance.status}' )我自己做这类系统最大的教训就是:不要等到功能全写完再考虑并发和日志,这两样东西越早加成本越低。等到答辩前一周发现超卖,回头改表结构和业务代码,那才是真的后悔药都没得吃。把号源扣减和状态流转这两块吃透,这套医院挂号诊疗管理系统就不只是一个毕设作业,而是你能在面试里讲二十分钟的真实项目。希望帮到你。
本文还有配套的精品资源,点击获取