SpringBoot2+Vue3+MySQL8.0医院资源管理系统实战:从数据库设计到部署
这个话题要从一个真实场景说起。我接过好几个医疗类的系统包括实验室管理系统、体检中心预约平台但医院资源管理系统Hospital Resource Management SystemHRMS是比较综合的。它解决的核心问题很直接大型医院里科室、医生、病床、设备、药品这些资源如果靠 Excel 表或者纸质台账来管理到了就诊高峰期排班冲突、床位不足、设备闲置这类问题会把人逼疯。这个系统的目标就是把资源台账和业务流串起来。资源管理系统和单纯的 CRUD 不一样。虽然表面上都是管理员在后台维护数据但医院场景对数据的准确性和并发要求很高。比如同一个诊室上午被心内科排了门诊下午被内分泌科排了检查系统必须能控制冲突再比如病房床位一个床位不能同时分配给两个病人。这些东西在普通教务管理系统里可能不会把冲突校验做得这么严格但在医疗项目里是刚需。项目标题里的几个关键词——SpringBoot2、Vue3、MyBatis-Plus、MySQL8.0——正好是一个前后端完全分离的经典组合。后端用 SpringBoot2 提供 REST API前端用 Vue3 做单页应用数据库用 MySQL 8.0ORM 层用 MyBatis-Plus 提升单表操作的效率。这套组合的特点是开发效率高、控制优雅、社区资源多特别适合资源管理这类以增删改查为基础、又带复杂查询的业务系统。而且源码里还带了配套文档数据库设计说明、接口约定、部署步骤这些最容易被忽略的部分都整理好了这点对于想把这个项目拿去做课程设计或者毕业设计的人来说价值不比代码本身小。1. 项目概述与技术选型分析1.1 医院资源管理系统是干什么的医院资源管理系统的核心不是挂号而是调配。挂号的本质是号源分配而号源又来源于医生的排班排班要依赖科室的诊室资源住院要依赖病房床位的周转设备、药品则要追踪使用状态和维保情况。所以这个系统本质上是把医院内部的人、财、物资源统一到一套后台里做调度。从使用者角度拆系统里面有几种角色。系统管理员负责维护基础数据、分配账号权限科室秘书负责排班、管理本科室资源护士长或住院部管理员负责病房和床位的分配医生登录后可以看自己的排班和预约列表还有前台或分诊人员处理预约签到、状态变更。不同的角色看到的界面不一样操作权限也不一样这就是为什么权限模块在管理系统里永远是第一优先级。这个项目适合谁来参考如果你的目标是用 Java Web 技术栈完成一个前后端分离的综合性管理系统或者正在为课程设计、毕业设计找一套完整可复现的工程那这个项目的结构和代码风格都值得借鉴。它不算特别庞大但把管理系统开发的常用套路都涵盖了登录鉴权、权限路由、分页查询、树形结构、状态流转、报表统计。你把这个项目跑通一次再换个业务域做第二个系统基本不会卡壳。1.2 技术栈为什么这么选先聊后端。SpringBoot2 是国内企业级项目的主力版本这一点到现在都没变。它基于 Spring Framework 5自带容器、AOP、事务管理配合 Spring Security 或者 JWT 机制可以完成用户认证授权。选 SpringBoot2 而不是追新上 SpringBoot3核心原因是生态和兼容性。SpringBoot3 把 javax 命名空间换成了 jakarta很多老教程和现成依赖直接对不上对于做毕设或者中小型团队来说时间成本不划算。SpringBoot2 的教程资料是全网最多的遇到问题搜索解决方案快这是开发阶段最大的隐形优势。ORM 层选 MyBatis-Plus说白了就是为了提升开发效率。单表的增删改查它帮你做完了简单列表查询连 XML 都不用写。它有个 LambdaQueryWrapper写查询条件用链式调用比如wrapper.like(StringUtils.hasText(name), Doctor::getName, name)这种写法在动态查询场景下特别好用不用拼接 SQL 字符串逻辑清晰。它还内置了分页插件和逻辑删除功能这两点对管理类系统是刚需不用自己再封装一套分页工具了。MySQL 8.0 不用犹豫。相比 5.7它在性能和功能上都有明显提升。默认字符集是 utf8mb4直接支持表情符号和生僻汉字医疗系统里经常遇到特殊字符处理起来没有后患。它支持窗口函数比如科室就诊量的按月度趋势统计一条OVER(PARTITION BY ...)就搞定在 5.7 里还得用临时表加变量去模拟麻烦还容易出错。前端 Vue3 就不用多说了。组合式 API 配合 setup 语法糖写业务逻辑比 Vue2 的选项式 API 舒服太多。配合 Vite 构建热更新速度极快。Element Plus 组件库现成可用表格、表单、弹窗这些管理系统的常用组件都齐了。管理类页面本身 UI 复杂度不高用组件库能省下绝大部分样式时间。这套技术组合还有一个隐性优势复用自己的能力。哪怕你以后不做医疗项目把它换个业务模板比如物业管理系统、实验室管理系统核心架构和代码骨架是不变的。所以这套源码的价值不仅限于医院这个业务域而是把它当作一套标准的前后端分离管理系统脚手架。这也是我在实际带人做项目时比较推荐这套技术栈的原因。2. 数据库设计与核心模块拆解2.1 核心业务关系梳理建表的第一步不是写 DDL而是把业务关系搞清楚。医院资源管理系统我按三条主线来拆。一条是人。人员分两类系统用户和医护人员。系统用户可能是管理员、护士长、科室秘书需要登录后台操作医生本身也可能要登录查看自己的排班和预约情况。所以人员表要能区分账号体系和业务体系。我的做法是单独建一张 sys_user 表存登录账号再建一张 doctor 表存医生的业务信息doctor 表通过 user_id 关联账号如果医生不需要登录账号这个字段可以为空。一条是物。病房、设备、药品这些都属于资源资产。它们的特点是都有状态病房有占用/空闲/清洁中设备有在用/闲置/维修中药品有库存充足/预警/缺货。状态迁移规则各不相同表里需要提前设计状态字段后续业务判断都用状态字段做分支。一条是事。预约、排班、领用、维保这些操作记录就是资源管理的过程数据。预约表会把病人、医生、科室、时段、号源类型都关联起来排班表则记录某医生在某时间段的出诊科室、号源总数、已约数。这三条主线的关系很清晰一个科室department下面有多个医生一个病房ward从属于一个病区一个病区从属于一个科室一张排班表schedule通过 doctor_id、department_id 关联到医生和科室预约表appointment通过 schedule_id 关联到具体排班。实际建表时我不太推荐加物理外键只建普通索引就够了因为物理外键在数据量大时影响写入性能也让逻辑删除变得复杂。2.2 关键表结构设计先给一个模块与核心表的快速对照方便你理解整个系统的横向范围模块核心功能涉及表系统管理用户、角色、菜单权限sys_user、sys_role、sys_menu科室管理科室层级维护、启停状态department医生管理医生档案、职称、所属科室doctor排班管理排班创建、冲突校验、号源管理schedule预约管理预约登记、号源扣减、签到appointment病房管理病房床位状态流转、分配ward、bed设备管理设备台账、状态、维保记录equipment、maintenance药品管理药品批次、库存预警drug、drug_batch然后看科室表 department 的 DDLCREATE TABLE department ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, dept_code VARCHAR(32) NOT NULL COMMENT 科室编码, dept_name VARCHAR(64) NOT NULL COMMENT 科室名称, type TINYINT NOT NULL DEFAULT 1 COMMENT 类型:1-门诊,2-住院,3-医技,4-行政, parent_id BIGINT DEFAULT NULL COMMENT 上级科室ID, sort_order INT DEFAULT 0 COMMENT 排序号, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态:1-启用,0-停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_dept_code (dept_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT科室表;这里有两个设计细节值得说。第一deleted 字段是给 MyBatis-Plus 逻辑删除用的加了它之后你写的 DELETE 语句会被自动改写成 UPDATE数据不会物理删除适合排班、预约这类有审计需求的表。第二status 和 deleted 是两个维度status 是业务上的启用停用deleted 是数据删除标记别混用。病房和床位我建议拆成两张表。病房表维护房间级信息床位因为数量多、状态变化频繁单独一张表每张床一条记录用状态字段区分空闲、占用、清洁中同时记录当前病人 IDCREATE TABLE bed ( id BIGINT NOT NULL AUTO_INCREMENT, ward_id BIGINT NOT NULL COMMENT 所属病房ID, bed_no VARCHAR(16) NOT NULL COMMENT 床号, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-空闲,1-占用,2-清洁中, patient_id BIGINT DEFAULT NULL COMMENT 当前病人ID, note VARCHAR(255) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_ward_status (ward_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT床位表;联合索引 idx_ward_status 是给查某病房的所有空闲床位这个高频场景准备的。查询条件同时命中 ward_id 和 status索引效率最高。如果你只建单列索引 ward_id全量扫出来再在内存里过滤状态数据量小时没问题一栋楼几百张病床同时查性能差距就出来了。排班表 schedule 是整个预约业务的核心。这个表的 DDL 我要重点讲CREATE TABLE schedule ( id BIGINT NOT NULL AUTO_INCREMENT, doctor_id BIGINT NOT NULL COMMENT 医生ID, department_id BIGINT NOT NULL COMMENT 科室ID, work_date DATE NOT NULL COMMENT 出诊日期, period TINYINT NOT NULL COMMENT 时段:1-上午,2-下午,3-晚班, total_count INT NOT NULL DEFAULT 0 COMMENT 总号源数, used_count INT NOT NULL DEFAULT 0 COMMENT 已预约数, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-正常,1-停诊,2-满号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_doc_date_period (doctor_id, work_date, period), KEY idx_dept_date (department_id, work_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT医生排班表;唯一索引 uk_doc_date_period 是防重复排班的最后一道防线。它从数据库层面保证同一个医生同一天同一个时段只能有一条排班记录。业务层可以写判断但两个管理员并发操作时请求都可能同时通过判断、同时插入这时候只有唯一索引能兜底。很多人在设计时忽略了这个细节上线后数据里出现重复排班排查起来特别费劲。这里的 version 字段是乐观锁版本号预约时扣减号源要用到后面详细说。定义好这几张主表预约表、设备表、药品表就顺着这个思路继续展开。设备表重点记录资产编号、类型、采购日期、供应商、状态、所在科室药品表要关注批次和有效期两个维度医院药品管理比普通仓库严格得多。权限相关我用五表模型sys_user、sys_role、sys_menu、sys_user_role、sys_role_menu通过关联表实现多对多关系这套模型在后台管理系统里非常成熟不需要过多解释。3. 后端核心实现详解3.1 SpringBoot2 项目搭建与配置项目结构我按后端开发惯例分层包名建议用com.hospital.admincom.hospital.admin ├── common # 通用工具、统一返回对象、异常处理 ├── config # 配置类MyBatis-Plus、拦截器、跨域 ├── controller # 控制器层REST API ├── entity # 数据库实体 ├── mapper # MyBatis-Plus Mapper接口 ├── service # 服务接口与实现 └── security # 登录认证与权限控制创建项目可以用 Spring Initializr 生成语言版本选 Java 8 或 Java 11。SpringBoot2 的 2.7.x 支持 Java 8 到 17但考虑到国内服务器环境Java 8 的兼容性最好。引入依赖时注意三个关键坐标spring-boot-starter-web、mysql-connector-java、mybatis-plus-boot-starter。MyBatis-Plus 的 groupId 是com.baomidouartifactId 是mybatis-plus-boot-starter3.5.x 版本对 SpringBoot2 兼容良好。如果从 Maven 仓库下载慢配置阿里云镜像就能解决这个在本地开发时几乎必做。核心配置文件 application.yml 我直接给出一份能跑的版本server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hospital_resource?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 banner: false configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: truedriver-class-name 写com.mysql.cj.jdbc.Driver这是 MySQL 8.0 的驱动类名和 MySQL 5.x 时代的com.mysql.jdbc.Driver不一样。新驱动连 MySQL 5.7 也能连反过来用旧驱动连 8.0 会出现认证协议或 SSL 报错。serverTimezone 这个参数很多人都踩过坑。MySQL 8.0 默认使用服务器系统时区如果系统时区不是北京时间连接池建立连接时会执行时区换算业务代码查出的时间字段会相差8小时。URL 里显式指定serverTimezoneAsia/Shanghai就能根治。3.2 MyBatis-Plus 实战要点先注册分页插件。这个配置在 SpringBoot2 里通过一个 Configuration 类完成Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); // 单页最大条数防止恶意大分页 pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } }分页插件一定要加上 DbType.MYSQL 参数。它会根据数据库方言生成对应的分页 SQLMySQL 是 LIMIT 语句。如果不设置 DbType插件在某些场景下识别不了方言就会生成错误的 SQL。然后写 Service 层直接继承 MyBatis-Plus 的 IService 接口public interface DoctorService extends IServiceDoctor { } public class DoctorServiceImpl extends ServiceImplDoctorMapper, Doctor implements DoctorService { }继承 IService 之后save、removeById、page、list、getById 这些基础方法全都有了。Controller 里配合 LambdaQueryWrapper 写分页查询非常快GetMapping(/page) public RIPageDoctor page(RequestParam(required false) Long departmentId, RequestParam(required false) String keyword, RequestParam(defaultValue 1) long pageNum, RequestParam(defaultValue 10) long pageSize) { PageDoctor page new Page(pageNum, pageSize); LambdaQueryWrapperDoctor wrapper new LambdaQueryWrapper(); wrapper.eq(departmentId ! null, Doctor::getDepartmentId, departmentId) .and(StringUtils.hasText(keyword), w - w .like(Doctor::getName, keyword) .or() .like(Doctor::getTitle, keyword)); wrapper.orderByDesc(Doctor::getSortOrder); IPageDoctor result doctorService.page(page, wrapper); return R.ok(result); }这段代码的业务含义是departmentId 不为空就按科室精确过滤keyword 有值就在姓名和职称两个字段上做模糊匹配排序按 sortOrder 倒序。eq、like 这类条件方法第一个参数是 boolean 条件只有条件成立时才会把对应片段拼进 SQL这就是 MyBatis-Plus 做动态查询的优雅之处完全不需要自己写 XML 里的if标签。但多表连接查询我还是建议写原生 SQL。比如统计各科室门急诊量用 LambdaQueryWrapper 也能实现但 GROUP BY 聚合在 XML 里可读性更好select idcountVisitsByDept resultTypejava.util.Map SELECT d.dept_name AS deptName, COUNT(a.id) AS visitCount FROM appointment a INNER JOIN schedule s ON a.schedule_id s.id INNER JOIN department d ON s.department_id d.id WHERE a.visit_date BETWEEN #{startDate} AND #{endDate} AND a.status 1 GROUP BY d.id, d.dept_name ORDER BY visitCount DESC /select经验是MyBatis-Plus 只管单表操作多表关联统计交给 XML 更清晰。Controller 里调用自定义方法只需要在 Mapper 接口上声明方法签名XML 文件里组合好 namespace 和 id。3.3 核心业务接口实现预约挂号接口是整个系统业务重心的体现涉及库存扣减和并发控制Override Transactional(rollbackFor Exception.class) public boolean book(AppointmentDTO dto) { // 1. 校验排班是否存在 Schedule schedule scheduleService.getById(dto.getScheduleId()); if (schedule null || schedule.getStatus() 1) { throw new BizException(排班不存在或已停诊); } // 2. 校验号源 if (schedule.getUsedCount() schedule.getTotalCount()) { throw new BizException(号源已满请选择其他时段); } // 3. 乐观锁扣减号源 int rows scheduleMapper.increaseUsedCount(schedule.getId(), schedule.getVersion()); if (rows 0) { throw new BizException(当前预约人数较多请重试); } // 4. 插入预约记录 Appointment appointment new Appointment(); appointment.setScheduleId(dto.getScheduleId()); appointment.setDoctorId(schedule.getDoctorId()); appointment.setDepartmentId(schedule.getDepartmentId()); appointment.setPatientName(dto.getPatientName()); appointment.setPatientIdCard(dto.getPatientIdCard()); appointment.setPhone(dto.getPhone()); appointment.setVisitDate(schedule.getWorkDate()); appointment.setPeriod(schedule.getPeriod()); appointment.setStatus(0); // 0-待就诊 appointmentService.save(appointment); return true; }方法标注了 Transactional保证扣减号源和插入预约记录在同一事务里。扣减号源对应的 SQL 是带乐观锁条件的更新UPDATE schedule SET used_count used_count 1, version version 1 WHERE id #{id} AND version #{version}这个方案可以避免不加锁导致的超卖。如果业务量特别大也可以换成悲观锁SELECT ... FOR UPDATE但医院门诊的大多数场景乐观锁性能更好冲突时提示用户重试即可。事务这里要强调一个细节Spring 的 Transactional 默认只在 RuntimeException 时回滚如果你抛的是检查型异常事务不会自动回滚。所以 rollbackFor Exception.class 这个参数建议写上稳妥。业务异常 BizException 通常继承 RuntimeException这样写也能保证业务校验失败时不产生脏数据。4. 前端 Vue3 项目实战4.1 项目脚手架与目录规范前端用 Vite 创建项目一条命令搞定npm create vitelatest hospital-web -- --template vue项目生成后我建议立刻做一次目录整理把默认样式清掉改成下面这套结构src/ ├── api/ # 接口请求模块 │ ├── doctor.js │ ├── schedule.js │ └── appointment.js ├── components/ # 通用组件 ├── views/ # 页面 │ ├── Login.vue │ ├── Dashboard.vue │ ├── department/ │ ├── doctor/ │ └── schedule/ ├── router/ # 路由配置 ├── stores/ # Pinia 状态仓库 ├── utils/ # 工具函数 └── App.vue这套目录的核心思想是页面归页面、接口归接口、状态归状态。每个业务模块对应一个 api 文件views 按业务模块建子目录。项目变大以后找人改代码不会出现所有接口塞在一个 request.js 里的情况。路由配置要加登录拦截。用 vue-router 的全局前置守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.public) { next() } else if (!token) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })这个守卫的逻辑很直白公开页面直接放行访问其他页面时检查 token没有 token 就踢回登录页并把原始目标路径放到 query 里登录成功后跳转回原来的页面。状态管理我用 Pinia因为它是 Vue3 官方推荐的方案对 TypeScript 支持更好写法更简洁。登录接口返回 token 后存入 localStorage用户信息放进 Pinia后续组件里随时读取。4.2 登录鉴权与请求封装axios 封装是前端的基础设施。通常建一个 utils/request.js把 baseURL、拦截器、统一错误处理集中在这里import axios from axios import { ElMessage } from element-plus const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code 200) { return res } if (res.code 401) { localStorage.removeItem(token) window.location.href /login return Promise.reject(new Error(登录已过期)) } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message || 请求失败)) }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )统一处理的好处是业务页面里不用每个请求都重复判断状态码和弹错误提示。一个细节拦截器里判断的是后端返回的业务码 code不是 HTTP 状态码。如果你的后端用的是 RESTful 风格同时用 HTTP 状态码和业务码那需要对两个维度都做兼容不然部分错误会跑到 error 回调里被误判成网络异常。具体模块的接口直接导出一个函数集合比如医生模块import request from /utils/request export function getDoctorPage(data) { return request({ url: /doctor/page, method: get, params: data }) } export function saveDoctor(data) { return request({ url: /doctor, method: post, data }) }这样页面里 import 对应函数直接用即可接口 URL 统一收敛在 api 目录改后端地址时不用翻业务页面。4.3 核心页面交互实现管理系统页面的形态高度类似顶部搜索区、中间表格、右侧或底部弹窗表单。用 Element Plus 的 el-table、el-form、el-dialog 就能把骨架搭起来。以医生管理页为例模板结构大致如下template el-card div classsearch-bar el-input v-modelqueryForm.keyword placeholder姓名/职称 clearable / el-select v-modelqueryForm.departmentId placeholder所属科室 clearable el-option v-foritem in deptList :keyitem.id :labelitem.deptName :valueitem.id / /el-select el-button typeprimary clickloadData查询/el-button el-button clickresetQuery重置/el-button /div el-table :datatableData v-loadingloading border stripe el-table-column propname label姓名 width120 / el-table-column proptitle label职称 width120 / el-table-column propdeptName label科室 / el-table-column label操作 width160 fixedright template #default{ row } el-button link typeprimary clickopenEdit(row)编辑/el-button el-button link typedanger clickhandleDelete(row)删除/el-button /template /el-table-column /el-table el-pagination v-model:current-pagequeryForm.pageNum v-model:page-sizequeryForm.pageSize :totaltotal current-changeloadData / /el-card /template操作按钮建议根据角色权限加 v-if 控制普通用户看到删除按钮点了也只会得到 403界面体验不好。按钮级权限可以用自定义指令也可以用简单的 v-if 从用户信息里判断不需要引入重型权限框架。弹窗表单我推荐把 el-dialog 和 el-form 单独封装成一个组件通过 v-model 控制显隐。原因是管理系统的表单校验规则往往很长单独文件可读性更好复用时传 props 就行el-form refformRef :modelform :rulesrules label-width100px el-form-item label姓名 propname el-input v-modelform.name / /el-form-item el-form-item label职称 proptitle el-select v-modelform.title el-option label主任医师 value主任医师 / el-option label副主任医师 value副主任医师 / el-option label主治医师 value主治医师 / /el-select /el-form-item /el-formrules 里用 async-validator 的标准写法比如姓名必填、手机号正则匹配。日期选择器如果涉及排班需要加 disabled-date 禁止选择过去日期这种细节很影响使用体验。排班页面是 Vue3 组合式 API 发挥价值的地方。你可以用 reactive 维护一个周排班表格每行是一个医生每列是一个日期时段修改某个单元格时调用接口保存。这种二维数据的更新逻辑用 Vue3 的响应式数据配合钩子函数做比 Vue2 时代清爽很多代码行数能少一半。5. MySQL 8.0 配置与部署联调5.1 MySQL 8.0 环境要点MySQL 8.0 的安装本身不难难点通常在安装之后。Windows 推荐用 ZIP 包方式安装解压后在 my.ini 里配置 basedir 和 datadir以管理员权限执行mysqld --initialize-insecure初始化初始密码为空再执行net start mysql启动服务。这套流程在 Windows 10 上反复验证过一次成功的概率很高前提是命令提示符要管理员身份运行。Linux 或服务器上用 Docker 是最省事的docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD123456 \ -e TZAsia/Shanghai \ -v /opt/mysql8/data:/var/lib/mysql \ mysql:8.0这里加了 TZAsia/Shanghai 环境变量很多部署问题的根源就是时区。不加这个容器内系统时区默认是 UTC比北京时间慢8小时应用层启动后查时间会有偏差。初始化脚本的执行顺序建议明确。先建业务库再按 DDL 建表CREATE DATABASE hospital_resource DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;这里要提一个 MyBatis-Plus 用户应该知道的好东西代码生成器。用 Mybatis-Plus 的 Generator 配置好数据库连接可以根据表结构自动生成 entity、mapper、service、controller 全套代码。生成的代码虽然不能完全符合你的编码规范但能把一半的重复劳动省掉剩下的核心业务逻辑再手写即可。不过要提醒一下生成代码前一定要把表结构的字段注释写清楚因为实体类的中文注释就是从数据库字段注释里带出来的。5.2 前后端联调经验联调的核心痛点只有两个一个是跨域一个是参数格式。开发环境下Vite 默认跑在 5173 端口后端跑在 8080 端口浏览器访问前端页面时发请求到 8080会被同源策略拦截。解决办法两个方案。后端加跨域配置是最直接的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); } }前端 Vite 配置代理也可以// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } }个人习惯是前后端同时配合开发阶段用 Vite 代理配置文件跟着代码仓库走团队每个成员都不用改后端代码部署阶段用 Nginx 反向代理前端静态文件和 /api 请求统一交给 Nginx 转发。两个方案互不冲突。联调阶段最常见的报错是 415 Unsupported Media Type。axios 默认 POST 请求发送的是 JSON 字符串Content-Type 是 application/json后端 SpringBoot 接收 JSON 参数必须在方法参数上标注 RequestBody。如果你的 Controller 参数是普通对象或者 RequestParam请求就 415。排查这种问题先打开浏览器网络面板看请求头到底发的什么格式再定位后端参数接收方式不要乱猜。6. 开发过程中的坑与实战避坑指南6.1 后端常见坑第一个坑MySQL 8.0 驱动导致的连接报错。用旧版本 JDBC 驱动连接 MySQL 8.0大概率报 Public Key Retrieval is not allowed 或者 SSLHandshakeException。解决办法是在 JDBC URL 上加allowPublicKeyRetrievaltrue和useSSLfalse同时把驱动升级到 mysql-connector-java 8.x。开发环境直接关掉 SSL省心。第二个坑SpringBoot2 和 MyBatis-Plus 的版本匹配。3.5.1 之后的 MyBatis-Plus 拆出了 mybatis-plus-spring-boot3-starter 给 SpringBoot3 用SpringBoot2 项目必须继续引入 mybatis-plus-boot-starter。如果从网上复制代码时不小心用了 boot3 的 starter启动会直接报版本冲突。这个报错比较明显但很多人还是会忽略 maven 的依赖树分析。第三个坑逻辑删除字段和唯一索引冲突。我在排班表上建了唯一索引 uk_doc_date_period但逻辑删除只是把记录更新成 deleted1并不会物理删除。如果同一天同一个时段再次创建排班唯一索引里 doctor_id、work_date、period 三个字段完全相等历史逻辑删除记录还在新的插入会被唯一索引挡掉。解决办法有两种一是把 deleted 字段也纳入唯一索引同时让每次删除的 deleted 值不同比如删除时把 deleted 更新为当前记录 ID 或时间戳这样历史删除记录和新增记录在索引维度上不会冲突二是干脆去掉唯一索引只在应用层做排班冲突校验。按我的实践方案一更稳数据库层面的防线不能丢。第四个坑Spring 事务不生效。很多人把 Transactional 加在 Service 接口上但调用发生在同一个类的内部方法事务不会生效。Spring 事务默认基于 AOP 代理只有通过代理对象调用才会进入事务逻辑同类内部调用绕过了代理。推荐做法是把事务注解加到实现类的方法上并且不要在同类内部直接调用带事务的方法而是拆到另外一个 Service 类里。6.2 前端常见坑Vue3 的响应式虽然比 Vue2 聪明但陷阱也不少。ref 和 reactive 用混的时候页面不更新是最常见的问题。经验法则是基础类型用 ref对象类型用 reactive复杂场景宁可全用 ref因为 ref 在模板里会自动解包。如果你把 reactive 对象直接赋值给另一个变量或者从数组里取出某个对象再修改属性容易出现响应性丢失页面上看起来数据改了但 UI 不刷新。排查时先在控制台打印一下修改后的值确认数据变了再考虑渲染层面的问题。路由重复跳转报错在 Vue Router 4 里很常见。连续点击同一个菜单或者进入详情页之后再次点同一个导航会抛 NavigationDuplicated 错误。虽然功能不受影响但控制台一堆红色报错影响排查体验。解决方法是给 router.push 包一层 catch比如router.push(url).catch(() {})让重复导航静默处理。Element Plus 的 el-select 回显问题同样高频。当 v-model 绑定的值在 options 列表里找不到对应项时选中的文本会直接显示成原始 value 而不是 label。典型场景是编辑弹窗打开时options 是异步加载的而绑定值已经赋值组件 props 里没有匹配项。解决办法是编辑打开时先加载 options 再赋值选中值或者用 el-select 的 filterable 属性让组件能按已绑定值回显。还有一个容易被忽略的点是 Vue3 的 v-model 在组件上默认监听的是 modelValue/update:modelValue 事件。如果你习惯了 Vue2 的 .sync 写法在封装弹窗组件时容易把 prop 名写错。组件内部用defineProps([modelValue])接收defineEmits([update:modelValue])触发父组件写v-modelvisible就对了。6.3 部署环境坑部署阶段的坑最磨人。一个典型场景是本地测试正常部署到服务器后很多接口报 SQL 语法错误或者字符乱码。排查后发现是服务器的 MySQL 字符集没有设为 utf8mb4默认 latin1 导致中文乱码。解决办法是修改 my.cnf 配置文件设置character-set-serverutf8mb4和collation-serverutf8mb4_general_ci重启 MySQL 服务后重建数据库。另一个经典问题是服务器防火墙和云安全组忘开端口。后端 8080 端口、前端 80 端口本地测试通了部署到云服务器浏览器访问超时十有八九是安全组没放通端口。排查顺序建议先看云服务商安全组规则再查服务器防火墙最后看应用是否有监听异常不要一上来就怀疑代码。Nginx 部署 Vue3 项目时有一个几乎必踩的坑vue-router 用的是 HTML5 History 模式刷新页面时 Nginx 会返回 404因为 Nginx 找不到 /doctor 这个物理路径。你必须加上这一行location / { try_files $uri $uri/ /index.html; }请求路径匹配不到物理文件时就回退到 index.html交给前端路由去处理。几乎所有的 Vue 单页应用部署都要加这个配置不加就会遇到刷新页面白屏的经典问题。部署完开始维护的时候文档的价值就凸显了。我在交付这类系统时会强制要求把数据库设计说明书、接口文档、部署手册三样东西整理好和源码放一起。数据库设计说明书里写清楚每张表的业务含义和状态字段的枚举值接口文档用 OpenAPI 规范维护部署手册从 JDK 安装开始记录每个步骤。因为半年之后你自己回头看代码有些表为什么要这么设计可能也想不起来了有文档兜底无论是自己接手还是交给别人都从容得多。这个项目的源码配套文档正好做了这件事这也是我在拿到一份系统源码时最看重的部分。最后再多说一句题外话。技术选型、代码实现都是可以复制的真正拉开差距的是对业务的理解和细节上的坚持。一个排班冲突校验、一个号源扣减的并发控制、一个时区问题的处理这些点滴细节才是让你从会写 CRUD走向能交付系统的关键。希望这套项目能成为你迈过这道坎的台阶。