2026-03-14 02:09:38 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 18:14:16 +08:00
|
|
|
|
// 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 {
|
2026-03-14 02:09:38 +08:00
|
|
|
|
db *gorm.DB
|
|
|
|
|
|
cache activityCache
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewUserActivityRepository 创建用户活跃数据仓储
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func NewUserActivityRepository(db *gorm.DB, cache activityCache) UserActivityRepository {
|
|
|
|
|
|
return &userActivityRepository{db: db, cache: cache}
|
2026-03-14 02:09:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 数据库操作方法 ====================
|
|
|
|
|
|
|
|
|
|
|
|
// RecordActivity 记录用户活跃(写入数据库)
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func (r *userActivityRepository) RecordActivity(ctx context.Context, userID, loginType, ip, userAgent string) error {
|
2026-03-14 02:09:38 +08:00
|
|
|
|
log := &model.UserActiveLog{
|
|
|
|
|
|
UserID: userID,
|
|
|
|
|
|
ActiveDate: time.Now(),
|
|
|
|
|
|
LoginType: loginType,
|
|
|
|
|
|
LoginIP: ip,
|
|
|
|
|
|
UserAgent: userAgent,
|
|
|
|
|
|
}
|
|
|
|
|
|
return r.db.WithContext(ctx).Create(log).Error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetDAU 获取指定日期的日活用户数(从数据库)
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func (r *userActivityRepository) GetDAU(ctx context.Context, date time.Time) (int64, error) {
|
2026-03-14 02:09:38 +08:00
|
|
|
|
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列表
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func (r *userActivityRepository) GetActiveUserIDs(ctx context.Context, date time.Time) ([]string, error) {
|
2026-03-14 02:09:38 +08:00
|
|
|
|
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 获取用户在指定日期范围内的活跃天数
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func (r *userActivityRepository) GetUserActivityCount(ctx context.Context, userID string, startDate, endDate time.Time) (int64, error) {
|
2026-03-14 02:09:38 +08:00
|
|
|
|
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 保存统计数据快照
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func (r *userActivityRepository) SaveStat(ctx context.Context, stat *model.UserActivityStat) error {
|
2026-03-14 02:09:38 +08:00
|
|
|
|
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 获取统计数据快照
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func (r *userActivityRepository) GetStat(ctx context.Context, statType string, date time.Time) (*model.UserActivityStat, error) {
|
2026-03-14 02:09:38 +08:00
|
|
|
|
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
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func (r *userActivityRepository) RecordActivityToRedis(ctx context.Context, userID string) error {
|
2026-03-14 02:09:38 +08:00
|
|
|
|
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 获取日活用户数
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func (r *userActivityRepository) GetDAUFromRedis(ctx context.Context, date time.Time) (int64, error) {
|
2026-03-14 02:09:38 +08:00
|
|
|
|
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 获取周活用户数
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func (r *userActivityRepository) GetWAUFromRedis(ctx context.Context, year int, week int) (int64, error) {
|
2026-03-14 02:09:38 +08:00
|
|
|
|
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 获取月活用户数
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func (r *userActivityRepository) GetMAUFromRedis(ctx context.Context, year int, month int) (int64, error) {
|
2026-03-14 02:09:38 +08:00
|
|
|
|
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列表
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func (r *userActivityRepository) GetDAUUserIDsFromRedis(ctx context.Context, date time.Time) ([]string, error) {
|
2026-03-14 02:09:38 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|