package repository import ( "context" "fmt" "time" "carrot_bbs/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) // Redis Key 前缀常量(遵循项目命名规范) const ( // DAUKeyPrefix DAU Key: stats:dau:YYYYMMDD DAUKeyPrefix = "stats:dau:" // WAUKeyPrefix WAU Key: stats:wau:YYYYWW (年-周) WAUKeyPrefix = "stats:wau:" // MAUKeyPrefix MAU Key: stats:mau:YYYYMM MAUKeyPrefix = "stats:mau:" ) // TTL 常量 const ( DAUTTL = 45 * 24 * time.Hour // DAU: 45天 WAUTTL = 120 * 24 * time.Hour // WAU: 120天 MAUTTL = 400 * 24 * time.Hour // MAU: 400天 ) // activityCache 用户活跃缓存接口(避免循环导入) type activityCache interface { ZAdd(ctx context.Context, key string, score float64, member string) error ZCard(ctx context.Context, key string) (int64, error) ZRangeByScore(ctx context.Context, key string, min, max string, offset, count int64) ([]string, error) Expire(ctx context.Context, key string, ttl time.Duration) error } // UserActivityRepository 用户活跃数据仓储 type UserActivityRepository struct { db *gorm.DB cache activityCache } // NewUserActivityRepository 创建用户活跃数据仓储 func NewUserActivityRepository(db *gorm.DB, cache activityCache) *UserActivityRepository { return &UserActivityRepository{db: db, cache: cache} } // ==================== 数据库操作方法 ==================== // RecordActivity 记录用户活跃(写入数据库) func (r *UserActivityRepository) RecordActivity(ctx context.Context, userID, loginType, ip, userAgent string) error { log := &model.UserActiveLog{ UserID: userID, ActiveDate: time.Now(), LoginType: loginType, LoginIP: ip, UserAgent: userAgent, } return r.db.WithContext(ctx).Create(log).Error } // GetDAU 获取指定日期的日活用户数(从数据库) func (r *UserActivityRepository) GetDAU(ctx context.Context, date time.Time) (int64, error) { var count int64 startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) endOfDay := startOfDay.Add(24 * time.Hour) err := r.db.WithContext(ctx). Model(&model.UserActiveLog{}). Where("active_date >= ? AND active_date < ?", startOfDay, endOfDay). Distinct("user_id"). Count(&count).Error return count, err } // GetActiveUserIDs 获取指定日期的活跃用户ID列表 func (r *UserActivityRepository) GetActiveUserIDs(ctx context.Context, date time.Time) ([]string, error) { var userIDs []string startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) endOfDay := startOfDay.Add(24 * time.Hour) err := r.db.WithContext(ctx). Model(&model.UserActiveLog{}). Select("DISTINCT user_id"). Where("active_date >= ? AND active_date < ?", startOfDay, endOfDay). Pluck("user_id", &userIDs).Error return userIDs, err } // GetUserActivityCount 获取用户在指定日期范围内的活跃天数 func (r *UserActivityRepository) GetUserActivityCount(ctx context.Context, userID string, startDate, endDate time.Time) (int64, error) { var count int64 startOfDay := time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, startDate.Location()) endOfDay := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 0, endDate.Location()) err := r.db.WithContext(ctx). Model(&model.UserActiveLog{}). Where("user_id = ? AND active_date >= ? AND active_date <= ?", userID, startOfDay, endOfDay). Distinct("DATE(active_date)"). Count(&count).Error return count, err } // SaveStat 保存统计数据快照 func (r *UserActivityRepository) SaveStat(ctx context.Context, stat *model.UserActivityStat) error { return r.db.WithContext(ctx). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "stat_type"}, {Name: "stat_date"}}, DoUpdates: clause.AssignmentColumns([]string{"active_count", "new_count", "retention_1", "retention_7", "retention_30", "updated_at"}), }). Create(stat).Error } // GetStat 获取统计数据快照 func (r *UserActivityRepository) GetStat(ctx context.Context, statType string, date time.Time) (*model.UserActivityStat, error) { var stat model.UserActivityStat startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) err := r.db.WithContext(ctx). Where("stat_type = ? AND stat_date = ?", statType, startOfDay). First(&stat).Error if err != nil { return nil, err } return &stat, nil } // ==================== Redis 操作方法 ==================== // RecordActivityToRedis 记录用户活跃到 Redis Sorted Set // score 为时间戳,member 为用户ID func (r *UserActivityRepository) RecordActivityToRedis(ctx context.Context, userID string) error { now := time.Now() score := float64(now.Unix()) // 记录到 DAU dauKey := formatDAUKey(now) if err := r.cache.ZAdd(ctx, dauKey, score, userID); err != nil { return fmt.Errorf("failed to record DAU: %w", err) } r.cache.Expire(ctx, dauKey, DAUTTL) // 记录到 WAU year, week := now.ISOWeek() wauKey := formatWAUKey(year, week) if err := r.cache.ZAdd(ctx, wauKey, score, userID); err != nil { return fmt.Errorf("failed to record WAU: %w", err) } r.cache.Expire(ctx, wauKey, WAUTTL) // 记录到 MAU mauKey := formatMAUKey(now.Year(), int(now.Month())) if err := r.cache.ZAdd(ctx, mauKey, score, userID); err != nil { return fmt.Errorf("failed to record MAU: %w", err) } r.cache.Expire(ctx, mauKey, MAUTTL) return nil } // GetDAUFromRedis 从 Redis 获取日活用户数 func (r *UserActivityRepository) GetDAUFromRedis(ctx context.Context, date time.Time) (int64, error) { key := formatDAUKey(date) count, err := r.cache.ZCard(ctx, key) if err != nil { return 0, fmt.Errorf("failed to get DAU from redis: %w", err) } return count, nil } // GetWAUFromRedis 从 Redis 获取周活用户数 func (r *UserActivityRepository) GetWAUFromRedis(ctx context.Context, year int, week int) (int64, error) { key := formatWAUKey(year, week) count, err := r.cache.ZCard(ctx, key) if err != nil { return 0, fmt.Errorf("failed to get WAU from redis: %w", err) } return count, nil } // GetMAUFromRedis 从 Redis 获取月活用户数 func (r *UserActivityRepository) GetMAUFromRedis(ctx context.Context, year int, month int) (int64, error) { key := formatMAUKey(year, month) count, err := r.cache.ZCard(ctx, key) if err != nil { return 0, fmt.Errorf("failed to get MAU from redis: %w", err) } return count, nil } // GetDAUUserIDsFromRedis 获取日活用户ID列表 func (r *UserActivityRepository) GetDAUUserIDsFromRedis(ctx context.Context, date time.Time) ([]string, error) { key := formatDAUKey(date) // 获取所有成员(从最小分数到最大分数) members, err := r.cache.ZRangeByScore(ctx, key, "-inf", "+inf", 0, -1) if err != nil { return nil, fmt.Errorf("failed to get DAU user IDs from redis: %w", err) } return members, nil } // ==================== 辅助方法 ==================== // formatDAUKey 格式化 DAU Key // 格式: stats:dau:YYYYMMDD func formatDAUKey(date time.Time) string { return fmt.Sprintf("%s%s", DAUKeyPrefix, date.Format("20060102")) } // formatWAUKey 格式化 WAU Key // 格式: stats:wau:YYYYWW (年-周,周数补零到2位) func formatWAUKey(year int, week int) string { return fmt.Sprintf("%s%04d%02d", WAUKeyPrefix, year, week) } // formatMAUKey 格式化 MAU Key // 格式: stats:mau:YYYYMM func formatMAUKey(year int, month int) string { return fmt.Sprintf("%s%04d%02d", MAUKeyPrefix, year, month) }