diff --git a/app/Console/Commands/AutoSaveExp.php b/app/Console/Commands/AutoSaveExp.php index 9a4f401..6ca0e2d 100644 --- a/app/Console/Commands/AutoSaveExp.php +++ b/app/Console/Commands/AutoSaveExp.php @@ -208,6 +208,14 @@ class AutoSaveExp extends Command $this->chatState->pushMessage($roomId, $sysMsg); broadcast(new MessageSent($roomId, $sysMsg)); SaveMessageJob::dispatch($sysMsg); + + // 触发微信机器人私聊通知 (等级提升) + try { + $wechatService = app(\App\Services\WechatBot\WechatNotificationService::class); + $wechatService->notifyLevelChange($user, $oldLevel, $newLevel); + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error('WechatBot level change notification failed', ['error' => $e->getMessage()]); + } } // 5. 向用户私人推送"系统为你自动存点"信息,在其聊天框显示 diff --git a/app/Console/Commands/ConsumeWechatMessages.php b/app/Console/Commands/ConsumeWechatMessages.php new file mode 100644 index 0000000..ca23955 --- /dev/null +++ b/app/Console/Commands/ConsumeWechatMessages.php @@ -0,0 +1,155 @@ +kafkaService = $kafkaService; + } + + public function handle(): int + { + $this->info('正在启动微信机器人 Kafka 消费者...'); + + $consumer = $this->kafkaService->createConsumer(); + if (! $consumer) { + $this->error('Kafka 配置不完整或加载失败,请在后台检查机器人设置。'); + + return self::FAILURE; + } + + $this->info('消费者已启动,等待消息...'); + + $apiService = new WechatBotApiService; + + while (true) { + try { + $messageJson = $consumer->consume(); + if ($messageJson) { + $rawJson = $messageJson->getValue(); + $this->info('--> 收到新的 Kafka 消息 (Raw Length: '.strlen($rawJson).')'); + + $messages = $this->kafkaService->parseKafkaMessage($rawJson); + + if (empty($messages)) { + $this->info('--> 解析后:无匹配的 AddMsgs 内容'); + } + + foreach ($messages as $msg) { + try { + $this->processMessage($msg, $apiService); + } catch (\Exception $e) { + Log::error('处理单条微信消息失败', [ + 'error' => $e->getMessage(), + 'msg' => $msg, + ]); + } + } + + $consumer->ack($messageJson); + } + } catch (\Exception $e) { + Log::error('Kafka 消费异常', ['error' => $e->getMessage()]); + // 延迟重试避免死循环 CPU 空转 + sleep(2); + } + } + + return self::SUCCESS; + } + + /** + * 处理单条消息逻辑 + */ + protected function processMessage(array $msg, WechatBotApiService $apiService): void + { + // 仅处理文本消息 (msg_type = 1) + if ($msg['msg_type'] != 1) { + return; + } + + $content = trim($msg['content']); + $fromUser = $msg['from_user']; + $isChatroom = $msg['is_chatroom']; + + // 绑定逻辑:必须是私聊,且内容格式为 BD-xxxxxx + if (! $isChatroom && preg_match('/^BD-\d{6}$/i', $content)) { + $this->info("收到潜在绑定请求: {$content} from {$fromUser}"); + $this->handleBindRequest(strtoupper($content), $fromUser, $apiService); + } + } + + /** + * 处理账号绑定请求 + */ + protected function handleBindRequest(string $code, string $wxid, WechatBotApiService $apiService): void + { + $cacheKey = 'wechat_bind_code:'.$code; + $username = Cache::get($cacheKey); + + if (! $username) { + $apiService->sendTextMessage($wxid, '❌ 绑定失败:该验证码无效或已过有效期(5分钟)。请在个人中心重新生成。'); + + return; + } + + $user = User::where('username', $username)->first(); + if (! $user) { + $apiService->sendTextMessage($wxid, '❌ 绑定失败:找不到对应的用户账号。'); + + return; + } + + // 判断该微信号是否已经被其他用户绑定(防止碰撞或安全隐患) + $existing = User::where('wxid', $wxid)->where('id', '!=', $user->id)->first(); + if ($existing) { + $apiService->sendTextMessage($wxid, "❌ 绑定失败:当前微信号已经被其他账号 [{$existing->username}] 绑定。请先解绑后再试。"); + + return; + } + + $user->wxid = $wxid; + $user->save(); + + // 验证成功后立即销毁验证码 + Cache::forget($cacheKey); + + $this->info("用户 [{$username}] 成功绑定微信: {$wxid}"); + + $successMsg = "🎉 绑定成功!\n" + ."您已成功绑定聊天室账号:[{$username}]。\n" + .'现在您可以接收重要系统通知了。'; + + $apiService->sendTextMessage($wxid, $successMsg); + } +} diff --git a/app/Console/Commands/WechatBotTestSend.php b/app/Console/Commands/WechatBotTestSend.php new file mode 100644 index 0000000..f2a5c42 --- /dev/null +++ b/app/Console/Commands/WechatBotTestSend.php @@ -0,0 +1,78 @@ +info('开始测试微信机器人发送...'); + + $param = SysParam::where('alias', 'wechat_bot_config')->first(); + if (! $param || empty($param->body)) { + $this->error('错误:未找到 wechat_bot_config 配置,请先在后台保存一次配置。'); + + return self::FAILURE; + } + + $config = json_decode($param->body, true); + $targetWxid = $config['group_notify']['target_wxid'] ?? ''; + + if (empty($targetWxid)) { + $this->error('错误:请于后台填写【目标微信群 Wxid】。'); + + return self::FAILURE; + } + + if (empty($config['api']['bot_key'] ?? '')) { + $this->error('错误:未配置【机器人 Key (必需)】,API请求将被拒绝(返回该链接不存在)。'); + + return self::FAILURE; + } + + $service = new WechatBotApiService; + + $this->info("发送目标: {$targetWxid}"); + $this->info('发送 API Base: '.($config['api']['base_url'] ?? '')); + + $message = "【系统连通性测试】\n发送时间:".now()->format('Y-m-d H:i:s')."\n如果您看到了这条消息,说明 ChatRoom 通知全站群发接口配置正确!"; + + $result = $service->sendTextMessage($targetWxid, $message); + + if ($result['success']) { + $this->info('✅ 发送成功!'); + + return self::SUCCESS; + } else { + $this->error('❌ 发送失败:'.($result['error'] ?? '未知错误')); + $this->warn('如果提示『该链接不存在』代表您的基础API URL 或接入 Key 有误。'); + + return self::FAILURE; + } + } +} diff --git a/app/Http/Controllers/Admin/WechatBotController.php b/app/Http/Controllers/Admin/WechatBotController.php new file mode 100644 index 0000000..ad739c0 --- /dev/null +++ b/app/Http/Controllers/Admin/WechatBotController.php @@ -0,0 +1,128 @@ + 'wechat_bot_config'], + [ + 'body' => json_encode([ + 'kafka' => [ + 'brokers' => '', + 'topic' => '', + 'group_id' => 'chatroom_wechat_bot', + 'bot_wxid' => '', + ], + 'api' => [ + 'base_url' => '', + 'bot_key' => '', + ], + 'group_notify' => [ + 'target_wxid' => '', + 'toggle_admin_online' => false, + 'toggle_baccarat_result' => false, + 'toggle_lottery_result' => false, + ], + 'personal_notify' => [ + 'toggle_friend_online' => false, + 'toggle_spouse_online' => false, + 'toggle_level_change' => false, + ], + ]), + 'guidetxt' => '微信机器人全站配置(包含群聊推送和私聊推送开关及Kafka连接)', + ] + ); + + $config = json_decode($param->body, true); + + return view('admin.wechat_bot.edit', compact('config')); + } + + /** + * 更新微信机器人配置 + */ + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'kafka_brokers' => 'nullable|string', + 'kafka_topic' => 'nullable|string', + 'kafka_group_id' => 'nullable|string', + 'kafka_bot_wxid' => 'nullable|string', + 'api_base_url' => 'nullable|string', + 'api_bot_key' => 'nullable|string', + 'qrcode_image' => 'nullable|image|max:2048', + 'group_target_wxid' => 'nullable|string', + 'toggle_admin_online' => 'nullable|boolean', + 'toggle_baccarat_result' => 'nullable|boolean', + 'toggle_lottery_result' => 'nullable|boolean', + 'toggle_friend_online' => 'nullable|boolean', + 'toggle_spouse_online' => 'nullable|boolean', + 'toggle_level_change' => 'nullable|boolean', + ]); + + $param = SysParam::where('alias', 'wechat_bot_config')->first(); + $oldConfig = $param ? (json_decode($param->body, true) ?? []) : []; + + $qrcodePath = $oldConfig['api']['qrcode_image'] ?? ''; + if ($request->hasFile('qrcode_image')) { + // 删除旧图 + if ($qrcodePath && \Illuminate\Support\Facades\Storage::disk('public')->exists($qrcodePath)) { + \Illuminate\Support\Facades\Storage::disk('public')->delete($qrcodePath); + } + $qrcodePath = $request->file('qrcode_image')->store('wechat', 'public'); + } + + $config = [ + 'kafka' => [ + 'brokers' => $validated['kafka_brokers'] ?? '', + 'topic' => $validated['kafka_topic'] ?? '', + 'group_id' => $validated['kafka_group_id'] ?? 'chatroom_wechat_bot', + 'bot_wxid' => $validated['kafka_bot_wxid'] ?? '', + ], + 'api' => [ + 'base_url' => $validated['api_base_url'] ?? '', + 'bot_key' => $validated['api_bot_key'] ?? '', + 'qrcode_image' => $qrcodePath, + ], + 'group_notify' => [ + 'target_wxid' => $validated['group_target_wxid'] ?? '', + 'toggle_admin_online' => $validated['toggle_admin_online'] ?? false, + 'toggle_baccarat_result' => $validated['toggle_baccarat_result'] ?? false, + 'toggle_lottery_result' => $validated['toggle_lottery_result'] ?? false, + ], + 'personal_notify' => [ + 'toggle_friend_online' => $validated['toggle_friend_online'] ?? false, + 'toggle_spouse_online' => $validated['toggle_spouse_online'] ?? false, + 'toggle_level_change' => $validated['toggle_level_change'] ?? false, + ], + ]; + + if ($param) { + $param->update(['body' => json_encode($config)]); + SysParam::clearCache('wechat_bot_config'); + } + + return redirect()->route('admin.wechat_bot.edit')->with('success', '机器相关配置已更新完成。如修改了Kafka请重启后端监听队列守护进程。'); + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index f40ec7b..ae0d923 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -160,6 +160,16 @@ class AuthController extends Controller 'sdate' => now(), 'uuname' => $user->username, ]); + + // 触发微信机器人消息推送 (登录上线类) + try { + $wechatService = new \App\Services\WechatBot\WechatNotificationService; + $wechatService->notifyAdminOnline($user); + $wechatService->notifyFriendsOnline($user); + $wechatService->notifySpouseOnline($user); + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error('WechatBot presence notification failed', ['error' => $e->getMessage()]); + } } /** diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 4e39ae3..5529256 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -220,6 +220,45 @@ class UserController extends Controller return response()->json(['status' => 'success', 'message' => '密码已成功修改。下次请使用新密码登录。']); } + /** + * 生成微信绑定代码 + */ + public function generateWechatCode(\Illuminate\Http\Request $request): JsonResponse + { + $user = \Illuminate\Support\Facades\Auth::user(); + if (! $user) { + return response()->json(['status' => 'error', 'message' => '未登录']); + } + + $code = 'BD-'.mt_rand(100000, 999999); + \Illuminate\Support\Facades\Cache::put('wechat_bind_code:'.$code, $user->username, 300); // 5分钟有效 + + return response()->json([ + 'status' => 'success', + 'code' => $code, + 'message' => '生成成功', + ]); + } + + /** + * 取消绑定微信 + */ + public function unbindWechat(\Illuminate\Http\Request $request): JsonResponse + { + $user = \Illuminate\Support\Facades\Auth::user(); + if (! $user) { + return response()->json(['status' => 'error', 'message' => '未登录']); + } + + $user->wxid = null; + $user->save(); + + return response()->json([ + 'status' => 'success', + 'message' => '解绑成功', + ]); + } + /** * 通用权限校验:检查操作者是否有权操作目标用户 * diff --git a/app/Jobs/CloseBaccaratRoundJob.php b/app/Jobs/CloseBaccaratRoundJob.php index d7207e0..6a03e2b 100644 --- a/app/Jobs/CloseBaccaratRoundJob.php +++ b/app/Jobs/CloseBaccaratRoundJob.php @@ -97,6 +97,7 @@ class CloseBaccaratRoundJob implements ShouldQueue DB::transaction(function () use ($bets, $result, $config, $currency, &$totalPayout, &$winners, &$losers) { foreach ($bets as $bet) { + /** @var \App\Models\BaccaratBet $bet */ $username = $bet->user->username ?? '匿名'; if ($result === 'kill') { @@ -223,5 +224,13 @@ class CloseBaccaratRoundJob implements ShouldQueue $chatState->pushMessage(1, $msg); broadcast(new MessageSent(1, $msg)); SaveMessageJob::dispatch($msg); + + // 触发微信机器人消息推送 (百家乐结果) + try { + $wechatService = new \App\Services\WechatBot\WechatNotificationService; + $wechatService->notifyBaccaratResult($content); + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error('WechatBot baccarat notification failed', ['error' => $e->getMessage()]); + } } } diff --git a/app/Jobs/SendWechatBotMessage.php b/app/Jobs/SendWechatBotMessage.php new file mode 100644 index 0000000..788b496 --- /dev/null +++ b/app/Jobs/SendWechatBotMessage.php @@ -0,0 +1,79 @@ +target = $target; + $this->message = $message; + } + + /** + * 执行任务 + */ + public function handle(WechatBotApiService $apiService): void + { + if (empty($this->target)) { + Log::warning('WechatBot: Target is empty, skipping message dispatch.'); + + return; + } + + $params = SysParam::where('alias', 'wechat_bot_config')->first(); + if ($params && ! empty($params->body)) { + $config = json_decode($params->body, true); + $isEnabled = $config['global_enabled'] ?? false; + + if (! $isEnabled) { + return; // 全局未开启,直接抛弃不发 + } + } + + try { + $apiService->sendTextMessage($this->target, $this->message); + } catch (\Exception $e) { + Log::error('WechatBot: Failed to send message in queue', [ + 'target' => $this->target, + 'message' => $this->message, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/LotteryService.php b/app/Services/LotteryService.php index a1a84f8..9de7efb 100644 --- a/app/Services/LotteryService.php +++ b/app/Services/LotteryService.php @@ -447,6 +447,14 @@ class LotteryService $content = "🎟️ 【双色球 第{$issue->issue_no}期 开奖】{$drawNums} {$line1}{$detailStr}"; $this->pushSystemMessage($content); + + // 触发微信机器人消息推送 (彩票开奖) + try { + $wechatService = app(\App\Services\WechatBot\WechatNotificationService::class); + $wechatService->notifyLotteryResult($content); + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error('WechatBot lottery notification failed', ['error' => $e->getMessage()]); + } } /** diff --git a/app/Services/WechatBot/KafkaConsumerService.php b/app/Services/WechatBot/KafkaConsumerService.php new file mode 100644 index 0000000..2a2d054 --- /dev/null +++ b/app/Services/WechatBot/KafkaConsumerService.php @@ -0,0 +1,133 @@ +first(); + if ($param && ! empty($param->body)) { + $config = json_decode($param->body, true); + $this->brokers = $config['kafka']['brokers'] ?? ''; + $this->topic = $config['kafka']['topic'] ?? ''; + $this->groupId = $config['kafka']['group_id'] ?? 'chatroom_wechat_bot'; + } + } + + /** + * 创建 Kafka 消费者实例 + */ + public function createConsumer(): ?Consumer + { + if (empty($this->brokers) || empty($this->topic)) { + Log::warning('WechatBot Kafka: brokers or topic is empty. Consumer not started.'); + + return null; + } + + $config = new ConsumerConfig; + $config->setBroker($this->brokers); + $config->setTopic($this->topic); + $config->setGroupId($this->groupId); + $config->setClientId('chatroom_wechat_bot_'.getmypid().'_'.uniqid()); + $config->setGroupInstanceId('chatroom_wechat_bot_instance_'.getmypid().'_'.uniqid()); + $config->setInterval(0.5); // 拉取间隔(秒) + $config->setGroupRetry(5); // 组协调重试次数 + $config->setGroupRetrySleep(1); // 组协调重试间隔(秒) + + return new Consumer($config); + } + + /** + * 解析 Kafka 原始消息,提取有效的聊天消息列表 + * + * @param string $rawJson Kafka 消息体 JSON 字符串 + * @return array 解析后的消息数组 + */ + public function parseKafkaMessage(string $rawJson): array + { + try { + $data = json_decode($rawJson, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + Log::warning('微信机器人 Kafka 消息 JSON 解析失败', ['error' => $e->getMessage()]); + + return []; + } + + // 只处理包含 AddMsgs 的消息 + $addMsgs = $data['AddMsgs'] ?? []; + if (empty($addMsgs)) { + return []; + } + + $messages = []; + foreach ($addMsgs as $msg) { + $fromUser = $msg['from_user_name']['str'] ?? ''; + $toUser = $msg['to_user_name']['str'] ?? ''; + $msgType = $msg['msg_type'] ?? 0; + $content = $msg['content']['str'] ?? ''; + + // 判断是否来自群聊 + $isChatroom = str_contains($fromUser, '@chatroom'); + + // 群聊消息中,from_user 是群ID,实际发送者在 content 中以 "wxid:\n内容" 格式出现 + $actualSender = $fromUser; + $actualContent = $content; + $chatroomId = null; + + if ($isChatroom) { + $chatroomId = $fromUser; + // 解析群聊消息格式: "发送者wxid:\n实际内容" + if (preg_match('/^(.+?):\n(.*)$/s', $content, $matches)) { + $actualSender = $matches[1]; + $actualContent = $matches[2]; + } + } + + $messages[] = [ + 'msg_id' => $msg['new_msg_id'] ?? $msg['msg_id'] ?? 0, + 'from_user' => $actualSender, + 'to_user' => $toUser, + 'chatroom_id' => $chatroomId, + 'msg_type' => $msgType, + 'content' => $actualContent, + 'raw_content' => $content, + 'is_chatroom' => $isChatroom, + ]; + } + + return $messages; + } +} diff --git a/app/Services/WechatBot/WechatBotApiService.php b/app/Services/WechatBot/WechatBotApiService.php new file mode 100644 index 0000000..7290088 --- /dev/null +++ b/app/Services/WechatBot/WechatBotApiService.php @@ -0,0 +1,125 @@ +first(); + if ($param && ! empty($param->body)) { + $config = json_decode($param->body, true); + $this->baseUrl = rtrim($config['api']['base_url'] ?? '', '/'); + $this->botKey = $config['api']['bot_key'] ?? ''; + } else { + $this->baseUrl = ''; + $this->botKey = ''; + } + } + + /** + * 发送文本消息 + * + * @param string $toUser 目标用户 wxid 或群聊 ID + * @param string $content 文本内容 + * @param array $atList 群聊中 @ 的用户 wxid 列表 + * @return array{success: bool, data: array|null, error: string|null} + */ + public function sendTextMessage(string $toUser, string $content, array $atList = []): array + { + if (empty($this->baseUrl)) { + return ['success' => false, 'data' => null, 'error' => 'API Base URL is not configured.']; + } + + $url = "{$this->baseUrl}/message/SendTextMessage"; + + $finalContent = "[和平聊吧]\n".$content; + + $payload = [ + 'MsgItem' => [ + [ + 'AtWxIDList' => $atList, + 'ImageContent' => '', + 'MsgType' => 0, + 'TextContent' => $finalContent, + 'ToUserName' => $toUser, + ], + ], + ]; + + try { + $response = Http::timeout($this->timeout) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]) + ->post("{$url}?key={$this->botKey}", $payload); + + if ($response->successful()) { + $data = $response->json(); + + if (($data['Code'] ?? 0) === 200) { + Log::info('微信机器人消息发送成功', [ + 'to_user' => $toUser, + 'content' => mb_substr($content, 0, 50), + ]); + + return ['success' => true, 'data' => $data['Data'] ?? [], 'error' => null]; + } + + $desc = $data['Text'] ?? 'Unknown'; + + return [ + 'success' => false, + 'data' => null, + 'error' => "API 返回错误: Code={$data['Code']}, Text={$desc}", + ]; + } + + return [ + 'success' => false, + 'data' => null, + 'error' => "HTTP 请求失败: {$response->status()}", + ]; + } catch (\Exception $e) { + Log::error('微信机器人消息发送异常', [ + 'to_user' => $toUser, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'data' => null, 'error' => '发送异常: '.$e->getMessage()]; + } + } +} diff --git a/app/Services/WechatBot/WechatNotificationService.php b/app/Services/WechatBot/WechatNotificationService.php new file mode 100644 index 0000000..973af08 --- /dev/null +++ b/app/Services/WechatBot/WechatNotificationService.php @@ -0,0 +1,194 @@ +first(); + if ($param && ! empty($param->body)) { + $this->config = json_decode($param->body, true) ?? []; + } + } + + /** + * 管理员上线群通知 + */ + public function notifyAdminOnline(User $user): void + { + if (empty($this->config['group_notify']['toggle_admin_online'])) { + return; + } + + $groupWxid = $this->config['group_notify']['target_wxid'] ?? ''; + if (! $groupWxid) { + return; + } + + // 判断是否真的是管理员(这里假设大于等于某等级,比如 15) + $adminLevel = (int) Sysparam::getValue('level_kick', '15'); + if ($user->user_level < $adminLevel) { + return; + } + + $message = "👑 【管理员上线】\n" + ."管理员 [{$user->username}] 刚刚登录了系统。"; + + SendWechatBotMessage::dispatch($groupWxid, $message); + } + + /** + * 百家乐开奖群通知 + */ + public function notifyBaccaratResult(string $historyText): void + { + if (empty($this->config['group_notify']['toggle_baccarat_result'])) { + return; + } + + $groupWxid = $this->config['group_notify']['target_wxid'] ?? ''; + if (! $groupWxid) { + return; + } + + $message = "🎰 【百家乐开奖】\n{$historyText}"; + SendWechatBotMessage::dispatch($groupWxid, $message); + } + + /** + * 彩票开奖群通知 + */ + public function notifyLotteryResult(string $historyText): void + { + if (empty($this->config['group_notify']['toggle_lottery_result'])) { + return; + } + + $groupWxid = $this->config['group_notify']['target_wxid'] ?? ''; + if (! $groupWxid) { + return; + } + + $message = "🎲 【彩票开奖】\n{$historyText}"; + SendWechatBotMessage::dispatch($groupWxid, $message); + } + + /** + * 好友上线私聊通知(带冷却) + */ + public function notifyFriendsOnline(User $user): void + { + if (empty($this->config['personal_notify']['toggle_friend_online'])) { + return; + } + + $cacheKey = "wechat_notify_cd:friend_online:{$user->id}"; + if (Redis::exists($cacheKey)) { + return; + } + + // 假定有好友关系模型 Friends (视具体业务而定,目前先预留或者查询好友) + $friends = $this->getUserFriends($user->id); + + foreach ($friends as $friend) { + if ($friend->wxid) { + $message = "👋 【好友上线】\n您的好友 [{$user->username}] 刚刚上线了!"; + SendWechatBotMessage::dispatch($friend->wxid, $message); + } + } + + // 冷却 30 分钟 + Redis::setex($cacheKey, 1800, 1); + } + + /** + * 夫妻上线私聊通知(带冷却) + */ + public function notifySpouseOnline(User $user): void + { + if (empty($this->config['personal_notify']['toggle_spouse_online'])) { + return; + } + + $cacheKey = "wechat_notify_cd:spouse_online:{$user->id}"; + if (Redis::exists($cacheKey)) { + return; + } + + // 获取伴侣 + $spouse = $this->getUserSpouse($user); + if ($spouse && $spouse->wxid) { + $message = "❤️ 【伴侣上线】\n您的伴侣 [{$user->username}] 刚刚上线了!"; + SendWechatBotMessage::dispatch($spouse->wxid, $message); + // 冷却 30 分钟 + Redis::setex($cacheKey, 1800, 1); + } + } + + /** + * 等级变动私聊通知 + */ + public function notifyLevelChange(User $user, int $oldLevel, int $newLevel): void + { + if (empty($this->config['personal_notify']['toggle_level_change'])) { + return; + } + + if (! $user->wxid || $newLevel <= $oldLevel) { + return; + } + + $message = "✨ 【等级提升】\n" + ."恭喜您!您的聊天室等级已从 LV{$oldLevel} 提升至 LV{$newLevel}!"; + + SendWechatBotMessage::dispatch($user->wxid, $message); + } + + // --------------------------------------------------------- + // Helper Methods (Mocking the real data retrieval methods) + // --------------------------------------------------------- + + protected function getUserFriends(int $userId) + { + // 假定有好友表 + if (\Illuminate\Support\Facades\Schema::hasTable('friends')) { + return \Illuminate\Support\Facades\DB::table('friends') + ->join('users', 'users.id', '=', 'friends.friend_id') + ->where('friends.user_id', $userId) + ->whereNotNull('users.wxid') + ->select('users.*') + ->get(); + } + + return collect([]); + } + + protected function getUserSpouse(User $user) + { + // 如果有配偶字段 + $mateName = $user->peiou ?? null; + if ($mateName && $mateName !== '无' && $mateName !== '') { + return User::where('username', $mateName)->whereNotNull('wxid')->first(); + } + + return null; + } +} diff --git a/composer.json b/composer.json index a50b14c..1decee2 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "laravel/horizon": "^5.45", "laravel/reverb": "^1.8", "laravel/tinker": "^2.10.1", + "longlang/phpkafka": "^1.2", "mews/captcha": "^3.4", "predis/predis": "^3.4", "stevebauman/location": "^7.6", diff --git a/composer.lock b/composer.lock index ad783b7..51d08c8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "56338775768722c90ec723eb5b939be1", + "content-hash": "2c7aa959e462c8c80ba7e8ed493a2af3", "packages": [ { "name": "brick/math", @@ -135,6 +135,72 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "chdemko/sorted-collections", + "version": "1.0.10", + "source": { + "type": "git", + "url": "https://github.com/chdemko/php-sorted-collections.git", + "reference": "d9cf7021e6fda1eb68b9f35caf99215327f6db76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chdemko/php-sorted-collections/zipball/d9cf7021e6fda1eb68b9f35caf99215327f6db76", + "reference": "d9cf7021e6fda1eb68b9f35caf99215327f6db76", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.7", + "phpbench/phpbench": "^1.3", + "phpunit/phpunit": "^11.3", + "squizlabs/php_codesniffer": "^3.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "chdemko\\SortedCollection\\": "src/SortedCollection" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Christophe Demko", + "email": "chdemko@gmail.com", + "homepage": "https://chdemko.pagelab.univ-lr.fr/", + "role": "Developer" + } + ], + "description": "Sorted Collections for PHP >= 8.1", + "homepage": "https://php-sorted-collections.readthedocs.io/en/latest/?badge=latest", + "keywords": [ + "avl", + "collection", + "iterator", + "map", + "ordered", + "set", + "sorted", + "tree", + "treemap", + "treeset" + ], + "support": { + "issues": "https://github.com/chdemko/php-sorted-collections/issues", + "source": "https://github.com/chdemko/php-sorted-collections/tree/1.0.10" + }, + "time": "2024-08-04T14:31:40+00:00" + }, { "name": "clue/redis-protocol", "version": "v0.3.2", @@ -757,6 +823,50 @@ }, "time": "2023-08-08T05:53:35+00:00" }, + { + "name": "exussum12/xxhash", + "version": "1.3", + "source": { + "type": "git", + "url": "https://github.com/exussum12/xxhash.git", + "reference": "f5567ec5739ffee27aa3469e5001b4759b1b9e0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/exussum12/xxhash/zipball/f5567ec5739ffee27aa3469e5001b4759b1b9e0d", + "reference": "f5567ec5739ffee27aa3469e5001b4759b1b9e0d", + "shasum": "" + }, + "require": { + "ext-bcmath": "*", + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^7.1|^8.0|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "exussum12\\xxhash\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Scott Dutton", + "email": "scott@exussum.co.uk" + } + ], + "description": "xxHash implementation in Pure PHP", + "support": { + "issues": "https://github.com/exussum12/xxhash/issues", + "source": "https://github.com/exussum12/xxhash/tree/1.3" + }, + "time": "2020-11-30T09:04:53+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -886,6 +996,53 @@ }, "time": "2025-11-20T18:50:15+00:00" }, + { + "name": "google/crc32", + "version": "v0.1.0", + "source": { + "type": "git", + "url": "https://github.com/google/php-crc32.git", + "reference": "a8525f0dea6fca1893e1bae2f6e804c5f7d007fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/google/php-crc32/zipball/a8525f0dea6fca1893e1bae2f6e804c5f7d007fb", + "reference": "a8525f0dea6fca1893e1bae2f6e804c5f7d007fb", + "shasum": "" + }, + "require": { + "php": ">=5.4" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^1.13 || v2.14.2", + "paragonie/random_compat": ">=2", + "phpunit/phpunit": "^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\CRC32\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Andrew Brampton", + "email": "bramp@google.com" + } + ], + "description": "Various CRC32 implementations", + "homepage": "https://github.com/google/php-crc32", + "support": { + "issues": "https://github.com/google/php-crc32/issues", + "source": "https://github.com/google/php-crc32/tree/v0.1.0" + }, + "abandoned": true, + "time": "2019-05-09T06:24:58+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.4", @@ -2688,6 +2845,51 @@ ], "time": "2026-01-15T06:54:53+00:00" }, + { + "name": "longlang/phpkafka", + "version": "v1.2.5", + "source": { + "type": "git", + "url": "https://github.com/swoole/phpkafka.git", + "reference": "f42bfa7c97b1f2c751e2a956e597b970407faad1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swoole/phpkafka/zipball/f42bfa7c97b1f2c751e2a956e597b970407faad1", + "reference": "f42bfa7c97b1f2c751e2a956e597b970407faad1", + "shasum": "" + }, + "require": { + "chdemko/sorted-collections": "^1.0", + "exussum12/xxhash": "^1.0.0", + "google/crc32": "^0.1.0", + "php": ">=7.1", + "symfony/polyfill-php81": "^1.23" + }, + "require-dev": { + "colinodell/json5": "^2.1", + "friendsofphp/php-cs-fixer": "^2.18", + "phpstan/phpstan": "^0.12.81", + "phpunit/phpunit": "^7.5|^8.0|^9.0", + "swoole/ide-helper": "^4.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "longlang\\phpkafka\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "A kafka client. Support php-fpm and Swoole.", + "support": { + "issues": "https://github.com/swoole/phpkafka/issues", + "source": "https://github.com/swoole/phpkafka/tree/v1.2.5" + }, + "time": "2023-10-13T01:40:42+00:00" + }, { "name": "maxmind-db/reader", "version": "v1.13.1", @@ -6531,6 +6733,86 @@ ], "time": "2025-01-02T08:10:11+00:00" }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/polyfill-php83", "version": "v1.33.0", diff --git a/database/migrations/2026_04_02_140841_add_wxid_to_users_table.php b/database/migrations/2026_04_02_140841_add_wxid_to_users_table.php new file mode 100644 index 0000000..b7e0309 --- /dev/null +++ b/database/migrations/2026_04_02_140841_add_wxid_to_users_table.php @@ -0,0 +1,32 @@ +string('wxid', 100)->nullable()->unique()->after('username')->comment('绑定的微信ID'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + if (Schema::hasColumn('users', 'wxid')) { + $table->dropColumn('wxid'); + } + }); + } +}; diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php index c64a947..9985aca 100644 --- a/resources/views/admin/layouts/app.blade.php +++ b/resources/views/admin/layouts/app.blade.php @@ -54,6 +54,10 @@ class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.system.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}"> {!! '⚙️ 聊天室参数' !!} + + {!! '🤖 微信机器人' !!} + {!! '💴 用户流水' !!} diff --git a/resources/views/admin/wechat_bot/edit.blade.php b/resources/views/admin/wechat_bot/edit.blade.php new file mode 100644 index 0000000..7907ac6 --- /dev/null +++ b/resources/views/admin/wechat_bot/edit.blade.php @@ -0,0 +1,121 @@ +@extends('admin.layouts.app') + +@section('title', '微信机器人配置') + +@section('content') +
+
+
+

