feat(auth): add Casbin RBAC and user activity tracking

Integrate Casbin for role-based access control with admin routes for role management. Add user activity logging service to track login and refresh events. Refactor audit service to use AI-based content moderation.
This commit is contained in:
2026-03-14 02:09:38 +08:00
parent c561a0bb0c
commit ae5b997924
27 changed files with 2197 additions and 691 deletions

View File

@@ -0,0 +1,97 @@
package repository
import (
"context"
"carrot_bbs/internal/model"
"gorm.io/gorm"
)
// RoleRepository 角色仓储接口
type RoleRepository interface {
// GetUserRoles 获取用户的所有角色
GetUserRoles(ctx context.Context, userID string) ([]string, error)
// AddRoleForUser 为用户添加角色
AddRoleForUser(ctx context.Context, userID, role string) error
// DeleteRoleForUser 移除用户的角色
DeleteRoleForUser(ctx context.Context, userID, role string) error
// DeleteAllRolesForUser 移除用户的所有角色
DeleteAllRolesForUser(ctx context.Context, userID string) error
// HasRole 检查用户是否拥有指定角色
HasRole(ctx context.Context, userID, role string) (bool, error)
// GetRole 获取角色信息
GetRole(ctx context.Context, name string) (*model.Role, error)
// GetAllRoles 获取所有角色
GetAllRoles(ctx context.Context) ([]model.Role, error)
}
type roleRepository struct {
db *gorm.DB
}
func NewRoleRepository(db *gorm.DB) RoleRepository {
return &roleRepository{db: db}
}
func (r *roleRepository) GetUserRoles(ctx context.Context, userID string) ([]string, error) {
var userRoles []model.UserRole
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&userRoles).Error; err != nil {
return nil, err
}
roles := make([]string, len(userRoles))
for i, ur := range userRoles {
roles[i] = ur.Role
}
return roles, nil
}
func (r *roleRepository) AddRoleForUser(ctx context.Context, userID, role string) error {
userRole := model.UserRole{
UserID: userID,
Role: role,
}
return r.db.WithContext(ctx).Create(&userRole).Error
}
func (r *roleRepository) DeleteRoleForUser(ctx context.Context, userID, role string) error {
return r.db.WithContext(ctx).
Where("user_id = ? AND role = ?", userID, role).
Delete(&model.UserRole{}).Error
}
func (r *roleRepository) DeleteAllRolesForUser(ctx context.Context, userID string) error {
return r.db.WithContext(ctx).
Where("user_id = ?", userID).
Delete(&model.UserRole{}).Error
}
func (r *roleRepository) HasRole(ctx context.Context, userID, role string) (bool, error) {
var count int64
err := r.db.WithContext(ctx).
Model(&model.UserRole{}).
Where("user_id = ? AND role = ?", userID, role).
Count(&count).Error
return count > 0, err
}
func (r *roleRepository) GetRole(ctx context.Context, name string) (*model.Role, error) {
var role model.Role
err := r.db.WithContext(ctx).Where("name = ?", name).First(&role).Error
if err != nil {
return nil, err
}
return &role, nil
}
func (r *roleRepository) GetAllRoles(ctx context.Context) ([]model.Role, error) {
var roles []model.Role
err := r.db.WithContext(ctx).Order("priority DESC").Find(&roles).Error
return roles, err
}

View File

@@ -0,0 +1,225 @@
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)
}