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