
很多同学在学习 Java Web 开发时都卡在同一个地方单看 Spring Boot、Vue 或 MySQL 的教程都能看懂一旦要自己从零做一个前后端分离的完整项目就不知道该怎么把这三者串起来。课程网站系统是一个非常适合练手的全栈项目功能边界清晰、表结构简单、业务逻辑直观同时又覆盖了前后端分离开发中最常见的完整链路。这篇文章会围绕“课程网站系统”完整讲解一个可运行的全栈方案。后端使用 Java Spring Boot 3前端使用 Vue.js 3数据库使用 MySQL并包含课程管理、讲师信息、选课报名等基础功能。文章会从需求拆解、数据库设计到后端接口开发、前端页面实现再到联调排错和部署建议一步步带你把项目跑起来。无论你是准备做 Java 课程设计还是想补全 Spring Boot 3 Vue 3 的项目实战经验这篇文章都值得点赞收藏慢慢看。1. 项目背景与核心功能拆解1.1 课程网站系统解决什么问题课程网站系统的本质是把线下“课程报名与信息发布”流程搬到线上。传统情况下学员需要靠宣传海报、人工咨询了解课程信息运营人员需要手动登记报名学员非常容易漏记、错记。系统化之后通常需要解决以下几类问题课程信息统一发布课程名称、封面图、讲师、课时、价格、开课时间等不再靠人传人。学员注册与登录为后续选课、报名、查看个人课程提供身份基础。课程检索浏览学员能按分类或关键词快速找到可报名的课程。报名与选课学员对感兴趣的课程发起报名后台能统计报名人数和学员列表。后台数据管理管理员维护课程信息、上下架课程也能查看报名记录。放到课程设计或毕业设计场景中它还是理解“角色权限、关联表、后端分层、前端组件化”的好载体。你以后去做电商、知识付费、在线教育项目时很多表结构和服务代码都可以复用。1.2 为什么选择 Java Spring Boot 3 Vue.js 3 MySQL这个组合在 2024 到 2025 年的 Java 后端学习路线里非常主流原因主要有三点。第一Spring Boot 3 是当前 Java 后端的主线版本。它基于 Spring Framework 6全面支持 Jakarta EE最低要求 Java 17。相比 Spring Boot 2.xSpring Boot 3 在启动性能、可观测性、AOT 编译等方面都有升级新项目从 3.x 起步更合适。第二Vue.js 3 已经成为前端开发的主流选择。Vue 3 引入了 Composition API、Teleport、Suspense 等特性配合 Vite 开发服务器开发体验和构建速度都明显优于 Vue 2 Webpack。对后端同学来说Vue 3 的组件化开发方式清晰心智负担并不大。第三MySQL 在中小型项目中的普适性很强。MySQL 是最常见的关系型数据库之一招聘岗位要求多、社区资料丰富适合存课程、用户、报名记录等结构化数据。课程网站这类系统对事务要求不高但又能用它练习索引、关联查询、事务等数据库基本功。1.3 本文实现的功能范围为了避免文章篇幅失控本文的目标是做一个“麻雀虽小、五脏俱全”的单体项目功能范围设定如下用户端用户注册 / 登录浏览课程列表查看课程详情报名课程。管理端登录后简单用角色区分可以发布课程、编辑课程、下线课程查看报名列表。技术实现上后端只提供 RESTful API前端通过 Axios 调用接口并渲染页面。权限不过度复杂化演示用简单的拦截器 角色字段控制即可生产环境可以再引入 Spring Security。2. 技术栈与环境准备2.1 开发环境清单在动手之前建议先确认本地环境是否就绪。版本需要根据实际情况调整本文示例以常见环境为例重点演示配置思路。软件建议版本用途JDK17 或 21Spring Boot 3 编译运行基础Maven3.6后端依赖管理与构建MySQL8.0数据存储Node.js18前端 Vite 构建环境包管理器npm 或 pnpm安装前端依赖IDEIntelliJ IDEA 或 VS Code代码编写如果你本机还没有 Java 环境需要先安装 JDK 并配置JAVA_HOME如果还没有 MySQL可以参考常见的 MySQL 安装教程完成安装记得把 root 密码记好后面写在配置里。强烈建议使用 JDK 17 以上版本因为 Spring Boot 3 在 JDK 8 上根本无法启动。如果看到UnsupportedClassVersionError或启动时报 Java 版本错误先检查java -version。2.2 初始化后端工程后端工程建议直接使用 Spring Initializr 生成。可以访问 start.spring.io也可以直接在 IDEA 中通过 Spring Initializr 创建。创建时选择ProjectMavenLanguageJavaSpring Boot3.x 稳定版本PackagingJarJava17 或 21DependenciesSpring Web、Validation、MySQL Driver、LombokSpring Initializr 生成的项目里会包含一个mvnw包装器所以你不需要单独安装 Maven 也能构建。2.3 初始化前端工程前端工程使用 Vite 创建命令如下npm create vitelatest course-frontend -- --template vue cd course-frontend npm install npm install vue-router4 axios element-plus npm run dev说明一下上面安装的依赖vue-router4Vue 3 官方路由负责页面跳转。axiosHTTP 客户端用来调用后端接口。element-plusVue 3 的 UI 组件库课程表格、表单、消息提示都可以直接复用组件。前端默认运行在5173端口后端默认运行在8080端口。2.4 项目最终目录规划建议在本地新建一个总目录course-website下面放两个子项目course-website/ ├── course-server/ # 后端 Spring Boot 工程 │ ├── src/main/java/com/example/course/ │ └── src/main/resources/application.yml └── course-frontend/ # 前端 Vue 3 工程 ├── src/ │ ├── api/ │ ├── router/ │ ├── views/ │ └── main.js └── package.json这样拆分的好处是前端和后端代码互不干扰每个工程可以独立开发、独立启动符合前后端分离项目的真实工作方式。3. 数据库设计与初始化3.1 从需求到表结构把需求转成表时可以先从名词入手用户、课程、讲师、分类、报名。本文为了代码量适中采用 4 张核心表的设计user用户表存登录账号、密码、昵称、角色。角色用role字段区分STUDENT表示学员ADMIN表示管理员。category课程分类表存分类名称比如 Java、前端、数据库。course课程表存课程名称、封面图、简介、价格、课时、分类、上线状态。course_signup报名表记录哪个学员报名了哪门课程以及报名时间。为什么需要course_signup这张表因为一个学员可以报名多门课程一门课程也可以被多个学员报名这就是典型的多对多关系。常规做法是拆成一张中间表只在里面存储“谁在什么时候报了哪门课”的关联信息不要在课程表里加一个student_ids字段。3.2 完整建库建表脚本登录 MySQL 后先创建数据库再执行下面的 SQL-- 建库字符集选择 utf8mb4避免中文乱码 CREATE DATABASE IF NOT EXISTS course_website DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE course_website; -- 课程分类表 CREATE TABLE category ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 分类ID, name VARCHAR(50) NOT NULL COMMENT 分类名称, sort INT DEFAULT 0 COMMENT 排序值越小越靠前, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB COMMENT课程分类表; -- 用户表 CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名登录账号, password VARCHAR(100) NOT NULL COMMENT 密码建议BCrypt加密, nickname VARCHAR(50) COMMENT 昵称, role VARCHAR(20) NOT NULL DEFAULT STUDENT COMMENT 角色ADMIN/STUDENT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB COMMENT用户表; -- 课程表 CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 课程ID, category_id BIGINT COMMENT 分类ID, title VARCHAR(100) NOT NULL COMMENT 课程标题, teacher_name VARCHAR(50) COMMENT 讲师姓名, cover_url VARCHAR(255) COMMENT 封面图地址, intro TEXT COMMENT 课程简介, price DECIMAL(10,2) DEFAULT 0 COMMENT 价格单位元, total_hours INT DEFAULT 0 COMMENT 总课时, status TINYINT DEFAULT 0 COMMENT 是否上线0下线1上线, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, KEY idx_category_id (category_id), KEY idx_status (status) ) ENGINEInnoDB COMMENT课程表; -- 课程报名表 CREATE TABLE course_signup ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 报名ID, course_id BIGINT NOT NULL COMMENT 课程ID, user_id BIGINT NOT NULL COMMENT 学员ID, signup_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 报名时间, UNIQUE KEY uk_course_user (course_id, user_id), KEY idx_user_id (user_id) ) ENGINEInnoDB COMMENT课程报名表;几点设计解释课程表用status字段0 / 1表示下线和上线而不是直接删除记录。实际业务中课程历史数据往往有保留价值软状态下架更安全。course_signup中建立了(course_id, user_id)联合唯一索引可以同时做到“防止一个学员重复报名同一门课程”和“按课程查学员、按学员查课程”都走索引。所有表统一使用 InnoDB 引擎支持事务和外键相关操作。课程网站虽然事务不重但如果以后接支付报名事务会非常重要。3.3 插入基础测试数据为了让前后端联调时有真实数据可看插入几条分类和课程数据INSERT INTO category (name, sort) VALUES (Java, 1), (前端, 2), (数据库, 3); INSERT INTO category (name) VALUES (课程网站系统怎么建表数据);执行完成后可以通过下面语句快速确认SELECT id, name FROM category;同时可以插入一个管理员账号和一个学员账号。密码先不加密后台代码里我们会单独处理。INSERT INTO user (username, password, nickname, role) VALUES (admin, 123456, 系统管理员, ADMIN), (zhangsan, 123456, 张三, STUDENT);在演示阶段明文密码是可以的但后面接入正式项目前必须改成 BCrypt 加密存储。4. 后端开发Spring Boot 3 核心代码4.1 调整 pom.xml 依赖用 Spring Initializr 生成项目后默认已经有spring-boot-starter-web、spring-boot-starter-validation、mysql-connector-j等依赖。为了方便操作数据库还需要加入 MyBatis-Plus。MyBatis-Plus 是一个 MyBatis 的增强工具单表 CRUD 不用手写 SQL非常契合课程网站这类简单业务。修改后的核心依赖如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- mybatis-plus 官方提供了 Spring Boot3 的 starter -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- lombok减少 getter/setter 样板代码 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里有一个容易踩的坑如果你用的是 Spring Boot 2.xMyBatis-Plus 应该引入mybatis-plus-boot-starter而 Spring Boot 3 下需要引入mybatis-plus-spring-boot3-starter。两个坐标不一样类名与自动配置也存在差异不能混用。4.2 编写 application.yml修改src/main/resources/application.yml配置端口、数据源和 MyBatis-Plus。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/course_website?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 你的数据库密码 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0serverTimezoneAsia/Shanghai解决日期时间差 8 小时的问题allowPublicKeyRetrievaltrue是 MySQL 8 使用 caching_sha2_password 认证时常见连接参数。4.3 编写实体类与 Mapper以课程表为例实体类对应数据库字段package com.example.course.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; Data TableName(course) public class Course { TableId(type IdType.AUTO) private Long id; private Long categoryId; private String title; private String teacherName; private String coverUrl; private String intro; private BigDecimal price; private Integer totalHours; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; }Mapper 接口只需要继承BaseMapperpackage com.example.course.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.course.entity.Course; import org.apache.ibatis.annotations.Mapper; Mapper public interface CourseMapper extends BaseMapperCourse { }MyBatis-Plus 会自动生成单表 CRUD 方法比如selectById、selectList、insert、updateById。你不用为每一张表手写 XML 或 SQL业务代码可以更聚焦在接口设计上。4.4 Service 和 Controller 实现课程列表需要分页、按分类筛选并且要返回报名人数。纯单表查询拿不到报名人数我们可以先通过CourseMapper查出课程分页数据再把课程 ID 列表传给报名 Mapper 做聚合查询。先定义一个CourseServicepackage com.example.course.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.course.entity.Course; public interface CourseService { PageCourse listPublishedCourses(int page, int size, Long categoryId); Course getCourseDetail(Long id); Course createCourse(Course course); Course updateCourse(Long id, Course course); void deleteCourse(Long id); }实现类如下package com.example.course.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.course.entity.Course; import com.example.course.mapper.CourseMapper; import com.example.course.service.CourseService; import org.springframework.stereotype.Service; Service public class CourseServiceImpl implements CourseService { private final CourseMapper courseMapper; public CourseServiceImpl(CourseMapper courseMapper) { this.courseMapper courseMapper; } Override public PageCourse listPublishedCourses(int page, int size, Long categoryId) { LambdaQueryWrapperCourse wrapper new LambdaQueryWrapper(); wrapper.eq(Course::getStatus, 1) .eq(categoryId ! null, Course::getCategoryId, categoryId) .orderByDesc(Course::getCreateTime); return courseMapper.selectPage(new Page(page, size), wrapper); } Override public Course getCourseDetail(Long id) { Course course courseMapper.selectById(id); if (course null) { throw new RuntimeException(课程不存在); } return course; } Override public Course createCourse(Course course) { course.setStatus(0); courseMapper.insert(course); return course; } Override public Course updateCourse(Long id, Course course) { course.setId(id); courseMapper.updateById(course); return courseMapper.selectById(id); } Override public void deleteCourse(Long id) { courseMapper.deleteById(id); } }Controller 层使用 RESTful 风格统一加/api/v1前缀方便以后扩展版本。package com.example.course.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.course.common.Result; import com.example.course.entity.Course; import com.example.course.service.CourseService; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/v1/courses) public class CourseController { private final CourseService courseService; public CourseController(CourseService courseService) { this.courseService courseService; } GetMapping public ResultPageCourse list(RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size, RequestParam(required false) Long categoryId) { return Result.success(courseService.listPublishedCourses(page, size, categoryId)); } GetMapping(/{id}) public ResultCourse detail(PathVariable Long id) { return Result.success(courseService.getCourseDetail(id)); } PostMapping public ResultCourse create(RequestBody Valid Course course) { return Result.success(courseService.createCourse(course)); } PutMapping(/{id}) public ResultCourse update(PathVariable Long id, RequestBody Course course) { return Result.success(courseService.updateCourse(id, course)); } DeleteMapping(/{id}) public ResultVoid delete(PathVariable Long id) { courseService.deleteCourse(id); return Result.success(null); } }Result是一个统一返回类型包装类通常包含code、message、data三个字段。它的意义在于给前端一个统一的数据结构便于 Axios 响应拦截器统一处理业务错误。package com.example.course.common; import lombok.Data; Data public class ResultT { private int code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT error(int code, String message) { ResultT result new Result(); result.code code; result.message message; return result; } }4.5 开启虚拟线程与分页插件Spring Boot 3.2 以上版本提供了虚拟线程支持。Tomcat 默认每个请求占用一个平台线程线程数有限开启虚拟线程后可以减少 IO 密集型操作的线程资源浪费。在启动类中启用即可package com.example.course; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer; import org.springframework.context.annotation.Bean; import java.util.concurrent.Executors; SpringBootApplication public class CourseApplication { public static void main(String[] args) { SpringApplication.run(CourseApplication.class, args); } Bean public TomcatProtocolHandlerCustomizer? protocolHandlerCustomizer() { return protocolHandler - protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); } }注意虚拟线程功能在 JDK 21 上体验最好如果你还在使用 JDK 17需要删掉这一段否则编译报错。JDK 17 下也可以正常运行 Spring Boot 3只是享受不到虚拟线程的优化。另外还需要配置 MyBatis-Plus 分页插件新增一个配置类package com.example.course.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInnerInterceptor.setMaxLimit(100L); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }setMaxLimit(100L)限制单页最大条数防止前端传一个巨大的size拖垮数据库这是一种最基础的接口自我保护。4.6 启动后端验证接口在 IDEA 中启动CourseApplication看到类似日志表示成功Tomcat started on port 8080 (http) with context path Started CourseApplication in 1.5 seconds浏览器访问http://localhost:8080/api/v1/courses?page1size10正常返回课程分页 JSON 内容。这一步能通说明数据库连接、实体映射、Controller 路由全部没有大问题。5. 前端开发Vue.js 3 Element Plus5.1 配置 Vite 代理前后端分离开发时最常见的跨域方案并不是后端写 CORS而是让 Vite 开发服务器把/api开头的请求代理到localhost:8080。这样浏览器看到的请求始终是同源请求不会触发跨域限制。修改vite.config.jsimport { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })在开发阶段前端请求/api/v1/courses会被自动转发到http://localhost:8080/api/v1/courses。5.2 封装 Axios 请求模块新建src/api/request.js统一处理请求的基础 URL、超时时间和响应拦截import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器以后可以在登录后往 header 中加 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization 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 { ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default request这里有一点需要注意响应拦截器在res.code 200时返回res.data这样调用层直接拿到业务数据对象页面组件里不再需要反复写.data.data。课程相关的接口可以单独放到src/api/course.jsimport request from ./request export function getCourses(params) { return request.get(/v1/courses, { params }) } export function createCourse(data) { return request.post(/v1/courses, data) } export function updateCourse(id, data) { return request.put(/v1/courses/${id}, data) } export function deleteCourse(id) { return request.delete(/v1/courses/${id}) }5.3 配置 Vue Router新建src/router/index.jsimport { createRouter, createWebHistory } from vue-router const routes [ { path: /, redirect: /courses }, { path: /courses, name: CourseList, component: () import(../views/CourseList.vue) }, { path: /course/detail/:id, name: CourseDetail, component: () import(../views/CourseDetail.vue) }, { path: /admin/course/edit/:id?, name: CourseEdit, component: () import(../views/CourseEdit.vue) } ] const router createRouter({ history: createWebHistory(), routes }) export default router使用() import()做路由懒加载后访问对应页面才加载组件 JS首屏更快生产环境会自动分包。5.4 课程列表页课程列表页是最典型的前后端交互页面请求数据、渲染表格、处理加载态。template div classcourse-page el-row :gutter16 el-col v-forcourse in courseList :keycourse.id :span6 el-card classcourse-card shadowhover clickgoDetail(course.id) template #header span{{ course.title }}/span /template p讲师{{ course.teacherName }}/p p课时{{ course.totalHours }} 小时/p p classprice{{ course.price }}/p /el-card /el-col /el-row el-pagination v-model:current-pagequery.page v-model:page-sizequery.size :totaltotal layouttotal, prev, pager, next current-changeloadCourses / /div /template script setup import { ref, reactive, onMounted } from vue import { useRouter } from vue-router import { getCourses } from ../api/course const router useRouter() const courseList ref([]) const total ref(0) const query reactive({ page: 1, size: 10 }) async function loadCourses() { const data await getCourses(query) courseList.value data.records total.value data.total } function goDetail(id) { router.push(/course/detail/${id}) } onMounted(() { loadCourses() }) /script style scoped .course-page { padding: 24px; } .course-card { margin-bottom: 16px; cursor: pointer; } .price { color: #f56c6c; font-weight: bold; } /style页面中使用了 Element Plus 的el-row、el-col、el-card、el-pagination组件响应式布局和分页交互都不用自己写 CSS 和 JS。5.5 课程编辑页课程编辑页同时承担新增和编辑两种职责利用路由参数id是否存在来做判断。表单适合用el-form并配rules做基础校验。template el-form refformRef :modelform :rulesrules label-width100px el-form-item label课程标题 proptitle el-input v-modelform.title / /el-form-item el-form-item label讲师姓名 propteacherName el-input v-modelform.teacherName / /el-form-item el-form-item label价格 propprice el-input-number v-modelform.price :min0 :precision2 / /el-form-item el-form-item label课程简介 propintro el-input v-modelform.intro typetextarea :rows4 / /el-form-item el-form-item el-button typeprimary clicksubmit保存/el-button el-button clickrouter.back()返回/el-button /el-form-item /el-form /template script setup import { ref, reactive, onMounted } from vue import { useRoute, useRouter } from vue-router import { ElMessage } from element-plus import { createCourse, updateCourse } from ../api/course const route useRoute() const router useRouter() const formRef ref() const form reactive({ title: , teacherName: , price: 0, totalHours: 0, intro: }) const rules { title: [{ required: true, message: 请输入课程标题, trigger: blur }], teacherName: [{ required: true, message: 请输入讲师姓名, trigger: blur }] } const courseId route.params.id async function loadDetail() { if (!courseId) return // 这里省略通过 getCourseDetail 获取详情的调用 } async function submit() { await formRef.value.validate() if (courseId) { await updateCourse(courseId, form) } else { await createCourse(form) } ElMessage.success(保存成功) router.push(/courses) } onMounted(loadDetail) /script点击保存后先做前端表单校验通过后再调接口。前端校验只能减少无效请求后端接口中的Valid校验才是安全底线。5.6 main.js 完整入口最后在src/main.js中引入 Element Plus 和路由import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue import router from ./router const app createApp(App) app.use(router) app.use(ElementPlus) app.mount(#app)6. 前后端联调与常见问题排查6.1 联调工具选择联调阶段建议先不用页面而是用 Apifox 或 Postman 直接调后端接口。先验证接口能正常返回再去前端排查问题。很多新手浪费大量时间在前端代码里找问题最后发现是后端接口根本没通。推荐联调顺序先用 Apifox 测试GET /api/v1/courses确认后端可用。启动前端工程直接访问http://localhost:5173/courses。打开浏览器开发者工具 Network观察/api/v1/courses请求状态。如果 404检查 Vite 代理路径和接口前缀。如果超时检查后端是否启动、端口是否一致。6.2 常见问题清单问题现象常见原因解决思路后端启动报Failed to configure a DataSource没有配置数据源或 MySQL 未启动检查application.yml数据源配置确认 MySQL 服务已启动Access denied for user数据库账号或密码错误确认 application.yml 中的用户名密码是否匹配本机 MySQLPublic Key Retrieval is not allowedMySQL 8 认证插件问题URL 中加allowPublicKeyRetrievaltrue前端请求 404Vite 代理没配置或接口前缀不对检查vite.config.jsproxy确认后端路径带/api前端请求 500后端代码报错查看后端控制台异常堆栈先解决后端问题CORS 跨域报错直接请求了 http://localhost:8080都改走/api代理同源请求不要用完整跨域地址中文字段乱码数据库字符集不是 utf8mb4建库时指定 utf8mb4URL 中加characterEncodingutf8UnsupportedClassVersionErrorJDK 版本过低切换 JDK 17并检查 IDE Project Structure 中的 SDK分页无效返回全部数据未注册分页插件添加PaginationInnerInterceptor配置类Error 2002连接不到 MySQLMySQL 未启动或 socket 路径错误Linux 下先重启 MySQL 服务检查服务状态如果遇到java.lang.NullPointerException不一定要马上改代码先看异常信息里提示的是哪一行很多情况是请求参数没有正确绑定或查询结果为空需要在 Controller 参数上增加RequestParam、PathVariable等注解。6.3 前端控制台如何快速定位问题前端页面打开后按 F12 打开开发者工具按优先级关注三个面板Network请求是否发出、状态码是多少、请求地址是不是 404。ConsoleJS 运行时报错、跨域报错、Axios 拦截器弹出的错误消息。Sources / Vue DevTools查看组件状态和路由是否正确。很多新手打开页面空白第一个反应是改代码实际上应该先看 Console 有没有报错。一个稳妥的排查流程是后端接口先通 → 前端控制台无 JS 报错 → 页面逐步渲染出数据。如果页面空白但接口通了重点检查响应拦截器返回的数据结构是否与页面代码读取字段一致。7. 扩展功能与工程化建议7.1 用户登录、JWT 与权限控制本文的课程管理接口没有做登录和权限控制这是为了让入门读者先跑通核心链路。真实的课程网站必须补充用户体系。推荐做 JWT 登录流程用户提交用户名密码后端校验通过后生成 token 返回。前端把 token 存在 localStorage后续请求放在 Authorization 请求头。后端使用拦截器解析 token把用户信息放入 ThreadLocal 或请求上下文。管理员接口额外校验role ADMIN没有权限就返回 403。JWT 的优点是后端无状态、方便横向扩展缺点是 token 签发后不能主动失效所以纯管理后台项目可以保守一点简单的 session 或基于数据库的 token 有时更可控。7.2 报名模块实现思路课程报名可以直接复用课程模块的分层思路。核心点在于报名表的联合唯一约束和事务Override Transactional public void signUp(Long courseId, Long userId) { boolean exists signupMapper.exists( new LambdaQueryWrapperCourseSignup() .eq(CourseSignup::getCourseId, courseId) .eq(CourseSignup::getUserId, userId) ); if (exists) { throw new RuntimeException(你已经报过这门课程); } CourseSignup signup new CourseSignup(); signup.setCourseId(courseId); signup.setUserId(userId); signupMapper.insert(signup); }Transactional在这里虽然只有一次插入但你先养成习惯后续如果要在报名时同时扣减名额、写订单流水就能避免“插入了报名记录但库存没扣成功”这类问题。7.3 推荐的学习进阶方向当你能把课程网站完整跑通之后接下来的学习重点可以按下面顺序展开第一把系统改造为“后台管理 用户前台”两个界面。后台用 Element Plus 管理课程上下架用户前台只展示已上架课程。这个过程能加深你对状态字段和角色权限的理解。第二引入 Spring Security 或 Sa-Token 做认证授权。不要满足于“拦截器 role 字段”的演示方案真正理解登录后的身份传递和保护机制。第三给关键接口补充参数校验和统一异常处理。比如用RestControllerAdvice捕获MethodArgumentNotValidException把校验错误信息统一返回给前端。第四部署上线。可以把后端打成 jar 包放到云服务器前端使用npm run build生成dist静态文件再用 Nginx 托管同时把/api请求反向代理到后端服务。当你完成了上述四步就已经具备独立开发一个中小型前后端分离项目的能力。此时再回看 Java 面试中关于 Spring Boot 自动配置、MyBatis-Plus 使用、MySQL 索引优化、Vue 组件通信等高频问题都会更有画面感因为你在项目里都实际经历过它们。代码示例建议结合自己的数据库密码、包名和前端页面做调整。如果在运行过程中遇到本文没有覆盖到的问题欢迎在评论区把报错信息发出来一起讨论排错思路。