feat(wechat): 微信机器人全链路集成与稳定性修复
- 新增:管理员后台的微信机器人双向收发参数设置页面及扫码绑定能力。 - 新增:WechatBotApiService 与 KafkaConsumerService 模块打通过往僵尸进程导致的拒绝连接问题。 - 新增:下发所有群发/私聊通知时统一带上「[和平聊吧]」标注前缀。 - 优化:前端个人中心绑定逻辑支持一键生成及复制动态口令。 - 修复:闭环联调修补各个模型中产生的变量警告如 stdClass 对象获取等异常预警。
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:Kafka 消费者服务
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Services\WechatBot;
|
||||
|
||||
use App\Models\SysParam;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use longlang\phpkafka\Consumer\Consumer;
|
||||
use longlang\phpkafka\Consumer\ConsumerConfig;
|
||||
|
||||
class KafkaConsumerService
|
||||
{
|
||||
/**
|
||||
* Kafka Broker 地址
|
||||
*/
|
||||
protected string $brokers = '';
|
||||
|
||||
/**
|
||||
* 消费 Topic
|
||||
*/
|
||||
protected string $topic = '';
|
||||
|
||||
/**
|
||||
* 消费者组 ID
|
||||
*/
|
||||
protected string $groupId = '';
|
||||
|
||||
/**
|
||||
* 构造函数 — 从 SysParam 获取配置
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$param = SysParam::where('alias', 'wechat_bot_config')->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:微信机器人接口调用服务
|
||||
*
|
||||
* 封装与微信机器人 HTTP API 的通信逻辑,用于向上游服务发送文本和富文本消息。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Services\WechatBot;
|
||||
|
||||
use App\Models\SysParam;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class WechatBotApiService
|
||||
{
|
||||
/**
|
||||
* @var string API 基础地址
|
||||
*/
|
||||
protected string $baseUrl;
|
||||
|
||||
/**
|
||||
* @var int 请求超时时间(秒)
|
||||
*/
|
||||
protected int $timeout = 10;
|
||||
|
||||
/**
|
||||
* @var string 机器人 Key
|
||||
*/
|
||||
protected string $botKey;
|
||||
|
||||
/**
|
||||
* 构造函数 — 从 SysParam 获取 api_base_url 配置
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$param = SysParam::where('alias', 'wechat_bot_config')->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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:微信消息发送核心业务逻辑服务
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Services\WechatBot;
|
||||
|
||||
use App\Jobs\SendWechatBotMessage;
|
||||
use App\Models\SysParam;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class WechatNotificationService
|
||||
{
|
||||
protected array $config = [];
|
||||
|
||||
protected bool $globalEnabled = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$param = SysParam::where('alias', 'wechat_bot_config')->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user