4 Commits

Auteur SHA1 Bericht Datum
  fengsilin 6f6c68787f feat(volcengine): manage per-user asset groups on DoubaoVideo channels 1 maand geleden
  fengsilin b9edaa1ad3 feat(volcengine): add asset library support for official DoubaoVideo channels 1 maand geleden
  fengsilin b2d43b6c29 fix(doubao-video): parse camelCase fields from Volcengine task responses 1 maand geleden
  fengsilin 12686067c5 feat(volcengine): serve native /api/v3 video path on official DoubaoVideo channels 1 maand geleden
16 gewijzigde bestanden met toevoegingen van 583 en 54 verwijderingen
  1. +12
    -2
      controller/channel.go
  2. +6
    -0
      controller/channel_test_tianyiyun_test.go
  3. +15
    -7
      controller/doubao_asset.go
  4. +1
    -0
      controller/relay.go
  5. +6
    -0
      middleware/doubao_asset_binding_test.go
  6. +26
    -13
      relay/channel/task/doubao/adaptor.go
  7. +95
    -3
      relay/channel/task/doubao/adaptor_test.go
  8. +1
    -0
      service/asset_adapter.go
  9. +200
    -0
      service/asset_doubao.go
  10. +93
    -0
      service/asset_doubao_test.go
  11. +21
    -2
      service/asset_resolver.go
  12. +14
    -8
      service/chinamobile_user_asset_group.go
  13. +10
    -10
      service/chinamobile_user_asset_group_test.go
  14. +53
    -0
      service/doubao_user_asset_group.go
  15. +10
    -0
      service/video_asset_channel.go
  16. +20
    -9
      web/src/components/table/channels/modals/EditChannelModal.jsx

+ 12
- 2
controller/channel.go Bestand weergeven

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


+ 6
- 0
controller/channel_test_tianyiyun_test.go Bestand weergeven

@@ -33,4 +33,10 @@ func TestRequiredTaskChannelTypeForTianyiYunSeedanceModelUsesAllowedFamily(t *te
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,
)
}

+ 15
- 7
controller/doubao_asset.go Bestand weergeven

