package service import ( "strconv" "time" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) // AnalyticsService 管理端聚合分析 type AnalyticsService struct { db *gorm.DB } func NewAnalyticsService(db *gorm.DB) *AnalyticsService { return &AnalyticsService{db: db} } // DayCount 按日计数点 type DayCount struct { Date string `json:"date"` Count int64 `json:"count"` } // VisitDay 访问日序列 type VisitDay struct { Date string `json:"date"` PV int64 `json:"pv"` UV int64 `json:"uv"` } // NamedCount 命名计数(板块/类型等) type NamedCount struct { Key string `json:"key"` Label string `json:"label"` Count int64 `json:"count"` } // TopViewPost 高浏览帖 type TopViewPost struct { ID uint `json:"id"` Title string `json:"title"` ViewCount int `json:"view_count"` BoardID uint `json:"board_id"` } // AnalyticsOverview 仪表盘聚合 type AnalyticsOverview struct { RangeDays int `json:"range_days"` TodayPV int64 `json:"today_pv"` TodayUV int64 `json:"today_uv"` Visits []VisitDay `json:"visits"` PostsSeries []DayCount `json:"posts_series"` CommentsSeries []DayCount `json:"comments_series"` RegistersSeries []DayCount `json:"registers_series"` Boards []NamedCount `json:"boards"` PostTypes []NamedCount `json:"post_types"` TopViewed []TopViewPost `json:"top_viewed"` } func parseRangeDays(rangeQ string) int { switch rangeQ { case "30d", "30": return 30 default: return 7 } } func dateKey(t time.Time) string { return t.Format("2006-01-02") } func fillDaySeries(start, end time.Time, got map[string]int64) []DayCount { out := make([]DayCount, 0) for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { k := dateKey(d) out = append(out, DayCount{Date: k, Count: got[k]}) } return out } // Overview 返回近 N 日访问与内容趋势。 // scoped=true 时按 boardIDs 过滤内容类指标,并隐藏全站 PV/UV/注册。 func (s *AnalyticsService) Overview(rangeQ string, boardIDs []uint, scoped bool) (*AnalyticsOverview, error) { days := parseRangeDays(rangeQ) now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) start := today.AddDate(0, 0, -(days - 1)) end := today out := &AnalyticsOverview{ RangeDays: days, Visits: make([]VisitDay, 0, days), Boards: make([]NamedCount, 0), PostTypes: make([]NamedCount, 0), TopViewed: make([]TopViewPost, 0), } if scoped && len(boardIDs) == 0 { for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { out.Visits = append(out.Visits, VisitDay{Date: dateKey(d)}) } out.PostsSeries = fillDaySeries(start, end, nil) out.CommentsSeries = fillDaySeries(start, end, nil) out.RegistersSeries = fillDaySeries(start, end, nil) return out, nil } if !scoped { var stats []model.SiteDailyStats if err := s.db.Where("date >= ? AND date <= ?", start, end). Order("date ASC").Find(&stats).Error; err != nil { return nil, err } visitMap := map[string]model.SiteDailyStats{} for _, st := range stats { visitMap[dateKey(st.Date)] = st } for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { k := dateKey(d) st := visitMap[k] out.Visits = append(out.Visits, VisitDay{Date: k, PV: st.PV, UV: st.UV}) if k == dateKey(today) { out.TodayPV = st.PV out.TodayUV = st.UV } } } else { for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { out.Visits = append(out.Visits, VisitDay{Date: dateKey(d)}) } } type dayRow struct { Day string Cnt int64 } scanDayCounts := func(sql string, args ...interface{}) (map[string]int64, error) { var rows []dayRow if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil { return nil, err } m := map[string]int64{} for _, r := range rows { m[r.Day] = r.Cnt } return m, nil } boardFilter := "" var postArgs []interface{} postArgs = append(postArgs, model.ContentStatusPublished, start, end.AddDate(0, 0, 1)) if scoped { boardFilter = " AND board_id IN ?" postArgs = append(postArgs, boardIDs) } postMap, err := scanDayCounts(` SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt FROM posts WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?`+boardFilter+` GROUP BY day ORDER BY day`, postArgs...) if err != nil { return nil, err } out.PostsSeries = fillDaySeries(start, end, postMap) var commentArgs []interface{} commentArgs = append(commentArgs, model.ContentStatusPublished, start, end.AddDate(0, 0, 1)) commentBoard := "" if scoped { commentBoard = " AND post_id IN (SELECT id FROM posts WHERE board_id IN ?)" commentArgs = append(commentArgs, boardIDs) } commentMap, err := scanDayCounts(` SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt FROM comments WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?`+commentBoard+` GROUP BY day ORDER BY day`, commentArgs...) if err != nil { return nil, err } out.CommentsSeries = fillDaySeries(start, end, commentMap) if !scoped { regMap, err := scanDayCounts(` SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt FROM users WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ? GROUP BY day ORDER BY day`, start, end.AddDate(0, 0, 1)) if err != nil { return nil, err } out.RegistersSeries = fillDaySeries(start, end, regMap) } else { out.RegistersSeries = fillDaySeries(start, end, nil) } type boardRow struct { ID uint Name string PostCount int64 } var boards []boardRow boardSQL := ` SELECT b.id, b.name, count(p.id) AS post_count FROM boards b LEFT JOIN posts p ON p.board_id = b.id AND p.deleted_at IS NULL AND p.status = ? WHERE b.deleted_at IS NULL` boardArgs := []interface{}{model.ContentStatusPublished} if scoped { boardSQL += ` AND b.id IN ?` boardArgs = append(boardArgs, boardIDs) } boardSQL += ` GROUP BY b.id, b.name ORDER BY post_count DESC` if err := s.db.Raw(boardSQL, boardArgs...).Scan(&boards).Error; err != nil { return nil, err } for _, b := range boards { out.Boards = append(out.Boards, NamedCount{ Key: strconv.FormatUint(uint64(b.ID), 10), Label: b.Name, Count: b.PostCount, }) } type typeRow struct { PostType string Cnt int64 } typeQ := s.db.Model(&model.Post{}). Select("post_type, count(*) as cnt"). Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished) if scoped { typeQ = typeQ.Where("board_id IN ?", boardIDs) } var types []typeRow if err := typeQ.Group("post_type").Scan(&types).Error; err != nil { return nil, err } for _, t := range types { pt := model.NormalizePostType(t.PostType) out.PostTypes = append(out.PostTypes, NamedCount{ Key: pt, Label: pt, Count: t.Cnt, }) } topQ := s.db.Select("id, title, view_count, board_id"). Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished) if scoped { topQ = topQ.Where("board_id IN ?", boardIDs) } var tops []model.Post if err := topQ.Order("view_count DESC").Limit(8).Find(&tops).Error; err != nil { return nil, err } for _, p := range tops { out.TopViewed = append(out.TopViewed, TopViewPost{ ID: p.ID, Title: p.Title, ViewCount: p.ViewCount, BoardID: p.BoardID, }) } return out, nil }