Files
backend/internal/service/post_ai_service.go
lafay 38d628c696
Some checks failed
Build Backend / build (push) Failing after 45s
Build Backend / build-docker (push) Has been skipped
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.
2026-04-21 22:31:30 +08:00

368 lines
9.2 KiB
Go

package service
import (
"context"
"fmt"
"strings"
"carrot_bbs/internal/pkg/openai"
"carrot_bbs/internal/pkg/tencent"
"go.uber.org/zap"
)
type PostModerationRejectedError struct {
Reason string
}
func (e *PostModerationRejectedError) Error() string {
if strings.TrimSpace(e.Reason) == "" {
return "post rejected by moderation"
}
return "post rejected by moderation: " + e.Reason
}
func (e *PostModerationRejectedError) UserMessage() string {
if strings.TrimSpace(e.Reason) == "" {
return "内容未通过审核,请修改后重试"
}
return strings.TrimSpace(e.Reason)
}
type PostModerationReviewError struct {
Reason string
}
func (e *PostModerationReviewError) Error() string {
if strings.TrimSpace(e.Reason) == "" {
return "post needs manual review"
}
return "post needs manual review: " + e.Reason
}
func (e *PostModerationReviewError) UserMessage() string {
if strings.TrimSpace(e.Reason) == "" {
return "内容需要人工复审,请耐心等待"
}
return strings.TrimSpace(e.Reason)
}
func (e *PostModerationReviewError) IsReview() bool {
return true
}
type CommentModerationRejectedError struct {
Reason string
}
func (e *CommentModerationRejectedError) Error() string {
if strings.TrimSpace(e.Reason) == "" {
return "comment rejected by moderation"
}
return "comment rejected by moderation: " + e.Reason
}
func (e *CommentModerationRejectedError) UserMessage() string {
if strings.TrimSpace(e.Reason) == "" {
return "评论未通过审核,请修改后重试"
}
return strings.TrimSpace(e.Reason)
}
type CommentModerationReviewError struct {
Reason string
}
func (e *CommentModerationReviewError) Error() string {
if strings.TrimSpace(e.Reason) == "" {
return "comment needs manual review"
}
return "comment needs manual review: " + e.Reason
}
func (e *CommentModerationReviewError) UserMessage() string {
if strings.TrimSpace(e.Reason) == "" {
return "评论需要人工复审,请耐心等待"
}
return strings.TrimSpace(e.Reason)
}
func (e *CommentModerationReviewError) IsReview() bool {
return true
}
type ImageModerationRejectedError struct {
Reason string
}
func (e *ImageModerationRejectedError) Error() string {
if strings.TrimSpace(e.Reason) == "" {
return "image rejected by moderation"
}
return "image rejected by moderation: " + e.Reason
}
func (e *ImageModerationRejectedError) UserMessage() string {
if strings.TrimSpace(e.Reason) == "" {
return "图片未通过审核,请更换后重试"
}
return strings.TrimSpace(e.Reason)
}
type ImageModerationReviewError struct {
Reason string
}
func (e *ImageModerationReviewError) Error() string {
if strings.TrimSpace(e.Reason) == "" {
return "image needs manual review"
}
return "image needs manual review: " + e.Reason
}
func (e *ImageModerationReviewError) UserMessage() string {
if strings.TrimSpace(e.Reason) == "" {
return "图片需要人工复审,请耐心等待"
}
return strings.TrimSpace(e.Reason)
}
func (e *ImageModerationReviewError) IsReview() bool {
return true
}
type BioModerationRejectedError struct {
Reason string
}
func (e *BioModerationRejectedError) Error() string {
if strings.TrimSpace(e.Reason) == "" {
return "bio rejected by moderation"
}
return "bio rejected by moderation: " + e.Reason
}
func (e *BioModerationRejectedError) UserMessage() string {
if strings.TrimSpace(e.Reason) == "" {
return "个性签名未通过审核,请修改后重试"
}
return strings.TrimSpace(e.Reason)
}
type BioModerationReviewError struct {
Reason string
}
func (e *BioModerationReviewError) Error() string {
if strings.TrimSpace(e.Reason) == "" {
return "bio needs manual review"
}
return "bio needs manual review: " + e.Reason
}
func (e *BioModerationReviewError) UserMessage() string {
if strings.TrimSpace(e.Reason) == "" {
return "个性签名需要人工复审,请耐心等待"
}
return strings.TrimSpace(e.Reason)
}
func (e *BioModerationReviewError) IsReview() bool {
return true
}
type PostAIService struct {
openAIClient openai.Client
tencentClient tencent.Client
strictMode bool
}
func NewPostAIService(openAIClient openai.Client, tencentClient tencent.Client, strictMode bool) *PostAIService {
return &PostAIService{
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
}
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
}
}
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 {
if !s.IsEnabled() {
return nil
}
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
}
}
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.isOpenAIEnabled() {
return nil
}
resp, err := s.openAIClient.ModerateImage(ctx, imageURL)
if err != nil {
if s.strictMode {
return err
}
zap.L().Warn("AI image moderation failed, fallback allow",
zap.Error(err),
)
return nil
}
switch resp.Result {
case openai.ModerationResultBlock:
return &ImageModerationRejectedError{Reason: resp.Reason}
case openai.ModerationResultReview:
return &ImageModerationReviewError{Reason: resp.Reason}
default:
return nil
}
}
func (s *PostAIService) ModerateBio(ctx context.Context, bio string) error {
if !s.IsEnabled() {
return nil
}
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.strictMode {
return err
}
zap.L().Warn("Tencent TMS moderation failed, fallback allow",
zap.Error(err),
)
return nil
}
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
}
}