引言

教培机构线上化转型的核心技术挑战不在于"做个网站",而在于构建一套支撑视频内容管理、直播互动、多端学习同步和海量数据处理的系统架构。线下课程搬到线上涉及内容结构化重组、视频转码分发、实时互动直播、跨端学习进度同步等多个技术模块的协作。

本文从系统架构设计的角度,讨论教培机构线上化场景下的几个关键技术实现:视频直传与转码回调机制、直播房间的状态管理与录播自动关联、多端学习进度的心跳同步与对账、课程内容的树形结构建模与缓存策略。


一、课程内容结构化建模

在这里插入图片描述

1.1 课程内容的数据模型

线下课程搬到线上,第一步是将课程内容拆分为可管理的结构化单元。一个课程包含多个章节(Chapter),每个章节包含多个课时(Lesson),课时是学习的最小单元:

// 课程实体
public class Course {
    private Long courseId;
    private String title;
    private String coverUrl;
    private String description;
    private Long lecturerId;          // 讲师ID
    private CourseStatus status;       // DRAFT / PUBLISHED / ARCHIVED
    private Integer totalLessons;      // 课时总数(冗余字段)
    private Integer totalDuration;     // 总时长(秒,冗余字段)
    private LocalDateTime publishedAt;
}

// 章节实体
public class Chapter {
    private Long chapterId;
    private Long courseId;
    private String title;
    private Integer sortOrder;          // 排序
    private List<Lesson> lessons;
}

// 课时实体(学习最小单元)
public class Lesson {
    private Long lessonId;
    private Long chapterId;
    private String title;
    private LessonType type;            // VIDEO / LIVE / DOCUMENT / QUIZ
    private Integer duration;           // 时长(秒)
    private Integer sortOrder;
    private Boolean freePreview;        // 是否免费试看
    private String vodVideoId;          // 关联的VOD视频ID(VIDEO类型)
    private String ossKey;              // 关联的文档OSS Key(DOCUMENT类型)
}

totalLessons 和 totalDuration 做冗余存储——课程列表页展示时避免对章节和课时做 COUNT + SUM 聚合查询。课时变更时同步更新冗余字段:

@Transactional
public void onLessonChanged(Long courseId) {
    Course course = courseRepo.findById(courseId).orElseThrow();
    course.setTotalLessons(lessonRepo.countByCourseId(courseId));
    course.setTotalDuration(lessonRepo.sumDurationByCourseId(courseId));
    courseRepo.save(course);
}

1.2 课程树缓存

课程结构数据读取频率高(每次进入课程详情页都要查),写入频率低(讲师编辑时才变),适合做缓存。缓存设计为两层:

请求 → Redis 缓存 → 数据库
         ↓ 缓存结构
    course:structure:{courseId} → JSON
    (包含 chapters + lessons 完整树结构)
public CourseStructure getCourseStructure(Long courseId) {
    String key = "course:structure:" + courseId;
    String cached = redisTemplate.opsForValue().get(key);
    
    if (cached != null) {
        return JsonUtils.parse(cached, CourseStructure.class);
    }
    
    // 查库:一次查询获取章节,一次查询获取所有课时,内存中组装树
    List<Chapter> chapters = chapterRepo.findByCourseIdOrderBySortOrder(courseId);
    List<Lesson> lessons = lessonRepo.findByCourseIdOrderBySortOrder(courseId);
    
    // 按 chapterId 分组,组装树结构
    Map<Long, List<Lesson>> lessonMap = lessons.stream()
        .collect(Collectors.groupingBy(Lesson::getChapterId));
    chapters.forEach(ch -> ch.setLessons(lessonMap.get(ch.getChapterId())));
    
    CourseStructure structure = new CourseStructure(courseId, chapters);
    redisTemplate.opsForValue().set(key, JsonUtils.stringify(structure), 1, TimeUnit.HOURS);
    return structure;
}

课程结构变更(增删改课时)时主动清除缓存:

public void publishLessonChange(Long courseId) {
    redisTemplate.delete("course:structure:" + courseId);
}

二、视频直传与转码回调

在这里插入图片描述

2.1 客户端直传方案

视频文件体积大,如果先上传到应用服务器再转发到 VOD,服务器带宽和内存压力很大。采用客户端直传方案——前端直接上传到云存储,应用服务器只负责签发上传凭证:

