本文约 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 官方目录里的成熟问答插件,前者自带投票、信誉、分类与标签,二开前先评估它与你主题的兼容性。
常见踩坑
- 把答案做成评论。 后期无法做排序、投票、采纳,只能重构。
- 投票并发丢票。 用先读后写的方式判断”是否已投”,并发时两个请求都读到未投票,导致一人两票。
- 允许给自己投票。 必须在服务端校验作者 ID。
- 案例: 某问答站上线后有用户用脚本注册 200 个小号给自己的答案刷票,排行榜一度被其占据。根因是投票只校验登录态、没做 IP 与注册时长限制。补齐”注册满 24 小时才能投票 + 同 IP 每日投票上限 10 次”后恢复正常。
- 采纳权限没校验。 任何人都能调接口采纳自己的答案,属于越权。
- 列表页 N+1 查询。 每个答案都单独查一次票数,50 个答案就是 50 条 SQL,必须用 meta 缓存票数。
- 答案 CPT 开了归档。 导致搜索引擎收录大量无上下文的答案页,产生大量薄内容。
总结
问答站的最小可用模型是:问题与答案两个 CPT 用 post_parent 关联、投票独立建表靠唯一索引保证一人一票、票数冗余到 post meta 供排序、采纳权限严格校验提问者身份。判断方案是否合格,看列表页 SQL 条数是否恒定(不随答案数增长)。社区的审核机制见社区内容审核与反垃圾;论坛形态的对比见WordPress 论坛插件对比。
