☰
Python+Flask 实现医院分时段预约挂号系统:从设计到部署
2026/10/8 2:44:52 网站建设 项目流程

医院预约分时段挂号系统,用 Python 和 Flask 来实现,这个组合在毕业设计、课程设计和中小型业务系统里出现的频率相当高。它解决的问题很具体:传统挂号是患者到院排队,挂完号继续在候诊区干等,整个就诊节奏完全不可控,医院也没法精细管理号源。分时段挂号的核心是把门诊时间切成固定时段,每个时段锁定号源数量,患者提前在线选时段、完成预约,到点再来,错峰就诊。这套系统从前到后覆盖了用户注册登录、科室医生浏览、时段选择、预约锁定、取消改约、后台统计的完整链路,不是只能演示的空壳。我正在帮人评审、改造、部署类似项目时,把这类系统从需求分析、表结构设计到核心预约逻辑完整拆一遍,顺便把最容易踩的几个坑一并说清楚。适合正在做毕设的学生,或者想快速搭一套中小型预约系统的开发者参考。

1. 项目概述与两个关键认知

1.1 分时段挂号到底在解决什么业务问题

传统的医院门诊流程,患者早上到医院挂号窗口排队,挂完号去候诊区等,等多久取决于医生当天看诊速度和前面患者病情的复杂程度。高峰期一个普通门诊候诊区动辄几十人挤在一起,患者时间完全不可控,医院对号源分配、医生负荷也没有数据化管理手段,只能凭经验放号。

分时段挂号做的就是把这套"不可控"变成"可控"。以上午 8 点到 12 点的门诊为例,按每 30 分钟或 1 小时切成若干个时段,每个时段只放固定数量的号源。患者提前在手机上选择某个时段完成预约,系统锁定号源,患者只需要在预约时段到来前到院签到。这个机制直接缓解了三个痛点:患者在医院无效停留的时间大幅缩短,候诊区人群聚集程度得到缓解,医生每天看多少病人、每个时段接诊节奏如何,也都有了可量化的规划。

这套系统用 Python + Flask 实现,业务链路本身并不复杂,但要做到能真正落地,需要处理清楚角色权限、排班配置、时段规则、号源扣减的一致性、取消预约后的号源释放这些细节。正因为业务真实度高、技术栈主流、演示效果好,它作为毕设题目和中小型预约系统的基础骨架,一直都没过时。

1.2 技术选型:Flask 为什么是这类系统的合适选择

选题阶段最常被问到的问题是:同样是 Python,为什么不用 Django 或 FastAPI?把三个框架放在"医院预约挂号系统"这个具体场景下对比,答案就清楚了。

框架上手成本灵活度自带能力适合场景
Flask低高,按需扩展路由、模板、请求处理,核心能力精炼中小型系统、毕设、快速落地
Django中高中,全家桶约束多自带 admin、ORM、auth 等完整方案大型复杂系统、内容管理类网站
FastAPI中高异步原生、自动生成 API 文档高并发 API 服务、前后端分离架构

Flask 的定位是微框架,核心只提供路由、模板、请求响应管理,用户认证、数据库 ORM、表单校验这些都可以按需集成。预约挂号系统的业务量级不大、并发不高,开发团队通常也就一两个人,Flask 的轻量反而成了明显优势:代码结构一眼能看完,业务逻辑完全按自己的想法组织,不会被框架规范绑架。

有人会问 FastAPI 性能更好为什么不选。FastAPI 强在异步接口和高吞吐场景,但预约挂号这类管理系统真正的瓶颈不在接口性能,而在业务规则的正确性和数据一致性。Flask 同步阻塞模型下写事务逻辑非常直观,对新手更友好,等哪天系统真需要高并发接口了,再单独用 FastAPI 重写一层 API 服务也不迟,前期没必要给项目增加复杂度。另外,Flask 生态足够成熟,Flask-SQLAlchemy 管数据库、Flask-WTF 管表单、Flask-Login 管登录,拼装起来跟乐高一样自由,对于分时段规则、号源扣减这种定制逻辑较多的项目来说,这种掌控感比 Django 全家桶舒服得多。

2. 系统设计与数据库建模:先把地基打牢

2.1 功能模块拆解:三种角色、一条主线

医院预约系统天然分成三种角色,所有功能都围绕角色展开。患者负责注册、登录、浏览科室和医生、选择日期时段、提交预约、取消预约、查看历史记录;医生查看自己的排班表和各时段预约患者列表;管理员维护科室和医生信息、为医生配置排班时段、查看整体预约统计、管理用户账号。

