Преглед на файлове

feat(volcengine): manage per-user asset groups on DoubaoVideo channels

Bring official Volcengine channels in line with the China Mobile asset
isolation model: the platform owns the upstream asset group lifecycle
per (user, channel). asset_group.* APIs are forbidden for clients;
CreateAsset is scoped to the user's managed group (client GroupId is
overwritten), ListAssets is scoped with Filter.GroupIds to the managed
group, and Get/Update/Delete verify ownership via GetAsset before
forwarding, returning not-found for foreign assets.

Generalize the shared managed-group plumbing (GetOrCreateUserAssetGroup,
ScopeManagedAssetRequest, RequireManagedAssetOwnership) and add the
DoubaoVideo-specific group creator (Ark CreateAssetGroup returns the id
in Result.Id; GroupType must be omitted). The DoubaoVideo asset adapter
now serializes the request body from req.Body so platform rewrites take
effect (RawBody was bypassing the scoping) and the V4 signature always
covers the actual payload.

Verified end-to-end: group ops rejected, forged GroupId overwritten by
the auto-created managed group, ListAssets returns only the managed
group, foreign assets hidden from get/delete.

Co-Authored-By: ZCode <noreply@anthropic.com>
master
fengsilin преди 6 дни
родител
ревизия
6f6c68787f
променени са 5 файла, в които са добавени 104 реда и са изтрити 27 реда
  1. +15
    -7
      controller/doubao_asset.go
  2. +12
    -2
      service/asset_doubao.go
  3. +14
    -8
      service/chinamobile_user_asset_group.go
  4. +10
    -10
      service/chinamobile_user_asset_group_test.go
  5. +53
    -0
      service/doubao_user_asset_group.go

+ 15
- 7
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
}


+ 12
- 2
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)
}


+ 14
- 8
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)


+ 10
- 10
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)


+ 53
- 0
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
}

Зареждане…
Отказ
Запис