每年到毕设季我都能在技术群里见到一批同学拿着“城乡居民基本医疗信息管理系统”这个题目问怎么下手。说实话这个题目看着就是一堆增删改查维护参保人、录缴费记录、算报销金额、出统计报表。但真正动手之后你会发现后面的“报销比例怎么算”“跨年度缴费怎么处理”“审核状态怎么流转”这些问题每一个都能让你在电脑前坐一晚上。我当初做这个题目的时候也踩了不少坑这篇文章就把整个系统的业务逻辑、技术选型、数据库设计、核心代码实现和前后端联调的细节一次讲透适合正在做毕设、课设或者想用SpringBootVueMySQL练手的学习型项目参考。整篇内容会严格围绕这个项目的源代码展开重点解释那些“网上抄不到”的设计思路和实现细节。1. 先把业务搞清楚城乡居民医保系统到底管什么1.1 这个系统和“医院HIS系统”完全不是一回事很多同学第一次接触这个题目会下意识把它理解成医院里的门诊住院管理系统然后跑去研究挂号、开药、电子病历方向一下子就偏了。城乡居民基本医疗信息管理系统本质上是一个社保业务经办平台它管的是“钱”和“人”的关系而不是“病”和“药”的关系。核心要处理的几类对象参保人也就是居民本人系统要管他的身份信息、户籍信息、参保状态。缴费记录每年按规定缴纳的医保费用系统要记录缴没缴、缴了多少、缴到哪一年。报销申请居民看病发生医疗费用后提交报销单据系统按规则计算出可以报销多少钱并走审核流程。定点医疗机构医院、社区卫生服务中心、药店等存在基础档案里报销时根据医院级别决定报销比例。系统用户管理员、业务经办人员、审核人员、查询统计人员等。所以你在设计系统的时候脑子里要有一条完整的数据链路参保人建档 - 年度缴费 - 看病产生费用 - 提交报销申请 - 系统计算报销金额 - 审核 - 支付 - 汇总统计。这才是一个合格的医保管理业务系统而不是一个莫名其妙的“医院挂号平台”。1.2 角色梳理谁在用什么功能没有真实需求文档的时候角色和功能全靠业务常识推。我的做法是先列角色再给每个角色列操作清单功能模块自然就出来了。角色核心操作系统管理员用户管理、角色分配、参数配置报销比例、起付线、封顶线、数据字典维护经办人员参保人登记、信息修改、缴费录入、报销申请初审审核人员报销单复审、驳回或通过、支付状态确认查询统计人员参保情况统计、缴费情况统计、报销支出统计、明细导出这里有个很重要的取舍城乡居民医保系统在真实业务里往往还有居民自助端让居民自己在网上查缴费记录、看报销进度。但毕设项目我建议一开始别碰居民端先把管理端做得像样再有余力可以加一个独立的Vue页面做查询前后端分离的技术栈做这个扩展并不难。1.3 功能边界怎么控制不要一上来就需求膨胀我见过很多项目计划书写了十几个模块真做的时候连登录都写不明白。这个题目的合理功能边界应该是“四大模块 系统管理”参保管理参保人信息的新增、修改、查询、状态变更。缴费管理按年度缴费记录缴费状态的校验、欠费提醒、缴费记录查询。报销管理报销申请的提交、金额自动计算、多级审核、支付状态跟踪。这是系统的核心也是面试和答辩时最容易被追问的部分。统计报表参保人数、缴费金额、报销支出、医院报销排行等用柱状图或表格展示。系统管理用户、角色、菜单权限、操作日志、数据字典。把边界控制在这个范围开发量完全可控同时又足够体现架构能力。如果你一开始就想把所有真实医保业务都塞进来比如大病保险、门诊慢特病、异地就医结算、家庭共济这些项目基本就烂尾了。2. 技术栈选型的底层逻辑为什么偏偏是SpringBootVueMySQL2.1 后端SpringBoot比SSM更适合这类学习项目如果是五六年前很多人会推荐SSMSpring SpringMVC MyBatis组合。但现在的实际情况是SpringBoot已经成了绝大多数公司新项目的标配面试时也默认你会SpringBoot。它解决了SSM最大的痛点——繁琐的XML配置和环境搭建。用SpringBoot做这个项目核心收益有几点starter机制引入spring-boot-starter-web、spring-boot-starter-security、mybatis-plus-boot-starter依赖版本由框架统一管理再也不会出现“包冲突搞一天”。内置Tomcatjava -jar启动部署和演示都方便毕设答辩时老师会让你现场跑起来看一条命令启动比配置外部Tomcat省心太多。结构分层清晰controller、service、mapper、entity、config各司其职也方便后面讲架构。2.2 前端Vue负责“管理后台体验”单页应用的优势在这个项目里体现得很明显左侧菜单切换到不同管理页面不用重新加载axios请求后端接口操作反馈快页面也不需要刷新。关键是Vue的生态成熟组件化拆分让每个功能模块像一个独立积木整改起来心智负担小。版本选择上Vue 2 Element UI网上案例最多找资料容易老一点的教学视频基本都是这套。Vue 3 Element Plus新项目推荐但如果导师或参考资料都是Vue 2选Vue 2也完全够用毕竟管理系统核心是数据展示和表单交互不依赖响应式语法的高级特性。另外要提一个部署细节Vue项目npm run build之后生成dist目录。最省事的做法是配置axios的baseURL为绝对路径例如 http://localhost:8080/apiSpringBoot跑在8080端口前端用Vue dev server跑在5173或8081开发环境通过跨域配置联调。生产演示时再用nginx把前端dist包和SpringBoot反向代理到一起。如果你不想引入nginx也可以直接把dist目录拷进SpringBoot的src/main/resources/static下后端一jar包全带走了对毕设演示来说这招非常实用。2.3 数据库MySQL和ORM框架的选择MySQL是这类项目最稳妥的选择原因很简单资料多、Navicat可视化管理方便、绝大多数云服务器都支持。版本建议MySQL 8.0性能更好SQL窗口函数等特性也更丰富。ORM层我推荐MyBatis-Plus而不是原生MyBatis或JPA。理由很直接单表CRUD不需要写SQL内置BaseMapper就能搞定。分页查询有内置插件不用手写limit和count。条件构造器LambdaQueryWrapper写查询条件非常直观。JPA在这个项目里也能用但JPA的级联关系、懒加载问题对于初学者来说踩坑成本比MyBatis-Plus高不少。MyBatis-Plus配合逻辑删除注解TableLogic做“删参保人”这类功能时不用真的DELETE一个注解就搞定了。3. 数据建模与接口设计把业务翻译成表和API3.1 核心数据表设计字段怎么定主外键怎么处理数据库设计是整个系统最见功底的部分。一张表字段随便加但后面写业务代码的时候发现设计不对改起来想哭。我给出一个经过实践验证的5张核心表覆盖这个项目的全部主业务。参保人表insured_person字段名类型说明idbigint主键自增id_cardvarchar(18)身份证号唯一索引业务核心标识namevarchar(50)姓名gendertinyint性别 0未知 1男 2女birth_datedate出生日期household_addressvarchar(200)户籍地址insured_statustinyint参保状态 0暂停 1正常 2退保create_timedatetime创建时间update_timedatetime更新时间为什么要用id_card做唯一索引因为医保业务天然就以身份证号作为人的唯一标识同一参保人不能重复建档。这个唯一约束在代码层也要做校验否则并发下会插入重复数据。缴费记录表payment_record字段名类型说明idbigint主键person_idbigint参保人ID逻辑外键yearint缴费年度如2025amountdecimal(10,2)缴费金额pay_timedatetime缴费时间statustinyint0未到账 1已到账create_timedatetime创建时间注意amount用DECIMAL而不是float/double这个是所有涉及金额系统的通识后面我会专门讲为什么。报销申请表reimbursement_apply字段名类型说明idbigint主键person_idbigint参保人IDhospital_idbigint定点医院IDreimburse_typetinyint1普通门诊 2住院 3大病total_amountdecimal(10,2)总医疗费用start_linedecimal(10,2)起付线ratiodecimal(5,2)报销比例如0.7表示70%ceiling_linedecimal(10,2)封顶线reimburse_amountdecimal(10,2)最终报销金额statustinyint0待审核 1初审通过 2终审通过 3已驳回 4已支付apply_timedatetime申请时间audit_remarkvarchar(255)审核备注这张表比较有意思的点起付线、比例、封顶线这些值我建议设计成报销申请单上的“快照字段”而不是每次计算时去查当前配置。为什么因为报销规则会调整你今年按70%算的明年政策变了历史单据要按当时规则留存这个就是业务上的“数据快照”思想。毕设答辩时把这个点讲出来是一个亮点。定点医疗机构表medical_org字段名类型说明idbigint主键org_namevarchar(100)机构名称org_leveltinyint1社区 2二级 3三级org_typetinyint1医院 2药房addressvarchar(200)地址contactvarchar(50)联系人报销规则配置表reimburse_rule字段名类型说明idbigint主键rule_namevarchar(50)规则名称如“住院三级医院”reimburse_typetinyint对应报销类型org_leveltinyint对应医院级别start_linedecimal(10,2)起付线ratiodecimal(5,2)报销比例ceiling_linedecimal(10,2)封顶线这张表的作用是让管理员可以在页面上灵活配置报销规则而不用改代码。有了它你的报销计算逻辑就能写成一个通用方法从规则表里查参数。外键策略上我强烈建议不要建物理外键约束只保留person_id这种逻辑外键关联关系在应用层维护。原因物理外键会阻止删除、影响写入性能而且MyBatis-Plus做分页关联查询时物理外键并没有什么帮助。真实的开发规范里也普遍是逻辑外键这个习惯越早养成越好。3.2 接口设计Restful API怎么划分后端接口路径我推荐按资源来划分/api/insured参保人增删改查、状态变更/api/payment缴费记录的录入、查询、校验/api/reimbursement报销申请的提交、审核、计算/api/org定点机构管理/api/rule报销规则配置/api/report统计数据接口/api/auth登录、获取用户信息/api/system用户、角色、菜单所有接口统一返回一个结构体前端好处理{ code: 200, message: success, data: { } }分页接口统一接收current和size参数返回records、total、pages等字段。用MyBatis-Plus的PageT对象直接返回即可前端配合Element UI的el-table和el-pagination非常顺手。3.3 数据字典一张“表驱动”的清单系统中大量使用状态值和枚举值参保状态、报销状态、医院级别、报销类型。我建议做一张数据字典表来管理而不是把这些枚举写死在代码里。字典表的核心思路就两个字段type标识字典类型value存实际值。比如typeinsured_statusvalue1label正常参保。typereimburse_typevalue2label住院。这样做的好处前端下拉框可以动态拉数据不用改前端代码新增一个医院级别也不用后端发版。虽然这种设计在简单系统里有点“重”但既然是学习项目体现出业务建模的通用性在答辩时是很加分的设计。4. 核心业务逻辑实现参保、缴费、报销三条主线的代码级拆解4.1 参保登记身份证唯一性和状态流转参保人的新增和修改最基础也最容易出问题的是身份证校验。Service public class InsuredPersonServiceImpl extends ServiceImplInsuredPersonMapper, InsuredPerson { public void addInsured(InsuredPersonDTO dto) { // 1. 身份证号格式校验15/18位最后一位可能是X boolean idCardValid IdCardUtils.validate(dto.getIdCard()); if (!idCardValid) { throw new BusinessException(身份证号格式不正确); } // 2. 身份证号唯一性校验 long count lambdaQuery() .eq(InsuredPerson::getIdCard, dto.getIdCard()) .count(); if (count 0) { throw new BusinessException(该身份证号已存在参保记录); } // 3. 出生日期从身份证号中解析避免用户填错 String birth dto.getIdCard().substring(6, 14); dto.setBirthDate(LocalDate.parse(birth, DateTimeFormatter.ofPattern(yyyyMMdd))); // 4. 初始参保状态为正常 dto.setInsuredStatus(InsuredStatusEnum.NORMAL.getCode()); this.save(BeanCopyUtils.copy(dto, InsuredPerson.class)); } }性别和出生日期我建议直接从身份证号码里解析而不是让操作员手动选。这个细节属于“做了就很专业、不做也不影响功能”的那种但答辩时讲到这一步老师会觉得你确实考虑过真实业务。身份证第17位奇数为男、偶数为女第7到14位是出生日期写两个工具方法就搞定。参保状态的变化做一个最简单的状态机正常 - 暂停 - 退保同时禁止已退保的人再次缴费。这个状态机用枚举加上一个allowedTransitions映射表来实现Getter public enum InsuredStatusEnum { PAUSE(0, 暂停), NORMAL(1, 正常), CANCEL(2, 退保); private final int code; private final String desc; public static final MapInteger, SetInteger ALLOWED_TRANSITIONS new HashMap() {{ put(NORMAL.getCode(), new HashSet(Arrays.asList(PAUSE.getCode(), CANCEL.getCode()))); put(PAUSE.getCode(), new HashSet(Collections.singletonList(NORMAL.getCode()))); put(CANCEL.getCode(), new HashSet()); // 退保为终态 }}; public static void checkTransition(int from, int to) { if (!ALLOWED_TRANSITIONS.getOrDefault(from, new HashSet()).contains(to)) { throw new BusinessException(非法的参保状态变更); } } }状态机把业务校验集中到枚举里后续任何service方法要改状态先调checkTransition从源头杜绝了“从退保直接跳到正常”这种逻辑漏洞。4.2 缴费记录年度校验是重点缴费业务的本质是“某个参保人在某一年交了一笔钱”所以核心校验有两个这个参保人当前状态必须是正常或暂停退保人员不能缴费。同一个参保人在同一年度不能重复缴费。第二个校验非常容易被忽略。如果没有唯一约束前端连续双击保存按钮就会插两条同一年的缴费记录后面统计缴费金额直接翻倍。数据库层面给payment_record表加个联合唯一索引ALTER TABLE payment_record ADD UNIQUE KEY uk_person_year (person_id, year);代码层面保存之前也要查一遍public void addPayment(PaymentDTO dto) { InsuredPerson person insuredService.getById(dto.getPersonId()); if (person null || person.getInsuredStatus() InsuredStatusEnum.CANCEL.getCode()) { throw new BusinessException(参保人不存在或已退保无法缴费); } long count lambdaQuery() .eq(PaymentRecord::getPersonId, dto.getPersonId()) .eq(PaymentRecord::getYear, dto.getYear()) .count(); if (count 0) { throw new BusinessException(该参保人本年度已缴费不能重复录入); } this.save(...); }两道防线都做了才是可靠的实现。数据库唯一约束兜底代码校验给出友好提示这是典型的后端开发经验。4.3 报销金额计算起付线、比例、封顶线的完整实现这可以说是整个系统最核心的一个方法也是答辩时老师最喜欢问的。报销逻辑的正确性直接决定系统能不能用。核心规则用一句话描述报销金额 min( (总医疗费用 - 起付线) × 报销比例 , 封顶线 )且计算结果不能小于0。如果总金额没有超过起付线那么报销金额为0。为了把规则做灵活我设计了一个计算方法先把规则参数从规则表里查出来再套公式public ReimburseResult calculateReimburse(ReimburseParam param) { // 1. 查报销规则表按报销类型和医院级别匹配 ReimburseRule rule ruleMapper.selectOne(new LambdaQueryWrapperReimburseRule() .eq(ReimburseRule::getReimburseType, param.getReimburseType()) .eq(ReimburseRule::getOrgLevel, param.getOrgLevel())); if (rule null) { throw new BusinessException(未匹配到报销规则请联系管理员配置); } // 2. 总费用低于起付线直接返回0 if (param.getTotalAmount().compareTo(rule.getStartLine()) 0) { return ReimburseResult.zero(param.getTotalAmount()); } // 3. 计算 (总费用 - 起付线) * 比例 BigDecimal base param.getTotalAmount().subtract(rule.getStartLine()); BigDecimal reimburseAmount base.multiply(rule.getRatio()).setScale(2, RoundingMode.HALF_UP); // 4. 超过封顶线按封顶线报销 if (reimburseAmount.compareTo(rule.getCeilingLine()) 0) { reimburseAmount rule.getCeilingLine(); } return new ReimburseResult(param.getTotalAmount(), rule.getStartLine(), rule.getRatio(), rule.getCeilingLine(), reimburseAmount); }注意几个工程细节乘法先乘后除“总费用 - 起付线”的结果乘以比例用multiply。如果你先把比例除以100再用BigDecimal计算可能出现除不尽的情况所以最好是规则表里直接存0.7这种小数而不是存70。四舍五入金额保留2位小数用setScale(2, RoundingMode.HALF_UP)这是银行家公式的实际应用直接丢掉和进位都可能对不上账。compareTo判断大小BigDecimal的compareTo才能正确比较数值相同但scale不同的对象不能用equals也别转成double比较。这个计算方法的测试用例也简单我建议你写几个场景直接跑一遍总费用低于起付线、等于起付线、正常区间、超过封顶线。四个用例覆盖了所有分支能帮你在答辩前发现问题。4.4 报销审核状态机与日志记录报销单不是申请完直接给钱而是一套审核流转。最简单的流程是“待审核 - 初审通过 - 终审通过 - 已支付”任一环节可驳回。状态流转合法性用和参保状态一样的枚举映射实现public enum ReimburseStatusEnum { PENDING(0, 待审核), FIRST_APPROVED(1, 初审通过), FINAL_APPROVED(2, 终审通过), REJECTED(3, 已驳回), PAID(4, 已支付); public static final MapInteger, SetInteger ALLOWED new HashMap() {{ put(0, new HashSet(Arrays.asList(1, 3))); put(1, new HashSet(Arrays.asList(2, 3))); put(2, new HashSet(Collections.singletonList(4))); put(3, new HashSet()); put(4, new HashSet()); }}; }每次审核操作除了改状态还需要记录审核人、审核时间、审核意见。我建议建一张audit_log表字段包括业务类型、业务单号、操作人、操作内容、操作时间。前端展示报销详情时把这条链路拉出来整个单据的流转过程一目了然这个对“资料审核类”系统来说是标配也是管理平台区别于普通CRUD的关键特征。报销通过后如果要模拟“拨款”可以把状态改成已支付同时写入一条支付时间。如果你还想做得更完整可以在支付这一步引入一张支付流水表把业务单和支付单关联起来。5. 前后端联调与安全细节最容易翻车的几个地方5.1 登录鉴权SpringSecurity JWT Vue路由守卫生成一个闭环管理系统必须做登录但做了登录就要处理三个问题密码加密、登录凭证、页面访问控制。密码加密必须用BCryptSpringSecurity自带BCryptPasswordEncoder不用自己写加密算法。注册用户时对明文密码加密登录时用passwordEncoder.matches(明文, 密文)校验。登录成功后签发JWT令牌后端返回token前端存到localStorage。axios请求拦截器在每次请求头加Authorization: Bearer tokenservice.interceptors.request.use( config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }, error Promise.reject(error) )后端写一个JWT拦截器解析token并放入SecurityContext同时从token里拿出用户ID和角色列表。SpringBoot的配置类里放行登录接口和静态资源其余接口全部要认证。前端Vue Router的全局前置守卫检查是否存在token没有token直接跳到登录页router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path ! /login !token) { next(/login) } else { next() } })如果要做菜单权限可以在用户登录后从后端接口拉取当前用户的角色和菜单然后动态生成侧边栏菜单。这个功能叫做“动态路由”对毕设来说属于进阶加分项有余力再做。5.2 金额字段必须用BigDecimal和DECIMAL这个坑太经典了前面提到过这里展开说。float和double在计算机里是二进制浮点数0.1加0.2可能得到0.30000000000000004。医保系统里每个报销单都是钱差一分钱都不行。所以三个地方必须统一Java实体类中金额字段用BigDecimal不能是Double或Float。MySQL表中金额字段用DECIMAL(10,2)不能是double类型。前端提交金额时注意不要精度丢失。JavaScript的Number在金额较大或小数位多时也会出问题表单里可以用Number(value)展示时用toFixed(2)。后端在接收前端传参时JSON序列化可能会把BigDecimal变成数字再反序列化时又变回BigDecimal这块SpringBoot的Jackson默认就能处理。但要注意如果你用Fastjson金额字段最好配JSONField(serialzeFeatures SerializerFeature.WriteBigDecimalAsPlain)否则可能变成科学计数法。5.3 日期和跨年度处理LocalDateTime从入门到防呆系统的缴费是按年度的天然涉及年度边界的判断。缴费记录表里的year字段存的是Integer类型的年份如2025。不要在Java里用new Date()去解析字符串再取年份直接用LocalDate.now().getYear()。报销申请时间、审核时间、支付时间都要精确到时分秒用LocalDateTime。MyBatis-Plus自动填充创建时间和更新时间可以实现MetaObjectHandler接口统一处理不用每个插入方法都手动set。前端展示日期时需要注意时区问题。SpringBoot默认序列化LocalDateTime的格式可能带T前端展示不友好。在application.yml里配一段全局格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8再用JsonFormat做兜底。5.4 分页查询与索引优化数据量上来之后不能卡毕设项目数据量不大但如果测试时每次查询都要扫全表体验也会很差。两个最简单的优化点身份证号字段必须加索引因为参保人查询几乎都靠身份证号精确匹配。缴费记录表、报销申请表的person_id加普通索引分页查询按创建时间倒序排列。MyBatis-Plus分页查询的写法PageReimbursementApply page new Page(current, size); LambdaQueryWrapperReimbursementApply wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.hasText(insuredName), ReimbursementApply::getPersonId, insuredId) .eq(status ! null, ReimbursementApply::getStatus, status) .orderByDesc(ReimbursementApply::getApplyTime); PageReimbursementApply result this.page(page, wrapper);条件构造器里eq(condition, column, value)的重载condition为false时自动忽略这个条件这是实现“多条件组合查询”最省事的方式不用写一堆if拼SQL。6. 写在最后怎么让这份源码真正变成“你的”项目6.1 不要照抄按这个顺序重构一遍很多同学拿到的源码直接跑起来就去答辩了导师一问“报销比例存在哪张表”就愣住。我的建议是拿源码当参考但一定要自己动手重建一遍顺序如下先画业务流程图把参保、缴费、报销三条完整链路画清楚。建数据库表按本文的设计把你自己的字段写出来不一定要和参考源码完全一致。用SpringInitializr新建工程按“实体 - Mapper - Service - Controller”的顺序逐层写。前端用Vue脚手架建工程先做登录页再接参保人管理模块模块顺序从简单到复杂。最后再做报销金额计算这个核心方法单独写测试验证。这样走完一遍你对整个代码的掌控力是完全不同的。面试或答辩时任何一个表、任何一个状态枚举、任何一个计算公式你都能讲清楚来龙去脉。6.2 答辩时老师最可能追问的问题清单我根据自己的经验整理了一份高频提问提前准备别到时候现想为什么医保报销金额计算要把起付线、比例和封顶线存在规则表里而不是写死 - 业务规则会变化表驱动便于配置历史单据可以做数据快照。BigDecimal和double的区别 - 精度问题涉及金额必须用BigDecimal和DECIMAL。系统的权限是怎么控制的 - SpringSecurity JWT 前端路由守卫用户可以配置角色。JWT比Session有什么优点 - 无状态、支持跨域、适合前后端分离。如果同一参保人重复提交报销单怎么办 - 在申请表上做联合唯一约束或先校验再插入。你系统的数据量能支撑多少 - 分页查询 索引优化几万到几十万条数据没问题。这些问题很多并不难但如果不提前把逻辑理清楚临场确实会卡壳。尤其报销计算那道题一定要用上面的代码实例练一遍。6.3 后续还能扩展的方向如果你做完主流程还有精力我建议优先加这三个方向投入产出比最高居民自助查询端基于Vue单独做一个页面居民输入身份证号查询自己的缴费记录和报销进度技术难度不大但系统完整度提升一大截。Excel导入导出用EasyExcel实现参保人批量导入和报销明细导出这是真实办公场景里的刚需。报表可视化接入ECharts把参保人数、年度缴费金额、报销支出按月份画成折线图和柱状图统计模块就不再只有干巴巴的表格了。这个项目说到底是给学习和答辩用的技术不在多在于每一项技术你都真正理解它为什么这样用。把SpringBootVueMySQL这条链路吃透再往后做任何管理系统换汤不换药只是业务表不一样而已。