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

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