ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Python Flask的医院体检预约挂号系统设计与实现

基于Python Flask的医院体检预约挂号系统设计与实现 1. 项目概述为什么一个体检挂号系统值得自己做先说个真实的场景。三甲医院体检中心早上七点半窗口已经排了四五十人。有人手里捏着一张皱巴巴的预约单有人对着护士反复问“我这个套餐含不含CT”还有人因为当天约满了白跑一趟。我去年陪家人去体检就撞上这一幕当时就在想医院内部的体检系统常年不更新线上预约要么没有要么难用如果能有一套餐号、项目一目了然、管理员能灵活调整的预约系统效率起码翻一倍。这就是这个项目的出发点基于 Python 实现一套医院体检挂号系统把“用户选套餐、选日期时段、完成预约、管理员审核和排班”这条核心链路全部线上化。整套系统包含源码和配套文档技术栈不冷门、代码量适中既可作为毕业设计、课程实训的完整项目也能直接改改用到中小型体检机构或企业年度体检的预约场景里。项目做出来之后我测试了几轮完整流程用户注册、登录、浏览套餐、选时段、提交预约再到管理员后台确认、调整预约容量整个过程没有多余的中间环节。相比手工登记和Excel排班最大的提升在于“时段容量实时可控”——管理员设置一天只能接待 40 人系统就会自动挡住第 41 个预约现场再也不会出现“排到了才发现号没了”的尴尬。如果你正在找 Python 课程设计或毕设题目或者你手头有体检机构预约管理的需求这套系统的设计和源码都值得参考。下面我把整体架构、数据库设计、核心功能实现和排障经验完整拆一遍代码层面的细节会尽可能还原方便你直接照着落地。2. 系统整体设计与技术选型思路2.1 为什么用 Flask 而不是 Django我在技术选型上犹豫过几天Django 自带 Admin 后台、ORM、Auth开发效率确实高但正因为“自带太多”新手看源码时容易被各种约定和自动生成的东西绕晕。而且体检预约这类业务逻辑并不复杂核心无非是“用户—套餐—时段—预约记录”四张表之间的状态流转用 Flask 做反而清爽。Flask 的好处在于轻和透明。一个 app.py 起步路由自己写ORM 用 Flask-SQLAlchemy 挂接会话用 Flask-Session 处理整个请求链路在源码里一眼能看完。对学 Python 的人来说这种“你没帮我藏任何东西”的设计才是理解 Web 项目最好的教材。项目里我给你保留了 MySQL 的完整建表脚本同时也兼容 SQLite想快速跑起来就用 SQLite想部署到线上再切 MySQL连接配置只改一行。2.2 前端方案服务端渲染为主不搞前后端分离现在一提开发系统很多人第一反应是 Vue/React 后端接口。但体检挂号系统这种场景信息展示密度高、表单交互明确没有太复杂的实时联动用服务端渲染 Bootstrap 就已经足够稳。页面通过 Jinja2 模板渲染表单提交后由后端校验、重定向全程不需要设计 RESTful API 的鉴权也不需要解决跨域问题开发量直接砍掉一半。同时模板组织得很清晰base.html 定义公共布局各页面单独继承改动导航栏或页脚只碰一个文件对后续维护非常友好。2.3 核心模块划分与请求流程系统按角色分成前台、后台两条线对应四个核心模块模块角色核心职责用户认证未登录用户/注册用户注册、登录、退出、密码加密校验套餐与项目展示已登录用户浏览体检类型、项目明细、价格、预计时长预约流程已登录用户选择日期与时段、提交预约、查看个人预约记录、取消预约后台管理管理员套餐增删改、时段容量设置、预约记录审核/确认、用户统计请求流程是浏览器发起请求 - Flask 路由函数执行逻辑 - SQLAlchemy 读写数据库 - 模板渲染返回页面。这里没有中间件、没有消息队列、没有异步任务保持简单但不简陋。后面我在“扩展性”部分会讲怎么在不改动核心代码的前提下加短信通知和报告上传。3. 数据库设计四张核心表撑起整条业务链3.1 用户表与安全设计用户表把注册信息和个人信息拆开避免预约时重复填身份证和手机号。字段设计如下CREATE TABLE service_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, real_name VARCHAR(50) NOT NULL, id_card VARCHAR(18) NOT NULL, phone VARCHAR(11) NOT NULL, gender TINYINT COMMENT 0女 1男, age INT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码绝不存明文使用 werkzeug.security 的 generate_password_hash 生成带随机盐的哈希值。校验时用 check_password_hash 对比。这一点我特别强调我见过太多课程设计项目把密码原样存库一旦数据库泄露用户的银行密码都可能受牵连。哪怕只是练手项目安全习惯要从第一行代码养成。3.2 套餐表与项目明细表体检中心的项目很多如果每个项目单独做成一个套餐管理会崩溃。现实业务里套餐是“基础血检 心电图 胸部DR”的组合不同套餐引用不同项目。所以设计成两张表CREATE TABLE service_type ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, description TEXT, price DECIMAL(10,2) NOT NULL, duration_minutes INT COMMENT 预计耗时, is_active TINYINT DEFAULT 1 ); CREATE TABLE service_item ( id INT PRIMARY KEY AUTO_INCREMENT, type_id INT NOT NULL, item_name VARCHAR(100) NOT NULL, item_desc VARCHAR(255), KEY idx_type_id (type_id) );套餐和项目是 1 对 N 关系前台展示时做一个 LEFT JOIN 就能把某个套餐的全部项目列出来。这里要注意一个业务细节删除套餐前必须先确认没有未完成的预约记录关联否则会破坏外键约束实际项目里管理员只能做“下架”is_active0不能物理删除这是一种更安全的做法。3.3 预约时段与预约记录容量和冲突处理的核心预约地段表的设计是这套系统最容易出错的地方。时段表存的是“上午 8:00-10:00”这类固定时段每个时段有一个总容量和当前已预约数。预约记录表保存“谁、在什么日期、哪个时段、约了哪个套餐”以及当前状态。CREATE TABLE periodic ( id INT PRIMARY KEY AUTO_INCREMENT, period_name VARCHAR(30) NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, capacity INT NOT NULL DEFAULT 20, booked_count INT NOT NULL DEFAULT 0 ); CREATE TABLE reserve ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, type_id INT NOT NULL, periodic_id INT NOT NULL, reserve_date DATE NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待确认 1已确认 2已取消 3已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_date (user_id, reserve_date), KEY idx_period (periodic_id, reserve_date) );预约日期和时段要分开存因为同一天的体检是分多个时段的。判断某个时段是否约满不能用“等于容量”判断因为取消预约会让 booked_count 变小可能出现“先超卖后有余量”的问题所以提交时统一用事务处理后面专门写。4. 核心功能实现预约全流程的代码级拆解4.1 用户登录与注册会话保持与表单校验注册页面的核心接口如下from flask import Blueprint, render_template, request, redirect, url_for, session from werkzeug.security import generate_password_hash, check_password_hash from models import db, ServiceUser user_bp Blueprint(user, __name__) user_bp.route(/register, methods[GET, POST]) def register(): if request.method POST: username request.form.get(username, ).strip() password request.form.get(password, ) real_name request.form.get(real_name, ).strip() id_card request.form.get(id_card, ).strip() phone request.form.get(phone, ).strip() if not all([username, password, real_name, id_card, phone]): return render_template(user/register.html, error所有字段均为必填) if ServiceUser.query.filter_by(usernameusername).first(): return render_template(user/register.html, error用户名已存在) user ServiceUser( usernameusername, password_hashgenerate_password_hash(password), real_namereal_name, id_cardid_card, phonephone ) db.session.add(user) db.session.commit() return redirect(url_for(user.login)) return render_template(user/register.html)两个细节说明第一表单校验放在后端而不是只靠前端因为前端校验可以用浏览器工具绕过。第二用户名重复要提前查一次虽然用户名有唯一索引但先查再插入能让错误提示更友好而不是抛一个难看的数据库异常。4.2 套餐浏览按类型分组展示详情套餐列表页需要成组展示我用一个字典把套餐和项目装好再传给模板user_bp.route(/types) def type_list(): types ServiceType.query.filter_by(is_active1).all() type_data [] for t in types: items ServiceItem.query.filter_by(type_idt.id).all() type_data.append({type: t, items: items}) return render_template(user/type_list.html, type_datatype_data)这里注意用 is_active1 过滤管理员下架的套餐不会出现在前台。前端模板里体检时长和注意事项要列得清楚实测下来这些信息能明显减少客服被询问的次数。4.3 预约界面不可约的日期直接灰掉预约页有一个关键逻辑体检机构通常周日休息且当天不能约当天。日期控件的禁用逻辑如下// 页面加载时由后端传入 disableDates 列表不可预约日期 const disabled JSON.parse({{ disable_dates | tojson }}); const dateInput document.getElementById(reserve_date); for (const d of disabled) { dateInput.querySelector(option[value${d}]).disabled true; }后端在渲染预约页时先把过去日期、周日、已约满的日期算出来作为 disable_dates 传给模板。这样用户在界面上就选不到无效日期而不是等提交了才提示“该日期不可约”。这属于体验细节但实战里非常影响用户感受。4.4 提交预约与并发冲突处理必须用事务预约提交是整个系统最核心、也最容易踩坑的逻辑。很多人第一次写的时候会这样实现period Periodic.query.get(periodic_id) if period.booked_count period.capacity: period.booked_count 1 db.session.commit() # 再插入预约记录这在单用户测试时没问题但一旦两个人同时抢最后一个名额两个请求都读到 booked_count capacity都执行加一最后就超卖了一个号。解决思路有两种第一种数据库行锁。查询时加上 with_for_update()让 MySQL 在事务期间锁住这行其他写请求必须等待这是最稳妥的解决方式。对应代码try: period Periodic.query.filter_by(idperiodic_id).with_for_update().first() if not period: return error(时段不存在) if period.booked_count period.capacity: db.session.rollback() return error(该时段已约满) reserve Reserve( user_idcurrent_user.id, type_idtype_id, periodic_idperiodic_id, reserve_datereserve_date, status0 ) db.session.add(reserve) period.booked_count 1 db.session.commit() except Exception: db.session.rollback() return error(预约失败请重试)注意事务的顺序先加锁读取时段再校验容量然后插入记录并增加计数最后统一提交。不能用 try 包住全程然后“看心情回滚”必须把异常处理写完整。SQLite 在并发场景下对行锁支持较弱如果你是拿 SQLite 在本地测试可以用数据库文件锁但部署到生产环境我强烈建议切 MySQL。第二种乐观锁。在 periodic 表加一个 version 字段更新时 SET booked_count booked_count 1, version version 1 WHERE id ? AND version old_version。受影响行数为 0 就说明并发冲突让用户重新尝试。这种方式适合读多写少的场景但这个系统里预约提交频率不算高用行锁更直观也更容易讲清楚。4.5 个人预约记录与取消逻辑用户“取消预约”这个功能不是简单地删记录而是状态变更否则统计预约总量时会丢失审计线索。取消时要同时把时段表的 booked_count 减回去user_bp.route(/cancel/int:reserve_id, methods[POST]) def cancel_reserve(reserve_id): reserve Reserve.query.filter_by(idreserve_id, user_idcurrent_user.id).first() if not reserve: return error(记录不存在) if reserve.status not in (0, 1): return error(当前状态不可取消) period Periodic.query.filter_by(idreserve.periodic_id).with_for_update().first() if period: period.booked_count max(0, period.booked_count - 1) reserve.status 2 db.session.commit() return redirect(url_for(user.my_reserves))这里有两个细节一是取消操作只允许更新自己的预约用 filter_by(user_idcurrent_user.id) 做归属校验防止越权二是减容量用 max(0, ...)即使数据异常也不会出现负数容量。4.6 管理员后台参数可配权限隔离管理员页面独立成 admin 蓝图和前台用户完全隔离。登录会话里区分角色普通用户访问 /admin/ 直接 403。后台的套餐管理走增删改查但“删”换成了“下架”原因前面已经说过。时段容量管理是我个人认为这套系统里做得最顺手的功能管理员修改时段容量后当天已预约人数超过新容量时系统会提示“当前时段已有 N 人预约容量不能低于 N”。这行判断虽然简单却避免了大量数据不一致的隐患。5. 部署运行演示与坑点排查5.1 下载源码后 5 分钟跑起来拿到源码包后按下面步骤操作只要 Python 环境没问题五分钟内能在本地跑起来# 1. 创建虚拟环境推荐避免污染全局 Python python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate # 2. 安装依赖 pip install -r requirements.txt # 3. 初始化数据库 flask db init flask db migrate flask db upgrade # 或者直接 python init_db.py项目里有现成的初始化脚本 # 4. 启动 flask run 或 python app.py项目默认使用 SQLite启动后访问 http://127.0.0.1:5000管理员账号在 README 里有说明用户名 admin密码 123456。首次登录后第一件事是改掉初始密码这个习惯不管在什么项目里都值得坚持。如果切换到 MySQL修改 config.py 中 SQLALCHEMY_DATABASE_URIapp.config[SQLALCHEMY_DATABASE_URI] mysqlpymysql://root:yourpasswordlocalhost/hospital_reserve?charsetutf8mb4换成 MySQL 后需要先在数据库里建好 hospital_reserve 这个库字符集务必选 utf8mb4否则中文姓名、地址等字段可能变成问号。5.2 高频坑点中文乱码中文乱码出现的场景集中在两个地方数据库交互和 JSON 返回。数据库侧建表字符统一用 utf8mb4连接串带上 charsetutf8mb4基本可以避免模板渲染时记得在 HTML head 里加meta charsetutf-8。如果你用的是 PyMySQL 驱动连接参数里没带 charset插入的中文在 MySQL 里会显示为乱码这个检查优先级最高。5.3 高频坑点日期类型处理Python 的 date 对象直接传到 Jinja2 模板或 JSON 序列化时有时会因为时区或格式问题报错。我在项目里统一做了格式化def format_date(value): return value.strftime(%Y-%m-%d) if value else app.jinja_env.filters[datefmt] format_date模板里调用{{ reserve.reserve_date | datefmt }}。序列化传给前端 JS 时也明确转成字符串避免 MySQL 返回 datetime 类型导致 JSON 报错。5.4 常见问题速查表问题现象可能原因解决办法两个用户同时预约同一个时段最后一个名额都显示成功没有用事务加行锁用 with_for_update() 加悲观锁中文存进 MySQL 后变成问号连接串没设 charset加上 ?charsetutf8mb4注册后无法登录密码哈希生成和校验方法不匹配确认都用 werkzeug.security 同一套方法预约日期能选到周日前端禁用逻辑未生效检查 disable_dates 是否正确传给模板取消预约后容量没有恢复只更新了预约状态没更新时段表取消逻辑中同步减 booked_count后台无法登录默认账号被改或会话过期检查 user 表角色字段或重置初始账号图片/静态资源 404模板路径写错确认使用 url_for(static, filename...) 生成路径5.5 部署到服务器的两个建议本地跑通之后如果要部署到 Linux 服务器注意两点。第一用 gunicorn 或 uwsgi 跑 Flask 应用不要用内置的 dev serverdev server 在并发上非常脆弱。启动命令大概长这样gunicorn -w 4 -b 0.0.0.0:8000 app:app第二生产环境必须把 SECRET_KEY 换成随机长字符串不要用源码里默认的。simple:import secrets app.secret_key secrets.token_hex(32)很多人把默认密钥直接暴露攻击者可以利用 Flask 的 session 伪造机制绕过登录这是相当危险的。6. 系统扩展在不改核心的基础上加功能这套系统的扩展性我在一开始设计时就留了余地。如果你拿到源码之后想更进一步我推荐按下面三个方向扩展难度从低到高都能复用现有的表结构。第一短信提醒。预约成功后在 reserve 插入代码里调用一个发短信函数通知用户“您已成功预约 X 月 X 日上午时段”。因为手机号已经在用户表里了不需要动表结构只需要写好发送逻辑接入云片、阿里云等短信平台即可。第二体检报告上传与查看。预约状态改为 3已完成时由管理员上传 PDF/图片报告用户端“我的预约”页面里增加报告下载入口。这需要新增一张 report_table字段包括预约 ID、报告文件路径、上传时间。第三体检标准项库。把体检项目从套餐中独立出来做成“项目库 套餐与项目多对多关联”这样管理员可以灵活组合套餐。这个改动会涉及表结构调整需要迁移脚本但逻辑上不复杂属于更合理的数据模型演进方向。7. 写在最后这套系统的价值与迭代建议前几天我又跑了一遍完整流程从一个新用户注册、完成预约、到管理员把状态改成已完成总共没超过两分钟。对比最开始手改 Excel 排班的日子这个效率提升是实打实的。我特别想提醒一点很多人拿毕设或源码项目跑通演示一遍就满足了。但一个系统真正的地基不在功能列表而在边界条件。你有没有想过“同一个用户能不能在同一天预约两个套餐”当前系统的索引设计并没有禁止这种情况业务规则里也没有明确“一人一天只约一个套餐”。这种规则类问题正是你拿到源码后最值得动手补完的地方——比多写十页 CRUD 更能体现系统设计能力。另外如果这是准备用于答辩的项目建议把“并发预约冲突”“如何防止重复预约”“为什么管理员不能删除套餐”这三个问题彻底吃透。这些点来源于真实的业务思考也是评审老师最可能追问的细节。最后再分享一个小经验体检预约系统最重要的不是界面多炫而是“情况变更后数据依然准确”。调试这种系统时多花点时间模拟异常分支比追求页面效果好得多。先把一套核心链路打磨扎实后续加再多功能也不会慌。
返回列表