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
+14 -16
View File
@@ -2,18 +2,18 @@ module backend
go 1.26.0
replace github.com/quic-go/qpack => github.com/quic-go/qpack v0.5.1
require (
github.com/bogdanfinn/fhttp v0.6.8
github.com/bogdanfinn/tls-client v1.11.2
github.com/bogdanfinn/tls-client v1.15.1
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
github.com/chromedp/chromedp v0.15.1
github.com/dop251/goja v0.0.0-20260701091749-b07b74453ea9
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/redis/go-redis/v9 v9.16.0
golang.org/x/crypto v0.51.0
golang.org/x/crypto v0.54.0
golang.org/x/image v0.43.0
gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0
@@ -23,13 +23,14 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bogdanfinn/quic-go-utls v1.0.4-utls // indirect
github.com/bdandy/go-errors v1.2.2 // indirect
github.com/bdandy/go-socks4 v1.2.3 // indirect
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect
github.com/chromedp/chromedp v0.15.1 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
@@ -62,19 +63,16 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.61.0 // indirect
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.2 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
)
+36 -22
View File
@@ -4,14 +4,20 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI=
github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4=
github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M=
github.com/bogdanfinn/quic-go-utls v1.0.4-utls h1:zPjusVVNeJFA2ORMAP0rjnrZrBkV4Dnia4e6ToOfUDA=
github.com/bogdanfinn/quic-go-utls v1.0.4-utls/go.mod h1:UONJOaHGWho08kZtkkgH7GjktEPjMemGxjTcNpVPZVA=
github.com/bogdanfinn/tls-client v1.11.2 h1:o6qX0L1cEi+4MaBqujxqOeK254VZM20t3QR+A34/V6I=
github.com/bogdanfinn/tls-client v1.11.2/go.mod h1:qQIsVGe35NdxYEozNh9JuDZ+aOaOEq2tKAsu2iYEGZg=
github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s=
github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg=
github.com/bogdanfinn/tls-client v1.15.1 h1:KiFAlED55DJ8Fcocn+/1nX6PrDFcttIHAf/GDkV6KN8=
github.com/bogdanfinn/tls-client v1.15.1/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM=
github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU=
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -101,6 +107,8 @@ github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uq
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
@@ -116,14 +124,18 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -148,23 +160,25 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -342,6 +342,7 @@ func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
// 「删除异常账号」to collect every dead id regardless of the current page.
typeFilter := strings.TrimSpace(c.Query("type"))
statusFilter := strings.TrimSpace(c.Query("status"))
planFilter := strings.TrimSpace(c.Query("plan"))
deadOnly := c.Query("dead") == "1"
q := strings.ToLower(strings.TrimSpace(c.Query("q")))
filtered := make([]map[string]any, 0, len(data))
@@ -355,6 +356,22 @@ func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
if statusFilter != "" && rowStr(row, "status") != statusFilter {
continue
}
// plan filter: vip=非free, free=free, sub=子号, master=vip且非子号
if planFilter != "" {
plan := strings.ToLower(rowStr(row, "plan"))
isSub := rowBool(row, "sub_account")
isFree := plan == "" || plan == "free"
switch planFilter {
case "vip":
if isFree { continue }
case "free":
if !isFree { continue }
case "sub":
if !isSub { continue }
case "master":
if isFree || isSub { continue }
}
}
if q != "" {
email := strings.ToLower(rowStr(row, "email"))
id := strings.ToLower(rowStr(row, "id"))
@@ -68,6 +68,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) {
Resolution string `json:"resolution"`
Duration string `json:"duration"`
ReferenceImages []string `json:"reference_images"`
ReferenceMode string `json:"reference_mode"`
DeAI bool `json:"deai"`
}
if err := c.ShouldBindJSON(&body); err != nil {
@@ -82,6 +83,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) {
Resolution: body.Resolution,
Duration: body.Duration,
ReferenceImages: body.ReferenceImages,
ReferenceMode: body.ReferenceMode,
DeAI: body.DeAI,
})
if err != nil {
@@ -345,7 +347,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"durations": []string{"4s", "6s", "8s"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"max_reference_images": 2,
"max_reference_images": 9,
"reference_mode": "frame",
},
{
@@ -356,7 +358,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"durations": []string{"4s", "6s", "8s"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"max_reference_images": 2,
"max_reference_images": 9,
"reference_mode": "frame",
},
{
@@ -369,7 +371,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"resolutions": []string{"720p"},
},
{
"key": "seedance-fast",
"key": "seedance-2.0-fast",
"label": "Seedance 2.0 Fast",
"type": "video",
"provider": "adobe",
@@ -377,7 +379,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"max_reference_images": 9,
"reference_mode": "frame",
"reference_mode": "style",
},
{
"key": "seedance-2.0",
@@ -388,7 +390,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"max_reference_images": 9,
"reference_mode": "frame",
"reference_mode": "style",
},
{
"key": "firefly-video",
@@ -398,7 +400,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
"durations": []string{"5s"},
"ratios": []string{"16:9", "1:1", "9:16"},
"resolutions": []string{"540p", "720p", "1080p"},
"max_reference_images": 2,
"max_reference_images": 9,
"reference_mode": "frame",
},
{
@@ -524,7 +526,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"durations": []string{"4s", "6s", "8s"},
"max_reference_images": 2,
"max_reference_images": 3,
"reference_mode": "frame",
"description": "Veo 3.1 Fast",
},
@@ -533,17 +535,18 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"resolutions": []string{"720p", "1080p"},
"durations": []string{"4s", "6s", "8s"},
"max_reference_images": 0,
"max_reference_images": 3,
"reference_mode": "style",
"description": "Veo 3.1",
},
{
"id": "firefly-ray",
"id": "gemini-veo3.1",
"provider": "adobe",
"type": "video",
"ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"},
"resolutions": []string{"720p"},
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p", "1080p"},
"durations": []string{"5s", "10s"},
"max_reference_images": 2,
"reference_mode": "frame",
@@ -561,14 +564,14 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"description": "Adobe Firefly Video",
},
{
"id": "seedance-fast",
"id": "seedance-2.0-fast",
"provider": "adobe",
"type": "video",
"ratios": []string{"16:9", "9:16"},
"resolutions": []string{"720p"},
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
"max_reference_images": 9,
"reference_mode": "frame",
"reference_mode": "style",
"description": "Seedance 2.0 Fast",
},
{
@@ -579,7 +582,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"resolutions": []string{"720p", "1080p"},
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
"max_reference_images": 9,
"reference_mode": "frame",
"reference_mode": "style",
"description": "Seedance 2.0",
},
{
@@ -763,7 +766,7 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"stub": false,
},
{
"id": "seedance-fast",
"id": "seedance-2.0-fast",
"provider": "adobe",
"kind": "video",
"ratios": []string{"16:9", "9:16"},
+12 -10
View File
@@ -129,10 +129,10 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
h.writeAuthError(c, err)
return
}
var modelID, prompt, seconds, size string
var modelID, prompt, seconds, size, referenceMode string
var refs []string
if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
if err := c.Request.ParseMultipartForm(64 << 20); err != nil {
if err := c.Request.ParseMultipartForm(256 << 20); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
return
}
@@ -140,17 +140,17 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
prompt = c.PostForm("prompt")
seconds = c.PostForm("seconds")
size = c.PostForm("size")
referenceMode = c.PostForm("reference_mode")
refs = readMultipartImages(c, "input_reference", "input_reference[]")
} else {
var body struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Seconds json.RawMessage `json:"seconds"`
Size string `json:"size"`
// Reference frames (image-to-video / first-last frames) as base64 or
// data-URI strings — the JSON equivalent of multipart input_reference.
InputReference []string `json:"input_reference"`
ReferenceImages []string `json:"reference_images"`
Model string `json:"model"`
Prompt string `json:"prompt"`
Seconds json.RawMessage `json:"seconds"`
Size string `json:"size"`
ReferenceMode string `json:"reference_mode"`
InputReference []string `json:"input_reference"`
ReferenceImages []string `json:"reference_images"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
@@ -158,6 +158,7 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
}
modelID, prompt, size = body.Model, body.Prompt, body.Size
seconds = rawToString(body.Seconds)
referenceMode = strings.TrimSpace(body.ReferenceMode)
refs = append(body.InputReference, body.ReferenceImages...)
}
duration := strings.TrimSpace(seconds)
@@ -172,6 +173,7 @@ func (h *V1Handler) CreateVideo(c *gin.Context) {
AspectRatio: aspect,
Resolution: resolution,
ReferenceImages: refs,
ReferenceMode: referenceMode,
BaseURL: requestBaseURL(c),
})
if err != nil {
+1
View File
@@ -125,6 +125,7 @@ type ModelConfig struct {
Alias string `gorm:"column:alias;size:255;not null;default:''"`
Provider string `gorm:"size:100;index;not null"`
Enabled bool `gorm:"not null;default:true"`
FreeAllowed bool `gorm:"not null;default:false"` // 普号(free)能否调度此模型,默认不可用
Ratios datatypes.JSON `gorm:"type:jsonb"`
Prices datatypes.JSONMap `gorm:"type:jsonb"`
Resolutions datatypes.JSON `gorm:"type:jsonb"`
+5
View File
@@ -11,6 +11,11 @@ const (
refreshURL = "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"
clientID = "projectx_webapp"
scopeValue = "AdobeID,firefly_api,openid"
// clioClientID / clioScopeValue mint the firefly.adobe.com token that is
// entitled to the Firefly-native seedance models (creative_production scope).
// Unlike the Express token this exchange requires user_id and no guest_allowed.
clioClientID = "clio-playground-web"
clioScopeValue = "AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext,additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations,additional_info.roles,account_cluster.read,creative_production,tk_platform,tk_platform_sync,profile"
)
var ErrAdobeCookieEmpty = errors.New("cookie is empty")
+179 -81
View File
@@ -28,8 +28,12 @@ const (
fireflyVideoSubmitURL = "https://video-v1.ff.adobe.io/v2/videos/generate"
fireflyVideoUploadURL = "https://video-v1.ff.adobe.io/v2/storage/image"
uploadURL = "https://firefly-3p.ff.adobe.io/v2/storage/image"
creditsURL = "https://firefly.adobe.io/v1/credits/balance"
creditsAPIKey = "SunbreakWebUI1"
// Seedance reference video/audio go to their own storage endpoints; posting
// them to /v2/storage/image is rejected.
uploadVideoURL = "https://firefly-3p.ff.adobe.io/v2/storage/video"
uploadAudioURL = "https://firefly-3p.ff.adobe.io/v2/storage/audio"
creditsURL = "https://firefly.adobe.io/v1/credits/balance"
creditsAPIKey = "SunbreakWebUI1"
)
var (
@@ -43,14 +47,24 @@ var (
// the account's — every account rejects the same content — so the caller must
// surface it as-is without penalizing/killing the account or failing over.
ErrContentRejected = errors.New("adobe content rejected")
// ErrNotEntitled is a 403 user_not_entitled on submit: the account has no
// Firefly entitlement at all. It wraps ErrAuth so existing auth handling still
// applies, but callers can single it out — unlike an expired access token this
// cannot be fixed by refreshing from the cookie, so the account is done.
ErrNotEntitled = fmt.Errorf("%w: user not entitled", ErrAuth)
)
// isContentRejection reports whether an Adobe response (status + body) is a
// content-safety refusal rather than a genuine upstream/account failure. Adobe
// returns HTTP 451 with an "*_unsafe" error_code when moderation blocks the
// prompt or the produced image.
// prompt or the produced image, and "reference_image_privacy_error" when a
// reference image contains a real person's face. Both are the request's fault —
// every account rejects them identically — so neither may penalize the account.
func isContentRejection(status int, body string) bool {
return status == 451 && strings.Contains(body, "unsafe")
if status != 451 {
return false
}
return strings.Contains(body, "unsafe") || strings.Contains(body, "privacy_error")
}
var profileURLs = []string{
@@ -59,9 +73,8 @@ var profileURLs = []string{
}
type Client struct {
apiKey string
proxy string
arpSessionID string // cached per-client, reused across requests (matches adobe2api)
apiKey string
proxy string
}
func NewClient(apiKey, proxy string) *Client {
@@ -71,16 +84,11 @@ func NewClient(apiKey, proxy string) *Client {
}
}
// getARPSessionID returns a cached ARP session id matching adobe2api's format:
// base64({"sid":"<uuid>","ftr":"<hex16>_<ts_ms>_<pid>_dUAL43-mnts-ants-d4_31ck__tt"})
// Generated once per client and reused — adobe2api reuses the same session id per
// token/profile instead of rotating every request.
func (c *Client) getARPSessionID() string {
if c.arpSessionID != "" {
return c.arpSessionID
}
c.arpSessionID = buildARPSessionID()
return c.arpSessionID
// getARPSessionID returns a fresh ARP session id for this token. The value is
// regenerated per call, but the embedded pid stays stable per token; see
// buildARPSessionID for the wire format.
func (c *Client) getARPSessionID(token string) string {
return buildARPSessionID(token)
}
func (c *Client) SetProxy(proxy string) {
@@ -116,19 +124,36 @@ func (c *Client) UploadImage(ctx context.Context, token string, content []byte,
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return "", err
return "", fmt.Errorf("adobe upload bad response: %w (%s)", err, clip(body, 300))
}
if images, ok := payload["images"].([]any); ok && len(images) > 0 {
if first, ok := images[0].(map[string]any); ok {
if id := strings.TrimSpace(stringValue(first["id"])); id != "" {
return id, nil
}
}
}
if id := strings.TrimSpace(stringValue(payload["id"])); id != "" {
if id := blobIDFromUploadPayload(payload); id != "" {
return id, nil
}
return "", errors.New("adobe upload missing blob id")
return "", fmt.Errorf("adobe upload missing blob id: %s", clip(body, 300))
}
// blobIDFromUploadPayload digs the blob id out of a storage response. The image
// endpoint answers {"images":[{"id":...}]}, video/audio use their own key
// ("videos"/"audios"/"assets"/"files"), and some responses put the id at the top
// level — accept any of them instead of only "images".
func blobIDFromUploadPayload(payload map[string]any) string {
if id := strings.TrimSpace(stringValue(payload["id"])); id != "" {
return id
}
for _, key := range []string{"images", "videos", "audios", "audio", "assets", "files"} {
items, ok := payload[key].([]any)
if !ok || len(items) == 0 {
continue
}
first, ok := items[0].(map[string]any)
if !ok {
continue
}
if id := strings.TrimSpace(stringValue(first["id"])); id != "" {
return id
}
}
return ""
}
// uploadImageOnce performs a single upload attempt and returns the raw response
@@ -141,8 +166,19 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by
}
endpoint := uploadURL
if engine == "firefly-video" {
switch {
case engine == "firefly-video":
endpoint = fireflyVideoUploadURL
case strings.HasPrefix(contentType, "video/"):
endpoint = uploadVideoURL
case strings.HasPrefix(contentType, "audio/"):
endpoint = uploadAudioURL
}
// Seedance is a Firefly-native model: its uploads need the clio-playground-web
// key, the express key is rejected with 403 access_error.
apiKey := c.apiKey
if strings.HasPrefix(engine, "seedance-") {
apiKey = clioClientID
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(content))
if err != nil {
@@ -151,7 +187,7 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by
req = req.WithContext(ctx)
req.Header = http.Header{
"authorization": {"Bearer " + strings.TrimSpace(token)},
"x-api-key": {c.apiKey},
"x-api-key": {apiKey},
"content-type": {defaultString(contentType, "image/png")},
"accept": {"*/*"},
"user-agent": {sess.fp.userAgent},
@@ -233,24 +269,36 @@ func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspe
// Content-safety refusal: the prompt/image is blocked, retrying other payloads
// or accounts is pointless — surface it as-is so the pool doesn't fail over.
if errors.Is(lastErr, ErrContentRejected) {
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrContentRejected, clip(lastBody, 300))
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrContentRejected, submitDetail(lastErr, lastBody))
}
// Preserve the temporary classification so the pool retries (overload / 5xx /
// rate-limit) instead of failing the request outright.
if errors.Is(lastErr, ErrDeadUpstream) {
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrDeadUpstream, clip(lastBody, 300))
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrDeadUpstream, submitDetail(lastErr, lastBody))
}
if errors.Is(lastErr, ErrTemporaryUpstream) {
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrTemporaryUpstream, clip(lastBody, 300))
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrTemporaryUpstream, submitDetail(lastErr, lastBody))
}
return nil, nil, fmt.Errorf("adobe submit failed: %s", clip(lastBody, 300))
return nil, nil, fmt.Errorf("adobe submit failed: %s", submitDetail(lastErr, lastBody))
}
// submitDetail 拼接上游细节:优先用响应体,响应体为空时(传输层报错,例如超时、
// 连接被切)退回内层错误文本,避免错误只剩一句 "adobe submit:" 无从排查。
func submitDetail(err error, body []byte) string {
if s := clip(body, 300); strings.TrimSpace(s) != "" {
return s
}
if err != nil {
return err.Error()
}
return "(empty response)"
}
// GenerateVideo renders the clip and (when downloadResult) downloads the MP4.
// With downloadResult=false it returns nil bytes and the upstream presigned URL
// in meta["video_url"] — used by the async /v1/videos job, which proxies that URL
// on /content instead of persisting the file.
func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string, downloadResult bool) ([]byte, map[string]any, error) {
func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs, videoBlobIDs, audioBlobIDs []string, downloadResult bool) ([]byte, map[string]any, error) {
// Only the submit goes through the proxy; polling + download run on the local IP.
submitSess, err := c.newTLSClient()
if err != nil {
@@ -261,7 +309,7 @@ func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspec
return nil, nil, err
}
payload := BuildVideoPayload(engine, prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs)
payload := BuildVideoPayload(engine, prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs, videoBlobIDs, audioBlobIDs)
endpoint := videoSubmitURL
if engine == "firefly-video" {
endpoint = fireflyVideoSubmitURL
@@ -368,7 +416,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
}, nil
}
sess, err := c.newDirectTLSClient()
sess, err := c.newTLSClient()
if err != nil {
return nil, err
}
@@ -444,6 +492,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
"used": intOrNil(quota["used"]),
"total": intOrNil(quota["total"]),
"available_until": emptyStringNil(strings.TrimSpace(stringValue(totalInfo["availableUntil"]))),
"plan": strings.TrimSpace(stringValue(totalInfo["planCap"])),
"unknown": false,
"error": nil,
}, nil
@@ -462,6 +511,7 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
req.Header = http.Header{
"authorization": {"Bearer " + strings.TrimSpace(token)},
"x-api-key": {c.apiKey},
"x-arp-session-id": {c.getARPSessionID(token)},
"content-type": {"application/json"},
"accept": {"*/*"},
"origin": {"https://new.express.adobe.com"},
@@ -477,6 +527,7 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
http.HeaderOrderKey: {
"authorization",
"x-api-key",
"x-arp-session-id",
"content-type",
"accept",
"origin",
@@ -506,10 +557,14 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
return nil, "", err
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") {
accessErr := resp.Header.Get("x-access-error")
if strings.EqualFold(accessErr, "taste_exhausted") {
return respBody, "", ErrQuotaExhausted
}
return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300))
if strings.EqualFold(accessErr, "user_not_entitled") {
return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrNotEntitled, resp.StatusCode, accessErr, clip(respBody, 300))
}
return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrAuth, resp.StatusCode, accessErr, clip(respBody, 300))
}
// "system under load" / timeout_error = adobe rate-limit/overload (can come on a
// non-5xx) — treat as temporary so the pool retries instead of failing.
@@ -520,10 +575,10 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
return respBody, "", ErrContentRejected
}
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
return respBody, "", ErrDeadUpstream
return respBody, "", fmt.Errorf("%w (submit %d: %s)", ErrDeadUpstream, resp.StatusCode, clip(respBody, 300))
}
if resp.StatusCode != 200 {
return respBody, "", errors.New("submit rejected")
return respBody, "", fmt.Errorf("submit rejected: %d %s", resp.StatusCode, clip(respBody, 300))
}
var payloadResp map[string]any
@@ -588,7 +643,7 @@ func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL
return nil, nil, fmt.Errorf("%w: %s", ErrContentRejected, clip(body, 300))
}
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
return nil, nil, ErrDeadUpstream
return nil, nil, fmt.Errorf("%w (poll %d: %s)", ErrDeadUpstream, resp.StatusCode, clip(body, 300))
}
if resp.StatusCode != 200 {
return nil, nil, fmt.Errorf("adobe poll failed: %d %s", resp.StatusCode, clip(body, 300))
@@ -635,13 +690,23 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo
return nil, "", err
}
req = req.WithContext(ctx)
// Seedance is a Firefly-native model and needs the clio-playground-web key;
// the express key is rejected with 403 access_error. The origin stays on the
// Express surface for every engine — the upstream only gates on the key and
// x-arp-session-id, not on origin.
apiKey := c.apiKey
origin := "https://new.express.adobe.com"
if strings.EqualFold(stringValue(payload["modelId"]), "seedance") {
apiKey = clioClientID
}
req.Header = http.Header{
"authorization": {"Bearer " + strings.TrimSpace(token)},
"x-api-key": {c.apiKey},
"x-api-key": {apiKey},
"x-arp-session-id": {c.getARPSessionID(token)},
"content-type": {"application/json"},
"accept": {"*/*"},
"origin": {"https://new.express.adobe.com"},
"referer": {"https://new.express.adobe.com/"},
"origin": {origin},
"referer": {origin + "/"},
"accept-language": {"en-US,en;q=0.9"},
"sec-ch-ua": {sess.fp.secCHUA},
"sec-ch-ua-mobile": {"?0"},
@@ -653,6 +718,7 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo
http.HeaderOrderKey: {
"authorization",
"x-api-key",
"x-arp-session-id",
"content-type",
"accept",
"origin",
@@ -686,18 +752,22 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo
return nil, "", err
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") {
accessErr := resp.Header.Get("x-access-error")
if strings.EqualFold(accessErr, "taste_exhausted") {
return respBody, "", ErrQuotaExhausted
}
if strings.EqualFold(accessErr, "user_not_entitled") {
return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrNotEntitled, resp.StatusCode, accessErr, clip(respBody, 300))
}
// Surface Adobe's response body — "adobe auth failed" alone hides whether
// it's a bad token, a missing scope, or a WAF/fingerprint block.
return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300))
return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, accessErr, clip(respBody, 300))
}
if isContentRejection(resp.StatusCode, string(respBody)) {
return respBody, "", ErrContentRejected
}
if resp.StatusCode == 408 || resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
return respBody, "", ErrDeadUpstream
return respBody, "", fmt.Errorf("%w (video submit %d: %s)", ErrDeadUpstream, resp.StatusCode, clip(respBody, 300))
}
// "system under load" / timeout_error = adobe overload — treat as a temporary
// error so the tempFailover policy moves to the next account (same as the image path).
@@ -773,7 +843,7 @@ func (c *Client) pollVideo(ctx context.Context, sess *tlsSession, token, pollURL
return nil, nil, fmt.Errorf("%w: %s", ErrContentRejected, clip(body, 300))
}
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
return nil, nil, ErrDeadUpstream
return nil, nil, fmt.Errorf("%w (video poll %d: %s)", ErrDeadUpstream, resp.StatusCode, clip(body, 300))
}
if resp.StatusCode != 200 {
return nil, nil, fmt.Errorf("adobe video poll failed: %d %s", resp.StatusCode, clip(body, 300))
@@ -889,7 +959,12 @@ const (
osMac
)
func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int) fingerprint {
// newChromeFingerprint pairs a TLS ClientProfile with headers for the same
// Chrome major. secCHUA is passed in verbatim rather than templated: the GREASE
// brand entry ("Not(A:Brand", "Not_A Brand", …), its version, and its position
// in the list all rotate per Chrome release, so there is no formula to derive
// it from the major.
func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int, secCHUA string) fingerprint {
ua := fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.0.0 Safari/537.36", major)
platform := `"Windows"`
if osKind == osMac {
@@ -899,7 +974,7 @@ func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int) fin
return fingerprint{
profile: profile,
userAgent: ua,
secCHUA: fmt.Sprintf(`"Not?A_Brand";v="99", "Google Chrome";v="%d", "Chromium";v="%d"`, major, major),
secCHUA: secCHUA,
platform: platform,
}
}
@@ -908,12 +983,21 @@ func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int) fin
// Windows/macOS. Each request draws one at random so the outbound TLS
// fingerprint and headers vary from call to call instead of presenting a single
// static signature to Adobe's anti-bot layer.
//
// The majors here are capped by what tls-client ships a TLS profile for
// (Chrome_146 is the newest in v1.15.1). Live Adobe traffic is on Chromium
// 150+, so the UA is deliberately NOT set higher than the pool: advertising a
// newer UA than the JA3/JA4 handshake backs up is a sharper signal than being a
// few releases behind. tls-client has no Edge profile at any version, so the
// pool stays pure Chrome rather than pairing an Edge UA with a Chrome
// handshake.
var adobeFingerprints = []fingerprint{
newChromeFingerprint(profiles.Chrome_133, 133, osWindows),
newChromeFingerprint(profiles.Chrome_131, 131, osWindows),
newChromeFingerprint(profiles.Chrome_124, 124, osWindows),
newChromeFingerprint(profiles.Chrome_133, 133, osMac),
newChromeFingerprint(profiles.Chrome_131, 131, osMac),
newChromeFingerprint(profiles.Chrome_146, 146, osWindows, `"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"`),
newChromeFingerprint(profiles.Chrome_146, 146, osMac, `"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"`),
newChromeFingerprint(profiles.Chrome_144, 144, osWindows, `"Chromium";v="144", "Google Chrome";v="144", "Not?A_Brand";v="24"`),
newChromeFingerprint(profiles.Chrome_144, 144, osMac, `"Chromium";v="144", "Google Chrome";v="144", "Not?A_Brand";v="24"`),
newChromeFingerprint(profiles.Chrome_133, 133, osWindows, `"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"`),
newChromeFingerprint(profiles.Chrome_131, 131, osMac, `"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"`),
}
func randomFingerprint() fingerprint {
@@ -962,14 +1046,41 @@ func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie s
return nil, ErrAdobeCookieEmpty
}
// Prefer user_id (HAR behavior); fall back to guest_allowed for first-time login.
userID := extractUserIDFromCookie(cookie)
var body string
if userID != "" {
body = "client_id=" + clientID + "&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C") + "&user_id=" + url.QueryEscape(userID)
} else {
body = "client_id=" + clientID + "&guest_allowed=true&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C")
body := "client_id=" + clientID + "&guest_allowed=true&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C")
payload, err := doCookieExchange(ctx, sess, cookie, body, "https://new.express.adobe.com")
if err != nil {
return nil, err
}
token := strings.TrimSpace(stringValue(payload["access_token"]))
if token == "" {
return nil, errors.New("adobe cookie exchange missing access_token")
}
// The Express token (projectx_webapp) covers image/veo/luma/sora but is not
// entitled to the Firefly-native seedance models — those return 403
// model_not_authorized. firefly.adobe.com mints a second token for
// client_id=clio-playground-web with the creative_production scope, keyed by
// user_id, which IS entitled. Mint that here too (best-effort: if it fails we
// keep the Express token so the other models still work).
if uid := ExtractAccountID(token); uid != "" {
clioBody := "client_id=" + clioClientID + "&scope=" + strings.ReplaceAll(clioScopeValue, ",", "%2C") + "&user_id=" + url.QueryEscape(uid)
if clioPayload, err := doCookieExchange(ctx, sess, cookie, clioBody, "https://firefly.adobe.com"); err == nil {
if clioToken := strings.TrimSpace(stringValue(clioPayload["access_token"])); clioToken != "" {
return &CookieExchangeResult{
AccessToken: clioToken,
ExpiresIn: intValue(clioPayload["expires_in"]),
Raw: clioPayload,
}, nil
}
}
}
return &CookieExchangeResult{
AccessToken: token,
ExpiresIn: intValue(payload["expires_in"]),
Raw: payload,
}, nil
}
func doCookieExchange(ctx context.Context, sess *tlsSession, cookie, body, origin string) (map[string]any, error) {
req, err := http.NewRequest(http.MethodPost, refreshURL, strings.NewReader(body))
if err != nil {
return nil, err
@@ -977,20 +1088,15 @@ func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie s
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"*/*"},
"accept-language": {"zh-CN,zh;q=0.9"},
"accept-language": {"en-US,en;q=0.9"},
"content-type": {"application/x-www-form-urlencoded;charset=UTF-8"},
"cookie": {cookie},
"origin": {"https://new.express.adobe.com"},
"referer": {"https://new.express.adobe.com/"},
"origin": {origin},
"referer": {origin + "/"},
"user-agent": {sess.fp.userAgent},
http.HeaderOrderKey: {
"accept",
"accept-language",
"content-type",
"cookie",
"origin",
"referer",
"user-agent",
"accept", "accept-language", "content-type", "cookie",
"origin", "referer", "user-agent",
},
}
resp, err := sess.client.Do(req)
@@ -1010,15 +1116,7 @@ func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie s
if err := json.Unmarshal(respBody, &payload); err != nil {
return nil, fmt.Errorf("adobe cookie exchange invalid json: %w", err)
}
token := strings.TrimSpace(stringValue(payload["access_token"]))
if token == "" {
return nil, errors.New("adobe cookie exchange missing access_token")
}
return &CookieExchangeResult{
AccessToken: token,
ExpiresIn: intValue(payload["expires_in"]),
Raw: payload,
}, nil
return payload, nil
}
func buildSubmitNonce(token, prompt string) string {
+35 -25
View File
@@ -251,7 +251,7 @@ func cloneMap(in map[string]any) map[string]any {
return out
}
func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string) map[string]any {
func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs, videoBlobIDs, audioBlobIDs []string) map[string]any {
seedVal := rand.Intn(999999)
engine = defaultString(engine, "sora2")
resolution = defaultString(resolution, "720p")
@@ -274,10 +274,10 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
"prompt": prompt,
"seeds": []int{seedVal},
"sizes": []any{map[string]any{"width": w, "height": h, "numFrames": frames}},
"videoSettings": map[string]any{},
"locale": "en-US",
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
"output": map[string]any{"storeInputs": true},
"videoSettings": map[string]any{},
"locale": "en-US",
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
"output": map[string]any{"storeInputs": true},
}
if len(blobIDs) > 0 {
conds := make([]any, 0, 2)
@@ -307,7 +307,7 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
"prompt": prompt,
"negativePrompt": "",
"duration": durationSeconds,
"generateAudio": true,
"generateAudio": false,
"generationMetadata": map[string]any{
"module": "text2video",
"submodule": "ff-video-generate",
@@ -330,38 +330,48 @@ func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int,
payload["referenceBlobs"] = refs
}
return payload
case "seedance-fast", "seedance-2.0":
case "seedance-2.0-fast", "seedance-2.0":
modelVersion := "seedance_2.0"
if engine == "seedance-fast" {
if engine == "seedance-2.0-fast" {
modelVersion = "seedance_2.0_fast"
}
payload := map[string]any{
"modelId": "seedance",
"modelVersion": modelVersion,
"size": videoSize(aspectRatio, resolution),
"seeds": []int{seedVal},
"prompt": prompt,
"negativePrompt": "",
"duration": durationSeconds,
"generateAudio": true,
"generationSettings": map[string]any{"aspectRatio": aspectRatio},
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
"output": map[string]any{"storeInputs": true},
"referenceBlobs": []any{},
"modelId": "seedance",
"modelVersion": modelVersion,
"size": videoSize(aspectRatio, resolution),
"seeds": []int{seedVal},
"prompt": prompt,
"negativePrompt": "",
"duration": durationSeconds,
"generateAudio": true,
"generationSettings": map[string]any{"aspectRatio": aspectRatio},
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
"output": map[string]any{"storeInputs": true},
"referenceBlobs": []any{},
}
if len(blobIDs) > 0 {
payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"}
refs := make([]any, 0, min(len(blobIDs), 9))
// Seedance keeps module "text2video" even with reference blobs
// (HAR-confirmed) — unlike veo/luma it does not switch to image2video.
if referenceMode == "frame" {
for idx, id := range blobIDs[:min(len(blobIDs), 9)] {
refs := make([]any, 0, min(len(blobIDs), 2))
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
}
payload["referenceBlobs"] = refs
} else {
refs := make([]any, 0, min(len(blobIDs), 9))
for _, id := range blobIDs[:min(len(blobIDs), 9)] {
refs = append(refs, map[string]any{"id": id, "usage": "asset"})
refs = append(refs, map[string]any{"id": id, "usage": "style"})
}
payload["referenceBlobs"] = refs
}
payload["referenceBlobs"] = refs
}
// Video/audio source refs (seedance)
for _, id := range videoBlobIDs[:min(len(videoBlobIDs), 3)] {
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
}
for _, id := range audioBlobIDs[:min(len(audioBlobIDs), 3)] {
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
}
return payload
case "luma":
+99 -8
View File
@@ -10,6 +10,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
@@ -19,6 +20,13 @@ import (
// "4BDA81F069FC6DA40A495FAB@AdobeID").
var adobeUserIDPat = regexp.MustCompile(`[A-Fa-f0-9]{20,}@AdobeID`)
// PID pool: one PID per token, reused across requests for the same account.
var (
arpPIDMu sync.Mutex
arpTokenPID = map[string]int{} // token → pid
arpPIDToken = map[int]string{} // pid → token
)
func extractUserIDFromCookie(cookie string) string {
decoded, err := url.QueryUnescape(cookie)
if err != nil {
@@ -95,19 +103,102 @@ func decodeJWTPayload(token string) map[string]any {
return out
}
func buildARPSessionID() string {
// Matches adobe2api's format exactly:
// base64({"sid":"<uuid>","ftr":"<hex16>_<ts_ms>_<pid>_dUAL43-mnts-ants-d4_31ck__tt"})
// Two fields only (no "ark") — mirrors what a real browser session sends.
ftr := randomHex(16) + "_" + strconv.FormatInt(time.Now().UnixMilli(), 10) + "_" + strconv.Itoa(randomInt(1000, 99999)) + "_dUAL43-mnts-ants-d4_31ck__tt"
raw := map[string]any{
// arkHead and arkTail are the site-constant halves of the ark blob, as captured
// from firefly.adobe.com. The leading "<17hex>.<10digits>" pair and the rid
// field sit between them and vary per session, so buildARKBlob randomizes those
// instead of shipping one frozen blob for every account.
const (
arkHead = "|r=ap-southeast-1|meta=3|metabgclr=transparent|metaiconclr=%23757575|guitextcolor=%23000000|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40|sup=1"
arkTail = "|ag=101|cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|surl=https%3A%2F%2Farks-client.adobe.com|smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager"
)
func buildARKBlob() string {
return randomHex(9)[:17] + "." + randomDigits(10) +
arkHead + "|rid=" + strconv.Itoa(randomInt(1, 99)) + arkTail
}
// buildARPSessionID returns the x-arp-session-id value: base64 of
// {"sid","ark","ftr"}, shaped to match live firefly.adobe.com traffic:
//
// ark: <17hex>.<10digits>|…|rid=<n>|…
// ftr: <32hex>_<unix_ms>_<pid>_UDF43-m4_31ck__tt
//
// The pid is allocated per token (allocPID) so it stays stable across an
// account's requests, the way a real browser session would.
//
// The Arkose segment is left empty on purpose. In a real browser that slot
// carries a "<b64>=-<4digits>-v2" payload emitted by Adobe's Arkose bot-manager
// script; a synthesized one has the right shape but fails Adobe's server-side
// validation, and 3p-images/generate-async rejects it with a misleading
// {"error_code":"timeout_error","message":"system under load"}. Sending the
// slot empty reads as "no Arkose payload yet" and is accepted.
func buildARPSessionID(token string) string {
ftr := strings.Join([]string{
randomHex(16), // 32 hex chars, matching the capture
strconv.FormatInt(time.Now().UnixMilli(), 10),
strconv.Itoa(allocPID(token)),
"UDF43-m4",
"31ck",
"",
"tt",
}, "_")
b, _ := json.Marshal(map[string]any{
"sid": uuid.NewString(),
"ark": buildARKBlob(),
"ftr": ftr,
}
b, _ := json.Marshal(raw)
})
return base64.StdEncoding.EncodeToString(b)
}
// randomDigits returns n decimal digits with a non-zero leading digit, so the
// rendered value keeps a constant width.
func randomDigits(n int) string {
if n <= 0 {
return ""
}
var b strings.Builder
b.Grow(n)
b.WriteByte(byte('1' + randomInt(0, 8)))
for i := 1; i < n; i++ {
b.WriteByte(byte('0' + randomInt(0, 9)))
}
return b.String()
}
func randomBase64(n int) string {
buf := make([]byte, n)
rand.Read(buf)
return base64.RawURLEncoding.EncodeToString(buf)
}
func allocPID(token string) int {
arpPIDMu.Lock()
defer arpPIDMu.Unlock()
if pid, ok := arpTokenPID[token]; ok {
return pid
}
for {
pid := randomInt(1000, 99999)
if _, used := arpPIDToken[pid]; !used {
arpPIDToken[pid] = token
arpTokenPID[token] = pid
return pid
}
}
}
// ReleasePID releases the PID bound to token so it can be reused by another
// account.
func ReleasePID(token string) {
arpPIDMu.Lock()
defer arpPIDMu.Unlock()
if pid, ok := arpTokenPID[token]; ok {
delete(arpPIDToken, pid)
delete(arpTokenPID, token)
}
}
func randomHex(n int) string {
if n <= 0 {
return ""
+5
View File
@@ -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 {
+42 -11
View File
@@ -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
}
+63 -5
View File
@@ -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
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
}