wechat_minapp/minapp_security/
msg_sec_check.rs1use super::{Label, MinappSecurity, Suggest};
53use crate::{Result, constants, error::Error};
54use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
55use serde::{Deserialize, Serialize};
56use std::collections::HashMap;
57use tracing::debug;
58
59#[derive(Debug, Serialize, Clone, Copy, PartialEq)]
84pub enum Scene {
85 Profile = 1,
87 Comment = 2,
89 Forum = 3,
91 SocialLog = 4,
93}
94
95#[derive(Debug, Serialize, Clone)]
119pub struct Args {
120 pub content: String,
122 pub version: u32,
124 pub scene: Scene,
126 pub openid: String,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub title: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub nickname: Option<String>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub signature: Option<String>,
137}
138
139#[derive(Debug, Default)]
158pub struct ArgsBuilder {
159 content: Option<String>,
160 version: Option<u32>,
161 scene: Option<Scene>,
162 openid: Option<String>,
163 title: Option<String>,
164 nickname: Option<String>,
165 signature: Option<String>,
166}
167
168impl ArgsBuilder {
169 pub fn new() -> Self {
171 Self::default()
172 }
173
174 pub fn content(mut self, content: impl Into<String>) -> Self {
176 self.content = Some(content.into());
177 self
178 }
179
180 pub fn version(mut self, version: u32) -> Self {
182 self.version = Some(version);
183 self
184 }
185
186 pub fn scene(mut self, scene: Scene) -> Self {
188 self.scene = Some(scene);
189 self
190 }
191
192 pub fn openid(mut self, openid: impl Into<String>) -> Self {
194 self.openid = Some(openid.into());
195 self
196 }
197
198 pub fn title(mut self, title: impl Into<String>) -> Self {
200 self.title = Some(title.into());
201 self
202 }
203
204 pub fn nickname(mut self, nickname: impl Into<String>) -> Self {
206 self.nickname = Some(nickname.into());
207 self
208 }
209
210 pub fn signature(mut self, signature: impl Into<String>) -> Self {
212 self.signature = Some(signature.into());
213 self
214 }
215
216 pub fn build(self) -> Result<Args> {
218 let content = self
219 .content
220 .ok_or(Error::InvalidParameter("content 是必填参数".to_string()))?;
221 let version = self.version.unwrap_or(2); let scene = self
223 .scene
224 .ok_or(Error::InvalidParameter("scene 是必填参数".to_string()))?;
225 let openid = self
226 .openid
227 .ok_or(Error::InvalidParameter("openid 是必填参数".to_string()))?;
228
229 if content.len() > 2500 {
231 return Err(Error::InvalidParameter(
232 "content 长度不能超过2500字".to_string(),
233 ));
234 }
235
236 if self.signature.is_some() && scene != Scene::Profile {
238 return Err(Error::InvalidParameter(
239 "signature 仅在资料场景(scene=1)下有效".to_string(),
240 ));
241 }
242
243 Ok(Args {
244 content,
245 version,
246 scene,
247 openid,
248 title: self.title,
249 nickname: self.nickname,
250 signature: self.signature,
251 })
252 }
253}
254
255impl Args {
257 pub fn builder() -> ArgsBuilder {
259 ArgsBuilder::new()
260 }
261
262 pub fn new(content: impl Into<String>, scene: Scene, openid: impl Into<String>) -> Self {
264 Self {
265 content: content.into(),
266 version: 2,
267 scene,
268 openid: openid.into(),
269 title: None,
270 nickname: None,
271 signature: None,
272 }
273 }
274
275 pub fn is_profile_scene(&self) -> bool {
277 self.scene == Scene::Profile
278 }
279
280 pub fn content_length(&self) -> usize {
282 self.content.len()
283 }
284
285 pub fn validate(&self) -> Result<()> {
287 if self.content.len() > 2500 {
288 return Err(Error::InvalidParameter(
289 "content 长度不能超过2500字".to_string(),
290 ));
291 }
292
293 if self.signature.is_some() && !self.is_profile_scene() {
294 return Err(Error::InvalidParameter(
295 "signature 仅在资料场景(scene=1)下有效".to_string(),
296 ));
297 }
298
299 Ok(())
300 }
301}
302
303impl Scene {
305 pub fn from_value(value: u32) -> Option<Self> {
307 match value {
308 1 => Some(Scene::Profile),
309 2 => Some(Scene::Comment),
310 3 => Some(Scene::Forum),
311 4 => Some(Scene::SocialLog),
312 _ => None,
313 }
314 }
315
316 pub fn description(&self) -> &'static str {
318 match self {
319 Scene::Profile => "资料",
320 Scene::Comment => "评论",
321 Scene::Forum => "论坛",
322 Scene::SocialLog => "社交日志",
323 }
324 }
325}
326
327#[derive(Debug, Deserialize, Serialize, Clone)]
340pub struct DetailResult {
341 pub strategy: String,
343 pub errcode: i32,
345 #[serde(skip_serializing_if = "Option::is_none")]
347 pub suggest: Option<Suggest>,
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub label: Option<Label>,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub keyword: Option<String>,
354 #[serde(skip_serializing_if = "Option::is_none")]
356 pub prob: Option<f64>,
357}
358
359#[derive(Debug, Deserialize, Serialize, Clone)]
361pub struct ComprehensiveResult {
362 pub suggest: Suggest,
364 pub label: Label,
366}
367
368#[derive(Debug, Deserialize, Serialize, Clone)]
402pub struct MsgSecCheckResult {
403 pub errcode: i32,
405 pub errmsg: String,
407 #[serde(skip_serializing_if = "Option::is_none")]
409 pub detail: Option<Vec<DetailResult>>,
410 #[serde(skip_serializing_if = "Option::is_none")]
412 pub result: Option<ComprehensiveResult>,
413 #[serde(skip_serializing_if = "Option::is_none")]
415 pub trace_id: Option<String>,
416}
417
418impl MsgSecCheckResult {
420 pub fn is_success(&self) -> bool {
422 self.errcode == 0
423 }
424
425 pub fn get_suggest(&self) -> Option<&Suggest> {
427 self.result.as_ref().map(|r| &r.suggest)
428 }
429
430 pub fn get_label(&self) -> Option<&Label> {
432 self.result.as_ref().map(|r| &r.label)
433 }
434
435 pub fn is_pass(&self) -> bool {
437 self.get_suggest().map(|s| s.is_pass()).unwrap_or(false)
438 }
439
440 pub fn is_risky(&self) -> bool {
442 self.get_suggest().map(|s| s.is_risky()).unwrap_or(false)
443 }
444
445 pub fn needs_review(&self) -> bool {
447 self.get_suggest()
448 .map(|s| s.needs_review())
449 .unwrap_or(false)
450 }
451
452 pub fn get_valid_details(&self) -> Vec<&DetailResult> {
454 self.detail
455 .as_ref()
456 .map(|details| details.iter().filter(|d| d.errcode == 0).collect())
457 .unwrap_or_default()
458 }
459}
460
461impl MinappSecurity {
462 pub async fn msg_sec_check(&self, args: &Args) -> Result<MsgSecCheckResult> {
513 debug!("msg_sec_check args: {:?}", &args);
514
515 args.validate()?;
517 let client = &self.client.inner_client().client;
518 let access_token = &self.client.token().await?;
519 let mut query = HashMap::new();
520 let mut body = HashMap::new();
521 let version = args.version.to_string();
522 let scene = (args.scene as u32).to_string();
523 query.insert("access_token", &access_token);
525
526 body.insert("content", &args.content);
528 body.insert("version", &version);
529 body.insert("scene", &scene);
530 body.insert("openid", &args.openid);
531
532 if let Some(title) = &args.title {
533 body.insert("title", title);
534 }
535
536 if let Some(nickname) = &args.nickname {
537 body.insert("nickname", nickname);
538 }
539
540 if let Some(signature) = &args.signature {
541 body.insert("signature", signature);
542 }
543
544 let mut headers = HeaderMap::new();
545 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
546
547 let response = client
548 .post(constants::MSG_SEC_CHECK_END_POINT)
549 .headers(headers)
550 .query(&query)
551 .json(&body)
552 .send()
553 .await?;
554
555 debug!("msg_sec_check response: {:#?}", response);
556
557 if response.status().is_success() {
558 let response_text = response.text().await?;
559 debug!("msg_sec_check response body: {}", response_text);
560
561 let result: MsgSecCheckResult = serde_json::from_str(&response_text)?;
562
563 if result.is_success() {
564 Ok(result)
565 } else {
566 Err(Error::InternalServer(format!(
568 "微信内容安全检测API错误: {} - {}",
569 result.errcode, result.errmsg
570 )))
571 }
572 } else {
573 Err(Error::InternalServer(response.text().await?))
575 }
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn test_args_builder() {
585 let args = Args::builder()
586 .content("测试内容")
587 .scene(Scene::Comment)
588 .openid("test_openid")
589 .build()
590 .unwrap();
591
592 assert_eq!(args.content, "测试内容");
593 assert_eq!(args.version, 2);
594 assert_eq!(args.scene, Scene::Comment);
595 assert_eq!(args.openid, "test_openid");
596 }
597
598 #[test]
599 fn test_args_builder_validation() {
600 let result = Args::builder()
602 .scene(Scene::Comment)
603 .openid("test_openid")
604 .build();
605 assert!(result.is_err());
606
607 let long_content = "a".repeat(2501);
609 let result = Args::builder()
610 .content(long_content)
611 .scene(Scene::Comment)
612 .openid("openid")
613 .build();
614 assert!(result.is_err());
615
616 let result = Args::builder()
618 .content("内容")
619 .scene(Scene::Comment)
620 .openid("openid")
621 .signature("签名")
622 .build();
623 assert!(result.is_err());
624 }
625
626 #[test]
627 fn test_scene_enum() {
628 assert_eq!(Scene::from_value(1), Some(Scene::Profile));
629 assert_eq!(Scene::Profile.description(), "资料");
630 assert_eq!(Scene::Profile as u32, 1);
631 }
632
633 #[test]
634 fn test_msg_sec_check_result() {
635 let json = r#"
636 {
637 "errcode": 0,
638 "errmsg": "ok",
639 "detail": [
640 {
641 "strategy": "content_model",
642 "errcode": 0,
643 "suggest": "pass",
644 "label": 100,
645 "prob": 90.5
646 }
647 ],
648 "result": {
649 "suggest": "pass",
650 "label": 100
651 },
652 "trace_id": "test_trace_id"
653 }"#;
654
655 let result: MsgSecCheckResult = serde_json::from_str(json).unwrap();
656
657 assert!(result.is_success());
658 assert!(result.is_pass());
659 assert!(!result.is_risky());
660 assert!(!result.needs_review());
661 assert_eq!(result.get_valid_details().len(), 1);
662 assert_eq!(result.trace_id, Some("test_trace_id".to_string()));
663 }
664
665 #[test]
666 fn test_msg_sec_check_result_with_risk() {
667 let json = r#"
668 {
669 "errcode": 0,
670 "errmsg": "ok",
671 "detail": [
672 {
673 "strategy": "content_model",
674 "errcode": 0,
675 "suggest": "risky",
676 "label": 20001,
677 "keyword": "敏感词",
678 "prob": 95.0
679 }
680 ],
681 "result": {
682 "suggest": "risky",
683 "label": 20001
684 }
685 }"#;
686
687 let result: MsgSecCheckResult = serde_json::from_str(json).unwrap();
688
689 assert!(result.is_success());
690 assert!(!result.is_pass());
691 assert!(result.is_risky());
692 assert!(!result.needs_review());
693 assert_eq!(
694 result.get_valid_details()[0].keyword,
695 Some("敏感词".to_string())
696 );
697 }
698}