Co-Authored-By: Codex <noreply@anthropic.com>master
| @@ -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) { | |||
| }, | |||
| }) | |||
| } | |||
| @@ -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) | |||
| 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() | |||
| @@ -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) { | |||
| @@ -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( | |||
| &Channel{}, | |||
| &ChannelAssetCredential{}, | |||
| &Token{}, | |||
| &User{}, | |||
| &PasskeyCredential{}, | |||
| @@ -329,6 +330,7 @@ func migrateDBFast() error { | |||
| name string | |||
| }{ | |||
| {&Channel{}, "Channel"}, | |||
| {&ChannelAssetCredential{}, "ChannelAssetCredential"}, | |||
| {&Token{}, "Token"}, | |||
| {&User{}, "User"}, | |||
| {&PasskeyCredential{}, "PasskeyCredential"}, | |||
| @@ -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 | |||
| @@ -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 { | |||
| @@ -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) | |||
| @@ -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 | |||
| @@ -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' | |||