☰
SpringBoot医院陪诊系统毕设实战:状态机、乐观锁与自动装配
2026/10/30 5:48:17 网站建设 项目流程

毕业设计选了“医院陪诊系统”这个题目,说实话是个很聪明的选择。它不冷门,但也没有烂大街到和图书管理系统一样毫无区分度;业务上贴近现实,有实际的社会价值,逻辑条理清晰,技术栈又有足够的发挥空间。更重要的是,SpringBoot全家桶满足当前企业级开发的主流需求,用它来落地一个完整项目,无论是答辩展示还是写进简历,都有非常具体的可讲内容。

这篇文章我打算直接把这个项目从头到尾拆一遍,从需求、技术选型到数据库设计、核心代码实现,再到答辩和排坑。如果你是计算机相关专业,正在纠结选题或者已经选了类似题目,这篇内容能帮你省掉大量的试错时间。

1. 陪诊系统到底要解决什么问题

1.1 为什么选这个题目,而不是随大流做电商或图书管理

每年的毕业设计里,SSM图书管理、SpringBoot商城几乎是模板化的存在,导师看了想睡觉,答辩时也很难讲出花来。医院陪诊系统不一样。首先它的业务模型天然带“预约—派单—服务—结算—评价”的闭环,跟外卖、网约车的核心业务类似,却又是医疗健康领域,有社会价值感;其次,这类系统的数据关系和状态流转比普通CRUD复杂一点点,但又不至于复杂到毕设阶段做不出来的程度,属于“跳一跳够得着”的难度区间。

从用户痛点来说,现在很多城市的大医院流程繁杂,年轻人要上班没法陪父母去医院,异地就医的人对当地医院不熟,孕妇、行动不便者、独居老人去医院做检查确实需要专业陪同。陪诊师或者陪诊平台在一二线城市已经大量出现,说明这个需求是真实的。你做一个线上平台来连接患者和陪诊员,是有落地意义的。毕设PPT和答辩词里,你只要把这个痛点讲清楚,“为什么做”这一块就很扎实。

1.2 系统里都涉及哪些角色和业务动作

我明确把系统分成了三类角色:患者/家属(下单方)、陪诊员(服务方)、平台管理员(管理方)。还有一个隐含角色是医院本身,但毕设一般不跟真实医院的号源系统对接,能模拟接入流程就算完整。

核心业务动线是这样的:

  • 用户注册登录后,选择陪诊类型(比如“取报告”“代跑腿”“全程陪同就诊”“异地陪同”),填写就诊医院、科室、期望时间和服务备注,提交订单。
  • 管理员在后台审核订单,确认信息有效后发布订单,或者系统自动进入待接单池。
  • 陪诊员在陪诊端看到可接单列表,选择合适的订单接单。
  • 接单成功后,用户能看到陪诊员的基本信息和联系方式,双方在线下按约定时间和医院地点对接。
  • 服务完成后,陪诊员点击“完成服务”,用户在订单详情页进行评价和打分。
  • 管理员随时可以查看订单统计、用户数据、维权退款等。

这里面最关键的是订单的状态流转,这个在第三章我会详细讲。你只需要先记住一个结论:状态机设计是这个系统能不能拿高分的关键,很多人就是死在做成了纯增删改查上。

1.3 为什么技术框架选择了SpringBoot

我选SpringBoot不是因为名字顺口,真正原因是它解决了SSM时代大量的配置成本和部署成本。

传统的SSM整合需要配置一大堆XML文件:数据源配置、事务管理器配置、映射器扫描配置、Druid连接池配置……一个不小心就启动报错,经常浪费一下午排查一个bean注入失败的问题。SpringBoot的核心思想是“约定优于配置,开箱即用”,你只要引入一个spring-boot-starter-web,再写一个启动类,就能跑起来一个Web项目;引入spring-boot-starter-data-jdbc或者MyBatis-Plus,数据库访问的问题就迎刃而解。

对毕业设计而言,SpringBoot还有一个特别重要的优势:独立打jar包,内置Tomcat,部署极其方便。你导师要在你电脑上跑你项目,或者你想部署到云服务器上演示,一行java -jar就完成了。还有“SpringBoot自动装配原理”这个点本身就是Java后端的经典面试题,做毕设的时候顺带把@SpringBootApplication、@EnableAutoConfiguration、spring.factories或AutoConfiguration.imports这些机制搞透,答辩被问到的概率是百分之百,你答好了就是项目一个很大的加分项。

