From fa5e37f0031b962d70cfc8d9a9ccf980174503be Mon Sep 17 00:00:00 2001 From: lkddi Date: Thu, 2 Apr 2026 16:07:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=8F=91=E9=80=81?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E7=BE=A4=E5=86=85=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=85=AC=E5=91=8A=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=A6=BB=E7=BA=BF=E9=98=B2=E6=8A=96=E4=B8=8E=E8=87=AA?= =?UTF-8?q?=E6=88=91=E6=92=AD=E6=8A=A5=E8=BF=87=E6=BB=A4=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后台微信机器人增加群内独立公告的分发推送模块 - 聊天室系统引入3秒离线延迟(防抖)防重复播报 - 优化聊天界面消息拉取过滤自身的欢迎或离场广播 - 管理员登录时的烟花特效同步至用户当前的前端显示 --- .../Controllers/Admin/WechatBotController.php | 21 ++ app/Http/Controllers/ChatController.php | 236 ++++++++---------- app/Jobs/ProcessUserLeave.php | 113 +++++++++ app/Services/ChatStateService.php | 10 + .../WechatBot/WechatNotificationService.php | 15 ++ .../views/admin/wechat_bot/edit.blade.php | 33 +++ .../views/chat/partials/scripts.blade.php | 7 +- routes/web.php | 1 + 8 files changed, 300 insertions(+), 136 deletions(-) create mode 100644 app/Jobs/ProcessUserLeave.php diff --git a/app/Http/Controllers/Admin/WechatBotController.php b/app/Http/Controllers/Admin/WechatBotController.php index 8e284d4..d0c2018 100644 --- a/app/Http/Controllers/Admin/WechatBotController.php +++ b/app/Http/Controllers/Admin/WechatBotController.php @@ -135,4 +135,25 @@ class WechatBotController extends Controller return redirect()->route('admin.wechat_bot.edit')->with('success', '机器相关配置已更新完成。如修改了Kafka请重启后端监听队列守护进程。'); } + + /** + * 发送群内公告 + */ + public function sendAnnouncement(Request $request, \App\Services\WechatBot\WechatNotificationService $wechatService): RedirectResponse + { + $validated = $request->validate([ + 'announcement_content' => 'required|string|max:1000', + ], [ + 'announcement_content.required' => '请输入公告内容', + 'announcement_content.max' => '公告内容太长,不能超过1000字', + ]); + + try { + $wechatService->sendCustomGroupAnnouncement($validated['announcement_content']); + + return back()->with('success', '群公告已通过微信机器人发送成功!(消息已进入队列)'); + } catch (\Exception $e) { + return back()->withInput()->withErrors(['announcement_content' => $e->getMessage()]); + } + } } diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 14cd025..a470263 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -14,7 +14,6 @@ namespace App\Http\Controllers; use App\Enums\CurrencySource; use App\Events\MessageSent; use App\Events\UserJoined; -use App\Events\UserLeft; use App\Http\Requests\SendMessageRequest; use App\Jobs\SaveMessageJob; use App\Models\Autoact; @@ -69,6 +68,9 @@ class ChatController extends Controller // 用户进房时间刷新 $user->update(['in_time' => now()]); + // 0. 判断是否已经是当前房间的在线状态 + $isAlreadyInRoom = $this->chatState->isUserInRoom($id, $user->username); + // 1. 先将用户从其他所有房间的在线名单中移除(切换房间时旧记录自动清理) // 避免直接跳转页面时 leave 接口未触发导致"幽灵在线"问题 $oldRoomIds = $this->chatState->getUserRooms($user->username); @@ -95,82 +97,91 @@ class ChatController extends Controller 'position_name' => $activePosition?->position?->name ?? '', ]; $this->chatState->userJoin($id, $user->username, $userData); + // 记录重新加入房间的精确时间戳(微秒),用于防抖判断(刷新的时候避免闪退闪进播报) + \Illuminate\Support\Facades\Redis::set("room:{$id}:join_time:{$user->username}", microtime(true)); - // 3. 广播 UserJoined 事件,通知房间内的其他人 - broadcast(new UserJoined($id, $user->username, $userData))->toOthers(); - - // 3. 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告 + // 3. 广播和初始化欢迎(仅限初次进入) $newbieEffect = null; - if (! $user->has_received_new_gift) { - // 通过统一积分服务发放新人礼包 6666 金币并记录流水 - $this->currencyService->change( - $user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id, - ); - $user->update(['has_received_new_gift' => true]); - // 发送新人专属欢迎公告 - $newbieMsg = [ - 'id' => $this->chatState->nextMessageId($id), - 'room_id' => $id, - 'from_user' => '系统公告', - 'to_user' => '大家', - 'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 【{$user->username}】 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!", - 'is_secret' => false, - 'font_color' => '#b91c1c', - 'action' => '', - 'welcome_user' => $user->username, - 'sent_at' => now()->toDateTimeString(), - ]; - $this->chatState->pushMessage($id, $newbieMsg); - broadcast(new MessageSent($id, $newbieMsg)); + if (! $isAlreadyInRoom) { + // 广播 UserJoined 事件,通知房间内的其他人 + broadcast(new UserJoined($id, $user->username, $userData))->toOthers(); - // 广播烟花特效给此时已在房间的其他用户 - broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers(); + // 新人首次进入:赠送 6666 金币、播放满场烟花、发送全场欢迎通告 + if (! $user->has_received_new_gift) { + // 通过统一积分服务发放新人礼包 6666 金币并记录流水 + $this->currencyService->change( + $user, 'gold', 6666, CurrencySource::NEWBIE_BONUS, '新人首次入场婿赠的 6666 金币大礼包', $id, + ); + $user->update(['has_received_new_gift' => true]); - // 传给前端,让新人自己的屏幕上也燃放烟花 - $newbieEffect = 'fireworks'; - } + // 发送新人专属欢迎公告 + $newbieMsg = [ + 'id' => $this->chatState->nextMessageId($id), + 'room_id' => $id, + 'from_user' => '系统公告', + 'to_user' => '大家', + 'content' => "🎉 缤纷礼花满天飞,热烈欢迎新朋友 【{$user->username}】 首次驾临本聊天室!系统已自动赠送 6666 金币新人大礼包!", + 'is_secret' => false, + 'font_color' => '#b91c1c', + 'action' => '', + 'welcome_user' => $user->username, + 'sent_at' => now()->toDateTimeString(), + ]; + $this->chatState->pushMessage($id, $newbieMsg); + broadcast(new MessageSent($id, $newbieMsg)); - // 4. superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报 - // 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条 - $this->chatState->removeOldWelcomeMessages($id, $user->username); + // 广播烟花特效给此时已在房间的其他用户 + broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username))->toOthers(); - if ($user->user_level >= $superLevel) { - // 管理员专属:全房间烟花 - broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username)); + // 传给前端,让新人自己的屏幕上也燃放烟花 + $newbieEffect = 'fireworks'; + } - $welcomeMsg = [ - 'id' => $this->chatState->nextMessageId($id), - 'room_id' => $id, - 'from_user' => '系统公告', - 'to_user' => '大家', - 'content' => "🎉 欢迎管理员 【{$user->username}】 驾临本聊天室!请各位文明聊天!", - 'is_secret' => false, - 'font_color' => '#b91c1c', - 'action' => 'admin_welcome', - 'welcome_user' => $user->username, - 'sent_at' => now()->toDateTimeString(), - ]; - $this->chatState->pushMessage($id, $welcomeMsg); - broadcast(new MessageSent($id, $welcomeMsg)); - } else { - // 5. 非站长:生成通用播报(有职务 > 有VIP > 普通随机词) - [$text, $color] = $this->broadcast->buildEntryBroadcast($user); + // superlevel 管理员进入:触发全房间烟花 + 系统公告,其他人走通用播报 + // 每次进入先清理掉历史中旧的欢迎消息,保证同一个人只保留最后一条 + $this->chatState->removeOldWelcomeMessages($id, $user->username); - $generalWelcomeMsg = [ - 'id' => $this->chatState->nextMessageId($id), - 'room_id' => $id, - 'from_user' => '进出播报', - 'to_user' => '大家', - 'content' => "{$text}", - 'is_secret' => false, - 'font_color' => $color, - 'action' => 'system_welcome', - 'welcome_user' => $user->username, - 'sent_at' => now()->toDateTimeString(), - ]; - $this->chatState->pushMessage($id, $generalWelcomeMsg); - broadcast(new MessageSent($id, $generalWelcomeMsg))->toOthers(); + if ($user->user_level >= $superLevel) { + // 管理员专属:全房间烟花 + broadcast(new \App\Events\EffectBroadcast($id, 'fireworks', $user->username)); + + // 传给前端,让管理员自己屏幕上也按规矩加载燃放烟花 + $newbieEffect = 'fireworks'; + + $welcomeMsg = [ + 'id' => $this->chatState->nextMessageId($id), + 'room_id' => $id, + 'from_user' => '系统公告', + 'to_user' => '大家', + 'content' => "🎉 欢迎管理员 【{$user->username}】 驾临本聊天室!请各位文明聊天!", + 'is_secret' => false, + 'font_color' => '#b91c1c', + 'action' => 'admin_welcome', + 'welcome_user' => $user->username, + 'sent_at' => now()->toDateTimeString(), + ]; + $this->chatState->pushMessage($id, $welcomeMsg); + broadcast(new MessageSent($id, $welcomeMsg)); + } else { + // 非站长:生成通用播报(有职务 > 有VIP > 普通随机词) + [$text, $color] = $this->broadcast->buildEntryBroadcast($user); + + $generalWelcomeMsg = [ + 'id' => $this->chatState->nextMessageId($id), + 'room_id' => $id, + 'from_user' => '进出播报', + 'to_user' => '大家', + 'content' => "{$text}", + 'is_secret' => false, + 'font_color' => $color, + 'action' => 'system_welcome', + 'welcome_user' => $user->username, + 'sent_at' => now()->toDateTimeString(), + ]; + $this->chatState->pushMessage($id, $generalWelcomeMsg); + broadcast(new MessageSent($id, $generalWelcomeMsg))->toOthers(); + } } // 6. 获取历史消息并过滤,只保留和当前用户相关的消息用于初次渲染 @@ -181,6 +192,13 @@ class ChatController extends Controller $toUser = $msg['to_user'] ?? ''; $fromUser = $msg['from_user'] ?? ''; $isSecret = ! empty($msg['is_secret']); + $action = $msg['action'] ?? ''; + $welcomeUser = $msg['welcome_user'] ?? ''; + + // 过滤自己的进出场提示,避免自己被自己刷屏 + if (($action === 'system_welcome' || $action === 'admin_welcome' || empty($action)) && $welcomeUser === $username) { + return false; + } // 公众发言(对大家说):所有人都可以看到 if ($toUser === '大家' || $toUser === '') { @@ -196,20 +214,22 @@ class ChatController extends Controller return $fromUser === $username || $toUser === $username; })); - // 7. 如果用户有在职職务,开始记录这次入场的在职登录 - $activeUP = $user->activePosition; - if ($activeUP) { - PositionDutyLog::create([ - 'user_id' => $user->id, - 'user_position_id' => $activeUP->id, - 'login_at' => now(), - 'ip_address' => request()->ip(), - 'room_id' => $id, - ]); - } + // 7. 如果用户有在职職务,开始记录这次入场的心跳登录 (仅初次) + if (! $isAlreadyInRoom) { + $activeUP = $user->activePosition; + if ($activeUP) { + PositionDutyLog::create([ + 'user_id' => $user->id, + 'user_position_id' => $activeUP->id, + 'login_at' => now(), + 'ip_address' => request()->ip(), + 'room_id' => $id, + ]); + } - // 8. 好友上线通知:向此房间内在线的好友推送慧慧话 - $this->notifyFriendsOnline($id, $user->username); + // 8. 好友上线通知:向此房间内在线的好友推送慧慧话 + $this->notifyFriendsOnline($id, $user->username); + } // 9. 检查是否有未处理的求婚 $pendingProposal = \App\Models\Marriage::with(['user', 'ringItem']) @@ -590,55 +610,11 @@ class ChatController extends Controller return response()->json(['status' => 'error'], 401); } - // 1. 从 Redis 删除该用户 - $this->chatState->userLeave($id, $user->username); - - // 记录退出时间和退出信息 - $user->update([ - 'out_time' => now(), - 'out_info' => '正常退出了房间', - ]); - - // 关闭该用户尚未结束的在职登录记录(结算在线时长) - $this->closeDutyLog($user->id); - - // 2. 发送离场播报 - $superLevel = (int) Sysparam::getValue('superlevel', '100'); - - if ($user->user_level >= $superLevel) { - // 管理员离场:系统公告 - $leaveMsg = [ - 'id' => $this->chatState->nextMessageId($id), - 'room_id' => $id, - 'from_user' => '系统公告', - 'to_user' => '大家', - 'content' => "👋 管理员 【{$user->username}】 已离开聊天室。", - 'is_secret' => false, - 'font_color' => '#b91c1c', - 'action' => 'admin_welcome', - 'welcome_user' => $user->username, - 'sent_at' => now()->toDateTimeString(), - ]; - } else { - [$leaveText, $color] = $this->broadcast->buildLeaveBroadcast($user); - $leaveMsg = [ - 'id' => $this->chatState->nextMessageId($id), - 'room_id' => $id, - 'from_user' => '进出播报', - 'to_user' => '大家', - 'content' => "{$leaveText}", - 'is_secret' => false, - 'font_color' => $color, - 'action' => 'system_welcome', - 'welcome_user' => $user->username, - 'sent_at' => now()->toDateTimeString(), - ]; - } - $this->chatState->pushMessage($id, $leaveMsg); - - // 3. 广播通知他人 (UserLeft 更新用户名单列表,MessageSent 更新消息记录) - broadcast(new UserLeft($id, $user->username))->toOthers(); - broadcast(new MessageSent($id, $leaveMsg))->toOthers(); + // 不立刻执行离线逻辑,而是给个 3 秒的防抖延迟 + // 这样如果用户只是刷新页面,很快在 init 中又会重新加入房间(记录的 join_time 会大于当前 leave 时的 leaveTime) + // Job 中就不会执行完整的离线播报和注销流程 + $leaveTime = microtime(true); + \App\Jobs\ProcessUserLeave::dispatch($id, clone $user, $leaveTime)->delay(now()->addSeconds(3)); return response()->json(['status' => 'success']); } diff --git a/app/Jobs/ProcessUserLeave.php b/app/Jobs/ProcessUserLeave.php new file mode 100644 index 0000000..4182fb2 --- /dev/null +++ b/app/Jobs/ProcessUserLeave.php @@ -0,0 +1,113 @@ +roomId}:join_time:{$this->user->username}"); + + // 如果最后一次加入的时间 > 当前离线任务产生的时间,说明用户又刷新重新进来了 + if ($lastJoinTime >= $this->leaveTime) { + return; + } + + // 1. 从 Redis 删除该用户 + $chatState->userLeave($this->roomId, $this->user->username); + + // 记录退出时间和退出信息 + $this->user->update([ + 'out_time' => now(), + 'out_info' => '正常退出了房间', + ]); + + // 关闭该用户尚未结束的在职登录记录(结算在线时长) + $this->closeDutyLog($this->user->id); + + // 2. 发送离场播报 + $superLevel = (int) Sysparam::getValue('superlevel', '100'); + + if ($this->user->user_level >= $superLevel) { + // 管理员离场:系统公告 + $leaveMsg = [ + 'id' => $chatState->nextMessageId($this->roomId), + 'room_id' => $this->roomId, + 'from_user' => '系统公告', + 'to_user' => '大家', + 'content' => "👋 管理员 【{$this->user->username}】 已离开聊天室。", + 'is_secret' => false, + 'font_color' => '#b91c1c', + 'action' => 'admin_welcome', + 'welcome_user' => $this->user->username, + 'sent_at' => now()->toDateTimeString(), + ]; + } else { + [$leaveText, $color] = $broadcast->buildLeaveBroadcast($this->user); + $leaveMsg = [ + 'id' => $chatState->nextMessageId($this->roomId), + 'room_id' => $this->roomId, + 'from_user' => '进出播报', + 'to_user' => '大家', + 'content' => "{$leaveText}", + 'is_secret' => false, + 'font_color' => $color, + 'action' => 'system_welcome', + 'welcome_user' => $this->user->username, + 'sent_at' => now()->toDateTimeString(), + ]; + } + + // 将播报存入 Redis 历史及广播 + $chatState->pushMessage($this->roomId, $leaveMsg); + broadcast(new \App\Events\UserLeft($this->roomId, $this->user->username))->toOthers(); + broadcast(new \App\Events\MessageSent($this->roomId, $leaveMsg))->toOthers(); + } + + /** + * 关闭该用户尚未结束的在职登录记录(结算在线时长) + */ + private function closeDutyLog(int $userId): void + { + // 将今日开放日志关闭并结算实际时长 + PositionDutyLog::query() + ->where('user_id', $userId) + ->whereNull('logout_at') + ->whereDate('login_at', today()) + ->update([ + 'logout_at' => now(), + 'duration_seconds' => DB::raw('GREATEST(0, TIMESTAMPDIFF(SECOND, login_at, NOW()))'), + ]); + + // 关闭历史遗留的跨天未关闭日志(login_at 非今日) + PositionDutyLog::query() + ->where('user_id', $userId) + ->whereNull('logout_at') + ->whereDate('login_at', '!=', today()) + ->update([ + 'logout_at' => DB::raw('DATE_ADD(DATE(login_at), INTERVAL "23:59:59" HOUR_SECOND)'), + ]); + } +} diff --git a/app/Services/ChatStateService.php b/app/Services/ChatStateService.php index de3f68d..c33cbf2 100644 --- a/app/Services/ChatStateService.php +++ b/app/Services/ChatStateService.php @@ -46,6 +46,16 @@ class ChatStateService Redis::del("room:{$roomId}:alive:{$username}"); } + /** + * 判断用户是否已经在某个房间的在线列表中 + */ + public function isUserInRoom(int $roomId, string $username): bool + { + $key = "room:{$roomId}:users"; + + return Redis::hexists($key, $username); + } + /** * 刷新用户心跳活跃标记(心跳接口调用)。 * diff --git a/app/Services/WechatBot/WechatNotificationService.php b/app/Services/WechatBot/WechatNotificationService.php index d36e36a..9d5cf8a 100644 --- a/app/Services/WechatBot/WechatNotificationService.php +++ b/app/Services/WechatBot/WechatNotificationService.php @@ -123,6 +123,21 @@ class WechatNotificationService SendWechatBotMessage::dispatch($groupWxid, $historyText); } + /** + * 发送自定义群内公告 + * + * @throws \Exception + */ + public function sendCustomGroupAnnouncement(string $message): void + { + $groupWxid = $this->config['group_notify']['target_wxid'] ?? ''; + if (! $groupWxid) { + throw new \Exception('未配置目标微信群 Wxid,无法发送公告消息'); + } + + SendWechatBotMessage::dispatch($groupWxid, $message); + } + /** * 好友上线私聊通知(带冷却) */ diff --git a/resources/views/admin/wechat_bot/edit.blade.php b/resources/views/admin/wechat_bot/edit.blade.php index 8aa388f..4ccf099 100644 --- a/resources/views/admin/wechat_bot/edit.blade.php +++ b/resources/views/admin/wechat_bot/edit.blade.php @@ -137,4 +137,37 @@ + + +
+
+
+

