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:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user