diff --git a/controller/doubao_asset.go b/controller/doubao_asset.go index 224e395..9efc2b4 100644 --- a/controller/doubao_asset.go +++ b/controller/doubao_asset.go @@ -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 } diff --git a/service/asset_doubao.go b/service/asset_doubao.go index e5ff798..7d20e95 100644 --- a/service/asset_doubao.go +++ b/service/asset_doubao.go @@ -72,16 +72,26 @@ func (a *DoubaoVideoAssetAdapter) DoAssetRequest(ctx context.Context, channel *m 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(req.RawBody)) + 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, req.RawBody, rawQuery) + signDoubaoVideoAssetRequest(httpReq, credential.AccessKey, credential.SecretKey, body, rawQuery) if projectCode := strings.TrimSpace(credential.PoolID); projectCode != "" { httpReq.Header.Set("X-Project-Code", projectCode) } diff --git a/service/chinamobile_user_asset_group.go b/service/chinamobile_user_asset_group.go index a5519c2..4445aa0 100644 --- a/service/chinamobile_user_asset_group.go +++ b/service/chinamobile_user_asset_group.go @@ -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) diff --git a/service/chinamobile_user_asset_group_test.go b/service/chinamobile_user_asset_group_test.go index ca5f514..3af0e95 100644 --- a/service/chinamobile_user_asset_group_test.go +++ b/service/chinamobile_user_asset_group_test.go @@ -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) diff --git a/service/doubao_user_asset_group.go b/service/doubao_user_asset_group.go new file mode 100644 index 0000000..a87d703 --- /dev/null +++ b/service/doubao_user_asset_group.go @@ -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 +}