发送群内公告 (即时通知)

+

发出的内容将通过微信机器人直接推送到上方配置的目标微信群。

+
+
+ +
+ @if($errors->has('announcement_content')) +
+ ❌ {{ $errors->first('announcement_content') }} +
+ @endif + +
+ @csrf +
+ + +
+ +
+ +
+
+
+
@endsection diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index 4d79f4c..56e3937 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -722,12 +722,7 @@ renderUserList(); - // 管理员自己进房时,在本地播放烟花(服务端广播可能在 WS 连上前已发出) - const ctx = window.chatContext; - if (ctx && ctx.userLevel >= ctx.superLevel && typeof EffectManager !== 'undefined') { - // 延迟 800ms 确保页面渲染完成再播特效 - setTimeout(() => EffectManager.play('fireworks'), 800); - } + }); // 监听机器人动态开关 diff --git a/routes/web.php b/routes/web.php index 30488fa..b67b62f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -386,6 +386,7 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad // 微信机器人配置 Route::get('/wechat-bot', [\App\Http\Controllers\Admin\WechatBotController::class, 'edit'])->name('wechat_bot.edit'); Route::put('/wechat-bot', [\App\Http\Controllers\Admin\WechatBotController::class, 'update'])->name('wechat_bot.update'); + Route::post('/wechat-bot/announcement', [\App\Http\Controllers\Admin\WechatBotController::class, 'sendAnnouncement'])->name('wechat_bot.send_announcement'); // 运维工具(仅 id=1 超管可用) Route::get('/ops', [\App\Http\Controllers\Admin\OpsController::class, 'index'])->name('ops.index');