
做这样一个系统最深的体会就是一个在线问诊的完整链路绕来绕去总跑不开患者发起问诊、医生接诊回复、最后开出处方这三件事。但真要把这三件事用代码落成一个能上线的东西牵扯到的设计远比表面看到的要多。这篇文章就把我完成这套医院就诊管理系统的全过程——技术选型、后端建模、前端架构、核心业务流程、权限控制、部署上线——按实际开发的顺序捋一遍。项目最终配合的是Python Flask提供APIVue做前端页面中间走的是标准的前后端分离模式适合正在做Web全栈课设、毕业设计或者想从零搭一个中小型信息管理系统的同学参考。1. 为什么是FlaskVue这套技术选型的真实考量1.1 对比了Spring Boot、若依、Django之后的选择开始动手之前我在Spring Boot、Django和Flask之间纠结了挺久。很多人一提医院管理系统就默认要上Spring Boot再加上若依这种后台脚手架感觉这才是正路。但我仔细算了一笔账这个系统的核心是问诊流程的管理并发量不高功能边界清晰最需要的其实是开发速度和灵活度。我列过一张对比表选型时心里就清楚了方案上手成本部署重量适合场景Spring Boot较高Java生态配置多需要JDKMaven运行环境大型企业级、高并发Django中等自带后台可快速搭建一套固定的MTV模式内容站点、快速原型Flask低路由和视图模型极简轻量一个Gunicorn就够小型SaaS、内部系统、API服务若依脚手架高代码量大前后端分离但结构重整体工程体量偏大后台管理框架型项目Flask的优势不是功能强大而是恰到好处。这个系统就三类角色患者、医生、管理员。核心业务是问诊单的流转不是复杂的权限矩阵也不是海量数据的统计分析。Flask的路由装饰器、蓝图模块化、以及庞大的第三方扩展库能让我花最少的时间把API写出来把时间省给前端交互和问诊流程的打磨。另外还有一个很现实的原因Flask框架本身是纯Python实现数据模型用SQLAlchemy来映射对后来做数据分析和扩展都有好处。比如后期要加个简单的诊断统计直接复用现有的ORM模型写脚本就行不用额外接数据管道。1.2 Vue放在这里的真正作用我选了Vue来做前端而且刻意没有用Flask的Jinja2模板渲染。原因不复杂在线问诊的交互是典型的多状态页面——患者要能实时看到问诊单状态从待接诊变成医生已接诊医生那边要快速切换接诊队列这是传统模板渲染很难做流畅的。Vue的好处在于组件化和响应式。我把患者端的问诊详情页拆成了几个组件病情描述卡片、聊天记录区域、处方展示卡片、就医指引卡片。状态一变页面局部刷新不用整个重新加载。Vue 3的组合式APIComposition API写这类业务逻辑非常顺手逻辑相关的东西放在一起比Vue 2的选项式API清晰不少。前后端分离的另一个隐性好处是后端Flask只需要老老实实提供JSON接口我前端页面怎么跳转、组件怎么复用完全自己说了算。调试的时候开个浏览器DevTools直接看接口请求问题定位起来特别快。2. 后端骨架Flask的模块化设计与数据库建模2.1 项目目录结构设计Flask项目最忌讳把代码全堆在app.py里哪怕系统不大我也从一开始就按模块拆好了。目录结构供参考flask_backend/ ├── app/ │ ├── __init__.py # 应用工厂注册蓝图和扩展 │ ├── config.py # 配置项包含数据库连接、SECRET_KEY │ ├── models/ │ │ ├── __init__.py │ │ ├── user.py # 用户、患者、医生模型 │ │ ├── department.py # 科室模型 │ │ ├── consultation.py # 问诊单模型 │ │ ├── prescription.py # 处方及明细模型 │ │ └── message.py # 聊天消息模型 │ ├── api/ │ │ ├── __init__.py │ │ ├── auth.py # 登录注册接口 │ │ ├── patient.py # 患者端接口 │ │ ├── doctor.py # 医生端接口 │ │ ├── admin.py # 管理端接口 │ │ └── common.py # 科室、公共数据接口 │ ├── utils/ │ │ ├── jwt_auth.py # JWT生成与校验 │ │ ├── decorators.py # 角色鉴权装饰器 │ │ └── response.py # 统一返回格式 │ └── extensions.py # db SQLAlchemy()等扩展实例 ├── migrations/ # 数据库迁移文件 ├── requirements.txt └── run.py应用工厂模式是关键。extensions.py里单独存放db SQLAlchemy()和ma Marshmallow()这类扩展实例然后在app/__init__.py里把它们绑定到应用上。这样写的好处是需要跑单元测试时可以创建多个不同配置的应用实例互不干扰。2.2 数据模型的设计思路数据库我用的MySQL字符集统一utf8mb4。表结构设计是整个系统里最需要想清楚的部分因为看病流程的状态流转本质上就是数据表里几个字段的变化。核心表如下表名作用关键字段users所有登录账号统一存放id, username, password_hash, role, real_name, phonepatients患者的扩展信息id, user_id, gender, birth_date, medical_historydoctors医生的扩展信息id, user_id, department_id, title, introduction, consultation_feedepartments科室信息id, name, descriptionconsultations问诊单id, patient_id, doctor_id, status, symptom_desc, diagnosis, created_atmessages问诊聊天记录id, consultation_id, sender_id, content, msg_type, created_atprescriptions处方主表id, consultation_id, doctor_id, patient_id, total_amount, note, created_atprescription_items处方明细id, prescription_id, drug_name, spec, unit_price, quantity, dosage这里有个设计点我想单独说说为什么不单独建患者表而是搞了个users表加扩展表因为不管是患者还是医生登录认证的逻辑是一样的——账号、密码、角色。把公共字段放在一张表里鉴权逻辑写一次就够。扩展信息单独拆出去又不会让users表字段太多。实际开发时一张user表配一个role字段比患者表、医生表分开建要省很多重复代码。问诊单状态字段status我设计成了整数枚举0待接诊、1已接诊问诊中、2已完成、3已取消。为什么不直接用字符串因为前端做状态判断时字符串比较容易出错比如写成正在接诊和接诊中数据不一致前端就看不到正确状态了。数据库里只存数字含义在代码里定义成常量两端都按这份约定来。2.3 Flask API路由怎么组织API层用蓝图划分每个文件只管自己那类接口。比如doctor.py里是用Blueprint(doctor, __name__)来创建的注册路由时统一加前缀doctor_bp Blueprint(doctor, __name__, url_prefix/api/doctor) doctor_bp.route(/consultations/pending, methods[GET]) jwt_required role_required(doctor) def pending_consultations(): doctor_id current_user.doctor_profile.id consultations Consultation.query.filter_by( doctor_iddoctor_id, status0 ).order_by(Consultation.created_at.asc()).all() return success_response([c.to_dict() for c in consultations])所有接口统一走success_response()或error_response()前端axios拦截器拿到数据后直接解包不用每次都判断HTTP状态码和业务状态码的对应关系。这种统一格式写起来可能感觉多此一举但当前端有几十个页面都要调接口时统一封装的好处马上就出来了。3. 前端Vue侧的页面架构路由、状态管理与问诊流程交互3.1 前端工程结构和路由规划前端用的Vue 3加Vue Router 4配合Pinia做状态管理。目录结构也不复杂但路由规划是花了不少心思的。这个系统前端分四个大区域访客区、患者区、医生区、管理区。路由设计如下路由路径页面访问角色/login、/register登录、注册所有人/首页展示科室、医院信息所有登录用户/patient/consult发起问诊选科室选医生患者/patient/consultations我的问诊列表患者/patient/consultations/:id问诊详情聊天室患者、医生/doctor/workbench医生接诊工作台医生/doctor/prescription/:cId开处方页面医生/admin/departments科室管理管理员/admin/doctors医生审核与管理管理员最先写的是登录登录后根据角色跳转到对应的工作台。这个跳转逻辑我放在路由守卫里结合Pinia里保存的userInfo判断。组件层面views目录放页面components目录放复用组件。问诊聊天室是一个单独组件患者和医生端共用只通过role属性控制显示哪些按钮。比如患者端显示等待医生接诊医生端显示接诊按钮聊天记录组件本身是同一套。3.2 axios封装与登录态管理axios封装是这个前端项目的关键。我在src/utils/request.js里做了一个带请求拦截器和响应拦截器的实例import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(med_token) if (token) { config.headers[Authorization] Bearer token } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { localStorage.removeItem(med_token) router.push(/login) } ElMessage.error(error.message || 网络错误) return Promise.reject(error) } ) export default request这里有个细节baseURL配的是/api而不是完整地址是因为生产环境前后端共用同一个域名靠Nginx把/api转发给Flask服务这就避免了写死IP端口带来的跨域问题。开发环境再通过Vite的proxy把/api代理到Flask的8000端口一套代码开发生产都跑得通非常省心。登录态我选了localStorage存token而不是sessionStorage。原因是刷新页面时token不能丢否则患者刷新一下聊天页就要重新登录体验很糟。Pinia里则用userInfo对象记录用户名、角色、显示昵称这些信息在页面导航上要频繁使用。3.3 患者端和医生端的页面交互拆解患者端的核心交互流程是首页点在线问诊→选择科室→查看医生列表→选择医生→填写病情描述→提交订单。提交完成后患者进入我的问诊看到这个问诊单的状态时点是待接诊。点击进入详情就是和医生对话的聊天室。医生端则完全不一样。医生登录后直接进接诊工作台页面分成两部分左边是待接诊列表右边是当前正在处理的问诊会话。点接诊后问诊单状态从0变1患者那边同步看到状态变化聊天室里医生端的输入框也解锁。这两个页面的交互模式不同但底层依赖的是同一个问诊单状态字段。前端之所以能做得这么顺畅靠的就是Vue的响应式——医生接诊触发的接口返回后前端把返回的status值更新到Pinia里相关组件自动刷新。4. 在线问诊的核心链路从发起问诊到医生接诊再到处方查看4.1 问诊单的创建与状态流转在线问诊最核心的业务逻辑是问诊单的状态流转。从代码角度说就是一套状态机的实现。我把状态定义放在后端模型上class Consultation(db.Model): __tablename__ consultations STATUS_PENDING 0 # 待接诊 STATUS_ACCEPTED 1 # 已接诊问诊中 STATUS_FINISHED 2 # 已完成 STATUS_CANCELLED 3 # 已取消 id db.Column(db.Integer, primary_keyTrue) patient_id db.Column(db.Integer, db.ForeignKey(patients.id), nullableFalse) doctor_id db.Column(db.Integer, db.ForeignKey(doctors.id), nullableFalse) status db.Column(db.Integer, defaultSTATUS_PENDING, nullableFalse, indexTrue) symptom_desc db.Column(db.Text, nullableFalse) # 病情描述 diagnosis db.Column(db.Text) # 医生诊断结果 created_at db.Column(db.DateTime, defaultdatetime.utcnow) accepted_at db.Column(db.DateTime) finished_at db.Column(db.DateTime)创建问诊单时患者在前端表单填病情描述提交后后端做几件事确认患者存在、确认医生存在且属于所选科室、然后创建一条status0的记录。这里特别值得注意的一点是创建后要返回完整的问诊单信息包括model里的to_dict()前端拿到后就跳转问诊详情页。状态的合法流转是这样的状态名数值触发动作什么角色能触发待接诊0患者创建问诊单患者已接诊1医生点击接诊医生已完成2医生提交诊断和处方医生已取消3患者取消或管理员关闭患者/管理员我在接诊和完成两个接口里都加了状态校验不是当前状态值就直接返回错误。像这样在接口层做一次判断虽然看着啰嗦但能防止前端误操作或者接口被直接调用时出现状态跳变。比如患者正在等医生已经提交了诊断这时候患者再点取消接口判断当前状态不是0了返回当前状态不允许取消就不会出现已完成的问诊单还能被撤销的问题。4.2 医生端接诊的并发问题接诊这个动作看起来简单——改状态、绑定医生——但实际做的时候我踩过并发问题的坑。设想这个场景两位医生同时在待接诊列表里看到同一张问诊单几乎同时点了接诊。如果用简单的查询更新逻辑两人都可能通过校验都修改了这条记录的doctor_id造成数据错乱。解决方案我用的MySQL的UPDATE ... WHERE status0条件更新。不是先查后改而是直接执行带条件限制的更新语句通过受影响行数判断是否成功result db.session.execute( db.update(Consultation) .where(Consultation.id consultation_id, Consultation.status 0) .values(status1, doctor_iddoctor_id, accepted_atdatetime.now()) ) db.session.commit() if result.rowcount 0: return error_response(该问诊单已被其他医生接诊)这种写法利用数据库的行锁来保证并发下只有一个医生能更新成功。受影响行数为0说明当前状态已经不是0——已经被抢了。这种不加锁却防了并发的写法对这种中小型系统来说是最简洁可靠的方案。4.3 聊天消息的设计方案问诊过程中患者和医生要交流病情所以需要消息功能。最初我考虑过WebSocket方案用Flask-SocketIO页面收到消息实时推送。但仔细评估后这个项目的聊天频率很低——一次问诊来回可能也就十几条消息对实时性要求并不高。最后选了轮询方案前端每2秒请求一次该问诊单的消息列表用消息id做增量拉取。如果系统规模大或者要做语音视频问诊再升级到WebSocket不迟。消息表结构很简单class Message(db.Model): __tablename__ messages id db.Column(db.Integer, primary_keyTrue) consultation_id db.Column(db.Integer, db.ForeignKey(consultations.id), nullableFalse, indexTrue) sender_id db.Column(db.Integer, db.ForeignKey(users.id), nullableFalse) content db.Column(db.Text, nullableFalse) created_at db.Column(db.DateTime, defaultdatetime.utcnow)增量拉取的接口逻辑是前端把当前已加载的最大消息id传过来后端只返回比这个id大的消息。这种设计比整表拉取高效也避免聊天室滚动时把全部历史都重新渲染一遍。4.4 处方生成与患者查看医生在问诊单处于已接诊状态时可以填诊断结论并开具处方。处方是一主多明细的结构主表记录归属关系明细表记录药品名、规格、价格、数量、用法用量。一次提交处方后端是在一个事务里同时插入prescriptions和prescription_items的doctor_bp.route(/consultations/int:consultation_id/prescription, methods[POST]) jwt_required role_required(doctor) def create_prescription(consultation_id): data request.get_json() items data.get(items, []) if not items: return error_response(处方明细不能为空) consultation Consultation.query.get(consultation_id) if not consultation or consultation.status ! 1: return error_response(当前问诊状态无法开处方) total_amount sum(item[unit_price] * item[quantity] for item in items) prescription Prescription( consultation_idconsultation_id, doctor_idconsultation.doctor_id, patient_idconsultation.patient_id, total_amounttotal_amount, notedata.get(note, ) ) db.session.add(prescription) for item in items: db.session.add(PrescriptionItem( prescriptionprescription, drug_nameitem[drug_name], specitem.get(spec, ), unit_priceitem[unit_price], quantityitem[quantity], dosageitem.get(dosage, ) )) consultation.status 2 consultation.diagnosis data.get(diagnosis, ) consultation.finished_at datetime.now() db.session.commit() return success_response({prescription_id: prescription.id})把开处方和完成问诊放在同一个事务里是刻意的医生只要提交了诊断和处方这次问诊就必须同时完成不能出现处方开了但状态还停在已接诊的悬空状态。前端展示处方时患者端详情页会多出一个处方卡片按药品明细列表展示每项有单价和数量底部汇总总价。因为涉及金额展示我在前端做了BigDecimal风格的金额处理思路——后端返回的是整数分前端自己除以100转为元再显示。这样避免浮点数计算误差也符合支付系统里以分为单位的惯例。这个小细节是在对接模拟缴费时想明白的建议做任何涉及金钱的系统都默认采用。5. 权限与安全患者、医生、管理员三种角色的权限控制5.1 JWT认证与登录流程登录接口接收用户名和密码校验通过后签发JWT。签发时把用户id和role放进去过期时间设为7天。接口端通过装饰器解析token拿到当前用户信息。def generate_token(user): payload { user_id: user.id, role: user.role, exp: datetime.utcnow() timedelta(days7) } return jwt.encode(payload, current_app.config[SECRET_KEY], algorithmHS256)jwt_required装饰器做token的解析和校验具体实现是取出Authorization请求头去掉Bearer前缀校验签名然后把解析出的用户对象挂到current_user这个全局对象上。Flask里用g对象来存当前请求全局数据装饰器里给g.user赋值接口里直接读这样很方便。不过要注意Flask的g对象是跟随请求上下文存在的多个并发请求不会互相串数据这个可以放心用。5.2 后端接口的角色鉴权只有登录校验还不够得防住患者调医生接口这类越权行为。Role-based的鉴权我直接做成装饰器和jwt_required配合用def role_required(*roles): def decorator(f): wraps(f) def wrapper(*args, **kwargs): user getattr(g, user, None) if not user: return error_response(未登录, 401) if user.role not in roles: return error_response(无权限访问, 403) return f(*args, **kwargs) return wrapper return decorator实际接口上就是这样用doctor_bp.route(/workbench, methods[GET]) jwt_required role_required(doctor) def workbench(): ...这种装饰器权限控制的思路适合角色固定、数量少的系统。遇到特别复杂、动态的权限模型建议还是用现成的权限框架但在这个系统里三行装饰器的方案远比引入一堆权限框架来得好维护。5.3 前端路由守卫后端做了鉴权前端还需要做路由守卫来拦掉未登录直接输URL跳页面的情况。Vue Router的beforeEach全局守卫逻辑不复杂但有两层判断。第一层白名单外的页面要求必须有token第二层登录后每个页面的路由的meta.roles数组判断当前用户角色是否匹配不匹配就跳回自己的工作台router.beforeEach((to, from, next) { const token localStorage.getItem(med_token) const userInfo JSON.parse(localStorage.getItem(med_user_info) || {}) if (!token to.path ! /login to.path ! /register) { next(/login) return } if (token to.meta.roles) { if (!to.meta.roles.includes(userInfo.role)) { next(userInfo.role doctor ? /doctor/workbench : /) return } } next() })前端路由守卫只能优化体验真正的安全防线在后端。这一点做前端时必须想清楚不然一不小心就会做出隐藏按钮但接口裸奔的系统。5.4 医疗数据安全处理的几个习惯涉及问诊聊天记录、病情描述这类敏感数据我做了几件额外的事数据库里密码存的是generate_password_hash后的值绝对不存明文敏感接口的响应里手动过滤字段不用to_dict()一股脑把整条record返回前端展示聊天记录时分页加载不把整个问诊的所有历史消息一次性拉下来。这些措施虽然不能说做到了等保级别但对一个标准的管理信息系统来说已经把能想到的风险点都覆盖了。6. 部署上线从开发环境到生产环境的折腾记录6.1 跨域问题怎么处理的开发阶段的跨域是最先遇到的坎。前端跑在Vite默认的5173端口后端Flask监听在8000端口直接请求必然被浏览器的同源策略拦掉。开发环境的方案是在Vite配置里做代理// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true } } } })这样前端页面里请求/api/xxxVite开发服务器会转发给后端的8000端口而且浏览器里看到的还是同源就没有跨域问题了。进入生产环境后前后端部署在同一个域名的不同路径下Nginx转发根本不会触发跨域限制。6.2 Flask后端在生产环境怎么跑Flask内置的开发服务器明确不能用于生产环境我用了Gunicorn来启动gunicorn -w 4 -b 127.0.0.1:8000 run:app-w 4是开了4个worker进程。这个数字不是越大越好要根据服务器CPU核数来定经验公式是2*CPU核数1。再配合supervisor做进程守护进程挂了自动拉起不然服务器一重启后端就再也不会自己跑起来了。迁移数据库用的Flask-Migrate。表结构改了之后执行flask db migrate和flask db upgrade推送变更。这个流程比手改SQL安全多了特别是表结构迭代了好几次之后历史变更记录一目了然。6.3 Nginx反向代理与前端history模式生产环境的Nginx配置文件是这个项目的关键一环。前端打包后如果是history路由模式直接部署会碰到404问题——因为刷新页面时Nginx去找/doctor/workbench对应的物理文件找不到就404了。解决办法是配置try_files指向index.htmlserver { listen 80; server_name your_domain.com; root /var/www/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { 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 /var/www/html/static/; expires 30d; } }这里location /api/把API请求单向转发给Gunicorn前端静态文件由Nginx直接服务。页面秒开接口响应也稳定整体架构非常干净。配套的前端路由不能用createWebHashHistory要用createWebHistory不然URL里到处都是#既不好看也不方便分享。6.4 上线后遇到的几个实际问题上线后最先暴露的是时区问题。Flask的datetime.utcnow()存进MySQL后前端展示的时候和本地时间差了8小时。解决方式是统一在后端返回时间戳epoch毫秒数前端格式化时用本地时区转换。这个方案跨时区无坑我从那之后所有项目的时间接口都返回时间戳。然后是上传图片的需求。如果患者或者医生要上传检查报告图片Flask需要配置一个上传目录并用send_from_directory提供访问。生产环境里更好用的方案是用MinIO或者云存储但小项目不想引外部依赖的话本地存储加Nginx静态映射也能跑。要注意的是上传目录的权限和文件名防注入文件后缀白名单校验不能省。还有个性能细节问诊列表页需要关联查询科室名、医生名、状态。最开始直接遍历每个问诊单再去查关联表一次性加载N条就产生N1次SQL查询数据库压力翻倍。改成join查询或SQLAlchemy的joinedload之后列表页秒开。这个优化虽然简单但在编码初期就注意能省掉后期不少重构。上线后做了一轮完整的流程验证从患者注册到问诊结束把每个接口和页面都过了一遍。整体跑通后终于松了口气。做这类系统的经验总结下来就是选型稳、建模细、流程少、权限不偷懒。Flask加Vue的组合在中小型信息管理系统这个体量上确实是开发效率和后期维护成本之间最平衡的选择。最后再分享一个小经验这种带角色流转的系统开发时一定先把状态机想清楚。哪个角色在哪个状态下能做什么操作在代码里用常量定义出来前后端约定好。状态一旦混乱排查成本远远大于提前设计的成本。做这个在线问诊系统我最庆幸的就是一开始把问诊单的状态定义清楚了后面所有功能都是在这个状态机上生长的结果。