积分流水"反向穿透"社区发帖接口:我如何用"预扣+回滚"模型解决并发刷分

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

最近在对接一个积分商城插件和社区发帖系统,遇到一个经典但容易踩坑的场景:用户发帖奖励积分,但帖子被删或审核不通过时要追回积分。最头疼的是并发——两个请求同时进来,积分表直接变负数。

先说说我的第一版"天真实现",估计不少人写过类似的:

// 发帖成功钩子
add_action('wp_insert_post', function($post_id, $post, $update) {
    if ($update || $post->post_status !== 'publish') return;
    
    $user_id = $post->post_author;
    $current = get_user_meta($user_id, 'credits', true) ?: 0;
    update_user_meta($user_id, 'credits', $current + 10); // +10分
}, 10, 3);

这代码在单机低并发能跑,但上了生产就是定时炸弹。get_user_metaupdate_user_meta 之间没有原子性,两个请求同时读到 100,都写成 110,实际应该 120。更惨的是删帖追分时,如果用户已经把积分花掉了,直接扣成负数。

我后来改成了"预扣+回滚"模型,核心思路是积分变动先记流水,状态跟着业务走,而不是直接改余额:

/**
 * 积分流水表结构
 * id | user_id | amount | type | ref_type | ref_id | status | created_at
 * 
 * status: pending -> committed / rolled_back
 */

// 发帖时预占积分(此时不可消费)
function reserve_credit($user_id, $amount, $ref_type, $ref_id) {
    global $wpdb;
    
    $wpdb->query('START TRANSACTION');
    
    try {
        // 先查真实可用余额(排除pending状态的预占)
        $available = $wpdb->get_var($wpdb->prepare("
            SELECT (
                SELECT COALESCE(SUM(amount), 0) FROM {$wpdb->prefix}credit_logs 
                WHERE user_id = %d AND status = 'committed'
            ) - (
                SELECT COALESCE(SUM(ABS(amount)), 0) FROM {$wpdb->prefix}credit_logs 
                WHERE user_id = %d AND status = 'pending' AND amount < 0
            )
        ", $user_id, $user_id));
        
        if ($available + $amount < 0) { // $amount 为负表示扣减
            throw new Exception('积分不足');
        }
        
        $wpdb->insert("{$wpdb->prefix}credit_logs", [
            'user_id'   => $user_id,
            'amount'    => $amount,
            'type'      => $amount > 0 ? 'post_reward' : 'post_deduct',
            'ref_type'  => $ref_type,
            'ref_id'    => $ref_id,
            'status'    => 'pending',
            'created_at'=> current_time('mysql')
        ]);
        
        $log_id = $wpdb->insert_id;
        $wpdb->query('COMMIT');
        
        return $log_id;
        
    } catch (Exception $e) {
        $wpdb->query('ROLLBACK');
        return new WP_Error('credit_error', $e->getMessage());
    }
}

这里有个细节:可用余额的计算要排除 pending 状态的预扣项。如果是奖励积分(发帖成功给分),预占是正向的,不需要检查余额;如果是消费积分(比如发帖需要消耗积分才能发),预占是负向的,必须确保够扣。

然后和社区状态联动,用 transition_post_status 而不是 wp_insert_post,因为后者在自动保存、修订版时也会触发:

// 状态流转时真正确认或回滚
add_action('transition_post_status', function($new_status, $old_status, $post) {
    // 找到对应的pending流水
    $log = get_pending_log('post', $post->ID);
    if (!$log) return;
    
    if ($new_status === 'publish' && $old_status !== 'publish') {
        // 正式发布:确认积分
        commit_credit($log->id);
        
        // 同时触发社区通知(解耦,用异步任务)
        wp_schedule_single_event(time(), 'notify_credit_arrived', [
            'user_id' => $post->post_author,
            'amount'  => $log->amount
        ]);
        
    } elseif ($new_status === 'trash' || $new_status === 'draft' && $old_status === 'publish') {
        // 删除或下架:回滚积分
        rollback_credit($log->id);
        
        // 但如果用户已经花掉了部分呢?这里需要"追债"逻辑
        $current_available = get_available_credit($post->post_author);
        if ($current_available < 0) {
            // 标记欠费,限制部分功能
            set_user_debt_flag($post->post_author, abs($current_available));
        }
    }
}, 10, 3);

commit 和 rollback 的实现很简单,就是改状态,但有个坑:如果同一笔流水被重复 commit 怎么办?比如网络抖动导致钩子执行两次。我的做法是用 status = 'pending' 作为乐观锁条件:

function commit_credit($log_id) {
    global $wpdb;
    $affected = $wpdb->update(
        "{$wpdb->prefix}credit_logs",
        ['status' => 'committed', 'committed_at' => current_time('mysql')],
        ['id' => $log_id, 'status' => 'pending'] // 关键:只有pending才能改
    );
    return $affected === 1; // 返回是否真正修改成功
}

再分享一个和回帖联动的场景。回帖奖励积分通常有"防刷"限制,比如同一帖子多次回帖只奖一次,或者每日上限。这些规则如果写在发帖钩子里,每次都要查历史,越来越重。我拆成了"事件产生"和"规则判定"两个阶段:

// 阶段一:只产生原始事件,不管规则
add_action('comment_post', function($comment_id, $comment_approved, $commentdata) {
    if ($comment_approved !== 1) return; // 只处理已审核的
    
    // 插入一条"待判定"的积分事件,不直接算分
    wp_insert_post([
        'post_type'   => 'credit_event', // 自定义事件队列
        'post_title'  => 'comment_reward',
        'post_content'=> json_encode([
            'user_id'     => $commentdata['user_id'],
            'comment_id'  => $comment_id,
            'post_id'     => $commentdata['comment_post_ID'],
            'created_at'  => time()
        ]),
        'post_status' => 'pending',
        'post_author' => $commentdata['user_id']
    ]);
});

// 阶段二:定时任务或异步消费,应用规则
add_action('process_credit_events', function() {
    $events = get_pending_credit_events(50); // 批量处理
    
    foreach ($events as $event) {
        $payload = json_decode($event->post_content, true);
        
        // 规则引擎:同一帖子今日是否已奖?
        $today_rewarded = get_today_comment_rewards($payload['user_id'], $payload['post_id']);
        if ($today_rewarded > 0) {
            mark_event_skipped($event->ID, 'duplicate_post_reward');
            continue;
        }
        
        // 规则:每日回帖奖励上限
        $today_total = get_today_total_rewards($payload['user_id']);
        if ($today_total >= 50) { // 每日上限50分
            mark_event_skipped($event->ID, 'daily_limit_reached');
            continue;
        }
        
        // 通过规则,真正发分
        $log_id = reserve_credit($payload['user_id'], 2, 'comment', $payload['comment_id']);
        commit_credit($log_id); // 回帖即时确认,不需要pending
        mark_event_processed($event->ID, $log_id);
    }
});

这个拆法的好处是规则可以热更新,不用改发帖逻辑。比如运营突然说"周末双倍积分",改规则引擎就行,comment_post 钩子完全不用动。

最后说个踩过的坑:社区接口和积分系统如果是两个插件,或者积分是第三方服务,网络超时怎么办?我早期用同步调用,社区发帖卡 3 秒等积分接口返回。后来改成上面的事件队列,发帖瞬间完成,积分异步到账,用户感知是"发帖成功,积分稍后到账",体验反而更好。

你们对接社区和积分时,是怎么处理"业务成功但积分失败"这种不一致状态的?我目前的做法是允许短暂不一致,靠对账任务兜底,但感觉还有优化空间。

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