From b9edaa1ad31a160b63d839081243b4ed42d351b2 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Thu, 20 Aug 2026 10:52:08 +0800 Subject: [PATCH] feat(volcengine): add asset library support for official DoubaoVideo channels Register a DoubaoVideo asset adapter that forwards the Ark-compatible asset API (POST /api/v1/volcengine/asset?Action=xx) to the official /openApi/portrait endpoint with a Volcengine V4 HMAC-SHA256 signature (canonical request over content-type/host/x-content-sha256/x-date, date/cn-beijing/ark/request scope). The AK/SK pair and optional project code are stored in channel_asset_credentials (pool_id holds the project code), so asset credentials stay separate from the video Bearer key; the channel form now accepts them for type 54. DoubaoVideo joins the seedance asset family so a user's asset upload channel and video task channel stay the same (asset:// references pass through to the upstream). Both resolution chains (asset proxy resolver and video asset family matcher) now require the asset credential to be configured before a type-54 channel can serve, bind, or be auto-matched for assets, falling through to compatible channels otherwise. The /api/v3 native-path whitelist simplifies back to the family list now that 54 is a member. Verified end-to-end against the real upstream: CreateAssetGroup, CreateAsset (real video upload, Processing -> Active moderation), ListAssets/GetAsset, and a video generation task referencing the asset via asset://video reference (succeeded, 432900 tokens). Co-Authored-By: ZCode --- controller/channel.go | 14 +- controller/channel_test_tianyiyun_test.go | 11 +- controller/relay.go | 3 +- middleware/distributor.go | 6 +- middleware/doubao_asset_binding_test.go | 11 +- service/asset_adapter.go | 1 + service/asset_doubao.go | 190 ++++++++++++++++++ service/asset_doubao_test.go | 93 +++++++++ service/asset_resolver.go | 23 ++- service/video_asset_channel.go | 10 + .../channels/modals/EditChannelModal.jsx | 29 ++- 11 files changed, 363 insertions(+), 28 deletions(-) create mode 100644 service/asset_doubao.go create mode 100644 service/asset_doubao_test.go diff --git a/controller/channel.go b/controller/channel.go index 7f9553b..c376591 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -726,13 +726,23 @@ type ChannelAssetCredentialInput struct { } func channelAssetCredentialFromInput(channelType int, input *ChannelAssetCredentialInput) (*model.ChannelAssetCredential, error) { - if channelType != constant.ChannelTypeChinaMobileSeedance || input == nil { + if input == nil { + return nil, nil + } + var label string + switch channelType { + case constant.ChannelTypeChinaMobileSeedance: + label = "移动云素材" + case constant.ChannelTypeDoubaoVideo: + label = "火山素材" + default: + // Other channel types do not use separate asset credentials. return nil, nil } ak := strings.TrimSpace(input.AccessKey) sk := strings.TrimSpace(input.SecretKey) if ak == "" || sk == "" { - return nil, errors.New("移动云素材 AccessKey 和 SecretKey 必须同时填写") + return nil, errors.New(label + " AccessKey 和 SecretKey 必须同时填写") } return &model.ChannelAssetCredential{AccessKey: ak, SecretKey: sk, PoolID: strings.TrimSpace(input.PoolID)}, nil } diff --git a/controller/channel_test_tianyiyun_test.go b/controller/channel_test_tianyiyun_test.go index 2355923..41b3e22 100644 --- a/controller/channel_test_tianyiyun_test.go +++ b/controller/channel_test_tianyiyun_test.go @@ -29,11 +29,14 @@ func TestRequiredTaskChannelTypeForTianyiYunSeedanceModelUsesAllowedFamily(t *te c := newControllerJSONContext(t, "/api/v3/contents/generations/tasks", `{"model":"Doubao-Seedance-2.0"}`) require.Equal(t, 0, requiredTaskChannelTypeForRequest(c)) - // The native /api/v3 path also accepts official Volcengine (DoubaoVideo) - // channels alongside the seedance asset family. require.ElementsMatch(t, - append(service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), - constant.ChannelTypeDoubaoVideo), + service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), allowedTaskChannelTypesForRequest(c), ) + // The seedance family must include the official Volcengine channel so the + // native /api/v3 path can route to it. + require.Contains(t, + allowedTaskChannelTypesForRequest(c), + constant.ChannelTypeDoubaoVideo, + ) } diff --git a/controller/relay.go b/controller/relay.go index d6dc657..ea97596 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -368,8 +368,7 @@ func requiredTaskChannelTypeForRequest(c *gin.Context) int { func allowedTaskChannelTypesForRequest(c *gin.Context) []int { if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { // Keep in sync with allowedChannelTypesForRequest in middleware/distributor.go - return append(service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), - constant.ChannelTypeDoubaoVideo) + return service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance) } if isKlingAipingNativePath(c.Request.URL.Path) { return service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilyKling) diff --git a/middleware/distributor.go b/middleware/distributor.go index 809e1c2..7f577fe 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -345,11 +345,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { func allowedChannelTypesForRequest(c *gin.Context, modelName string) []int { if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { - // DoubaoVideo channels (official Volcengine Ark) also serve the native - // /api/v3 path; they are appended here instead of joining the seedance - // asset family so they stay out of the user asset binding system. - return append(service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), - constant.ChannelTypeDoubaoVideo) + return service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance) } return nil } diff --git a/middleware/doubao_asset_binding_test.go b/middleware/doubao_asset_binding_test.go index 88f444f..624c5e0 100644 --- a/middleware/doubao_asset_binding_test.go +++ b/middleware/doubao_asset_binding_test.go @@ -212,13 +212,16 @@ func TestSeedanceTasksUseAllowedFamilyTypesInsteadOfModelPrefix(t *testing.T) { c.Request = httptest.NewRequest(http.MethodPost, "/api/v3/contents/generations/tasks", strings.NewReader(`{}`)) require.Equal(t, 0, requiredChannelTypeForRequest(c, "Doubao-Seedance-2.0")) - // The native /api/v3 path also accepts official Volcengine (DoubaoVideo) - // channels alongside the seedance asset family. require.ElementsMatch(t, - append(service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), - constant.ChannelTypeDoubaoVideo), + service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), allowedChannelTypesForRequest(c, "Doubao-Seedance-2.0"), ) + // The seedance family must include the official Volcengine channel so the + // native /api/v3 path can route to it. + require.Contains(t, + allowedChannelTypesForRequest(c, "Doubao-Seedance-2.0"), + constant.ChannelTypeDoubaoVideo, + ) } func TestShouldPersistDoubaoVideoBindingRequiresVideoSubmitRelayMode(t *testing.T) { diff --git a/service/asset_adapter.go b/service/asset_adapter.go index 8ff0ac4..f573123 100644 --- a/service/asset_adapter.go +++ b/service/asset_adapter.go @@ -60,6 +60,7 @@ func init() { func registerDefaultAssetAdapters() { assetAdapters[constant.ChannelTypeChinaMobileSeedance] = NewChinaMobileAssetAdapter() + assetAdapters[constant.ChannelTypeDoubaoVideo] = NewDoubaoVideoAssetAdapter() assetAdapters[constant.ChannelTypeDoubaoVideoCompatibleAiping] = NewCompatibleAssetAdapter("aiping_asset", []AssetOperation{ AssetOperationAssetCreate, AssetOperationAssetList, diff --git a/service/asset_doubao.go b/service/asset_doubao.go new file mode 100644 index 0000000..e5ff798 --- /dev/null +++ b/service/asset_doubao.go @@ -0,0 +1,190 @@ +package service + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/system_setting" +) + +// DoubaoVideoAssetAdapter serves the Ark-compatible asset API on official +// Volcengine (DoubaoVideo) channels. Unlike the aiping adapter (Bearer key), +// the official endpoint authenticates every request with a Volcengine V4 +// HMAC-SHA256 signature built from an AccessKey/SecretKey pair stored in +// channel_asset_credentials (PoolID holds the optional project code). +type DoubaoVideoAssetAdapter struct { + operation map[AssetOperation]struct{} +} + +func NewDoubaoVideoAssetAdapter() AssetAdapter { + operations := []AssetOperation{ + AssetOperationAssetCreate, + AssetOperationAssetList, + AssetOperationAssetGet, + AssetOperationAssetUpdate, + AssetOperationAssetDelete, + AssetOperationAssetGroupCreate, + AssetOperationAssetGroupList, + AssetOperationAssetGroupGet, + AssetOperationAssetGroupUpdate, + AssetOperationAssetGroupDelete, + } + supported := make(map[AssetOperation]struct{}, len(operations)) + for _, op := range operations { + supported[op] = struct{}{} + } + return &DoubaoVideoAssetAdapter{operation: supported} +} + +func (a *DoubaoVideoAssetAdapter) Name() string { + return "doubao_video_asset" +} + +func (a *DoubaoVideoAssetAdapter) Supports(operation AssetOperation) bool { + _, ok := a.operation[operation] + return ok +} + +func (a *DoubaoVideoAssetAdapter) DoAssetRequest(ctx context.Context, channel *model.Channel, req AssetRequest) (*AssetUpstreamResponse, *AssetError) { + if !a.Supports(req.Action.Operation) { + return nil, newAssetError(AssetErrorOperationNotSupported, fmt.Sprintf("asset operation %s is not supported", req.Action.Operation), http.StatusBadRequest) + } + credential, err := model.GetChannelAssetCredential(channel.Id) + if err != nil { + return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) + } + if credential == nil || strings.TrimSpace(credential.AccessKey) == "" || strings.TrimSpace(credential.SecretKey) == "" { + return nil, newAssetError(AssetErrorServer, "Volcengine asset AccessKey and SecretKey are required (configure the channel asset credential)", http.StatusBadRequest) + } + + upstreamURL, rawQuery, err := buildDoubaoVideoAssetURL(channel, req.Action.Action, req.Version) + if err != nil { + return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) + } + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(upstreamURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return nil, newAssetError(AssetErrorServer, fmt.Sprintf("request blocked: %v", err), http.StatusForbidden) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(req.RawBody)) + if err != nil { + return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) + } + signDoubaoVideoAssetRequest(httpReq, credential.AccessKey, credential.SecretKey, req.RawBody, rawQuery) + if projectCode := strings.TrimSpace(credential.PoolID); projectCode != "" { + httpReq.Header.Set("X-Project-Code", projectCode) + } + + client, err := GetHttpClientWithProxy(channel.GetSetting().Proxy) + if err != nil { + return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) + } + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(httpReq) + if err != nil { + return nil, newAssetError(AssetErrorUpstream, err.Error(), http.StatusBadGateway) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, newAssetError(AssetErrorUpstream, err.Error(), http.StatusBadGateway) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, newAssetError(AssetErrorUpstream, string(data), http.StatusBadGateway) + } + return &AssetUpstreamResponse{StatusCode: resp.StatusCode, Header: resp.Header, Body: data}, nil +} + +func buildDoubaoVideoAssetURL(channel *model.Channel, action string, version string) (string, string, error) { + baseURL := strings.TrimSpace(channel.GetBaseURL()) + if baseURL == "" { + baseURL = "https://ark.cn-beijing.volces.com" + } + u, err := url.Parse(baseURL) + if err != nil { + return "", "", err + } + u.Path = strings.TrimRight(u.Path, "/") + "/openApi/portrait" + if strings.TrimSpace(version) == "" { + version = "2024-01-01" + } + // Action < Version lexicographically, so this raw query is already the + // sorted canonical form required by the V4 signature. + rawQuery := "Action=" + url.QueryEscape(action) + "&Version=" + url.QueryEscape(version) + u.RawQuery = rawQuery + return u.String(), rawQuery, nil +} + +// signDoubaoVideoAssetRequest applies the Volcengine V4 HMAC-SHA256 signature +// to the request: canonical request over (method, path, sorted query, +// content-type/host/x-content-sha256/x-date headers, body hash) -> string to +// sign with the {date}/{region}/ark/request scope -> chained HMAC keys. +func signDoubaoVideoAssetRequest(req *http.Request, accessKey string, secretKey string, body []byte, rawQuery string) { + now := time.Now().UTC() + xDate := now.Format("20060102T150405Z") + shortDate := now.Format("20060102") + payloadHash := sha256.Sum256(body) + payloadHashHex := hex.EncodeToString(payloadHash[:]) + + canonicalHeaders := fmt.Sprintf( + "content-type:application/json\nhost:%s\nx-content-sha256:%s\nx-date:%s\n", + req.URL.Host, payloadHashHex, xDate, + ) + signedHeaders := "content-type;host;x-content-sha256;x-date" + canonicalRequest := strings.Join([]string{ + req.Method, + req.URL.EscapedPath(), + rawQuery, + canonicalHeaders, + signedHeaders, + payloadHashHex, + }, "\n") + + scope := shortDate + "/cn-beijing/ark/request" + canonicalHash := sha256.Sum256([]byte(canonicalRequest)) + stringToSign := strings.Join([]string{ + "HMAC-SHA256", + xDate, + scope, + hex.EncodeToString(canonicalHash[:]), + }, "\n") + + signingKey := volcengineSigningKey(secretKey, shortDate) + signature := hmac.New(sha256.New, signingKey) + signature.Write([]byte(stringToSign)) + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Date", xDate) + req.Header.Set("X-Content-Sha256", payloadHashHex) + req.Header.Set("Authorization", fmt.Sprintf( + "HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + accessKey, scope, signedHeaders, hex.EncodeToString(signature.Sum(nil)), + )) +} + +func volcengineSigningKey(secretKey string, shortDate string) []byte { + key := hmacSHA256([]byte(secretKey), shortDate) + key = hmacSHA256(key, "cn-beijing") + key = hmacSHA256(key, "ark") + return hmacSHA256(key, "request") +} + +func hmacSHA256(key []byte, message string) []byte { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(message)) + return mac.Sum(nil) +} diff --git a/service/asset_doubao_test.go b/service/asset_doubao_test.go new file mode 100644 index 0000000..20e58b7 --- /dev/null +++ b/service/asset_doubao_test.go @@ -0,0 +1,93 @@ +package service + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/stretchr/testify/require" +) + +func TestDoubaoVideoAssetAdapter_SupportsAllOperations(t *testing.T) { + adapter := NewDoubaoVideoAssetAdapter() + require.Equal(t, "doubao_video_asset", adapter.Name()) + for _, op := range []AssetOperation{ + AssetOperationAssetCreate, + AssetOperationAssetList, + AssetOperationAssetGet, + AssetOperationAssetUpdate, + AssetOperationAssetDelete, + AssetOperationAssetGroupCreate, + AssetOperationAssetGroupList, + AssetOperationAssetGroupGet, + AssetOperationAssetGroupUpdate, + AssetOperationAssetGroupDelete, + } { + require.True(t, adapter.Supports(op), "operation %s should be supported", op) + } +} + +func TestBuildDoubaoVideoAssetURL(t *testing.T) { + baseURL := "http://14.103.147.238:19220" + channel := &model.Channel{Type: constant.ChannelTypeDoubaoVideo, BaseURL: &baseURL} + rawURL, rawQuery, err := buildDoubaoVideoAssetURL(channel, "ListAssets", "") + require.NoError(t, err) + require.Equal(t, "Action=ListAssets&Version=2024-01-01", rawQuery) + parsed, err := url.Parse(rawURL) + require.NoError(t, err) + require.Equal(t, "/openApi/portrait", parsed.Path) + require.Equal(t, "Action=ListAssets&Version=2024-01-01", parsed.RawQuery) +} + +func TestBuildDoubaoVideoAssetURL_DefaultBaseAndTrailingSlash(t *testing.T) { + baseURL := "https://ark.cn-beijing.volces.com/" + channel := &model.Channel{Type: constant.ChannelTypeDoubaoVideo, BaseURL: &baseURL} + rawURL, _, err := buildDoubaoVideoAssetURL(channel, "CreateAsset", "2024-01-01") + require.NoError(t, err) + require.Equal(t, "https://ark.cn-beijing.volces.com/openApi/portrait?Action=CreateAsset&Version=2024-01-01", rawURL) +} + +func TestSignDoubaoVideoAssetRequest_HeaderShape(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://14.103.147.238:19220/openApi/portrait?Action=ListAssets&Version=2024-01-01", nil) + require.NoError(t, err) + signDoubaoVideoAssetRequest(req, "AK-test", "SK-test", []byte(`{"Filter":{"GroupType":"AIGC"}}`), "Action=ListAssets&Version=2024-01-01") + + auth := req.Header.Get("Authorization") + require.True(t, strings.HasPrefix(auth, "HMAC-SHA256 Credential=AK-test/"), "got %s", auth) + require.Contains(t, auth, "/cn-beijing/ark/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature=") + require.NotEmpty(t, req.Header.Get("X-Date")) + require.Len(t, req.Header.Get("X-Date"), 16) + require.Equal(t, "application/json", req.Header.Get("Content-Type")) + // body hash header must be the hex sha256 of the raw body + require.Regexp(t, `^[0-9a-f]{64}$`, req.Header.Get("X-Content-Sha256")) +} + +// The signature must change when any signed input changes (body, key, date). +func TestSignDoubaoVideoAssetRequest_DeterministicPerInputs(t *testing.T) { + build := func(body string) string { + req, _ := http.NewRequest(http.MethodPost, "http://h/openApi/portrait?Action=A&Version=2024-01-01", nil) + signDoubaoVideoAssetRequest(req, "AK", "SK", []byte(body), "Action=A&Version=2024-01-01") + return req.Header.Get("Authorization") + } + sig1 := build(`{"a":1}`) + sig2 := build(`{"a":1}`) + sig3 := build(`{"a":2}`) + require.Equal(t, sig1[strings.Index(sig1, "Signature="):], sig2[strings.Index(sig2, "Signature="):]) + require.NotEqual(t, sig1[strings.Index(sig1, "Signature="):], sig3[strings.Index(sig3, "Signature="):]) +} + +func TestDoubaoVideoAssetAdapter_MissingCredential(t *testing.T) { + db := setupDoubaoAssetChannelDB(t) + require.NoError(t, db.AutoMigrate(&model.ChannelAssetCredential{})) + adapter := NewDoubaoVideoAssetAdapter() + channel := &model.Channel{Id: 999999, Type: constant.ChannelTypeDoubaoVideo} + _, assetErr := adapter.DoAssetRequest(t.Context(), channel, AssetRequest{ + Action: AssetActionSpec{Action: "ListAssets", Operation: AssetOperationAssetList}, + RawBody: []byte(`{}`), + }) + require.NotNil(t, assetErr) + require.Contains(t, assetErr.Message, "AccessKey and SecretKey are required") +} diff --git a/service/asset_resolver.go b/service/asset_resolver.go index abab9e8..83006ae 100644 --- a/service/asset_resolver.go +++ b/service/asset_resolver.go @@ -4,12 +4,31 @@ import ( "errors" "fmt" "net/http" + "strings" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" "gorm.io/gorm" ) +// assetCredentialConfigured guards channel types whose asset adapter signs +// requests with a dedicated AK/SK pair stored in channel_asset_credentials +// (currently only official Volcengine DoubaoVideo). Without the credential +// the channel must not serve, be bound, or be auto-matched for assets; a DB +// error is treated as not configured so auto-match falls through to other +// candidate channels. +func assetCredentialConfigured(channel *model.Channel) bool { + if channel.Type != constant.ChannelTypeDoubaoVideo { + return true + } + credential, err := model.GetChannelAssetCredential(channel.Id) + if err != nil || credential == nil { + return false + } + return strings.TrimSpace(credential.AccessKey) != "" && strings.TrimSpace(credential.SecretKey) != "" +} + func ResolveAssetChannelForOperation(userID int, tokenGroup string, operation AssetOperation) (*model.Channel, AssetAdapter, *AssetError) { bindings, err := model.GetUserAssetChannelsByTypes(userID, RegisteredAssetChannelTypes(), tokenGroup) if err != nil { @@ -25,7 +44,7 @@ func ResolveAssetChannelForOperation(userID int, tokenGroup string, operation As return nil, nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) } adapter, ok := GetAssetAdapter(channel.Type) - if channel.Status != common.ChannelStatusEnabled || !assetChannelHasKey(channel) || !MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) || !ok { + if channel.Status != common.ChannelStatusEnabled || !assetChannelHasKey(channel) || !MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) || !ok || !assetCredentialConfigured(channel) { return nil, nil, newAssetError(AssetErrorBindingInvalid, "bound asset channel is not available for asset library", http.StatusBadGateway) } if !adapter.Supports(operation) { @@ -66,7 +85,7 @@ func autoMatchAssetChannelForOperation(tokenGroup string, operation AssetOperati } return nil, nil, err } - if channel.Status == common.ChannelStatusEnabled && assetChannelHasKey(channel) && MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) { + if channel.Status == common.ChannelStatusEnabled && assetChannelHasKey(channel) && MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) && assetCredentialConfigured(channel) { return channel, adapter, nil } } diff --git a/service/video_asset_channel.go b/service/video_asset_channel.go index 42fd84b..632f51a 100644 --- a/service/video_asset_channel.go +++ b/service/video_asset_channel.go @@ -25,7 +25,11 @@ func VideoAssetFamilies() []VideoAssetFamily { func VideoAssetChannelTypesForFamily(family VideoAssetFamily) []int { switch family { case VideoAssetFamilySeedance: + // DoubaoVideo (official Volcengine Ark) joins the family so a user's + // asset upload channel and video task channel stay the same; assets + // are referenced by asset:// ids passed through to the upstream. return []int{ + constant.ChannelTypeDoubaoVideo, constant.ChannelTypeDoubaoVideoCompatibleAiping, constant.ChannelTypeDoubaoVideoCompatibleTianyiYun, constant.ChannelTypeChinaMobileSeedance, @@ -218,6 +222,12 @@ func IsUsableVideoAssetChannelForFamily(channel *model.Channel, tokenGroup strin if !MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) { return false } + // Official Volcengine channels additionally need the asset AK/SK + // credential before they can serve the asset family; without it they are + // skipped so binding/auto-match falls through to compatible channels. + if !assetCredentialConfigured(channel) { + return false + } modelName = strings.TrimSpace(modelName) if modelName == "" { return true diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 57a514c..d306891 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -1365,7 +1365,7 @@ const EditChannelModal = (props) => { if (isEdit && (!localInputs.key || localInputs.key.trim() === '')) { delete localInputs.key; } - if (localInputs.type === 61) { + if ([54, 61].includes(localInputs.type)) { const credential = localInputs.asset_credential || {}; const accessKey = String(credential.access_key || '').trim(); const secretKey = String(credential.secret_key || '').trim(); @@ -2538,9 +2538,11 @@ const EditChannelModal = (props) => { )} - {inputs.type === 61 && ( + {[54, 61].includes(inputs.type) && ( - {t('移动云素材凭证')} + + {t(inputs.type === 61 ? '移动云素材凭证' : '火山素材凭证')} + {inputs.asset_credential_configured ? t('已配置素材凭证;留空不会覆盖现有凭证。') @@ -2558,12 +2560,21 @@ const EditChannelModal = (props) => { mode='password' autoComplete='new-password' /> - + {inputs.type === 61 ? ( + + ) : ( + + )} )}