batch_image_repo.go 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "strconv"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. )
  10. type batchImageSQLExecutor interface {
  11. ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
  12. QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
  13. QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
  14. }
  15. type batchImageRepository struct {
  16. db *sql.DB
  17. sql batchImageSQLExecutor
  18. }
  19. func NewBatchImageRepository(db *sql.DB) service.BatchImageRepository {
  20. return &batchImageRepository{db: db, sql: db}
  21. }
  22. func (r *batchImageRepository) CreateBatchImageJob(ctx context.Context, params service.CreateBatchImageJobParams) (*service.BatchImageJob, error) {
  23. if !service.IsSupportedBatchImageProvider(params.Provider) {
  24. return nil, service.ErrBatchImageInvalidProvider
  25. }
  26. if params.BatchID == "" {
  27. batchID, err := service.NewBatchImageID()
  28. if err != nil {
  29. return nil, err
  30. }
  31. params.BatchID = batchID
  32. }
  33. if params.Status == "" {
  34. params.Status = service.BatchImageJobStatusCreated
  35. }
  36. if params.Currency == "" {
  37. params.Currency = "USD"
  38. }
  39. job, err := createBatchImageJobWithSQL(ctx, r.sql, params)
  40. if err != nil {
  41. return nil, translatePersistenceError(err, nil, service.ErrBatchImageJobExists)
  42. }
  43. return job, nil
  44. }
  45. func (r *batchImageRepository) GetBatchImageJobByBatchID(ctx context.Context, batchID string) (*service.BatchImageJob, error) {
  46. job, err := scanBatchImageJob(r.sql.QueryRowContext(ctx, batchImageJobSelectSQL+" WHERE batch_id = $1", batchID))
  47. if err != nil {
  48. return nil, translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
  49. }
  50. return job, nil
  51. }
  52. func (r *batchImageRepository) GetBatchImageJobByIdempotencyKey(ctx context.Context, userID, apiKeyID int64, key string) (*service.BatchImageJob, error) {
  53. job, err := scanBatchImageJob(r.sql.QueryRowContext(ctx, batchImageJobSelectSQL+`
  54. WHERE user_id = $1 AND api_key_id = $2 AND idempotency_key = $3
  55. ORDER BY id DESC LIMIT 1`, userID, apiKeyID, key))
  56. if err != nil {
  57. return nil, translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
  58. }
  59. return job, nil
  60. }
  61. func (r *batchImageRepository) GetBatchImageJobByBatchIDForOwner(ctx context.Context, userID, apiKeyID int64, batchID string) (*service.BatchImageJob, error) {
  62. job, err := scanBatchImageJob(r.sql.QueryRowContext(ctx, batchImageJobSelectSQL+`
  63. WHERE batch_id = $1 AND user_id = $2 AND api_key_id = $3 AND user_deleted_at IS NULL`, batchID, userID, apiKeyID))
  64. if err != nil {
  65. return nil, translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
  66. }
  67. return job, nil
  68. }
  69. func (r *batchImageRepository) ListBatchImageJobsForOwner(ctx context.Context, userID, apiKeyID int64, filter service.BatchImageJobFilter) ([]*service.BatchImageJob, error) {
  70. limit := filter.Limit
  71. if limit <= 0 || limit > 100 {
  72. limit = 20
  73. }
  74. if filter.Offset < 0 {
  75. filter.Offset = 0
  76. }
  77. query := batchImageJobSelectSQL + " WHERE user_id = $1 AND api_key_id = $2"
  78. args := []any{userID, apiKeyID}
  79. if filter.ExcludeDeleted {
  80. query += " AND user_deleted_at IS NULL"
  81. }
  82. if filter.Status != "" {
  83. query += " AND status = $" + strconv.Itoa(len(args)+1)
  84. args = append(args, filter.Status)
  85. }
  86. if filter.TaskNameLike != "" {
  87. query += " AND task_name ILIKE $" + strconv.Itoa(len(args)+1)
  88. args = append(args, "%"+filter.TaskNameLike+"%")
  89. }
  90. if filter.Downloaded != nil {
  91. if *filter.Downloaded {
  92. query += " AND downloaded_at IS NOT NULL"
  93. } else {
  94. query += " AND downloaded_at IS NULL"
  95. }
  96. }
  97. if filter.CreatedAfter != nil {
  98. query += " AND created_at >= $" + strconv.Itoa(len(args)+1)
  99. args = append(args, *filter.CreatedAfter)
  100. }
  101. if filter.CreatedBefore != nil {
  102. query += " AND created_at < $" + strconv.Itoa(len(args)+1)
  103. args = append(args, *filter.CreatedBefore)
  104. }
  105. query += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(args)+1) + " OFFSET $" + strconv.Itoa(len(args)+2)
  106. args = append(args, limit, filter.Offset)
  107. rows, err := r.sql.QueryContext(ctx, query, args...)
  108. if err != nil {
  109. return nil, err
  110. }
  111. defer func() { _ = rows.Close() }()
  112. return scanBatchImageJobs(rows)
  113. }
  114. func (r *batchImageRepository) GetBatchImageJobByID(ctx context.Context, id int64) (*service.BatchImageJob, error) {
  115. job, err := scanBatchImageJob(r.sql.QueryRowContext(ctx, batchImageJobSelectSQL+" WHERE id = $1", id))
  116. if err != nil {
  117. return nil, translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
  118. }
  119. return job, nil
  120. }
  121. func (r *batchImageRepository) TransitionBatchImageJobStatus(ctx context.Context, batchID, toStatus string, opts service.BatchImageTransitionOptions) error {
  122. if r.db == nil {
  123. return r.transitionBatchImageJobStatusWithSQL(ctx, r.sql, batchID, toStatus, opts)
  124. }
  125. tx, err := r.db.BeginTx(ctx, nil)
  126. if err != nil {
  127. return err
  128. }
  129. defer func() {
  130. _ = tx.Rollback()
  131. }()
  132. if err := r.transitionBatchImageJobStatusWithSQL(ctx, tx, batchID, toStatus, opts); err != nil {
  133. return err
  134. }
  135. return tx.Commit()
  136. }
  137. func (r *batchImageRepository) TouchBatchImageJobSubmitting(ctx context.Context, batchID string) error {
  138. _, err := r.sql.ExecContext(ctx, `
  139. UPDATE batch_image_jobs
  140. SET updated_at = $2
  141. WHERE batch_id = $1
  142. AND status IN ('created', 'uploading')`, batchID, time.Now())
  143. return err
  144. }
  145. func (r *batchImageRepository) FailStaleUnsubmittedBatchImageJob(ctx context.Context, batchID string, cutoff time.Time, code, message string) (bool, error) {
  146. now := time.Now()
  147. res, err := r.sql.ExecContext(ctx, `
  148. UPDATE batch_image_jobs
  149. SET status = 'failed',
  150. last_error_code = $2,
  151. last_error_message = $3,
  152. finished_at = CASE WHEN finished_at IS NULL THEN $4 ELSE finished_at END,
  153. updated_at = $4,
  154. version = version + 1
  155. WHERE batch_id = $1
  156. AND status IN ('created', 'uploading')
  157. AND provider_job_name IS NULL
  158. AND updated_at <= $5`, batchID, code, message, now, cutoff)
  159. if err != nil {
  160. return false, err
  161. }
  162. affected, err := res.RowsAffected()
  163. if err != nil {
  164. return false, err
  165. }
  166. if affected == 0 {
  167. return false, nil
  168. }
  169. return true, appendBatchImageEventWithSQL(ctx, r.sql, batchID, "billing_hold_recovery_failed_unsubmitted", map[string]any{
  170. "batch_id": batchID,
  171. "error_code": code,
  172. })
  173. }
  174. func (r *batchImageRepository) UpdateBatchImageJobProviderOutputRef(ctx context.Context, batchID, providerOutputRef string) error {
  175. res, err := r.sql.ExecContext(ctx, `
  176. UPDATE batch_image_jobs
  177. SET provider_output_ref = $2, updated_at = $3
  178. WHERE batch_id = $1`, batchID, providerOutputRef, time.Now())
  179. if err != nil {
  180. return translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
  181. }
  182. if affected, err := res.RowsAffected(); err == nil && affected == 0 {
  183. return service.ErrBatchImageJobNotFound
  184. }
  185. return nil
  186. }
  187. func (r *batchImageRepository) UpdateBatchImageJobProviderSubmit(ctx context.Context, params service.UpdateBatchImageJobProviderSubmitParams) error {
  188. if r.db == nil {
  189. return r.updateBatchImageJobProviderSubmitWithSQL(ctx, r.sql, params)
  190. }
  191. tx, err := r.db.BeginTx(ctx, nil)
  192. if err != nil {
  193. return err
  194. }
  195. defer func() {
  196. _ = tx.Rollback()
  197. }()
  198. if err := r.updateBatchImageJobProviderSubmitWithSQL(ctx, tx, params); err != nil {
  199. return err
  200. }
  201. return tx.Commit()
  202. }
  203. func (r *batchImageRepository) updateBatchImageJobProviderSubmitWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, params service.UpdateBatchImageJobProviderSubmitParams) error {
  204. var current string
  205. if err := sqlq.QueryRowContext(ctx, `SELECT status FROM batch_image_jobs WHERE batch_id = $1 FOR UPDATE`, params.BatchID).Scan(&current); err != nil {
  206. return translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
  207. }
  208. if !service.CanTransitionBatchImageJob(current, service.BatchImageJobStatusSubmitted) {
  209. return service.ErrBatchImageInvalidTransition
  210. }
  211. now := time.Now()
  212. if _, err := sqlq.ExecContext(ctx, `
  213. UPDATE batch_image_jobs
  214. SET status = 'submitted',
  215. provider_job_name = $2,
  216. provider_input_ref = NULLIF($3, ''),
  217. provider_output_ref = NULLIF($4, ''),
  218. gcs_input_uri = NULLIF($5, ''),
  219. gcs_output_uri = NULLIF($6, ''),
  220. submitted_at = CASE WHEN submitted_at IS NULL THEN $7 ELSE submitted_at END,
  221. updated_at = $7,
  222. version = version + 1
  223. WHERE batch_id = $1`, params.BatchID, params.ProviderJobName, params.ProviderInputRef, params.ProviderOutputRef, params.GCSInputURI, params.GCSOutputURI, now); err != nil {
  224. return err
  225. }
  226. return appendBatchImageEventWithSQL(ctx, sqlq, params.BatchID, "provider_submitted", params.EventPayload)
  227. }
  228. func (r *batchImageRepository) RecordBatchImageJobSubmitFailure(ctx context.Context, batchID, code, message string, markFailed bool) error {
  229. now := time.Now()
  230. statusSQL := "status"
  231. if markFailed {
  232. statusSQL = "'failed'"
  233. }
  234. _, err := r.sql.ExecContext(ctx, `
  235. UPDATE batch_image_jobs
  236. SET status = `+statusSQL+`,
  237. last_error_code = $2,
  238. last_error_message = $3,
  239. finished_at = CASE WHEN `+statusSQL+` = 'failed' AND finished_at IS NULL THEN $4 ELSE finished_at END,
  240. updated_at = $4,
  241. version = version + 1
  242. WHERE batch_id = $1`, batchID, code, message, now)
  243. if err != nil {
  244. return err
  245. }
  246. eventType := "submit_failed"
  247. if !markFailed {
  248. eventType = "queue_failed"
  249. }
  250. return appendBatchImageEventWithSQL(ctx, r.sql, batchID, eventType, map[string]any{"error_code": code})
  251. }
  252. func (r *batchImageRepository) MarkBatchImageJobSettled(ctx context.Context, params service.MarkBatchImageJobSettledParams) error {
  253. if r.db == nil {
  254. return r.markBatchImageJobSettledWithSQL(ctx, r.sql, params)
  255. }
  256. tx, err := r.db.BeginTx(ctx, nil)
  257. if err != nil {
  258. return err
  259. }
  260. defer func() {
  261. _ = tx.Rollback()
  262. }()
  263. if err := r.markBatchImageJobSettledWithSQL(ctx, tx, params); err != nil {
  264. return err
  265. }
  266. return tx.Commit()
  267. }
  268. func (r *batchImageRepository) markBatchImageJobSettledWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, params service.MarkBatchImageJobSettledParams) error {
  269. now := time.Now()
  270. if params.Now != nil {
  271. now = *params.Now
  272. }
  273. outputExpiresAt := params.OutputExpiresAt
  274. res, err := sqlq.ExecContext(ctx, `
  275. UPDATE batch_image_jobs
  276. SET status = 'completed',
  277. actual_cost = $2,
  278. manifest_hash = $3,
  279. settled_at = CASE WHEN settled_at IS NULL THEN $4 ELSE settled_at END,
  280. finished_at = CASE WHEN finished_at IS NULL THEN $4 ELSE finished_at END,
  281. output_expires_at = CASE WHEN output_expires_at IS NULL THEN $5 ELSE output_expires_at END,
  282. updated_at = $4,
  283. version = version + 1
  284. WHERE batch_id = $1
  285. AND status = 'settling'
  286. AND (manifest_hash IS NULL OR manifest_hash = '' OR manifest_hash = $3)`, params.BatchID, params.ActualCost, params.ManifestHash, now, outputExpiresAt)
  287. if err != nil {
  288. return err
  289. }
  290. affected, err := res.RowsAffected()
  291. if err != nil {
  292. return err
  293. }
  294. if affected == 0 {
  295. job, getErr := scanBatchImageJob(sqlq.QueryRowContext(ctx, batchImageJobSelectSQL+" WHERE batch_id = $1", params.BatchID))
  296. if getErr != nil {
  297. return translatePersistenceError(getErr, service.ErrBatchImageJobNotFound, nil)
  298. }
  299. if job.Status != service.BatchImageJobStatusSettling {
  300. if job.Status == service.BatchImageJobStatusCompleted {
  301. return service.ErrBatchImageAlreadySettled
  302. }
  303. return service.ErrBatchImageSettlementInvalidStatus
  304. }
  305. return service.ErrBatchImageSettlementManifestConflict
  306. }
  307. return appendBatchImageEventWithSQL(ctx, sqlq, params.BatchID, "settlement_completed", params.EventPayload)
  308. }
  309. func (r *batchImageRepository) SetBatchImageJobSettlementFailed(ctx context.Context, batchID, code, message string) (int, error) {
  310. var retryCount int
  311. err := r.sql.QueryRowContext(ctx, `
  312. UPDATE batch_image_jobs
  313. SET last_error_code = $2,
  314. last_error_message = $3,
  315. retry_count = retry_count + 1,
  316. updated_at = $4
  317. WHERE batch_id = $1
  318. RETURNING retry_count`, batchID, code, message, time.Now()).Scan(&retryCount)
  319. if err != nil {
  320. return 0, translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
  321. }
  322. return retryCount, appendBatchImageEventWithSQL(ctx, r.sql, batchID, "settlement_failed", map[string]any{
  323. "error_code": code,
  324. })
  325. }
  326. func (r *batchImageRepository) transitionBatchImageJobStatusWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, batchID, toStatus string, opts service.BatchImageTransitionOptions) error {
  327. var current string
  328. if err := sqlq.QueryRowContext(ctx, `SELECT status FROM batch_image_jobs WHERE batch_id = $1 FOR UPDATE`, batchID).Scan(&current); err != nil {
  329. return translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
  330. }
  331. if !service.CanTransitionBatchImageJob(current, toStatus) {
  332. return service.ErrBatchImageInvalidTransition
  333. }
  334. now := time.Now()
  335. if opts.Now != nil {
  336. now = *opts.Now
  337. }
  338. if _, err := sqlq.ExecContext(ctx, `
  339. UPDATE batch_image_jobs
  340. SET
  341. status = $2::varchar,
  342. version = version + 1,
  343. updated_at = $3,
  344. last_error_code = CASE WHEN $2::varchar = 'failed' THEN $4 ELSE last_error_code END,
  345. last_error_message = CASE WHEN $2::varchar = 'failed' THEN $5 ELSE last_error_message END,
  346. submitted_at = CASE WHEN $2::varchar = 'submitted' AND submitted_at IS NULL THEN $3 ELSE submitted_at END,
  347. started_at = CASE WHEN $2::varchar = 'running' AND started_at IS NULL THEN $3 ELSE started_at END,
  348. finished_at = CASE WHEN $2::varchar IN ('completed', 'failed', 'cancelled') AND finished_at IS NULL THEN $3 ELSE finished_at END,
  349. settled_at = CASE WHEN $2::varchar = 'completed' AND settled_at IS NULL THEN $3 ELSE settled_at END,
  350. output_deleted_at = CASE WHEN $2::varchar = 'output_deleted' AND output_deleted_at IS NULL THEN $3 ELSE output_deleted_at END
  351. WHERE batch_id = $1`, batchID, toStatus, now, opts.ErrorCode, opts.ErrorMessage); err != nil {
  352. return err
  353. }
  354. if opts.EventType != "" {
  355. return appendBatchImageEventWithSQL(ctx, sqlq, batchID, opts.EventType, opts.EventPayload)
  356. }
  357. return nil
  358. }
  359. func (r *batchImageRepository) CreateBatchImageItem(ctx context.Context, params service.CreateBatchImageItemParams) (*service.BatchImageItem, error) {
  360. item, err := createBatchImageItemWithSQL(ctx, r.sql, params)
  361. if err != nil {
  362. return nil, translatePersistenceError(err, nil, service.ErrBatchImageItemExists)
  363. }
  364. return item, nil
  365. }
  366. func (r *batchImageRepository) BulkCreateBatchImageItems(ctx context.Context, params []service.CreateBatchImageItemParams) error {
  367. if len(params) == 0 {
  368. return nil
  369. }
  370. if r.db == nil {
  371. for _, param := range params {
  372. if _, err := createBatchImageItemWithSQL(ctx, r.sql, param); err != nil {
  373. return translatePersistenceError(err, nil, service.ErrBatchImageItemExists)
  374. }
  375. }
  376. return nil
  377. }
  378. tx, err := r.db.BeginTx(ctx, nil)
  379. if err != nil {
  380. return err
  381. }
  382. defer func() {
  383. _ = tx.Rollback()
  384. }()
  385. for _, param := range params {
  386. if _, err := createBatchImageItemWithSQL(ctx, tx, param); err != nil {
  387. return translatePersistenceError(err, nil, service.ErrBatchImageItemExists)
  388. }
  389. }
  390. return tx.Commit()
  391. }
  392. func (r *batchImageRepository) ReplaceBatchImageItemsForJob(ctx context.Context, batchID string, items []service.CreateBatchImageItemParams, counts service.BatchImageCounts) error {
  393. if r.db == nil {
  394. return r.replaceBatchImageItemsForJobWithSQL(ctx, r.sql, batchID, items, counts)
  395. }
  396. tx, err := r.db.BeginTx(ctx, nil)
  397. if err != nil {
  398. return err
  399. }
  400. defer func() {
  401. _ = tx.Rollback()
  402. }()
  403. if err := r.replaceBatchImageItemsForJobWithSQL(ctx, tx, batchID, items, counts); err != nil {
  404. return err
  405. }
  406. return tx.Commit()
  407. }
  408. func (r *batchImageRepository) replaceBatchImageItemsForJobWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, batchID string, items []service.CreateBatchImageItemParams, counts service.BatchImageCounts) error {
  409. var id int64
  410. var status string
  411. if err := sqlq.QueryRowContext(ctx, `SELECT id, status FROM batch_image_jobs WHERE batch_id = $1 FOR UPDATE`, batchID).Scan(&id, &status); err != nil {
  412. return translatePersistenceError(err, service.ErrBatchImageJobNotFound, nil)
  413. }
  414. // 仅允许 indexing 状态重建 item 表:防止锁过期后掉队的 worker
  415. // 重写已完成/已结算 job 的条目,造成账目与结果漂移。
  416. if status != service.BatchImageJobStatusIndexing {
  417. return service.ErrBatchImageIndexStateConflict
  418. }
  419. promptPreviews, err := r.batchImageItemPromptPreviews(ctx, sqlq, batchID)
  420. if err != nil {
  421. return err
  422. }
  423. if _, err := sqlq.ExecContext(ctx, `DELETE FROM batch_image_items WHERE job_id = $1`, batchID); err != nil {
  424. return err
  425. }
  426. for _, item := range items {
  427. item.JobID = batchID
  428. if item.PromptPreview == nil {
  429. if preview := promptPreviews[item.CustomID]; preview != "" {
  430. item.PromptPreview = &preview
  431. }
  432. }
  433. if _, err := createBatchImageItemWithSQL(ctx, sqlq, item); err != nil {
  434. return translatePersistenceError(err, nil, service.ErrBatchImageItemExists)
  435. }
  436. }
  437. _, err = sqlq.ExecContext(ctx, `
  438. UPDATE batch_image_jobs
  439. SET success_count = $2,
  440. fail_count = $3,
  441. updated_at = $4
  442. WHERE batch_id = $1`, batchID, counts.SuccessCount, counts.FailCount, time.Now())
  443. return err
  444. }
  445. func (r *batchImageRepository) batchImageItemPromptPreviews(ctx context.Context, sqlq batchImageSQLExecutor, batchID string) (map[string]string, error) {
  446. rows, err := sqlq.QueryContext(ctx, `SELECT custom_id, prompt_preview FROM batch_image_items WHERE job_id = $1 AND prompt_preview IS NOT NULL`, batchID)
  447. if err != nil {
  448. return nil, err
  449. }
  450. defer func() { _ = rows.Close() }()
  451. out := make(map[string]string)
  452. for rows.Next() {
  453. var customID string
  454. var preview sql.NullString
  455. if err := rows.Scan(&customID, &preview); err != nil {
  456. return nil, err
  457. }
  458. if preview.Valid && preview.String != "" {
  459. out[customID] = preview.String
  460. }
  461. }
  462. return out, rows.Err()
  463. }
  464. func (r *batchImageRepository) ListBatchImageItems(ctx context.Context, batchID string, filter service.BatchImageItemFilter) ([]*service.BatchImageItem, error) {
  465. limit := filter.Limit
  466. if limit <= 0 || limit > 500 {
  467. limit = 100
  468. }
  469. if filter.Offset < 0 {
  470. filter.Offset = 0
  471. }
  472. query := batchImageItemSelectSQL + " WHERE job_id = $1"
  473. args := []any{batchID}
  474. if filter.Status != "" {
  475. query += " AND status = $2"
  476. args = append(args, filter.Status)
  477. }
  478. query += " ORDER BY id ASC LIMIT $" + strconv.Itoa(len(args)+1) + " OFFSET $" + strconv.Itoa(len(args)+2)
  479. args = append(args, limit, filter.Offset)
  480. rows, err := r.sql.QueryContext(ctx, query, args...)
  481. if err != nil {
  482. return nil, err
  483. }
  484. defer func() { _ = rows.Close() }()
  485. var items []*service.BatchImageItem
  486. for rows.Next() {
  487. item, err := scanBatchImageItem(rows)
  488. if err != nil {
  489. return nil, err
  490. }
  491. items = append(items, item)
  492. }
  493. if err := rows.Err(); err != nil {
  494. return nil, err
  495. }
  496. return items, nil
  497. }
  498. func (r *batchImageRepository) ListBatchImageItemsForOwner(ctx context.Context, userID, apiKeyID int64, batchID string, filter service.BatchImageItemFilter) ([]*service.BatchImageItem, error) {
  499. if _, err := r.GetBatchImageJobByBatchIDForOwner(ctx, userID, apiKeyID, batchID); err != nil {
  500. return nil, err
  501. }
  502. return r.ListBatchImageItems(ctx, batchID, filter)
  503. }
  504. func (r *batchImageRepository) GetBatchImageJobForDownload(ctx context.Context, userID, apiKeyID int64, batchID string) (*service.BatchImageJob, error) {
  505. return r.GetBatchImageJobByBatchIDForOwner(ctx, userID, apiKeyID, batchID)
  506. }
  507. func (r *batchImageRepository) GetBatchImageItemForDownload(ctx context.Context, batchID, customID string) (*service.BatchImageItem, error) {
  508. item, err := scanBatchImageItem(r.sql.QueryRowContext(ctx, batchImageItemSelectSQL+`
  509. WHERE job_id = $1 AND custom_id = $2`, batchID, customID))
  510. if err != nil {
  511. return nil, translatePersistenceError(err, service.ErrBatchImageItemNotFound, nil)
  512. }
  513. return item, nil
  514. }
  515. func (r *batchImageRepository) ListBatchImageItemsForDownload(ctx context.Context, batchID string, status string, limit int) ([]*service.BatchImageItem, error) {
  516. return r.ListBatchImageItems(ctx, batchID, service.BatchImageItemFilter{Status: status, Limit: limit})
  517. }
  518. func (r *batchImageRepository) ListBatchImageJobsDueForInputCleanup(ctx context.Context, cutoff time.Time, limit int) ([]*service.BatchImageJob, error) {
  519. if limit <= 0 || limit > 1000 {
  520. limit = 100
  521. }
  522. rows, err := r.sql.QueryContext(ctx, batchImageJobSelectSQL+`
  523. WHERE input_deleted_at IS NULL
  524. AND provider_input_ref IS NOT NULL
  525. AND status IN ('completed', 'failed', 'cancelled', 'output_deleted')
  526. AND COALESCE(finished_at, settled_at, updated_at, created_at) <= $1
  527. ORDER BY id ASC
  528. LIMIT $2`, cutoff, limit)
  529. if err != nil {
  530. return nil, err
  531. }
  532. defer func() { _ = rows.Close() }()
  533. return scanBatchImageJobs(rows)
  534. }
  535. func (r *batchImageRepository) ListBatchImageJobsDueForOutputCleanup(ctx context.Context, now time.Time, limit int) ([]*service.BatchImageJob, error) {
  536. if limit <= 0 || limit > 1000 {
  537. limit = 100
  538. }
  539. rows, err := r.sql.QueryContext(ctx, batchImageJobSelectSQL+`
  540. WHERE output_deleted_at IS NULL
  541. AND provider_output_ref IS NOT NULL
  542. AND status = 'completed'
  543. AND output_expires_at IS NOT NULL
  544. AND output_expires_at <= $1
  545. ORDER BY output_expires_at ASC, id ASC
  546. LIMIT $2`, now, limit)
  547. if err != nil {
  548. return nil, err
  549. }
  550. defer func() { _ = rows.Close() }()
  551. return scanBatchImageJobs(rows)
  552. }
  553. func (r *batchImageRepository) ListStaleUnsubmittedBatchImageJobs(ctx context.Context, cutoff time.Time, limit int) ([]*service.BatchImageJob, error) {
  554. if limit <= 0 || limit > 1000 {
  555. limit = 100
  556. }
  557. rows, err := r.sql.QueryContext(ctx, batchImageJobSelectSQL+`
  558. WHERE status IN ('created', 'uploading')
  559. AND provider_job_name IS NULL
  560. AND COALESCE(hold_amount, estimated_cost, 0) > 0
  561. AND updated_at <= $1
  562. ORDER BY updated_at ASC, id ASC
  563. LIMIT $2`, cutoff, limit)
  564. if err != nil {
  565. return nil, err
  566. }
  567. defer func() { _ = rows.Close() }()
  568. return scanBatchImageJobs(rows)
  569. }
  570. func (r *batchImageRepository) MarkBatchImageInputDeleted(ctx context.Context, batchID string, deletedAt time.Time) error {
  571. res, err := r.sql.ExecContext(ctx, `
  572. UPDATE batch_image_jobs
  573. SET input_deleted_at = CASE WHEN input_deleted_at IS NULL THEN $2 ELSE input_deleted_at END,
  574. updated_at = $2,
  575. version = version + 1
  576. WHERE batch_id = $1`, batchID, deletedAt)
  577. if err != nil {
  578. return err
  579. }
  580. if affected, err := res.RowsAffected(); err == nil && affected == 0 {
  581. return service.ErrBatchImageJobNotFound
  582. }
  583. return appendBatchImageEventWithSQL(ctx, r.sql, batchID, "input_cleanup_completed", map[string]any{
  584. "batch_id": batchID,
  585. "cleanup_target": "input",
  586. "deleted_at": deletedAt.UTC().Format(time.RFC3339),
  587. })
  588. }
  589. func (r *batchImageRepository) MarkBatchImageOutputDeleted(ctx context.Context, batchID string, deletedAt time.Time) error {
  590. res, err := r.sql.ExecContext(ctx, `
  591. UPDATE batch_image_jobs
  592. SET status = CASE WHEN status = 'completed' THEN 'output_deleted' ELSE status END,
  593. output_deleted_at = CASE WHEN output_deleted_at IS NULL THEN $2 ELSE output_deleted_at END,
  594. finished_at = CASE WHEN status = 'completed' AND finished_at IS NULL THEN $2 ELSE finished_at END,
  595. updated_at = $2,
  596. version = version + 1
  597. WHERE batch_id = $1`, batchID, deletedAt)
  598. if err != nil {
  599. return err
  600. }
  601. if affected, err := res.RowsAffected(); err == nil && affected == 0 {
  602. return service.ErrBatchImageJobNotFound
  603. }
  604. return appendBatchImageEventWithSQL(ctx, r.sql, batchID, "output_cleanup_completed", map[string]any{
  605. "batch_id": batchID,
  606. "cleanup_target": "output",
  607. "deleted_at": deletedAt.UTC().Format(time.RFC3339),
  608. })
  609. }
  610. func (r *batchImageRepository) MarkBatchImageDownloaded(ctx context.Context, batchID string, downloadedAt time.Time) error {
  611. res, err := r.sql.ExecContext(ctx, `
  612. UPDATE batch_image_jobs
  613. SET downloaded_at = CASE WHEN downloaded_at IS NULL THEN $2 ELSE downloaded_at END,
  614. updated_at = $2
  615. WHERE batch_id = $1`, batchID, downloadedAt)
  616. if err != nil {
  617. return err
  618. }
  619. if affected, err := res.RowsAffected(); err == nil && affected == 0 {
  620. return service.ErrBatchImageJobNotFound
  621. }
  622. return appendBatchImageEventWithSQL(ctx, r.sql, batchID, "download_completed", map[string]any{
  623. "batch_id": batchID,
  624. "downloaded_at": downloadedAt.UTC().Format(time.RFC3339),
  625. })
  626. }
  627. func (r *batchImageRepository) MarkBatchImageJobUserDeleted(ctx context.Context, userID, apiKeyID int64, batchID string, deletedAt time.Time) error {
  628. res, err := r.sql.ExecContext(ctx, `
  629. UPDATE batch_image_jobs
  630. SET user_deleted_at = CASE WHEN user_deleted_at IS NULL THEN $4 ELSE user_deleted_at END,
  631. updated_at = $4
  632. WHERE batch_id = $1
  633. AND user_id = $2
  634. AND api_key_id = $3
  635. AND user_deleted_at IS NULL
  636. AND status IN ('completed', 'failed', 'cancelled', 'output_deleted')`, batchID, userID, apiKeyID, deletedAt)
  637. if err != nil {
  638. return err
  639. }
  640. if affected, err := res.RowsAffected(); err == nil && affected == 0 {
  641. return service.ErrBatchImageRecordDeleteNotReady
  642. }
  643. return appendBatchImageEventWithSQL(ctx, r.sql, batchID, "user_record_deleted", map[string]any{
  644. "batch_id": batchID,
  645. "deleted_at": deletedAt.UTC().Format(time.RFC3339),
  646. "user_id": userID,
  647. "api_key_id": apiKeyID,
  648. })
  649. }
  650. func (r *batchImageRepository) SetBatchImageOutputExpiresAt(ctx context.Context, batchID string, expiresAt time.Time) error {
  651. res, err := r.sql.ExecContext(ctx, `
  652. UPDATE batch_image_jobs
  653. SET output_expires_at = CASE WHEN output_expires_at IS NULL THEN $2 ELSE output_expires_at END,
  654. updated_at = $3
  655. WHERE batch_id = $1`, batchID, expiresAt, time.Now())
  656. if err != nil {
  657. return err
  658. }
  659. if affected, err := res.RowsAffected(); err == nil && affected == 0 {
  660. return service.ErrBatchImageJobNotFound
  661. }
  662. return nil
  663. }
  664. func (r *batchImageRepository) RecordBatchImageCleanupFailure(ctx context.Context, batchID, code, message string) error {
  665. _, err := r.sql.ExecContext(ctx, `
  666. UPDATE batch_image_jobs
  667. SET last_error_code = $2,
  668. last_error_message = $3,
  669. retry_count = retry_count + 1,
  670. updated_at = $4
  671. WHERE batch_id = $1`, batchID, code, message, time.Now())
  672. if err != nil {
  673. return err
  674. }
  675. return appendBatchImageEventWithSQL(ctx, r.sql, batchID, "output_cleanup_failed", map[string]any{"error_code": code})
  676. }
  677. func (r *batchImageRepository) AppendBatchImageEvent(ctx context.Context, batchID, eventType string, payload any) error {
  678. return appendBatchImageEventWithSQL(ctx, r.sql, batchID, eventType, payload)
  679. }
  680. func createBatchImageJobWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, params service.CreateBatchImageJobParams) (*service.BatchImageJob, error) {
  681. return scanBatchImageJob(sqlq.QueryRowContext(ctx, `
  682. INSERT INTO batch_image_jobs (
  683. batch_id, user_id, api_key_id, account_id, provider, model, task_name, parent_batch_id, status,
  684. provider_job_name, provider_input_ref, provider_output_ref, gcs_input_uri, gcs_output_uri,
  685. item_count, success_count, fail_count, cancelled_count,
  686. estimated_cost, hold_amount, actual_cost,
  687. base_unit_price, group_rate_multiplier, account_rate_multiplier,
  688. batch_discount_multiplier, hold_multiplier, billable_unit_price, hold_unit_price,
  689. pricing_snapshot_version,
  690. currency, hold_id,
  691. idempotency_key, request_hash, manifest_hash, retry_count, session_id, output_expires_at
  692. ) VALUES (
  693. $1, $2, $3, $4, $5, $6, $7, $8, $9,
  694. $10, $11, $12, $13, $14,
  695. $15, $16, $17, $18,
  696. $19, $20, $21,
  697. $22, $23, $24,
  698. $25, $26, $27, $28,
  699. $29,
  700. $30, $31,
  701. $32, $33, $34, $35, $36, $37
  702. )
  703. RETURNING `+batchImageJobColumns,
  704. params.BatchID, params.UserID, params.APIKeyID, params.AccountID, params.Provider, params.Model, params.TaskName, params.ParentBatchID, params.Status,
  705. params.ProviderJobName, params.ProviderInputRef, params.ProviderOutputRef, params.GCSInputURI, params.GCSOutputURI,
  706. params.ItemCount, params.SuccessCount, params.FailCount, params.CancelledCount,
  707. params.EstimatedCost, params.HoldAmount, params.ActualCost,
  708. params.BaseUnitPrice, params.GroupRateMultiplier, params.AccountRateMultiplier,
  709. params.BatchDiscountMultiplier, params.HoldMultiplier, params.BillableUnitPrice, params.HoldUnitPrice,
  710. params.PricingSnapshotVersion,
  711. params.Currency, params.HoldID,
  712. params.IdempotencyKey, params.RequestHash, params.ManifestHash, params.RetryCount, params.SessionID, params.OutputExpiresAt,
  713. ))
  714. }
  715. func createBatchImageItemWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, params service.CreateBatchImageItemParams) (*service.BatchImageItem, error) {
  716. return scanBatchImageItem(sqlq.QueryRowContext(ctx, `
  717. INSERT INTO batch_image_items (
  718. job_id, custom_id, status, request_hash, prompt_preview, provider_source_object,
  719. source_line_number, source_byte_offset, source_byte_length,
  720. mime_type, file_extension, image_count,
  721. error_code, error_message, billed_amount, indexed_at
  722. ) VALUES (
  723. $1, $2, $3, $4, $5, $6,
  724. $7, $8, $9,
  725. $10, $11, $12,
  726. $13, $14, $15, $16
  727. )
  728. RETURNING `+batchImageItemColumns,
  729. params.JobID, params.CustomID, params.Status, params.RequestHash, params.PromptPreview, params.ProviderSourceObject,
  730. params.SourceLineNumber, params.SourceByteOffset, params.SourceByteLength,
  731. params.MimeType, params.FileExtension, params.ImageCount,
  732. params.ErrorCode, params.ErrorMessage, params.BilledAmount, params.IndexedAt,
  733. ))
  734. }
  735. func appendBatchImageEventWithSQL(ctx context.Context, sqlq batchImageSQLExecutor, batchID, eventType string, payload any) error {
  736. var payloadArg any
  737. if payload != nil {
  738. payloadBytes, err := json.Marshal(payload)
  739. if err != nil {
  740. return err
  741. }
  742. payloadArg = string(payloadBytes)
  743. }
  744. _, err := sqlq.ExecContext(ctx, `
  745. INSERT INTO batch_image_events (job_id, event_type, payload)
  746. VALUES ($1, $2, $3)`, batchID, eventType, payloadArg)
  747. return err
  748. }
  749. type rowScanner interface {
  750. Scan(dest ...any) error
  751. }
  752. const batchImageJobColumns = `
  753. id, batch_id, user_id, api_key_id, account_id, provider, model, task_name, parent_batch_id, status,
  754. provider_job_name, provider_input_ref, provider_output_ref, gcs_input_uri, gcs_output_uri,
  755. item_count, success_count, fail_count, cancelled_count,
  756. estimated_cost, hold_amount, actual_cost,
  757. base_unit_price, group_rate_multiplier, account_rate_multiplier,
  758. batch_discount_multiplier, hold_multiplier, billable_unit_price, hold_unit_price,
  759. pricing_snapshot_version,
  760. currency, hold_id,
  761. idempotency_key, request_hash, manifest_hash,
  762. retry_count, version, session_id, output_expires_at, input_deleted_at, output_deleted_at, downloaded_at, user_deleted_at,
  763. last_error_code, last_error_message,
  764. created_at, updated_at, submitted_at, started_at, finished_at, settled_at`
  765. const batchImageJobSelectSQL = `SELECT ` + batchImageJobColumns + ` FROM batch_image_jobs`
  766. func scanBatchImageJob(row rowScanner) (*service.BatchImageJob, error) {
  767. var job service.BatchImageJob
  768. var apiKeyID, accountID sql.NullInt64
  769. var providerJobName, providerInputRef, providerOutputRef, gcsInputURI, gcsOutputURI sql.NullString
  770. var parentBatchID sql.NullString
  771. var holdAmount, actualCost sql.NullFloat64
  772. var holdID, idempotencyKey, requestHash, manifestHash sql.NullString
  773. var sessionID sql.NullString
  774. var outputExpiresAt, inputDeletedAt, outputDeletedAt, downloadedAt, userDeletedAt sql.NullTime
  775. var lastErrorCode, lastErrorMessage sql.NullString
  776. var submittedAt, startedAt, finishedAt, settledAt sql.NullTime
  777. err := row.Scan(
  778. &job.ID, &job.BatchID, &job.UserID, &apiKeyID, &accountID, &job.Provider, &job.Model, &job.TaskName, &parentBatchID, &job.Status,
  779. &providerJobName, &providerInputRef, &providerOutputRef, &gcsInputURI, &gcsOutputURI,
  780. &job.ItemCount, &job.SuccessCount, &job.FailCount, &job.CancelledCount,
  781. &job.EstimatedCost, &holdAmount, &actualCost,
  782. &job.BaseUnitPrice, &job.GroupRateMultiplier, &job.AccountRateMultiplier,
  783. &job.BatchDiscountMultiplier, &job.HoldMultiplier, &job.BillableUnitPrice, &job.HoldUnitPrice,
  784. &job.PricingSnapshotVersion,
  785. &job.Currency, &holdID,
  786. &idempotencyKey, &requestHash, &manifestHash,
  787. &job.RetryCount, &job.Version, &sessionID, &outputExpiresAt, &inputDeletedAt, &outputDeletedAt, &downloadedAt, &userDeletedAt,
  788. &lastErrorCode, &lastErrorMessage,
  789. &job.CreatedAt, &job.UpdatedAt, &submittedAt, &startedAt, &finishedAt, &settledAt,
  790. )
  791. if err != nil {
  792. return nil, err
  793. }
  794. job.APIKeyID = batchImageNullInt64Ptr(apiKeyID)
  795. job.AccountID = batchImageNullInt64Ptr(accountID)
  796. job.ProviderJobName = batchImageNullStringPtr(providerJobName)
  797. job.ProviderInputRef = batchImageNullStringPtr(providerInputRef)
  798. job.ProviderOutputRef = batchImageNullStringPtr(providerOutputRef)
  799. job.ParentBatchID = batchImageNullStringPtr(parentBatchID)
  800. job.GCSInputURI = batchImageNullStringPtr(gcsInputURI)
  801. job.GCSOutputURI = batchImageNullStringPtr(gcsOutputURI)
  802. job.HoldAmount = batchImageNullFloat64Ptr(holdAmount)
  803. job.ActualCost = batchImageNullFloat64Ptr(actualCost)
  804. job.HoldID = batchImageNullStringPtr(holdID)
  805. job.IdempotencyKey = batchImageNullStringPtr(idempotencyKey)
  806. job.RequestHash = batchImageNullStringPtr(requestHash)
  807. job.ManifestHash = batchImageNullStringPtr(manifestHash)
  808. job.SessionID = batchImageNullStringPtr(sessionID)
  809. job.OutputExpiresAt = batchImageNullTimePtr(outputExpiresAt)
  810. job.InputDeletedAt = batchImageNullTimePtr(inputDeletedAt)
  811. job.OutputDeletedAt = batchImageNullTimePtr(outputDeletedAt)
  812. job.DownloadedAt = batchImageNullTimePtr(downloadedAt)
  813. job.UserDeletedAt = batchImageNullTimePtr(userDeletedAt)
  814. job.LastErrorCode = batchImageNullStringPtr(lastErrorCode)
  815. job.LastErrorMessage = batchImageNullStringPtr(lastErrorMessage)
  816. job.SubmittedAt = batchImageNullTimePtr(submittedAt)
  817. job.StartedAt = batchImageNullTimePtr(startedAt)
  818. job.FinishedAt = batchImageNullTimePtr(finishedAt)
  819. job.SettledAt = batchImageNullTimePtr(settledAt)
  820. return &job, nil
  821. }
  822. func scanBatchImageJobs(rows *sql.Rows) ([]*service.BatchImageJob, error) {
  823. var jobs []*service.BatchImageJob
  824. for rows.Next() {
  825. job, err := scanBatchImageJob(rows)
  826. if err != nil {
  827. return nil, err
  828. }
  829. jobs = append(jobs, job)
  830. }
  831. if err := rows.Err(); err != nil {
  832. return nil, err
  833. }
  834. return jobs, nil
  835. }
  836. const batchImageItemColumns = `
  837. id, job_id, custom_id, status, request_hash, prompt_preview, provider_source_object,
  838. source_line_number, source_byte_offset, source_byte_length,
  839. mime_type, file_extension, image_count,
  840. error_code, error_message, billed_amount,
  841. created_at, indexed_at`
  842. const batchImageItemSelectSQL = `SELECT ` + batchImageItemColumns + ` FROM batch_image_items`
  843. func scanBatchImageItem(row rowScanner) (*service.BatchImageItem, error) {
  844. var item service.BatchImageItem
  845. var requestHash, promptPreview, providerSourceObject sql.NullString
  846. var sourceLineNumber sql.NullInt64
  847. var sourceByteOffset, sourceByteLength sql.NullInt64
  848. var mimeType, fileExtension, errorCode, errorMessage sql.NullString
  849. var billedAmount sql.NullFloat64
  850. var indexedAt sql.NullTime
  851. err := row.Scan(
  852. &item.ID, &item.JobID, &item.CustomID, &item.Status, &requestHash, &promptPreview, &providerSourceObject,
  853. &sourceLineNumber, &sourceByteOffset, &sourceByteLength,
  854. &mimeType, &fileExtension, &item.ImageCount,
  855. &errorCode, &errorMessage, &billedAmount,
  856. &item.CreatedAt, &indexedAt,
  857. )
  858. if err != nil {
  859. return nil, err
  860. }
  861. item.RequestHash = batchImageNullStringPtr(requestHash)
  862. item.PromptPreview = batchImageNullStringPtr(promptPreview)
  863. item.ProviderSourceObject = batchImageNullStringPtr(providerSourceObject)
  864. item.SourceLineNumber = batchImageNullIntPtr(sourceLineNumber)
  865. item.SourceByteOffset = batchImageNullInt64Ptr(sourceByteOffset)
  866. item.SourceByteLength = batchImageNullInt64Ptr(sourceByteLength)
  867. item.MimeType = batchImageNullStringPtr(mimeType)
  868. item.FileExtension = batchImageNullStringPtr(fileExtension)
  869. item.ErrorCode = batchImageNullStringPtr(errorCode)
  870. item.ErrorMessage = batchImageNullStringPtr(errorMessage)
  871. item.BilledAmount = batchImageNullFloat64Ptr(billedAmount)
  872. item.IndexedAt = batchImageNullTimePtr(indexedAt)
  873. return &item, nil
  874. }
  875. func batchImageNullStringPtr(v sql.NullString) *string {
  876. if !v.Valid {
  877. return nil
  878. }
  879. return &v.String
  880. }
  881. func batchImageNullInt64Ptr(v sql.NullInt64) *int64 {
  882. if !v.Valid {
  883. return nil
  884. }
  885. return &v.Int64
  886. }
  887. func batchImageNullIntPtr(v sql.NullInt64) *int {
  888. if !v.Valid {
  889. return nil
  890. }
  891. i := int(v.Int64)
  892. return &i
  893. }
  894. func batchImageNullFloat64Ptr(v sql.NullFloat64) *float64 {
  895. if !v.Valid {
  896. return nil
  897. }
  898. return &v.Float64
  899. }
  900. func batchImageNullTimePtr(v sql.NullTime) *time.Time {
  901. if !v.Valid {
  902. return nil
  903. }
  904. return &v.Time
  905. }
  906. var _ service.BatchImageRepository = (*batchImageRepository)(nil)