1441 lines
48 KiB
Go
1441 lines
48 KiB
Go
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
|
|||
|
|
}
|