feat: 二次元前端改版 + 后端账号plan探测/调度修复

This commit is contained in:
2026-08-06 10:11:05 +08:00
parent 7f4701d362
commit f76ecd5769
37 changed files with 2484 additions and 813 deletions
+295 -33
View File
@@ -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
}