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

@@ -10,22 +10,13 @@ import (
"time"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/openai"
"gorm.io/gorm"
)
// ==================== 内容审核服务接口和实现 ====================
// AuditServiceProvider 内容审核服务提供商接口
type AuditServiceProvider interface {
// AuditText 审核文本
AuditText(ctx context.Context, text string, scene string) (*AuditResult, error)
// AuditImage 审核图片
AuditImage(ctx context.Context, imageURL string) (*AuditResult, error)
// GetName 获取提供商名称
GetName() string
}
// AuditResult 审核结果
type AuditResult struct {
Pass bool `json:"pass"` // 是否通过
@@ -42,169 +33,293 @@ type AuditService interface {
AuditText(ctx context.Context, text string, auditType string) (*AuditResult, error)
// AuditImage 审核图片
AuditImage(ctx context.Context, imageURL string) (*AuditResult, error)
// AuditPost 审核帖子
AuditPost(ctx context.Context, title, content string, images []string) (*AuditResult, error)
// AuditComment 审核评论
AuditComment(ctx context.Context, content string, images []string) (*AuditResult, error)
// GetAuditResult 获取审核结果
GetAuditResult(ctx context.Context, auditID string) (*AuditResult, error)
// SetProvider 设置审核服务提供商
SetProvider(provider AuditServiceProvider)
// GetProvider 获取当前审核服务提供商
GetProvider() AuditServiceProvider
// IsEnabled 检查审核服务是否启用
IsEnabled() bool
}
// auditServiceImpl 内容审核服务实现
type auditServiceImpl struct {
db *gorm.DB
provider AuditServiceProvider
config *AuditConfig
mu sync.RWMutex
db *gorm.DB
openaiClient openai.Client
config *AuditConfig
mu sync.RWMutex
}
// AuditConfig 内容审核服务配置
type AuditConfig struct {
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
// 审核服务提供商: local, aliyun, tencent, baidu
Provider string `mapstructure:"provider" yaml:"provider"`
// 阿里云配置
AliyunAccessKey string `mapstructure:"aliyun_access_key" yaml:"aliyun_access_key"`
AliyunSecretKey string `mapstructure:"aliyun_secret_key" yaml:"aliyun_secret_key"`
AliyunRegion string `mapstructure:"aliyun_region" yaml:"aliyun_region"`
// 腾讯云配置
TencentSecretID string `mapstructure:"tencent_secret_id" yaml:"tencent_secret_id"`
TencentSecretKey string `mapstructure:"tencent_secret_key" yaml:"tencent_secret_key"`
// 百度云配置
BaiduAPIKey string `mapstructure:"baidu_api_key" yaml:"baidu_api_key"`
BaiduSecretKey string `mapstructure:"baidu_secret_key" yaml:"baidu_secret_key"`
// 是否自动审核
AutoAudit bool `mapstructure:"auto_audit" yaml:"auto_audit"`
// 审核超时时间(秒)
Timeout int `mapstructure:"timeout" yaml:"timeout"`
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
StrictModeration bool `mapstructure:"strict_moderation" yaml:"strict_moderation"` // 严格模式:审核失败时拒绝;非严格模式:审核失败时放行
Timeout int `mapstructure:"timeout" yaml:"timeout"`
}
// NewAuditService 创建内容审核服务
func NewAuditService(db *gorm.DB, config *AuditConfig) AuditService {
s := &auditServiceImpl{
db: db,
config: config,
func NewAuditService(db *gorm.DB, openaiClient openai.Client, config *AuditConfig) AuditService {
return &auditServiceImpl{
db: db,
openaiClient: openaiClient,
config: config,
}
// 根据配置初始化提供商
if config.Enabled {
provider := s.initProvider(config.Provider)
s.provider = provider
}
return s
}
// initProvider 根据配置初始化审核服务提供商
func (s *auditServiceImpl) initProvider(providerType string) AuditServiceProvider {
switch strings.ToLower(providerType) {
case "aliyun":
return NewAliyunAuditProvider(s.config.AliyunAccessKey, s.config.AliyunSecretKey, s.config.AliyunRegion)
case "tencent":
return NewTencentAuditProvider(s.config.TencentSecretID, s.config.TencentSecretKey)
case "baidu":
return NewBaiduAuditProvider(s.config.BaiduAPIKey, s.config.BaiduSecretKey)
case "local":
fallthrough
default:
// 默认使用本地审核服务
return NewLocalAuditProvider()
}
// IsEnabled 检查审核服务是否启用
func (s *auditServiceImpl) IsEnabled() bool {
return s != nil && s.config != nil && s.config.Enabled && s.openaiClient != nil && s.openaiClient.IsEnabled()
}
// AuditText 审核文本
func (s *auditServiceImpl) AuditText(ctx context.Context, text string, auditType string) (*AuditResult, error) {
if !s.config.Enabled {
// 如果审核服务未启用,直接返回通过
if !s.IsEnabled() {
return &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: "Audit service disabled",
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: "Audit service disabled",
Provider: "ai",
}, nil
}
if text == "" {
return &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: "Empty text",
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: "Empty text",
Provider: "ai",
}, nil
}
var result *AuditResult
var err error
// 使用提供商审核
if s.provider != nil {
result, err = s.provider.AuditText(ctx, text, auditType)
} else {
// 如果没有设置提供商,使用本地审核
localProvider := NewLocalAuditProvider()
result, err = localProvider.AuditText(ctx, text, auditType)
// 使用 AI 审核文本
approved, reason, err := s.openaiClient.ModerateComment(ctx, text, nil)
if err != nil {
log.Printf("AI audit text error: %v", err)
if s.config.StrictModeration {
return &AuditResult{
Pass: false,
Risk: "high",
Suggest: "review",
Detail: fmt.Sprintf("Audit error: %v", err),
Provider: "ai",
}, err
}
// 非严格模式下,审核失败时放行
return &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: fmt.Sprintf("Audit fallback pass due to error: %v", err),
Provider: "ai",
}, nil
}
if err != nil {
log.Printf("Audit text error: %v", err)
return &AuditResult{
Pass: false,
Risk: "high",
Suggest: "review",
Detail: fmt.Sprintf("Audit error: %v", err),
}, err
result := &AuditResult{
Pass: approved,
Provider: "ai",
Detail: reason,
}
if approved {
result.Risk = "low"
result.Suggest = "pass"
} else {
result.Risk = "high"
result.Suggest = "block"
result.Labels = []string{"ai_rejected"}
}
// 记录审核日志
go s.saveAuditLog(ctx, "text", "", text, auditType, result)
go s.saveAuditLog(ctx, "text", text, "", auditType, result)
return result, nil
}
// AuditImage 审核图片
func (s *auditServiceImpl) AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) {
if !s.config.Enabled {
if !s.IsEnabled() {
return &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: "Audit service disabled",
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: "Audit service disabled",
Provider: "ai",
}, nil
}
if imageURL == "" {
return &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: "Empty image URL",
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: "Empty image URL",
Provider: "ai",
}, nil
}
var result *AuditResult
var err error
// 使用提供商审核
if s.provider != nil {
result, err = s.provider.AuditImage(ctx, imageURL)
} else {
// 如果没有设置提供商,使用本地审核
localProvider := NewLocalAuditProvider()
result, err = localProvider.AuditImage(ctx, imageURL)
// 使用 AI 审核图片
approved, reason, err := s.openaiClient.ModerateComment(ctx, "", []string{imageURL})
if err != nil {
log.Printf("AI audit image error: %v", err)
if s.config.StrictModeration {
return &AuditResult{
Pass: false,
Risk: "high",
Suggest: "review",
Detail: fmt.Sprintf("Audit error: %v", err),
Provider: "ai",
}, err
}
// 非严格模式下,审核失败时放行
return &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: fmt.Sprintf("Audit fallback pass due to error: %v", err),
Provider: "ai",
}, nil
}
if err != nil {
log.Printf("Audit image error: %v", err)
return &AuditResult{
Pass: false,
Risk: "high",
Suggest: "review",
Detail: fmt.Sprintf("Audit error: %v", err),
}, err
result := &AuditResult{
Pass: approved,
Provider: "ai",
Detail: reason,
}
if approved {
result.Risk = "low"
result.Suggest = "pass"
} else {
result.Risk = "high"
result.Suggest = "block"
result.Labels = []string{"ai_rejected"}
}
// 记录审核日志
go s.saveAuditLog(ctx, "image", "", "", "image", result)
go s.saveAuditLog(ctx, "image", "", imageURL, "image", result)
return result, nil
}
// AuditPost 审核帖子
func (s *auditServiceImpl) AuditPost(ctx context.Context, title, content string, images []string) (*AuditResult, error) {
if !s.IsEnabled() {
return &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: "Audit service disabled",
Provider: "ai",
}, nil
}
// 使用 AI 审核帖子
approved, reason, err := s.openaiClient.ModeratePost(ctx, title, content, images)
if err != nil {
log.Printf("AI audit post error: %v", err)
if s.config.StrictModeration {
return &AuditResult{
Pass: false,
Risk: "high",
Suggest: "review",
Detail: fmt.Sprintf("Audit error: %v", err),
Provider: "ai",
}, err
}
// 非严格模式下,审核失败时放行
return &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: fmt.Sprintf("Audit fallback pass due to error: %v", err),
Provider: "ai",
}, nil
}
result := &AuditResult{
Pass: approved,
Provider: "ai",
Detail: reason,
}
if approved {
result.Risk = "low"
result.Suggest = "pass"
} else {
result.Risk = "high"
result.Suggest = "block"
result.Labels = []string{"ai_rejected"}
}
// 记录审核日志
contentSummary := fmt.Sprintf("标题: %s, 内容: %s", title, content)
if len(content) > 200 {
contentSummary = fmt.Sprintf("标题: %s, 内容: %s...", title, content[:200])
}
go s.saveAuditLog(ctx, "post", contentSummary, strings.Join(images, ","), "post", result)
return result, nil
}
// AuditComment 审核评论
func (s *auditServiceImpl) AuditComment(ctx context.Context, content string, images []string) (*AuditResult, error) {
if !s.IsEnabled() {
return &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: "Audit service disabled",
Provider: "ai",
}, nil
}
// 使用 AI 审核评论
approved, reason, err := s.openaiClient.ModerateComment(ctx, content, images)
if err != nil {
log.Printf("AI audit comment error: %v", err)
if s.config.StrictModeration {
return &AuditResult{
Pass: false,
Risk: "high",
Suggest: "review",
Detail: fmt.Sprintf("Audit error: %v", err),
Provider: "ai",
}, err
}
// 非严格模式下,审核失败时放行
return &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Detail: fmt.Sprintf("Audit fallback pass due to error: %v", err),
Provider: "ai",
}, nil
}
result := &AuditResult{
Pass: approved,
Provider: "ai",
Detail: reason,
}
if approved {
result.Risk = "low"
result.Suggest = "pass"
} else {
result.Risk = "high"
result.Suggest = "block"
result.Labels = []string{"ai_rejected"}
}
// 记录审核日志
contentSummary := content
if len(content) > 200 {
contentSummary = content[:200] + "..."
}
go s.saveAuditLog(ctx, "comment", contentSummary, strings.Join(images, ","), "comment", result)
return result, nil
}
@@ -235,32 +350,25 @@ func (s *auditServiceImpl) GetAuditResult(ctx context.Context, auditID string) (
return result, nil
}
// SetProvider 设置审核服务提供商
func (s *auditServiceImpl) SetProvider(provider AuditServiceProvider) {
s.mu.Lock()
defer s.mu.Unlock()
s.provider = provider
}
// GetProvider 获取当前审核服务提供商
func (s *auditServiceImpl) GetProvider() AuditServiceProvider {
s.mu.RLock()
defer s.mu.RUnlock()
return s.provider
}
// saveAuditLog 保存审核日志
func (s *auditServiceImpl) saveAuditLog(ctx context.Context, contentType, content, imageURL, auditType string, result *AuditResult) {
if s.db == nil {
return
}
labelsJSON := ""
if len(result.Labels) > 0 {
if data, err := json.Marshal(result.Labels); err == nil {
labelsJSON = string(data)
}
}
auditLog := model.AuditLog{
ContentType: contentType,
Content: content,
ContentURL: imageURL,
AuditType: auditType,
Labels: strings.Join(result.Labels, ","),
Labels: labelsJSON,
Suggestion: result.Suggest,
Detail: result.Detail,
Source: model.AuditSourceAuto,
@@ -291,294 +399,6 @@ func (s *auditServiceImpl) saveAuditLog(ctx context.Context, contentType, conten
}
}
// ==================== 本地审核服务提供商 ====================
// localAuditProvider 本地审核服务提供商
type localAuditProvider struct {
// 可以注入敏感词服务进行本地审核
sensitiveService SensitiveService
}
// NewLocalAuditProvider 创建本地审核服务提供商
func NewLocalAuditProvider() AuditServiceProvider {
return &localAuditProvider{
sensitiveService: nil,
}
}
// GetName 获取提供商名称
func (p *localAuditProvider) GetName() string {
return "local"
}
// AuditText 审核文本
func (p *localAuditProvider) AuditText(ctx context.Context, text string, scene string) (*AuditResult, error) {
// 本地审核逻辑
// 1. 敏感词检查
// 2. 规则匹配
// 3. 简单的关键词检测
result := &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Labels: []string{},
Provider: "local",
}
// 如果有敏感词服务,使用它进行检测
if p.sensitiveService != nil {
hasSensitive, words := p.sensitiveService.Check(ctx, text)
if hasSensitive {
result.Pass = false
result.Risk = "high"
result.Suggest = "block"
result.Detail = fmt.Sprintf("包含敏感词: %s", strings.Join(words, ","))
result.Labels = append(result.Labels, "sensitive")
}
}
// 简单的关键词检测规则
// 实际项目中应该从数据库加载
suspiciousPatterns := []string{
"诈骗",
"钓鱼",
"木马",
"病毒",
}
for _, pattern := range suspiciousPatterns {
if strings.Contains(text, pattern) {
result.Pass = false
result.Risk = "high"
result.Suggest = "block"
result.Labels = append(result.Labels, "suspicious")
if result.Detail == "" {
result.Detail = fmt.Sprintf("包含可疑内容: %s", pattern)
} else {
result.Detail += fmt.Sprintf(", %s", pattern)
}
}
}
return result, nil
}
// AuditImage 审核图片
func (p *localAuditProvider) AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) {
// 本地图片审核逻辑
// 1. 图片URL合法性检查
// 2. 图片格式检查
// 3. 可以扩展接入本地图片识别服务
result := &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Labels: []string{},
Provider: "local",
}
// 检查URL是否为空
if imageURL == "" {
result.Detail = "Empty image URL"
return result, nil
}
// 检查是否为支持的图片URL格式
validPrefixes := []string{"http://", "https://", "s3://", "oss://", "cos://"}
isValid := false
for _, prefix := range validPrefixes {
if strings.HasPrefix(strings.ToLower(imageURL), prefix) {
isValid = true
break
}
}
if !isValid {
result.Pass = false
result.Risk = "medium"
result.Suggest = "review"
result.Detail = "Invalid image URL format"
result.Labels = append(result.Labels, "invalid_url")
}
return result, nil
}
// SetSensitiveService 设置敏感词服务
func (p *localAuditProvider) SetSensitiveService(ss SensitiveService) {
p.sensitiveService = ss
}
// ==================== 阿里云审核服务提供商 ====================
// aliyunAuditProvider 阿里云审核服务提供商
type aliyunAuditProvider struct {
accessKey string
secretKey string
region string
}
// NewAliyunAuditProvider 创建阿里云审核服务提供商
func NewAliyunAuditProvider(accessKey, secretKey, region string) AuditServiceProvider {
return &aliyunAuditProvider{
accessKey: accessKey,
secretKey: secretKey,
region: region,
}
}
// GetName 获取提供商名称
func (p *aliyunAuditProvider) GetName() string {
return "aliyun"
}
// AuditText 审核文本
func (p *aliyunAuditProvider) AuditText(ctx context.Context, text string, scene string) (*AuditResult, error) {
// 阿里云内容安全API调用
// 实际项目中需要实现阿里云SDK调用
// 这里预留接口
result := &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Labels: []string{},
Provider: "aliyun",
Detail: "Aliyun audit not implemented, using pass",
}
// TODO: 实现阿里云内容安全API调用
// 具体参考: https://help.aliyun.com/document_detail/28417.html
return result, nil
}
// AuditImage 审核图片
func (p *aliyunAuditProvider) AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) {
result := &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Labels: []string{},
Provider: "aliyun",
Detail: "Aliyun image audit not implemented, using pass",
}
// TODO: 实现阿里云图片审核API调用
return result, nil
}
// ==================== 腾讯云审核服务提供商 ====================
// tencentAuditProvider 腾讯云审核服务提供商
type tencentAuditProvider struct {
secretID string
secretKey string
}
// NewTencentAuditProvider 创建腾讯云审核服务提供商
func NewTencentAuditProvider(secretID, secretKey string) AuditServiceProvider {
return &tencentAuditProvider{
secretID: secretID,
secretKey: secretKey,
}
}
// GetName 获取提供商名称
func (p *tencentAuditProvider) GetName() string {
return "tencent"
}
// AuditText 审核文本
func (p *tencentAuditProvider) AuditText(ctx context.Context, text string, scene string) (*AuditResult, error) {
result := &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Labels: []string{},
Provider: "tencent",
Detail: "Tencent audit not implemented, using pass",
}
// TODO: 实现腾讯云内容审核API调用
// 具体参考: https://cloud.tencent.com/document/product/1124/64508
return result, nil
}
// AuditImage 审核图片
func (p *tencentAuditProvider) AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) {
result := &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Labels: []string{},
Provider: "tencent",
Detail: "Tencent image audit not implemented, using pass",
}
// TODO: 实现腾讯云图片审核API调用
return result, nil
}
// ==================== 百度云审核服务提供商 ====================
// baiduAuditProvider 百度云审核服务提供商
type baiduAuditProvider struct {
apiKey string
secretKey string
}
// NewBaiduAuditProvider 创建百度云审核服务提供商
func NewBaiduAuditProvider(apiKey, secretKey string) AuditServiceProvider {
return &baiduAuditProvider{
apiKey: apiKey,
secretKey: secretKey,
}
}
// GetName 获取提供商名称
func (p *baiduAuditProvider) GetName() string {
return "baidu"
}
// AuditText 审核文本
func (p *baiduAuditProvider) AuditText(ctx context.Context, text string, scene string) (*AuditResult, error) {
result := &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Labels: []string{},
Provider: "baidu",
Detail: "Baidu audit not implemented, using pass",
}
// TODO: 实现百度云内容审核API调用
// 具体参考: https://cloud.baidu.com/doc/ANTISPAM/s/Jjw0r1iF6
return result, nil
}
// AuditImage 审核图片
func (p *baiduAuditProvider) AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) {
result := &AuditResult{
Pass: true,
Risk: "low",
Suggest: "pass",
Labels: []string{},
Provider: "baidu",
Detail: "Baidu image audit not implemented, using pass",
}
// TODO: 实现百度云图片审核API调用
return result, nil
}
// ==================== 审核结果回调处理 ====================
// AuditCallback 审核回调处理

