我带着这套医院后台管理系统跑完过两三个实际项目,前后端分离从零搭到上线,中间踩了不少坑,也总结出一些值得写下来的东西。这篇就把整个系统的模块设计、技术选型逻辑、SpringBoot+Vue前后端交互的落地细节、MyBatis+MySQL数据层怎么组织,以及从本地联调到服务器部署的完整过程,原原本本过一遍。这套系统的代码结构和部署方式很典型,尤其适合刚接触SpringBoot+Vue的开发者和正在准备毕设或实训项目的学生参考——它不算炫技,但把后台管理系统最常见的需求都覆盖了,做完一遍,对工程化开发的理解会比只看教程扎实很多。
医院后台管理系统这类项目,核心不是新潮技术,而是业务边界是否清晰、数据模型是否合理、权限是否管得住。我把整个系统拆开来讲,先看你需要做什么,再看每层怎么实现。
1. 医院后台管理系统:业务模块与技术选型
1.1 需求边界:医院后台到底管什么
先想明白一件事:医院后台管理系统不是一个"医院官网",也不是给患者挂号用的前端应用,它服务的对象是医院内部的工作人员。常见的角色包括系统管理员、门诊医生、收费员、药房药师、护士等。不同角色的操作对象完全不同,这就需要系统具备清晰的模块划分。
我在这套系统里规划了以下几大模块:
- 系统管理:用户管理、角色管理、菜单管理、操作日志
- 科室管理:科室信息维护、科室排班基础数据
- 医生管理:医生信息、职称、所属科室、出诊状态
- 患者管理:患者基本信息、就诊记录、历史病历索引
- 挂号管理:挂号登记、号源状态、退号处理
- 门诊收费:收费单生成、费用结算、退费操作
- 药房管理:药品信息、库存变动、处方发药记录
- 统计报表:挂号量统计、收费汇总、科室工作量统计
模块之间不是孤立的。患者先建档,再挂号,医生接诊后开处方,收费员根据处方收费,药房凭收费状态发药。这条链路就是整张业务关系表的主干。想清楚了这条链路,数据库表设计的骨架也就出来了。
1.2 前端模块与后端模块的映射关系
前后端分离项目里,一个常见误区是把前端页面和后端模块做一一对应的小接口系统,结果Controller层写了一堆零散方法,服务层几乎没有业务逻辑。我在这套系统里做了几个聚合处理:
- 前端把"挂号登记页"需要的患者搜索、号源查询、挂号提交三个操作合并成一个页面模块,对应后端三个独立接口,但通过同一个Vue组件内的API函数统一管理。
- 收费模块的"结算"操作在后端一个事务里完成,同时更新收费单状态和药品库存核减,前端只需要收到成功或失败的结果。
- 统计报表在前端用独立的图表组件展示,后端提供聚合查询接口,不在前端做二次统计。
前端和后端分离的是代码与部署,不是业务逻辑。业务规则必须留在后端,前端只负责展示和交互。
1.3 为什么选了SpringBoot+Vue+MyBatis+MySQL这套组合
这套组合在技术层面不是最新的,但胜在稳。SpringBoot把配置简化到了几乎不需要你操心XML的程度,内嵌Tomcat让打jar包就能跑;Vue的组件化开发在管理后台这种大量表单和表格的场景里配合Element UI非常顺手;MyBatis相比JPA更接近SQL本身,医院系统里有很多多表关联统计查询,写SQL的效率反而更高;MySQL则是中小型项目最稳妥的选择,运维资料多,遇到问题基本都能找到答案。
选型时也考虑过替换方案,比如用Spring Data JPA真的会更省事吗?实测下来,JPA在简单CRUD上确实快,但一旦涉及"按科室统计某月的挂号量并按医生分组"这类查询,要么写@Query注解SQL,要么折腾 Specification。MyBatis直接上XML映射,复杂SQL可控性高很多。Vue这边也没必要讨论,管理后台生态里Element UI组件的成熟度摆在那里。
2. 前后端分离落地:从项目结构到接口规范
2.1 后端项目结构与启动入口
SpringBoot项目结构如果一开始就乱了,后面所有联调都会难受。我习惯按照"Controller-Service-Mapper-Entity"四层加一个通用模块来组织:
hospital-admin ├── src/main/java/com/hospital/admin │ ├── common // 通用返回结果、全局异常、常量 │ ├── config // 跨域、拦截器、WebMvc配置 │ ├── controller // 接口层 │ ├── service // 业务层(接口+实现) │ ├── mapper // MyBatis Mapper接口 │ ├── entity // 数据库实体类 │ └── HospitalAdminApplication.java └── src/main/resources ├── mapper // MyBatis XML映射文件 └── application.yml启动入口就是一个标准的SpringBootApplication类。需要注意的细节是@MapperScan注解扫描包路径,以及application.yml里的多环境配置。我在本地开发、测试、生产三套环境之间切换,通过spring.profiles.active来区分。很多新手直接把数据库密码写死在配置文件里,我建议至少把生产环境的配置外置,用启动参数--spring.profiles.active=prod加载application-prod.yml。
2.2 前端项目结构与路由组织
前端我用的是Vue CLI搭的项目,目录结构按模块划分,而不是按页面文件堆砌:
hospital-admin-web ├── src │ ├── api // 所有接口请求封装 │ │ ├── patient.js │ │ ├── registration.js │ │ └── statistics.js │ ├── router // 路由配置 │ ├── store // Vuex状态管理 │ ├── views // 页面组件 │ │ ├── system │ │ ├── registration │ │ └── pharmacy │ ├── components // 通用组件 │ └── utils // axios封装、权限指令等路由我采用了动态路由的思路:登录时后端返回当前用户的菜单权限码,前端根据权限码动态注册路由。这个设计在管理后台里非常重要,否则前端把所有路由都写死,用户通过URL直接就能访问没有权限的页面,接口防了但页面没防,体验很割裂。
接口请求统一封装在src/api目录,每个模块一个文件,而不是在views里到处写axios。这样做的好处是接口URL集中管理,后端改了接口路径只改一个文件。
2.3 统一返回结构与异常处理
前后端联调最容易吵架的就是接口返回格式不一致。我在后端定义了统一返回体:
public class Result<T> { private Integer code; // 200成功,其他为失败 private String message; // 提示信息 private T data; // 业务数据 }所有Controller方法统一返回Result对象,成功时用Result.success(data),失败时用Result.error(code, message)。配合全局异常处理器@RestControllerAdvice,业务代码里不需要到处写try-catch。业务异常直接抛自定义的BusinessException,全局处理器捕获后转换为统一的错误返回格式。
前端axios封装的响应拦截器里做统一处理:
service.interceptors.response.use( (response) => { const res = response.data if (res.code !== 200) { Message.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, (error) => { Message.error(error.message) return Promise.reject(error) } )成功状态下直接返回res.data,页面里拿到的就是业务数据,不需要每个页面都解包一次。这个约定从一开始就定清楚,后面几十个页面全都复用同一套逻辑,效率高很多。
2.4 跨域问题:前后端分离必须迈过的坎
本地联调时前端跑在8080端口,后端跑在9090端口,浏览器一定会拦截跨域请求。处理方式有两种,我在不同阶段各用了一种:
- 开发阶段:前端Vue配置代理,在
vue.config.js里设置devServer.proxy,把/api开头的请求转发到后端的9090端口,这样浏览器看着是同源的,最省事。 - 部署阶段:后端配置CORS,在SpringBoot里加一个
WebMvcConfigurer注入跨域映射,允许指定来源访问。
后端CORS配置示例:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }要特别注意allowCredentials(true)和allowedOriginPatterns("*")的搭配,早期版本直接配allowedOrigins("*")加上Credentials会报错。这个坑我踩过,后面部署章节还会提到。
3. MyBatis+MySQL数据层设计:核心表与动态SQL实战
3.1 核心表设计与关系说明
数据库schema我用了十张核心表,这里挑最关键的几张说明。
用户与权限相关表:
sys_user:用户表,字段包括id、username、password、real_name、dept_id、statussys_role:角色表,如管理员、门诊医生、收费员、药师sys_menu:菜单/权限表,页面按钮级别到菜单级别sys_user_role和sys_role_menu:中间表,维护多对多关系
业务核心表:
department:科室表,字段包括dept_name、dept_desc、statusdoctor:医生表,字段包括user_id、dept_id、professional_title、registration_feepatient:患者表,字段包括patient_no、name、gender、age、phone、id_cardregistration:挂号记录表,字段包括registration_no、patient_id、doctor_id、dept_id、visit_date、visit_time、fee、statusprescription:处方表,关联挂号记录、医生、患者prescription_item:处方明细,关联药品、数量、金额drug:药品表,字段包括drug_code、drug_name、specification、unit、stock、price
表与表之间的关系非常明确:user是登录账号,doctor是user在业务上的扩展;patient是独立实体;registration是连接患者与医生的枢纽;prescription_item把药品与处方关联起来。
3.2 建表时的几个关键约束
MySQL建表时,我重点关注三件事。
第一是编码,建库时指定utf8mb4。医院系统里可能录入患者姓名,万一遇到生僻字,utf8会存不进去,utf8mb4才能覆盖全部Unicode字符。建库语句长这样:
CREATE DATABASE hospital_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;第二是时间字段,统一用datetime类型,业务含义明确。挂号记录里的visit_date用date类型更合适,它不需要时分秒。统计某天挂号量直接按visit_date分组即可。
第三是金额字段,别用float或double,会出现精度问题。用decimal(10,2),药店收费和挂号费这种场景对金额精度有要求,浮点数在累加计算时会有不可控的误差。
3.3 MyBatis动态SQL:复杂的条件查询场景
医院后台最典型的业务就是列表查询,而且几乎都是多条件组合查询。以挂号记录查询为例,收费员要按日期查、按患者查、按医生查,三个条件可能是任意的。MyBatis的动态SQL在这里就很好用:
<select id="selectRegistrationList" resultType="com.hospital.admin.entity.Registration"> SELECT r.*, d.dept_name, doc.doc_name FROM registration r LEFT JOIN department d ON r.dept_id = d.id LEFT JOIN doctor doc ON r.doctor_id = doc.id <where> <if test="patientId != null"> AND r.patient_id = #{patientId} </if> <if test="doctorId != null"> AND r.doctor_id = #{doctorId} </if> <if test="visitDate != null"> AND r.visit_date = #{visitDate} </if> <if test="status != null"> AND r.status = #{status} </if> </where> ORDER BY r.create_time DESC </select><where>标签的妙处是能自动去掉第一个条件前面的AND,不用自己在每个<if>里加where 1=1这种丑写法。动态SQL写多了会发现,它的核心价值就是让SQL逻辑和Java判断解耦,页面上的筛选条件加到后端Mapper里就能生效。
3.4 分页查询与多表关联统计
列表页几乎都要分页,我在项目里用了PageHelper插件。接入很轻量,引入依赖后在application.yml里配一个helper-dialect: mysql即可。接口代码里要分页时:
PageHelper.startPage(currentPage, pageSize); List<RegistrationVO> list = registrationMapper.selectRegistrationList(query); PageInfo<RegistrationVO> pageInfo = new PageInfo<>(list);返回给前端的就是页码、每页大小、总记录数、总页数、数据列表五个字段。这里有个小坑:PageHelper.startPage必须紧接着Mapper方法调用,中间不能穿插任何其他SQL操作,否则分页会错乱。这个插件底层是基于ThreadLocal的,多线程环境下尤其要小心。
统计报表是另一个常用场景。比如"统计每个科室每月的挂号量",直接用SQL的GROUP BY效率最高:
<select id="countByDeptAndMonth" resultType="java.util.Map"> SELECT d.dept_name AS deptName, DATE_FORMAT(r.visit_date, '%Y-%m') AS month, COUNT(*) AS totalCount FROM registration r LEFT JOIN department d ON r.dept_id = d.id WHERE r.visit_date BETWEEN #{startDate} AND #{endDate} GROUP BY d.dept_name, DATE_FORMAT(r.visit_date, '%Y-%m') ORDER BY month DESC </select>这类返回Map的查询,字段别名一定要和前端约定的key一致,不然前端拿到数据又要做一层适配。全套系统用下来,直接返回指定别名的Map再转JSON,比专门建一堆统计实体类要省事得多。
4. 安全防线:登录认证与RBAC权限模型
4.1 JWT登录认证流程
医院后台系统的安全级别比普通管理后台要高,因为涉及患者隐私数据。登录认证我用的是JWT方案,流程不难,但每个环节都要做对。
账号密码校验通过后,后端生成Token返回给前端。Token里可以包含用户id、用户名、角色编码,但绝不能放密码。我用的密钥设置得足够长,存入application.yml的jwt.secret配置里。
拦截器负责校验每个需要认证的请求:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException(401, "未登录或token已失效"); } // 解析token,获取用户信息并存入ThreadLocal Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); UserContext.set(claims); return true; } }同时注册拦截器,排除登录接口和静态资源路径。要注意的是,Token过期时间我设置得比较保守,默认两小时,前端在请求拦截器里发现401就跳转登录页。这个方案没有做刷新机制,体验上会有点粗糙,但作为后台管理系统是够用的。
4.2 RBAC权限模型:用户-角色-菜单
权限模型我用了经典的RBAC。用户与角色多对多,角色与菜单多对多。登录时根据用户id查出角色,再查出角色对应的菜单编码集合,返回给前端。前端拿到权限编码后,通过自定义指令v-permission控制按钮的显示隐藏:
<el-button v-permission="'sys:user:delete'" type="danger">删除</el-button>这个指令的实现也很简单,就是在inserted钩子里从Vuex中取出当前用户的权限编码数组,如果不存在该权限码就移除当前DOM元素。按钮级别权限在很多管理系统里容易被忽略,但医院系统里"收费员不能操作退费"这类需求,就必须靠权限码来控制。
后端的权限控制放在Service层,用一个辅助方法校验当前用户是否有某个权限码:
public void checkPermission(String permissionCode) { String userRole = UserContext.getRoleCode(); if (!rolePermissionCache.containsKey(userRole) || !rolePermissionCache.get(userRole).contains(permissionCode)) { throw new BusinessException(403, "无权限执行该操作"); } }接口层的@PreAuthorize注解同样能实现,但那需要引入Spring Security,对于我这种自定义JWT+拦截器的轻量方案,直接在Service层校验反而更直观。前端隐藏按钮只是提升体验,后端接口校验才是真正的安全边界。
4.3 越权访问的防护细节
权限系统只防角色,不够,垂直越权同样要防。比如一个医生登录后,查挂号记录的接口传入了doctorId=2,而自己实际上是doctorId=3,如果不校验就会看到别人的数据。
我在查询接口里加了一层数据归属校验,通过当前登录用户id反查对应的doctorId,强制用这个doctorId去查数据,前端传什么参数都不能覆盖掉这个归属条件。患者查询同理,患者档案信息默认只有关联本次就诊的医生和授权管理员可以查看。
密码加密用的是BCrypt,这个方案自带盐值,每次哈希结果都不同,比直接MD5安全得多。数据库里如果看到明文密码或者统一加盐的MD5,那基本等于没防护。
5. 部署教程:本地联调到服务器上线的完整过程
5.1 本地环境准备与启动顺序
先把本地环境确认清楚,这一套系统需要的工具版本如下:
- JDK 1.8及以上(我这边用的1.8,稳定)
- Maven 3.6+
- Node.js 14+(Vue CLI项目建议14以上)
- MySQL 5.7或8.0
- Redis非必需,这套系统没引入
启动顺序有讲究,先启动MySQL和创建数据库,再启动后端,最后启动前端。
MySQL建库建表我准备了init.sql脚本,包含建库语句和初始化数据。初始化数据必须包括管理员账号和基础菜单数据,否则连登录页面都进不去。
后端启动时如果有端口冲突,改application.yml里的server.port即可。我约定本地后端用9090端口,前端用8080端口,避免和默认端口撞车。
前端启动前先执行npm install,装依赖可能遇到版本问题,常见的坑是Node版本过高导致node-sass装不上。我在项目里统一改用sass(dart-sass),或者直接用vue-cli默认的样式方案,能少很多折腾。
5.2 前端打包后如何放进SpringBoot
部署方式我试过两种,Nginx方案更标准,但直接把前端打包文件放进SpringBoot也有其适用场景——个人项目、教学演示、没有独立Nginx环境的服务器。
第一种方式:前端执行npm run build生成dist目录,然后上传到服务器,Nginx配置静态文件根目录指向这个dist,再配置一个反向代理把/api请求转发到后端9090端口。这种方式前后端完全独立,升级后端不影响前端静态资源。
第二种方式:把dist目录里的文件复制到SpringBoot的src/main/resources/static目录下,重新打包为同一个jar。这样只需部署一个进程。注意前端打包时publicPath要改为相对路径:
// vue.config.js module.exports = { publicPath: './', outputDir: 'dist' }我实测中比较推荐第一种方式。前后端分离的意义就是可以独立部署、独立升级,硬塞进同一个jar虽然省事,但在后续维护时会很痛苦。如果只是想快速演示给朋友看,第二种方式也不是不行,但最好明确这只是权宜之计。
5.3 服务器部署全流程
服务器以Linux(CentOS 7或Ubuntu)为例,部署步骤基本固定:
- 安装JDK、MySQL、Nginx
- 上传
init.sql到服务器,执行建库命令 - 上传后端jar包,使用
nohup java -jar启动:
nohup java -jar hospital-admin.jar --spring.profiles.active=prod > log.txt 2>&1 &- 确认后端进程存活,
curl http://localhost:9090/api/health能返回200即可 - 上传前端dist目录到Nginx配置的目录,修改Nginx配置:
server { listen 80; server_name your_domain_or_ip; root /www/hospital-admin-web/dist; index index.html; location /api { proxy_pass http://127.0.0.1:9090; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }最后一定要把后端生产环境的账号密码改为强密码,并修改JWT密钥,千万不要把开发环境的配置直接带上线。
5.4 上线后才会暴露的几个坑
本地开发一切正常,部署到服务器后出现了几个典型问题。
一是MySQL时区问题。登录时报"com.mysql.cj.exceptions.InvalidConnectionAttributeException",根本原因是应用服务和数据库服务时区不一致。解决办法是在数据库连接串上显式指定:
jdbc:mysql://localhost:3306/hospital_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai服务器默认时区如果是UTC,而你在中国,不显式配置时区就会差8个小时,所有时间统计全部错乱。
二是Nginx部署前端后刷新404问题。VueRouter如果使用history模式,刷新非首页路径时Nginx找不到对应的静态文件。解决办法是配置try_files:
location / { try_files $uri $uri/ /index.html; }这个坑在部署单页应用时极其常见,几乎每一个第一次上线的Vue项目都会遇到。
三是服务器防火墙忘记开放端口,浏览器访问超时。排查顺序应该是:先看本机curl通不通,再看安全组,再检查Nginx状态。我见过太多人一上来就改代码,实际只是防火墙没放行。
6. 实践中踩过的坑与下一步优化方向
6.1 前后端分离的隐性成本
前后端分离不等于把代码拆成两个仓库就完事。实际开发中最大的隐性成本在联调和接口变更。前端假设接口返回{code: 200, data: {list: []}},后端某次调整返回了{status: 200, result: []},测试同事就报故障。这套系统的接口规范从一开始就要用统一返回体约束住,我用Result类的泛型结构解决了一半问题,另一半靠接口文档约定。
另一个坑是前端联调环境配置多时容易乱。我推荐在项目根目录建.env.development、.env.production、.env.test三个文件,分别配置不同的VUE_APP_BASE_API地址,构建时自动加载对应环境变量,比手动改代码科学得多。
6.2 这套系统后续值得扩展的方向
如果让我继续优化这套系统,我的优先级如下。
先把Redis加进来,把医生排班、药品库存这类热点数据做缓存。挂号高峰期同一个医生号源会被大量并行查询,MySQL单表单查扛扛还行,但对数据库来说压力并不小。
然后引入Spring Security替换自定义拦截器,虽然自定义方案轻量,但Security在密码策略、会话管理、CSRF防护上更成熟。不过这会增加学习成本,如果项目本身不复杂,自定义方案完全够用。
再就是引入文件上传和预览,医院场景里病历图片、检查报告单的存储是刚需,这个在现有系统里是明确的扩展点。
最后可以考虑把系统拆成微服务架构,但我要泼一盆冷水:这种规模的业务拆成微服务会带来无穷无尽的分布式事务和运维复杂度,单体应用把模块边界做好就够了。业务量没上来之前,微服务是大炮打蚊子。
6.3 我对这类项目管理上的一些体会
做这类系统,要紧的其实不是技术栈本身,而是"能不能把一条完整的业务链路跑通"。很多同学写完用户管理就停住了,觉得系统做完了,其实核心的挂号-接诊-开处方-收费-发药这条主链路才是真正的业务主体。我建议把主链路梳理清楚之后,再回头做权限、日志、统计这些支撑功能,整个系统在结构上才算闭环。
我在这套系统的开发过程中反复验证了一个观点:好的工程习惯比花哨技术更扛得住变动。统一返回结构能少吵很多架,数据库脚本纳入版本管理能少漏很多变更,部署步骤写成文档能免掉很多回忆。这些细节单个看着不起眼,加在一起才是项目能顺利交付的真正原因。如果你也想拿这套系统练手,建议不要只看代码跑起来就完事,试着改一个模块、加一个字段、追一遍主链路的表关系,收获会比想象中大很多。