先交代一下背景。前阵子帮一家社区诊所搭了一套挂号就诊系统,技术栈就是标题里这套:Spring Boot 2 + Vue 3 + MyBatis-Plus + MySQL 8.0。整个项目做下来,从数据库设计到前后端联调,再到部署上线,踩了不少坑,也沉淀了一些比较顺手的写法。这套组合在2024、2025年的中小型医疗信息化项目里非常典型:Spring Boot 2 依然是企业级后端主力,Vue 3 的 Composition API 开发效率确实比 Vue 2 高一大截,MyBatis-Plus 省掉了大量单表 CRUD 模板代码,MySQL 8.0 的窗口函数和 JSON 支持也让统计和扩展方便很多。如果你正准备做毕业设计、课程设计,或者公司要快速搭一个轻量级的挂号平台,这篇文章应该能帮你省下至少两周的摸索时间。
我写这篇文章不只是罗列源码结构,而是把设计和落地过程中真正重要的决策点讲清楚:为什么这么选、表怎么建模、号源怎么防超卖、权限怎么控制、部署有哪些隐藏坑。内容偏实操,会直接给建表 SQL、核心代码片段和排查思路,跟着走基本能复现一套能跑起来的系统。
1. 系统整体设计与技术选型思路
1.1 为什么是 Spring Boot 2 而不是 Spring Boot 3
选 Spring Boot 2.7.x 而不是 3.x,核心原因就三个:JDK 版本兼容、第三方生态成熟度、学习资料覆盖率。
Spring Boot 3 强制要求 JDK 17,这本身没问题,但很多医院、诊所现有的运维环境还停留在 JDK 8。一套系统要落地,往往不只是代码能跑,还要考虑客户服务器上的 Java 环境能不能动。Spring Boot 2.7 是 2.x 的最后一个版本,支持 JDK 8,也支持 JDK 11,兼容性最好。另外,Spring Boot 3 基于 Jakarta EE 9,很多老项目的依赖坐标要从javax改成jakarta,如果团队之前积累了不少基于javax.servlet的代码,迁移成本不低。
在第三方组件方面,Spring Boot 2.7 对 MyBatis-Plus、Druid、ShardingSphere、Knife4j 这些国内开发高频使用的库支持得最稳。尤其 MyBatis-Plus 的mybatis-plus-boot-starter在 3.5.x 版本对 Boot 2 的分页插件、自动填充、乐观锁支持都非常丝滑,放到 Boot 3 上偶尔会遇到拦截器不生效或者ClassNotFoundException的问题。
还有一点是学习门槛。目前大部分高校和培训机构讲 Spring Boot 还是以 2.x 为主,碰到报错网上一搜全是现成答案。用 Spring Boot 3 写项目,遇到冷门问题可能翻半天 GitHub Issue 才能解决。对于业务系统来说,稳定大于激进,Boot 2.7 是当下最务实的选择。
1.2 Vue 3 组合式 API 与 Vue 2 选项式的分水岭
前端这块直接上了 Vue 3 + Vite + Element Plus。Vue 3 最大的变化是 Composition API,它把同一个业务逻辑的响应式数据、计算属性、监听器、方法聚合在一起,而不是像 Vue 2 的 Options API 那样强制分到data、methods、computed不同的区块里。
举个实际感受。挂号页面里有一个「选择科室 -> 选择医生 -> 选择号源时段」的联动逻辑。Vue 2 的写法是watch科室 ID,然后去改医生列表数据;Vue 3 里直接写一个computed,根据当前selectedDeptId过滤出医生列表,数据流更直观,改动时也不用担心watch顺序错乱的问题。
<script setup> import { ref, computed, watch } from 'vue' const selectedDeptId = ref('') const doctorList = ref([]) const availableDoctors = computed(() => doctorList.value.filter(d => d.deptId === selectedDeptId.value) ) watch(selectedDeptId, async (newVal) => { if (!newVal) return isLoading.value = true try { const { data } = await getScheduleByDept(newVal) doctorList.value = data } finally { isLoading.value = false } }) </script>Vite 替代 Webpack 之后,冷启动速度非常明显,开发时几乎秒开,HMR 也是毫秒级更新。Element Plus 是 Element UI 的 Vue 3 版本,表格、表单、弹窗、日期选择器这些后台管理高频组件都有,和 Vue 3 的响应式系统配合得也很好。
如果你现在还在纠结"要不要从 Vue 2 迁到 Vue 3",我的建议是直接迁。Vue 2 已经停止维护,新项目再用它就是在给未来挖坑。
1.3 MyBatis-Plus 和 MySQL 8.0 的实际价值
MyBatis-Plus 不是替代 MyBatis,而是 MyBatis 的增强工具。它最核心的价值是把单表 CRUD 的模板代码彻底消灭。BaseMapper接口里已经内置了selectById、selectList、insert、updateById、deleteById这些方法,你只要定义一个继承它的接口,什么都不用写就能用。
public interface PatientMapper extends BaseMapper<Patient> { }配合Wrapper条件构造器,复杂的动态查询也能用代码表达,不用写 XML:
LambdaQueryWrapper<Patient> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StrUtil.isNotBlank(keyword), Patient::getName, keyword) .eq(Patient::getDeleted, 0) .orderByDesc(Patient::getCreateTime); IPage<Patient> page = patientMapper.selectPage(new Page<>(current, size), wrapper);MySQL 8.0 相比 5.7 有几个明显的优势。默认字符集是utf8mb4,存 emoji 和生僻字不会再报Incorrect string value错误;支持窗口函数,做挂号统计、排名这类查询时不用写复杂的子查询;支持WITH公共表表达式,分页和复杂统计的 SQL 更清晰。8.0 还引入了caching_sha2_password认证插件,如果项目中用的是老版本驱动,比如 5.1.x 的 MySQL Connector/J,连接会报Public Key Retrieval is not allowed,这个问题在部署章节会专门说。
2. 核心数据模型与数据库设计
2.1 用户体系与基础档案的三表联动
医院挂号系统的数据模型核心有两块:基础档案和业务流水。基础档案包括用户、患者、科室、医生、排班,业务流水包括挂号单和就诊记录。
先看最基础的三张表:系统用户表、患者档案表、医生信息表。这三张表不能混,否则后面扩展会很难受。
系统用户表sys_user存的是登录账号和密码,这是登录认证的主体:
CREATE TABLE `sys_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` VARCHAR(50) NOT NULL COMMENT '登录账号', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', `real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名', `role` TINYINT NOT NULL DEFAULT 0 COMMENT '角色: 0-管理员 1-医生 2-患者', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 0-禁用 1-正常', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';患者档案表patient_info则专门存诊疗过程中需要的医疗信息,比如身份证号、过敏史、医保卡号。医生信息表doctor_info关联所属科室、职称、擅长领域,后续在排班和挂号列表里展示。
三张表通过sys_user.id做外键关联。之所以分开,是因为一个系统用户不一定有患者档案(比如管理员),一个患者档案也可能在极端情况下被多个家庭成员账号关联(比如老人不会用手机,子女帮忙挂号)。这种设计在业务扩展时更从容。
2.2 科室、医生与排班:号源从哪里来
科室表department和医生表doctor_info是挂号的前置依赖。科室表字段比较常规:科室名称、科室位置、科室简介、是否开放挂号。医生表要额外加一个dept_id和title,用于前端做科室联动筛选。
排班表schedule是号源生成的源头,设计时要考虑两个维度:时间和号源数量。
CREATE TABLE `schedule` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `doctor_id` BIGINT NOT NULL COMMENT '医生ID', `dept_id` BIGINT NOT NULL COMMENT '科室ID', `work_date` DATE NOT NULL COMMENT '出诊日期', `period_type` TINYINT NOT NULL COMMENT '时段: 1-上午 2-下午', `start_time` TIME NOT NULL COMMENT '开始时间', `end_time` TIME NOT NULL COMMENT '结束时间', `total_num` INT NOT NULL DEFAULT 0 COMMENT '总号源数', `remain_num` INT NOT NULL DEFAULT 0 COMMENT '剩余号源数', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 0-停诊 1-放号 2-约满', PRIMARY KEY (`id`), KEY `idx_doctor_date` (`doctor_id`, `work_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生排班表';关键设计点在remain_num。每次用户挂号成功时,除了插入一条挂号单记录,还要同步扣减remain_num。这里必须确保扣减操作的原子性,防止两个人同时挂最后一个号导致超卖。后面写实现时我会给出具体的 SQL。
2.3 挂号单的状态机与超时释放机制
挂号单表registration是系统的核心业务表,记录每一笔挂号操作。
CREATE TABLE `registration` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '挂号单号', `patient_id` BIGINT NOT NULL COMMENT '患者档案ID', `user_id` BIGINT NOT NULL COMMENT '操作账号ID', `doctor_id` BIGINT NOT NULL COMMENT '医生ID', `dept_id` BIGINT NOT NULL COMMENT '科室ID', `schedule_id` BIGINT NOT NULL COMMENT '排班ID', `reg_date` DATE NOT NULL COMMENT '就诊日期', `period_type` TINYINT NOT NULL COMMENT '时段', `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态: 0-待就诊 1-已就诊 2-已取消 3-已退号', `visit_no` INT DEFAULT NULL COMMENT '就诊序号', `remark` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_patient_status` (`patient_id`, `order_status`), KEY `idx_schedule` (`schedule_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='挂号单表';状态流转看起来简单,但实际落地要处理三个边界场景:
- 取消挂号的余号回补:用户取消挂号时,
order_status变成 2,同时排班表的remain_num要 +1。这个操作必须放在同一个数据库事务里。 - 过期未支付释放:如果系统支持在线支付但用户未支付,需要定时任务把超过 15 分钟的待支付订单自动取消,释放号源。
- 停诊处理:医生临时停诊时,需要把该排班和所有待就诊状态的挂号单统一处理,通常是电话通知后手动退号。
这个状态机的设计直接决定业务是否闭环,一定要在开发前理清楚,不要边写边改。
3. 后端核心模块实现
3.1 通用 CRUD 服务:MyBatis-Plus 的最佳实践
MyBatis-Plus 的IService和ServiceImpl提供了一套通用 CRUD 模板。比如患者管理的 Service 接口,可以这样写:
public interface PatientService extends IService<Patient> { PageResult<Patient> pageQuery(PatientQuery query); boolean addPatient(Patient patient); boolean updatePatient(Patient patient); }ServiceImpl 里直接继承ServiceImpl<PatientMapper, Patient>,基础的save、removeById、page方法都自动具备了。真正需要手写的只有业务逻辑,例如"新增患者前检查身份证号是否重复":
@Override public boolean addPatient(Patient patient) { LambdaQueryWrapper<Patient> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Patient::getIdCard, patient.getIdCard()); Long count = baseMapper.selectCount(wrapper); if (count > 0) { throw new BusinessException("身份证号已存在"); } return save(patient); }这里有一个实际经验要分享:不要把所有查询都交给 MP。复杂报表统计、多表关联查询还是建议写 XML。
比如「按科室统计每日挂号量」这种需求,用 MP 的 Wrapper 会写得很别扭,但用 MySQL 8.0 的窗口函数直接一条 SQL 解决:
SELECT d.dept_name, DATE(r.reg_date) AS reg_day, COUNT(*) AS reg_count, SUM(COUNT(*)) OVER (PARTITION BY d.dept_name ORDER BY DATE(r.reg_date)) AS cumulative_count FROM registration r JOIN department d ON r.dept_id = d.id WHERE r.order_status IN (0, 1) GROUP BY d.dept_name, DATE(r.reg_date) ORDER BY d.dept_name, reg_day;所以我在项目里定了两条规矩:单表操作无脑用 MP,多表统计一律走自定义 SQL。
3.2 JWT + Spring Security 认证授权落地
Spring Security 在 Java Web 项目里几乎是标配,但它配置繁琐也是出了名的。这个项目采用 JWT + Spring Security 的方案,核心是让无状态接口可以识别"你是谁"。
流程是:用户登录成功后,后端生成一个带过期时间的 JWT Token 返回给前端。前端把 Token 存在localStorage或 Pinia 里,每次请求在Authorization头带上。后端用 OncePerRequestFilter 拦截请求,解析 Token,把用户信息放进SecurityContext。
配置 Spring Security 时最核心的代码是SecurityFilterChain:
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/register", "/doc.html").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/doctor/**").hasRole("DOCTOR") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }关于角色权限,这里踩过的一个坑是 Spring Security 的hasRole会自动加上ROLE_前缀。如果数据库角色字段存的是ADMIN,那hasRole("ADMIN")会检查ROLE_ADMIN,如果存的是自定义格式就要用hasAuthority。这个细节看似小,但报错时很难排查。
密码加密统一用 BCryptPasswordEncoder,不要用 MD5。MD5 是哈希算法不是加密算法,而且彩虹表查起来太容易了。BCrypt 每次加密的结果都不同,即使两个用户密码相同,密文也不同,安全性靠谱很多。
3.3 挂号核心流程:事务 + 乐观锁防超卖
挂号是这个系统里并发要求最高的操作。两个用户同时点击余号为 1 的号源,只能有一个人成功。实现方案有两种常见做法:
- 悲观锁:SELECT FOR UPDATE 锁定排班记录,事务结束后释放。适合并发量极高的场景,但会锁行,影响性能。
- 乐观锁:在排班表加
version字段,更新时检查版本号。适合中低并发场景,不锁行。
医院挂号系统的并发量通常每秒几十到几百,乐观锁完全够用。更新语句是核心:
@Transactional(rollbackFor = Exception.class) public RegistrationResult register(RegisterRequest request) { Schedule schedule = scheduleMapper.selectById(request.getScheduleId()); if (schedule.getRemainNum() <= 0) { throw new BusinessException("号源已挂完"); } if (schedule.getStatus() != 1) { throw new BusinessException("该时段已停诊或约满"); } int rows = scheduleMapper.updateRemainNumWithVersion( schedule.getId(), schedule.getVersion() ); if (rows == 0) { throw new BusinessException("号源被抢完了,请刷新重试"); } Registration registration = buildRegistration(request, schedule); registrationMapper.insert(registration); return RegistrationResult.from(registration); }对应的 Mapper XML 里的更新 SQL 是:
<update id="updateRemainNumWithVersion"> UPDATE schedule SET remain_num = remain_num - 1, version = version + 1 WHERE id = #{id} AND remain_num > 0 AND version = #{version} </update>这个 WHERE 条件里同时带了remain_num > 0和version = #{version},等于双保险:即使两个请求同时提交,数据库行锁会让其中一个更新失败,rows == 0就能感知到并返回友好提示。
事务边界也要注意,@Transactional注解必须加到 public 方法上,而且要通过外部调用进来,否则 Spring AOP 代理不生效。同一个类里方法之间调用this.register()会让事务失效,这是新人最容易犯的错。
3.4 定时任务与统计报表
挂号系统离不开定时任务:取消超时未支付的订单、每天早上生成号源、定时统计就诊数据。Spring 自带的@Scheduled足够应付,不需要引入 Quartz。
自动取消超时待支付订单:
@Scheduled(fixedDelay = 60_000) public void cancelTimeoutOrders() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(15); LambdaUpdateWrapper<Registration> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(Registration::getOrderStatus, 4) // 待支付 .lt(Registration::getCreateTime, deadline) .set(Registration::getOrderStatus, 2); // 改为已取消 List<Registration> timeoutOrders = registrationMapper.selectList(wrapper); if (!timeoutOrders.isEmpty()) { timeoutOrders.forEach(order -> { scheduleMapper.increaseRemainNum(order.getScheduleId()); }); } }注意这里要先把超时订单查出来,再循环回补号源。如果直接批量更新状态而不回补号源,号源就会越来越少。
还可以扩展一个每日定时任务:每天晚上 8 点生成第二天的号源,把排班表的remain_num重置为total_num。或者用 ES 做搜索引擎,但这套系统用 MySQL 就够了。
4. 前端 Vue3 + Element Plus 实操要点
4.1 工程化初始化与 Axios 请求封装
前端项目用 Vite 初始化,一行命令搞定:
npm create vite@latest frontend -- --template vue然后安装依赖:
npm install element-plus axios pinia vue-routerVite 的vite.config.js里配置开发服务器代理,解决跨域问题,这里要注意代理不只是为了解决跨域,还是为了让前端请求路径和后端接口路径解耦:
export default defineConfig({ plugins: [vue()], server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })Axios 封装时最重要的统一响应拦截器。后端约定返回格式为{ code, message, data },前端统一处理code === 200和code !== 200的情况,避免每个页面都重复做错误提示。下面这个封装是个人项目中比较顺手的版本:
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) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { ElMessage.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } ) export default service4.2 挂号页面的联动交互与表单校验
挂号是患者端最高频的页面,交互必须顺滑。页面上有三个联动选择:科室 -> 医生 -> 号源时段。数据结构上我建议在 Pinia 里维护一个registrationStore,因为用户可能在挂号页和确认页之间来回跳,临时选择的数据需要跨组件共享。
// store/registration.js import { defineStore } from 'pinia' export const useRegistrationStore = defineStore('registration', { state: () => ({ selectedDept: null, selectedDoctor: null, selectedSchedule: null, patient: null }), actions: { setDept(dept) { this.selectedDept = dept this.selectedDoctor = null this.selectedSchedule = null } } })选中科室后自动清空医生和号源选择,这个逻辑放在 action 里而不是组件里,可以保证不管在哪个页面触发切换,状态都保持一致。
表单校验方面,Element Plus 的el-form用rules配置校验规则。这里要注意自定义校验函数,比如校验身份证号是否合法,不光要正则匹配,还要校验最后一位校验码,避免 18 位数字乱填也能通过。
还有一个细节:挂号时要让用户选择就诊人。如果当前登录用户没有维护患者档案,要弹出一个动态表单让他先创建档案。动态表单的「添加一行」功能可以这样实现:
<template> <div v-for="(item, index) in formList" :key="index"> <el-input v-model="item.name" placeholder="就诊人姓名" /> <el-input v-model="item.idCard" placeholder="身份证号" /> <el-button @click="addRow">添加</el-button> <el-button v-if="formList.length > 1" @click="removeRow(index)">删除</el-button> </div> </template> <script setup> import { reactive } from 'vue' const formList = reactive([{ name: '', idCard: '' }]) function addRow() { formList.push({ name: '', idCard: '' }) } function removeRow(index) { formList.splice(index, 1) } </script>4.3 路由守卫与按钮级权限控制
前端的权限控制不是安全边界,但能极大提升用户体验。方案是根据用户角色动态生成路由。具体思路是:登录后拿到用户角色,用router.addRoute动态注册该角色能访问的路由表。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token) { if (to.path === '/login') { next() } else { next('/login') } return } if (to.path === '/login') { next('/') return } next() })按钮级权限的核心是自定义指令。比如删除用户的按钮只有管理员才能看到:
app.directive('permission', { mounted(el, binding) { const required = binding.value const role = localStorage.getItem('role') if (required !== role) { el.parentNode?.removeChild(el) } } })这个写法如果要更优雅,可以把角色信息放到 Pinia + 持久化插件里,刷新页面也不会丢。
4.4 前后端接口规范与联调经验
前后端联调最怕的不是 bug,而是接口文档不一致。这个项目里强制约定了一套统一响应格式:
{ "code": 200, "message": "操作成功", "data": { "list": [], "total": 0 } }分页接口固定用page和size参数,返回固定的list和total字段。这样前端分页组件完全不用改,一套代码通用所有列表页。
接口路径也统一规则:/api/admin/、/api/doctor/、/api/patient/ 开头,一眼能看出模块和权限。后端用 Knife4j 生成接口文档,地址固定/doc.html,开发环境和测试环境都开着,前端随时可以对着文档调试,不用每次跑来问字段含义。
5. MySQL 8.0 环境搭建与部署踩坑记录
5.1 MySQL 8.0 安装与驱动兼容问题
MySQL 8.0 的安装本身不复杂,但有几个坑新手经常踩。Windows 上安装时要注意选择Server Only还是Full,开发机建议选 Full,但生产环境一定选 Server Only。安装到最后一步会要求设置 root 密码和认证方式,这时候如果选了Use Legacy Authentication,后续驱动连接会更顺滑;选了默认的caching_sha2_password,则必须确保驱动版本是 8.0 以上。
Docker 部署 MySQL 8.0 是最推荐的方式,一条命令搞定:
docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=yourpassword \ -e MYSQL_DATABASE=hospital \ -v /data/mysql:/var/lib/mysql \ mysql:8.0用 Docker 容器的好处是隔离环境干净,卸载也不留垃圾。但记得把数据目录挂载出来,否则容器删除数据就没了。
JDBC 连接串要注意加时区参数,不加的话可能会报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized这种乱码错误:
spring.datasource.url=jdbc:mysql://localhost:3306/hospital?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true5.2 典型报错排查与解决方案
整理几个这个项目里实际碰到的高频报错,希望能帮你少走弯路。
报错一:Public Key Retrieval is not allowed
这个错误在使用 MySQL 8.0 + 老版本 JDBC 驱动时非常典型。原因是在caching_sha2_password认证模式下,客户端首次连接需要向服务器请求公钥,但连接串默认不允许这样做。解决方式是在连接串里加allowPublicKeyRetrieval=true。
但更推荐的方案是升级驱动到mysql-connector-java8.0.33 或使用新坐标com.mysql:mysql-connector-j,这样连接串配置更标准。
报错二:MyBatis-Plus 分页不生效
分页插件 3.5.x 版本的配置方式如下:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(50L); interceptor.addInnerInterceptor(pagination); return interceptor; }如果这个 Bean 没配置,selectPage会把所有数据查出来内存分页,数据量一上来直接内存溢出。
报错三:Vue 项目引入 Element Plus 样式不生效
Element Plus 按需引入时如果用的是unplugin-auto-import和unplugin-vue-components,需要在vite.config.js里配置:
import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] })不配置的话,组件虽然渲染了但样式全没了,查半天都找不到原因。
报错四:Invalid bound statement (not found)
Mapper 接口定义了方法但找不到对应的 SQL,通常是两种原因:XML 文件没放在resources/mapper目录下;或者application.yml里没配置 mapper 扫描路径。
mybatis-plus.mapper-locations=classpath*:mapper/**/*.xml5.3 文档与部署:系统性交付的关键
打包部署这块容易被忽略,但却是项目能不能真正交付的关键。后端打包用的是 Maven,直接mvn clean package -DskipTests生成 jar 包,生产环境用nohup java -jar hospital-server.jar启动。如果需要修改端口,启动命令带参数:
nohup java -jar hospital-server.jar --server.port=8080 \ --spring.profiles.active=prod \ --spring.datasource.url="jdbc:mysql://..." \ > app.log 2>&1 &前端打包是npm run build,产物在dist目录。最方便的做法是在 Nginx 里配置静态资源服务,并把/api反向代理到后端:
server { listen 80; server_name hospital.example.com; location / { root /www/hospital-frontend; index index.html; 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; } }这里配置try_files $uri $uri/ /index.html是必要的,Vue Router 用 history 模式时刷新任意子路由页面都得能回到 index.html,否则会 404。
项目文档我习惯用 Markdown 写到docs/目录,包含:环境准备文档、数据库初始化 SQL 脚本、接口文档入口、部署手册。这样任何一个新人拿到项目都能在半天内跑起来。
6. 可扩展的方向与我的实战心得
这套挂号就诊系统做完之后,我发现它的扩展能力强得超出预期。它不是一个静态的毕业设计,而是一个可以不断往里面加模块的基础框架。
如果要在真实医院环境中落地,至少要补三块内容:
在线支付:挂号单表增加pay_status和pay_time字段,对接微信支付或支付宝。这里要注意,支付回调必须是后端主动查询核对,不能完全依赖前端跳转结果,否则金额对不上很麻烦。
消息通知:挂号成功、停诊提醒、就诊前一天提醒,可以通过短信或微信模板消息推送。定时任务每天扫一遍第二天的挂号单,筛选出待就诊状态的记录,批量发通知。
电子病历与报告查询:在就诊环节增加病历录入、检验报告上传,患者端可以查看历史病历。这块是医院信息化真正的刚需,虽然是很大的工程,但表设计上完全能兼容。
最后说几句真心话。我在这套系统上踩过最大的坑,不是某个具体技术细节,而是"东拼西凑的代码堆在一起没人能维护"。技术栈老练与否真的不是第一位的,第一位是思路清晰、模块边界合理、数据建模严谨。Spring Boot 2 + Vue 3 + MyBatis-Plus + MySQL 8.0 这套组合,胜在每一层都有成熟的生态支撑,遇到问题能找到现成解决方案,这才是做业务系统最看重的。
如果你是自己学习,建议不要只看源码就完事,自己动手把挂号流程、号源扣减、权限控制这几个核心场景从零写一遍。写不出来的地方再回头看项目代码,收获会大得多。项目里附带的那份文档也值得认真读一遍,很多设计和取舍在文档里讲得比代码清楚。