wechat_minapp/minapp_security/
msg_sec_check.rs1use super::{Label, MinappSecurity, Suggest};
53use crate::utils::build_request;
54use crate::{Result, constants, error::Error};
55use http::Method;
56use serde::{Deserialize, Serialize};
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 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: 2,
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
518 let query = serde_json::json!({
519 "access_token":self.client.token().await?
520 });
521
522 let body = serde_json::to_value(Args {
523 content: args.content.clone(),
524 version: args.version,
525 scene: args.scene,
526 openid: args.openid.clone(),
527 title: args.title.clone(),
528 nickname: args.nickname.clone(),
529 signature: args.signature.clone(),
530 })?;
531
532 let request = build_request(
533 constants::MSG_SEC_CHECK_END_POINT,
534 Method::POST,
535 None,
536 Some(query),
537 Some(body),
538 )?;
539
540 let client = &self.client.client;
541
542 let response = client.execute(request).await?;
543
544 debug!("response: {:#?}", response);
545
546 if response.status().is_success() {
547 let (_parts, body) = response.into_parts();
548 let json = serde_json::from_slice::<MsgSecCheckResult>(&body.to_vec())?;
549
550 debug!("msg_sec_check result: {:#?}", json);
551
552 Ok(json)
553 } else {
554 let (_parts, body) = response.into_parts();
555 let message = String::from_utf8_lossy(&body.to_vec()).to_string();
556 Err(Error::InternalServer(message))
557 }
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 #[test]
566 fn test_args_builder() {
567 let args = Args::builder()
568 .content("测试内容")
569 .scene(Scene::Comment)
570 .openid("test_openid")
571 .build()
572 .unwrap();
573
574 assert_eq!(args.content, "测试内容");
575 assert_eq!(args.version, 2);
576 assert_eq!(args.scene, Scene::Comment);
577 assert_eq!(args.openid, "test_openid");
578 }
579
580 #[test]
581 fn test_args_builder_validation() {
582 let result = Args::builder()
584 .scene(Scene::Comment)
585 .openid("test_openid")
586 .build();
587 assert!(result.is_err());
588
589 let long_content = "a".repeat(2501);
591 let result = Args::builder()
592 .content(long_content)
593 .scene(Scene::Comment)
594 .openid("openid")
595 .build();
596 assert!(result.is_err());
597
598 let result = Args::builder()
600 .content("内容")
601 .scene(Scene::Comment)
602 .openid("openid")
603 .signature("签名")
604 .build();
605 assert!(result.is_err());
606 }
607
608 #[test]
609 fn test_scene_enum() {
610 assert_eq!(Scene::from_value(1), Some(Scene::Profile));
611 assert_eq!(Scene::Profile.description(), "资料");
612 assert_eq!(Scene::Profile as u32, 1);
613 }
614
615 #[test]
616 fn test_msg_sec_check_result() {
617 let json = r#"
618 {
619 "errcode": 0,
620 "errmsg": "ok",
621 "detail": [
622 {
623 "strategy": "content_model",
624 "errcode": 0,
625 "suggest": "pass",
626 "label": 100,
627 "prob": 90.5
628 }
629 ],
630 "result": {
631 "suggest": "pass",
632 "label": 100
633 },
634 "trace_id": "test_trace_id"
635 }"#;
636
637 let result: MsgSecCheckResult = serde_json::from_str(json).unwrap();
638
639 assert!(result.is_success());
640 assert!(result.is_pass());
641 assert!(!result.is_risky());
642 assert!(!result.needs_review());
643 assert_eq!(result.get_valid_details().len(), 1);
644 assert_eq!(result.trace_id, Some("test_trace_id".to_string()));
645 }
646
647 #[test]
648 fn test_msg_sec_check_result_with_risk() {
649 let json = r#"
650 {
651 "errcode": 0,
652 "errmsg": "ok",
653 "detail": [
654 {
655 "strategy": "content_model",
656 "errcode": 0,
657 "suggest": "risky",
658 "label": 20001,
659 "keyword": "敏感词",
660 "prob": 95.0
661 }
662 ],
663 "result": {
664 "suggest": "risky",
665 "label": 20001
666 }
667 }"#;
668
669 let result: MsgSecCheckResult = serde_json::from_str(json).unwrap();
670
671 assert!(result.is_success());
672 assert!(!result.is_pass());
673 assert!(result.is_risky());
674 assert!(!result.needs_review());
675 assert_eq!(
676 result.get_valid_details()[0].keyword,
677 Some("敏感词".to_string())
678 );
679 }
680}