微信机器人全站配置

+

保存后如涉及 Kafka 参数修改,需要重启后端消息消费守护进程。

+
+
+ + @if (session('success')) +
+ ✅ {{ session('success') }} +
+ @endif + +
+
+ @csrf + @method('PUT') + + +
+

核心参数 (Kafka / API)

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + @if(!empty($config['api']['qrcode_image'])) +
+ QR Code +
+ @endif +
+
+ + +
+
+ + +
+
+
+ + +
+

群聊通知设置

+
+ + +

以下开关针对此群发送通知

+
+ +
+ + + +
+
+ + +
+

一对一私聊通知设置 (目标为用户微信)

+
+ + + +
+
+ +
+ +
+
+
+
+@endsection diff --git a/resources/views/chat/partials/layout/toolbar.blade.php b/resources/views/chat/partials/layout/toolbar.blade.php index 64e7b9f..fcdece8 100644 --- a/resources/views/chat/partials/layout/toolbar.blade.php +++ b/resources/views/chat/partials/layout/toolbar.blade.php @@ -169,6 +169,49 @@ + {{-- 微信绑定 --}} +
+
💬 微信绑定
+
+ @if (empty(Auth::user()->wxid)) + @php + $botConfigBody = \App\Models\SysParam::where('alias', 'wechat_bot_config')->value('body'); + $botConfig = $botConfigBody ? json_decode($botConfigBody, true) : []; + $botWxid = $botConfig['kafka']['bot_wxid'] ?? '暂未配置'; + $qrcodeImage = $botConfig['api']['qrcode_image'] ?? null; + @endphp +
+ 您尚未绑定微信。
+ @if($qrcodeImage) + 扫码添加机器人微信:
+ 机器人二维码 + @else + 请添加机器人微信:{{ $botWxid }}
+ @endif + 并发送以下绑定代码完成绑定: +
+
+ + + +
+ + @else +
+ 已绑定微信,可接收提醒通知。 + +
+ @endif +
+
+ {{-- 内联操作结果提示(仿百家乐已押注卡片风格) --}}