@@ -107,21 +107,29 @@ func DoubaoAssetProxy(c *gin.Context) {
Body: body,
RawBody: rawBody,
}
if service.IsChinaMobileAssetChannel(channel) {
if service.IsChinaMobileAssetChannel(channel) || service.IsDoubaoVideoAssetChannel(channel) {
if service.IsAssetGroupOperation(action.Operation) {
assetProxyError(c, http.StatusForbidden, service.AssetErrorOperationNotSupported, "China Mobile asset group APIs are managed by the platform")
assetProxyError(c, http.StatusForbidden, service.AssetErrorOperationNotSupported, "asset group APIs are managed by the platform")
return
}
groupID, assetErr := service.GetOrCreateChinaMobileUserAssetGroup(c.Request.Context(), userID, channel, func(ctx context.Context, userID int, channel *model.Channel) (string, *service.AssetError) {
return service.CreateChinaMobileUserAssetGroup(ctx, userID, adapter, channel)
})
var createGroup service.ManagedAssetGroupCreator
if service.IsChinaMobileAssetChannel(channel) {
createGroup = func(ctx context.Context, userID int, ch *model.Channel) (string, *service.AssetError) {
return service.CreateChinaMobileUserAssetGroup(ctx, userID, adapter, ch)
}
} else {
createGroup = func(ctx context.Context, userID int, ch *model.Channel) (string, *service.AssetError) {
return service.CreateDoubaoVideoUserAssetGroup(ctx, userID, adapter, ch)
}
}
groupID, assetErr := service.GetOrCreateUserAssetGroup(c.Request.Context(), userID, channel, createGroup)
if assetErr != nil {
assetProxyError(c, assetErr.HTTPStatus, assetErr.Type, assetErr.Message)
return
}
service.ScopeChinaMobileAssetRequest(&assetRequest, groupID)
service.ScopeManagedAssetRequest(&assetRequest, groupID)
if action.Operation == service.AssetOperationAssetGet || action.Operation == service.AssetOperationAssetUpdate || action.Operation == service.AssetOperationAssetDelete {
if assetErr := service.RequireChinaMobileAssetOwnership(c.Request.Context(), adapter, channel, assetRequest, groupID); assetErr != nil {
if assetErr := service.RequireManagedAssetOwnership(c.Request.Context(), adapter, channel, assetRequest, groupID); assetErr != nil {
assetProxyError(c, assetErr.HTTPStatus, assetErr.Type, assetErr.Message)
return
}


+ 1
- 0
controller/relay.go Bestand weergeven

@@ -367,6 +367,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 service.VideoAssetChannelTypesForFamily(service.VideoAssetFamilySeedance)
}
if isKlingAipingNativePath(c.Request.URL.Path) {


+ 6
- 0
middleware/doubao_asset_binding_test.go Bestand weergeven

@@ -216,6 +216,12 @@ func TestSeedanceTasksUseAllowedFamilyTypesInsteadOfModelPrefix(t *testing.T) {
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) {


+ 26
- 13
relay/channel/task/doubao/adaptor.go Bestand weergeven

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"strconv"
"strings"
"time"

"github.com/QuantumNous/new-api/common"
@@ -70,24 +71,24 @@ type responseTask struct {
Model string `json:"model"`
Status string `json:"status"`
Content struct {
VideoURL string `json:"video_url"`
VideoURL string `json:"videoUrl"`
} `json:"content"`
Seed int `json:"seed"`
Resolution string `json:"resolution"`
Duration int `json:"duration"`
Ratio string `json:"ratio"`
FramesPerSecond int `json:"framespersecond"`
ServiceTier string `json:"service_tier"`
FramesPerSecond int `json:"framesPerSecond"`
ServiceTier string `json:"serviceTier"`
Usage struct {
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
} `json:"usage"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}

// ============================
@@ -109,7 +110,14 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {

// ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
// Accept only POST /v1/video/generations as "generate" action.
// The native /api/v3/contents/generations/tasks path pre-parses the
// Volcengine-native body and stores it in the context; reuse it instead of
// re-parsing the body as TaskSubmitReq (whose prompt validation would fail,
// since the native prompt lives inside content[].text).
if _, err := relaycommon.GetTaskRequest(c); err == nil {
info.Action = constant.TaskActionGenerate
return nil
}
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
}

@@ -245,11 +253,16 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
r.Duration = lo.ToPtr(dto.IntValue(sec))
}

r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" })
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
// An explicit prompt replaces any text item from metadata. An empty prompt
// only happens on the native /api/v3 path, where the prompt already lives
// in a content text item that must be preserved as-is.
if strings.TrimSpace(req.Prompt) != "" {
r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" })
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
}

return &r, nil
}


+ 95
- 3
relay/channel/task/doubao/adaptor_test.go Bestand weergeven

@@ -13,8 +13,8 @@ func TestParseTaskResult_SuccessWithUsage(t *testing.T) {
adaptor := &TaskAdaptor{}
taskInfo, err := adaptor.ParseTaskResult([]byte(`{
"status":"succeeded",
"content":{"video_url":"https://example.test/video.mp4"},
"usage":{"completion_tokens":12,"total_tokens":3456}
"content":{"videoUrl":"https://example.test/video.mp4"},
"usage":{"completionTokens":12,"totalTokens":3456}
}`))

require.NoError(t, err)
@@ -28,7 +28,7 @@ func TestParseTaskResult_SuccessWithoutUsage(t *testing.T) {
adaptor := &TaskAdaptor{}
taskInfo, err := adaptor.ParseTaskResult([]byte(`{
"status":"succeeded",
"content":{"video_url":"https://example.test/video.mp4"}
"content":{"videoUrl":"https://example.test/video.mp4"}
}`))

require.NoError(t, err)
@@ -145,6 +145,66 @@ func TestConvertToRequestPayload_PromptAppendedAfterMetadataAndReplacesMetadataT
require.Equal(t, "current prompt", payload.Content[1].Text)
}

// Native /api/v3/contents/generations/tasks requests store the prompt inside a
// content text item while req.Prompt stays empty; such text items must survive.
func TestConvertToRequestPayload_EmptyPromptKeepsNativeContentText(t *testing.T) {
adaptor := &TaskAdaptor{}
req := &relaycommon.TaskSubmitReq{
Model: "doubao-seedance-2-0-260128",
Metadata: map[string]interface{}{
"content": []interface{}{
map[string]interface{}{
"type": "text",
"text": "一只猫在打哈欠",
},
map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": "https://example.test/cat.png",
},
},
},
"resolution": "1080p",
"ratio": "16:9",
},
}

payload, err := adaptor.convertToRequestPayload(req)

require.NoError(t, err)
require.Len(t, payload.Content, 2)
require.Equal(t, "text", payload.Content[0].Type)
require.Equal(t, "一只猫在打哈欠", payload.Content[0].Text)
require.Equal(t, "image_url", payload.Content[1].Type)
require.Equal(t, "https://example.test/cat.png", payload.Content[1].ImageURL.URL)
require.Equal(t, "1080p", payload.Resolution)
require.Equal(t, "16:9", payload.Ratio)
}

// Image-to-video without any text item must not produce an empty text item.
func TestConvertToRequestPayload_EmptyPromptWithoutTextContentAppendsNothing(t *testing.T) {
adaptor := &TaskAdaptor{}
req := &relaycommon.TaskSubmitReq{
Model: "doubao-seedance-2-0-260128",
Metadata: map[string]interface{}{
"content": []interface{}{
map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": "https://example.test/cat.png",
},
},
},
},
}

payload, err := adaptor.convertToRequestPayload(req)

require.NoError(t, err)
require.Len(t, payload.Content, 1)
require.Equal(t, "image_url", payload.Content[0].Type)
}

func TestParseTaskResult_FailedUsesUpstreamErrorMessage(t *testing.T) {
adaptor := &TaskAdaptor{}
taskInfo, err := adaptor.ParseTaskResult([]byte(`{
@@ -181,6 +241,38 @@ func TestConvertToOpenAIVideo_FailedUsesUpstreamError(t *testing.T) {
require.Contains(t, string(data), `"code":"InvalidParameter"`)
}

// Parses the exact Volcengine response shape observed in production:
// camelCase videoUrl / usage tokens, not snake_case.
func TestParseTaskResult_RealVolcengineResponse(t *testing.T) {
adaptor := &TaskAdaptor{}
taskInfo, err := adaptor.ParseTaskResult([]byte(`{
"id":"cgt-20260819170015-glgnd",
"seed":46303,
"draft":false,
"model":"doubao-seedance-2-0-260128",
"ratio":"16:9",
"usage":{"totalTokens":216900,"completionTokens":216900},
"status":"succeeded",
"content":{"videoUrl":"https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/output.mp4"},
"duration":10,
"priority":0,
"createdAt":1787130015,
"updatedAt":1787130344,
"resolution":"720p",
"serviceTier":"default",
"outputFormat":"mp4",
"generateAudio":false,
"framesPerSecond":24,
"executionExpiresAfter":172800
}`))

require.NoError(t, err)
require.Equal(t, string(model.TaskStatusSuccess), taskInfo.Status)
require.Equal(t, "https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/output.mp4", taskInfo.Url)
require.Equal(t, 216900, taskInfo.CompletionTokens)
require.Equal(t, 216900, taskInfo.TotalTokens)
}

func TestGetModelList_IncludesSeedance20Models(t *testing.T) {
adaptor := &TaskAdaptor{}
models := adaptor.GetModelList()


+ 1
- 0
service/asset_adapter.go Bestand weergeven

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


+ 200
- 0
service/asset_doubao.go Bestand weergeven

@@ -0,0 +1,200 @@
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)
}
// The body is always serialized from req.Body so platform-side rewrites
// (managed group scoping, ownership checks) take effect; RawBody is
// intentionally not used here. The V4 signature covers the sent payload.
body, err := common.Marshal(req.Body)
if err != nil {
return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError)
}
if len(body) == 0 {
body = []byte("{}")
}
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(body))
if err != nil {
return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError)
}
signDoubaoVideoAssetRequest(httpReq, credential.AccessKey, credential.SecretKey, body, 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)
}

+ 93
- 0
service/asset_doubao_test.go Bestand weergeven

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

+ 21
- 2
service/asset_resolver.go Bestand weergeven

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


+ 14
- 8
service/chinamobile_user_asset_group.go Bestand weergeven

@@ -11,7 +11,9 @@ import (
"github.com/QuantumNous/new-api/model"
)

type chinaMobileAssetGroupCreator func(context.Context, int, *model.Channel) (string, *AssetError)
// ManagedAssetGroupCreator creates an upstream asset group for a user on a
// channel; implementations differ per upstream (China Mobile, DoubaoVideo).
type ManagedAssetGroupCreator func(context.Context, int, *model.Channel) (string, *AssetError)

func IsChinaMobileAssetChannel(channel *model.Channel) bool {
return channel != nil && channel.Type == constant.ChannelTypeChinaMobileSeedance
@@ -21,9 +23,13 @@ func IsAssetGroupOperation(operation AssetOperation) bool {
return strings.HasPrefix(string(operation), "asset_group.")
}

func GetOrCreateChinaMobileUserAssetGroup(ctx context.Context, userId int, channel *model.Channel, createGroup chinaMobileAssetGroupCreator) (string, *AssetError) {
// GetOrCreateUserAssetGroup returns the platform-managed upstream asset group
// for (user, channel). Shared by managed channels (China Mobile, DoubaoVideo):
// the first request creates the upstream group via the provided creator and
// persists the mapping; concurrent first requests reconcile via re-read.
func GetOrCreateUserAssetGroup(ctx context.Context, userId int, channel *model.Channel, createGroup ManagedAssetGroupCreator) (string, *AssetError) {
if channel == nil {
return "", newAssetError(AssetErrorServer, "China Mobile asset channel is required", http.StatusInternalServerError)
return "", newAssetError(AssetErrorServer, "managed asset channel is required", http.StatusInternalServerError)
}
binding, err := model.GetUserAssetGroup(userId, channel.Id)
if err != nil {
@@ -38,7 +44,7 @@ func GetOrCreateChinaMobileUserAssetGroup(ctx context.Context, userId int, chann
return "", assetErr
}
if strings.TrimSpace(groupId) == "" {
return "", newAssetError(AssetErrorUpstream, "China Mobile asset group creation returned an empty group ID", http.StatusBadGateway)
return "", newAssetError(AssetErrorUpstream, "asset group creation returned an empty group ID", http.StatusBadGateway)
}
if err := model.CreateUserAssetGroup(userId, channel.Id, groupId); err == nil {
return groupId, nil
@@ -52,7 +58,7 @@ func GetOrCreateChinaMobileUserAssetGroup(ctx context.Context, userId int, chann
if readErr != nil {
return "", newAssetError(AssetErrorServer, readErr.Error(), http.StatusInternalServerError)
}
return "", newAssetError(AssetErrorServer, "failed to persist China Mobile user asset group", http.StatusInternalServerError)
return "", newAssetError(AssetErrorServer, "failed to persist user asset group", http.StatusInternalServerError)
}

func CreateChinaMobileUserAssetGroup(ctx context.Context, userId int, adapter AssetAdapter, channel *model.Channel) (string, *AssetError) {
@@ -82,7 +88,7 @@ func CreateChinaMobileUserAssetGroup(ctx context.Context, userId int, adapter As
return strings.TrimSpace(payload.Result.GroupId), nil
}

func ScopeChinaMobileAssetRequest(req *AssetRequest, groupId string) {
func ScopeManagedAssetRequest(req *AssetRequest, groupId string) {
switch req.Action.Operation {
case AssetOperationAssetCreate:
req.Body["GroupId"] = groupId
@@ -96,7 +102,7 @@ func ScopeChinaMobileAssetRequest(req *AssetRequest, groupId string) {
}
}

func RequireChinaMobileAssetOwnership(ctx context.Context, adapter AssetAdapter, channel *model.Channel, request AssetRequest, groupId string) *AssetError {
func RequireManagedAssetOwnership(ctx context.Context, adapter AssetAdapter, channel *model.Channel, request AssetRequest, groupId string) *AssetError {
spec, ok := ParseAssetAction("GetAsset")
if !ok {
return newAssetError(AssetErrorServer, "GetAsset action is not registered", http.StatusInternalServerError)
@@ -119,7 +125,7 @@ func RequireChinaMobileAssetOwnership(ctx context.Context, adapter AssetAdapter,
} `json:"Result"`
}
if err := common.Unmarshal(resp.Body, &payload); err != nil {
return newAssetError(AssetErrorUpstream, fmt.Sprintf("invalid China Mobile asset response: %v", err), http.StatusBadGateway)
return newAssetError(AssetErrorUpstream, fmt.Sprintf("invalid asset response: %v", err), http.StatusBadGateway)
}
if strings.TrimSpace(payload.Result.GroupId) != groupId {
return newAssetError(AssetErrorNotFound, "asset not found", http.StatusNotFound)


+ 10
- 10
service/chinamobile_user_asset_group_test.go Bestand weergeven

@@ -41,7 +41,7 @@ func chinaMobileAssetAction(t *testing.T, action string) AssetActionSpec {
return spec
}

func TestGetOrCreateChinaMobileUserAssetGroupCreatesThenReuses(t *testing.T) {
func TestGetOrCreateUserAssetGroupCreatesThenReuses(t *testing.T) {
setupChinaMobileUserAssetGroupDB(t)
channel := &model.Channel{Id: 101, Type: constant.ChannelTypeChinaMobileSeedance}
createCalls := 0
@@ -50,17 +50,17 @@ func TestGetOrCreateChinaMobileUserAssetGroupCreatesThenReuses(t *testing.T) {
return "group-1", nil
}

groupID, assetErr := GetOrCreateChinaMobileUserAssetGroup(context.Background(), 10, channel, creator)
groupID, assetErr := GetOrCreateUserAssetGroup(context.Background(), 10, channel, creator)
require.Nil(t, assetErr)
assert.Equal(t, "group-1", groupID)

again, assetErr := GetOrCreateChinaMobileUserAssetGroup(context.Background(), 10, channel, creator)
again, assetErr := GetOrCreateUserAssetGroup(context.Background(), 10, channel, creator)
require.Nil(t, assetErr)
assert.Equal(t, "group-1", again)
assert.Equal(t, 1, createCalls)
}

func TestGetOrCreateChinaMobileUserAssetGroupConcurrentRequestsReuseBinding(t *testing.T) {
func TestGetOrCreateUserAssetGroupConcurrentRequestsReuseBinding(t *testing.T) {
setupChinaMobileUserAssetGroupDB(t)
channel := &model.Channel{Id: 102, Type: constant.ChannelTypeChinaMobileSeedance}
creator := func(context.Context, int, *model.Channel) (string, *AssetError) {
@@ -75,7 +75,7 @@ func TestGetOrCreateChinaMobileUserAssetGroupConcurrentRequestsReuseBinding(t *t
wg.Add(1)
go func() {
defer wg.Done()
groupID, assetErr := GetOrCreateChinaMobileUserAssetGroup(context.Background(), 10, channel, creator)
groupID, assetErr := GetOrCreateUserAssetGroup(context.Background(), 10, channel, creator)
results <- groupID
errs <- assetErr
}()
@@ -96,21 +96,21 @@ func TestGetOrCreateChinaMobileUserAssetGroupConcurrentRequestsReuseBinding(t *t
assert.Equal(t, "group-concurrent", binding.GroupId)
}

func TestScopeChinaMobileAssetRequestOverwritesClientGroup(t *testing.T) {
func TestScopeManagedAssetRequestOverwritesClientGroup(t *testing.T) {
create := AssetRequest{Action: chinaMobileAssetAction(t, "CreateAsset"), Body: map[string]any{"GroupId": "forged"}}
ScopeChinaMobileAssetRequest(&create, "owned")
ScopeManagedAssetRequest(&create, "owned")
assert.Equal(t, "owned", create.Body["GroupId"])

list := AssetRequest{Action: chinaMobileAssetAction(t, "ListAssets"), Body: map[string]any{"Filter": map[string]any{"GroupIds": []any{"forged"}}}}
ScopeChinaMobileAssetRequest(&list, "owned")
ScopeManagedAssetRequest(&list, "owned")
assert.Equal(t, []string{"owned"}, list.Body["Filter"].(map[string]any)["GroupIds"])
}

func TestRequireChinaMobileAssetOwnershipHidesMismatchedGroup(t *testing.T) {
func TestRequireManagedAssetOwnershipHidesMismatchedGroup(t *testing.T) {
adapter := &recordingAssetAdapter{getAssetGroupID: "other"}
request := AssetRequest{Action: chinaMobileAssetAction(t, "DeleteAsset"), Body: map[string]any{"Id": "asset-1"}}

assetErr := RequireChinaMobileAssetOwnership(context.Background(), adapter, &model.Channel{}, request, "owned")
assetErr := RequireManagedAssetOwnership(context.Background(), adapter, &model.Channel{}, request, "owned")

require.NotNil(t, assetErr)
assert.Equal(t, AssetErrorNotFound, assetErr.Type)


+ 53
- 0
service/doubao_user_asset_group.go Bestand weergeven

@@ -0,0 +1,53 @@
package service

import (
"context"
"fmt"
"net/http"
"strings"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
)

// IsDoubaoVideoAssetChannel reports whether the channel is an official
// Volcengine (DoubaoVideo) channel whose assets are managed by the platform.
func IsDoubaoVideoAssetChannel(channel *model.Channel) bool {
return channel != nil && channel.Type == constant.ChannelTypeDoubaoVideo
}

// CreateDoubaoVideoUserAssetGroup creates a per-user upstream asset group.
// The Ark API derives the group type internally; passing GroupType is
// rejected (InvalidParameter), so only the name is sent. The response
// carries the new group id in Result.Id (Result.GroupId is tolerated too).
func CreateDoubaoVideoUserAssetGroup(ctx context.Context, userId int, adapter AssetAdapter, channel *model.Channel) (string, *AssetError) {
spec, ok := ParseAssetAction("CreateAssetGroup")
if !ok {
return "", newAssetError(AssetErrorServer, "CreateAssetGroup action is not registered", http.StatusInternalServerError)
}
resp, assetErr := adapter.DoAssetRequest(ctx, channel, AssetRequest{
Action: spec,
Version: "2024-01-01",
Body: map[string]any{
"Name": fmt.Sprintf("new-api-user-%d-channel-%d", userId, channel.Id),
},
})
if assetErr != nil {
return "", assetErr
}
var payload struct {
Result struct {
Id string `json:"Id"`
GroupId string `json:"GroupId"`
} `json:"Result"`
}
if err := common.Unmarshal(resp.Body, &payload); err != nil {
return "", newAssetError(AssetErrorUpstream, fmt.Sprintf("invalid Volcengine asset group response: %v", err), http.StatusBadGateway)
}
groupId := strings.TrimSpace(payload.Result.Id)
if groupId == "" {
groupId = strings.TrimSpace(payload.Result.GroupId)
}
return groupId, nil
}

+ 10
- 0
service/video_asset_channel.go Bestand weergeven

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


+ 20
- 9
web/src/components/table/channels/modals/EditChannelModal.jsx Bestand weergeven

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



Laden…
Annuleren
Opslaan