feat: 增加发送微信群内自定义公告功能,并优化离线防抖与自我播报过滤机制
- 后台微信机器人增加群内独立公告的分发推送模块 - 聊天室系统引入3秒离线延迟(防抖)防重复播报 - 优化聊天界面消息拉取过滤自身的欢迎或离场广播 - 管理员登录时的烟花特效同步至用户当前的前端显示
This commit is contained in:
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
|
||||
'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' => "<span style=\"color: {$color}; font-weight: bold;\">{$text}</span>",
|
||||
'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' => "<span style=\"color: {$color}; font-weight: bold;\">{$leaveText}</span>",
|
||||
'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']);
|
||||
}
|
||||
|
||||
113
app/Jobs/ProcessUserLeave.php
Normal file
113
app/Jobs/ProcessUserLeave.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\PositionDutyLog;
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\RoomBroadcastService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class ProcessUserLeave implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $roomId,
|
||||
public User $user,
|
||||
public float $leaveTime
|
||||
) {}
|
||||
|
||||
public function handle(ChatStateService $chatState, RoomBroadcastService $broadcast): void
|
||||
{
|
||||
// 获取该用户最后一次进入房间的时间
|
||||
$lastJoinTime = (float) Redis::get("room:{$this->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' => "<span style=\"color: {$color}; font-weight: bold;\">{$leaveText}</span>",
|
||||
'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)'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新用户心跳活跃标记(心跳接口调用)。
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 好友上线私聊通知(带冷却)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user