feat: 增加发送微信群内自定义公告功能,并优化离线防抖与自我播报过滤机制

- 后台微信机器人增加群内独立公告的分发推送模块
- 聊天室系统引入3秒离线延迟(防抖)防重复播报
- 优化聊天界面消息拉取过滤自身的欢迎或离场广播
- 管理员登录时的烟花特效同步至用户当前的前端显示
This commit is contained in:
2026-04-02 16:07:40 +08:00
parent e36b779a4a
commit fa5e37f003
8 changed files with 300 additions and 136 deletions

View File

@@ -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()]);
}
}
}

View File

@@ -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']);
}

View 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)'),
]);
}
}

View File

@@ -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);
}
/**
* 刷新用户心跳活跃标记(心跳接口调用)。
*

View File

@@ -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);
}
/**
* 好友上线私聊通知(带冷却)
*/

View File

@@ -137,4 +137,37 @@
</form>
</div>
</div>
<!-- 发送群内公告/消息 独立模块 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden mt-6">
<div class="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50">
<div>
<h2 class="text-lg font-bold text-gray-800">发送群内公告 (即时通知)</h2>
<p class="text-xs text-gray-500 mt-1">发出的内容将通过微信机器人直接推送到上方配置的目标微信群。</p>
</div>
</div>
<div class="p-6">
@if($errors->has('announcement_content'))
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{{ $errors->first('announcement_content') }}
</div>
@endif
<form action="{{ route('admin.wechat_bot.send_announcement') }}" method="POST">
@csrf
<div class="mb-4">
<label class="block text-sm font-bold text-gray-700 mb-2">公告内容</label>
<textarea name="announcement_content" rows="4" class="w-full border-gray-300 rounded-md shadow-sm p-3 bg-gray-50 border focus:ring-indigo-500 focus:border-indigo-500" placeholder="请输入要发送到微信群内的公告/通知内容...">{{ old('announcement_content') }}</textarea>
</div>
<div class="flex">
<button type="submit" class="px-6 py-2 bg-indigo-600 text-white rounded-md font-bold hover:bg-indigo-700 shadow-sm transition flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path></svg>
立即发送公告
</button>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -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);
}
});
// 监听机器人动态开关

View File

@@ -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');