Skip to main content

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