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