ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+MyBatis+Vue医疗报销系统开发实战:状态机与安全设计

SpringBoot+MyBatis+Vue医疗报销系统开发实战:状态机与安全设计 每年毕设季医疗报销系统这个题目的出现频率都相当高。原因不难理解——它是典型的业务管理系统覆盖了用户认证、流程审批、数据权限、金额计算、报表统计、文件上传这些JavaWeb必考知识点难度又刚好卡在“工作量够、难度可控”的位置所以老师们也爱往这个方向出题。但做过的人心里都清楚“看起来简单”和“真正能跑通”是两回事。报销系统最核心的难点不在CRUD而在于报销状态的流转控制、金额的精确认算、审核并发的安全性以及一整套可追溯的操作记录。这篇文章就从零拆解一个基于SpringBoot的医疗报销系统包括需求分析、表结构设计、核心接口实现、前后端打包部署以及我在实际开发中踩过的坑。不管你是刚拿到题目的应届生还是想快速搭一套内部报销工具的开发者花十分钟读完对这套系统的整体脉络基本就能心中有数。1. 项目定位医疗报销系统到底在解决什么问题1.1 一句话说清楚业务本质医疗报销系统表面看是“用户上传发票、管理员审核、然后打款”但往深了挖它背后其实是三件事单据处理、资金核算、流程管控。单据处理是说用户每次看病会产生挂号单、药品清单、检查报告、费用发票这些零散的纸质和电子凭证必须被录入系统形成一笔完整的报销记录。资金核算则是说系统需要根据报销政策计算可报金额。比如城镇职工医保、城乡居民医保的起付线和报销比例不同一个健壮的系统要把这些规则配置化而不是写死在代码里。流程管控更关键报销单从“草稿”到“待审核”到“已通过”再到“已打款”每一个动作都要有人负责、有迹可循。所以做这个项目第一步不是建表而是把业务边界划定清楚。哪些用户能做什么操作、报销单要经过哪些状态、每一笔金额如何计算必须提前想明白否则后期改起来非常痛苦。这也就是为什么这类系统虽然功能看起来简单但真正做得规范的版本并不多。1.2 角色划分与权限边界绝大多数毕设版本至少包含两种角色普通用户和管理员。如果想让系统更完整可以再加一个“财务/出纳”角色专门负责打款回执登记这样权限层级更清晰工作量也更好看。普通用户的核心操作是维护个人信息、提交报销申请、上传费用凭证、查看审核进度、查看历史报销记录和统计。管理员的职责是审核用户提交的报销单、驳回不合规的申请、维护报销政策与药品目录、查看全平台的报销统计报表。角色的权限控制在SpringBoot项目里最常用的方案就是Spring Security加JWT或者简单一点用拦截器加自定义注解。我在实际做的时候倾向于用Spring Security做认证用PreAuthorize(hasRole(ADMIN))这种注解做方法级权限控制。这样Controller层的职责很清楚权限逻辑不散落到业务代码里后面要在某个接口上收紧权限加一行注解就行。1.3 核心功能清单与主流程梳理把这个系统的核心功能列一下方便后面做表设计和接口设计时对照用户注册与登录密码加密存储报销单管理支持新建、编辑、提交、撤回、删除草稿费用明细管理一条报销单可以挂多条明细比如挂号费、诊疗费、药品费、检查费凭证附件上传用于存发票照片、检查报告PDF审核管理管理员执行通过或驳回操作填写审核意见报销进度查询用户随时看到当前单子走到哪一步统计报表按时间、按费用类型统计报销金额系统管理用户管理、政策参数配置、数据字典维护主流程也很清楚用户创建报销单填写费用明细上传附件提交管理员审核通过则进入待打款流程财务登记回执用户看到最终状态。这个流程想清楚之后数据库的表结构其实已经呼之欲出了。建议大家动手写代码之前先把这条主流程画在纸上每个状态节点、每个操作对应的角色标清楚后面开发效率会高很多。2. 技术选型拆解SpringBoot MyBatis Vue这套组合为什么经典2.1 SpringBoot自动装配到底帮我们省了什么这个项目标题里把SpringBoot放在最前面不是没道理的。SpringBoot这几年几乎成了JavaWeb项目的默认起点核心原因就一个字省。在Spring时代配一个SpringMVC项目要写一堆XML还要手动配置DispatcherServlet、包扫描、视图解析器、数据源。SpringBoot把这些东西全部通过自动装配搞定了。你加一个spring-boot-starter-web依赖内嵌Tomcat自动启动Controller直接能用你加一个mybatis-spring-boot-starterSqlSessionFactory自动建好Mapper直接注入。这就是热词里“springboot自动装配原理”反复被讨论的原因。理解自动装配对看懂这个项目很有帮助。SpringBoot的starter本质上是一个Maven描述符里面打包了相关依赖和自动配置类。以mybatis-spring-boot-starter为例它内部的AutoConfiguration会读取你在application.yml里的spring.datasource配置自动创建DataSource、SqlSessionFactory并通过MapperScannerRegistrar把你的Mapper接口扫描注册成Bean。这也是为什么你写MapperScan(com.example.reimburse.mapper)时实际上是在补充自动装配没有覆盖到的扫描范围。2.2 持久层选型为什么是MyBatis而不是JPA做这个系统我强烈建议用MyBatis。原因有三第一国内项目、教材、技术社区里MyBatis的普及度是压倒性的遇到问题随便搜就有答案第二医疗报销这种业务SQL里常要写复杂的条件查询和多表关联比如按时间范围统计报销金额、查某个分类的费用总和这种SQL用MyBatis的XML写起来非常顺手用JPA就得写JPQL或者更别扭的Specification第三MyBatis的动态SQL在组合查询场景下极其好用。有些同学纠结“MyBatis要写XML太麻烦”。麻烦是麻烦但可控性极高。尤其当你后续要加一个多条件组合查询时MyBatis的if、where、foreach标签能让你少写很多字符串拼接代码。我经常跟人说一个类比JPA像自动挡开起来省心但紧急情况下你控制不了档位MyBatis像手动挡操作多一些但动力输出和工况判断都在你手里。做这类业务系统手动挡更稳妥。2.3 前端方案“Vue 前后端分离”与“Vue打包进SpringBoot”现在的毕设主力前端就是Vue。Vue 2虽然还很稳定但新项目我更推荐Vue 3加Element Plus加Vite组件库顺手开发速度快文档也全。前端页面主要就是登录页、报销单填写页、报销记录列表、审核管理页、统计图表页这几大块Element Plus的表格、表单、上传组件基本都能直接满足需求。这里有一个热词需要展开讲讲“vue打包放进springboot中”。很多同学不想单独部署前端或者不想让老师看到两个服务联调半天就选择把Vue执行npm run build之后生成的dist目录直接放到SpringBoot的src/main/resources/static下。这样最终交付的时候一个jar包跑起来前端文件跟着一起被服务部署成本很低。我的建议是开发阶段用Vite devServer加后端proxy转发解决跨域部署阶段执行npm run build然后把dist目录内容复制到static目录再执行mvn clean package打成jar。但要注意几个点第一Vue路由如果是history模式刷新会出现404需要在SpringBoot里配置一个转发规则把非/api开头的未知路径转发到index.html第二如果SpringBoot设置了server.servlet.context-path前端静态资源的路径也要对应调整否则js/css加载不出来。这些细节我放在后面常见问题章节具体讲。2.4 附件存储MinIO集成要点热词里有“minio加入到springboot”这个点和医疗报销系统的关系非常密切。系统里用户要上传发票图片、检查报告PDF这些文件如果直接存数据库的BLOB字段数据库会迅速膨胀备份和查询速度都会受影响如果存本地磁盘部署到服务器后又不方便统一管理换个机器文件就丢了。MinIO是一个开源的对象存储服务兼容S3协议搭建也简单下载一个服务端二进制启动就行默认端口9000。在SpringBoot里集成的步骤也是标准化的第一步引入io.minio的minio依赖 第二步在application.yml里配置endpoint、accessKey、secretKey、bucketName 第三步写一个MinioService封装upload、download、delete三个方法 第四步业务代码里调用上传接口把返回的文件URL存到数据库的attachment表。注意两个坑文件名冲突要处理我习惯用UUID加原始文件名后缀重新生成存储对象名另外大文件上传建议检查Nginx的client_max_body_size配置否则会报413错误。3. 核心功能模块是怎么落地的3.1 登录认证与JWT签名认证用户表加角色表加用户角色关联表这是权限系统的三件套。密码不能用明文也尽量不要用MD5我在项目里用的是BCrypt加密Spring Security自带的BCryptPasswordEncoder直接就能用。登录成功之后签发JWTJWT的claims里放userId和role下次请求通过拦截器解析Token把用户信息放进ThreadLocal上下文里方便后面Service层随时获取当前登录人。热词里提到的“springboot 签名认证”其实就是JWT签名这套。我的建议是token过期时间设成2小时刷新Token的机制可以后续再扩展校验签名用HS256秘钥放配置文件别硬编码在代码里拦截器要排除login、register、静态资源等路径权限控制推荐方法级别的PreAuthorize比在拦截器里写角色判断更清晰可读性也更好。登录认证这一块如果弄不明白整个系统开发会有一种“到哪都卡住”的感觉。所以拿到项目第一步可以先做一个最简单的注册登录接口把SpringBoot加MyBatis加MySQL这条链路跑通再做别的功能。热词里的“第1关项目整合 - springboot mybatis”和“第2关使用springboot mybatis实现一个最简单的注册功能”说的就是这个思路先把骨架立起来。3.2 报销单状态机系统最核心的设计这是整个系统的灵魂值得多花点篇幅展开。报销单的一条完整生命线是这样的草稿DRAFT到待审核PENDING到通过APPROVED再到已打款PAID从待审核可以驳回到草稿REJECTED。如果用户提交之后想改可以在待审核之前撤回被驳回之后只能修改草稿重新提交。状态机设计最忌讳的就是到处写if/else判断。我推荐在Service层建一个状态流转校验的方法整体思路是让枚举自己管理“哪些状态可以流转到哪些状态”public enum ReimburseStatus { DRAFT, PENDING, APPROVED, REJECTED, PAID; public boolean canTransferTo(ReimburseStatus target) { switch (this) { case DRAFT: return target PENDING; case PENDING: return target APPROVED || target REJECTED; case APPROVED: return target PAID; case REJECTED: return target PENDING; default: return false; } } }调用时统一走校验private void checkStateChange(ReimburseOrder order, ReimburseStatus target) { if (!order.getStatus().canTransferTo(target)) { throw new BusinessException(不允许从 order.getStatus() 流转到 target); } }这样的好处是所有状态流转的规则都集中在一个地方后续增加状态只改枚举即可。比如以后要加一个“已驳回待补充材料”的状态不会影响其他代码。这个设计是项目答辩时很值得拿出来讲的一个点。3.3 费用明细与金额计算精度报销单和费用明细是1对N的关系。费用明细表至少要包含医院名称、就诊时间、费用类型、总金额、自费金额、报销金额。这里有一个“魔鬼细节”金额计算精度。如果在Java代码里直接用double或者float做金额计算0.1 0.2这种问题会直接让你的报销金额差出分厘。我见过不止一个初学同学的代码里报销比例乘以总金额之后出现了19.999999这种结果。后端实体类里要用BigDecimal数据库里要用DECIMAL(10,2)前端传过来的字符串在接收时直接映射为BigDecimal。虽然实际金额差异可能也就是一分钱但作为报销系统钱的问题一点含糊不得。报销金额怎么算最灵活的方式是配置化规则。系统中维护一张报销政策配置表字段包括费用类型、报销比例、是否启用。计算时根据费用类型查配置套用公式BigDecimal actualAmount total.subtract(selfPay); BigDecimal reimbursable actualAmount.multiply(rate).setScale(2, RoundingMode.HALF_UP);如果规则更复杂比如按金额区间走不同比例可以在配置表里加minAmount和maxAmount字段查询时先找匹配区间再计算。这样就把业务规则从代码里剥离出来了运维人员改政策不需要重新发版。3.4 审核流程、乐观锁与操作留痕审核是后台管理员的核心动作。审核时管理员可以看到报销单的所有明细和上传的附件然后选择“通过”或“驳回”填写审核意见。审核通过后报销单状态从PENDING变为APPROVED进入待打款列表如果驳回状态变成REJECTED用户在前端看到驳回原因修改后可以重新提交。这里有一个容易被忽视但非常重要的设计操作留痕。报销系统牵扯资金必须保证每一次状态变更都有迹可溯。我建了一张operation_log表记录操作人、操作类型、操作时间、审核意见、变更前后的状态。管理员审核通过后日志里要清清楚楚写着“admin于2025-xx-xx将单号RE2025001从PENDING变更为APPROVED”。答辩时提一句“可通过日志表全链路追溯报销审核过程”是很明显的加分项因为这说明你不是只做了一个简单CRUD而是考虑了系统的合规性。3.5 统计报表怎么写得又简单又好看系统里要给用户端和管理员端都做些统计。用户端比较简单查看自己历史报销总额、报销次数、各类型费用占比。实现方式就是按userId分组查询前端用ECharts画饼图或柱状图。管理员端相对复杂一些按月度统计报销总金额、新增报销单数、通过率、未处理单数。这种统计SQL要单独写到Mapper的XML里不要在业务代码里做运算。一个比较典型的SQL是这样SELECT DATE_FORMAT(order_date, %Y-%m) AS month, COUNT(*) AS total, SUM(CASE WHEN status APPROVED THEN 1 ELSE 0 END) AS approved_count, SUM(CASE WHEN status PENDING THEN 1 ELSE 0 END) AS pending_count FROM reimburse_order WHERE deleted 0 GROUP BY DATE_FORMAT(order_date, %Y-%m) ORDER BY month DESC;统计查询尽量单独写Mapper方法不要为了偷懒在一个方法里塞各种if判断那会让SQL变得臃肿难维护。前端图表推荐使用ECharts体积可控文档细致给用户端画一个年度报销趋势折线图给管理员端画一个报销类型占比饼图视觉效果一下子就上来了。4. 关键表结构与核心代码实现4.1 数据库表结构设计数据库是这个项目的地基表设计做好后面写代码顺风顺水。核心表一共六张用户表、报销单主表、费用明细表、附件表、操作日志表、报销政策配置表。用户表sys_user字段类型说明idbigint主键usernamevarchar(50)登录名passwordvarchar(100)BCrypt加密后的密码real_namevarchar(50)真实姓名card_novarchar(30)社保卡号companyvarchar(100)所属单位phonevarchar(20)手机号rolevarchar(20)角色USER/ADMIN/FINANCEdeletedtinyint(1)逻辑删除标记create_timedatetime创建时间报销单主表reimburse_order字段类型说明idbigint主键order_novarchar(32)报销单号建议用时间戳序号user_idbigint申请人IDtitlevarchar(200)报销事由total_amountdecimal(10,2)总金额reimbursable_amountdecimal(10,2)可报销金额statusvarchar(20)状态DRAFT/PENDING/APPROVED/REJECTED/PAIDapply_timedatetime提交时间audit_timedatetime审核时间audit_user_idbigint审核人IDaudit_opinionvarchar(500)审核意见pay_timedatetime打款时间versionint乐观锁版本号deletedtinyint(1)逻辑删除标记create_timedatetime创建时间费用明细表reimburse_item字段类型说明idbigint主键order_idbigint报销单IDhospital_namevarchar(100)医院名称visit_datedate就诊日期item_typevarchar(20)费用类型挂号/检查/药品/治疗total_amountdecimal(10,2)总金额self_paydecimal(10,2)自费金额reimbursabledecimal(10,2)报销金额remarkvarchar(500)备注附件表attachment、操作日志表operation_log、政策配置表policy_config的字段按照前面章节的设计写就行这里不再重复。有两点提醒第一表名尽量不要用order因为order是MySQL的保留字非常容易踩坑加个前缀叫reimburse_order最省心第二所有表都加逻辑删除标记deleted查询时统一加WHERE deleted 0这样以后用户删了报销单后台还能查到记录。这也能在答辩时体现工程规范性。4.2 报销单提交的Service实现提交报销单这个接口业务逻辑上是校验用户是否登录校验报销单状态是否为草稿校验费用明细非空计算报销金额更新主表状态最后落一条操作日志。代码写成一个事务方法Transactional(rollbackFor Exception.class) public Long submitOrder(Long orderId, Long userId) { // 查询报销单 ReimburseOrder order orderMapper.selectById(orderId); if (order null) { throw new BusinessException(报销单不存在); } // 权限校验 if (!order.getUserId().equals(userId)) { throw new BusinessException(无权操作该报销单); } // 状态校验 if (order.getStatus() ! ReimburseStatus.DRAFT) { throw new BusinessException(只有草稿状态的报销单才能提交); } // 明细非空校验 ListReimburseItem items itemMapper.selectByOrderId(orderId); if (items.isEmpty()) { throw new BusinessException(报销单明细不能为空); } // 计算可报销金额 BigDecimal reimbursable calculateReimbursable(items); order.setReimbursableAmount(reimbursable); order.setStatus(ReimburseStatus.PENDING); order.setApplyTime(new Date()); order.setVersion(order.getVersion() 1); orderMapper.updateById(order); // 写入操作日志 logService.log(orderId, userId, SUBMIT, ReimburseStatus.DRAFT, ReimburseStatus.PENDING, ); return order.getId(); }事务对这个系统极其重要。报销单创建、明细插入、附件记录、日志写入必须保证“要么全部成功要么全部回滚”否则就会出现明细有数据、主表没有或者日志缺失的脏数据。Transactional要放在public方法上并确保外部调用不能自己调自己否则Spring的代理机制不生效。这就绕到了热词“springboot默认使用cglib代理”的知识点SpringBoot 2.x之后默认使用CGLIB代理它通过生成子类来增强Bean所以目标方法不能是private否则无法被拦截。4.3 审核操作的并发控制代码管理员审核也是一个事务方法先查出报销单校验状态是PENDING然后执行审核动作。这里存在一个并发隐患如果两个管理员同时打开同一个报销单一个点击“通过”一个点击“驳回”没有锁保护的情况下后执行的一方可能基于过期的状态做判断导致两条审核操作都“成功”状态却错乱。我用乐观锁解决这个问题。审核更新时在SQL里带上version条件int rows orderMapper.auditUpdate( orderId, ReimburseStatus.PENDING, targetStatus, order.getVersion(), auditUserId, auditOpinion, new Date()); if (rows 0) { throw new BusinessException(操作失败该报销单状态已发生变化请刷新后重试); }对应的Mapper XML大概是update idauditUpdate UPDATE reimburse_order SET status #{targetStatus}, audit_time #{auditTime}, audit_user_id #{auditUserId}, audit_opinion #{auditOpinion}, version version 1 WHERE id #{orderId} AND status #{expectedStatus} AND version #{version} /update如果没有更新到任何行说明状态或版本已经被别人改过了直接拒绝并提示用户刷新页面。这就是典型的CAS比较并交换思路。虽然一个小型的报销系统几乎不会有两个人同时审核同一个单子的情况但把这种保护机制写进去无论从工程质量还是答辩角度看都是值钱的细节。5. 打包部署、踩坑记录与答辩加分点5.1 前后端联调与Maven构建部署开发阶段前端Vite启动在5173端口SpringBoot跑在8080通过Vite的proxy配置把/api开头的请求转发到8080这样解决跨域问题同时前端代码里请求路径直接写/api/...不用写死主机名后续改部署环境也方便。部署阶段执行npm run build把生成的dist目录中的内容复制到SpringBoot的src/main/resources/static下然后执行标准的三步构建mvn clean mvn package java -jar target/reimburse-system.jar --spring.profiles.activeprod热词里“javamaven项目构建方法springboot”说的就是这个标准流程。还有“idea 2026怎么配置springboot服务编辑配置数据比如启动端口”经常有人问。答案其实有两层一是在application.yml里配置server.port这是推荐做法因为不同环境可以用不同配置文件管理二是在IDEA的Run/Debug Configuration里给VM options加-Dserver.port8081或者Environment variables里加SERVER_PORT8081适合临时调试。生产环境优先用配置文件方案这是规范。5.2 常见坑位速查表把这个项目开发运维中容易踩的坑整理成一张速查表现象根本原因解决方案白屏或Whitelabel Error Page接口404或静态资源路径不对检查Controller的RequestMapping和前端请求地址是否一致刷新Vue页面后404前端history路由模式在SpringBoot未配置转发在WebMvcConfigurer里添加转发规则非/api路径转发到index.html上传文件报错SpringBoot默认单文件最大1MB在配置里调大spring.servlet.multipart.max-file-size、max-request-size前端传日期报400日期字符串与后端Date类型格式不一致用JsonFormat(pattern yyyy-MM-dd HH:mm:ss)统一格式Mapper接口Autowired报错Mapper扫描范围没覆盖启动类加MapperScan(...mapper)或每个接口加Mapper创建的表名带order报SQL语法错order是MySQL保留字加前缀改名比如reimburse_orderLocalDateTime序列化不一致Jackson版本或配置差异统一配置ObjectMapper或用spring.jackson.date-formatSpringBoot版本引太高以后部分starter不兼容版本不匹配统一用Spring Initializr生成的版本兼容组合比如SpringBoot 2.7.x配MyBatis 2.x事务方法不生效方法私有或类内部调用私有方法不能加事务调用的方法不能是private也不能同类内部自调金额计算结果出现0.3000000001用了double做金额计算金额一律用BigDecimal数据库用DECIMAL(10,2)遇到这些问题时不要慌先看日志再对照配置大多数都是配置层面的小问题。日志配置建议在application.yml里把logging.level.com.example.reimburse设为debug大问题小问题一眼就能看到Mapper对应的SQL执行情况。5.3 答辩汇报时值得重点讲的几个加分点这个项目做完答辩的时候有些点值得系统地讲。第一状态机设计。用枚举管理状态流转集中校验杜绝了if/else散落各处这是软件工程里“状态机模式”的实践。第二乐观锁控制审核并发体现工程意识你可以现场演示两个管理员同时点审核后一个会收到友好的错误提示。第三操作日志全链路留痕呼应“资金安全可追溯”这个核心诉求。第四金额基于BigDecimal加配置化规则计算体现对金融计算精度的理解。第五MinIO对象存储管理附件说明你了解对象存储的基本思路而不是把文件存数据库。这些点不是花架子每一个都切中医疗报销系统“流程严谨、数据准确、可追溯”的行业属性。把这几条讲清楚比你对着代码念10分钟CRUD强得多。最后再分享一点个人体会。这个项目我在帮人改代码和指导学生的过程中看过不下二十个版本做得好的各有各的好做得差的都逃不过几个通病状态乱跳、金额用double、日志缺失、权限只靠前端隐藏。如果看这篇文章的你正在做这个题我建议把上面几个点逐一核对一遍改掉这些毛病你的系统无论从运行稳定性还是答辩表现都会远超平均水准。我个人最大的感受是这类业务系统项目技术框架反而是次要的真正见功力的是对业务状态的梳理和对核心数据规则的敬畏。状态机想清楚、金额计算用对类型、每一步操作可追溯这个项目的灵魂就立住了。比起纠结某个组件的新版本特性先把这些基本功做扎实才是一个开发者真正的底气。
返回列表