搜索全站
安装部署 15 分钟阅读

用 WordPress 搭建问答站:问答 CPT、投票机制与最佳答案实现

问答站的核心是"让好答案浮上来"。本文讲清问题与答案两个 CPT 的关联设计、投票表的去重逻辑、以及最佳答案的采纳与权限控制。

U
UFO_PF 2018-03-14 · 阅读 15 分钟
用 安装部署
阅读提示
本文约 5,736 字,预计阅读 15 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:问答站失败的原因往往是答案排序

搭个问答站技术上不难:注册两个文章类型,让用户发问题和回答。难的是排序——如果答案按时间排,最专业的回答如果发得晚就沉在底部;如果按点赞排,又容易被小号刷票。Quora、Stack Overflow 这类产品真正的技术含量在排序权重与信誉体系上。用 WordPress 做问答站,我们不必做到那个程度,但至少要解决三件事:答案与问题的关联、一人一票的去重、以及提问者采纳最佳答案的闭环。这三件事构成了问答站的最小可用模型。

原理分析:答案不该是评论

很多人第一反应是把”答案”做成评论。这是个错误:评论不支持单独排序、不支持投票 meta 的灵活扩展、不支持独立审核流,也没法给回答者单独计分。正确做法是注册独立的 answer CPT,用 post_parent 指向问题,这样既能用 WordPress 原生的父子关系查询,又能独立控制状态、排序与权限。

投票数据同样不能写进 post meta 的单个键值里(无法原子累加,并发会丢票)。做法是建一张投票表,联合唯一索引 (user_id, post_id, vote_type),用 INSERT IGNORE 保证一人一票,票数用 COUNT 实时算或冗余到 post meta 缓存。

数据 存储 理由
问题 / 答案 question / answer CPT 需独立排序与状态
关联关系 post_parent 原生支持,查询高效
投票 独立表 + 唯一索引 保证一人一票
票数缓存 post meta _vote_count 避免列表页重复 COUNT

保姆级实操

步骤 1:注册两个 CPT。 答案设为不公开归档(has_archive => false),它只在问题页内部展示。

add_action( 'init', 'mimangfei_register_qa_types' );
function mimangfei_register_qa_types() {
    register_post_type( 'question', array(
        'labels'      => array( 'name' => '问题', 'singular_name' => '问题' ),
        'public'      => true,
        'has_archive' => true,
        'supports'    => array( 'title', 'editor', 'author', 'comments' ),
        'rewrite'     => array( 'slug' => 'question' ),
        'show_in_rest'=> true,
    ) );

    register_post_type( 'answer', array(
        'labels'       => array( 'name' => '答案', 'singular_name' => '答案' ),
        'public'       => true,
        'has_archive'  => false,
        'hierarchical' => true,
        'supports'     => array( 'editor', 'author' ),
        'show_in_rest' => true,
    ) );
}

步骤 2:建投票表。 联合唯一索引是”一人一票”的技术保障,靠代码判断在并发下会失效。

function mimangfei_create_vote_table() {
    global $wpdb;
    $table   = $wpdb->prefix . 'qa_votes';
    $charset = $wpdb->get_charset_collate();
    $sql = "CREATE TABLE {$table} (
  id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  user_id bigint(20) unsigned NOT NULL,
  post_id bigint(20) unsigned NOT NULL,
  vote_type tinyint(1) NOT NULL DEFAULT 1,
  created_at datetime NOT NULL,
  PRIMARY KEY  (id),
  UNIQUE KEY user_post (user_id,post_id),
  KEY post_vote (post_id,vote_type)
) {$charset};";
    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( $sql );
}

步骤 3:实现投票。 用 $wpdb->query() 配合 INSERT IGNORE,插入失败即代表已投过票,返回受影响行数判断结果。

function mimangfei_cast_vote( $user_id, $post_id, $vote_type = 1 ) {
    global $wpdb;
    if ( ! $user_id ) {
        return new WP_Error( 'no_login', '请先登录' );
    }
    if ( (int) get_post_field( 'post_author', $post_id ) === (int) $user_id ) {
        return new WP_Error( 'self_vote', '不能给自己投票' );
    }

    $result = $wpdb->query( $wpdb->prepare(
        "INSERT IGNORE INTO {$wpdb->prefix}qa_votes
         (user_id, post_id, vote_type, created_at) VALUES (%d, %d, %d, %s)",
        $user_id, $post_id, ( 1 === (int) $vote_type ? 1 : -1 ), current_time( 'mysql' )
    ) );

    if ( 0 === $result ) {
        return new WP_Error( 'duplicate', '已经投过票了' );
    }

    $count = (int) $wpdb->get_var( $wpdb->prepare(
        "SELECT SUM(vote_type) FROM {$wpdb->prefix}qa_votes WHERE post_id = %d", $post_id
    ) );
    update_post_meta( $post_id, '_vote_count', $count );
    return $count;
}

