wechat_minapp/minapp_security/
msg_sec_check.rs1use super::{Label, Suggest};
51use crate::{Result, client::Client, constants, error::Error};
52use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
53use serde::{Deserialize, Serialize};
54use std::collections::HashMap;
55use tracing::debug;
56
57#[derive(Debug, Serialize, Clone, Copy, PartialEq)]
82pub enum Scene {
83 Profile = 1,
85 Comment = 2,
87 Forum = 3,
89 SocialLog = 4,
91}
92
93#[derive(Debug, Serialize, Clone)]
117pub struct Args {
118 pub content: String,
120 pub version: u32,
122 pub scene: Scene,
124 pub openid: String,
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub title: Option<String>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub nickname: Option<String>,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub signature: Option<String>,
135}
136
137#[derive(Debug, Default)]
156pub struct ArgsBuilder {
157 content: Option<String>,
158 version: Option<u32>,
159 scene: Option<Scene>,
160 openid: Option<String>,
161 title: Option<String>,
162 nickname: Option<String>,
163 signature: Option<String>,
164}
165
166impl ArgsBuilder {
167 pub fn new() -> Self {
169 Self::default()
170 }
171
172 pub fn content(mut self, content: impl Into<String>) -> Self {
174 self.content = Some(content.into());
175 self
176 }
177
178 pub fn version(mut self, version: u32) -> Self {
180 self.version = Some(version);
181 self
182 }
183
184 pub fn scene(mut self, scene: Scene) -> Self {
186 self.scene = Some(scene);
187 self
188 }
189
190 pub fn openid(mut self, openid: impl Into<String>) -> Self {
192 self.openid = Some(openid.into());
193 self
194 }
195
196 pub fn title(mut self, title: impl Into<String>) -> Self {
198 self.title = Some(title.into());
199 self
200 }
201
202 pub fn nickname(mut self, nickname: impl Into<String>) -> Self {
204 self.nickname = Some(nickname.into());
205 self
206 }
207
208 pub fn signature(mut self, signature: impl Into<String>) -> Self {
210 self.signature = Some(signature.into());
211 self
212 }
213
214 pub fn build(self) -> Result<Args> {
216 let content = self
217 .content
218 .ok_or(Error::InvalidParameter("content 是必填参数".to_string()))?;
219 let version = self.version.unwrap_or(2); let scene = self
221 .scene
222 .ok_or(Error::InvalidParameter("scene 是必填参数".to_string()))?;
223 let openid = self
224 .openid
225 .ok_or(Error::InvalidParameter("openid 是必填参数".to_string()))?;
226
227 if content.len() > 2500 {
229 return Err(Error::InvalidParameter(
230 "content 长度不能超过2500字".to_string(),
231 ));
232 }
233
234 if self.signature.is_some() && scene != Scene::Profile {
236 return Err(Error::InvalidParameter(
237 "signature 仅在资料场景(scene=1)下有效".to_string(),
238 ));
239 }
240
241 Ok(Args {
242 content,
243 version,
244 scene,
245 openid,
246 title: self.title,
247 nickname: self.nickname,
248 signature: self.signature,
249 })
250 }
251}
252
253impl Args {
255 pub fn builder() -> ArgsBuilder {
257 ArgsBuilder::new()
258 }
259
260 pub fn new(content: impl Into<String>, scene: Scene, openid: impl Into<String>) -> Self {
262 Self {
263 content: content.into(),
264 version: 2,
265 scene,
266 openid: openid.into(),
267 title: None,
268 nickname: None,
269 signature: None,
270 }
271 }
272
273 pub fn is_profile_scene(&self) -> bool {
275 self.scene == Scene::Profile
276 }
277
278 pub fn content_length(&self) -> usize {
280 self.content.len()
281 }
282
283 pub fn validate(&self) -> Result<()> {
285 if self.content.len() > 2500 {
286 return Err(Error::InvalidParameter(
287 "content 长度不能超过2500字".to_string(),
288 ));
289 }
290
291 if self.signature.is_some() && !self.is_profile_scene() {
292 return Err(Error::InvalidParameter(
293 "signature 仅在资料场景(scene=1)下有效".to_string(),
294 ));
295 }
296
297 Ok(())
298 }
299}
300
301impl Scene {
303 pub fn from_value(value: u32) -> Option<Self> {
305 match value {
306 1 => Some(Scene::Profile),
307 2 => Some(Scene::Comment),
308 3 => Some(Scene::Forum),
309 4 => Some(Scene::SocialLog),
310 _ => None,
311 }
312 }
313
314 pub fn description(&self) -> &'static str {
316 match self {
317 Scene::Profile => "资料",
318 Scene::Comment => "评论",
319 Scene::Forum => "论坛",
320 Scene::SocialLog => "社交日志",
321 }
322 }
323}
324
325#[derive(Debug, Deserialize, Serialize, Clone)]
338pub struct DetailResult {
339 pub strategy: String,
341 pub errcode: i32,
343 #[serde(skip_serializing_if = "Option::is_none")]
345 pub suggest: Option<Suggest>,
346 #[serde(skip_serializing_if = "Option::is_none")]
348 pub label: Option<Label>,
349 #[serde(skip_serializing_if = "Option::is_none")]
351 pub keyword: Option<String>,
352 #[serde(skip_serializing_if = "Option::is_none")]
354 pub prob: Option<f64>,
355}
356
357#[derive(Debug, Deserialize, Serialize, Clone)]
359pub struct ComprehensiveResult {
360 pub suggest: Suggest,
362 pub label: Label,
364}
365
366#[derive(Debug, Deserialize, Serialize, Clone)]
400pub struct MsgSecCheckResult {
401 pub errcode: i32,
403 pub errmsg: String,
405 #[serde(skip_serializing_if = "Option::is_none")]
407 pub detail: Option<Vec<DetailResult>>,
408 #[serde(skip_serializing_if = "Option::is_none")]
410 pub result: Option<ComprehensiveResult>,
411 #[serde(skip_serializing_if = "Option::is_none")]
413 pub trace_id: Option<String>,
414}
415
416impl MsgSecCheckResult {
418 pub fn is_success(&self) -> bool {
420 self.errcode == 0
421 }
422
423 pub fn get_suggest(&self) -> Option<&Suggest> {
425 self.result.as_ref().map(|r| &r.suggest)
426 }
427
428 pub fn get_label(&self) -> Option<&Label> {
430 self.result.as_ref().map(|r| &r.label)
431 }
432
433 pub fn is_pass(&self) -> bool {
435 self.get_suggest().map(|s| s.is_pass()).unwrap_or(false)
436 }
437
438 pub fn is_risky(&self) -> bool {
440 self.get_suggest().map(|s| s.is_risky()).unwrap_or(false)
441 }
442
443 pub fn needs_review(&self) -> bool {
445 self.get_suggest()
446 .map(|s| s.needs_review())
447 .unwrap_or(false)
448 }
449
450 pub fn get_valid_details(&self) -> Vec<&DetailResult> {
452 self.detail
453 .as_ref()
454 .map(|details| details.iter().filter(|d| d.errcode == 0).collect())
455 .unwrap_or_default()
456 }
457}
458
459impl Client {
460 pub async fn msg_sec_check(&self, args: &Args) -> Result<MsgSecCheckResult> {
510 debug!("msg_sec_check args: {:?}", &args);
511
512 args.validate()?;
514 let access_token = self.access_token().await?;
515 let mut query = HashMap::new();
516 let mut body = HashMap::new();
517 let version = args.version.to_string();
518 let scene = (args.scene as u32).to_string();
519 query.insert("access_token", &access_token);
521
522 body.insert("content", &args.content);
524 body.insert("version", &version);
525 body.insert("scene", &scene);
526 body.insert("openid", &args.openid);
527
528 if let Some(title) = &args.title {
529 body.insert("title", title);
530 }
531
532 if let Some(nickname) = &args.nickname {
533 body.insert("nickname", nickname);
534 }
535
536 if let Some(signature) = &args.signature {
537 body.insert("signature", signature);
538 }
539
540 let mut headers = HeaderMap::new();
541 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
542
543 let response = self
544 .request()
545 .post(constants::MSG_SEC_CHECK_END_POINT)
546 .headers(headers)
547 .query(&query)
548 .json(&body)
549 .send()
550 .await?;
551
552 debug!("msg_sec_check response: {:#?}", response);
553
554 if response.status().is_success() {
555 let response_text = response.text().await?;
556 debug!("msg_sec_check response body: {}", response_text);
557
558 let result: MsgSecCheckResult = serde_json::from_str(&response_text)?;
559
560 if result.is_success() {
561 Ok(result)
562 } else {
563 Err(Error::InternalServer(format!(
565 "微信内容安全检测API错误: {} - {}",
566 result.errcode, result.errmsg
567 )))
568 }
569 } else {
570 Err(Error::InternalServer(response.text().await?))
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 let result = Args::builder()
599 .scene(Scene::Comment)
600 .openid("test_openid")
601 .build();
602 assert!(result.is_err());
603
604 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 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}