开源项目这东西,最怕的就是代码闭眼能跑、文档翻遍看不懂。这份Java Web中小型医院网站系统就是冲着"能跑、能改、能答辩"这三个目标来的,技术栈上选了SpringBoot2做后端、Vue3做前端、MyBatis-Plus负责数据库交互、MySQL8.0存数据,前后来一套完整的医院网站业务闭环。我拿到源码后第一件事就是把整个项目从数据库建表到前后端联调完整复现了一遍,这篇文章就把我的实操记录、参数选择、踩坑经历全部摊开讲,不管是拿来做毕业设计,还是想学前后端分离项目结构,都有可以直接抄作业的参考价值。
1. 项目整体设计与技术选型思路
1.1 中小型医院网站到底需要哪些功能模块
先别急着看代码,拿到这种项目第一件事是梳理业务模型。中小型医院网站和普通企业官网有本质区别——它不只是展示页面,而是要撑起真实就诊流程里的预约、查询、展示三类核心任务。
从源码里的 controller 和表结构能反推出一套很典型的业务设计:医院资讯模块负责动态公告和健康科普的发布展示;科室介绍模块把医院内外科、儿科、妇产科等科室做成了可配置的树状结构;医生团队模块和科室关联,展示擅长领域和排班时间;在线预约挂号是核心业务,前端选科室、选医生、选时段,后端生成预约记录并维护状态;系统管理模块用 Spring Security + JWT 撑起管理员后台,普通用户和医生、管理员各有不同权限。
这套设计的聪明之处在于它没有贪大求全。没有去做复杂的电子病历、移动支付、排队叫号,只聚焦在"用户能浏览医院信息并约到号"这个最小可用闭环上。对学习者和毕设场景来说,复杂度刚好够展示技术水平,又不至于让工作量失控。
1.2 为什么是 SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0
技术栈的选型是这个项目含金量最高的部分。SpringBoot2 放在今天看起来不是最新版本,但它是目前中文社区资料量最大、面试和毕设最常用的版本,整合 Redis、Security、第三方 SDK 时遇到的问题几乎都能搜到现成答案。SpringBoot3 虽然性能更好,但 Jakarta EE 的包名变更和 Spring Security6 的配置差异会让新手踩坑成本翻倍,这个项目选 SpringBoot2 反而是最稳妥的判断。
Vue3 的选择是紧跟趋势的。Vue2 官方维护已经进入尾声,新开的不复杂项目用 Vue3 是国内现在的主流共识。项目更进一步用了组合式 API(setup 语法糖)而不是选项式 API,配合 Element Plus 组件库,页面逻辑复用效率明显高一个档位。
MyBatis-Plus 则是杀鸡用牛刀但用得很爽的典型。常规模块直接继承 BaseMapper,单表 CRUD 不用写一行 SQL;复杂查询再配合 LambdaQueryWrapper 用链式条件拼装,可读性比 XML 里的动态 SQL 强很多。它的乐观锁插件、分页插件、自动填充功能都是真实生产环境常用的能力,写到简历上不虚。
MySQL8.0 是目前各大云数据库和本地开发默认的版本,这项目没有迁就老版本去用 5.7 的 mysql-connector-java 依赖,而是直接用了 mysql-connector-j 8.0.x,从根上避开了连接驱动兼容性问题。
1.3 前后端分离架构的请求流转路径
整个项目是基于前后端完全分离的思路搭建的,这在部署方式上和传统的 SpringBoot 打包 JSP/Thymeleaf 模板有天壤之别。浏览器先向 Vue3 开发服务器(或构建后的 Nginx 静态目录)请求页面资源,页面里的 axios 再向后端 SpringBoot 服务发/api/**请求,Spring Security 放行登录注册这些公开接口,其余接口要求请求头里带着 JWT token 做身份认证。
实际开发里这种架构最麻烦的是跨域问题。前端站点是http://localhost:5173(Vite 默认端口),后端是http://localhost:8080,端口都不一样,直接请求必然触发跨域拦截。所以项目里配了两种解决路径,一个是 Vite 开发服务器代理转发,一个是后端 CORS 配置类。我实际操作下来推荐优先用 Vite 代理方式,因为它在生产环境用 Nginx 反向代理时思路完全一致。
2. 环境准备与数据库核心配置
2.1 MySQL 8.0 的安装与字符集坑
拿到项目文档后第一步不是改代码,而是先把 MySQL8.0 跑起来。Windows 用户最省事的是用 ZIP 免安装版:解压后用管理员身份打开终端,在bin目录执行下面的初始化命令:
mysqld --initialize-insecure注意这里用initialize-insecure而不是initialize,区别在于前者会生成一个密码为空的 root 账户,适合本地开发;正式initialize会生成随机强密码,落在data目录的日志文件里,新手容易找不到。
然后是注册 Windows 服务并启动:
mysqld --install mysql8 net start mysql8服务起来之后连接进去建库。用 Docker 跑 MySQL8.0 也是我强烈推荐的方式,一条命令完成,镜像拉取后容器一启就自动初始化:
docker run -d --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=123456 \ -e TZ=Asia/Shanghai \ -v mysql8-data:/var/lib/mysql \ mysql:8.0这里有两个必须注意的点。第一是TZ=Asia/Shanghai一定要配,否则数据库容器默认 UTC 时区,和 SpringBoot 里中国时区时间戳一对比就会出现时间差八个小时的诡异问题。第二是建议在 my.cnf 或 docker 启动参数里显式配置:
[mysqld] character-set-server=utf8mb4 collation-server=utf8mb4_unicode_ciutf8mb4 才是真正完整的 UTF-8,覆盖 emoji 字符和生僻字。项目里涉及患者备注、家庭住址这类字段,用老式的 utf8mb3 在特殊字符插入时会直接把应用搞报错,这是数据库层最值得提前处理好的隐患。
2.2 初始化 SQL 脚本的核心表结构设计
项目文档目录下的sql/hospital.sql就是完整的库表初始化脚本,我个人实际导入时没有做任何修改就能直接用。这里我挑几张核心表展开说设计思路。
用户表sys_user设计得很克制——主键 id、用户名、加密密码、真实姓名、手机号、角色标识(admin/doctor/user)。密码字段存的是 BCrypt 加密后的哈希串,这也是 Spring Security 内置支持的标准加密算法,注意是不能反解密的,所以千万别在数据库里直接手改成明文,否则登录永远失败。
预约核心表appointment的字段我贴一下实际结构关键部分:
CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '预约人ID', doctor_id BIGINT NOT NULL COMMENT '医生ID', schedule_id BIGINT NOT NULL COMMENT '排班ID', appointment_date DATE NOT NULL COMMENT '就诊日期', time_slot VARCHAR(20) NOT NULL COMMENT '时段:上午/下午', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待就诊 1已完成 2已取消', remark VARCHAR(255), create_time DATETIME, update_time DATETIME );字段time_slot用上午/下午这种可读性强的字符串而不是 0/1 数字,严格意义上会牺牲一点存储效率,但在中小型项目里能直接看明白业务状态,减少排查问题时的脑力消耗,我觉得这个取舍是合理的。status字段用数字枚举,是因为它的状态流转逻辑比较复杂(待就诊、完成、取消),用数字避免前后端约定字符串时大小写不匹配的问题,这也是实际开发里很常见的策略。
2.3 SpringBoot 2.7 连接 MySQL8.0 的驱动版本选择
这个项目里 SpringBoot 版本是 2.7.18,数据库驱动跟 MySQL8.0 匹配存在一个非常经典的坑。老教程里常用的com.mysql.jdbc.Driver和mysql-connector-java只支持到 MySQL5.7,连 8.0 数据库必报Public Key Retrieval is not allowed或者CLIENT_PLUGIN_AUTH is required错误。
SpringBoot2.7.18 里正确引入驱动的方式是:
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> <scope>runtime</scope> </dependency>注意 artifactId 是mysql-connector-j,不是老的mysql-connector-java。对应的application.yml配置也需要兼顾时区与 SSL 行为:
spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.DriveruseSSL=false是因为本地开发没有配置 SSL 证书,不关的话 MySQL8.0 默认尝试 SSL 加密连接,容易在调试阶段多出些无意义的告警。allowPublicKeyRetrieval=true是配合 caching_sha2_password 认证插件用的,不配置这个参数,登录阶段直接报Public Key Retrieval is not allowed,很多人第一次遇到会误以为密码错了,其实只是少了这一行。
3. 后端核心架构与功能模块实现
3.1 统一返回结构与全局异常处理
这个项目在后端代码组织上有几个值得直接照抄的设计模式。第一个是统一响应体Result<T>,结构固定为code、message、data三个字段:
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("操作成功"); r.setData(data); return r; } public static <T> Result<T> error(String message) { Result<T> r = new Result<>(); r.setCode(500); r.setMessage(message); return r; } }所有 Controller 方法统一返回Result,前端 axios 响应拦截器只要判断code === 200就放行进业务逻辑,否则统一弹错误提示。前端不用在每个页面写 try-catch 处理错误,后端也只要维护一套错误边界。配上@RestControllerAdvice全局异常处理器,把校验异常、业务异常、兜底异常三档分开处理,接口报错时的信息会干净很多,联调时一眼就能定位是前端传参问题还是后端逻辑问题。
3.2 MyBatis-Plus 公共字段自动填充实现
MyBatis-Plus 在这个项目里除了基础 CRUD,最值得提的是字段自动填充功能。所有业务表都有create_time和update_time两个字段,如果靠每个 Service 手动 set,代码里全是噪音。项目里通过实现MetaObjectHandler接口统一处理:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }实体类对应字段上标两个注解:
@TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.UPDATE) private LocalDateTime updateTime;这样做的好处是所有 Mapper 的 insert/update 操作自动维护时间字段,不会出现某个业务模块漏赋值导致数据库时间全部为空的情况。如果你手上拿的是简化版项目,发现表里没有这两个字段,建议不要强行加,否则会影响已有查询语句,重要度排序永远以可以运行为先。
3.3 预约挂号核心事务流程
预约挂号是全系统最核心的接口,实现时牵扯到并发控制,这里把关键代码和设计思路展开。
先看 Service 方法的核心流程:
@Transactional(rollbackFor = Exception.class) public Result<?> createAppointment(AppointmentDTO dto) { // 1. 校验用户登录态(由拦截器注入用户ID) Long userId = SecurityUtils.getCurrentUserId(); // 2. 查询排班信息并锁定该行 Schedule schedule = scheduleMapper.selectByIdForUpdate(dto.getScheduleId()); if (schedule == null) { return Result.error("排班信息不存在"); } // 3. 判断余号是否充足 int usedCount = appointmentMapper.countByScheduleId(dto.getScheduleId()); if (usedCount >= schedule.getTotalNumber()) { return Result.error("该时段号源已约满"); } // 4. 插入预约记录 Appointment appointment = new Appointment(); appointment.setUserId(userId); // ... 省略字段赋值 appointmentMapper.insert(appointment); return Result.success("预约成功"); }这里有两个关键细节。第一个是@Transactional必须加上,一旦插入预约的过程中任何一个环节抛异常,数据库能自动回滚,不会出现"排班减少了一个号但预约记录没有生成"的一致性崩坏。第二个细节是selectByIdForUpdate这个方法,实际是用了 MyBatis-Plus 的自定义 SQL 加了一行FOR UPDATE,它的作用是给这条排班记录加上行级锁,这样两个用户同一毫秒点击预约按钮时,数据库层面就会让后一个请求阻塞到前面的请求事务提交,再执行时重新读到最新的余号数量。
对于中小型医院网站来说,这种基于数据库行锁的方案比 Redis 分布式锁简单可靠得多,不用额外引入中间件部署成本。到了要支持秒杀级高并发的规模再考虑 Redis Lua 脚本方案也不迟,现在这个阶段选行锁是正确的架构判断。
3.4 基于 JWT 的登录鉴权流程
登录模块是前后端分离项目里最容易写砸的部分。这个项目的做法是标准且成熟的:登录接口接收用户名密码,校验通过后生成 token 返回给前端,前端存进 localStorage 并每次请求放入 Header。
核心逻辑在JwtUtils里:
public String generateToken(Long userId, String username, String role) { Date now = new Date(); Date expireDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .claim("role", role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }JWT 的精髓在服务端不存登录状态,token 里自己带着身份信息和过期时间。服务端用一个过滤器拦截请求,取出 Header 的 token,解析校验签名和有效期,解析成功才放行。这个方案天然适合水平扩展——随便加多少个后端实例,token 都能独立校验,不需要共享 Session。
实际项目里拦截器/过滤器里还做了一个细节处理:失败时返回的 HTTP 状态码用的是 401,而不是笼统的 500。前端 axios 响应拦截器里针对status === 401做一次跳转登录页并清除本地 token,这个配合让会话过期后的用户体验非常流畅。拿到源码后你可以搜索401字样直接定位这一处,对着理解前后端鉴权闭环会很清楚。
3.5 分页查询与条件检索的 MyBatis-Plus 实现
科室列表、医生列表、公告列表、预约记录这些高频接口全部要用分页,项目里直接用 MyBatis-Plus 分页插件。特别提醒一下,分页插件必须显式配置,不然会发现执行了selectPage返回的全量数据且不受limit限制,这是非常常见的集成漏项。
分页配置类:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }实际业务代码里条件检索结合的写法:
public PageResult<Doctor> pageDoctors(DoctorQuery query) { LambdaQueryWrapper<Doctor> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.hasText(query.getDepartmentId()), Doctor::getDepartmentId, query.getDepartmentId()) .like(StringUtils.hasText(query.getName()), Doctor::getName, query.getName()) .orderByAsc(Doctor::getSortOrder); Page<Doctor> page = new Page<>(query.getPageNum(), query.getPageSize()); doctorMapper.selectPage(page, wrapper); PageResult<Doctor> result = new PageResult<>(); result.setTotal(page.getTotal()); result.setRecords(page.getRecords()); return result; }这里的LambdaQueryWrapper最大优点是类型安全,字段名用方法引用而非字符串,重构时字段改名 IDE 能直接报错,不会等到运行期才炸。条件拼接用StringUtils.hasText做判空,前端不传的参数不会拼进 SQL,天然的防空指针。
4. 前端 Vue3 项目结构与页面实现
4.1 Vite 脚手架与项目初始化配置
前端是在 Vue3 + Vite 环境下构建的,Vite 的快是真的快,热更新几乎是秒级响应。创建项目命令:
npm create vite@latest hospital-web -- --template vue cd hospital-web npm install npm install vue-router@4 pinia axios element-plus需要单独装的依赖大概就四个:路由、状态管理、HTTP 客户端、UI 组件库。Element Plus 的按需自动导入配置在vite.config.js里,这是 Vue3 结合 Element Plus 最规范的做法:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' 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()] }) ], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })中间这段 proxy 配置就是解决跨域的关键了。前端请求/api/login时 Vite 会把请求转发到http://localhost:8080/api/login,浏览器只看到请求发给了同源的 5173 端口,就不会触发跨域拦截。正规企业项目里生产环境用 Nginx 也会配置一模一样的location /api { proxy_pass http://后端服务; },思路完全复刻。
4.2 路由权限控制与前端登录拦截
前端路由用 Vue Router 4,项目里配了一个很实用的全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else if (!token) { next('/login') } else { next() } })这段逻辑在拿到项目后建议认真看一遍。它覆盖了三个场景:访问登录页直接放行,没 token 访问任何页面都跳到登录页,有 token 则正常访问。缺点是只判断了 token 存在与否,没有校验 token 是否过期,过期请求返回 401 才跳回登录页。这个设计属于可用但可以再完善,实际改造方向是在 axios 响应拦截器里加一个统一的 401 处理,把跳转逻辑收敛起来。
4.3 医院首页与科室详情页的组件化实现
网站首页由Home.vue聚合多个子组件:顶部导航栏、轮播图、科室快捷入口、今日医生出诊、公告列表、页脚。用 Vue3 组合式 API 实现时,页面组件的 script 部分长这样:
<script setup> import { ref, onMounted } from 'vue' import { getDepartmentList } from '@/api/department' import { getHotDoctors } from '@/api/doctor' const departmentList = ref([]) const doctorList = ref([]) const loading = ref(false) onMounted(async () => { loading.value = true try { const [deptRes, doctorRes] = await Promise.all([ getDepartmentList(), getHotDoctors({ limit: 4 }) ]) departmentList.value = deptRes.data doctorList.value = doctorRes.data } finally { loading.value = false } }) </script>Promise.all的用途是让首页的两个数据请求并发执行,而不是串行等待。串行写法会让首页白屏时间翻倍,这种细节在移动端弱网环境下体感差距极其明显。页面模板部分用 el-skeleton 做加载占位,数据没回来时显示骨架屏而不是空白,观感上专业很多。
科室详情页则要处理路由参数id,根据后端返回的科室实体数据渲染介绍长文和下属医生列表。长文用v-html渲染前注意一个安全问题:如果后端返回的富文本来自不可信的编辑器内容,存在 XSS 注入风险,这个项目里内容是管理员后台录入的固定数据,风险可控,但如果自己扩展,建议用 DOMPurify 之类的库先过滤再渲染。
4.4 预约页面的表单校验与提交逻辑
预约页是整个前端交互最复杂的页面,依赖用户先选科室、再选医生、再选日期、最后选上午还是下午。本项目用 Element Plus 表单完成科室和医生选择,排班时段用单选卡片:
<el-form :model="form" :rules="rules" ref="formRef"> <el-form-item label="选择医生" prop="doctorId"> <el-select v-model="form.doctorId" placeholder="请选择医生"> <el-option v-for="doc in doctorList" :key="doc.id" :label="`${doc.name}(${doc.title})`" :value="doc.id" /> </el-select> </el-form-item> <el-form-item label="就诊日期" prop="appointmentDate"> <el-date-picker v-model="form.appointmentDate" type="date" :disabled-date="disabledDate" value-format="YYYY-MM-DD" /> </el-form-item> </el-form>disabled-date会禁用今天之前的日期,用户只能预约未来时间,这是预约业务里的基础规则,后端同样校验了这个日期逻辑。前端负责体验,后端负责安全,两边都要做,这就是前后端双重校验的标准姿势。
提交逻辑会先从 pinia 的 user store 里读登录用户 token,无 token 时弹登录窗,有 token 则调预约接口。预约成功后跳转个人中心页能看到预约记录和状态。如果你要自己二次开发,把"预约成功页面"升级成"预约成功通知短信+微信模板消息",后端只需在创建预约成功后加一个异步通知任务即可。
4.5 管理员后台的 CRUD 页面实现套路
管理员后台在项目里是典型的前端增删改查页面集合,覆盖医生管理、科室管理、公告管理、排班管理四个模块。以医生管理为例,页面的核心套路是一致的:el-table展示列表、el-pagination分页、el-dialog放编辑表单、弹窗提交后重新拉列表。
这类页面有一个细节值得讲:编辑和新增共用同一个对话框组件,项目里用了个ref.value = null区分新增和编辑状态,每次打开前先重置表单。如果表单各项有默认值,建议用Object.assign({}, defaultForm)做深拷贝,否则重置时改了默认值对象里的嵌套字段,后续每次打开表单都会残留脏数据。这是我踩过的真实教训,第一次做管理系统时重置表单直接赋值同一个引用对象,用户数据对下一个用户可见,Bug 级别很高。
数据请求部分,标出两个关键经验:
const getAllDoctors = async () => { const res = await getDoctorList({ pageNum: page.value, pageSize: size.value, ...filters.value }) list.value = res.data.records total.value = res.data.total }filters用响应式对象收集查询条件时,注意每次查询前确认字段名和后端 DTO 完全一致,比如pageNum和pageNum,别写错成传统 Java 风格的pageNum转驼峰导致查询条件失效。一般项目会统一用驼峰风格,不会出现这个问题。
5. 项目部署与环境踩坑实录
5.1 本地前后端联调的端口配置
前后端分离项目联调时,最容易出现的诡异数据问题通常是后端日志里看到请求进来了,但前端报的却是 404 或 405。404 一般是路由没有匹配上 controller 的 RequestMapping,405 一般是前端的请求方法(GET/POST)和后端不一致。
这时第一步是看一眼后端启动日志里有没有出现:
Tomcat started on port(s): 8080 (http) with context path ''有这行说明服务起来了,再用 curl 手动测试接口,验证接口本身是否可用:
curl http://localhost:8080/api/doctor/list后端接口返回正常,问题就锁定在前端代理配置或者请求地址前缀上。Vite 代理是changeOrigin: true就基本能满足,但一定要确认前端 axios 实例的baseURL是/api而不是http://localhost:8080。如果 baseURL 写死了实际后端地址,代理配置再对也没用,请求直接发出去了,绕过了代理层,还是跨域。
5.2 SpringBoot 应用打包与运行
SpringBoot 后端标准打包方式是 Maven 构建 fat jar:
mvn clean package -DskipTests java -jar target/hospital-server.jar打包时间实测在三秒左右,启动后看到 Spring Boot 的启动图案就说明 jar 没问题。如果是在服务器上部署,建议把 jar 文件放到固定目录,然后用nohup java -jar hospital-server.jar > server.log 2>&1 &后台启动,日志重定向到文件里方便排查崩溃原因。
Vue3 前端构建产物是纯静态文件:
npm run build构建完的dist目录可以直接丢给 Nginx。Nginx 的站点配置核心是把前端 history 路由的刷新 404 问题处理掉:
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/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }没有这一行try_files $uri $uri/ /index.html,前端页面点浏览器刷新按钮就会因为 history 模式下找不到真实文件路径返回 404。这个问题在 Vue Router 的 createWebHistory 模式下是必然发生的,也是部署阶段最常用问题第一名。
5.3 MySQL 8.0 时报错速查与解决方案
整个复现过程中我整理了五个最高频的报错场景,做成了一张排查速查表,直接对着抄:
| 报错现象 | 根因 | 解决方案 |
|---|---|---|
Public Key Retrieval is not allowed | MySQL8.0 默认 caching_sha2_password 插件需要公钥 | JDBC 连接串加allowPublicKeyRetrieval=true |
The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized | 连接串没指定时区或字符集编码问题 | 连接串加serverTimezone=Asia/Shanghai,并确保characterEncoding=utf8 |
Access denied for user 'root'@'localhost' | root 密码错误或 hosts 表限定了 localhost | 确认安装时设置的密码,Docker 容器内用docker exec -it mysql8 mysql -p测试 |
Table 'hospital.appointment' doesn't exist | 初始化 SQL 未执行或者库名对不上 | 用source命令导入完整 SQL,检查数据库名是否和application.yml一致 |
| 页面中文全部乱码 | 前端静态文件、数据库字符集、页面 meta 三方不一致 | 数据库、连接串、HTML meta charset 全部统一 utf8mb4/utf-8 |
还有一个隐藏点值得提一下:MySQL8.0 的 ODBC 驱动如果要配合其他桌面工具使用,需要安装mysql-odbc-connector并保证系统里有 Microsoft Visual C++ 2015-2022 Redistributable 运行库。这个问题和项目本身无关,但很多人在 Navicat 上连不上 MySQL8.0 时第一个想到的就是 ODBC 驱动失效,顺手把运行库装上能省不少排查时间。
5.4 从 Vue2 迁移或从零接触 Vue3 的学习要点
如果你是第一次接触 Vue3,那这个项目里能看到几个典型的语法差异,我直接列出来:
composition API 的ref用于基本类型响应式,reactive用于对象类型,模板中使用时 ref 会自动解包,但在 script 中操作必须.value取值。初学最迷惑的就是这个,看到一堆.value以为写错了,其实是正常的。规则是:let count = ref(0)修改要写count.value++,模板里直接count。
生命周期方面,Vue2 的created、mounted在 Vue3 组合式 API 中变成了onMounted、onBeforeUnmount,而且可以在<script setup>中多次调用,这段在医生管理页面做事件监听的时取消就很有用:
onMounted(() => { window.addEventListener('resize', handleResize) }) onBeforeUnmount(() => { window.removeEventListener('resize', handleResize) })父子组件通信在 Vue3 里还是props和emit,只是 TypeScript 加持下的泛型校验更严格了。测试时可以先不着急上 TypeScript,先把语法跑通,再回来补类型,低成本平滑过渡。
6. 个人实操体会与扩展方向建议
整套项目完整跑通之后,我在本地又做了几个小改造来验证架构的扩展性。一个是给公告模块加了发布和下线两个状态字段,只改了announcement表一处和前端下拉筛选一个组件;另一个是给医生表加了一个出诊状态字段,首页"今日出诊"直接按照新字段过滤而不是依赖排班表,改动成本都很低。这从侧面印证了这套留着扩展余地的表结构设计是合格的。
如果拿到源码之后想继续往更完整的业务方向推进,我建议优先做三件事:第一是给排班模块加上一周的排班日历展示,而不是只有上下午两个时段,这块需要新增排班日期配置;第二是给预约加上取消流程并加上一号源回补逻辑,这部分要严格配合数据库事务,是练习并发控制的好场景;第三是给用户模块加上手机号验证和密码找回,这个对真实上线是刚需,同时能把 Spring Security 的自定义 UserDetailsService 再练一遍。
就我个人经验来说,这种中小型全栈项目最怕的不是某一个功能不会写,而是前后端数据约定不一致导致联调时间翻倍。这份源码里统一返回结构、统一命名规范、统一时间格式这三件事都做得比较收敛,复现过程中几乎没有因为字段驼峰或日期格式不匹配而浪费过时间。按照文档把数据库导入、依赖装齐、两个端跑起来,再花两到三个晚上把核心代码跟这篇记录对照着过一遍,你对这套技术栈的掌控程度会明显不一样。