Files
Blizzard 67b97e4b38 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>
2026-07-30 19:01:48 +08:00

237 lines
6.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// AdminLogin 校验管理员账号密码。仅允许配置里指定的唯一管理员账号登录后台。
func (s *Service) AdminLogin(username, password string) (*model.Admin, error) {
if username != s.cfg.Admin.Username {
return nil, ErrNotFound
}
var admin model.Admin
if err := s.db.Where("username = ?", username).First(&admin).Error; err != nil {
return nil, ErrNotFound
}
if admin.Disabled {
return nil, ErrNotFound
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
return nil, ErrNotFound
}
return &admin, nil
}
// GetAdmin 取管理员
func (s *Service) GetAdmin(id string) (*model.Admin, error) {
var admin model.Admin
if err := s.db.First(&admin, id).Error; err != nil {
return nil, ErrNotFound
}
return &admin, nil
}
// AdminStats 后台统计
type AdminStats struct {
Users int64 `json:"users"`
Pets int64 `json:"pets"`
Posts int64 `json:"posts"`
Records int64 `json:"records"`
}
// Stats Dashboard 计数
func (s *Service) Stats() (*AdminStats, error) {
var st AdminStats
s.db.Model(&model.User{}).Count(&st.Users)
s.db.Model(&model.Pet{}).Count(&st.Pets)
s.db.Model(&model.Post{}).Count(&st.Posts)
s.db.Model(&model.HealthRecord{}).Count(&st.Records)
return &st, nil
}
// ListUsers 用户分页(keyword 匹配昵称)
func (s *Service) ListUsers(keyword, userType string, offset, limit int) ([]model.User, int64, error) {
q := s.db.Model(&model.User{})
if keyword != "" {
q = q.Where("nickname LIKE ?", "%"+keyword+"%")
}
switch userType {
case "bot":
q = q.Where("is_bot = ?", true)
case "real":
q = q.Where("is_bot IS NULL OR is_bot = ?", false)
}
var total int64
q.Count(&total)
var users []model.User
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&users).Error
if err == nil {
s.fillPetCounts(users)
}
return users, total, err
}
// fillPetCounts 一次 GROUP BY 查出这一页用户各建了几个宠物档,
// 避免每行一次 count 把列表打成 N+1 查询
func (s *Service) fillPetCounts(users []model.User) {
if len(users) == 0 {
return
}
ids := make([]string, 0, len(users))
for i := range users {
ids = append(ids, users[i].ID)
}
var rows []struct {
UserID string
N int
}
if err := s.db.Model(&model.Pet{}).
Select("user_id, count(*) as n").
Where("user_id IN ?", ids).
Group("user_id").Scan(&rows).Error; err != nil {
return
}
m := make(map[string]int, len(rows))
for _, r := range rows {
m[r.UserID] = r.N
}
for i := range users {
users[i].PetCount = m[users[i].ID]
}
}
// SetUserDisabled 启用/禁用用户
func (s *Service) SetUserDisabled(id string, disabled bool) error {
if err := s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error; err != nil {
return err
}
if disabled {
// 顺手吊销刷新令牌,否则禁用后对方还能一直续期
s.RevokeAllRefreshTokens("user", id)
}
return nil
}
// ListPetsAdmin 宠物分页
func (s *Service) ListPetsAdmin(offset, limit int) ([]model.Pet, int64, error) {
var total int64
s.db.Model(&model.Pet{}).Count(&total)
var pets []model.Pet
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&pets).Error
return pets, total, err
}
// ListPostsAdmin 帖子分页(status 可选过滤)
func (s *Service) ListPostsAdmin(status string, offset, limit int) ([]model.Post, int64, error) {
q := s.db.Model(&model.Post{})
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
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
}
// SetPostStatus 审核帖子(published/hidden/deleted
func (s *Service) SetPostStatus(id string, status string) error {
res := s.db.Model(&model.Post{}).Where("id = ?", id).Update("status", status)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// 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
q.Count(&total)
var comments []model.Comment
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
}
// ListArticlesAdmin 文章分页(含未发布)
func (s *Service) ListArticlesAdmin(offset, limit int) ([]model.Article, int64, error) {
var total int64
s.db.Model(&model.Article{}).Count(&total)
var articles []model.Article
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&articles).Error
return articles, total, err
}
// SaveArticle 新增或更新文章(ID 为 0 则新增)
func (s *Service) SaveArticle(a *model.Article) error {
if a.ID == "" {
return s.db.Create(a).Error
}
return s.db.Model(&model.Article{}).Where("id = ?", a.ID).Updates(map[string]any{
"icon": a.Icon,
"title": a.Title,
"description": a.Description,
"content": a.Content,
"category": a.Category,
"related_sheet_type": a.RelatedSheetType,
"published": a.Published,
}).Error
}
// DeleteArticle 删除文章
func (s *Service) DeleteArticle(id string) error {
res := s.db.Delete(&model.Article{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// ListProAdmin 会员分页
func (s *Service) ListProAdmin(offset, limit int) ([]model.ProMembership, int64, error) {
var total int64
s.db.Model(&model.ProMembership{}).Count(&total)
var list []model.ProMembership
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&list).Error
return list, total, err
}