Files
sub2api/backend/internal/service/batch_image_public.go
T
shaw 80a229bce5 fix(batch-image): 修复审计发现的计费死锁、状态机与队列原子性缺陷
修复 PR #3768 批量图像 MVP 合并后审计报告中的全部问题:

结算与计费(高危):
- 所有 SETTLEMENT_* 失败(超冻结/计数非法/manifest 冲突/定价缺失/扣费失败)
  统一计入 retry_count 并在耗尽时释放冻结转 failed,消灭 settling 无限
  requeue 导致的冻结余额永久锁死
- 耗尽出口的释放指纹统一为 RequestHash,与 processor/Cancel/recovery 一致;
  release 遇同 request id 指纹冲突视为幂等成功,治愈历史毒消息
- 管理端校验 hold_multiplier >= discount_multiplier,定价快照对存量脏数据钳制
- 释放前校验 per-job hold claim(dedup+归档表),杜绝幻影释放

索引对账(高危):
- provider 输出与提交 custom_id 集对账:未知条目丢弃并记事件,
  漏项补 PROVIDER_RESULT_MISSING 失败行,保证 success+fail == item_count

提交与恢复(高危):
- 提交前转 uploading 并在 provider.Submit 期间心跳刷新 updated_at;
  恢复扫描改为原子复核(FailStaleUnsubmittedBatchImageJob),
  消灭慢提交被误杀退款而上游任务照常计费的孤儿场景
- 上游任务创建成功但本地状态推进失败时,尽力取消上游并清理输入
- recovery 释放失败时入队交由 worker releaseTerminalHold 兜底重试

队列与并发(中危):
- Enqueue(SetNX+LPush)与 Reserve(BRPop+ZAdd)均改为 Lua 原子脚本,
  消灭崩溃窗口导致 job 脱离队列、被 7 天 inflight 键锁死
- 锁冲突按 LockConflictDelay 重新入队(原直接丢弃需等 10 分钟 stale 恢复)
- 处理期间心跳:active zset 续期(ZAddXX 防幽灵成员)+ 锁 TTL 续期
- ReplaceBatchImageItemsForJob 增加 indexing 状态守卫,防掉队 worker 重写账目

存量回归(中危):
- image-only 定价条目(仅图片价无 token 价)恢复 token 计费 fail-closed,
  不再按 $0 计费;图片计费路径不受影响
- 鉴权余额门槛恢复 balance <= 0 语义,MinimumBalanceReserve 不再作硬 403

加固:
- ZIP max_items 钳制到管理员上限;Submit 补齐 Platform==Gemini 校验;
  gemini downloadUri 跟随前做 host 白名单校验
- 批量客户端改用共享 httpclient(拨号/TLS/响应头超时有界)
- 审计点名的忽略错误(MarkDownloaded/SettlementFailed/AppendEvent 等)改为记日志
2026-07-07 18:53:56 +08:00