2. 技术选型和项目搭建:把骨架立起来

2.1 版本选择:到底用Spring Boot 2.x还是3.x

这个选择会影响你整个项目的依赖写法。我当时用的是Spring Boot 2.7.x,搭配JDK 1.8。核心原因很现实:学校的机器、机房环境、大部分教程和网上demo都是基于JDK 8 + Spring Boot 2.x写的,遇到问题你能在CSDN、博客园、Stack Overflow上找到大量现成的答案。MyBatis-Plus 3.5.x也和Spring Boot 2.x兼容得最好。

而且有一点需要注意,Spring Boot 3.x基于Jakarta EE,要求JDK 17及以上,切换后很多旧版依赖的坐标要从javax.*改成jakarta.*,如果导师或学校环境还没跟上,容易在部署环节卡住。我并不是说3.x不好,它是趋势,但在“稳妥完成毕设”这个优先级下,我建议你选2.7.x。这不是技术倒退,是用最成熟稳定的方案把事做完。

选JDK 8的另一个理由是和MyBatis-Plus的代码生成器兼容性。记得当时我用MyBatis-Plus Generator生成实体类、Mapper、Service的代码时,直接省掉了一大堆手写CRUD的体力活。Spring Boot 2.x + MyBatis-Plus 3.4.2这个组合,就是学生党最省心的“黄金搭档”。

2.2 工程结构:不要一个包走天下

说实话,毕设项目最容易犯的毛病不是功能不够,而是代码全写在一个类或者一个包下面。你如果去看那些优秀毕业设计的源码,它们的工程结构基本都是分层清楚的。我用了很常规的单模块Maven结构,但包名划分让人一眼就能看出是标准的三层架构加模块化思想:

com.hospital.accompany ├── config // 配置类:跨域、拦截器、MyBatisPlus分页插件、文件上传配置 ├── controller // 控制层:接收请求,参数校验,响应封装 ├── service // 业务层:核心业务逻辑 │ └── impl ├── mapper // 数据访问层:MyBatis-Plus的Mapper接口 ├── entity // 实体类 ├── dto // 前端传入的数据传输对象:比如下单DTO ├── vo // 返回给前端的数据视图对象:比如订单详情VO ├── common // 统一返回结果、异常处理、常量定义 ├── utils // 工具类:JWT工具、日期工具等 └── AcpApplication.java // 启动类

看到这个结构,导师就会觉得你有工程意识和分层意识。Controller只干转发参数的事;Service里写真正的业务逻辑,比如校验陪诊员接单资格、扣减排班余量;Mapper只做数据交互。哪个环境出了问题,第一时间能定位到是哪一层。这不是花架子,真实开发中这也是团队协作的基本要求。

2.3 yml配置和自定义配置

配置文件我用的是application.yml而不是application.properties。YAML文件能通过缩进表达层级关系,数据源、Redis、JWT密钥等配置写在里面非常清晰直观。

我当时把端口设置成了8088,避免和本机其他项目冲突。数据库用accompany_db,数据源使用Alibaba Druid连接池,配置了初始化连接数、最大连接数和慢SQL监控,让项目看起来有生产水平。JWT的自定义配置写在一个单独的JwtProperties类里,通过@ConfigurationProperties(prefix = "jwt")读取yml里的jwt.secret和jwt.expire-time。

这一点值得单独拿出来说,因为热词里有一条“springboot yml密文”,这其实是生产环境的安全需求。如果答辩时你想展示自己的工程水平,可以提一句:配置文件中如果直接写数据库密码、JWT密钥,存在泄露风险,实际应用里可以用jasypt-spring-boot-starter对配置值加密,yml里只放密文,运行解密。把它作为一个“可优化/已实现”的亮点写进项目文档,专业感就会出来很多。

核心配置大致长这样:

server: port: 8088 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/accompany_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: xxxx type: com.alibaba.druid.pool.DruidDataSource druid: initial-size: 5 max-active: 20 min-idle: 5 max-wait: 60000 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: your-secret-key-please-change expire-time: 604800000

