Zsens Admin 插件打通社区发帖接口:我是如何用"事件代理层"把积分、回帖、用户等级串成一条链的

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

最近接了个需求,要在 Zsens Admin 插件里对接社区的发帖、回帖和积分系统。一开始想得很简单,调几个 API 不就完了,结果真干起来才发现,这三个模块各自有各自的事件触发点,积分计算还涉及回帖奖励、发帖冷却、等级阈值一堆逻辑,硬塞到控制器里直接变成一坨意大利面。

我的第一个尝试是直接在发帖成功的回调里写积分逻辑:

// 灾难版本:控制器里硬编码
public function handlePublish(Request $request) {
    $post = Post::create($request->all());
    
    // 积分逻辑直接塞这里
    $user = User::find(auth()->id());
    $user->increment('credits', 10);
    
    // 回帖奖励?再套一层 if
    if ($request->has('parent_id')) {
        $parentAuthor = Post::find($request->parent_id)->user;
        $parentAuthor->increment('credits', 5);
    }
    
    // 等级检查?继续堆...
    if ($user->credits >= 100 && $user->level < 2) {
        $user->update(['level' => 2]);
    }
    
    return response()->json(['id' => $post->id]);
}

这段代码跑了三天就出事:运营临时要加"每日发帖上限",我又得进来改;接着要加"精华帖双倍积分",再改;然后测试发现回帖奖励没走事务,高并发下积分对不上。最惨的是,另一个插件也监听了发帖事件,两边积分规则冲突,用户被扣成了负数。

我后来抽了一层事件代理层(Event Broker),把"发生了什么事"和"该怎么响应"彻底拆开。核心思路是:控制器只负责抛事件,一个中央代理负责分发,各业务模块注册自己的监听器,互不干扰。

先看事件定义,我把社区相关动作拆成原子事件:

// app/Events/Community/
class PostPublished extends CommunityEvent {
    public function __construct(
        public Post $post,
        public User $author,
        public ?Post $parent = null,  // 回帖时非空
        public array $metadata = []   // 扩展字段,比如是否精华、是否首帖
    ) {}
}

class CreditChanged extends CommunityEvent {
    public function __construct(
        public User $user,
        public int $delta,
        public string $reason,        // 必须写清来源,方便审计
        public string $sourceEventId  // 关联原始事件,追溯用
    ) {}
}

事件代理层是个简单的分发器,但加了关键的两件事:事务包裹规则冲突检测

class CommunityBroker {
    protected array $listeners = [];
    protected array $ruleRegistry = [];  // 记录谁对什么事件感兴趣
    
    public function emit(CommunityEvent $event): void {
        $eventId = Str::uuid()->toString();
        $event->setId($eventId);
        
        // 先检查规则冲突
        $conflicts = $this->detectConflicts($event);
        if ($conflicts) {
            Log::warning('规则冲突', ['event' => $eventId, 'conflicts' => $conflicts]);
            // 不阻断,但标记审计
        }
        
        DB::transaction(function () use ($event, $eventId) {
            foreach ($this->listenersFor($event) as $listener) {
                $listener->handle($event);
            }
            
            // 所有监听器跑完后,统一写审计日志
            AuditLog::create([
                'event_id' => $eventId,
                'snapshot' => $this->captureSnapshot($event)
            ]);
        });
    }
    
    protected function detectConflicts(CommunityEvent $event): array {
        // 比如两个插件都注册了 PostPublished 的积分规则
        $rules = $this->ruleRegistry[get_class($event)] ?? [];
        $creditRules = array_filter($rules, fn($r) => $r->affects('credits'));
        
        return count($creditRules) > 1 
            ? array_map(fn($r) => $r->pluginId(), $creditRules)
            : [];
    }
}

积分模块作为独立监听器注册,内部再拆规则引擎执行器

class CreditRuleEngine implements CommunityListener {
    protected array $rules = [];
    
    public function __construct() {
        // 规则从配置/数据库加载,支持热更新
        $this->rules = Cache::remember('credit_rules', 300, function () {
            return CreditRule::active()->orderBy('priority')->get();
        });
    }
    
    public function handle(CommunityEvent $event): void {
        if (!$event instanceof PostPublished) return;
        
        $context = $this->buildContext($event);
        $totalDelta = 0;
        $applied = [];
        
        foreach ($this->rules as $rule) {
            if (!$rule->matches($context)) continue;
            
            $delta = $rule->calculate($context);
            $totalDelta += $delta;
            $applied[] = ['rule' => $rule->name, 'delta' => $delta];
            
            // 触达上限?中断后续规则
            if ($rule->isTerminal()) break;
        }
        
        if ($totalDelta !== 0) {
            event(new CreditChanged(
                $event->author,
                $totalDelta,
                'post_publish:' . implode(',', array_column($applied, 'rule')),
                $event->getId()
            ));
        }
    }
    
    protected function buildContext(PostPublished $event): RuleContext {
        return new RuleContext([
            'user' => $event->author,
            'isReply' => $event->parent !== null,
            'isFirstPostToday' => !$this->hasPostedToday($event->author),
            'currentLevel' => $event->author->level,
            'postLength' => strlen($event->post->content),
            'hasImage' => $event->post->hasMedia('images'),
            // 扩展字段,规则配置里可以引用
        ]);
    }
}

规则配置我是存在数据库里的,运营后台可以直接改,不用发版:

-- 示例规则
INSERT IGNORE INTO credit_rules (name, condition_json, action_json, priority, terminal) VALUES
('基础发帖奖励', '{"isReply": false}', '{"delta": 10}', 100, 0),
('回帖奖励', '{"isReply": true}', '{"delta": 5}', 100, 0),
('每日首帖加成', '{"isFirstPostToday": true}', '{"delta": 5}', 200, 0),
('灌水检测降权', '{"postLength": {"lt": 10}}', '{"delta": -5}', 300, 1),
('等级上限保护', '{"currentLevel": {"gte": 5}}', '{"delta": 0}', 999, 1);

回帖奖励的拆分我单独做了个链式事件:PostPublished 触发后,CreditRuleEngine 算出要给楼主奖励,但它不直接操作数据库,而是再抛一个 CreditChanged 事件。这样积分变动本身也能被其他模块监听——比如等级系统。

等级升级我放在另一个监听器里,专门监听 CreditChanged:

class LevelUpgradeListener implements CommunityListener {
    public function handle(CommunityEvent $event): void {
        if (!$event instanceof CreditChanged) return;
        if ($event->delta user;
        $thresholds = config('community.level_thresholds'); // [0, 100, 300, 800...]
        
        $newLevel = 0;
        foreach ($thresholds as $level => $need) {
            if ($user->credits >= $need) $newLevel = $level;
        }
        
        if ($newLevel > $user->level) {
            // 升级事件,可以触发通知、徽章等
            event(new UserLevelUp($user, $user->level, $newLevel));
            $user->update(['level' => $newLevel]);
        }
    }
}

这个结构跑到现在,最大的好处是测试可以切片做。规则引擎单独测,mock 各种 context;等级升级单独测,直接构造 CreditChanged 事件;控制器只测有没有正确抛事件。集成测试跑一遍完整链路,但定位问题很快。

有个坑提醒下:事件代理层的事务包裹意味着,如果某个监听器挂了,整个操作回滚。但像"发送通知"这种不需要强一致的操作,我改成了事务提交后再异步触发,用数据库事务的 afterCommit 回调:

DB::transaction(function () use ($event) {
    // 核心数据操作...
    
    DB::afterCommit(function () use ($event) {
        // 通知、索引
评论0
回复 · 0
还没有回复
微信客服 微信客服