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