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
+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)
}