feat(be+admin): 后台看板扩展 + 社区内容安全审核

看板(Recharts):
- 概览页加 DAU/WAU/MAU、今日/本月新增 KPI,新增趋势/活跃趋势/月活/留存曲线
- GET /admin/analytics 内存计算,活跃口径=当天有记录/发帖/评论,排除 bot

内容安全(微信官方 UGC):
- 文本 msg_sec_check 同步判、图片 media_check_async 异步查
- 帖子/评论加 pending/rejected 审核态,feed 只放 published
- 图片结果回调 /api/wx/sec-callback,JSON/XML + 明文模式,签名校验
- 检测不了/未发布时一律转待审核,走后台手动审核
- 后台帖子/评论审核页:修好看不到图(补 attachPostImages)、
  加状态筛选 + 通过/打回;新增评论审核状态接口

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-30 19:01:48 +08:00
parent c48e241fe1
commit 67b97e4b38
23 changed files with 1444 additions and 156 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-BH08rQ_o.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-PPcOOdtF.css">
<script type="module" crossorigin src="/admin/assets/index-f13soFkd.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-pepKB4Fe.css">
</head>
<body>
<div id="root"></div>
+3
View File
@@ -76,6 +76,9 @@ type JWTConfig struct {
type WeChatConfig struct {
AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"`
// MsgToken 消息推送(服务器配置)校验用的 Token。图片异步安全检测的结果
// 由微信 push 到我们的回调地址,用它校验来源。留空则回调不做校验(仅本地调试)
MsgToken string `mapstructure:"msg_token"`
}
type AuthConfig struct {
+28 -1
View File
@@ -1,6 +1,8 @@
package handler
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
@@ -109,6 +111,17 @@ func (h *Handler) AdminStats(c *gin.Context) {
response.OK(c, stats)
}
// AdminAnalytics GET /api/admin/analytics?days=30 看板的活跃/新增/留存
func (h *Handler) AdminAnalytics(c *gin.Context) {
days, _ := strconv.Atoi(c.Query("days"))
data, err := h.svc.AdminAnalytics(days)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
type adminListReq struct {
response.PageQuery
Keyword string `form:"keyword"`
@@ -196,7 +209,7 @@ func (h *Handler) AdminListComments(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
comments, total, err := h.svc.ListCommentsAdmin(req.Offset(), req.Limit())
comments, total, err := h.svc.ListCommentsAdmin(req.Status, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
@@ -204,6 +217,20 @@ func (h *Handler) AdminListComments(c *gin.Context) {
response.OK(c, response.NewPage(comments, total, req.PageQuery))
}
// AdminSetCommentStatus PUT /api/admin/comments/:id/status
func (h *Handler) AdminSetCommentStatus(c *gin.Context) {
var req postStatusReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetCommentStatus(idParam(c, "id"), req.Status); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminDeleteComment DELETE /api/admin/comments/:id
func (h *Handler) AdminDeleteComment(c *gin.Context) {
if err := h.svc.DeleteCommentAdmin(idParam(c, "id")); err != nil {
+76
View File
@@ -0,0 +1,76 @@
package handler
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"encoding/xml"
"io"
"log"
"net/http"
"sort"
"strings"
"github.com/gin-gonic/gin"
)
// secCallbackMsg 兼容消息推送的两种数据格式:JSON 和 XML(字段名一致,标签两套都标)
type secCallbackMsg struct {
Event string `json:"Event" xml:"Event"`
MsgType string `json:"MsgType" xml:"MsgType"`
TraceID string `json:"trace_id" xml:"trace_id"`
Result struct {
Suggest string `json:"suggest" xml:"suggest"`
} `json:"result" xml:"result"`
}
// 图片异步安全检测的结果回调(微信「消息推送」服务器配置指向这里)。
// GET 校验服务器:把 echostr 原样返回
// POST 接收 wxa_media_check 结果:用 trace_id 找回帖子/评论并放出或打回
//
// 明文模式(消息加解密方式选「明文」)下 body 就是 JSON,直接解析;
// 若配了加密模式还需要 AES 解密,这里暂不处理(上线时消息推送选明文即可)。
// verifyWxSignature 校验 signature = sha1(sort(token, timestamp, nonce))
func (h *Handler) verifyWxSignature(c *gin.Context) bool {
token := h.cfg.WeChat.MsgToken
if token == "" {
return true // 没配 token(本地调试):不校验
}
sig := c.Query("signature")
parts := []string{token, c.Query("timestamp"), c.Query("nonce")}
sort.Strings(parts)
sum := sha1.Sum([]byte(strings.Join(parts, "")))
return hex.EncodeToString(sum[:]) == sig
}
// WxSecCallback GET+POST /api/wx/sec-callback
func (h *Handler) WxSecCallback(c *gin.Context) {
if !h.verifyWxSignature(c) {
c.String(http.StatusForbidden, "invalid signature")
return
}
if c.Request.Method == http.MethodGet {
c.String(http.StatusOK, c.Query("echostr")) // 服务器配置校验
return
}
raw, _ := io.ReadAll(c.Request.Body)
// 数据格式 JSON / XML 都兼容:以 '<' 开头当 XML,否则当 JSON
var msg secCallbackMsg
var err error
if trimmed := strings.TrimSpace(string(raw)); strings.HasPrefix(trimmed, "<") {
err = xml.Unmarshal(raw, &msg)
} else {
err = json.Unmarshal(raw, &msg)
}
if err != nil {
log.Printf("[warn] sec-callback 解析失败: %v, body=%s", err, string(raw))
c.String(http.StatusOK, "success") // 回 success 让微信别重推
return
}
if msg.Event == "wxa_media_check" && msg.TraceID != "" {
h.svc.ResolveMediaCheck(msg.TraceID, msg.Result.Suggest)
}
c.String(http.StatusOK, "success")
}
+1
View File
@@ -52,5 +52,6 @@ func AllModels() []any {
&RefreshToken{},
&AIQuotaConfig{},
&AIUsage{},
&MediaCheck{},
}
}
+17
View File
@@ -0,0 +1,17 @@
package model
// 图片异步安全检测的一次提交。mediaCheckAsync 返回 trace_id
// 结果稍后由微信 push 回调,用 trace_id 找回它属于哪条帖子/评论。
const (
MediaPending = "pending"
MediaPass = "pass"
MediaRisky = "risky"
)
type MediaCheck struct {
Base
TraceID string `gorm:"size:64;index" json:"trace_id"`
TargetType string `gorm:"size:16;index" json:"target_type"` // post | comment
TargetID string `gorm:"size:24;index" json:"target_id"`
Status string `gorm:"size:16" json:"status"` // pending | pass | risky
}
+3 -1
View File
@@ -4,7 +4,9 @@ import "gorm.io/datatypes"
// 帖子状态
const (
PostPublished = "published"
PostPending = "pending" // 待审核:内容安全未通过/待人工,前端 feed 不展示
PostPublished = "published" // 已过审,正常展示
PostRejected = "rejected" // 内容安全判定风险,打回
PostHidden = "hidden"
PostDeleted = "deleted"
)
+4
View File
@@ -20,6 +20,8 @@ func New(h *handler.Handler, jm *appjwt.Manager, mode string) *gin.Engine {
r.GET("/api/ping", func(c *gin.Context) { response.OK(c, gin.H{"pong": true}) })
// 根路径跳转后台
r.GET("/", func(c *gin.Context) { c.Redirect(302, "/admin/") })
// 微信图片异步安全检测的结果回调(消息推送),公开、自带签名校验
r.Any("/api/wx/sec-callback", h.WxSecCallback)
api := r.Group("/api")
registerAuth(api, h)
@@ -141,6 +143,7 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
g.GET("/me", h.AdminMe)
g.GET("/stats", h.AdminStats)
g.GET("/analytics", h.AdminAnalytics)
g.GET("/users", h.AdminListUsers)
g.PUT("/users/:id/disabled", h.AdminSetUserDisabled)
@@ -151,6 +154,7 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
g.PUT("/posts/:id/status", h.AdminSetPostStatus)
g.GET("/comments", h.AdminListComments)
g.PUT("/comments/:id/status", h.AdminSetCommentStatus)
g.DELETE("/comments/:id", h.AdminDeleteComment)
g.GET("/articles", h.AdminListArticles)
+31 -4
View File
@@ -2,6 +2,7 @@ package service
import (
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
@@ -133,6 +134,8 @@ func (s *Service) ListPostsAdmin(status string, offset, limit int) ([]model.Post
q.Count(&total)
var posts []model.Post
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error
// 真实帖子的图存的是 file id,要解析成 URL 后台才看得到图
s.attachPostImages(posts)
return posts, total, err
}
@@ -148,15 +151,39 @@ func (s *Service) SetPostStatus(id string, status string) error {
return nil
}
// ListCommentsAdmin 评论分页
func (s *Service) ListCommentsAdmin(offset, limit int) ([]model.Comment, int64, error) {
// ListCommentsAdmin 评论分页,status 非空则按状态过滤(待审核用 pending)
func (s *Service) ListCommentsAdmin(status string, offset, limit int) ([]model.Comment, int64, error) {
q := s.db.Model(&model.Comment{})
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
s.db.Model(&model.Comment{}).Count(&total)
q.Count(&total)
var comments []model.Comment
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error
return comments, total, err
}
// SetCommentStatus 审核评论。从待审核过审时补上楼主的评论数
func (s *Service) SetCommentStatus(id, status string) error {
var c model.Comment
if err := s.db.First(&c, "id = ?", id).Error; err != nil {
return ErrNotFound
}
if c.Status == status {
return nil
}
if err := s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", status).Error; err != nil {
return err
}
// pending → published:这条评论此前没计数,过审后补 1
if status == model.PostPublished && c.Status == model.PostPending {
s.db.Model(&model.Post{}).Where("id = ?", c.PostID).
UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
}
return nil
}
// DeleteCommentAdmin 删除评论
func (s *Service) DeleteCommentAdmin(id string) error {
return s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", "deleted").Error
+199
View File
@@ -0,0 +1,199 @@
package service
import (
"time"
"github.com/sundynix/pets-be/internal/model"
)
// 后台看板的分析数据。真实用户口径(排除 bot),活跃口径=当天产生过
// 健康记录/发帖/评论中的任意一种。数据量在预生产规模下很小,直接拉进内存算,
// 省掉一堆按天分组的 SQL。
// DayPoint 折线/柱状的一个点。Date 视粒度是 MM-DD 或 YYYY-MM
type DayPoint struct {
Date string `json:"date"`
Count int `json:"count"`
}
// RetPoint 留存曲线一个点:注册后第 Day 天仍活跃的比例
type RetPoint struct {
Day int `json:"day"` // 注册后天数
Rate float64 `json:"rate"` // 0~1
Base int `json:"base"` // 该口径下够“年龄”的用户数(分母)
}
type Analytics struct {
Summary struct {
TotalUsers int `json:"total_users"` // 真实用户总数
NewToday int `json:"new_today"`
NewMonth int `json:"new_month"`
DAU int `json:"dau"` // 今日活跃
WAU int `json:"wau"` // 近 7 日活跃
MAU int `json:"mau"` // 近 30 日活跃
TotalPets int `json:"total_pets"`
TotalPosts int `json:"total_posts"`
TotalRecords int `json:"total_records"`
} `json:"summary"`
NewTrend []DayPoint `json:"new_trend"` // 每日新增,近 days 天
ActiveTrend []DayPoint `json:"active_trend"` // 每日活跃(DAU),近 days 天
MauTrend []DayPoint `json:"mau_trend"` // 月活,近 6 个月
Retention []RetPoint `json:"retention"` // 留存曲线
}
// dayKey 把时间压成 yyyymmdd 整数,单调,便于比较和当 map key
func dayKey(t time.Time) int {
return t.Year()*10000 + int(t.Month())*100 + t.Day()
}
// AdminAnalytics days=趋势天数(默认 30,封顶 180)
func (s *Service) AdminAnalytics(days int) (*Analytics, error) {
if days <= 0 || days > 180 {
days = 30
}
loc := time.Local
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
out := &Analytics{}
// —— 真实用户(排除 bot)——
type uRow struct {
ID string
CreatedAt time.Time
}
var users []uRow
s.db.Model(&model.User{}).
Where("is_bot IS NULL OR is_bot = 0").
Select("id, created_at").Scan(&users)
signup := make(map[string]int, len(users)) // userID -> 注册日 dayKey
signupTime := make(map[string]time.Time, len(users))
ids := make([]string, 0, len(users))
todayKey := dayKey(today)
monthStart := dayKey(time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc))
for _, u := range users {
t := u.CreatedAt.In(loc)
signup[u.ID] = dayKey(t)
signupTime[u.ID] = t
ids = append(ids, u.ID)
k := dayKey(t)
if k == todayKey {
out.Summary.NewToday++
}
if k >= monthStart {
out.Summary.NewMonth++
}
}
out.Summary.TotalUsers = len(users)
// —— 活跃事件:近 180 天,真实用户的 记录/帖子/评论 ——
windowStart := today.AddDate(0, 0, -180)
type ev struct {
UserID string
CreatedAt time.Time
}
// userID -> 活跃日集合;以及每日活跃用户集合(供 DAU/MAU 用)
userActive := make(map[string]map[int]bool)
dayUsers := make(map[int]map[string]bool)
mark := func(table string) {
if len(ids) == 0 {
return
}
var rows []ev
s.db.Table(table).Select("user_id, created_at").
Where("created_at >= ?", windowStart).
Where("user_id IN ?", ids).Scan(&rows)
for _, r := range rows {
k := dayKey(r.CreatedAt.In(loc))
if userActive[r.UserID] == nil {
userActive[r.UserID] = map[int]bool{}
}
userActive[r.UserID][k] = true
if dayUsers[k] == nil {
dayUsers[k] = map[string]bool{}
}
dayUsers[k][r.UserID] = true
}
}
mark("sundynix_health_records")
mark("sundynix_posts")
mark("sundynix_comments")
// distinctInRange 统计 [fromDay, toDay] 内的去重活跃用户
distinctInRange := func(from, to time.Time) int {
seen := map[string]bool{}
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
for u := range dayUsers[dayKey(d)] {
seen[u] = true
}
}
return len(seen)
}
out.Summary.DAU = len(dayUsers[todayKey])
out.Summary.WAU = distinctInRange(today.AddDate(0, 0, -6), today)
out.Summary.MAU = distinctInRange(today.AddDate(0, 0, -29), today)
// —— 趋势:新增 + DAU,近 days 天 ——
for i := days - 1; i >= 0; i-- {
d := today.AddDate(0, 0, -i)
k := dayKey(d)
label := d.Format("01-02")
newN := 0
for _, sk := range signup {
if sk == k {
newN++
}
}
out.NewTrend = append(out.NewTrend, DayPoint{Date: label, Count: newN})
out.ActiveTrend = append(out.ActiveTrend, DayPoint{Date: label, Count: len(dayUsers[k])})
}
// —— 月活:近 6 个月 ——
for i := 5; i >= 0; i-- {
mStart := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -i, 0)
mEnd := mStart.AddDate(0, 1, -1)
out.MauTrend = append(out.MauTrend, DayPoint{
Date: mStart.Format("2006-01"),
Count: distinctInRange(mStart, mEnd),
})
}
// —— 留存曲线:注册后第 K 天仍活跃(滚动口径:K 天当天或之后还有活跃)——
// 分母只算“年龄”够 K 天、且在观测窗内的用户
offsets := []int{1, 3, 7, 14, 30}
minSignup := dayKey(windowStart)
for _, K := range offsets {
base, retained := 0, 0
for id, sk := range signup {
if sk < minSignup {
continue // 太老,活跃窗看不全
}
plusK := dayKey(signupTime[id].AddDate(0, 0, K))
if plusK > todayKey {
continue // 还没到第 K 天,不够“年龄”
}
base++
for ak := range userActive[id] {
if ak >= plusK {
retained++
break
}
}
}
rate := 0.0
if base > 0 {
rate = float64(retained) / float64(base)
}
out.Retention = append(out.Retention, RetPoint{Day: K, Rate: rate, Base: base})
}
var pets, posts, records int64
s.db.Model(&model.Pet{}).Count(&pets)
s.db.Model(&model.Post{}).Count(&posts)
s.db.Model(&model.HealthRecord{}).Count(&records)
out.Summary.TotalPets = int(pets)
out.Summary.TotalPosts = int(posts)
out.Summary.TotalRecords = int(records)
return out, nil
}
+46 -4
View File
@@ -125,19 +125,50 @@ func (s *Service) CreatePost(userID string, in PostInput) (*model.Post, error) {
}
}
}
// 取作者 openid(内容安全接口 v2 要求带上),并把图片 file id 解析成可访问 URL
var user model.User
s.db.Select("id, open_id").First(&user, userID)
imgURLs := s.resolveImageURLs(in.ImageFileIDs)
// 发布前过内容安全:文本同步判、图片提交异步查。检测不了或有图 → 待人工/待回调
status, _ := s.moderateInitial(in.Content, user.OpenID, 3, len(imgURLs) > 0)
p := model.Post{
UserID: userID, PetID: in.PetID, AuthorName: authorName, AuthorEmoji: authorEmoji,
Identity: in.Identity, Content: in.Content, Tags: in.Tags, Images: in.Images,
ImageFileIDs: in.ImageFileIDs, Status: model.PostPublished,
ImageFileIDs: in.ImageFileIDs, Status: status,
}
if err := s.db.Create(&p).Error; err != nil {
return nil, err
}
// 文本已过、但有图 → 逐张提交异步检测,结果回调再决定放出/打回
if status == model.PostPending && len(imgURLs) > 0 {
s.submitImageChecks("post", p.ID, user.OpenID, imgURLs, 3)
}
one := []model.Post{p}
s.attachPostImages(one)
return &one[0], nil
}
// resolveImageURLs 把 file id 数组(JSON)解析成可访问 URL 列表
func (s *Service) resolveImageURLs(fileIDs datatypes.JSON) []string {
if len(fileIDs) == 0 {
return nil
}
var ids []string
if json.Unmarshal(fileIDs, &ids) != nil || len(ids) == 0 {
return nil
}
m := s.fileURLs(ids)
urls := make([]string, 0, len(ids))
for _, id := range ids {
if u := m[id]; u != "" {
urls = append(urls, u)
}
}
return urls
}
// LikePost 点赞(幂等:已赞则不重复计数)
func (s *Service) LikePost(userID, postID string) (int, error) {
if _, err := s.GetPost(postID); err != nil {
@@ -275,9 +306,12 @@ func (s *Service) CreateComment(userID, postID string, content string, images []
}
}
// 评论也过内容安全(scene=2 评论)。图片这里已经是可访问 URL
status, _ := s.moderateInitial(content, user.OpenID, 2, len(images) > 0)
comment := model.Comment{
PostID: postID, UserID: userID, AuthorName: name, Content: content,
Status: "published", ParentID: parentID, ReplyToName: replyTo,
Status: status, ParentID: parentID, ReplyToName: replyTo,
}
// 配图存 JSON 数组。空数组也要显式写,否则前端拿到 null 还要额外判空
if b, err := json.Marshal(images); err == nil && len(images) > 0 {
@@ -287,11 +321,19 @@ func (s *Service) CreateComment(userID, postID string, content string, images []
if err := tx.Create(&comment).Error; err != nil {
return err
}
return tx.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error
// 待审核/打回的评论先不计入楼主的评论数,过审后再补
if status == model.PostPublished {
return tx.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error
}
return nil
}); err != nil {
return nil, err
}
// 文本已过、有图 → 异步查图
if status == model.PostPending && len(images) > 0 {
s.submitImageChecks("comment", comment.ID, user.OpenID, images, 2)
}
return &comment, nil
}
+171
View File
@@ -0,0 +1,171 @@
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)
}