Files
sub2api/backend/internal/service/image_storage.go
T
harukaandClaude Opus 4.8 0eb6e21aaa feat: 异步图片任务结果落对象存储
为异步生图任务增加 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
2026-07-15 19:57:37 -07:00

193 lines
6.3 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 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"
}
}