├── .gitignore ├── .env.example ├── src ├── Exceptions │ └── MessageNotificationException.php ├── Notify.php ├── Contracts │ └── MessageNotifyInterface.php ├── Channel │ ├── AbstractChannel.php │ ├── WechatChannel.php │ ├── MailChannel.php │ ├── DingTalkChannel.php │ └── FeiShuChannel.php ├── ConfigProvider.php ├── Template │ ├── AbstractTemplate.php │ ├── Text.php │ └── Markdown.php └── Client.php ├── phpstan.neon.dist ├── test ├── bootstrap.php ├── NotifyTest.php └── MailChannelTest.php ├── phpunit.xml ├── LICENSE ├── composer.json ├── README.md ├── publish └── message.php └── .php-cs-fixer.php /.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | !.gitattributes 3 | *.DS_Store 4 | *.idea 5 | *.svn 6 | *.git 7 | composer.lock 8 | *.cache 9 | vendor 10 | config 11 | .env -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 钉钉群通知配置信息 2 | NOTIFY_DINGTALK_TOKEN= 3 | NOTIFY_DINGTALK_SECRET= 4 | NOTIFY_DINGTALK_KEYWORD= 5 | 6 | # 飞书群通知配置信息 7 | NOTIFY_FEISHU_TOKEN= 8 | NOTIFY_FEISHU_SECRET= 9 | NOTIFY_FEISHU_KEYWORD= 10 | 11 | # 微信群通知配置信息 12 | NOTIFY_WECHAT_TOKEN= -------------------------------------------------------------------------------- /src/Exceptions/MessageNotificationException.php: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./test 14 | 15 | 16 | 17 | 18 | ./src 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Channel/AbstractChannel.php: -------------------------------------------------------------------------------- 1 | get('message.channels.' . get_class($this)); 23 | } 24 | 25 | throw new MessageNotificationException('ApplicationContext is not exist'); 26 | } 27 | 28 | abstract public function send(AbstractTemplate $template); 29 | } 30 | -------------------------------------------------------------------------------- /src/ConfigProvider.php: -------------------------------------------------------------------------------- 1 | [ 18 | MessageNotifyInterface::class => Client::class, 19 | ], 20 | 'annotations' => [ 21 | 'scan' => [ 22 | 'paths' => [ 23 | __DIR__, 24 | ], 25 | ], 26 | ], 27 | 'publish' => [ 28 | [ 29 | 'id' => 'config', 30 | 'description' => 'The config of message client.', 31 | 'source' => __DIR__ . '/../publish/message.php', 32 | 'destination' => BASE_PATH . '/config/autoload/message.php', 33 | ], 34 | ], 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Vinchan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /test/NotifyTest.php: -------------------------------------------------------------------------------- 1 | setChannel(DingTalkChannel::class) 35 | ->setAt(['all']) 36 | ->setTitle('标题') 37 | ->setText('测试') 38 | ->setPipeline(MessageNotifyInterface::INFO) 39 | ->setTemplate(Markdown::class) 40 | ->send(); 41 | 42 | $this->assertTrue($notify); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vinchan/message-notify", 3 | "description": "MIT", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Vinchan", 8 | "email": "ademo@vip.qq.com" 9 | } 10 | ], 11 | "autoload": { 12 | "psr-4": { 13 | "MessageNotify\\": "src/" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "MessageNotifyTest\\": "test/" 19 | } 20 | }, 21 | "require": { 22 | "php": ">=8.1", 23 | "ext-json": "*", 24 | "hyperf/guzzle": "~3.1.0", 25 | "symfony/mailer": "^6.0" 26 | }, 27 | "require-dev": { 28 | "friendsofphp/php-cs-fixer": "^3.0", 29 | "hyperf/config": "~3.1.0", 30 | "hyperf/di": "~3.1.0", 31 | "hyperf/ide-helper": "~3.1.0", 32 | "hyperf/utils": "~3.1.0", 33 | "phpstan/phpstan": "^1.11", 34 | "phpunit/phpunit": "^9.4" 35 | }, 36 | "scripts": { 37 | "test": "phpunit -c phpunit.xml --colors=always", 38 | "cs-fix": "./vendor/bin/php-cs-fixer fix", 39 | "analyse": "phpstan analyse --memory-limit 4028M" 40 | }, 41 | "config": { 42 | "sort-packages": true 43 | }, 44 | "extra": { 45 | "hyperf": { 46 | "config": "MessageNotify\\ConfigProvider" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 消息通知组件 2 | 3 | ## 功能 4 | 5 | * 监控发送应用异常 6 | * 支持多种通道(钉钉群机器人、飞书群机器人、邮件、QQ 频道机器人、企业微信群机器人) 7 | * 支持扩展自定义通道 8 | 9 | ## 环境要求 10 | 11 | | 组件版本 | 框架版本 | 12 | |------|--------------| 13 | | v2.0 | hyperf 2.0.* | 14 | | v3.0 | hyperf 3.0.* | 15 | | v3.0 | hyperf 3.1.* | 16 | * 17 | 18 | ## 安装 19 | 20 | ```bash 21 | composer require vinchan/message-notify -vvv 22 | ``` 23 | 24 | ## 配置文件 25 | 26 | 发布配置文件`config/message.php` 27 | 28 | ```bash 29 | hyperf vendor:publish vinchan/message-notify 30 | ``` 31 | 32 | 33 | ## 使用 34 | ```php 35 | Notify::make()->setChannel(DingTalkChannel::class) 36 | ->setTemplate(Text::class) 37 | ->setTitle('标题')->setText('内容')->setAt(['all'])->setPipeline('info') 38 | ->send(); 39 | ``` 40 | 41 | ## 通道 42 | 43 | | 通道名称 | 命名空间 | 支持格式 | 44 | |-------|----------------------------------------|---------------| 45 | | 钉钉群 | \MessageNotify\Channel\DingTalkChannel | Text、Markdown | 46 | | 飞书群 | \MessageNotify\Channel\FeiShuChannel | Text、Markdown | 47 | | 企业微信群 | \MessageNotify\Channel\WechatChannel | Text、Markdown | 48 | | 邮件 | \MessageNotify\Channel\MailChannel | Text、Markdown | 49 | 50 | ## 格式 51 | 52 | | 格式名称 | 命名空间 | 53 | |----------|----------------------------------| 54 | | Text | \MessageNotify\Template\Text | 55 | | Markdown | \MessageNotify\Template\Markdown | 56 | 57 | ## 协议 58 | 59 | MIT 许可证(MIT)。有关更多信息,请参见[协议文件](LICENSE)。 -------------------------------------------------------------------------------- /src/Template/AbstractTemplate.php: -------------------------------------------------------------------------------- 1 | text; 25 | } 26 | 27 | public function setText(string $text): AbstractTemplate 28 | { 29 | $this->text = $text; 30 | return $this; 31 | } 32 | 33 | public function getTitle(): string 34 | { 35 | return $this->title; 36 | } 37 | 38 | public function setTitle(string $title): AbstractTemplate 39 | { 40 | $this->title = $title; 41 | return $this; 42 | } 43 | 44 | public function getPipeline(): string 45 | { 46 | return $this->pipeline; 47 | } 48 | 49 | public function setPipeline(string $pipeline): AbstractTemplate 50 | { 51 | $this->pipeline = $pipeline; 52 | return $this; 53 | } 54 | 55 | public function setAt(array $at = []): AbstractTemplate 56 | { 57 | $this->at = $at; 58 | return $this; 59 | } 60 | 61 | public function getAt(): array 62 | { 63 | return $this->at; 64 | } 65 | 66 | public function isAtAll(): bool 67 | { 68 | return in_array('all', $this->at) || in_array('ALL', $this->at); 69 | } 70 | 71 | abstract public function getBody(); 72 | } 73 | -------------------------------------------------------------------------------- /src/Channel/WechatChannel.php: -------------------------------------------------------------------------------- 1 | getClient($template->getPipeline()); 28 | 29 | $option = [ 30 | RequestOptions::HEADERS => [], 31 | RequestOptions::JSON => $template->wechatBody(), 32 | ]; 33 | $request = $client->post('', $option); 34 | $result = json_decode($request->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); 35 | 36 | if ($result['errcode'] !== 0) { 37 | throw new MessageNotificationException($result['errmsg']); 38 | } 39 | 40 | return true; 41 | } 42 | 43 | private function getClient(string $pipeline) 44 | { 45 | $config = $this->config($pipeline); 46 | 47 | $uri['base_uri'] = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=' . $config['token']; 48 | 49 | if (class_exists(ApplicationContext::class)) { 50 | return make(Client::class, [$uri]); 51 | } 52 | 53 | return new Client($config); 54 | } 55 | 56 | private function config(string $pipeline) 57 | { 58 | $config = $this->getConfig(); 59 | return $config['pipeline'][$pipeline] ?? $config['pipeline'][$config['default']]; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Channel/MailChannel.php: -------------------------------------------------------------------------------- 1 | getConfig(); 25 | $config = $config['pipeline'][$template->getPipeline()] ?? $config['pipeline'][$config['default']]; 26 | 27 | if (empty($config['dsn']) || empty($config['from']) || empty($config['to'])) { 28 | throw new MessageNotificationException('Mail configuration is incomplete'); 29 | } 30 | 31 | try { 32 | $transport = Transport::fromDsn($config['dsn']); 33 | $mailer = new Mailer($transport); 34 | 35 | $email = new Email(); 36 | $email->from($config['from']) 37 | ->to(...(is_array($config['to']) ? $config['to'] : [$config['to']])) 38 | ->subject($template->getTitle()); 39 | 40 | // 根据模板类型设置邮件内容 41 | if ($template instanceof MarkdownTemplate) { 42 | // 对于Markdown模板,使用HTML格式 43 | $email->html($template->getText()); 44 | } else { 45 | // 对于其他模板类型,使用纯文本格式 46 | $email->text($template->getText()); 47 | } 48 | 49 | $mailer->send($email); 50 | return true; 51 | } catch (\Throwable $e) { 52 | throw new MessageNotificationException('Failed to send email: ' . $e->getMessage()); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Template/Text.php: -------------------------------------------------------------------------------- 1 | 'text', 21 | 'text' => [ 22 | 'content' => $this->getText(), 23 | ], 24 | 'at' => [ 25 | 'isAtAll' => $this->isAtAll(), 26 | 'atMobiles' => $this->getAt(), 27 | ], 28 | ]; 29 | } 30 | 31 | public function feiShuBody(): array 32 | { 33 | return [ 34 | 'msg_type' => 'text', 35 | 'content' => [ 36 | 'text' => $this->getText() . $this->getFeiShuAt(), 37 | ], 38 | ]; 39 | } 40 | 41 | public function wechatBody(): array 42 | { 43 | return [ 44 | 'msgtype' => 'text', 45 | 'text' => [ 46 | 'content' => $this->getText(), 47 | 'mentioned_list' => in_array('all', $this->getAt()) ? [] : [$this->getAt()], 48 | 'mentioned_mobile_list' => in_array('all', $this->getAt()) ? ['@all'] : [$this->getAt()], 49 | ], 50 | ]; 51 | } 52 | 53 | private function getFeiShuAt(): string 54 | { 55 | if ($this->isAtAll()) { 56 | return '所有人'; 57 | } 58 | 59 | $at = $this->getAt(); 60 | $result = ''; 61 | foreach ($at as $item) { 62 | if (! str_contains($item, '@')) { 63 | $result .= '' . $item . ''; 64 | } else { 65 | $result .= '' . $item . ''; 66 | } 67 | } 68 | return $result; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Channel/DingTalkChannel.php: -------------------------------------------------------------------------------- 1 | getQuery($template->getPipeline()); 28 | 29 | $client = $this->getClient($query); 30 | 31 | $option = [ 32 | RequestOptions::HEADERS => [], 33 | RequestOptions::JSON => $template->dingTalkBody(), 34 | ]; 35 | $request = $client->post('', $option); 36 | $result = json_decode($request->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); 37 | 38 | if ($result['errcode'] !== 0) { 39 | throw new MessageNotificationException($result['errmsg']); 40 | } 41 | 42 | return true; 43 | } 44 | 45 | public function getClient(string $query) 46 | { 47 | $config['base_uri'] = 'https://oapi.dingtalk.com/robot/send' . $query; 48 | 49 | if (class_exists(ApplicationContext::class)) { 50 | return make(Client::class, [$config]); 51 | } 52 | 53 | return new Client($config); 54 | } 55 | 56 | private function getQuery(string $pipeline): string 57 | { 58 | $timestamp = time() * 1000; 59 | 60 | $config = $this->getConfig(); 61 | $config = $config['pipeline'][$pipeline] ?? $config['pipeline'][$config['default']]; 62 | 63 | $secret = hash_hmac('sha256', $timestamp . "\n" . $config['secret'], $config['secret'], true); 64 | $sign = urlencode(base64_encode($secret)); 65 | return "?access_token={$config['token']}×tamp={$timestamp}&sign={$sign}"; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Channel/FeiShuChannel.php: -------------------------------------------------------------------------------- 1 | getClient($template->getPipeline()); 28 | 29 | $timestamp = time(); 30 | $config = [ 31 | 'timestamp' => $timestamp, 32 | 'sign' => $this->getSign($timestamp, $template->getPipeline()), 33 | ]; 34 | 35 | $option = [ 36 | RequestOptions::HEADERS => [], 37 | RequestOptions::JSON => array_merge($config, $template->feiShuBody()), 38 | ]; 39 | 40 | $request = $client->post('', $option); 41 | $result = json_decode($request->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); 42 | 43 | if (! isset($result['StatusCode']) || $result['StatusCode'] !== 0) { 44 | throw new MessageNotificationException($result['msg']); 45 | } 46 | 47 | return true; 48 | } 49 | 50 | public function getClient(string $pipeline) 51 | { 52 | $config = $this->config($pipeline); 53 | 54 | $uri['base_uri'] = 'https://open.feishu.cn/open-apis/bot/v2/hook/' . $config['token']; 55 | 56 | if (class_exists(ApplicationContext::class)) { 57 | return make(Client::class, [$uri]); 58 | } 59 | 60 | return new Client($config); 61 | } 62 | 63 | private function getSign(int $timestamp, string $pipeline): string 64 | { 65 | $config = $this->config($pipeline); 66 | $secret = hash_hmac('sha256', '', $timestamp . "\n" . $config['secret'], true); 67 | return base64_encode($secret); 68 | } 69 | 70 | private function config(string $pipeline) 71 | { 72 | $config = $this->getConfig(); 73 | return $config['pipeline'][$pipeline] ?? $config['pipeline'][$config['default']]; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /publish/message.php: -------------------------------------------------------------------------------- 1 | env('NOTIFY_DEFAULT_CHANNEL', 'mail'), 17 | 'channels' => [ 18 | // 钉钉群机器人 19 | DingTalkChannel::class => [ 20 | 'default' => MessageNotifyInterface::INFO, 21 | 'pipeline' => [ 22 | // 业务信息告警群 23 | MessageNotifyInterface::INFO => [ 24 | 'token' => env('NOTIFY_DINGTALK_TOKEN', ''), 25 | 'secret' => env('NOTIFY_DINGTALK_SECRET', ''), 26 | 'keyword' => env('NOTIFY_DINGTALK_KEYWORD', []), 27 | ], 28 | // 错误信息告警群 29 | MessageNotifyInterface::ERROR => [ 30 | 'token' => env('NOTIFY_DINGTALK_TOKEN', ''), 31 | 'secret' => env('NOTIFY_DINGTALK_SECRET', ''), 32 | 'keyword' => env('NOTIFY_DINGTALK_KEYWORD', []), 33 | ], 34 | ], 35 | ], 36 | 37 | // 飞书群机器人 38 | FeiShuChannel::class => [ 39 | 'default' => MessageNotifyInterface::INFO, 40 | 'pipeline' => [ 41 | 'info' => [ 42 | 'token' => env('NOTIFY_FEISHU_TOKEN', ''), 43 | 'secret' => env('NOTIFY_FEISHU_SECRET', ''), 44 | 'keyword' => env('NOTIFY_FEISHU_KEYWORD'), 45 | ], 46 | ], 47 | ], 48 | 49 | // 邮件 50 | MailChannel::class => [ 51 | 'default' => MessageNotifyInterface::INFO, 52 | 'pipeline' => [ 53 | 'info' => [ 54 | 'dsn' => env('NOTIFY_MAIL_DSN'), // SMTP连接字符串,例如:smtp://user:pass@smtp.example.com:587 55 | 'from' => env('NOTIFY_MAIL_FROM'), 56 | 'to' => env('NOTIFY_MAIL_TO'), 57 | ], 58 | ], 59 | ], 60 | 61 | // 企业微信群机器人 62 | WechatChannel::class => [ 63 | 'default' => MessageNotifyInterface::INFO, 64 | 'pipeline' => [ 65 | 'info' => [ 66 | 'token' => env('NOTIFY_WECHAT_TOKEN'), 67 | ], 68 | ], 69 | ], 70 | ], 71 | ]; 72 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 14 | ->setRules([ 15 | '@PSR2' => true, 16 | '@Symfony' => true, 17 | '@DoctrineAnnotation' => true, 18 | '@PhpCsFixer' => true, 19 | 'header_comment' => [ 20 | 'comment_type' => 'PHPDoc', 21 | 'header' => $header, 22 | 'separate' => 'none', 23 | 'location' => 'after_declare_strict', 24 | ], 25 | 'array_syntax' => [ 26 | 'syntax' => 'short', 27 | ], 28 | 'list_syntax' => [ 29 | 'syntax' => 'short', 30 | ], 31 | 'concat_space' => [ 32 | 'spacing' => 'one', 33 | ], 34 | 'blank_line_before_statement' => [ 35 | 'statements' => [ 36 | 'declare', 37 | ], 38 | ], 39 | 'general_phpdoc_annotation_remove' => [ 40 | 'annotations' => [ 41 | 'author', 42 | ], 43 | ], 44 | 'ordered_imports' => [ 45 | 'imports_order' => [ 46 | 'class', 'function', 'const', 47 | ], 48 | 'sort_algorithm' => 'alpha', 49 | ], 50 | 'single_line_comment_style' => [ 51 | 'comment_types' => [ 52 | ], 53 | ], 54 | 'yoda_style' => [ 55 | 'always_move_variable' => false, 56 | 'equal' => false, 57 | 'identical' => false, 58 | ], 59 | 'phpdoc_align' => [ 60 | 'align' => 'left', 61 | ], 62 | 'multiline_whitespace_before_semicolons' => [ 63 | 'strategy' => 'no_multi_line', 64 | ], 65 | 'constant_case' => [ 66 | 'case' => 'lower', 67 | ], 68 | 'class_attributes_separation' => true, 69 | 'combine_consecutive_unsets' => true, 70 | 'declare_strict_types' => true, 71 | 'linebreak_after_opening_tag' => true, 72 | 'lowercase_static_reference' => true, 73 | 'no_useless_else' => true, 74 | 'no_unused_imports' => true, 75 | 'not_operator_with_successor_space' => true, 76 | 'not_operator_with_space' => false, 77 | 'ordered_class_elements' => true, 78 | 'php_unit_strict' => false, 79 | 'phpdoc_separation' => false, 80 | 'single_quote' => true, 81 | 'standardize_not_equals' => true, 82 | 'multiline_comment_opening_closing' => true, 83 | ]) 84 | ->setFinder( 85 | Finder::create() 86 | ->exclude('public') 87 | ->exclude('runtime') 88 | ->exclude('vendor') 89 | ->in(__DIR__) 90 | ) 91 | ->setUsingCache(false); 92 | -------------------------------------------------------------------------------- /src/Template/Markdown.php: -------------------------------------------------------------------------------- 1 | 'markdown', 21 | 'markdown' => [ 22 | 'title' => $this->getTitle(), 23 | 'text' => $this->getText(), 24 | ], 25 | 'at' => [ 26 | 'isAtAll' => $this->isAtAll(), 27 | 'atMobiles' => $this->getAt(), 28 | ], 29 | ]; 30 | } 31 | 32 | public function feiShuBody(): array 33 | { 34 | return [ 35 | 'msg_type' => 'post', 36 | 'content' => [ 37 | 'post' => [ 38 | 'zh_cn' => [ 39 | 'title' => $this->getTitle(), 40 | 'content' => [$this->getFeiShuText()], 41 | ], 42 | ], 43 | ], 44 | ]; 45 | } 46 | 47 | public function wechatBody(): array 48 | { 49 | return [ 50 | 'msgtype' => 'markdown', 51 | 'markdown' => [ 52 | 'content' => $this->getTitle() . $this->getText(), 53 | 'mentioned_list' => in_array('all', $this->getAt()) ? [] : [$this->getAt()], 54 | 'mentioned_mobile_list' => in_array('all', $this->getAt()) ? ['@all'] : [$this->getAt()], 55 | ], 56 | ]; 57 | } 58 | 59 | private function getFeiShuText(): array 60 | { 61 | $text = is_array($this->getText()) ? $this->getText() : json_decode($this->getText(), true) ?? [ 62 | [ 63 | 'tag' => 'text', 64 | 'text' => $this->getText(), 65 | ], 66 | ]; 67 | 68 | $at = $this->getFeiShuAt(); 69 | 70 | return array_merge($text, $at); 71 | } 72 | 73 | private function getFeiShuAt(): array 74 | { 75 | $result = []; 76 | if ($this->isAtAll()) { 77 | $result[] = [ 78 | 'tag' => 'at', 79 | 'user_id' => 'all', 80 | ]; 81 | 82 | return $result; 83 | } 84 | 85 | $at = $this->getAt(); 86 | foreach ($at as $item) { 87 | // TODO::需要加入邮箱与收集@人 88 | if (strchr($item, '@') === false) { 89 | $result[] = [ 90 | 'tag' => 'at', 91 | 'email' => $item, 92 | ]; 93 | } else { 94 | $result[] = [ 95 | 'tag' => 'at', 96 | 'user_id' => $item, 97 | ]; 98 | } 99 | } 100 | 101 | return $result; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | channel; 37 | } 38 | 39 | public function getTemplate(): AbstractTemplate 40 | { 41 | return $this->template ?? new Text(); 42 | } 43 | 44 | public function getAt(): array 45 | { 46 | return $this->at; 47 | } 48 | 49 | public function getTitle(): string 50 | { 51 | return $this->title; 52 | } 53 | 54 | public function getText(): string 55 | { 56 | return $this->text; 57 | } 58 | 59 | public function setChannel($channel = null): Client 60 | { 61 | if (! $channel instanceof AbstractChannel) { 62 | $channel = make($channel); 63 | } 64 | 65 | $this->channel = $channel; 66 | return $this; 67 | } 68 | 69 | public function setTemplate($template = ''): Client 70 | { 71 | if (! $template instanceof AbstractChannel) { 72 | $template = make($template); 73 | } 74 | 75 | $this->template = $template; 76 | return $this; 77 | } 78 | 79 | public function getPipeline(): string 80 | { 81 | return $this->pipeline; 82 | } 83 | 84 | public function setPipeline(string $pipeline = ''): Client 85 | { 86 | $this->pipeline = $pipeline ?? MessageNotifyInterface::INFO; 87 | return $this; 88 | } 89 | 90 | public function setAt(array $at = []): Client 91 | { 92 | $this->at = $at; 93 | return $this; 94 | } 95 | 96 | public function setTitle(string $title = ''): Client 97 | { 98 | $this->title = $title; 99 | return $this; 100 | } 101 | 102 | public function setText(string $text = ''): Client 103 | { 104 | $this->text = $text; 105 | return $this; 106 | } 107 | 108 | public function send(): bool 109 | { 110 | try { 111 | $template = $this->getTemplate() 112 | ->setAt($this->getAt()) 113 | ->setTitle($this->getTitle()) 114 | ->setText($this->getText()) 115 | ->setPipeline($this->getPipeline()); 116 | 117 | $this->getChannel()->send($template); 118 | return true; 119 | } catch (\Throwable $throwable) { 120 | throw new MessageNotificationException($throwable->getMessage()); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /test/MailChannelTest.php: -------------------------------------------------------------------------------- 1 | loadEnvConfig(); 36 | 37 | // 创建测试用MailChannel 38 | $this->mailChannel = new class($this->config) extends MailChannel { 39 | private array $testConfig; 40 | 41 | public function __construct(array $config) 42 | { 43 | $this->testConfig = $config; 44 | } 45 | 46 | public function getConfig(): array 47 | { 48 | return $this->testConfig; 49 | } 50 | }; 51 | } 52 | 53 | /** 54 | * 测试Text模板邮件发送 55 | */ 56 | public function testSendTextMail(): void 57 | { 58 | // 检查是否配置了环境变量 59 | if (empty($this->config)) { 60 | $this->markTestSkipped('没有配置邮件环境变量,跳过测试'); 61 | } 62 | 63 | $template = new Text(); 64 | $template->setTitle('测试邮件 - Text格式') 65 | ->setText('这是一封测试邮件,用于测试邮件发送功能。') 66 | ->setPipeline(MessageNotifyInterface::INFO); 67 | 68 | try { 69 | $result = $this->mailChannel->send($template); 70 | $this->assertTrue($result, '邮件发送成功'); 71 | } catch (\Throwable $e) { 72 | $this->fail('邮件发送失败: ' . $e->getMessage()); 73 | } 74 | } 75 | 76 | /** 77 | * 测试Markdown模板邮件发送 78 | */ 79 | public function testSendMarkdownMail(): void 80 | { 81 | // 检查是否配置了环境变量 82 | if (empty($this->config)) { 83 | $this->markTestSkipped('没有配置邮件环境变量,跳过测试'); 84 | } 85 | 86 | $template = new Markdown(); 87 | $template->setTitle('测试邮件 - Markdown格式') 88 | ->setText("## 测试内容\n\n**这是测试内容**\n\n发送时间: " . date('Y-m-d H:i:s')) 89 | ->setPipeline(MessageNotifyInterface::INFO); 90 | 91 | try { 92 | $result = $this->mailChannel->send($template); 93 | $this->assertTrue($result, '邮件发送成功'); 94 | } catch (\Throwable $e) { 95 | $this->fail('邮件发送失败: ' . $e->getMessage()); 96 | } 97 | } 98 | 99 | /** 100 | * 测试错误配置抛出异常. 101 | */ 102 | public function testInvalidConfigThrowsException(): void 103 | { 104 | $invalidChannel = new class extends MailChannel { 105 | public function getConfig(): array 106 | { 107 | return [ 108 | 'default' => MessageNotifyInterface::INFO, 109 | 'pipeline' => [ 110 | MessageNotifyInterface::INFO => [ 111 | // 缺少必须的配置项 112 | ], 113 | ], 114 | ]; 115 | } 116 | }; 117 | 118 | $template = new Text(); 119 | $template->setTitle('测试标题') 120 | ->setText('这是测试内容') 121 | ->setPipeline(MessageNotifyInterface::INFO); 122 | 123 | $this->expectException(MessageNotificationException::class); 124 | $invalidChannel->send($template); 125 | } 126 | 127 | /** 128 | * 从.env加载邮件配置. 129 | */ 130 | private function loadEnvConfig(): void 131 | { 132 | // 尝试加载.env文件 133 | $envFile = dirname(__DIR__) . '/.env'; 134 | 135 | if (! file_exists($envFile)) { 136 | $this->config = []; 137 | return; 138 | } 139 | 140 | try { 141 | // 解析.env文件 142 | $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); 143 | $env = []; 144 | foreach ($lines as $line) { 145 | // 跳过注释和无效行 146 | if (str_starts_with(trim($line), '#') || ! str_contains($line, '=')) { 147 | continue; 148 | } 149 | 150 | [$name, $value] = explode('=', $line, 2); 151 | $name = trim($name); 152 | $value = trim($value); 153 | 154 | // 移除引号 155 | if (str_starts_with($value, '"') && str_ends_with($value, '"')) { 156 | $value = substr($value, 1, -1); 157 | } elseif (str_starts_with($value, "'") && str_ends_with($value, "'")) { 158 | $value = substr($value, 1, -1); 159 | } 160 | 161 | $env[$name] = $value; 162 | } 163 | 164 | // 检查是否有邮件配置 165 | if (! isset($env['NOTIFY_MAIL_DSN']) || ! isset($env['NOTIFY_MAIL_FROM']) || ! isset($env['NOTIFY_MAIL_TO'])) { 166 | $this->config = []; 167 | return; 168 | } 169 | 170 | // 设置测试配置 171 | $this->config = [ 172 | 'default' => MessageNotifyInterface::INFO, 173 | 'pipeline' => [ 174 | MessageNotifyInterface::INFO => [ 175 | 'dsn' => $env['NOTIFY_MAIL_DSN'], 176 | 'from' => $env['NOTIFY_MAIL_FROM'], 177 | 'to' => $env['NOTIFY_MAIL_TO'], 178 | ], 179 | MessageNotifyInterface::ERROR => [ 180 | 'dsn' => $env['NOTIFY_MAIL_DSN'] ?? $env['NOTIFY_MAIL_ERROR_DSN'] ?? '', 181 | 'from' => $env['NOTIFY_MAIL_FROM'] ?? $env['NOTIFY_MAIL_ERROR_FROM'] ?? '', 182 | 'to' => $env['NOTIFY_MAIL_TO'] ?? $env['NOTIFY_MAIL_ERROR_TO'] ?? '', 183 | ], 184 | ], 185 | ]; 186 | } catch (\Throwable $e) { 187 | $this->config = []; 188 | } 189 | } 190 | } 191 | --------------------------------------------------------------------------------