View File

@@ -0,0 +1,225 @@
package service
import (
"context"
"fmt"
"time"
"carrot_bbs/internal/cache"
"carrot_bbs/internal/config"
"carrot_bbs/internal/repository"
"github.com/casbin/casbin/v2"
)
// CasbinService Casbin 权限服务接口
type CasbinService interface {
// Enforce 检查权限
Enforce(ctx context.Context, sub, obj, act string) (bool, error)
// EnforceForUser 检查用户权限 (自动获取用户角色)
EnforceForUser(ctx context.Context, userID, obj, act string) (bool, error)
// AddRoleForUser 为用户添加角色
AddRoleForUser(ctx context.Context, userID, role string) error
// DeleteRoleForUser 移除用户角色
DeleteRoleForUser(ctx context.Context, userID, role string) error
// GetRolesForUser 获取用户所有角色
GetRolesForUser(ctx context.Context, userID string) ([]string, error)
// HasRoleForUser 检查用户是否拥有指定角色
HasRoleForUser(ctx context.Context, userID, role string) (bool, error)
// AddPolicy 添加策略
AddPolicy(ctx context.Context, role, resource, action string) error
// RemovePolicy 移除策略
RemovePolicy(ctx context.Context, role, resource, action string) error
// LoadPolicy 从数据库重新加载策略
LoadPolicy(ctx context.Context) error
// ClearCache 清除权限缓存
ClearCache(ctx context.Context) error
}
type casbinService struct {
enforcer *casbin.Enforcer
cache cache.Cache
cacheTTL time.Duration
roleRepo repository.RoleRepository
skipRoutes map[string]bool
}
func NewCasbinService(
enforcer *casbin.Enforcer,
cache cache.Cache,
roleRepo repository.RoleRepository,
cfg *config.CasbinConfig,
) CasbinService {
skipRoutes := make(map[string]bool)
for _, route := range cfg.SkipRoutes {
skipRoutes[route] = true
}
return &casbinService{
enforcer: enforcer,
cache: cache,
cacheTTL: time.Duration(cfg.CacheTTL) * time.Second,
roleRepo: roleRepo,
skipRoutes: skipRoutes,
}
}
func (s *casbinService) Enforce(ctx context.Context, sub, obj, act string) (bool, error) {
// 检查是否跳过权限检查
if s.skipRoutes[obj] {
return true, nil
}
// 检查缓存
if s.cache != nil {
cacheKey := fmt.Sprintf("casbin:check:%s:%s:%s", sub, obj, act)
if cached, ok := s.cache.Get(cacheKey); ok {
if allowed, ok := cached.(bool); ok {
return allowed, nil
}
}
}
// 执行权限检查
allowed, err := s.enforcer.Enforce(sub, obj, act)
if err != nil {
return false, err
}
// 缓存结果
if s.cache != nil {
cacheKey := fmt.Sprintf("casbin:check:%s:%s:%s", sub, obj, act)
s.cache.Set(cacheKey, allowed, s.cacheTTL)
}
return allowed, nil
}
func (s *casbinService) EnforceForUser(ctx context.Context, userID, obj, act string) (bool, error) {
// 获取用户角色
roles, err := s.GetRolesForUser(ctx, userID)
if err != nil {
return false, err
}
// 如果用户没有角色,使用默认角色
if len(roles) == 0 {
roles = []string{"user"} // 默认角色
}
// 检查每个角色的权限
for _, role := range roles {
allowed, err := s.Enforce(ctx, role, obj, act)
if err != nil {
return false, err
}
if allowed {
return true, nil
}
}
return false, nil
}
func (s *casbinService) AddRoleForUser(ctx context.Context, userID, role string) error {
// 添加到数据库
if err := s.roleRepo.AddRoleForUser(ctx, userID, role); err != nil {
return err
}
// 添加到 Casbin
if _, err := s.enforcer.AddRoleForUser(userID, role); err != nil {
return err
}
// 清除缓存
return s.clearUserCache(ctx, userID)
}
func (s *casbinService) DeleteRoleForUser(ctx context.Context, userID, role string) error {
// 从数据库删除
if err := s.roleRepo.DeleteRoleForUser(ctx, userID, role); err != nil {
return err
}
// 从 Casbin 删除
if _, err := s.enforcer.DeleteRoleForUser(userID, role); err != nil {
return err
}
// 清除缓存
return s.clearUserCache(ctx, userID)
}
func (s *casbinService) GetRolesForUser(ctx context.Context, userID string) ([]string, error) {
// 检查缓存
if s.cache != nil {
cacheKey := fmt.Sprintf("casbin:user:roles:%s", userID)
if cached, ok := s.cache.Get(cacheKey); ok {
if roles, ok := cached.([]string); ok {
return roles, nil
}
}
}
// 从数据库获取
roles, err := s.roleRepo.GetUserRoles(ctx, userID)
if err != nil {
return nil, err
}
// 缓存结果
if s.cache != nil {
cacheKey := fmt.Sprintf("casbin:user:roles:%s", userID)
s.cache.Set(cacheKey, roles, s.cacheTTL)
}
return roles, nil
}
func (s *casbinService) HasRoleForUser(ctx context.Context, userID, role string) (bool, error) {
return s.roleRepo.HasRole(ctx, userID, role)
}
func (s *casbinService) AddPolicy(ctx context.Context, role, resource, action string) error {
if _, err := s.enforcer.AddPolicy(role, resource, action); err != nil {
return err
}
return s.ClearCache(ctx)
}
func (s *casbinService) RemovePolicy(ctx context.Context, role, resource, action string) error {
if _, err := s.enforcer.RemovePolicy(role, resource, action); err != nil {
return err
}
return s.ClearCache(ctx)
}
func (s *casbinService) LoadPolicy(ctx context.Context) error {
return s.enforcer.LoadPolicy()
}
func (s *casbinService) ClearCache(ctx context.Context) error {
if s.cache != nil {
// 清除所有 casbin 相关缓存
s.cache.DeleteByPrefix("casbin:")
}
return nil
}
func (s *casbinService) clearUserCache(ctx context.Context, userID string) error {
if s.cache != nil {
cacheKey := fmt.Sprintf("casbin:user:roles:%s", userID)
s.cache.Delete(cacheKey)
}
return nil
}

