ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot 3 + Vue 3 + MySQL 实战:构建完整课程网站系统

Spring Boot 3 + Vue 3 + MySQL 实战:构建完整课程网站系统 很多同学准备做“课程网站系统”这类 Java Web 项目时最后交出来的往往是“一个会动的静态网页”课程列表写死在页面里点击选课只是弹个提示重启后数据全部消失。原因是把大量精力放在了页面样式上反而忽略了最关键的工程链路——数据表怎么设计、后端接口怎么提供、前端怎么调用、数据怎么落库。这篇文章用 Spring Boot 3 Vue 3 MySQL 做一套完整、可运行的课程网站系统重点不是贴完整源码而是讲清楚一个真实学员选课业务从数据库到前端页面是怎么串起来的。文章会先明确项目定位再拆解核心模块、数据表设计和接口设计然后分别完成后端工程、前端页面、联调验证、问题排查和工程化建议。整篇内容不需要你已经会 Spring Security、Redis 或分布式架构但需要你有 Java 基础和 MySQL 基础。读者能得到的核心收获是理解前后端分离项目的开发顺序搞懂一张选课记录表如何用唯一索引防止重复选课学会用事务保证选课一致性并能独立完成 Spring Boot 3 Vue 3 项目的联调与部署。这是一个乍看普通、实际非常锻炼开发习惯的项目。1. 课程网站系统到底在做什么网上搜“课程网站系统”能搜到大量千篇一律的“JavaWeb 课程设计”。它们的问题通常不在功能少而是没有把开发主线和真实业务对齐。你做完一个课程网站系统面试官大概率会问三个问题一个用户重复点击选课你怎么防止脏数据课程表和选课表是什么关系为什么不用一个字段存多个课程 ID前端拿到后端接口返回的 JSON 之后怎么渲染成页面如果这三类问题答不清楚说明项目还停留在“照着模板改页面”的阶段。一个标准的课程网站系统核心业务逻辑可以拆成四条线课程内容展示线用户打开网站能看到课程列表、课程详情、课程分类用户选课线注册用户能选择课程、取消课程、查看自己已选课程后台管理线管理员能上架课程、修改课程信息、下架课程数据可靠性线选课记录不能重复用户不存在或课程不存在时不能写入数据多人操作时数据不能乱。本文不会把“后台管理”做成大而全的权限系统而是用最小可用范围把这条链路跑通学生查课程、学生选课、查看我的课程。你会发现只要把这一条核心链路做扎实后台管理无非是在同样的分层结构上增加接口而已。这里要给出一个明确判断课程网站系统真正考察的不是前端特效而是“数据库关系设计是否正确、后端接口职责是否清晰、前端有没有正确消费接口”。因此本文把后端和数据库作为重点展开前端提供可复制的 Vue 3 页面代码。2. 技术选型与前后端分离概念Spring Boot 3、Vue.js 3、MySQL 并不是三门独立技术它们组合后对应着一种典型的全栈开发模式。2.1 为什么选择这套组合先看后端。Spring Boot 3 是基于 Java 17 的微服务开发框架相比传统 Spring MVC 加 XML 配置的方式它的自动配置能力极大简化了项目启动成本。Spring Boot 3 默认使用 Jakarta EE 9 规范实体注解包名从 javax 变成了 jakarta这也是它和旧教程最明显的区别。再看前端。Vue 3 是目前国内使用率很高的前端框架组合式 API 让代码逻辑比 Vue 2 更清晰配合 Vite 开发服务器能实现秒级热更新。MySQL 则负责持久化。很多人在课程网站项目里忽视数据库设计直接用“用户表里塞一个 courseIds 字符串”这是十分危险的做法。系统上线后用户选课数量增长SQL 无法高效查询也无法保证数据完整性最终被迫重构。正确的做法是使用关系表表达用户与课程的多对多关系。2.2 前后端分离与传统 JSP 开发的区别传统 Java Web 开发中JSP 页面在服务端渲染 HTML再返回给浏览器。这样做在小页面时代够用但页面负责展示、跳转、数据访问职责混在一起项目维护成本高。前后端分离后职责发生了明确变化对比维度传统 JSP 方式Spring Boot 3 Vue 3 方式页面运行位置服务端生成 HTML浏览器端通过 JS 渲染数据获取方式JSP 标签直接查数据前端调用后端 JSON API部署单元WAR 包放 Tomcat后端 JAR 前端静态文件接口复用页面耦合接口一套 API 可以服务 Web/App/小程序团队协作往往一个人写到底前端、后端可并行开发这套结构也意味着接口设计很重要。前端只认识 HTTP 方法和 JSON 数据不关心后端用什么语言实现更不关心 MySQL 查了几张表。后端应尽量提供语义清晰的接口比如GET /api/courses表示查询课程列表POST /api/enrollments表示创建选课记录。3. 项目业务模块与数据库表设计数据库表设计是课程网站系统最容易暴露问题的地方。很多初学者喜欢把所有东西塞进一张表或者不确定主键、唯一约束怎么写。这里先给出整个项目的功能边界再逐表设计。3.1 功能模块划分实现一个完整可演示的课程网站系统至少需要以下模块课程模块课程基本信息、课程列表、课程关键词搜索用户模块用户基本信息、登录状态识别本文先简化用 userId 传递生产环境必须接认证选课模块学生选课、取消选课、查看我的课程课程资源关联为后续扩展章节课时预留结构。3.2 表结构设计数据库名设计为course_website统一使用 utf8mb4避免中文乱码。规划三张核心表第一张是用户表sys_user。命名为 sys_user 而不是 user是因为 MySQL 系统库中存在 user 表虽然普通业务可以建 user 表但更容易引起歧义。第二张是课程表course。课程表存储课程通用信息比如标题、讲师、分类、封面图 URL、课程描述。第三张是选课关系表course_enrollment。这张表表达了用户与课程的多对多关系一个用户可以选择多门课程一门课程可以被多个用户选择。关系表同时记录 userId 和 courseId。课程表与选课关系表是一对多关系用户表与选课关系表也是一对多关系。这里最关键的设计是(user_id, course_id)必须建立联合唯一约束这是数据库层面防止重复选课的最后一道防线。下面是初始化数据库的 SQL 脚本可以直接在 MySQL 中执行。-- 文件路径sql/init.sql CREATE DATABASE IF NOT EXISTS course_website DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE course_website; DROP TABLE IF EXISTS course_enrollment; DROP TABLE IF EXISTS course; DROP TABLE IF EXISTS sys_user; CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, nickname VARCHAR(50) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(120) NOT NULL, description TEXT, teacher_name VARCHAR(50) NOT NULL, category VARCHAR(50) DEFAULT NULL, cover_url VARCHAR(500) DEFAULT NULL, difficulty TINYINT NOT NULL DEFAULT 1 COMMENT 1入门 2进阶 3高级, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category), KEY idx_teacher_name (teacher_name) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE course_enrollment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, course_id BIGINT NOT NULL, enroll_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_course (user_id, course_id), KEY idx_course_id (course_id), CONSTRAINT fk_enroll_course FOREIGN KEY (course_id) REFERENCES course (id), CONSTRAINT fk_enroll_user FOREIGN KEY (user_id) REFERENCES sys_user (id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; INSERT INTO sys_user (username, password, nickname) VALUES (stu001, 123456, 小明), (stu002, 123456, 小红); INSERT INTO course (title, description, teacher_name, category, difficulty) VALUES (Java 基础入门, 适合零基础学员的 Java 核心语法课程, 王老师, 后端开发, 1), (Spring Boot 3 实战, 从 0 到 1 开发 RESTful API, 李老师, 后端开发, 2), (Vue3 组件化开发, 掌握组合式 API 与组件通信, 陈老师, 前端开发, 2), (MySQL 索引与优化, 理解索引原理与慢 SQL 排查, 刘老师, 数据库, 3);代码说明唯一索引uk_user_course是选课防重的关键。就算后端忘记判断数据库也能在第二次插入时抛出 DuplicateKeyException外键约束保证不会出现“为不存在的课程选课”的孤儿数据DROP TABLE IF EXISTS只适合开发环境初始化生产环境不能随意使用课程表中添加category和teacher_name索引因为常见的业务查询是按分类检索、按老师检索。如果觉得执行 SQL 麻烦也可以把 Spring Boot JPA 的spring.jpa.hibernate.ddl-auto配置成update让 Hibernate 根据实体自动建表。但我会更推荐手写 SQL因为你能明确知道表结构、索引、唯一约束到底是怎么生成的对排查线上问题更有帮助。4. 后端工程创建与基础配置后端项目使用 Maven 工程结构JDK 版本需要为 17 或以上。Spring Boot 3 不可以再使用 JDK 8这是一个硬性门槛。如果你准备用新版 Spring Boot 做课程网站先检查本机java -version。4.1 创建 Maven 工程IDE 推荐使用 IntelliJ IDEA创建 Spring Initializr 项目时选择 Java 17依赖选择 Spring Web、Spring Data JPA、MySQL Driver、Lombok。如果使用手动创建可以参考下面的 pom.xml。!-- 文件路径course-website/pom.xml -- ?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent groupIdcom.example/groupId artifactIdcourse-website/artifactId version0.0.1-SNAPSHOT/version namecourse-website/name description课程网站系统/description properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project说明Spring Boot 3 中 MySQL 驱动坐标是com.mysql:mysql-connector-j旧教程里的mysql:mysql-connector-java已经不建议使用。版本号以你的本地环境和 Spring Boot 版本为准这里只演示依赖结构。4.2 application.yml 配置在src/main/resources/application.yml中配置数据源和 JPA。# 文件路径course-website/src/main/resources/application.yml server: port: 8080 spring: application: name: course-website datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/course_website?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 jpa: hibernate: ddl-auto: none show-sql: true open-in-view: false properties: hibernate: format_sql: true配置说明ddl-auto: none表示不自动建表表结构由 init.sql 控制这样可以避免 Hibernate 自动生成结构与手写脚本不一致show-sql: true开发阶段建议开启方便看到每条 SQLopen-in-view: false是 Spring Boot 官方推荐的做法防止在视图层意外持有数据库连接allowPublicKeyRetrievaltrue用于兼容 MySQL 8 的 caching_sha2_password 认证插件开发环境方便生产环境要评估安全性。5. Spring Boot 3 后端代码实现后端不追求炫酷的架构但要有清晰的分层Controller 负责接收 HTTP 请求Service 负责业务逻辑Repository 负责数据访问。实体类放在 entity 包接口放在 repository 包。5.1 实体类课程实体类对应 course 表注意 import 包名是jakarta.persistence。// 文件路径src/main/java/com/example/coursewebsite/entity/Course.java package com.example.coursewebsite.entity; import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime; Getter Setter Entity Table(name course) public class Course { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, length 120) private String title; Column(columnDefinition TEXT) private String description; Column(name teacher_name, nullable false, length 50) private String teacherName; Column(nullable false, length 50) private String category; Column(name cover_url, length 500) private String coverUrl; Column(nullable false) private Integer difficulty 1; Column(name created_at, nullable false, updatable false) private LocalDateTime createdAt; PrePersist public void prePersist() { if (createdAt null) { createdAt LocalDateTime.now(); } } }注意点使用Column(name teacher_name)即使字段名是 teacherName也能准确映射到表字段 teacher_namePrePersist是 JPA 生命周期回调插入前自动填充创建时间Lombok 的Getter Setter可以减少样板代码减少手写 getter/setter 的出错概率。选课关系实体类如下// 文件路径src/main/java/com/example/coursewebsite/entity/CourseEnrollment.java package com.example.coursewebsite.entity; import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime; Getter Setter Entity Table( name course_enrollment, uniqueConstraints UniqueConstraint( name uk_user_course, columnNames {user_id, course_id} ) ) public class CourseEnrollment { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name user_id, nullable false) private Long userId; Column(name course_id, nullable false) private Long courseId; Column(name enroll_time, nullable false, updatable false) private LocalDateTime enrollTime; PrePersist public void prePersist() { if (enrollTime null) { enrollTime LocalDateTime.now(); } } }这里的独特之处在于用注解再次声明数据库唯一约束。就算有人绕过 Service 直接调用 Repository 保存数据数据库层的唯一索引仍然能拦住重复记录。5.2 Repository 数据访问层Repository 接口继承 JpaRepository就能获得常用的 find、save、delete 方法。// 文件路径src/main/java/com/example/coursewebsite/repository/CourseRepository.java package com.example.coursewebsite.repository; import com.example.coursewebsite.entity.Course; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; public interface CourseRepository extends JpaRepositoryCourse, Long { /** * 根据关键字搜索课程标题或讲师 */ Query(SELECT c FROM Course c WHERE :keyword IS NULL OR c.title LIKE CONCAT(%, :keyword, %) OR c.teacherName LIKE CONCAT(%, :keyword, %)) ListCourse search(Param(keyword) String keyword); }// 文件路径src/main/java/com/example/coursewebsite/repository/EnrollmentRepository.java package com.example.coursewebsite.repository; import com.example.coursewebsite.entity.CourseEnrollment; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface EnrollmentRepository extends JpaRepositoryCourseEnrollment, Long { boolean existsByUserIdAndCourseId(Long userId, Long courseId); ListCourseEnrollment findByUserId(Long userId); }Spring Data JPA 会根据方法名自动生成 SQL。existsByUserIdAndCourseId会生成判断记录是否存在的查询findByUserId会生成查询某用户选课记录的查询。这比手写复杂 SQL 清晰得多。5.3 业务层与事务处理选课业务看起来简单实际是典型的“写多张表或多次校验”的事务场景。服务层需要做三件事校验用户存在校验课程存在校验是否重复选课最后插入选课记录。如果用户同时点了两次“选课”按钮两个请求同时进入方法单纯靠 if 判断不够安全。这里有两层保护业务层的 exists 判断用于返回友好提示数据库唯一索引作为最后的兜底。// 文件路径src/main/java/com/example/coursewebsite/service/EnrollmentService.java package com.example.coursewebsite.service; import com.example.coursewebsite.entity.Course; import com.example.coursewebsite.entity.CourseEnrollment; import com.example.coursewebsite.exception.BusinessException; import com.example.coursewebsite.repository.CourseRepository; import com.example.coursewebsite.repository.EnrollmentRepository; import com.example.coursewebsite.repository.SysUserRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Objects; Service public class EnrollmentService { private final EnrollmentRepository enrollmentRepository; private final CourseRepository courseRepository; private final SysUserRepository sysUserRepository; public EnrollmentService(EnrollmentRepository enrollmentRepository, CourseRepository courseRepository, SysUserRepository sysUserRepository) { this.enrollmentRepository enrollmentRepository; this.courseRepository courseRepository; this.sysUserRepository sysUserRepository; } Transactional public void enroll(Long userId, Long courseId) { if (!sysUserRepository.existsById(userId)) { throw new BusinessException(用户不存在); } if (!courseRepository.existsById(courseId)) { throw new BusinessException(课程不存在); } if (enrollmentRepository.existsByUserIdAndCourseId(userId, courseId)) { throw new BusinessException(请勿重复选课); } CourseEnrollment enrollment new CourseEnrollment(); enrollment.setUserId(userId); enrollment.setCourseId(courseId); enrollmentRepository.save(enrollment); } Transactional(readOnly true) public ListCourse findCoursesByUserId(Long userId) { Objects.requireNonNull(userId, userId cannot be null); return enrollmentRepository.findByUserId(userId).stream() .map(enrollment - courseRepository.findById(enrollment.getCourseId()).orElse(null)) .filter(Objects::nonNull) .toList(); } }事务说明Transactional保证 save 过程中如果出现异常数据库不会留下半条脏数据Transactional(readOnly true)标记只读事务可以减少数据库开销如果你在 Service 层不写事务把多个数据库操作分散在 Controller 中一旦第二步失败第一步的数据就会残留。业务异常类与统一状态码能让前端实现更简单。// 文件路径src/main/java/com/example/coursewebsite/exception/BusinessException.java package com.example.coursewebsite.exception; public class BusinessException extends RuntimeException { public BusinessException(String message) { super(message); } }5.4 Controller 接口层课程查询接口// 文件路径src/main/java/com/example/coursewebsite/controller/CourseController.java package com.example.coursewebsite.controller; import com.example.coursewebsite.entity.Course; import com.example.coursewebsite.repository.CourseRepository; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import java.util.List; RestController RequestMapping(/api/courses) public class CourseController { private final CourseRepository courseRepository; public CourseController(CourseRepository courseRepository) { this.courseRepository courseRepository; } GetMapping public ListCourse list(RequestParam(required false) String keyword) { if (!StringUtils.hasText(keyword)) { return courseRepository.findAll(); } return courseRepository.search(keyword); } GetMapping(/{id}) public Course detail(PathVariable Long id) { return courseRepository.findById(id).orElse(null); } }选课接口// 文件路径src/main/java/com/example/coursewebsite/controller/EnrollmentController.java package com.example.coursewebsite.controller; import com.example.coursewebsite.entity.Course; import com.example.coursewebsite.service.EnrollmentService; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.List; import java.util.Map; RestController RequestMapping(/api/enrollments) public class EnrollmentController { private final EnrollmentService enrollmentService; public EnrollmentController(EnrollmentService enrollmentService) { this.enrollmentService enrollmentService; } PostMapping public MapString, Object enroll(RequestBody EnrollRequest request) { enrollmentService.enroll(request.userId(), request.courseId()); MapString, Object result new HashMap(); result.put(code, 0); result.put(message, 选课成功); return result; } GetMapping(/my) public ListCourse myCourses(RequestParam Long userId) { return enrollmentService.findCoursesByUserId(userId); } public record EnrollRequest(Long userId, Long courseId) { } }在这个阶段接口返回结果还没有加入统一包装。真实项目里可以进一步定义 Result 或 ApiResponse 类让前端能统一解析 code、message、data。但核心套路已经清楚了Controller 不写业务逻辑只负责参数接收、调用服务和返回 JSON。6. Vue 3 前端工程实现前端工程放在单独目录使用 Vite 创建项目。如果你对前端工程较陌生可以先按步骤执行理解“前端通过 HTTP 请求后端接口”这个核心点。6.1 创建 Vue 3 项目终端执行npm create vitelatest course-web -- --template vue cd course-web npm install npm install axios安装完成后启动开发服务器npm run dev默认会在http://localhost:5173启动。Vite 开发服务器支持跨域代理真正的浏览器请求不需要关心跨域因为它访问的是同一个前端地址。6.2 配置 Vite 代理为了让前端代码中的/api请求能转发到 Spring Boot 后端修改 vite.config.js。// 文件路径course-web/vite.config.js import { 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/courses时Vite 会把请求转发到http://localhost:8080/api/courses这样就绕开了跨域限制。生产环境部署时一般由 Nginx 执行同样的反向代理。6.3 封装请求工具在 src 目录下新建api/request.js。// 文件路径course-web/src/api/request.js import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) export default request6.4 课程列表页面新建
返回列表