前两天帮家里老人跑了一趟医院,挂号在二楼、影像检查在一楼、取药在负一层,半天下来两万步起步,中间还因为找不到科室问了三个人。这不是个例,空巢老人、异地就医、孕产妇独自跑医院的场景太多了,陪诊服务就是在这个需求上长出来的。但市面上的陪诊大多还是在微信群里口头约,价格靠聊,服务过程靠自觉,出了问题连个记录都没有。
所以我做这个 Spring Boot 陪诊服务平台时,从一开始就没打算只做一个“预约挂号”页面,而是要把线下这种分散、不透明的陪诊行为,变成一条完整、可追踪的线上服务闭环。这套系统覆盖用户端、陪诊员端、管理后台三端,核心功能包括实名认证、在线预约、智能匹配、订单跟踪、支付退款和评价体系。如果你正准备做 Java 方向的项目,或者接了医疗健康类的开发,这篇博客讲的建模思路和实现细节可以直接拿过去用。
1. 陪诊平台要解决的真实业务问题:从线下乱象到线上撮合
1.1 “三个角色,一条链路”才是完整需求全景
很多人一听“陪诊服务平台”,第一反应是“做个约陪诊员的 App”,这个想法容易把项目做偏。真实业务里有三个角色:患者或家属、陪诊员、平台运营人员,三者缺一不可。需求调研阶段把这几个角色涉及的功能列出来,项目边界才清晰。
用户端要能注册登录、实名认证、发布陪诊需求(选城市、医院、科室、时间)、查看匹配到的陪诊员并确认下单、在线支付、跟踪订单状态、服务完成后评价。陪诊员端要能申请入驻、通过实名审核、按城市和时间段筛选可接订单、接单后查看服务详情、发起服务开始和结束、查看接单记录和收入。管理后台要负责用户和陪诊员的审核、订单查询、异常订单处理、投诉仲裁、退款审核,以及运营数据报表的统计。
我最初只设计了用户端和陪诊员端的接口,做到管理后台时发现很多订单状态字段根本不够用,只能回头改表。这是第一个教训:三端的功能列表必须在设计阶段一次性过一遍,哪怕后端的接口可以分批实现,字段和状态也要提前定好。
1.2 线下陪诊的痛点如何变成平台规则
线下陪诊市场长期有几个没人管的问题:定价随心情,陪诊员的真实身份没法核验,服务做到什么程度没有记录,出现纠纷也拿不出证据。互联网平台本质上解决的,就是让每个环节都有据可查。价格不透明,就在下单时锁定金额,服务过程中不允许额外加价;身份没保障,就在入驻时做实名认证加人工审核;服务不可控,就用订单状态机把每个节点记录下来,什么时候开始服务、什么时候结束,时间线清清楚楚;日常沟通和关键操作全部在平台内留痕,投诉处理不再是一笔糊涂账。
这些规则落到系统里,每一块都对应具体的表结构和接口逻辑。比如“下单时锁定金额”,对应订单表里的 amount 字段在支付成功后不可变更;“服务过程可追踪”,对应订单日志表里每一条状态变更记录。把线下痛点转化成线上规则,不是靠产品经理讲一堆大道理,而是靠字段、约束、日志一条条顶住。
1.3 最容易被忽略的异常场景:取消、改期与退款
正常流程人人都会设计,但订单系统的复杂程度几乎全在异常分支里。我梳理过陪诊订单至少得考虑这些情况:用户下单后临时取消、陪诊员接单后用户想改期、陪诊员迟到或临时来不了、支付成功但订单状态没更新、退款需要平台审核、陪诊员服务到一半用户不认可。
每个异常分支都意味着订单状态多一个节点、日志表多一类操作记录,这个环节不提前想清楚,后续写代码就会反复打补丁。实际项目里我定的规则是:下单后 15 分钟内未支付自动取消;服务开始前 2 小时用户可免费取消;接单后取消需要客服介入确认退款金额;退款一律原路返回。这些规则全写进状态机和定时任务里,而不是靠人工在后台手动改状态。
2. Spring Boot + Vue3 的技术选型逻辑:为什么不是别的框架
2.1 后端框架为什么锁定 Spring Boot
Java 服务端开发这么多年,想快速起步、生态又完整的框架,Spring Boot 依然是首选。它把 Spring 家族里复杂的 XML 配置简化成“约定大于配置”,内嵌容器让本地开发从写一个 main 方法到能访问接口,通常只需要几分钟。对于陪诊平台这种典型业务系统,用户认证、数据访问、事务处理、定时任务、参数校验这些能力,Spring Boot 都有现成的 starter 可以组合使用。
我理解 Spring Boot 的自动配置,本质上就是靠一堆条件注解,在 classpath 上发现什么依赖就自动装配什么组件。项目一启动,数据源、Redis 客户端、JSON 序列化器就都准备好了,开发者只要关心自己的业务代码。更关键的是,网上可参考的资料非常多,遇到问题能搜到大量同类解决方案,这一点在工期紧张的时候尤其重要。
2.2 前端选 Vue3 而不是 JSP 和 Thymeleaf
这个项目的另一个特点是一套后端要支撑三种界面:用户端、陪诊员端、管理后台。如果沿用 JSP 或 Thymeleaf 做服务端渲染,三个界面要维护三套模板,前后端耦合得很紧,改一个小功能都要重新打包部署。用 Vue3 做前后端分离之后,三个前端工程可以独立开发、独立部署,共用同一套基于 Token 的认证机制和 REST 接口。
项目里我用的是 Vue3 + Vite + Element Plus + Pinia + Axios,用户端和陪诊员端后续要转小程序,后端接口不用怎么动,前端技术栈也可以平滑切换。接口层面统一走 RESTful 风格,返回结构统一用一个 Result 包装类,前端拿到之后只用判断 code 是否为 0,再也不用为每个接口单独写异常分支。
2.3 版本坑:Spring Boot 2.7 与 3.x 的选择
很多朋友问我的第一个问题就是“Spring Boot 用哪个版本”,这确实是个坑。Spring Boot 3.x 要求 JDK 17,并且把核心命名空间从 javax.* 迁移到了 jakarta.*,这意味着项目里所有依赖 Servlet、持久化 API 的地方都要跟着改。更麻烦的是,如果用了某些没有适配 3.x 的第三方组件,会花大量时间做兼容。
我给这个项目定的是 Spring Boot 2.7.18 + JDK 8 + MyBatis-Plus 3.5.3。选 2.7 不是因为它新,而是因为它成熟:资料多、坑少、第三方组件兼容性最好,尤其适合时间紧的毕设或外包项目。如果目标是一个长期维护的新平台,团队也已经熟悉 JDK 17,选 3.2 不反对,但要把迁移成本提前算进去。
| 对比点 | Spring Boot 2.7 | Spring Boot 3.2 |
|---|---|---|
| JDK 要求 | JDK 8/11 均可 | 最低 JDK 17 |
| 命名空间 | javax.* | jakarta.* |
| 第三方兼容性 | 大多数组件可直接用 | 需要确认适配版本 |
| 学习资料量 | 全网量最大 | 相对较少但增长快 |
| 适合场景 | 毕设、外包、短周期交付 | 全新系统、团队熟悉新 JDK |
3. 数据库建模:围绕订单生命周期而不是用户管理
3.1 五张核心表的职责划分
数据库设计是这个系统里最值得花时间的部分。很多项目习惯“用户表第一”,把用户字段堆得很庞大,结果订单逻辑一展开就发现根本接不上。我设计数据模型时以订单为核心,把数据分成了五块:用户表存患者和陪诊员的公共信息,包括账号、手机号、密码、角色标识、昵称、头像;陪诊员扩展表存服务专属信息,比如服务城市、接单次数、评分、服务标签、上下架状态;订单表承载整个业务流转;接单记录表用于追溯每个陪诊员的接单操作;支付流水表记录每一笔支付和退款。此外还有一张评价表,保存用户对陪诊员的服务评价,后续用于计算综合评分。
用户表里我单独放了 role 字段来区分普通用户和陪诊员,没有做成标准的 RBAC 五表模型,因为系统角色只有两种,用一张表加一个字段解决足够了。陪诊员扩展表通过 user_id 和用户表关联,两个表一对一的关系,字段分开而不是全塞在用户表里,是因为陪诊员的入驻审核状态、服务标签这些信息,用户角色根本用不上。
3.2 订单表核心字段为什么这么多
订单表是状态流转的主战场,字段设计直接影响后续功能开发成本。除了主键和用户ID、陪诊员ID,至少要有这些:平台生成的订单号、服务城市、医院名称、科室、期望开始时间、实际开始和结束时间、订单金额、支付状态、订单状态、取消原因、联系人姓名和电话、紧急联系人信息,以及创建时间和更新时间。
订单号不要用数据库自增主键,因为暴露给用户之后容易被猜出业务量。我用的方案是“yyyyMMddHHmmss + 当天自增序号 + 两位随机数”,自增序号通过 Redis 的 INCR 生成,保证全局唯一。订单状态字段我建议用 TINYINT 存编码,代码里用枚举统一转换,不要直接存中文,否则后续统计报表和条件查询都很别扭。
3.3 索引设计与常见查询的对应关系
陪诊平台的高频查询场景并不复杂,但并发量一旦起来,没有索引就会拖垮库。最典型的三个场景是:用户查自己的历史订单,SQL 落在 user_id + create_time;陪诊员在首页看某个城市、某个时间段内可接的订单,SQL 落在 city + order_status + service_start_time;管理后台按订单状态和时间筛选,SQL 落在 order_status + create_time。对应的索引就是给订单表加复合索引。
实际操作中,早期数据量小看不出差距,等测试数据灌到十万条之后,一个缺少索引的列表页可能从 50ms 涨到 5 秒,这个对比非常直观。另外要注意字段类型的一致性,查询条件的字段是 VARCHAR、表里却是 BIGINT 的时候,MySQL 会做隐式类型转换,索引直接失效。
订单表的关键建表语句大致如下:
CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', order_no VARCHAR(32) NOT NULL COMMENT '订单号', user_id BIGINT NOT NULL COMMENT '下单用户ID', companion_id BIGINT DEFAULT NULL COMMENT '陪诊员ID', city VARCHAR(50) NOT NULL COMMENT '服务城市', hospital_name VARCHAR(100) NOT NULL COMMENT '医院名称', department VARCHAR(50) DEFAULT NULL COMMENT '科室', service_start_time DATETIME NOT NULL COMMENT '期望开始时间', service_end_time DATETIME DEFAULT NULL COMMENT '实际结束时间', amount DECIMAL(10,2) NOT NULL COMMENT '订单金额', order_status TINYINT NOT NULL COMMENT '订单状态', pay_status TINYINT NOT NULL COMMENT '支付状态', cancel_reason VARCHAR(200) DEFAULT NULL COMMENT '取消原因', contact_name VARCHAR(50) NOT NULL COMMENT '联系人', contact_phone VARCHAR(20) NOT NULL COMMENT '联系电话', create_time DATETIME NOT NULL COMMENT '创建时间', update_time DATETIME NOT NULL COMMENT '更新时间', UNIQUE KEY uk_order_no (order_no), KEY idx_user_order (user_id, create_time), KEY idx_city_status_time (city, order_status, service_start_time), KEY idx_status_create (order_status, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='陪诊订单表';4. 订单撮合与状态流转:并发场景下的设计实现
4.1 接单并发:一条 UPDATE 语句解决 95% 的问题
一个订单同时推给多个陪诊员,谁先点“接单”谁获得服务机会,本质上就是一个并发抢购问题。最容易犯的错误是先用 SELECT 查出订单状态,判断是“待指派”再去执行 UPDATE。并发下两个请求可能同时读到“待指派”,然后双双更新成功,数据就错了。正确做法是直接把状态判断写进 UPDATE 的条件里,让数据库保证原子性。
int rows = orderMapper.updateCompanionAndStatus( orderId, companionId, OrderStatus.ACCEPTED.getValue(), OrderStatus.MATCHING.getValue() ); if (rows == 1) { // 接单成功,更新陪诊员状态,发送通知 } else { // 接单失败,说明订单已被抢 }这条 UPDATE 的语义是:只有订单还处于“待指派”状态时,才把陪诊员 ID 改成当前请求人、状态改成“已接单”,数据库的行锁会保证同一时间只有一个请求更新成功。我还给订单表加了乐观锁字段 version,用于其他修改场景的冲突控制,但接单这个核心操作,一条带条件的状态更新就是最简洁可靠的方案。
4.2 订单状态机:状态流转的合法性要集中管理
我把订单状态定义成一组枚举,而不是散落在代码里的魔法数字。从这个项目的真实状态看,用户视角的流程是:待支付、已支付、待指派、已接单、服务中、已完成;异常视角还有:已取消、退款中、已退款。状态之间并不是任意跳转的,比如“待支付”只能走到“已支付”或“已取消”,“已接单”不能直接跳到“已完成”,中间必须经过“服务中”。
这种合法性校验如果靠每个接口自己写 if,十有八九会漏掉边界。我用一个 Map 集中定义了允许流转的路径,所有订单状态修改都走同一个方法,非法流转直接抛业务异常。下面是简化版的流转规则表:
| 当前状态 | 允许流转到 |
|---|---|
| 待支付 | 已支付、已取消 |
| 已支付 | 待指派 |
| 待指派 | 已接单、已取消(退款) |
| 已接单 | 服务中、已取消(退款) |
| 服务中 | 已完成、退款中 |
| 已完成 | 已评价 |
| 退款中 | 已退款 |
4.3 支付回调:接口要做成“笨且稳”
接入第三方支付平台后,回调通知是最需要小心的地方。回调可能被重复调用,也可能因为网络原因延迟很久才送达。我在支付模块里最核心的设计思路是“先记流水,再改订单”。每次回调先根据平台返回的流水号查支付流水表,不存在就插入一条新流水,存在就说明是重复通知,直接忽略。
给支付流水表加唯一索引,能保证并发场景下不会重复处理,然后再更新订单的支付状态、触发后续的派单逻辑。整个过程里,订单更新和流水写入放进同一个事务,避免“钱到了但订单没更新”这种灾难。模拟支付阶段,我要在测试环境加一个简单的支付回调模拟接口,方便调试整个支付链路的时序。
4.4 超时未接单:定时任务兜底,别依赖用户耐心
订单支付成功之后如果没有陪诊员接单,不能一直挂在“待指派”状态上。我在项目里用 Spring 的 @Scheduled 写了一个定时任务,每五分钟扫描一次超过十分钟仍未接单的订单,把它们重新加入可接单列表,并给用户发送提示消息,允许用户选择继续等待或者取消退款。
这个方案实现简单,配合 Redis 缓存也能承受正常的业务量。等订单量真正上来之后,再考虑引入延迟队列或者分布式调度框架,现在这个量级完全没必要提前上重型中间件。定时任务务必加好分布式部署保护,比如通过 Redis 锁保证集群里只有一个节点执行,否则多实例部署时任务会重复执行。
5. 后端落地最容易翻车的三个地方:版本、配置、打包
5.1 javax 到 jakarta:升级 Spring Boot 3.x 前先做好三件事
如果聊到“Spring Boot 版本太高”这个话题,真正的痛点不是框架本身,而是迁移的连带成本。升到 3.x 之后,所有依赖 javax.servlet、javax.persistence 这类包的地方都要替换成 jakarta.*,导入语句成片地变红。网上最常见的报错包括 ClassNotFoundException: javax.servlet.Filter、NoSuchMethodError 找不到 javax.persistence 相关方法,一搜就是一堆,基本都是命名空间问题。
做 Spring Boot 3.x 迁移前,建议先确认三件事:第三方 starter 是否有 3.x 兼容版本、JDK 是否已经升到 17、接口文档组件是否从 Springfox 换成了 SpringDoc。做过一次迁移之后,我的结论是非必要不追新,追新要预留至少半天到一天的专项测试时间,把所有涉及文件上传、鉴权过滤器的入口都过一遍。
5.2 多环境配置与自定义自动配置
开发、测试、生产三套环境,Spring Boot 的多配置文件是标配。application.yml 放公共配置,application-dev.yml、application-prod.yml 分别覆盖数据源、Redis、端口等。数据库密码、第三方平台密钥这类敏感信息绝不能写死在仓库里,我用的是启动时从环境变量读取的方式,保证换环境只需要改配置参数,不用动代码。
再说说自定义自动配置,这个能力非常适合封装业务无关的通用组件。我给项目封了一个统一的订单号生成器、一个全局日志切面。核心原理在 Spring Boot 3 之前是 spring.factories 文件,3.x 之后改成 META-INF/spring 目录下的 AutoConfiguration.imports 文件,把自动配置类全限定名写进去,框架启动时就会自动加载。理解了这个机制,再看网上很多开源组件的用法就会很通透。
5.3 打包部署:内置 Tomcat 还是外部容器
Spring Boot 默认内嵌 Tomcat,直接 mvn package 产出可执行 jar,java -jar 就能跑,这是最省事的部署方式。那“Spring Boot 可以不内置 Tomcat 吗”?当然可以,从 pom 里排除 spring-boot-starter-tomcat 依赖,再让启动类继承 SpringBootServletInitializer,打成 war 包放到外部 Tomcat 容器里也能运行。
不过现在做传统 war 部署已经有点逆潮流了,我自己更推荐 Docker 部署。一条 docker-compose 把 jar、MySQL、Redis 编排起来,环境一致性比手动部署可靠得多。下面是一个简化版的编排示例:
services: mysql: image: mysql:8.0 environment: - MYSQL_ROOT_PASSWORD=123456 volumes: - mysql_data:/var/lib/mysql redis: image: redis:6.0 app: build: . ports: - "8080:8080" depends_on: - mysql - redis volumes: mysql_data:正式部署时记得把数据库密码改成配置文件挂载或者环境变量注入,不要直接写在编排文件里。
5.4 单模块还是多模块:别把毕设做成大泥球
项目结构上,如果是团队合作或者系统确实复杂,我建议按多模块拆分:common 模块放统一返回结果、异常处理、工具类;system 模块放用户和权限;business 模块放陪诊订单的完整业务;api 模块放对外接口层。多模块在构建和依赖管理上要花心思,但后期维护非常舒服。
如果这个项目是个人毕业设计或者短周期外包,单模块加上按包名分包完全够用,硬拆模块反而增加空转成本。我目前这个项目用的多模块骨架,业务核心没有刻意往模块边界上死磕,重点保证依赖方向清晰:common 不依赖任何业务模块,business 只依赖 common。最容易出现的坏味道是 common 模块反过来依赖 business,或者实体类里塞了 service 逻辑,遇到这种问题别急着加模块,先调整代码归属。
6. 实测反馈与功能迭代方向
6.1 一次并发接单测试暴露的问题
功能写完后我用压测工具模拟了 50 个陪诊员同时刷新“待指派订单列表”并抢同一单的场景。第一轮结果很狼狈:请求平均响应时间从平时的 50ms 飙升到 1.2 秒,数据库连接池被打满,部分请求直接超时。排查下来问题不在接单 UPDATE 语句本身,而在于列表接口每次刷新都触发全字段查询,50 个请求把数据库资源挤满了。
我把接口调用链打印出来,发现耗时几乎全在列表查询这条 SQL 上,执行计划显示 type=ALL 全表扫描,索引没命中。进一步核对发现是查询字段的类型和表结构不一致,MySQL 触发了隐式转换,导致复合索引完全失效。优化方案很简单:列表接口只返回订单概要字段,配合 Redis 缓存城市维度的可接单订单 ID 列表,抢单时再回表查详情。调整之后,同样场景下 50 个并发请求的服务端平均响应时间降到了 200ms 以内,核心接口的稳定性立刻不一样了。
6.2 从“能用”到“好用”的功能迭代清单
系统跑通之后,真正提升体验的迭代主要围绕三个方向。第一个是 WebSocket 实时推送,陪诊员接单成功、服务开始、状态变化都实时推给用户和陪诊员,不让前端反复刷新页面。实现上用 Spring 自带的 WebSocketHandler 就够,连接建立后按 userId 维护一个会话集合,状态变更时推送对应消息。
第二个是智能匹配。我曾经用开源中文分词组件做过一个小实验,用户输入“最近总头晕眼花,不知道挂什么科”这类描述,分词提取出“头晕”“眼花”之后,后台按科室标签库做匹配,推荐擅长相关方向的陪诊员,对提升下单转化率帮助明显。第三个是管理后台的报表统计,用图表库把按城市、按科室维度的订单量可视化,运营人员才能快速看到一周里哪些城市的陪诊需求在增长。这些功能不是核心流程,但决定了平台能不能长期用下去。
6.3 项目交付阶段的三条经验
几次返工之后,我沉淀了三条经验。第一,状态机文档在任何时候都比代码先动手,把所有状态流转和异常分支列成表格,开发过程会少踩一半坑。第二,不要因为“以后可能用得上”就提前上消息中间件这类基础设施,当前量级用 Redis 和定时任务解决即可,等真的出现削峰需求再说。第三,真实支付需要商户资质,第一版先用平台内部的模拟支付把全流程跑通,后续再按第三方支付规范替换支付接口,业务逻辑不用大改。
最后说一点个人感受。做这个 Spring Boot 陪诊服务平台的整个过程,最花时间的不是增删改查,而是把一条条业务规则变成准确的代码状态。尤其是订单状态机那一块,前后改了三次,第一次漏了退款中,第二次忘了处理取消后的自动退款,直到把所有分支列成一张流转表才踏实。如果你也正在做业务系统,哪怕是再小的项目,开工前先花一小时把状态流转写清楚,这个习惯至少能帮你少加三天班。