Просмотр исходного кода

feat(chinamobile): add channel asset credentials

Co-Authored-By: Codex <noreply@anthropic.com>
master
fengsilin 3 недель назад
Родитель
Сommit
cd35e66b33
12 измененных файлов: 658 добавлений и 47 удалений
  1. +101
    -9
      controller/channel.go
  2. +50
    -0
      controller/channel_asset_credential_test.go
  3. +7
    -1
      controller/doubao_asset_real_e2e_test.go
  4. +86
    -16
      model/channel.go
  5. +99
    -0
      model/channel_asset_credential.go
  6. +132
    -0
      model/channel_asset_credential_test.go
  7. +2
    -0
      model/main.go
  8. +25
    -6
      service/asset_chinamobile.go
  9. +87
    -11
      service/asset_chinamobile_test.go
  10. +4
    -1
      setup_test.sql
  11. +3
    -2
      setup_test_env.ps1
  12. +62
    -1
      web/src/components/table/channels/modals/EditChannelModal.jsx

+ 101
- 9
controller/channel.go Просмотреть файл

@@ -19,6 +19,7 @@ import (
"github.com/QuantumNous/new-api/service"

"github.com/gin-gonic/gin"
"gorm.io/gorm"
)

type OpenAIModel struct {
@@ -68,6 +69,30 @@ func clearChannelInfo(channel *model.Channel) {
}
}

func attachChannelAssetCredentialSummaries(channels []*model.Channel) error {
ids := make([]int, 0)
for _, channel := range channels {
if channel != nil && channel.Type == constant.ChannelTypeChinaMobileSeedance {
ids = append(ids, channel.Id)
}
}
summaries, err := model.GetChannelAssetCredentialSummaries(ids)
if err != nil {
return err
}
for _, channel := range channels {
if channel == nil || channel.Type != constant.ChannelTypeChinaMobileSeedance {
continue
}
summary, ok := summaries[channel.Id]
channel.AssetCredentialConfigured = ok
if ok {
channel.AssetCredentialPoolID = summary.PoolID
}
}
return nil
}

func GetAllChannels(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
channelData := make([]*model.Channel, 0)
@@ -144,6 +169,10 @@ func GetAllChannels(c *gin.Context) {
}
}

if err := attachChannelAssetCredentialSummaries(channelData); err != nil {
common.ApiError(c, err)
return
}
for _, datum := range channelData {
clearChannelInfo(datum)
}
@@ -482,6 +511,10 @@ func SearchChannels(c *gin.Context) {

pagedData := channelData[startIdx:endIdx]

if err := attachChannelAssetCredentialSummaries(pagedData); err != nil {
common.ApiError(c, err)
return
}
for _, datum := range pagedData {
clearChannelInfo(datum)
}
@@ -510,6 +543,10 @@ func GetChannel(c *gin.Context) {
return
}
if channel != nil {
if err := attachChannelAssetCredentialSummaries([]*model.Channel{channel}); err != nil {
common.ApiError(c, err)
return
}
clearChannelInfo(channel)
}
c.JSON(http.StatusOK, gin.H{
@@ -675,10 +712,29 @@ func RefreshCodexChannelCredential(c *gin.Context) {
}

type AddChannelRequest struct {
Mode string `json:"mode"`
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
BatchAddSetKeyPrefix2Name bool `json:"batch_add_set_key_prefix_2_name"`
Channel *model.Channel `json:"channel"`
Mode string `json:"mode"`
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
BatchAddSetKeyPrefix2Name bool `json:"batch_add_set_key_prefix_2_name"`
Channel *model.Channel `json:"channel"`
AssetCredential *ChannelAssetCredentialInput `json:"asset_credential"`
}

type ChannelAssetCredentialInput struct {
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
PoolID string `json:"pool_id"`
}

func channelAssetCredentialFromInput(channelType int, input *ChannelAssetCredentialInput) (*model.ChannelAssetCredential, error) {
if channelType != constant.ChannelTypeChinaMobileSeedance || input == nil {
return nil, nil
}
ak := strings.TrimSpace(input.AccessKey)
sk := strings.TrimSpace(input.SecretKey)
if ak == "" || sk == "" {
return nil, errors.New("移动云素材 AccessKey 和 SecretKey 必须同时填写")
}
return &model.ChannelAssetCredential{AccessKey: ak, SecretKey: sk, PoolID: strings.TrimSpace(input.PoolID)}, nil
}

func getVertexArrayKeys(keys string) ([]string, error) {
@@ -729,6 +785,15 @@ func AddChannel(c *gin.Context) {
})
return
}
credential, err := channelAssetCredentialFromInput(addChannelRequest.Channel.Type, addChannelRequest.AssetCredential)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
if credential != nil && addChannelRequest.Mode == "batch" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "移动云素材凭证不支持批量添加渠道"})
return
}