三种角色共享同一条数据主线:科室 -> 医生 -> 排班 -> 时段 -> 预约记录。页面和接口都围绕这条主线组织,理解了这个主线,整个系统的结构就清晰了。权限控制上不需要引入复杂的权限框架,用 Flask-Login 加上一个role字段就够了,因为角色只有三种,逻辑简单直接。我在代码里用current_user.role判断当前用户能进入哪些页面,比配置一堆装饰器和权限类简单得多。

2.2 数据库表设计:五个核心表的职责划分

表结构是这类系统最核心的部分,设计得好,后面写代码会非常顺。我直接给出一版经过多轮迭代、可以实际使用的表设计,总共五张表。

用户表user:

字段类型说明
idint主键
usernamevarchar(50)用户名,唯一
password_hashvarchar(200)密码哈希,绝不存明文
real_namevarchar(50)真实姓名
phonevarchar(20)手机号,预约通知用
id_cardvarchar(20)身份证号
rolevarchar(20)角色:patient/doctor/admin
created_atdatetime注册时间

科室表department、医生表doctor、排班表schedule、预约表appointment的关系比较直接:

department 1 -> n doctor doctor 1 -> n schedule schedule 1 -> n appointment user 1 -> n appointment

排班表和预约表是整个设计的重点。排班表把一个医生的某一天按多个时段拆成多条记录,每条记录带total总号源数和booked已预约数。预约时只需要检查booked < total,通过一次原子 UPDATE 把booked加 1,就能保证不超号。

排班表字段类型说明
idint主键
doctor_idint关联医生表
work_datedate出诊日期
time_slotvarchar(50)时间段,形如 08:00-08:30
totalint该时段总号源
bookedint该时段已预约数
预约表字段类型说明
idint主键
user_idint关联患者用户
schedule_idint关联排班时段
statusvarchar(20)confirmed / cancelled
created_atdatetime预约创建时间

这个设计的聪明之处在于,排班数据被"预生成"好了:管理员提前配置某医生某天的所有时段,每个时段自带独立号源。预约操作不涉及复杂的动态查找,直接对确定的schedule_id做扣减即可,逻辑清晰、性能也好。如果反过来设计成"动态创建预约时段",每次预约都要扫描医生空闲时间,实现复杂度会高一个量级,还容易在边界情况下出 bug。

2.3 分时段规则的设计:粒度、唯一约束与排班方式

时段粒度是这里最容易拍脑袋决定的参数。主任医师看一个病人通常要 15 到 20 分钟,普通门诊可能 5 到 10 分钟。按 30 分钟一个时段、每时段放 8 到 12 个号来算,一个上午 4 小时约 8 个时段、共 80 个号左右,既起到分流效果,又不会因为时段切得太碎导致号源零散难管理。实际项目中粒度建议固定为 30 分钟或 1 小时,具体看医院管理要求。

排班时段不要用数据库动态生成,用固定字典最省事。我通常这样定义:

TIME_SLOTS = [ "08:00-08:30", "08:30-09:00", "09:00-09:30", "09:30-10:00", "10:00-10:30", "10:30-11:00", "11:00-11:30", "11:30-12:00" ]

管理员配置排班时从这个列表里勾选,而不是手输时段文本,这样能从根本上避免格式不统一、边界重叠的问题。

时段重叠是排班里最隐蔽的坑。同一个医生同一天,如果排班数据里出现了两个重复或交叠的时段,患者就会看到冲突的号源。我的做法是给排班表加联合唯一约束:

__table_args__ = ( db.UniqueConstraint('doctor_id', 'work_date', 'time_slot', name='uq_schedule'), )

从数据库层面杜绝重复排班,比在业务代码里每次插入前查一遍可靠得多。

3. 核心代码实现:从路由到数据库操作

3.1 项目骨架:用蓝图组织业务模块

很多人一上来把全部路由塞进一个app.py,项目规模小的时候还能忍,等加上登录、预约、后台管理,文件很快膨胀到上千行,改起来极其痛苦。我建议按角色拆分蓝图,每个模块一个文件。

hospital_booking/ ├── app.py # Flask 应用入口 ├── config.py # 配置 ├── models.py # 数据库模型 ├── routes/ │ ├── auth.py # 登录注册蓝图 │ ├── patient.py # 患者端蓝图 │ ├── doctor.py # 医生端蓝图 │ └── admin.py # 管理端蓝图 ├── templates/ │ ├── base.html │ ├── auth/ │ ├── patient/ │ ├── doctor/ │ └── admin/ ├── static/ │ ├── css/ │ └── js/ └── requirements.txt

这个结构不算复杂,但职责分层已经清晰到位。蓝图注册方式很简单,以患者端为例:

