Files
sub2api/backend/internal/service/batch_image_public.go
T

1441 lines
48 KiB
Go
Raw Normal View History

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"`
SessionID *string `json:"-"`
}
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),
SessionID: normalized.SessionID,
})
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
}