// 签发 VOD 上传凭证
@PostMapping("/api/vod/upload-auth")
public Result<VodUploadAuthVO> getUploadAuth(@RequestBody VodUploadAuthDTO dto) {
    // 权限校验:当前用户是否有权上传该课程的课时
    lessonService.assertUploadPermission(dto.getLessonId(), SecurityUtils.getCurrentUserId());
    
    // 调用 VOD API 创建上传凭证
    CreateUploadVideoRequest request = new CreateUploadVideoRequest()
        .setTitle(dto.getTitle())
        .setFileName(dto.getFileName())
        .setFileSize(dto.getFileSize())
        .setCateId(vodConfig.getCategory())  // VOD 分类ID
        .setTemplateGroupId(vodConfig.getTranscodeTemplate()); // 转码模板
    
    CreateUploadVideoResponse response = vodClient.createUploadVideo(request);
    
    // 预关联 videoId 到课时(状态标记为"转码中")
    lessonService.bindVodVideo(dto.getLessonId(), response.getVideoId());
    
    return Result.success(VodUploadAuthVO.builder()
        .uploadAuth(response.getUploadAuth())
        .uploadAddress(response.getUploadAddress())
        .videoId(response.getVideoId())
        .build());
}

上传流程:

前端 → 应用服务器:请求上传凭证
应用服务器 → VOD API:创建上传任务,获取凭证
应用服务器 → 前端:返回凭证 + uploadAddress
前端 → VOD CDN:直接上传文件(不经过应用服务器)
VOD → 应用服务器:转码完成回调(HTTP POST)
应用服务器:更新课时状态为"可用"

2.2 转码回调处理

VOD 转码是异步过程,完成后通过 HTTP 回调通知应用服务器。回调处理需要做签名验证和幂等控制:

@PostMapping("/api/vod/transcode-callback")
public Result<Void> onTranscodeComplete(
    @RequestHeader("X-VOD-Signature") String signature,
    @RequestBody String rawBody
) {
    // 1. 签名验证(防止伪造回调)
    String expected = hmacSha256(rawBody, vodConfig.getCallbackSecret());
    if (!MessageDigest.isEqual(signature.getBytes(), expected.getBytes())) {
        log.warn("VOD 回调签名验证失败");
        return Result.fail("签名验证失败");
    }
    
    VodCallbackDTO dto = JsonUtils.parse(rawBody, VodCallbackDTO.class);
    
    // 2. 幂等控制(VOD可能重复回调)
    String dedupKey = "vod:callback:" + dto.getVideoId();
    if (!redisTemplate.opsForValue().setIfAbsent(dedupKey, "1", 24, TimeUnit.HOURS)) {
        log.info("VOD 回调重复处理,videoId={}", dto.getVideoId());
        return Result.success();
    }
    
    // 3. 更新课时状态
    Lesson lesson = lessonRepo.findByVodVideoId(dto.getVideoId());
    if (lesson != null) {
        lesson.setStatus(LessonStatus.AVAILABLE);
        lesson.setDuration(dto.getDuration());
        lesson.setPlayUrl(dto.getPlayUrl());
        lessonRepo.save(lesson);
        
        // 4. 更新课程冗余字段
        courseService.onLessonChanged(lesson.getCourseId());
        
        // 5. 清除课程结构缓存
        redisTemplate.delete("course:structure:" + lesson.getCourseId());
    }
    
    return Result.success();
}

2.3 多码率与 CDN 分发

VOD 自动进行多码率转码(240P/360P/720P/1080P),播放端根据网络条件自适应切换。播放凭证有时效限制,每次播放都需要向服务端请求:

// 获取播放凭证(带权限校验)
@GetMapping("/api/vod/play-auth/{lessonId}")
public Result<PlayAuthVO> getPlayAuth(@PathVariable Long lessonId) {
    Long userId = SecurityUtils.getCurrentUserId();
    
    // 1. 权限校验:学员是否已购买该课程
    if (!accessService.hasAccess(userId, lessonId)) {
        // 检查是否免费试看
        Lesson lesson = lessonRepo.findById(lessonId).orElseThrow();
        if (!lesson.getFreePreview()) {
            throw new AccessDeniedException("请先购买课程");
        }
    }
    
    // 2. 获取 VOD 播放凭证(有效期100秒)
    Lesson lesson = lessonRepo.findById(lessonId).orElseThrow();
    GetPlayInfoResponse playInfo = vodClient.getPlayInfo(lesson.getVodVideoId());
    
    return Result.success(PlayAuthVO.builder()
        .playAuth(playInfo.getPlayAuth())
        .playUrl(playInfo.getPlayInfoList().get(0).getPlayUrl())
        .expiresIn(100)
        .build());
}

