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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;密码绝不存明文,使用 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_active=0),不能物理删除,这是一种更安全的做法。
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(username=username).first(): return render_template('user/register.html', error='用户名已存在') user = ServiceUser( username=username, password_hash=generate_password_hash(password), real_name=real_name, id_card=id_card, phone=phone ) 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_active=1).all() type_data = [] for t in types: items = ServiceItem.query.filter_by(type_id=t.id).all() type_data.append({'type': t, 'items': items}) return render_template('user/type_list.html', type_data=type_data)这里注意用 is_active=1 过滤,管理员下架的套餐不会出现在前台。前端模板里,体检时长和注意事项要列得清楚,实测下来这些信息能明显减少客服被询问的次数。
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(id=periodic_id).with_for_update().first() if not period: return error('时段不存在') if period.booked_count >= period.capacity: db.session.rollback() return error('该时段已约满') reserve = Reserve( user_id=current_user.id, type_id=type_id, periodic_id=periodic_id, reserve_date=reserve_date, status=0 ) 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(id=reserve_id, user_id=current_user.id).first() if not reserve: return error('记录不存在') if reserve.status not in (0, 1): return error('当前状态不可取消') period = Periodic.query.filter_by(id=reserve.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_id=current_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_URI:
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:yourpassword@localhost/hospital_reserve?charset=utf8mb4'换成 MySQL 后,需要先在数据库里建好 hospital_reserve 这个库,字符集务必选 utf8mb4,否则中文姓名、地址等字段可能变成问号。
5.2 高频坑点:中文乱码
中文乱码出现的场景集中在两个地方:数据库交互和 JSON 返回。数据库侧,建表字符统一用 utf8mb4,连接串带上 charset=utf8mb4,基本可以避免;模板渲染时记得在 HTML head 里加<meta charset="utf-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 | 加上 ?charset=utf8mb4 |
| 注册后无法登录 | 密码哈希生成和校验方法不匹配 | 确认都用 werkzeug.security 同一套方法 |
| 预约日期能选到周日 | 前端禁用逻辑未生效 | 检查 disable_dates 是否正确传给模板 |
| 取消预约后容量没有恢复 | 只更新了预约状态,没更新时段表 | 取消逻辑中同步减 booked_count |
| 后台无法登录 | 默认账号被改或会话过期 | 检查 user 表角色字段,或重置初始账号 |
| 图片/静态资源 404 | 模板路径写错 | 确认使用 url_for('static', filename=...) 生成路径 |
5.5 部署到服务器的两个建议
本地跑通之后,如果要部署到 Linux 服务器,注意两点。第一,用 gunicorn 或 uwsgi 跑 Flask 应用,不要用内置的 dev server,dev 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 更能体现系统设计能力。
另外,如果这是准备用于答辩的项目,建议把“并发预约冲突”“如何防止重复预约”“为什么管理员不能删除套餐”这三个问题彻底吃透。这些点来源于真实的业务思考,也是评审老师最可能追问的细节。
最后再分享一个小经验:体检预约系统最重要的不是界面多炫,而是“情况变更后数据依然准确”。调试这种系统时,多花点时间模拟异常分支比追求页面效果好得多。先把一套核心链路打磨扎实,后续加再多功能也不会慌。