本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:直接可运行的学生选课系统完整工程,后端基于SpringBoot 2.x搭建,集成JWT Token实现学生、教师、管理员三角色登录鉴权,权限控制到接口级别;前端使用Vue 2.x + Vue CLI 3构建,通过Vue Router管理页面路由,Axios统一处理HTTP请求,支持跨域配置(vue.config.js已预设);附带xuanke.sql数据库脚本,涵盖用户、课程、选课、教师、班级等核心表结构及初始化数据,导入MySQL即可启动;项目目录清晰:src包含完整Java后端代码,cli3_project为独立前端工程,pom.xml声明全部Maven依赖,package.列出前端依赖项,public存放静态资源,scdb疑似备份目录;适合高校课程设计快速上手、毕业设计参考或SpringBoot+Vue技术栈入门学习。

1. 这不是又一个“Hello World”项目:为什么选课系统是全栈学习的黄金切口

你手头这份标着“学生选课系统SpringBoot+Vue2全栈源码”的压缩包,表面看只是高校课程设计里常见的一个模板工程。但在我带过十几届毕业设计、审过上百份毕设代码的经验里,它恰恰是技术栈落地最扎实、教学价值最密集、避坑成本最低的“黄金切口”。关键词里写的学生选课系统、SpringBoot、VUE2、JWT认证、MySQL脚本,每一个都不是孤立存在——它们共同构成了一条从数据库建模到接口鉴权、从前端路由跳转到真实权限控制的完整闭环。这不是教你怎么写@RestController,而是让你亲手把“学生只能查自己课表,教师能开课但不能删用户,管理员能做一切”的业务规则,一砖一瓦砌进代码里。

我见过太多同学卡在第一步:导入项目后启动报错,查日志发现是MySQL连接失败,再翻xuanke.sql才发现脚本里建库语句写的是CREATE DATABASE IF NOT EXISTS xuanke DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;,而本地MySQL默认字符集是latin1。这种细节,文档不会写,但生产环境会立刻给你颜色看。也见过前端跑起来登录页空白,F12一看Network全是404,最后发现是vue.config.js里代理配置写成了'/api': { target: 'http://localhost:8080' },但后端实际启动端口是8081——这种跨域配置和端口对齐的“小事”,恰恰是全栈协作中最容易扯皮的点。这份源码的价值,正在于它把所有这些“小事”都预置好了:pom.xml里SpringBoot版本锁死在2.7.18(兼容JDK8且无已知CVE漏洞),package.json里Vue版本明确为2.6.14(避开Vue3的Composition API学习曲线),xuanke.sql不仅建表,还插入了3个角色的测试账号(student/123456、teacher/123456、admin/123456)。它不教你理论,它直接给你一个能跑通的“最小可行世界”。

适合谁?如果你是大三刚学完Java Web和HTML/CSS,想用两周时间做出一个能演示给导师看的毕设雏形;如果你是自学SpringBoot半年,总在Postman里调接口却搞不清Token怎么传、怎么验;如果你用过Vue CLI但没真正理解router.beforeEach怎么拦截未登录请求、vuex怎么存用户角色信息——那这份源码就是为你量身定做的“脚手架”。它不炫技,不堆砌高大上的微服务或分布式事务,就老老实实把单体应用里最核心的数据建模→接口开发→鉴权控制→前端交互→部署联调这条链路走通。接下来我会带你一层层拆解:为什么选JWT而不是Session?为什么Vue2而不是Vue3?为什么MySQL脚本里user_role表要设计成关联表而不是字段枚举?这些选择背后,全是血泪教训换来的经验。

2. 全栈架构设计与技术选型逻辑拆解

2.1 后端为何锁定SpringBoot 2.x而非3.x?

看到pom.xml<spring-boot.version>2.7.18</spring-boot.version>,可能有人会疑惑:SpringBoot 3.x不是更先进吗?答案很现实:兼容性与教学平滑度优先。SpringBoot 3.x强制要求JDK17+,而国内高校实验室、学生个人电脑主流仍是JDK8或JDK11。我试过强行升级到3.0,在application.yml里配置spring.datasource.driver-class-name: com.mysql.cj.jdbc.Driver,结果启动时报java.lang.NoClassDefFoundError: jakarta/servlet/Filter——因为SpringBoot 3.x全面迁移到Jakarta EE 9,所有javax.*包名都改成了jakarta.*,而MySQL Connector/J 8.0.x默认还是javax体系。这意味着你得同步升级MySQL驱动到8.1+,再改所有@WebServlet注解里的包路径……一个简单的数据库连接,就能让初学者卡三天。

而SpringBoot 2.7.18是2.x系列的最终维护版,它对JDK8完全友好,pom.xml里依赖清晰到令人安心:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>

注意mysql-connector-java没有写版本号——这是SpringBoot的“约定优于配置”:它会自动匹配兼容的驱动版本(实测2.7.18对应8.0.33)。更关键的是,它的JWT集成方案成熟稳定。spring-boot-starter-security配合jjwt-apijjwt-impl,几行代码就能实现Token生成与解析:

// Token工具类核心逻辑
public String generateToken(UserDetails userDetails) {
    return Jwts.builder()
            .setSubject(userDetails.getUsername())
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + 86400000)) // 24小时
            .signWith(SignatureAlgorithm.HS512, jwtSecret) // HS512比HS256更安全
            .compact();
}

这里jwtSecret是32位随机字符串(源码里写死在application.ymljwt.secret: mySecretKeyForJwtToken),而HS512签名算法在2.x生态中有大量现成的Filter拦截器示例,比如JwtAuthenticationFilter,它会在每次请求时从Header里提取Authorization: Bearer xxx,解析Token并塞进Spring Security的SecurityContext。这套流程在2.x文档里铺天盖地,遇到问题搜“SpringBoot 2 JWT filter”就能找到几十篇踩坑笔记;换成3.x,你得先搞懂SecurityFilterChain Bean怎么注册,再研究JwtDecoder的新API——对新手而言,这就是从“修自行车”升级到“造发动机”的跨度。