addChannelRequest.Channel.CreatedTime = common.GetTimestamp()
keys := make([]string, 0)
@@ -800,7 +865,15 @@ func AddChannel(c *gin.Context) {
}
channels = append(channels, *localChannel)
}
err = model.BatchInsertChannels(channels)
if credential != nil {
if len(channels) != 1 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "移动云素材凭证仅支持创建一个渠道"})
return
}
err = model.InsertChannelWithAssetCredential(&channels[0], credential)
} else {
err = model.BatchInsertChannels(channels)
}
if err != nil {
common.ApiError(c, err)
return
@@ -985,8 +1058,9 @@ func DeleteChannelBatch(c *gin.Context) {

type PatchChannel struct {
model.Channel
MultiKeyMode *string `json:"multi_key_mode"`
KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加
MultiKeyMode *string `json:"multi_key_mode"`
KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加
AssetCredential *ChannelAssetCredentialInput `json:"asset_credential"`
}

func UpdateChannel(c *gin.Context) {
@@ -1103,7 +1177,22 @@ func UpdateChannel(c *gin.Context) {
// 覆盖模式:直接使用新密钥(默认行为,不需要特殊处理)
}
}
err = channel.Update()
credential, err := channelAssetCredentialFromInput(channel.Type, channel.AssetCredential)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
if credential != nil {
err = model.DB.Transaction(func(tx *gorm.DB) error {
if err := channel.UpdateWithTx(tx); err != nil {
return err
}
credential.ChannelId = channel.Id
return model.UpsertChannelAssetCredentialWithTx(tx, credential)
})
} else {
err = channel.Update()
}
if err != nil {
common.ApiError(c, err)
return
@@ -1112,6 +1201,10 @@ func UpdateChannel(c *gin.Context) {
service.ResetProxyClientCache()
channel.Key = ""
clearChannelInfo(&channel.Channel)
if err := attachChannelAssetCredentialSummaries([]*model.Channel{&channel.Channel}); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -2105,4 +2198,3 @@ func OllamaVersion(c *gin.Context) {
},
})
}


+ 50
- 0
controller/channel_asset_credential_test.go Просмотреть файл

@@ -0,0 +1,50 @@
package controller

import (
"testing"

"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)

func setupChannelAssetCredentialControllerDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:controller_channel_asset_credentials?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
originalDB := model.DB
model.DB = db
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.ChannelAssetCredential{}))
t.Cleanup(func() {
model.DB = originalDB
require.NoError(t, sqlDB.Close())
})
return db
}

func TestAttachChannelAssetCredentialSummariesDoesNotExposeSecrets(t *testing.T) {
db := setupChannelAssetCredentialControllerDB(t)
channel := &model.Channel{Id: 61, Type: constant.ChannelTypeChinaMobileSeedance, Key: "video-key", Name: "channel"}
require.NoError(t, db.Create(channel).Error)
require.NoError(t, model.UpsertChannelAssetCredential(&model.ChannelAssetCredential{
ChannelId: 61,
AccessKey: "ak-secret",
SecretKey: "sk-secret",
PoolID: "pool-61",
}))

require.NoError(t, attachChannelAssetCredentialSummaries([]*model.Channel{channel}))
assert.True(t, channel.AssetCredentialConfigured)
assert.Equal(t, "pool-61", channel.AssetCredentialPoolID)
assert.NotContains(t, channel.Key, "ak-secret")
assert.NotContains(t, channel.Key, "sk-secret")
}

+ 7
- 1
controller/doubao_asset_real_e2e_test.go Просмотреть файл

@@ -32,6 +32,12 @@ func TestChinaMobileAssetControllerWithRealUpstream(t *testing.T) {
}
db := setupRealAssetE2EDB(t)
createDoubaoAssetProxyChannel(t, db, 7101, constant.ChannelTypeChinaMobileSeedance, "default", "video-generation-key", common.ChannelStatusEnabled)
require.NoError(t, model.UpsertChannelAssetCredential(&model.ChannelAssetCredential{
ChannelId: 7101,
AccessKey: ak,
SecretKey: sk,
PoolID: poolID,
}))
router := setupRealAssetE2ERouter()
suffix := time.Now().Format("20060102150405")
groupName := "new-api-e2e-" + suffix
@@ -119,7 +125,7 @@ func setupRealAssetE2EDB(t *testing.T) *gorm.DB {
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.UserAssetChannel{}))
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.UserAssetChannel{}, &model.ChannelAssetCredential{}))

