feat(moderation): add Tencent TMS as fallback for AI content moderation
Some checks failed
Build Backend / build (push) Failing after 45s
Build Backend / build-docker (push) Has been skipped

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.
This commit is contained in:
lafay
2026-04-21 22:31:30 +08:00
parent 90495385cd
commit 38d628c696
10 changed files with 546 additions and 48 deletions

115
.kilo/package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

View File

@@ -54,7 +54,8 @@ func InitializeApp() (*App, error) {
userProfileAuditRepository := repository.NewUserProfileAuditRepository(db) userProfileAuditRepository := repository.NewUserProfileAuditRepository(db)
manager := wire.ProvideHookManager() manager := wire.ProvideHookManager()
openaiClient := wire.ProvideOpenAIClient(config) openaiClient := wire.ProvideOpenAIClient(config)
postAIService := wire.ProvidePostAIService(openaiClient) tencentClient := wire.ProvideTencentClient(config)
postAIService := wire.ProvidePostAIService(openaiClient, tencentClient, config)
commentRepository := repository.NewCommentRepository(db) commentRepository := repository.NewCommentRepository(db)
sensitiveWordRepository := repository.NewSensitiveWordRepository(db) sensitiveWordRepository := repository.NewSensitiveWordRepository(db)
sensitiveService := wire.ProvideSensitiveService(sensitiveWordRepository, client, config) sensitiveService := wire.ProvideSensitiveService(sensitiveWordRepository, client, config)

View File

@@ -176,6 +176,19 @@ openai:
request_timeout: 30 request_timeout: 30
strict_moderation: false 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 # SMTP发信配置gomail.v2
# 环境变量: # 环境变量:
# APP_EMAIL_ENABLED, APP_EMAIL_HOST, APP_EMAIL_PORT # APP_EMAIL_ENABLED, APP_EMAIL_HOST, APP_EMAIL_PORT

View File

@@ -23,6 +23,7 @@ type Config struct {
RateLimit RateLimitConfig `mapstructure:"rate_limit"` RateLimit RateLimitConfig `mapstructure:"rate_limit"`
Upload UploadConfig `mapstructure:"upload"` Upload UploadConfig `mapstructure:"upload"`
OpenAI OpenAIConfig `mapstructure:"openai"` OpenAI OpenAIConfig `mapstructure:"openai"`
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
Email EmailConfig `mapstructure:"email"` Email EmailConfig `mapstructure:"email"`
ConversationCache ConversationCacheConfig `mapstructure:"conversation_cache"` ConversationCache ConversationCacheConfig `mapstructure:"conversation_cache"`
GRPC GRPCConfig `mapstructure:"grpc"` 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.moderation_max_images_per_request", 1)
viper.SetDefault("openai.request_timeout", 30) viper.SetDefault("openai.request_timeout", 30)
viper.SetDefault("openai.strict_moderation", false) 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.enabled", false)
viper.SetDefault("email.host", "") viper.SetDefault("email.host", "")
viper.SetDefault("email.port", 587) 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.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.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))) 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.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.Host = getEnvOrDefault("APP_EMAIL_HOST", cfg.Email.Host)
cfg.Email.Port, _ = strconv.Atoi(getEnvOrDefault("APP_EMAIL_PORT", fmt.Sprintf("%d", cfg.Email.Port))) cfg.Email.Port, _ = strconv.Atoi(getEnvOrDefault("APP_EMAIL_PORT", fmt.Sprintf("%d", cfg.Email.Port)))

View File