步骤 4:答案排序。 最佳答案置顶,其余按票数降序、时间升序。用 WP_Query 的 orderby => meta_value_num 加 post_parent 查询。

function mimangfei_get_answers( $question_id ) {
    $accepted = (int) get_post_meta( $question_id, '_accepted_answer', true );

    $args = array(
        'post_type'      => 'answer',
        'post_parent'    => $question_id,
        'post_status'    => 'publish',
        'posts_per_page' => 50,
        'meta_key'       => '_vote_count',
        'orderby'        => array( 'meta_value_num' => 'DESC', 'date' => 'ASC' ),
    );
    $query = new WP_Query( $args );

    $list = $query->posts;
    if ( $accepted ) {
        // 把最佳答案挪到最前面
        usort( $list, function( $a, $b ) use ( $accepted ) {
            if ( $a->ID === $accepted ) { return -1; }
            if ( $b->ID === $accepted ) { return 1; }
            return 0;
        } );
    }
    return $list;
}

步骤 5:采纳最佳答案。 只有提问者本人(或管理员)能采纳,采纳后把答案 ID 写进问题的 meta,并给回答者加信誉分。

function mimangfei_accept_answer( $question_id, $answer_id, $user_id ) {
    $question = get_post( $question_id );
    if ( ! $question || (int) $question->post_author !== (int) $user_id ) {
        if ( ! current_user_can( 'manage_options' ) ) {
            return new WP_Error( 'denied', '只有提问者本人可以采纳' );
        }
    }
    if ( (int) get_post_field( 'post_parent', $answer_id ) !== (int) $question_id ) {
        return new WP_Error( 'mismatch', '答案不属于该问题' );
    }

    update_post_meta( $question_id, '_accepted_answer', $answer_id );
    update_post_meta( $question_id, '_status', 'resolved' );

    $author = (int) get_post_field( 'post_author', $answer_id );
    $rep    = (int) get_user_meta( $author, '_reputation', true );
    update_user_meta( $author, '_reputation', $rep + 15 );
    return true;
}

步骤 6:加悬赏与关闭机制。 问题是 CPT,加一个 _bounty meta 就能做悬赏;用 _status 标记 open / resolved / closed,列表页据此筛选。

步骤 7:用现成插件加速。 如果不想自己写,AnsPress 与 DW Question Answer 都是 WordPress 官方目录里的成熟问答插件,前者自带投票、信誉、分类与标签,二开前先评估它与你主题的兼容性。

常见踩坑

  1. 把答案做成评论。 后期无法做排序、投票、采纳,只能重构。
  2. 投票并发丢票。 用先读后写的方式判断”是否已投”,并发时两个请求都读到未投票,导致一人两票。
  3. 允许给自己投票。 必须在服务端校验作者 ID。
  4. 案例: 某问答站上线后有用户用脚本注册 200 个小号给自己的答案刷票,排行榜一度被其占据。根因是投票只校验登录态、没做 IP 与注册时长限制。补齐”注册满 24 小时才能投票 + 同 IP 每日投票上限 10 次”后恢复正常。
  5. 采纳权限没校验。 任何人都能调接口采纳自己的答案,属于越权。
  6. 列表页 N+1 查询。 每个答案都单独查一次票数,50 个答案就是 50 条 SQL,必须用 meta 缓存票数。
  7. 答案 CPT 开了归档。 导致搜索引擎收录大量无上下文的答案页,产生大量薄内容。

总结

问答站的最小可用模型是:问题与答案两个 CPT 用 post_parent 关联、投票独立建表靠唯一索引保证一人一票、票数冗余到 post meta 供排序、采纳权限严格校验提问者身份。判断方案是否合格,看列表页 SQL 条数是否恒定(不随答案数增长)。社区的审核机制见社区内容审核与反垃圾;论坛形态的对比见WordPress 论坛插件对比。

U
UFO_PF作者

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

留下你的想法

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