map-underscore-to-camel-case: true可以自动将数据库字段的created_time映射为Java属性的createdTime,少写很多@TableField注解。

另外,serverTimezone=Asia/Shanghai必须加,这能防止MySQL时区原因导致的时间差八小时问题。地址栏里的useSSL=false是因为本地开发不会有SSL证书,如果你不关掉,控制台会疯狂报警告。

2.4 自动装配原理:答辩的必考点

既然热词里频繁出现“SpringBoot自动装配原理”,我强烈建议你在毕设准备阶段就把这块啃透。面试和答辩问到SpringBoot,问的核心就是starter机制、自动配置原理、条件注解。

一步步说。SpringBoot的自动装配核心是@SpringBootApplication,它是一个组合注解,里面包含了@SpringBootConfiguration、@EnableAutoConfiguration和@ComponentScan。

@EnableAutoConfiguration会通过AutoConfigurationImportSelector扫描所有jar包里的META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件(Spring Boot 2.7及之后)或者META-INF/spring.factories文件(旧版本),把里面列出的配置类加载出来。那是不是所有配置类都会直接生效?并不是,大部分配置类上都标了@ConditionalOnClass、@ConditionalOnBean、@ConditionalOnProperty之类的条件注解。

举一个特别直观的例子:如果你引入了spring-boot-starter-web,classpath下有了Servlet类和DispatcherServlet类,DispatcherServletAutoConfiguration才会生效,Spring Boot才会帮你自动配置Spring MVC相关的Bean;如果你没有引入这个starter,那么这个配置类就会被跳过。这就是“按需自动配置”的真正含义。

答辩的时候你只要把这个链路说清楚:@SpringBootApplication→@EnableAutoConfiguration→ 扫描自动配置候选 → 条件注解筛选 → 注册灵活可覆盖的Bean。不用洋洋洒洒讲满十分钟,这一段说完,导师就知道你是真的搞懂了框架的核心机制,而不是只会写接口。

3. 数据库设计和状态机:系统能不能立住的关键

3.1 核心表:不只围绕用户和订单

一般CRUD型毕设的数据表可能就是用户表、商品表、订单表,关系清晰但没深度。陪诊系统我设计了六个核心表,一方面业务上确实需要,另一方面也让E-R图的丰富度更好看:

  • user:平台用户表,包含患者/家属账号,支持一个家属给多个老人下单。
  • accompanier:陪诊员信息表,包含姓名、年龄、性别、服务次数、评分、身份证号、健康证编号、擅长科室、审核状态。
  • accompany_order:核心订单表,包含用户ID、陪诊员ID、订单状态、陪诊类型、医院名称、医院地址、就诊科室、预约时间、备注、服务时长、费用明细等。
  • order_evaluation:订单评价表,包含评分、内容、匿名标识、服务快照信息。
  • complaint:用户投诉/退款表,记录订单ID、投诉原因、处理状态、处理结果。
  • business_type:陪诊业务类型表,比如“半天陪诊”“全天陪诊”“单次跑腿”“异地陪诊”,价格不同,结算逻辑不同。

有朋友会问要不要做支付表。我的建议是:你可以在演示视频里加入模拟支付流程,但真的对接微信支付/支付宝支付,需要企业资质和商业申请,学生个人做不了。更合理的做法是把订单的“支付状态”设计成字段,文档里写清楚真实场景如何对接支付平台接口,演示的时候用模拟支付网关。这样既有深度又不卡脖子。

3.2 订单表的核心字段设计

订单表是整个系统的数据心脏,字段不能乱写。我当时的设计大概是这样,可以直接参考:

