Ver a proveniência

feat(volcengine): decouple asset endpoint and persist asset binding

Complete the DoubaoVideo asset library integration on top of the
managed per-user asset groups:

- Asset endpoint resolution is an explicit three-level priority: a
  hard-coded override wins, otherwise the per-channel credential
  base_url from channel_asset_credentials is used verbatim (it already
  contains the full /openApi/portrait path), otherwise the official
  default. The channel video base URL no longer participates in asset
  routing, so video (official Ark) and asset (gateway) addresses stay
  fully independent. The channel form gains an "素材 API 地址" field with
  a hint that the official address has no asset API, and the channel
  type label becomes "豆包视频(素材网关)" to surface the split. Credential
  summaries now cover DoubaoVideo channels and echo the base URL back
  for editing.

- After an asset request auto-matches a channel (no existing user
  binding), persist it to user_asset_channels so subsequent asset and
  video requests stay on the same channel, keeping asset:// references
  consistent with the upload channel. This mirrors the video-task
  binding backfill; binding failure logs a warning and does not fail
  the asset operation.

Co-Authored-By: ZCode <noreply@anthropic.com>
master
fengsilin há 6 dias
ascendente
cometimento
8a891364c1
10 ficheiros alterados com 145 adições e 22 eliminações
  1. +12
    -2
      controller/channel.go
  2. +1
    -0
      model/channel.go
  3. +8
    -3
      model/channel_asset_credential.go
  4. +17
    -3
      service/asset_doubao.go
  5. +45
    -7
      service/asset_doubao_test.go
  6. +10
    -0
      service/asset_resolver.go
  7. +19
    -0
      service/asset_resolver_test.go
  8. +29
    -6
      web/src/components/table/channels/modals/EditChannelModal.jsx
  9. +1
    -1
      web/src/constants/channel.constants.js
  10. +3
    -0
      web/src/i18n/locales/en.json

+ 12
- 2
controller/channel.go Ver ficheiro

@@ -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) {


+ 1
- 0
model/channel.go Ver ficheiro

@@ -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 {


+ 8
- 3
model/channel_asset_credential.go Ver ficheiro

@@ -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


+ 17
- 3
service/asset_doubao.go Ver ficheiro

@@ -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"
}


+ 45
- 7
service/asset_doubao_test.go Ver ficheiro

@@ -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) {


+ 10
- 0
service/asset_resolver.go Ver ficheiro

@@ -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
}



+ 19
- 0
service/asset_resolver_test.go Ver ficheiro

@@ -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)


+ 29
- 6
web/src/components/table/channels/modals/EditChannelModal.jsx Ver ficheiro

@@ -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')}
/>
) : (
<Form.Input
field='asset_credential.pool_id'
label={t('项目编码 ProjectCode')}
placeholder='bTrHbDj6TB6ZhF6O'
extraText={t('火山项目编码,可选')}
/>
<>
<Form.Input
field='asset_credential.pool_id'
label={t('项目编码 ProjectCode')}
placeholder='bTrHbDj6TB6ZhF6O'
extraText={t('火山项目编码,可选')}
/>
<Form.Input
field='asset_credential.base_url'
label={t('素材 API 地址')}
placeholder='http://14.103.147.238:19220'
extraText={t(
'素材库接口调用该地址下的 /openApi/portrait,为素材网关专属;留空则使用渠道 API 地址。火山官方地址不提供此接口。',
)}
/>
</>
)}
</Card>
)}


+ 1
- 1
web/src/constants/channel.constants.js Ver ficheiro

@@ -172,7 +172,7 @@ export const CHANNEL_OPTIONS = [
{
value: 54,
color: 'blue',
label: '豆包视频',
label: '豆包视频(素材网关)',
},
{
value: 55,


+ 3
- 0
web/src/i18n/locales/en.json Ver ficheiro

@@ -2720,6 +2720,9 @@
"谨慎": "Cautious",
"豆包": "Doubao",
"豆包视频": "Doubao Video",
"豆包视频(素材网关)": "Doubao Video (Asset Gateway)",
"素材 API 地址": "Asset API Base URL",
"素材库接口调用该地址下的 /openApi/portrait,为素材网关专属;留空则使用渠道 API 地址。火山官方地址不提供此接口。": "Asset APIs call /openApi/portrait under this address (asset gateway only); leave empty to use the channel API address. The official Volcengine address does not provide this API.",
"账单": "Bills",
"账户充值": "Account recharge",
"账户已删除!": "Account has been deleted!",


Carregando…
Cancelar
Guardar