简介:基于Java语言的在线教育平台设计源码,面向Java开发者与在线教育系统设计人员,利用Java跨平台、高性能特性,聚焦课程管理、用户验证、在线测试等核心功能,可满足在线学习场景的常见需求。压缩包共21个文件,大小38KB,包含10个XML配置文件、7个Java源文件、2个Git忽略文件、1个属性文件及1个说明文本;其中XML负责数据库连接、依赖注入等配置,Java源码实现业务逻辑,属性文件存储运行参数,模块化结构清晰,便于学习和二次扩展。目前已有355人学习下载,适合希望快速理解在线教育平台架构、掌握Spring等框架实践的初中级开发者参考。借助该源码可了解从配置管理到功能编码的完整流程,其模块化设计也便于按需裁剪,可有效支撑毕业设计、课程项目或小型教育平台的搭建。
1. 在线教育平台设计源码不是拿来就能跑的:先看清这套Java代码里有什么
从网盘里把「基于Java语言的在线教育平台设计源码.zip」拖出来,解压、导入 IDEA、点启动,然后被一连串的 ClassNotFoundException、Access denied for user 和 Table doesn't exist 按在地上摩擦——这是绝大多数人拿到这套源码的第一天。我见过不少拿它做课设、毕设或者面试项目的人,卡住的原因不是代码复杂,而是没搞明白压缩包里到底有什么:一个 Spring Boot 后端、一个前端页面工程、一份 SQL 脚本,再加几篇说明文档。先理清楚这三样东西之间的关系,比盲目启动重要得多。
这套源码解决的是在线教育最核心的一条链路:注册登录 → 浏览课程 → 下单购买 → 观看视频。它适合用来做 Java 课程设计、毕业设计,也适合作为 Java 面试项目经验写进简历。你要做的不是读懂每一行,而是把这条链路完整跑通、把关键点讲清楚、再改出两三个亮点。这篇文章就把这条链路上的环境配置、表设计、接口调用和常见踩坑挨个拆开。
2. 拆开这套Java源码的技术栈与数据模型:Spring Boot三层加六张表怎么闭环
2.1 前后端分离与经典三层架构:为什么这套源码选用Spring Boot + MyBatis-Plus
市面上能搜到的“基于Java语言的在线教育平台设计源码”,九成以上是同一类组合:后端 Spring Boot,持久层 MyBatis 或 MyBatis-Plus,数据库 MySQL,缓存 Redis,前端 Vue 或 Thymeleaf。这不是巧合。课设和毕设场景要求你展示“懂 Java 开发”,又不允许在环境对接上消耗太多时间。Spring Boot 把配置收敛到 application.yml,MyBatis-Plus 把单表 CRUD 收敛到 BaseMapper,两者配合能把从建表到出接口的时间压缩到一周以内。如果你拿到的是更老的 SSM 项目(Spring + Spring MVC + MyBatis),原理一样,只是多了一堆 XML 配置,跑通更费劲,我会建议你优先换 Spring Boot 版本再开工。
为什么持久层普遍选 MyBatis-Plus 而不是 Spring Data JPA?在线教育平台的表关系并不复杂,但查询条件很碎:课程按分类筛选、按价格排序、按讲师查、按上下架状态查,还要分页。这类动态 SQL 用 MyBatis 的<if>标签写起来最顺手,打印出来的 SQL 也直观,出了问题可以直接复制到 Navicat 里跑。JPA 的级联和懒加载在这种场景反而容易把人绕晕,所以大多数同类源码不会选它。
| 分层 | 常见选型 | 在源码里的作用 |
|---|---|---|
| 表现层 | Spring MVC + REST Controller | 暴露/api/course/list这类 JSON 接口 |
| 业务层 | Service + 事务注解 | 处理下单、开课、支付回调等业务规则 |
| 持久层 | MyBatis-Plus + MySQL | 单表 CRUD 和联表查询,SQL 写在 Mapper 里 |
| 缓存层 | Redis | 首页轮播图、课程详情缓存,JWT 黑名单 |
| 前端 | Vue + Axios | 前后端分离,通过 HTTP 调用后端接口 |
这一层决定了你怎么排查问题:请求进来先走 Controller,业务落在 Service,SQL 执行在 Mapper。如果你看到一个接口返回数据不对,先顺着调用链确认是 Controller 参数接收错了,还是 Service 里业务算错了,不要一上来就怀疑 MyBatis 映射有问题。SQL 层面的问题通常表现为“接口不报错但字段为空”,业务层面的问题通常表现为“逻辑结果和预期不符”,这两类问题的排查方向完全不同。
2.2 从SQL脚本看业务闭环:六张核心表串起注册到看视频
打开源码包里的 doc/sql 目录,一般能找到 init.sql 或 edu.sql。下面这份建表脚本是我按常见在线教育项目整理的最小版本,字段和索引足够应付课设演示,也方便讲解。注意建库语句必须带 utf8mb4,否则课程标题里一旦出现特殊符号,写入 MySQL 会直接变成问号。
CREATE DATABASE IF NOT EXISTS edu DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE edu; CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密文', nickname VARCHAR(50) COMMENT '昵称', avatar VARCHAR(255) COMMENT '头像地址', role TINYINT DEFAULT 0 COMMENT '0学员 1讲师 2管理员', status TINYINT DEFAULT 1 COMMENT '0禁用 1正常', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='用户表'; CREATE TABLE edu_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, teacher_id BIGINT NOT NULL COMMENT '讲师ID,关联sys_user.id', title VARCHAR(120) NOT NULL COMMENT '课程标题', cover VARCHAR(255) COMMENT '封面图地址', price DECIMAL(10,2) DEFAULT 0.00 COMMENT '售价', original_price DECIMAL(10,2) DEFAULT 0.00 COMMENT '原价', status TINYINT DEFAULT 0 COMMENT '0下架 1上架', detail TEXT COMMENT '课程详情', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_teacher (teacher_id), KEY idx_status (status) ) ENGINE=InnoDB COMMENT='课程表'; CREATE TABLE edu_course_chapter ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT '所属课程ID', title VARCHAR(120) NOT NULL COMMENT '章节/课时名', video_url VARCHAR(255) COMMENT '视频地址(相对路径或完整URL)', duration INT DEFAULT 0 COMMENT '视频时长,单位秒', sort INT DEFAULT 0 COMMENT '排序号', KEY idx_course (course_id) ) ENGINE=InnoDB COMMENT='课程章节表'; CREATE TABLE pay_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '业务订单号', user_id BIGINT NOT NULL COMMENT '下单用户ID', course_id BIGINT NOT NULL COMMENT '购买的课程ID', amount DECIMAL(10,2) NOT NULL COMMENT '实付金额', status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已取消 3已退款', pay_time DATETIME DEFAULT NULL COMMENT '支付时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_course (course_id) ) ENGINE=InnoDB COMMENT='订单表'; CREATE TABLE sys_user_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, course_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_course (user_id, course_id) ) ENGINE=InnoDB COMMENT='用户已购课程表';这五张表已经覆盖了完整业务闭环:用户在 sys_user 注册,讲师在 edu_course 发布课程,课程下面挂 edu_course_chapter 的视频,学员下单走 pay_order,支付成功后往 sys_user_course 写一条已购记录。后续查看“我的课程”,就是查 sys_user_course 关联 edu_course;结算购物车,就是查购物车表再生成订单。
有几个字段要额外注意。第一是金额用 DECIMAL(10,2) 而不是 DOUBLE,在线教育涉及退款和优惠计算,用浮点数会出现 0.1+0.2=0.30000000000000004 这类问题,DECIMAL 是精确小数,比较时配合 BigDecimal.compareTo 才能保证相等判断正确。第二是 order_no 必须带唯一索引,这是防止重复下单的最后一道防线,后面讲并发验证时还会提到。第三是 sys_user_course 的唯一索引(user_id, course_id)保证了同一学员不会重复购买同一门课,也天然防止了“支付一次获得两遍课”的并发问题。
注意:如果你拿到的源码 SQL 脚本里有外键约束,导入时经常因为表顺序问题报错。我一般会把外键全部去掉,只在代码层维护关联关系。外键在互联网项目里用得越来越少,课设答辩也完全不会因为“没用外键”扣分。
3. 用 IDEA 把这个 Java 在线教育源码跑起来:从 JDK 到第一条课程列表 API
3.1 启动前的环境匹配:JDK、MySQL 8、Redis 与配置文件,一个都不能错
导入源码遇到的第一类问题是版本不匹配。很多在线教育源码 README 里写的是 JDK 1.8 + MySQL 5.7,但你本机装的是 JDK 17 + MySQL 8.0,直接跑会报加密插件错误,或者编译阶段就挂掉。开跑之前先花两分钟确认环境变量配置:命令行分别执行 java -version、mvn -v、mysql --version,三个命令的输出决定了你后面要不要改 pom.xml。如果你的机器上装了多个 JDK,注意 IDEA 的 Project Structure 里 Project SDK 和模块 SDK 必须指向同一个版本,这是 java 基础里最容易忽略的点。
pom.xml 里的关键依赖大体是下面这套,版本号以你自己源码里的为准:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> </dependencies>这份依赖里有几个容易翻车的点。spring-boot-starter-parent 是统一版本管理入口,Spring Boot 2.7 对应的 JDK 版本兼容 8 到 17,如果你本机是 JDK 17 一般没问题;但如果是 Spring Boot 3.x,它强制要求 JDK 17 以上,且 javax 要换成 jakarta,源码里如果大量使用 javax.servlet 就会编译失败,这种情况不要硬改,直接换一个 2.7.x 的父版本反而省事。mysql-connector-j 在 Spring Boot 2.7 里默认是 com.mysql.cj.jdbc.Driver,老源码里写的 com.mysql.jdbc.Driver 在 MySQL 8 下会直接启动报错。
再来看 application.yml 的完整配置:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/edu?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 redis: host: localhost port: 6379 database: 0 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl jwt: secret: your-256-bit-secret-key-change-me expire-hours: 24datasource.url 里最关键的是两个参数:serverTimezone=Asia/Shanghai 解决 MySQL 8 时区报错,allowPublicKeyRetrieval=true 解决 caching_sha2_password 插件导致的连接失败。字符集参数 characterEncoding=utf8 必须和建库时的 utf8mb4 配套,否则中文写入正常,但生僻字和 emoji 会丢。mybatis-plus 的 map-underscore-to-camel-case 打开后,数据库字段 teacher_id 才能自动映射到实体类的 teacherId,很多源码的实体类字段是驼峰命名的,这个开关关掉会导致联表查询返回 null。
提示:如果 README 要求先启动 Redis,那就先启动 redis-server 再启动项目。启动报错时看堆栈里第一个 Caused by:如果是 Connection refused: localhost/6379,那是 Redis 没起来;如果是 Access denied for user,那是数据库账号密码问题。分不清的话先开两个终端分别试 redis-cli ping 和 mysql -uroot -p,哪个不通先修哪个。
3.2 核心链路最小样例:Mapper → Service → Controller 接口怎么一步步通
“课程列表”接口是这套源码里最典型的链路:用户打开首页,前端调 /api/course/list,后端返回上架课程。下面这段浓缩代码展示了完整的三层写法,也是面试时被问得最多的部分。
@Mapper public interface CourseMapper extends BaseMapper<Course> { @Select("SELECT c.id, c.title, c.cover, c.price, u.nickname AS teacherName " + "FROM edu_course c LEFT JOIN sys_user u ON c.teacher_id = u.id " + "WHERE c.status = 1 ORDER BY c.create_time DESC") List<CourseVO> selectOnSaleCourse(); }@Service public class CourseService { private final CourseMapper courseMapper; public CourseService(CourseMapper courseMapper) { this.courseMapper = courseMapper; } public List<CourseVO> listOnSale() { return courseMapper.selectOnSaleCourse(); } }@RestController @RequestMapping("/api/course") public class CourseController { private final CourseService courseService; public CourseController(CourseService courseService) { this.courseService = courseService; } @GetMapping("/list") public R<List<CourseVO>> list() { return R.ok(courseService.listOnSale()); } }CourseMapper 继承 BaseMapper 后,自动拥有了 selectById、insert、updateById 等单表方法,不需要手写 SQL。真正需要手写的是联表查询,比如课程表关联用户表拿讲师昵称。注意 @Select 注解里给 u.nickname 起了别名 teacherName,因为 CourseVO 里的属性叫 teacherName,MyBatis 开启驼峰映射后会把 teacher_name 和 teacherName 都识别到——但如果你没开驼峰开关,这种别名必须和实体字段严格同名。
Service 层用构造器注入而非 @Autowired 字段注入,这是 Spring 官方推荐的写法,方便写单元测试,也能避免静态工具类里拿不到 Bean 的问题。Controller 统一返回 R 包装对象,R 是源码里常见的统一响应体,包含 code、message、data 三个字段。前端拿到 code==200 才渲染数据,这种包装规范化后,后面加全局异常处理也方便统一兜底。
第一次跑通时,我建议先不碰缓存,直接请求接口确认裸 SQL 是通的。打开 application.yml 里的 SQL 日志后,控制台会打印 MyBatis 执行的每一条 SQL,这是排查数据问题的黑匣子。接口返回空数组时先看日志里 SQL 执行了没,执行了就把 SQL 复制到 Navicat 里手动跑一遍,用来确认是 SQL 写错还是数据没插入。这个方法能过滤掉一半以上的“接口不通”问题。
4. 在线教育源码常见翻车点:登录态、订单状态、视频播放与跨域的五条血泪记录
4.1 启动时连不上数据库:MySQL 8 时区与密码插件在教做人
现象:运行 Spring Boot 项目,控制台抛出“The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized”或者“Public Key Retrieval is not allowed”。
原因:MySQL 8 的驱动默认要求客户端指定时区,而服务器端的系统时区格式不是驱动认识的格式,于是抛出一段乱码错误;第二个错误是 MySQL 8 默认认证插件 caching_sha2_password 在非 SSL 连接下首次交互需要获取 RSA 公钥,JDBC 出于安全考虑默认不允许。
解决:在 datasource.url 里补全两个参数:serverTimezone=Asia/Shanghai 和 allowPublicKeyRetrieval=true。顺手把驱动类改成 com.mysql.cj.jdbc.Driver,因为 MySQL 8 的驱动包已经把旧类移除了。改完重启,这两个问题同时消失。
这个坑所以高发,是因为大多数源码 README 面向的还是 MySQL 5.7,截图里根本没有后面那串参数。遇到这类连接问题,不需要翻源码,直接改 JDBC URL 参数即可,属于环境问题,跟代码无关。
4.2 登录后接口依旧 401:JWT 密钥不统一与拦截器放行清单写错
现象:登录接口能正常返回 token,前端把 token 放进 Authorization 请求头,但调用“我的课程”“个人中心”这类接口依然返回 401 未授权。
原因:十有八九是拦截器的问题。拦截器把需要登录的接口拦下来后,解析 token 用的 secret 和登录签发 token 用的 secret 不一致——源码里可能有两套密钥,一套在 application.yml,另一套硬编码在某个常量类里。还有可能是拦截器放行清单写漏了,登录接口本身被拦截,前端拿到的 token 其实是一次被拦截后 401 返回的假响应。
解决:全局搜 jwt.secret 和签名工具类里的 secret 常量,确认只有一个来源;再检查拦截器注册代码,确认 excludePathPatterns 放行了 /api/auth/**、/api/course/list 这类匿名接口。如果源码用的是 Spring Security 而不是拦截器,排错重点要换成 SecurityFilterChain 里的 permitAll 配置,逻辑一致。这种问题用 Postman 直接试最方便:先调登录,再复制 token 去试受保护接口,能快速区分是前端问题还是后端拦截配置问题。
4.3 支付回调永远失败:回调地址、金额精度和订单状态更新顺序
现象:用模拟支付工具支付成功后,订单表里 status 还是 0,学员的“我的课程”里看不到刚买的课。
原因:三个细节一起踩坑。回调地址写的是 http://localhost:8080/pay/callback,这在本地模拟没问题,但如果支付平台配置的是外网回调地址,本地根本收不到;金额比较用了 Double 的 == 运算符;订单状态更新和“开通课程”没有放在同一个事务里,前者成功后者失败,数据就错位了。
解决:本地调试时把回调地址配置项指向本机可被公网访问的地址,或直接用内网穿透工具;金额比较统一用 BigDecimal.compareTo() == 0,不要用 equals,因为 BigDecimal 的精度不同会导致 equals 返回 false;状态更新顺序上先更新订单为已支付,再插入 sys_user_course 记录,整个方法加 @Transactional,任一步失败整体回滚。回调接口第一行就打印收到的原始报文,后面排查“到底有没有回调过”就不用猜了。
4.4 前端调接口跨域:CORS 和反向代理哪个更适合课设
现象:前端页面能打开,但浏览器控制台报 Access to XMLHttpRequest ... blocked by CORS policy,接口数据全部不见。
原因:前后端分离部署,Vue 开发服务器跑在 8081,后端接口跑在 8080,浏览器出于同源策略把跨域请求拦了。
解决:后端加全局 CORS 配置是最快的做法,Nginx 反代要额外一层配置,课设阶段没必要。下面这段是标准写法:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }allowedOriginPatterns 用模式匹配而不是 allowedOrigins("*"),是因为 allowCredentials(true) 时浏览器不允许携带凭证的请求匹配通配符来源。allowCredentials(true) 会允许前端把 Cookie 和 Authorization 头一起带上来,登录态相关的接口都必须开。注意 OPTIONS 预检请求也要放行,否则前端在正式请求前会被预检拦截。如果你不想动后端,也可以在 Vue 的 vite.config.js 里配 proxy 把 /api 转发到 8080,两种方式选一种,别同时用,不然排查时不知道是哪一层在转发。
4.5 视频页面黑屏:相对路径、防盗链过期与播放器参数
现象:课程详情页点播放,播放器一直转圈或者直接 403,控制台看视频请求的 URL 是 404。
原因:章节表里存的是相对路径 /video/lesson01.mp4,播放器拿相对路径拼当前域名去请求,和后端静态资源映射对不上;或者用了对象存储的签名 URL,签名过期后播放器拿旧 URL 请求被拒。
解决:导入 SQL 脚本后先查一遍 edu_course_chapter.video_url,凡是相对路径的先补齐成完整 URL;对象存储签名 URL 要保证有效期覆盖视频完整播放时长,过期了就从后端重新申请再交给前端。另外确认后端对静态视频文件的访问路径有没有配置资源映射,Spring Boot 默认只映射 classpath:/static/,视频文件不在这个目录下必须写 addResourceHandlers。视频播放这类问题最好直接看浏览器 Network 面板里视频请求的状态码,403 是鉴权问题,404 是路径映射问题,网络错误是文件不存在或磁盘路径不对,症状指向完全不同。
5. 从“能跑”到“能答辩”:给在线教育源码补三个加分的工程化模块
5.1 用拦截器统一做登录态校验,而不是在每个 Controller 里重复查 Session
课设源码里最常见的不优雅写法,是每个 Controller 方法开头都写一遍“从 Header 里取 token,再查一遍用户”。代码重复不说,漏写一个接口就留了一个未授权访问的口子。我一般会把它收敛成拦截器,让受保护接口的校验变成一道统一关卡。
@Component public class AuthInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; public AuthInterceptor(JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } Long userId = jwtUtil.parseUserId(token.substring(7)); if (userId == null) { response.setStatus(401); return false; } request.setAttribute("currentUserId", userId); return true; } }preHandle 里做三件事:取 Header、解析 token、把 userId 塞进 request 属性。后面的 Controller 方法只要加一个参数@RequestAttribute Long currentUserId就能拿到当前登录用户,不需要再查一次库。这里 jjwt 解析 token 时要注意过期异常捕获,ExpiredJwtException 和 SignatureException 都要单独 catch,不能一棍子打成“token 无效”,否则前端没法区分“登录过期需要重新登录”和“token 被篡改”。
拦截器注册时把匿名接口放进排除清单:登录、注册、课程列表、课程详情、轮播图,这些接口不需要登录态;其余 /api/order/、/api/user/、/api/my/** 全部拦截。答辩时如果被问到“怎么保证接口安全”,把拦截器这一层讲出来,比说“我在每个接口里都做了判断”要高级得多。
5.2 订单状态和课程上下架用枚举收敛,拒绝魔法值 0/1/2
源码里订单状态的判断通常散落在各个 Service:订单查询里写 status == 1,支付回调里写 status = 1,退款里又写 status = 3。这些裸数字就是魔法值,改一个漏一个,是线上事故的高发源。用枚举收敛之后,Java 代码里不再出现任何数字字面量:
public enum OrderStatus { WAIT_PAY(0, "待支付"), PAID(1, "已支付"), CANCELLED(2, "已取消"), REFUNDED(3, "已退款"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }改造方式很简单:把原来写 status = 1 的地方换成 OrderStatus.PAID.getCode(),把判断 status == 1 的地方换成 OrderStatus.PAID.getCode() == order.getStatus()。数据库里存的还是数字,不影响原有数据,但代码可读性和可维护性提升一个档次。课程状态同理,定义一个 CourseStatus 枚举。这一步属于纯重构,不改表不改接口,风险极低,收益在答辩时能直接讲出来。
注意:枚举里不要塞太复杂的业务判断,保持只有 code 和 desc 两个属性。有些进阶写法会在枚举里加“状态能否流转到下一个状态”的方法,课设阶段可以先不做,过度设计反而容易被问住。
5.3 给视频点播加时间戳签名防盗链,顺便解决缓存串号
视频播放是这套源码里最容易被问“你怎么防盗链”的环节。答案可以很简单:不让视频 URL 永久有效,拼接一个带过期时间的签名,服务端每次下发 URL 时动态生成。
public class VideoSignUtil { private static final String SECRET_KEY = "your-video-sign-key"; public static String buildSignedUrl(String rawUrl, long expireSeconds) { long expireAt = System.currentTimeMillis() / 1000 + expireSeconds; String path = extractPath(rawUrl); String sign = md5(SECRET_KEY + path + expireAt); return rawUrl + (rawUrl.contains("?") ? "&" : "?") + "expire=" + expireAt + "&sign=" + sign; } }签名规则不复杂:把密钥、视频路径、过期时间一起做 MD5,URL 上带上 expire 和 sign。播放器加载视频时服务端校验签名和过期时间,过期就拒绝。这是现在最常见的对象存储防盗链方案,面试官听到你能说出“签名过期时间不可能覆盖永久,必须每次重新签发”这个边界,就知道你真做过而不是背的。
改造时注意一个细节:签名 URL 不能缓存。课程详情接口如果加了 Redis 缓存,缓存里存的必须是“未签名的永久 URL”,等接口返回给前端前再动态签名。如果缓存里存的是第一次生成的签名 URL,缓存过期前所有用户拿到的都是同一个 URL,签名一旦过期整片用户看不了视频。这个坑我踩过一次,后来养成了“缓存存原始数据,出口处统一签名”的习惯,顺便把之前说的缓存串号问题也一起解决了。
6. 源码改完怎么验证:半小时做一轮接口自测与日志体检
改动完代码别急着点启动,先用脚本把核心接口扫一遍,确认没把老功能弄坏。下面这个 bash 脚本是我每次改完后台代码都会跑的冒烟测试,输出每个接口的 HTTP 状态码:
#!/bin/bash BASE=http://localhost:8080 apis=( "GET /api/course/list" "POST /api/auth/login" "GET /api/course/detail/1" ) for item in "${apis[@]}"; do method=${item%% *} path=${item##* } code=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" "$BASE$path") echo "$method $path -> $code" done登录接口需要带 JSON 参数,curl 里要加 -H "Content-Type: application/json" -d '{"username":"student","password":"123456"}';受保护接口要先拿登录返回的 token,再加 -H "Authorization: Bearer $token"。这一步能发现 500、405、401 三类最常见的回归问题。接口自测通过后,再打开日志文件跑一个 grep 检查:
grep -i "ERROR\|Exception" logs/app.log | tail -50如果日志里有异常堆栈但接口状态码正常,多半是“异常被吞了”,这种隐藏问题比直接报错更危险。数据库侧再执行一条验证订单状态流转的 SQL:查 pay_order 里有没有 status=0 的订单超过一小时还没变,有的话说明支付回调链路有问题。整个验证流程控制在半小时内。
我个人的习惯是每次改动前先建一个 git 分支或者打一个 tag,改坏了随时回滚。在线教育这类业务对数据一致性要求高,特别是订单和开课记录,出错后的血泪教训都是“以为改了一行不会有事,结果回调顺序一换,用户付了钱课没到账”。后来我养成了两个习惯:所有订单号加唯一索引,所有状态变更先打印日志再落库。这样出了问题能顺着日志把时间线拉出来,而不是靠猜。希望这些排查方法能帮你在做 Java 在线教育平台时少走弯路。
本文还有配套的精品资源,点击获取