搜索全站
建站 15 分钟阅读

WordPress 课程站结构设计:课程、章节与课时的 CPT 层级与进度记录

课程站做起来容易,做顺手难。本文讲清课程—章节—课时三层的 CPT 该怎么注册、层级关系怎么存、进度数据记在哪里才不会拖垮数据库。

U
UFO_PF 2020-06-28 · 阅读 15 分钟
Wo 建站
阅读提示
本文约 5,691 字,预计阅读 15 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:层级设计错了,后面全是补丁

见过太多课程站把 60 个课时平铺在一个列表里,学员往下翻三屏就放弃了。也见过另一种极端:课程、章节、课时、小节四层,后台编辑点四次才能改一个视频。层级不是越细越好,它直接决定三件事:后台维护成本、前端面包屑与导航复杂度、进度计算的查询开销。这一节我们把结构拆开讲,从注册 CPT 到进度落表,给出一套能撑到几千学员的实现。

原理分析:层级用 post_parent,进度单独落表

WordPress 原生的层级能力来自 post_type 的 hierarchical 参数与 post_parent 字段。章节与课时之间用 post_parent 建立父子关系,好处是天然支持 wp_list_pages()、面包屑、get_post_ancestors(),不需要额外维护关联表。

进度数据不要写进 post meta 的”课时—用户”组合键里。假设 3000 学员 × 60 课时,就是 18 万行 post meta,查询某个学员的完成度要扫全表,页面会明显变慢。正确做法是建一张独立表,主键 (user_id, lesson_id),一次查询拿全部进度。

存储方式 优点 缺点 适用
post meta 实现快,无建表 数据量大后慢 学员 < 500
独立数据表 查询快,可统计 需 dbDelta 建表 学员 > 500
用户 meta 存数组 读取一次拿到全部 不好做反向统计 课时少的小站

保姆级实操

步骤 1:注册课程与课时两个 CPT。 课时设为层级型(hierarchical => true),这样课时下面可以再挂补充材料页;课程不设层级。

add_action( 'init', 'mimangfei_register_course_types' );
function mimangfei_register_course_types() {
    register_post_type( 'course', array(
        'labels'      => array( 'name' => '课程', 'singular_name' => '课程' ),
        'public'      => true,
        'has_archive' => true,
        'supports'    => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
        'rewrite'     => array( 'slug' => 'course' ),
        'show_in_rest'=> true,
    ) );

    register_post_type( 'lesson', array(
        'labels'       => array( 'name' => '课时', 'singular_name' => '课时' ),
        'public'       => true,
        'hierarchical' => true,
        'supports'     => array( 'title', 'editor', 'page-attributes' ),
        'rewrite'      => array( 'slug' => 'lesson' ),
        'show_in_rest' => true,
    ) );
}

步骤 2:用分类法做”章节”。 不要为章节再注册一个 CPT,用 register_taxonomy() 注册 lesson_section,并设为 hierarchical => true。章节本质是分组标签,用分类法能直接拿到归档页和筛选。

add_action( 'init', 'mimangfei_register_section_tax' );
function mimangfei_register_section_tax() {
    register_taxonomy( 'lesson_section', array( 'lesson' ), array(
        'labels'       => array( 'name' => '章节', 'singular_name' => '章节' ),
        'public'       => true,
        'hierarchical' => true,
        'show_in_rest' => true,
        'rewrite'      => array( 'slug' => 'section' ),
    ) );
}

步骤 3:建一张进度表。 用 dbDelta() 建表,注意它要求每个字段单独一行、主键单独声明,且 dbDelta 对大小写敏感。

function mimangfei_maybe_create_progress_table() {
    global $wpdb;
    $table   = $wpdb->prefix . 'course_progress';
    $charset = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE {$table} (
  id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  user_id bigint(20) unsigned NOT NULL,
  lesson_id bigint(20) unsigned NOT NULL,
  course_id bigint(20) unsigned NOT NULL,
  status varchar(20) NOT NULL DEFAULT 'not_started',
  updated_at datetime NOT NULL,
  PRIMARY KEY  (id),
  UNIQUE KEY user_lesson (user_id,lesson_id),
  KEY course_status (course_id,status)
) {$charset};";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( $sql );
}