1439 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"go.uber.org/zap"
)
const (
defaultBatchImageMaxItems = 200
defaultBatchImageMaxOutputImages = 200
defaultBatchImageMaxOutputCount = 4
defaultBatchImageMaxPromptChars = 8000
defaultBatchImageResponseMime = "image/png"
defaultBatchImageImageSize = "1K"
defaultBatchImageDiscountMultiplier = 0.5
defaultBatchImageHoldMultiplier = 0.6
maxBatchImagePublicErrorChars = 500
maxBatchImageReferenceImageBytes = 10 * 1024 * 1024
defaultBatchImageMaxReferenceImages = 1000
defaultBatchImageMaxReferenceBytes = 128 * 1024 * 1024
)
type BatchImageAccountSelectionRepository interface {
GetByID(ctx context.Context, id int64) (*Account, error)
ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error)
ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error)
}
type BatchImageGroupPricingRepository interface {
GetByIDLite(ctx context.Context, id int64) (*Group, error)
}
type BatchImageUserGroupRateRepository interface {
GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error)
}
type BatchImageSubmitRequest struct {
Model string `json:"model"`
TaskName string `json:"task_name"`
ParentBatchID string `json:"parent_batch_id"`
Provider string `json:"provider"`
Items []BatchImageSubmitItem `json:"items"`
ResponseMimeType string `json:"response_mime_type"`
AspectRatio string `json:"aspect_ratio"`
ImageSize string `json:"image_size"`
Metadata map[string]string `json:"metadata"`
}
type BatchImageSubmitItem struct {
CustomID string `json:"custom_id"`
Prompt string `json:"prompt"`
OutputCount int `json:"output_count,omitempty"`
ReferenceImages []BatchImageReferenceInput `json:"reference_images,omitempty"`
}
type BatchImageReferenceInput struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
MimeType string `json:"mime_type"`
Data []byte `json:"data,omitempty"`
FileURI string `json:"file_uri,omitempty"`
}
type BatchImageOwner struct {
UserID int64
APIKeyID int64
GroupID *int64
}
type BatchImagePublicService struct {
Repo BatchImageRepository
AccountRepo BatchImageAccountSelectionRepository
GroupRepo BatchImageGroupPricingRepository
UserGroupRateRepo BatchImageUserGroupRateRepository
Queue BatchImageQueue
ProviderRegistry *BatchImageProviderRegistry
Pricing BatchImagePricingResolver
BillingRepo UsageBillingRepository
AuthCache APIKeyAuthCacheInvalidator
Config *config.Config
}
type BatchImagePricingSnapshot struct {
BaseUnitPrice float64
GroupRateMultiplier float64
AccountRateMultiplier float64
BatchDiscountMultiplier float64
HoldMultiplier float64
BillableUnitPrice float64
HoldUnitPrice float64
EstimatedCost float64
HoldAmount float64
}
type BatchImagePublicBatch struct {
ID string `json:"id"`
Object string `json:"object"`
TaskName string `json:"task_name"`
ParentBatchID *string `json:"parent_batch_id,omitempty"`
Status string `json:"status"`
Model string `json:"model"`
Provider string `json:"provider"`
ItemCount int `json:"item_count"`
SuccessCount int `json:"success_count"`
FailCount int `json:"fail_count"`
EstimatedCost float64 `json:"estimated_cost"`
HoldAmount float64 `json:"hold_amount"`
ActualCost *float64 `json:"actual_cost"`
CreatedAt int64 `json:"created_at"`
SubmittedAt *int64 `json:"submitted_at"`
SettledAt *int64 `json:"settled_at"`
DownloadedAt *int64 `json:"downloaded_at,omitempty"`
OutputDeletedAt *int64 `json:"output_deleted_at,omitempty"`
}
type BatchImagePublicItem struct {
CustomID string `json:"custom_id"`
Status string `json:"status"`
PromptPreview *string `json:"prompt_preview,omitempty"`
MimeType *string `json:"mime_type"`
FileExtension *string `json:"file_extension"`
ImageCount int `json:"image_count"`
Error *BatchImagePublicError `json:"error"`
}
type BatchImagePublicError struct {
Code string `json:"code"`
Message string `json:"message"`
Source string `json:"source,omitempty"`
}
type BatchImagePublicItemsResponse struct {
Object string `json:"object"`
Data []BatchImagePublicItem `json:"data"`
HasMore bool `json:"has_more"`
}
type BatchImagePublicListResponse struct {
Object string `json:"object"`
Data []*BatchImagePublicBatch `json:"data"`
HasMore bool `json:"has_more"`
}
type BatchImagePublicModel struct {
ID string `json:"id"`
Object string `json:"object"`
Provider string `json:"provider"`
}
type BatchImagePublicModelsResponse struct {
Object string `json:"object"`
Data []BatchImagePublicModel `json:"data"`
}
type BatchImageJobsQuery struct {
Status string
TaskName string
Downloaded string
From string
To string
Limit int
Cursor string
}
type BatchImageItemsQuery struct {
Status string
Limit int
Cursor string
}
func NewBatchImagePublicService(repo BatchImageRepository, accountRepo AccountRepository, groupRepo GroupRepository, userGroupRateRepo UserGroupRateRepository, queue BatchImageQueue, pricing *BatchImageModelPricingResolver, billingRepo UsageBillingRepository, authCache APIKeyAuthCacheInvalidator, cfg *config.Config) *BatchImagePublicService {
return &BatchImagePublicService{
Repo: repo,
AccountRepo: accountRepo,
GroupRepo: groupRepo,
UserGroupRateRepo: userGroupRateRepo,
Queue: queue,
ProviderRegistry: NewBatchImageProviderRegistryFromConfig(cfg),
Pricing: pricing,
BillingRepo: billingRepo,
AuthCache: authCache,
Config: cfg,
}
}
func (s *BatchImagePublicService) Submit(ctx context.Context, owner BatchImageOwner, req BatchImageSubmitRequest, idempotencyKey string) (*BatchImagePublicBatch, error) {
if !s.enabled() {
return nil, ErrBatchImageDisabled
}
normalized, err := s.validateSubmitRequest(req)
if err != nil {
return nil, err
}
// 与 ListModels 使用同一鉴权谓词(AllowBatchImageGeneration + Platform==Gemini),
// 避免两个入口校验口径不一致留下防御纵深缺口。
if err := s.ensureGroupAllowsBatchImage(ctx, owner.GroupID); err != nil {
return nil, err
}
requestHash := HashBatchImageSubmitRequest(normalized)
idempotencyKey = strings.TrimSpace(idempotencyKey)
if idempotencyKey != "" {
existing, err := s.Repo.GetBatchImageJobByIdempotencyKey(ctx, owner.UserID, owner.APIKeyID, idempotencyKey)
if err == nil {
if batchImageDerefString(existing.RequestHash) != requestHash {
return nil, ErrBatchImageIdempotencyConflict
}
if existing.Status == BatchImageJobStatusSubmitted && s.Queue != nil {
if enqueueErr := s.Queue.Enqueue(ctx, existing.BatchID); enqueueErr != nil && !errors.Is(enqueueErr, ErrBatchImageAlreadyQueued) {
_ = s.Repo.RecordBatchImageJobSubmitFailure(ctx, existing.BatchID, "QUEUE_FAILED", sanitizeBatchImagePublicMessage(enqueueErr.Error()), false)
return nil, ErrBatchImageQueueFailed
}
}
return BatchImageJobToPublic(existing), nil
}
if !errors.Is(err, ErrBatchImageJobNotFound) {
return nil, err
}
}
provider, account, err := s.selectProviderAndAccount(ctx, owner, normalized.Provider, normalized.Model)
if err != nil {
return nil, err
}
pricingSnapshot, err := s.resolvePricingSnapshot(ctx, owner, normalized, provider.Name(), account)
if err != nil {
return nil, err
}
parentBatchID := batchImageOptionalStringPtr(normalized.ParentBatchID)
if parentBatchID != nil {
parent, parentErr := s.Repo.GetBatchImageJobByBatchIDForOwner(ctx, owner.UserID, owner.APIKeyID, *parentBatchID)
if parentErr != nil {
return nil, parentErr
}
if parent.ParentBatchID != nil && strings.TrimSpace(*parent.ParentBatchID) != "" {
parentBatchID = batchImageOptionalStringPtr(*parent.ParentBatchID)
}
}
batchID, err := NewBatchImageID()
if err != nil {
return nil, err
}
apiKeyID := owner.APIKeyID
accountID := account.ID
holdID := BatchImageHoldRequestID(batchID)
holdAmount := pricingSnapshot.HoldAmount
job, err := s.Repo.CreateBatchImageJob(ctx, CreateBatchImageJobParams{
BatchID: batchID,
UserID: owner.UserID,
APIKeyID: &apiKeyID,
AccountID: &accountID,
Provider: provider.Name(),
Model: normalized.Model,
TaskName: normalized.TaskName,
ParentBatchID: parentBatchID,
Status: BatchImageJobStatusCreated,
ItemCount: len(normalized.Items),
EstimatedCost: pricingSnapshot.EstimatedCost,
HoldAmount: &holdAmount,
BaseUnitPrice: pricingSnapshot.BaseUnitPrice,
GroupRateMultiplier: pricingSnapshot.GroupRateMultiplier,
AccountRateMultiplier: pricingSnapshot.AccountRateMultiplier,
BatchDiscountMultiplier: pricingSnapshot.BatchDiscountMultiplier,
HoldMultiplier: pricingSnapshot.HoldMultiplier,
BillableUnitPrice: pricingSnapshot.BillableUnitPrice,
HoldUnitPrice: pricingSnapshot.HoldUnitPrice,
PricingSnapshotVersion: 1,
Currency: "USD",
HoldID: &holdID,
IdempotencyKey: batchImageOptionalStringPtr(idempotencyKey),
RequestHash: batchImageStringPtr(requestHash),
})
if err != nil {
return nil, err
}
if err := reserveBatchImageBalanceHold(ctx, s.BillingRepo, job, requestHash); err != nil {
code := "BILLING_HOLD_FAILED"
if errors.Is(err, ErrBatchImageInsufficientBalance) {
code = "INSUFFICIENT_BALANCE"
}
_ = s.Repo.RecordBatchImageJobSubmitFailure(ctx, job.BatchID, code, sanitizeBatchImagePublicMessage(err.Error()), true)
s.hidePreUpstreamSubmitFailure(ctx, owner, job)
return nil, err
}
s.invalidateAuthCache(ctx, owner.UserID)
if err := s.createPendingItems(ctx, job.BatchID, requestHash, normalized.Items); err != nil {
if releaseErr := s.releaseFailedSubmitHold(ctx, job, requestHash); releaseErr != nil {
return nil, releaseErr
}
_ = s.Repo.RecordBatchImageJobSubmitFailure(ctx, job.BatchID, "ITEM_CREATE_FAILED", sanitizeBatchImagePublicMessage(err.Error()), true)
s.hidePreUpstreamSubmitFailure(ctx, owner, job)
return nil, ErrBatchImageQueueFailed
}
input := BatchImageInput{
BatchID: job.BatchID,
Model: normalized.Model,
DisplayName: job.BatchID,
ResponseMimeType: normalized.ResponseMimeType,
AspectRatio: normalized.AspectRatio,
ImageSize: normalized.ImageSize,
Metadata: normalized.Metadata,
Items: make([]BatchImageInputItem, 0, len(normalized.Items)),
}
for _, item := range normalized.Items {
refs := make([]BatchImageReference, 0, len(item.ReferenceImages))
for _, ref := range item.ReferenceImages {
refs = append(refs, BatchImageReference(ref))
}
input.Items = append(input.Items, BatchImageInputItem{
CustomID: item.CustomID,
Prompt: item.Prompt,
ReferenceImages: refs,
})
}
// 上游提交(上传参考图 + 创建批任务)可能长达数分钟且不刷新 updated_at,
// 会被 stale 恢复扫描误判为滞留并退款。提交前转入 uploading 刷新时间戳,
// 提交期间用心跳持续续期。
if err := s.Repo.TransitionBatchImageJobStatus(ctx, job.BatchID, BatchImageJobStatusUploading, BatchImageTransitionOptions{
EventType: "upload_started",
EventPayload: map[string]any{"batch_id": job.BatchID},
}); err != nil {
if releaseErr := s.releaseFailedSubmitHold(ctx, job, requestHash); releaseErr != nil {
return nil, releaseErr
}
// 并发 Cancel 等导致的非法转换:job 已处于终态,不再覆盖其状态。
if !errors.Is(err, ErrBatchImageInvalidTransition) {
_ = s.Repo.RecordBatchImageJobSubmitFailure(ctx, job.BatchID, "UPLOAD_TRANSITION_FAILED", sanitizeBatchImagePublicMessage(err.Error()), true)
s.hidePreUpstreamSubmitFailure(ctx, owner, job)
}
return nil, err
}
job.Status = BatchImageJobStatusUploading
hbCtx, hbCancel := context.WithCancel(ctx)
hbDone := make(chan struct{})
go s.runSubmitHeartbeat(hbCtx, job.BatchID, hbDone)
providerJob, err := provider.Submit(ctx, job, account, input)
hbCancel()
<-hbDone
if err != nil {
if releaseErr := s.releaseFailedSubmitHold(ctx, job, requestHash); releaseErr != nil {
return nil, releaseErr
}
publicErr := batchImageProviderSubmitPublicError(err)
reason := batchImageProviderSubmitRecordCode(publicErr)
_ = s.Repo.RecordBatchImageJobSubmitFailure(ctx, job.BatchID, reason, sanitizeBatchImagePublicMessage(err.Error()), true)
s.hidePreUpstreamSubmitFailure(ctx, owner, job)
return nil, publicErr
}
if providerJob == nil || strings.TrimSpace(providerJob.ProviderJobName) == "" {
if releaseErr := s.releaseFailedSubmitHold(ctx, job, requestHash); releaseErr != nil {
return nil, releaseErr
}
_ = s.Repo.RecordBatchImageJobSubmitFailure(ctx, job.BatchID, "PROVIDER_SUBMIT_FAILED", "provider job name missing", true)
s.hidePreUpstreamSubmitFailure(ctx, owner, job)
return nil, ErrBatchImageProviderSubmitFailed
}
if err := s.Repo.UpdateBatchImageJobProviderSubmit(ctx, UpdateBatchImageJobProviderSubmitParams{
BatchID: job.BatchID,
ProviderJobName: providerJob.ProviderJobName,
ProviderInputRef: providerJob.ProviderInputRef,
ProviderOutputRef: providerJob.ProviderOutputRef,
GCSInputURI: batchImageGCSRef(provider.Name(), providerJob.ProviderInputRef),
GCSOutputURI: batchImageGCSRef(provider.Name(), providerJob.ProviderOutputRef),
EventPayload: map[string]any{"provider": provider.Name()},
}); err != nil {
// job 可能已被恢复扫描转 failed 并退款:上游批任务已创建成功,
// 必须尽力取消并清理输入,否则上游照常产生成本(孤儿任务)。
s.abortOrphanProviderJob(ctx, provider, job, account, providerJob)
return nil, err
}
if s.Queue != nil {
if err := s.Queue.Enqueue(ctx, job.BatchID); err != nil && !errors.Is(err, ErrBatchImageAlreadyQueued) {
_ = s.Repo.RecordBatchImageJobSubmitFailure(ctx, job.BatchID, "QUEUE_FAILED", sanitizeBatchImagePublicMessage(err.Error()), false)
return nil, ErrBatchImageQueueFailed
}
}
created, err := s.Repo.GetBatchImageJobByBatchID(ctx, job.BatchID)
if err != nil {
return nil, err
}
return BatchImageJobToPublic(created), nil
}
func (s *BatchImagePublicService) releaseFailedSubmitHold(ctx context.Context, job *BatchImageJob, requestHash string) error {
if err := releaseBatchImageBalanceHold(ctx, s.BillingRepo, job, requestHash); err != nil {
_ = s.Repo.RecordBatchImageJobSubmitFailure(ctx, job.BatchID, "BILLING_RELEASE_FAILED", sanitizeBatchImagePublicMessage(err.Error()), true)
s.enqueueBillingRetry(ctx, job.BatchID)
return ErrBatchImageBillingHoldFailed
}
s.invalidateAuthCache(ctx, job.UserID)
return nil
}
// runSubmitHeartbeat 在 provider.Submit 期间周期性刷新 job 的 updated_at,
// 使 stale 恢复扫描能区分"仍在慢提交"与"进程死亡后的滞留"。
func (s *BatchImagePublicService) runSubmitHeartbeat(ctx context.Context, batchID string, done chan<- struct{}) {
defer close(done)
interval := s.submitHeartbeatInterval()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.Repo.TouchBatchImageJobSubmitting(ctx, batchID); err != nil && ctx.Err() == nil {
logger.L().Warn("batch_image.submit_heartbeat_failed",
zap.String("batch_id", batchID),
zap.Error(err),
)
}
}
}
}
func (s *BatchImagePublicService) submitHeartbeatInterval() time.Duration {
staleAfter := 10 * time.Minute
if s != nil && s.Config != nil && s.Config.BatchImage.StaleActiveAfterSeconds > 0 {
staleAfter = time.Duration(s.Config.BatchImage.StaleActiveAfterSeconds) * time.Second
}
interval := staleAfter / 3
if interval < 15*time.Second {
interval = 15 * time.Second
}
return interval
}
// abortOrphanProviderJob 在上游任务创建成功但本地状态推进失败时,
// 尽力取消上游批任务并清理已上传的输入文件,避免孤儿任务持续产生成本。
func (s *BatchImagePublicService) abortOrphanProviderJob(ctx context.Context, provider BatchImageProvider, job *BatchImageJob, account *Account, providerJob *BatchProviderJob) {
if s == nil || provider == nil || job == nil || providerJob == nil {
return
}
orphan := *job
orphan.ProviderJobName = batchImageOptionalStringPtr(providerJob.ProviderJobName)
orphan.ProviderInputRef = batchImageOptionalStringPtr(providerJob.ProviderInputRef)
orphan.GCSInputURI = batchImageOptionalStringPtr(batchImageGCSRef(provider.Name(), providerJob.ProviderInputRef))
if err := provider.Cancel(ctx, &orphan, account); err != nil {
logger.L().Warn("batch_image.orphan_provider_job_cancel_failed",
zap.String("batch_id", job.BatchID),
zap.String("provider", provider.Name()),
zap.Error(err),
)
}
if err := provider.Cleanup(ctx, &orphan, account, CleanupTargetInput); err != nil {
logger.L().Warn("batch_image.orphan_provider_job_cleanup_failed",
zap.String("batch_id", job.BatchID),
zap.String("provider", provider.Name()),
zap.Error(err),
)
}
if err := s.Repo.AppendBatchImageEvent(ctx, job.BatchID, "provider_job_aborted_after_submit", map[string]any{
"batch_id": job.BatchID,
"provider": provider.Name(),
}); err != nil {
logger.L().Warn("batch_image.orphan_provider_job_event_failed",
zap.String("batch_id", job.BatchID),
zap.Error(err),
)
}
}
func (s *BatchImagePublicService) createPendingItems(ctx context.Context, batchID, requestHash string, items []BatchImageSubmitItem) error {
if s == nil || s.Repo == nil || len(items) == 0 {
return nil
}
params := make([]CreateBatchImageItemParams, 0, len(items))
for _, item := range items {
preview := truncateBatchImageMessage(item.Prompt, s.maxPromptChars())
params = append(params, CreateBatchImageItemParams{
JobID: batchID,
CustomID: item.CustomID,
Status: BatchImageItemStatusPending,
RequestHash: batchImageStringPtr(requestHash),
PromptPreview: batchImageStringPtr(preview),
ImageCount: 0,
})
}
return s.Repo.BulkCreateBatchImageItems(ctx, params)
}
func (s *BatchImagePublicService) enqueueBillingRetry(ctx context.Context, batchID string) {
if s == nil || s.Queue == nil {
return
}
if err := s.Queue.Enqueue(ctx, batchID); err != nil && !errors.Is(err, ErrBatchImageAlreadyQueued) {
logger.L().Warn("batch_image.billing_retry_enqueue_failed",
zap.String("batch_id", batchID),
zap.Error(err),
)
if eventErr := s.Repo.AppendBatchImageEvent(ctx, batchID, "billing_retry_enqueue_failed", map[string]any{
"batch_id": batchID,
"error": sanitizeBatchImagePublicMessage(err.Error()),
}); eventErr != nil {
logger.L().Warn("batch_image.billing_retry_event_failed",
zap.String("batch_id", batchID),
zap.Error(eventErr),
)
}
}
}
func (s *BatchImagePublicService) hidePreUpstreamSubmitFailure(ctx context.Context, owner BatchImageOwner, job *BatchImageJob) {
if s == nil || s.Repo == nil || job == nil || job.ProviderJobName != nil {
return
}
if err := s.Repo.MarkBatchImageJobUserDeleted(ctx, owner.UserID, owner.APIKeyID, job.BatchID, time.Now()); err != nil {
logger.L().Warn("batch_image.hide_pre_upstream_failure_failed",
zap.String("batch_id", job.BatchID),
zap.Error(err),
)
}
}
func (s *BatchImagePublicService) Get(ctx context.Context, owner BatchImageOwner, batchID string) (*BatchImagePublicBatch, error) {
job, err := s.Repo.GetBatchImageJobByBatchIDForOwner(ctx, owner.UserID, owner.APIKeyID, batchID)
if err != nil {
return nil, err
}
return BatchImageJobToPublic(job), nil
}
func (s *BatchImagePublicService) List(ctx context.Context, owner BatchImageOwner, query BatchImageJobsQuery) (*BatchImagePublicListResponse, error) {
filter := BatchImageJobFilter{Limit: query.Limit, Offset: parseBatchImageCursor(query.Cursor), ExcludeDeleted: true}
filter.TaskNameLike = strings.TrimSpace(query.TaskName)
switch strings.TrimSpace(query.Status) {
case "", "all":
case "queued":
filter.Status = BatchImageJobStatusSubmitted
case "processing_results":
filter.Status = BatchImageJobStatusIndexing
case "completed":
filter.Status = BatchImageJobStatusCompleted
case "failed":
filter.Status = BatchImageJobStatusFailed
case "cancelled":
filter.Status = BatchImageJobStatusCancelled
case "output_deleted":
filter.Status = BatchImageJobStatusOutputDeleted
default:
filter.Status = strings.TrimSpace(query.Status)
}
switch strings.TrimSpace(strings.ToLower(query.Downloaded)) {
case "", "all":
case "true", "1", "yes", "downloaded":
downloaded := true
filter.Downloaded = &downloaded
case "false", "0", "no", "not_downloaded":
downloaded := false
filter.Downloaded = &downloaded
default:
return nil, ErrBatchImageInvalidItems
}
if from := parseBatchImageListTime(query.From); from != nil {
filter.CreatedAfter = from
}
if to := parseBatchImageListTime(query.To); to != nil {
filter.CreatedBefore = to
}
if filter.Limit <= 0 || filter.Limit > 100 {
filter.Limit = 20
}
jobs, err := s.Repo.ListBatchImageJobsForOwner(ctx, owner.UserID, owner.APIKeyID, filter)
if err != nil {
return nil, err
}
data := make([]*BatchImagePublicBatch, 0, len(jobs))
for _, job := range jobs {
data = append(data, BatchImageJobToPublic(job))
}
return &BatchImagePublicListResponse{
Object: "list",
Data: data,
HasMore: len(data) == filter.Limit,
}, nil
}
func (s *BatchImagePublicService) MarkDownloaded(ctx context.Context, owner BatchImageOwner, batchID string) error {
job, err := s.Repo.GetBatchImageJobByBatchIDForOwner(ctx, owner.UserID, owner.APIKeyID, batchID)
if err != nil {
return err
}
return s.Repo.MarkBatchImageDownloaded(ctx, job.BatchID, time.Now())
}
func (s *BatchImagePublicService) DeleteRecord(ctx context.Context, owner BatchImageOwner, batchID string) error {
job, err := s.Repo.GetBatchImageJobByBatchIDForOwner(ctx, owner.UserID, owner.APIKeyID, batchID)
if err != nil {
return err
}
if !isBatchImageProcessorDoneStatus(job.Status) {
return ErrBatchImageRecordDeleteNotReady
}
return s.Repo.MarkBatchImageJobUserDeleted(ctx, owner.UserID, owner.APIKeyID, job.BatchID, time.Now())
}
func (s *BatchImagePublicService) ListModels(ctx context.Context, owner BatchImageOwner) (*BatchImagePublicModelsResponse, error) {
if !s.enabled() {
return nil, ErrBatchImageDisabled
}
if s.Pricing == nil {
return nil, ErrBatchImageSettlementPricingMissing
}
if err := s.ensureGroupAllowsBatchImage(ctx, owner.GroupID); err != nil {
return nil, err
}
modelsByProvider := make(map[string]map[string]struct{})
for _, providerName := range batchImageProviderSelectionOrder("") {
provider, ok := s.ProviderRegistry.Get(providerName)
if !ok || provider == nil {
continue
}
accounts, err := s.listCandidateAccounts(ctx, owner.GroupID, batchImageProviderPlatform(providerName))
if err != nil {
return nil, err
}
for i := range accounts {
account := accounts[i]
if !account.IsSchedulable() || !provider.SupportsAccount(&account) {
continue
}
for _, model := range batchImageModelsFromAccountMapping(&account) {
if _, err := s.Pricing.BatchImageUnitPrice(ctx, &BatchImageJob{Provider: providerName, Model: model}); err != nil {
continue
}
if !account.IsModelSupported(model) {
continue
}
if modelsByProvider[providerName] == nil {
modelsByProvider[providerName] = make(map[string]struct{})
}
modelsByProvider[providerName][model] = struct{}{}
}
}
}
out := make([]BatchImagePublicModel, 0)
for _, providerName := range batchImageProviderSelectionOrder("") {
models := make([]string, 0, len(modelsByProvider[providerName]))
for model := range modelsByProvider[providerName] {
models = append(models, model)
}
sort.Strings(models)
for _, model := range models {
out = append(out, BatchImagePublicModel{
ID: model,
Object: "image.batch.model",
Provider: providerName,
})
}
}
return &BatchImagePublicModelsResponse{Object: "list", Data: out}, nil
}
func (s *BatchImagePublicService) ListItems(ctx context.Context, owner BatchImageOwner, batchID string, query BatchImageItemsQuery) (*BatchImagePublicItemsResponse, error) {
filter := BatchImageItemFilter{Limit: query.Limit, Offset: parseBatchImageCursor(query.Cursor)}
switch strings.TrimSpace(query.Status) {
case "", "all":
case "succeeded", "success":
filter.Status = BatchImageItemStatusSuccess
case "pending":
filter.Status = BatchImageItemStatusPending
case "failed":
filter.Status = BatchImageItemStatusFailed
default:
return nil, ErrBatchImageInvalidItems
}
if filter.Limit <= 0 || filter.Limit > 500 {
filter.Limit = 100
}
items, err := s.Repo.ListBatchImageItemsForOwner(ctx, owner.UserID, owner.APIKeyID, batchID, filter)
if err != nil {
return nil, err
}
data := make([]BatchImagePublicItem, 0, len(items))
for _, item := range items {
data = append(data, BatchImageItemToPublic(item))
}
return &BatchImagePublicItemsResponse{
Object: "list",
Data: data,
HasMore: len(data) == filter.Limit,
}, nil
}
func (s *BatchImagePublicService) Cancel(ctx context.Context, owner BatchImageOwner, batchID string) (*BatchImagePublicBatch, error) {
job, err := s.Repo.GetBatchImageJobByBatchIDForOwner(ctx, owner.UserID, owner.APIKeyID, batchID)
if err != nil {
return nil, err
}
if isBatchImageProcessorDoneStatus(job.Status) {
if job.Status == BatchImageJobStatusFailed || job.Status == BatchImageJobStatusCancelled {
if err := releaseBatchImageBalanceHold(ctx, s.BillingRepo, job, batchImageDerefString(job.RequestHash)); err != nil {
s.enqueueBillingRetry(ctx, job.BatchID)
return nil, ErrBatchImageCancelFailed
}
s.invalidateAuthCache(ctx, owner.UserID)
}
return BatchImageJobToPublic(job), nil
}
if job.ProviderJobName != nil && strings.TrimSpace(*job.ProviderJobName) != "" {
provider, ok := s.ProviderRegistry.Get(job.Provider)
if !ok || provider == nil {
return nil, ErrBatchImageUnsupportedProvider
}
if job.AccountID == nil {
return nil, ErrBatchImageCancelFailed
}
account, err := s.AccountRepo.GetByID(ctx, *job.AccountID)
if err != nil {
return nil, ErrBatchImageCancelFailed
}
if err := provider.Cancel(ctx, job, account); err != nil {
return nil, ErrBatchImageCancelFailed
}
if eventErr := s.Repo.AppendBatchImageEvent(ctx, job.BatchID, "job_cancel_requested", map[string]any{"batch_id": job.BatchID}); eventErr != nil {
logger.L().Warn("batch_image.cancel_event_failed",
zap.String("batch_id", job.BatchID),
zap.Error(eventErr),
)
}
if s.Queue != nil {
if err := s.Queue.Enqueue(ctx, job.BatchID); err != nil && !errors.Is(err, ErrBatchImageAlreadyQueued) {
return nil, ErrBatchImageCancelFailed
}
}
updated, err := s.Repo.GetBatchImageJobByBatchIDForOwner(ctx, owner.UserID, owner.APIKeyID, batchID)
if err != nil {
return nil, err
}
return BatchImageJobToPublic(updated), nil
}
if err := s.Repo.TransitionBatchImageJobStatus(ctx, job.BatchID, BatchImageJobStatusCancelled, BatchImageTransitionOptions{
EventType: "job_cancelled",
EventPayload: map[string]any{"batch_id": job.BatchID},
}); err != nil {
return nil, err
}
if err := releaseBatchImageBalanceHold(ctx, s.BillingRepo, job, batchImageDerefString(job.RequestHash)); err != nil {
s.enqueueBillingRetry(ctx, job.BatchID)
return nil, ErrBatchImageCancelFailed
}
s.invalidateAuthCache(ctx, owner.UserID)
updated, err := s.Repo.GetBatchImageJobByBatchIDForOwner(ctx, owner.UserID, owner.APIKeyID, batchID)
if err != nil {
return nil, err
}
return BatchImageJobToPublic(updated), nil
}
func (s *BatchImagePublicService) validateSubmitRequest(req BatchImageSubmitRequest) (BatchImageSubmitRequest, error) {
req.Model = strings.TrimSpace(req.Model)
req.TaskName = strings.TrimSpace(req.TaskName)
req.ParentBatchID = strings.TrimSpace(req.ParentBatchID)
req.Provider = strings.TrimSpace(req.Provider)
req.ResponseMimeType = strings.TrimSpace(req.ResponseMimeType)
req.AspectRatio = strings.TrimSpace(req.AspectRatio)
req.ImageSize = strings.TrimSpace(req.ImageSize)
if req.Model == "" {
return req, ErrBatchImageInvalidModel
}
if req.TaskName == "" {
req.TaskName = defaultBatchImageTaskName(time.Now())
}
if len(req.TaskName) > 255 {
req.TaskName = truncateBatchImageMessage(req.TaskName, 255)
}
if req.Provider != "" && !IsSupportedBatchImageProvider(req.Provider) {
return req, ErrBatchImageUnsupportedProvider
}
if len(req.Items) == 0 {
return req, ErrBatchImageInvalidItems
}
maxItems := s.maxItems()
if len(req.Items) > maxItems {
return req, ErrBatchImageInvalidItems
}
if req.ResponseMimeType == "" {
req.ResponseMimeType = s.defaultResponseMimeType()
}
if req.ImageSize == "" {
req.ImageSize = s.defaultImageSize()
}
if !strings.EqualFold(req.ImageSize, defaultBatchImageImageSize) {
return req, ErrBatchImageInvalidItems
}
req.ImageSize = defaultBatchImageImageSize
req.Metadata = sanitizeBatchImageMetadata(req.Metadata)
seen := make(map[string]struct{}, len(req.Items))
totalReferenceImages := 0
totalInlineReferenceBytes := 0
totalOutputImages := 0
expandedItems := make([]BatchImageSubmitItem, 0, len(req.Items))
for i := range req.Items {
req.Items[i].CustomID = strings.TrimSpace(req.Items[i].CustomID)
if req.Items[i].CustomID == "" {
req.Items[i].CustomID = fmt.Sprintf("item_%06d", i+1)
}
outputCount := req.Items[i].OutputCount
if outputCount == 0 {
outputCount = 1
}
if outputCount < 1 || outputCount > s.maxOutputImagesPerItem() {
return req, ErrBatchImageInvalidItems
}
totalOutputImages += outputCount
if totalOutputImages > s.maxOutputImagesPerJob() {
return req, ErrBatchImageTooManyOutputImages
}
req.Items[i].Prompt = strings.TrimSpace(req.Items[i].Prompt)
if req.Items[i].Prompt == "" {
return req, ErrBatchImageInvalidItems
}
if len(req.Items[i].Prompt) > s.maxPromptChars() {
return req, ErrBatchImagePromptTooLong
}
referenceCount, inlineReferenceBytes, err := normalizeBatchImageReferenceInputs(req.Model, &req.Items[i])
if err != nil {
return req, err
}
totalReferenceImages += referenceCount * outputCount
if totalReferenceImages > s.maxReferenceImagesPerJob() {
return req, ErrBatchImageTooManyReferenceImages
}
totalInlineReferenceBytes += inlineReferenceBytes * outputCount
if totalInlineReferenceBytes > s.maxReferenceInlineBytesPerJob() {
return req, ErrBatchImageReferenceImagesTooLarge
}
for repeatIndex := 1; repeatIndex <= outputCount; repeatIndex++ {
expanded := req.Items[i]
expanded.OutputCount = 0
if outputCount > 1 {
expanded.CustomID = fmt.Sprintf("%s_%0*d", req.Items[i].CustomID, batchImageRepeatSuffixWidth(outputCount), repeatIndex)
}
if _, ok := seen[expanded.CustomID]; ok {
return req, ErrBatchImageDuplicateCustomIDInRequest
}
seen[expanded.CustomID] = struct{}{}
expandedItems = append(expandedItems, expanded)
}
}
req.Items = expandedItems
return req, nil
}
func normalizeBatchImageReferenceInputs(model string, item *BatchImageSubmitItem) (int, int, error) {
if item == nil || len(item.ReferenceImages) == 0 {
return 0, 0, nil
}
maxRefs := maxBatchImageReferenceImagesForModel(model)
if maxRefs <= 0 || len(item.ReferenceImages) > maxRefs {
return 0, 0, ErrBatchImageTooManyReferenceImages
}
out := make([]BatchImageReferenceInput, 0, len(item.ReferenceImages))
inlineBytes := 0
for _, ref := range item.ReferenceImages {
ref.ID = truncateBatchImageMessage(strings.TrimSpace(ref.ID), 80)
ref.Type = truncateBatchImageMessage(strings.TrimSpace(ref.Type), 40)
ref.MimeType = normalizeBatchImageReferenceMimeType(ref.MimeType)
ref.FileURI = strings.TrimSpace(ref.FileURI)
if ref.MimeType == "" {
return 0, 0, ErrBatchImageInvalidReferenceImage
}
if len(ref.Data) == 0 && ref.FileURI == "" {
return 0, 0, ErrBatchImageInvalidReferenceImage
}
if len(ref.Data) > 0 && ref.FileURI != "" {
return 0, 0, ErrBatchImageInvalidReferenceImage
}
if len(ref.Data) > maxBatchImageReferenceImageBytes {
return 0, 0, ErrBatchImageInvalidReferenceImage
}
if ref.FileURI != "" && !strings.HasPrefix(ref.FileURI, "gs://") {
return 0, 0, ErrBatchImageInvalidReferenceImage
}
inlineBytes += len(ref.Data)
out = append(out, ref)
}
item.ReferenceImages = out
return len(out), inlineBytes, nil
}
func normalizeBatchImageReferenceMimeType(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "image/jpeg", "image/jpg":
return "image/jpeg"
case "image/png":
return "image/png"
case "image/webp":
return "image/webp"
default:
return ""
}
}
func batchImageRepeatSuffixWidth(count int) int {
if count < 10 {
return 2
}
return len(strconv.Itoa(count))
}
func maxBatchImageReferenceImagesForModel(model string) int {
model = strings.ToLower(strings.TrimSpace(model))
if strings.Contains(model, "pro-image") {
return 14
}
if strings.Contains(model, "flash-image") {
return 3
}
return 0
}
func (s *BatchImagePublicService) selectProviderAndAccount(ctx context.Context, owner BatchImageOwner, requestedProvider, model string) (BatchImageProvider, *Account, error) {
providers := batchImageProviderSelectionOrder(requestedProvider)
for _, providerName := range providers {
provider, ok := s.ProviderRegistry.Get(providerName)
if !ok || provider == nil {
continue
}
accounts, err := s.listCandidateAccounts(ctx, owner.GroupID, batchImageProviderPlatform(providerName))
if err != nil {
return nil, nil, err
}
sort.SliceStable(accounts, func(i, j int) bool {
if accounts[i].Priority != accounts[j].Priority {
return accounts[i].Priority > accounts[j].Priority
}
return accounts[i].ID < accounts[j].ID
})
for i := range accounts {
account := accounts[i]
if !account.IsSchedulable() || !account.IsModelSupported(model) {
continue
}
if provider.SupportsAccount(&account) {
return provider, &account, nil
}
}
}
if requestedProvider != "" {
return nil, nil, ErrBatchImageNoAccountAvailable
}
return nil, nil, ErrBatchImageNoAccountAvailable
}
func (s *BatchImagePublicService) listCandidateAccounts(ctx context.Context, groupID *int64, platform string) ([]Account, error) {
if s.AccountRepo == nil {
return nil, ErrBatchImageNoAccountAvailable
}
if groupID != nil && *groupID > 0 {
return s.AccountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform)
}
return s.AccountRepo.ListSchedulableByPlatform(ctx, platform)
}
func (s *BatchImagePublicService) ensureGroupAllowsBatchImage(ctx context.Context, groupID *int64) error {
if groupID == nil || *groupID <= 0 {
return nil
}
if s.GroupRepo == nil {
return ErrBatchImageSettlementPricingMissing
}
group, err := s.GroupRepo.GetByIDLite(ctx, *groupID)
if err != nil || group == nil {
return ErrBatchImageSettlementPricingMissing
}
if !group.AllowBatchImageGeneration {
return ErrBatchImageGroupDisabled
}
if group.Platform != PlatformGemini {
return ErrBatchImageGroupDisabled
}
return nil
}
func (s *BatchImagePublicService) resolvePricingSnapshot(ctx context.Context, owner BatchImageOwner, req BatchImageSubmitRequest, provider string, account *Account) (*BatchImagePricingSnapshot, error) {
unit := -1.0
groupMultiplier := 1.0
discountMultiplier := defaultBatchImageDiscountMultiplier
holdMultiplier := defaultBatchImageHoldMultiplier
if owner.GroupID != nil && *owner.GroupID > 0 {
if s.GroupRepo == nil {
return nil, ErrBatchImageSettlementPricingMissing
}
group, err := s.GroupRepo.GetByIDLite(ctx, *owner.GroupID)
if err != nil || group == nil {
return nil, ErrBatchImageSettlementPricingMissing
}
if !group.AllowBatchImageGeneration {
return nil, ErrBatchImageGroupDisabled
}
groupDefaultMultiplier := group.RateMultiplier
if groupDefaultMultiplier < 0 {
groupDefaultMultiplier = 0
}
effectiveGroupMultiplier := groupDefaultMultiplier
if s.UserGroupRateRepo != nil {
userRate, rateErr := s.UserGroupRateRepo.GetByUserAndGroup(ctx, owner.UserID, group.ID)
if rateErr != nil {
return nil, ErrBatchImageSettlementPricingMissing
}
if userRate != nil {
effectiveGroupMultiplier = *userRate
}
}
groupMultiplier = effectiveGroupMultiplier
if group.ImageRateIndependent {
groupMultiplier = group.ImageRateMultiplier
}
if groupMultiplier < 0 {
groupMultiplier = 0
}
discountMultiplier = group.BatchImageDiscountMultiplier
if discountMultiplier < 0 {
discountMultiplier = 0
}
if group.BatchImageHoldMultiplier >= 0 {
holdMultiplier = group.BatchImageHoldMultiplier
}
if configuredUnit := group.GetImagePrice(req.ImageSize); configuredUnit != nil && *configuredUnit >= 0 {
unit = *configuredUnit
}
}
if unit < 0 {
if s.Pricing == nil {
return nil, ErrBatchImageSettlementPricingMissing
}
resolvedUnit, err := s.Pricing.BatchImageUnitPrice(ctx, &BatchImageJob{Provider: provider, Model: req.Model})
if err != nil || resolvedUnit < 0 {
return nil, ErrBatchImageSettlementPricingMissing
}
unit = resolvedUnit
}
// 定价不变式:hold 比例不得低于 discount 比例,否则成功率足够高时
// actualCost > holdAmount,结算永远失败、冻结余额无法解冻。
// 管理端已校验新配置,此处兜底钳制存量脏数据。
if holdMultiplier < discountMultiplier {
logger.L().Warn("batch_image.hold_multiplier_below_discount_clamped",
zap.Float64("hold_multiplier", holdMultiplier),
zap.Float64("discount_multiplier", discountMultiplier),
)
holdMultiplier = discountMultiplier
}
accountMultiplier := 1.0
if account != nil {
accountMultiplier = account.BillingRateMultiplier()
}
if accountMultiplier < 0 {
accountMultiplier = 0
}
standardUnitPrice := unit * groupMultiplier * accountMultiplier
billableUnitPrice := standardUnitPrice * discountMultiplier
holdUnitPrice := standardUnitPrice * holdMultiplier
return &BatchImagePricingSnapshot{
BaseUnitPrice: unit,
GroupRateMultiplier: groupMultiplier,
AccountRateMultiplier: accountMultiplier,
BatchDiscountMultiplier: discountMultiplier,
HoldMultiplier: holdMultiplier,
BillableUnitPrice: billableUnitPrice,
HoldUnitPrice: holdUnitPrice,
EstimatedCost: billableUnitPrice * float64(len(req.Items)),
HoldAmount: holdUnitPrice * float64(len(req.Items)),
}, nil
}
func (s *BatchImagePublicService) enabled() bool {
return s != nil && s.Repo != nil && s.AccountRepo != nil && s.Config != nil && s.Config.BatchImage.Enabled
}
func (s *BatchImagePublicService) invalidateAuthCache(ctx context.Context, userID int64) {
if s != nil && s.AuthCache != nil && userID > 0 {
s.AuthCache.InvalidateAuthCacheByUserID(ctx, userID)
}
}
func (s *BatchImagePublicService) maxItems() int {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxItemsPerJobDefault > 0 {
return s.Config.BatchImage.MaxItemsPerJobDefault
}
return defaultBatchImageMaxItems
}
func (s *BatchImagePublicService) maxOutputImagesPerJob() int {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxOutputImagesPerJob > 0 {
return s.Config.BatchImage.MaxOutputImagesPerJob
}
return defaultBatchImageMaxOutputImages
}
func (s *BatchImagePublicService) maxOutputImagesPerItem() int {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxOutputImagesPerItem > 0 {
return s.Config.BatchImage.MaxOutputImagesPerItem
}
return defaultBatchImageMaxOutputCount
}
func (s *BatchImagePublicService) maxPromptChars() int {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxPromptCharsPerItem > 0 {
return s.Config.BatchImage.MaxPromptCharsPerItem
}
return defaultBatchImageMaxPromptChars
}
func (s *BatchImagePublicService) maxReferenceImagesPerJob() int {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxReferenceImagesPerJob > 0 {
return s.Config.BatchImage.MaxReferenceImagesPerJob
}
return defaultBatchImageMaxReferenceImages
}
func (s *BatchImagePublicService) maxReferenceInlineBytesPerJob() int {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxReferenceInlineBytesPerJob > 0 {
return s.Config.BatchImage.MaxReferenceInlineBytesPerJob
}
return defaultBatchImageMaxReferenceBytes
}
func (s *BatchImagePublicService) defaultResponseMimeType() string {
if s != nil && s.Config != nil && strings.TrimSpace(s.Config.BatchImage.DefaultResponseMimeType) != "" {
return strings.TrimSpace(s.Config.BatchImage.DefaultResponseMimeType)
}
return defaultBatchImageResponseMime
}
func (s *BatchImagePublicService) defaultImageSize() string {
if s != nil && s.Config != nil && strings.TrimSpace(s.Config.BatchImage.DefaultImageSize) != "" {
return strings.TrimSpace(s.Config.BatchImage.DefaultImageSize)
}
return defaultBatchImageImageSize
}
func BatchImageJobToPublic(job *BatchImageJob) *BatchImagePublicBatch {
if job == nil {
return nil
}
holdAmount := job.EstimatedCost
if job.HoldAmount != nil {
holdAmount = *job.HoldAmount
}
return &BatchImagePublicBatch{
ID: job.BatchID,
Object: "image.batch",
TaskName: batchImagePublicTaskName(job),
ParentBatchID: job.ParentBatchID,
Status: PublicBatchImageStatus(job.Status),
Model: job.Model,
Provider: job.Provider,
ItemCount: job.ItemCount,
SuccessCount: job.SuccessCount,
FailCount: job.FailCount,
EstimatedCost: job.EstimatedCost,
HoldAmount: holdAmount,
ActualCost: job.ActualCost,
CreatedAt: job.CreatedAt.Unix(),
SubmittedAt: batchImageUnixPtr(job.SubmittedAt),
SettledAt: batchImageUnixPtr(job.SettledAt),
DownloadedAt: batchImageUnixPtr(job.DownloadedAt),
OutputDeletedAt: batchImageUnixPtr(job.OutputDeletedAt),
}
}
func BatchImageItemToPublic(item *BatchImageItem) BatchImagePublicItem {
out := BatchImagePublicItem{
CustomID: item.CustomID,
Status: "failed",
PromptPreview: item.PromptPreview,
MimeType: item.MimeType,
FileExtension: item.FileExtension,
ImageCount: item.ImageCount,
}
if item.Status == BatchImageItemStatusPending {
out.Status = "pending"
return out
}
if item.Status == BatchImageItemStatusSuccess {
out.Status = "succeeded"
return out
}
out.Error = &BatchImagePublicError{
Code: batchImageDerefString(item.ErrorCode),
Message: sanitizeBatchImagePublicMessage(batchImageDerefString(item.ErrorMessage)),
Source: batchImageItemErrorSource(item),
}
return out
}
func batchImageItemErrorSource(item *BatchImageItem) string {
if item == nil || item.ErrorCode == nil {
return ""
}
code := strings.TrimSpace(*item.ErrorCode)
if batchImageDerefString(item.ProviderSourceObject) != "" {
return "provider"
}
switch code {
case "EMPTY_IMAGE_OUTPUT", "PROVIDER_ITEM_FAILED":
return "provider"
case "INDEX_OUTPUT_MISSING", "INDEX_PARSE_FAILED", "DUPLICATE_CUSTOM_ID_IN_OUTPUT":
return "system"
default:
return ""
}
}
func PublicBatchImageStatus(status string) string {
switch status {
case BatchImageJobStatusCreated, BatchImageJobStatusUploading, BatchImageJobStatusSubmitted:
return "queued"
case BatchImageJobStatusRunning:
return "running"
case BatchImageJobStatusIndexing:
return "processing_results"
case BatchImageJobStatusSettling:
return "settling"
case BatchImageJobStatusCompleted:
return "completed"
case BatchImageJobStatusFailed:
return "failed"
case BatchImageJobStatusCancelled:
return "cancelled"
case BatchImageJobStatusOutputDeleted:
return "output_deleted"
default:
return status
}
}
func HashBatchImageSubmitRequest(req BatchImageSubmitRequest) string {
req.Metadata = sanitizeBatchImageMetadata(req.Metadata)
b, _ := json.Marshal(req)
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func batchImageProviderPlatform(provider string) string {
switch provider {
case BatchImageProviderGeminiAPI, BatchImageProviderVertex:
return PlatformGemini
default:
return PlatformGemini
}
}
func batchImageProviderSelectionOrder(requestedProvider string) []string {
if strings.TrimSpace(requestedProvider) != "" {
return []string{strings.TrimSpace(requestedProvider)}
}
return []string{BatchImageProviderGeminiAPI, BatchImageProviderVertex}
}
func batchImageModelsFromAccountMapping(account *Account) []string {
if account == nil {
return nil
}
mapping := account.GetModelMapping()
if len(mapping) == 0 {
return nil
}
models := make(map[string]struct{})
for model := range mapping {
model = strings.TrimSpace(model)
if model == "" {
continue
}
if strings.ContainsAny(model, "*?") {
for _, candidate := range defaultBatchImageModelCandidates() {
if matchWildcard(model, candidate) {
models[candidate] = struct{}{}
}
}
continue
}
models[model] = struct{}{}
}
out := make([]string, 0, len(models))
for model := range models {
out = append(out, model)
}
sort.Strings(out)
return out
}
func defaultBatchImageModelCandidates() []string {
return []string{
"gemini-2.0-flash-exp-image-generation",
"gemini-2.5-flash-image",
"gemini-3-pro-image",
"gemini-3-pro-image-preview",
"gemini-3.1-flash-image",
"gemini-3.1-flash-image-preview",
"gemini-3.1-flash-lite-image",
}
}
func batchImageGCSRef(provider, ref string) string {
if provider == BatchImageProviderVertex && strings.HasPrefix(strings.TrimSpace(ref), "gs://") {
return strings.TrimSpace(ref)
}
return ""
}
func batchImageProviderSubmitPublicError(err error) error {
reason := strings.TrimSpace(infraerrors.Reason(err))
switch reason {
case "VERTEX_MANAGED_GCS_BUCKET_MISSING":
return ErrBatchImageVertexGCSBucketMissing
case "BATCH_IMAGE_PROVIDER_MISSING_API_KEY":
return ErrBatchImageProviderMissingAPIKey
case "BATCH_IMAGE_PROVIDER_MISSING_SERVICE_ACCOUNT":
return ErrBatchImageProviderMissingServiceAccount
case "BATCH_IMAGE_PROVIDER_UNSUPPORTED_ACCOUNT":
return ErrBatchImageProviderUnsupportedAccount
default:
return ErrBatchImageProviderSubmitFailed
}
}
func batchImagePublicTaskName(job *BatchImageJob) string {
if job == nil {
return ""
}
if strings.TrimSpace(job.TaskName) != "" {
return strings.TrimSpace(job.TaskName)
}
return defaultBatchImageTaskName(job.CreatedAt)
}
func defaultBatchImageTaskName(now time.Time) string {
if now.IsZero() {
now = time.Now()
}
return now.Format("2006-01-02 15:04:05")
}
func batchImageProviderSubmitRecordCode(err error) string {
reason := strings.TrimSpace(infraerrors.Reason(err))
if reason == "" || reason == "BATCH_IMAGE_PROVIDER_SUBMIT_FAILED" {
return "PROVIDER_SUBMIT_FAILED"
}
return reason
}
func parseBatchImageListTime(raw string) *time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
if unix, err := strconv.ParseInt(raw, 10, 64); err == nil && unix > 0 {
t := time.Unix(unix, 0)
return &t
}
if t, err := time.Parse(time.RFC3339, raw); err == nil {
return &t
}
if t, err := time.Parse("2006-01-02", raw); err == nil {
return &t
}
return nil
}
func sanitizeBatchImageMetadata(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
keys := make([]string, 0, len(in))
for k := range in {
keys = append(keys, k)
}
sort.Strings(keys)
out := make(map[string]string, len(keys))
for _, k := range keys {
key := strings.TrimSpace(k)
if key == "" || len(key) > 64 {
continue
}
value := strings.TrimSpace(in[k])
if len(value) > 256 {
value = value[:256]
}
out[key] = value
if len(out) >= 20 {
break
}
}
return out
}
func sanitizeBatchImagePublicMessage(message string) string {
message = strings.TrimSpace(message)
for _, marker := range []string{"gs://", "files/", "projects/"} {
if strings.Contains(message, marker) {
message = "upstream provider operation failed"
break
}
}
if len(message) > maxBatchImagePublicErrorChars {
message = message[:maxBatchImagePublicErrorChars]
}
return message
}
func batchImageUnixPtr(t *time.Time) *int64 {
if t == nil {
return nil
}
v := t.Unix()
return &v
}
func parseBatchImageCursor(cursor string) int {
offset, err := strconv.Atoi(strings.TrimSpace(cursor))
if err != nil || offset < 0 {
return 0
}
return offset
}