Co-Authored-By: Codex <noreply@anthropic.com>master
| @@ -19,6 +19,7 @@ import ( | |||||
| "github.com/QuantumNous/new-api/service" | "github.com/QuantumNous/new-api/service" | ||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| "gorm.io/gorm" | |||||
| ) | ) | ||||
| type OpenAIModel struct { | 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) { | func GetAllChannels(c *gin.Context) { | ||||
| pageInfo := common.GetPageQuery(c) | pageInfo := common.GetPageQuery(c) | ||||
| channelData := make([]*model.Channel, 0) | 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 { | for _, datum := range channelData { | ||||
| clearChannelInfo(datum) | clearChannelInfo(datum) | ||||
| } | } | ||||
| @@ -482,6 +511,10 @@ func SearchChannels(c *gin.Context) { | |||||
| pagedData := channelData[startIdx:endIdx] | pagedData := channelData[startIdx:endIdx] | ||||
| if err := attachChannelAssetCredentialSummaries(pagedData); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| for _, datum := range pagedData { | for _, datum := range pagedData { | ||||
| clearChannelInfo(datum) | clearChannelInfo(datum) | ||||
| } | } | ||||
| @@ -510,6 +543,10 @@ func GetChannel(c *gin.Context) { | |||||
| return | return | ||||
| } | } | ||||
| if channel != nil { | if channel != nil { | ||||
| if err := attachChannelAssetCredentialSummaries([]*model.Channel{channel}); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| clearChannelInfo(channel) | clearChannelInfo(channel) | ||||
| } | } | ||||
| c.JSON(http.StatusOK, gin.H{ | c.JSON(http.StatusOK, gin.H{ | ||||
| @@ -675,10 +712,29 @@ func RefreshCodexChannelCredential(c *gin.Context) { | |||||
| } | } | ||||
| type AddChannelRequest struct { | 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) { | func getVertexArrayKeys(keys string) ([]string, error) { | ||||
| @@ -729,6 +785,15 @@ func AddChannel(c *gin.Context) { | |||||
| }) | }) | ||||
| return | 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() | addChannelRequest.Channel.CreatedTime = common.GetTimestamp() | ||||
| keys := make([]string, 0) | keys := make([]string, 0) | ||||
| @@ -800,7 +865,15 @@ func AddChannel(c *gin.Context) { | |||||
| } | } | ||||
| channels = append(channels, *localChannel) | 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 { | if err != nil { | ||||
| common.ApiError(c, err) | common.ApiError(c, err) | ||||
| return | return | ||||
| @@ -985,8 +1058,9 @@ func DeleteChannelBatch(c *gin.Context) { | |||||
| type PatchChannel struct { | type PatchChannel struct { | ||||
| model.Channel | 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) { | 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 { | if err != nil { | ||||
| common.ApiError(c, err) | common.ApiError(c, err) | ||||
| return | return | ||||
| @@ -1112,6 +1201,10 @@ func UpdateChannel(c *gin.Context) { | |||||
| service.ResetProxyClientCache() | service.ResetProxyClientCache() | ||||
| channel.Key = "" | channel.Key = "" | ||||
| clearChannelInfo(&channel.Channel) | clearChannelInfo(&channel.Channel) | ||||
| if err := attachChannelAssetCredentialSummaries([]*model.Channel{&channel.Channel}); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| c.JSON(http.StatusOK, gin.H{ | c.JSON(http.StatusOK, gin.H{ | ||||
| "success": true, | "success": true, | ||||
| "message": "", | "message": "", | ||||
| @@ -2105,4 +2198,3 @@ func OllamaVersion(c *gin.Context) { | |||||
| }, | }, | ||||
| }) | }) | ||||
| } | } | ||||
| @@ -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") | |||||
| } | |||||
| @@ -32,6 +32,12 @@ func TestChinaMobileAssetControllerWithRealUpstream(t *testing.T) { | |||||
| } | } | ||||
| db := setupRealAssetE2EDB(t) | db := setupRealAssetE2EDB(t) | ||||
| createDoubaoAssetProxyChannel(t, db, 7101, constant.ChannelTypeChinaMobileSeedance, "default", "video-generation-key", common.ChannelStatusEnabled) | 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() | router := setupRealAssetE2ERouter() | ||||
| suffix := time.Now().Format("20060102150405") | suffix := time.Now().Format("20060102150405") | ||||
| groupName := "new-api-e2e-" + suffix | groupName := "new-api-e2e-" + suffix | ||||
| @@ -119,7 +125,7 @@ func setupRealAssetE2EDB(t *testing.T) *gorm.DB { | |||||
| sqlDB, err := db.DB() | sqlDB, err := db.DB() | ||||
| require.NoError(t, err) | require.NoError(t, err) | ||||
| sqlDB.SetMaxOpenConns(1) | 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() { | t.Cleanup(func() { | ||||
| _ = sqlDB.Close() | _ = sqlDB.Close() | ||||
| @@ -56,6 +56,10 @@ type Channel struct { | |||||
| // cache info | // cache info | ||||
| Keys []string `json:"-" gorm:"-"` | 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 { | type ChannelInfo struct { | ||||
| @@ -283,7 +287,6 @@ func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Chan | |||||
| return channels, err | return channels, err | ||||
| } | } | ||||
| func GetChannelsByTag(tag string, idSort bool, selectAll bool) ([]*Channel, error) { | func GetChannelsByTag(tag string, idSort bool, selectAll bool) ([]*Channel, error) { | ||||
| var channels []*Channel | var channels []*Channel | ||||
| order := "priority desc" | order := "priority desc" | ||||
| @@ -403,7 +406,7 @@ func BatchDeleteChannels(ids []int) error { | |||||
| return tx.Error | return tx.Error | ||||
| } | } | ||||
| for _, chunk := range lo.Chunk(ids, 200) { | 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() | tx.Rollback() | ||||
| return err | return err | ||||
| } | } | ||||
| @@ -411,6 +414,10 @@ func BatchDeleteChannels(ids []int) error { | |||||
| tx.Rollback() | tx.Rollback() | ||||
| return err | return err | ||||
| } | } | ||||
| if err := tx.Where("id in (?)", chunk).Delete(&Channel{}).Error; err != nil { | |||||
| tx.Rollback() | |||||
| return err | |||||
| } | |||||
| } | } | ||||
| return tx.Commit().Error | return tx.Commit().Error | ||||
| } | } | ||||
| @@ -465,6 +472,12 @@ func (channel *Channel) Insert() error { | |||||
| } | } | ||||
| func (channel *Channel) Update() 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 this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys | ||||
| if channel.ChannelInfo.IsMultiKey { | if channel.ChannelInfo.IsMultiKey { | ||||
| var keyStr string | var keyStr string | ||||
| @@ -472,7 +485,8 @@ func (channel *Channel) Update() error { | |||||
| keyStr = channel.Key | keyStr = channel.Key | ||||
| } else { | } else { | ||||
| // If key is not provided, read the existing key from the database | // 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 | 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 { | if err != nil { | ||||
| return err | 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 | 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) { | func (channel *Channel) UpdateResponseTime(responseTime int64) { | ||||
| err := DB.Model(channel).Select("response_time", "test_time").Updates(Channel{ | err := DB.Model(channel).Select("response_time", "test_time").Updates(Channel{ | ||||
| TestTime: common.GetTimestamp(), | TestTime: common.GetTimestamp(), | ||||
| @@ -534,13 +562,19 @@ func (channel *Channel) UpdateBalance(balance float64) { | |||||
| } | } | ||||
| func (channel *Channel) Delete() error { | 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 | 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 | var channelStatusLock sync.Mutex | ||||
| @@ -778,13 +812,49 @@ func updateChannelUsedQuota(id int, quota int) { | |||||
| } | } | ||||
| func DeleteChannelByStatus(status int64) (int64, error) { | 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) { | 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) { | func GetPaginatedTags(offset int, limit int) ([]*string, error) { | ||||
| @@ -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 | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -259,6 +259,7 @@ func migrateDB() error { | |||||
| err := DB.AutoMigrate( | err := DB.AutoMigrate( | ||||
| &Channel{}, | &Channel{}, | ||||
| &ChannelAssetCredential{}, | |||||
| &Token{}, | &Token{}, | ||||
| &User{}, | &User{}, | ||||
| &PasskeyCredential{}, | &PasskeyCredential{}, | ||||
| @@ -329,6 +330,7 @@ func migrateDBFast() error { | |||||
| name string | name string | ||||
| }{ | }{ | ||||
| {&Channel{}, "Channel"}, | {&Channel{}, "Channel"}, | ||||
| {&ChannelAssetCredential{}, "ChannelAssetCredential"}, | |||||
| {&Token{}, "Token"}, | {&Token{}, "Token"}, | ||||
| {&User{}, "User"}, | {&User{}, "User"}, | ||||
| {&PasskeyCredential{}, "PasskeyCredential"}, | {&PasskeyCredential{}, "PasskeyCredential"}, | ||||
| @@ -62,7 +62,7 @@ func (a *ChinaMobileAssetAdapter) DoAssetRequest(ctx context.Context, channel *m | |||||
| return nil, err | return nil, err | ||||
| } | } | ||||
| credential, err := chinaMobileAssetCredentialFromEnv() | |||||
| credential, err := chinaMobileAssetCredentialFromChannel(channel) | |||||
| if err != nil { | if err != nil { | ||||
| return nil, newAssetError(AssetErrorInvalidRequest, err.Error(), http.StatusBadRequest) | return nil, newAssetError(AssetErrorInvalidRequest, err.Error(), http.StatusBadRequest) | ||||
| } | } | ||||
| @@ -101,11 +101,30 @@ type chinaMobileAssetCredential struct { | |||||
| PoolID string `json:"pool_id"` | 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{ | 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.SK = strings.TrimSpace(credential.SK) | ||||
| credential.PoolID = strings.TrimSpace(credential.PoolID) | credential.PoolID = strings.TrimSpace(credential.PoolID) | ||||
| if credential.AK == "" || credential.SK == "" { | 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 == "" { | if credential.PoolID == "" { | ||||
| credential.PoolID = defaultChinaMobileAssetPoolID | credential.PoolID = defaultChinaMobileAssetPoolID | ||||
| @@ -8,10 +8,13 @@ import ( | |||||
| "time" | "time" | ||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| "github.com/glebarez/sqlite" | |||||
| "github.com/stretchr/testify/assert" | "github.com/stretchr/testify/assert" | ||||
| "github.com/stretchr/testify/require" | "github.com/stretchr/testify/require" | ||||
| cmerrs "gitlab.ecloud.com/ecloud/ecloudsdkcore/errs" | cmerrs "gitlab.ecloud.com/ecloud/ecloudsdkcore/errs" | ||||
| cmmodel "gitlab.ecloud.com/ecloud/ecloudsdkmaas/model" | cmmodel "gitlab.ecloud.com/ecloud/ecloudsdkmaas/model" | ||||
| "gorm.io/gorm" | |||||
| "gorm.io/gorm/logger" | |||||
| ) | ) | ||||
| func TestChinaMobileAssetAdapterCreateAssetMapsRequestAndResponse(t *testing.T) { | func TestChinaMobileAssetAdapterCreateAssetMapsRequestAndResponse(t *testing.T) { | ||||
| @@ -49,10 +52,10 @@ func TestChinaMobileAssetAdapterCreateAssetMapsRequestAndResponse(t *testing.T) | |||||
| assert.Contains(t, string(resp.Body), `"Result":"asset-1"`) | assert.Contains(t, string(resp.Body), `"Result":"asset-1"`) | ||||
| } | } | ||||
| func TestChinaMobileAssetCredentialFromEnvDefaultsCenterPool(t *testing.T) { | |||||
| func TestChinaMobileAssetCredentialFromChannelDefaultsCenterPool(t *testing.T) { | |||||
| setChinaMobileAssetTestEnv(t, "") | setChinaMobileAssetTestEnv(t, "") | ||||
| credential, err := chinaMobileAssetCredentialFromEnv() | |||||
| credential, err := chinaMobileAssetCredentialFromChannel(&model.Channel{}) | |||||
| require.NoError(t, err) | require.NoError(t, err) | ||||
| assert.Equal(t, "ak", credential.AK) | assert.Equal(t, "ak", credential.AK) | ||||
| @@ -60,10 +63,10 @@ func TestChinaMobileAssetCredentialFromEnvDefaultsCenterPool(t *testing.T) { | |||||
| assert.Equal(t, defaultChinaMobileAssetPoolID, credential.PoolID) | assert.Equal(t, defaultChinaMobileAssetPoolID, credential.PoolID) | ||||
| } | } | ||||
| func TestChinaMobileAssetCredentialFromEnvSupportsPoolID(t *testing.T) { | |||||
| func TestChinaMobileAssetCredentialFromChannelSupportsPoolID(t *testing.T) { | |||||
| setChinaMobileAssetTestEnv(t, "CIDC-RP-29") | setChinaMobileAssetTestEnv(t, "CIDC-RP-29") | ||||
| credential, err := chinaMobileAssetCredentialFromEnv() | |||||
| credential, err := chinaMobileAssetCredentialFromChannel(&model.Channel{}) | |||||
| require.NoError(t, err) | require.NoError(t, err) | ||||
| assert.Equal(t, "ak", credential.AK) | assert.Equal(t, "ak", credential.AK) | ||||
| @@ -71,16 +74,66 @@ func TestChinaMobileAssetCredentialFromEnvSupportsPoolID(t *testing.T) { | |||||
| assert.Equal(t, "CIDC-RP-29", credential.PoolID) | 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(chinaMobileAssetAKEnv, "") | ||||
| t.Setenv(chinaMobileAssetSKEnv, "") | t.Setenv(chinaMobileAssetSKEnv, "") | ||||
| t.Setenv(chinaMobileAssetPoolIDEnv, "") | t.Setenv(chinaMobileAssetPoolIDEnv, "") | ||||
| _, err := chinaMobileAssetCredentialFromEnv() | |||||
| _, err := chinaMobileAssetCredentialFromChannel(&model.Channel{Id: 99}) | |||||
| require.Error(t, err) | 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) { | func TestChinaMobileAssetAdapterAllOfficialActions(t *testing.T) { | ||||
| @@ -280,9 +333,32 @@ func TestCallChinaMobileAssetSDKReturnsWhenContextCancelled(t *testing.T) { | |||||
| func setChinaMobileAssetTestEnv(t *testing.T, poolID string) { | func setChinaMobileAssetTestEnv(t *testing.T, poolID string) { | ||||
| t.Helper() | 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 { | type blockingChinaMobileAssetSDKClient struct { | ||||
| @@ -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 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 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 channels WHERE name = 'cm-asset-channel'; | ||||
| DELETE FROM users WHERE username LIKE 'asset-admin' OR username LIKE 'asset-user'; | 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") | 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'); | 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) | 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) | 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); | 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) | INSERT INTO tokens (id, user_id, key, status, name, "group", expired_time, unlimited_quota) | ||||
| @@ -17,8 +17,9 @@ if ($ak.Contains("'") -or $sk.Contains("'") -or $poolId.Contains("'")) { | |||||
| throw "China Mobile asset credentials must not contain single quotes" | throw "China Mobile asset credentials must not contain single quotes" | ||||
| } | } | ||||
| $channelKey = "$ak|$sk|$poolId" | |||||
| $sql = Get-Content -LiteralPath $TemplatePath -Raw | $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)) | [System.IO.File]::WriteAllText($OutputPath, $sql, [System.Text.UTF8Encoding]::new($false)) | ||||
| Write-Output $OutputPath | Write-Output $OutputPath | ||||
| @@ -183,6 +183,12 @@ const EditChannelModal = (props) => { | |||||
| allow_include_obfuscation: false, | allow_include_obfuscation: false, | ||||
| allow_inference_geo: false, | allow_inference_geo: false, | ||||
| claude_beta_query: false, | claude_beta_query: false, | ||||
| asset_credential: { | |||||
| access_key: '', | |||||
| secret_key: '', | |||||
| pool_id: '', | |||||
| }, | |||||
| asset_credential_configured: false, | |||||
| }; | }; | ||||
| const [batch, setBatch] = useState(false); | const [batch, setBatch] = useState(false); | ||||
| const [multiToSingle, setMultiToSingle] = useState(false); | const [multiToSingle, setMultiToSingle] = useState(false); | ||||
| @@ -1359,6 +1365,27 @@ const EditChannelModal = (props) => { | |||||
| if (isEdit && (!localInputs.key || localInputs.key.trim() === '')) { | if (isEdit && (!localInputs.key || localInputs.key.trim() === '')) { | ||||
| delete localInputs.key; | 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; | delete localInputs.vertex_files; | ||||
| if (!isEdit && (!localInputs.name || !localInputs.key)) { | if (!isEdit && (!localInputs.name || !localInputs.key)) { | ||||
| @@ -1551,10 +1578,15 @@ const EditChannelModal = (props) => { | |||||
| key_mode: isMultiKeyChannel ? keyMode : undefined, // 只在多key模式下传递 | key_mode: isMultiKeyChannel ? keyMode : undefined, // 只在多key模式下传递 | ||||
| }); | }); | ||||
| } else { | } else { | ||||
| const { | |||||
| asset_credential: assetCredential, | |||||
| ...channelInputs | |||||
| } = localInputs; | |||||
| res = await API.post(`/api/channel/`, { | res = await API.post(`/api/channel/`, { | ||||
| mode: mode, | mode: mode, | ||||
| multi_key_mode: mode === 'multi_to_single' ? multiKeyMode : undefined, | multi_key_mode: mode === 'multi_to_single' ? multiKeyMode : undefined, | ||||
| channel: localInputs, | |||||
| channel: channelInputs, | |||||
| asset_credential: assetCredential, | |||||
| }); | }); | ||||
| } | } | ||||
| const { success, message } = res.data; | 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 && ( | {isEdit && isMultiKeyChannel && ( | ||||
| <Form.Select | <Form.Select | ||||
| field='key_mode' | field='key_mode' | ||||