Zsens Admin 插件打通社区发帖接口:我是如何用"事件代理层"把积分、回帖、用户等级串成一条链的
最近接了个需求,要在 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) {
// 通知、索引

