wechat_minapp/minapp_security/
msg_sec_check.rs1use super::{Label, MinappSecurity, Suggest};
53use crate::utils::{RequestBuilder, ResponseExt};
54use crate::{Result, constants, error::Error};
55use serde::{Deserialize, Serialize};
56use tracing::debug;
57
58#[derive(Debug, Serialize, Clone, Copy, PartialEq)]
83pub enum Scene {
84 Profile = 1,
86 Comment = 2,
88 Forum = 3,
90 SocialLog = 4,
92}
93
94#[derive(Debug, Serialize, Clone)]
118pub struct Args {
119 pub content: String,
121 pub version: u32,
123 pub scene: Scene,
125 pub openid: String,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub title: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub nickname: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub signature: Option<String>,
136}
137
138#[derive(Debug, Default)]
157pub struct ArgsBuilder {
158 content: Option<String>,
159 version: Option<u32>,
160 scene: Option<Scene>,
161 openid: Option<String>,
162 title: Option<String>,
163 nickname: Option<String>,
164 signature: Option<String>,
165}
166
167impl ArgsBuilder {
168 pub fn new() -> Self {
170 Self::default()
171 }
172
173 pub fn content(mut self, content: impl Into<String>) -> Self {
175 self.content = Some(content.into());
176 self
177 }
178
179 pub fn version(mut self, version: u32) -> Self {
181 self.version = Some(version);
182 self
183 }
184
185 pub fn scene(mut self, scene: Scene) -> Self {
187 self.scene = Some(scene);
188 self
189 }
190
191 pub fn openid(mut self, openid: impl Into<String>) -> Self {
193 self.openid = Some(openid.into());
194 self
195 }
196
197 pub fn title(mut self, title: impl Into<String>) -> Self {
199 self.title = Some(title.into());
200 self
201 }
202
203 pub fn nickname(mut self, nickname: impl Into<String>) -> Self {
205 self.nickname = Some(nickname.into());
206 self
207 }
208
209 pub fn signature(mut self, signature: impl Into<String>) -> Self {
211 self.signature = Some(signature.into());
212 self
213 }
214
215 pub fn build(self) -> Result<Args> {
217 let content = self
218 .content
219 .ok_or(Error::InvalidParameter("content 是必填参数".to_string()))?;
220 let scene = self
222 .scene
223 .ok_or(Error::InvalidParameter("scene 是必填参数".to_string()))?;
224 let openid = self
225 .openid
226 .ok_or(Error::InvalidParameter("openid 是必填参数".to_string()))?;
227
228 if content.len() > 2500 {
230 return Err(Error::InvalidParameter(
231 "content 长度不能超过2500字".to_string(),
232 ));
233 }
234
235 if self.signature.is_some() && scene != Scene::Profile {
237 return Err(Error::InvalidParameter(
238 "signature 仅在资料场景(scene=1)下有效".to_string(),
239 ));
240 }
241
242 Ok(Args {
243 content,
244 version: 2,
245 scene,
246 openid,
247 title: self.title,
248 nickname: self.nickname,
249 signature: self.signature,
250 })
251 }
252}
253
254impl Args {
256 pub fn builder() -> ArgsBuilder {
258 ArgsBuilder::new()
259 }
260
261 pub fn new(content: impl Into<String>, scene: Scene, openid: impl Into<String>) -> Self {
263 Self {
264 content: content.into(),
265 version: 2,
266 scene,
267 openid: openid.into(),
268 title: None,
269 nickname: None,
270 signature: None,
271 }
272 }
273
274 pub fn is_profile_scene(&self) -> bool {
276 self.scene == Scene::Profile
277 }
278
279 pub fn content_length(&self) -> usize {
281 self.content.len()
282 }
283
284 pub fn validate(&self) -> Result<()> {
286 if self.content.len() > 2500 {
287 return Err(Error::InvalidParameter(
288 "content 长度不能超过2500字".to_string(),
289 ));
290 }
291
292 if self.signature.is_some() && !self.is_profile_scene() {
293 return Err(Error::InvalidParameter(
294 "signature 仅在资料场景(scene=1)下有效".to_string(),
295 ));
296 }
297
298 Ok(())
299 }
300}
301
302impl Scene {
304 pub fn from_value(value: u32) -> Option<Self> {
306 match value {
307 1 => Some(Scene::Profile),
308 2 => Some(Scene::Comment),
309 3 => Some(Scene::Forum),
310 4 => Some(Scene::SocialLog),
311 _ => None,
312 }
313 }
314
315 pub fn description(&self) -> &'static str {
317 match self {
318 Scene::Profile => "资料",
319 Scene::Comment => "评论",
320 Scene::Forum => "论坛",
321 Scene::SocialLog => "社交日志",
322 }
323 }
324}
325
326#[derive(Debug, Deserialize, Serialize, Clone)]
339pub struct DetailResult {
340 pub strategy: String,
342 pub errcode: i32,
344 #[serde(skip_serializing_if = "Option::is_none")]
346 pub suggest: Option<Suggest>,
347 #[serde(skip_serializing_if = "Option::is_none")]
349 pub label: Option<Label>,
350 #[serde(skip_serializing_if = "Option::is_none")]
352 pub keyword: Option<String>,
353 #[serde(skip_serializing_if = "Option::is_none")]
355 pub prob: Option<f64>,
356}
357
358#[derive(Debug, Deserialize, Serialize, Clone)]
360pub struct ComprehensiveResult {
361 pub suggest: Suggest,
363 pub label: Label,
365}
366
367#[derive(Debug, Deserialize, Serialize, Clone)]
401pub struct MsgSecCheckResult {
402 pub errcode: i32,
404 pub errmsg: String,
406 #[serde(skip_serializing_if = "Option::is_none")]
408 pub detail: Option<Vec<DetailResult>>,
409 #[serde(skip_serializing_if = "Option::is_none")]
411 pub result: Option<ComprehensiveResult>,
412 #[serde(skip_serializing_if = "Option::is_none")]
414 pub trace_id: Option<String>,
415}
416
417impl MsgSecCheckResult {
419 pub fn is_success(&self) -> bool {
421 self.errcode == 0
422 }
423
424 pub fn get_suggest(&self) -> Option<&Suggest> {
426 self.result.as_ref().map(|r| &r.suggest)
427 }
428
429 pub fn get_label(&self) -> Option<&Label> {
431 self.result.as_ref().map(|r| &r.label)
432 }
433
434 pub fn is_pass(&self) -> bool {
436 self.get_suggest().map(|s| s.is_pass()).unwrap_or(false)
437 }
438
439 pub fn is_risky(&self) -> bool {
441 self.get_suggest().map(|s| s.is_risky()).unwrap_or(false)
442 }
443
444 pub fn needs_review(&self) -> bool {
446 self.get_suggest()
447 .map(|s| s.needs_review())
448 .unwrap_or(false)
449 }
450
451 pub fn get_valid_details(&self) -> Vec<&DetailResult> {
453 self.detail
454 .as_ref()
455 .map(|details| details.iter().filter(|d| d.errcode == 0).collect())
456 .unwrap_or_default()
457 }
458}
459
460impl MinappSecurity {
461 pub async fn msg_sec_check(&self, args: &Args) -> Result<MsgSecCheckResult> {
512 debug!("msg_sec_check args: {:?}", &args);
513
514 args.validate()?;
516
517 let query = serde_json::json!({
518 "access_token":self.client.token().await?
519 });
520
521 let body = serde_json::to_value(Args {
522 content: args.content.clone(),
523 version: args.version,
524 scene: args.scene,
525 openid: args.openid.clone(),
526 title: args.title.clone(),
527 nickname: args.nickname.clone(),
528 signature: args.signature.clone(),
529 })?;
530
531 let request = RequestBuilder::new(constants::MSG_SEC_CHECK_END_POINT)
532 .query(query)
533 .body(body)
534 .build()?;
535
536 let client = &self.client.client;
537
538 let response = client.execute(request).await?;
539
540 debug!("response: {:#?}", response);
541 response.to_json::<MsgSecCheckResult>()
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 #[test]
550 fn test_args_builder() {
551 let args = Args::builder()
552 .content("测试内容")
553 .scene(Scene::Comment)
554 .openid("test_openid")
555 .build()
556 .unwrap();
557
558 assert_eq!(args.content, "测试内容");
559 assert_eq!(args.version, 2);
560 assert_eq!(args.scene, Scene::Comment);
561 assert_eq!(args.openid, "test_openid");
562 }
563
564 #[test]
565 fn test_args_builder_validation() {
566 let result = Args::builder()
568 .scene(Scene::Comment)
569 .openid("test_openid")
570 .build();
571 assert!(result.is_err());
572
573 let long_content = "a".repeat(2501);
575 let result = Args::builder()
576 .content(long_content)
577 .scene(Scene::Comment)
578 .openid("openid")
579 .build();
580 assert!(result.is_err());
581
582 let result = Args::builder()
584 .content("内容")
585 .scene(Scene::Comment)
586 .openid("openid")
587 .signature("签名")
588 .build();
589 assert!(result.is_err());
590 }
591
592 #[test]
593 fn test_scene_enum() {
594 assert_eq!(Scene::from_value(1), Some(Scene::Profile));
595 assert_eq!(Scene::Profile.description(), "资料");
596 assert_eq!(Scene::Profile as u32, 1);
597 }
598
599 #[test]
600 fn test_msg_sec_check_result() {
601 let json = r#"
602 {
603 "errcode": 0,
604 "errmsg": "ok",
605 "detail": [
606 {
607 "strategy": "content_model",
608 "errcode": 0,
609 "suggest": "pass",
610 "label": 100,
611 "prob": 90.5
612 }
613 ],
614 "result": {
615 "suggest": "pass",
616 "label": 100
617 },
618 "trace_id": "test_trace_id"
619 }"#;
620
621 let result: MsgSecCheckResult = serde_json::from_str(json).unwrap();
622
623 assert!(result.is_success());
624 assert!(result.is_pass());
625 assert!(!result.is_risky());
626 assert!(!result.needs_review());
627 assert_eq!(result.get_valid_details().len(), 1);
628 assert_eq!(result.trace_id, Some("test_trace_id".to_string()));
629 }
630
631 #[test]
632 fn test_msg_sec_check_result_with_risk() {
633 let json = r#"
634 {
635 "errcode": 0,
636 "errmsg": "ok",
637 "detail": [
638 {
639 "strategy": "content_model",
640 "errcode": 0,
641 "suggest": "risky",
642 "label": 20001,
643 "keyword": "敏感词",
644 "prob": 95.0
645 }
646 ],
647 "result": {
648 "suggest": "risky",
649 "label": 20001
650 }
651 }"#;
652
653 let result: MsgSecCheckResult = serde_json::from_str(json).unwrap();
654
655 assert!(result.is_success());
656 assert!(!result.is_pass());
657 assert!(result.is_risky());
658 assert!(!result.needs_review());
659 assert_eq!(
660 result.get_valid_details()[0].keyword,
661 Some("敏感词".to_string())
662 );
663 }
664}