Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions common/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"github.com/gin-gonic/gin/binding"
)

type RawMessage = json.RawMessage

// hostJSONCodec is the single place where the host chooses its JSON engine.
// Swap the implementation here (for example to sonic.ConfigStd) and every
// common.* and kitutil.* JSON helper, including relaykit DTO (un)marshalling,
Expand Down Expand Up @@ -73,11 +75,11 @@ func IndentJson(data []byte) ([]byte, error) {
return buffer.Bytes(), nil
}

func GetJsonType(data json.RawMessage) string {
func GetJsonType(data RawMessage) string {
return kitutil.GetJsonType(data)
}

// JsonRawMessageToString returns JSON strings as their decoded value and other JSON values as raw text.
func JsonRawMessageToString(data json.RawMessage) string {
func JsonRawMessageToString(data RawMessage) string {
return kitutil.JsonRawMessageToString(data)
}
111 changes: 107 additions & 4 deletions controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,68 @@ func clearChannelInfo(channel *model.Channel) {
}
}

func channelIDsFromChannels(channels []*model.Channel) []int {
ids := make([]int, 0, len(channels))
for _, channel := range channels {
if channel != nil && channel.Id > 0 {
ids = append(ids, channel.Id)
}
}
return ids
}

func closeActiveChannelWebSockets(channelIDs []int) {
service.CloseActiveWebSocketsForChannels(channelIDs, service.ChannelDisabledCloseReason)
}

func hasEnabledMultiKey(channel *model.Channel) bool {
if channel == nil || !channel.ChannelInfo.IsMultiKey {
return true
}
keys := channel.GetKeys()
if len(keys) == 0 {
return false
}
for i := range keys {
if channel.ChannelInfo.MultiKeyStatusList == nil {
return true
}
if status, ok := channel.ChannelInfo.MultiKeyStatusList[i]; !ok || status == common.ChannelStatusEnabled {
return true
}
}
return false
}

func disableMultiKeyChannelIfUnavailable(channel *model.Channel) bool {
if channel == nil || !channel.ChannelInfo.IsMultiKey || hasEnabledMultiKey(channel) {
return false
}
if channel.Status != common.ChannelStatusEnabled {
return true
}
channel.Status = common.ChannelStatusManuallyDisabled
info := channel.GetOtherInfo()
info["status_reason"] = model.ChannelStatusReasonAllKeysDisabled
info["status_time"] = common.GetTimestamp()
channel.SetOtherInfo(info)
return true
}

func restoreMultiKeyChannelIfAvailable(channel *model.Channel) {
if channel.Status != common.ChannelStatusManuallyDisabled || !hasEnabledMultiKey(channel) {
return
}
info := channel.GetOtherInfo()
if info["status_reason"] != model.ChannelStatusReasonAllKeysDisabled {
return
}
channel.Status = common.ChannelStatusEnabled
info["status_reason"] = ""
info["status_time"] = common.GetTimestamp()
channel.SetOtherInfo(info)
}

func applyChannelStatusFilter(query *gorm.DB, statusFilter int) *gorm.DB {
if statusFilter == common.ChannelStatusEnabled {
return query.Where("status = ?", common.ChannelStatusEnabled)
Expand Down Expand Up @@ -767,6 +829,7 @@ func DeleteChannel(c *gin.Context) {
"id": id,
"name": channelName,
})
closeActiveChannelWebSockets([]int{id})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand All @@ -775,6 +838,13 @@ func DeleteChannel(c *gin.Context) {
}

func DeleteDisabledChannel(c *gin.Context) {
var ids []int
if err := model.DB.Model(&model.Channel{}).
Where("status = ? or status = ?", common.ChannelStatusAutoDisabled, common.ChannelStatusManuallyDisabled).
Pluck("id", &ids).Error; err != nil {
common.ApiError(c, err)
return
}
rows, err := model.DeleteDisabledChannel()
if err != nil {
common.ApiError(c, err)
Expand All @@ -787,6 +857,7 @@ func DeleteDisabledChannel(c *gin.Context) {
recordManageAudit(c, "channel.delete_disabled", map[string]any{
"count": rows,
})
closeActiveChannelWebSockets(ids)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down Expand Up @@ -817,6 +888,12 @@ func DisableTagChannels(c *gin.Context) {
})
return
}
channels, err := model.GetChannelsByTag(channelTag.Tag, false, false)
if err != nil {
common.ApiError(c, err)
return
}
ids := channelIDsFromChannels(channels)
err = model.DisableChannelByTag(channelTag.Tag)
if err != nil {
common.ApiError(c, err)
Expand All @@ -826,6 +903,7 @@ func DisableTagChannels(c *gin.Context) {
recordManageAudit(c, "channel.tag_disable", map[string]any{
"tag": channelTag.Tag,
})
closeActiveChannelWebSockets(ids)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down Expand Up @@ -946,6 +1024,7 @@ func DeleteChannelBatch(c *gin.Context) {
recordManageAudit(c, "channel.delete_batch", map[string]any{
"count": deletedCount,
})
closeActiveChannelWebSockets(channelBatch.Ids)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down Expand Up @@ -1180,6 +1259,9 @@ func UpdateChannelStatus(c *gin.Context) {
changed := model.UpdateChannelStatus(id, "", req.Status, "manual operation")
if changed {
model.InitChannelCache()
if req.Status != common.ChannelStatusEnabled {
closeActiveChannelWebSockets([]int{id})
}
}
recordManageAudit(c, "channel.status_update", map[string]any{
"id": id,
Expand All @@ -1200,14 +1282,21 @@ func BatchUpdateChannelStatus(c *gin.Context) {
return
}
changedCount := 0
var disabledIDs []int
for _, id := range req.Ids {
if model.UpdateChannelStatus(id, "", req.Status, "manual batch operation") {
changedCount++
if req.Status != common.ChannelStatusEnabled {
disabledIDs = append(disabledIDs, id)
}
}
}
if changedCount > 0 {
model.InitChannelCache()
}
if len(disabledIDs) > 0 {
closeActiveChannelWebSockets(disabledIDs)
}
recordManageAudit(c, "channel.status_update_batch", map[string]any{
"count": changedCount,
"total": len(req.Ids),
Expand Down Expand Up @@ -1724,13 +1813,16 @@ func ManageMultiKeys(c *gin.Context) {

channel.ChannelInfo.MultiKeyStatusList[keyIndex] = 2 // disabled

shouldCloseWebSocket := disableMultiKeyChannelIfUnavailable(channel)
err = channel.Update()
if err != nil {
common.ApiError(c, err)
return
}

model.InitChannelCache()
if shouldCloseWebSocket {
closeActiveChannelWebSockets([]int{channel.Id})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "密钥已禁用",
Expand Down Expand Up @@ -1765,6 +1857,7 @@ func ManageMultiKeys(c *gin.Context) {
if channel.ChannelInfo.MultiKeyDisabledReason != nil {
delete(channel.ChannelInfo.MultiKeyDisabledReason, keyIndex)
}
restoreMultiKeyChannelIfAvailable(channel)

err = channel.Update()
if err != nil {
Expand All @@ -1789,6 +1882,7 @@ func ManageMultiKeys(c *gin.Context) {
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64)
channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string)
restoreMultiKeyChannelIfAvailable(channel)

err = channel.Update()
if err != nil {
Expand Down Expand Up @@ -1837,13 +1931,16 @@ func ManageMultiKeys(c *gin.Context) {
return
}

shouldCloseWebSocket := disableMultiKeyChannelIfUnavailable(channel)
err = channel.Update()
if err != nil {
common.ApiError(c, err)
return
}

model.InitChannelCache()
if shouldCloseWebSocket {
closeActiveChannelWebSockets([]int{channel.Id})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("已禁用 %d 个密钥", disabledCount),
Expand Down Expand Up @@ -1917,13 +2014,16 @@ func ManageMultiKeys(c *gin.Context) {
channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime
channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason

shouldCloseWebSocket := disableMultiKeyChannelIfUnavailable(channel)
err = channel.Update()
if err != nil {
common.ApiError(c, err)
return
}

model.InitChannelCache()
if shouldCloseWebSocket {
closeActiveChannelWebSockets([]int{channel.Id})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "密钥已删除",
Expand Down Expand Up @@ -1985,13 +2085,16 @@ func ManageMultiKeys(c *gin.Context) {
channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime
channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason

shouldCloseWebSocket := disableMultiKeyChannelIfUnavailable(channel)
err = channel.Update()
if err != nil {
common.ApiError(c, err)
return
}

model.InitChannelCache()
if shouldCloseWebSocket {
closeActiveChannelWebSockets([]int{channel.Id})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("已删除 %d 个自动禁用的密钥", deletedCount),
Expand Down
111 changes: 111 additions & 0 deletions controller/channel_multi_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package controller

import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestMultiKeyEnableRestoresOnlyExhaustedChannels(t *testing.T) {
previousDB, previousLogDB := model.DB, model.LOG_DB
previousType, previousLogType := common.MainDatabaseType(), common.LogDatabaseType()
previousMaster, previousCache, previousRedis, previousSQLite := common.IsMasterNode, common.MemoryCacheEnabled, common.RedisEnabled, common.SQLitePath
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.SetDatabaseTypes(previousType, previousLogType)
common.IsMasterNode, common.MemoryCacheEnabled, common.RedisEnabled, common.SQLitePath = previousMaster, previousCache, previousRedis, previousSQLite
})
t.Setenv("SQL_DSN", os.Getenv("TEST_CHANNEL_SQL_DSN"))
t.Setenv("LOG_SQL_DSN", "")
common.IsMasterNode, common.MemoryCacheEnabled, common.RedisEnabled = false, false, false
common.SQLitePath = filepath.Join(t.TempDir(), "channel.db")
require.NoError(t, model.InitDB())
database := model.DB
sqlDB, err := database.DB()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
model.LOG_DB = database
common.SetLogDatabaseType(common.MainDatabaseType())
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.User{}, &model.Log{}, &model.AuditLog{}))
root := &model.User{Username: "multi-key-review-root", Role: common.RoleRootUser, Status: common.UserStatusEnabled}
require.NoError(t, database.Create(root).Error)
t.Cleanup(func() { require.NoError(t, database.Unscoped().Delete(root).Error) })
versionQuery := "SELECT VERSION()"
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
versionQuery = "SELECT sqlite_version()"
}
var version string
require.NoError(t, database.Raw(versionQuery).Scan(&version).Error)
t.Logf("database=%s version=%s", common.MainDatabaseType(), version)

for _, cacheEnabled := range []bool{false, true} {
for _, action := range []string{"enable_key", "enable_all_keys"} {
for _, tc := range []struct {
name string
initialStatus int
manualOverride string
wantStatus int
}{
{name: "key exhaustion restores", initialStatus: common.ChannelStatusEnabled, wantStatus: common.ChannelStatusEnabled},
{name: "manual disable is preserved", initialStatus: common.ChannelStatusManuallyDisabled, wantStatus: common.ChannelStatusManuallyDisabled},
{name: "manual disable after exhaustion is preserved", initialStatus: common.ChannelStatusEnabled, manualOverride: "status", wantStatus: common.ChannelStatusManuallyDisabled},
{name: "tag disable after exhaustion is preserved", initialStatus: common.ChannelStatusEnabled, manualOverride: "tag", wantStatus: common.ChannelStatusManuallyDisabled},
} {
t.Run(fmt.Sprintf("cache=%t/%s/%s", cacheEnabled, action, tc.name), func(t *testing.T) {
common.MemoryCacheEnabled = cacheEnabled
tag := t.Name()
channel := &model.Channel{Name: t.Name(), Type: 1, Key: "key-one\nkey-two", Status: tc.initialStatus, Models: "test-model", Group: "default", Tag: &tag,
ChannelInfo: model.ChannelInfo{IsMultiKey: true, MultiKeySize: 2, MultiKeyStatusList: map[int]int{1: common.ChannelStatusManuallyDisabled}},
}
require.NoError(t, channel.Insert())
t.Cleanup(func() {
require.NoError(t, channel.Delete())
model.InitChannelCache()
})
for _, operation := range []string{"disable_key", action} {
if operation == action {
if tc.manualOverride == "status" {
model.UpdateChannelStatus(channel.Id, "", common.ChannelStatusManuallyDisabled, "manual operation")
} else if tc.manualOverride == "tag" {
require.NoError(t, model.DisableChannelByTag(tag))
}
}
payload, err := common.Marshal(MultiKeyManageRequest{ChannelId: channel.Id, Action: operation, KeyIndex: common.GetPointer(0)})
require.NoError(t, err)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set("id", root.Id)
c.Set("role", common.RoleRootUser)
c.Request = httptest.NewRequest(http.MethodPost, "/api/channel/multi_key", bytes.NewReader(payload))
c.Request.Header.Set("Content-Type", "application/json")
ManageMultiKeys(c)
var result struct {
Success bool `json:"success"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &result))
require.True(t, result.Success, recorder.Body.String())
}
loaded, err := model.GetChannelById(channel.Id, true)
require.NoError(t, err)
assert.Equal(t, tc.wantStatus, loaded.Status)
assert.NotContains(t, loaded.ChannelInfo.MultiKeyStatusList, 0)
assert.NotContains(t, loaded.ChannelInfo.MultiKeyDisabledReason, 0)
assert.NotContains(t, loaded.ChannelInfo.MultiKeyDisabledTime, 0)
var ability model.Ability
require.NoError(t, database.Where("channel_id = ?", channel.Id).First(&ability).Error)
assert.Equal(t, tc.wantStatus == common.ChannelStatusEnabled, ability.Enabled)
})
}
}
}
}
Loading
Loading