wechat_minapp/minapp_security/
msg_sec_check.rs1use 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#[derive(Debug, Serialize, Clone, Copy, PartialEq)]
85pub enum Scene {
86 Profile = 1,
88 Comment = 2,
90 Forum = 3,
92 SocialLog = 4,
94}
95
96#[derive(Debug, Serialize, Clone)]
120pub struct Args {
121 pub content: String,
123 pub version: u32,
125 pub scene: Scene,
127 pub openid: String,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub title: Option<String>,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub nickname: Option<String>,
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub signature: Option<String>,
138}
139
140#[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 pub fn new() -> Self {
172 Self::default()
173 }
174
175 pub fn content(mut self, content: impl Into<String>) -> Self {
177 self.content = Some(content.into());
178 self
179 }
180
181 pub fn version(mut self, version: u32) -> Self {
183 self.version = Some(version);
184 self
185 }
186
187 pub fn scene(mut self, scene: Scene) -> Self {
189 self.scene = Some(scene);
190 self
191 }
192
193 pub fn openid(mut self, openid: impl Into<String>) -> Self {
195 self.openid = Some(openid.into());
196 self
197 }
198
199 pub fn title(mut self, title: impl Into<String>) -> Self {
201 self.title = Some(title.into());
202 self
203 }
204
205 pub fn nickname(mut self, nickname: impl Into<String>) -> Self {
207 self.nickname = Some(nickname.into());
208 self
209 }
210
211 pub fn signature(mut self, signature: impl Into<String>) -> Self {
213 self.signature = Some(signature.into());
214 self
215 }
216
217 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); 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 if content.len() > 2500 {
232 return Err(Error::InvalidParameter(
233 "content 长度不能超过2500字".to_string(),
234 ));
235 }
236
237 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
256impl Args {
258 pub fn builder() -> ArgsBuilder {
260 ArgsBuilder::new()
261 }
262
263 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 pub fn is_profile_scene(&self) -> bool {
278 self.scene == Scene::Profile
279 }
280
281 pub fn content_length(&self) -> usize {
283 self.content.len()
284 }
285
286 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
304impl Scene {
306 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 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#[derive(Debug, Deserialize, Serialize, Clone)]
341pub struct DetailResult {
342 pub strategy: String,
344 pub errcode: i32,
346 #[serde(skip_serializing_if = "Option::is_none")]
348 pub suggest: Option<Suggest>,
349 #[serde(skip_serializing_if = "Option::is_none")]
351 pub label: Option<Label>,
352 #[serde(skip_serializing_if = "Option::is_none")]
354 pub keyword: Option<String>,
355 #[serde(skip_serializing_if = "Option::is_none")]
357 pub prob: Option<f64>,
358}
359
360#[derive(Debug, Deserialize, Serialize, Clone)]
362pub struct ComprehensiveResult {
363 pub suggest: Suggest,
365 pub label: Label,
367}
368
369#[derive(Debug, Deserialize, Serialize, Clone)]
403pub struct MsgSecCheckResult {
404 pub errcode: i32,
406 pub errmsg: String,
408 #[serde(skip_serializing_if = "Option::is_none")]
410 pub detail: Option<Vec<DetailResult>>,
411 #[serde(skip_serializing_if = "Option::is_none")]
413 pub result: Option<ComprehensiveResult>,
414 #[serde(skip_serializing_if = "Option::is_none")]
416 pub trace_id: Option<String>,
417}
418
419impl MsgSecCheckResult {
421 pub fn is_success(&self) -> bool {
423 self.errcode == 0
424 }
425
426 pub fn get_suggest(&self) -> Option<&Suggest> {
428 self.result.as_ref().map(|r| &r.suggest)
429 }
430
431 pub fn get_label(&self) -> Option<&Label> {
433 self.result.as_ref().map(|r| &r.label)
434 }
435
436 pub fn is_pass(&self) -> bool {
438 self.get_suggest().map(|s| s.is_pass()).unwrap_or(false)
439 }
440
441 pub fn is_risky(&self) -> bool {
443 self.get_suggest().map(|s| s.is_risky()).unwrap_or(false)
444 }
445
446 pub fn needs_review(&self) -> bool {
448 self.get_suggest()
449 .map(|s| s.needs_review())
450 .unwrap_or(false)
451 }
452
453 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 pub async fn msg_sec_check(&self, args: &Args) -> Result<MsgSecCheckResult> {
514 debug!("msg_sec_check args: {:?}", &args);
515
516 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 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 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}