学习进度怎么做到不断点、不刷时长、不算重?——织码在线教育系统学习追踪引擎技术实现详解

引言:学习进度为什么总是最容易“看起来简单,做起来翻车”

在大多数在线教育项目里,学习进度往往是最容易被低估的模块之一。产品经理一句“记录用户学到第几分钟”,听起来像是半天就能做完的需求;但真正进入上线场景后,研发团队很快会发现,这根本不是一个播放器时间字段的问题,而是一套横跨播放器、缓存、数据库、状态机、反作弊和多端同步的系统工程。

真正麻烦的,从来都不是“当前看到第几秒”,而是这些高频又棘手的真实场景:

  • 学员在 PC 端看了 30 分钟,切到 App 继续学习,进度如何无缝接续?
  • 页面异常关闭、浏览器崩溃、移动端切后台时,最近一次学习位置如何尽量不丢?
  • 用户拖动进度条、倍速播放、挂机不动时,平台如何识别真实学习时长?
  • 多端同时在线学习时,总时长如何去重,避免把并发观看误算成双倍学习?
  • 合规培训、企业培训场景下,平台如何证明“这个人是真的学完了,而不是把视频拖完了”?

如果这些问题处理不好,学习进度模块很快就会从一个体验问题,演变成统计不准、培训考核不可信、合规审计无法追溯的系统性问题。

织码在线教育系统在这一块采用的是 前端心跳上报 + Redis 实时聚合 + MySQL 批量落库 + 章节级断点续学 + 防快进拖拽校验 + 多端进度合并 的组合方案。它的设计目标不是简单记录一个播放时间,而是把“学习行为”抽象成一套可追踪、可校验、可恢复、可审计的状态同步链路。

本文不会停留在“功能介绍”层面,而会直接从数据建模、心跳机制、断点续学、防作弊、多端一致性和性能治理几个层面,系统拆解织码在线教育系统学习追踪引擎的技术实现。


一、为什么“学习进度”绝对不能只存一个字段

在这里插入图片描述

1.1 进度、时长、完成状态,本质上是三类不同数据

很多系统第一版实现学习进度时,喜欢在课程表里直接挂一个 progress_percentcurrent_position。这种方式开发快,但在业务一复杂之后很容易失真,因为下面三类数据并不是一回事:

  1. 播放位置:用户当前看到视频第几秒,用于断点续学。
  2. 有效学习时长:真正计入培训时长、积分、学分、合规统计的学习时长。
  3. 完成状态:某章节或课程是否达到平台定义的完成条件。

这三者不总是线性关系:

  • 用户把视频拖到最后,播放位置可能已经到结尾,但有效学习时长并不足。
  • 用户反复回看重点内容,有效学习时长会增加,但当前位置未必持续向前。
  • 某些课程的完成判定,可能还依赖考试通过、问卷提交、章节解锁等业务规则,并不只看播放时长。

因此,进度系统如果要稳定,必须把“位置、时长、状态”拆开建模,而不是塞进一个字段里硬算。

1.2 推荐的数据分层:事实层、章节层、课程聚合层

织码在线教育系统里,学习进度至少拆成三层:

  • 学习会话事实层:记录每次学习行为的原始轨迹。
  • 章节级进度层:记录章节当前位置、章节累计时长、章节完成状态。
  • 课程级聚合层:对外提供课程总进度、最近学习章节、最近学习时间等高频查询结果。

这种拆法的核心优势是:

  • 事实层保留最原始数据,便于追溯、排障、审计。
  • 章节层支撑断点续学、已完成跳转、章节解锁等播放器能力。
  • 聚合层保证高频页面查询不需要每次实时扫全量明细。

1.3 数据模型设计

下面是更适合长期演进的表结构设计。相比单表方案,这种设计更适合后续扩展倍速校验、多端冲突合并和反作弊分析。

