image_task.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "net/http"
  7. "strings"
  8. "time"
  9. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  11. "github.com/google/uuid"
  12. "go.uber.org/zap"
  13. )
  14. const (
  15. ImageTaskStatusProcessing = "processing"
  16. ImageTaskStatusCompleted = "completed"
  17. ImageTaskStatusFailed = "failed"
  18. defaultImageTaskTTL = 24 * time.Hour
  19. defaultImageTaskExecutionTimeout = 30 * time.Minute
  20. )
  21. var (
  22. ErrImageTaskNotFound = infraerrors.New(http.StatusNotFound, "IMAGE_TASK_NOT_FOUND", "image task not found")
  23. ErrImageTaskForbidden = infraerrors.New(http.StatusForbidden, "IMAGE_TASK_FORBIDDEN", "image task does not belong to this API key")
  24. ErrImageTaskUnavailable = infraerrors.New(http.StatusServiceUnavailable, "IMAGE_TASK_UNAVAILABLE", "image task storage is unavailable")
  25. )
  26. // ImageTaskRecord is the private Redis representation of an asynchronous image
  27. // request. Ownership fields are intentionally omitted from the public view.
  28. type ImageTaskRecord struct {
  29. ID string `json:"id"`
  30. UserID int64 `json:"user_id"`
  31. APIKeyID int64 `json:"api_key_id"`
  32. Status string `json:"status"`
  33. HTTPStatus int `json:"http_status,omitempty"`
  34. Result json.RawMessage `json:"result,omitempty"`
  35. Error json.RawMessage `json:"error,omitempty"`
  36. CreatedAt int64 `json:"created_at"`
  37. CompletedAt *int64 `json:"completed_at,omitempty"`
  38. ExpiresAt int64 `json:"expires_at"`
  39. }
  40. // ImageTask is the API-safe task representation returned to callers.
  41. type ImageTask struct {
  42. ID string `json:"id"`
  43. TaskID string `json:"task_id"`
  44. Object string `json:"object"`
  45. Status string `json:"status"`
  46. HTTPStatus int `json:"http_status,omitempty"`
  47. ImageURL string `json:"image_url,omitempty"`
  48. Result json.RawMessage `json:"result,omitempty"`
  49. Error json.RawMessage `json:"error,omitempty"`
  50. CreatedAt int64 `json:"created_at"`
  51. CompletedAt *int64 `json:"completed_at,omitempty"`
  52. ExpiresAt int64 `json:"expires_at"`
  53. }
  54. type ImageTaskOwner struct {
  55. UserID int64
  56. APIKeyID int64
  57. }
  58. type ImageTaskStore interface {
  59. Save(ctx context.Context, task *ImageTaskRecord, ttl time.Duration) error
  60. Get(ctx context.Context, id string) (*ImageTaskRecord, error)
  61. }
  62. // ImageStorageResolver reports the currently effective object-storage binding.
  63. // It exists so the async image feature can be switched on and off from the admin
  64. // UI without a restart: the wiring below is fixed at startup, but the answer to
  65. // "is object storage configured right now" is re-read (and cached) per call.
  66. type ImageStorageResolver func() (uploader *ImageResultUploader, enabled bool)
  67. type ImageTaskService struct {
  68. store ImageTaskStore
  69. uploader *ImageResultUploader
  70. enabled bool
  71. resolve ImageStorageResolver
  72. ttl time.Duration
  73. executionTimeout time.Duration
  74. }
  75. func NewImageTaskService(store ImageTaskStore) *ImageTaskService {
  76. return NewImageTaskServiceWithOptions(store, defaultImageTaskTTL, defaultImageTaskExecutionTimeout)
  77. }
  78. func NewImageTaskServiceWithOptions(store ImageTaskStore, ttl, executionTimeout time.Duration) *ImageTaskService {
  79. if ttl <= 0 {
  80. ttl = defaultImageTaskTTL
  81. }
  82. if executionTimeout <= 0 {
  83. executionTimeout = defaultImageTaskExecutionTimeout
  84. }
  85. return &ImageTaskService{store: store, ttl: ttl, executionTimeout: executionTimeout}
  86. }
  87. // NewImageTaskServiceWithUploader 构造一个已启用的图片任务服务:结果会先经 uploader
  88. // 转存到对象存储再落 Redis。uploader 为 nil 时不做转存(仅用于测试)。
  89. func NewImageTaskServiceWithUploader(store ImageTaskStore, uploader *ImageResultUploader, ttl, executionTimeout time.Duration) *ImageTaskService {
  90. s := NewImageTaskServiceWithOptions(store, ttl, executionTimeout)
  91. s.uploader = uploader
  92. s.enabled = true
  93. return s
  94. }
  95. // NewImageTaskServiceWithResolver 构造一个由 resolver 决定启用状态的服务:
  96. // 开关与凭证来自后台设置,保存后立即生效,无需重启。
  97. func NewImageTaskServiceWithResolver(store ImageTaskStore, resolve ImageStorageResolver, ttl, executionTimeout time.Duration) *ImageTaskService {
  98. s := NewImageTaskServiceWithOptions(store, ttl, executionTimeout)
  99. s.resolve = resolve
  100. return s
  101. }
  102. // current 返回当前生效的 uploader 与启用状态。
  103. // 注入了 resolver 时以 resolver 为准(后台设置可热切换),否则回落到构造时固定的值。
  104. func (s *ImageTaskService) current() (*ImageResultUploader, bool) {
  105. if s == nil {
  106. return nil, false
  107. }
  108. if s.resolve != nil {
  109. return s.resolve()
  110. }
  111. return s.uploader, s.enabled
  112. }
  113. // Enabled 表示异步图片任务功能是否可用(总开关 + 凭证齐全)。
  114. // 关闭时 handler 直接返回 404,不创建任务、不写 Redis。
  115. func (s *ImageTaskService) Enabled() bool {
  116. if s == nil || s.store == nil {
  117. return false
  118. }
  119. _, enabled := s.current()
  120. return enabled
  121. }
  122. // Pollable 表示已创建的任务能否被查询。
  123. // 比 Enabled 弱:只要 store 可用即可,从而在功能被关掉后仍能取回进行中的任务结果。
  124. func (s *ImageTaskService) Pollable() bool {
  125. return s != nil && s.store != nil
  126. }
  127. func (s *ImageTaskService) ExecutionTimeout() time.Duration {
  128. if s == nil || s.executionTimeout <= 0 {
  129. return defaultImageTaskExecutionTimeout
  130. }
  131. return s.executionTimeout
  132. }
  133. func (s *ImageTaskService) Create(ctx context.Context, owner ImageTaskOwner) (*ImageTask, error) {
  134. if s == nil || s.store == nil {
  135. return nil, ErrImageTaskUnavailable
  136. }
  137. now := time.Now().UTC()
  138. task := &ImageTaskRecord{
  139. ID: "imgtask_" + strings.ReplaceAll(uuid.NewString(), "-", ""),
  140. UserID: owner.UserID,
  141. APIKeyID: owner.APIKeyID,
  142. Status: ImageTaskStatusProcessing,
  143. CreatedAt: now.Unix(),
  144. ExpiresAt: now.Add(s.ttl).Unix(),
  145. }
  146. if err := s.store.Save(ctx, task, s.ttl); err != nil {
  147. return nil, ErrImageTaskUnavailable.WithCause(err)
  148. }
  149. return imageTaskToPublic(task), nil
  150. }
  151. func (s *ImageTaskService) Get(ctx context.Context, owner ImageTaskOwner, id string) (*ImageTask, error) {
  152. if s == nil || s.store == nil {
  153. return nil, ErrImageTaskUnavailable
  154. }
  155. task, err := s.store.Get(ctx, strings.TrimSpace(id))
  156. if err != nil {
  157. if errors.Is(err, ErrImageTaskNotFound) {
  158. return nil, ErrImageTaskNotFound
  159. }
  160. return nil, ErrImageTaskUnavailable.WithCause(err)
  161. }
  162. if task.UserID != owner.UserID || task.APIKeyID != owner.APIKeyID {
  163. // Do not reveal whether a random task ID exists for another caller.
  164. return nil, ErrImageTaskNotFound
  165. }
  166. return imageTaskToPublic(task), nil
  167. }
  168. func (s *ImageTaskService) Complete(ctx context.Context, id string, statusCode int, result json.RawMessage) error {
  169. if !json.Valid(result) {
  170. return s.Fail(ctx, id, http.StatusBadGateway, imageTaskErrorJSON("api_error", "upstream returned a non-JSON image response"))
  171. }
  172. if uploader, _ := s.current(); uploader != nil {
  173. rewritten, err := uploader.Rewrite(ctx, id, result)
  174. if err != nil {
  175. // 转存失败不回退存 base64,避免大 blob 撑爆 Redis:直接把任务标记为失败。
  176. logger.L().Error("image_task.offload_failed", zap.String("task_id", id), zap.Error(err))
  177. return s.Fail(ctx, id, http.StatusBadGateway, imageTaskErrorJSON("api_error", "failed to store generated image to object storage"))
  178. }
  179. result = rewritten
  180. }
  181. return s.finish(ctx, id, ImageTaskStatusCompleted, statusCode, result, nil)
  182. }
  183. func (s *ImageTaskService) Fail(ctx context.Context, id string, statusCode int, taskErr json.RawMessage) error {
  184. if !json.Valid(taskErr) {
  185. taskErr = imageTaskErrorJSON("api_error", "image generation failed")
  186. }
  187. return s.finish(ctx, id, ImageTaskStatusFailed, statusCode, nil, taskErr)
  188. }
  189. func (s *ImageTaskService) finish(ctx context.Context, id, status string, statusCode int, result, taskErr json.RawMessage) error {
  190. if s == nil || s.store == nil {
  191. return ErrImageTaskUnavailable
  192. }
  193. task, err := s.store.Get(ctx, id)
  194. if err != nil {
  195. if errors.Is(err, ErrImageTaskNotFound) {
  196. return ErrImageTaskNotFound
  197. }
  198. return ErrImageTaskUnavailable.WithCause(err)
  199. }
  200. now := time.Now().UTC()
  201. completedAt := now.Unix()
  202. task.Status = status
  203. task.HTTPStatus = statusCode
  204. task.Result = result
  205. task.Error = taskErr
  206. task.CompletedAt = &completedAt
  207. task.ExpiresAt = now.Add(s.ttl).Unix()
  208. if err := s.store.Save(ctx, task, s.ttl); err != nil {
  209. return ErrImageTaskUnavailable.WithCause(err)
  210. }
  211. return nil
  212. }
  213. func imageTaskToPublic(task *ImageTaskRecord) *ImageTask {
  214. if task == nil {
  215. return nil
  216. }
  217. return &ImageTask{
  218. ID: task.ID,
  219. TaskID: task.ID,
  220. Object: "image.generation.task",
  221. Status: task.Status,
  222. HTTPStatus: task.HTTPStatus,
  223. ImageURL: firstImageTaskURL(task.Result),
  224. Result: task.Result,
  225. Error: task.Error,
  226. CreatedAt: task.CreatedAt,
  227. CompletedAt: task.CompletedAt,
  228. ExpiresAt: task.ExpiresAt,
  229. }
  230. }
  231. func firstImageTaskURL(result json.RawMessage) string {
  232. if len(result) == 0 || !json.Valid(result) {
  233. return ""
  234. }
  235. var response struct {
  236. Data []struct {
  237. URL string `json:"url"`
  238. } `json:"data"`
  239. }
  240. if json.Unmarshal(result, &response) != nil || len(response.Data) == 0 {
  241. return ""
  242. }
  243. return strings.TrimSpace(response.Data[0].URL)
  244. }
  245. func imageTaskErrorJSON(errorType, message string) json.RawMessage {
  246. data, _ := json.Marshal(map[string]string{"type": errorType, "message": message})
  247. return data
  248. }