t.Cleanup(func() {
_ = sqlDB.Close()


+ 86
- 16
model/channel.go Просмотреть файл

@@ -56,6 +56,10 @@ type Channel struct {

// cache info
Keys []string `json:"-" gorm:"-"`

// 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:"-"`
}

type ChannelInfo struct {
@@ -283,7 +287,6 @@ func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Chan
return channels, err
}


func GetChannelsByTag(tag string, idSort bool, selectAll bool) ([]*Channel, error) {
var channels []*Channel
order := "priority desc"
@@ -403,7 +406,7 @@ func BatchDeleteChannels(ids []int) error {
return tx.Error
}
for _, chunk := range lo.Chunk(ids, 200) {
if err := tx.Where("id in (?)", chunk).Delete(&Channel{}).Error; err != nil {
if err := DeleteChannelAssetCredentialsWithTx(tx, chunk); err != nil {
tx.Rollback()
return err
}
@@ -411,6 +414,10 @@ func BatchDeleteChannels(ids []int) error {
tx.Rollback()
return err
}
if err := tx.Where("id in (?)", chunk).Delete(&Channel{}).Error; err != nil {
tx.Rollback()
return err
}
}
return tx.Commit().Error
}
@@ -465,6 +472,12 @@ func (channel *Channel) Insert() error {
}

func (channel *Channel) Update() error {
return DB.Transaction(func(tx *gorm.DB) error {
return channel.UpdateWithTx(tx)
})
}

func (channel *Channel) UpdateWithTx(tx *gorm.DB) error {
// If this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys
if channel.ChannelInfo.IsMultiKey {
var keyStr string
@@ -472,7 +485,8 @@ func (channel *Channel) Update() error {
keyStr = channel.Key
} else {
// If key is not provided, read the existing key from the database
if existing, err := GetChannelById(channel.Id, true); err == nil {
var existing Channel
if err := tx.First(&existing, "id = ?", channel.Id).Error; err == nil {
keyStr = existing.Key
}
}
@@ -503,16 +517,30 @@ func (channel *Channel) Update() error {
}
}
}
var err error
err = DB.Model(channel).Updates(channel).Error
err := tx.Model(channel).Updates(channel).Error
if err != nil {
return err
}
DB.Model(channel).First(channel, "id = ?", channel.Id)
err = channel.UpdateAbilities(nil)
if err = tx.First(channel, "id = ?", channel.Id).Error; err != nil {
return err
}
err = channel.UpdateAbilities(tx)
return err
}

func InsertChannelWithAssetCredential(channel *Channel, credential *ChannelAssetCredential) error {
return DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(channel).Error; err != nil {
return err
}
if err := channel.AddAbilities(tx); err != nil {
return err
}
credential.ChannelId = channel.Id
return UpsertChannelAssetCredentialWithTx(tx, credential)
})
}

func (channel *Channel) UpdateResponseTime(responseTime int64) {
err := DB.Model(channel).Select("response_time", "test_time").Updates(Channel{
TestTime: common.GetTimestamp(),
@@ -534,13 +562,19 @@ func (channel *Channel) UpdateBalance(balance float64) {
}

func (channel *Channel) Delete() error {
var err error
err = DB.Delete(channel).Error
if err != nil {
return DB.Transaction(func(tx *gorm.DB) error {
return channel.DeleteWithTx(tx)
})
}

func (channel *Channel) DeleteWithTx(tx *gorm.DB) error {
if err := DeleteChannelAssetCredentialWithTx(tx, channel.Id); err != nil {
return err
}
err = channel.DeleteAbilities()
return err
if err := tx.Where("channel_id = ?", channel.Id).Delete(&Ability{}).Error; err != nil {
return err
}
return tx.Delete(channel).Error
}

var channelStatusLock sync.Mutex
@@ -778,13 +812,49 @@ func updateChannelUsedQuota(id int, quota int) {
}

func DeleteChannelByStatus(status int64) (int64, error) {
result := DB.Where("status = ?", status).Delete(&Channel{})
return result.RowsAffected, result.Error
var ids []int
if err := DB.Model(&Channel{}).Where("status = ?", status).Pluck("id", &ids).Error; err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, nil
}
var rows int64
err := DB.Transaction(func(tx *gorm.DB) error {
if err := DeleteChannelAssetCredentialsWithTx(tx, ids); err != nil {
return err
}
if err := tx.Where("channel_id IN ?", ids).Delete(&Ability{}).Error; err != nil {
return err
}
result := tx.Where("id IN ?", ids).Delete(&Channel{})
rows = result.RowsAffected
return result.Error
})
return rows, err
}

func DeleteDisabledChannel() (int64, error) {
result := DB.Where("status = ? or status = ?", common.ChannelStatusAutoDisabled, common.ChannelStatusManuallyDisabled).Delete(&Channel{})
return result.RowsAffected, result.Error
var ids []int
if err := DB.Model(&Channel{}).Where("status = ? or status = ?", common.ChannelStatusAutoDisabled, common.ChannelStatusManuallyDisabled).Pluck("id", &ids).Error; err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, nil
}
var rows int64
err := DB.Transaction(func(tx *gorm.DB) error {
if err := DeleteChannelAssetCredentialsWithTx(tx, ids); err != nil {
return err
}
if err := tx.Where("channel_id IN ?", ids).Delete(&Ability{}).Error; err != nil {
return err
}
result := tx.Where("id IN ?", ids).Delete(&Channel{})
rows = result.RowsAffected
return result.Error
})
return rows, err
}

func GetPaginatedTags(offset int, limit int) ([]*string, error) {


+ 99
- 0
model/channel_asset_credential.go Просмотреть файл

@@ -0,0 +1,99 @@
package model

import (
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)

// ChannelAssetCredential stores the China Mobile asset credentials separately
// from the channel video-generation key.
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"`
CreatedAt int64 `json:"created_at" gorm:"bigint;not null"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint;not null"`
}

// ChannelAssetCredentialSummary is safe to expose in channel management APIs.
type ChannelAssetCredentialSummary struct {
ChannelId int
PoolID string
}

func GetChannelAssetCredential(channelID int) (*ChannelAssetCredential, error) {
var credential ChannelAssetCredential
err := DB.Where("channel_id = ?", channelID).First(&credential).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &credential, nil
}

func UpsertChannelAssetCredential(credential *ChannelAssetCredential) error {
return DB.Transaction(func(tx *gorm.DB) error {
return UpsertChannelAssetCredentialWithTx(tx, credential)
})
}

func UpsertChannelAssetCredentialWithTx(tx *gorm.DB, credential *ChannelAssetCredential) error {
now := common.GetTimestamp()
credential.CreatedAt = now
credential.UpdatedAt = now
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "channel_id"}},
DoUpdates: clause.Assignments(map[string]any{
"access_key": credential.AccessKey,
"secret_key": credential.SecretKey,
"pool_id": credential.PoolID,
"updated_at": credential.UpdatedAt,
}),
}).Create(credential).Error
}

