batch_image_handler.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. package handler
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "io"
  6. "net/http"
  7. "strconv"
  8. "strings"
  9. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  11. "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  12. "github.com/Wei-Shaw/sub2api/internal/service"
  13. "github.com/gin-gonic/gin"
  14. "go.uber.org/zap"
  15. )
  16. type BatchImageHandler struct {
  17. service *service.BatchImagePublicService
  18. download *service.BatchImageDownloadService
  19. cleanup *service.BatchImageCleanupService
  20. openAI *OpenAIGatewayHandler
  21. }
  22. func NewBatchImageHandler(service *service.BatchImagePublicService, download *service.BatchImageDownloadService, cleanup *service.BatchImageCleanupService) *BatchImageHandler {
  23. return &BatchImageHandler{service: service, download: download, cleanup: cleanup}
  24. }
  25. func (h *BatchImageHandler) Submit(c *gin.Context) {
  26. var req service.BatchImageSubmitRequest
  27. if err := c.ShouldBindJSON(&req); err != nil {
  28. batchImageError(c, service.ErrBatchImageInvalidItems)
  29. return
  30. }
  31. owner, ok := batchImageOwnerFromContext(c)
  32. if !ok {
  33. batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
  34. return
  35. }
  36. if !h.checkSecurityAuditBeforeSubmit(c, &req) {
  37. return
  38. }
  39. if sessionID := service.ExtractClientSessionID(c); sessionID != "" {
  40. req.SessionID = &sessionID
  41. }
  42. got, err := h.service.Submit(c.Request.Context(), owner, req, c.GetHeader("Idempotency-Key"))
  43. if err != nil {
  44. batchImageError(c, err)
  45. return
  46. }
  47. c.JSON(http.StatusOK, got)
  48. }
  49. func (h *BatchImageHandler) checkSecurityAuditBeforeSubmit(c *gin.Context, req *service.BatchImageSubmitRequest) bool {
  50. if h == nil || h.openAI == nil || req == nil {
  51. return true
  52. }
  53. apiKey, ok := middleware.GetAPIKeyFromContext(c)
  54. if !ok || apiKey == nil {
  55. batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
  56. return false
  57. }
  58. subject, ok := middleware.GetAuthSubjectFromContext(c)
  59. if !ok {
  60. batchImageError(c, infraerrors.New(http.StatusInternalServerError, "USER_CONTEXT_REQUIRED", "User context not found"))
  61. return false
  62. }
  63. items := make([]map[string]string, 0, len(req.Items))
  64. for _, item := range req.Items {
  65. if prompt := strings.TrimSpace(item.Prompt); prompt != "" {
  66. items = append(items, map[string]string{"prompt": prompt})
  67. }
  68. }
  69. if len(items) == 0 {
  70. return true
  71. }
  72. body, err := json.Marshal(map[string]any{"request": map[string]any{"items": items}})
  73. if err != nil {
  74. batchImageError(c, infraerrors.New(http.StatusBadRequest, "INVALID_BATCH_PROMPT", "batch prompts are invalid"))
  75. return false
  76. }
  77. reqLog := requestLogger(c, "handler.batch_image.security_audit",
  78. zap.Int64("user_id", subject.UserID), zap.Int64("api_key_id", apiKey.ID), zap.String("model", req.Model))
  79. decision := h.openAI.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, req.Model, body)
  80. if decision != nil && !decision.AllowNextStage {
  81. h.openAI.openAISecurityAuditError(c, decision)
  82. return false
  83. }
  84. return true
  85. }
  86. func (h *BatchImageHandler) Get(c *gin.Context) {
  87. owner, ok := batchImageOwnerFromContext(c)
  88. if !ok {
  89. batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
  90. return
  91. }
  92. got, err := h.service.Get(c.Request.Context(), owner, c.Param("id"))
  93. if err != nil {
  94. batchImageError(c, err)
  95. return
  96. }
  97. c.JSON(http.StatusOK, got)
  98. }
  99. func (h *BatchImageHandler) List(c *gin.Context) {
  100. owner, ok := batchImageOwnerFromContext(c)
  101. if !ok {
  102. batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
  103. return
  104. }
  105. limit, _ := strconv.Atoi(c.Query("limit"))
  106. got, err := h.service.List(c.Request.Context(), owner, service.BatchImageJobsQuery{
  107. Status: c.Query("status"),
  108. TaskName: c.Query("task_name"),
  109. Downloaded: c.Query("downloaded"),
  110. From: c.Query("from"),
  111. To: c.Query("to"),
  112. Limit: limit,
  113. Cursor: c.Query("cursor"),
  114. })
  115. if err != nil {
  116. batchImageError(c, err)
  117. return
  118. }
  119. c.JSON(http.StatusOK, got)
  120. }
  121. func (h *BatchImageHandler) Models(c *gin.Context) {
  122. owner, ok := batchImageOwnerFromContext(c)
  123. if !ok {
  124. batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
  125. return
  126. }
  127. got, err := h.service.ListModels(c.Request.Context(), owner)
  128. if err != nil {
  129. batchImageError(c, err)
  130. return
  131. }
  132. // 分组级模型白名单:ListModels 已按分组账号枚举,这里再按白名单过滤一次。
  133. if apiKey, ok := middleware.GetAPIKeyFromContext(c); ok && apiKey != nil && apiKey.Group != nil {
  134. got.Data = filterBatchImageModelsByAllowlist(got.Data, apiKey.Group.ModelAllowlist)
  135. }
  136. c.JSON(http.StatusOK, got)
  137. }
  138. // filterBatchImageModelsByAllowlist 按分组模型白名单过滤批量生图模型列表。
  139. // 白名单未开启时原样返回。
  140. func filterBatchImageModelsByAllowlist(models []service.BatchImagePublicModel, allowlist service.GroupModelAllowlist) []service.BatchImagePublicModel {
  141. if !allowlist.Enabled {
  142. return models
  143. }
  144. filtered := make([]service.BatchImagePublicModel, 0, len(models))
  145. for _, model := range models {
  146. if allowlist.Allows(model.ID) {
  147. filtered = append(filtered, model)
  148. }
  149. }
  150. return filtered
  151. }
  152. func (h *BatchImageHandler) Items(c *gin.Context) {
  153. owner, ok := batchImageOwnerFromContext(c)
  154. if !ok {
  155. batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
  156. return
  157. }
  158. limit, _ := strconv.Atoi(c.Query("limit"))
  159. got, err := h.service.ListItems(c.Request.Context(), owner, c.Param("id"), service.BatchImageItemsQuery{
  160. Status: c.Query("status"),
  161. Limit: limit,
  162. Cursor: c.Query("cursor"),
  163. })
  164. if err != nil {
  165. batchImageError(c, err)
  166. return
  167. }
  168. c.JSON(http.StatusOK, got)
  169. }
  170. func (h *BatchImageHandler) Cancel(c *gin.Context) {
  171. owner, ok := batchImageOwnerFromContext(c)
  172. if !ok {
  173. batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
  174. return
  175. }
  176. got, err := h.service.Cancel(c.Request.Context(), owner, c.Param("id"))
  177. if err != nil {
  178. batchImageError(c, err)
  179. return
  180. }
  181. c.JSON(http.StatusOK, got)
  182. }
  183. func (h *BatchImageHandler) ItemContent(c *gin.Context) {
  184. owner, ok := batchImageOwnerFromContext(c)
  185. if !ok {
  186. batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
  187. return
  188. }
  189. imageIndex := 0
  190. if raw := c.Query("image_index"); raw != "" {
  191. parsed, err := strconv.Atoi(raw)
  192. if err != nil {
  193. batchImageError(c, service.ErrBatchImageItemImageIndexOutOfRange)
  194. return
  195. }
  196. imageIndex = parsed
  197. }
  198. stream, err := h.download.OpenItemContent(c.Request.Context(), owner, c.Param("id"), c.Param("custom_id"), imageIndex)
  199. if err != nil {
  200. batchImageError(c, err)
  201. return
  202. }
  203. defer func() { _ = stream.Reader.Close() }()
  204. c.Header("Content-Type", stream.ContentType)
  205. c.Header("Content-Disposition", service.BatchImageContentDispositionAttachment(stream.Filename))
  206. c.Header("Cache-Control", "private, max-age=300")
  207. c.Header("X-Content-Type-Options", "nosniff")
  208. if stream.ContentLength != nil && *stream.ContentLength >= 0 {
  209. c.Header("Content-Length", strconv.FormatInt(*stream.ContentLength, 10))
  210. }
  211. c.Status(http.StatusOK)
  212. if _, err := io.Copy(c.Writer, stream.Reader); err != nil {
  213. return
  214. }
  215. h.markDownloadedBestEffort(c, owner)
  216. }
  217. // markDownloadedBestEffort 在响应体已写出后标记下载状态;
  218. // 此时无法再向客户端返回错误,失败只能记日志(不能静默丢弃)。
  219. func (h *BatchImageHandler) markDownloadedBestEffort(c *gin.Context, owner service.BatchImageOwner) {
  220. if err := h.service.MarkDownloaded(c.Request.Context(), owner, c.Param("id")); err != nil {
  221. logger.L().Warn("batch_image.mark_downloaded_failed",
  222. zap.String("batch_id", c.Param("id")),
  223. zap.Error(err),
  224. )
  225. }
  226. }
  227. func (h *BatchImageHandler) Download(c *gin.Context) {
  228. owner, ok := batchImageOwnerFromContext(c)
  229. if !ok {
  230. batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
  231. return
  232. }
  233. maxItems, _ := strconv.Atoi(c.Query("max_items"))
  234. c.Header("Content-Type", "application/zip")
  235. c.Header("Content-Disposition", service.BatchImageContentDispositionAttachment(c.Param("id")+".zip"))
  236. c.Header("Cache-Control", "private, no-store")
  237. c.Header("X-Content-Type-Options", "nosniff")
  238. result, err := h.download.StreamZip(c.Request.Context(), owner, c.Param("id"), service.BatchImageZipOptions{
  239. Status: c.Query("status"),
  240. MaxItems: maxItems,
  241. IncludeManifest: true,
  242. }, c.Writer)
  243. if err != nil {
  244. if result == nil || !c.Writer.Written() {
  245. batchImageError(c, err)
  246. }
  247. return
  248. }
  249. h.markDownloadedBestEffort(c, owner)
  250. }
  251. func (h *BatchImageHandler) DeleteRecord(c *gin.Context) {
  252. owner, ok := batchImageOwnerFromContext(c)
  253. if !ok {
  254. batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
  255. return
  256. }
  257. if err := h.service.DeleteRecord(c.Request.Context(), owner, c.Param("id")); err != nil {
  258. batchImageError(c, err)
  259. return
  260. }
  261. c.Status(http.StatusNoContent)
  262. }
  263. func (h *BatchImageHandler) DeleteOutputs(c *gin.Context) {
  264. owner, ok := batchImageOwnerFromContext(c)
  265. if !ok {
  266. batchImageError(c, infraerrors.New(http.StatusUnauthorized, "API_KEY_REQUIRED", "API key is required"))
  267. return
  268. }
  269. got, err := h.cleanup.DeleteOutputsForOwner(c.Request.Context(), owner, c.Param("id"))
  270. if err != nil {
  271. batchImageError(c, err)
  272. return
  273. }
  274. c.JSON(http.StatusOK, got)
  275. }
  276. func batchImageOwnerFromContext(c *gin.Context) (service.BatchImageOwner, bool) {
  277. apiKey, ok := middleware.GetAPIKeyFromContext(c)
  278. if !ok || apiKey == nil || apiKey.ID <= 0 || apiKey.UserID <= 0 {
  279. return service.BatchImageOwner{}, false
  280. }
  281. return service.BatchImageOwner{
  282. UserID: apiKey.UserID,
  283. APIKeyID: apiKey.ID,
  284. GroupID: apiKey.GroupID,
  285. }, true
  286. }
  287. func batchImageError(c *gin.Context, err error) {
  288. status := infraerrors.Code(err)
  289. code := infraerrors.Reason(err)
  290. message := infraerrors.Message(err)
  291. if err == nil {
  292. status = http.StatusInternalServerError
  293. code = "INTERNAL_ERROR"
  294. message = "internal error"
  295. }
  296. if status == 0 || (status == http.StatusInternalServerError && strings.TrimSpace(code) == "") {
  297. status = http.StatusInternalServerError
  298. code = "INTERNAL_ERROR"
  299. message = "internal error"
  300. }
  301. if errors.Is(err, service.ErrBatchImageJobNotFound) {
  302. status = http.StatusNotFound
  303. code = "BATCH_IMAGE_NOT_FOUND"
  304. message = "batch image job not found"
  305. }
  306. c.JSON(status, gin.H{
  307. "error": gin.H{
  308. "type": "invalid_request_error",
  309. "code": code,
  310. "message": message,
  311. },
  312. })
  313. }