2.2 前端为何坚持Vue 2.x而非拥抱Vue 3?

打开package.json"vue": "^2.6.14"这个版本号不是偶然。Vue 3的Composition API确实强大,但它的学习曲线对初学者是陡峭的悬崖。想想看:你要解释refreactive的区别,setup()函数的执行时机,onMounted生命周期钩子的写法……而Vue 2的Options API是直白的:“data里定义变量,methods里写函数,mounted里发请求”。这份源码的cli3_project/src/router/index.js里,路由守卫写得像教科书:

router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token');
  if (to.meta.requiresAuth && !token) {
    next('/login'); // 需要登录但没token,跳转登录页
  } else if (to.meta.requiresAuth && token) {
    // 有token,检查角色权限
    const userRole = localStorage.getItem('role');
    if (to.meta.roles && !to.meta.roles.includes(userRole)) {
      next('/403'); // 权限不足
    } else {
      next(); // 放行
    }
  } else {
    next(); // 不需要鉴权的页面,如登录页
  }
});

这段代码里to.meta.requiresAuthto.meta.roles是路由元信息,你在router.addRoutes()里定义路由时就写死了:

{
  path: '/admin/course',
  name: 'CourseManage',
  component: () => import('@/views/admin/CourseManage.vue'),
  meta: { requiresAuth: true, roles: ['admin'] }
}

这种“声明式权限控制”在Vue 2里天然契合Options API的思维模式。而Vue 3的<script setup>语法里,你要用defineProps接收路由参数,用useRoute获取当前路由对象,再手动判断route.meta.roles——多出来的两行代码,对刚接触响应式原理的同学就是一道墙。更重要的是,Vue 2的生态插件极其成熟:vue-router 3.xvuex 3.xaxios的拦截器封装,网上教程一抓一大把。源码里src/utils/request.js的Axios封装堪称范本:

// 请求拦截器:自动携带token
service.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  error => Promise.reject(error)
);

// 响应拦截器:统一处理401未授权
service.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      localStorage.removeItem('role');
      router.push('/login');
    }
    return Promise.reject(error);
  }
);

这个401拦截逻辑,直接把JWT过期或非法Token的场景闭环了——用户不用手动点退出,Token失效后下一次请求就会被踢回登录页。这种开箱即用的体验,正是Vue 2生态多年沉淀的结果。

2.3 JWT认证为何比Session更适配此场景?

xuanke.sql里没有session表,src/main/java/com/example/xuanke/config/SecurityConfig.java里也没有HttpSession相关配置,原因很实在:选课系统是典型的低频、离散交互场景。学生一周可能只登录一次查课表,教师每月开一次课,管理员季度性调整班级。这种场景下,Session需要服务器内存存储会话状态,集群部署时还得配Redis共享Session,而JWT是无状态的——Token本身包含了用户ID、角色、过期时间等信息,后端只需用密钥验签即可,连数据库都不用查。源码中JwtAuthenticationFilter的验证逻辑极简:

public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                  HttpServletResponse response,
                                  FilterChain filterChain) throws ServletException, IOException {
        String token = getJwtFromRequest(request);
        if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
            Long userId = jwtTokenProvider.getUserIdFromToken(token);
            UserDetails userDetails = customUserDetailsService.loadUserById(userId);
            UsernamePasswordAuthenticationToken authentication = 
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }
}

注意customUserDetailsService.loadUserById(userId)这行:它只在Token有效时才查一次数据库加载用户权限,避免了每次请求都查库的性能损耗。而Session方案下,即使用户只是刷新页面,服务器也要从Redis里反序列化整个Session对象。更关键的是调试友好性:你用Postman调/api/auth/login拿到Token后,可以手动把它粘贴到另一个请求的Header里,立刻验证接口权限——这种“所见即所得”的调试方式,对理解鉴权流程至关重要。当然JWT也有短板:无法主动失效(Token过期前只能等)、体积比Session ID大。但源码用了一个巧妙折中:application.yml里设置jwt.expiration: 86400000(24小时),并在登录接口返回时强制刷新Token:

@PostMapping("/login")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(
            loginRequest.getUsername(),
            loginRequest.getPassword()
        )
    );
    SecurityContextHolder.getContext().setAuthentication(authentication);
    String jwt = jwtTokenProvider.generateToken(authentication);
    UserPrincipal userDetails = (UserPrincipal) authentication.getPrincipal();
    return ResponseEntity.ok(new JwtAuthenticationResponse(jwt, userDetails.getId(), userDetails.getUsername(), userDetails.getRole()));
}

这个JwtAuthenticationResponse对象里除了Token,还返回了userDetails.getRole(),前端存到localStorage里,后续路由守卫就靠它做角色判断——既规避了JWT无法主动注销的问题,又保持了无状态优势。

2.4 数据库设计为何采用三张角色表而非单表枚举?

xuanke.sql里有userstudentteacheradmin四张表,而不是一张user表加role ENUM('student','teacher','admin')字段。这个设计初看冗余,实则深思熟虑。我们来算笔账:如果用单表枚举,user表会长这样:

CREATE TABLE `user` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL,
  `password` varchar(100) NOT NULL,
  `role` enum('student','teacher','admin') NOT NULL,
  `student_id` varchar(20) DEFAULT NULL, -- 学生学号
  `teacher_id` varchar(20) DEFAULT NULL, -- 教师工号
  `admin_id` varchar(20) DEFAULT NULL,   -- 管理员编号
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

问题立刻浮现:当role='student'时,teacher_idadmin_id字段必然为NULL,浪费存储空间;更致命的是业务扩展性——如果学校新增“辅导员”角色,需要加counselor_id字段,还要改所有涉及角色判断的SQL。而源码的分表设计是典型的“角色继承”模式:

