| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257 |
- package service
- import (
- "context"
- "encoding/base64"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "mime"
- "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 != "" {
- if len(rawURL) >= len("data:") && strings.EqualFold(rawURL[:len("data:")], "data:") {
- return u.decodeImageDataURL(rawURL)
- }
- return u.download(ctx, rawURL)
- }
- }
- }
- return nil, "", errors.New("image item has neither b64_json nor url")
- }
- func (u *ImageResultUploader) decodeImageDataURL(rawURL string) ([]byte, string, error) {
- header, payload, ok := strings.Cut(rawURL[len("data:"):], ",")
- if !ok {
- return nil, "", errors.New("decode image data URL: missing comma separator")
- }
- parts := strings.Split(header, ";")
- if strings.TrimSpace(parts[0]) == "" {
- return nil, "", errors.New("decode image data URL: missing media type")
- }
- base64Index := len(parts) - 1
- if base64Index < 1 || !strings.EqualFold(strings.TrimSpace(parts[base64Index]), "base64") {
- for i := 1; i < base64Index; i++ {
- if strings.EqualFold(strings.TrimSpace(parts[i]), "base64") {
- return nil, "", errors.New("decode image data URL: base64 marker must be the final header token")
- }
- }
- return nil, "", errors.New("decode image data URL: payload is not base64 encoded")
- }
- for i := 1; i < base64Index; i++ {
- if strings.EqualFold(strings.TrimSpace(parts[i]), "base64") {
- return nil, "", errors.New("decode image data URL: duplicate base64 marker")
- }
- }
- mediaTypeHeader := strings.Join(parts[:base64Index], ";")
- declaredType, _, err := mime.ParseMediaType(mediaTypeHeader)
- if err != nil {
- return nil, "", fmt.Errorf("decode image data URL: invalid media type: %w", err)
- }
- declaredType = strings.ToLower(declaredType)
- if !strings.HasPrefix(declaredType, "image/") {
- return nil, "", fmt.Errorf("decode image data URL: media type %q is not an image", declaredType)
- }
- limit := u.maxDownloadBytes
- if limit <= 0 {
- limit = defaultImageMaxDownloadBytes
- }
- decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(payload))
- data, err := io.ReadAll(io.LimitReader(decoder, limit+1))
- if err != nil {
- return nil, "", fmt.Errorf("decode image data URL base64 payload: %w", err)
- }
- if int64(len(data)) > limit {
- return nil, "", fmt.Errorf("decoded image data URL exceeds %d bytes", limit)
- }
- contentType := detectedImageContentType(data)
- if contentType == "" {
- contentType = declaredType
- }
- return data, contentType, nil
- }
- 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 {
- if ct := detectedImageContentType(data); ct != "" {
- return ct
- }
- return "image/png"
- }
- func detectedImageContentType(data []byte) string {
- ct := strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])
- if strings.HasPrefix(ct, "image/") {
- return ct
- }
- return ""
- }
- 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"
- }
- }
|