-- 课程级聚合进度:一个用户一门课程一条记录
CREATE TABLE `user_course_progress` (
  `id` bigint NOT NULL COMMENT '主键',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `course_id` bigint NOT NULL COMMENT '课程ID',
  `current_chapter_id` bigint DEFAULT NULL COMMENT '当前学习章节ID',
  `current_position` int DEFAULT 0 COMMENT '当前播放位置(秒)',
  `valid_duration` int DEFAULT 0 COMMENT '累计有效学习时长(秒)',
  `progress_percent` int DEFAULT 0 COMMENT '课程进度百分比',
  `last_device_type` tinyint DEFAULT NULL COMMENT '最近学习设备',
  `last_study_time` datetime DEFAULT NULL COMMENT '最后学习时间',
  `status` tinyint DEFAULT 0 COMMENT '0未开始 1学习中 2已完成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_course` (`user_id`, `course_id`),
  KEY `idx_last_study` (`user_id`, `last_study_time`)
) ENGINE=InnoDB COMMENT='用户课程聚合进度表';

-- 章节级进度:断点续学和章节完成判定的核心
CREATE TABLE `user_chapter_progress` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `course_id` bigint NOT NULL COMMENT '课程ID',
  `chapter_id` bigint NOT NULL COMMENT '章节ID',
  `position` int DEFAULT 0 COMMENT '章节当前位置(秒)',
  `valid_duration` int DEFAULT 0 COMMENT '章节累计有效学习时长(秒)',
  `complete_status` tinyint DEFAULT 0 COMMENT '0未完成 1正常完成 2异常完成',
  `completed_time` datetime DEFAULT NULL COMMENT '完成时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_chapter` (`user_id`, `chapter_id`)
) ENGINE=InnoDB COMMENT='用户章节进度表';

-- 学习会话明细:学习行为的事实表
CREATE TABLE `study_session_log` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `course_id` bigint NOT NULL COMMENT '课程ID',
  `chapter_id` bigint NOT NULL COMMENT '章节ID',
  `start_position` int NOT NULL COMMENT '开始位置(秒)',
  `end_position` int DEFAULT NULL COMMENT '结束位置(秒)',
  `start_time` datetime NOT NULL COMMENT '开始时间',
  `end_time` datetime DEFAULT NULL COMMENT '结束时间',
  `raw_duration` int DEFAULT 0 COMMENT '自然流逝时长(秒)',
  `valid_duration` int DEFAULT 0 COMMENT '有效学习时长(秒)',
  `device_type` tinyint NOT NULL COMMENT '设备: 1PC 2App 3小程序 4H5',
  `playback_rate` decimal(4,2) DEFAULT 1.00 COMMENT '播放倍速',
  `is_valid` tinyint DEFAULT 1 COMMENT '是否有效: 0无效 1有效',
  PRIMARY KEY (`id`),
  KEY `idx_user_course` (`user_id`, `course_id`),
  KEY `idx_user_time` (`user_id`, `start_time`)
) ENGINE=InnoDB COMMENT='学习会话明细表';

这个模型解决了两个常见问题:

  • 既能对外快速查询当前进度,也保留了足够的底层事实数据。
  • 后续无论做拖拽识别、挂机识别、多端冲突分析,都会有原始数据可回放。

二、为什么学习进度不能每秒写库,而要走“心跳 + 缓存聚合”

2.1 前端心跳协议设计

学习进度最常见的反模式,是播放器每秒直接调一次接口写数据库。这样实现简单,但一旦并发起来,数据库会被大量小更新压垮,而且网络抖动会直接放大数据不一致问题。

织码在线教育系统采用的是 15 秒心跳 + 关键事件补报 的策略:

  • start:开始学习会话
  • heartbeat:定时上报位置、倍速、设备类型等
  • end:离开页面、切章节、播放器关闭时主动结束会话

前端实现示例如下:

/**
 * 学习进度心跳上报
 */
class StudyHeartbeat {
  constructor(courseId, chapterId, deviceType) {
    this.courseId = courseId
    this.chapterId = chapterId
    this.deviceType = deviceType
    this.lastPosition = 0
    this.playbackRate = 1
    this.timer = null
    this.heartbeatInterval = 15000
  }

  onTimeUpdate(currentTime) {
    this.lastPosition = Math.floor(currentTime)
  }

  onRateChange(rate) {
    this.playbackRate = rate
  }

