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) { | 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 | return nil, nil | ||||
| } | } | ||||
| ak := strings.TrimSpace(input.AccessKey) | ak := strings.TrimSpace(input.AccessKey) | ||||
| sk := strings.TrimSpace(input.SecretKey) | sk := strings.TrimSpace(input.SecretKey) | ||||
| if ak == "" || sk == "" { | 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 | 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"}`) | c := newControllerJSONContext(t, "/api/v3/contents/generations/tasks", `{"model":"Doubao-Seedance-2.0"}`) | ||||
| require.Equal(t, 0, requiredTaskChannelTypeForRequest(c)) | 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, | require.ElementsMatch(t, | ||||
| append(service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), | |||||
| constant.ChannelTypeDoubaoVideo), | |||||
| service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), | |||||
| allowedTaskChannelTypesForRequest(c), | 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 { | func allowedTaskChannelTypesForRequest(c *gin.Context) []int { | ||||
| if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { | if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { | ||||
| // Keep in sync with allowedChannelTypesForRequest in middleware/distributor.go | // 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) { | if isKlingAipingNativePath(c.Request.URL.Path) { | ||||
| return service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilyKling) | 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 { | func allowedChannelTypesForRequest(c *gin.Context, modelName string) []int { | ||||
| if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { | 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 | return nil | ||||
| } | } | ||||
| @@ -212,13 +212,16 @@ func TestSeedanceTasksUseAllowedFamilyTypesInsteadOfModelPrefix(t *testing.T) { | |||||
| c.Request = httptest.NewRequest(http.MethodPost, "/api/v3/contents/generations/tasks", strings.NewReader(`{}`)) | c.Request = httptest.NewRequest(http.MethodPost, "/api/v3/contents/generations/tasks", strings.NewReader(`{}`)) | ||||
| require.Equal(t, 0, requiredChannelTypeForRequest(c, "Doubao-Seedance-2.0")) | 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, | require.ElementsMatch(t, | ||||
| append(service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), | |||||
| constant.ChannelTypeDoubaoVideo), | |||||
| service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance), | |||||
| allowedChannelTypesForRequest(c, "Doubao-Seedance-2.0"), | 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) { | func TestShouldPersistDoubaoVideoBindingRequiresVideoSubmitRelayMode(t *testing.T) { | ||||
| @@ -60,6 +60,7 @@ func init() { | |||||
| func registerDefaultAssetAdapters() { | func registerDefaultAssetAdapters() { | ||||
| assetAdapters[constant.ChannelTypeChinaMobileSeedance] = NewChinaMobileAssetAdapter() | assetAdapters[constant.ChannelTypeChinaMobileSeedance] = NewChinaMobileAssetAdapter() | ||||
| assetAdapters[constant.ChannelTypeDoubaoVideo] = NewDoubaoVideoAssetAdapter() | |||||
| assetAdapters[constant.ChannelTypeDoubaoVideoCompatibleAiping] = NewCompatibleAssetAdapter("aiping_asset", []AssetOperation{ | assetAdapters[constant.ChannelTypeDoubaoVideoCompatibleAiping] = NewCompatibleAssetAdapter("aiping_asset", []AssetOperation{ | ||||
| AssetOperationAssetCreate, | AssetOperationAssetCreate, | ||||
| AssetOperationAssetList, | 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" | "errors" | ||||
| "fmt" | "fmt" | ||||
| "net/http" | "net/http" | ||||
| "strings" | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/constant" | |||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| "gorm.io/gorm" | "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) { | func ResolveAssetChannelForOperation(userID int, tokenGroup string, operation AssetOperation) (*model.Channel, AssetAdapter, *AssetError) { | ||||
| bindings, err := model.GetUserAssetChannelsByTypes(userID, RegisteredAssetChannelTypes(), tokenGroup) | bindings, err := model.GetUserAssetChannelsByTypes(userID, RegisteredAssetChannelTypes(), tokenGroup) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -25,7 +44,7 @@ func ResolveAssetChannelForOperation(userID int, tokenGroup string, operation As | |||||
| return nil, nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) | return nil, nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) | ||||
| } | } | ||||
| adapter, ok := GetAssetAdapter(channel.Type) | 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) | return nil, nil, newAssetError(AssetErrorBindingInvalid, "bound asset channel is not available for asset library", http.StatusBadGateway) | ||||
| } | } | ||||
| if !adapter.Supports(operation) { | if !adapter.Supports(operation) { | ||||
| @@ -66,7 +85,7 @@ func autoMatchAssetChannelForOperation(tokenGroup string, operation AssetOperati | |||||
| } | } | ||||
| return nil, nil, err | 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 | return channel, adapter, nil | ||||
| } | } | ||||
| } | } | ||||
| @@ -25,7 +25,11 @@ func VideoAssetFamilies() []VideoAssetFamily { | |||||
| func VideoAssetChannelTypesForFamily(family VideoAssetFamily) []int { | func VideoAssetChannelTypesForFamily(family VideoAssetFamily) []int { | ||||
| switch family { | switch family { | ||||
| case VideoAssetFamilySeedance: | 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{ | return []int{ | ||||
| constant.ChannelTypeDoubaoVideo, | |||||
| constant.ChannelTypeDoubaoVideoCompatibleAiping, | constant.ChannelTypeDoubaoVideoCompatibleAiping, | ||||
| constant.ChannelTypeDoubaoVideoCompatibleTianyiYun, | constant.ChannelTypeDoubaoVideoCompatibleTianyiYun, | ||||
| constant.ChannelTypeChinaMobileSeedance, | constant.ChannelTypeChinaMobileSeedance, | ||||
| @@ -218,6 +222,12 @@ func IsUsableVideoAssetChannelForFamily(channel *model.Channel, tokenGroup strin | |||||
| if !MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) { | if !MatchDoubaoAssetGroup(channel.GetGroups(), tokenGroup) { | ||||
| return false | 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) | modelName = strings.TrimSpace(modelName) | ||||
| if modelName == "" { | if modelName == "" { | ||||
| return true | return true | ||||
| @@ -1365,7 +1365,7 @@ const EditChannelModal = (props) => { | |||||
| if (isEdit && (!localInputs.key || localInputs.key.trim() === '')) { | if (isEdit && (!localInputs.key || localInputs.key.trim() === '')) { | ||||
| delete localInputs.key; | delete localInputs.key; | ||||
| } | } | ||||
| if (localInputs.type === 61) { | |||||
| if ([54, 61].includes(localInputs.type)) { | |||||
| const credential = localInputs.asset_credential || {}; | const credential = localInputs.asset_credential || {}; | ||||
| const accessKey = String(credential.access_key || '').trim(); | const accessKey = String(credential.access_key || '').trim(); | ||||
| const secretKey = String(credential.secret_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'> | <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'> | <Text type='tertiary' size='small' className='block mb-3'> | ||||
| {inputs.asset_credential_configured | {inputs.asset_credential_configured | ||||
| ? t('已配置素材凭证;留空不会覆盖现有凭证。') | ? t('已配置素材凭证;留空不会覆盖现有凭证。') | ||||
| @@ -2558,12 +2560,21 @@ const EditChannelModal = (props) => { | |||||
| mode='password' | mode='password' | ||||
| autoComplete='new-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> | </Card> | ||||
| )} | )} | ||||