用Flask构建医院挂号就诊系统:数据库建模、并发控制与实战解析

发布时间:2026/10/11 12:39:22
用Flask构建医院挂号就诊系统:数据库建模、并发控制与实战解析
每年一到毕业设计和面试季节总有人问我同一个问题“想做一个带点实际业务逻辑的Web项目用什么技术栈最划算”我的回答一般都很固定Flask配Python。如果再追问一句具体做什么我会直接抛出一个练手与实战兼顾的题目——医院挂号就诊系统。这个题目覆盖了用户认证、权限控制、数据关系设计、业务状态流转、并发处理等多个高价值知识点难度适中又足够拿得出手。这篇文章就来完整拆解这个项目的建模思路、核心功能实现和我在实操中踩过的坑希望能给正在选型或开发的同学一些能直接落地的参考。1. 项目整体设计与技术选型1.1 为什么选Flask而不是其他框架聊到Web开发很多人第一时间想到的是Django因为它“全家桶”式的设计确实省事自带Admin后台、ORM、表单处理全都给你安排好了。但对于医院挂号就诊系统这个体量的项目来说Flask的轻量反而成了最大的优势。一是因为Flask足够灵活。它不像Django那样替你把一切都定好而是让你自己决定用什么数据库、什么扩展、怎么组织项目结构。这种自由度对理解Web框架的工作机制特别有帮助——你在Flask里能真切感受到“路由怎么匹配”“请求上下文生命周期是什么”“Session是怎么跨请求保持状态的”而不是仅仅调用一个现成的接口。二是因为Flask的生态足够成熟。虽然轻量但该有的扩展一个不少SQLAlchemy管数据库、Flask-Login管登录态、Flask-WTF管表单校验、Flask-Migrate管数据表迁移这些组合起来完全够用。真遇到需要横向扩展的情况Flask也能Hold住不会出现“框架瓶颈卡死项目”的尴尬局面。三是学习曲线平缓。对于一个需要快速上手的项目Flask只需要掌握路由、视图函数、模板渲染、ORM基本操作这四板斧就能跑通百分之八十的功能。不像部分框架配置繁琐启动一个Hello World都要理解一堆中间件概念。1.2 系统整体架构与角色划分医院挂号就诊系统核心的参与者其实就三类患者、医生、管理员。这三类角色对应了三种完全不同的界面和操作权限。整个系统采用传统的MVC分层思路来组织代码。Models层负责和数据表打交道用SQLAlchemy定义模型类把Python对象和数据库记录映射起来Views层就是视图函数接收前端请求、处理业务逻辑、返回响应模板目录下的HTML文件则只负责渲染和展示。这样做的好处是逻辑清晰出了问题能快速定位是数据层、业务层还是展示层出问题。Flask的项目结构建议不要全部挤在一个入口文件里。我习惯用类似工厂模式的组织方式hospital_system/ ├── run.py # 应用启动入口 ├── config.py # 全局配置 ├── app/ │ ├── __init__.py # 应用工厂 │ ├── models/ # 数据库模型 │ ├── views/ # 蓝图路由 │ ├── forms/ # 表单类 │ ├── templates/ # HTML模板 │ └── static/ # 静态资源 └── manage.py # 数据迁移与命令行工具用蓝图Blueprint来拆分路由模块是Flask项目规范化的关键一步。比如认证功能注册成auth蓝图挂号业务单独放booking蓝图后台管理用admin蓝图互不干扰。这个习惯在项目变大时能省下大量的调试时间。1.3 技术创新点与技术难点预判这个项目表面看是CRUD但深挖下去有不少值得注意的技术点第一个是号源并发控制。患者同时点击“挂号”按钮时数据库层怎么保证不会出现一号多卖的情况这需要事务配合行级锁或乐观锁才能解决纯靠Python代码判断余号数量是防不住的。第二个是多角色权限控制。同一个系统里普通用户只能看到自己的挂号记录医生只能处理自己名下的患者管理员能管理全部基础数据。这种基于角色的访问控制RBAC模型怎么设计才能既安全又灵活是需要认真思考的问题。第三个是业务状态机设计。挂号单从“已预约”到“已完成”中间可能经历“已取消”“已就诊”“已过号”等不同状态这些状态之间哪些转换是合法的在设计阶段就要想清楚而不是等出了Bug再补逻辑。理解了整体架构和难点下面进入硬核环节——数据库表设计。2. 数据库建模与核心表设计2.1 核心表概览与关系梳理医院挂号系统的数据模型核心可以归纳为“人和事的关系”。人包括患者、医生、管理员事就是挂号与就诊。再加上科室这种基础资料表整个系统的数据关系并不复杂但每一张表的字段设计都有讲究。我的建议是至少设计以下六张表表名核心作用重要字段users患者与管理员统一账号表username、password_hash、roledoctors医生信息表name、title、department_id、schedule_infodepartments科室表name、descriptionappointments挂号单表user_id、doctor_id、appointment_date、time_slot、status、order_numbermedical_records就诊记录表appointment_id、diagnosis、prescriptionschedules医生排班表doctor_id、work_date、total_count、remaining_countusers表和doctors表为什么分开因为医生不仅有账号属性还有职业属性比如职称、所属科室、擅长领域这些字段放在用户表里会让表变得臃肿而且角色扩展后维护成本很高。所以用户表只保留最基础的认证信息业务信息单独下沉到各自的资料表里通过外键关联。2.2 用户与医生表设计细节用户表的密码字段建议用password_hash而不是明文密码GitHub上很多教程会直接存明文这种代码demo里跑跑没问题但如果你要拿这个项目去面试或做毕业设计密码哈希是必须有的。# models/user.py from werkzeug.security import generate_password_hash, check_password_hash from flask_login import UserMixin class User(UserMixin, db.Model): __tablename__ users id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(64), uniqueTrue, nullableFalse) password_hash db.Column(db.String(256), nullableFalse) role db.Column(db.String(16), defaultpatient) # patient / admin created_at db.Column(db.DateTime, defaultdatetime.utcnow) def set_password(self, password): self.password_hash generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password)医生表需要特别注意的是排班信息的存储方式。最偷懒的做法是在医生表里直接加一个“出诊时间”的字符串字段比如“周一上午、周三下午”。但如果想做出“剩余号量实时扣减”的功能这种字符串结构根本无法支撑。正确的做法是单独设计排班表把每个医生每天每个时段的号源总数和剩余数拆成一条条记录才能支持精确控制和并发扣减。2.3 挂号单表与状态字段设计挂号单表是整个系统的核心业务表其状态字段的设计直接决定了业务流程的复杂度和稳定性。我建议使用整型状态码配合常量定义而不是在数据库里直接存中文状态描述。好处有两个一是数据库占用更小、匹配更快二是代码里用常量引用可读性好后续改动状态文案只需要改常量映射不需要动数据库里的一堆字符串。class AppointmentStatus: PENDING 0 # 已预约待就诊 CANCELED 1 # 已取消 COMPLETED 2 # 已完成 MISSED 3 # 过号未就诊此外挂号单表还需要一个编号字段。不要用自增主键直接当挂号编号给用户看因为那样会暴露系统的每日挂单量有经验的用户能根据单号推测出业务冷热。可以用日期加随机数的组合来生成对外展示的订单号主键只用来内部关联。2.4 数据库设计的三个容易踩的坑第一多对多关系的处理不要绕圈子。一个医生属于一个科室科室有一堆医生这是典型的一对多。但如果使用了错误的关联方式查询就会变得特别别扭。建议先画ER图理清楚再建表不然后面重构表的成本远大于一开始多花的那半小时。第二逻辑删除还是物理删除要提前定。用户取消挂号后挂号单记录是直接从数据库删除还是保留一条状态为“已取消”的记录我的建议是保留因为取消记录对统计失约率、分析号源利用率都有价值。给表加上一个is_deleted字段或者依赖状态字段都行但务必要在方案设计阶段想好别做到一半才发现统计数据对不上。第三索引不能乱建。很多初学者喜欢给所有字段都加索引导致插入更新变慢、存储膨胀。我的建议是只在查询条件中高频使用的字段上建索引比如user_id和status的组合索引、appointment_date和doctor_id的组合索引而像departments表的name字段权限很低建普通索引纯属浪费。3. 核心功能模块拆解与实现3.1 用户注册登录与会话管理用户的注册登录是整个系统的基础也是最容易在网络教程中被糊弄过去的部分。一个完整的认证模块至少需要覆盖以下几个环节注册时要做用户名唯一性校验、密码强度校验长度最短6位、必须包含字母和数字、邮箱或手机号格式校验。登录时要做账号密码校验、记住登录状态的处理还要防暴力破解多次失败后限制登录。这些逻辑虽然琐碎但没有一个可以省略。Flask中做登录管理我会选择Flask-Login扩展它能帮我们管理用户会话、实现“记住我”功能并且在视图中提供login_required装饰器来做权限拦截。# views/auth.py from flask_login import login_user, logout_user, login_required, current_user auth_bp.route(/login, methods[GET, POST]) def login(): if request.method POST: username request.form.get(username) password request.form.get(password) user User.query.filter_by(usernameusername).first() if user and user.check_password(password): login_user(user, rememberTrue) return redirect(url_for(index)) flash(用户名或密码错误) return render_template(login.html)用login_user登录后视图函数中通过current_user就能拿到当前登录用户对象非常方便。退出登录就调用logout_user()。权限控制方面我用了两层设计。第一层是装饰器拦截比如医生相关的路由都加上login_required和admin_required第二层是模板内判断通过当前用户的角色来控制哪些菜单、按钮可见。虽然有些人觉得第二层有点多余但我的经验是加上这层能给用户更好的交互体验不显示没有权限的入口还能阻止一部分“伪装URL”的越权访问。3.2 科室与医生信息展示模块展示科室和医生信息看起来最简单但里面的性能细节值得说道。如果直接在模板里嵌套循环查询医生列表也就是经典的N1查询问题——先查出所有科室然后逐个科室再查它的医生列表数据库查询次数随着科室数量线性增长。正确做法是用SQLAlchemy的joinedload或selectinload一次性把关联数据查出来。# views/hospital.py from sqlalchemy.orm import joinedload hospital_bp.route(/departments) def department_list(): departments Department.query.options( joinedload(Department.doctors) ).all() return render_template(departments.html, departmentsdepartments)写项目的时候如果你发现页面加载越来越卡先别急着考虑加缓存检查一下SQL查询次数往往能解决百分之八十的瓶颈问题。科室列表页还可以加上搜索功能。按科室名称模糊查询、按医生职称筛选、按科室类别分组展示这些交互看起来不起眼但对用户体验的提升是实打实的。实现也不难SQLAlchemy里用like操作符就好。3.3 排班与号源管理模块排班表是挂号系统的“库存表”设计它的核心思路是“当日号源总数与剩余号数分离”。医生表里有一个初始的总号源数但每天因为出诊安排不同实际的放号量可能不一样所以排班表需要每天生成一条独立的记录包含日期、时段、总号量、剩余号量。排班的生成逻辑靠后台管理员操作。管理员选定医生、日期、时段、周号量后系统自动生成对应的排班记录。如果医生在多个时段出诊就生成多条记录。一个需要注意的小细节是排班日期的有效期问题。排班表不应该允许“过去时间”的排班被修改否则就会出现给昨天补号的奇怪数据。我一般在视图层加一道日期校验只在编辑今天和未来的排班时才允许保存。3.4 核心挂号流程实现挂号是整个系统的核心操作涉及号源校验、事务操作、状态变更三步。患者在前端选择一个家中的医生和日期后系统需要先检查这个医生的排班记录是否存在、剩余号量是否大于0、患者当天是否已经挂过这个医生的号防止重复挂号。全部通过后才能进行数据库操作。# views/booking.py booking_bp.route(/book/int:schedule_id, methods[POST]) login_required def book_appointment(schedule_id): schedule Schedule.query.filter_by(idschedule_id).with_for_update().first() if not schedule or schedule.remaining_count 0: flash(该时段号源已满) return redirect(url_for(hospital.schedule_list)) duplicate Appointment.query.filter_by( user_idcurrent_user.id, schedule_idschedule.id, statusAppointmentStatus.PENDING ).first() if duplicate: flash(您已预约该时段的号源请勿重复挂号) return redirect(url_for(hospital.my_appointments)) schedule.remaining_count - 1 appointment Appointment( user_idcurrent_user.id, doctor_idschedule.doctor_id, schedule_idschedule.id, appointment_dateschedule.work_date, statusAppointmentStatus.PENDING ) db.session.add(appointment) db.session.commit() flash(挂号成功) return redirect(url_for(hospital.my_appointments))这里最关键的是with_for_update()方法它会对选中的排班记录加行级锁直到事务提交或回滚后才释放。这能有效防止两个用户同时挂号时“超卖”的问题。挂号成功后用户可以在“我的挂号”页面看到所有预约记录并能执行取消操作。取消的逻辑就是反向操作把状态改成已取消然后把对应的排班记录剩余号和号量加回去。注意这里也需要使用行级锁防止和新的挂号请求同时修改同一数据造成不一致。4. 就诊流程与业务状态流转设计4.1 从挂号到就诊的完整闭环很多课程设计做到挂号就结束了就诊环节草草糊弄。但“挂号就诊系统”的重点恰恰在就诊记录的管理上这部分做扎实了整个项目的含金量立刻上一个台阶。就诊流程可以简单描述为患者挂号成功变成待就诊状态按预约时间到科室候诊医生在系统中把该患者的状态改为就诊中然后录入诊断结果和处方信息最后结案并把状态改成已完成。这个流程看着简单实现时有两个细节要处理。一是状态流转的保护。不可能让一个已经完成就诊的患者被取消挂号也不能让一个还没来就诊的患者直接被录成已完成。这种非法状态转换必须在代码层做出判断不能让前端按钮来控制合法性。二是就诊记录的创建时机。我建议在预约成功时就同步创建一条空的就诊记录就诊时只需更新这条记录的内容。这样做的好处是医生查看待就诊列表时可以直接关联到已完成和未完成的所有信息不需要临时拼接数据。同时挂号单和就诊记录保持一对一的关系逻辑非常清晰。4.2 状态机设计与状态流转控制状态机是业务逻辑中最容易被忽视但又最重要的部分。挂号单的状态转换必须是有方向的不是任何状态都能直接跳到任何状态。一个合理的状态转换表当前状态可转换状态触发条件已预约(0)已取消(1)、已完成(2)、过号(3)用户取消/医生结束就诊/超时未到已取消(1)无终态已完成(2)无终态过号(3)无终态这个设计用代码实现时可以写一个状态转换校验函数或者更简单一点在每个状态变更的视图函数里先查询当前状态再判断是否允许变更不允许就直接报错或返回提示。这种写法虽然稍微有点笨但最容易理解和维护适合课程设计和中小型系统。4.3 候诊列表与医生工作台医生登录后看到的界面和患者完全不同。医生工作台应该包含以下功能查看今天自己的出诊排班、查看待就诊患者列表、搜索患者历史就诊记录、录入当前患者的诊断和处方、标记就诊完成。候诊列表的实现逻辑不复杂就是查询今天状态为“已预约”且医生ID等于当前登录用户的挂号单列表按挂号顺序排列。但如果挂号量大前端需要做自动刷新。我的做法是用JavaScript定时器每隔30秒拉取一次接口更新当前候诊人数和列表。单纯等待的时候患者看到数字变化心里会更踏实。就诊记录保存后就可以在患者的历史记录里看到完整的就诊历史包括就诊时间、医生姓名、诊断结果、处方信息。这个功能对用户的价值非常大很多患者需要复诊时查历史记录一定要做。5. 常见问题与排查技巧实录5.1 并发挂号导致超卖问题这是挂号系统最容易出现且最致命的问题一定要认真对待。我在第一次开发时也遇到过两个用户几乎同时点击挂号都通过了“剩余号量大于0”的检查然后都成功扣减了号源整个流程走下来明明每个请求单独看都没问题但结果就是超卖了。问题根源在于“检查剩余号量和扣减号量”不是一个原子操作中间出现了时间窗口。解决办法有两种主流方案一种是事务加行级锁前面代码里写的with_for_update()让检查代码在数据库层面串行执行另一种是乐观锁在排班表加一个版本号字段更新时比对版本号不一致就重试或报错。我个人的建议是对于课程设计和毕设直接使用悲观锁方案最省心代码可读性也好面试被问到还能把原理讲清楚。5.2 用户重复挂号的屏蔽逻辑重复挂号的判断需要在数据库层面做约束而不是单纯依赖代码查询。如果你不放心并发下的重复预约可以在Appointment表加上联合唯一索引__table_args__ ( db.UniqueConstraint(user_id, schedule_id, status, nameuniq_user_schedule), )这个约束能防止同一个用户在同一排班记录下创建两条状态完全相同的挂号单。但注意状态变成已取消后不能也用同样的唯一约束否则用户取消后想再挂同一时段就挂不上了。我的做法是把status字段存进去做联合唯一但已取消的记录状态码不同不会和新记录冲突。实际使用中这个方案比较顺手。5.3 时区与时间处理很多新手写时间字段直接默认了本地时间但部署到服务器后就发现差8个小时。建议在config.py里显式设置TIMEZONE Asia/Shanghai同时建议数据库统一存UTC时间具体展示时再转本地。SQLAlchemy里datetime.utcnow存的是UTC时间展示时用前端模板或者后端转换函数加上时区偏移量。这个小细节在写就诊时间、挂号时间时尤其重要不然患者看到的预约时间和实际对不上会引发投诉。5.4 CSRF防护与Session安全Flask-WTF默认开启CSRF防护使用form.hidden_tag()渲染隐藏字段就行。如果你有些表单是用原生HTML手写的忘了加隐藏字段提交时就会被Flask-WTF的CSRF校验拦截。我的习惯是表单统一用模板渲染省心不出错。Session过期时间也建议设置一下默认的session是浏览器会话级关闭即失效如果想要保持登录状态在Flask的配置里加PERMANENT_SESSION_LIFETIME timedelta(days7)。6. 实操心得与扩展建议做完这个系统如果还想往上提升有三个扩展方向值得投入。第一是消息通知模块。挂号成功、就诊前提醒、号源已满时通知都可以通过邮件或者短信的方式触达用户。单是做挂号成功后的邮件通知就比纯页面提示显得专业很多也是面试时容易聊出深度的加分点。第二是数据可视化统计。给管理员加一个仪表盘今日挂号量、各科室就诊热度、每周门诊趋势、医生工作量排名。这些统计用SQLAlchemy的聚合查询加上前端图表库比如ECharts或Chart.js就能实现但做出来的效果非常直观。第三是搜索优化。比如医生首页提供“按科室、按职称、按姓名拼音首字母”的筛选组合。这个功能看着小但涉及多条件组合查询的写法锻炼价值很大。我在实际开发这个项目的过程中最大的体会是别急着写代码。先花两个小时把表结构、状态转换、角色权限这三件事画清楚后面写代码会顺畅很多。“数据关系理清”这件事决定了这个项目是牵一发动全身的噩梦还是有条不紊的精品工程。如果你正在写这个项目遇到卡住的地方优先检查数据库表关系是不是绕了、状态转换是不是没加限制、并发操作是不是漏了锁。这三个问题解决掉项目的完成度基本就到了一个很能拿得出手的水平。