Skip to main content

sz_rust_core/
notify.rs

1//! Notify 模块 — 通知抽象层(对齐 PHP `think\facade\Notify`)
2//!
3//! 提供通知发送的统一抽象,支持多渠道(Slack、短信、邮件等)扩展。
4//!
5//! ## PHP 对齐
6//!
7//! ### 核心 API 映射
8//!
9//! | PHP 方法 | Rust 方法 | 说明 |
10//! |---------|-----------|------|
11//! | `Notify::channel($name)` | [`Notification::channel`] | 设置通知渠道 |
12//! | `Notify::title($title)` | [`Notification::title`] | 设置通知标题 |
13//! | `Notify::content($content)` | [`Notification::content`] | 设置通知内容 |
14//! | `Notify::level($level)` | [`Notification::level`] | 设置通知级别 |
15//! | `Notify::send()` | [`Notifier::send`] | 发送通知 |
16//!
17//! ### PHP 行为对齐
18//!
19//! - **Builder 模式**:PHP `Notify::channel()->title()->content()->send()` 链式调用。
20//!   Rust 通过 [`Notification`] builder 实现相同链式 API。
21//! - **多级别**:PHP 支持 info/warning/error/critical 四种级别。Rust 通过 [`NotifyLevel`] 表达。
22//! - **多渠道**:PHP 支持按渠道分发(slack/sms/mail)。Rust 通过 [`Notifier`] trait 抽象。
23//!
24//! ## 架构说明
25//!
26//! - **Notifier trait 抽象**:业务方实现具体发送逻辑(Slack Webhook / 短信 API / 日志等)
27//! - **MemoryNotifier**:内置内存实现,将通知暂存到 Vec,用于测试和开发环境
28//! - **SlackNotifier**:Slack Webhook 实现,通过 [`HttpTransport`] trait 抽象 HTTP 发送,
29//!   业务方注入具体 HTTP 客户端(如 reqwest)即可投入生产
30//! - **HttpTransport trait**:HTTP 传输抽象,解耦 SlackNotifier 与具体 HTTP 库
31//! - **MemoryHttpTransport**:内存 HTTP 传输实现,记录所有请求供测试断言
32
33use parking_lot::Mutex;
34use std::sync::Arc;
35use thiserror::Error;
36
37// ============================================================================
38// 错误类型
39// ============================================================================
40
41/// Notify 错误
42#[derive(Debug, Error)]
43pub enum NotifyError {
44    /// 缺少必填字段(渠道、标题、内容等)
45    #[error("通知字段缺失: {0}")]
46    MissingField(String),
47    /// 通知发送失败
48    #[error("通知发送失败: {0}")]
49    SendFailed(String),
50    /// HTTP 传输失败
51    #[error("HTTP 传输失败: {0}")]
52    HttpTransport(String),
53    /// 序列化失败
54    #[error("序列化失败: {0}")]
55    Serialize(String),
56}
57
58// ============================================================================
59// 通知级别
60// ============================================================================
61
62/// 通知级别 — 对齐 PHP `think\notify\Level`
63///
64/// 支持四种级别,按严重程度递增。
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
66#[repr(u8)]
67pub enum NotifyLevel {
68    /// 信息(最低级别,蓝色)
69    #[default]
70    Info = 0,
71    /// 警告(黄色)
72    Warning = 1,
73    /// 错误(红色)
74    Error = 2,
75    /// 严重(最高级别,加粗红色)
76    Critical = 3,
77}
78
79impl NotifyLevel {
80    /// 转换为 Slack 颜色(hex 颜色码或 Slack 内置颜色名)
81    ///
82    /// - `Info` → `#36a64f`(绿色)
83    /// - `Warning` → `#ffcc00`(黄色)
84    /// - `Error` → `#ff0000`(红色)
85    /// - `Critical` → `#b22222`(深红)
86    pub fn slack_color(self) -> &'static str {
87        match self {
88            Self::Info => "#36a64f",
89            Self::Warning => "#ffcc00",
90            Self::Error => "#ff0000",
91            Self::Critical => "#b22222",
92        }
93    }
94
95    /// 转换为字符串标识(对齐 PHP `strtolower(Level::class)`)
96    pub fn as_str(self) -> &'static str {
97        match self {
98            Self::Info => "info",
99            Self::Warning => "warning",
100            Self::Error => "error",
101            Self::Critical => "critical",
102        }
103    }
104}
105
106impl std::fmt::Display for NotifyLevel {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.write_str(self.as_str())
109    }
110}
111
112impl std::str::FromStr for NotifyLevel {
113    type Err = NotifyError;
114
115    fn from_str(s: &str) -> Result<Self, Self::Err> {
116        match s.to_lowercase().as_str() {
117            "info" => Ok(Self::Info),
118            "warning" | "warn" => Ok(Self::Warning),
119            "error" | "err" => Ok(Self::Error),
120            "critical" | "crit" => Ok(Self::Critical),
121            other => Err(NotifyError::MissingField(format!("未知通知级别: {other}"))),
122        }
123    }
124}
125
126// ============================================================================
127// 通知消息(Builder 模式)
128// ============================================================================
129
130/// 通知消息 — 对齐 PHP `think\notify\Message`
131///
132/// 使用 Builder 模式构建通知内容,通过 [`Notifier::send`] 发送。
133///
134/// # PHP 对齐
135///
136/// ```php
137/// // PHP think\facade\Notify
138/// Notify::channel('slack')
139///     ->title('部署完成')
140///     ->content('服务已成功部署到生产环境')
141///     ->level('info')
142///     ->send();
143/// ```
144///
145/// # Rust 用法
146///
147/// ```rust,ignore
148/// use sz_rust_core::notify::{Notification, MemoryNotifier, NotifyLevel};
149///
150/// let msg = Notification::new()
151///     .channel("slack")
152///     .title("部署完成")
153///     .content("服务已成功部署到生产环境")
154///     .level(NotifyLevel::Info);
155///
156/// let notifier = MemoryNotifier::new();
157/// notifier.send(msg).unwrap();
158/// ```
159#[derive(Debug, Clone, Default)]
160pub struct Notification {
161    /// 通知渠道(如 `slack`、`sms`、`mail`)
162    pub channel: String,
163    /// 通知标题
164    pub title: String,
165    /// 通知正文
166    pub content: String,
167    /// 通知级别
168    pub level: NotifyLevel,
169    /// 附加元数据(JSON 值,供具体 Notifier 使用)
170    pub metadata: serde_json::Value,
171}
172
173impl Notification {
174    /// 创建空通知消息
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    /// 设置通知渠道
180    pub fn channel(mut self, channel: impl Into<String>) -> Self {
181        self.channel = channel.into();
182        self
183    }
184
185    /// 设置通知标题
186    pub fn title(mut self, title: impl Into<String>) -> Self {
187        self.title = title.into();
188        self
189    }
190
191    /// 设置通知正文
192    pub fn content(mut self, content: impl Into<String>) -> Self {
193        self.content = content.into();
194        self
195    }
196
197    /// 设置通知级别
198    pub fn level(mut self, level: NotifyLevel) -> Self {
199        self.level = level;
200        self
201    }
202
203    /// 设置附加元数据
204    pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
205        self.metadata = metadata;
206        self
207    }
208
209    /// 校验必填字段
210    ///
211    /// # 返回
212    ///
213    /// - 渠道为空 → [`NotifyError::MissingField`]("channel")
214    /// - 标题为空 → [`NotifyError::MissingField`]("title")
215    /// - 内容为空 → [`NotifyError::MissingField`]("content")
216    pub fn validate(&self) -> Result<(), NotifyError> {
217        if self.channel.is_empty() {
218            return Err(NotifyError::MissingField("channel".into()));
219        }
220        if self.title.is_empty() {
221            return Err(NotifyError::MissingField("title".into()));
222        }
223        if self.content.is_empty() {
224            return Err(NotifyError::MissingField("content".into()));
225        }
226        Ok(())
227    }
228}
229
230// ============================================================================
231// Notifier trait
232// ============================================================================
233
234/// 通知发送器 trait — 对齐 PHP `think\notify\Notifier`
235///
236/// 抽象通知发送行为,业务方实现具体发送逻辑(Slack Webhook / 短信 API / 日志等)。
237///
238/// # PHP 对齐
239///
240/// ```php
241/// // PHP think\notify\Notifier 接口
242/// interface Notifier {
243///     public function send(Message $message): bool;
244/// }
245/// ```
246pub trait Notifier: Send + Sync {
247    /// 发送通知
248    ///
249    /// # 参数
250    ///
251    /// - `notification`: 通知消息
252    ///
253    /// # 返回
254    ///
255    /// 成功返回 `Ok(())`,失败返回 [`NotifyError`]。
256    fn send(&self, notification: Notification) -> Result<(), NotifyError>;
257}
258
259// ============================================================================
260// MemoryNotifier(测试/开发用实现)
261// ============================================================================
262
263/// 内存通知发送器 — 用于测试和开发环境
264///
265/// 不实际发送通知,而是将通知暂存到内部 Vec,供测试断言使用。
266///
267/// # 线程安全
268///
269/// 通过 `Arc<Mutex<Vec<Notification>>>` 保护,支持并发写入。
270#[derive(Debug, Clone, Default)]
271pub struct MemoryNotifier {
272    /// 已"发送"的通知列表
273    sent: Arc<Mutex<Vec<Notification>>>,
274}
275
276impl MemoryNotifier {
277    /// 创建新的内存通知发送器
278    pub fn new() -> Self {
279        Self::default()
280    }
281
282    /// 获取已发送通知数量
283    pub fn count(&self) -> usize {
284        self.sent.lock().len()
285    }
286
287    /// 获取所有已发送通知(快照)
288    pub fn all(&self) -> Vec<Notification> {
289        self.sent.lock().clone()
290    }
291
292    /// 获取最后发送的通知
293    pub fn last(&self) -> Option<Notification> {
294        self.sent.lock().last().cloned()
295    }
296
297    /// 清空已发送通知
298    pub fn clear(&self) {
299        self.sent.lock().clear();
300    }
301}
302
303impl Notifier for MemoryNotifier {
304    fn send(&self, notification: Notification) -> Result<(), NotifyError> {
305        // 校验必要字段
306        notification.validate()?;
307
308        // 暂存到内存
309        self.sent.lock().push(notification);
310        Ok(())
311    }
312}
313
314// ============================================================================
315// HttpTransport trait(HTTP 传输抽象)
316// ============================================================================
317
318/// HTTP 传输 trait — 用于解耦 Notifier 与具体 HTTP 库
319///
320/// 业务方实现此 trait 注入 reqwest / hyper / etc.,即可让 [`SlackNotifier`] 等基于 HTTP 的
321/// 通知器投入生产。
322///
323/// # 线程安全
324///
325/// 实现者必须保证 `Send + Sync`,因为 Notifier 通常作为单例在多线程下使用。
326pub trait HttpTransport: Send + Sync {
327    /// 发送 POST 请求,Content-Type: application/json
328    ///
329    /// # 参数
330    ///
331    /// - `url`: 目标 URL
332    /// - `body`: 请求体(JSON 字符串)
333    ///
334    /// # 返回
335    ///
336    /// 成功返回 `Ok(())`,失败返回 [`NotifyError`]。
337    fn post_json(&self, url: &str, body: &str) -> Result<(), NotifyError>;
338}
339
340// ============================================================================
341// MemoryHttpTransport(测试/开发用 HTTP 传输实现)
342// ============================================================================
343
344/// 内存 HTTP 传输 — 用于测试和开发环境
345///
346/// 不实际发送 HTTP 请求,而是将请求暂存到内部 Vec,供测试断言使用。
347#[derive(Debug, Default)]
348pub struct MemoryHttpTransport {
349    /// 已"发送"的 HTTP 请求列表(url, body)
350    requests: Mutex<Vec<(String, String)>>,
351}
352
353impl MemoryHttpTransport {
354    /// 创建新的内存 HTTP 传输
355    pub fn new() -> Self {
356        Self::default()
357    }
358
359    /// 获取已发送请求数量
360    pub fn count(&self) -> usize {
361        self.requests.lock().len()
362    }
363
364    /// 获取所有已发送请求(快照)
365    pub fn all(&self) -> Vec<(String, String)> {
366        self.requests.lock().clone()
367    }
368
369    /// 获取最后发送的请求
370    pub fn last(&self) -> Option<(String, String)> {
371        self.requests.lock().last().cloned()
372    }
373
374    /// 清空已发送请求
375    pub fn clear(&self) {
376        self.requests.lock().clear();
377    }
378}
379
380impl HttpTransport for MemoryHttpTransport {
381    fn post_json(&self, url: &str, body: &str) -> Result<(), NotifyError> {
382        self.requests
383            .lock()
384            .push((url.to_string(), body.to_string()));
385        Ok(())
386    }
387}
388
389// ============================================================================
390// Slack 配置
391// ============================================================================
392
393/// Slack Webhook 配置
394///
395/// 对齐 PHP `think\notify\driver\Slack` 的配置项。
396#[derive(Debug, Clone)]
397pub struct SlackConfig {
398    /// Slack Webhook URL(必填,格式:`https://hooks.slack.com/services/...`)
399    pub webhook_url: String,
400    /// 目标频道(可选,对齐 PHP `channel` 配置;Webhook URL 已绑定频道时可为 None)
401    pub channel: Option<String>,
402    /// 发送者显示名(可选,对齐 PHP `username` 配置)
403    pub username: Option<String>,
404    /// 发送者图标 emoji(可选,对齐 PHP `icon_emoji` 配置,如 `:alarm_clock:`)
405    pub icon_emoji: Option<String>,
406}
407
408impl SlackConfig {
409    /// 创建 Slack 配置
410    ///
411    /// # 参数
412    ///
413    /// - `webhook_url`: Slack Webhook URL
414    pub fn new(webhook_url: impl Into<String>) -> Self {
415        Self {
416            webhook_url: webhook_url.into(),
417            channel: None,
418            username: None,
419            icon_emoji: None,
420        }
421    }
422
423    /// 设置目标频道
424    pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
425        self.channel = Some(channel.into());
426        self
427    }
428
429    /// 设置发送者显示名
430    pub fn with_username(mut self, username: impl Into<String>) -> Self {
431        self.username = Some(username.into());
432        self
433    }
434
435    /// 设置发送者图标 emoji
436    pub fn with_icon_emoji(mut self, icon_emoji: impl Into<String>) -> Self {
437        self.icon_emoji = Some(icon_emoji.into());
438        self
439    }
440}
441
442// ============================================================================
443// Slack Webhook 请求体
444// ============================================================================
445
446/// Slack Webhook 请求体 — 对齐 Slack API 的 `chat.postMessage` 兼容格式
447///
448/// 参考:https://api.slack.com/messaging/webhooks
449#[derive(Debug, Clone, serde::Serialize)]
450struct SlackPayload {
451    /// 通知文本(必填,作为消息预览和 fallback)
452    text: String,
453    /// 目标频道(可选)
454    #[serde(skip_serializing_if = "Option::is_none")]
455    channel: Option<String>,
456    /// 发送者显示名(可选)
457    #[serde(skip_serializing_if = "Option::is_none")]
458    username: Option<String>,
459    /// 发送者图标 emoji(可选)
460    #[serde(skip_serializing_if = "Option::is_none")]
461    icon_emoji: Option<String>,
462    /// Slack Block Kit 附件(用于富文本展示,含颜色条)
463    attachments: Vec<SlackAttachment>,
464}
465
466/// Slack 附件 — 用于在消息旁显示颜色条(按通知级别着色)
467#[derive(Debug, Clone, serde::Serialize)]
468struct SlackAttachment {
469    /// 颜色条颜色(hex 颜色码,如 `#ff0000`)
470    color: String,
471    /// 附件标题
472    title: String,
473    /// 附件正文
474    text: String,
475    /// 时间戳(Unix 秒,用于 Slack 消息时间显示)
476    ts: i64,
477}
478
479// ============================================================================
480// SlackNotifier
481// ============================================================================
482
483/// Slack 通知发送器 — 基于 Slack Webhook API
484///
485/// 通过 [`HttpTransport`] trait 抽象 HTTP 发送,业务方注入具体 HTTP 客户端即可使用。
486///
487/// # 用法
488///
489/// ```rust,ignore
490/// use sz_rust_core::notify::{
491///     SlackConfig, SlackNotifier, MemoryHttpTransport, Notification, NotifyLevel, Notifier,
492/// };
493///
494/// let transport = std::sync::Arc::new(MemoryHttpTransport::new());
495/// let config = SlackConfig::new("https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX")
496///     .with_channel("#alerts")
497///     .with_username("SZ-Rust Bot")
498///     .with_icon_emoji(":alarm_clock:");
499/// let notifier = SlackNotifier::new(config, transport);
500///
501/// let msg = Notification::new()
502///     .channel("slack")
503///     .title("部署完成")
504///     .content("服务已成功部署到生产环境")
505///     .level(NotifyLevel::Info);
506///
507/// notifier.send(msg).unwrap();
508/// ```
509pub struct SlackNotifier {
510    /// Slack 配置
511    config: SlackConfig,
512    /// HTTP 传输实现
513    transport: Arc<dyn HttpTransport>,
514}
515
516impl SlackNotifier {
517    /// 创建 Slack 通知发送器
518    ///
519    /// # 参数
520    ///
521    /// - `config`: Slack 配置
522    /// - `transport`: HTTP 传输实现(业务方注入 reqwest / hyper / etc.)
523    pub fn new(config: SlackConfig, transport: Arc<dyn HttpTransport>) -> Self {
524        Self { config, transport }
525    }
526
527    /// 构造 Slack Webhook 请求体
528    ///
529    /// # 参数
530    ///
531    /// - `notification`: 通知消息
532    ///
533    /// # 返回
534    ///
535    /// 成功返回 JSON 字符串,失败返回 [`NotifyError`]。
536    fn build_payload(&self, notification: &Notification) -> Result<String, NotifyError> {
537        let ts = chrono::Utc::now().timestamp();
538        let payload = SlackPayload {
539            // Slack 要求 text 字段非空(作为消息预览和 fallback)
540            text: format!(
541                "[{}] {} — {}",
542                notification.level.as_str().to_uppercase(),
543                notification.title,
544                notification.content
545            ),
546            channel: self.config.channel.clone(),
547            username: self.config.username.clone(),
548            icon_emoji: self.config.icon_emoji.clone(),
549            attachments: vec![SlackAttachment {
550                color: notification.level.slack_color().to_string(),
551                title: notification.title.clone(),
552                text: notification.content.clone(),
553                ts,
554            }],
555        };
556
557        serde_json::to_string(&payload).map_err(|e| NotifyError::Serialize(e.to_string()))
558    }
559}
560
561impl Notifier for SlackNotifier {
562    fn send(&self, notification: Notification) -> Result<(), NotifyError> {
563        // 1. 校验通知字段
564        notification.validate()?;
565
566        // 2. 校验 webhook_url 非空
567        if self.config.webhook_url.is_empty() {
568            return Err(NotifyError::MissingField("webhook_url".into()));
569        }
570
571        // 3. 构造 Slack Webhook 请求体
572        let body = self.build_payload(&notification)?;
573
574        // 4. 通过 HttpTransport 发送
575        self.transport
576            .post_json(&self.config.webhook_url, &body)
577            .map_err(|e| NotifyError::HttpTransport(format!("Slack Webhook 发送失败: {e}")))?;
578
579        Ok(())
580    }
581}
582
583// ============================================================================
584// 短信消息(SmsMessage + SmsNotifier + MemorySmsNotifier)
585// ============================================================================
586
587/// 短信消息 — 对齐 PHP `think\notify\SmsMessage`
588///
589/// 使用 Builder 模式构建短信内容,通过 [`SmsNotifier::send_sms`] 发送。
590///
591/// # PHP 对齐
592///
593/// ```php
594/// // PHP think\notify\SmsMessage
595/// $sms = (new SmsMessage())
596///     ->setPhone('13800138000')
597///     ->setTemplateId('123456')
598///     ->setTemplateParams(['1234']);
599/// ```
600///
601/// # Rust 用法
602///
603/// ```rust,ignore
604/// use sz_rust_core::notify::{SmsMessage, MemorySmsNotifier, SmsNotifier};
605///
606/// let msg = SmsMessage::new()
607///     .phone("+8613800138000")
608///     .template_id("123456")
609///     .template_param("1234");
610///
611/// let notifier = MemorySmsNotifier::new();
612/// notifier.send_sms(msg).unwrap();
613/// ```
614#[derive(Debug, Clone, Default)]
615pub struct SmsMessage {
616    /// 手机号(必填)
617    pub phone: String,
618    /// 短信模板 ID(必填,对齐腾讯云 TemplateId)
619    pub template_id: String,
620    /// 模板参数(按顺序匹配模板中的 {1}、{2}... 占位符)
621    pub template_params: Vec<String>,
622    /// 短信签名(可选,默认使用配置中的签名)
623    pub sign_name: Option<String>,
624    /// 附加元数据
625    pub metadata: serde_json::Value,
626}
627
628impl SmsMessage {
629    /// 创建空短信消息
630    pub fn new() -> Self {
631        Self::default()
632    }
633
634    /// 设置手机号
635    pub fn phone(mut self, phone: impl Into<String>) -> Self {
636        self.phone = phone.into();
637        self
638    }
639
640    /// 设置短信模板 ID
641    pub fn template_id(mut self, template_id: impl Into<String>) -> Self {
642        self.template_id = template_id.into();
643        self
644    }
645
646    /// 追加单个模板参数
647    pub fn template_param(mut self, param: impl Into<String>) -> Self {
648        self.template_params.push(param.into());
649        self
650    }
651
652    /// 替换全部模板参数
653    pub fn template_params(mut self, params: Vec<String>) -> Self {
654        self.template_params = params;
655        self
656    }
657
658    /// 设置短信签名
659    pub fn sign_name(mut self, sign_name: impl Into<String>) -> Self {
660        self.sign_name = Some(sign_name.into());
661        self
662    }
663
664    /// 设置附加元数据
665    pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
666        self.metadata = metadata;
667        self
668    }
669
670    /// 校验必填字段
671    ///
672    /// # 返回
673    ///
674    /// - 手机号为空 → [`NotifyError::MissingField`]("phone")
675    /// - 模板 ID 为空 → [`NotifyError::MissingField`]("template_id")
676    pub fn validate(&self) -> Result<(), NotifyError> {
677        if self.phone.is_empty() {
678            return Err(NotifyError::MissingField("phone".into()));
679        }
680        if self.template_id.is_empty() {
681            return Err(NotifyError::MissingField("template_id".into()));
682        }
683        Ok(())
684    }
685}
686
687/// 短信通知发送器 trait — 对齐 PHP `think\notify\SmsNotifier`
688///
689/// 抽象短信发送行为,业务方实现具体发送逻辑(腾讯云 / 阿里云 / etc.)。
690///
691/// # PHP 对齐
692///
693/// ```php
694/// // PHP think\notify\SmsNotifier 接口
695/// interface SmsNotifier {
696///     public function sendSms(SmsMessage $message): bool;
697/// }
698/// ```
699pub trait SmsNotifier: Send + Sync {
700    /// 发送短信
701    ///
702    /// # 参数
703    ///
704    /// - `message`: 短信消息
705    ///
706    /// # 返回
707    ///
708    /// 成功返回 `Ok(())`,失败返回 [`NotifyError`]。
709    fn send_sms(&self, message: SmsMessage) -> Result<(), NotifyError>;
710}
711
712/// 内存短信通知发送器 — 用于测试和开发环境
713///
714/// 不实际发送短信,而是将消息暂存到内部 Vec,供测试断言使用。
715///
716/// # 线程安全
717///
718/// 通过 `Arc<Mutex<Vec<SmsMessage>>>` 保护,支持并发写入。
719#[derive(Debug, Clone, Default)]
720pub struct MemorySmsNotifier {
721    /// 已"发送"的短信列表
722    sent: Arc<Mutex<Vec<SmsMessage>>>,
723}
724
725impl MemorySmsNotifier {
726    /// 创建新的内存短信通知发送器
727    pub fn new() -> Self {
728        Self::default()
729    }
730
731    /// 获取已发送短信数量
732    pub fn count(&self) -> usize {
733        self.sent.lock().len()
734    }
735
736    /// 获取所有已发送短信(快照)
737    pub fn all(&self) -> Vec<SmsMessage> {
738        self.sent.lock().clone()
739    }
740
741    /// 获取最后发送的短信
742    pub fn last(&self) -> Option<SmsMessage> {
743        self.sent.lock().last().cloned()
744    }
745
746    /// 清空已发送短信
747    pub fn clear(&self) {
748        self.sent.lock().clear();
749    }
750}
751
752impl SmsNotifier for MemorySmsNotifier {
753    fn send_sms(&self, message: SmsMessage) -> Result<(), NotifyError> {
754        // 校验必要字段
755        message.validate()?;
756        // 暂存到内存
757        self.sent.lock().push(message);
758        Ok(())
759    }
760}
761
762// ============================================================================
763// 腾讯云短信配置
764// ============================================================================
765
766/// 腾讯云短信配置 — 对齐 `tencentcloud/tencentcloud-sdk-php` SmsClient
767///
768/// 对齐 PHP SDK `TencentCloud\Sms\V20210111\Models\SendSmsRequest` 的配置项。
769#[derive(Debug, Clone)]
770pub struct TencentSmsConfig {
771    /// SecretId(必填)
772    pub secret_id: String,
773    /// SecretKey(必填)
774    pub secret_key: String,
775    /// 短信 AppId(必填,如 `1400000000`)
776    pub app_id: String,
777    /// 默认短信签名(可选,如 `鲜视达科技`)
778    pub default_sign_name: Option<String>,
779    /// 地域(默认 `ap-guangzhou`)
780    pub region: String,
781    /// API 端点(默认 `sms.tencentcloudapi.com`)
782    pub endpoint: String,
783}
784
785impl TencentSmsConfig {
786    /// 创建腾讯云短信配置
787    ///
788    /// # 参数
789    ///
790    /// - `secret_id`: 腾讯云 SecretId
791    /// - `secret_key`: 腾讯云 SecretKey
792    /// - `app_id`: 短信 AppId
793    pub fn new(
794        secret_id: impl Into<String>,
795        secret_key: impl Into<String>,
796        app_id: impl Into<String>,
797    ) -> Self {
798        Self {
799            secret_id: secret_id.into(),
800            secret_key: secret_key.into(),
801            app_id: app_id.into(),
802            default_sign_name: None,
803            region: "ap-guangzhou".to_string(),
804            endpoint: "sms.tencentcloudapi.com".to_string(),
805        }
806    }
807
808    /// 设置默认短信签名
809    pub fn with_default_sign_name(mut self, sign_name: impl Into<String>) -> Self {
810        self.default_sign_name = Some(sign_name.into());
811        self
812    }
813
814    /// 设置地域
815    pub fn with_region(mut self, region: impl Into<String>) -> Self {
816        self.region = region.into();
817        self
818    }
819
820    /// 设置 API 端点
821    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
822        self.endpoint = endpoint.into();
823        self
824    }
825}
826
827// ============================================================================
828// 腾讯云短信请求体
829// ============================================================================
830
831/// 腾讯云短信请求体 — 对齐 `SendSms` action
832///
833/// 参考:https://cloud.tencent.com/document/product/382/55981
834///
835/// 注意:真实签名由业务方在实现 [`HttpTransport`] 时注入(如通过 header 携带
836/// `Authorization`、`X-TC-Action`、`X-TC-Region` 等),此处仅构造 JSON body。
837#[derive(Debug, Clone, serde::Serialize)]
838struct TencentSmsPayload {
839    /// 手机号集合(对齐腾讯云 `PhoneNumbers`)
840    #[serde(rename = "PhoneNumbers")]
841    phone_numbers: Vec<String>,
842    /// 模板 ID(对齐腾讯云 `TemplateId`)
843    #[serde(rename = "TemplateId")]
844    template_id: String,
845    /// 模板参数集合(对齐腾讯云 `TemplateParamSet`)
846    #[serde(rename = "TemplateParamSet")]
847    template_param_set: Vec<String>,
848    /// 短信 SDK AppId(对齐腾讯云 `SmsSdkAppId`)
849    #[serde(rename = "SmsSdkAppId")]
850    sms_sdk_app_id: String,
851    /// 短信签名(对齐腾讯云 `SignName`,可选)
852    #[serde(rename = "SignName", skip_serializing_if = "Option::is_none")]
853    sign_name: Option<String>,
854}
855
856// ============================================================================
857// TencentSmsNotifier
858// ============================================================================
859
860/// 腾讯云短信通知发送器 — 基于腾讯云短信 API(`SendSms` action)
861///
862/// 通过 [`HttpTransport`] trait 抽象 HTTP 发送,业务方注入具体 HTTP 客户端即可使用。
863/// 真实签名由业务方在实现 [`HttpTransport`] 时注入(如通过 header 携带
864/// `Authorization`、`X-TC-Action` 等),本实现仅构造请求 JSON body。
865///
866/// # 用法
867///
868/// ```rust,ignore
869/// use sz_rust_core::notify::{
870///     TencentSmsConfig, TencentSmsNotifier, MemoryHttpTransport, SmsMessage, SmsNotifier,
871/// };
872/// use std::sync::Arc;
873///
874/// let transport = Arc::new(MemoryHttpTransport::new());
875/// let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000")
876///     .with_default_sign_name("鲜视达科技");
877/// let notifier = TencentSmsNotifier::new(config, transport);
878///
879/// let msg = SmsMessage::new()
880///     .phone("+8613800138000")
881///     .template_id("123456")
882///     .template_param("1234");
883///
884/// notifier.send_sms(msg).unwrap();
885/// ```
886pub struct TencentSmsNotifier {
887    /// 腾讯云短信配置
888    config: TencentSmsConfig,
889    /// HTTP 传输实现
890    transport: Arc<dyn HttpTransport>,
891}
892
893impl TencentSmsNotifier {
894    /// 创建腾讯云短信通知发送器
895    ///
896    /// # 参数
897    ///
898    /// - `config`: 腾讯云短信配置
899    /// - `transport`: HTTP 传输实现(业务方注入 reqwest / hyper / etc.)
900    pub fn new(config: TencentSmsConfig, transport: Arc<dyn HttpTransport>) -> Self {
901        Self { config, transport }
902    }
903
904    /// 构造腾讯云短信请求体
905    ///
906    /// # 参数
907    ///
908    /// - `message`: 短信消息
909    ///
910    /// # 返回
911    ///
912    /// 成功返回 JSON 字符串,失败返回 [`NotifyError`]。
913    ///
914    /// # 说明
915    ///
916    /// 若 `message.sign_name` 为 `None`,则回退使用配置中的默认签名
917    /// `config.default_sign_name`;两者均为 `None` 时,请求体中不包含 `SignName` 字段。
918    fn build_payload(&self, message: &SmsMessage) -> Result<String, NotifyError> {
919        let sign_name = message
920            .sign_name
921            .clone()
922            .or_else(|| self.config.default_sign_name.clone());
923
924        let payload = TencentSmsPayload {
925            phone_numbers: vec![message.phone.clone()],
926            template_id: message.template_id.clone(),
927            template_param_set: message.template_params.clone(),
928            sms_sdk_app_id: self.config.app_id.clone(),
929            sign_name,
930        };
931
932        serde_json::to_string(&payload).map_err(|e| NotifyError::Serialize(e.to_string()))
933    }
934}
935
936impl SmsNotifier for TencentSmsNotifier {
937    fn send_sms(&self, message: SmsMessage) -> Result<(), NotifyError> {
938        // 1. 校验短信消息字段
939        message.validate()?;
940
941        // 2. 校验腾讯云凭据
942        if self.config.secret_id.is_empty() {
943            return Err(NotifyError::MissingField("secret_id".into()));
944        }
945        if self.config.secret_key.is_empty() {
946            return Err(NotifyError::MissingField("secret_key".into()));
947        }
948        if self.config.app_id.is_empty() {
949            return Err(NotifyError::MissingField("app_id".into()));
950        }
951
952        // 3. 构造请求体
953        let body = self.build_payload(&message)?;
954
955        // 4. 构造请求 URL(对齐腾讯云 API 端点格式)
956        let url = format!("https://{}/", self.config.endpoint);
957
958        // 5. 通过 HttpTransport 发送
959        self.transport
960            .post_json(&url, &body)
961            .map_err(|e| NotifyError::HttpTransport(format!("腾讯云短信发送失败: {e}")))?;
962
963        Ok(())
964    }
965}
966
967// ============================================================================
968// 单元测试
969// ============================================================================
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974
975    // ------------------------------------------------------------------------
976    // NotifyLevel 测试
977    // ------------------------------------------------------------------------
978
979    /// 测试 NotifyLevel 默认值为 Info
980    #[test]
981    fn test_notify_level_default() {
982        let level = NotifyLevel::default();
983        assert_eq!(level, NotifyLevel::Info);
984    }
985
986    /// 测试 NotifyLevel::slack_color 返回正确的颜色码
987    #[test]
988    fn test_notify_level_slack_color() {
989        assert_eq!(NotifyLevel::Info.slack_color(), "#36a64f");
990        assert_eq!(NotifyLevel::Warning.slack_color(), "#ffcc00");
991        assert_eq!(NotifyLevel::Error.slack_color(), "#ff0000");
992        assert_eq!(NotifyLevel::Critical.slack_color(), "#b22222");
993    }
994
995    /// 测试 NotifyLevel::as_str 返回正确的字符串标识
996    #[test]
997    fn test_notify_level_as_str() {
998        assert_eq!(NotifyLevel::Info.as_str(), "info");
999        assert_eq!(NotifyLevel::Warning.as_str(), "warning");
1000        assert_eq!(NotifyLevel::Error.as_str(), "error");
1001        assert_eq!(NotifyLevel::Critical.as_str(), "critical");
1002    }
1003
1004    /// 测试 NotifyLevel::Display 实现
1005    #[test]
1006    fn test_notify_level_display() {
1007        assert_eq!(format!("{}", NotifyLevel::Info), "info");
1008        assert_eq!(format!("{}", NotifyLevel::Warning), "warning");
1009        assert_eq!(format!("{}", NotifyLevel::Error), "error");
1010        assert_eq!(format!("{}", NotifyLevel::Critical), "critical");
1011    }
1012
1013    /// 测试 NotifyLevel::FromStr 实现(含别名和大小写不敏感)
1014    #[test]
1015    fn test_notify_level_from_str() {
1016        // 标准名称
1017        assert_eq!("info".parse::<NotifyLevel>().unwrap(), NotifyLevel::Info);
1018        assert_eq!(
1019            "warning".parse::<NotifyLevel>().unwrap(),
1020            NotifyLevel::Warning
1021        );
1022        assert_eq!("error".parse::<NotifyLevel>().unwrap(), NotifyLevel::Error);
1023        assert_eq!(
1024            "critical".parse::<NotifyLevel>().unwrap(),
1025            NotifyLevel::Critical
1026        );
1027
1028        // 别名
1029        assert_eq!("warn".parse::<NotifyLevel>().unwrap(), NotifyLevel::Warning);
1030        assert_eq!("err".parse::<NotifyLevel>().unwrap(), NotifyLevel::Error);
1031        assert_eq!(
1032            "crit".parse::<NotifyLevel>().unwrap(),
1033            NotifyLevel::Critical
1034        );
1035
1036        // 大小写不敏感
1037        assert_eq!("INFO".parse::<NotifyLevel>().unwrap(), NotifyLevel::Info);
1038        assert_eq!(
1039            "Critical".parse::<NotifyLevel>().unwrap(),
1040            NotifyLevel::Critical
1041        );
1042
1043        // 未知级别
1044        assert!("unknown".parse::<NotifyLevel>().is_err());
1045    }
1046
1047    // ------------------------------------------------------------------------
1048    // Notification 测试
1049    // ------------------------------------------------------------------------
1050
1051    /// 测试 Notification builder 模式
1052    #[test]
1053    fn test_notification_builder() {
1054        let notification = Notification::new()
1055            .channel("slack")
1056            .title("部署完成")
1057            .content("服务已成功部署到生产环境")
1058            .level(NotifyLevel::Info)
1059            .metadata(serde_json::json!({"env": "prod"}));
1060
1061        assert_eq!(notification.channel, "slack");
1062        assert_eq!(notification.title, "部署完成");
1063        assert_eq!(notification.content, "服务已成功部署到生产环境");
1064        assert_eq!(notification.level, NotifyLevel::Info);
1065        assert_eq!(notification.metadata["env"], "prod");
1066    }
1067
1068    /// 测试 Notification 默认值
1069    #[test]
1070    fn test_notification_default() {
1071        let notification = Notification::default();
1072        assert!(notification.channel.is_empty());
1073        assert!(notification.title.is_empty());
1074        assert!(notification.content.is_empty());
1075        assert_eq!(notification.level, NotifyLevel::Info);
1076        assert!(notification.metadata.is_null());
1077    }
1078
1079    /// 测试 Notification::validate 校验必填字段
1080    #[test]
1081    fn test_notification_validate_ok() {
1082        let notification = Notification::new()
1083            .channel("slack")
1084            .title("标题")
1085            .content("内容");
1086        assert!(notification.validate().is_ok());
1087    }
1088
1089    /// 测试 Notification::validate 缺少渠道
1090    #[test]
1091    fn test_notification_validate_missing_channel() {
1092        let notification = Notification::new().title("标题").content("内容");
1093        let err = notification.validate().unwrap_err();
1094        match err {
1095            NotifyError::MissingField(field) => assert_eq!(field, "channel"),
1096            other => panic!("期望 MissingField, 实际 {other:?}"),
1097        }
1098    }
1099
1100    /// 测试 Notification::validate 缺少标题
1101    #[test]
1102    fn test_notification_validate_missing_title() {
1103        let notification = Notification::new().channel("slack").content("内容");
1104        let err = notification.validate().unwrap_err();
1105        match err {
1106            NotifyError::MissingField(field) => assert_eq!(field, "title"),
1107            other => panic!("期望 MissingField, 实际 {other:?}"),
1108        }
1109    }
1110
1111    /// 测试 Notification::validate 缺少内容
1112    #[test]
1113    fn test_notification_validate_missing_content() {
1114        let notification = Notification::new().channel("slack").title("标题");
1115        let err = notification.validate().unwrap_err();
1116        match err {
1117            NotifyError::MissingField(field) => assert_eq!(field, "content"),
1118            other => panic!("期望 MissingField, 实际 {other:?}"),
1119        }
1120    }
1121
1122    // ------------------------------------------------------------------------
1123    // MemoryNotifier 测试
1124    // ------------------------------------------------------------------------
1125
1126    /// 测试 MemoryNotifier 发送通知
1127    #[test]
1128    fn test_memory_notifier_send() {
1129        let notifier = MemoryNotifier::new();
1130        let notification = Notification::new()
1131            .channel("slack")
1132            .title("标题")
1133            .content("内容")
1134            .level(NotifyLevel::Warning);
1135
1136        notifier.send(notification).unwrap();
1137        assert_eq!(notifier.count(), 1);
1138
1139        let last = notifier.last().unwrap();
1140        assert_eq!(last.channel, "slack");
1141        assert_eq!(last.title, "标题");
1142        assert_eq!(last.content, "内容");
1143        assert_eq!(last.level, NotifyLevel::Warning);
1144    }
1145
1146    /// 测试 MemoryNotifier 发送多封通知
1147    #[test]
1148    fn test_memory_notifier_send_multiple() {
1149        let notifier = MemoryNotifier::new();
1150        for i in 0..5 {
1151            notifier
1152                .send(
1153                    Notification::new()
1154                        .channel("slack")
1155                        .title(format!("标题{i}"))
1156                        .content("内容"),
1157                )
1158                .unwrap();
1159        }
1160        assert_eq!(notifier.count(), 5);
1161
1162        let all = notifier.all();
1163        assert_eq!(all[0].title, "标题0");
1164        assert_eq!(all[4].title, "标题4");
1165    }
1166
1167    /// 测试 MemoryNotifier 发送无效通知返回错误
1168    #[test]
1169    fn test_memory_notifier_send_invalid() {
1170        let notifier = MemoryNotifier::new();
1171        let notification = Notification::new().title("标题").content("内容");
1172        // 缺少 channel
1173        assert!(notifier.send(notification).is_err());
1174        assert_eq!(notifier.count(), 0);
1175    }
1176
1177    /// 测试 MemoryNotifier clear
1178    #[test]
1179    fn test_memory_notifier_clear() {
1180        let notifier = MemoryNotifier::new();
1181        notifier
1182            .send(
1183                Notification::new()
1184                    .channel("slack")
1185                    .title("标题")
1186                    .content("内容"),
1187            )
1188            .unwrap();
1189        assert_eq!(notifier.count(), 1);
1190
1191        notifier.clear();
1192        assert_eq!(notifier.count(), 0);
1193        assert!(notifier.last().is_none());
1194    }
1195
1196    // ------------------------------------------------------------------------
1197    // MemoryHttpTransport 测试
1198    // ------------------------------------------------------------------------
1199
1200    /// 测试 MemoryHttpTransport 记录请求
1201    #[test]
1202    fn test_memory_http_transport_post_json() {
1203        let transport = MemoryHttpTransport::new();
1204        transport
1205            .post_json("https://hooks.slack.com/services/xxx", r#"{"text":"hi"}"#)
1206            .unwrap();
1207
1208        assert_eq!(transport.count(), 1);
1209        let (url, body) = transport.last().unwrap();
1210        assert_eq!(url, "https://hooks.slack.com/services/xxx");
1211        assert_eq!(body, r#"{"text":"hi"}"#);
1212    }
1213
1214    /// 测试 MemoryHttpTransport clear
1215    #[test]
1216    fn test_memory_http_transport_clear() {
1217        let transport = MemoryHttpTransport::new();
1218        transport.post_json("url", "body").unwrap();
1219        assert_eq!(transport.count(), 1);
1220
1221        transport.clear();
1222        assert_eq!(transport.count(), 0);
1223    }
1224
1225    // ------------------------------------------------------------------------
1226    // SlackConfig 测试
1227    // ------------------------------------------------------------------------
1228
1229    /// 测试 SlackConfig builder 模式
1230    #[test]
1231    fn test_slack_config_builder() {
1232        let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X")
1233            .with_channel("#alerts")
1234            .with_username("SZ-Rust Bot")
1235            .with_icon_emoji(":alarm_clock:");
1236
1237        assert_eq!(config.webhook_url, "https://hooks.slack.com/services/T/B/X");
1238        assert_eq!(config.channel.as_deref(), Some("#alerts"));
1239        assert_eq!(config.username.as_deref(), Some("SZ-Rust Bot"));
1240        assert_eq!(config.icon_emoji.as_deref(), Some(":alarm_clock:"));
1241    }
1242
1243    /// 测试 SlackConfig 默认值(仅 webhook_url)
1244    #[test]
1245    fn test_slack_config_minimal() {
1246        let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1247        assert_eq!(config.webhook_url, "https://hooks.slack.com/services/T/B/X");
1248        assert!(config.channel.is_none());
1249        assert!(config.username.is_none());
1250        assert!(config.icon_emoji.is_none());
1251    }
1252
1253    // ------------------------------------------------------------------------
1254    // SlackNotifier 测试
1255    // ------------------------------------------------------------------------
1256
1257    /// 测试 SlackNotifier 发送通知(通过 MemoryHttpTransport 验证请求体)
1258    #[test]
1259    fn test_slack_notifier_send() {
1260        let transport = Arc::new(MemoryHttpTransport::new());
1261        let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X")
1262            .with_channel("#alerts")
1263            .with_username("SZ-Rust Bot")
1264            .with_icon_emoji(":alarm_clock:");
1265        let notifier = SlackNotifier::new(config, transport.clone());
1266
1267        let notification = Notification::new()
1268            .channel("slack")
1269            .title("部署完成")
1270            .content("服务已成功部署到生产环境")
1271            .level(NotifyLevel::Info);
1272
1273        notifier.send(notification).unwrap();
1274
1275        // 验证 HTTP 请求
1276        assert_eq!(transport.count(), 1);
1277        let (url, body) = transport.last().unwrap();
1278        assert_eq!(url, "https://hooks.slack.com/services/T/B/X");
1279
1280        // 解析 body 验证结构
1281        let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1282        assert!(payload["text"].as_str().unwrap().contains("部署完成"));
1283        assert!(payload["text"]
1284            .as_str()
1285            .unwrap()
1286            .contains("服务已成功部署到生产环境"));
1287        assert!(payload["text"].as_str().unwrap().contains("[INFO]"));
1288        assert_eq!(payload["channel"], "#alerts");
1289        assert_eq!(payload["username"], "SZ-Rust Bot");
1290        assert_eq!(payload["icon_emoji"], ":alarm_clock:");
1291
1292        // 验证 attachments
1293        let attachments = payload["attachments"].as_array().unwrap();
1294        assert_eq!(attachments.len(), 1);
1295        assert_eq!(attachments[0]["color"], "#36a64f"); // Info level color
1296        assert_eq!(attachments[0]["title"], "部署完成");
1297        assert_eq!(attachments[0]["text"], "服务已成功部署到生产环境");
1298        assert!(attachments[0]["ts"].as_i64().is_some());
1299    }
1300
1301    /// 测试 SlackNotifier 不同级别使用不同颜色
1302    #[test]
1303    fn test_slack_notifier_level_colors() {
1304        let transport = Arc::new(MemoryHttpTransport::new());
1305        let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1306        let notifier = SlackNotifier::new(config, transport.clone());
1307
1308        // Warning
1309        notifier
1310            .send(
1311                Notification::new()
1312                    .channel("slack")
1313                    .title("w")
1314                    .content("c")
1315                    .level(NotifyLevel::Warning),
1316            )
1317            .unwrap();
1318        let (_, body) = transport.last().unwrap();
1319        let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1320        assert_eq!(payload["attachments"][0]["color"], "#ffcc00");
1321
1322        // Error
1323        notifier
1324            .send(
1325                Notification::new()
1326                    .channel("slack")
1327                    .title("w")
1328                    .content("c")
1329                    .level(NotifyLevel::Error),
1330            )
1331            .unwrap();
1332        let (_, body) = transport.last().unwrap();
1333        let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1334        assert_eq!(payload["attachments"][0]["color"], "#ff0000");
1335
1336        // Critical
1337        notifier
1338            .send(
1339                Notification::new()
1340                    .channel("slack")
1341                    .title("w")
1342                    .content("c")
1343                    .level(NotifyLevel::Critical),
1344            )
1345            .unwrap();
1346        let (_, body) = transport.last().unwrap();
1347        let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1348        assert_eq!(payload["attachments"][0]["color"], "#b22222");
1349
1350        assert_eq!(transport.count(), 3);
1351    }
1352
1353    /// 测试 SlackNotifier 缺少 channel 字段返回错误
1354    #[test]
1355    fn test_slack_notifier_missing_channel() {
1356        let transport = Arc::new(MemoryHttpTransport::new());
1357        let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1358        let notifier = SlackNotifier::new(config, transport.clone());
1359
1360        let notification = Notification::new().title("标题").content("内容");
1361        let err = notifier.send(notification).unwrap_err();
1362        match err {
1363            NotifyError::MissingField(field) => assert_eq!(field, "channel"),
1364            other => panic!("期望 MissingField, 实际 {other:?}"),
1365        }
1366
1367        // 不应发送任何 HTTP 请求
1368        assert_eq!(transport.count(), 0);
1369    }
1370
1371    /// 测试 SlackNotifier 缺少 webhook_url 返回错误
1372    #[test]
1373    fn test_slack_notifier_missing_webhook_url() {
1374        let transport = Arc::new(MemoryHttpTransport::new());
1375        let config = SlackConfig::new(""); // 空 webhook_url
1376        let notifier = SlackNotifier::new(config, transport.clone());
1377
1378        let notification = Notification::new()
1379            .channel("slack")
1380            .title("标题")
1381            .content("内容");
1382        let err = notifier.send(notification).unwrap_err();
1383        match err {
1384            NotifyError::MissingField(field) => assert_eq!(field, "webhook_url"),
1385            other => panic!("期望 MissingField, 实际 {other:?}"),
1386        }
1387
1388        assert_eq!(transport.count(), 0);
1389    }
1390
1391    /// 测试 SlackNotifier 在 HttpTransport 失败时返回错误
1392    #[test]
1393    fn test_slack_notifier_http_failure() {
1394        // 自定义失败 HttpTransport
1395        struct FailingTransport;
1396        impl HttpTransport for FailingTransport {
1397            fn post_json(&self, _url: &str, _body: &str) -> Result<(), NotifyError> {
1398                Err(NotifyError::HttpTransport("connection refused".to_string()))
1399            }
1400        }
1401
1402        let transport = Arc::new(FailingTransport);
1403        let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1404        let notifier = SlackNotifier::new(config, transport);
1405
1406        let notification = Notification::new()
1407            .channel("slack")
1408            .title("标题")
1409            .content("内容");
1410        let err = notifier.send(notification).unwrap_err();
1411        match err {
1412            NotifyError::HttpTransport(msg) => assert!(msg.contains("connection refused")),
1413            other => panic!("期望 HttpTransport, 实际 {other:?}"),
1414        }
1415    }
1416
1417    /// 测试 SlackNotifier build_payload 序列化结果正确
1418    #[test]
1419    fn test_slack_notifier_build_payload() {
1420        let transport: Arc<dyn HttpTransport> = Arc::new(MemoryHttpTransport::new());
1421        let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X")
1422            .with_channel("#alerts")
1423            .with_username("Bot")
1424            .with_icon_emoji(":bell:");
1425        let notifier = SlackNotifier::new(config, transport.clone());
1426
1427        let notification = Notification::new()
1428            .channel("slack")
1429            .title("Test Title")
1430            .content("Test Content")
1431            .level(NotifyLevel::Error);
1432
1433        let payload_json = notifier.build_payload(&notification).unwrap();
1434        let payload: serde_json::Value = serde_json::from_str(&payload_json).unwrap();
1435
1436        // 验证 text 字段格式
1437        assert_eq!(
1438            payload["text"].as_str().unwrap(),
1439            "[ERROR] Test Title — Test Content"
1440        );
1441
1442        // 验证可选字段
1443        assert_eq!(payload["channel"], "#alerts");
1444        assert_eq!(payload["username"], "Bot");
1445        assert_eq!(payload["icon_emoji"], ":bell:");
1446
1447        // 验证 attachments
1448        let attachments = payload["attachments"].as_array().unwrap();
1449        assert_eq!(attachments.len(), 1);
1450        assert_eq!(attachments[0]["color"], "#ff0000");
1451        assert_eq!(attachments[0]["title"], "Test Title");
1452        assert_eq!(attachments[0]["text"], "Test Content");
1453        assert!(attachments[0]["ts"].as_i64().is_some());
1454    }
1455
1456    /// 测试 SlackNotifier 多次发送计数
1457    #[test]
1458    fn test_slack_notifier_send_multiple() {
1459        let transport = Arc::new(MemoryHttpTransport::new());
1460        let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1461        let notifier = SlackNotifier::new(config, transport.clone());
1462
1463        for i in 0..3 {
1464            notifier
1465                .send(
1466                    Notification::new()
1467                        .channel("slack")
1468                        .title(format!("Title {i}"))
1469                        .content("content"),
1470                )
1471                .unwrap();
1472        }
1473        assert_eq!(transport.count(), 3);
1474    }
1475
1476    /// 测试 SlackNotifier 使用最小配置(仅 webhook_url)
1477    #[test]
1478    fn test_slack_notifier_minimal_config() {
1479        let transport = Arc::new(MemoryHttpTransport::new());
1480        let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1481        let notifier = SlackNotifier::new(config, transport.clone());
1482
1483        notifier
1484            .send(
1485                Notification::new()
1486                    .channel("slack")
1487                    .title("Title")
1488                    .content("Content"),
1489            )
1490            .unwrap();
1491
1492        let (_, body) = transport.last().unwrap();
1493        let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1494
1495        // 可选字段不应出现在序列化结果中(skip_serializing_if)
1496        assert!(payload.get("channel").is_none());
1497        assert!(payload.get("username").is_none());
1498        assert!(payload.get("icon_emoji").is_none());
1499    }
1500
1501    /// 测试 SlackNotifier metadata 字段不影响发送
1502    #[test]
1503    fn test_slack_notifier_with_metadata() {
1504        let transport = Arc::new(MemoryHttpTransport::new());
1505        let config = SlackConfig::new("https://hooks.slack.com/services/T/B/X");
1506        let notifier = SlackNotifier::new(config, transport.clone());
1507
1508        notifier
1509            .send(
1510                Notification::new()
1511                    .channel("slack")
1512                    .title("Title")
1513                    .content("Content")
1514                    .metadata(serde_json::json!({"env": "prod", "version": "1.0.0"})),
1515            )
1516            .unwrap();
1517
1518        assert_eq!(transport.count(), 1);
1519    }
1520
1521    // ------------------------------------------------------------------------
1522    // SmsMessage 测试
1523    // ------------------------------------------------------------------------
1524
1525    /// 测试 SmsMessage builder 模式
1526    #[test]
1527    fn test_sms_message_builder() {
1528        let msg = SmsMessage::new()
1529            .phone("+8613800138000")
1530            .template_id("123456")
1531            .template_param("1234")
1532            .template_param("5")
1533            .sign_name("鲜视达科技")
1534            .metadata(serde_json::json!({"scene": "login"}));
1535
1536        assert_eq!(msg.phone, "+8613800138000");
1537        assert_eq!(msg.template_id, "123456");
1538        assert_eq!(msg.template_params, vec!["1234", "5"]);
1539        assert_eq!(msg.sign_name.as_deref(), Some("鲜视达科技"));
1540        assert_eq!(msg.metadata["scene"], "login");
1541    }
1542
1543    /// 测试 SmsMessage::validate 成功
1544    #[test]
1545    fn test_sms_message_validate_ok() {
1546        let msg = SmsMessage::new()
1547            .phone("+8613800138000")
1548            .template_id("123456");
1549        assert!(msg.validate().is_ok());
1550    }
1551
1552    /// 测试 SmsMessage::validate 缺少手机号
1553    #[test]
1554    fn test_sms_message_validate_missing_phone() {
1555        let msg = SmsMessage::new().template_id("123456");
1556        let err = msg.validate().unwrap_err();
1557        match err {
1558            NotifyError::MissingField(field) => assert_eq!(field, "phone"),
1559            other => panic!("期望 MissingField, 实际 {other:?}"),
1560        }
1561    }
1562
1563    /// 测试 SmsMessage::validate 缺少模板 ID
1564    #[test]
1565    fn test_sms_message_validate_missing_template_id() {
1566        let msg = SmsMessage::new().phone("+8613800138000");
1567        let err = msg.validate().unwrap_err();
1568        match err {
1569            NotifyError::MissingField(field) => assert_eq!(field, "template_id"),
1570            other => panic!("期望 MissingField, 实际 {other:?}"),
1571        }
1572    }
1573
1574    // ------------------------------------------------------------------------
1575    // MemorySmsNotifier 测试
1576    // ------------------------------------------------------------------------
1577
1578    /// 测试 MemorySmsNotifier 发送短信
1579    #[test]
1580    fn test_memory_sms_notifier_send() {
1581        let notifier = MemorySmsNotifier::new();
1582        let msg = SmsMessage::new()
1583            .phone("+8613800138000")
1584            .template_id("123456")
1585            .template_param("1234");
1586
1587        notifier.send_sms(msg).unwrap();
1588        assert_eq!(notifier.count(), 1);
1589
1590        let last = notifier.last().unwrap();
1591        assert_eq!(last.phone, "+8613800138000");
1592        assert_eq!(last.template_id, "123456");
1593        assert_eq!(last.template_params, vec!["1234"]);
1594    }
1595
1596    /// 测试 MemorySmsNotifier 发送多条短信
1597    #[test]
1598    fn test_memory_sms_notifier_send_multiple() {
1599        let notifier = MemorySmsNotifier::new();
1600        for i in 0..5 {
1601            notifier
1602                .send_sms(
1603                    SmsMessage::new()
1604                        .phone(format!("+861380013{i:04}"))
1605                        .template_id("123456"),
1606                )
1607                .unwrap();
1608        }
1609        assert_eq!(notifier.count(), 5);
1610
1611        let all = notifier.all();
1612        assert_eq!(all[0].phone, "+8613800130000");
1613        assert_eq!(all[4].phone, "+8613800130004");
1614    }
1615
1616    /// 测试 MemorySmsNotifier 发送无效短信返回错误
1617    #[test]
1618    fn test_memory_sms_notifier_send_invalid() {
1619        let notifier = MemorySmsNotifier::new();
1620        let msg = SmsMessage::new().template_id("123456");
1621        // 缺少 phone
1622        assert!(notifier.send_sms(msg).is_err());
1623        assert_eq!(notifier.count(), 0);
1624    }
1625
1626    /// 测试 MemorySmsNotifier clear
1627    #[test]
1628    fn test_memory_sms_notifier_clear() {
1629        let notifier = MemorySmsNotifier::new();
1630        notifier
1631            .send_sms(
1632                SmsMessage::new()
1633                    .phone("+8613800138000")
1634                    .template_id("123456"),
1635            )
1636            .unwrap();
1637        assert_eq!(notifier.count(), 1);
1638
1639        notifier.clear();
1640        assert_eq!(notifier.count(), 0);
1641        assert!(notifier.last().is_none());
1642    }
1643
1644    // ------------------------------------------------------------------------
1645    // TencentSmsConfig 测试
1646    // ------------------------------------------------------------------------
1647
1648    /// 测试 TencentSmsConfig builder 模式
1649    #[test]
1650    fn test_tencent_sms_config_builder() {
1651        let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000")
1652            .with_default_sign_name("鲜视达科技")
1653            .with_region("ap-beijing")
1654            .with_endpoint("sms.tencentcloudapi.com");
1655
1656        assert_eq!(config.secret_id, "AKIDxxx");
1657        assert_eq!(config.secret_key, "SKxxx");
1658        assert_eq!(config.app_id, "1400000000");
1659        assert_eq!(config.default_sign_name.as_deref(), Some("鲜视达科技"));
1660        assert_eq!(config.region, "ap-beijing");
1661        assert_eq!(config.endpoint, "sms.tencentcloudapi.com");
1662    }
1663
1664    /// 测试 TencentSmsConfig 默认值(仅必填项)
1665    #[test]
1666    fn test_tencent_sms_config_minimal() {
1667        let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000");
1668        assert_eq!(config.secret_id, "AKIDxxx");
1669        assert_eq!(config.secret_key, "SKxxx");
1670        assert_eq!(config.app_id, "1400000000");
1671        assert!(config.default_sign_name.is_none());
1672        assert_eq!(config.region, "ap-guangzhou");
1673        assert_eq!(config.endpoint, "sms.tencentcloudapi.com");
1674    }
1675
1676    // ------------------------------------------------------------------------
1677    // TencentSmsNotifier 测试
1678    // ------------------------------------------------------------------------
1679
1680    /// 测试 TencentSmsNotifier 发送短信(通过 MemoryHttpTransport 验证请求体)
1681    #[test]
1682    fn test_tencent_sms_notifier_send() {
1683        let transport = Arc::new(MemoryHttpTransport::new());
1684        let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000")
1685            .with_default_sign_name("鲜视达科技");
1686        let notifier = TencentSmsNotifier::new(config, transport.clone());
1687
1688        let msg = SmsMessage::new()
1689            .phone("+8613800138000")
1690            .template_id("123456")
1691            .template_param("1234")
1692            .template_param("5");
1693
1694        notifier.send_sms(msg).unwrap();
1695
1696        // 验证 HTTP 请求
1697        assert_eq!(transport.count(), 1);
1698        let (url, body) = transport.last().unwrap();
1699        assert_eq!(url, "https://sms.tencentcloudapi.com/");
1700
1701        // 解析 body 验证结构
1702        let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1703        assert_eq!(payload["PhoneNumbers"][0], "+8613800138000");
1704        assert_eq!(payload["TemplateId"], "123456");
1705        assert_eq!(payload["TemplateParamSet"][0], "1234");
1706        assert_eq!(payload["TemplateParamSet"][1], "5");
1707        assert_eq!(payload["SmsSdkAppId"], "1400000000");
1708        assert_eq!(payload["SignName"], "鲜视达科技");
1709    }
1710
1711    /// 测试 TencentSmsNotifier 缺少手机号返回错误
1712    #[test]
1713    fn test_tencent_sms_notifier_missing_phone() {
1714        let transport = Arc::new(MemoryHttpTransport::new());
1715        let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000");
1716        let notifier = TencentSmsNotifier::new(config, transport.clone());
1717
1718        let msg = SmsMessage::new().template_id("123456");
1719        let err = notifier.send_sms(msg).unwrap_err();
1720        match err {
1721            NotifyError::MissingField(field) => assert_eq!(field, "phone"),
1722            other => panic!("期望 MissingField, 实际 {other:?}"),
1723        }
1724
1725        // 不应发送任何 HTTP 请求
1726        assert_eq!(transport.count(), 0);
1727    }
1728
1729    /// 测试 TencentSmsNotifier 缺少凭据返回错误
1730    #[test]
1731    fn test_tencent_sms_notifier_missing_credentials() {
1732        let transport = Arc::new(MemoryHttpTransport::new());
1733        // 空 secret_id
1734        let config = TencentSmsConfig::new("", "SKxxx", "1400000000");
1735        let notifier = TencentSmsNotifier::new(config, transport.clone());
1736
1737        let msg = SmsMessage::new()
1738            .phone("+8613800138000")
1739            .template_id("123456");
1740        let err = notifier.send_sms(msg).unwrap_err();
1741        match err {
1742            NotifyError::MissingField(field) => assert_eq!(field, "secret_id"),
1743            other => panic!("期望 MissingField, 实际 {other:?}"),
1744        }
1745
1746        // 不应发送任何 HTTP 请求
1747        assert_eq!(transport.count(), 0);
1748
1749        // 验证空 secret_key
1750        let config2 = TencentSmsConfig::new("AKIDxxx", "", "1400000000");
1751        let notifier2 = TencentSmsNotifier::new(config2, transport.clone());
1752        let msg2 = SmsMessage::new()
1753            .phone("+8613800138000")
1754            .template_id("123456");
1755        let err2 = notifier2.send_sms(msg2).unwrap_err();
1756        match err2 {
1757            NotifyError::MissingField(field) => assert_eq!(field, "secret_key"),
1758            other => panic!("期望 MissingField, 实际 {other:?}"),
1759        }
1760
1761        // 验证空 app_id
1762        let config3 = TencentSmsConfig::new("AKIDxxx", "SKxxx", "");
1763        let notifier3 = TencentSmsNotifier::new(config3, transport.clone());
1764        let msg3 = SmsMessage::new()
1765            .phone("+8613800138000")
1766            .template_id("123456");
1767        let err3 = notifier3.send_sms(msg3).unwrap_err();
1768        match err3 {
1769            NotifyError::MissingField(field) => assert_eq!(field, "app_id"),
1770            other => panic!("期望 MissingField, 实际 {other:?}"),
1771        }
1772
1773        // 全程不应发送任何 HTTP 请求
1774        assert_eq!(transport.count(), 0);
1775    }
1776
1777    /// 测试 TencentSmsNotifier 使用默认签名
1778    #[test]
1779    fn test_tencent_sms_notifier_uses_default_sign_name() {
1780        let transport = Arc::new(MemoryHttpTransport::new());
1781        let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000")
1782            .with_default_sign_name("鲜视达科技");
1783        let notifier = TencentSmsNotifier::new(config, transport.clone());
1784
1785        // 消息未设置 sign_name,应使用 config 的 default_sign_name
1786        let msg = SmsMessage::new()
1787            .phone("+8613800138000")
1788            .template_id("123456");
1789
1790        notifier.send_sms(msg).unwrap();
1791
1792        let (_, body) = transport.last().unwrap();
1793        let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1794        assert_eq!(payload["SignName"], "鲜视达科技");
1795
1796        // 验证消息级 sign_name 优先于 config 的 default_sign_name
1797        let msg2 = SmsMessage::new()
1798            .phone("+8613800138000")
1799            .template_id("123456")
1800            .sign_name("覆盖签名");
1801        notifier.send_sms(msg2).unwrap();
1802
1803        let (_, body2) = transport.last().unwrap();
1804        let payload2: serde_json::Value = serde_json::from_str(&body2).unwrap();
1805        assert_eq!(payload2["SignName"], "覆盖签名");
1806
1807        assert_eq!(transport.count(), 2);
1808    }
1809
1810    /// 测试 TencentSmsNotifier 无签名时请求体不包含 SignName 字段
1811    #[test]
1812    fn test_tencent_sms_notifier_no_sign_name() {
1813        let transport = Arc::new(MemoryHttpTransport::new());
1814        let config = TencentSmsConfig::new("AKIDxxx", "SKxxx", "1400000000");
1815        let notifier = TencentSmsNotifier::new(config, transport.clone());
1816
1817        let msg = SmsMessage::new()
1818            .phone("+8613800138000")
1819            .template_id("123456");
1820
1821        notifier.send_sms(msg).unwrap();
1822
1823        let (_, body) = transport.last().unwrap();
1824        let payload: serde_json::Value = serde_json::from_str(&body).unwrap();
1825        // 无签名时 SignName 字段不应出现在请求体中
1826        assert!(payload.get("SignName").is_none());
1827    }
1828}