Files
backend/internal/pkg/openai/client.go
lafay a971fb0e29
All checks were successful
Build Backend / build (push) Successful in 2m20s
Build Backend / build-docker (push) Successful in 1m7s
feat(moderation): make moderation more lenient and add timeout fallback behavior
The moderation system now uses a "pass/review" strategy instead of "pass/review/block", treating hard violations the same as review (only routing to manual review rather than auto-rejecting). Add fallback-approve behavior in the hook system so slow moderation APIs never silently block normal content when they time out or get cancelled - timed-out moderation now approves with "ReviewedBy: system" instead of leaving the zero-value result that would reject content. Update post/comment/vote services and handlers to use the new unified behavior. Simplify moderation prompts to emphasize "宁可放过,不可误伤" (prefer false positives over false negatives).
2026-07-08 01:47:48 +08:00

684 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package openai
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
_ "image/png"
"io"
"net/http"
"net/url"
"strings"
"time"
"with_you/internal/pkg/netutil"
xdraw "golang.org/x/image/draw"
)
const moderationSystemPrompt = `你是中文社区的内容审核助手,负责对"帖子标题、正文、配图"做联合审核。原则是**宁可放过,不可误伤**:只有明确违法违规内容才标记 review 交人工复核,其余一律 pass。请只输出指定JSON。
核心原则:
- 除明确违法违规外,不要拦截或送审正常内容,包括尖锐吐槽、玩梗、二创、争论;
- 判定从严,倾向放行:宁可放过十个可疑,不可误伤一个正常。
只有命中**明确违法违规**时才输出 result=review
A. 违法犯罪:明确宣传违法犯罪行为、教唆或指导他人犯罪(如毒品、枪支、诈骗教程)。
B. 真实暴力威胁:针对具体个人或群体的实质性暴力威胁、人身伤害恐吓。
C. 色情低俗:明确色情、露骨性描写或诱导性内容;正常人体艺术、二次创作用感形象不在此列。
D. 诈骗引流:明确的诈骗信息、钓鱼链接、黑产引流。
E. 未成年保护:涉及未成年人的色情或剥削内容。
F. 开盒/人肉:故意公开他人可识别隐私信息(身份证号、住址、联系方式、定位轨迹)并伴有曝光意图。
G. 极端仇恨:鼓动对特定族裔/宗教/残障群体的仇恨与暴力,含纳粹等极端符号。
以下情形即使存在也**一律 pass**(不送审、不拦截):
- 正常玩梗、表情包、谐音梗、二次创作、无恶意的吐槽;
- 非定向、轻度口语化吐槽、玩笑、自嘲、朋友间互动;
- 对社会事件/作品的理性讨论、观点争论(即使语气尖锐、用词激烈);
- 一般性粗口、调侃、讽刺,未煽动对立或人身攻击的;
- 对政治人物/社会热点的评论、争议性观点表达;
- 含义模糊但无明显违法意图的内容。
reason 要求:
- result=review 时中文10-30字说明疑似违法点
- result=pass 时reason 为空字符串。
输出格式(严格):
仅输出一行JSON对象不要Markdown不要额外解释
{"result": "pass"/"review", "reason": "..."}`
const (
defaultMaxImagesPerModerationRequest = 1
maxAllowedImagesPerModerationRequest = 4
maxModerationResultRetries = 3
initialRetryBackoff = 500 * time.Millisecond
maxDownloadImageBytes = 10 * 1024 * 1024
maxModerationImageSide = 1280
compressedJPEGQuality = 72
maxCompressedPayloadBytes = 1536 * 1024
)
type ModerationResult string
const (
ModerationResultPass ModerationResult = "pass"
ModerationResultReview ModerationResult = "review"
ModerationResultBlock ModerationResult = "block"
)
type ModerationResponse struct {
Result ModerationResult `json:"result"`
Reason string `json:"reason"`
}
type Client interface {
IsEnabled() bool
Config() Config
ModeratePost(ctx context.Context, title, content string, images []string) (*ModerationResponse, error)
ModerateComment(ctx context.Context, content string, images []string) (*ModerationResponse, error)
ModerateImage(ctx context.Context, imageURL string) (*ModerationResponse, error)
ModerateBio(ctx context.Context, bio string) (*ModerationResponse, error)
}
type clientImpl struct {
cfg Config
httpClient *http.Client
}
func NewClient(cfg Config) Client {
timeout := cfg.RequestTimeoutSeconds
if timeout <= 0 {
timeout = 30
}
return &clientImpl{
cfg: cfg,
httpClient: netutil.NewSafeHTTPClient(time.Duration(timeout) * time.Second),
}
}
func (c *clientImpl) IsEnabled() bool {
return c.cfg.Enabled && c.cfg.APIKey != "" && c.cfg.BaseURL != ""
}
func (c *clientImpl) Config() Config {
return c.cfg
}
func (c *clientImpl) ModeratePost(ctx context.Context, title, content string, images []string) (*ModerationResponse, error) {
if !c.IsEnabled() {
return &ModerationResponse{Result: ModerationResultPass}, nil
}
var prompt strings.Builder
if strings.TrimSpace(title) != "" {
prompt.WriteString("帖子标题:" + title)
}
if strings.TrimSpace(content) != "" {
if prompt.Len() > 0 {
prompt.WriteString("\n")
}
prompt.WriteString("帖子内容:" + content)
}
if prompt.Len() == 0 && len(normalizeImageURLs(images)) > 0 {
prompt.WriteString("帖子内容:[仅包含图片]")
}
if prompt.Len() == 0 {
prompt.WriteString("帖子内容:[空]")
}
return c.moderateContentInBatches(ctx, prompt.String(), images)
}
func (c *clientImpl) ModerateComment(ctx context.Context, content string, images []string) (*ModerationResponse, error) {
if !c.IsEnabled() {
return &ModerationResponse{Result: ModerationResultPass}, nil
}
var prompt strings.Builder
if strings.TrimSpace(content) != "" {
prompt.WriteString("评论内容:" + content)
} else if len(normalizeImageURLs(images)) > 0 {
prompt.WriteString("评论内容:[仅包含图片]")
} else {
prompt.WriteString("评论内容:[空]")
}
return c.moderateContentInBatches(ctx, prompt.String(), images)
}
const imageModerationSystemPrompt = `你是图片内容审核助手,负责审核用户头像和背景图片。原则是**宁可放过,不可误伤**:只有明确违法违规的图片才标记 review 交人工复核,其余一律 pass。请只输出指定JSON。
核心原则:
- 除明确违法违规外,不要拦截或送审正常图片;
- 判定从严,倾向放行:宁可放过十张可疑,不可误伤一张正常。
只有命中**明确违法违规**时才输出 result=review
- 色情低俗:明确色情、露骨性描写或诱导性图片;正常人体艺术、二次创作用感形象不在此列;
- 真实暴力/血腥:明确展示严重暴力、凶杀、虐待等违法犯罪画面;
- 违法犯罪相关:明确涉及毒品、枪支、诈骗、黑产等内容;
- 未成年保护:涉及未成年人的色情或剥削图片;
- 诈骗引流:明确的诈骗二维码、钓鱼链接、黑产广告;
- 极端仇恨:纳粹等极端仇恨符号或标志。
以下图片即使存在也**一律 pass**(不送审、不拦截):
- 正常风景、人物、动物、动漫等图片;
- 表情包、梗图、二次创作;
- 正常的艺术创作、摄影作品;
- 含义模糊但无明显违法意图的图片。
reason 要求:
- result=review 时中文10-30字说明疑似违法点
- result=pass 时reason 为空字符串。
输出格式(严格):
仅输出一行JSON对象不要Markdown不要额外解释
{"result": "pass"/"review", "reason": "..."}`
const bioModerationSystemPrompt = `你是用户个性签名内容审核助手,负责审核用户个人简介/签名。原则是**宁可放过,不可误伤**:只有明确违法违规内容才标记 review 交人工复核,其余一律 pass。请只输出指定JSON。
核心原则:
- 除明确违法违规外,不要拦截或送审正常签名;
- 判定从严,倾向放行:宁可放过十个可疑,不可误伤一个正常。
只有命中**明确违法违规**时才输出 result=review
- 明确违法犯罪、暴力威胁内容;
- 色情低俗、露骨性暗示描述;
- 诈骗引流、黑产广告;
- 恶意公开他人可识别隐私信息;
- 涉及未成年人的色情或剥削内容。
以下签名即使存在也**一律 pass**(不送审、不拦截):
- 正常的自我介绍、心情表达;
- 玩梗、幽默、吐槽、谐音梗;
- 引用、歌词、诗句等;
- 理性的观点表达、对社会事件的评论;
- 含义模糊但无明显违法意图的内容。
reason 要求:
- result=review 时中文10-30字说明疑似违法点
- result=pass 时reason 为空字符串。
输出格式(严格):
仅输出一行JSON对象不要Markdown不要额外解释
{"result": "pass"/"review", "reason": "..."}`
func (c *clientImpl) ModerateImage(ctx context.Context, imageURL string) (*ModerationResponse, error) {
if !c.IsEnabled() {
return &ModerationResponse{Result: ModerationResultPass}, nil
}
if strings.TrimSpace(imageURL) == "" {
return &ModerationResponse{Result: ModerationResultPass}, nil
}
userPrompt := "请审核以下用户图片"
cleanImages := normalizeImageURLs([]string{imageURL})
optimizedImages := c.optimizeImagesForModeration(ctx, cleanImages)
resp, err := c.moderateWithRetry(ctx, imageModerationSystemPrompt, userPrompt, optimizedImages)
if err != nil {
return nil, fmt.Errorf("image moderation failed after %d attempts: %w", maxModerationResultRetries, err)
}
return resp, nil
}
func (c *clientImpl) ModerateBio(ctx context.Context, bio string) (*ModerationResponse, error) {
if !c.IsEnabled() {
return &ModerationResponse{Result: ModerationResultPass}, nil
}
if strings.TrimSpace(bio) == "" {
return &ModerationResponse{Result: ModerationResultPass}, nil
}
userPrompt := "个性签名内容:" + bio
return c.moderateWithCustomPrompt(ctx, bioModerationSystemPrompt, userPrompt)
}
func (c *clientImpl) moderateWithCustomPrompt(ctx context.Context, systemPrompt string, userPrompt string) (*ModerationResponse, error) {
resp, err := c.moderateWithRetry(ctx, systemPrompt, userPrompt, nil)
if err != nil {
return nil, fmt.Errorf("moderation failed after %d attempts: %w", maxModerationResultRetries, err)
}
return resp, nil
}
func (c *clientImpl) moderateContentInBatches(ctx context.Context, contentPrompt string, images []string) (*ModerationResponse, error) {
cleanImages := normalizeImageURLs(images)
optimizedImages := c.optimizeImagesForModeration(ctx, cleanImages)
maxImagesPerRequest := c.maxImagesPerModerationRequest()
totalBatches := 1
if len(optimizedImages) > 0 {
totalBatches = (len(optimizedImages) + maxImagesPerRequest - 1) / maxImagesPerRequest
}
for i := 0; i < totalBatches; i++ {
start := i * maxImagesPerRequest
end := start + maxImagesPerRequest
if end > len(optimizedImages) {
end = len(optimizedImages)
}
batchImages := []string{}
if len(optimizedImages) > 0 {
batchImages = optimizedImages[start:end]
}
resp, err := c.moderateSingleBatch(ctx, contentPrompt, batchImages, i+1, totalBatches)
if err != nil {
return nil, err
}
if resp.Result != ModerationResultPass {
if totalBatches > 1 && strings.TrimSpace(resp.Reason) != "" {
resp.Reason = fmt.Sprintf("第%d/%d批图片未通过%s", i+1, totalBatches, resp.Reason)
}
return resp, nil
}
}
return &ModerationResponse{Result: ModerationResultPass}, nil
}
func (c *clientImpl) moderateSingleBatch(
ctx context.Context,
contentPrompt string,
images []string,
batchNo, totalBatches int,
) (*ModerationResponse, error) {
userPrompt := fmt.Sprintf(
"%s\n图片批次%d/%d本次仅提供当前批次图片",
contentPrompt,
batchNo,
totalBatches,
)
resp, err := c.moderateWithRetry(ctx, moderationSystemPrompt, userPrompt, images)
if err != nil {
return nil, fmt.Errorf(
"moderation batch %d/%d failed after %d attempts: %w",
batchNo,
totalBatches,
maxModerationResultRetries,
err,
)
}
return resp, nil
}
type chatCompletionsRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
EnableThinking *bool `json:"enable_thinking,omitempty"`
ThinkingBudget *int `json:"thinking_budget,omitempty"`
ResponseFormat *responseFormatConfig `json:"response_format,omitempty"`
}
type responseFormatConfig struct {
Type string `json:"type"`
}
type chatMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
type contentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *imageURLPart `json:"image_url,omitempty"`
}
type imageURLPart struct {
URL string `json:"url"`
}
type chatCompletionsResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
func (c *clientImpl) chatCompletion(
ctx context.Context,
model string,
systemPrompt string,
userPrompt string,
images []string,
temperature float64,
maxTokens int,
) (string, error) {
if model == "" {
return "", fmt.Errorf("model is empty")
}
cleanImages := normalizeImageURLs(images)
userParts := []contentPart{
{Type: "text", Text: userPrompt},
}
for _, image := range cleanImages {
userParts = append(userParts, contentPart{
Type: "image_url",
ImageURL: &imageURLPart{URL: image},
})
}
reqBody := chatCompletionsRequest{
Model: model,
Messages: []chatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userParts},
},
Temperature: temperature,
MaxTokens: maxTokens,
}
falseVal := false
reqBody.EnableThinking = &falseVal
zero := 0
reqBody.ThinkingBudget = &zero
reqBody.ResponseFormat = &responseFormatConfig{Type: "json_object"}
data, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
endpoint := baseURL + "/v1/chat/completions"
if strings.HasSuffix(baseURL, "/v1") {
endpoint = baseURL + "/chat/completions"
}
body, statusCode, err := c.doChatCompletionRequest(ctx, endpoint, data)
if err != nil {
return "", &chatCompletionError{StatusCode: 0, Err: err}
}
if statusCode >= 400 {
return "", &chatCompletionError{
StatusCode: statusCode,
Err: fmt.Errorf("openai error status=%d body=%s", statusCode, string(body)),
}
}
var parsed chatCompletionsResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return "", &chatCompletionError{
StatusCode: statusCode,
Err: fmt.Errorf("decode response: %w", err),
}
}
if len(parsed.Choices) == 0 {
return "", &chatCompletionError{
StatusCode: statusCode,
Err: fmt.Errorf("empty response choices"),
}
}
return parsed.Choices[0].Message.Content, nil
}
// chatCompletionError wraps errors from chatCompletion so callers can decide
// retryability based on the upstream HTTP status code. StatusCode == 0 means
// the request never reached the server (transport failure).
type chatCompletionError struct {
StatusCode int
Err error
}
func (e *chatCompletionError) Error() string {
if e.Err == nil {
return "chat completion error"
}
return e.Err.Error()
}
func (e *chatCompletionError) Unwrap() error {
return e.Err
}
// isRetryableModerationErr reports whether a moderation caller should retry
// after the given chatCompletion error. Context cancellation and non-429 4xx
// responses are not retryable; transport, 429, 5xx, and parsing errors are.
func isRetryableModerationErr(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
var ce *chatCompletionError
if errors.As(err, &ce) {
if ce.StatusCode >= 400 && ce.StatusCode != http.StatusTooManyRequests && ce.StatusCode < 500 {
return false
}
}
return true
}
func (c *clientImpl) doChatCompletionRequest(ctx context.Context, endpoint string, data []byte) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return nil, 0, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("request openai: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, fmt.Errorf("read response: %w", err)
}
return body, resp.StatusCode, nil
}
func sleepWithBackoff(ctx context.Context, attempt int) error {
delay := initialRetryBackoff * time.Duration(1<<attempt)
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return fmt.Errorf("request cancelled: %w", ctx.Err())
case <-timer.C:
return nil
}
}
// moderateWithRetry runs a single moderation request up to maxModerationResultRetries
// times, retrying only on retryable chatCompletion errors. The underlying chatCompletion
// no longer retries on its own, so the total number of upstream calls is bounded by
// maxModerationResultRetries (previously 3x3=9).
func (c *clientImpl) moderateWithRetry(
ctx context.Context,
systemPrompt, userPrompt string,
images []string,
) (*ModerationResponse, error) {
var lastErr error
for attempt := 0; attempt < maxModerationResultRetries; attempt++ {
replyText, err := c.chatCompletion(ctx, c.cfg.ModerationModel, systemPrompt, userPrompt, images, 0.1, 220)
if err != nil {
lastErr = err
} else {
resp, parseErr := parseModerationReply(replyText)
if parseErr != nil {
lastErr = parseErr
} else {
return resp, nil
}
}
if attempt == maxModerationResultRetries-1 {
break
}
if !isRetryableModerationErr(lastErr) {
break
}
if sleepErr := sleepWithBackoff(ctx, attempt); sleepErr != nil {
return nil, sleepErr
}
}
return nil, lastErr
}
func parseModerationReply(replyText string) (*ModerationResponse, error) {
parsed := struct {
Result string `json:"result"`
Reason string `json:"reason"`
}{}
if err := json.Unmarshal([]byte(extractJSONObject(replyText)), &parsed); err != nil {
return nil, fmt.Errorf("failed to parse moderation result: %w", err)
}
result := ModerationResultPass
switch parsed.Result {
case "review":
result = ModerationResultReview
case "block":
result = ModerationResultBlock
}
return &ModerationResponse{
Result: result,
Reason: parsed.Reason,
}, nil
}
func normalizeImageURLs(images []string) []string {
clean := make([]string, 0, len(images))
for _, image := range images {
trimmed := strings.TrimSpace(image)
if trimmed == "" {
continue
}
clean = append(clean, trimmed)
}
return clean
}
func extractJSONObject(raw string) string {
text := strings.TrimSpace(raw)
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start >= 0 && end > start {
return text[start : end+1]
}
return text
}
func (c *clientImpl) maxImagesPerModerationRequest() int {
configured := c.cfg.ModerationMaxImagesPerRequest
if configured <= 0 {
return defaultMaxImagesPerModerationRequest
}
if configured > maxAllowedImagesPerModerationRequest {
return maxAllowedImagesPerModerationRequest
}
return configured
}
func (c *clientImpl) optimizeImagesForModeration(ctx context.Context, images []string) []string {
if len(images) == 0 {
return images
}
optimized := make([]string, 0, len(images))
for _, imageURL := range images {
optimized = append(optimized, c.tryCompressImageForModeration(ctx, imageURL))
}
return optimized
}
func (c *clientImpl) tryCompressImageForModeration(ctx context.Context, imageURL string) string {
parsed, err := url.Parse(imageURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return imageURL
}
if err := netutil.ValidateURL(imageURL); err != nil {
return imageURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {
return imageURL
}
resp, err := c.httpClient.Do(req)
if err != nil {
return imageURL
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return imageURL
}
if !strings.HasPrefix(strings.ToLower(resp.Header.Get("Content-Type")), "image/") {
return imageURL
}
originBytes, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadImageBytes))
if err != nil || len(originBytes) == 0 {
return imageURL
}
srcImg, _, err := image.Decode(bytes.NewReader(originBytes))
if err != nil {
return imageURL
}
dstImg := resizeIfNeeded(srcImg, maxModerationImageSide)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, dstImg, &jpeg.Options{Quality: compressedJPEGQuality}); err != nil {
return imageURL
}
compressed := buf.Bytes()
if len(compressed) == 0 || len(compressed) > maxCompressedPayloadBytes {
return imageURL
}
if len(compressed) >= int(float64(len(originBytes))*0.95) {
return imageURL
}
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(compressed)
}
func resizeIfNeeded(src image.Image, maxSide int) image.Image {
bounds := src.Bounds()
w := bounds.Dx()
h := bounds.Dy()
if w <= 0 || h <= 0 || max(w, h) <= maxSide {
return src
}
newW, newH := w, h
if w >= h {
newW = maxSide
newH = int(float64(h) * (float64(maxSide) / float64(w)))
} else {
newH = maxSide
newW = int(float64(w) * (float64(maxSide) / float64(h)))
}
if newW < 1 {
newW = 1
}
if newH < 1 {
newH = 1
}
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, bounds, xdraw.Over, nil)
return dst
}