from flask import Blueprint patient_bp = Blueprint('patient', __name__, url_prefix='/patient')

然后在app.py里注册:

app.register_blueprint(auth_bp) app.register_blueprint(patient_bp) app.register_blueprint(doctor_bp) app.register_blueprint(admin_bp)

3.2 数据模型代码与细节说明

在models.py里定义五张表对应的 ORM 模型,代码比较直接:

from datetime import datetime from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() class Department(db.Model): __tablename__ = 'department' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(50), nullable=False, unique=True) description = db.Column(db.Text) class User(db.Model): __tablename__ = 'user' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(50), nullable=False, unique=True) password_hash = db.Column(db.String(200), nullable=False) real_name = db.Column(db.String(50)) phone = db.Column(db.String(20)) id_card = db.Column(db.String(20)) role = db.Column(db.String(20), default='patient') created_at = db.Column(db.DateTime, default=datetime.now) class Doctor(db.Model): __tablename__ = 'doctor' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) name = db.Column(db.String(50), nullable=False) title = db.Column(db.String(50)) department_id = db.Column(db.Integer, db.ForeignKey('department.id')) class Schedule(db.Model): __tablename__ = 'schedule' __table_args__ = ( db.UniqueConstraint('doctor_id', 'work_date', 'time_slot', name='uq_schedule'), ) id = db.Column(db.Integer, primary_key=True) doctor_id = db.Column(db.Integer, db.ForeignKey('doctor.id')) work_date = db.Column(db.Date, nullable=False) time_slot = db.Column(db.String(50), nullable=False) total = db.Column(db.Integer, default=10) booked = db.Column(db.Integer, default=0) class Appointment(db.Model): __tablename__ = 'appointment' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) schedule_id = db.Column(db.Integer, db.ForeignKey('schedule.id')) status = db.Column(db.String(20), default='confirmed') created_at = db.Column(db.DateTime, default=datetime.now)

三个细节值得注意。密码字段必须存哈希,用 Werkzeug 自带的generate_password_hash和check_password_hash,这是安全底线,不能图省事存明文。排班表加了联合唯一约束,这是防止重复排班的最后一道保险。预约状态我做成最简的 confirm 和 cancelled 两种,不搞复杂状态机,够用就好。

3.3 预约核心逻辑:条件更新防超号

预约的核心逻辑只有三步:合理性校验、原子扣减号源、创建预约记录。最容易出问题的是第二步。

很多初版实现会写"先查询再更新":先查Schedule.booked,判断是否小于total,然后booked + 1。在低并发下这看起来没什么问题,但一旦两个用户同时预约最后一个号,可能出现两个请求都查到booked=9、都认为还有号,然后都执行booked=10、都插入预约记录——这就超号了。

我推荐的做法是把检查号源和扣减号源合并成一条条件 UPDATE 语句,数据库层面的原子性保证了不会超卖:

from flask import request, jsonify, session from models import db, Schedule, Appointment @app.route('/api/appointment', methods=['POST']) def create_appointment(): user_id = session.get('user_id') if not user_id: return jsonify({'code': 401, 'msg': '请先登录'}), 401 schedule_id = request.json.get('schedule_id') if not schedule_id: return jsonify({'code': 400, 'msg': '参数错误'}), 400 # 核心:条件更新,只有 booked < total 时才增加 booked result = db.session.execute( db.update(Schedule) .where(Schedule.id == schedule_id, Schedule.booked < Schedule.total) .values(booked=Schedule.booked + 1) ) if result.rowcount == 0: return jsonify({'code': 400, 'msg': '该时段号源已满'}) # 防止同一患者重复预约同一时段 existing = Appointment.query.filter_by( user_id=user_id, schedule_id=schedule_id, status='confirmed' ).first() if existing: db.session.rollback() return jsonify({'code': 400, 'msg': '您已预约该时段'}) appointment = Appointment( user_id=user_id, schedule_id=schedule_id, status='confirmed' ) db.session.add(appointment) db.session.commit() return jsonify({'code': 0, 'msg': '预约成功'})

这段代码的核心在那一行条件 UPDATE 上。数据库在行锁层面保证:当两个请求同时对上一条schedule记录做这个操作时,只有一个请求的rowcount会是 1,另一个一定是 0。这比"先 select 再 update"的方案安全得多,也比引入 Redis 分布式锁或悲观锁简单得多。

有一个细节:去重校验我放在条件更新之后,因为"号源已满"是更常见的情况,先让它挡掉,能少一次数据库查询,逻辑上也符合业务优先级。如果用户重复预约,需要rollback回滚掉刚才已经加上的booked,避免号源被白白扣掉。

