wechat_minapp/minapp_security/
msg_sec_check.rs1use 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#[derive(Debug, Serialize, Clone, Copy, PartialEq)]
85pub enum Scene {
86 Profile = 1,
88 Comment = 2,
90 Forum = 3,
92 SocialLog = 4,
94}
95
96
97#[derive(Debug, Serialize, Clone)]
121pub struct Args {
122 pub content: String,
124 pub version: u32,
126 pub scene: Scene,
128 pub openid: String,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub title: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub nickname: Option<String>,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub signature: Option<String>,
139}
140
141#[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 pub fn new() -> Self {
173 Self::default()
174 }
175
176 pub fn content(mut self, content: impl Into<String>) -> Self {
178 self.content = Some(content.into());
179 self
180 }
181
182 pub fn version(mut self, version: u32) -> Self {
184 self.version = Some(version);
185 self
186 }
187
188 pub fn scene(mut self, scene: Scene) -> Self {
190 self.scene = Some(scene);
191 self
192 }
193
194 pub fn openid(mut self, openid: impl Into<String>) -> Self {
196 self.openid = Some(openid.into());
197 self
198 }
199
200 pub fn title(mut self, title: impl Into<String>) -> Self {
202 self.title = Some(title.into());
203 self
204 }
205
206 pub fn nickname(mut self, nickname: impl Into<String>) -> Self {
208 self.nickname = Some(nickname.into());
209 self
210 }
211
212 pub fn signature(mut self, signature: impl Into<String>) -> Self {
214 self.signature = Some(signature.into());
215 self
216 }
217
218 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); 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 if content.len() > 2500 {
233 return Err(Error::InvalidParameter(
234 "content 长度不能超过2500字".to_string(),
235 ));
236 }
237
238 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
257impl Args {
259 pub fn builder() -> ArgsBuilder {
261 ArgsBuilder::new()
262 }
263
264 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 pub fn is_profile_scene(&self) -> bool {
279 self.scene == Scene::Profile
280 }
281
282 pub fn content_length(&self) -> usize {
284 self.content.len()
285 }
286
287 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
305impl Scene {
307 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 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#[derive(Debug, Deserialize, Serialize, Clone)]
343pub struct DetailResult {
344 pub strategy: String,
346 pub errcode: i32,
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub suggest: Option<Suggest>,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub label: Option<Label>,
354 #[serde(skip_serializing_if = "Option::is_none")]
356 pub keyword: Option<String>,
357 #[serde(skip_serializing_if = "Option::is_none")]
359 pub prob: Option<f64>,
360}
361
362#[derive(Debug, Deserialize, Serialize, Clone)]
364pub struct ComprehensiveResult {
365 pub suggest: Suggest,
367 pub label: Label,
369}
370
371
372#[derive(Debug, Deserialize, Serialize, Clone)]
406pub struct MsgSecCheckResult {
407 pub errcode: i32,
409 pub errmsg: String,
411 #[serde(skip_serializing_if = "Option::is_none")]
413 pub detail: Option<Vec<DetailResult>>,
414 #[serde(skip_serializing_if = "Option::is_none")]
416 pub result: Option<ComprehensiveResult>,
417 #[serde(skip_serializing_if = "Option::is_none")]
419 pub trace_id: Option<String>,
420}
421
422impl MsgSecCheckResult {
424 pub fn is_success(&self) -> bool {
426 self.errcode == 0
427 }
428
429 pub fn get_suggest(&self) -> Option<&Suggest> {
431 self.result.as_ref().map(|r| &r.suggest)
432 }
433
434 pub fn get_label(&self) -> Option<&Label> {
436 self.result.as_ref().map(|r| &r.label)
437 }
438
439 pub fn is_pass(&self) -> bool {
441 self.get_suggest().map(|s| s.is_pass()).unwrap_or(false)
442 }
443
444 pub fn is_risky(&self) -> bool {
446 self.get_suggest().map(|s| s.is_risky()).unwrap_or(false)
447 }
448
449 pub fn needs_review(&self) -> bool {
451 self.get_suggest()
452 .map(|s| s.needs_review())
453 .unwrap_or(false)
454 }
455
456 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 pub async fn msg_sec_check(&self, args: &Args) -> Result<MsgSecCheckResult> {
516 debug!("msg_sec_check args: {:?}", &args);
517
518 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 query.insert("access_token", &access_token);
527
528 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 Err(Error::InternalServer(format!(
571 "微信内容安全检测API错误: {} - {}",
572 result.errcode, result.errmsg
573 )))
574 }
575 } else {
576 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 let result = Args::builder()
605 .scene(Scene::Comment)
606 .openid("test_openid")
607 .build();
608 assert!(result.is_err());
609
610 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 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}