diff --git a/controller/channel.go b/controller/channel.go
index 0531927..7f9553b 100644
--- a/controller/channel.go
+++ b/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) {
},
})
}
-
diff --git a/controller/channel_asset_credential_test.go b/controller/channel_asset_credential_test.go
new file mode 100644
index 0000000..f87a56c
--- /dev/null
+++ b/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")
+}
diff --git a/controller/doubao_asset_real_e2e_test.go b/controller/doubao_asset_real_e2e_test.go
index 48bf769..58cd68c 100644
--- a/controller/doubao_asset_real_e2e_test.go
+++ b/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()
diff --git a/model/channel.go b/model/channel.go
index e16a544..bba7752 100644
--- a/model/channel.go
+++ b/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) {
diff --git a/model/channel_asset_credential.go b/model/channel_asset_credential.go
new file mode 100644
index 0000000..d90801c
--- /dev/null
+++ b/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
+}
diff --git a/model/channel_asset_credential_test.go b/model/channel_asset_credential_test.go
new file mode 100644
index 0000000..3614a16
--- /dev/null
+++ b/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)
+}
diff --git a/model/main.go b/model/main.go
index d50d47a..b60fa97 100644
--- a/model/main.go
+++ b/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"},
diff --git a/service/asset_chinamobile.go b/service/asset_chinamobile.go
index f3c0a92..ad359f5 100644
--- a/service/asset_chinamobile.go
+++ b/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
diff --git a/service/asset_chinamobile_test.go b/service/asset_chinamobile_test.go
index 23182d1..300830c 100644
--- a/service/asset_chinamobile_test.go
+++ b/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 {
diff --git a/setup_test.sql b/setup_test.sql
index 79ffe20..5080e6a 100644
--- a/setup_test.sql
+++ b/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)
diff --git a/setup_test_env.ps1 b/setup_test_env.ps1
index a8d2e7c..775d690 100644
--- a/setup_test_env.ps1
+++ b/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
diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx
index 757f4ee..57a514c 100644
--- a/web/src/components/table/channels/modals/EditChannelModal.jsx
+++ b/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 && (
+