CREATE TABLE `accompany_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号,展示用', `user_id` bigint(20) NOT NULL COMMENT '下单用户ID', `accompanier_id` bigint(20) DEFAULT NULL COMMENT '接单陪诊员ID,未接单时为NULL', `business_type_id` bigint(20) NOT NULL COMMENT '陪诊业务类型ID', `hospital_name` varchar(100) NOT NULL COMMENT '医院名称', `hospital_address` varchar(255) DEFAULT NULL COMMENT '医院详细地址', `department` varchar(50) DEFAULT NULL COMMENT '就诊科室', `visit_time` datetime NOT NULL COMMENT '预约服务时间', `service_duration` int DEFAULT 4 COMMENT '预计服务时长(小时)', `remark` varchar(500) DEFAULT NULL COMMENT '用户备注', `service_fee` decimal(10,2) NOT NULL COMMENT '服务费用', `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '订单状态:0待接单 1已接单 2服务中 3待支付 4已完成 5已取消 6退款中 7已退款', `cancel_reason` varchar(255) DEFAULT NULL COMMENT '取消原因', `cancel_time` datetime DEFAULT NULL COMMENT '取消时间', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` tinyint(1) DEFAULT 0 COMMENT '逻辑删除标记', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_accompanier_id` (`accompanier_id`), KEY `idx_status` (`status`), KEY `idx_visit_time` (`visit_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='陪诊服务订单表';

需要解释的几个点:

order_no和自增主键id是两回事。自增主键对内使用,订单号对外展示。我生成订单号的规则是“yyyyMMddHHmmss + 6位随机数”,再在数据库层面加唯一索引兜底,防止极端并发下重复。

accompanier_id在订单刚创建时为NULL,只有陪诊员接单成功后才写入。这种设计在查询“某个陪诊员已接订单”和“可抢订单列表”时非常高效。

deleted字段配合MyBatis-Plus的逻辑删除功能,达到“软删除”效果。用户取消订单时不会物理删除这条记录,所有数据都留着,这相当重要——导师问“你的数据审计怎么做”,你就不会哑口无言。

另外,线上做这类系统时,订单状态字段往往会用字符串或者枚举类来定义,而不是散落的魔法数字。我在Java代码里定义了一个枚举类:

public enum OrderStatusEnum { PENDING(0, "待接单"), ACCEPTED(1, "已接单"), IN_SERVICE(2, "服务中"), WAIT_PAY(3, "待支付"), FINISHED(4, "已完成"), CANCELED(5, "已取消"), REFUNDING(6, "退款中"), REFUNDED(7, "已退款"); private final Integer code; private final String desc; // 构造方法、getter... }

所有涉及订单状态的判断,统一用这个枚举。这样不会出现有人写1、有人写"1"、有人写"ACCEPTED"导致比较不一致的情况。

3.3 订单状态机:逻辑不混乱的保证

订单状态千万不要写成“随便在service层if-else改一个数字”,我吃过这个亏,后来重构后代码逻辑就清晰了。

我把状态流转强制收敛在一个OrderStateService或者OrderStatusTransition工具里。合法迁移路径是这样的:

  • 待接单PENDING可以到 已接单ACCEPTED、已取消CANCELED(用户取消或超时未接单自动取消)
  • 已接单ACCEPTED可以到 服务中IN_SERVICE(陪诊员点击开始服务)、已取消CANCELED(服务开始前双方协商取消)
  • 服务中IN_SERVICE可以到 待支付WAIT_PAY(陪诊员确认完成)
  • 待支付WAIT_PAY可以到 已完成FINISHED(用户确认支付/模拟支付),或者 退款中REFUNDING(用户发起退款)
  • 退款中REFUNDING可以到 已退款REFUNDED(管理员同意退款)、已完成FINISHED(管理员驳回退款)

非法迁移路径直接抛出业务异常。比如订单还在“待接单”,用户想操作“确认完成”,那就应该被拦截。

在代码层面很容易实现,举个简化例子:

@Component public class OrderStatusHandler { private static final Map<OrderStatusEnum, Set<OrderStatusEnum>> TRANSITIONS = new EnumMap<>(OrderStatusEnum.class); static { TRANSITIONS.put(OrderStatusEnum.PENDING, EnumSet.of(OrderStatusEnum.ACCEPTED, OrderStatusEnum.CANCELED)); TRANSITIONS.put(OrderStatusEnum.ACCEPTED, EnumSet.of(OrderStatusEnum.IN_SERVICE, OrderStatusEnum.CANCELED)); TRANSITIONS.put(OrderStatusEnum.IN_SERVICE, EnumSet.of(OrderStatusEnum.WAIT_PAY)); TRANSITIONS.put(OrderStatusEnum.WAIT_PAY, EnumSet.of(OrderStatusEnum.FINISHED, OrderStatusEnum.REFUNDING)); TRANSITIONS.put(OrderStatusEnum.REFUNDING, EnumSet.of(OrderStatusEnum.REFUNDED, OrderStatusEnum.FINISHED)); } public void acquire(OrderStatusEnum currentStatus, OrderStatusEnum targetStatus) { Set<OrderStatusEnum> allowed = TRANSITIONS.get(currentStatus); if (allowed == null || !allowed.contains(targetStatus)) { throw new BizException("非法订单状态流转:" + currentStatus + " -> " + targetStatus); } } }

封装完之后,Service层都是这样的写法:

orderStatusHandler.acquire(order.getStatus(), OrderStatusEnum.ACCEPTED); order.setStatus(OrderStatusEnum.ACCEPTED.getCode()); orderService.updateById(order);

代码可读性极大提升,而且很自然地能够解释“为什么我要设计状态机”这个答辩问题。它避免了状态到处乱改、改错之后很难排查的灾难现场。

4. 核心功能实现:从下单、接单到服务完成的完整链路

4.1 用户下单:校验与订单生成

下单是用户端第一个核心动作。用户在页面上选择陪诊业务类型、填写医院、科室和预约时间,点击提交之后,后端要做的事情其实很多。

第一步是基础参数校验:陪诊业务类型是否有效、预约时间不能早于当前时间、医院名称不能为空、服务时长必须大于0。第二步是校验用户本身的状态:账号是否冻结、是否有未完成订单占用资金,如果用户上一单还在待支付,不限制的话体验上会有刷单风险。第三步才是组装订单实体、生成订单号、计算服务费用。

服务费用的计算逻辑我放在business_type表里,每个类型有基础价和计时价格。用户选择“半天陪诊4小时”时,前端如果传了个自定义价格,后端绝不能直接信任,必须要根据业务类型ID重新算一遍。这是一条铁律:前端传入的数据一律不可信,所有关键数据必须在后端重新计算并校验。

保存订单时还有个小技巧,用MyBatis-Plus的字段自动填充功能:

@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", Date.class, new Date()); this.strictInsertFill(metaObject, "updateTime", Date.class, new Date()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date()); } }

这样插入和更新时,create_time和update_time会自动填充,Service代码里不用反复手写setCreateTime(new Date()),既统一又省事。

为防止用户狂点“提交订单”生成大量重复垃圾单,我还在前端做了按钮节流,后端Redis里加了简单的防重校验,同一个用户在10秒内只能创建一单。毕设能做到这个细节,已经比绝大多数人强了。

4.2 陪诊员接单:并发场景下的“抢单”怎么办

接单是这个系统里最像真实生产环境的功能,因为碰上了并发问题。

一个订单发布后,可能有好几个陪诊员同时点击“接单”。如果我写成这样:

Order order = orderService.getById(orderId); if (order.getStatus() == OrderStatusEnum.PENDING.getCode()) { order.setAccompanierId(userId); order.setStatus(OrderStatusEnum.ACCEPTED.getCode()); orderService.updateById(order); }

这段代码在单用户下没有问题,但在并发情况下一查一改,两个陪诊员同时读到订单状态是PENDING,然后都执行了更新,后一个更新会把前一个的accompanier_id给覆盖掉,导致两个陪诊员都以为自己接单成功,实际上订单只有一个陪诊员ID,另一个人的接单是无效的,会造成非常严重的体验事故。

解决方案我用的是“乐观锁”。在订单表加一个版本字段version,更新条件带上版本号:

boolean success = orderMapper.updateOrderByVersion(orderId, accompanierId, status, version);

或者直接用MyBatis-Plus的乐观锁插件:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }

对应的实体类加上:

@Version private Integer version;

它的原理很简单,更新时自动在SQL里加上WHERE version = ?,更新成功后version加一。如果执行更新时发现版本号已经被别的线程改过了,当前更新影响行数为0,说明抢单失败。这样在并发情况下,只会有一个陪诊员的更新成功。

但需要注意,查询时要把version查出来再更新,不能用脱敏后的VO对象去更新,不然字段丢失会导致版本号拿不到。

如果项目里引入了Redis,还可以用setnx做简单的分布式锁,把订单ID作为锁key。但对毕业设计来说,乐观锁是成本最低、效果最明显的方案,答辩时也能把原理讲得很清楚。

4.3 超时未接单的订单怎么处理:定时任务实录

大部分订单会被及时接走,但总有一些边缘时间订单或者价格偏低的订单没有人接。如果一直停留在“待接单”状态,用户会觉得平台毫无反应,体验很差。所以项目里我加了一个自动处理机制:超过预定服务时间还没人接的订单,系统自动取消,并给用户发送站内信或短信模板通知。

具体实现就是SpringBoot的定时任务。在启动类加@EnableScheduling,然后写一个定时任务类:

@Component @Slf4j public class OrderTimeoutTask { @Resource private OrderService orderService; @Scheduled(cron = "0 */5 * * * ?") public void cancelTimeoutOrders() { List<Order> timeoutOrders = orderService.list( new LambdaQueryWrapper<Order>() .eq(Order::getStatus, OrderStatusEnum.PENDING.getCode()) .lt(Order::getVisitTime, new Date()) ); for (Order order : timeoutOrders) { order.setStatus(OrderStatusEnum.CANCELED.getCode()); order.setCancelReason("订单超时未接单,系统自动取消"); order.setCancelTime(new Date()); orderService.updateById(order); } } }

这段定时任务每5分钟跑一次,在演示的时候很实用,你不需要干等一笔订单超时,改一下数据库的visit_time,下一次轮询就会自动处理掉。

任务圈到这里,我还额外加了一个“每天凌晨统计昨日收益”的定时任务,把各陪诊员前一天的服务总时长、总收入、完成单量汇总到统计表里。这在答辩演示时一拉出来,瞬间就能展示出你对业务的理解不止于CRUD。

4.4 事务:一个操作涉及多张表,不能只改一半

接单成功之后,要做事情不止是更新订单表,还要更新陪诊员的今日接单数量、给用户发通知、记录一条操作日志。如果更新订单成功、后续写日志失败,订单和日志就出现了数据不一致。

解决办法很简单,在需要保证原子性的方法上加上@Transactional注解:

@Transactional(rollbackFor = Exception.class) public void acceptOrder(Long orderId) { // 1. 乐观锁更新订单 // 2. 更新陪诊员服务统计 // 3. 插入操作日志 }

rollbackFor = Exception.class一定要写明。默认情况下@Transactional只对RuntimeException回滚,对受检异常不会回滚,你如果只是throws Exception,事务是不会回滚的。这是事务使用中最容易被忽略的细节,所有Java应届生面试几乎必考。

还有一个细节是:不要随便在Controller层直接调用多表数据变更的业务方法,事务的边界应该控制在Service层。一个Controller只做参数接收和数据响应,不做任何与业务规则有关的判断,这是工程规范,也是事务正确的边界。

我遇到过一些同学的项目,一个Mapper方法里写十几行关联SQL,事务注解加到Controller方法上,这种做法很难维护,答辩时导师随便问一句“事务失效的情况有哪些”就答不上来。其实就是规范出了问题。

5. 权限控制、文件映射和部署,这些细节拉满的模块也别忽视

5.1 登录校验只用JWT够吗,要不要Spring Security

陪诊系统的角色有三种,权限要求不一样:普通用户能下单、能查看自己的订单和评价;陪诊员只能接单、只能查看自己接的订单;管理员能查看所有数据、审核陪诊员入驻、处理投诉。

我第一版是用JWT加拦截器做的。用户登录成功后签发一个JWT,里面带上用户ID和角色标识,后面的每次请求都在Authorization头里带上这个Token,拦截器解析Token并校验角色权限。三个角色分别定义访问端点,用拦截器做路径匹配。

这个方案的好处是轻量、简单、容易讲清楚,也很方便在AOP或拦截器里打印日志做统一异常处理。如果你学有余力,也可以把Spring Security引入进来,配合JWT做过滤链配置。它确实更规范,但配置项多,学习成本高,在毕设里一旦没有完整掌握,反而容易把自己绕进去。我的建议是拦截器方案足够,把重点放在订单业务上,“资源守卫”的概念讲清楚就行。

权限拦截器核心逻辑大致是:

@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录、注册、获取验证码等白名单路径 String uri = request.getRequestURI(); if (isWhiteList(uri)) { return true; } String token = request.getHeader("Authorization"); if (!StringUtils.hasText(token)) { throw new BizException("未登录"); } // 解析token并存入ThreadLocal或request attribute LoginUser user = JwtUtils.parseToken(token); request.setAttribute("loginUser", user); return true; } }

配合WebMvcConfigurer注册拦截器,只拦截需要登录的路径,配置起来也就是十几行代码的事。

5.2 文件上传和静态资源映射:陪诊员资质证书怎么传

陪诊员入驻时我要求上传身份证照片、健康证照片、资格证扫描件。这就涉及文件上传和后续访问的问题。

上传文件本身很简单,用MultipartFile接收,保存到本地固定目录:

public String uploadFile(MultipartFile file) { String originalFilename = file.getOriginalFilename(); String ext = StringUtils.getFilenameExtension(originalFilename); String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext; Path path = Paths.get(uploadDir, fileName); Files.copy(file.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING); return "/files/" + fileName; }

关键是“怎么让浏览器能访问到这个文件”。SpringBoot默认只映射classpath:/static/下的静态资源,如果你把文件保存到了服务器的磁盘上,比如/home/data/upload/,就需要手动加资源映射。

我当时在WebMvcConfigurer的addResourceHandlers里配置:

@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + uploadDir + "/"); }

这样前端只需要访问http://localhost:8088/files/xxx.jpg就能看到上传的图片。这个点很小,但正好对应热词里那句“SpringBoot如何做资源映射”,属于实战中一定会遇到的坑。

有一点必须提醒:上传文件一定要做大小和类型校验。我用getContentType()和扩展名双重校验,只允许jpg/png/pdf,并且大小限制在5MB以内。如果不做校验,让用户随便传任意文件,就出现了任意文件上传的安全漏洞。这个在答辩时如果导师恰好问到,你答得好就是明显的加分项。

5.3 部署上线:IDEA、Maven、Docker

毕设很多人的卡点是“代码写完了,怎么给导师演示”。其实SpringBoot最强的就是部署容易,一条命令就能跑。

本地开发正常用IDEA启动。要打包给导师演示,直接在Maven面板执行package,在target目录下得到accompany-server-0.0.1.jar,服务器上执行:

java -jar accompany-server-0.0.1.jar

如果服务器上装了Docker,还能把它容器化。我当时的做法是写一个简单的Dockerfile:

FROM openjdk:8-jre WORKDIR /app COPY target/accompany-server-0.0.1.jar app.jar EXPOSE 8088 ENTRYPOINT ["java", "-jar", "app.jar"]

然后:

docker build -t accompany-server . docker run -d -p 8088:8088 -v /home/data/upload:/app/upload --name accompany accompany-server

把宿主机的上传目录挂载到容器里,重启容器之后上传的图片不会丢。如果你没有服务器也没关系,本地打包运行就够了,但“部署方式和环境隔离”这个理念要讲出来。

如果遇到端口被占用:

lsof -i :8088 kill -9 进程号

然后重新启动即可。

还有数据库的导入导出,我用的是Navicat的转储SQL文件功能,交付时把SQL脚本一起放进项目根目录的sql/文件夹里,导师在新电脑上一导入就能跑。这个交付习惯非常加分。

6. 毕设答辩高频问题与实战排坑记录

6.1 从实际项目里抽出来的高频面试题

答辩和真实面试时,围绕SpringBoot问的问题其实很固定。我把这个项目里能引申出的高频问题整理成了一张速查表,建议你在答辩前对着这张表把答案背熟:

高频问题这个项目中怎么答
SpringBoot自动装配原理是什么从@EnableAutoConfiguration讲起,提到扫描AutoConfiguration.imports和条件注解,结合项目里的starter依赖说明
为什么用SpringBoot而不是SSM简化配置、内嵌服务器、自动装配机制、生态丰富,举个例子说“Druid数据源只加注解和配置项即可”
你的项目怎么做权限控制JWT + 拦截器,按角色校验路径,将登录用户信息放入Request作用域
数据库并发问题怎么解决以陪诊员抢单为例,现场分析“一查一改”下的并发覆盖问题,用乐观锁重新执行
项目里事务怎么控制@Transactional,展示Service层方法,说明事务边界在Service、rollbackFor配置的原因
你的日志怎么做统一的AOP切面打印Controller入参出参,配合@RestControllerAdvice做全局异常处理,统一返回Result<T>结构
数据库怎么优化查询订单表按status、visit_time建索引,分页查询用MyBatis-Plus分页插件,列出慢SQL第一版和第二版的区别

还有压轴问题,比如“这个系统怎么对接真实医院系统”,你不需要真的对接,但要说清楚思路:标准方案是对接医院的统一号源池平台或者检查检验结果推送接口,涉及HL7 FHIR等医疗数据交换标准、接口鉴权和数据脱敏,毕设阶段用模拟接口代替。这么答,导师能看出你想过深层问题。

6.2 那些踩过的坑,每一道都流着血泪

我在做这个项目的时候,踩过不少坑。挑几个最典型的说:

第一个坑是MyBatis-Plus逻辑删除配置后,拿它去查询某个关联表时条件没自动加上,导致删除的数据也查出来了。排查半天才发现是@TableLogic只对实体类映射的查询自动生效,如果你自己手写了自定义SQL,就得手动拼deleted = 0。所以我的习惯是:自定义SQL一律显示补上逻辑删除条件,不要偷懒依赖插件。

第二个坑是JWT的密钥太短。最开始我用了一个只包含几个字符的字符串作为secret,结果运行时报WeakKeyException。后来才发现jjwt 0.9.x对HS256算法的密钥长度有最低要求,太短会直接抛异常。实际项目里我改用了一段32字节以上的随机字符串。

第三个坑是前端上传图片的跨域问题。前端部署在localhost:5173(Vite开发服务器),后端在localhost:8088,直接请求一定会被浏览器跨域策略拦截。我在后端的配置类加了跨域配置:

@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); } }

需要注意,allowedOriginPatterns("*")配合allowCredentials(true)时必须加allowedMethods和allowedHeaders,否则预检请求会被拒绝。这个坑如果没有前排的同事提醒,真的不好找。

第四个坑是页面里文件上传的路径写死了C:/Users/xxx/upload/,换一台电脑跑就404。后来我改成在配置文件里定义upload.dir,通过@Value("${upload.dir}")读取,并在Docker部署时挂载宿主目录,彻底解决了路径漂移问题。任何时候都不要在代码里写死绝对路径。

6.3 让项目从“能跑”到“亮点突出”的小技巧

最后分享几个让毕设项目观感提升的小技巧。

第一,全局统一返回结构。我的所有接口都返回Result<T>,里面是code、message、data三个字段。即便某个接口出错了,返回JSON的结构也是固定的,前端处理起来非常舒服。这对应了企业开发中非常统一的规范。

第二,统一异常处理。我在@RestControllerAdvice里处理了业务异常BizException、参数校验异常MethodArgumentNotValidException和未知异常,这样任何Controller都不用写try-catch,异常处理逻辑全部沉淀在一个类里。

第三,写一份像样的README.md。放到项目根目录,里面写清功能模块、技术栈、运行步骤、账号说明、默认密码。导师要跑你的项目时第一件事就是看README,写得好不好直接影响他对你的第一印象。

第四,演示数据一定要准备充分。数据库里预置5个陪诊员、20个用户、30条不同状态的订单,覆盖从待接单到已完成的每一个状态。演示的时候直接进入状态,不用现场新建数据,观感好太多。

最后再分享一点实际体会

做完这个陪诊系统,最大的感受是:真正让你和别人拉开差距的,从来不是用了多炫酷的框架和中间件,而是你对业务逻辑的洞察和对工程细节的把控。SpringBoot只是工具,它帮你省去了繁琐的环境搭建,让你把精力放在真正值得思考的地方——“这个订单状态该怎么流转才合理”“抢单并发怎么保证数据一致”“文件上传后的访问路径怎么管理”。把这些问题一个个想清楚、写出来,你的毕设就能甩开一大片同组同学。

如果你正在写类似的系统,建议不要急着写代码,先花两天时间把状态机、角色权限、核心表关系画出来。数据结构设计对了,后面的编码会顺利非常多。如果卡在某个具体问题上,欢迎随时在评论区聊聊,我看到了会尽量回复。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询