3.4 前端页面与时段卡片交互

前端页面不需要复杂框架,用 Bootstrap 加 Jinja2 模板就够。整体交互流程是:科室列表 -> 医生列表 -> 选择日期 -> 显示该医生当天的时段格子 -> 点击预约。

服务端在渲染时段列表时,直接把每个时段的可用状态算好:

@app.route('/doctor/<int:doctor_id>') def doctor_detail(doctor_id): doctor = Doctor.query.get_or_404(doctor_id) # 默认展示今天的排班,也可通过 GET 参数切换日期 work_date = request.args.get('date', date.today().isoformat()) schedules = Schedule.query.filter_by( doctor_id=doctor_id, work_date=work_date ).all() slots = [{ 'id': s.id, 'time': s.time_slot, 'available': s.booked < s.total, 'left': s.total - s.booked } for s in schedules] return render_template('doctor_detail.html', doctor=doctor, slots=slots)

模板里循环渲染成可视化的时段卡片:

<div class="row" id="slot-list"> {% for slot in slots %} <div class="col-4 mb-3"> <button class="btn btn-slot {% if not slot.available %}disabled{% endif %}" onclick="bookSlot({{ slot.id }})"> {{ slot.time }} {% if slot.available %} 余{{ slot.left }}号 {% else %} 已满 {% endif %} </button> </div> {% endfor %} </div>

需要强调一点:前端展示的"余 X 号"不能替代后端校验,它只是用户体验层面的展示。用户看到有号点下去,后端提交时再校验一次是否真的有号,这才是安全边界。前端点击后用 AJAX 提交,后端返回"号源已满"时立即弹出提示并刷新列表,不能让用户看到的余号和真实库存长时间不一致。

取消预约的逻辑同样需要注意号源释放,扣号和释放是一对对称操作:

@app.route('/api/appointment/cancel', methods=['POST']) def cancel_appointment(): user_id = session.get('user_id') appointment_id = request.json.get('appointment_id') appointment = Appointment.query.filter_by( id=appointment_id, user_id=user_id, status='confirmed' ).first() if not appointment: return jsonify({'code': 400, 'msg': '预约不存在'}), 400 # 释放号源,条件更新防止 booked 变成负数 db.session.execute( db.update(Schedule) .where(Schedule.id == appointment.schedule_id, Schedule.booked > 0) .values(booked=Schedule.booked - 1) ) appointment.status = 'cancelled' db.session.commit() return jsonify({'code': 0, 'msg': '取消成功'})

4. 实操中高频踩坑与排查实录

4.1 时段排序与边界比较的陷阱

time_slot在数据库里用字符串存储,如果统一使用HH:MM的 24 小时制格式,字符串排序和比较是有效的,因为相同位数情况下字典序就是时间序。但一些排班表如果混入了9:00这种非补零格式,排序就会出错:"9:30"会排在"10:00"前面。这是时段选择页面显示顺序混乱最常见的根因。

建议在模型层面就做校验,time_slot只能从固定时段表TIME_SLOTS中取值,服务端统一从列表渲染,绝不接受管理员手输格式。还有一类问题出在"当前时间是否已过某个时段"的判断上,需要把time_slot按-拆成起止时间,再和datetime.now().time()比较,直接拿字符串比会漏掉跨日或者格式不一致的情况。

4.2 并发超号的第一现场

我在评审项目时见到的常见错误版本是:

# 错误示范:先查再改 schedule = Schedule.query.get(schedule_id) if schedule.booked < schedule.total: schedule.booked += 1 db.session.commit()

这段代码在单用户测试时完全正常,但用两三个浏览器同时点同一个时段的"立即预约"按钮,很容易复现超号。原因就是两个请求同时读到booked=9,都认为还有号,然后都写入。

改成条件 UPDATE 之后,还要注意一个问题:result.rowcount在不同的数据库驱动下表现略有差异,MySQL 和 SQLite 下通常都能正确返回受影响行数,但如果用了某些连接池配置,可能出现行数不准的情况。稳妥做法是条件更新之后,再查一次Schedule.booked与total对比确认,虽然多一次查询,但在极端复杂的环境下更保险。我测试过的绝大多数场景下,条件更新配合rowcount判断已经足够了。

4.3 SQLite 开发与 MySQL 部署的差异

开发环境用 SQLite 非常方便,一个文件就能跑起来,不用安装数据库服务。但 SQLite 在并发写入上支持较弱,条件 UPDATE 在高并发下会有锁等待,而且某些约束行为与 MySQL 有差异。我的习惯是:开发用 SQLite,部署到生产环境一定切到 MySQL 或 PostgreSQL。

