feat: 二次元前端改版 + 后端账号plan探测/调度修复
This commit is contained in:
+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