@@ -24,3 +24,13 @@ type OpenAIConfig struct {
RequestTimeout int `mapstructure:"request_timeout"` RequestTimeout int `mapstructure:"request_timeout"`
StrictModeration bool `mapstructure:"strict_moderation"` 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"`
}

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -2,9 +2,11 @@ package service
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"carrot_bbs/internal/pkg/openai" "carrot_bbs/internal/pkg/openai"
"carrot_bbs/internal/pkg/tencent"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -170,43 +172,66 @@ func (e *BioModerationReviewError) IsReview() bool {
} }
type PostAIService struct { 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{ return &PostAIService{
openAIClient: openAIClient, openAIClient: openAIClient,
tencentClient: tencentClient,
strictMode: strictMode,
} }
} }
func (s *PostAIService) IsEnabled() bool { 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() 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 { func (s *PostAIService) ModeratePost(ctx context.Context, title, content string, images []string) error {
if !s.IsEnabled() { if !s.IsEnabled() {
return nil return nil
} }
resp, err := s.openAIClient.ModeratePost(ctx, title, content, images) if s.isOpenAIEnabled() {
if err != nil { resp, err := s.openAIClient.ModeratePost(ctx, title, content, images)
if s.openAIClient.Config().StrictModeration { if err != nil {
return err 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 { return s.moderateTextWithTencent(ctx, title+" "+content,
case openai.ModerationResultBlock: func(reason string) error { return &PostModerationRejectedError{Reason: reason} },
return &PostModerationRejectedError{Reason: resp.Reason} func(reason string) error { return &PostModerationReviewError{Reason: reason} },
case openai.ModerationResultReview: )
return &PostModerationReviewError{Reason: resp.Reason}
default:
return nil
}
} }
func (s *PostAIService) ModerateComment(ctx context.Context, content string, images []string) error { 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 return nil
} }
resp, err := s.openAIClient.ModerateComment(ctx, content, images) if s.isOpenAIEnabled() {
if err != nil { resp, err := s.openAIClient.ModerateComment(ctx, content, images)
if s.openAIClient.Config().StrictModeration { if err != nil {
return err 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 { return s.moderateTextWithTencent(ctx, content,
case openai.ModerationResultBlock: func(reason string) error { return &CommentModerationRejectedError{Reason: reason} },
return &CommentModerationRejectedError{Reason: resp.Reason} func(reason string) error { return &CommentModerationReviewError{Reason: reason} },
case openai.ModerationResultReview: )
return &CommentModerationReviewError{Reason: resp.Reason}
default:
return nil
}
} }
func (s *PostAIService) ModerateImage(ctx context.Context, imageURL string) error { func (s *PostAIService) ModerateImage(ctx context.Context, imageURL string) error {
if !s.IsEnabled() { if !s.isOpenAIEnabled() {
return nil return nil
} }
resp, err := s.openAIClient.ModerateImage(ctx, imageURL) resp, err := s.openAIClient.ModerateImage(ctx, imageURL)
if err != nil { if err != nil {
if s.openAIClient.Config().StrictModeration { if s.strictMode {
return err return err
} }
zap.L().Warn("AI image moderation failed, fallback allow", 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 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 err != nil {
if s.openAIClient.Config().StrictModeration { if s.strictMode {
return err return err
} }
zap.L().Warn("AI bio moderation failed, fallback allow", zap.L().Warn("Tencent TMS moderation failed, fallback allow",
zap.Error(err), zap.Error(err),
) )
return nil return nil
} }
switch resp.Result { reason := resp.Label
case openai.ModerationResultBlock: if len(resp.Keywords) > 0 {
return &BioModerationRejectedError{Reason: resp.Reason} reason = resp.Label + ": " + strings.Join(resp.Keywords, ", ")
case openai.ModerationResultReview: }
return &BioModerationReviewError{Reason: resp.Reason}
switch resp.Suggestion {
case tencent.SuggestionBlock:
return rejectFactory(reason)
case tencent.SuggestionReview:
return reviewFactory(reason)
default: default:
return nil return nil
} }

View File

@@ -12,6 +12,7 @@ import (
"carrot_bbs/internal/pkg/openai" "carrot_bbs/internal/pkg/openai"
"carrot_bbs/internal/pkg/redis" "carrot_bbs/internal/pkg/redis"
"carrot_bbs/internal/pkg/s3" "carrot_bbs/internal/pkg/s3"
"carrot_bbs/internal/pkg/tencent"
"carrot_bbs/internal/pkg/ws" "carrot_bbs/internal/pkg/ws"
"carrot_bbs/internal/repository" "carrot_bbs/internal/repository"
"carrot_bbs/internal/service" "carrot_bbs/internal/service"
@@ -49,6 +50,7 @@ var InfrastructureSet = wire.NewSet(
// 外部服务客户端 // 外部服务客户端
ProvideOpenAIClient, ProvideOpenAIClient,
ProvideTencentClient,
ProvideEmailClient, ProvideEmailClient,
ProvideS3Client, ProvideS3Client,
@@ -169,6 +171,18 @@ func ProvideOpenAIClient(cfg *config.Config) openai.Client {
return openai.NewClient(openai.ConfigFromAppConfig(&cfg.OpenAI)) 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 提供邮件客户端 // ProvideEmailClient 提供邮件客户端
func ProvideEmailClient(cfg *config.Config) email.Client { func ProvideEmailClient(cfg *config.Config) email.Client {
return email.NewClient(email.ConfigFromAppConfig(&cfg.Email)) return email.NewClient(email.ConfigFromAppConfig(&cfg.Email))

View File

@@ -11,6 +11,7 @@ import (
"carrot_bbs/internal/pkg/openai" "carrot_bbs/internal/pkg/openai"
"carrot_bbs/internal/pkg/redis" "carrot_bbs/internal/pkg/redis"
"carrot_bbs/internal/pkg/s3" "carrot_bbs/internal/pkg/s3"
"carrot_bbs/internal/pkg/tencent"
"carrot_bbs/internal/pkg/ws" "carrot_bbs/internal/pkg/ws"
"carrot_bbs/internal/repository" "carrot_bbs/internal/repository"
"carrot_bbs/internal/service" "carrot_bbs/internal/service"
@@ -87,8 +88,8 @@ func ProvideEmailService(client email.Client) service.EmailService {
} }
// ProvidePostAIService 提供 AI 服务 // ProvidePostAIService 提供 AI 服务
func ProvidePostAIService(client openai.Client) *service.PostAIService { func ProvidePostAIService(openAIClient openai.Client, tencentClient tencent.Client, cfg *config.Config) *service.PostAIService {
return service.NewPostAIService(client) return service.NewPostAIService(openAIClient, tencentClient, cfg.OpenAI.StrictModeration)
} }
// ProvidePushService 提供推送服务 // ProvidePushService 提供推送服务