Files
backend/internal/repository/user_activity_repo.go
lafay 2f584c1f2c
All checks were successful
Build Backend / build (push) Successful in 5m10s
Build Backend / build-docker (push) Successful in 2m22s
This is a breaking change that renames the entire project from "Carrot BBS" to "WithYou".
The Go module name has been changed from `carrot_bbs` to `with_you`, which requires
all import paths to be updated throughout the codebase and by external consumers.

BREAKING CHANGE: Module name changed from carrot_bbs to with_you. All imports
and dependencies must be updated from carrot_bbs/* to with_you/*.
2026-04-22 16:01:59 +08:00

241 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}