wechat_minapp/minapp_security/
msg_sec_check.rs

1//! 微信小程序内容安全检测模块
2//!
3//! 该模块提供了微信小程序内容安全检测功能,用于检测文本内容是否包含违规信息。
4//!
5//! # 主要功能
6//!
7//! - 文本内容安全检测
8//! - 多场景检测支持(资料、评论、论坛、社交日志)
9//! - 详细的检测结果分析
10//! - 置信度评分和关键词命中
11//!
12//! # 使用场景
13//!
14//! 适用于需要用户生成内容的场景:
15//!
16//! - 用户昵称、个性签名
17//! - 评论、留言
18//! - 论坛帖子、文章
19//! - 社交动态、日志
20//!
21//! # 快速开始
22//!
23//! ```no_run
24//! use wechat_minapp::{Client, minapp_security::{Args, Scene}};
25//!
26//! #[tokio::main]
27//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
28//!     let client = Client::new("app_id", "secret");
29//!     
30//!     let args = Args::builder()
31//!         .content("需要检测的文本内容")
32//!         .scene(Scene::Comment)
33//!         .openid("user_openid")
34//!         .build()?;
35//!     
36//!     let result = client.msg_sec_check(&args).await?;
37//!     
38//!     if result.is_pass() {
39//!         println!("内容安全,可以发布");
40//!     } else if result.needs_review() {
41//!         println!("内容需要人工审核");
42//!     } else {
43//!         println!("内容有风险,建议修改");
44//!     }
45//!     
46//!     Ok(())
47//! }
48//! ```
49
50use super::{Label, Suggest};
51use crate::{Result, client::Client, constants, error::Error};
52use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
53use serde::{Deserialize, Serialize};
54use std::collections::HashMap;
55use tracing::debug;
56
57/// 内容安全检测场景
58///
59/// 定义不同的内容检测场景,不同场景有不同的检测策略和敏感度。
60///
61/// # 场景说明
62///
63/// - **资料**: 用户昵称、头像、个性签名等个人信息
64/// - **评论**: 用户评论、留言等互动内容  
65/// - **论坛**: 论坛帖子、文章等长文本内容
66/// - **社交日志**: 朋友圈、动态等社交内容
67///
68/// # 示例
69///
70/// ```
71/// use wechat_minapp::minapp_security::Scene;
72///
73/// let profile_scene = Scene::Profile;
74/// let comment_scene = Scene::Comment;
75/// let forum_scene = Scene::Forum;
76/// let social_scene = Scene::SocialLog;
77///
78/// assert_eq!(profile_scene as u32, 1);
79/// assert_eq!(profile_scene.description(), "资料");
80/// ```
81#[derive(Debug, Serialize, Clone, Copy, PartialEq)]
82pub enum Scene {
83    /// 资料
84    Profile = 1,
85    /// 评论
86    Comment = 2,
87    /// 论坛
88    Forum = 3,
89    /// 社交日志
90    SocialLog = 4,
91}
92
93/// 微信内容安全检测请求参数
94///
95/// 用于配置内容安全检测的各项参数,包括检测内容、场景、用户信息等。
96///
97/// # 字段说明
98///
99/// - `content`: 待检测的文本内容,最大长度2500字符
100/// - `version`: 接口版本号,固定为2
101/// - `scene`: 检测场景,不同场景有不同的检测策略
102/// - `openid`: 用户openid,用户需在近两小时访问过小程序
103/// - `title`: 文本标题(可选)
104/// - `nickname`: 用户昵称(可选)
105/// - `signature`: 个性签名,仅在资料场景有效(可选)
106///
107/// # 示例
108///
109/// ```
110/// use wechat_minapp::minapp_security::{Args, Scene};
111///
112/// let args = Args::new("待检测的文本内容", Scene::Comment, "user_openid");
113/// assert_eq!(args.content_length(), 18);
114/// assert!(args.is_profile_scene());
115/// ```
116#[derive(Debug, Serialize, Clone)]
117pub struct Args {
118    /// 需检测的文本内容,文本字数的上限为2500字,需使用UTF-8编码
119    pub content: String,
120    /// 接口版本号,2.0版本为固定值2
121    pub version: u32,
122    /// 场景枚举值
123    pub scene: Scene,
124    /// 用户的openid(用户需在近两小时访问过小程序)
125    pub openid: String,
126    /// 文本标题,需使用UTF-8编码
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub title: Option<String>,
129    /// 用户昵称,需使用UTF-8编码
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub nickname: Option<String>,
132    /// 个性签名,该参数仅在资料类场景有效(scene=1),需使用UTF-8编码
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub signature: Option<String>,
135}
136
137/// Args 构建器,提供链式调用和验证
138///
139/// 用于构建内容安全检测参数,提供参数验证和便捷的链式调用。
140///
141/// # 示例
142///
143/// ```
144/// use wechat_minapp::minapp_security::{Args, Scene};
145///
146/// let args = Args::builder()
147///     .content("待检测内容")
148///     .scene(Scene::Comment)
149///     .openid("user_openid")
150///     .title("文章标题")
151///     .nickname("用户昵称")
152///     .build()
153///     .unwrap();
154/// ```
155#[derive(Debug, Default)]
156pub struct ArgsBuilder {
157    content: Option<String>,
158    version: Option<u32>,
159    scene: Option<Scene>,
160    openid: Option<String>,
161    title: Option<String>,
162    nickname: Option<String>,
163    signature: Option<String>,
164}
165
166impl ArgsBuilder {
167    /// 创建新的构建器实例
168    pub fn new() -> Self {
169        Self::default()
170    }
171
172    /// 设置检测文本内容
173    pub fn content(mut self, content: impl Into<String>) -> Self {
174        self.content = Some(content.into());
175        self
176    }
177
178    /// 设置接口版本号(通常为2)
179    pub fn version(mut self, version: u32) -> Self {
180        self.version = Some(version);
181        self
182    }
183
184    /// 设置场景
185    pub fn scene(mut self, scene: Scene) -> Self {
186        self.scene = Some(scene);
187        self
188    }
189
190    /// 设置用户openid
191    pub fn openid(mut self, openid: impl Into<String>) -> Self {
192        self.openid = Some(openid.into());
193        self
194    }
195
196    /// 设置文本标题
197    pub fn title(mut self, title: impl Into<String>) -> Self {
198        self.title = Some(title.into());
199        self
200    }
201
202    /// 设置用户昵称
203    pub fn nickname(mut self, nickname: impl Into<String>) -> Self {
204        self.nickname = Some(nickname.into());
205        self
206    }
207
208    /// 设置个性签名(仅在资料场景有效)
209    pub fn signature(mut self, signature: impl Into<String>) -> Self {
210        self.signature = Some(signature.into());
211        self
212    }
213
214    /// 构建 Args,验证必填字段
215    pub fn build(self) -> Result<Args> {
216        let content = self
217            .content
218            .ok_or(Error::InvalidParameter("content 是必填参数".to_string()))?;
219        let version = self.version.unwrap_or(2); // 默认版本为2
220        let scene = self
221            .scene
222            .ok_or(Error::InvalidParameter("scene 是必填参数".to_string()))?;
223        let openid = self
224            .openid
225            .ok_or(Error::InvalidParameter("openid 是必填参数".to_string()))?;
226
227        // 内容长度验证
228        if content.len() > 2500 {
229            return Err(Error::InvalidParameter(
230                "content 长度不能超过2500字".to_string(),
231            ));
232        }
233
234        // 场景与签名的关联验证
235        if self.signature.is_some() && scene != Scene::Profile {
236            return Err(Error::InvalidParameter(
237                "signature 仅在资料场景(scene=1)下有效".to_string(),
238            ));
239        }
240
241        Ok(Args {
242            content,
243            version,
244            scene,
245            openid,
246            title: self.title,
247            nickname: self.nickname,
248            signature: self.signature,
249        })
250    }
251}
252
253// 为 Args 实现便捷的构建方法
254impl Args {
255    /// 创建构建器
256    pub fn builder() -> ArgsBuilder {
257        ArgsBuilder::new()
258    }
259
260    /// 快速创建基本参数(使用默认版本2)
261    pub fn new(content: impl Into<String>, scene: Scene, openid: impl Into<String>) -> Self {
262        Self {
263            content: content.into(),
264            version: 2,
265            scene,
266            openid: openid.into(),
267            title: None,
268            nickname: None,
269            signature: None,
270        }
271    }
272
273    /// 检查是否为资料场景
274    pub fn is_profile_scene(&self) -> bool {
275        self.scene == Scene::Profile
276    }
277
278    /// 获取内容长度
279    pub fn content_length(&self) -> usize {
280        self.content.len()
281    }
282
283    /// 验证参数是否有效
284    pub fn validate(&self) -> Result<()> {
285        if self.content.len() > 2500 {
286            return Err(Error::InvalidParameter(
287                "content 长度不能超过2500字".to_string(),
288            ));
289        }
290
291        if self.signature.is_some() && !self.is_profile_scene() {
292            return Err(Error::InvalidParameter(
293                "signature 仅在资料场景(scene=1)下有效".to_string(),
294            ));
295        }
296
297        Ok(())
298    }
299}
300
301// Scene 枚举的便捷方法
302impl Scene {
303    /// 从数值创建场景
304    pub fn from_value(value: u32) -> Option<Self> {
305        match value {
306            1 => Some(Scene::Profile),
307            2 => Some(Scene::Comment),
308            3 => Some(Scene::Forum),
309            4 => Some(Scene::SocialLog),
310            _ => None,
311        }
312    }
313
314    /// 获取场景描述
315    pub fn description(&self) -> &'static str {
316        match self {
317            Scene::Profile => "资料",
318            Scene::Comment => "评论",
319            Scene::Forum => "论坛",
320            Scene::SocialLog => "社交日志",
321        }
322    }
323}
324
325/// 详细检测结果
326///
327/// 包含具体的检测策略、建议、标签和置信度等信息。
328///
329/// # 字段说明
330///
331/// - `strategy`: 使用的检测策略类型
332/// - `errcode`: 错误码,0表示该项结果有效
333/// - `suggest`: 检测建议
334/// - `label`: 命中的标签类型
335/// - `keyword`: 命中的自定义关键词
336/// - `prob`: 置信度,0-100,越高越可能属于当前标签
337#[derive(Debug, Deserialize, Serialize, Clone)]
338pub struct DetailResult {
339    /// 策略类型
340    pub strategy: String,
341    /// 错误码,仅当该值为0时,该项结果有效
342    pub errcode: i32,
343    /// 建议
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub suggest: Option<Suggest>,
346    /// 命中标签枚举值(可能不存在)
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub label: Option<Label>,
349    /// 命中的自定义关键词(可能不存在)
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub keyword: Option<String>,
352    /// 0-100,代表置信度,越高代表越有可能属于当前返回的标签(label)(可能不存在)
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub prob: Option<f64>,
355}
356
357/// 综合结果
358#[derive(Debug, Deserialize, Serialize, Clone)]
359pub struct ComprehensiveResult {
360    /// 建议
361    pub suggest: Suggest,
362    /// 命中标签枚举值
363    pub label: Label,
364}
365
366/// 内容安全检测返回结果
367///
368/// 包含内容安全检测的完整结果信息。
369///
370/// # 字段说明
371///
372/// - `errcode`: 全局错误码,0表示请求成功
373/// - `errmsg`: 错误信息
374/// - `detail`: 详细的检测结果列表
375/// - `result`: 综合检测结果
376/// - `trace_id`: 唯一请求标识,用于问题排查
377///
378/// # 示例
379///
380/// ```no_run
381/// use wechat_minapp::minapp_security::MsgSecCheckResult;
382///
383/// # fn process_result(result: MsgSecCheckResult) {
384/// if result.is_success() {
385///     if result.is_pass() {
386///         println!("内容安全");
387///     } else if result.needs_review() {
388///         println!("需要人工审核");
389///     } else {
390///         println!("内容有风险");
391///     }
392///     
393///     for detail in result.get_valid_details() {
394///         println!("策略: {}, 置信度: {:?}", detail.strategy, detail.prob);
395///     }
396/// }
397/// # }
398/// ```
399#[derive(Debug, Deserialize, Serialize, Clone)]
400pub struct MsgSecCheckResult {
401    /// 错误码
402    pub errcode: i32,
403    /// 错误信息
404    pub errmsg: String,
405    /// 详细检测结果
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub detail: Option<Vec<DetailResult>>,
408    /// 综合结果
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub result: Option<ComprehensiveResult>,
411    /// 唯一请求标识,标记单次请求
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub trace_id: Option<String>,
414}
415
416// 为 MsgSecCheckResult 实现一些便捷方法
417impl MsgSecCheckResult {
418    /// 检查请求是否成功(errcode 为 0)
419    pub fn is_success(&self) -> bool {
420        self.errcode == 0
421    }
422
423    /// 获取综合建议
424    pub fn get_suggest(&self) -> Option<&Suggest> {
425        self.result.as_ref().map(|r| &r.suggest)
426    }
427
428    /// 获取综合标签
429    pub fn get_label(&self) -> Option<&Label> {
430        self.result.as_ref().map(|r| &r.label)
431    }
432
433    /// 检查是否通过
434    pub fn is_pass(&self) -> bool {
435        self.get_suggest().map(|s| s.is_pass()).unwrap_or(false)
436    }
437
438    /// 检查是否有风险
439    pub fn is_risky(&self) -> bool {
440        self.get_suggest().map(|s| s.is_risky()).unwrap_or(false)
441    }
442
443    /// 检查是否需要审核
444    pub fn needs_review(&self) -> bool {
445        self.get_suggest()
446            .map(|s| s.needs_review())
447            .unwrap_or(false)
448    }
449
450    /// 获取有效的详细检测结果(errcode 为 0 的项)
451    pub fn get_valid_details(&self) -> Vec<&DetailResult> {
452        self.detail
453            .as_ref()
454            .map(|details| details.iter().filter(|d| d.errcode == 0).collect())
455            .unwrap_or_default()
456    }
457}
458
459impl Client {
460    /// 内容安全检测
461    ///
462    /// 对文本内容进行安全检测,识别违规内容。
463    ///
464    /// # 参数
465    ///
466    /// - `args`: 内容安全检测参数
467    ///
468    /// # 返回
469    ///
470    /// 成功返回 `Ok(MsgSecCheckResult)`,包含检测结果
471    ///
472    /// # 错误
473    ///
474    /// - 参数验证错误
475    /// - 网络错误
476    /// - 微信 API 返回错误
477    ///
478    /// # 示例
479    ///
480    /// ```no_run
481    /// use wechat_minapp::{Client, minapp_security::{Args, Scene}};
482    ///
483    /// #[tokio::main]
484    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
485    ///     let client = Client::new("app_id", "secret");
486    ///     
487    ///     let args = Args::builder()
488    ///         .content("需要检测的文本内容")
489    ///         .scene(Scene::Comment)
490    ///         .openid("user_openid")
491    ///         .build()?;
492    ///     
493    ///     let result = client.msg_sec_check(&args).await?;
494    ///     
495    ///     match (result.is_pass(), result.needs_review(), result.is_risky()) {
496    ///         (true, _, _) => println!("内容安全,可以发布"),
497    ///         (_, true, _) => println!("内容需要人工审核"),
498    ///         (_, _, true) => println!("内容有风险,建议修改"),
499    ///         _ => println!("未知状态"),
500    ///     }
501    ///     
502    ///     Ok(())
503    /// }
504    /// ```
505    ///
506    /// # API 文档
507    ///
508    /// [文本安全检测](https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/sec-center/sec-check/msgSecCheck.html)
509    pub async fn msg_sec_check(&self, args: &Args) -> Result<MsgSecCheckResult> {
510        debug!("msg_sec_check args: {:?}", &args);
511
512        // 验证参数
513        args.validate()?;
514        let access_token = self.access_token().await?;
515        let mut query = HashMap::new();
516        let mut body = HashMap::new();
517        let version = args.version.to_string();
518        let scene = (args.scene as u32).to_string();
519        // URL 参数:access_token
520        query.insert("access_token", &access_token);
521
522        // Body 参数
523        body.insert("content", &args.content);
524        body.insert("version", &version);
525        body.insert("scene", &scene);
526        body.insert("openid", &args.openid);
527
528        if let Some(title) = &args.title {
529            body.insert("title", title);
530        }
531
532        if let Some(nickname) = &args.nickname {
533            body.insert("nickname", nickname);
534        }
535
536        if let Some(signature) = &args.signature {
537            body.insert("signature", signature);
538        }
539
540        let mut headers = HeaderMap::new();
541        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
542
543        let response = self
544            .request()
545            .post(constants::MSG_SEC_CHECK_END_POINT)
546            .headers(headers)
547            .query(&query)
548            .json(&body)
549            .send()
550            .await?;
551
552        debug!("msg_sec_check response: {:#?}", response);
553
554        if response.status().is_success() {
555            let response_text = response.text().await?;
556            debug!("msg_sec_check response body: {}", response_text);
557
558            let result: MsgSecCheckResult = serde_json::from_str(&response_text)?;
559
560            if result.is_success() {
561                Ok(result)
562            } else {
563                // 微信API返回错误
564                Err(Error::InternalServer(format!(
565                    "微信内容安全检测API错误: {} - {}",
566                    result.errcode, result.errmsg
567                )))
568            }
569        } else {
570            // HTTP 请求错误
571            Err(Error::InternalServer(response.text().await?))
572        }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn test_args_builder() {
582        let args = Args::builder()
583            .content("测试内容")
584            .scene(Scene::Comment)
585            .openid("test_openid")
586            .build()
587            .unwrap();
588
589        assert_eq!(args.content, "测试内容");
590        assert_eq!(args.version, 2);
591        assert_eq!(args.scene, Scene::Comment);
592        assert_eq!(args.openid, "test_openid");
593    }
594
595    #[test]
596    fn test_args_builder_validation() {
597        // 测试缺少必填参数
598        let result = Args::builder()
599            .scene(Scene::Comment)
600            .openid("test_openid")
601            .build();
602        assert!(result.is_err());
603
604        // 测试内容超长
605        let long_content = "a".repeat(2501);
606        let result = Args::builder()
607            .content(long_content)
608            .scene(Scene::Comment)
609            .openid("openid")
610            .build();
611        assert!(result.is_err());
612
613        // 测试场景与签名验证
614        let result = Args::builder()
615            .content("内容")
616            .scene(Scene::Comment)
617            .openid("openid")
618            .signature("签名")
619            .build();
620        assert!(result.is_err());
621    }
622
623    #[test]
624    fn test_scene_enum() {
625        assert_eq!(Scene::from_value(1), Some(Scene::Profile));
626        assert_eq!(Scene::Profile.description(), "资料");
627        assert_eq!(Scene::Profile as u32, 1);
628    }
629
630    #[test]
631    fn test_msg_sec_check_result() {
632        let json = r#"
633        {
634            "errcode": 0,
635            "errmsg": "ok",
636            "detail": [
637                {
638                    "strategy": "content_model",
639                    "errcode": 0,
640                    "suggest": "pass",
641                    "label": 100,
642                    "prob": 90.5
643                }
644            ],
645            "result": {
646                "suggest": "pass",
647                "label": 100
648            },
649            "trace_id": "test_trace_id"
650        }"#;
651
652        let result: MsgSecCheckResult = serde_json::from_str(json).unwrap();
653
654        assert!(result.is_success());
655        assert!(result.is_pass());
656        assert!(!result.is_risky());
657        assert!(!result.needs_review());
658        assert_eq!(result.get_valid_details().len(), 1);
659        assert_eq!(result.trace_id, Some("test_trace_id".to_string()));
660    }
661
662    #[test]
663    fn test_msg_sec_check_result_with_risk() {
664        let json = r#"
665        {
666            "errcode": 0,
667            "errmsg": "ok",
668            "detail": [
669                {
670                    "strategy": "content_model",
671                    "errcode": 0,
672                    "suggest": "risky",
673                    "label": 20001,
674                    "keyword": "敏感词",
675                    "prob": 95.0
676                }
677            ],
678            "result": {
679                "suggest": "risky",
680                "label": 20001
681            }
682        }"#;
683
684        let result: MsgSecCheckResult = serde_json::from_str(json).unwrap();
685
686        assert!(result.is_success());
687        assert!(!result.is_pass());
688        assert!(result.is_risky());
689        assert!(!result.needs_review());
690        assert_eq!(
691            result.get_valid_details()[0].keyword,
692            Some("敏感词".to_string())
693        );
694    }
695}