func DeleteChannelAssetCredentialWithTx(tx *gorm.DB, channelID int) error {
return tx.Where("channel_id = ?", channelID).Delete(&ChannelAssetCredential{}).Error
}

func DeleteChannelAssetCredential(channelID int) error {
return DB.Transaction(func(tx *gorm.DB) error {
return DeleteChannelAssetCredentialWithTx(tx, channelID)
})
}

func DeleteChannelAssetCredentialsWithTx(tx *gorm.DB, channelIDs []int) error {
if len(channelIDs) == 0 {
return nil
}
return tx.Where("channel_id IN ?", channelIDs).Delete(&ChannelAssetCredential{}).Error
}

func DeleteChannelAssetCredentials(channelIDs []int) error {
return DB.Transaction(func(tx *gorm.DB) error {
return DeleteChannelAssetCredentialsWithTx(tx, channelIDs)
})
}

func GetChannelAssetCredentialSummaries(channelIDs []int) (map[int]ChannelAssetCredentialSummary, error) {
summaries := make(map[int]ChannelAssetCredentialSummary)
if len(channelIDs) == 0 {
return summaries, nil
}
var rows []ChannelAssetCredentialSummary
if err := DB.Model(&ChannelAssetCredential{}).
Select("channel_id", "pool_id").
Where("channel_id IN ?", channelIDs).
Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
summaries[row.ChannelId] = row
}
return summaries, nil
}

+ 132
- 0
model/channel_asset_credential_test.go Просмотреть файл

@@ -0,0 +1,132 @@
package model