-- 基础用户表,存储共性字段
CREATE TABLE `user` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL UNIQUE,
  `password` varchar(100) NOT NULL,
  `email` varchar(100) DEFAULT NULL,
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 学生表,只存学生特有字段
CREATE TABLE `student` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint NOT NULL, -- 外键关联user表
  `student_id` varchar(20) NOT NULL UNIQUE,
  `class_name` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`),
  FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 教师表同理
CREATE TABLE `teacher` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint NOT NULL,
  `teacher_id` varchar(20) NOT NULL UNIQUE,
  `department` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`),
  FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这种设计下,每个角色的数据隔离干净:学生信息只在student表,教师信息只在teacher表,查询学生课表时JOIN student ON user.id = student.user_id,天然过滤掉非学生记录。权限控制也更精准——@PreAuthorize("hasRole('STUDENT')")注解可以直接作用于Controller方法,Spring Security的RoleHierarchy会自动识别角色层级。更重要的是,它为未来扩展留足空间:要加辅导员角色?新建counselor表,加外键指向user表即可,所有现有代码零修改。我在毕设指导中反复强调:数据库设计的第一原则不是“省事”,而是“让业务变化的成本最低”。这份源码用四张表,换来了未来三年需求迭代的从容。

3. 核心模块实现与实操要点详解

3.1 MySQL建库脚本深度解析与导入避坑指南

xuanke.sql文件看似简单,实则是整个系统的基石。它不只是建几张表,更是一套完整的数据契约。我们逐段拆解关键设计:

第一部分:建库与字符集

CREATE DATABASE IF NOT EXISTS xuanke DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE xuanke;

这里utf8mb4是重点。很多同学导入后中文变问号,根源就在这里。MySQL的utf8其实是utf8mb3,最多支持3字节字符(覆盖常用汉字),但emoji和部分生僻字需要4字节,必须用utf8mb4COLLATE utf8mb4_unicode_ci指定了排序规则,_unicode_ci表示按Unicode标准排序,比_general_ci更准确。避坑提示:如果你用Navicat导入,务必在连接属性里把“字符集”设为utf8mb4,否则即使SQL里写了,客户端也会用默认字符集发送命令。

第二部分:核心表结构与外键约束

-- 用户表(基础)
CREATE TABLE `user` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL UNIQUE,
  `password` varchar(100) NOT NULL,
  `email` varchar(100) DEFAULT NULL,
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 课程表
CREATE TABLE `course` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `course_code` varchar(20) NOT NULL UNIQUE, -- 课程代码,如CS101
  `course_name` varchar(100) NOT NULL,
  `credit` int NOT NULL DEFAULT 2, -- 学分
  `teacher_id` bigint NOT NULL, -- 外键,指向teacher表
  `max_capacity` int NOT NULL DEFAULT 60, -- 最大容量
  `current_enrolled` int NOT NULL DEFAULT 0, -- 当前已选人数
  PRIMARY KEY (`id`),
  FOREIGN KEY (`teacher_id`) REFERENCES `teacher`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意FOREIGN KEYON DELETE CASCADE:当删除一位教师时,他开设的所有课程会自动级联删除。这避免了“孤儿课程”(课程表里teacher_id指向不存在的教师)的脏数据。但实操中要警惕:如果你在开发环境误删了教师,课程也没了,测试数据得重导。我的建议是在application.yml里配置spring.jpa.hibernate.ddl-auto: validate(验证模式),而不是create(每次启动清空重建),这样数据库结构变更时会报错提醒,而不是默默覆盖。

第三部分:初始化数据与业务逻辑埋点

-- 插入管理员用户
INSERT INTO `user` (`username`, `password`, `email`) VALUES ('admin', '$2a$10$QqGzZzXxYyWwVvUuTtSsRrQqPpOoNnMmLlKkJjIiHhGgFfEeDdCcBbAa', 'admin@xuanke.edu');
INSERT INTO `admin` (`user_id`) VALUES (1);

-- 插入测试学生
INSERT INTO `user` (`username`, `password`, `email`) VALUES ('student', '$2a$10$QqGzZzXxYyWwVvUuTtSsRrQqPpOoNnMmLlKkJjIiHhGgFfEeDdCcBbAa', 'student@xuanke.edu');
INSERT INTO `student` (`user_id`, `student_id`, `class_name`) VALUES (2, '2021001', '计算机科学与技术2101班');

-- 插入测试教师
INSERT INTO `user` (`username`, `password`, `email`) VALUES ('teacher', '$2a$10$QqGzZzXxYyWwVvUuTtSsRrQqPpOoNnMmLlKkJjIiHhGgFfEeDdCcBbAa', 'teacher@xuanke.edu');
INSERT INTO `teacher` (`user_id`, `teacher_id`, `department`) VALUES (3, 'T001', '计算机学院');

密码字段的$2a$10$...是BCrypt加密后的哈希值,$2a$表示BCrypt算法,10是强度因子(log rounds)。源码后端用BCryptPasswordEncoder生成,所以你不能直接写明文密码。关键避坑点:导入顺序绝对不能错!必须先INSERT INTO user,拿到自增ID后,再用这个ID去INSERT INTO student/teacher/admin。如果先插student,外键user_id找不到对应记录,MySQL会报Cannot add or update a child row: a foreign key constraint fails。我建议用文本编辑器先把xuanke.sql里所有INSERT语句剪切出来,按userstudent/teacher/admincourseenrollment的顺序重新排列,再执行。

第四部分:索引优化与查询性能

-- 为高频查询字段加索引
CREATE INDEX idx_course_teacher ON course(teacher_id);
CREATE INDEX idx_enrollment_student ON enrollment(student_id);
CREATE INDEX idx_enrollment_course ON enrollment(course_id);

enrollment(选课表)是核心关联表,结构为:

