67b97e4b38
看板(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>
77 lines
2.4 KiB
Go
77 lines
2.4 KiB
Go
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")
|
|
}
|