积分回调"幽灵到账":我如何用 `transition_post_status` 替代 `publish_post` 堵上重复加分的 race condition

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 52 浏览 0 回复

上周有个用户投诉发了三篇帖子,积分却涨了五次。查日志发现 `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_postwp_remote_post 到积分中心,结果对方服务 502,用户页面白屏,帖子其实发成功了,但前端以为失败,又点了一次发布……循环噩梦。现在所有外部联动全走异步任务队列,主请求只负责"本地记账+发信号"。

你们处理积分并发时是用数据库唯一索引、Redis 分布式锁,还是干脆上消息队列?我这种"穷人版" wp_cache_add 方案在高压下能撑多久,心里还没底。

评论0
回复 · 0
还没有回复
微信客服 微信客服