  start() {
    this.report('start')
    this.timer = setInterval(() => {
      this.report('heartbeat')
    }, this.heartbeatInterval)
  }

  async report(type) {
    const payload = {
      courseId: this.courseId,
      chapterId: this.chapterId,
      position: this.lastPosition,
      deviceType: this.deviceType,
      playbackRate: this.playbackRate,
      clientTime: Date.now(),
      type
    }

    try {
      await api.post('/api/study/heartbeat', payload)
    } catch (e) {
      // 上报失败先落本地缓存,等待后续补发
      this.cacheFailedReport(payload)
    }
  }

  async stop() {
    clearInterval(this.timer)
    await this.report('end')
  }
}

这里有两个设计细节非常关键:

  1. 不是只有心跳才更新进度。开始播放、结束播放、切章节、切后台这些关键动作,都应该作为事件单独上报。
  2. 客户端时间要一并上送。后端在做迟到请求处理、重放排序和幂等控制时,需要用到客户端时间做辅助判断。

2.2 后端实时聚合:Redis 负责吃掉高频写

后端收到心跳后,不会每次都更新 MySQL,而是先写 Redis,利用 Redis 做实时进度聚合。典型键设计如下:

study:session:{userId}:{courseId}      -> 当前学习会话上下文
study:position:{userId}:{courseId}     -> 最近一次播放位置
study:duration:{userId}:{courseId}     -> 当前累计有效学习时长
study:chapter:{userId}:{chapterId}     -> 当前章节实时进度
study:dedup:{requestId}                -> 幂等去重标记

其中最关键的是:心跳不是简单 set position,而是根据本次位置和上次位置之间的差值,决定是否累计有效时长。

/**
 * 学习心跳处理服务
 */
@Service
public class StudyHeartbeatService {

    private static final String STUDY_POSITION_KEY = "study:position:";
    private static final String STUDY_DURATION_KEY = "study:duration:";
    private static final String STUDY_SESSION_KEY = "study:session:";
    private static final String STUDY_DEDUP_KEY = "study:dedup:";

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public void handleHeartbeat(HeartbeatDTO dto, Long userId) {
        // 1. 幂等去重,避免客户端重试导致重复累计
        String dedupKey = STUDY_DEDUP_KEY + dto.getRequestId();
        Boolean first = redisTemplate.opsForValue()
            .setIfAbsent(dedupKey, "1", 10, TimeUnit.MINUTES);
        if (Boolean.FALSE.equals(first)) {
            return;
        }

        String positionKey = STUDY_POSITION_KEY + userId + ":" + dto.getCourseId();
        String durationKey = STUDY_DURATION_KEY + userId + ":" + dto.getCourseId();
        String sessionKey = STUDY_SESSION_KEY + userId + ":" + dto.getCourseId();

        if ("start".equals(dto.getType())) {
            initSession(sessionKey, dto, userId);
            return;
        }

        int lastPos = Optional.ofNullable(redisTemplate.opsForValue().get(positionKey))
            .map(Integer::parseInt)
            .orElse(dto.getPosition());

        if ("heartbeat".equals(dto.getType())) {
            int delta = dto.getPosition() - lastPos;

            // 正向推进且不超过心跳窗口,视为有效学习
            if (delta > 0 && delta <= 15) {
                redisTemplate.opsForValue().increment(durationKey, delta);
            }

            redisTemplate.opsForValue().set(positionKey,
                String.valueOf(dto.getPosition()), 2, TimeUnit.HOURS);
            syncChapterProgress(dto, userId, delta);
            return;
        }

        if ("end".equals(dto.getType())) {
            endSession(userId, dto);
        }
    }
}

这段逻辑背后的核心思想是:

  • 高频请求先落缓存,避免数据库承担细粒度更新。
  • delta 合理时才累加有效学习时长,不让明显异常的数据直接进入统计。
  • 幂等键保护能避免客户端重试导致同一心跳被重复累计。

2.3 批量落库:把实时性和成本做平衡

课程聚合进度最终还是要进入 MySQL,但不需要每次心跳都写。织码在线教育系统采用定时任务按固定频率批量刷盘:

