完全不夸张地说,每年毕业季或者实训验收季,我都能在技术群里看到一大批奔着“城乡居民医疗信息管理”去的同学。这个题目几乎算是Java Web方向里最长青的选题之一了。但说实话,很多同学拿到源码之后,要么导进IDEA跑不起来,要么跑起来之后完全不知道怎么给答辩老师讲清楚业务逻辑。这套基于SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0的管理系统,正是目前最主流也最务实的一套组合。这篇文章我不去复述那些官方文档,而是以动手实操的角度把这个系统的业务拆解、技术选型逻辑、前后端对接方式和部署细节完完整整过一遍,让你能真正把项目吃透。
先从项目本身说起。这套系统的核心是“城乡居民基本医疗信息管理”,听起来有点官方,实际上业务边界非常清晰:它围绕参保人员的参保、缴费、就诊报销这整条链路做数据化管理。如果你是准备拿它当毕业设计,那你需要的不仅仅是代码能跑,还要能回答清楚“你的系统解决了什么问题”“为什么这么设计表结构”“权限是怎么控制的”“部署时遇到了哪些坑”。这些恰恰是这篇文章要展开的部分。
1. 这个医疗信息管理系统,到底要管什么
很多同学拿到项目后第一件事就是急着启动后端、跑前端页面,结果看到某个页面里的“参合管理”“转诊登记”就懵了。其实问题的根源不是代码看不懂,而是你没有先站在业务方的角度把这个系统想明白。
1.1 业务模块拆解:参保、缴费、就诊报销一条链路
城乡居民基本医疗和职工医保最大的区别在于,它的参保对象是城乡居民,缴费方式通常是按年度集中参保,报销政策也是分地区、分医院等级执行的。所以这个系统里的核心数据流是:
- 参保登记:记录居民的姓名、身份证号、户籍地址、参保年度、缴费状态等基本信息。这是整个系统最基础的数据源。
- 缴费管理:按年度记录参保缴费情况。这里有个非常容易忽略的细节——同一个居民在不同年度的参保状态不同,所以缴费记录和你理解的那种“订单表”很像,每一个年度会对应一条参保缴费记录。
- 就诊与报销:居民到定点医疗机构就诊之后,系统里会登记就诊记录、费用明细,并根据报销比例计算出报销金额。这里涉及到医院等级(一类、二类、三类)和报销比例。
也就是说,这套系统实际上是一条“参保 → 缴费 → 就诊 → 报销 → 统计”的完整业务链路。前端看到的菜单它也基本遵循这条链路来安排。理解了这一点之后,你再去看表结构就会轻松很多,页面之间的关系和字段之间的逻辑也不再是零散的。
1.2 账号体系:管理员、经办人员、参保居民三层权限
医疗信息系统的权限设计通常比普通管理系统要更严格一点,因为涉及个人敏感信息。这套系统里采用了很典型的三层账号体系:
- 系统管理员:拥有最高权限,可以管理人员账号、查看全量统计数据、管理系统配置。
- 经办人员:通常是乡镇或社区医保经办机构的工作人员,负责参保登记、缴费记录录入、报销审核等日常业务操作。
- 参保居民:这是大多数系统里最终端的一类用户,权限最受限,一般只能查看自己的参保状态、缴费记录和报销结果。
权限的落地上,后端通过Spring Security或自定义拦截器配合JWT完成接口级别的访问控制,前端通过Vue Router的路由守卫控制菜单和按钮的显示。使用这种方案不仅是因为前后端分离项目需要Token来做状态保持,更重要的是答辩时你能讲清楚“为什么不能在页面里隐藏一个按钮就当作没权限”。
提示:如果你准备用这套系统参加答辩或演示,建议把“管理员、经办人员、居民”三个账号的权限差异当做一个重点来讲,这比单纯演示一个列表更让老师信服。
2. 为什么是这套技术栈:SpringBoot2+Vue3+MyBatis-Plus的组合逻辑
这套技术栈放到今天来看,属于“既不掉队,也不冒进”的稳妥选择。很多同学会问,为什么不用SpringCloud?为什么不用Vite去搭工程?为什么不用JPA?我逐个说说我的理解。
2.1 后端选型:SpringBoot2的生态与版本取舍
SpringBoot2.x目前依然是绝大部分高校和中小项目团队消化得最好的版本。它的自动配置机制、Starter生态和成熟的中文资料储备都足够友好。对于毕业设计和课程实训来说,SpringBoot3虽说也稳定了,但它在javax到jakarta命名空间上的迁移问题,往往会让第一次折腾的同学花很多时间在不该花的地方上。
另一个现实问题是,很多学校机房里的JDK版本还停留在1.8,而SpringBoot3的最低要求是JDK17。所以SpringBoot2+JDK8的组合在兼容性上是最省心的。如果你用的是IDEA2023之后的版本,新建项目时也尽量选择Spring Initializr里提供的2.7.x版本,而不是直接默认拉最新版。
2.2 数据层:MyBatis-Plus解决的是重复劳动问题
MyBatis-Plus的定位是“MyBatis的增强工具”,它不改变MyBatis原有的写法,只是帮你把单表的CRUD、分页、逻辑删除这些重复劳动直接内置了。
它的几个核心特性在这个项目里都有实际应用:
- BaseMapper:继承之后单表增删改查不需要再写SQL。
- 分页插件:通过MybatisPlusInterceptor配置,分页查询不需要手动拼LIMIT。
- 逻辑删除:医疗数据比较敏感,删除操作一般不是物理删,而是用一个deleted字段标记。MyBatis-Plus通过@TableLogic注解就能自动处理。
- 字段自动填充:create_time、update_time这类字段可以自动填,不用在每个插入和更新方法里手动set。
这些特性单独看都很简单,但组合起来能让你的代码量肉眼可见地减少。答辩时你完全可以直接指着代码说“这里单表操作不用写SQL,MyBatis-Plus内部已经处理了”,这就是架构设计的亮点。
2.3 前端形态:Vue3的组合式API和生态现状
Vue3刚出的时候,很多人不太愿意迁移过去,Vue2的Options API确实太深入人心了。但放到今年来看,Vue3+组合式API(Composition API)早就是不可回避的主流。Element Plus、Pinia、Vue Router4这些配套生态也已经非常成熟,网上能找到的示例代码几乎都是这套组合。
在这个项目里,前端选Vue3不是赶时髦,而是有实打实的收益:
- 组合式API让业务逻辑的复用更自然。比如多个页面都需要“根据身份证号查询参保状态”这个逻辑,你可以直接抽成一个工具函数,而不是像Vue2那样去写Mixin。
- 响应式机制更高效,在数据量稍大、联动关系复杂的表单页面上,渲染性能明显优于Vue2的Object.defineProperty方案。
- Element Plus的组件丰富度足够支撑后台管理系统绝大部分界面需求,表格、表单、分页、对话框、消息提示这些都有现成封装。
看这款项目的源码时,你如果发现里面是setup语法糖配合ref、reactive、computed来组织业务逻辑,那就对了。这也是目前主流后台管理模板的标准写法。
3. 从零搭建后端:SpringBoot2+MyBatis-Plus的工程化实践
把工程结构讲清楚,比盯着某个业务方法逐行看要重要得多。拿到源码之后,你第一件该做的事不是急着启动,而是先看整个工程分了几层、每一层的职责是什么。
3.1 工程结构和依赖引入,别在起步就埋坑
以这套系统的标准做法为例,后端工程的包结构通常长这样:
com.{你的公司名}.medical ├── config // 配置类,比如MybatisPlus分页配置、CORS跨域配置 ├── controller // 控制器层,接收前端请求并返回统一结果 ├── service // 业务逻辑层,接口加实现类的写法 │ └── impl ├── mapper // 数据访问层,继承BaseMapper ├── entity // 实体类,和数据库表一一对应 ├── dto // 前端传来的数据对象、后端返回给前端的数据对象 ├── vo // 视图对象,通常用于页面展示聚合数据 ├── common // 公共类,比如统一返回结果、异常处理、工具类 └── utils // 工具类,比如JWT解析、身份证号校验依赖方面,最核心的几个POM依赖只需要关注这些:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>Lombok这里我要多说一句。Lombok通过注解帮我们自动生成getter、setter、toString这些样板代码,能让实体类干净很多。但有些同学在答辩前没装好Lombok插件,IDEA直接报一堆找不到getter的错误,这不是代码有问题,是编译环境没配好。新装的IDEA 2023版以上一般都自带插件支持,如果报错优先检查这个。
3.2 MySQL8.0的初始化,字符集、时区这些默认值要改
很多同学的项目跑不起来,不是代码问题,而是数据库连接这关就倒了。给MySQL8.0初始化数据库时,有三个细节必须处理干净。
第一,字符集。MySQL8.0默认字符集是utf8mb4,这个版本和utf8的区别在于它能存emoji和一些特殊生僻字。医疗系统里的居民姓名、地址信息经常有生僻字,所以建库时直接写成:
CREATE DATABASE medical_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;第二,时区。MySQL8.0的时区默认是跟随系统,但我们的后端连接串里如果没写时区参数,往往会报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized之类的错。这通常是CST时区解析异常导致的。所以JDBC连接串里直接显式声明:
jdbc:mysql://localhost:3306/medical_system?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true第三,MySQL8.0的驱动类名。如果你用的是com.mysql.jdbc.Driver,直接换成com.mysql.cj.jdbc.Driver,老驱动在新版本里已经移除了。
3.3 核心配置:逻辑删除、乐观锁、自动填充一次性配齐
MyBatis-Plus在项目里的价值,全靠配置类来体现。下面是这套系统代码里最常见的MybatisPlusConfig配置类,建议你可以直接对着它理解:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 乐观锁插件 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }分页插件具体解决什么问题?比如你要查询参保人员列表,前端传了pageNum=1&pageSize=10,后端只需要在service里这么写:
Page<InsuredPerson> page = new Page<>(pageNum, pageSize); Page<InsuredPerson> result = insuredPersonMapper.selectPage(page, new LambdaQueryWrapper<InsuredPerson>() .like(StringUtils.isNotBlank(name), InsuredPerson::getName, name) .eq(StringUtils.isNotBlank(idCard), InsuredPerson::getIdCard, idCard) .orderByDesc(InsuredPerson::getCreateTime));MyBatis-Plus会在执行查询时自动拼接LIMIT 0,10,还会额外执行一条SELECT COUNT(*)来算总条数。你不需要自己写count语句,也不用担心LIKE拼接时参数为空的陷阱——Like前面的条件判断是StringUtils.isNotBlank,只有传了值才会拼上这个条件。
关于逻辑删除,表设计里通常有一个deleted字段,实体类上这样标注:
@TableLogic private Integer deleted;之后调用deleteById或者delete方法时,MyBatis-Plus会自动生成UPDATE ... SET deleted = 1 WHERE id = ?,而不是执行DELETE。查询时也会自动加上AND deleted = 0。这一点对医疗系统尤其重要,因为报销记录、就诊记录属于需要长期留痕的数据,物理删除往往是业务上不允许的。
3.4 业务编码:以报销记录为例看CRUD背后的细节
讲一个具体的业务场景,你就明白这套系统的代码该怎么看了。居民张三参保后去镇卫生院就诊,花了500块,报销比例是70%。经办人员在“报销管理”页面填写就诊信息和费用后,后端在controller层接收请求,经过service层完成计算与保存。
@Service public class ReimbursementServiceImpl extends ServiceImpl<ReimbursementMapper, Reimbursement> implements ReimbursementService { @Override @Transactional(rollbackFor = Exception.class) public Result saveReimbursement(ReimbursementDTO dto) { // 1. 校验参保记录是否存在且有效 InsuredPerson insured = insuredPersonMapper.selectOne( new LambdaQueryWrapper<InsuredPerson>() .eq(InsuredPerson::getIdCard, dto.getIdCard()) .eq(InsuredPerson::getStatus, 1)); if (insured == null) { return Result.error("该居民未参保或参保已失效"); } // 2. 根据医院等级获取报销比例 BigDecimal ratio = hospitalLevelMapper.getRatioByLevel(dto.getHospitalLevel()); // 3. 计算报销金额 BigDecimal expense = dto.getTotalExpense(); BigDecimal reimbursementAmount = expense.multiply(ratio).setScale(2, RoundingMode.HALF_UP); // 4. 保存报销记录 Reimbursement reimbursement = new Reimbursement(); BeanUtils.copyProperties(dto, reimbursement); reimbursement.setReimbursementAmount(reimbursementAmount); reimbursement.setRatio(ratio); this.save(reimbursement); return Result.success(reimbursement); } }这套代码有很重要的几个点:校验参保人资格、从数据库读取报销比例、用BigDecimal做金额计算而不是double、使用@Transactional保证保存过程要么全成要么全不成。这些习惯在答辩和后续面试中都是加分项。
注意:凡涉及金额计算的字段一律用BigDecimal,千万别用double。用过的人都知道,double算出来的0.1+0.2有可能是0.30000000000000004,医疗系统这种和钱相关的场景扛不住这种误差。
4. 前端实战:Vue3后台管理系统的骨架搭建与业务对接
前端部分,这套系统整体呈现出的项目结构,和目前主流的Vue3后台管理模板基本一致。如果你之前用过若依或者vue-element-admin,再看这个项目会有很强烈的熟悉感。
4.1 路由状态设计与权限控制:登录态和菜单要前置打通
前端的工程结构里,最值得看的是src/router和src/store两部分。
路由这块,Vue Router4的写法相比Vue2时代最大的变化就是createRouter和createWebHistory。你需要理解动态路由和静态路由的差异。静态路由就是登录页、首页这种不需要权限就能访问的页面;动态路由则是根据用户的角色信息在登录后拼接进去的。这套系统里,前端通常会在用户登录后把token保存到LocalStorage或Pinia中,然后根据用户角色去请求后端拿到对应的菜单权限,再用router.addRoute()动态添加路由。
状态管理用的是Pinia。为什么会选择它?因为Vuex4的语法在Vue3里用起来不够自然,而Pinia的API更符合组合式API的习惯。你会在代码里看到类似这样的Store定义:
import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: {} }), actions: { setToken(token) { this.token = token localStorage.setItem('token', token) }, setUserInfo(info) { this.userInfo = info } } })业务逻辑里的登录过程通常是:登录页调用登录接口拿到token,存入Pinia和本地缓存,然后跳转首页。这里有个常见的坑,就是刷新页面之后Pinia里的state会全部清空,如果只存在内存而没存到本地,刷新后菜单和用户信息就会丢。所以token、用户信息这些必须同步存到localStorage里,刷新时再从本地恢复。
4.2 axios封装:用统一拦截器搞定请求和报错处理
后台管理系统的页面上,几乎每一个动作都在向后端发请求,如果每次发请求都手动处理loading、错误提示、token携带,代码会非常冗余。通常我们会封装一个request.js。
推荐的做法是创建一个Axios实例,在请求拦截器里带上token,在响应拦截器里做统一处理:
import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:携带token request.interceptors.request.use(config => { const token = localStorage.getItem('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.data }, error => { if (error.response && error.response.status === 401) { // token过期,跳回登录页 localStorage.removeItem('token') window.location.href = '/login' } ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } ) export default request统一结果里有个很实用的约定:后端固定返回{ code, message, data }三元组,前端拿到结果后只用关注code是不是200。这样做的好处是整个项目的错误处理逻辑只有这一处,不会出现每个页面都在写重复的if (res.code === 500) alert('错误')的情况。
4.3 典型业务页面拆解:参保人员列表的增删改查
以参保人员管理页面为例,这个页面算是整个系统里最经典的一个List页面。拆解它的结构,基本就是:“搜索表单 + 表格 + 分页 + 新增/编辑弹窗 + 删除确认”。
搜索表单的关键是“查询条件与表格数据的绑定”。常见做法是用一个响应式对象存搜索条件,点击“查询”按钮时调用加载方法,条件变化后请求参数跟着变:
const searchForm = reactive({ name: '', idCard: '', status: '' }) const tableData = ref([]) const total = ref(0) const pageNum = ref(1) const pageSize = ref(10) const loadData = async () => { const res = await request.get('/insured/list', { params: { ...searchForm, pageNum: pageNum.value, pageSize: pageSize.value } }) tableData.value = res.records total.value = res.total } const handleSearch = () => { pageNum.value = 1 loadData() }新增和编辑一般共用一个弹窗。区分两种模式通常用一个dialogType变量,比如add和edit,打开弹窗时如果是编辑模式,先把当前行的数据回填到表单里,保存时根据模式调不同接口。删除按钮需要二次确认,确认后请求后端接口,如果刷新后返回成功再刷新当前列表,这样页面数据才一致。
这套页面的代码写下来虽然不算少,但骨架非常清晰。你只要吃透一个List页面,系统的其他页面基本就是在这个基础上的排列组合,无非是表单字段不一样、表格列不一样。
5. 认证与权限:JWT+后端拦截器的实现细节
讲完业务页面,我们应该把眼光再往底层看一层,看看整个系统的门槛在哪里。前后端分离项目的身份认证,核心就是离不开JWT。
5.1 登录流程与Token鉴权链路的落地
这套系统里,认证的实现思路是这样的:
- 用户输入用户名密码,前端提交到
/api/auth/login。 - 后端校验账号密码是否正确,同时校验该用户是否被禁用。
- 校验通过后,把用户ID、角色等信息写入Token,使用JWT工具类生成Token返回给前端。
- 前端后续的所有请求都在请求头里带上
Authorization: Bearer <token>。 - 后端维护一个拦截器,对于需要权限的接口,先解析Token,解析成功就放行,失败就返回401。
拦截器在SpringBoot里的实现并不复杂,通常用HandlerInterceptor配合WebMvcConfigurer注册:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行OPTIONS预检请求 if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token) || !token.startsWith("Bearer ")) { throw new BizException("未登录或Token已过期"); } // 解析token,把用户信息放进去 Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } }这里绝大多数的坑集中在CORS跨域和预检请求上。前端访问后端的端口不同,自然会产生跨域问题。后端不仅要在WebMvcConfigurer里配置addCorsMappings,还要保证拦截器对OPTIONS请求直接放行,否则前端在发POST请求时,预检请求会被拦截器挡住,报CORS错误,而问题根本不是跨域配置没写,而是预检没有提前放行。
5.2 前端路由守卫与按钮级权限的取舍
前端这边的权限控制分为两种粒度:页面级权限和按钮级权限。
页面级权限一般靠路由守卫。在router.beforeEach里检查本地是否存在token,如果存在且访问的是登录页,就跳回首页;如果不存在且访问的是受保护页面,就跳到登录页。
按钮级权限更细腻一点。比如“删除参保人员”这个按钮可能只有管理员能看到,普通经办人员看不到。前端通常会在登录后把当前用户的权限标识(类似insured:delete)存到Pinia里,渲染按钮时用v-if判断一下。
这里要特别注意一个安全观点:前端的按钮隐藏只是用户体验层面的东西,真正的安全底线永远在后端拦截器。如果一个普通用户手动调接口,后端还是没有校验权限,那前端再花哨也是白搭。所以做权限时一定要“后端为准”。
6. 从开发环境到服务器部署:排查过的真实坑
最后这部分,我集中整理几个这套系统从开发机挪到服务器时最常见的问题。我自己的项目在部署过程中也踩过不少,挑最有价值的几条分享给你。
6.1 环境差异导致的三个典型故障
第一个是MySQL8.0的认证插件问题。如果你用Docker部署MySQL8.0,默认可能会遇到Public Key Retrieval is not allowed错误。解决办法要么在连接串里加allowPublicKeyRetrieval=true,要么在创建MySQL用户时把认证方式改成mysql_native_password。
第二个是Linux下MySQL的时区问题。开发机上系统时间正常,部署到云服务器后,如果服务器时区设置成了UTC,查询时间和本地正好差8小时。解决的思路是启动容器或者系统时强制指定时区:
docker run -d \ -p 3306:3306 \ --name mysql8 \ -e TZ=Asia/Shanghai \ -e MYSQL_ROOT_PASSWORD=yourpassword \ mysql:8.0第三个是Nginx托管前端时的刷新404问题。Vue3的createWebHistory是基于HTML5 History模式,路由切换时实际路径会变化。如果后端没有配置try_files,刷新/insured/list页面就会直接404。Nginx里需要设置:
location / { try_files $uri $uri/ /index.html; }6.2 身份证号与手机号校验:看起来小,报错很烦
医疗管理系统的表单里,身份证号和手机号的校验是绕不开的。前端用Element Plus的form rules能实现基础校验,但会给后端接口带来很大的数据清洗压力。所以交付项目里通常前后端都做校验。
后端的校验比较推荐一种轻量做法:用Hibernate Validator注解,或者写一个工具类做身份证号格式校验(包含最后一位校验码的算法校验)。身份证校验这一步千万不能把空字符串传进数据库,否则后边做统计和筛选时会出现不该出现的数据。
以“校验参保人身份信息真实性”这个角度,你还可以把身份证号的前六位和后八位联动解析出户籍地区,这也是很多医疗系统里常见的扩展功能点,答辩时提一句“这里以后可以接地区统计”,就能展示出你对业务深度的理解。
7. 关于这套系统的个人实操心得
文章写到这里,该讲的技术点基本都覆盖了。按老规矩,最后扯几句实际的体会吧。
如果你拿这套系统当毕业设计,请一定先把数据库的SQL脚本完整跑一遍,再启动后端,最后再启前端。这个顺序看着简单,但真的能帮你避免一半以上的“环境问题”。跑通之后,从头到尾点一遍功能,把涉及的业务名词都搞清楚,尤其是参合、报销比例这些和医疗场景强相关的词。答辩时,老师大概率不会为难你写代码的过程,而是更关心你对整个系统的理解边界在哪里。你能主动讲清楚“为什么用JWT做鉴权”“为什么幂等性对缴费接口重要”“报销金额为什么用BigDecimal计算”,效果远好于被动等提问。
部署层面,我更推荐你先在本地把前后端联调全部走通,统一了接口返回格式再去碰Nginx和反向代理。很多人一上来就折腾上线,结果浏览器F12里全是跨域和404,越调越乱,最后还得回头看本地环境。这其实是前后端分离项目最典型的“本末倒置”,希望大家绕开这条路。