image_storage.go 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. package service
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "mime"
  10. "net/http"
  11. "strconv"
  12. "strings"
  13. "time"
  14. )
  15. const defaultImageMaxDownloadBytes int64 = 32 << 20 // 32 MiB
  16. // ImageStorage 把图片字节写入对象存储并返回可访问 URL。
  17. //
  18. // 这是对象存储的可插拔抽象:适配一个新的对象存储厂商,只需实现本接口
  19. // (例如包一个厂商 SDK),无需改动任务/网关逻辑。仓库内自带一个 S3 兼容实现
  20. // (repository.S3ImageStorage),适用于 AWS S3 / Cloudflare R2 / 阿里云 OSS / MinIO 等。
  21. type ImageStorage interface {
  22. // Save 把 data 以 key 存入对象存储,返回可下载的 URL(公开直链或 presigned 临时链接)。
  23. // contentType 为图片 MIME 类型,如 "image/png"。
  24. Save(ctx context.Context, key, contentType string, data []byte) (url string, err error)
  25. }
  26. // ImageResultUploader 是 ImageStorage 的上层编排器(与具体厂商无关):
  27. // 把上游生图响应里的每张图片(b64_json 解码 / url 下载)转存到对象存储,
  28. // 并把响应结果改写为只含短链接的紧凑 JSON,从而避免大 base64 落 Redis。
  29. type ImageResultUploader struct {
  30. storage ImageStorage
  31. httpClient *http.Client
  32. prefix string
  33. maxDownloadBytes int64
  34. }
  35. // NewImageResultUploader 构造一个 uploader;storage 为 nil 时 Rewrite 直接透传。
  36. func NewImageResultUploader(storage ImageStorage, prefix string, maxDownloadBytes int64, httpClient *http.Client) *ImageResultUploader {
  37. if httpClient == nil {
  38. httpClient = defaultImageDownloadHTTPClient()
  39. }
  40. if maxDownloadBytes <= 0 {
  41. maxDownloadBytes = defaultImageMaxDownloadBytes
  42. }
  43. return &ImageResultUploader{
  44. storage: storage,
  45. httpClient: httpClient,
  46. prefix: prefix,
  47. maxDownloadBytes: maxDownloadBytes,
  48. }
  49. }
  50. func defaultImageDownloadHTTPClient() *http.Client {
  51. return &http.Client{Timeout: 60 * time.Second}
  52. }
  53. // Rewrite 将 result(上游生图响应 JSON)里的每张图片转存到对象存储,
  54. // 返回改写后的紧凑结果(data[i].url 指向对象存储,b64_json 被移除)。
  55. // 任一图片转存失败即返回 error(调用方据此将任务标记为失败,绝不把大 blob 落 Redis)。
  56. func (u *ImageResultUploader) Rewrite(ctx context.Context, taskID string, result json.RawMessage) (json.RawMessage, error) {
  57. if u == nil || u.storage == nil {
  58. return result, nil
  59. }
  60. var top map[string]json.RawMessage
  61. if err := json.Unmarshal(result, &top); err != nil {
  62. return nil, fmt.Errorf("parse image response: %w", err)
  63. }
  64. rawData, ok := top["data"]
  65. if !ok {
  66. // 没有 data 数组(结构不符合预期),保持原样返回,交由上层决定。
  67. return result, nil
  68. }
  69. var items []map[string]json.RawMessage
  70. if err := json.Unmarshal(rawData, &items); err != nil {
  71. return nil, fmt.Errorf("parse image response data: %w", err)
  72. }
  73. if len(items) == 0 {
  74. return result, nil
  75. }
  76. for i, item := range items {
  77. data, contentType, err := u.fetchImageBytes(ctx, item)
  78. if err != nil {
  79. return nil, fmt.Errorf("image %d: %w", i, err)
  80. }
  81. key := u.buildKey(taskID, i, contentType)
  82. url, err := u.storage.Save(ctx, key, contentType, data)
  83. if err != nil {
  84. return nil, fmt.Errorf("image %d: upload to object storage: %w", i, err)
  85. }
  86. urlRaw, err := json.Marshal(url)
  87. if err != nil {
  88. return nil, fmt.Errorf("image %d: encode url: %w", i, err)
  89. }
  90. item["url"] = urlRaw
  91. delete(item, "b64_json")
  92. items[i] = item
  93. }
  94. newData, err := json.Marshal(items)
  95. if err != nil {
  96. return nil, fmt.Errorf("encode image response data: %w", err)
  97. }
  98. top["data"] = newData
  99. out, err := json.Marshal(top)
  100. if err != nil {
  101. return nil, fmt.Errorf("encode image response: %w", err)
  102. }
  103. return out, nil
  104. }
  105. func (u *ImageResultUploader) fetchImageBytes(ctx context.Context, item map[string]json.RawMessage) ([]byte, string, error) {
  106. if raw, ok := item["b64_json"]; ok {
  107. var b64 string
  108. if err := json.Unmarshal(raw, &b64); err == nil {
  109. if b64 = strings.TrimSpace(b64); b64 != "" {
  110. data, err := base64.StdEncoding.DecodeString(b64)
  111. if err != nil {
  112. return nil, "", fmt.Errorf("decode b64_json: %w", err)
  113. }
  114. return data, detectImageContentType(data), nil
  115. }
  116. }
  117. }
  118. if raw, ok := item["url"]; ok {
  119. var rawURL string
  120. if err := json.Unmarshal(raw, &rawURL); err == nil {
  121. if rawURL = strings.TrimSpace(rawURL); rawURL != "" {
  122. if len(rawURL) >= len("data:") && strings.EqualFold(rawURL[:len("data:")], "data:") {
  123. return u.decodeImageDataURL(rawURL)
  124. }
  125. return u.download(ctx, rawURL)
  126. }
  127. }
  128. }
  129. return nil, "", errors.New("image item has neither b64_json nor url")
  130. }
  131. func (u *ImageResultUploader) decodeImageDataURL(rawURL string) ([]byte, string, error) {
  132. header, payload, ok := strings.Cut(rawURL[len("data:"):], ",")
  133. if !ok {
  134. return nil, "", errors.New("decode image data URL: missing comma separator")
  135. }
  136. parts := strings.Split(header, ";")
  137. if strings.TrimSpace(parts[0]) == "" {
  138. return nil, "", errors.New("decode image data URL: missing media type")
  139. }
  140. base64Index := len(parts) - 1
  141. if base64Index < 1 || !strings.EqualFold(strings.TrimSpace(parts[base64Index]), "base64") {
  142. for i := 1; i < base64Index; i++ {
  143. if strings.EqualFold(strings.TrimSpace(parts[i]), "base64") {
  144. return nil, "", errors.New("decode image data URL: base64 marker must be the final header token")
  145. }
  146. }
  147. return nil, "", errors.New("decode image data URL: payload is not base64 encoded")
  148. }
  149. for i := 1; i < base64Index; i++ {
  150. if strings.EqualFold(strings.TrimSpace(parts[i]), "base64") {
  151. return nil, "", errors.New("decode image data URL: duplicate base64 marker")
  152. }
  153. }
  154. mediaTypeHeader := strings.Join(parts[:base64Index], ";")
  155. declaredType, _, err := mime.ParseMediaType(mediaTypeHeader)
  156. if err != nil {
  157. return nil, "", fmt.Errorf("decode image data URL: invalid media type: %w", err)
  158. }
  159. declaredType = strings.ToLower(declaredType)
  160. if !strings.HasPrefix(declaredType, "image/") {
  161. return nil, "", fmt.Errorf("decode image data URL: media type %q is not an image", declaredType)
  162. }
  163. limit := u.maxDownloadBytes
  164. if limit <= 0 {
  165. limit = defaultImageMaxDownloadBytes
  166. }
  167. decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(payload))
  168. data, err := io.ReadAll(io.LimitReader(decoder, limit+1))
  169. if err != nil {
  170. return nil, "", fmt.Errorf("decode image data URL base64 payload: %w", err)
  171. }
  172. if int64(len(data)) > limit {
  173. return nil, "", fmt.Errorf("decoded image data URL exceeds %d bytes", limit)
  174. }
  175. contentType := detectedImageContentType(data)
  176. if contentType == "" {
  177. contentType = declaredType
  178. }
  179. return data, contentType, nil
  180. }
  181. func (u *ImageResultUploader) download(ctx context.Context, rawURL string) ([]byte, string, error) {
  182. req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
  183. if err != nil {
  184. return nil, "", fmt.Errorf("build download request: %w", err)
  185. }
  186. resp, err := u.httpClient.Do(req)
  187. if err != nil {
  188. return nil, "", fmt.Errorf("download image: %w", err)
  189. }
  190. defer func() { _ = resp.Body.Close() }()
  191. if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
  192. return nil, "", fmt.Errorf("download image: unexpected status %d", resp.StatusCode)
  193. }
  194. limit := u.maxDownloadBytes
  195. if limit <= 0 {
  196. limit = defaultImageMaxDownloadBytes
  197. }
  198. data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
  199. if err != nil {
  200. return nil, "", fmt.Errorf("read image body: %w", err)
  201. }
  202. if int64(len(data)) > limit {
  203. return nil, "", fmt.Errorf("downloaded image exceeds %d bytes", limit)
  204. }
  205. contentType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
  206. if !strings.HasPrefix(contentType, "image/") {
  207. contentType = detectImageContentType(data)
  208. }
  209. return data, contentType, nil
  210. }
  211. func (u *ImageResultUploader) buildKey(taskID string, index int, contentType string) string {
  212. return u.prefix + taskID + "-" + strconv.Itoa(index) + extensionForContentType(contentType)
  213. }
  214. func detectImageContentType(data []byte) string {
  215. if ct := detectedImageContentType(data); ct != "" {
  216. return ct
  217. }
  218. return "image/png"
  219. }
  220. func detectedImageContentType(data []byte) string {
  221. ct := strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])
  222. if strings.HasPrefix(ct, "image/") {
  223. return ct
  224. }
  225. return ""
  226. }
  227. func extensionForContentType(ct string) string {
  228. switch {
  229. case strings.Contains(ct, "png"):
  230. return ".png"
  231. case strings.Contains(ct, "jpeg"), strings.Contains(ct, "jpg"):
  232. return ".jpg"
  233. case strings.Contains(ct, "webp"):
  234. return ".webp"
  235. case strings.Contains(ct, "gif"):
  236. return ".gif"
  237. default:
  238. return ".png"
  239. }
  240. }