feat(admin): add comprehensive admin management system

- Add admin handlers and services for users, posts, comments, groups, and dashboard
- Implement role permission management with ability to view and update permissions
- Add database seeding for roles and Casbin permission policies
- Upgrade Casbin from v2 to v3 with GORM adapter for persistent policy storage
- Add admin-specific repository methods with filtering and pagination
- Register new admin API routes under /api/v1/admin/*
This commit is contained in:
2026-03-14 18:01:55 +08:00
parent ae5b997924
commit d3065b30cc
28 changed files with 4018 additions and 107 deletions

View File

@@ -0,0 +1,444 @@
package service
import (
"context"
"time"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/model"
"carrot_bbs/internal/repository"
"gorm.io/gorm"
)
// AdminDashboardService 管理端仪表盘服务接口
type AdminDashboardService interface {
// GetStats 获取统计数据
GetStats(ctx context.Context) (*dto.DashboardStatsResponse, error)
// GetUserActivityTrend 获取用户活跃度趋势
GetUserActivityTrend(ctx context.Context, days int) ([]dto.UserActivityTrendResponse, error)
// GetContentStats 获取内容统计
GetContentStats(ctx context.Context) (*dto.ContentStatsResponse, error)
// GetPendingContent 获取待审核内容
GetPendingContent(ctx context.Context, limit int) ([]dto.PendingContentResponse, error)
// GetOnlineUsers 获取在线用户数
GetOnlineUsers(ctx context.Context) (*dto.OnlineUsersResponse, error)
}
// adminDashboardServiceImpl 管理端仪表盘服务实现
type adminDashboardServiceImpl struct {
db *gorm.DB
userRepo *repository.UserRepository
postRepo *repository.PostRepository
commentRepo *repository.CommentRepository
groupRepo repository.GroupRepository
activityRepo *repository.UserActivityRepository
}
// NewAdminDashboardService 创建管理端仪表盘服务
func NewAdminDashboardService(
db *gorm.DB,
userRepo *repository.UserRepository,
postRepo *repository.PostRepository,
commentRepo *repository.CommentRepository,
groupRepo repository.GroupRepository,
activityRepo *repository.UserActivityRepository,
) AdminDashboardService {
return &adminDashboardServiceImpl{
db: db,
userRepo: userRepo,
postRepo: postRepo,
commentRepo: commentRepo,
groupRepo: groupRepo,
activityRepo: activityRepo,
}
}
// GetStats 获取统计数据
func (s *adminDashboardServiceImpl) GetStats(ctx context.Context) (*dto.DashboardStatsResponse, error) {
var stats dto.DashboardStatsResponse
// 获取当前时间
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
weekStart := todayStart.AddDate(0, 0, -int(now.Weekday()))
// 总用户数
if err := s.db.Model(&model.User{}).Count(&stats.TotalUsers).Error; err != nil {
return nil, err
}
// 今日新增用户
if err := s.db.Model(&model.User{}).Where("created_at >= ?", todayStart).Count(&stats.TodayNewUsers).Error; err != nil {
return nil, err
}
// 今日活跃用户
todayActive, err := s.activityRepo.GetDAU(ctx, now)
if err != nil {
// 如果出错,尝试从数据库获取
stats.ActiveUsersToday = 0
} else {
stats.ActiveUsersToday = todayActive
}
// 本周活跃用户(使用 WAU
year, week := now.ISOWeek()
weekActive, err := s.activityRepo.GetWAUFromRedis(ctx, year, week)
if err != nil {
// 如果 Redis 获取失败,从数据库计算
var weekActiveCount int64
err := s.db.Model(&model.UserActiveLog{}).
Where("active_date >= ?", weekStart).
Distinct("user_id").
Count(&weekActiveCount).Error
if err == nil {
stats.ActiveUsersWeek = weekActiveCount
}
} else {
stats.ActiveUsersWeek = weekActive
}
// 本月活跃用户(使用 MAU
monthActive, err := s.activityRepo.GetMAUFromRedis(ctx, now.Year(), int(now.Month()))
if err != nil {
// 如果 Redis 获取失败,从数据库计算
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
var monthActiveCount int64
err := s.db.Model(&model.UserActiveLog{}).
Where("active_date >= ?", monthStart).
Distinct("user_id").
Count(&monthActiveCount).Error
if err == nil {
stats.ActiveUsersMonth = monthActiveCount
}
} else {
stats.ActiveUsersMonth = monthActive
}
// 获取留存率数据(从统计快照表获取)
retentionStats := s.getRetentionStats(ctx, now)
stats.Retention1Day = retentionStats.Retention1Day
stats.Retention7Day = retentionStats.Retention7Day
stats.Retention30Day = retentionStats.Retention30Day
// 总帖子数
if err := s.db.Model(&model.Post{}).Count(&stats.TotalPosts).Error; err != nil {
return nil, err
}
// 今日帖子数
if err := s.db.Model(&model.Post{}).Where("created_at >= ?", todayStart).Count(&stats.TodayPosts).Error; err != nil {
return nil, err
}
// 待审核帖子数
if err := s.db.Model(&model.Post{}).Where("status = ?", model.PostStatusPending).Count(&stats.PendingPosts).Error; err != nil {
return nil, err
}
// 总评论数
if err := s.db.Model(&model.Comment{}).Count(&stats.TotalComments).Error; err != nil {
return nil, err
}
// 今日评论数
if err := s.db.Model(&model.Comment{}).Where("created_at >= ?", todayStart).Count(&stats.TodayComments).Error; err != nil {
return nil, err
}
// 总群组数
if err := s.db.Model(&model.Group{}).Count(&stats.TotalGroups).Error; err != nil {
return nil, err
}
// 今日新建群组
if err := s.db.Model(&model.Group{}).Where("created_at >= ?", todayStart).Count(&stats.TodayNewGroups).Error; err != nil {
return nil, err
}
// 待审核评论数
var pendingComments int64
if err := s.db.Model(&model.Comment{}).Where("status = ?", model.CommentStatusPending).Count(&pendingComments).Error; err != nil {
return nil, err
}
stats.PendingComments = pendingComments
// 计算待审核总数
stats.PendingReview = stats.PendingPosts + stats.PendingComments
stats.PendingPostsCount = stats.PendingPosts
return &stats, nil
}
// GetUserActivityTrend 获取用户活跃度趋势
func (s *adminDashboardServiceImpl) GetUserActivityTrend(ctx context.Context, days int) ([]dto.UserActivityTrendResponse, error) {
if days <= 0 {
days = 7
}
if days > 30 {
days = 30
}
now := time.Now()
results := make([]dto.UserActivityTrendResponse, days)
for i := days - 1; i >= 0; i-- {
date := now.AddDate(0, 0, -i)
dateStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
dateEnd := dateStart.Add(24 * time.Hour)
trend := dto.UserActivityTrendResponse{
Date: dateStart.Format("2006-01-02"),
}
// 新增用户数
var newUsers int64
if err := s.db.Model(&model.User{}).
Where("created_at >= ? AND created_at < ?", dateStart, dateEnd).
Count(&newUsers).Error; err == nil {
trend.NewUsers = newUsers
}
// 活跃用户数
activeUsers, err := s.activityRepo.GetDAU(ctx, date)
if err == nil {
trend.ActiveUsers = activeUsers
}
// 帖子数
var posts int64
if err := s.db.Model(&model.Post{}).
Where("created_at >= ? AND created_at < ?", dateStart, dateEnd).
Count(&posts).Error; err == nil {
trend.Posts = posts
}
// 评论数
var comments int64
if err := s.db.Model(&model.Comment{}).
Where("created_at >= ? AND created_at < ?", dateStart, dateEnd).
Count(&comments).Error; err == nil {
trend.Comments = comments
}
results[days-1-i] = trend
}
return results, nil
}
// GetContentStats 获取内容统计
func (s *adminDashboardServiceImpl) GetContentStats(ctx context.Context) (*dto.ContentStatsResponse, error) {
var response dto.ContentStatsResponse
// 内容分布
var totalPosts, totalComments, totalGroups int64
s.db.Model(&model.Post{}).Count(&totalPosts)
s.db.Model(&model.Comment{}).Count(&totalComments)
s.db.Model(&model.Group{}).Count(&totalGroups)
response.ContentDistribution = []dto.ContentDistributionItem{
{Type: "posts", Name: "帖子", Count: totalPosts},
{Type: "comments", Name: "评论", Count: totalComments},
{Type: "groups", Name: "群组", Count: totalGroups},
}
// 帖子状态分布
var publishedCount, pendingCount, rejectedCount, draftCount int64
s.db.Model(&model.Post{}).Where("status = ?", model.PostStatusPublished).Count(&publishedCount)
s.db.Model(&model.Post{}).Where("status = ?", model.PostStatusPending).Count(&pendingCount)
s.db.Model(&model.Post{}).Where("status = ?", model.PostStatusRejected).Count(&rejectedCount)
s.db.Model(&model.Post{}).Where("status = ?", model.PostStatusDraft).Count(&draftCount)
response.PostStatusDistribution = []dto.PostStatusDistributionItem{
{Status: string(model.PostStatusPublished), Name: "已发布", Count: publishedCount},
{Status: string(model.PostStatusPending), Name: "待审核", Count: pendingCount},
{Status: string(model.PostStatusRejected), Name: "已拒绝", Count: rejectedCount},
{Status: string(model.PostStatusDraft), Name: "草稿", Count: draftCount},
}
// 用户增长趋势最近7天
now := time.Now()
response.UserGrowth = make([]dto.UserGrowthItem, 7)
for i := 6; i >= 0; i-- {
date := now.AddDate(0, 0, -i)
dateStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
dateEnd := dateStart.Add(24 * time.Hour)
var newUsers int64
s.db.Model(&model.User{}).
Where("created_at >= ? AND created_at < ?", dateStart, dateEnd).
Count(&newUsers)
response.UserGrowth[6-i] = dto.UserGrowthItem{
Date: dateStart.Format("2006-01-02"),
NewUsers: newUsers,
}
}
return &response, nil
}
// GetPendingContent 获取待审核内容
func (s *adminDashboardServiceImpl) GetPendingContent(ctx context.Context, limit int) ([]dto.PendingContentResponse, error) {
if limit <= 0 {
limit = 10
}
if limit > 50 {
limit = 50
}
results := make([]dto.PendingContentResponse, 0)
// 获取待审核帖子
var posts []*model.Post
if err := s.db.Preload("User").
Where("status = ?", model.PostStatusPending).
Order("created_at DESC").
Limit(limit).
Find(&posts).Error; err != nil {
return nil, err
}
for _, post := range posts {
item := dto.PendingContentResponse{
ID: post.ID,
Type: "post",
Title: post.Title,
Content: post.Content,
CreatedAt: post.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if post.User != nil {
item.Author = &dto.PendingContentAuthor{
ID: post.User.ID,
Username: post.User.Username,
Nickname: post.User.Nickname,
Avatar: post.User.Avatar,
}
}
results = append(results, item)
}
// 获取待审核评论
var comments []*model.Comment
if err := s.db.Preload("User").
Where("status = ?", model.CommentStatusPending).
Order("created_at DESC").
Limit(limit).
Find(&comments).Error; err != nil {
return nil, err
}
for _, comment := range comments {
item := dto.PendingContentResponse{
ID: comment.ID,
Type: "comment",
Title: "",
Content: comment.Content,
CreatedAt: comment.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if comment.User != nil {
item.Author = &dto.PendingContentAuthor{
ID: comment.User.ID,
Username: comment.User.Username,
Nickname: comment.User.Nickname,
Avatar: comment.User.Avatar,
}
}
results = append(results, item)
}
// 按时间排序并限制数量
if len(results) > limit {
results = results[:limit]
}
return results, nil
}
// GetOnlineUsers 获取在线用户数
func (s *adminDashboardServiceImpl) GetOnlineUsers(ctx context.Context) (*dto.OnlineUsersResponse, error) {
// 获取当前日期的活跃用户数作为在线用户数的近似值
// 实际应用中可能需要使用 WebSocket 连接数或 Redis 中的在线状态
now := time.Now()
count, err := s.activityRepo.GetDAU(ctx, now)
if err != nil {
// 如果 Redis 获取失败,从数据库获取
var dbCount int64
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
if err := s.db.Model(&model.UserActiveLog{}).
Where("active_date >= ?", todayStart).
Distinct("user_id").
Count(&dbCount).Error; err != nil {
return nil, err
}
count = dbCount
}
return &dto.OnlineUsersResponse{
Count: count,
}, nil
}
// retentionStatsResult 留存率结果
type retentionStatsResult struct {
Retention1Day int64
Retention7Day int64
Retention30Day int64
}
// getRetentionStats 获取留存率统计数据
func (s *adminDashboardServiceImpl) getRetentionStats(ctx context.Context, now time.Time) retentionStatsResult {
var result retentionStatsResult
// 尝试从统计快照表获取昨日的留存率数据
yesterday := now.AddDate(0, 0, -1)
if stat, err := s.activityRepo.GetStat(ctx, "dau", yesterday); err == nil {
result.Retention1Day = int64(stat.Retention1)
result.Retention7Day = int64(stat.Retention7)
result.Retention30Day = int64(stat.Retention30)
return result
}
// 如果快照表没有数据,实时计算留存率
// 次日留存率:昨日新增用户中今日仍活跃的比例
result.Retention1Day = s.calculateRetention(ctx, 1)
result.Retention7Day = s.calculateRetention(ctx, 7)
result.Retention30Day = s.calculateRetention(ctx, 30)
return result
}
// calculateRetention 计算指定天数的留存率
func (s *adminDashboardServiceImpl) calculateRetention(ctx context.Context, days int) int64 {
now := time.Now()
// 获取days天前的新增用户
targetDate := now.AddDate(0, 0, -days)
targetDateStart := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), 0, 0, 0, 0, targetDate.Location())
targetDateEnd := targetDateStart.Add(24 * time.Hour)
var newUsers []string
if err := s.db.Model(&model.User{}).
Select("id").
Where("created_at >= ? AND created_at < ?", targetDateStart, targetDateEnd).
Pluck("id", &newUsers).Error; err != nil || len(newUsers) == 0 {
return 0
}
// 检查这些用户在今日是否活跃
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var activeCount int64
if err := s.db.Model(&model.UserActiveLog{}).
Where("user_id IN ?", newUsers).
Where("active_date >= ?", todayStart).
Distinct("user_id").
Count(&activeCount).Error; err != nil {
return 0
}
// 计算留存率(百分比)
retention := float64(activeCount) / float64(len(newUsers)) * 100
return int64(retention)
}