feat: 二次元前端改版 + 后端账号plan探测/调度修复
This commit is contained in:
@@ -165,6 +165,11 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
||||
|
||||
// 2. Auto-renew Adobe cookies whose refresh interval has elapsed.
|
||||
if m.refresh != nil {
|
||||
if m.settings != nil {
|
||||
if proxy, err := m.settings.GetValue(ctx, "proxy.url"); err == nil && proxy != "" {
|
||||
m.refresh.SetProxy(proxy)
|
||||
}
|
||||
}
|
||||
if n, err := m.refresh.RefreshDue(ctx); err != nil {
|
||||
log.Printf("maintenance: refresh_due: %v", err)
|
||||
} else if n > 0 {
|
||||
|
||||
@@ -26,6 +26,12 @@ func NewRefreshProfileService(profiles *repo.RefreshProfileRepository, tokens *r
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RefreshProfileService) SetProxy(proxy string) {
|
||||
if s.adobe != nil {
|
||||
s.adobe.SetProxy(proxy)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RefreshProfileService) List(ctx context.Context) ([]model.RefreshProfile, error) {
|
||||
return s.profiles.List(ctx)
|
||||
}
|
||||
@@ -64,12 +70,11 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error
|
||||
"consecutive_failures": failures,
|
||||
"next_retry_at": now.Add(time.Duration(secs) * time.Second),
|
||||
})
|
||||
// After repeated failures the cookie can no longer mint a token — it's
|
||||
// genuinely dead (expired/revoked). Lock the pool token (disabled+dead)
|
||||
// so the UI flags it red. A single failure may be a transient blip, so
|
||||
// only escalate after a few in a row (mirrors Python RefreshManager).
|
||||
if failures >= 3 {
|
||||
_, _ = s.tokens.Update(ctx, profile.Pool, id, map[string]any{
|
||||
// Mark the token dead when the cookie exchange keeps failing:
|
||||
// - 5 consecutive failures → cookie is likely expired/revoked
|
||||
// - ride_AdobeID_acct_actreq (Adobe requires account action) → permanently broken
|
||||
if failures >= 5 || strings.Contains(msg, "ride_AdobeID_acct_actreq") {
|
||||
_, _ = s.tokens.Update(ctx, "adobe", id, map[string]any{
|
||||
"status": "disabled",
|
||||
"dead": true,
|
||||
})
|
||||
@@ -84,12 +89,14 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error
|
||||
"fails": 0,
|
||||
"updated_at": now,
|
||||
}
|
||||
email, exp := parseJWTEmailExpiry(result.AccessToken)
|
||||
email, _ := parseJWTEmailExpiry(result.AccessToken)
|
||||
if email != "" {
|
||||
tokenPatch["account_email"] = email
|
||||
}
|
||||
if exp != nil {
|
||||
tokenPatch["cached_quota_reset_after"] = exp.Format(time.RFC3339)
|
||||
if profile.IntervalSeconds > 0 {
|
||||
tokenPatch["cached_quota_reset_after"] = now.Add(time.Duration(profile.IntervalSeconds) * time.Second).Format(time.RFC3339)
|
||||
} else {
|
||||
tokenPatch["cached_quota_reset_after"] = now.Add(54000 * time.Second).Format(time.RFC3339)
|
||||
}
|
||||
if profileData, profileErr := s.adobe.FetchAccountProfile(ctx, result.AccessToken); profileErr == nil {
|
||||
if email := strings.TrimSpace(stringValue(profileData["email"])); email != "" {
|
||||
@@ -99,6 +106,7 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error
|
||||
tokenPatch["account_display_name"] = displayName
|
||||
}
|
||||
}
|
||||
planKnown := false
|
||||
if quotaData, quotaErr := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); quotaErr == nil {
|
||||
meta := datatypes.JSONMap{
|
||||
"cached_quota_at": int(time.Now().Unix()),
|
||||
@@ -113,10 +121,33 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error
|
||||
meta["cached_quota_total"] = total
|
||||
}
|
||||
tokenPatch["meta"] = meta
|
||||
if resetAfter := strings.TrimSpace(stringValue(quotaData["available_until"])); resetAfter != "" {
|
||||
tokenPatch["cached_quota_reset_after"] = resetAfter
|
||||
|
||||
planCap := strings.ToLower(strings.TrimSpace(stringValue(quotaData["plan"])))
|
||||
meta["plan"] = planCap
|
||||
isVIP := planCap != "" && !strings.EqualFold(planCap, "free")
|
||||
planKnown = planCap != ""
|
||||
|
||||
// VIP: use Adobe's reset time; set concurrency 5.
|
||||
// 母号/子号 身份固定,不随积分动态变化——只有降级普号才刷新身份。
|
||||
if isVIP {
|
||||
if resetAfter := strings.TrimSpace(stringValue(quotaData["available_until"])); resetAfter != "" {
|
||||
tokenPatch["cached_quota_reset_after"] = resetAfter
|
||||
}
|
||||
tokenPatch["concurrency"] = 5
|
||||
} else {
|
||||
// Free account: concurrency 1, limit image+video
|
||||
tokenPatch["concurrency"] = 1
|
||||
tokenPatch["image_limited"] = true
|
||||
tokenPatch["video_limited"] = true
|
||||
meta["is_sub_account"] = false
|
||||
}
|
||||
}
|
||||
// 探测不到会员身份(credits 接口失败或没返回 plan)的号既不算普号也不算会员号,
|
||||
// 留在池里只会在需要会员的请求上撞 403 —— 直接置死号。
|
||||
if !planKnown {
|
||||
tokenPatch["status"] = "disabled"
|
||||
tokenPatch["dead"] = true
|
||||
}
|
||||
if _, err := s.tokens.Update(ctx, "adobe", id, tokenPatch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -709,17 +709,44 @@ func (s *TokenService) checkPendingAdobe(tokenID, cookie string) {
|
||||
_, _ = s.tokens.Update(ctx, "adobe", tokenID, seed)
|
||||
|
||||
quotaMeta := map[string]any{}
|
||||
planKnown := false
|
||||
if cb, e := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); e == nil {
|
||||
if ra := strings.TrimSpace(stringValue(cb["available_until"])); ra != "" {
|
||||
_, _ = s.tokens.Update(ctx, "adobe", tokenID, map[string]any{"cached_quota_reset_after": ra})
|
||||
}
|
||||
quotaMeta["cached_quota_at"] = int(time.Now().Unix())
|
||||
planCap := strings.ToLower(strings.TrimSpace(stringValue(cb["plan"])))
|
||||
quotaMeta["plan"] = planCap
|
||||
isVIP := planCap != "" && planCap != "free"
|
||||
planKnown = planCap != ""
|
||||
|
||||
if rem, ok := cb["remaining"].(int); ok {
|
||||
quotaMeta["cached_quota_remaining"] = rem
|
||||
if rem <= 4000 {
|
||||
if isVIP && rem > 0 && rem <= 4000 {
|
||||
quotaMeta["is_sub_account"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Set concurrency + limits based on plan
|
||||
concurrency := 1
|
||||
imageLimit := true
|
||||
videoLimit := true
|
||||
if isVIP {
|
||||
concurrency = 5
|
||||
imageLimit = false
|
||||
videoLimit = false
|
||||
if ra := strings.TrimSpace(stringValue(cb["available_until"])); ra != "" {
|
||||
_, _ = s.tokens.Update(ctx, "adobe", tokenID, map[string]any{"cached_quota_reset_after": ra})
|
||||
}
|
||||
}
|
||||
patch := map[string]any{
|
||||
"concurrency": concurrency,
|
||||
"image_limited": imageLimit,
|
||||
"video_limited": videoLimit,
|
||||
"meta": quotaMeta,
|
||||
}
|
||||
if planKnown {
|
||||
patch["status"] = "active"
|
||||
patch["dead"] = false
|
||||
}
|
||||
_, _ = s.tokens.Update(ctx, "adobe", tokenID, patch)
|
||||
}
|
||||
if prof, e := s.adobe.FetchAccountProfile(ctx, result.AccessToken); e == nil {
|
||||
p := map[string]any{}
|
||||
@@ -734,6 +761,13 @@ func (s *TokenService) checkPendingAdobe(tokenID, cookie string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 探测不到会员身份(credits 接口失败或没返回 plan)的号既不算普号也不算会员号,
|
||||
// 留在池里只会在需要会员的请求上撞 403 —— 直接置死号,等重新探测到 plan 再启用。
|
||||
if !planKnown {
|
||||
log.Printf("token import: adobe %s plan unknown, marking dead", tokenID)
|
||||
s.finishPending(ctx, "adobe", tokenID, "disabled", true, quotaMeta)
|
||||
return
|
||||
}
|
||||
s.finishPending(ctx, "adobe", tokenID, "active", false, quotaMeta)
|
||||
_, _ = s.refresh.Update(ctx, tokenID, map[string]any{
|
||||
"last_attempt_at": time.Now(),
|
||||
@@ -1274,9 +1308,21 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
|
||||
patch := map[string]any{}
|
||||
meta := cloneJSONMap(item.Meta)
|
||||
meta["cached_quota_at"] = int(time.Now().Unix())
|
||||
plan := strings.ToLower(strings.TrimSpace(stringValue(data["plan"])))
|
||||
if plan != "" {
|
||||
meta["plan"] = plan
|
||||
}
|
||||
if remaining, ok := data["remaining"].(int); ok {
|
||||
meta["cached_quota_remaining"] = remaining
|
||||
meta["is_sub_account"] = remaining <= 4000
|
||||
// is_sub_account 仅在导入时写入,刷新配额时不覆盖
|
||||
if _, hasFlag := meta["is_sub_account"]; !hasFlag && plan != "free" {
|
||||
meta["is_sub_account"] = remaining > 0 && remaining <= 4000
|
||||
}
|
||||
}
|
||||
// 子号必然是会员号:plan 查回 free 说明这个号没有会员,不能再挂子号标记
|
||||
// (否则同一行会同时显示 子号 和 普号)。
|
||||
if plan == "free" {
|
||||
meta["is_sub_account"] = false
|
||||
}
|
||||
if used, ok := data["used"].(int); ok {
|
||||
meta["cached_quota_used"] = used
|
||||
@@ -1285,6 +1331,12 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
|
||||
meta["cached_quota_total"] = total
|
||||
}
|
||||
patch["meta"] = meta
|
||||
// 探测不到会员身份(新旧 plan 都为空)的号既不算普号也不算会员号,
|
||||
// 留在池里只会在需要会员的请求上撞 403 —— 直接置死号。
|
||||
if strings.TrimSpace(stringValue(meta["plan"])) == "" {
|
||||
patch["status"] = "disabled"
|
||||
patch["dead"] = true
|
||||
}
|
||||
if resetAfter := strings.TrimSpace(stringValue(data["available_until"])); resetAfter != "" {
|
||||
patch["cached_quota_reset_after"] = resetAfter
|
||||
item.CachedQuotaResetAfter = resetAfter
|
||||
@@ -1294,6 +1346,7 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
|
||||
item = updated
|
||||
}
|
||||
}
|
||||
subAccount, _ := jsonMapBool(meta, "is_sub_account")
|
||||
return map[string]any{
|
||||
"supported": true,
|
||||
"remaining": data["remaining"],
|
||||
@@ -1304,6 +1357,9 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
|
||||
"unchanged": false,
|
||||
"unknown": boolValueWithDefault(data["unknown"], false),
|
||||
"error": data["error"],
|
||||
// 会员身份跟着额度一起返回,前端查一次额度就能刷新 子号/母号/普号 徽章。
|
||||
"plan": emptyToNil(strings.ToLower(strings.TrimSpace(stringValue(meta["plan"])))),
|
||||
"sub_account": subAccount,
|
||||
}, nil
|
||||
}
|
||||
if poolToType(item.Pool) == "krea" && s.krea != nil {
|
||||
@@ -1648,6 +1704,7 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
|
||||
quotaAt, _ := jsonMapInt(item.Meta, "cached_quota_at")
|
||||
pending, _ := jsonMapBool(item.Meta, "pending_check")
|
||||
subAccount, _ := jsonMapBool(item.Meta, "is_sub_account")
|
||||
plan, _ := item.Meta["plan"]
|
||||
// OpenAI email lives in the token's JWT (nested profile claim). Decode it at
|
||||
// render time like the Python reference (_account_row) so accounts imported
|
||||
// before the email was persisted still show a name; fall back to the cached
|
||||
@@ -1686,6 +1743,7 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
|
||||
"video_limited": item.VideoLimited,
|
||||
"pending": pending,
|
||||
"sub_account": subAccount,
|
||||
"plan": emptyToNil(strings.ToLower(strings.TrimSpace(stringValue(plan)))),
|
||||
"quota_supported": hasQuota,
|
||||
"needs_reset_fetch": typeLabel == "adobe" && item.Status == "active" && strings.TrimSpace(item.CachedQuotaResetAfter) == "",
|
||||
"weight": item.Weight,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"backend/internal/model"
|
||||
@@ -33,6 +34,9 @@ type UserGenerateRequest struct {
|
||||
Resolution string
|
||||
Duration string
|
||||
ReferenceImages []string
|
||||
// ReferenceMode overrides the model's default ("frame" or "asset") —
|
||||
// the 画图台 首尾帧/参考图 toggle for models that support both.
|
||||
ReferenceMode string
|
||||
// DeAI applies 去AI特征 post-processing to the generated image (image only)
|
||||
// and charges the per-tier surcharge on top of the model price.
|
||||
DeAI bool
|
||||
@@ -59,6 +63,9 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
|
||||
|
||||
switch modelItem.Type {
|
||||
case "video":
|
||||
if err := validateReferenceMode(in.ReferenceMode, modelItem, len(in.ReferenceImages)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.v1.prepareSessionVideo(ctx, principal, V1VideoRequest{
|
||||
Model: in.Model,
|
||||
Prompt: in.Prompt,
|
||||
@@ -66,6 +73,7 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
|
||||
AspectRatio: in.Ratio,
|
||||
Resolution: in.Resolution,
|
||||
ReferenceImages: in.ReferenceImages,
|
||||
ReferenceMode: strings.TrimSpace(in.ReferenceMode),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -87,6 +95,27 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
|
||||
}
|
||||
}
|
||||
|
||||
// validateReferenceMode mirrors the /v1 checks: the override must be "frame"
|
||||
// or "asset", the model must support references at all, and frame mode carries
|
||||
// at most 2 images (first+last frame).
|
||||
func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int) error {
|
||||
rm = strings.TrimSpace(rm)
|
||||
if rm == "" {
|
||||
return nil
|
||||
}
|
||||
supported := strings.TrimSpace(modelItem.ReferenceMode)
|
||||
if supported == "" || supported == "none" {
|
||||
return fmt.Errorf("%w: reference_mode not supported for this model", ErrUnsupportedParams)
|
||||
}
|
||||
if rm != "frame" && rm != "asset" {
|
||||
return fmt.Errorf("%w: reference_mode must be 'frame' or 'asset'", ErrUnsupportedParams)
|
||||
}
|
||||
if rm == "frame" && refCount > 2 {
|
||||
return fmt.Errorf("%w: frame mode supports at most 2 reference images (first+last frame), got %d", ErrUnsupportedParams, refCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) {
|
||||
if user == nil || strings.TrimSpace(user.ID) == "" {
|
||||
return nil, errors.New("未登录或会话已过期")
|
||||
|
||||
+295
-33
@@ -219,6 +219,7 @@ type V1VideoRequest struct {
|
||||
AspectRatio string
|
||||
Resolution string
|
||||
ReferenceImages []string
|
||||
ReferenceMode string // "frame" or "asset", overrides model default
|
||||
// BaseURL — see V1ImageRequest.BaseURL.
|
||||
BaseURL string
|
||||
// AccountID — see V1ImageRequest.AccountID.
|
||||
@@ -325,6 +326,11 @@ func (s *V1Service) refreshAdobeToken(ctx context.Context, tokenID string) (mode
|
||||
if s.refresh == nil {
|
||||
return model.TokenAccount{}, false
|
||||
}
|
||||
if s.settings != nil {
|
||||
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil && proxy != "" {
|
||||
s.refresh.SetProxy(proxy)
|
||||
}
|
||||
}
|
||||
if err := s.refresh.RefreshNow(ctx, tokenID); err != nil {
|
||||
return model.TokenAccount{}, false
|
||||
}
|
||||
@@ -883,6 +889,24 @@ func (s *V1Service) StartVideoJob(ctx context.Context, principal *APIPrincipal,
|
||||
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
// Validate reference_mode against model capabilities and reference count.
|
||||
if rm := strings.TrimSpace(in.ReferenceMode); rm != "" {
|
||||
supported := strings.TrimSpace(modelItem.ReferenceMode)
|
||||
if supported == "none" || supported == "" {
|
||||
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", "reference_mode not supported for this model")
|
||||
return nil, errors.New("reference_mode not supported for this model")
|
||||
}
|
||||
if rm != "frame" && rm != "asset" {
|
||||
s.logRejectedEvent(ctx, "video", in.Model, principal, in.Prompt, "v1", "reference_mode must be 'frame' or 'asset'")
|
||||
return nil, errors.New("reference_mode must be 'frame' or 'asset'")
|
||||
}
|
||||
if rm == "frame" && len(in.ReferenceImages) > 2 {
|
||||
return nil, fmt.Errorf("frame mode supports at most 2 reference images (first+last frame), got %d", len(in.ReferenceImages))
|
||||
}
|
||||
if strings.TrimSpace(in.ReferenceMode) == modelItem.ReferenceMode {
|
||||
in.ReferenceMode = "" // same as default, don't override
|
||||
}
|
||||
}
|
||||
// Source "v1": no output file is allocated — the result is the upstream URL,
|
||||
// stored on the event when the render completes.
|
||||
eventID, err := s.logPendingEvent(ctx, "video", modelItem, principal, in.Prompt, aspectRatio, resolution, duration, len(in.ReferenceImages), price, "", "v1", nil, false)
|
||||
@@ -1251,6 +1275,26 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
|
||||
if !modelItem.Enabled || modelItem.Type != "video" {
|
||||
return nil, "", "", "", 0, ErrUnknownModel
|
||||
}
|
||||
// Validate duration against model's supported range (from Durations JSON array).
|
||||
if secs := parseDurationSeconds(duration); secs > 0 {
|
||||
if durList := repo.JSONStrings(modelItem.Durations); len(durList) > 0 {
|
||||
minSecs, maxSecs := 9999, 0
|
||||
for _, d := range durList {
|
||||
n := parseDurationSeconds(d)
|
||||
if n > 0 {
|
||||
if n < minSecs {
|
||||
minSecs = n
|
||||
}
|
||||
if n > maxSecs {
|
||||
maxSecs = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if secs < minSecs || secs > maxSecs {
|
||||
return nil, "", "", "", 0, fmt.Errorf("duration %ds out of range [%d-%d] for model %s", secs, minSecs, maxSecs, modelItem.EffectiveName())
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fail fast before charging — effective provider (custom upstream by id, else native).
|
||||
if eff := s.effectiveProvider(ctx, modelItem); eff == "custom" {
|
||||
// custom serves this id (effectiveProvider guaranteed it) — precheck ok
|
||||
@@ -1296,6 +1340,11 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
|
||||
if err != nil {
|
||||
return nil, "", "", "", 0, err
|
||||
}
|
||||
// 规范化 duration 字段:前端 per_second 计费模式可能发来 "per_second" 字符串,
|
||||
// 统一转为 "Xs" 格式(如 "4s")存库,避免日志显示原始键名。
|
||||
if n := parseDurationSeconds(duration); n > 0 {
|
||||
duration = fmt.Sprintf("%ds", n)
|
||||
}
|
||||
return modelItem, resolution, aspectRatio, duration, price, nil
|
||||
}
|
||||
|
||||
@@ -1438,7 +1487,6 @@ func (s *V1Service) finishUnimplementedEvent(ctx context.Context, eventID string
|
||||
return s.events.UpdateStatus(ctx, eventID, "failed", "generation executor not implemented yet", 0)
|
||||
}
|
||||
|
||||
|
||||
// grokConcurrencyPerAccount is how many simultaneous generations one grok account
|
||||
// may run (grok tolerates 10, unlike the 1-per-account default elsewhere).
|
||||
const grokConcurrencyPerAccount = 10
|
||||
@@ -1447,7 +1495,7 @@ const grokConcurrencyPerAccount = 10
|
||||
// policy may burn per request before giving up, so an upstream-wide blip
|
||||
// ("system under load") can't fan a single request out across the whole pool.
|
||||
// After this many accounts fail this way, the request fails.
|
||||
const maxTempDeadAccounts = 3
|
||||
const maxTempDeadAccounts = 10
|
||||
|
||||
// runPoolWithFailover drives a generation across a round-robin-ordered account
|
||||
// list with per-error-class behavior, so a bad request never burns the whole
|
||||
@@ -1479,8 +1527,8 @@ func (s *V1Service) runPoolWithFailover(ctx context.Context, eventID, pool strin
|
||||
busy := 0
|
||||
tempDeadCount := 0
|
||||
for _, token := range active {
|
||||
// 1 concurrent job per account: skip any account already generating.
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
|
||||
// Per-account concurrency gate (defaults to 1 for built-in pools).
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
@@ -1533,6 +1581,15 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
authRefreshed := false
|
||||
if strings.TrimSpace(token.Value) == "" && refreshOnAuth != nil {
|
||||
if refreshed, ok := refreshOnAuth(token.ID); ok {
|
||||
token = refreshed
|
||||
authRefreshed = true
|
||||
} else {
|
||||
s.markTokenDead(ctx, pool, token, kind)
|
||||
return nil, ErrProviderExecution, true, true
|
||||
}
|
||||
}
|
||||
for {
|
||||
data, err := attempt(token)
|
||||
if err == nil {
|
||||
@@ -1549,6 +1606,13 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token
|
||||
return nil, err, true, false
|
||||
}
|
||||
if isAuth {
|
||||
// A 403 user_not_entitled means the account has no Firefly entitlement
|
||||
// — refreshing the access token can't grant one, so kill it now instead
|
||||
// of leaving it in rotation to burn every future request.
|
||||
if errors.Is(err, adobe.ErrNotEntitled) {
|
||||
s.markTokenDead(ctx, pool, token, kind)
|
||||
return nil, err, true, true
|
||||
}
|
||||
// Refresh from cookie and retry ONCE; otherwise the credential is dead.
|
||||
if refreshOnAuth != nil && !authRefreshed {
|
||||
if refreshed, ok := refreshOnAuth(token.ID); ok {
|
||||
@@ -1612,15 +1676,27 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
|
||||
for _, item := range items {
|
||||
// Adobe accounts are credit-based (积分号) — no per-kind quota locks.
|
||||
// Only skip accounts that are dead or disabled.
|
||||
if item.Status == "active" && !item.Dead && strings.TrimSpace(item.Value) != "" {
|
||||
active = append(active, item)
|
||||
if item.Status != "active" || item.Dead {
|
||||
continue
|
||||
}
|
||||
// plan 未探测到的号既不算普号也不算会员号,置死号、不参与调度
|
||||
if planUnknown(item.Meta) {
|
||||
s.markPlanUnknownDead(ctx, "adobe", item.ID)
|
||||
continue
|
||||
}
|
||||
// 普号(free)只能调度 free_allowed 的模型(香蕉2 仅 1K)
|
||||
if !freeAccountsAllowed(modelItem, resolution) && isFreeAccount(item.Meta) {
|
||||
continue
|
||||
}
|
||||
active = append(active, item)
|
||||
}
|
||||
active = pinTestAccount(items, active, in.AccountID)
|
||||
if len(active) == 0 {
|
||||
return nil, "", ErrNoProviderAccount
|
||||
}
|
||||
s.rotateRoundRobin("adobe", active)
|
||||
// 非 seedance 图片生成:普号 → 子号 → 母号
|
||||
active = prioritizeSubAccounts(active)
|
||||
|
||||
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
|
||||
if err != nil {
|
||||
@@ -1637,6 +1713,13 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
|
||||
for _, ref := range refs {
|
||||
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "image/png", "")
|
||||
if upErr != nil {
|
||||
if errors.Is(upErr, adobe.ErrRateLimited) {
|
||||
recoverAt := time.Now().Add(4 * time.Hour)
|
||||
s.tokens.Update(ctx, "adobe", token.ID, map[string]any{
|
||||
"status": "quota",
|
||||
"quota_recover_at": &recoverAt,
|
||||
})
|
||||
}
|
||||
return nil, upErr
|
||||
}
|
||||
blobIDs = append(blobIDs, id)
|
||||
@@ -1668,10 +1751,21 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
|
||||
}
|
||||
var active []model.TokenAccount
|
||||
for _, item := range items {
|
||||
if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
|
||||
if item.Status != "active" || item.Dead {
|
||||
continue
|
||||
}
|
||||
if isSeedanceModel(modelItem.ID) && isSubAccount(item.Meta) {
|
||||
// plan 未探测到的号既不算普号也不算会员号,置死号、不参与调度
|
||||
if planUnknown(item.Meta) {
|
||||
s.markPlanUnknownDead(ctx, "adobe", item.ID)
|
||||
continue
|
||||
}
|
||||
// Seedance 模型只允许 VIP 母号:必须正向识别(plan 非 free、非子号、
|
||||
// 积分 >4000),plan/额度未探测的账号一律不参与调度
|
||||
if isSeedanceModel(modelItem.ID) && !isVipMotherAccount(item.Meta) {
|
||||
continue
|
||||
}
|
||||
// 普号(free)只能调度 free_allowed 的模型
|
||||
if !freeAccountsAllowed(modelItem, resolution) && isFreeAccount(item.Meta) {
|
||||
continue
|
||||
}
|
||||
active = append(active, item)
|
||||
@@ -1681,6 +1775,11 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
|
||||
return nil, "", ErrNoProviderAccount
|
||||
}
|
||||
s.rotateRoundRobin("adobe", active)
|
||||
// 非 seedance 视频生成:普号 → 子号 → 母号
|
||||
// (seedance 已在上面过滤掉子号,此处无需额外处理)
|
||||
if !isSeedanceModel(modelItem.ID) {
|
||||
active = prioritizeSubAccounts(active)
|
||||
}
|
||||
|
||||
refLimit := modelItem.MaxReferenceImages
|
||||
if refLimit <= 0 {
|
||||
@@ -1690,9 +1789,24 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// Classify refs for seedance: images (usage:style), videos, audio (usage:source).
|
||||
var imgRefs, vidRefs, audRefs [][]byte
|
||||
for _, r := range refs {
|
||||
switch detectMediaType(r) {
|
||||
case "video":
|
||||
vidRefs = append(vidRefs, r)
|
||||
case "audio":
|
||||
audRefs = append(audRefs, r)
|
||||
default:
|
||||
imgRefs = append(imgRefs, r)
|
||||
}
|
||||
}
|
||||
|
||||
engine, upstreamModel := resolveAdobeVideoEngine(modelItem.ID)
|
||||
referenceMode := defaultString(strings.TrimSpace(modelItem.ReferenceMode), "frame")
|
||||
if rm := strings.TrimSpace(in.ReferenceMode); rm != "" {
|
||||
referenceMode = rm
|
||||
}
|
||||
|
||||
// Round-robin order; fail over to the next account on auth/quota; temporary
|
||||
// upstream errors fail over too without penalizing the account (tempFailover,
|
||||
@@ -1701,14 +1815,37 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
|
||||
var videoURL string
|
||||
data, err := s.runPoolWithFailover(ctx, eventID, "adobe", active, "video", func(token model.TokenAccount) ([]byte, error) {
|
||||
var blobIDs []string
|
||||
for _, ref := range refs {
|
||||
for _, ref := range imgRefs {
|
||||
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "image/png", engine)
|
||||
if upErr != nil {
|
||||
if errors.Is(upErr, adobe.ErrRateLimited) {
|
||||
recoverAt := time.Now().Add(4 * time.Hour)
|
||||
s.tokens.Update(ctx, "adobe", token.ID, map[string]any{
|
||||
"status": "quota",
|
||||
"quota_recover_at": &recoverAt,
|
||||
})
|
||||
}
|
||||
return nil, upErr
|
||||
}
|
||||
blobIDs = append(blobIDs, id)
|
||||
}
|
||||
bytes, meta, genErr := s.adobe.GenerateVideo(ctx, token.Value, engine, in.Prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs, downloadResult)
|
||||
var videoBlobIDs []string
|
||||
for _, ref := range vidRefs {
|
||||
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "video/mp4", engine)
|
||||
if upErr != nil {
|
||||
return nil, upErr
|
||||
}
|
||||
videoBlobIDs = append(videoBlobIDs, id)
|
||||
}
|
||||
var audioBlobIDs []string
|
||||
for _, ref := range audRefs {
|
||||
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "audio/mp3", engine)
|
||||
if upErr != nil {
|
||||
return nil, upErr
|
||||
}
|
||||
audioBlobIDs = append(audioBlobIDs, id)
|
||||
}
|
||||
bytes, meta, genErr := s.adobe.GenerateVideo(ctx, token.Value, engine, in.Prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs, videoBlobIDs, audioBlobIDs, downloadResult)
|
||||
if genErr == nil {
|
||||
videoURL = strings.TrimSpace(stringValue(meta["video_url"]))
|
||||
}
|
||||
@@ -1771,8 +1908,8 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod
|
||||
var videoURL string
|
||||
busy := 0
|
||||
for _, token := range active {
|
||||
// 1 concurrent job per account: skip any account already generating.
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
|
||||
// Per-account concurrency gate (defaults to 1 for built-in pools).
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
@@ -1869,9 +2006,21 @@ func (s *V1Service) customActive(ctx context.Context, modelID string) ([]model.T
|
||||
// accountConcurrency is the per-account simultaneous-job cap. Custom accounts use
|
||||
// their configured Concurrency (default 1); built-in pools use the system value.
|
||||
func accountConcurrency(item model.TokenAccount) int {
|
||||
if item.Pool == "adobe" {
|
||||
if isFreeAccount(item.Meta) {
|
||||
return 1 // FREE 普号 / 降级号限制为 1 并发
|
||||
}
|
||||
if item.Concurrency > 0 {
|
||||
return item.Concurrency
|
||||
}
|
||||
return 5 // VIP 会员号默认 5 并发
|
||||
}
|
||||
if item.Concurrency > 0 {
|
||||
return item.Concurrency
|
||||
}
|
||||
if item.Pool == "grok" {
|
||||
return grokConcurrencyPerAccount // 10
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -2159,9 +2308,8 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model
|
||||
var videoURL string
|
||||
busy := 0
|
||||
for _, token := range active {
|
||||
// grok allows 10 concurrent jobs per account (unlike the 1-per-account
|
||||
// default of the other pools).
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, grokConcurrencyPerAccount) {
|
||||
// Per-account concurrency gate (defaults to 1 for built-in pools).
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
@@ -2267,8 +2415,8 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
|
||||
var lastErr error
|
||||
busy := 0
|
||||
for _, token := range active {
|
||||
// 1 concurrent job per account: skip any account already generating.
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
|
||||
// Per-account concurrency gate (defaults to 1 for built-in pools).
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
@@ -2860,6 +3008,40 @@ func decodeReferenceImages(inputs []string, limit int) ([][]byte, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// detectMediaType inspects the first bytes of a decoded reference to classify it
|
||||
// as "video", "audio", or "image". Used to route refs to the correct upload MIME
|
||||
// and the correct referenceBlobs usage for seedance.
|
||||
func detectMediaType(data []byte) string {
|
||||
n := len(data)
|
||||
if n < 8 {
|
||||
return "image"
|
||||
}
|
||||
// MP4 / ISOBMFF
|
||||
if n >= 12 && string(data[4:8]) == "ftyp" {
|
||||
return "video"
|
||||
}
|
||||
// WebM
|
||||
if n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3 {
|
||||
return "video"
|
||||
}
|
||||
// MP3: ID3 header or sync word 0xFFFx
|
||||
if n >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
|
||||
return "audio"
|
||||
}
|
||||
if n >= 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0 {
|
||||
return "audio"
|
||||
}
|
||||
// WAV
|
||||
if n >= 4 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 {
|
||||
return "audio"
|
||||
}
|
||||
// OGG
|
||||
if n >= 4 && data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53 {
|
||||
return "audio"
|
||||
}
|
||||
return "image"
|
||||
}
|
||||
|
||||
func parseImageSize(size, aspectRatio, resolution string) (string, string) {
|
||||
ar := strings.TrimSpace(strings.ReplaceAll(aspectRatio, "x", ":"))
|
||||
rs := strings.TrimSpace(resolution)
|
||||
@@ -3162,8 +3344,8 @@ func resolveAdobeVideoEngine(modelID string) (string, string) {
|
||||
return "veo31-fast", ""
|
||||
case "gemini-veo3.1":
|
||||
return "veo31-standard", ""
|
||||
case "seedance-fast":
|
||||
return "seedance-fast", ""
|
||||
case "seedance-2.0-fast":
|
||||
return "seedance-2.0-fast", ""
|
||||
case "seedance-2.0":
|
||||
return "seedance-2.0", ""
|
||||
case "firefly-ray":
|
||||
@@ -3216,16 +3398,8 @@ func (s *V1Service) markTokenFailure(ctx context.Context, pool string, token mod
|
||||
}
|
||||
switch {
|
||||
case isQuota:
|
||||
// Adobe accounts are credit-based (积分号) — quota exhaustion is
|
||||
// non-locking: track the failure for rotation but don't limit or
|
||||
// sink the account. Other pools go straight to "quota" as before.
|
||||
if pool == "adobe" {
|
||||
// No-op: just track fails (already patched above), leave
|
||||
// image_limited/video_limited/status untouched.
|
||||
} else {
|
||||
patch["status"] = "quota"
|
||||
}
|
||||
if pool != "adobe" && strings.TrimSpace(token.CachedQuotaResetAfter) == "" {
|
||||
patch["status"] = "quota"
|
||||
if strings.TrimSpace(token.CachedQuotaResetAfter) == "" {
|
||||
recoverAt := time.Unix((time.Now().Unix()/86400+1)*86400, 0).UTC()
|
||||
patch["quota_recover_at"] = &recoverAt
|
||||
}
|
||||
@@ -3329,18 +3503,106 @@ func (s *V1Service) rotateRoundRobin(pool string, items []model.TokenAccount) {
|
||||
}
|
||||
}
|
||||
|
||||
// freeOnly1KModelID is the one free-allowed model 普号 may only serve at 1K
|
||||
// (香蕉2 的 2K/4K 需要会员号) — see freeAccountsAllowed.
|
||||
const freeOnly1KModelID = "nano-banana-2"
|
||||
|
||||
func isSeedanceModel(modelID string) bool {
|
||||
return modelID == "seedance-fast" || modelID == "seedance-2.0"
|
||||
return modelID == "seedance-2.0-fast" || modelID == "seedance-2.0"
|
||||
}
|
||||
|
||||
func isSubAccount(meta map[string]interface{}) bool {
|
||||
// freeAccountsAllowed reports whether 普号(free) may serve this request: the model
|
||||
// must be marked free_allowed. 香蕉2 另外只允许 1K 档,它的 2K/4K 只走会员号。
|
||||
func freeAccountsAllowed(modelItem *model.ModelConfig, resolution string) bool {
|
||||
if modelItem == nil || !modelItem.FreeAllowed {
|
||||
return false
|
||||
}
|
||||
if modelItem.ID == freeOnly1KModelID {
|
||||
return strings.EqualFold(strings.TrimSpace(resolution), "1K")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isFreeAccount(meta map[string]interface{}) bool {
|
||||
if meta == nil {
|
||||
return false
|
||||
}
|
||||
plan := strings.ToLower(strings.TrimSpace(stringValue(meta["plan"])))
|
||||
return plan == "free"
|
||||
}
|
||||
|
||||
// planUnknown 报告账号的会员身份还没探测出来(meta.plan 缺失或为空)。这类号
|
||||
// 既不能当普号也不能当会员号用:当会员号派出去会在需要会员的模型上撞 403
|
||||
// user_not_entitled。
|
||||
func planUnknown(meta map[string]interface{}) bool {
|
||||
if meta == nil {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(stringValue(meta["plan"])) == ""
|
||||
}
|
||||
|
||||
// markPlanUnknownDead 把选号时遇到的 plan 未探测账号置为死号,等重新探测到
|
||||
// plan 后再由额度刷新恢复。
|
||||
func (s *V1Service) markPlanUnknownDead(ctx context.Context, pool, id string) {
|
||||
s.tokens.Update(ctx, pool, id, map[string]any{"status": "disabled", "dead": true})
|
||||
}
|
||||
|
||||
// prioritizeSubAccounts 对非 Seedance 模型按 普号 → 子号 → 母号 的顺序排序:
|
||||
// 先消耗普号,普号不可用再用低积分子号,最后才动 vip 母号。
|
||||
func prioritizeSubAccounts(active []model.TokenAccount) []model.TokenAccount {
|
||||
var frees, subs, mothers []model.TokenAccount
|
||||
for _, a := range active {
|
||||
switch {
|
||||
case isFreeAccount(a.Meta):
|
||||
frees = append(frees, a)
|
||||
case isLowCredits(a.Meta):
|
||||
subs = append(subs, a)
|
||||
default:
|
||||
mothers = append(mothers, a)
|
||||
}
|
||||
}
|
||||
return append(append(frees, subs...), mothers...)
|
||||
}
|
||||
|
||||
// isVipMotherAccount 正向识别 VIP 母号:plan 已知且非 free,且 is_sub_account
|
||||
// 显式为 false。只看身份不看积分余额(低积分母号也可用);plan 未探测或
|
||||
// is_sub_account 缺失的账号返回 false,等刷新补齐后才可被 Seedance 调度。
|
||||
func isVipMotherAccount(meta map[string]interface{}) bool {
|
||||
if meta == nil {
|
||||
return false
|
||||
}
|
||||
plan := strings.ToLower(strings.TrimSpace(stringValue(meta["plan"])))
|
||||
if plan == "" || plan == "free" {
|
||||
return false
|
||||
}
|
||||
v, ok := meta["is_sub_account"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return !val
|
||||
case float64:
|
||||
return val == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isLowCredits(meta map[string]interface{}) bool {
|
||||
if meta == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := meta["is_sub_account"]; ok {
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
case float64:
|
||||
return val != 0
|
||||
}
|
||||
}
|
||||
// 兼容存量账号:is_sub_account 字段不存在时,用积分余额判断(>0 且 ≤4000 视为子号)
|
||||
if rem, ok := jsonMapInt(meta, "cached_quota_remaining"); ok {
|
||||
return rem > 0 && rem <= 4000
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user