diff --git a/internal/admin/stats_handler.go b/internal/admin/stats_handler.go new file mode 100644 index 0000000..ac4782e --- /dev/null +++ b/internal/admin/stats_handler.go @@ -0,0 +1,39 @@ +package admin + +import ( + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "wx_service/internal/model" +) + +func (h *Handler) StatsOverview(c *gin.Context) { + data, err := h.svc.StatsOverview(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load stats overview failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) StatsMiniPrograms(c *gin.Context) { + data, err := h.svc.StatsMiniPrograms(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load mini-program stats failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) StatsUserGrowth(c *gin.Context) { + days, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("days", "7"))) + data, err := h.svc.StatsUserGrowth(c.Request.Context(), days) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load user growth failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} diff --git a/internal/admin/stats_service.go b/internal/admin/stats_service.go new file mode 100644 index 0000000..4d05a49 --- /dev/null +++ b/internal/admin/stats_service.go @@ -0,0 +1,187 @@ +package admin + +import ( + "context" + "time" + + expirymodel "wx_service/internal/expiry" + membershipmodel "wx_service/internal/membership/model" + "wx_service/internal/model" + rmmodel "wx_service/internal/remove_watermark/model" +) + +type OverviewStats struct { + TotalMiniPrograms int64 `json:"total_mini_programs"` + TotalUsers int64 `json:"total_users"` + TotalMembers int64 `json:"total_members"` + TodayNewUsers int64 `json:"today_new_users"` +} + +func (s *Service) StatsOverview(ctx context.Context) (*OverviewStats, error) { + var result OverviewStats + if err := s.db.WithContext(ctx).Model(&model.MiniProgram{}).Count(&result.TotalMiniPrograms).Error; err != nil { + return nil, err + } + if err := s.db.WithContext(ctx).Model(&model.User{}).Count(&result.TotalUsers).Error; err != nil { + return nil, err + } + + now := time.Now() + if err := s.db.WithContext(ctx).Model(&model.UserMembership{}). + Where("status = ? AND ends_at > ?", "active", now). + Count(&result.TotalMembers).Error; err != nil { + return nil, err + } + + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + if err := s.db.WithContext(ctx).Model(&model.User{}). + Where("created_at >= ?", todayStart). + Count(&result.TodayNewUsers).Error; err != nil { + return nil, err + } + return &result, nil +} + +type MiniProgramStatsItem struct { + MiniProgramID uint `json:"mini_program_id"` + Name string `json:"name"` + UserCount int64 `json:"user_count"` + MemberCount int64 `json:"member_count"` + DataCount int64 `json:"data_count"` + TodayActive int64 `json:"today_active"` +} + +func (s *Service) StatsMiniPrograms(ctx context.Context) ([]MiniProgramStatsItem, error) { + var miniPrograms []model.MiniProgram + if err := s.db.WithContext(ctx). + Select("id", "name", "app_id", "description", "created_at", "updated_at"). + Order("id ASC"). + Find(&miniPrograms).Error; err != nil { + return nil, err + } + if len(miniPrograms) == 0 { + return []MiniProgramStatsItem{}, nil + } + + ids := make([]uint, 0, len(miniPrograms)) + for _, item := range miniPrograms { + ids = append(ids, item.ID) + } + + now := time.Now() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + + userCounts, err := s.groupCountByMiniProgramID(ctx, &model.User{}, ids, "") + if err != nil { + return nil, err + } + memberCounts, err := s.groupCountByMiniProgramID(ctx, &model.UserMembership{}, ids, "status = ? AND ends_at > ?", "active", now) + if err != nil { + return nil, err + } + todayUserCounts, err := s.groupCountByMiniProgramID(ctx, &model.User{}, ids, "created_at >= ?", todayStart) + if err != nil { + return nil, err + } + + expiryCounts, err := s.groupCountByMiniProgramID(ctx, &expirymodel.ExpiryItem{}, ids, "") + if err != nil { + return nil, err + } + videoCounts, err := s.groupCountByMiniProgramID(ctx, &rmmodel.VideoParseLog{}, ids, "") + if err != nil { + return nil, err + } + redeemCounts, err := s.groupCountByMiniProgramID(ctx, &membershipmodel.MembershipRedemption{}, ids, "") + if err != nil { + return nil, err + } + + result := make([]MiniProgramStatsItem, 0, len(miniPrograms)) + for _, item := range miniPrograms { + dataCount := expiryCounts[item.ID] + videoCounts[item.ID] + redeemCounts[item.ID] + result = append(result, MiniProgramStatsItem{ + MiniProgramID: item.ID, + Name: item.Name, + UserCount: userCounts[item.ID], + MemberCount: memberCounts[item.ID], + DataCount: dataCount, + TodayActive: todayUserCounts[item.ID], + }) + } + return result, nil +} + +type UserGrowthPoint struct { + Date string `json:"date"` + Count int64 `json:"count"` +} + +func (s *Service) StatsUserGrowth(ctx context.Context, days int) ([]UserGrowthPoint, error) { + if days <= 0 { + days = 7 + } + if days > 90 { + days = 90 + } + + now := time.Now() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + startDate := todayStart.AddDate(0, 0, -(days - 1)) + endDate := todayStart.AddDate(0, 0, 1) + + type row struct { + Date string `gorm:"column:date"` + Count int64 `gorm:"column:count"` + } + var rows []row + if err := s.db.WithContext(ctx). + Model(&model.User{}). + Select("DATE(created_at) AS date, COUNT(*) AS count"). + Where("created_at >= ? AND created_at < ?", startDate, endDate). + Group("DATE(created_at)"). + Order("DATE(created_at) ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + countByDate := make(map[string]int64, len(rows)) + for _, item := range rows { + countByDate[item.Date] = item.Count + } + + result := make([]UserGrowthPoint, 0, days) + for i := 0; i < days; i++ { + date := startDate.AddDate(0, 0, i).Format("2006-01-02") + result = append(result, UserGrowthPoint{ + Date: date, + Count: countByDate[date], + }) + } + return result, nil +} + +func (s *Service) groupCountByMiniProgramID(ctx context.Context, modelObj interface{}, ids []uint, where string, args ...interface{}) (map[uint]int64, error) { + result := make(map[uint]int64) + if len(ids) == 0 { + return result, nil + } + + query := s.db.WithContext(ctx). + Model(modelObj). + Select("mini_program_id, COUNT(*) AS count"). + Where("mini_program_id IN ?", ids) + + if where != "" { + query = query.Where(where, args...) + } + + var rows []groupedCountRow + if err := query.Group("mini_program_id").Scan(&rows).Error; err != nil { + return nil, err + } + for _, item := range rows { + result[item.MiniProgramID] = item.Count + } + return result, nil +} diff --git a/internal/routes/admin_routes.go b/internal/routes/admin_routes.go index 96aa148..48507b7 100644 --- a/internal/routes/admin_routes.go +++ b/internal/routes/admin_routes.go @@ -20,6 +20,10 @@ func registerAdminRoutes(router *gin.Engine, handler *adminhandler.Handler) { { protected.GET("/profile", handler.Profile) protected.POST("/logout", handler.Logout) + + protected.GET("/stats/overview", handler.StatsOverview) + protected.GET("/stats/mini-programs", handler.StatsMiniPrograms) + protected.GET("/stats/user-growth", handler.StatsUserGrowth) } } }