View File

@@ -0,0 +1,116 @@
package service
import (
"context"
apperrors "carrot_bbs/internal/errors"
"carrot_bbs/internal/model"
"carrot_bbs/internal/repository"
)
// RoleService 角色管理服务接口
type RoleService interface {
// GetUserRoles 获取用户的所有角色
GetUserRoles(ctx context.Context, userID string) ([]model.Role, error)
// AssignRole 为用户分配角色
AssignRole(ctx context.Context, operatorID, targetUserID, role string) error
// RemoveRole 移除用户的角色
RemoveRole(ctx context.Context, operatorID, targetUserID, role string) error
// GetAllRoles 获取所有角色定义
GetAllRoles(ctx context.Context) ([]model.Role, error)
// GetRole 获取角色详情
GetRole(ctx context.Context, name string) (*model.Role, error)
}
type roleService struct {
roleRepo repository.RoleRepository
casbinSvc CasbinService
}
// NewRoleService 创建角色管理服务
func NewRoleService(
roleRepo repository.RoleRepository,
casbinSvc CasbinService,
) RoleService {
return &roleService{
roleRepo: roleRepo,
casbinSvc: casbinSvc,
}
}
func (s *roleService) GetUserRoles(ctx context.Context, userID string) ([]model.Role, error) {
roleNames, err := s.casbinSvc.GetRolesForUser(ctx, userID)
if err != nil {
return nil, err
}
var roles []model.Role
for _, name := range roleNames {
role, err := s.roleRepo.GetRole(ctx, name)
if err != nil {
continue // 忽略找不到的角色
}
roles = append(roles, *role)
}
return roles, nil
}
func (s *roleService) AssignRole(ctx context.Context, operatorID, targetUserID, role string) error {
// 不能修改自己的角色
if operatorID == targetUserID {
return apperrors.ErrCannotModifyOwnRole
}
// 检查目标用户是否已有该角色
hasRole, err := s.casbinSvc.HasRoleForUser(ctx, targetUserID, role)
if err != nil {
return err
}
if hasRole {
return apperrors.ErrRoleAlreadyAssigned
}
// 检查角色是否存在
_, err = s.roleRepo.GetRole(ctx, role)
if err != nil {
return apperrors.ErrRoleNotFound
}
// 添加角色
return s.casbinSvc.AddRoleForUser(ctx, targetUserID, role)
}
func (s *roleService) RemoveRole(ctx context.Context, operatorID, targetUserID, role string) error {
// 不能修改自己的角色
if operatorID == targetUserID {
return apperrors.ErrCannotModifyOwnRole
}
// 不能修改超级管理员角色
if role == model.RoleSuperAdmin {
return apperrors.ErrCannotModifySuperAdmin
}
// 检查用户是否有该角色
hasRole, err := s.casbinSvc.HasRoleForUser(ctx, targetUserID, role)
if err != nil {
return err
}
if !hasRole {
return apperrors.ErrRoleNotAssigned
}
return s.casbinSvc.DeleteRoleForUser(ctx, targetUserID, role)
}
func (s *roleService) GetAllRoles(ctx context.Context) ([]model.Role, error) {
return s.roleRepo.GetAllRoles(ctx)
}
func (s *roleService) GetRole(ctx context.Context, name string) (*model.Role, error) {
return s.roleRepo.GetRole(ctx, name)
}

