
医院预约分时段挂号系统用 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/admincreated_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:30totalint该时段总号源bookedint该时段已预约数预约表字段类型说明idint主键user_idint关联患者用户schedule_idint关联排班时段statusvarchar(20)confirmed / cancelledcreated_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, nameuq_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_keyTrue) name db.Column(db.String(50), nullableFalse, uniqueTrue) description db.Column(db.Text) class User(db.Model): __tablename__ user id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(50), nullableFalse, uniqueTrue) password_hash db.Column(db.String(200), nullableFalse) 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), defaultpatient) created_at db.Column(db.DateTime, defaultdatetime.now) class Doctor(db.Model): __tablename__ doctor id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, db.ForeignKey(user.id)) name db.Column(db.String(50), nullableFalse) 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, nameuq_schedule), ) id db.Column(db.Integer, primary_keyTrue) doctor_id db.Column(db.Integer, db.ForeignKey(doctor.id)) work_date db.Column(db.Date, nullableFalse) time_slot db.Column(db.String(50), nullableFalse) total db.Column(db.Integer, default10) booked db.Column(db.Integer, default0) class Appointment(db.Model): __tablename__ appointment id db.Column(db.Integer, primary_keyTrue) 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), defaultconfirmed) created_at db.Column(db.DateTime, defaultdatetime.now)三个细节值得注意。密码字段必须存哈希用 Werkzeug 自带的generate_password_hash和check_password_hash这是安全底线不能图省事存明文。排班表加了联合唯一约束这是防止重复排班的最后一道保险。预约状态我做成最简的 confirm 和 cancelled 两种不搞复杂状态机够用就好。3.3 预约核心逻辑条件更新防超号预约的核心逻辑只有三步合理性校验、原子扣减号源、创建预约记录。最容易出问题的是第二步。很多初版实现会写先查询再更新先查Schedule.booked判断是否小于total然后booked 1。在低并发下这看起来没什么问题但一旦两个用户同时预约最后一个号可能出现两个请求都查到booked9、都认为还有号然后都执行booked10、都插入预约记录——这就超号了。我推荐的做法是把检查号源和扣减号源合并成一条条件 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(bookedSchedule.booked 1) ) if result.rowcount 0: return jsonify({code: 400, msg: 该时段号源已满}) # 防止同一患者重复预约同一时段 existing Appointment.query.filter_by( user_iduser_id, schedule_idschedule_id, statusconfirmed ).first() if existing: db.session.rollback() return jsonify({code: 400, msg: 您已预约该时段}) appointment Appointment( user_iduser_id, schedule_idschedule_id, statusconfirmed ) 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_iddoctor_id, work_datework_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, doctordoctor, slotsslots)模板里循环渲染成可视化的时段卡片div classrow idslot-list {% for slot in slots %} div classcol-4 mb-3 button classbtn btn-slot {% if not slot.available %}disabled{% endif %} onclickbookSlot({{ 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( idappointment_id, user_iduser_id, statusconfirmed ).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(bookedSchedule.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()这段代码在单用户测试时完全正常但用两三个浏览器同时点同一个时段的立即预约按钮很容易复现超号。原因就是两个请求同时读到booked9都认为还有号然后都写入。改成条件 UPDATE 之后还要注意一个问题result.rowcount在不同的数据库驱动下表现略有差异MySQL 和 SQLite 下通常都能正确返回受影响行数但如果用了某些连接池配置可能出现行数不准的情况。稳妥做法是条件更新之后再查一次Schedule.booked与total对比确认虽然多一次查询但在极端复杂的环境下更保险。我测试过的绝大多数场景下条件更新配合rowcount判断已经足够了。4.3 SQLite 开发与 MySQL 部署的差异开发环境用 SQLite 非常方便一个文件就能跑起来不用安装数据库服务。但 SQLite 在并发写入上支持较弱条件 UPDATE 在高并发下会有锁等待而且某些约束行为与 MySQL 有差异。我的习惯是开发用 SQLite部署到生产环境一定切到 MySQL 或 PostgreSQL。切换数据库时最容易踩的坑是字符集。MySQL 创建表时要显式使用utf8mb4字符集否则中文可能乱码。连接串写法类似mysqlpymysql://user:passwordhost/dbname?charsetutf8mb4。另外 MySQL 默认的事务隔离级别是可重复读REPEATABLE READ条件 UPDATE 的原子性仍然有效这一点比 SQLite 更可靠。4.4 登录态和会话配置的几个坑Flask 默认的 session 是基于客户端的签名 cookie不配置SECRET_KEY时每次重启应用随机生成会导致用户登录状态在重启后失效。这个键必须在配置文件和部署环境变量里固定下来。部署到公网时必须给 session 设置httponly和samesite属性防止会话被脚本读取。我在app.py里这样配置app.config.update( SECRET_KEYos.environ.get(SECRET_KEY, dev-secret-key-change-me), SESSION_COOKIE_HTTPONLYTrue, SESSION_COOKIE_SAMESITELax, SESSION_COOKIE_SECUREFalse # 启用 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 runNginx 静态文件路径是否正确权限是否可读检查排班表中是否存在脏数据重复时段是否清理干净用两个浏览器账号同时预约同一时段验证不超号检查取消预约后号源是否正确释放部署这件事我个人的体会是真正花时间的往往不是上线那一刻而是上线前的配置和检查。这套 Flask 预约系统本质上是一个 CRUD 为主的管理系统业务逻辑清晰只要数据库设计得当、并发扣号这个环节处理对了剩下的就是常规部署动作按清单一步步来基本不会出大问题。最后再分享一个实战小技巧如果预约系统后台需要频繁调整排班建议在管理员页面做一个批量排班功能一次选中多个时段、多天直接生成排班记录能省下管理员大量重复点击的时间。这个功能实现起来就是循环插入Schedule记录注意在循环里做好唯一冲突的判断捕获IntegrityError后提示哪些日期时段已存在而不是让整个请求失败。这个小功能在实际使用中往往比那些面面俱到的权限管理更能赢得使用者的好感。