播放凭证短期有效的设计有两个好处:防止凭证被盗用后长期播放;每次播放都经过服务端权限校验,学员退款后凭证立刻失效。

2.4 课件文档在线预览

PPT、PDF 等课件通过 OSS 存储,生成带时效的预览 URL 实现在线预览:

@GetMapping("/api/resource/preview-url/{resourceId}")
public Result<String> getPreviewUrl(@PathVariable Long resourceId) {
    Resource resource = resourceRepo.findById(resourceId).orElseThrow();
    
    // 生成带签名的预览URL(防盗链,1小时内有效)
    String previewUrl = ossService.generateSignedUrl(
        resource.getOssKey(),
        Duration.ofHours(1),
        "inline"  // Content-Disposition: inline(浏览器内嵌预览)
    );
    
    // 记录预览日志
    accessLogService.logPreview(SecurityUtils.getCurrentUserId(), resourceId);
    
    return Result.success(previewUrl);
}

Content-Disposition: inline 确保浏览器内嵌预览而非触发下载。签名 URL 包含过期时间戳,即使 URL 泄露也无法长期使用。


三、直播房间的状态管理与录播关联

在这里插入图片描述

3.1 直播房间状态机

直播课时关联一个直播房间,房间有明确的生命周期状态:

public enum LiveRoomStatus {
    CREATED,       // 已创建,未开始
    LIVE,          // 直播中
    ENDED,         // 直播结束,录播处理中
    REPLAY_READY   // 录播已生成,可回看
}

状态流转:

  • CREATED → LIVE:讲师点击"开始直播"
  • LIVE → ENDED:讲师点击"结束直播"或超时自动结束
  • ENDED → REPLAY_READY:直播服务回调录播处理完成
public class LiveLesson {
    private Long lessonId;
    private Long liveRoomId;          // 关联的直播间ID
    private LocalDateTime startAt;   // 计划开始时间
    private Integer durationMinutes; // 预计时长
    private String liveTitle;
    private LiveRoomStatus status;
    private String replayVideoId;    // 录播视频ID(REPLAY_READY后填充)
}

3.2 直播开始与结束

// 开始直播
@Transactional
public void startLive(Long lessonId, Long lecturerId) {
    LiveLesson lesson = liveLessonRepo.findById(lessonId).orElseThrow();
    
    // 校验权限
    if (!lesson.getLecturerId().equals(lecturerId)) {
        throw new AccessDeniedException("无权操作此直播课时");
    }
    
    // 状态校验:只有 CREATED 状态可以开始
    if (lesson.getStatus() != LiveRoomStatus.CREATED) {
        throw new IllegalStateException("当前状态不允许开始直播: " + lesson.getStatus());
    }
    
    // 调用直播服务创建推流地址
    String pushUrl = liveService.createPushUrl(lesson.getLiveRoomId());
    
    lesson.setStatus(LiveRoomStatus.LIVE);
    lesson.setActualStartAt(LocalDateTime.now());
    liveLessonRepo.save(lesson);
    
    // 异步通知已报名学员
    eventPublisher.publishEvent(new LiveStartedEvent(lessonId));
    
    return pushUrl;
}

// 结束直播
@Transactional
public void endLive(Long lessonId, Long lecturerId) {
    LiveLesson lesson = liveLessonRepo.findById(lessonId).orElseThrow();
    
    if (lesson.getStatus() != LiveRoomStatus.LIVE) {
        throw new IllegalStateException("当前状态不允许结束直播");
    }
    
    // 调用直播服务结束推流,触发录播处理
    liveService.stopPush(lesson.getLiveRoomId());
    
    lesson.setStatus(LiveRoomStatus.ENDED);
    lesson.setActualEndAt(LocalDateTime.now());
    liveLessonRepo.save(lesson);
}

3.3 录播自动关联

直播结束后,直播服务异步录制并转码为 VOD 视频。转码完成后通过回调通知应用服务器,自动关联到课时:

// 直播录播完成回调
@PostMapping("/api/live/replay-callback")
public Result<Void> onReplayReady(
    @RequestHeader("X-Live-Signature") String signature,
    @RequestBody String rawBody
) {
    // 签名验证
    verifySignature(signature, rawBody);
    
    LiveReplayCallbackDTO dto = JsonUtils.parse(rawBody, LiveReplayCallbackDTO.class);
    
    LiveLesson lesson = liveLessonRepo.findByLiveRoomId(dto.getRoomId());
    if (lesson != null && lesson.getStatus() == LiveRoomStatus.ENDED) {
        lesson.setStatus(LiveRoomStatus.REPLAY_READY);
        lesson.setReplayVideoId(dto.getVideoId());
        lesson.setDuration(dto.getDuration());
        liveLessonRepo.save(lesson);
        
        // 课时类型从 LIVE 转为 VIDEO(可回看的录播)
        Lesson baseLesson = lessonRepo.findById(lesson.getLessonId()).orElseThrow();
        baseLesson.setType(LessonType.VIDEO);
        baseLesson.setVodVideoId(dto.getVideoId());
        lessonRepo.save(baseLesson);
        
        // 清除课程结构缓存
        redisTemplate.delete("course:structure:" + baseLesson.getCourseId());
    }
    
    return Result.success();
}

直播结束后课时类型从 LIVE 变为 VIDEO——学员后续回看走录播视频的播放流程,不再依赖直播服务。

3.4 直播超时自动结束

讲师可能忘记点"结束直播",导致房间一直占用直播服务资源。用定时任务检测超时直播:

@Scheduled(fixedRate = 60000) // 每分钟检测一次
public void autoEndOvertimeLive() {
    List<LiveLesson> liveLessons = liveLessonRepo.findByStatus(LiveRoomStatus.LIVE);
    LocalDateTime now = LocalDateTime.now();
    
    for (LiveLesson lesson : liveLessons) {
        // 超过预计时长2倍仍未结束,自动关闭
        LocalDateTime timeoutAt = lesson.getActualStartAt()
            .plusMinutes(lesson.getDurationMinutes() * 2);
        
        if (now.isAfter(timeoutAt)) {
            log.warn("直播超时自动结束,lessonId={}", lesson.getLessonId());
            endLive(lesson.getLessonId(), lesson.getLecturerId());
        }
    }
}

四、多端学习进度同步

在这里插入图片描述

4.1 跨端断点续学

学员可能在手机上看了一半,切换到 PC 端继续看。断点续学的核心是进度数据服务端集中存储,各端播放前拉取上次进度:

// 前端播放器初始化
async function initPlayer(lessonId) {
    // 1. 拉取上次进度
    const progress = await api.getLessonProgress(lessonId)
    
    // 2. 拉取播放凭证
    const playAuth = await api.getPlayAuth(lessonId)
    
    // 3. 初始化播放器,从上次位置开始
    player = new AliPlayer({
        vid: lesson.vodVideoId,
        playauth: playAuth.playAuth,
        startTime: progress.watchedSeconds > 30 ? progress.watchedSeconds : 0
    })
    
    // 4. 心跳上报
    setInterval(() => {
        if (!player.paused && !player.ended) {
            api.reportProgress({
                lessonId,
                watchedSeconds: Math.floor(player.currentTime),
                duration: Math.floor(player.duration)
            })
        }
    }, 15000)
    
    // 5. 播放结束上报
    player.on('ended', () => {
        api.reportProgress({
            lessonId,
            watchedSeconds: Math.floor(player.duration),
            duration: Math.floor(player.duration),
            completed: true
        })
    })
}

4.2 服务端进度对账

心跳上报间隔 15 秒,网络问题可能导致最后几条心跳丢失。服务端做防回退 + 对账:

@PostMapping("/api/learning/heartbeat")
public Result<Void> reportProgress(@RequestBody ProgressHeartbeatDTO dto) {
    Long userId = SecurityUtils.getCurrentUserId();
    
    LearningProgress progress = progressRepo
        .findByUserIdAndLessonId(userId, dto.getLessonId());
    
    if (progress == null) {
        progress = new LearningProgress();
        progress.setUserId(userId);
        progress.setLessonId(dto.getLessonId());
        progress.setWatchedSeconds(0);
    }
    
    // 防回退:只接受比当前更大的进度
    if (dto.getWatchedSeconds() > progress.getWatchedSeconds()) {
        // 校验心跳间隔合理性(防快进)
        long delta = dto.getWatchedSeconds() - progress.getWatchedSeconds();
        double maxAcceptableDelta = 15 * 2.0 + 5; // 15秒间隔 × 2倍速 + 5秒容差
        
        if (delta < maxAcceptableDelta) {
            progress.setWatchedSeconds(dto.getWatchedSeconds());
            progress.setLastReportAt(LocalDateTime.now());
        }
    }
    
    // 完成判定:观看时长 ≥ 视频总时长的 90%
    if (dto.getDuration() != null && dto.getDuration() > 0) {
        double completionRate = (double) progress.getWatchedSeconds() / dto.getDuration();
        if (completionRate >= 0.9 && !progress.getCompleted()) {
            progress.setCompleted(true);
            progress.setCompletedAt(LocalDateTime.now());
            // 触发章节/课程完成事件
            eventPublisher.publishEvent(new LessonCompletedEvent(userId, dto.getLessonId()));
        }
    }
    
    progressRepo.save(progress);
    return Result.success();
}

