ThinkPHP6 事件订阅里我注册了"用户注册成功"钩子,结果短信验证码被发了三遍

站长杂谈 29 浏览 0 回复 返回上级

上周给一个客户做商城系统,注册流程里要接短信通知。我图省事,在 `EventServiceProvider` 里绑了个 `UserRegister` 事件,又在 `app/event.php` 的 `listen` 数组里挂了个 `SendSms` 监听器。本地测试一切正常,上线第二天客服就炸了——新用户注册一条短信收三条,凌晨两点被运营商风控直接限流。

排查了四十分钟才发现,我在 `EventServiceProvider` 的 `boot` 方法里写了个 `Event::listen('UserRegister', SendSms::class)`,同时 `event.php` 配置文件里又配了一遍同名事件。ThinkPHP6 的事件调度器是叠加注册的,不是覆盖,同一个事件被挂了两个监听器,再加上我手贱在控制器里又手动 `Event::trigger` 了一次,三条短信整整齐齐。

这里说说我现在的钩子挂载规矩,给同样迷糊的朋友提个醒:

一、先分清三种注册入口的优先级

TP6 里事件监听至少有四个地方能写:配置文件的 `listen` 数组、`EventServiceProvider` 的 `boot` 方法、模型观察器的 `Observer`、还有控制器里临时 `Event::listen`。执行顺序大概是:配置文件先加载 → Provider 里动态绑定的追加 → 运行时临时绑定的最后执行。同一个事件,四个地方都写,就会触发四次。

我现在强制自己:项目级的事件只放 `event.php`,插件级别的才走 Provider,控制器里绝不写 `Event::listen`,只保留 `Event::trigger`。

二、事件类 vs 字符串标识,混用要人命

我那次踩坑还有个隐藏原因:配置文件里写的是 `'App\Events\UserRegister' => [...]`,Provider 里写的是 `Event::listen('UserRegister', ...)`。一个是完整类名,一个是字符串别名,TP6 的调度器把它们当成两个不同事件处理,但实际上我触发时用的是 `event(new UserRegister($user))`,类名匹配上了配置文件的监听,字符串别名匹配上了 Provider 的监听,结果两边都跑了一遍。

现在我的项目里定了死规矩:事件标识统一用类名字符串,禁止起别名。`event.php` 里直接写 `UserRegister::class => [SendSms::class]`,杜绝任何歧义。

三、调试事件执行链的野路子

TP6 官方没给事件调试面板,我自己在 `app/middleware.php` 最前面塞了一个闭包中间件,专门记事件日志:

```php Event::listen('*', function ($event) { if (is_object($event)) { trace('事件触发: ' . get_class($event), 'info'); } }); ```

这段代码利用通配符监听所有事件,配合 `trace` 写进日志。上线前开一天,能完整看到哪些事件被触发、触发了几次。上线后关掉,避免性能损耗。比 `debugbar` 轻量,比 `var_dump` 体面。

四、模型观察器和事件钩子的"抢活"问题

还是这个注册流程,后来我又踩了个类似的坑。用户在 `User` 模型里写了 `UserObserver` 的 `created` 方法发欢迎邮件,同时我又在控制器 `register` 方法里手动触发 `UserRegister` 事件,事件监听器里还是发邮件。结果新用户收两封欢迎信,一封来自 Observer,一封来自 Event。

我的解决方式是画了一张生命周期职责表:模型观察器只处理数据层的事(比如级联写入、字段格式化),业务通知类的事情全部交给事件系统。`created` 里只做一件事——`event(new UserRegister($user))`,邮件、短信、推送都挂在这个事件上,控制器里不再手动触发。

这样模型复用的时候不会漏通知,事件系统也集中管理,调试只需要看 `event.php` 一个文件。

五、一个冷门但实用的技巧:事件的"条件订阅"

有些钩子不是每次都要跑。比如注册事件里,只有渠道来源是"邀请码"时才给上级返佣。我早期在监听器里写 `if` 判断,后来发现可以注册时直接过滤:

```php Event::listen('UserRegister', function ($event) { if ($event->user->source !== 'invite') return; // 返佣逻辑 }); ```

但这样每个监听器都要重复判断。现在我的写法是定义一个条件事件类,在 `shouldQueue` 或者自定义方法里做过滤,不符合条件的直接返回 `false`,调度器会自动跳过。代码干净很多,单元测试也好写。

---

说回开头那条短信,最后是给受影响用户补了短信余额,又在事件配置里加了单元测试——专门断言某个事件触发后监听器的执行次数。CI 跑不过不准合并。

你们项目里事件钩子是怎么管理的?有没有遇到过"一次触发、多次执行"的灵异现象?我这边现在看到 `Event::listen` 就条件反射检查有没有重复注册,算是后遗症了。

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