diff --git a/composer.json b/composer.json index 4bbd041..12baec9 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ "mcp/sdk": "^0.7", "psr/simple-cache": ">=1.0", "league/flysystem-aws-s3-v3": "^3.0", - "league/flysystem-webdav": "^3.0" + "league/flysystem-webdav": "^3.0", + "phpmailer/phpmailer": "^7.1" }, "require-dev": { "symfony/var-dumper": "*", diff --git a/composer.lock b/composer.lock index 3cbe36e..b5f4a4c 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": "e99cd9f4e74aad9a5d34050bab8d0c2a", + "content-hash": "3ed07ae29f248003750e594a22070cca", "packages": [ { "name": "aliyuncs/oss-sdk-php", @@ -2233,6 +2233,82 @@ }, "time": "2026-01-06T21:53:42+00:00" }, + { + "name": "phpmailer/phpmailer", + "version": "v7.1.1", + "dist": { + "type": "zip", + "url": "https://nexus.hl7.top:1243/repository/composer-proxy/phpmailer/phpmailer/v7.1.1/phpmailer-phpmailer-v7.1.1.zip", + "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "squizlabs/php_codesniffer": "^3.13.5", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example", + "ext-imap": "Needed to support advanced email address parsing according to RFC822", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "support": { + "issues": "https://github.com/PHPMailer/PHPMailer/issues", + "source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2026-05-18T08:06:14+00:00" + }, { "name": "phpoffice/phpspreadsheet", "version": "1.30.3", @@ -6174,7 +6250,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -6182,6 +6258,6 @@ "ext-json": "*", "ext-fileinfo": "*" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/extend/base/common/notice/channel/EmailChannel.php b/extend/base/common/notice/channel/EmailChannel.php new file mode 100644 index 0000000..b76a9be --- /dev/null +++ b/extend/base/common/notice/channel/EmailChannel.php @@ -0,0 +1,85 @@ +isSMTP(); + $mailer->Host = $host; + $mailer->Port = $port; + // SMTPSecure 映射:ssl→隐式 SMTPS;tls→STARTTLS;none/未知→明文。 + // 明文场景必须显式关闭 SMTPAutoTLS:PHPMailer 默认会对明文会话自动尝试 + // STARTTLS 升级,Mailpit 等纯明文本地服务会被误判为失败。 + if ($secure === 'ssl') { + $mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } elseif ($secure === 'tls') { + $mailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } else { + $mailer->SMTPSecure = ''; + $mailer->SMTPAutoTLS = false; + } + // SMTP 认证仅在配置了用户名时启用(Mailpit 等本地无认证服务不设置) + if ($user !== '') { + $mailer->SMTPAuth = true; + $mailer->Username = $user; + $mailer->Password = $pass; + } + $mailer->CharSet = 'UTF-8'; + $mailer->isHTML(true); + $mailer->setFrom($from, $fromName); + $mailer->addAddress((string) ($target['target_addr'] ?? '')); + $mailer->Subject = $title; + $mailer->Body = '
' + . '

' . $title . '

' + . '
' . nl2br($content) . '
' + . '
'; + + $mailer->send(); + } catch (PHPMailerException $e) { + // send() 抛出的 Exception 消息即 $mailer->ErrorInfo(setError 已拼接 + // SMTP lastError/detail/smtp_code),异常链保留原始异常供上游追溯 + throw new RuntimeException('邮件发送失败: ' . $e->getMessage(), 0, $e); + } + + return true; + } +} diff --git a/extend/base/common/notice/channel/SmsChannel.php b/extend/base/common/notice/channel/SmsChannel.php new file mode 100644 index 0000000..27ccc25 --- /dev/null +++ b/extend/base/common/notice/channel/SmsChannel.php @@ -0,0 +1,46 @@ + self::name(), + 'enabled' => $enabled, + 'notice_id' => $notice['id'] ?? 0, + 'target_addr' => $target['target_addr'] ?? '', + 'title' => $notice['title'] ?? '', + ]); + + return true; + } +} diff --git a/extend/base/common/notice/channel/WechatChannel.php b/extend/base/common/notice/channel/WechatChannel.php new file mode 100644 index 0000000..8d45878 --- /dev/null +++ b/extend/base/common/notice/channel/WechatChannel.php @@ -0,0 +1,46 @@ + self::name(), + 'enabled' => $enabled, + 'notice_id' => $notice['id'] ?? 0, + 'target_addr' => $target['target_addr'] ?? '', + 'title' => $notice['title'] ?? '', + ]); + + return true; + } +} diff --git a/tests/Feature/NoticeChannelTest.php b/tests/Feature/NoticeChannelTest.php new file mode 100644 index 0000000..98a9939 --- /dev/null +++ b/tests/Feature/NoticeChannelTest.php @@ -0,0 +1,123 @@ +setNotifyConfig('notify_email_enable', '0'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('渠道未启用'); + + (new EmailChannel())->send( + ['channel' => 'email', 'target_addr' => 'disabled@test.com', 'status' => 0], + ['id' => 1, 'scene' => 'common', 'title' => '标题', 'content' => '内容'] + ); + } + + // ========================================================================= + // 2. SmsChannel 骨架恒成功 + // ========================================================================= + + public function test_sms_channel_send_returns_true(): void + { + $this->assertTrue((new SmsChannel())->send( + ['channel' => 'sms', 'target_addr' => '13800138000', 'status' => 0], + ['id' => 1, 'scene' => 'common', 'title' => '标题', 'content' => '内容'] + )); + } + + // ========================================================================= + // 3. WechatChannel 骨架恒成功 + // ========================================================================= + + public function test_wechat_channel_send_returns_true(): void + { + $this->assertTrue((new WechatChannel())->send( + ['channel' => 'wechat', 'target_addr' => 'openid_demo', 'status' => 0], + ['id' => 1, 'scene' => 'common', 'title' => '标题', 'content' => '内容'] + )); + } + + // ========================================================================= + // 4. name() 契约与 channels() 映射一致 + // ========================================================================= + + public function test_channel_names_match_service_mapping(): void + { + $this->assertSame('email', EmailChannel::name()); + $this->assertSame('sms', SmsChannel::name()); + $this->assertSame('wechat', WechatChannel::name()); + + $channels = NoticeService::channels(); + $this->assertSame(EmailChannel::class, $channels['email']); + $this->assertSame(SmsChannel::class, $channels['sms']); + $this->assertSame(WechatChannel::class, $channels['wechat']); + } + + // ========================================================================= + // fixture 工厂(事务内运行,tearDown 自动回滚;缓存显式清理) + // ========================================================================= + + /** + * 写 notify 组配置行并清 sysconfig 缓存. + * + * sysconfig 缓存不受事务回滚影响:必须显式清 tag 才能让后续读取 + * 看到事务内的新值(value='0' 因 empty('0') 恒不写缓存,但清理是 + * 防御后续用例被前序非 '0' 缓存值污染的统一口径)。 + */ + private function setNotifyConfig(string $name, string $value): void + { + Db::name('system_config')->insert([ + 'name' => $name, + 'group' => 'notify', + 'value' => $value, + 'remark' => 'NoticeChannelTest fixture', + 'sort' => 100, + 'create_time' => time(), + 'update_time' => time(), + ]); + $this->touchedSysconfig = true; + Cache::tag('sysconfig')->clear(); + } + + protected function tearDown(): void + { + if ($this->touchedSysconfig) { + Cache::tag('sysconfig')->clear(); + } + parent::tearDown(); + } +}