
把一个医院挂号系统做成前后端分离的SpringBootVue3项目技术本身并不算花哨但真正整理需求的时候会发现它比普通的管理系统更需要把业务状态想清楚。医院挂号要解决的是患者不用到窗口排队、号源公开透明、医生排班同步以及停诊、退号这类异常情况有兜底机制。基于Java生态后端用SpringBoot提供REST接口前端用Vue3做交互持久层用MyBatis访问MySQL数据库这是一套非常常见但又必须认真对待的组合。这篇文章不是带你抄一份能跑的代码而是把源码里最关键的数据库设计、号源扣减、订单状态机、前后端联调、Nginx部署全部拆开讲。适合正在做毕业设计的同学、想接手门诊类外包项目的开发者也适合前端同学想搞明白后端接口背后到底有什么业务约束。我会尽量用实操中的语言把踩过的坑同步说出来。无论你是第一次用Vue3还是有经验的Java开发看完至少能回答几个问题号源为什么会超卖、事务为什么没回滚、前端刷新为什么404、停诊后存量订单怎么处理。1. 项目概述与整体架构拆解1.1 这个系统到底要解决什么问题医院挂号就诊系统的核心是“号源”它和普通商品库存不一样具有强时段性医生某天某个时段的号挂完了就是挂完了不能像补货一样随便加。患者端需要查科室、查医生、看排班、预约下单管理员端需要维护医生出诊日期、上午下午号源总量、停诊信息到最后还要沉淀挂号订单方便退号和统计。这些环节一旦有一个状态没同步就会出现“显示有号但是挂不了”“患者挂号成功却被通知停诊”这类有真实后果的问题。所以做这套系统重点不是把表和页面建出来而是把号源和订单的流转逻辑做对。我看过不少照着教程拼出来的版本挂号表简单insert一条记录完全不对号源做校验用户多点几次就把同一时段挂爆了。这次要讲的SpringBootVue3MyBatisMySQL版本引入了排班表、号源表、挂号订单表和带条件的扣减逻辑从注册登录、选科室、选医生、选时段、确认挂号到医生排班维护和停诊处理是一条完整闭环。它不算特别大的项目但把医院门诊最核心的几个动作都覆盖到了。对于需要交作业、做原型、或者接单后不知道怎么下手的朋友这套设计思路可以直接拿过去改。1.2 前后端分离的整体架构架构上前端用Vue3加Vite构建搭配Element Plus做管理界面和用户端界面后端用SpringBoot提供一组REST接口持久层用MyBatis操作MySQL。前后端通过JSON交互前端axios发请求后端统一返回Result封装体。开发阶段前端跑在5173端口后端跑在8080端口用Vite的proxy把/api路径代理到后端避免跨域生产阶段前端打包成静态文件放到NginxNginx收到的/api请求再转发给后端Java服务。这种结构让前端页面和后端服务彻底解耦部署时后端挂掉只影响接口静态页面仍然可以打开并做出友好提示。前后端分离的实际收益不只是“分开写代码”而是整个团队的协作方式变了。只要约定好接口文档前端可以直接用Mock数据先开发页面后端专注于实现事务、权限、状态流转两边并行推进联调的时候问题少很多。等以后要做小程序、App或者开放给其他医院系统对接后端接口可以直接复用前端只需要重新做一层壳。对医院挂号这种面向多端使用的业务前后端分离不是炫技是刚需。1.3 技术选型背后的原因SpringBoot胜在启动快、配置少、生态成熟。JDK版本只要不太旧加上起步依赖就能把Web、事务、参数校验、日志全部串起来打出来的jar包可以直接丢到服务器跑。这个项目没有引入SpringCloud那套东西因为单体应用在中小医院的门诊量下完全够用一个挂号接口的并发很难高到需要立刻拆微服务的程度。技术上最怕的不是不够新而是过度设计Nacos、Feign、分布式事务全上最后维护成本比业务本身还高。Vue3相比Vue2最大的进步是Composition API它能把“查排班、锁号源、提交订单”这类关联逻辑抽到组合式函数里多个页面共用不会像Options API那样把所有代码堆在一个setup方法里。MyBatis则是因为医院系统对查询统计要求多SQL写起来直观可控慢查询也好定位。它比JPA更适合这种需要明确控制SQL语句的场景。MySQL就更不用说了免费、稳定、文档多团队里随便一个人都能处理。对于这个项目最看重的是稳定、可控、成本低。要补充一句如果放号瞬间并发特别高比如上万人同时抢一个热门专家的号这套纯MySQL方案确实会被打穿。到时候可以引入Redis做预扣库存用Lua脚本保证原子性。但前提是先把MySQL的扣减逻辑做对再考虑Redis。连事务和防超卖都没做明白直接上缓存只会让数据更乱。2. 数据库设计与核心表结构2.1 核心业务表设计思路我把数据库表分成三类基础资料表、排班资源表、业务数据表。基础资料包括用户、科室、医生、诊室排班资源包括医生排班和号源时段业务数据包括挂号订单、支付流水、操作日志。很多同学会纠结要不要把排班和号源合并成一张表我更建议拆开。排班描述的是“哪个医生哪天坐诊、上午还是下午”号源描述的是“这个坐诊时间下具体有几个时段、每个时段剩多少号”。如果合并管理员临时增加一个时段就得改排班主记录字段职责会越来越模糊。下面是关键表的DDL我用一个简化但可运行的版本字段名都用小写下划线方便MyBatis开驼峰映射后直接对应。CREATE TABLE department ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, sort INT DEFAULT 0, deleted TINYINT DEFAULT 0 ); CREATE TABLE doctor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, department_id BIGINT NOT NULL, title VARCHAR(20), intro VARCHAR(500), deleted TINYINT DEFAULT 0 ); CREATE TABLE doctor_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, work_date DATE NOT NULL, period TINYINT NOT NULL COMMENT 1上午 2下午, total_num INT NOT NULL, remain_num INT NOT NULL, status TINYINT DEFAULT 1 COMMENT 1正常 2停诊 ); CREATE TABLE schedule_slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, slot_time VARCHAR(10), total_num INT NOT NULL, remain_num INT NOT NULL );挂号订单表单独拿出来设计是因为它承载了整个业务流程的状态。字段里既要有患者信息、医生信息、就诊日期也要有金额和状态。冗余一些展示字段是故意的到查历史订单列表的时候就不用每次都join医生姓名和科室名称查询性能更好。CREATE TABLE registration_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, slot_id BIGINT DEFAULT NULL, doctor_name VARCHAR(50), department_name VARCHAR(50), visit_date DATE NOT NULL, period TINYINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, amount DECIMAL(10,2) NOT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建议表结构里不要直接用用户ID、医生ID当主键还是要保留自增主键同时给order_no加唯一索引。挂号订单号是业务单号退号、对账、客服查询都要靠它不能直接用数据库id暴露给客户端。2.2 关键字段和命名上的坑先说金额字段挂号费必须用DECIMAL不能用float或double。二进制的浮点数存金额会有精度误差哪怕只是几毛钱到时候对账对不上就很麻烦。这里amount定义成DECIMAL(10,2)正常医院挂号费完全够用。时间字段统一用datetime。本地开发时经常会遇到“插入的数据比当前时间少8小时”的情况原因多半是JDBC连接串里没有指定serverTimezoneAsia/ShanghaiMySQL和Java进程的时区认知不一致。同一个坑不同电脑上出现的时间点还不一样排查起来特别让人抓狂。状态字段我建议用TINYINT加注释比如0待支付、1已支付、2已完成、3已取消、4停诊退号。代码里再定义一个枚举类或者常量类把这些值统一管理起来千万不要散落成魔法值。后面写“根据状态统计报表”的时候你会感谢这个决定。索引方面registration_order表上加了两类索引order_no唯一索引保证单号不重复user_id和status、create_time的联合索引用来支撑“我的挂号记录”查询。如果以后订单量大还要考虑更多查询维度比如按schedule_id查某个排班下的全部订单这个字段也需要索引。建索引不是越多越好但业务高频查询条件一定要覆盖到。关于软删除基础资料表可以加deleted字段。MyBatis Plus自带逻辑删除但如果你用的是原生MyBatis记得在每个查询SQL里手动加deleted条件或者通过全局配置处理。最怕的是加了字段却忘了查询过滤数据被查到又点不了用户以为系统坏了。2.3 号源扣减和事务边界号源扣减是整个系统的命门核心SQL只有一行UPDATE schedule_slot SET remain_num remain_num - 1 WHERE id #{slotId} AND remain_num 0;这里的关键是“先更新再判断影响行数”不要先select出来判断数量大于0再update。并发环境下两次请求可能同时select到remain_num1然后同时执行update最后都认为有号但实际上只有一个号。带 AND remain_num 0 的update是原子操作数据库行锁会保证同一时刻只有一个事务能更新成功。如果update返回0直接提示用户“该时段已无号源”。创建订单时还需要同步更新主排班表doctor_schedule的remain_num用于科室列表和医生页面的余号展示。两个update和insert必须在同一个事务里保证号源扣减和订单创建是一体的。Spring的Transactional注解默认只在RuntimeException时回滚建议显式声明rollbackFor Exception.class避免一些受检异常导致数据不一致。Transactional(rollbackFor Exception.class) public Long createOrder(CreateOrderRequest request) { int rows scheduleSlotMapper.decreaseRemain(request.getSlotId()); if (rows 0) { throw new BizException(该时段已无剩余号源); } RegistrationOrder order buildOrder(request); registrationOrderMapper.insert(order); return order.getId(); }创建订单时还有一个容易忽略的问题要不要恢复主排班表的余号如果只用schedule_slot的remain_num做展示那确实不需要但如果你在排班列表页查的是doctor_schedule.remain_num就必须在同一事务里同步扣减。最稳妥的方案是以schedule_slot为实际扣减依据doctor_schedule.remain_num通过事务保证同步更新查询展示时也以doctor_schedule.remain_num为准避免聚合统计。简单项目里少一张表逻辑会更清晰但扩展能力会差一些。3. 后端SpringBoot核心实现与避坑3.1 工程分层与基础配置后端包结构我习惯按controller、service、mapper、entity、dto、vo、config、common来分。controller只负责接收参数、调用service、返回Resultservice承载业务规则mapper只写SQLentity对应数据库表dto接收前端入参vo返回前端展示数据。这样分层边界清晰新人接手也能很快定位到代码位置。统一返回体Result至少包含code、message、data三个字段。code200表示成功其他code表示业务失败。全局异常处理用RestControllerAdvice把BizException、参数校验异常、数据库异常统一捕获转成Result格式。否则前端拿到一坨默认错误页排查成本很高。application.yml的基础配置里有三处值得单独说明server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hospital_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl第一个是MyBatis的驼峰映射打开后数据库字段user_name可以直接映射到实体属性userName省去大量手写resultMap。第二个是打印SQLStdOutImpl会把执行的SQL和参数输出到控制台开发阶段排查问题特别方便。第三个是数据库连接串characterEncoding和serverTimezone两个参数一定要写一个是防中文乱码一个是防时间差8小时。Maven依赖版本也要稍微留意。SpringBoot 3.x对应JDK 17SpringBoot 2.x可以跑在JDK 8上。如果环境里JDK版本卡着就选对应版本不要盲目上最新版。依赖下载慢或者下载失败优先检查阿里云镜像配置而不是反复刷新重试。3.2 挂号接口实现的关键点挂号接口是业务流程的入口参数上要特别小心。创建订单时用户ID不能由前端传过来必须从当前登录态里获取。也就是说前端只传slotId后端从token解析出userId再下单。否则只要有人改成别人的userId就能替别人挂号这属于严重越权漏洞。Controller层代码可以写得很薄PostMapping(/api/order/create) public ResultLong createOrder(RequestBody Valid CreateOrderRequest request) { Long orderId orderService.createOrder(request); return Result.ok(orderId); }真正的逻辑在Service层。前面提到需要加Transactional保证号源扣减和订单插入的原子性。还有一个很常见的坑Spring事务注解默认只对运行时异常回滚如果方法里catch了异常而且没有重新抛出事务就会正常提交导致号源扣了但订单没生成。所以我建议所有业务方法都显式写rollbackFor Exception.class并且在Service层不要随便catch异常除非你明确知道下一步该怎么做。幂等性问题也要考虑。用户在挂号页面连点两次提交如果前端没有禁用按钮后端可能收到两个相同的请求。最简单的幂等方案是给registration_order表加一个唯一的业务键比如前端每次进入挂号页面生成一个uuid创建订单时把这个uuid作为unique_key字段插入。第一次插入成功第二次插入因为唯一键冲突而失败数据库层天然挡掉了重复订单。这比在后端用synchronized块或者本地锁可靠得多本地锁在多个实例部署时是失效的。3.3 停诊、退号与状态机设计挂号订单的状态不能随便乱改最好用状态机来约束。我的设计是0待支付、1已支付、2已完成、3已取消、4停诊退号。正常流程是从待支付变已支付再变成已完成。患者主动取消可能从待支付或已支付变成已取消。停诊退号则是由管理员触发把还没就诊的订单统一置为停诊退号并且要恢复号源或退款。取消挂号的业务逻辑是先根据订单号查出订单校验当前状态是否允许取消如果是已支付订单还要走退款流程。然后恢复对应时段的号源保证金。Transactional(rollbackFor Exception.class) public void cancelOrder(Long orderId) { RegistrationOrder order registrationOrderMapper.selectById(orderId); if (order null) { throw new BizException(订单不存在); } if (order.getStatus() ! OrderStatus.WAIT_PAY.getValue() order.getStatus() ! OrderStatus.PAID.getValue()) { throw new BizException(当前状态不允许取消); } scheduleSlotMapper.increaseRemain(order.getSlotId()); registrationOrderMapper.updateStatus(orderId, OrderStatus.CANCELED.getValue()); }这里要注意恢复号源和更新订单状态必须在同一个事务里否则会出现订单已取消但号源没有恢复的情况。恢复号源的SQL不要用select再update直接写UPDATE schedule_slot SET remain_num remain_num 1 WHERE id #{slotId}停诊逻辑是另一个容易漏掉点。管理员把医生某天的排班状态改成停诊后需要把所有状态为待支付、已支付的订单全部置为停诊退号同时恢复号源。如果只改排班状态落实到excel里没有一句废话。用代码实现时建议先查一次这个排班下所有可退订单然后在事务里逐条更新订状态并恢复号源。如果订单量很大可以批量处理但要注意避免一次性加载所有数据到内存。超时未支付订单也需要定时处理。患者锁定号源但迟迟不支付号源就一直被占着。可以给订单表加一个create_time然后用Spring定时任务每5分钟扫描一次待支付超过30分钟的订单把状态改成已取消并恢复号源。定时任务本身要注意加分布式锁否则多个实例同时跑会重复恢复号源不过单体项目里可以先忽略。3.4 MyBatis使用中的细节和坑MyBatis的动态SQL是日常使用频率最高的。比如排班列表查询需要按科室、医生、日期、上午下午筛选最自然的写法是用一个 标签加多个 让它自动处理掉第一个多余AND而不是写WHERE 11。这里给出一个典型示例select idselectScheduleList resultTypecom.hospital.vo.ScheduleVO SELECT s.*, d.name AS doctorName, dep.name AS departmentName FROM doctor_schedule s LEFT JOIN doctor d ON s.doctor_id d.id LEFT JOIN department dep ON d.department_id dep.id where if testdepartmentId ! null AND d.department_id #{departmentId} /if if testdoctorId ! null AND s.doctor_id #{doctorId} /if if testworkDate ! null AND s.work_date #{workDate} /if if testperiod ! null AND s.period #{period} /if AND s.status 1 /where ORDER BY s.work_date, s.period /select关于缓存很多教程把MyBatis一级缓存、二级缓存说得天花乱坠但在SpringBoot项目里一级缓存的生命周期基本可以忽略因为SqlSession在每次Mapper调用后可能就关闭了。二级缓存默认关闭就算开了遇到排班余号这种实时性要求高的数据也容易返回脏数据。这个项目里我不建议开二级缓存需要缓存的地方用Spring Cache或者Redis单独处理至少你能控制缓存过期时间。参数拼接上统一用#{}不要用${}。${}会把参数直接拼进SQL存在SQL注入风险而且引号、特殊字符还会导致语法错误。做模糊查询时用CONCAT(%, #{keyword}, %)不要在Java代码里拼好再传。foreach标签遍历集合时如果集合为空动态SQL可能会生成无效的IN条件最好先用 包一层。MyBatis里还有一个常见操作批量插入或者批量更新。不要在一个for循环里执行单条insert数据库来回连接很浪费时间。用 标签拼成一条insert多值语句或者在XML里写一个批处理update能明显提升接口性能。挂号订单列表页如果需要批量更新状态同样建议用批量SQL。4. 前端Vue3Element Plus落地4.1 创建项目和目录组织前端建议直接用Vite初始化命令是npm create vitelatest hospital-web -- --template vue然后安装需要的依赖element-plus是UI组件库vue-router管路由pinia管全局状态axios发请求。Element Plus组件库全量引入在小项目里问题不大以后项目大了再改成按需引入避免一开始就配置unplugin-auto-import把时间花在环境上。目录结构我习惯这样组织src/api所有接口请求方法src/views页面组件用户端和管理端分目录src/components通用业务组件src/router路由配置src/storePinia状态src/utilsaxios实例、工具函数页面不要堆在一两个大组件里按业务模块拆开比如挂号页、订单列表页、排班管理页、医生管理页。组件拆得细一点后面维护的时候不用一个文件滚几百行代码。4.2 axios封装和接口调用axios封装不是可有可无的统一的拦截器能省掉大量重复代码。一个实际的request.js大概长这样import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response?.status 401) { router.push(/login) } ElMessage.error(error.message) return Promise.reject(error) } ) export default request这里的baseURL统一用/api开发环境和生产环境都走相对路径。开发时由Vite代理转发生产时由Nginx转发前端不需要关心后端端口变化。拦截器里会自动附带token接口返回非200时统一弹出错误提示代码里就不用每个接口都catch一遍了。接口方法单独放在api目录比如schedule.js里导出getScheduleList、createOrder、cancelOrder这些方法。这样页面组件只关心业务数据不关心URL和HTTP细节。如果后端改了路径只改一个文件就够了。4.3 用户端挂号页面核心流程挂号页面是整个前端最值得花时间的部分因为流程不是简单的一个表单提交。用户进来先选科室科室列表展示医生点进医生后展示排班日期再选择上午或下午最后是具体时段和剩余号数。每一步变化都可能影响后面的可用数据所以页面状态要理清楚。用Vue3的组合式函数把排班相关逻辑抽出来是一个不错的实践import { ref } from vue import { getScheduleList, createOrder } from /api/schedule export function useSchedule() { const loading ref(false) const scheduleList ref([]) async function loadSchedule(params) { loading.value true try { scheduleList.value await getScheduleList(params) } finally { loading.value false } } async function submitOrder(slotId) { return createOrder({ slotId }) } return { loading, scheduleList, loadSchedule, submitOrder } }页面里只需要引入useSchedule然后调用loadSchedule传入当前选中的医生和日期。这比把所有变量都定义在组件里清爽很多以后如果要做医生排班管理端同一个方法也能复用。提交订单时要注意防重复点击。最直接的工作是在提交按钮上绑定loading状态点击后按钮置灰接口返回后再恢复。但前端禁用只是第一层后端幂等校验才是底线。前端代码上也别忘记捕获异常接口失败时把按钮状态恢复用户还可以再次点击。注意不要用setTimeout做延迟恢复不然接口本来就慢用户狂点的问题依然存在。4.4 路由守卫和权限控制前端路由需要根据用户角色做隔离。普通用户可以看到挂号、我的订单管理员可以看到排班管理、医生管理、系统设置。用router.beforeEach每次跳转前检查token是否存在然后根据角色判断目标路由是否允许访问不允许就跳到403页或者登录页。这个守卫的作用是提升用户体验不是安全检查。真正要防的是有人绕过前端直接调用接口所以后端每个受保护的接口都必须校验登录态和权限。前端隐藏入口后端拒绝请求两边都做才安全。医院系统涉及患者隐私权限漏洞不能开玩笑。管理端的排班管理页面可以用一个日期选择器加医生多选批量生成一周的排班记录。生成时初始化total_num和remain_num生成后允许管理员单独调节每个时段的号源。这个页面的逻辑不复杂但和用户端挂号页面联动用来验证排班是否正确、号源扣减是否符合预期非常直观。5. 部署、联调与常见问题速查表5.1 本地环境准备和启动顺序本地要实现完整流程至少准备JDK、Maven、Node、MySQL四样东西。JDK版本根据SpringBoot版本选Maven用来管理后端依赖Node跑前端MySQL存数据。启动前先把建库SQL执行一遍然后修改application.yml里的数据库账号密码接着启动后端再启动前端。前端启动后浏览器打开Vite提供的地址正常情况下能看到登录页F12控制台里能看到请求经过代理访问后端接口。开发环境最怕的是前端和后端各自启动后发现端口冲突、路径不对、跨域报错。Vite的proxy配置可以减少很多问题server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这段配置的意思是前端发出的所有/api开头的请求都会被Vite转发到localhost:8080。后端不用额外配置CORS因为浏览器看到的始终是同源的5173端口请求只是被服务器端转发了一次。如果后端还独立开启了CORS注意不要和代理设置同时使用否则Options预检请求容易被拦截。5.2 打包部署到Nginx的细节后端打包用mvn clean package得到可执行的jar包后用nohup java -jar xxx.jar启动即可。前端打包用npm run build生成dist目录里面全是静态文件。把dist放到Nginx的root目录再配置一个转发规则。Nginx配置里最关键的几个点一个是history路由刷新问题一个是API代理。server { listen 80; server_name hospital.example.com; root /opt/hospital/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }location / 里的try_files是解决Vue Router history模式刷新404的关键。前端路由切到/user/order时服务器上并没有这个真实文件如果不处理一刷新就404。try_files加上/index.html兜底后所有路径都会回到前端入口由router自己解析。location /api/ 则把前端发出的接口请求转发给后端Java服务。前后端都部署在同一台服务器上时这个配置基本够用。不建议把dist直接塞进SpringBoot的static目录里虽然能跑但会让前后端部署耦合在一起前端每次更新都要重新打后端jar包。Nginx的方案更干净前端发布只需要替换静态文件后端升级也不影响前端。5.3 常见问题速查表下面是实际联调中最容易踩到的问题以及排查方向整理成一张表可以随时翻现象常见原因解决/排查方向接口返回中文乱码数据库连接字符集没配置JDBC url加characterEncodingutf8表使用utf8mb4页面时间比本地时间晚8小时JDBC时区错误url加serverTimezoneAsia/Shanghai前端路由刷新404history模式没有try_filesNginx加try_files $uri $uri/ /index.html跨域报错前后端端口不同用Vite或Nginx代理不要只配CORSMySQL连接失败密码不对/驱动版本不一致检查数据库名、账号密码、连接驱动jar包号源超卖扣减没有判断余号update语句必须带remain_num 0查询字段返回null驼峰映射没开设置map-underscore-to-camel-casetrue事务没回滚方法自调用或异常被catch使用注入自身调用显式rollbackFor接口突然变慢慢SQL没有索引用EXPLAIN分析SQL补联合索引端口被占用其他进程占用8080/5173netstat -ano找到PID后结束进程生产环境还有几个不能忽略的点MySQL要开启自动备份至少每天一次mysqldump到独立磁盘SpringBoot的日志要落到文件并做轮转挂号接口可以做一个简单的限流比如同一用户1秒内只能提交一次全站启用HTTPS尤其登录和支付环节明文传输太危险。这些不写也能跑但上线后早晚要补。6. 项目扩展思路与个人复盘6.1 业务功能扩展方向这套表结构和状态机设计可以继续扩展很多真实场景。接入在线支付时只需要增加支付流水表和支付回调接口订单状态从待支付变成已支付由回调驱动排队叫号时给订单增加一个排队序号字段医生端可以按顺序呼叫停诊时发短信通知患者只需要在停诊逻辑里加一个消息推送接口。这些扩展都不需要推翻现有设计。从业务上还可以考虑多院区支持。doctor_schedule表增加一个clinic_id字段前端按院区筛选挂号订单也记录院区退号和统计都按院区分开。这个扩展对表结构改动很小但能明显扩大系统的使用范围。再往后做数据分析就能统计各院区、各科室、各医生的号源利用率帮助医院调整出诊安排。6.2 性能演进路线如果项目上线后确实遇到放号瞬间高并发比如每周一早上8点抢号那性能优化的路径可以一步一步来。第一步分析数据库慢查询确认索引是否合理事务是否太长第二步把热门医生的号源预扣逻辑放到Redis里用Lua脚本保证扣减原子性异步把订单写入MySQL第三步引入消息队列削峰把挂号请求先入队后端worker慢慢消费最后才是SpringBoot集群和MySQL主从。每一步都解决一个明确瓶颈不要一上来就堆中间件。在大多数医院场景下单体加MySQL已经能扛住日常压力。与其盲目追求高并发设计不如先保证业务不出错。我所见过的失败案例往往不是并发不够而是超卖、重复支付、号源对不上这类基础问题。6.3 开发效率和协作建议最后分享几个开发过程中的习惯。接口文档一定要先定可以用Apifox或者Swagger把路径、入参、出参、状态码约定好。前后端并行开发时前端用Mock数据后端用接口文档实现联调时再一起对齐。不要等到页面写完再来改接口字段那是大量返工的来源。Git分支管理也很重要按功能建分支比如feature/doctor-schedule、feature/order-list每个分支完成后再合并到develop合并前确认build能过。否则大家一起在master上改冲突会让你怀疑人生。日志一定要留记录。谁修改了排班状态、谁取消了订单、停诊操作是谁触发的这些都要有操作日志。出了问题的时候能查出责任人也能还原当时的业务现场。很多项目debug非常痛苦就是因为日志打太少关键步骤没有留下任何痕迹。我个人在实际开发中的体会是这种系统再复杂拆开看就是“号源库存扣减、订单状态机、用户权限”三个核心。把这三件事理解透了页面再多也不会乱。如果你接下来要做一个类似的项目建议从数据库表和状态枚举开始读然后顺着一个挂号请求从头跟到尾比漫无目的地看代码快得多。最后再补一句功能可以慢慢加但表结构和状态机设计最好在第一版就定好中途重构的代价比想象中大太多了。