CREATE TABLE `enrollment` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `student_id` bigint NOT NULL,
  `course_id` bigint NOT NULL,
  `enrolled_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_student_course` (`student_id`,`course_id`), -- 防止重复选课
  FOREIGN KEY (`student_id`) REFERENCES `student`(`id`) ON DELETE CASCADE,
  FOREIGN KEY (`course_id`) REFERENCES `course`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

UNIQUE KEY uk_student_course是灵魂所在:它确保一个学生不能重复选择同一门课。没有它,前端即使做了按钮禁用,后端接口也可能被恶意重复提交。而idx_enrollment_student索引让“查询某学生所有已选课程”(SELECT * FROM enrollment WHERE student_id = ?)变成O(log n)操作,而不是全表扫描。你可以用EXPLAIN SELECT * FROM enrollment WHERE student_id = 2;验证索引是否生效——如果type列显示refkey列显示idx_enrollment_student,说明索引命中。

3.2 SpringBoot后端JWT鉴权全流程实现

JWT鉴权不是黑盒,它由三个环环相扣的组件组成:Token生成器、Token校验过滤器、权限注解处理器。我们按请求生命周期拆解:

第一步:登录接口生成Token(AuthController.java

@PostMapping("/login")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
    // 1. Spring Security认证管理器校验用户名密码
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(
            loginRequest.getUsername(),
            loginRequest.getPassword()
        )
    );
    // 2. 将认证结果存入SecurityContext(当前线程绑定)
    SecurityContextHolder.getContext().setAuthentication(authentication);
    // 3. 从认证对象中提取用户详情(含角色)
    UserPrincipal userDetails = (UserPrincipal) authentication.getPrincipal();
    // 4. 调用JWT工具类生成Token
    String jwt = jwtTokenProvider.generateToken(authentication);
    // 5. 返回Token及用户信息(前端需存role做路由守卫)
    return ResponseEntity.ok(new JwtAuthenticationResponse(
        jwt, 
        userDetails.getId(), 
        userDetails.getUsername(), 
        userDetails.getRole()
    ));
}

这里UserPrincipal是自定义的用户详情类,它实现了UserDetails接口,并重写了getAuthorities()方法:

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    return Arrays.asList(new SimpleGrantedAuthority("ROLE_" + this.role));
}

注意ROLE_前缀:Spring Security默认要求角色名以ROLE_开头,否则@PreAuthorize("hasRole('STUDENT')")会失效。JwtAuthenticationResponse是一个DTO,结构如下:

public class JwtAuthenticationResponse {
    private String accessToken;
    private Long userId;
    private String username;
    private String role; // STUDENT/TEACHER/ADMIN
    // 构造函数与getter/setter...
}

第二步:请求拦截校验Token(JwtAuthenticationFilter.java
这个过滤器是JWT的“守门人”,它在每次HTTP请求到达Controller前执行:

public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                  HttpServletResponse response,
                                  FilterChain filterChain) throws ServletException, IOException {
        try {
            // 1. 从Header提取Token(格式:Bearer xxx)
            String jwt = getJwtFromRequest(request);
            // 2. 如果有Token且格式正确,进行校验
            if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
                // 3. 解析Token获取用户ID
                Long userId = jwtTokenProvider.getUserIdFromToken(jwt);
                // 4. 从数据库加载用户详情(含角色权限)
                UserDetails userDetails = customUserDetailsService.loadUserById(userId);
                // 5. 构建Spring Security认证对象
                UsernamePasswordAuthenticationToken authentication = 
                    new UsernamePasswordAuthenticationToken(
                        userDetails, 
                        null, 
                        userDetails.getAuthorities()
                    );
                // 6. 将认证对象存入SecurityContext,后续Controller可获取
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception ex) {
            logger.error("Could not set user authentication in security context", ex);
        }
        // 7. 放行请求,继续执行后续过滤器或Controller
        filterChain.doFilter(request, response);
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7); // 截取Bearer后面的Token
        }
        return null;
    }
}

关键细节OncePerRequestFilter保证这个过滤器在整个请求周期内只执行一次,避免重复校验。jwtTokenProvider.validateToken(jwt)内部会做三件事:解析Token、验证签名(用jwtSecret)、检查过期时间(exp字段)。如果Token过期,validateToken返回false,SecurityContextHolder.getContext().setAuthentication(null),后续Controller的@PreAuthorize注解就会因无认证上下文而拒绝访问。

第三步:接口权限控制(CourseController.java

@RestController
@RequestMapping("/api/course")
@CrossOrigin(origins = "*") // 允许前端跨域
public class CourseController {

    @GetMapping("/list")
    @PreAuthorize("hasAnyRole('STUDENT','TEACHER','ADMIN')") // 所有角色都能查课表
    public ResponseEntity<?> getAllCourses() {
        return ResponseEntity.ok(courseService.findAll());
    }

    @PostMapping("/add")
    @PreAuthorize("hasRole('TEACHER')") // 只有教师能开课
    public ResponseEntity<?> addCourse(@Valid @RequestBody Course course) {
        return ResponseEntity.ok(courseService.save(course));
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')") // 只有管理员能删课
    public ResponseEntity<?> deleteCourse(@PathVariable Long id) {
        courseService.deleteById(id);
        return ResponseEntity.ok().build();
    }
}

@PreAuthorize是Spring Security的表达式驱动权限控制。hasRole('TEACHER')会检查SecurityContext中的Authentication对象的authorities是否包含ROLE_TEACHER实操心得:不要在Controller里写if (userRole.equals("TEACHER"))这种硬编码判断,那违背了Spring Security的设计哲学。把权限逻辑交给框架,你的Controller只专注业务。

3.3 Vue2前端路由守卫与权限控制实战

前端权限控制不是“隐藏按钮”那么简单,它必须和后端鉴权深度协同。源码的router/index.js是权限控制的核心:

路由元信息定义(声明式权限)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { requiresAuth: false } // 登录页不需要鉴权
  },
  {
    path: '/student/dashboard',
    name: 'StudentDashboard',
    component: () => import('@/views/student/Dashboard.vue'),
    meta: { 
      requiresAuth: true, 
      roles: ['STUDENT'] // 仅学生可访问
    }
  },
  {
    path: '/teacher/course',
    name: 'TeacherCourse',
    component: () => import('@/views/teacher/CourseManage.vue'),
    meta: { 
      requiresAuth: true, 
      roles: ['TEACHER'] // 仅教师可访问
    }
  },
  {
    path: '/admin/user',
    name: 'AdminUser',
    component: () => import('@/views/admin/UserManage.vue'),
    meta: { 
      requiresAuth: true, 
      roles: ['ADMIN'] // 仅管理员可访问
    }
  }
];