步骤 4:写入与查询进度。 用 $wpdb->replace() 保证同一学员同一课时只有一行记录,ON DUPLICATE KEY 由 replace 自动处理。

function mimangfei_mark_lesson_done( $user_id, $lesson_id, $course_id ) {
    global $wpdb;
    $wpdb->replace(
        $wpdb->prefix . 'course_progress',
        array(
            'user_id'    => $user_id,
            'lesson_id'  => $lesson_id,
            'course_id'  => $course_id,
            'status'     => 'completed',
            'updated_at' => current_time( 'mysql' ),
        ),
        array( '%d', '%d', '%d', '%s', '%s' )
    );
}

function mimangfei_get_course_progress( $user_id, $course_id ) {
    global $wpdb;
    $total = $wpdb->get_var( $wpdb->prepare(
        "SELECT COUNT(*) FROM {$wpdb->posts} p
         INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
         WHERE p.post_type = 'lesson' AND p.post_status = 'publish'
           AND pm.meta_key = '_course_id' AND pm.meta_value = %d",
        $course_id
    ) );
    $done = $wpdb->get_var( $wpdb->prepare(
        "SELECT COUNT(*) FROM {$wpdb->prefix}course_progress
         WHERE user_id = %d AND course_id = %d AND status = 'completed'",
        $user_id, $course_id
    ) );
    return array(
        'total' => (int) $total,
        'done'  => (int) $done,
        'rate'  => $total ? round( $done / $total * 100 ) : 0,
    );
}

步骤 5:给课时绑定所属课程。 用 _course_id 这个 post meta 记录课时属于哪门课,配合 meta_query 取课时列表,避免依赖菜单顺序。

步骤 6:加一条”解锁”判断。 是否允许学员跳着学,用一条规则集中控制,不要散落在模板各处。

function mimangfei_lesson_unlocked( $user_id, $lesson_id ) {
    $course_id = (int) get_post_meta( $lesson_id, '_course_id', true );
    if ( ! $course_id ) {
        return true;
    }
    // 顺序解锁:前一课时完成才开放
    $prev = get_post_meta( $lesson_id, '_prev_lesson_id', true );
    if ( ! $prev ) {
        return true;
    }
    global $wpdb;
    $ok = $wpdb->get_var( $wpdb->prepare(
        "SELECT id FROM {$wpdb->prefix}course_progress
         WHERE user_id = %d AND lesson_id = %d AND status = 'completed' LIMIT 1",
        $user_id, (int) $prev
    ) );
    return (bool) $ok;
}

步骤 7:把进度接口暴露给前端。 注册 REST 路由,permission_callback 里校验 is_user_logged_in() 与课程权限,前端用 fetch 轮询更新进度条。

常见踩坑

  1. 忘了 flush 重写规则。 注册完 CPT 后课程页 404,去”设置 → 固定链接”点一次保存即可。
  2. dbDelta 没生效。 SQL 里字段必须一行一个,KEY 后要有两个空格,否则 WordPress 不认。
  3. 把进度写进 post meta。 学员上量后后台列表页加载超过 10 秒,只能迁表。
  4. 案例: 某课程站把章节注册成了独立 CPT,导致一个章节页要维护父子两套关系,编辑在后台改一次顺序要动 5 处,最后用 wp post 批量改 menu_order 才理顺。教训:分组用分类法,不要用 CPT。
  5. menu_order 与 post_parent 混用。 排序只看 menu_order,层级只看 post_parent,两者别混为一谈。
  6. REST 接口没做权限校验。 任何人能读到他人进度,属于越权漏洞。
  7. 统计查询没加索引。 course_id + status 一定要建联合索引,否则统计页全表扫描。

总结

课程结构的最小可用方案是:课程与课时两个 CPT + 一个层级分类法做章节 + 一张独立进度表。层级控制在三层以内,分组用分类法而非 CPT,进度坚决不走 post meta。判断标准:当你发现后台改一次课时要点超过三次,说明结构该收敛了。课程卖出去之后怎么设计付费与试读,见知识付费站付费墙设计;进度表建好后的权限控制见会员权限与内容保护。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。