☰
SpringBoot+Vue3医院挂号系统:从数据库设计到部署避坑指南
2026/10/7 3:06:27 网站建设 项目流程

把一个医院挂号系统做成前后端分离的SpringBoot+Vue3项目,技术本身并不算花哨,但真正整理需求的时候会发现,它比普通的管理系统更需要把业务状态想清楚。医院挂号要解决的是:患者不用到窗口排队、号源公开透明、医生排班同步,以及停诊、退号这类异常情况有兜底机制。基于Java生态,后端用SpringBoot提供REST接口,前端用Vue3做交互,持久层用MyBatis访问MySQL数据库,这是一套非常常见但又必须认真对待的组合。

这篇文章不是带你抄一份能跑的代码,而是把源码里最关键的数据库设计、号源扣减、订单状态机、前后端联调、Nginx部署全部拆开讲。适合正在做毕业设计的同学、想接手门诊类外包项目的开发者,也适合前端同学想搞明白后端接口背后到底有什么业务约束。我会尽量用实操中的语言,把踩过的坑同步说出来。无论你是第一次用Vue3,还是有经验的Java开发,看完至少能回答几个问题:号源为什么会超卖、事务为什么没回滚、前端刷新为什么404、停诊后存量订单怎么处理。

1. 项目概述与整体架构拆解

1.1 这个系统到底要解决什么问题

医院挂号就诊系统的核心是“号源”,它和普通商品库存不一样,具有强时段性,医生某天某个时段的号挂完了,就是挂完了,不能像补货一样随便加。患者端需要查科室、查医生、看排班、预约下单;管理员端需要维护医生出诊日期、上午下午号源总量、停诊信息;到最后还要沉淀挂号订单,方便退号和统计。这些环节一旦有一个状态没同步,就会出现“显示有号但是挂不了”“患者挂号成功却被通知停诊”这类有真实后果的问题。所以做这套系统,重点不是把表和页面建出来,而是把号源和订单的流转逻辑做对。

我看过不少照着教程拼出来的版本,挂号表简单insert一条记录,完全不对号源做校验,用户多点几次就把同一时段挂爆了。这次要讲的SpringBoot+Vue3+MyBatis+MySQL版本,引入了排班表、号源表、挂号订单表和带条件的扣减逻辑,从注册登录、选科室、选医生、选时段、确认挂号,到医生排班维护和停诊处理,是一条完整闭环。它不算特别大的项目,但把医院门诊最核心的几个动作都覆盖到了。对于需要交作业、做原型、或者接单后不知道怎么下手的朋友,这套设计思路可以直接拿过去改。

1.2 前后端分离的整体架构

架构上,前端用Vue3加Vite构建,搭配Element Plus做管理界面和用户端界面;后端用SpringBoot提供一组REST接口;持久层用MyBatis操作MySQL。前后端通过JSON交互:前端axios发请求,后端统一返回Result封装体。开发阶段,前端跑在5173端口,后端跑在8080端口,用Vite的proxy把/api路径代理到后端,避免跨域;生产阶段,前端打包成静态文件放到Nginx,Nginx收到的/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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

建议表结构里不要直接用用户ID、医生ID当主键,还是要保留自增主键,同时给order_no加唯一索引。挂号订单号是业务单号,退号、对账、客服查询都要靠它,不能直接用数据库id暴露给客户端。

2.2 关键字段和命名上的坑

先说金额字段,挂号费必须用DECIMAL,不能用float或double。二进制的浮点数存金额会有精度误差,哪怕只是几毛钱,到时候对账对不上就很麻烦。这里amount定义成DECIMAL(10,2),正常医院挂号费完全够用。

时间字段统一用datetime。本地开发时经常会遇到“插入的数据比当前时间少8小时”的情况,原因多半是JDBC连接串里没有指定serverTimezone=Asia/Shanghai,MySQL和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_num=1,然后同时执行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、返回Result;service承载业务规则;mapper只写SQL;entity对应数据库表;dto接收前端入参;vo返回前端展示数据。这样分层边界清晰,新人接手也能很快定位到代码位置。

统一返回体Result至少包含code、message、data三个字段。code=200表示成功,其他code表示业务失败。全局异常处理用@RestControllerAdvice,把BizException、参数校验异常、数据库异常统一捕获,转成Result格式。否则前端拿到一坨默认错误页,排查成本很高。

application.yml的基础配置里,有三处值得单独说明:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hospital_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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。第二个是打印SQL,StdOutImpl会把执行的SQL和参数输出到控制台,开发阶段排查问题特别方便。第三个是数据库连接串,characterEncoding和serverTimezone两个参数一定要写,一个是防中文乱码,一个是防时间差8小时。

Maven依赖版本也要稍微留意。SpringBoot 3.x对应JDK 17,SpringBoot 2.x可以跑在JDK 8上。如果环境里JDK版本卡着,就选对应版本,不要盲目上最新版。依赖下载慢或者下载失败,优先检查阿里云镜像配置,而不是反复刷新重试。

3.2 挂号接口实现的关键点

挂号接口是业务流程的入口,参数上要特别小心。创建订单时,用户ID不能由前端传过来,必须从当前登录态里获取。也就是说,前端只传slotId,后端从token解析出userId再下单。否则只要有人改成别人的userId,就能替别人挂号,这属于严重越权漏洞。

Controller层代码可以写得很薄:

@PostMapping("/api/order/create") public Result<Long> 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 1=1。这里给出一个典型示例:

<select id="selectScheduleList" resultType="com.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 test="departmentId != null"> AND d.department_id = #{departmentId} </if> <if test="doctorId != null"> AND s.doctor_id = #{doctorId} </if> <if test="workDate != null"> AND s.work_date = #{workDate} </if> <if test="period != 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. 前端Vue3+Element Plus落地

4.1 创建项目和目录组织

前端建议直接用Vite初始化,命令是npm create vite@latest 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/store:Pinia状态
  • src/utils:axios实例、工具函数

页面不要堆在一两个大组件里,按业务模块拆开,比如挂号页、订单列表页、排班管理页、医生管理页。组件拆得细一点,后面维护的时候不用一个文件滚几百行代码。

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加characterEncoding=utf8,表使用utf8mb4
页面时间比本地时间晚8小时JDBC时区错误url加serverTimezone=Asia/Shanghai
前端路由刷新404history模式没有try_filesNginx加try_files $uri $uri/ /index.html
跨域报错前后端端口不同用Vite或Nginx代理,不要只配CORS
MySQL连接失败密码不对/驱动版本不一致检查数据库名、账号密码、连接驱动jar包
号源超卖扣减没有判断余号update语句必须带remain_num > 0
查询字段返回null驼峰映射没开设置map-underscore-to-camel-case=true
事务没回滚方法自调用或异常被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非常痛苦,就是因为日志打太少,关键步骤没有留下任何痕迹。

我个人在实际开发中的体会是,这种系统再复杂,拆开看就是“号源库存扣减、订单状态机、用户权限”三个核心。把这三件事理解透了,页面再多也不会乱。如果你接下来要做一个类似的项目,建议从数据库表和状态枚举开始读,然后顺着一个挂号请求从头跟到尾,比漫无目的地看代码快得多。最后再补一句:功能可以慢慢加,但表结构和状态机设计最好在第一版就定好,中途重构的代价比想象中大太多了。

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

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

立即咨询