/**
 * 每60秒将 Redis 聚合结果批量刷入 MySQL
 */
@Scheduled(fixedRate = 60000)
public void batchFlushToMySQL() {
    Set<String> sessionKeys = redisTemplate.keys(STUDY_SESSION_KEY + "*");
    if (CollUtil.isEmpty(sessionKeys)) {
        return;
    }

    List<UserCourseProgress> batchUpdate = new ArrayList<>();
    for (String key : sessionKeys) {
        StudySession session = loadSession(key);

        int duration = loadValidDuration(session.getUserId(), session.getCourseId());
        int totalVideoDuration = courseService.getCourseTotalDuration(session.getCourseId());
        int percent = totalVideoDuration > 0
            ? Math.min(100, duration * 100 / totalVideoDuration)
            : 0;

        UserCourseProgress progress = new UserCourseProgress();
        progress.setUserId(session.getUserId());
        progress.setCourseId(session.getCourseId());
        progress.setCurrentChapterId(session.getChapterId());
        progress.setCurrentPosition(session.getCurrentPosition());
        progress.setValidDuration(duration);
        progress.setProgressPercent(percent);
        progress.setLastStudyTime(new Date());
        progress.setStatus(percent >= 100 ? 2 : 1);
        batchUpdate.add(progress);
    }

    if (!batchUpdate.isEmpty()) {
        progressMapper.batchUpsert(batchUpdate);
    }
}

这样设计的收益很直接:

  • 页面查进度时可以优先读 Redis,实时性更好。
  • 数据库只接收批量写,避免海量小事务。
  • 异常退出场景下,仍然能依赖最近一次心跳保住大部分学习进度。
学习心跳架构:前端15秒心跳 → Redis实时聚合 → 60秒批量落库MySQL → 多端进度合并

在这里插入图片描述

三、断点续学怎么做,才能既不断点又防刷进度

3.1 断点续学真正依赖的是章节级位置,而不是课程百分比

如果系统只记录课程级 progress_percent,用户重新打开课程时,播放器并不知道应该跳到哪一章、哪一秒。因此断点续学最小可恢复单元一定是:

  • 当前章节 chapter_id
  • 当前章节位置 position
  • 当前章节完成状态 complete_status

典型恢复逻辑如下:

/**
 * 获取断点续学位置
 */
public ResumePositionDTO getResumePosition(Long userId, Long courseId) {
    // 1. 先查课程级聚合进度,定位最近学习章节
    UserCourseProgress progress = progressMapper.selectByUserAndCourse(userId, courseId);
    if (progress == null) {
        CourseChapter firstChapter = chapterMapper.selectFirstChapter(courseId);
        return ResumePositionDTO.builder()
            .chapterId(firstChapter.getId())
            .position(0)
            .build();
    }

    // 2. 再查章节级进度,定位章节内断点
    UserChapterProgress chapterProgress = chapterProgressMapper
        .selectByUserAndChapter(userId, progress.getCurrentChapterId());

    int position = chapterProgress != null ? chapterProgress.getPosition() : 0;

    // 3. 如果当前章节已完成,则自动跳到下一个未完成章节
    if (chapterProgress != null && chapterProgress.getCompleteStatus() == 1) {
        CourseChapter next = chapterMapper.selectNextUncompleted(
            courseId, progress.getCurrentChapterId(), userId);
        if (next != null) {
            return ResumePositionDTO.builder()
                .chapterId(next.getId())
                .position(0)
                .build();
        }
    }

    return ResumePositionDTO.builder()
        .chapterId(progress.getCurrentChapterId())
        .position(position)
        .build();
}

这里的关键不是简单返回最后位置,而是让播放器恢复到业务上真正合理的下一个学习点

3.2 防快进拖拽:为什么必须做位置变化合理性校验

在线教育最典型的作弊方式之一,就是拖拽进度条快速“刷完”视频。如果系统只根据当前位置判定进度,用户拖到视频末尾就能瞬间完成章节,这样的进度数据没有任何可信度。

织码在线教育系统在这一块采用的是 位置变化合理性校验

  • 正常播放时,两次心跳之间的位置变化应接近心跳周期。
  • 回看视频允许位置回退。
  • 如果短时间内位置跳跃过大,则判定为疑似拖拽,不计入有效时长。
