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