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 提供推送服务