/**
 * 防快进检测:校验进度变化是否合理
 */
@Service
public class ProgressValidationService {

    private static final int HEARTBEAT_INTERVAL_SECONDS = 15;
    private static final int MAX_ACCEPTABLE_DELTA = 20;

    public boolean validateProgressForSpeedDrag(Long userId, Long courseId,
                                                int currentPosition, int lastPosition) {
        int delta = currentPosition - lastPosition;

        // 正常播放
        if (delta > 0 && delta <= MAX_ACCEPTABLE_DELTA) {
            return true;
        }

        // 回看允许
        if (delta < 0) {
            return true;
        }

        // 异常快进
        if (delta > MAX_ACCEPTABLE_DELTA) {
            log.warn("用户{}课程{}疑似快进拖拽: 上次位置{}秒→当前位置{}秒, 变化{}秒",
                userId, courseId, lastPosition, currentPosition, delta);
            return false;
        }

        return true;
    }
}

更进一步,实际线上通常不会只靠一个 delta 判定,还会联动以下信号:

  • 当前页面是否前台可见
  • 是否处于静音或切后台状态
  • 当前倍速是否超出平台允许阈值
  • 是否连续多个心跳都出现不合理跳变

也就是说,平台真正要防的不是“拖动行为本身”,而是“借拖动行为伪造有效学习时长”。

3.3 原始时长和有效时长要分开记

为了让进度系统更可解释,建议会话表同时记录两种时长:

  • raw_duration:自然流逝的会话时长
  • valid_duration:通过校验后真正计入学习结果的时长

这样做的价值很大:

  • 便于后续做反作弊复盘
  • 能区分“在线时长”和“有效学习时长”
  • 对合规培训和考核场景尤其重要,因为最终考核通常只认有效时长

四、多端切换时,进度为什么最容易算乱

4.1 多端切换下,位置和时长不能用同一套合并规则

用户在 PC、App、小程序之间切换学习几乎是常态,这时候最容易犯的两个错误是:

  1. 后一次写入直接覆盖前一次进度,导致位置回退。
  2. 不区分时间重叠,直接把多个端的时长相加,导致总学习时长虚高。

织码在线教育系统的策略是:

  • 当前位置以最新时间戳优先
  • 有效学习时长按时间段合并去重

位置是一个时间点状态,时长是一段时间区间,这两者本来就不该共用同一种 merge 规则。

4.2 当前位置合并:以最新学习会话为准

/**
 * 多端进度合并:当前位置以最新学习会话为准
 */
public UserCourseProgress mergeMultiDeviceProgress(Long userId, Long courseId) {
    List<StudySessionLog> sessions = sessionLogMapper.selectByUserAndCourse(userId, courseId);
    if (CollUtil.isEmpty(sessions)) {
        return null;
    }

    sessions.sort(Comparator.comparing(StudySessionLog::getEndTime).reversed());
    StudySessionLog latest = sessions.get(0);

    UserCourseProgress merged = new UserCourseProgress();
    merged.setUserId(userId);
    merged.setCourseId(courseId);
    merged.setCurrentChapterId(latest.getChapterId());
    merged.setCurrentPosition(latest.getEndPosition());
    merged.setLastStudyTime(latest.getEndTime());

    int totalDuration = calculateEffectiveDuration(sessions);
    merged.setValidDuration(totalDuration);

    int totalVideoDuration = courseService.getCourseTotalDuration(courseId);
    merged.setProgressPercent(
        totalVideoDuration > 0 ? Math.min(100, totalDuration * 100 / totalVideoDuration) : 0
    );

    progressMapper.upsert(merged);
    return merged;
}

4.3 有效时长合并:重叠区间只算一份

当同一用户在多个端同时学习时,会话之间很可能出现时间重叠。如果直接 sum(valid_duration),结果一定会偏大。因此需要做区间合并:

/**
 * 合并有效学习时长:重叠时间段只算一份
 */
