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 }