切换数据库时最容易踩的坑是字符集。MySQL 创建表时要显式使用utf8mb4字符集,否则中文可能乱码。连接串写法类似mysql+pymysql://user:password@host/dbname?charset=utf8mb4。另外 MySQL 默认的事务隔离级别是可重复读(REPEATABLE READ),条件 UPDATE 的原子性仍然有效,这一点比 SQLite 更可靠。

4.4 登录态和会话配置的几个坑

Flask 默认的 session 是基于客户端的签名 cookie,不配置SECRET_KEY时每次重启应用随机生成,会导致用户登录状态在重启后失效。这个键必须在配置文件和部署环境变量里固定下来。

部署到公网时必须给 session 设置httponly和samesite属性,防止会话被脚本读取。我在app.py里这样配置:

app.config.update( SECRET_KEY=os.environ.get('SECRET_KEY', 'dev-secret-key-change-me'), SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Lax', SESSION_COOKIE_SECURE=False # 启用 HTTPS 后改为 True )

还有一个常见问题是忘记关闭 Flask 的debug模式就部署上线,调试器在公网环境下等于直接暴露了代码执行入口,这是必须避免的。部署环境中debug必须为False。

5. 部署上线细节:让系统真正跑起来

5.1 用 Gunicorn 运行 Flask 应用

Flask 自带的开发服务器只适合本地调试,不能直接用于生产。Linux 服务器上常用 Gunicorn 启动应用,安装和启动都很简单:

pip install gunicorn gunicorn -w 4 -b 0.0.0.0:8000 "app:app"

-w 4表示启动 4 个 worker 进程,这个数字通常按服务器 CPU 核心数来定,不是越多越好。worker 数量超过 CPU 核心数太多,进程切换反而会拖慢性能。对于预约挂号这类系统,4 个 worker 配合 MySQL 已经能扛住中小型医院门诊的日常流量。如果接触过 gunicorn,看到worker这个概念就知道它是通过多进程复用 CPU 来提升并发吞吐的,跟线程池的思路类似,但隔离性更好。

需要注意,Gunicorn 本身管理的是 WSGI 应用,而 Flask 应用对象通常叫app,所以启动参数里"app:app"的意思是"从 app 模块导入名为 app 的应用对象"。这里写错了启动会直接报错。

5.2 Nginx 反向代理与静态文件处理

前面 Gunicorn 监听了 8000 端口,但不能直接把 8000 端口暴露给用户。常规做法是前面加一层 Nginx 做反向代理,把来自 80 端口的请求转发到 Gunicorn。

server { listen 80; server_name your-domain.com; 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; } location /static/ { alias /path/to/hospital_booking/static/; expires 7d; } }

静态文件(CSS、JS、图片)直接由 Nginx 服务,不走 Gunicorn,能省下大量 Python 进程资源。expires 7d是给静态资源设置七天的浏览器缓存,页面加载速度会有明显提升。如果系统后续要支持 HTTPS,在 Nginx 层配置证书也方便很多,应用本身的改动为零。

5.3 部署前的检查清单

部署上线前,我建议对照这个清单过一遍,能避免九成以上的线上事故:

  • SECRET_KEY是否已从环境变量注入,而不是用默认值
  • debug模式是否为False
  • 数据库连接是否切换到 MySQL 并指定了utf8mb4
  • 是否用 Gunicorn 或其他生产级 WSGI 服务器,而不是flask run
  • Nginx 静态文件路径是否正确,权限是否可读
  • 检查排班表中是否存在脏数据,重复时段是否清理干净
  • 用两个浏览器账号同时预约同一时段,验证不超号
  • 检查取消预约后号源是否正确释放

部署这件事我个人的体会是,真正花时间的往往不是上线那一刻,而是上线前的配置和检查。这套 Flask 预约系统本质上是一个 CRUD 为主的管理系统,业务逻辑清晰,只要数据库设计得当、并发扣号这个环节处理对了,剩下的就是常规部署动作,按清单一步步来基本不会出大问题。

最后再分享一个实战小技巧:如果预约系统后台需要频繁调整排班,建议在管理员页面做一个"批量排班"功能,一次选中多个时段、多天直接生成排班记录,能省下管理员大量重复点击的时间。这个功能实现起来就是循环插入Schedule记录,注意在循环里做好唯一冲突的判断,捕获IntegrityError后提示哪些日期时段已存在,而不是让整个请求失败。这个小功能在实际使用中,往往比那些面面俱到的权限管理更能赢得使用者的好感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询