private int calculateEffectiveDuration(List<StudySessionLog> sessions) {
    List<StudySessionLog> valid = sessions.stream()
        .filter(s -> s.getIsValid() == 1)
        .sorted(Comparator.comparing(StudySessionLog::getStartTime))
        .collect(Collectors.toList());

    int totalDuration = 0;
    long lastEnd = 0;
    for (StudySessionLog session : valid) {
        long start = session.getStartTime().getTime() / 1000;
        long end = session.getEndTime() != null
            ? session.getEndTime().getTime() / 1000
            : start + session.getValidDuration();

        if (start >= lastEnd) {
            totalDuration += (end - start);
            lastEnd = end;
        } else if (end > lastEnd) {
            totalDuration += (end - lastEnd);
            lastEnd = end;
        }
    }
    return totalDuration;
}

这个算法的核心意义是:

  • 防止用户双端同时在线时,时长被重复累计
  • 保证最终时长更接近真实有效学习时长
  • 为后续学习行为分析保留更稳定的统计口径

在这里插入图片描述

五、一个真正能上线的学习追踪系统,还缺不了哪些底层能力

5.1 幂等、补偿和过期会话回收

学习追踪系统不是一次成功就算完的接口,而是一条长链路,任何一段都可能因为网络、客户端退出、服务重试而丢数据或重复数据。因此除了主流程,至少还要补上三类能力:

  • 幂等控制:避免重复心跳重复累计时长
  • 补偿机制:客户端缓存失败心跳,下次重放
  • 过期会话回收:长时间没有心跳的会话,需要自动收口并落库

否则,最终最常见的线上问题会是:

  • 用户说我明明学了,怎么进度没变
  • 运营说为什么学习时长偶尔会翻倍
  • 开发排查时发现会话既没正确结束,也没被系统补偿

5.2 可观测性:没有指标,排障几乎不可能

学习追踪系统要稳定运行,必须有足够的监控指标。至少要覆盖:

  • 每分钟心跳请求量
  • 心跳处理成功率、失败率
  • Redis 活跃会话数
  • 批量落库耗时和失败次数
  • 进度回退次数
  • 快进拖拽命中次数
  • 多端冲突合并次数

只有把这些指标建起来,系统出现进度丢失、统计不准、状态异常时,才能快速定位问题是在前端播放器、心跳接口、缓存聚合还是落库任务。

5.3 可扩展性:后续还能接哪些能力

如果学习进度引擎设计得足够清晰,后续其实还能自然延伸出很多能力:

  • 学习活跃度分析
  • 学习卡顿与播放失败率统计
  • 章节热力图和回看热点分析
  • 课程完成率与流失节点分析
  • 培训考核、学分结算、合规审计联动

换句话说,学习追踪系统不仅是播放器的一个功能模块,它其实还是课程数据分析和培训运营体系的数据底座。


六、总结:学习进度做不好,后面的培训统计和考核都会失真

学习进度追踪真正的难点,从来不在于把播放器时间写进数据库,而在于如何把学习行为建模成一套稳定可信的状态同步系统。织码在线教育系统在这一块的核心设计可以概括为:

  1. 分层建模:会话事实层、章节进度层、课程聚合层分离,避免单字段承载全部语义。
  2. 缓存聚合:前端 15 秒心跳,后端 Redis 实时聚合,MySQL 批量落库,兼顾实时性与成本。
  3. 有效时长校验:通过位置变化合理性、防拖拽、防挂机等机制,保证统计的是“真实学习”。
  4. 多端一致性:当前位置以最新时间戳为准,有效时长按时间段去重,确保跨端学习结果稳定可信。
  5. 可恢复可审计:通过断点续学、会话明细、补偿机制和监控指标,让系统既服务用户体验,也服务培训统计与合规分析。

如果你正在做企业培训平台、在线课堂、合规学习平台或考试系统,那么学习进度模块一定要按“引擎级能力”来设计,而不是把它当成一个普通字段存储问题处理。只有把这套底层机制打稳,后续的学习分析、培训考核和运营决策才有可信的数据基础。

如需私有化部署报价、远程产品演示,可访问官网https://www.weavecodes.com/,私信作者领取企业落地案例。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值