拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Spring Boot 体检预约系统:领域建模、号源并发与报告判定

简介这份资料面向计算机与信息管理类专业的本科毕业生提供一套基于Springboot的医院健康体检系统毕业设计论文适合正在寻找选题、需要参照完整论文范式写作的同学。论文围绕人口老龄化背景下体检需求增长展开依次论述需求分析与功能划分、三层架构总体设计、数据库表结构设计以及用户管理、预约排期、体检套餐与流程、结果录入查询、数据统计等模块的具体实现并交代Java、Springboot、Layui、MySQL、Navicat等技术选型及代码规范、性能与安全方面的优化思考。压缩包内共1个docx文件约4.16MB即整篇论文说明书正本包含中英文摘要、关键词与正文各章节。目前已有268人学习下载可用于借鉴章节组织、论证逻辑与摘要写法快速搭建论文框架并明确系统功能边界。1. 体检套餐卖出去报告却查不到Spring Boot 要接住的四个业务边界体检系统的复杂度不在页面上而在后端怎么把「套餐—项目—预约—报告」这条链串起来。典型翻车现场是用户买了含血常规、肝功、腹部彩超的套餐到院后报告只出来一半或者同一台彩超机上午被约了 40 个人。这类问题前端兜不住只能落在 Spring Boot 的领域建模和事务边界上。系统真正要处理的是体检项目与套餐的多对多组合、按日期和时段切分的号源、检验结果与参考区间的判定、报告审核状态流转最后才轮到给前端吐 JSON。它适合两类人做 Java 毕设、想要一套能跑通的完整骨架的学生以及接手体检科线下流程、准备把电子表格搬上线的后端工程师。后面按工程搭建、领域建模、核心接口、结果判定、并发调优五步展开代码可以直接抄进项目改。2. Spring Boot 工程骨架搭建与体检领域建模2.1 用 IDEA 创建 Spring Boot 项目与 JDK 版本选择先定版本这一步踩错后面全白干。Spring Boot 3.x 强制要求 JDK 17 起步如果院里服务器还锁在 JDK 8就在 IDEA 的 Spring Initializr 里把 Boot 版本选到 2.7.x 这条线它是最后一批支持 JDK 8 的版本。常见的「IDEA 新建项目时选不了 1.8」多数是 Initializr 默认给了 3.x手动改父版本即可不必去动 IDE 的 SDK 配置。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId !-- JDK 8 环境锁 2.7.xJDK 17 可换 3.2.x -- version2.7.18/version relativePath/ /parent properties java.version1.8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 预约时间、手机号、身份证校验都要用 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- 单表 CRUD 用 MyBatis-Plus 省事复杂报表 SQL 仍然手写 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies依赖的意义starter-web带进内嵌 Tomcat 和 Jackson接口层不用额外配starter-validation提供NotNull、Pattern这一套约束注解预约表单基本靠它MyBatis-Plus 版本要跟 Boot 大版本对上2.7.x 配 3.5.x 系列稳定配 3.6 以上偶尔会出现SqlSessionFactory装配冲突。Lombok 只做样板代码消减团队不接受注释处理器时删掉也不影响主流程。2.2 体检领域四张核心表与关联关系体检套餐和体检项目是多对多报告和项目是一对多预约夹在中间。把这几张表的关系先画清楚后面接口才不会写出「套餐改了历史报告跟着变」的坑——解决办法是报告明细落库时把参考区间快照进去而不是查的时候再关联。表名职责关键字段关联t_check_item单个体检项目item_code、category、unit、ref_min、ref_max被套餐和报告引用t_check_package可售卖套餐package_code、gender_limit、price、status与项目多对多t_package_item套餐项目关联package_id、item_id、sort_no中间表t_appointment预约单appointment_no、package_id、appoint_date、time_slot、status对应一份报告t_report / t_report_detail报告头与明细report_id、item_id、result_value、abnormal_flag明细带参考区间快照CREATE TABLE t_check_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_code VARCHAR(32) NOT NULL COMMENT 项目编码如 GLU, item_name VARCHAR(64) NOT NULL, category VARCHAR(16) NOT NULL COMMENT LAB/IMAGE/GENERAL, unit VARCHAR(16) DEFAULT NULL, ref_min DECIMAL(10,2) DEFAULT NULL COMMENT 数值型参考下限, ref_max DECIMAL(10,2) DEFAULT NULL, gender_limit TINYINT NOT NULL DEFAULT 0 COMMENT 0不限 1男 2女, price DECIMAL(10,2) NOT NULL DEFAULT 0, UNIQUE KEY uk_item_code (item_code) ) COMMENT 体检项目字典; CREATE TABLE t_package_item ( package_id BIGINT NOT NULL, item_id BIGINT NOT NULL, sort_no INT NOT NULL DEFAULT 0, PRIMARY KEY (package_id, item_id) ) COMMENT 套餐项目关联; CREATE TABLE t_report_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, report_id BIGINT NOT NULL, item_id BIGINT NOT NULL, result_value VARCHAR(64) DEFAULT NULL, ref_min DECIMAL(10,2) DEFAULT NULL COMMENT 录入时快照避免字典改动污染历史报告, ref_max DECIMAL(10,2) DEFAULT NULL, abnormal_flag TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1偏高 2偏低 3阳性, KEY idx_report_id (report_id) ) COMMENT 报告明细;快照这一条值得单独强调体检项目的参考区间会随检验科标准调整如果报告明细只存item_id、展示时再回查字典两年前那份报告就会显示成新标准这在复检对比里是硬伤。明细表存ref_min、ref_max是必须的。2.3 application.yml 与自动装配数据源、MyBatis-Plus 配置Spring Boot 自动装配的核心是SpringBootApplication里的EnableAutoConfiguration它按META-INF/spring.factoriesBoot 2.7或AutoConfiguration.importsBoot 3.x加载候选配置类再用ConditionalOnClass、ConditionalOnMissingBean决定装不装。所以spring-boot-starter-data-redis一进依赖RedisTemplate就自动就位除非你自己定义了同类型 Bean。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/health_exam?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: exam_app password: ${DB_PWD:change_me} hikari: maximum-pool-size: 20 connection-timeout: 3000 redis: host: 127.0.0.1 port: 6379 timeout: 2000ms lettuce: pool: max-active: 16 mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted${DB_PWD:change_me}这种占位写法让生产环境用环境变量注入密码配置文件里不留明文。连接池maximum-pool-size给到 20 是中小体检科的常见量级体检高峰集中在早上 7 点到 10 点池子开太大会把数据库连接数顶满。真正要做业务库和只读报表库分离时才引入springboot多数据源用两个ConfigurationProperties(spring.datasource.business)和(spring.datasource.report)分别绑定DataSource在各自的SqlSessionFactory上指定MapperScan包路径别让两套 Mapper 互相扫到。3. 体检预约与套餐组合的核心接口实现3.1 体检套餐与项目的多对多绑定套餐编辑页保存时前端通常只提交一个itemIds数组后端要做的是「覆盖式更新」先按package_id删掉旧关联再批量插入新关联整个过程放进一个事务。分开写两次更新会出现中间态——删完还没插的瞬间另一个请求查套餐会拿到空项目列表。Service public class PackageService { Resource private PackageItemMapper packageItemMapper; /** * 覆盖式保存套餐项目绑定 * param packageId 套餐主键 * param itemIds 体检项目主键列表顺序即展示顺序 */ Transactional(rollbackFor Exception.class) public void bindItems(Long packageId, ListLong itemIds) { if (CollectionUtils.isEmpty(itemIds)) { throw new BizException(套餐至少包含一个体检项目); } // 先清空旧绑定避免残留已下线项目 packageItemMapper.delete(Wrappers.PackageItemlambdaQuery() .eq(PackageItem::getPackageId, packageId)); ListPackageItem rows new ArrayList(itemIds.size()); for (int i 0; i itemIds.size(); i) { PackageItem pi new PackageItem(); pi.setPackageId(packageId); pi.setItemId(itemIds.get(i)); pi.setSortNo(i); // 保留前端拖拽排序 rows.add(pi); } packageItemMapper.insertBatch(rows); } }Transactional(rollbackFor Exception.class)把受检异常也纳入回滚默认只回滚RuntimeException包一层自定义BizException却忘了设rollbackFor是很常见的丢数据原因。sort_no保留前端拖拽顺序报告单打印时按这个字段排检验类项目排在影像类前面医生看起来顺手。3.2 预约下单接口号源容量校验与事务边界预约的本质是「某个日期某个时段还有没有位置」。号源表按schedule_date time_slot建一条记录存容量和已约数。下单接口先用条件更新扣减再写预约单扣减失败直接返回「该时段已约满」。Service public class AppointmentService { Resource private ScheduleMapper scheduleMapper; Resource private AppointmentMapper appointmentMapper; Transactional(rollbackFor Exception.class) public String create(AppointmentCreateDTO dto) { // 条件更新booked 未达上限才 1用 affected rows 判断是否抢到 int affected scheduleMapper.tryBook(dto.getScheduleId()); if (affected 0) { throw new BizException(该时段号源已约满请换一个时段); } Appointment appt new Appointment(); appt.setAppointmentNo(NoGenerator.next(TJ)); appt.setUserId(dto.getUserId()); appt.setPackageId(dto.getPackageId()); appt.setAppointDate(dto.getAppointDate()); appt.setTimeSlot(dto.getTimeSlot()); appt.setStatus(AppointStatus.WAITING_PAY.getCode()); appointmentMapper.insert(appt); return appt.getAppointmentNo(); } }UPDATE t_schedule SET booked booked 1 WHERE id #{scheduleId} AND booked capacity这里刻意不用「先 select 查余量、再 update」那样两个人同时读到 0 余量都会放行。条件更新把判断和扣减压成一条原子语句靠数据库的行锁保证串行。事务里不要塞短信通知、报告预生成这类远程调用否则一次推送超时会把号源锁死几百毫秒早高峰的并发全堵在这。3.3 用 Validated 与自定义注解校验预约入参预约表单的校验点集中在日期不能早于今天、时段必须是上午或下午、手机号格式、套餐必须已上架。前三条用注解就能表达最后一条需要查库放 Service 里做。Data public class AppointmentCreateDTO { NotNull(message 用户不能为空) private Long userId; NotNull(message 套餐不能为空) private Long packageId; NotNull(message 预约日期不能为空) FutureOrPresent(message 预约日期不能早于今天) JsonFormat(pattern yyyy-MM-dd) private LocalDate appointDate; NotBlank(message 时段不能为空) Pattern(regexp ^(AM|PM)$, message 时段只能是 AM 或 PM) private String timeSlot; } RestController RequestMapping(/api/appointment) public class AppointmentController { PostMapping(/create) public RString create(RequestBody Validated AppointmentCreateDTO dto) { return R.ok(appointmentService.create(dto)); } }Validated加在参数上校验失败会抛MethodArgumentNotValidException在RestControllerAdvice里统一捕获成错误码省得每个接口写 if 判断。这些注解本身也是常见springboot面试题的落点Valid和Validated的区别在分组校验能力后者支持groups属性预约和改约字段要求不同时可以复用同一个 DTO。4. 体检结果录入、参考区间判定与报告查询4.1 检验指标参考区间的存储与判定逻辑结果录入时最重要的一步是打异常标记。数值型指标拿结果和ref_min、ref_max比定性指标尿蛋白、乙肝表面抗原按「阴性为正常」判定。标记结果要落库不能查询时实时算否则报告详情页在两千条明细下会反复比较。指标类型示例判定规则abnormal_flag数值-双侧血糖、白细胞 ref_min偏低 ref_max偏高1 / 2数值-单侧肌酐只有上限ref_min为空时只判断上限1定性尿蛋白、潜血结果等于阳性值时标异常3文本影像描述不下标记由医生人工判读0public class ResultJudger { /** * param value 录入结果数值型存字符串入库前已校验 * param refMin 参考下限可空 * param refMax 参考上限可空 * return 0 正常 / 1 偏高 / 2 偏低 */ public static int judgeNumeric(String value, BigDecimal refMin, BigDecimal refMax) { if (StringUtils.isBlank(value) || !value.matches(-?\\d(\\.\\d)?)) { return 0; // 非数值文本不参与判定交医生人工确认 } BigDecimal v new BigDecimal(value); if (refMax ! null v.compareTo(refMax) 0) { return 1; } if (refMin ! null v.compareTo(refMin) 0) { return 2; } return 0; } }注意compareTo而不是equalsnew BigDecimal(5.0).equals(new BigDecimal(5.00))返回 false直接比较会在小数位不同的场景下误判。单侧区间靠refMin null天然支持不需要额外枚举的类型字段。4.2 报告详情分页查询的 SQL 与索引报告查询有两条常用路径受检者按预约号看自己的报告医生按日期和审核状态批量看。前者命中appointment_id后者命中create_time audit_status两个索引都要建别只靠主键。-- 报告详情一次拉出头信息和全部明细避免 N1 SELECT r.report_no, r.audit_status, r.summary, r.doctor_advice, d.item_id, i.item_name, i.unit, i.category, d.result_value, d.ref_min, d.ref_max, d.abnormal_flag FROM t_report r JOIN t_report_detail d ON d.report_id r.id JOIN t_check_item i ON i.id d.item_id WHERE r.appointment_id #{appointmentId} ORDER BY i.category, d.id; -- 医生工作台分页先查主表再批量查明细避免 JOIN 后分页越界 SELECT id, report_no, appointment_id, audit_status, create_time FROM t_report WHERE audit_status #{status} AND create_time #{begin} AND create_time #{end} ORDER BY create_time DESC LIMIT #{offset}, #{size};第一条 SQL 用 JOIN 一次取全明细量在 50 到 200 条之间走idx_report_id足够。第二条不要用「JOIN 明细再 LIMIT」那样分页数是按明细行算的一页 10 份报告会被拆成半份。正确做法是主表分页拿到 10 个report_id再WHERE report_id IN (...)批查明细两次查询搞定。这也是 MyBatis-Plus 里Page查主表加listByIds补明细的模式。4.3 异常指标汇总与报告小结生成报告首页那句「血糖偏高、建议内分泌科复查」不该由医生逐条翻着写。汇总接口把abnormal_flag ! 0的明细按category分组拼成一句话摘要医生在此基础上增删。规则可以做得更细连续两年同一指标偏高时给出「持续偏高」提示这需要按user_id item_id拉历史报告明细。public String buildSummary(Long reportId) { ListReportDetailVO abnormal detailMapper.listAbnormal(reportId); if (abnormal.isEmpty()) { return 本次体检各项指标未见明显异常建议保持规律作息。; } MapString, ListReportDetailVO byCategory abnormal.stream() .collect(Collectors.groupingBy(ReportDetailVO::getCategory)); StringBuilder sb new StringBuilder(); byCategory.forEach((cat, items) - { sb.append(CategoryEnum.desc(cat)).append() .append(items.stream().map(ReportDetailVO::getItemName) .collect(Collectors.joining(、))) .append(异常); }); return sb.append(建议携带本报告至相应科室复查。).toString(); }这段摘要只是草稿必须经过医生端确认才能置为「已审核」。审核状态就是t_report.audit_status的流转0 待录入、1 待审核、2 已审核、3 已作废任何一次状态变更都建议单独记一张操作日志表医疗数据的可追溯性比性能更重要。5. Redis 缓存与并发预约体检系统的两个进阶调优点5.1 套餐详情缓存与缓存穿透处理套餐详情是典型的读多写少数据早高峰被反复查。用 Cache Aside 模式先查 Redis未命中查库后回填编辑套餐时主动删缓存而不是改缓存。public PackageVO getPackage(Long packageId) { String key exam:pkg: packageId; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { // 空串是防穿透的占位标记直接返回 null 不再查库 return .equals(cached) ? null : JSON.parseObject(cached, PackageVO.class); } PackageVO vo loadFromDb(packageId); // 内部含套餐 项目列表 if (vo null) { redisTemplate.opsForValue().set(key, , 60, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue().set(key, JSON.toJSONString(vo), 30, TimeUnit.MINUTES); return vo; }两个细节空值占位过期时间必须比正常缓存短60 秒足够挡住恶意刷不存在 ID 的请求又不至于让新上架套餐长时间不可见缓存的是整个PackageVO而不是分片字段改套餐时统一delete(key)避免局部更新导致项目和套餐对不上。给缓存加随机过期偏移比如 30 分钟 ± 3 分钟能削掉一批同时过期的雪崩。5.2 号源扣减的并发控制条件更新与 Redis 预扣怎么选后台预约和现场窗口同时开时号源扣减是唯一的强一致点。两种做法各有适用方案实现优点代价数据库条件更新UPDATE ... WHERE booked capacity强一致无需额外组件行锁竞争峰值 TPS 有限Redis 预扣DECR号源 key成功后异步落库吞吐高需补偿机制缓存与库可能不一致单院区体检科的量级早高峰几十到一两百并发用条件更新就够别过度设计。-- 条件更新方案受影响行数为 0 即视为抢号失败 UPDATE t_schedule SET booked booked 1 WHERE id #{scheduleId} AND booked capacity; -- 把容量判断写进 WHERE靠行锁串行化// Redis 预扣方案Lua 保证判断与扣减原子避免竞态 String lua local left redis.call(GET, KEYS[1]) if left and tonumber(left) 0 then return redis.call(DECR, KEYS[1]) end return -1; Long left redisTemplate.execute( new DefaultRedisScript(lua, Long.class), Collections.singletonList(exam:schedule: scheduleId)); if (left null || left 0) { throw new BizException(该时段号源已约满); }Lua 脚本里GET和DECR在同一段原子执行中间不会插进其他请求这比客户端「先 GET 再 DECR」可靠得多。选 Redis 预扣就必须补上对账任务预约落库失败或用户取消时把INCR还回去每天凌晨用数据库booked和缓存值做一次比对修正。总结成一条经验体检系统里号源、报告这类不能错的数据以库为准套餐详情这种读多写少、短时不一致可接受的才交给缓存。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门