From 98b8abd26a06c95f4bdfa4b82069ccea2a674b9c Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 7 Apr 2026 00:07:40 +0800 Subject: [PATCH] feat(config): add sensitive word filtering system with database support Implement sensitive word filtering feature with configurable database and Redis support. Add security enhancements including WebSocket origin validation, improved password strength requirements, timing attack prevention, and production JWT secret validation. Add batch operation validation limits and optimize user blocking queries with subqueries. BREAKING CHANGE: Password validation now requires minimum 8 characters with uppercase, lowercase, and digit (was 6-50 characters without complexity requirements) --- internal/config/config.go | 22 ++++++++ internal/config/sensitive.go | 10 ++++ internal/dto/dto.go | 6 +- internal/handler/ws_handler.go | 34 ++++++++++- internal/model/comment.go | 4 +- internal/model/post.go | 22 ++++---- internal/pkg/utils/validator.go | 62 ++++++++++++++++++-- internal/repository/comment_repo.go | 4 +- internal/repository/group_repo.go | 8 +-- internal/repository/message_repo.go | 4 +- internal/repository/notification_repo.go | 2 +- internal/repository/operation_log_repo.go | 5 +- internal/repository/post_repo.go | 10 ++-- internal/repository/sensitive_word_repo.go | 66 ++++++++++++++++++++++ internal/repository/user_repo.go | 14 ++--- internal/service/group_service.go | 2 +- internal/service/sensitive_service.go | 48 +++++----------- internal/service/user_service.go | 3 + internal/wire/repository.go | 3 + internal/wire/service.go | 18 ++++++ 20 files changed, 263 insertions(+), 84 deletions(-) create mode 100644 internal/config/sensitive.go create mode 100644 internal/repository/sensitive_word_repo.go diff --git a/internal/config/config.go b/internal/config/config.go index 1e73710..8dd55b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,7 @@ type Config struct { HotRank HotRankConfig `mapstructure:"hot_rank"` WebRTC WebRTCConfig `mapstructure:"webrtc"` Report ReportConfig `mapstructure:"report"` + Sensitive SensitiveConfig `mapstructure:"sensitive"` } // Load 加载配置文件 @@ -97,6 +98,10 @@ func Load(configPath string) (*Config, error) { viper.SetDefault("s3.domain", "") viper.SetDefault("sensitive.enabled", true) viper.SetDefault("sensitive.replace_str", "***") + viper.SetDefault("sensitive.min_match_len", 2) + viper.SetDefault("sensitive.load_from_db", true) + viper.SetDefault("sensitive.load_from_redis", false) + viper.SetDefault("sensitive.redis_key_prefix", "sensitive_words") viper.SetDefault("audit.enabled", false) viper.SetDefault("audit.provider", "local") viper.SetDefault("openai.enabled", true) @@ -242,6 +247,23 @@ func Load(configPath string) (*Config, error) { cfg.Encryption.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_ENCRYPTION_ENABLED", fmt.Sprintf("%t", cfg.Encryption.Enabled))) cfg.Encryption.Key = getEnvOrDefault("APP_ENCRYPTION_KEY", cfg.Encryption.Key) cfg.Encryption.KeyVersion, _ = strconv.Atoi(getEnvOrDefault("APP_ENCRYPTION_KEY_VERSION", fmt.Sprintf("%d", cfg.Encryption.KeyVersion))) + // Sensitive 环境变量覆盖 + cfg.Sensitive.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_ENABLED", fmt.Sprintf("%t", cfg.Sensitive.Enabled))) + cfg.Sensitive.ReplaceStr = getEnvOrDefault("APP_SENSITIVE_REPLACE_STR", cfg.Sensitive.ReplaceStr) + cfg.Sensitive.MinMatchLen, _ = strconv.Atoi(getEnvOrDefault("APP_SENSITIVE_MIN_MATCH_LEN", fmt.Sprintf("%d", cfg.Sensitive.MinMatchLen))) + cfg.Sensitive.LoadFromDB, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_LOAD_FROM_DB", fmt.Sprintf("%t", cfg.Sensitive.LoadFromDB))) + cfg.Sensitive.LoadFromRedis, _ = strconv.ParseBool(getEnvOrDefault("APP_SENSITIVE_LOAD_FROM_REDIS", fmt.Sprintf("%t", cfg.Sensitive.LoadFromRedis))) + cfg.Sensitive.RedisKeyPrefix = getEnvOrDefault("APP_SENSITIVE_REDIS_KEY_PREFIX", cfg.Sensitive.RedisKeyPrefix) + + // 安全检查:生产模式必须设置JWT密钥 + if cfg.Server.Mode != "debug" { + if cfg.JWT.Secret == "" || cfg.JWT.Secret == "your-jwt-secret-key-change-in-production" { + return nil, fmt.Errorf("SECURITY ERROR: JWT secret must be set via APP_JWT_SECRET environment variable in production mode") + } + if len(cfg.JWT.Secret) < 32 { + return nil, fmt.Errorf("SECURITY ERROR: JWT secret must be at least 32 characters long in production mode") + } + } return &cfg, nil } diff --git a/internal/config/sensitive.go b/internal/config/sensitive.go new file mode 100644 index 0000000..e39d951 --- /dev/null +++ b/internal/config/sensitive.go @@ -0,0 +1,10 @@ +package config + +type SensitiveConfig struct { + Enabled bool `mapstructure:"enabled"` + ReplaceStr string `mapstructure:"replace_str"` + MinMatchLen int `mapstructure:"min_match_len"` + LoadFromDB bool `mapstructure:"load_from_db"` + LoadFromRedis bool `mapstructure:"load_from_redis"` + RedisKeyPrefix string `mapstructure:"redis_key_prefix"` +} diff --git a/internal/dto/dto.go b/internal/dto/dto.go index 715097d..80c0735 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -972,12 +972,12 @@ type AdminModeratePostRequest struct { // AdminBatchDeletePostsRequest 批量删除帖子请求 type AdminBatchDeletePostsRequest struct { - IDs []string `json:"ids" binding:"required,min=1"` + IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"` } // AdminBatchModeratePostsRequest 批量审核帖子请求 type AdminBatchModeratePostsRequest struct { - IDs []string `json:"ids" binding:"required,min=1"` + IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"` Status string `json:"status" binding:"required,oneof=published rejected"` } @@ -1045,7 +1045,7 @@ type AdminCommentDetailResponse struct { // AdminBatchDeleteCommentsRequest 批量删除评论请求 type AdminBatchDeleteCommentsRequest struct { - IDs []string `json:"ids" binding:"required,min=1"` + IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"` } // ==================== Admin Group DTOs ==================== diff --git a/internal/handler/ws_handler.go b/internal/handler/ws_handler.go index 8e1bb95..a0d8594 100644 --- a/internal/handler/ws_handler.go +++ b/internal/handler/ws_handler.go @@ -27,11 +27,43 @@ var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 4096, CheckOrigin: func(r *http.Request) bool { - return true // 允许所有来源,实际生产环境应该限制 + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + return isAllowedWebSocketOrigin(origin) }, HandshakeTimeout: 10 * time.Second, } +var allowedWebSocketOrigins = []string{ + "https://bbs.littlelan.cn", + "https://admin.littlelan.cn", + "https://browser.littlelan.cn", + "http://localhost:3000", + "http://localhost:5173", + "http://127.0.0.1:3000", + "http://127.0.0.1:5173", +} + +func isAllowedWebSocketOrigin(origin string) bool { + for _, o := range allowedWebSocketOrigins { + if o == origin { + return true + } + } + + if strings.HasPrefix(origin, "http://localhost") || strings.HasPrefix(origin, "http://127.0.0.1") { + return true + } + + if strings.HasPrefix(origin, "https://") && strings.HasSuffix(origin, ".littlelan.cn") { + return true + } + + return false +} + // WSHandler WebSocket处理器 type WSHandler struct { wsHub *ws.Hub diff --git a/internal/model/comment.go b/internal/model/comment.go index f8c5946..d7ee53c 100644 --- a/internal/model/comment.go +++ b/internal/model/comment.go @@ -62,8 +62,8 @@ func (Comment) TableName() string { // CommentLike 评论点赞 type CommentLike struct { ID string `json:"id" gorm:"type:varchar(36);primaryKey"` - CommentID string `json:"comment_id" gorm:"type:varchar(36);index;not null"` - UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"` + CommentID string `json:"comment_id" gorm:"type:varchar(36);not null;uniqueIndex:idx_comment_like_user,priority:1"` + UserID string `json:"user_id" gorm:"type:varchar(36);not null;uniqueIndex:idx_comment_like_user,priority:2"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` } diff --git a/internal/model/post.go b/internal/model/post.go index 4546506..d576aba 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -20,11 +20,11 @@ const ( // Post 帖子实体 type Post struct { - ID string `json:"id" gorm:"type:varchar(36);primaryKey"` - UserID string `json:"user_id" gorm:"type:varchar(36);index;index:idx_posts_user_status_created,priority:1;not null"` + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + UserID string `json:"user_id" gorm:"type:varchar(36);index;index:idx_posts_user_status_created,priority:1;not null"` ChannelID *string `json:"channel_id,omitempty" gorm:"type:varchar(36);index"` - Title string `json:"title" gorm:"type:varchar(200);not null"` - Content string `json:"content" gorm:"type:text;not null"` + Title string `json:"title" gorm:"type:varchar(200);not null"` + Content string `json:"content" gorm:"type:text;not null"` // 关联 // User 需要参与缓存序列化;否则列表命中缓存后会丢失作者信息,前端退化为“匿名用户” @@ -38,11 +38,11 @@ type Post struct { RejectReason string `json:"reject_reason" gorm:"type:varchar(500)"` // 统计 - LikesCount int `json:"likes_count" gorm:"column:likes_count;default:0"` - CommentsCount int `json:"comments_count" gorm:"column:comments_count;default:0"` - FavoritesCount int `json:"favorites_count" gorm:"column:favorites_count;default:0"` - SharesCount int `json:"shares_count" gorm:"column:shares_count;default:0"` - ViewsCount int `json:"views_count" gorm:"column:views_count;default:0"` + LikesCount int `json:"likes_count" gorm:"column:likes_count;default:0"` + CommentsCount int `json:"comments_count" gorm:"column:comments_count;default:0"` + FavoritesCount int `json:"favorites_count" gorm:"column:favorites_count;default:0"` + SharesCount int `json:"shares_count" gorm:"column:shares_count;default:0"` + ViewsCount int `json:"views_count" gorm:"column:views_count;default:0"` // 置顶/锁定 IsPinned bool `json:"is_pinned" gorm:"default:false"` @@ -78,7 +78,7 @@ func (Post) TableName() string { // PostImage 帖子图片 type PostImage struct { ID string `json:"id" gorm:"type:varchar(36);primaryKey"` - PostID string `json:"post_id" gorm:"type:varchar(36);index;not null"` + PostID string `json:"post_id" gorm:"type:varchar(36);not null;index:idx_post_images_post_sort,priority:1"` URL string `json:"url" gorm:"type:text;not null"` ThumbnailURL string `json:"thumbnail_url" gorm:"type:text"` PreviewURL string `json:"preview_url" gorm:"type:text"` // 列表/网格预览图(最大300px) @@ -87,7 +87,7 @@ type PostImage struct { Height int `json:"height" gorm:"default:0"` Size int64 `json:"size" gorm:"default:0"` // 文件大小(字节) MimeType string `json:"mime_type" gorm:"type:varchar(50)"` - SortOrder int `json:"sort_order" gorm:"default:0"` + SortOrder int `json:"sort_order" gorm:"default:0;index:idx_post_images_post_sort,priority:2"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` } diff --git a/internal/pkg/utils/validator.go b/internal/pkg/utils/validator.go index 81782a3..78c5578 100644 --- a/internal/pkg/utils/validator.go +++ b/internal/pkg/utils/validator.go @@ -24,10 +24,57 @@ func ValidateUsername(username string) bool { // ValidatePassword 验证密码强度 func ValidatePassword(password string) bool { - if len(password) < 6 || len(password) > 50 { + if len(password) < 8 || len(password) > 50 { return false } - return true + + var hasUpper, hasLower, hasDigit bool + for _, c := range password { + switch { + case 'A' <= c && c <= 'Z': + hasUpper = true + case 'a' <= c && c <= 'z': + hasLower = true + case '0' <= c && c <= '9': + hasDigit = true + } + } + + return hasUpper && hasLower && hasDigit +} + +// ValidatePasswordWithDetail 验证密码强度并返回详细信息 +func ValidatePasswordWithDetail(password string) (bool, string) { + if len(password) < 8 { + return false, "密码长度至少8位" + } + if len(password) > 50 { + return false, "密码长度不能超过50位" + } + + var hasUpper, hasLower, hasDigit bool + for _, c := range password { + switch { + case 'A' <= c && c <= 'Z': + hasUpper = true + case 'a' <= c && c <= 'z': + hasLower = true + case '0' <= c && c <= '9': + hasDigit = true + } + } + + if !hasUpper { + return false, "密码必须包含大写字母" + } + if !hasLower { + return false, "密码必须包含小写字母" + } + if !hasDigit { + return false, "密码必须包含数字" + } + + return true, "" } // ValidatePhone 验证手机号 @@ -37,10 +84,13 @@ func ValidatePhone(phone string) bool { return matched } -// SanitizeHTML 清理HTML +// SanitizeHTML 清理HTML,防止XSS攻击 func SanitizeHTML(input string) string { - // 简单实现,实际使用建议用专门库 - input = strings.ReplaceAll(input, "<", "<") - input = strings.ReplaceAll(input, ">", ">") + input = strings.ReplaceAll(input, "&", "&") + input = strings.ReplaceAll(input, "<", "<") + input = strings.ReplaceAll(input, ">", ">") + input = strings.ReplaceAll(input, "\"", """) + input = strings.ReplaceAll(input, "'", "'") + input = strings.ReplaceAll(input, "/", "/") return input } diff --git a/internal/repository/comment_repo.go b/internal/repository/comment_repo.go index 8eab433..7835ac5 100644 --- a/internal/repository/comment_repo.go +++ b/internal/repository/comment_repo.go @@ -508,7 +508,7 @@ func (r *commentRepository) GetCommentsByCursor(ctx context.Context, postID stri if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.Comment]([]*model.Comment{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.Comment{}, "", "", false), nil } // 执行查询 @@ -629,7 +629,7 @@ func (r *commentRepository) GetRepliesByCursor(ctx context.Context, rootID strin if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.Comment]([]*model.Comment{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.Comment{}, "", "", false), nil } // 执行查询 diff --git a/internal/repository/group_repo.go b/internal/repository/group_repo.go index cbe2b43..7e96585 100644 --- a/internal/repository/group_repo.go +++ b/internal/repository/group_repo.go @@ -492,7 +492,7 @@ func (r *groupRepository) GetGroupsByCursor(ctx context.Context, req *cursor.Pag if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.Group]([]*model.Group{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.Group{}, "", "", false), nil } // 执行查询 @@ -551,7 +551,7 @@ func (r *groupRepository) GetUserGroupsByCursor(ctx context.Context, userID stri if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.Group]([]*model.Group{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.Group{}, "", "", false), nil } // 执行查询 @@ -605,7 +605,7 @@ func (r *groupRepository) GetMembersByCursor(ctx context.Context, groupID string if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.GroupMember]([]*model.GroupMember{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.GroupMember{}, "", "", false), nil } // 执行查询 @@ -660,7 +660,7 @@ func (r *groupRepository) GetAnnouncementsByCursor(ctx context.Context, groupID if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.GroupAnnouncement]([]*model.GroupAnnouncement{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.GroupAnnouncement{}, "", "", false), nil } // 执行查询(置顶的排在前面) diff --git a/internal/repository/message_repo.go b/internal/repository/message_repo.go index cdbed0d..b2bee1a 100644 --- a/internal/repository/message_repo.go +++ b/internal/repository/message_repo.go @@ -747,7 +747,7 @@ func (r *messageRepository) GetMessagesByCursor(ctx context.Context, conversatio if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.Message]([]*model.Message{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.Message{}, "", "", false), nil } // 执行查询 @@ -810,7 +810,7 @@ func (r *messageRepository) GetConversationsByCursor(ctx context.Context, userID if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.Conversation]([]*model.Conversation{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.Conversation{}, "", "", false), nil } // 执行查询 - 需要添加置顶排序 diff --git a/internal/repository/notification_repo.go b/internal/repository/notification_repo.go index 6aa8784..59c4d67 100644 --- a/internal/repository/notification_repo.go +++ b/internal/repository/notification_repo.go @@ -111,7 +111,7 @@ func (r *notificationRepository) GetNotificationsByCursor(ctx context.Context, u if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.Notification]([]*model.Notification{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.Notification{}, "", "", false), nil } // 执行查询 diff --git a/internal/repository/operation_log_repo.go b/internal/repository/operation_log_repo.go index b5a45b7..de7c21a 100644 --- a/internal/repository/operation_log_repo.go +++ b/internal/repository/operation_log_repo.go @@ -127,9 +127,10 @@ func (r *operationLogRepository) GetStatistics(ctx context.Context, startTime, e // 汇总结果 for _, r := range results { stats.TotalCount += r.Count - if r.Status == "success" { + switch r.Status { + case "success": stats.SuccessCount = r.Count - } else if r.Status == "fail" { + case "fail": stats.FailCount = r.Count } } diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index c6ade3e..0ef7e4f 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -996,7 +996,7 @@ func (r *postRepository) GetPostsByCursor(ctx context.Context, userID string, in if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.Post]([]*model.Post{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.Post{}, "", "", false), nil } // 执行查询 @@ -1064,7 +1064,7 @@ func (r *postRepository) SearchPostsByCursor(ctx context.Context, keyword string if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.Post]([]*model.Post{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.Post{}, "", "", false), nil } // 执行查询 @@ -1124,7 +1124,7 @@ func (r *postRepository) GetUserPostsByCursor(ctx context.Context, userID string if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.Post]([]*model.Post{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.Post{}, "", "", false), nil } // 执行查询 @@ -1180,7 +1180,7 @@ func (r *postRepository) GetFollowingPostsByCursor(ctx context.Context, userID s if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.Post]([]*model.Post{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.Post{}, "", "", false), nil } // 执行查询 @@ -1234,7 +1234,7 @@ func (r *postRepository) GetHotPostsByCursor(ctx context.Context, req *cursor.Pa if builder.Error() != nil { // 无效游标,返回空列表 - return cursor.NewCursorPageResult[*model.Post]([]*model.Post{}, "", "", false), nil + return cursor.NewCursorPageResult([]*model.Post{}, "", "", false), nil } // 执行查询 diff --git a/internal/repository/sensitive_word_repo.go b/internal/repository/sensitive_word_repo.go new file mode 100644 index 0000000..b754d67 --- /dev/null +++ b/internal/repository/sensitive_word_repo.go @@ -0,0 +1,66 @@ +package repository + +import ( + "context" + + "carrot_bbs/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type SensitiveWordRepository interface { + Create(ctx context.Context, word *model.SensitiveWord) error + GetByWord(ctx context.Context, word string) (*model.SensitiveWord, error) + Update(ctx context.Context, word *model.SensitiveWord) error + Upsert(ctx context.Context, word *model.SensitiveWord) error + DeactivateByWord(ctx context.Context, word string) error + ListActive(ctx context.Context) ([]model.SensitiveWord, error) +} + +type sensitiveWordRepository struct { + db *gorm.DB +} + +func NewSensitiveWordRepository(db *gorm.DB) SensitiveWordRepository { + return &sensitiveWordRepository{db: db} +} + +func (r *sensitiveWordRepository) Create(ctx context.Context, word *model.SensitiveWord) error { + return r.db.WithContext(ctx).Create(word).Error +} + +func (r *sensitiveWordRepository) GetByWord(ctx context.Context, word string) (*model.SensitiveWord, error) { + var sw model.SensitiveWord + err := r.db.WithContext(ctx).Where("word = ?", word).First(&sw).Error + if err != nil { + return nil, err + } + return &sw, nil +} + +func (r *sensitiveWordRepository) Update(ctx context.Context, word *model.SensitiveWord) error { + return r.db.WithContext(ctx).Save(word).Error +} + +func (r *sensitiveWordRepository) Upsert(ctx context.Context, word *model.SensitiveWord) error { + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "word"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "category", "level", "is_active", "updated_by", "remark", "updated_at", + }), + }).Create(word).Error +} + +func (r *sensitiveWordRepository) DeactivateByWord(ctx context.Context, word string) error { + return r.db.WithContext(ctx). + Model(&model.SensitiveWord{}). + Where("word = ?", word). + Update("is_active", false).Error +} + +func (r *sensitiveWordRepository) ListActive(ctx context.Context) ([]model.SensitiveWord, error) { + var words []model.SensitiveWord + err := r.db.WithContext(ctx).Where("is_active = ?", true).Find(&words).Error + return words, err +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index c0e5f7f..c1448a7 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -377,21 +377,15 @@ func (r *userRepository) BlockUserAndCleanupRelations(blockerID, blockedID strin } for _, uid := range []string{blockerID, blockedID} { - var followersCount int64 - if err := tx.Model(&model.Follow{}).Where("following_id = ?", uid).Count(&followersCount).Error; err != nil { - return err - } if err := tx.Model(&model.User{}).Where("id = ?", uid). - UpdateColumn("followers_count", followersCount).Error; err != nil { + UpdateColumn("followers_count", tx.Model(&model.Follow{}). + Select("COUNT(*)").Where("following_id = ?", uid)).Error; err != nil { return err } - var followingCount int64 - if err := tx.Model(&model.Follow{}).Where("follower_id = ?", uid).Count(&followingCount).Error; err != nil { - return err - } if err := tx.Model(&model.User{}).Where("id = ?", uid). - UpdateColumn("following_count", followingCount).Error; err != nil { + UpdateColumn("following_count", tx.Model(&model.Follow{}). + Select("COUNT(*)").Where("follower_id = ?", uid)).Error; err != nil { return err } } diff --git a/internal/service/group_service.go b/internal/service/group_service.go index 82e9c6f..982d668 100644 --- a/internal/service/group_service.go +++ b/internal/service/group_service.go @@ -207,7 +207,7 @@ func (s *groupService) CreateGroup(ownerID string, name string, description stri Name: name, Description: description, OwnerID: ownerID, - MemberCount: 1, // 群主 + MemberCount: 0, MaxMembers: 500, JoinType: model.JoinTypeAnyone, MuteAll: false, diff --git a/internal/service/sensitive_service.go b/internal/service/sensitive_service.go index a95998d..60beb02 100644 --- a/internal/service/sensitive_service.go +++ b/internal/service/sensitive_service.go @@ -12,9 +12,9 @@ import ( "carrot_bbs/internal/model" redisclient "carrot_bbs/internal/pkg/redis" + "carrot_bbs/internal/repository" "go.uber.org/zap" - "gorm.io/gorm" ) // ==================== DFA 敏感词过滤实现 ==================== @@ -250,7 +250,7 @@ type SensitiveService interface { // sensitiveServiceImpl 敏感词服务实现 type sensitiveServiceImpl struct { tree *SensitiveWordTree - db *gorm.DB + repo repository.SensitiveWordRepository redis *redisclient.Client config *SensitiveConfig mu sync.RWMutex @@ -272,10 +272,10 @@ type SensitiveConfig struct { } // NewSensitiveService 创建敏感词服务 -func NewSensitiveService(db *gorm.DB, redisClient *redisclient.Client, config *SensitiveConfig) SensitiveService { +func NewSensitiveService(repo repository.SensitiveWordRepository, redisClient *redisclient.Client, config *SensitiveConfig) SensitiveService { s := &sensitiveServiceImpl{ tree: NewSensitiveWordTree(), - db: db, + repo: repo, redis: redisClient, config: config, replaceStr: config.ReplaceStr, @@ -356,33 +356,16 @@ func (s *sensitiveServiceImpl) AddWord(ctx context.Context, word string, categor s.tree.AddWord(word, wordLevel, wordCategory) // 持久化到数据库 - if s.db != nil { - sensitiveWord := model.SensitiveWord{ + if s.repo != nil { + sensitiveWord := &model.SensitiveWord{ Word: word, Category: wordCategory, Level: wordLevel, IsActive: true, } - // 使用 upsert 逻辑 - var existing model.SensitiveWord - result := s.db.Where("word = ?", word).First(&existing) - if result.Error == gorm.ErrRecordNotFound { - if err := s.db.Create(&sensitiveWord).Error; err != nil { - zap.L().Warn("Failed to save sensitive word to database", - zap.Error(err), - ) - } - } else if result.Error == nil { - // 更新已存在的记录 - existing.Category = wordCategory - existing.Level = wordLevel - existing.IsActive = true - if err := s.db.Save(&existing).Error; err != nil { - zap.L().Warn("Failed to update sensitive word in database", - zap.Error(err), - ) - } + if err := s.repo.Upsert(ctx, sensitiveWord); err != nil { + return fmt.Errorf("failed to upsert sensitive word to database: %w", err) } } @@ -411,12 +394,9 @@ func (s *sensitiveServiceImpl) RemoveWord(ctx context.Context, word string) erro s.tree.RemoveWord(word) // 从数据库中标记为不活跃 - if s.db != nil { - result := s.db.Model(&model.SensitiveWord{}).Where("word = ?", word).Update("is_active", false) - if result.Error != nil { - zap.L().Warn("Failed to deactivate sensitive word in database", - zap.Error(result.Error), - ) + if s.repo != nil { + if err := s.repo.DeactivateByWord(ctx, word); err != nil { + return fmt.Errorf("failed to deactivate sensitive word in database: %w", err) } } @@ -458,12 +438,12 @@ func (s *sensitiveServiceImpl) GetWordCount(ctx context.Context) int { // loadFromDB 从数据库加载敏感词 func (s *sensitiveServiceImpl) loadFromDB(ctx context.Context) error { - if s.db == nil { + if s.repo == nil { return nil } - var words []model.SensitiveWord - if err := s.db.Where("is_active = ?", true).Find(&words).Error; err != nil { + words, err := s.repo.ListActive(ctx) + if err != nil { return err } diff --git a/internal/service/user_service.go b/internal/service/user_service.go index d0093af..2f6d8c9 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -278,6 +278,9 @@ func (s *userServiceImpl) Login(ctx context.Context, account, password string) ( var failReason string if err != nil || user == nil { + // 时序攻击防护:执行假的密码哈希验证,保持响应时间一致 + _ = utils.CheckPasswordHash(password, "$2a$10$fakehashfortimingattackprevention1234567890") + loginResult = string(model.LoginResultFail) failReason = string(model.FailReasonUserNotFound) diff --git a/internal/wire/repository.go b/internal/wire/repository.go index 18b4cb5..2017973 100644 --- a/internal/wire/repository.go +++ b/internal/wire/repository.go @@ -37,6 +37,9 @@ var RepositorySet = wire.NewSet( // 身份认证相关仓储 repository.NewVerificationRepository, + + // 敏感词相关仓储 + repository.NewSensitiveWordRepository, ) // ProvideUserActivityRepository 提供用户活跃数据仓储 diff --git a/internal/wire/service.go b/internal/wire/service.go index 438ec72..6e676d3 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -60,6 +60,7 @@ var ServiceSet = wire.NewSet( ProvideAdminReportService, ProvideVerificationService, ProvideAdminVerificationService, + ProvideSensitiveService, // 日志服务 ProvideAsyncLogManager, @@ -445,3 +446,20 @@ func ProvideAdminVerificationService( ) service.AdminVerificationService { return service.NewAdminVerificationService(verificationRepo, userRepo) } + +// ProvideSensitiveService 提供敏感词服务 +func ProvideSensitiveService( + sensitiveWordRepo repository.SensitiveWordRepository, + redisClient *redis.Client, + cfg *config.Config, +) service.SensitiveService { + sensitiveCfg := &service.SensitiveConfig{ + Enabled: cfg.Sensitive.Enabled, + ReplaceStr: cfg.Sensitive.ReplaceStr, + MinMatchLen: cfg.Sensitive.MinMatchLen, + LoadFromDB: cfg.Sensitive.LoadFromDB, + LoadFromRedis: cfg.Sensitive.LoadFromRedis, + RedisKeyPrefix: cfg.Sensitive.RedisKeyPrefix, + } + return service.NewSensitiveService(sensitiveWordRepo, redisClient, sensitiveCfg) +}