topfans/backend/gateway/service/openai_client.go
Lenticular Studio Agent af7908e72e feat: 接入微达API中转站,重构镭射卡生图流程
- 替换中转站从 xbcl.link 到 weda.cc
- prompt 模板改为镭射卡全图生成(去掉 6 层合成/抠图依赖)
- 4 路并发调用 + 原图展示 = 5 张 variant
- 前端提示词中译英支持
- 全局 Vue errorHandler
- WebSocket 鉴权失败跳登录
- 删除已弃用的 laserCompositor 微服务

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 22:43:49 +08:00

322 lines
10 KiB
Go

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