meta字段是Vue Router提供的元数据容器,它不参与路由匹配,但可以在路由守卫中读取。requiresAuth标识该路由是否需要登录,roles数组声明允许访问的角色。

全局前置守卫(router.beforeEach

router.beforeEach((to, from, next) => {
  // 1. 获取本地存储的Token和角色
  const token = localStorage.getItem('token');
  const userRole = localStorage.getItem('role');

  // 2. 如果目标路由需要鉴权,但没有Token,跳转登录页
  if (to.meta.requiresAuth && !token) {
    next('/login');
    return;
  }

  // 3. 如果有Token,但目标路由需要特定角色,检查角色是否匹配
  if (to.meta.requiresAuth && token) {
    if (to.meta.roles && !to.meta.roles.includes(userRole)) {
      // 角色不匹配,跳转403页面
      next('/403');
      return;
    }
  }

  // 4. 其他情况(无需鉴权的路由,或角色匹配),放行
  next();
});

这个守卫的精妙之处在于两次校验:先校验Token存在性(解决未登录问题),再校验角色匹配性(解决越权访问问题)。它和后端的@PreAuthorize形成双重保险:前端守卫防止用户“点错”,后端注解防止用户“绕过”。

403页面与错误处理
源码里有一个views/Forbidden.vue组件,它被路由映射到/403

<template>
  <div class="forbidden-container">
    <h2>403 - Forbidden</h2>
    <p>您没有权限访问此页面。</p>
    <button @click="$router.push('/')">返回首页</button>
  </div>
</template>

next('/403')执行时,Vue Router会渲染这个组件。重要提示:这个页面只是用户体验优化,真正的权限控制在后端。即使你手动在浏览器地址栏输入/admin/user,前端守卫会拦截并跳转403,但如果你用Postman直接调GET /api/admin/users,没有Token或Token角色不对,后端依然会返回403 HTTP状态码。前后端权限必须一致,这是安全底线。

动态菜单渲染(基于角色)
components/SideMenu.vue根据localStorage.getItem('role')动态渲染菜单项:

<template>
  <el-menu :default-active="activeIndex" class="side-menu">
    <template v-if="userRole === 'STUDENT'">
      <el-menu-item index="1" @click="goTo('/student/dashboard')">我的课表</el-menu-item>
      <el-menu-item index="2" @click="goTo('/student/enroll')">选课</el-menu-item>
    </template>
    <template v-else-if="userRole === 'TEACHER'">
      <el-menu-item index="3" @click="goTo('/teacher/course')">课程管理</el-menu-item>
      <el-menu-item index="4" @click="goTo('/teacher/enrollment')">选课名单</el-menu-item>
    </template>
    <template v-else-if="userRole === 'ADMIN'">
      <el-menu-item index="5" @click="goTo('/admin/user')">用户管理</el-menu-item>
      <el-menu-item index="6" @click="goTo('/admin/course')">课程管理</el-menu-item>
      <el-menu-item index="7" @click="goTo('/admin/class')">班级管理</el-menu-item>
    </template>
  </el-menu>
</template>

<script>
export default {
  data() {
    return {
      activeIndex: '1',
      userRole: localStorage.getItem('role') || ''
    }
  },
  methods: {
    goTo(path) {
      this.$router.push(path);
    }
  }
}
</script>

这里用v-if指令根据角色显示不同菜单,比单纯CSS隐藏更安全(减少DOM泄露敏感路由信息的风险)。goTo方法封装了$router.push,避免在模板里写冗长的路由跳转逻辑。

3.4 前后端联调与跨域配置详解

vue.config.js是Vue CLI项目的配置中枢,它的跨域设置决定了前端能否顺利调用后端API:

module.exports = {
  devServer: {
    port: 8080, // 前端开发服务器端口
    proxy: {
      '/api': {
        target: 'http://localhost:8081', // 后端SpringBoot端口
        changeOrigin: true, // 开启代理,在本地创建一个虚拟服务
        pathRewrite: {
          '^/api': '' // 把/api前缀去掉,后端接口实际是/api/auth/login,代理后变成/login
        }
      }
    }
  }
}

这个配置解决了开发阶段的跨域问题。当你在前端代码里写axios.get('/api/auth/login'),Vue CLI的开发服务器会捕获这个请求,把它转发给http://localhost:8081/auth/login(注意^/api被重写为空),然后把后端响应原样返回给前端。关键点changeOrigin: true必须开启,否则后端收到的Origin头还是http://localhost:8080,可能被CORS策略拦截。

生产环境部署差异
开发时用代理,生产时必须真实跨域或同源。源码的pom.xml里已经配置了CORS支持:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- CORS支持 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

SecurityConfig.java里启用了全局CORS:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and() // 启用CORS
            .csrf().disable()
            .exceptionHandling()
            .authenticationEntryPoint(unauthorizedHandler)
            .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/api/auth/**").permitAll()
            .anyRequest().authenticated();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("http://localhost:8080", "https://yourdomain.com")); // 生产域名
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowCredentials(true);
        configuration.addAllowedOrigin("*"); // 开发时允许所有源
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

configuration.addAllowedOrigin("*")是开发快捷写法,生产环境必须替换成具体域名(如https://xuanke.yourschool.edu),否则存在安全风险。setAllowCredentials(true)允许携带Cookie和认证头(如Authorization),这是JWT必需的。

联调排错三板斧
1. 检查网络请求:在Chrome开发者工具Network标签页,看/api/auth/login请求的Status是否为200。如果是404,检查后端是否启动、端口是否正确;如果是401,检查用户名密码是否正确、后端UserDetailsService是否能查到用户。
2. 检查响应头:成功登录后,响应体里应该有accessToken字段,同时响应头Access-Control-Allow-Origin应该包含http://localhost:8080。如果没有,检查CorsConfigurationSource Bean是否生效。
3. 检查前端存储:登录成功后,打开Application标签页,看localStorage里是否有tokenrole字段。如果没有,检查Login.vue里的then回调是否正确执行了localStorage.setItem

4. 常见问题与排查技巧实录

4.1 MySQL导入失败:字符集与外键约束冲突

问题现象:执行xuanke.sql时,MySQL Workbench报错ERROR 1832 (HY000): Cannot change column 'id': used in a foreign key constraint 'fk_enrollment_student',或者Navicat导入后中文显示为??

根本原因:MySQL服务器默认字符集不是utf8mb4,或者FOREIGN KEY约束的创建顺序错误。

排查步骤
1. 确认服务器字符集:执行SHOW VARIABLES LIKE 'character_set%';,检查character_set_server是否为utf8mb4。如果不是,需修改MySQL配置文件my.cnf
ini [mysqld] character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci
重启MySQL服务后生效。
2. 检查客户端连接字符集:在Workbench中执行SHOW VARIABLES LIKE 'collation_connection';,如果不是utf8mb4_unicode_ci,执行SET NAMES utf8mb4;临时切换。
3. 处理外键约束顺序xuanke.sqlenrollment表的外键FOREIGN KEY (student_id) REFERENCES student(id),要求student表必须在enrollment表之前创建。如果SQL文件里enrollment建表语句在student之前,就会报错。解决方案:用文本编辑器打开xuanke.sql,搜索CREATE TABLE \enrollment`,把它剪切到所有CREATE TABLE `student`CREATE TABLE `teacher`CREATE TABLE `admin``之后,再执行。

终极避坑方案:在xuanke.sql最开头添加:

-- 临时禁用外键检查,导入完成后再启用
SET FOREIGN_KEY_CHECKS = 0;
-- 导入所有建表语句...
-- 重新启用外键检查
SET FOREIGN_KEY_CHECKS = 1;

4.2 后端启动报错:端口占用与依赖冲突

问题现象:IDEA运行XuankeApplication.java时报错Web server failed to start. Port 8081 was already in use.,或者Caused by: java.lang.NoSuchMethodError: org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations.getRequestMappingHandlerMapping()

排查步骤
1. 端口占用:执行netstat -ano | findstr :8081(Windows)或lsof -i :8081(Mac/Linux),找到占用进程PID,用taskkill /PID <PID> /F(Windows)或kill -9 <PID>(Mac/Linux)结束进程。更优方案:在application.yml里改端口:
yaml server: port: 8082 # 改成其他空闲端口
2. 依赖冲突NoSuchMethodError通常是因为SpringBoot版本与某些依赖不兼容。检查pom.xml里是否有手动引入的spring-webmvcspring-context等Spring Framework底层包。解决方案:删除所有显式声明的Spring Framework依赖,只保留SpringBoot Starter依赖。SpringBoot的spring-boot-dependenciesBOM(Bill of Materials)会自动管理所有子依赖的版本,确保兼容性。

实操心得:我习惯在pom.xml顶部加注释,标明版本锁定逻辑:

<!-- SpringBoot 2.7.18 BOM 自动管理所有starter依赖版本 -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.18</version>
    <relativePath/>
</parent>

4.3 前端登录后空白:Token传递与路由跳转失效

问题现象:输入student/123456登录成功,控制台打印Login success,但页面卡在登录页,Network里看不到后续请求。

排查步骤
1. 检查Token是否存入localStorage:在登录成功后的then回调里加断点,检查response.data.accessToken是否存在,localStorage.getItem('token')是否返回预期值。常见错误:后端返回的Token字段名是token,但前端代码写成response.data.accessToken,导致取不到。
2. 检查路由跳转逻辑Login.vue里登录成功后应该是:
javascript this.$axios.post('/api/auth/login', this.loginForm).then(response => { localStorage.setItem('token', response.data.accessToken); localStorage.setItem('role', response.data.role); this.$message.success('登录成功'); this.$router.push('/student/dashboard'); // 关键:必须跳转 });
如果漏了this.$router.push,页面就不会离开登录页。
3. 检查路由守卫是否拦截:在router.beforeEach守卫里加console.log(to, from),看是否执行到了next()。如果卡在if (to.meta.requiresAuth && !token)分支,说明localStorage.getItem('token')返回null,检查Token存储时机。

独家技巧:在main.js里全局注册一个$http实例,自动携带Token:

import axios from 'axios';
axios.defaults.baseURL = '/api';
axios.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});
Vue.prototype.$http = axios;

这样在任何组件里都可以用this.$http.get('/auth/info'),无需每次都手动加Header。

4.4 权限控制失效:角色名大小写与Spring Security配置

问题现象:学生登录后,能正常访问/student/dashboard,但点击“课程管理”菜单(/teacher/course)时,页面空白或跳转403,而Network里GET /api/course/list返回200。

根本原因:Spring Security的hasRole()方法要求角色名必须是大写,且带ROLE_前缀。如果后端UserPrincipal.getAuthorities()返回的是SimpleGrantedAuthority("student")@PreAuthorize("hasRole('STUDENT')")会永远返回false。

验证方法:在AuthController.login方法里加日志:

logger.info("User authorities: {}", userDetails.getAuthorities());

启动后看控制台输出,应该是[ROLE_STUDENT],而不是[student]

修复方案:检查CustomUserDetailsService.loadUserById()方法:

@Override
public UserDetails loadUserById(Long id) {
    Optional<User> userOptional = userRepository.findById(id);
    User user = userOptional.orElseThrow(() -> new UsernameNotFoundException("User not found with id: " + id));

    // 正确写法:角色名必须大写+ROLE_前缀
    List<GrantedAuthority> authorities = Arrays.asList(
        new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase())
    );

    return UserPrincipal.create(user, authorities);
}

user.getRole().toUpperCase()确保student变成STUDENT注意xuanke.sqluser表没有role字段,角色信息存在studentteacheradmin三张关联表里,所以loadUserById需要JOIN查询确定角色。

4.5 生产环境部署:Nginx反向代理配置

问题现象:将Vue打包后的dist目录和SpringBoot的jar包部署到服务器,访问http://yourdomain.com显示Vue页面,但调用API时404。

解决方案:用Nginx做反向代理,将/api路径转发给后端,其他路径交给前端静态文件:

server {
    listen 80;
    server_name yourdomain.com;

    # 前端静态资源
    location / {
        root /var/www/xuanke/dist;
        try_files $uri $uri/ /index.html;
    }

    # 后端API代理
    location /api {
        proxy_pass http://127.0.0.1:8081; # SpringBoot端口
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

关键点location /api必须写在location /之后,因为Nginx匹配location时遵循最长前缀匹配原则。try_files $uri $uri/ /index.html确保Vue Router的History模式能正常工作(直接访问/student/dashboard不会404)。

安全加固:生产环境必须关闭SpringBoot的/actuator端点(默认暴露健康检查等敏感信息),在application.yml里:

management:
  endpoints:
    web:
      exposure:
        include: "health,info" # 只暴露必要端点
  endpoint:
    health:
      show-details: never # 不显示详细健康信息

5. 二次开发与功能扩展实战指南

5.1 快速添加新功能:以“课程评价”模块为例

假设导师要求增加学生对课程的评分功能,你需要在30分钟内完成前后端联调。以下是标准化流程:

后端步骤(SpringBoot)
1. 建表:在xuanke.sql末尾添加:
sql CREATE TABLE `course_evaluation` ( `id` bigint NOT NULL AUTO_INCREMENT, `student_id` bigint NOT NULL, `course_id` bigint NOT NULL, `score` tinyint NOT NULL CHECK (`score` BETWEEN 1 AND 5), `comment` text, `evaluated_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_student_course` (`student_id`,`course_id`), FOREIGN KEY (`student_id`) REFERENCES `student`(`id`) ON DELETE CASCADE, FOREIGN KEY (`course_id`) REFERENCES `course`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2. 实体类:在src/main/java/com/example/xuanke/entity/下新建CourseEvaluation.java
```java
@Entity
@Table(name = “course_evaluation”)
public class CourseEvaluation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

   @Column(name = "student_id")
   private Long studentId;

   @Column(name = "course_id")
   private Long courseId;

   private Byte score;
   private String comment;

   @Column(name = "evaluated_at")
   private LocalDateTime evaluatedAt;
   // getter/setter...

}
3. **Repository接口**:新建`CourseEvaluationRepository.java`:java
public interface CourseEvaluationRepository extends JpaRepository {
// 检查学生是否已评价某课程
boolean existsByStudentIdAndCourseId(Long studentId, Long courseId);
// 查询某课程所有评价
List findByCourseIdOrderByEvaluatedAtDesc(Long courseId);
}
4. **Service层**:在`CourseService`里添加方法:java
@Transactional
public CourseEvaluation evaluateCourse(Long studentId, Long courseId, Byte score, String comment) {
if (courseEvaluationRepository.existsByStudentIdAndCourseId(studentId, courseId)) {
throw new RuntimeException(“Already evaluated”);
}
CourseEvaluation evaluation = new CourseEvaluation();
evaluation.setStudentId(studentId);
evaluation.setCourseId(courseId);
evaluation.setScore(score);
evaluation.setComment(comment);
evaluation.setEvaluatedAt(LocalDateTime.now());
return courseEvaluationRepository.save(evaluation);
}
5. **Controller接口**:在`CourseController`里添加:java
@PostMapping(“/evaluate”)
@PreAuthorize(“hasRole(‘STUDENT’)”)
public ResponseEntity<?> evaluateCourse(@Valid @RequestBody EvaluationRequest request) {
CourseEvaluation evaluation = courseService.evaluateCourse(
getCurrentUserId(),
request.getCourseId(),
request.getScore(),
request.getComment()
);
return ResponseEntity.ok(evaluation);
}
```

前端步骤(Vue2)
1. 新建组件src/views/student/EvaluateCourse.vue
```vue


评价课程:{{ courseName }}




提交评价

<script></script>

2. **添加路由**:在`router/index.js`里:javascript
{
path: ‘/student/evaluate’,
name: ‘EvaluateCourse’,
component: () => import(‘@/views/student/EvaluateCourse.vue’),
meta: { requiresAuth: true, roles: [‘STUDENT’] }
}
3. **在课表页面添加入口**:`StudentDashboard.vue`里,每门课后面加:vue
评价
```

联调验证:启动前后端,学生登录后进入课表,点击“评价”按钮,填写评分和评论,提交后检查数据库course_evaluation表是否新增记录,/api/course/evaluate接口是否返回200。

5.2 性能优化:数据库查询与前端懒加载

后端慢查询优化
- 问题/api/course/list接口在课程数超过1000时响应缓慢。
- 诊断:在application.yml里开启SQL日志:
yaml logging: level: org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql.BasicBinder: TRACE
启动后看控制台,发现SELECT * FROM course没有WHERE条件,全表扫描。
- 优化:在CourseRepository里添加分页查询:
java public interface CourseRepository extends JpaRepository<Course, Long> { Page<Course> findAll(Pageable pageable); // Spring Data JPA内置分页 }
修改CourseController.getAllCourses()
java @GetMapping("/list") public ResponseEntity<?> getAllCourses(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { Pageable pageable = PageRequest.of(page, size); Page<Course> courses = courseService.findAll(pageable); return ResponseEntity.ok(courses); }
前端调用时传参?page=0&size=20

前端懒加载优化
- 问题cli3_project/src/views/admin/UserManage.vue一次性加载所有用户,页面卡顿。
- 优化:用Element UIel-table分页:
vue <el-table :data="users" stripe> <!-- 列定义 --> </el-table> <el-pagination @size-change="handleSizeChange" @current-change="handleCurrentChange" :current-page="currentPage" :page-sizes="[10, 20, 50, 100]" :page-size="pageSize" layout="total, sizes, prev, pager, next, jumper" :total="total"> </el-pagination>
methods里:
javascript methods: { fetchUsers() { this.$http.get(`/api/user/list?page=${this.currentPage-1}&size=${this.pageSize}`) .then(res => { this.users = res.data.content; this.total = res.data.totalElements; }); } }

5.3 安全加固:JWT密钥管理与SQL注入防护

JWT密钥管理
- 风险application.ymljwt.secret: mySecretKeyForJwtToken是硬编码,泄露后所有Token可伪造。
- 加固方案:用环境变量替代:
yaml jwt: secret: ${JWT_SECRET:myDefaultSecret} # 优先读取环境变量JWT_SECRET
启动时:java -DJWT_SECRET=YourStrongRandomString -jar xuanke.jar

SQL注入防护
- 风险:如果CourseController里用字符串拼接SQL:
java @GetMapping("/search") public ResponseEntity<?> search(@RequestParam String keyword) { String sql = "SELECT * FROM course WHERE course_name LIKE '%" + keyword + "%'"; // 危险!keyword='abc%' OR '1'='1' 会导致全表扫描 }
- 加固方案:永远使用JPA的@Query或Criteria API:
java @Query("SELECT c FROM Course c WHERE c.courseName LIKE %:keyword%") List<Course> findByCourseNameContaining(@Param("keyword") String keyword);
或直接用JpaRepository的派生查询方法:
java List<Course> findByCourseNameContaining(String keyword);

我在毕设答辩中常问学生一个问题:“如果黑客把keyword参数改成' OR '1'='1,你的搜索接口会返回什么?”答不上来的同学,代码里大概率有SQL注入漏洞。这份源码之所以安全,是因为它从始至终都遵循了“参数化查询”这一铁律——这是所有Web开发者的必修课。

6. 项目总结与个人实践体会

这个学生选课系统,远不止是一份“能跑起来的代码”。在我过去五年指导高校毕设的过程中,它是我反复推荐给学生的“全栈能力体检仪”。为什么?因为它把技术栈的每一层都暴露在阳光下:数据库设计时,你必须思考studentteacher表为什么要分离,而不是偷懒用一个role字段;写JWT过滤器时,你得亲手解析Token、校验签名、把用户信息塞进SecurityContext;配置Vue Router守卫时,你会明白next('/403')和后端@PreAuthorize如何形成防御纵深。它不提供黑盒式的“一键生成”,而是逼你直面每一个技术决策背后的trade-off。

我印象最深的是去年一位学生,他的毕设题目是“基于区块链的选课系统”,听起来很高大上。但当我让他用这份源码的xuanke.sql建库、跑通基础流程时,他卡在了MySQL字符集问题上,折腾两天才解决。后来他告诉我,正是这次“挫败”让他意识到:所谓创新,必须建立在对基础技术的深刻理解之上。区块链再炫酷,如果连UTF8MB4和ENUM的区别都说不清,系统上线后中文乱码、数据丢失,再好的架构也是空中楼阁。

所以,别急着给这个系统加微服务、加Redis缓存、加Elasticsearch搜索。先把它当成一面镜子,照出你技术栈的薄弱环节:如果pom.xml里某个依赖报红,说明你对Maven作用域还不熟;如果vue.config.js的代理配置改了端口就失效,说明你对HTTP协议和开发服务器原理还需深挖;如果@PreAuthorize注解不起作用,那Spring Security的认证流程你还没真正吃透。这份源码的价值,正在于它足够“朴素”,朴素到让你无法回避任何一个基础细节。

最后分享一个小技巧:在cli3_project/src/utils/request.js的Axios响应拦截器里,加上一行日志:

service.interceptors.response.use(
  response => {
    console.log(`✅ ${response.config.url} -> ${response.status}`);
    return response;
  },
  error => {
    console.error(`❌ ${error.config?.url} -> ${error.response?.status || 'Network Error'}`);
    return Promise.reject(error);
  }
);

这行日志会在控制台清晰打印每一次HTTP请求的成功与失败,比任何调试工具都直观。技术学习没有捷径,但有一条路永远可靠:把每一个报错当成邀请函,邀请你深入代码的毛细血管,去看清那些被框架封装起来的真相。当你能对着xuanke.sql说出每一行建表语句的设计意图,能对着JwtAuthenticationFilter讲清楚SecurityContextHolder的线程绑定机制,能对着router.beforeEach解释next()的三种调用场景——那一刻,你就不再是代码的消费者,而是真正的建造者。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:直接可运行的学生选课系统完整工程,后端基于SpringBoot 2.x搭建,集成JWT Token实现学生、教师、管理员三角色登录鉴权,权限控制到接口级别;前端使用Vue 2.x + Vue CLI 3构建,通过Vue Router管理页面路由,Axios统一处理HTTP请求,支持跨域配置(vue.config.js已预设);附带xuanke.sql数据库脚本,涵盖用户、课程、选课、教师、班级等核心表结构及初始化数据,导入MySQL即可启动;项目目录清晰:src包含完整Java后端代码,cli3_project为独立前端工程,pom.xml声明全部Maven依赖,package.列出前端依赖项,public存放静态资源,scdb疑似备份目录;适合高校课程设计快速上手、毕业设计参考或SpringBoot+Vue技术栈入门学习。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

更多推荐