package service import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "mime" "mime/multipart" "net/http" "net/textproto" "path/filepath" "strings" "time" "github.com/topfans/backend/pkg/logger" "go.uber.org/zap" ) // OpenAIClient OpenAI Images API 客户端 // // 当前仅实现 /v1/images/edits 端点(图生图,镭射卡场景): // - 输入:远程图片 URL(由本服务先下载) + 风格 prompt // - 输出:PNG 字节流(透明背景,直接可上传 OSS) // // 不接 /v1/images/generations(文生图),不接 variations(已弃用)。 // 失败时直接 error,无任何 fallback,由 controller 决定如何处理。 type OpenAIClient struct { BaseURL string // 默认 https://api.openai.com/v1 APIKey string Model string // 默认 gpt-image-1.5 HTTPClient *http.Client } // NewOpenAIClient 创建 OpenAI 客户端 func NewOpenAIClient(baseURL, apiKey, model string) *OpenAIClient { if baseURL == "" { baseURL = "https://api.openai.com/v1" } if model == "" { model = "gpt-image-1.5" } return &OpenAIClient{ BaseURL: baseURL, APIKey: apiKey, Model: model, HTTPClient: &http.Client{ Timeout: 360 * time.Second, // gpt-image-2 通过中转站时可能达 5min+, 设 6min }, } } // OpenAIEditResponse /v1/images/edits 响应结构 type OpenAIEditResponse struct { Created int64 `json:"created"` Data []struct { B64JSON string `json:"b64_json,omitempty"` URL string `json:"url,omitempty"` RevisedPrompt string `json:"revised_prompt,omitempty"` } `json:"data"` Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error,omitempty"` } // EditImage 调用 OpenAI /v1/images/edits 端点 // // 参数: // - imageURL: 远程图片 URL(必须是 GET 可访问的图片,OpenAI 服务端会拉取) // - prompt: 风格描述(<= 32000 字符) // // 返回:解码后的 PNG 字节流;失败返回 error func (c *OpenAIClient) EditImage(ctx context.Context, imageURL, prompt string) ([]byte, error) { if c.APIKey == "" { return nil, fmt.Errorf("OpenAI API key not configured (set OPENAI_API_KEY)") } if imageURL == "" { return nil, fmt.Errorf("imageURL is required for edit endpoint") } if prompt == "" { return nil, fmt.Errorf("prompt is required") } // 基底镭射卡模板 + 拼接用户提示词 // 用户提示词建议传入英文,中文 GPT-image 理解效果略差 prompt = `Transform the input image into a premium holographic visual artwork. Preserve the original subject identity, pose, and composition. This is a high-end artistic re-imagining of the photograph, not a card, not a poster. Randomly apply combinations of: holographic light refraction, prismatic color grading, iridescent reflections, laser light dispersion, metallic sheen overlays, glass-like distortion, aurora color bleeding, spectral glow enhancement, cinematic post-processing, abstract particle integration. Random holographic styles: oil-slick sheen, prism refraction, aurora diffusion, laser scattering, liquid metal reflection, fractured spectrum, holographic bloom, rainbow diffraction, crystal refraction, spectral distortion. Visual direction may vary between: luxury editorial photography, experimental fine art, cinematic portrait enhancement, museum-grade print, surreal holographic art, avant-garde visual experiment. Background may subtly transform into abstract light fields, cosmic gradients, or studio-like luxury atmospheres. Constraints: Do not add frames, borders, cards, labels, or typography. Do not convert into trading card or poster layout. Do not alter subject identity. Result should feel like a luxury holographic fine art photograph. Ultra detailed, high-end commercial imaging quality. User direction: ` + prompt // 1. 下载远程图片到 []byte imgData, err := downloadRemoteImage(ctx, imageURL) if err != nil { return nil, fmt.Errorf("download image: %w", err) } // 2. 构造 multipart/form-data body body := &bytes.Buffer{} writer := multipart.NewWriter(body) // image[]: PNG 文件 // ⚠️ 不能用 CreateFormFile —— 它不设置 Content-Type, multipart 默认会给二进制 part // 加 application/octet-stream, OpenAI 后端只接受 image/{jpeg,png,webp},会直接 400 // 改用 CreatePart 手动探测真实 MIME 并显式设置 contentType := detectImageContentType(imgData, "input.png") h := make(textproto.MIMEHeader) h.Set("Content-Disposition", `form-data; name="image[]"; filename="input.png"`) h.Set("Content-Type", contentType) part, err := writer.CreatePart(h) if err != nil { return nil, fmt.Errorf("create form file: %w", err) } if _, err := part.Write(imgData); err != nil { return nil, fmt.Errorf("write image data: %w", err) } // 表单字段 fields := buildEditFields(c.Model, prompt) for k, v := range fields { if err := writer.WriteField(k, v); err != nil { return nil, fmt.Errorf("write field %s: %w", k, err) } } if err := writer.Close(); err != nil { return nil, fmt.Errorf("close multipart writer: %w", err) } // 3. 发送请求 req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/images/edits", body) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.APIKey) req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("openai request: %w", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("openai HTTP %d: %s", resp.StatusCode, safePrefixBytes(respBody, 500)) } // 4. 解析响应 var edResp OpenAIEditResponse if err := json.Unmarshal(respBody, &edResp); err != nil { return nil, fmt.Errorf("parse response: %w, body=%s", err, safePrefixBytes(respBody, 500)) } if edResp.Error != nil { return nil, fmt.Errorf("openai error: %s - %s", edResp.Error.Code, edResp.Error.Message) } if len(edResp.Data) == 0 { return nil, fmt.Errorf("openai returned no data array") } // 5. 解析图片:优先 b64_json,fallback 到 url pngBytes, source, err := decodeImageFromResponse(ctx, &edResp.Data[0]) if err != nil { return nil, err } logger.Logger.Info("OpenAI edit image succeeded", zap.String("model", c.Model), zap.String("source", source), zap.Int("bytes", len(pngBytes)), zap.String("revised_prompt", safePrefixBytes([]byte(edResp.Data[0].RevisedPrompt), 80)), ) return pngBytes, nil } // downloadRemoteImage 下载远程图片到 []byte // 失败时返回 error,无 fallback func downloadRemoteImage(ctx context.Context, imageURL string) ([]byte, error) { cli := &http.Client{Timeout: 30 * time.Second} req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil) if err != nil { return nil, err } resp, err := cli.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("download HTTP %d (url prefix: %s)", resp.StatusCode, safePrefixStr(imageURL, 60)) } return io.ReadAll(resp.Body) } // safePrefixBytes 截断字节用于日志(避免巨大 body 撑爆日志) func safePrefixBytes(b []byte, n int) string { if len(b) <= n { return string(b) } return string(b[:n]) + "...(truncated)" } // safePrefixStr 截断字符串 func safePrefixStr(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." } // buildEditFields 根据 model 名前缀构造 /v1/images/edits 的 multipart 字段集。 // // gpt-image-*: OpenAI 新版,支持 transparent 背景 + 1024x1536 竖版 + 高级 quality // 其他(包括 dall-e-* / image-*): 旧版 / 中转站,只接受基础字段,size 限定 square func buildEditFields(model, prompt string) map[string]string { fields := map[string]string{ "model": model, "prompt": prompt, "n": "1", "size": "1024x1536", } return fields } // decodeImageFromResponse 从 OpenAI 响应里解出 PNG 字节。 // // 优先 b64_json(OpenAI 官方 / 中转站都常用),fallback 到 url(要再发一次 GET)。 // 返回 (bytes, source, err) — source 写日志用,标识数据来自 b64_json 还是 url 下载。 func decodeImageFromResponse(ctx context.Context, item *struct { B64JSON string `json:"b64_json,omitempty"` URL string `json:"url,omitempty"` RevisedPrompt string `json:"revised_prompt,omitempty"` }) ([]byte, string, error) { if item.B64JSON != "" { pngBytes, err := base64.StdEncoding.DecodeString(item.B64JSON) if err != nil { return nil, "", fmt.Errorf("base64 decode: %w", err) } return pngBytes, "b64_json", nil } if item.URL != "" { // 走 downloadRemoteImage 拉 URL 字节(dall-e-2 旧版经常返这种) // 但 image URL 通常 1h 过期,务必立即下载 imgData, err := downloadRemoteImage(ctx, item.URL) if err != nil { return nil, "", fmt.Errorf("download openai output url: %w", err) } return imgData, "url", nil } return nil, "", fmt.Errorf("openai response item has neither b64_json nor url") } // detectImageContentType 探测图片真实 MIME,供 multipart Content-Type 使用。 // // 策略: // 1. 先按 magic bytes(http.DetectContentType) —— 不依赖文件名/扩展名,准 // 2. 检测失败(空文件/未知格式)时, fallback 到文件名扩展名(应该是 input.png → image/png) // 3. 兜底 application/octet-stream // // 返回值限定 OpenAI 接受的 image/{jpeg,png,webp} 集合,其他格式也走 octet-stream 让 OpenAI 直接拒绝 // (而不是上传个错的格式,后端报个更离谱的错) func detectImageContentType(data []byte, filename string) string { detected := http.DetectContentType(data) // 返回 "image/png" 等 switch detected { case "image/jpeg", "image/png", "image/webp": return detected } // 探测不到或不在 OpenAI 白名单 → 用文件名扩展名兜底 if ext := strings.ToLower(filepath.Ext(filename)); ext != "" { if mt := mime.TypeByExtension(ext); mt != "" { // mime.TypeByExtension 返回值如 "image/png; charset=utf-8",需要去掉 charset if i := strings.Index(mt, ";"); i > 0 { mt = strings.TrimSpace(mt[:i]) } switch mt { case "image/jpeg", "image/png", "image/webp": return mt } } } // 兜底 —— OpenAI 会以 "unsupported mimetype" 拒绝,但请求能正常发出去便于诊断 return "application/octet-stream" }