Skip to main content

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