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 <noreply@anthropic.com>master
| @@ -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 | |||
| } | |||
| @@ -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, | |||
| ) | |||
| } | |||
| @@ -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) | |||
| @@ -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 | |||
| } | |||
| @@ -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) { | |||
| @@ -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, | |||
| @@ -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) | |||
| } | |||
| @@ -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") | |||
| } | |||
| @@ -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 | |||
| } | |||
| } | |||
| @@ -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 | |||
| @@ -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) && ( | |||
| <Card className='mb-4' shadows='always'> | |||
| <Text strong>{t('移动云素材凭证')}</Text> | |||
| <Text strong> | |||
| {t(inputs.type === 61 ? '移动云素材凭证' : '火山素材凭证')} | |||
| </Text> | |||
| <Text type='tertiary' size='small' className='block mb-3'> | |||
| {inputs.asset_credential_configured | |||
| ? t('已配置素材凭证;留空不会覆盖现有凭证。') | |||
| @@ -2558,12 +2560,21 @@ const EditChannelModal = (props) => { | |||
| mode='password' | |||
| autoComplete='new-password' | |||
| /> | |||
| <Form.Input | |||
| field='asset_credential.pool_id' | |||
| label='PoolID' | |||
| placeholder='CIDC-CORE-00' | |||
| extraText={t('留空使用默认 PoolID:CIDC-CORE-00')} | |||
| /> | |||
| {inputs.type === 61 ? ( | |||
| <Form.Input | |||
| field='asset_credential.pool_id' | |||
| label='PoolID' | |||
| placeholder='CIDC-CORE-00' | |||
| extraText={t('留空使用默认 PoolID:CIDC-CORE-00')} | |||
| /> | |||
| ) : ( | |||
| <Form.Input | |||
| field='asset_credential.pool_id' | |||
| label={t('项目编码 ProjectCode')} | |||
| placeholder='bTrHbDj6TB6ZhF6O' | |||
| extraText={t('火山项目编码,可选')} | |||
| /> | |||
| )} | |||
| </Card> | |||
| )} | |||