import (
"testing"

"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)

func setupChannelAssetCredentialDB(t *testing.T) *gorm.DB {
t.Helper()

db, err := gorm.Open(sqlite.Open("file:channel_asset_credentials?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)

originalDB := DB
DB = db
t.Cleanup(func() {
DB = originalDB
require.NoError(t, sqlDB.Close())
})
return db
}

func TestChannelAssetCredentialUpsertKeepsOneCredentialPerChannel(t *testing.T) {
db := setupChannelAssetCredentialDB(t)
require.NoError(t, db.AutoMigrate(&ChannelAssetCredential{}))

require.NoError(t, UpsertChannelAssetCredential(&ChannelAssetCredential{
ChannelId: 61,
AccessKey: "ak-old",
SecretKey: "sk-old",
PoolID: "pool-old",
}))
require.NoError(t, UpsertChannelAssetCredential(&ChannelAssetCredential{
ChannelId: 61,
AccessKey: "ak-new",
SecretKey: "sk-new",
PoolID: "pool-new",
}))

credential, err := GetChannelAssetCredential(61)
require.NoError(t, err)
require.NotNil(t, credential)
assert.Equal(t, "ak-new", credential.AccessKey)
assert.Equal(t, "sk-new", credential.SecretKey)
assert.Equal(t, "pool-new", credential.PoolID)

var count int64
require.NoError(t, db.Model(&ChannelAssetCredential{}).Where("channel_id = ?", 61).Count(&count).Error)
assert.Equal(t, int64(1), count)
}

func TestChannelAssetCredentialSummariesDoNotContainSecrets(t *testing.T) {
db := setupChannelAssetCredentialDB(t)
require.NoError(t, db.AutoMigrate(&ChannelAssetCredential{}))
require.NoError(t, UpsertChannelAssetCredential(&ChannelAssetCredential{
ChannelId: 61,
AccessKey: "ak-secret",
SecretKey: "sk-secret",
PoolID: "pool-61",
}))

summaries, err := GetChannelAssetCredentialSummaries([]int{61, 62})
require.NoError(t, err)
require.Contains(t, summaries, 61)
assert.Equal(t, "pool-61", summaries[61].PoolID)
assert.NotContains(t, summaries, 62)
}

func TestChannelDeleteRemovesOnlyItsAssetCredential(t *testing.T) {
db := setupChannelAssetCredentialDB(t)
require.NoError(t, db.AutoMigrate(&Channel{}, &Ability{}, &ChannelAssetCredential{}))
for _, id := range []int{61, 62} {
require.NoError(t, db.Create(&Channel{Id: id, Key: "key", Name: "channel", Group: "default", Models: "model"}).Error)
require.NoError(t, UpsertChannelAssetCredential(&ChannelAssetCredential{ChannelId: id, AccessKey: "ak", SecretKey: "sk"}))
}

require.NoError(t, (&Channel{Id: 61}).Delete())
credential, err := GetChannelAssetCredential(61)
require.NoError(t, err)
assert.Nil(t, credential)
credential, err = GetChannelAssetCredential(62)
require.NoError(t, err)
assert.NotNil(t, credential)
}

func TestDeleteDisabledChannelRemovesAssetCredentials(t *testing.T) {
db := setupChannelAssetCredentialDB(t)
require.NoError(t, db.AutoMigrate(&Channel{}, &Ability{}, &ChannelAssetCredential{}))
require.NoError(t, db.Create(&Channel{Id: 61, Key: "key", Name: "disabled", Group: "default", Models: "model", Status: 2}).Error)
require.NoError(t, db.Create(&Channel{Id: 62, Key: "key", Name: "enabled", Group: "default", Models: "model", Status: 1}).Error)
for _, id := range []int{61, 62} {
require.NoError(t, UpsertChannelAssetCredential(&ChannelAssetCredential{ChannelId: id, AccessKey: "ak", SecretKey: "sk"}))
}

_, err := DeleteDisabledChannel()
require.NoError(t, err)
credential, err := GetChannelAssetCredential(61)
require.NoError(t, err)
assert.Nil(t, credential)
credential, err = GetChannelAssetCredential(62)
require.NoError(t, err)
assert.NotNil(t, credential)
}

func TestDeleteChannelByStatusRemovesAssetCredentials(t *testing.T) {
db := setupChannelAssetCredentialDB(t)
require.NoError(t, db.AutoMigrate(&Channel{}, &Ability{}, &ChannelAssetCredential{}))
require.NoError(t, db.Create(&Channel{Id: 61, Key: "key", Name: "disabled", Group: "default", Models: "model", Status: 2}).Error)
require.NoError(t, db.Create(&Channel{Id: 62, Key: "key", Name: "enabled", Group: "default", Models: "model", Status: 1}).Error)
for _, id := range []int{61, 62} {
require.NoError(t, UpsertChannelAssetCredential(&ChannelAssetCredential{ChannelId: id, AccessKey: "ak", SecretKey: "sk"}))
}

_, err := DeleteChannelByStatus(2)
require.NoError(t, err)
credential, err := GetChannelAssetCredential(61)
require.NoError(t, err)
assert.Nil(t, credential)
credential, err = GetChannelAssetCredential(62)
require.NoError(t, err)
assert.NotNil(t, credential)
}

+ 2
- 0
model/main.go Просмотреть файл

@@ -259,6 +259,7 @@ func migrateDB() error {

err := DB.AutoMigrate(
&Channel{},
&ChannelAssetCredential{},
&Token{},
&User{},
&PasskeyCredential{},
@@ -329,6 +330,7 @@ func migrateDBFast() error {
name string
}{
{&Channel{}, "Channel"},
{&ChannelAssetCredential{}, "ChannelAssetCredential"},
{&Token{}, "Token"},
{&User{}, "User"},
{&PasskeyCredential{}, "PasskeyCredential"},


+ 25
- 6
service/asset_chinamobile.go Просмотреть файл

@@ -62,7 +62,7 @@ func (a *ChinaMobileAssetAdapter) DoAssetRequest(ctx context.Context, channel *m
return nil, err
}

credential, err := chinaMobileAssetCredentialFromEnv()
credential, err := chinaMobileAssetCredentialFromChannel(channel)
if err != nil {
return nil, newAssetError(AssetErrorInvalidRequest, err.Error(), http.StatusBadRequest)
}
@@ -101,11 +101,30 @@ type chinaMobileAssetCredential struct {
PoolID string `json:"pool_id"`
}

func chinaMobileAssetCredentialFromEnv() (chinaMobileAssetCredential, error) {
func chinaMobileAssetCredentialFromChannel(channel *model.Channel) (chinaMobileAssetCredential, error) {
if channel == nil {
return chinaMobileAssetCredential{}, fmt.Errorf("China Mobile asset channel is required")
}
credential, err := model.GetChannelAssetCredential(channel.Id)
if err != nil {
return chinaMobileAssetCredential{}, err
}
if credential == nil {
legacyCredential, legacyErr := normalizeChinaMobileAssetCredential(chinaMobileAssetCredential{
AK: os.Getenv(chinaMobileAssetAKEnv),
SK: os.Getenv(chinaMobileAssetSKEnv),
PoolID: os.Getenv(chinaMobileAssetPoolIDEnv),
})
if legacyErr != nil {
return chinaMobileAssetCredential{}, fmt.Errorf("该移动云渠道未配置素材凭证")
}
common.SysLog(fmt.Sprintf("using legacy China Mobile asset credentials for channel %d; configure channel asset credentials before removing environment fallback", channel.Id))
return legacyCredential, nil
}
return normalizeChinaMobileAssetCredential(chinaMobileAssetCredential{
AK: os.Getenv(chinaMobileAssetAKEnv),
SK: os.Getenv(chinaMobileAssetSKEnv),
PoolID: os.Getenv(chinaMobileAssetPoolIDEnv),
AK: credential.AccessKey,
SK: credential.SecretKey,
PoolID: credential.PoolID,
})
}

@@ -114,7 +133,7 @@ func normalizeChinaMobileAssetCredential(credential chinaMobileAssetCredential)
credential.SK = strings.TrimSpace(credential.SK)
credential.PoolID = strings.TrimSpace(credential.PoolID)
if credential.AK == "" || credential.SK == "" {
return chinaMobileAssetCredential{}, fmt.Errorf("%s and %s are required for China Mobile asset library", chinaMobileAssetAKEnv, chinaMobileAssetSKEnv)
return chinaMobileAssetCredential{}, fmt.Errorf("China Mobile asset AccessKey and SecretKey are required")
}
if credential.PoolID == "" {
credential.PoolID = defaultChinaMobileAssetPoolID


+ 87
- 11
service/asset_chinamobile_test.go Просмотреть файл

@@ -8,10 +8,13 @@ import (
"time"

"github.com/QuantumNous/new-api/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
cmerrs "gitlab.ecloud.com/ecloud/ecloudsdkcore/errs"
cmmodel "gitlab.ecloud.com/ecloud/ecloudsdkmaas/model"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)

func TestChinaMobileAssetAdapterCreateAssetMapsRequestAndResponse(t *testing.T) {
@@ -49,10 +52,10 @@ func TestChinaMobileAssetAdapterCreateAssetMapsRequestAndResponse(t *testing.T)
assert.Contains(t, string(resp.Body), `"Result":"asset-1"`)
}

func TestChinaMobileAssetCredentialFromEnvDefaultsCenterPool(t *testing.T) {
func TestChinaMobileAssetCredentialFromChannelDefaultsCenterPool(t *testing.T) {
setChinaMobileAssetTestEnv(t, "")

credential, err := chinaMobileAssetCredentialFromEnv()
credential, err := chinaMobileAssetCredentialFromChannel(&model.Channel{})

require.NoError(t, err)
assert.Equal(t, "ak", credential.AK)
@@ -60,10 +63,10 @@ func TestChinaMobileAssetCredentialFromEnvDefaultsCenterPool(t *testing.T) {
assert.Equal(t, defaultChinaMobileAssetPoolID, credential.PoolID)
}

func TestChinaMobileAssetCredentialFromEnvSupportsPoolID(t *testing.T) {
func TestChinaMobileAssetCredentialFromChannelSupportsPoolID(t *testing.T) {
setChinaMobileAssetTestEnv(t, "CIDC-RP-29")

credential, err := chinaMobileAssetCredentialFromEnv()
credential, err := chinaMobileAssetCredentialFromChannel(&model.Channel{})

require.NoError(t, err)
assert.Equal(t, "ak", credential.AK)
@@ -71,16 +74,66 @@ func TestChinaMobileAssetCredentialFromEnvSupportsPoolID(t *testing.T) {
assert.Equal(t, "CIDC-RP-29", credential.PoolID)
}

func TestChinaMobileAssetCredentialFromEnvRequiresAKAndSK(t *testing.T) {
func TestChinaMobileAssetCredentialFromChannelRequiresCredential(t *testing.T) {
setupChinaMobileAssetTestDB(t)
t.Setenv(chinaMobileAssetAKEnv, "")
t.Setenv(chinaMobileAssetSKEnv, "")
t.Setenv(chinaMobileAssetPoolIDEnv, "")

_, err := chinaMobileAssetCredentialFromEnv()
_, err := chinaMobileAssetCredentialFromChannel(&model.Channel{Id: 99})

require.Error(t, err)
assert.Contains(t, err.Error(), chinaMobileAssetAKEnv)
assert.Contains(t, err.Error(), chinaMobileAssetSKEnv)
assert.Contains(t, err.Error(), "未配置素材凭证")
}

func TestChinaMobileAssetCredentialFromChannelFallsBackToLegacyEnvironment(t *testing.T) {
setupChinaMobileAssetTestDB(t)
t.Setenv(chinaMobileAssetAKEnv, "legacy-ak")
t.Setenv(chinaMobileAssetSKEnv, "legacy-sk")
t.Setenv(chinaMobileAssetPoolIDEnv, "legacy-pool")

credential, err := chinaMobileAssetCredentialFromChannel(&model.Channel{Id: 99})

require.NoError(t, err)
assert.Equal(t, "legacy-ak", credential.AK)
assert.Equal(t, "legacy-sk", credential.SK)
assert.Equal(t, "legacy-pool", credential.PoolID)
}

func TestChinaMobileAssetCredentialFromChannelPrefersChannelCredentialOverLegacyEnvironment(t *testing.T) {
setupChinaMobileAssetTestDB(t)
require.NoError(t, model.UpsertChannelAssetCredential(&model.ChannelAssetCredential{
ChannelId: 99,
AccessKey: "channel-ak",
SecretKey: "channel-sk",
PoolID: "channel-pool",
}))
t.Setenv(chinaMobileAssetAKEnv, "legacy-ak")
t.Setenv(chinaMobileAssetSKEnv, "legacy-sk")
t.Setenv(chinaMobileAssetPoolIDEnv, "legacy-pool")

credential, err := chinaMobileAssetCredentialFromChannel(&model.Channel{Id: 99})

require.NoError(t, err)
assert.Equal(t, "channel-ak", credential.AK)
assert.Equal(t, "channel-sk", credential.SK)
assert.Equal(t, "channel-pool", credential.PoolID)
}

func TestChinaMobileAssetAdapterReturnsBadRequestWhenCredentialIsMissing(t *testing.T) {
setupChinaMobileAssetTestDB(t)
adapter := &ChinaMobileAssetAdapter{newClient: func(credential chinaMobileAssetCredential) chinaMobileAssetSDKClient {
return &fakeChinaMobileAssetSDKClient{}
}}
spec, ok := ParseAssetAction("CreateAsset")
require.True(t, ok)

_, assetErr := adapter.DoAssetRequest(context.Background(), &model.Channel{Id: 61}, AssetRequest{Action: spec, Body: validChinaMobileCreateAssetBody()})

require.NotNil(t, assetErr)
assert.Equal(t, AssetErrorInvalidRequest, assetErr.Type)
assert.Equal(t, http.StatusBadRequest, assetErr.HTTPStatus)
assert.Contains(t, assetErr.Message, "未配置素材凭证")
}

func TestChinaMobileAssetAdapterAllOfficialActions(t *testing.T) {
@@ -280,9 +333,32 @@ func TestCallChinaMobileAssetSDKReturnsWhenContextCancelled(t *testing.T) {

func setChinaMobileAssetTestEnv(t *testing.T, poolID string) {
t.Helper()
t.Setenv(chinaMobileAssetAKEnv, "ak")
t.Setenv(chinaMobileAssetSKEnv, "sk")
t.Setenv(chinaMobileAssetPoolIDEnv, poolID)
setupChinaMobileAssetTestDB(t)
require.NoError(t, model.UpsertChannelAssetCredential(&model.ChannelAssetCredential{
ChannelId: 0,
AccessKey: "ak",
SecretKey: "sk",
PoolID: poolID,
}))
}

func setupChinaMobileAssetTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:service_chinamobile_asset?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
originalDB := model.DB
model.DB = db
require.NoError(t, db.AutoMigrate(&model.ChannelAssetCredential{}))
t.Cleanup(func() {
model.DB = originalDB
require.NoError(t, sqlDB.Close())
})
return db
}

type blockingChinaMobileAssetSDKClient struct {


+ 4
- 1
setup_test.sql Просмотреть файл

@@ -1,5 +1,6 @@
DELETE FROM tokens WHERE user_id IN (SELECT id FROM users WHERE username LIKE 'asset-admin' OR username LIKE 'asset-user');
DELETE FROM user_asset_channels WHERE user_id IN (9001, 9002);
DELETE FROM channel_asset_credentials WHERE channel_id = 9001;
DELETE FROM channels WHERE name = 'cm-asset-channel';
DELETE FROM users WHERE username LIKE 'asset-admin' OR username LIKE 'asset-user';

@@ -8,7 +9,9 @@ VALUES (9001, 'asset-admin', 'pbkdf2_sha256$600000$dGVzdA==$hash', '素材管理
INSERT INTO users (id, username, password, display_name, role, status, quota, "group")
VALUES (9002, 'asset-user', 'pbkdf2_sha256$600000$dGVzdA==$hash', '素材用户', 0, 1, 1000000000, 'default');
INSERT INTO channels (id, type, key, status, name, "group", models, weight, priority, auto_ban, created_time)
VALUES (9001, 61, '__CHINAMOBILE_ASSET_CHANNEL_KEY__', 1, 'cm-asset-channel', 'default', 'seedance-2', 10, 1, 1, 1784103836);
VALUES (9001, 61, 'video-generation-key', 1, 'cm-asset-channel', 'default', 'seedance-2', 10, 1, 1, 1784103836);
INSERT INTO channel_asset_credentials (channel_id, access_key, secret_key, pool_id, created_at, updated_at)
VALUES (9001, '__CHINAMOBILE_ASSET_AK__', '__CHINAMOBILE_ASSET_SK__', '__CHINAMOBILE_ASSET_POOL_ID__', 1784103836, 1784103836);
INSERT INTO tokens (id, user_id, key, status, name, "group", expired_time, unlimited_quota)
VALUES (9001, 9001, 'assetadmintestkey00000000000000000000000000001', 1, 'asset-admin-token', 'default', -1, 1);
INSERT INTO tokens (id, user_id, key, status, name, "group", expired_time, unlimited_quota)


+ 3
- 2
setup_test_env.ps1 Просмотреть файл

@@ -17,8 +17,9 @@ if ($ak.Contains("'") -or $sk.Contains("'") -or $poolId.Contains("'")) {
throw "China Mobile asset credentials must not contain single quotes"
}

$channelKey = "$ak|$sk|$poolId"
$sql = Get-Content -LiteralPath $TemplatePath -Raw
$sql = $sql.Replace("__CHINAMOBILE_ASSET_CHANNEL_KEY__", $channelKey)
$sql = $sql.Replace("__CHINAMOBILE_ASSET_AK__", $ak)
$sql = $sql.Replace("__CHINAMOBILE_ASSET_SK__", $sk)
$sql = $sql.Replace("__CHINAMOBILE_ASSET_POOL_ID__", $poolId)
[System.IO.File]::WriteAllText($OutputPath, $sql, [System.Text.UTF8Encoding]::new($false))
Write-Output $OutputPath

+ 62
- 1
web/src/components/table/channels/modals/EditChannelModal.jsx Просмотреть файл

@@ -183,6 +183,12 @@ const EditChannelModal = (props) => {
allow_include_obfuscation: false,
allow_inference_geo: false,
claude_beta_query: false,
asset_credential: {
access_key: '',
secret_key: '',
pool_id: '',
},
asset_credential_configured: false,
};
const [batch, setBatch] = useState(false);
const [multiToSingle, setMultiToSingle] = useState(false);
@@ -1359,6 +1365,27 @@ const EditChannelModal = (props) => {
if (isEdit && (!localInputs.key || localInputs.key.trim() === '')) {
delete localInputs.key;
}
if (localInputs.type === 61) {
const credential = localInputs.asset_credential || {};
const accessKey = String(credential.access_key || '').trim();
const secretKey = String(credential.secret_key || '').trim();
const poolId = String(credential.pool_id || '').trim();
if ((accessKey === '') !== (secretKey === '')) {
showInfo(t('请同时填写素材 AccessKey 和 SecretKey'));
return;
}
if (accessKey === '') {
delete localInputs.asset_credential;
} else {
localInputs.asset_credential = {
access_key: accessKey,
secret_key: secretKey,
pool_id: poolId,
};
}
} else {
delete localInputs.asset_credential;
}
delete localInputs.vertex_files;

if (!isEdit && (!localInputs.name || !localInputs.key)) {
@@ -1551,10 +1578,15 @@ const EditChannelModal = (props) => {
key_mode: isMultiKeyChannel ? keyMode : undefined, // 只在多key模式下传递
});
} else {
const {
asset_credential: assetCredential,
...channelInputs
} = localInputs;
res = await API.post(`/api/channel/`, {
mode: mode,
multi_key_mode: mode === 'multi_to_single' ? multiKeyMode : undefined,
channel: localInputs,
channel: channelInputs,
asset_credential: assetCredential,
});
}
const { success, message } = res.data;
@@ -2506,6 +2538,35 @@ const EditChannelModal = (props) => {
</>
)}

{inputs.type === 61 && (
<Card className='mb-4' shadows='always'>
<Text strong>{t('移动云素材凭证')}</Text>
<Text type='tertiary' size='small' className='block mb-3'>
{inputs.asset_credential_configured
? t('已配置素材凭证;留空不会覆盖现有凭证。')
: t('未配置素材凭证;素材上传接口将不可用。')}
</Text>
<Form.Input
field='asset_credential.access_key'
label='AccessKey'
mode='password'
autoComplete='new-password'
/>
<Form.Input
field='asset_credential.secret_key'
label='SecretKey'
mode='password'
autoComplete='new-password'
/>
<Form.Input
field='asset_credential.pool_id'
label='PoolID'
placeholder='CIDC-CORE-00'
extraText={t('留空使用默认 PoolID:CIDC-CORE-00')}
/>
</Card>
)}

{isEdit && isMultiKeyChannel && (
<Form.Select
field='key_mode'


Загрузка…
Отмена
Сохранить