为异步生图任务增加 S3 兼容对象存储支持,任务结果不再把大图内联存进 Redis: - 新增可插拔接口 service.ImageStorage(Save -> url),适配别的厂商只需实现它 - S3 实现 S3ImageStorage(AWS S3 / R2 / 阿里云 OSS / MinIO),与备份共用 S3 客户端构造 - 新增 image_storage 配置(config.yaml + IMAGE_STORAGE_* 环境变量),默认关闭 - enabled 同时作为总开关:关闭或未配置对象存储时,异步生图接口返回 404 且不写 Redis,从根上避免几 MB 的 b64_json 结果撑爆 Redis - 完成时把图片上传对象存储并把结果改写为短链接(公开直链或 presigned), 上传失败则任务标记为失败 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SM1tf3CFVRzC7guuhBXvMd
193 lines
6.3 KiB
Go
193 lines
6.3 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const defaultImageMaxDownloadBytes int64 = 32 << 20 // 32 MiB
|
||
|
||
// ImageStorage 把图片字节写入对象存储并返回可访问 URL。
|
||
//
|
||
// 这是对象存储的可插拔抽象:适配一个新的对象存储厂商,只需实现本接口
|
||
// (例如包一个厂商 SDK),无需改动任务/网关逻辑。仓库内自带一个 S3 兼容实现
|
||
// (repository.S3ImageStorage),适用于 AWS S3 / Cloudflare R2 / 阿里云 OSS / MinIO 等。
|
||
type ImageStorage interface {
|
||
// Save 把 data 以 key 存入对象存储,返回可下载的 URL(公开直链或 presigned 临时链接)。
|
||
// contentType 为图片 MIME 类型,如 "image/png"。
|
||
Save(ctx context.Context, key, contentType string, data []byte) (url string, err error)
|
||
}
|
||
|
||
// ImageResultUploader 是 ImageStorage 的上层编排器(与具体厂商无关):
|
||
// 把上游生图响应里的每张图片(b64_json 解码 / url 下载)转存到对象存储,
|
||
// 并把响应结果改写为只含短链接的紧凑 JSON,从而避免大 base64 落 Redis。
|
||
type ImageResultUploader struct {
|
||
storage ImageStorage
|
||
httpClient *http.Client
|
||
prefix string
|
||
maxDownloadBytes int64
|
||
}
|
||
|
||
// NewImageResultUploader 构造一个 uploader;storage 为 nil 时 Rewrite 直接透传。
|
||
func NewImageResultUploader(storage ImageStorage, prefix string, maxDownloadBytes int64, httpClient *http.Client) *ImageResultUploader {
|
||
if httpClient == nil {
|
||
httpClient = defaultImageDownloadHTTPClient()
|
||
}
|
||
if maxDownloadBytes <= 0 {
|
||
maxDownloadBytes = defaultImageMaxDownloadBytes
|
||
}
|
||
return &ImageResultUploader{
|
||
storage: storage,
|
||
httpClient: httpClient,
|
||
prefix: prefix,
|
||
maxDownloadBytes: maxDownloadBytes,
|
||
}
|
||
}
|
||
|
||
func defaultImageDownloadHTTPClient() *http.Client {
|
||
return &http.Client{Timeout: 60 * time.Second}
|
||
}
|
||
|
||
// Rewrite 将 result(上游生图响应 JSON)里的每张图片转存到对象存储,
|
||
// 返回改写后的紧凑结果(data[i].url 指向对象存储,b64_json 被移除)。
|
||
// 任一图片转存失败即返回 error(调用方据此将任务标记为失败,绝不把大 blob 落 Redis)。
|
||
func (u *ImageResultUploader) Rewrite(ctx context.Context, taskID string, result json.RawMessage) (json.RawMessage, error) {
|
||
if u == nil || u.storage == nil {
|
||
return result, nil
|
||
}
|
||
var top map[string]json.RawMessage
|
||
if err := json.Unmarshal(result, &top); err != nil {
|
||
return nil, fmt.Errorf("parse image response: %w", err)
|
||
}
|
||
rawData, ok := top["data"]
|
||
if !ok {
|
||
// 没有 data 数组(结构不符合预期),保持原样返回,交由上层决定。
|
||
return result, nil
|
||
}
|
||
var items []map[string]json.RawMessage
|
||
if err := json.Unmarshal(rawData, &items); err != nil {
|
||
return nil, fmt.Errorf("parse image response data: %w", err)
|
||
}
|
||
if len(items) == 0 {
|
||
return result, nil
|
||
}
|
||
for i, item := range items {
|
||
data, contentType, err := u.fetchImageBytes(ctx, item)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("image %d: %w", i, err)
|
||
}
|
||
key := u.buildKey(taskID, i, contentType)
|
||
url, err := u.storage.Save(ctx, key, contentType, data)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("image %d: upload to object storage: %w", i, err)
|
||
}
|
||
urlRaw, err := json.Marshal(url)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("image %d: encode url: %w", i, err)
|
||
}
|
||
item["url"] = urlRaw
|
||
delete(item, "b64_json")
|
||
items[i] = item
|
||
}
|
||
newData, err := json.Marshal(items)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("encode image response data: %w", err)
|
||
}
|
||
top["data"] = newData
|
||
out, err := json.Marshal(top)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("encode image response: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (u *ImageResultUploader) fetchImageBytes(ctx context.Context, item map[string]json.RawMessage) ([]byte, string, error) {
|
||
if raw, ok := item["b64_json"]; ok {
|
||
var b64 string
|
||
if err := json.Unmarshal(raw, &b64); err == nil {
|
||
if b64 = strings.TrimSpace(b64); b64 != "" {
|
||
data, err := base64.StdEncoding.DecodeString(b64)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("decode b64_json: %w", err)
|
||
}
|
||
return data, detectImageContentType(data), nil
|
||
}
|
||
}
|
||
}
|
||
if raw, ok := item["url"]; ok {
|
||
var rawURL string
|
||
if err := json.Unmarshal(raw, &rawURL); err == nil {
|
||
if rawURL = strings.TrimSpace(rawURL); rawURL != "" {
|
||
return u.download(ctx, rawURL)
|
||
}
|
||
}
|
||
}
|
||
return nil, "", errors.New("image item has neither b64_json nor url")
|
||
}
|
||
|
||
func (u *ImageResultUploader) download(ctx context.Context, rawURL string) ([]byte, string, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("build download request: %w", err)
|
||
}
|
||
resp, err := u.httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("download image: %w", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||
return nil, "", fmt.Errorf("download image: unexpected status %d", resp.StatusCode)
|
||
}
|
||
limit := u.maxDownloadBytes
|
||
if limit <= 0 {
|
||
limit = defaultImageMaxDownloadBytes
|
||
}
|
||
data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("read image body: %w", err)
|
||
}
|
||
if int64(len(data)) > limit {
|
||
return nil, "", fmt.Errorf("downloaded image exceeds %d bytes", limit)
|
||
}
|
||
contentType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
|
||
if !strings.HasPrefix(contentType, "image/") {
|
||
contentType = detectImageContentType(data)
|
||
}
|
||
return data, contentType, nil
|
||
}
|
||
|
||
func (u *ImageResultUploader) buildKey(taskID string, index int, contentType string) string {
|
||
return u.prefix + taskID + "-" + strconv.Itoa(index) + extensionForContentType(contentType)
|
||
}
|
||
|
||
func detectImageContentType(data []byte) string {
|
||
ct := strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])
|
||
if strings.HasPrefix(ct, "image/") {
|
||
return ct
|
||
}
|
||
return "image/png"
|
||
}
|
||
|
||
func extensionForContentType(ct string) string {
|
||
switch {
|
||
case strings.Contains(ct, "png"):
|
||
return ".png"
|
||
case strings.Contains(ct, "jpeg"), strings.Contains(ct, "jpg"):
|
||
return ".jpg"
|
||
case strings.Contains(ct, "webp"):
|
||
return ".webp"
|
||
case strings.Contains(ct, "gif"):
|
||
return ".gif"
|
||
default:
|
||
return ".png"
|
||
}
|
||
}
|