package service import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strings" "time" "github.com/sundynix/pets-be/internal/model" ) // 微信官方 UGC 内容安全: // - msg_sec_check 文本,同步返回 pass/review/risky // - media_check_async 图片,异步:先提交拿 trace_id,结果由微信 push 回调 // // 口径:社区所有内容默认不自动放出,只有文本检测明确 pass 且没有图片时才自动过审; // 有图片走异步检测,结果回来再决定;检测不了(如小程序未发布、无 openid)一律转人工。 // MsgSecCheck 文本安全检测(v2)。scene: 1资料 2评论 3论坛 4日志。 // 返回 suggest: pass / review / risky func (s *Service) MsgSecCheck(content, openid string, scene int) (string, error) { content = strings.TrimSpace(content) if content == "" { return "pass", nil // 没文本不用查 } token, err := s.wechatAccessToken() if err != nil { return "", err } payload, _ := json.Marshal(map[string]any{ "content": content, "version": 2, "scene": scene, "openid": openid, }) var out struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` Result struct { Suggest string `json:"suggest"` Label int `json:"label"` } `json:"result"` } if err := postJSON("https://api.weixin.qq.com/wxa/msg_sec_check?access_token="+token, payload, &out); err != nil { return "", err } if out.ErrCode != 0 { return "", fmt.Errorf("msg_sec_check(%d): %s", out.ErrCode, out.ErrMsg) } if out.Result.Suggest == "" { return "pass", nil } return out.Result.Suggest, nil } // MediaCheckAsync 提交一张图片做异步检测,返回 trace_id。mediaType 2=图片 func (s *Service) MediaCheckAsync(mediaURL, openid string, scene int) (string, error) { token, err := s.wechatAccessToken() if err != nil { return "", err } payload, _ := json.Marshal(map[string]any{ "media_url": mediaURL, "media_type": 2, "version": 2, "scene": scene, "openid": openid, }) var out struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` TraceID string `json:"trace_id"` } if err := postJSON("https://api.weixin.qq.com/wxa/media_check_async?access_token="+token, payload, &out); err != nil { return "", err } if out.ErrCode != 0 { return "", fmt.Errorf("media_check_async(%d): %s", out.ErrCode, out.ErrMsg) } return out.TraceID, nil } // moderateInitial 发布前的初判:跑文本检测,决定初始状态和是否要提交图片异步检测。 // 检测不了一律转 pending 人工审核——保证「必须通过后才自动放出」。 func (s *Service) moderateInitial(content, openid string, scene int, hasImages bool) (status string, submitImages bool) { suggest, err := s.MsgSecCheck(content, openid, scene) if err != nil { return model.PostPending, false // 验证不了 → 人工 } switch suggest { case "risky": return model.PostRejected, false case "review": return model.PostPending, false } // 文本 pass if hasImages { return model.PostPending, true // 图片还要异步查,先挂 pending } return model.PostPublished, true // 纯文本且 pass:自动过审(submitImages 无意义) } // submitImageChecks 逐张提交图片检测,落 trace 记录,供回调找回目标 func (s *Service) submitImageChecks(targetType, targetID, openid string, urls []string, scene int) { for _, u := range urls { if u == "" { continue } trace, err := s.MediaCheckAsync(u, openid, scene) if err != nil || trace == "" { continue // 提交失败的图不阻塞,靠人工兜底 } s.db.Create(&model.MediaCheck{ TraceID: trace, TargetType: targetType, TargetID: targetID, Status: model.MediaPending, }) } } // ResolveMediaCheck 处理一条异步回调结果:更新 trace 记录,并据此决定目标状态。 // 任一张图 risky → 打回;全部 pass 且目标仍 pending → 放出。 func (s *Service) ResolveMediaCheck(traceID, suggest string) { var mc model.MediaCheck if err := s.db.Where("trace_id = ?", traceID).First(&mc).Error; err != nil { return // 不认识的 trace,忽略 } st := model.MediaPass if suggest == "risky" || suggest == "review" { st = model.MediaRisky } s.db.Model(&model.MediaCheck{}).Where("id = ?", mc.ID).Update("status", st) if st == model.MediaRisky { s.setTargetStatus(mc.TargetType, mc.TargetID, model.PostRejected, model.PostPending) return } // 这张 pass 了——看这条目标是否还有没回来的图 var pending int64 s.db.Model(&model.MediaCheck{}). Where("target_type = ? AND target_id = ? AND status = ?", mc.TargetType, mc.TargetID, model.MediaPending). Count(&pending) if pending == 0 { s.setTargetStatus(mc.TargetType, mc.TargetID, model.PostPublished, model.PostPending) } } // setTargetStatus 把帖子/评论改成 newStatus,仅当它当前还是 onlyIf(避免覆盖人工已处理的) func (s *Service) setTargetStatus(targetType, targetID, newStatus, onlyIf string) { switch targetType { case "post": s.db.Model(&model.Post{}).Where("id = ? AND status = ?", targetID, onlyIf).Update("status", newStatus) case "comment": s.db.Model(&model.Comment{}).Where("id = ? AND status = ?", targetID, onlyIf).Update("status", newStatus) } } // postJSON 一个小的 JSON POST 帮手 func postJSON(url string, body []byte, out any) error { ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() return json.NewDecoder(resp.Body).Decode(out) }