package repository import ( "context" "fmt" "time" "with_you/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 interface { RecordActivity(ctx context.Context, userID, loginType, ip, userAgent string) error GetDAU(ctx context.Context, date time.Time) (int64, error) GetActiveUserIDs(ctx context.Context, date time.Time) ([]string, error) GetUserActivityCount(ctx context.Context, userID string, startDate, endDate time.Time) (int64, error) SaveStat(ctx context.Context, stat *model.UserActivityStat) error GetStat(ctx context.Context, statType string, date time.Time) (*model.UserActivityStat, error) RecordActivityToRedis(ctx context.Context, userID string) error GetDAUFromRedis(ctx context.Context, date time.Time) (int64, error) GetWAUFromRedis(ctx context.Context, year int, week int) (int64, error) GetMAUFromRedis(ctx context.Context, year int, month int) (int64, error) GetDAUUserIDsFromRedis(ctx context.Context, date time.Time) ([]string, 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) }