diff --git a/controller/channel.go b/controller/channel.go index c376591..4495ef6 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strconv" "strings" "time" @@ -81,13 +82,14 @@ func attachChannelAssetCredentialSummaries(channels []*model.Channel) error { return err } for _, channel := range channels { - if channel == nil || channel.Type != constant.ChannelTypeChinaMobileSeedance { + if channel == nil || (channel.Type != constant.ChannelTypeChinaMobileSeedance && channel.Type != constant.ChannelTypeDoubaoVideo) { continue } summary, ok := summaries[channel.Id] channel.AssetCredentialConfigured = ok if ok { channel.AssetCredentialPoolID = summary.PoolID + channel.AssetCredentialBaseURL = summary.BaseURL } } return nil @@ -723,6 +725,7 @@ type ChannelAssetCredentialInput struct { AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` PoolID string `json:"pool_id"` + BaseURL string `json:"base_url"` } func channelAssetCredentialFromInput(channelType int, input *ChannelAssetCredentialInput) (*model.ChannelAssetCredential, error) { @@ -744,7 +747,14 @@ func channelAssetCredentialFromInput(channelType int, input *ChannelAssetCredent if ak == "" || sk == "" { return nil, errors.New(label + " AccessKey 和 SecretKey 必须同时填写") } - return &model.ChannelAssetCredential{AccessKey: ak, SecretKey: sk, PoolID: strings.TrimSpace(input.PoolID)}, nil + baseURL := strings.TrimSpace(input.BaseURL) + if channelType == constant.ChannelTypeDoubaoVideo && baseURL != "" { + parsed, err := url.ParseRequestURI(baseURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return nil, errors.New("火山素材 API 地址必须是合法的 http/https URL") + } + } + return &model.ChannelAssetCredential{AccessKey: ak, SecretKey: sk, PoolID: strings.TrimSpace(input.PoolID), BaseURL: baseURL}, nil } func getVertexArrayKeys(keys string) ([]string, error) { diff --git a/model/channel.go b/model/channel.go index bba7752..8359090 100644 --- a/model/channel.go +++ b/model/channel.go @@ -60,6 +60,7 @@ type Channel struct { // Asset credential metadata is populated only for management API responses. AssetCredentialConfigured bool `json:"asset_credential_configured,omitempty" gorm:"-"` AssetCredentialPoolID string `json:"asset_credential_pool_id,omitempty" gorm:"-"` + AssetCredentialBaseURL string `json:"asset_credential_base_url,omitempty" gorm:"-"` } type ChannelInfo struct { diff --git a/model/channel_asset_credential.go b/model/channel_asset_credential.go index d90801c..8efe2c9 100644 --- a/model/channel_asset_credential.go +++ b/model/channel_asset_credential.go @@ -6,14 +6,17 @@ import ( "gorm.io/gorm/clause" ) -// ChannelAssetCredential stores the China Mobile asset credentials separately -// from the channel video-generation key. +// ChannelAssetCredential stores the channel asset credentials separately +// from the channel video-generation key. For DoubaoVideo (official +// Volcengine) channels BaseURL optionally holds a dedicated asset API base +// (the asset gateway), falling back to the channel base URL when empty. type ChannelAssetCredential struct { Id int `json:"id"` ChannelId int `json:"channel_id" gorm:"uniqueIndex;not null"` AccessKey string `json:"-" gorm:"not null;size:255"` SecretKey string `json:"-" gorm:"not null;size:255"` PoolID string `json:"pool_id" gorm:"size:255"` + BaseURL string `json:"base_url" gorm:"size:255"` CreatedAt int64 `json:"created_at" gorm:"bigint;not null"` UpdatedAt int64 `json:"updated_at" gorm:"bigint;not null"` } @@ -22,6 +25,7 @@ type ChannelAssetCredential struct { type ChannelAssetCredentialSummary struct { ChannelId int PoolID string + BaseURL string } func GetChannelAssetCredential(channelID int) (*ChannelAssetCredential, error) { @@ -52,6 +56,7 @@ func UpsertChannelAssetCredentialWithTx(tx *gorm.DB, credential *ChannelAssetCre "access_key": credential.AccessKey, "secret_key": credential.SecretKey, "pool_id": credential.PoolID, + "base_url": credential.BaseURL, "updated_at": credential.UpdatedAt, }), }).Create(credential).Error @@ -87,7 +92,7 @@ func GetChannelAssetCredentialSummaries(channelIDs []int) (map[int]ChannelAssetC } var rows []ChannelAssetCredentialSummary if err := DB.Model(&ChannelAssetCredential{}). - Select("channel_id", "pool_id"). + Select("channel_id", "pool_id", "base_url"). Where("channel_id IN ?", channelIDs). Find(&rows).Error; err != nil { return nil, err diff --git a/service/asset_doubao.go b/service/asset_doubao.go index 7d20e95..9778e05 100644 --- a/service/asset_doubao.go +++ b/service/asset_doubao.go @@ -118,16 +118,30 @@ func (a *DoubaoVideoAssetAdapter) DoAssetRequest(ctx context.Context, channel *m return &AssetUpstreamResponse{StatusCode: resp.StatusCode, Header: resp.Header, Body: data}, nil } +// doubaoVideoAssetEndpointOverride hard-codes the asset endpoint for testing +// or special deployments. When non-empty it wins over the per-channel +// credential address (channel_asset_credentials.base_url); leave empty to +// use the stored value, or the official default when nothing is configured. +var doubaoVideoAssetEndpointOverride = "" + +// buildDoubaoVideoAssetURL returns the full asset endpoint URL and its raw +// query (sorted Action < Version, as required by the V4 signature). Address +// resolution order: hard-coded override, credential base_url (used verbatim, +// it already includes the /openApi/portrait path), official default. func buildDoubaoVideoAssetURL(channel *model.Channel, action string, version string) (string, string, error) { - baseURL := strings.TrimSpace(channel.GetBaseURL()) + baseURL := strings.TrimSpace(doubaoVideoAssetEndpointOverride) if baseURL == "" { - baseURL = "https://ark.cn-beijing.volces.com" + if credential, err := model.GetChannelAssetCredential(channel.Id); err == nil && credential != nil { + baseURL = strings.TrimSpace(credential.BaseURL) + } + } + if baseURL == "" { + baseURL = "https://ark.cn-beijing.volces.com/openApi/portrait" } 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" } diff --git a/service/asset_doubao_test.go b/service/asset_doubao_test.go index 20e58b7..85d3d05 100644 --- a/service/asset_doubao_test.go +++ b/service/asset_doubao_test.go @@ -31,8 +31,9 @@ func TestDoubaoVideoAssetAdapter_SupportsAllOperations(t *testing.T) { } func TestBuildDoubaoVideoAssetURL(t *testing.T) { - baseURL := "http://14.103.147.238:19220" - channel := &model.Channel{Type: constant.ChannelTypeDoubaoVideo, BaseURL: &baseURL} + db := setupDoubaoAssetChannelDB(t) + require.NoError(t, db.AutoMigrate(&model.ChannelAssetCredential{})) + channel := &model.Channel{Type: constant.ChannelTypeDoubaoVideo} rawURL, rawQuery, err := buildDoubaoVideoAssetURL(channel, "ListAssets", "") require.NoError(t, err) require.Equal(t, "Action=ListAssets&Version=2024-01-01", rawQuery) @@ -42,12 +43,49 @@ func TestBuildDoubaoVideoAssetURL(t *testing.T) { 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") +func TestBuildDoubaoVideoAssetURL_DedicatedCredentialBaseWins(t *testing.T) { + db := setupDoubaoAssetChannelDB(t) + require.NoError(t, db.AutoMigrate(&model.ChannelAssetCredential{})) + require.NoError(t, model.UpsertChannelAssetCredential(&model.ChannelAssetCredential{ + ChannelId: 26, + AccessKey: "AK", + SecretKey: "SK", + BaseURL: "http://14.103.147.238:19220/openApi/portrait", + })) + channel := &model.Channel{Id: 26, Type: constant.ChannelTypeDoubaoVideo} + + rawURL, _, err := buildDoubaoVideoAssetURL(channel, "ListAssets", "") + require.NoError(t, err) + require.Equal(t, "http://14.103.147.238:19220/openApi/portrait?Action=ListAssets&Version=2024-01-01", rawURL) +} + +func TestBuildDoubaoVideoAssetURL_HardcodedOverrideWinsOverCredential(t *testing.T) { + db := setupDoubaoAssetChannelDB(t) + require.NoError(t, db.AutoMigrate(&model.ChannelAssetCredential{})) + require.NoError(t, model.UpsertChannelAssetCredential(&model.ChannelAssetCredential{ + ChannelId: 26, + AccessKey: "AK", + SecretKey: "SK", + BaseURL: "http://credential.example/openApi/portrait", + })) + old := doubaoVideoAssetEndpointOverride + doubaoVideoAssetEndpointOverride = "http://override.example:19220/openApi/portrait" + defer func() { doubaoVideoAssetEndpointOverride = old }() + channel := &model.Channel{Id: 26, Type: constant.ChannelTypeDoubaoVideo} + + rawURL, _, err := buildDoubaoVideoAssetURL(channel, "ListAssets", "") + require.NoError(t, err) + require.Equal(t, "http://override.example:19220/openApi/portrait?Action=ListAssets&Version=2024-01-01", rawURL) +} + +func TestBuildDoubaoVideoAssetURL_FallsBackToOfficialDefault(t *testing.T) { + db := setupDoubaoAssetChannelDB(t) + require.NoError(t, db.AutoMigrate(&model.ChannelAssetCredential{})) + channel := &model.Channel{Id: 26, Type: constant.ChannelTypeDoubaoVideo} + + rawURL, _, err := buildDoubaoVideoAssetURL(channel, "ListAssets", "") require.NoError(t, err) - require.Equal(t, "https://ark.cn-beijing.volces.com/openApi/portrait?Action=CreateAsset&Version=2024-01-01", rawURL) + require.Equal(t, "https://ark.cn-beijing.volces.com/openApi/portrait?Action=ListAssets&Version=2024-01-01", rawURL) } func TestSignDoubaoVideoAssetRequest_HeaderShape(t *testing.T) { diff --git a/service/asset_resolver.go b/service/asset_resolver.go index 83006ae..1f6b904 100644 --- a/service/asset_resolver.go +++ b/service/asset_resolver.go @@ -60,6 +60,16 @@ func ResolveAssetChannelForOperation(userID int, tokenGroup string, operation As if channel == nil { return nil, nil, newAssetError(AssetErrorChannelNotFound, "no available asset channel supports requested operation", http.StatusBadGateway) } + // Persist the auto-matched channel as the user's asset binding so later + // asset and video requests stay on the same channel (asset:// references + // are passed through verbatim and must match the upload channel). This + // mirrors the video-task binding backfill. Binding is best-effort: a + // failure must not fail the asset operation itself. + if family, ok := VideoAssetFamilyForChannelType(channel.Type); ok && tokenGroup != "" && tokenGroup != "auto" { + if bindErr := BindVideoAssetChannel(userID, tokenGroup, channel, family); bindErr != nil { + common.SysLog(fmt.Sprintf("failed to persist asset channel binding for user %d group %s channel %d: %s", userID, tokenGroup, channel.Id, bindErr.Error())) + } + } return channel, adapter, nil } diff --git a/service/asset_resolver_test.go b/service/asset_resolver_test.go index 1de6443..717cc47 100644 --- a/service/asset_resolver_test.go +++ b/service/asset_resolver_test.go @@ -79,6 +79,25 @@ func TestResolveAssetChannelIgnoresBindingFromUnrelatedFamily(t *testing.T) { assert.Equal(t, 2, ch.Id) } +func TestResolveAssetChannelAutoMatchPersistsBinding(t *testing.T) { + db := setupDoubaoAssetChannelDB(t) + resetAssetAdapterRegistryForTest(t) + RegisterAssetAdapterForTest(constant.ChannelTypeChinaMobileSeedance, fakeAssetAdapter{name: "cm", operation: AssetOperationAssetCreate}) + createDoubaoAssetChannelForTest(t, db, 2, constant.ChannelTypeChinaMobileSeedance, "default", "cm-key", common.ChannelStatusEnabled) + + ch, adapter, assetErr := ResolveAssetChannelForOperation(10, "default", AssetOperationAssetCreate) + + require.Nil(t, assetErr) + require.NotNil(t, ch) + require.NotNil(t, adapter) + // auto-match must persist the binding so later asset/video requests stay + // on the same channel + binding, err := model.GetUserAssetChannel(10, constant.ChannelTypeChinaMobileSeedance, "default") + require.NoError(t, err) + require.NotNil(t, binding) + assert.Equal(t, 2, binding.ChannelId) +} + func TestResolveAssetChannelAutoMatchDoesNotReplaceVideoBinding(t *testing.T) { db := setupDoubaoAssetChannelDB(t) resetAssetAdapterRegistryForTest(t) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index d306891..9167e05 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -187,6 +187,7 @@ const EditChannelModal = (props) => { access_key: '', secret_key: '', pool_id: '', + base_url: '', }, asset_credential_configured: false, }; @@ -719,6 +720,16 @@ const EditChannelModal = (props) => { setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); } + // Asset credential metadata arrives at the top level (pool id / base + // url only, keys never exposed); map it into the nested form fields. + if ([54, 61].includes(data.type)) { + data.asset_credential = { + ...(data.asset_credential || {}), + pool_id: data.asset_credential_pool_id || '', + base_url: data.asset_credential_base_url || '', + }; + } + setInputs(data); if (formApiRef.current) { formApiRef.current.setValues(data); @@ -1370,6 +1381,7 @@ const EditChannelModal = (props) => { const accessKey = String(credential.access_key || '').trim(); const secretKey = String(credential.secret_key || '').trim(); const poolId = String(credential.pool_id || '').trim(); + const baseUrl = String(credential.base_url || '').trim(); if ((accessKey === '') !== (secretKey === '')) { showInfo(t('请同时填写素材 AccessKey 和 SecretKey')); return; @@ -1381,6 +1393,7 @@ const EditChannelModal = (props) => { access_key: accessKey, secret_key: secretKey, pool_id: poolId, + base_url: baseUrl, }; } } else { @@ -2568,12 +2581,22 @@ const EditChannelModal = (props) => { extraText={t('留空使用默认 PoolID:CIDC-CORE-00')} /> ) : ( -