1. 先把需求盘清楚:医院后台管理系统的六个核心模块
做毕设选医院后台管理系统,几乎成了Java Web方向的一个"经典款"。每年都有人做,每年答辩时老师问的问题也差不多:你的系统到底管了什么?哪些模块是真正跑通的业务逻辑,哪些只是凑数的CRUD?
如果你手上也有一份类似的SpringBoot+Vue完整项目源码,我建议你别急着点运行,先把需求层面的事情想明白。这个系统本质上不是给你练习增删改查的,它要模拟的是一家医院门诊部在接待患者过程中的全部后台操作。
我拆了一下,核心模块大概六个:系统管理、科室医生管理、患者档案管理、预约挂号管理、病历处方管理、药品库存与收费统计。下面这张表能比较清楚地看到每个模块对应的业务实体和主要页面动作:
| 模块 | 核心实体 | 主要页面功能 | 关键业务动作 |
|---|---|---|---|
| 系统管理 | 用户、角色、菜单 | 用户列表、角色分配、菜单权限 | 登录鉴权、权限拦截 |
| 科室与医生 | 科室、医生 | 科室列表、医生排班 | 科室与医生的关联维护 |
| 患者档案 | 患者 | 患者信息录入、查询 | 患者ID生成、信息修改 |
| 预约挂号 | 挂号单 | 号源管理、挂号记录 | 排班号源校验、防重复挂号 |
| 病历处方 | 病历、处方 | 病历创建、处方开立 | 诊断记录与处方关联 |
| 药品与收费 | 药品、收费单 | 药品库存管理、收费结算 | 库存扣减、收费状态变更 |
为什么要刻意把模块盘这么细?因为很多毕设源码的问题恰恰出在这里:菜单栏写了七八个入口,点进去全是同一个表格模板,没有真实的业务流转。比如"预约挂号"如果只是往表里insert一条记录,不校验当天该医生还有没有号,那答辩时老师一问就穿帮了。
所以做这个项目时,我给自己定的原则是:模块可以不多,但每个模块里必须有一条能走通的业务线。挂号要能挂满号源,病历要能关联到处方,处方要能扣减库存,收费要能改变挂号单状态。系统管理模块单独做一套RBAC权限模型,把用户、角色、菜单三张表通过关联表串起来,这样答辩时也有得讲。
2. 技术选型为什么不纠结:SpringBoot+Vue 各自解决什么
先说结论:SpringBoot+Vue+MySQL这组组合做Java Web毕设,在现阶段几乎是最省心的方案,没有之一。不是说它一定比别的方案更先进,而是它把前后端开发过程中的"烦心事儿"压到了最低。
后端用SpringBoot,核心原因有三个。第一,它内置了Tomcat,打包成jar直接java -jar就能跑,不用像传统SSM项目那样还得单独部署外部Tomcat,这对环境配置能力偏弱的毕设学生太友好了。第二,SpringBoot的起步依赖机制把你常用的框架组合都封装好了,引入spring-boot-starter-web就自带了Spring MVC和Jackson,引入mybatis-plus-boot-starter就自动配好了MyBatis Plus的数据源,自动配置帮你省掉了大量XML配置。第三,SpringBoot自带的application.yml把所有配置集中在一起,数据库连接、端口、上传大小限制都清晰可见,排查问题的时候非常直接。
前端用Vue也不难理解。Vue的组件化开发方式天然适配"后台管理"这种页面高度相似的场景,配合Element Plus组件库,表格、表单、弹窗、分页这些管理后台的高频组件全部开箱即用。更重要的是Vue使用响应式数据绑定,表单的各种联动、表格数据的实时刷新写起来非常顺手,不用像jQuery时代那样手动操作DOM。
但选型这件事,不能只看单个框架好不好,还要看前后端怎么协作。这套系统的开发期协作模式是这样的:
前端Vue开发服务器: http://localhost:8080 后端SpringBoot服务: http://localhost:8081Vue的dev server通过proxy配置把/api开头的请求转发到8081端口,这样前端代码里所有请求都写相对路径,不写完整域名。好处是:本地联调时不用处理CORS跨域,因为浏览器看到的请求是同源的;部署生产环境时,前端打包后的dist目录和后端jar包可以放在同一个服务器上,由Nginx统一管理,或者直接把dist复制到SpringBoot的static目录下,改动极小。
项目骨架方面,我自己习惯把整个工程分成两个独立目录,后端是一个标准的Maven项目,前端是一个Vue3+Vite项目。后端目录结构大致如下:
hospital-backend ├── src/main/java/com/hospital │ ├── controller # 控制层:接收前端请求 │ ├── service # 业务层:核心业务逻辑 │ ├── mapper # 数据访问层:MyBatis Plus的Mapper接口 │ ├── entity # 实体类:与数据库表对应 │ ├── common # 通用类:统一返回结构、异常处理、工具类 │ ├── config # 配置类:拦截器、CORS、Knife4j │ └── HospitalApplication.java ├── src/main/resources │ ├── mapper # 复杂的SQL语句XML │ └── application.yml └── pom.xml这个结构遵循的就是SpringBoot的分层约定,控制层只管参数接收和结果返回,业务层管真正的逻辑,数据访问层用MyBatis Plus的BaseMapper接口搞定大部分单表操作,只有统计报表这类复杂查询才写到XML里。分层干净了,后续接口文档的编写和测试也会顺利很多。
3. SQL脚本里的数据建模:建表顺序与关键字段的取舍
拿到这套项目的SQL脚本,先别急着执行。我建议你先打开脚本文件,从头到尾过一遍建表顺序,因为建表顺序本身就反映了数据建模的思考逻辑。好的脚本一定是先建基础表,再建业务表,最后建关联表,这样才能保证外键引用和业务数据有承载。
我的建议顺序是这样:
- 基础表:sys_user(用户)、sys_role(角色)、sys_menu(菜单),这是权限模型的地基。
- 关联表:sys_user_role、sys_role_menu,把用户、角色、菜单串起来。
- 业务主表:department(科室)、doctor(医生)、patient(患者)、medicine(药品)。
- 业务流水表:appointment(挂号单)、medical_record(病历)、prescription(处方)。
- 处方明细与收费:prescription_item、payment。
这里有一个很容易忽略的点:顺序不是随便定的,因为后面业务表的外键会引用前面的主键。比如doctor表里通常会有department_id字段引用科室ID,patient表里的create_by引用用户ID,如果顺序反了,创建表时就会报外键不存在。
字段设计上,我重点说一下几个最有讲究的地方。
密码字段不能存明文。sys_user表里的密码我用的BCrypt加密存储,登录时用BCryptPasswordEncoder.matches()校验。如果SQL脚本里看到的是明文密码,那说明这个项目在安全维度上基本是不合格的,答辩时老师一旦从浏览器开发者工具里看到返回的密码字段内容,会非常尴尬。
金额字段必须用decimal。medicine表里的单价、payment表里的金额,我用的是decimal(10,2),禁止用float或double。浮点数在计算机里本身有精度问题,金额计算出现0.1+0.2不等于0.3这种离谱结果的时候,哭都来不及。
预约挂号防重复,需要唯一约束和状态校验配合。appointment表我加了一个(patient_id, doctor_id, visit_date)的联合唯一索引,并且挂号状态字段cur_status有取值范围:0已取消、1已预约、2已就诊、3已退号。插入挂号记录前,业务层先查一次当天该医生号源是否已满,再查patient_id和doctor_id当天是否存在有效记录,如果已有1状态的记录,直接抛业务异常。
患者ID用业务规则生成。患者主键虽然是自增ID,但医院场景的患者编号是给前台人员看的,需要可读性。我用了"P"前缀加时间戳后六位,比如P20240615000001,这样不看数据库也能大致知道建档时间。
核心的appointment建表脚本可以参考这个思路:
CREATE TABLE appointment ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键', patient_id BIGINT NOT NULL COMMENT '患者ID', doctor_id BIGINT NOT NULL COMMENT '医生ID', visit_date DATE NOT NULL COMMENT '就诊日期', time_slot VARCHAR(20) NOT NULL COMMENT '时段:AM/PM', cur_status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0取消 1预约 2就诊 3退号', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_patient_doctor_date (patient_id, doctor_id, visit_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约挂号表';另外,每张表我都加了create_time和update_time两个审计字段,MyBatis Plus用MetaObjectHandler自动填充,这样查问题或者做统计的时候能知道数据是什么时候产生的,在后面做门诊量统计报表时会非常有用。网站上很多管理后台项目表里没有审计字段,缺了它,后续数据回溯基本无从下手。
4. 接口文档怎么写才能让前后端不打架
很多毕设项目都有接口文档,但大多数只是从网上找的模板,或者让AI生成一式两份的Swagger注解,根本没有投入到实际开发中使用。接口文档这件事,它的本质是前后端之间的"契约",前端根据文档确定请求参数和返回结构,后端根据文档实现接口,两边对这个契约有一致的理解,联调时才不会你等我我等你,你传参格式对不上他也没法接。
这套系统的接口文档,我是从统一返回结构开始定义的。所有接口都返回下面的结构:
{ "code": 200, "message": "操作成功", "data": {} }code约定为:200成功、400参数错误、401未登录或token过期、403无权限、500服务器异常。data字段可以是对象、数组或者空。前端axios的响应拦截器只用判断code不是200就提示message,业务逻辑和错误处理完全解耦。
接口路径遵循RESTful风格,统一加/api/v1前缀,后面跟资源名称。比如:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/v1/auth/login | 登录 |
| GET | /api/v1/doctors?departmentId=1 | 按科室查医生列表 |
| POST | /api/v1/appointments | 创建挂号单 |
| PUT | /api/v1/appointments/{id}/status | 更新挂号状态 |
| GET | /api/v1/statistics/outpatient | 门诊量统计 |
接口文档中,每个接口至少包含请求地址、请求方式、请求参数表、成功响应示例、失败响应示例五部分。比如登录接口:
POST /api/v1/auth/login Content-Type: application/json { "username": "root", "password": "123456" }成功响应:
{ "code": 200, "message": "登录成功", "data": { "token": "eyJhbGciOiJIUzI1NiJ...", "userInfo": { "id": 1, "username": "root", "realName": "系统管理员", "roles": ["admin"] } } }后端的登录Controller实现也很简单,核心逻辑放在service层,Controller只做参数接收和结果封装:
@PostMapping("/auth/login") public Result<LoginVO> login(@RequestBody @Valid LoginDTO dto) { LoginVO vo = authService.login(dto.getUsername(), dto.getPassword()); return Result.ok(vo); }登录成功后,后端返回JWT token,前端把它存在localStorage里,axios请求拦截器自动加上Authorization: Bearer <token>这个请求头,后端再用拦截器统一校验token有效性,有效才放行。拦截器逻辑大致是:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ") && jwtUtil.validate(token)) { // 解析出用户信息,放到ThreadLocal供后续使用 return true; } response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; }接口文档编写还有个实用技巧:如果项目里集成了Knife4j,Swagger注解生成的在线文档到后期维护成本会越来越低,代码里一改注释,在线文档就同步更新。但手写的接口文档仍然是答辩时给老师翻阅的最直接材料,两者建议同时维护。接口文档放在项目docs目录下,和源码、SQL脚本打包在一起,正好对应标题里"完整项目源码+SQL脚本+接口文档"的三件套。
5. 前端页面怎么写才不像"管理后台模板":路由、请求与组件的组织逻辑
Vue前端这部分是很多人眼中的"体力活",其实它也有自己的组织学问。直接照着管理后台模板套页面不难,但要把页面组织得清晰、可维护、权限到位,需要动点脑筋。
先看前端目录结构:
hospital-frontend ├── src │ ├── api │ │ ├── auth.js │ │ ├── doctor.js │ │ ├── patient.js │ │ └── appointment.js │ ├── assets │ ├── components │ │ ├── Pagination.vue │ │ └── SearchForm.vue │ ├── layout │ │ └── MainLayout.vue │ ├── router │ │ └── index.js │ ├── store │ ├── views │ │ ├── login/index.vue │ │ ├── system │ │ ├── medical │ │ └── dashboard/index.vue │ ├── utils │ │ ├── request.js │ │ └── auth.js │ └── main.js这里要重点说两个东西:路由设计、请求封装。
路由设计上,我采用的是静态路由加动态路由结合。登录页、404页是静态路由,其余业务页面全部放在动态路由里。用户登录成功后,后端根据角色返回该用户有权限的菜单列表,前端拿到列表后用router.addRoute()动态添加路由。这样做的好处是没有权限的用户即使手动在地址栏输入/system/user,路由表里根本没有这个路径,自然就拦截住了。配合前端路由守卫(beforeEach里校验token是否存在),实现双重保护。
动态路由的菜单结构从后端接口获取,格式类似于[{ path: '/system', title: '系统管理', icon: '', children: [...] }],前端根据这个结构渲染侧边栏菜单,成功做到了"菜单跟着权限走"。
请求封装上,request.js是所有接口请求的统一出口。我在axios实例上配置了baseURL为/api/v1,设置了请求超时时间和请求拦截器(添加token),响应拦截器里做了一层统一的错误处理:如果code是401,清除本地用户信息并跳转到登录页;如果code是403,提示"没有操作权限";其他非200的code统一弹ElMessage.error。这样做的好处是业务代码里不用处理任何错误分支,写接口请求时只需关心成功时的数据:
// api/doctor.js import request from '@/utils/request' export function getDoctorList(params) { return request.get('/doctors', { params }) }页面组件里调用时拿到的就是response.data已经是data字段里的内容,因为响应拦截器直接返回了解包后的数据。
页面组件组织上,管理后台最常见的形态是"搜索条件 + 表格 + 分页 + 新增/编辑弹窗"。这四件套在科室、医生、患者、药品等管理页面上高度重复。我将它抽象成几个通用组件后,新写一个管理页面只需要写搜索字段配置和表格列配置,加一个表单配置,再绑上对应的增删改查接口,页面的骨架代码量能少掉六成。但要提醒一句,业务系统里翻来覆去的复用组件,最怕的就是为了"通用"把组件配置写得无比复杂,配置比页面本身还难懂。折中的做法是:只在确实重复三遍以上的场景做抽象,写的时候预留插槽和配置项,不要一上来就是general目的的大一统组件。
如果你拿到的是这份源码,检验前端水平最好的方式就是看api目录和views目录里的业务代码是否规范,而不是看用了多少高级语法。清晰的api函数封装、表格数据流、弹窗表单组件,这套结构打通之后,新增一个管理模块就是复制粘贴改配置的活儿,效率非常可观。
6. 联调阶段最容易翻车的三个地方:跨域、token刷新、history模式404
最后写写真实的项目落地环节。代码写完后,从"能跑通"到"能交付",联调阶段往往是翻车重灾区。我在这套系统的调试过程中,碰到过三个高频问题,这里一个个说清楚。
第一个是跨域问题。开发期Vue的dev server在8080端口,后端在8081端口,浏览器里前端页面发请求到8081天然跨域。解决方法是在vite.config.js里配置proxy代理,把请求代理到后端端口,而不是去后端写一堆@CrossOrigin注解:
// vite.config.js server: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } }注意这里changeOrigin必须设为true,否则后端拿到请求的Host还是8080,某些框架校验会出问题。很多新手在这里卡半天,最后发现是少写了这一个配置项。
第二个是token刷新问题。本地登录后token存到了localStorage,但页面一刷新,前端状态管理里的用户信息就丢了,菜单也没了,页面还报401。典型的坑是:后端动态路由的addRoute过程只在登录后的初始化流程里执行了一次,刷新页面后初始化流程没走,路由没有被重新注册。解决思路是在beforeEach里判断当前是否已登录,登录了但状态管理里没有用户信息时,先调用"获取当前用户信息"接口,拿到用户数据和权限菜单后,重新注册动态路由再放行。
更隐蔽的情况是后端拦截器校验token过期了,前端收到401后跳转到登录页,登录页又把用户带回了原来的页面。这里要注意,跳转登录页时要带上redirect参数,否则用户每次都回到首页,体验很差。
第三个是history模式刷新404。Vue Router默认用history模式时,地址栏路径是真实的浏览器路径,但静态服务器上根本不存在/system/user这个文件,刷新时服务器直接返回404。两个选型:要么把Vue Router改成hash模式(路径带#号,不需要服务器配合),要么在Nginx里做try_files回退。
如果你选择部署到Nginx,配置基本是这个样子:
server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8081; } }try_files让所有不存在的路径都回到index.html,Vue Router接管后再根据路由渲染对应页面;/api开头的请求反向代理到后端8081端口。这个配置同时解决了前端静态页面和历史模式刷新404的问题。
另外还有一个小建议,是部署方式上的。如果你不想多维护一个Nginx,可以直接用SpringBoot来托管前端静态资源:把前端build生成的dist目录里的内容,复制到SpringBoot项目的src/main/resources/static目录下,重新打jar包,这样一个8081端口就同时提供前端页面和后端接口服务,适合毕设演示场景,只需要一条java -jar hospital-server.jar命令就能全部跑起来。
我做完这套项目后最大的体会是:严谨的数据建模、统一接口约定、清晰的前端分层,这三件事比任何花哨的技术点都重要。代码能跑只是底线,答辩时老师问到的数据流、设计决策、异常处理,全都来自你实际做项目时考虑过的问题。愿你也能顺着这条思路,把这个经典选题做出自己的亮点来。