积分回调"幽灵到账":我如何用 `transition_post_status` 替代 `publish_post` 堵上重复加分的 race condition
上周有个用户投诉发了三篇帖子,积分却涨了五次。查日志发现 `publish_post` 钩子在同一次请求里被触发了两回——一次是自动保存转正式,一次是 Gutenberg 的二次确认。更坑的是,快速连续点击发布按钮时,前端没拦住,后端也没幂等,积分直接"幽灵到账"。
先说我的原始写法,典型的"新手村陷阱":
add_action( 'publish_post', function( $post_id, $post ) {
$user_id = $post->post_author;
$current = get_user_meta( $user_id, 'community_points', true );
update_user_meta( $user_id, 'community_points', $current + 10 );
// 写流水日志
$wpdb->insert( 'points_log', [...] );
}, 10, 2 );
问题一:publish_post 在 `draft → publish` 和 `auto-draft → publish` 都会炸,某些编辑器插件还会手动再 do_action 一次。问题二:完全没做"这笔积分是否已加过"的校验,纯靠信任链。
我现在的方案是换成 transition_post_status,并且把"状态迁移方向"卡死:
add_action( 'transition_post_status', function( $new_status, $old_status, $post ) {
// 只认:非发布态 → 发布态,且必须是手动触发
if ( $new_status !== 'publish' || $old_status === 'publish' ) {
return;
}
// 排除自动保存的"伪发布"
if ( wp_is_post_autosave( $post->ID ) || wp_is_post_revision( $post->ID ) ) {
return;
}
$user_id = $post->post_author;
$post_id = $post->ID;
// 幂等锁:用 post_meta 当"已记账"标记,比查流水表轻量
if ( get_post_meta( $post_id, '_points_credited', true ) ) {
return;
}
// 原子操作:先锁再算,防止并发请求穿透
$lock_key = 'points_lock_' . $post_id;
if ( ! wp_cache_add( $lock_key, 1, '', 10 ) ) {
error_log( '积分加锁冲突: post_id=' . $post_id );
return;
}
$current = (int) get_user_meta( $user_id, 'community_points', true );
$new_total = $current + apply_filters( 'points_for_publish', 10, $post );
update_user_meta( $user_id, 'community_points', $new_total );
update_post_meta( $post_id, '_points_credited', time() ); // 记账标记
// 异步写流水,不卡主请求
wp_schedule_single_event( time(), 'async_points_log', [
'user_id' => $user_id,
'post_id' => $post_id,
'action' => 'publish',
'delta' => 10,
'balance' => $new_total,
] );
wp_cache_delete( $lock_key );
}, 10, 3 );
这里有个反直觉的点:我用 wp_cache_add 而不是 Redis 的 SETNX,因为多数站点装了 object cache 插件后,wp_cache_add 天然就是原子操作。没装的话……至少不会比原来更差,日志里能抓到冲突。
回帖的积分逻辑更脏。评论是嵌套结构,wp_insert_comment 触发时评论可能还没正式入库,comment_approved 还是 0。我踩的坑是:先给用户加了分,结果评论进审核了,用户删了评论,积分却没收回来。
现在的回帖积分拆成两段:
// 第一段:评论入库时只打"待审核"标记,不加积分
add_action( 'wp_insert_comment', function( $comment_id, $comment ) {
update_comment_meta( $comment_id, '_points_pending', 1 );
}, 10, 2 );
// 第二段:审核通过时,确认是"首次通过"才触发
add_action( 'transition_comment_status', function( $new_status, $old_status, $comment ) {
if ( $new_status !== 'approved' || $old_status === 'approved' ) {
return;
}
$comment_id = $comment->comment_ID;
if ( ! get_comment_meta( $comment_id, '_points_pending', true ) ) {
return; // 已经处理过,或者不是走正常流程的
}
// ... 加积分、清 pending 标记、写日志
delete_comment_meta( $comment_id, '_points_pending' );
update_comment_meta( $comment_id, '_points_credited', time() );
}, 10, 3 );
这套结构还有个好处:支持"积分追回"。如果后期把评论标为 spam,可以读 _points_credited 的时间戳,去流水表找对应记录做逆向冲正。我专门留了个 wp_schedule_single_event 的 hook 给这个场景,目前还没上线,但接口已经埋好了。
最后说个血泪教训:积分接口千万别在钩子回调里直接调 REST API 或者发 webhook。我曾经在 publish_post 里 wp_remote_post 到积分中心,结果对方服务 502,用户页面白屏,帖子其实发成功了,但前端以为失败,又点了一次发布……循环噩梦。现在所有外部联动全走异步任务队列,主请求只负责"本地记账+发信号"。
你们处理积分并发时是用数据库唯一索引、Redis 分布式锁,还是干脆上消息队列?我这种"穷人版" wp_cache_add 方案在高压下能撑多久,心里还没底。