4.3 并发上报处理

学员同时打开两个端播放同一课时(如手机和PC),两个端的心跳会并发到达。用分布式锁串行化:

@PostMapping("/api/learning/heartbeat")
public Result<Void> reportProgress(@RequestBody ProgressHeartbeatDTO dto) {
    Long userId = SecurityUtils.getCurrentUserId();
    String lockKey = "progress:" + userId + ":" + dto.getLessonId();
    
    RLock lock = redissonClient.getLock(lockKey);
    try {
        if (!lock.tryLock(3, TimeUnit.SECONDS)) {
            // 获取锁超时,跳过本次上报(下次会再来)
            return Result.success();
        }
        
        // ... 进度更新逻辑 ...
        
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

tryLock(3, TimeUnit.SECONDS) 设置 3 秒超时——如果另一个端正在写,本次心跳直接跳过,下一个 15 秒周期会重新上报,不影响最终进度准确性。


五、多端 API 设计与认证

5.1 统一 API 网关

App、小程序、H5、PC 共用同一套后端 API,通过网关层统一处理认证和限流:

App / 小程序 / H5 / PC
        ↓
   API 网关
   ├── 认证(Token 校验)
   ├── 限流(按用户/IP)
   ├── 请求日志
   └── 路由分发
        ↓
   后端微服务

不同端的差异化通过请求头标识,后端按端做差异化处理:

@Component
public class PlatformInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, ...) {
        String platform = request.getHeader("X-Platform"); // app / miniapp / h5 / pc
        request.setAttribute("platform", platform);
        return true;
    }
}

5.2 跨端 Token 认证

学员在 PC 端登录后,手机端不需要重新登录。方案是采用统一的 JWT Token + Redis Session:

// 登录成功后生成 Token
public LoginResult login(String phone, String password) {
    User user = userService.verify(phone, password);
    
    // 生成 JWT Token
    String token = jwtUtil.generate(user.getId(), user.getPhone());
    
    // 存入 Redis(支持跨端共享)
    String sessionKey = "session:" + user.getId();
    redisTemplate.opsForValue().set(sessionKey, token, 7, TimeUnit.DAYS);
    
    return LoginResult.builder()
        .token(token)
        .userId(user.getId())
        .expiresIn(7 * 24 * 3600)
        .build();
}

// Token 校验(所有端共用)
public boolean verifyToken(String token) {
    Long userId = jwtUtil.parseUserId(token);
    if (userId == null) return false;
    
    String sessionKey = "session:" + userId;
    String storedToken = redisTemplate.opsForValue().get(sessionKey);
    
    // 比对 Redis 中的 Token,支持踢人(删除 Session 即可让 Token 失效)
    return token.equals(storedToken);
}

JWT 自身携带用户信息,无状态解析;Redis 存一份活跃 Token,支持主动失效(如改密码后踢下线)。两者结合兼顾性能和可控性。


六、总结

本文讨论了教培机构线上化转型中几个核心技术模块的设计思路:

  1. 课程结构建模采用 Course → Chapter → Lesson 三层树结构,冗余字段避免聚合查询,课程树缓存用 JSON 存储完整结构,变更时主动失效
  2. 视频直传采用客户端直传方案减轻服务器压力,转码回调通过签名验证 + 幂等控制保证安全,播放凭证短期有效实现权限动态控制
  3. 直播房间用状态机管理生命周期(CREATED → LIVE → ENDED → REPLAY_READY),录播完成后自动关联到课时,超时自动结束防止资源泄漏
  4. 多端进度同步通过心跳上报 + 防回退 + 防快进 + 分布式锁串行化,90% 阈值判定完成,跨端拉取进度实现断点续学
  5. 跨端认证采用 JWT + Redis Session 方案,JWT 无状态解析保证性能,Redis 存储支持主动失效

这些模块的共同特点是:异步回调的幂等性控制、跨端数据的一致性保障、缓存与数据库的一致性维护是工程实现的核心难点。在实际项目中需要根据用户规模和业务要求做针对性设计。


Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