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