From 38d628c6960b6ed89fd05f685778f9e28aa62584 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 21 Apr 2026 22:31:30 +0800 Subject: [PATCH] feat(moderation): add Tencent TMS as fallback for AI content moderation Add Tencent Cloud Text Moderation System (TMS) as a fallback moderation service when OpenAI is unavailable or returns an error. The service now checks OpenAI first, and falls back to Tencent TMS if it fails, providing better reliability for content moderation. Features: - New Tencent TMS configuration in config.yaml with environment variable overrides - New tencent package in internal/pkg/tencent/ with TMS client implementation - PostAIService now uses dual moderation with OpenAI primary and Tencent fallback - Strict mode configuration passed to PostAIService for consistent behavior BREAKING CHANGE: NewPostAIService now requires tencentClient and strictMode parameters - update wire configuration accordingly. --- .kilo/package-lock.json | 115 +++++++++++++++++ cmd/server/wire_gen.go | 3 +- configs/config.yaml | 13 ++ internal/config/config.go | 14 ++ internal/config/external.go | 10 ++ internal/pkg/tencent/client.go | 193 ++++++++++++++++++++++++++++ internal/pkg/tencent/config.go | 58 +++++++++ internal/service/post_ai_service.go | 169 +++++++++++++++++------- internal/wire/infrastructure.go | 14 ++ internal/wire/service.go | 5 +- 10 files changed, 546 insertions(+), 48 deletions(-) create mode 100644 .kilo/package-lock.json create mode 100644 internal/pkg/tencent/client.go create mode 100644 internal/pkg/tencent/config.go diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json new file mode 100644 index 0000000..86ea34f --- /dev/null +++ b/.kilo/package-lock.json @@ -0,0 +1,115 @@ +{ + "name": ".kilo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@kilocode/plugin": "7.2.10" + } + }, + "node_modules/@kilocode/plugin": { + "version": "7.2.10", + "resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.10.tgz", + "integrity": "sha512-VJPhJC+E5WWu7XgEJzrVOxKJlwJ+OATwxEzgjqEPj8KN5N38YxUPBY/rzUTjv90x7nkzyk1rFGfCVqXdA/Koug==", + "license": "MIT", + "dependencies": { + "@kilocode/sdk": "7.2.10", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.97", + "@opentui/solid": ">=0.1.97" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@kilocode/sdk": { + "version": "7.2.10", + "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.10.tgz", + "integrity": "sha512-H6jGXYAhN/yjOGX3MRZ0OxyEAuRGY3VOwDbLTh4O6ljpgutFHaLvomDZ82qNVy7gl7AjJgi3SAQAt9UQpeGl/w==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index 18420f0..80b9fd8 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -54,7 +54,8 @@ func InitializeApp() (*App, error) { userProfileAuditRepository := repository.NewUserProfileAuditRepository(db) manager := wire.ProvideHookManager() openaiClient := wire.ProvideOpenAIClient(config) - postAIService := wire.ProvidePostAIService(openaiClient) + tencentClient := wire.ProvideTencentClient(config) + postAIService := wire.ProvidePostAIService(openaiClient, tencentClient, config) commentRepository := repository.NewCommentRepository(db) sensitiveWordRepository := repository.NewSensitiveWordRepository(db) sensitiveService := wire.ProvideSensitiveService(sensitiveWordRepository, client, config) diff --git a/configs/config.yaml b/configs/config.yaml index 7872949..0115a18 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -176,6 +176,19 @@ openai: request_timeout: 30 strict_moderation: false +# 腾讯云文本内容安全配置(作为 OpenAI 备用) +# 当 OpenAI 不可用或审核失败时,使用腾讯TMS进行文本审核 +# 环境变量: +# APP_TENCENT_TMS_ENABLED, APP_TENCENT_TMS_SECRET_ID, APP_TENCENT_TMS_SECRET_KEY +# APP_TENCENT_TMS_REGION, APP_TENCENT_TMS_BIZ_TYPE, APP_TENCENT_TMS_TIMEOUT +tencent_tms: + enabled: false + secret_id: "" + secret_key: "" + region: "ap-guangzhou" + biz_type: "" # 可选,使用控制台配置的自定义策略 + timeout: 30 + # SMTP发信配置(gomail.v2) # 环境变量: # APP_EMAIL_ENABLED, APP_EMAIL_HOST, APP_EMAIL_PORT diff --git a/internal/config/config.go b/internal/config/config.go index 8dd55b2..0024212 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,7 @@ type Config struct { RateLimit RateLimitConfig `mapstructure:"rate_limit"` Upload UploadConfig `mapstructure:"upload"` OpenAI OpenAIConfig `mapstructure:"openai"` + TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"` Email EmailConfig `mapstructure:"email"` ConversationCache ConversationCacheConfig `mapstructure:"conversation_cache"` GRPC GRPCConfig `mapstructure:"grpc"` @@ -111,6 +112,12 @@ func Load(configPath string) (*Config, error) { viper.SetDefault("openai.moderation_max_images_per_request", 1) viper.SetDefault("openai.request_timeout", 30) viper.SetDefault("openai.strict_moderation", false) + viper.SetDefault("tencent_tms.enabled", false) + viper.SetDefault("tencent_tms.secret_id", "") + viper.SetDefault("tencent_tms.secret_key", "") + viper.SetDefault("tencent_tms.region", "ap-guangzhou") + viper.SetDefault("tencent_tms.biz_type", "") + viper.SetDefault("tencent_tms.timeout", 30) viper.SetDefault("email.enabled", false) viper.SetDefault("email.host", "") viper.SetDefault("email.port", 587) @@ -221,6 +228,13 @@ func Load(configPath string) (*Config, error) { cfg.OpenAI.RequestTimeout, _ = strconv.Atoi(getEnvOrDefault("APP_OPENAI_REQUEST_TIMEOUT", fmt.Sprintf("%d", cfg.OpenAI.RequestTimeout))) cfg.OpenAI.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_OPENAI_ENABLED", fmt.Sprintf("%t", cfg.OpenAI.Enabled))) cfg.OpenAI.StrictModeration, _ = strconv.ParseBool(getEnvOrDefault("APP_OPENAI_STRICT_MODERATION", fmt.Sprintf("%t", cfg.OpenAI.StrictModeration))) + // TencentTMS 环境变量覆盖 + cfg.TencentTMS.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_TENCENT_TMS_ENABLED", fmt.Sprintf("%t", cfg.TencentTMS.Enabled))) + cfg.TencentTMS.SecretID = getEnvOrDefault("APP_TENCENT_TMS_SECRET_ID", cfg.TencentTMS.SecretID) + cfg.TencentTMS.SecretKey = getEnvOrDefault("APP_TENCENT_TMS_SECRET_KEY", cfg.TencentTMS.SecretKey) + cfg.TencentTMS.Region = getEnvOrDefault("APP_TENCENT_TMS_REGION", cfg.TencentTMS.Region) + cfg.TencentTMS.BizType = getEnvOrDefault("APP_TENCENT_TMS_BIZ_TYPE", cfg.TencentTMS.BizType) + cfg.TencentTMS.Timeout, _ = strconv.Atoi(getEnvOrDefault("APP_TENCENT_TMS_TIMEOUT", fmt.Sprintf("%d", cfg.TencentTMS.Timeout))) cfg.Email.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_ENABLED", fmt.Sprintf("%t", cfg.Email.Enabled))) cfg.Email.Host = getEnvOrDefault("APP_EMAIL_HOST", cfg.Email.Host) cfg.Email.Port, _ = strconv.Atoi(getEnvOrDefault("APP_EMAIL_PORT", fmt.Sprintf("%d", cfg.Email.Port))) diff --git a/internal/config/external.go b/internal/config/external.go index fb16378..dede0d3 100644 --- a/internal/config/external.go +++ b/internal/config/external.go @@ -24,3 +24,13 @@ type OpenAIConfig struct { RequestTimeout int `mapstructure:"request_timeout"` StrictModeration bool `mapstructure:"strict_moderation"` } + +// TencentTMSConfig 腾讯云文本内容安全配置(作为 OpenAI 审核的备用) +type TencentTMSConfig struct { + Enabled bool `mapstructure:"enabled"` + SecretID string `mapstructure:"secret_id"` + SecretKey string `mapstructure:"secret_key"` + Region string `mapstructure:"region"` + BizType string `mapstructure:"biz_type"` + Timeout int `mapstructure:"timeout"` +} diff --git a/internal/pkg/tencent/client.go b/internal/pkg/tencent/client.go new file mode 100644 index 0000000..2a963c9 --- /dev/null +++ b/internal/pkg/tencent/client.go @@ -0,0 +1,193 @@ +package tencent + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "go.uber.org/zap" +) + +const ( + host = "tms.tencentcloudapi.com" + service = "tms" + version = "2020-12-29" + action = "TextModeration" + algorithm = "TC3-HMAC-SHA256" +) + +type Client interface { + IsEnabled() bool + TextModeration(ctx context.Context, content string) (*ModerationResponse, error) +} + +type clientImpl struct { + cfg Config + httpClient *http.Client +} + +func NewClient(cfg Config) Client { + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 30 + } + return &clientImpl{ + cfg: cfg, + httpClient: &http.Client{ + Timeout: time.Duration(timeout) * time.Second, + }, + } +} + +func (c *clientImpl) IsEnabled() bool { + return c.cfg.Enabled && c.cfg.SecretID != "" && c.cfg.SecretKey != "" +} + +func (c *clientImpl) TextModeration(ctx context.Context, content string) (*ModerationResponse, error) { + if !c.IsEnabled() { + return nil, fmt.Errorf("tencent tms client is not enabled") + } + + if strings.TrimSpace(content) == "" { + return &ModerationResponse{Suggestion: SuggestionPass}, nil + } + + encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) + reqBody := textModerationRequest{ + Content: encodedContent, + BizType: c.cfg.BizType, + } + payload, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + timestamp := time.Now().Unix() + authorization, err := c.sign(string(payload), timestamp) + if err != nil { + return nil, fmt.Errorf("sign request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+host, strings.NewReader(string(payload))) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", authorization) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Host", host) + req.Header.Set("X-TC-Action", action) + req.Header.Set("X-TC-Timestamp", strconv.FormatInt(timestamp, 10)) + req.Header.Set("X-TC-Version", version) + req.Header.Set("X-TC-Region", c.cfg.Region) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request tencent tms: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("tencent tms error status=%d body=%s", resp.StatusCode, string(body)) + } + + var apiResp tencentAPIResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + + if apiResp.Response.Error != nil { + return nil, fmt.Errorf("tencent tms api error: code=%s message=%s", + apiResp.Response.Error.Code, + apiResp.Response.Error.Message) + } + + keywords := make([]string, 0, len(apiResp.Response.Keywords)) + for _, kw := range apiResp.Response.Keywords { + keywords = append(keywords, kw.Word) + } + + result := &ModerationResponse{ + Suggestion: ModerationSuggestion(apiResp.Response.Suggestion), + Label: apiResp.Response.Label, + RiskLevel: apiResp.Response.RiskLevel, + Keywords: keywords, + RequestID: apiResp.Response.RequestID, + } + + zap.L().Debug("Tencent TMS moderation result", + zap.String("suggestion", string(result.Suggestion)), + zap.String("label", result.Label), + zap.String("risk_level", result.RiskLevel), + zap.Strings("keywords", result.Keywords), + zap.String("request_id", result.RequestID), + ) + + return result, nil +} + +func (c *clientImpl) sign(payload string, timestamp int64) (string, error) { + date := time.Unix(timestamp, 0).UTC().Format("2006-01-02") + + canonicalHeaders := "content-type:application/json; charset=utf-8\n" + "host:" + host + "\n" + signedHeaders := "content-type;host" + hashedPayload := sha256hex(payload) + + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", + "POST", + "/", + "", + canonicalHeaders, + signedHeaders, + hashedPayload, + ) + + credentialScope := fmt.Sprintf("%s/%s/tc3_request", date, service) + hashedCanonicalRequest := sha256hex(canonicalRequest) + string2sign := fmt.Sprintf("%s\n%d\n%s\n%s", + algorithm, + timestamp, + credentialScope, + hashedCanonicalRequest, + ) + + secretDate := hmacsha256(date, []byte("TC3"+c.cfg.SecretKey)) + secretService := hmacsha256(service, secretDate) + secretSigning := hmacsha256("tc3_request", secretService) + signature := hex.EncodeToString(hmacsha256(string2sign, secretSigning)) + + authorization := fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", + algorithm, + c.cfg.SecretID, + credentialScope, + signedHeaders, + signature, + ) + + return authorization, nil +} + +func sha256hex(s string) string { + b := sha256.Sum256([]byte(s)) + return hex.EncodeToString(b[:]) +} + +func hmacsha256(s string, key []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write([]byte(s)) + return h.Sum(nil) +} diff --git a/internal/pkg/tencent/config.go b/internal/pkg/tencent/config.go new file mode 100644 index 0000000..906d184 --- /dev/null +++ b/internal/pkg/tencent/config.go @@ -0,0 +1,58 @@ +package tencent + +type Config struct { + Enabled bool + SecretID string + SecretKey string + Region string + BizType string + Timeout int +} + +type ModerationSuggestion string + +const ( + SuggestionPass ModerationSuggestion = "Pass" + SuggestionReview ModerationSuggestion = "Review" + SuggestionBlock ModerationSuggestion = "Block" +) + +type ModerationResponse struct { + Suggestion ModerationSuggestion + Label string + RiskLevel string + Keywords []string + RequestID string +} + +type textModerationRequest struct { + Content string `json:"Content"` + BizType string `json:"BizType,omitempty"` +} + +type tencentAPIResponse struct { + Response struct { + Suggestion string `json:"Suggestion"` + Label string `json:"Label"` + RiskLevel string `json:"RiskLevel"` + Keywords []struct { + Word string `json:"Word"` + } `json:"Keywords,omitempty"` + RequestID string `json:"RequestId"` + Error *struct { + Code string `json:"Code"` + Message string `json:"Message"` + } `json:"Error,omitempty"` + } `json:"Response"` +} + +func ConfigFromAppConfig(cfg *Config) Config { + return Config{ + Enabled: cfg.Enabled, + SecretID: cfg.SecretID, + SecretKey: cfg.SecretKey, + Region: cfg.Region, + BizType: cfg.BizType, + Timeout: cfg.Timeout, + } +} diff --git a/internal/service/post_ai_service.go b/internal/service/post_ai_service.go index 1119662..541bcc7 100644 --- a/internal/service/post_ai_service.go +++ b/internal/service/post_ai_service.go @@ -2,9 +2,11 @@ package service import ( "context" + "fmt" "strings" "carrot_bbs/internal/pkg/openai" + "carrot_bbs/internal/pkg/tencent" "go.uber.org/zap" ) @@ -170,43 +172,66 @@ func (e *BioModerationReviewError) IsReview() bool { } type PostAIService struct { - openAIClient openai.Client + openAIClient openai.Client + tencentClient tencent.Client + strictMode bool } -func NewPostAIService(openAIClient openai.Client) *PostAIService { +func NewPostAIService(openAIClient openai.Client, tencentClient tencent.Client, strictMode bool) *PostAIService { return &PostAIService{ - openAIClient: openAIClient, + openAIClient: openAIClient, + tencentClient: tencentClient, + strictMode: strictMode, } } func (s *PostAIService) IsEnabled() bool { + if s == nil { + return false + } + return (s.openAIClient != nil && s.openAIClient.IsEnabled()) || + (s.tencentClient != nil && s.tencentClient.IsEnabled()) +} + +func (s *PostAIService) isOpenAIEnabled() bool { return s != nil && s.openAIClient != nil && s.openAIClient.IsEnabled() } +func (s *PostAIService) isTencentEnabled() bool { + return s != nil && s.tencentClient != nil && s.tencentClient.IsEnabled() +} + func (s *PostAIService) ModeratePost(ctx context.Context, title, content string, images []string) error { if !s.IsEnabled() { return nil } - resp, err := s.openAIClient.ModeratePost(ctx, title, content, images) - if err != nil { - if s.openAIClient.Config().StrictModeration { - return err + if s.isOpenAIEnabled() { + resp, err := s.openAIClient.ModeratePost(ctx, title, content, images) + if err != nil { + zap.L().Warn("AI post moderation failed, trying Tencent fallback", + zap.Error(err), + ) + return s.moderateTextWithTencent(ctx, title+" "+content, + func(reason string) error { return &PostModerationRejectedError{Reason: reason} }, + func(reason string) error { return &PostModerationReviewError{Reason: reason} }, + ) + } + + switch resp.Result { + case openai.ModerationResultBlock: + return &PostModerationRejectedError{Reason: resp.Reason} + case openai.ModerationResultReview: + return &PostModerationReviewError{Reason: resp.Reason} + default: + return nil } - zap.L().Warn("AI moderation failed, fallback allow", - zap.Error(err), - ) - return nil } - switch resp.Result { - case openai.ModerationResultBlock: - return &PostModerationRejectedError{Reason: resp.Reason} - case openai.ModerationResultReview: - return &PostModerationReviewError{Reason: resp.Reason} - default: - return nil - } + return s.moderateTextWithTencent(ctx, title+" "+content, + func(reason string) error { return &PostModerationRejectedError{Reason: reason} }, + func(reason string) error { return &PostModerationReviewError{Reason: reason} }, + ) } func (s *PostAIService) ModerateComment(ctx context.Context, content string, images []string) error { @@ -214,35 +239,42 @@ func (s *PostAIService) ModerateComment(ctx context.Context, content string, ima return nil } - resp, err := s.openAIClient.ModerateComment(ctx, content, images) - if err != nil { - if s.openAIClient.Config().StrictModeration { - return err + if s.isOpenAIEnabled() { + resp, err := s.openAIClient.ModerateComment(ctx, content, images) + if err != nil { + zap.L().Warn("AI comment moderation failed, trying Tencent fallback", + zap.Error(err), + ) + return s.moderateTextWithTencent(ctx, content, + func(reason string) error { return &CommentModerationRejectedError{Reason: reason} }, + func(reason string) error { return &CommentModerationReviewError{Reason: reason} }, + ) + } + + switch resp.Result { + case openai.ModerationResultBlock: + return &CommentModerationRejectedError{Reason: resp.Reason} + case openai.ModerationResultReview: + return &CommentModerationReviewError{Reason: resp.Reason} + default: + return nil } - zap.L().Warn("AI comment moderation failed, fallback allow", - zap.Error(err), - ) - return nil } - switch resp.Result { - case openai.ModerationResultBlock: - return &CommentModerationRejectedError{Reason: resp.Reason} - case openai.ModerationResultReview: - return &CommentModerationReviewError{Reason: resp.Reason} - default: - return nil - } + return s.moderateTextWithTencent(ctx, content, + func(reason string) error { return &CommentModerationRejectedError{Reason: reason} }, + func(reason string) error { return &CommentModerationReviewError{Reason: reason} }, + ) } func (s *PostAIService) ModerateImage(ctx context.Context, imageURL string) error { - if !s.IsEnabled() { + if !s.isOpenAIEnabled() { return nil } resp, err := s.openAIClient.ModerateImage(ctx, imageURL) if err != nil { - if s.openAIClient.Config().StrictModeration { + if s.strictMode { return err } zap.L().Warn("AI image moderation failed, fallback allow", @@ -266,22 +298,69 @@ func (s *PostAIService) ModerateBio(ctx context.Context, bio string) error { return nil } - resp, err := s.openAIClient.ModerateBio(ctx, bio) + if s.isOpenAIEnabled() { + resp, err := s.openAIClient.ModerateBio(ctx, bio) + if err != nil { + zap.L().Warn("AI bio moderation failed, trying Tencent fallback", + zap.Error(err), + ) + return s.moderateTextWithTencent(ctx, bio, + func(reason string) error { return &BioModerationRejectedError{Reason: reason} }, + func(reason string) error { return &BioModerationReviewError{Reason: reason} }, + ) + } + + switch resp.Result { + case openai.ModerationResultBlock: + return &BioModerationRejectedError{Reason: resp.Reason} + case openai.ModerationResultReview: + return &BioModerationReviewError{Reason: resp.Reason} + default: + return nil + } + } + + return s.moderateTextWithTencent(ctx, bio, + func(reason string) error { return &BioModerationRejectedError{Reason: reason} }, + func(reason string) error { return &BioModerationReviewError{Reason: reason} }, + ) +} + +func (s *PostAIService) moderateTextWithTencent( + ctx context.Context, + text string, + rejectFactory func(string) error, + reviewFactory func(string) error, +) error { + if !s.isTencentEnabled() { + if s.strictMode { + return fmt.Errorf("no moderation service available") + } + zap.L().Warn("No moderation service available, fallback allow") + return nil + } + + resp, err := s.tencentClient.TextModeration(ctx, text) if err != nil { - if s.openAIClient.Config().StrictModeration { + if s.strictMode { return err } - zap.L().Warn("AI bio moderation failed, fallback allow", + zap.L().Warn("Tencent TMS moderation failed, fallback allow", zap.Error(err), ) return nil } - switch resp.Result { - case openai.ModerationResultBlock: - return &BioModerationRejectedError{Reason: resp.Reason} - case openai.ModerationResultReview: - return &BioModerationReviewError{Reason: resp.Reason} + reason := resp.Label + if len(resp.Keywords) > 0 { + reason = resp.Label + ": " + strings.Join(resp.Keywords, ", ") + } + + switch resp.Suggestion { + case tencent.SuggestionBlock: + return rejectFactory(reason) + case tencent.SuggestionReview: + return reviewFactory(reason) default: return nil } diff --git a/internal/wire/infrastructure.go b/internal/wire/infrastructure.go index dc9e4e0..9a76012 100644 --- a/internal/wire/infrastructure.go +++ b/internal/wire/infrastructure.go @@ -12,6 +12,7 @@ import ( "carrot_bbs/internal/pkg/openai" "carrot_bbs/internal/pkg/redis" "carrot_bbs/internal/pkg/s3" + "carrot_bbs/internal/pkg/tencent" "carrot_bbs/internal/pkg/ws" "carrot_bbs/internal/repository" "carrot_bbs/internal/service" @@ -49,6 +50,7 @@ var InfrastructureSet = wire.NewSet( // 外部服务客户端 ProvideOpenAIClient, + ProvideTencentClient, ProvideEmailClient, ProvideS3Client, @@ -169,6 +171,18 @@ func ProvideOpenAIClient(cfg *config.Config) openai.Client { return openai.NewClient(openai.ConfigFromAppConfig(&cfg.OpenAI)) } +// ProvideTencentClient 提供腾讯云TMS客户端 +func ProvideTencentClient(cfg *config.Config) tencent.Client { + return tencent.NewClient(tencent.Config{ + Enabled: cfg.TencentTMS.Enabled, + SecretID: cfg.TencentTMS.SecretID, + SecretKey: cfg.TencentTMS.SecretKey, + Region: cfg.TencentTMS.Region, + BizType: cfg.TencentTMS.BizType, + Timeout: cfg.TencentTMS.Timeout, + }) +} + // ProvideEmailClient 提供邮件客户端 func ProvideEmailClient(cfg *config.Config) email.Client { return email.NewClient(email.ConfigFromAppConfig(&cfg.Email)) diff --git a/internal/wire/service.go b/internal/wire/service.go index cafff6e..59b9fbe 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -11,6 +11,7 @@ import ( "carrot_bbs/internal/pkg/openai" "carrot_bbs/internal/pkg/redis" "carrot_bbs/internal/pkg/s3" + "carrot_bbs/internal/pkg/tencent" "carrot_bbs/internal/pkg/ws" "carrot_bbs/internal/repository" "carrot_bbs/internal/service" @@ -87,8 +88,8 @@ func ProvideEmailService(client email.Client) service.EmailService { } // ProvidePostAIService 提供 AI 服务 -func ProvidePostAIService(client openai.Client) *service.PostAIService { - return service.NewPostAIService(client) +func ProvidePostAIService(openAIClient openai.Client, tencentClient tencent.Client, cfg *config.Config) *service.PostAIService { + return service.NewPostAIService(openAIClient, tencentClient, cfg.OpenAI.StrictModeration) } // ProvidePushService 提供推送服务