View File

@@ -0,0 +1,223 @@
package service
import (
"context"
"fmt"
"log"
"time"
"carrot_bbs/internal/repository"
)
// UserActivityService 用户活跃统计服务接口
type UserActivityService interface {
// RecordUserActive 记录用户活跃(核心方法)
RecordUserActive(ctx context.Context, userID, loginType, ip, userAgent string) error
// GetDAU 获取日活用户数
GetDAU(ctx context.Context, date time.Time) (int64, error)
// GetWAU 获取周活用户数
GetWAU(ctx context.Context, year int, week int) (int64, error)
// GetMAU 获取月活用户数
GetMAU(ctx context.Context, year int, month int) (int64, error)
// GetRetention 计算留存率
// day1: 基准日期, dayN: N天后的日期
// 返回: day1的活跃用户在dayN仍然活跃的比例
GetRetention(ctx context.Context, day1, dayN time.Time) (float64, error)
// GetActivityStats 获取综合统计数据
GetActivityStats(ctx context.Context) (*ActivityStatsResponse, error)
}
// userActivityService 实现
type userActivityService struct {
repo *repository.UserActivityRepository
}
// NewUserActivityService 创建用户活跃统计服务
func NewUserActivityService(repo *repository.UserActivityRepository) UserActivityService {
return &userActivityService{repo: repo}
}
// ActivityStatsResponse 活跃统计响应
type ActivityStatsResponse struct {
Today DailyStats `json:"today"`
Yesterday DailyStats `json:"yesterday"`
ThisWeek PeriodStats `json:"this_week"`
ThisMonth PeriodStats `json:"this_month"`
Retention RetentionStats `json:"retention"`
}
// DailyStats 日统计
type DailyStats struct {
Date string `json:"date"`
DAU int64 `json:"dau"`
NewUsers int64 `json:"new_users"`
}
// PeriodStats 周期统计
type PeriodStats struct {
Period string `json:"period"`
ActiveUsers int64 `json:"active_users"`
}
// RetentionStats 留存统计
type RetentionStats struct {
Day1 float64 `json:"day_1"` // 次日留存
Day7 float64 `json:"day_7"` // 7日留存
Day30 float64 `json:"day_30"` // 30日留存
}
// RecordUserActive 记录用户活跃
func (s *userActivityService) RecordUserActive(ctx context.Context, userID, loginType, ip, userAgent string) error {
// 1. 记录到 Redis同步快速
if err := s.repo.RecordActivityToRedis(ctx, userID); err != nil {
// 记录日志但不阻断流程
log.Printf("failed to record activity to redis: %v", err)
}
// 2. 异步写入数据库(可以使用 goroutine 或消息队列)
go func() {
bgCtx := context.Background()
if err := s.repo.RecordActivity(bgCtx, userID, loginType, ip, userAgent); err != nil {
log.Printf("failed to record activity to db: %v", err)
}
}()
return nil
}
// GetDAU 获取日活用户数
func (s *userActivityService) GetDAU(ctx context.Context, date time.Time) (int64, error) {
return s.repo.GetDAUFromRedis(ctx, date)
}
// GetWAU 获取周活用户数
func (s *userActivityService) GetWAU(ctx context.Context, year int, week int) (int64, error) {
return s.repo.GetWAUFromRedis(ctx, year, week)
}
// GetMAU 获取月活用户数
func (s *userActivityService) GetMAU(ctx context.Context, year int, month int) (int64, error) {
return s.repo.GetMAUFromRedis(ctx, year, month)
}
// GetRetention 计算留存率
func (s *userActivityService) GetRetention(ctx context.Context, day1, dayN time.Time) (float64, error) {
// 1. 获取 day1 的活跃用户列表
day1Users, err := s.repo.GetDAUUserIDsFromRedis(ctx, day1)
if err != nil {
return 0, err
}
if len(day1Users) == 0 {
return 0, nil
}
// 2. 获取 dayN 的活跃用户列表
dayNUsers, err := s.repo.GetDAUUserIDsFromRedis(ctx, dayN)
if err != nil {
return 0, err
}
// 3. 计算交集
day1Set := make(map[string]bool)
for _, u := range day1Users {
day1Set[u] = true
}
retained := 0
for _, u := range dayNUsers {
if day1Set[u] {
retained++
}
}
// 4. 计算留存率(返回百分比形式 0-100
retention := float64(retained) / float64(len(day1Users)) * 100
return retention, nil
}
// GetActivityStats 获取综合统计数据
func (s *userActivityService) GetActivityStats(ctx context.Context) (*ActivityStatsResponse, error) {
now := time.Now()
// 获取今日和昨日的日期(零点)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
yesterday := today.AddDate(0, 0, -1)
// 获取当前年周
year, week := now.ISOWeek()
// 并发获取各项统计数据
resp := &ActivityStatsResponse{}
// 获取今日 DAU
todayDAU, err := s.GetDAU(ctx, today)
if err != nil {
log.Printf("failed to get today DAU: %v", err)
}
resp.Today = DailyStats{
Date: today.Format("2006-01-02"),
DAU: todayDAU,
}
// 获取昨日 DAU
yesterdayDAU, err := s.GetDAU(ctx, yesterday)
if err != nil {
log.Printf("failed to get yesterday DAU: %v", err)
}
resp.Yesterday = DailyStats{
Date: yesterday.Format("2006-01-02"),
DAU: yesterdayDAU,
}
// 获取本周 WAU
wau, err := s.GetWAU(ctx, year, week)
if err != nil {
log.Printf("failed to get WAU: %v", err)
}
resp.ThisWeek = PeriodStats{
Period: fmt.Sprintf("%d-W%02d", year, week),
ActiveUsers: wau,
}
// 获取本月 MAU
mau, err := s.GetMAU(ctx, now.Year(), int(now.Month()))
if err != nil {
log.Printf("failed to get MAU: %v", err)
}
resp.ThisMonth = PeriodStats{
Period: now.Format("2006-01"),
ActiveUsers: mau,
}
// 获取留存率
// 次日留存
day1Retention, err := s.GetRetention(ctx, yesterday, today)
if err != nil {
log.Printf("failed to get day 1 retention: %v", err)
}
resp.Retention.Day1 = day1Retention
// 7日留存
day7Date := today.AddDate(0, 0, -7)
day7Retention, err := s.GetRetention(ctx, day7Date, today)
if err != nil {
log.Printf("failed to get day 7 retention: %v", err)
}
resp.Retention.Day7 = day7Retention
// 30日留存
day30Date := today.AddDate(0, 0, -30)
day30Retention, err := s.GetRetention(ctx, day30Date, today)
if err != nil {
log.Printf("failed to get day 30 retention: %v", err)
}
resp.Retention.Day30 = day30Retention
return resp, nil
}