From f76ecd576980ff2605a8889be29ffde6efa87659 Mon Sep 17 00:00:00 2001 From: chiyi Date: Thu, 6 Aug 2026 10:11:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=8C=E6=AC=A1=E5=85=83=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=94=B9=E7=89=88=20+=20=E5=90=8E=E7=AB=AF=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7plan=E6=8E=A2=E6=B5=8B/=E8=B0=83=E5=BA=A6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/go.mod | 30 +- backend/go.sum | 58 +- .../internal/http/handler/provider_admin.go | 17 + .../internal/http/handler/user_generation.go | 35 +- backend/internal/http/handler/v1.go | 22 +- backend/internal/model/models.go | 1 + backend/internal/provider/adobe/auth.go | 5 + backend/internal/provider/adobe/client.go | 260 ++++-- backend/internal/provider/adobe/payloads.go | 60 +- backend/internal/provider/adobe/util.go | 107 ++- backend/internal/service/maintenance.go | 5 + backend/internal/service/refresh_profiles.go | 53 +- backend/internal/service/tokens.go | 68 +- backend/internal/service/user_generation.go | 29 + backend/internal/service/v1.go | 328 ++++++- frontend/public/favicon.svg | 24 +- frontend/src/components/AccountEditModal.vue | 11 +- frontend/src/components/Icon.vue | 3 + frontend/src/components/ImportModal.vue | 2 +- frontend/src/components/LoginModal.vue | 2 +- frontend/src/components/Logo.vue | 40 +- frontend/src/components/Mascot.vue | 102 +++ frontend/src/layouts/AdminLayout.vue | 2 +- frontend/src/layouts/PublicLayout.vue | 388 +++++--- frontend/src/main.js | 50 +- frontend/src/style.css | 128 +++ frontend/src/views/AboutView.vue | 96 +- frontend/src/views/AccountsView.vue | 53 +- frontend/src/views/DocsView.vue | 104 ++- frontend/src/views/HomeView.vue | 839 ++++++++++++------ frontend/src/views/InviteView.vue | 11 +- frontend/src/views/ModelsView.vue | 69 +- frontend/src/views/OrdersView.vue | 10 +- frontend/src/views/PlaygroundView.vue | 238 ++++- frontend/src/views/SettingsView.vue | 17 +- frontend/src/views/UserLogsTableView.vue | 12 +- frontend/src/views/UserLogsView.vue | 18 +- 37 files changed, 2484 insertions(+), 813 deletions(-) create mode 100644 frontend/src/components/Mascot.vue diff --git a/backend/go.mod b/backend/go.mod index bb681dd..e2d4df0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) diff --git a/backend/go.sum b/backend/go.sum index c640bba..49a24f7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/http/handler/provider_admin.go b/backend/internal/http/handler/provider_admin.go index 4b3d9da..4768543 100644 --- a/backend/internal/http/handler/provider_admin.go +++ b/backend/internal/http/handler/provider_admin.go @@ -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")) diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index 6bf44bd..748af88 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -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"}, diff --git a/backend/internal/http/handler/v1.go b/backend/internal/http/handler/v1.go index 9cdfd75..f6569dc 100644 --- a/backend/internal/http/handler/v1.go +++ b/backend/internal/http/handler/v1.go @@ -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 { diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 9e94481..4197809 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -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"` diff --git a/backend/internal/provider/adobe/auth.go b/backend/internal/provider/adobe/auth.go index 603e367..2a1ab77 100644 --- a/backend/internal/provider/adobe/auth.go +++ b/backend/internal/provider/adobe/auth.go @@ -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") diff --git a/backend/internal/provider/adobe/client.go b/backend/internal/provider/adobe/client.go index bf6082c..2e9230c 100644 --- a/backend/internal/provider/adobe/client.go +++ b/backend/internal/provider/adobe/client.go @@ -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":"","ftr":"___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 { diff --git a/backend/internal/provider/adobe/payloads.go b/backend/internal/provider/adobe/payloads.go index 4068ba7..844661a 100644 --- a/backend/internal/provider/adobe/payloads.go +++ b/backend/internal/provider/adobe/payloads.go @@ -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": diff --git a/backend/internal/provider/adobe/util.go b/backend/internal/provider/adobe/util.go index ad800b2..3a36448 100644 --- a/backend/internal/provider/adobe/util.go +++ b/backend/internal/provider/adobe/util.go @@ -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":"","ftr":"___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=|… +// ftr: <32hex>___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 "=-<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 "" diff --git a/backend/internal/service/maintenance.go b/backend/internal/service/maintenance.go index fde6599..bbb875c 100644 --- a/backend/internal/service/maintenance.go +++ b/backend/internal/service/maintenance.go @@ -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 { diff --git a/backend/internal/service/refresh_profiles.go b/backend/internal/service/refresh_profiles.go index f167c84..c36c77b 100644 --- a/backend/internal/service/refresh_profiles.go +++ b/backend/internal/service/refresh_profiles.go @@ -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 } diff --git a/backend/internal/service/tokens.go b/backend/internal/service/tokens.go index d45786b..d9b4a9a 100644 --- a/backend/internal/service/tokens.go +++ b/backend/internal/service/tokens.go @@ -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, diff --git a/backend/internal/service/user_generation.go b/backend/internal/service/user_generation.go index 86934cb..92c159e 100644 --- a/backend/internal/service/user_generation.go +++ b/backend/internal/service/user_generation.go @@ -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("未登录或会话已过期") diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index 3a82299..7fa8cb2 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -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 } diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 79153eb..fb1f84c 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,17 +1,17 @@ - + - - - - + + - - - + + - - - - + + + + + diff --git a/frontend/src/components/AccountEditModal.vue b/frontend/src/components/AccountEditModal.vue index ae8b0d3..58c5d97 100644 --- a/frontend/src/components/AccountEditModal.vue +++ b/frontend/src/components/AccountEditModal.vue @@ -6,8 +6,15 @@ import Icon from './Icon.vue' const props = defineProps({ account: { type: Object, required: true } }) const emit = defineEmits(['close', 'saved']) -// concurrency is only adjustable for custom upstreams — others are system-fixed. +// concurrency is only adjustable for custom upstreams — others are system-fixed +// (adobe is set from the plan on every quota refresh: 会员号 5 / 普号 1). const canEditConcurrency = props.account.type === 'custom' +const fixedConcurrency = + props.account.type === 'adobe' + ? (props.account.plan === 'free' ? 1 : Number(props.account.concurrency) || 5) + : props.account.type === 'grok' + ? 10 + : 1 const weight = ref(Number(props.account.weight) || 0) const concurrency = ref(Number(props.account.concurrency) || 1) @@ -61,7 +68,7 @@ async function submit() {
- +

{{ status }}

diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index a4eab0f..738e55b 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -25,6 +25,9 @@ const PATHS = { receipt: '', book: '', info: '', + music: '', + play: '', + pause: '', } defineProps({ name: { type: String, required: true } }) diff --git a/frontend/src/components/ImportModal.vue b/frontend/src/components/ImportModal.vue index c40b552..e1673cd 100644 --- a/frontend/src/components/ImportModal.vue +++ b/frontend/src/components/ImportModal.vue @@ -113,7 +113,7 @@ async function doSmartImport() { 全粘进来即可,无需任何前缀。

- 📢 需要购买 Adobe 号请联系 QQ:432539 + 📢 需要购买 Adobe 号请联系 QQ:28998306