Align console behavior with operator routing and image workflow expectations

Constraint: Must preserve current exchange-rate logic, same-group routing boundary, and temporary image-file behavior while fixing UX-visible inconsistencies.
Rejected: Large frontend chunk refactor | introduced risky circular chunking and did not improve deliverability for this rollout.
Confidence: medium
Scope-risk: moderate
Directive: Keep channel failover constrained to the selected group and preserve Beijing-day semantics for user cache dashboard metrics.
Tested: go test ./controller ./model ./service ./common -count=1; npm run build; remote smoke on source deployment at http://38.76.218.56:18081/ plus /api/status and /api/image-studio/settings.
Not-tested: Browser-driven end-to-end verification of every console page interaction.
This commit is contained in:
ikmkj
2026-05-05 15:27:51 +08:00
parent 92ee465a41
commit 8ee89478a9
46 changed files with 1341 additions and 289 deletions

View File

@@ -111,31 +111,13 @@ func GetChannel(group string, model string, retry int) (*Channel, error) {
if err != nil {
return nil, err
}
if common.UsingSQLite || common.UsingPostgreSQL {
err = channelQuery.Order("weight DESC").Find(&abilities).Error
} else {
err = channelQuery.Order("weight DESC").Find(&abilities).Error
}
err = channelQuery.Order("weight DESC, channel_id ASC").Find(&abilities).Error
if err != nil {
return nil, err
}
channel := Channel{}
if len(abilities) > 0 {
// Randomly choose one
weightSum := uint(0)
for _, ability_ := range abilities {
weightSum += ability_.Weight + 10
}
// Randomly choose one
weight := common.GetRandomInt(int(weightSum))
for _, ability_ := range abilities {
weight -= int(ability_.Weight) + 10
//log.Printf("weight: %d, ability weight: %d", weight, *ability_.Weight)
if weight <= 0 {
channel.Id = ability_.ChannelId
break
}
}
channel.Id = abilities[0].ChannelId
} else {
return nil, nil
}

View File

@@ -1,9 +1,7 @@
package model
import (
"errors"
"fmt"
"math/rand"
"sort"
"strings"
"sync"
@@ -115,13 +113,6 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
return nil, nil
}
if len(channels) == 1 {
if channel, ok := channelsIDM[channels[0]]; ok {
return channel, nil
}
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0])
}
uniquePriorities := make(map[int]bool)
for _, channelId := range channels {
if channel, ok := channelsIDM[channelId]; ok {
@@ -136,58 +127,50 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
}
sort.Sort(sort.Reverse(sort.IntSlice(sortedUniquePriorities)))
if retry >= len(uniquePriorities) {
retry = len(uniquePriorities) - 1
if len(sortedUniquePriorities) == 0 {
return nil, nil
}
if retry < 0 {
retry = 0
}
if retry >= len(channels) {
retry = len(channels) - 1
}
targetPriority := int64(sortedUniquePriorities[retry])
// get the priority for the given retry number
var sumWeight = 0
var targetChannels []*Channel
for _, channelId := range channels {
if channel, ok := channelsIDM[channelId]; ok {
orderedChannels := make([]*Channel, 0, len(channels))
for _, priority := range sortedUniquePriorities {
targetPriority := int64(priority)
targetChannels := make([]*Channel, 0)
for _, channelId := range channels {
channel, ok := channelsIDM[channelId]
if !ok {
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId)
}
if channel.GetPriority() == targetPriority {
sumWeight += channel.GetWeight()
targetChannels = append(targetChannels, channel)
}
} else {
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId)
}
sort.SliceStable(targetChannels, func(i, j int) bool {
left := targetChannels[i]
right := targetChannels[j]
if left.GetWeight() != right.GetWeight() {
return left.GetWeight() > right.GetWeight()
}
if left.GetPriority() != right.GetPriority() {
return left.GetPriority() > right.GetPriority()
}
return left.Id < right.Id
})
orderedChannels = append(orderedChannels, targetChannels...)
}
if len(targetChannels) == 0 {
return nil, errors.New(fmt.Sprintf("no channel found, group: %s, model: %s, priority: %d", group, model, targetPriority))
if len(orderedChannels) == 0 {
return nil, nil
}
// smoothing factor and adjustment
smoothingFactor := 1
smoothingAdjustment := 0
if sumWeight == 0 {
// when all channels have weight 0, set sumWeight to the number of channels and set smoothing adjustment to 100
// each channel's effective weight = 100
sumWeight = len(targetChannels) * 100
smoothingAdjustment = 100
} else if sumWeight/len(targetChannels) < 10 {
// when the average weight is less than 10, set smoothing factor to 100
smoothingFactor = 100
if retry >= len(orderedChannels) {
retry = len(orderedChannels) - 1
}
// Calculate the total weight of all channels up to endIdx
totalWeight := sumWeight * smoothingFactor
// Generate a random value in the range [0, totalWeight)
randomWeight := rand.Intn(totalWeight)
// Find a channel based on its weight
for _, channel := range targetChannels {
randomWeight -= channel.GetWeight()*smoothingFactor + smoothingAdjustment
if randomWeight < 0 {
return channel, nil
}
}
// return null if no channel is not found
return nil, errors.New("channel not found")
return orderedChannels[retry], nil
}
func CacheGetChannel(id int) (*Channel, error) {

View File

@@ -0,0 +1,57 @@
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
)
func TestGetRandomSatisfiedChannelOrderedByPriorityThenWeight(t *testing.T) {
origMemoryCacheEnabled := common.MemoryCacheEnabled
origCacheEnabled := group2model2channels
origChannels := channelsIDM
defer func() {
common.MemoryCacheEnabled = origMemoryCacheEnabled
group2model2channels = origCacheEnabled
channelsIDM = origChannels
}()
common.MemoryCacheEnabled = true
group2model2channels = map[string]map[string][]int{
"image": {
"gpt-image-2": {1, 2, 3},
},
}
channelsIDM = map[int]*Channel{
1: {Id: 1, Priority: int64Ptr(10), Weight: uintPtr(50)},
2: {Id: 2, Priority: int64Ptr(10), Weight: uintPtr(20)},
3: {Id: 3, Priority: int64Ptr(5), Weight: uintPtr(99)},
}
first, err := GetRandomSatisfiedChannel("image", "gpt-image-2", 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if first == nil || first.Id != 1 {
t.Fatalf("expected first channel id 1, got %+v", first)
}
second, err := GetRandomSatisfiedChannel("image", "gpt-image-2", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if second == nil || second.Id != 2 {
t.Fatalf("expected second channel id 2, got %+v", second)
}
third, err := GetRandomSatisfiedChannel("image", "gpt-image-2", 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if third == nil || third.Id != 3 {
t.Fatalf("expected third channel id 3, got %+v", third)
}
}
func int64Ptr(v int64) *int64 { return &v }
func uintPtr(v uint) *uint { return &v }

View File

@@ -221,11 +221,9 @@ type CacheDashboardMetrics struct {
}
type cacheMetricRow struct {
CreatedAt int64 `gorm:"column:created_at"`
PromptTokens int `gorm:"column:prompt_tokens"`
CompletionTokens int `gorm:"column:completion_tokens"`
ModelName string `gorm:"column:model_name"`
Other string `gorm:"column:other"`
CreatedAt int64 `gorm:"column:created_at"`
PromptTokens int `gorm:"column:prompt_tokens"`
Other string `gorm:"column:other"`
}
type DashboardConsumeStats struct {
@@ -330,14 +328,26 @@ func finalizeCacheMetrics(m *CacheDashboardMetrics) {
}
}
var dashboardMetricsLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
func getDashboardDayStartUnix(now time.Time) int64 {
localNow := now.In(dashboardMetricsLocation)
return time.Date(
localNow.Year(),
localNow.Month(),
localNow.Day(),
0, 0, 0, 0,
dashboardMetricsLocation,
).Unix()
}
func GetUserCacheDashboardMetrics(userId int) (CacheDashboardMetrics, CacheDashboardMetrics, error) {
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
start := getDashboardDayStartUnix(time.Now())
overall := CacheDashboardMetrics{}
today := CacheDashboardMetrics{}
var rows []cacheMetricRow
err := LOG_DB.Model(&Log{}).
Select("created_at, prompt_tokens, completion_tokens, model_name, other").
Select("created_at, prompt_tokens, other").
Where("user_id = ? AND type = ?", userId, LogTypeConsume).
Order("id ASC").
FindInBatches(&rows, 1000, func(tx *gorm.DB, batch int) error {

View File

@@ -0,0 +1,40 @@
package model
import (
"testing"
"time"
)
func TestGetDashboardDayStartUnixUsesShanghai(t *testing.T) {
utcTime := time.Date(2026, 5, 5, 0, 30, 0, 0, time.UTC)
got := getDashboardDayStartUnix(utcTime)
want := time.Date(2026, 5, 5, 0, 0, 0, 0, dashboardMetricsLocation).Unix()
if got != want {
t.Fatalf("expected Shanghai day start %d, got %d", want, got)
}
}
func TestAccumulateCacheMetrics(t *testing.T) {
row := cacheMetricRow{
PromptTokens: 100,
Other: `{"cache_read_tokens":40,"cache_write_tokens":10}`,
}
metrics := CacheDashboardMetrics{}
accumulateCacheMetrics(&metrics, row)
finalizeCacheMetrics(&metrics)
if metrics.CacheReadTokens != 40 {
t.Fatalf("expected cache read 40, got %d", metrics.CacheReadTokens)
}
if metrics.CacheWriteTokens != 10 {
t.Fatalf("expected cache write 10, got %d", metrics.CacheWriteTokens)
}
if metrics.CacheTokens != 50 {
t.Fatalf("expected cache tokens 50, got %d", metrics.CacheTokens)
}
if metrics.CacheHitRate <= 0 {
t.Fatalf("expected positive hit rate, got %f", metrics.CacheHitRate)
}
}

View File

@@ -162,6 +162,8 @@ func InitOptionMap() {
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
common.OptionMap["ImageStudioSafetyPrompt"] = setting.ImageStudioSafetyPromptToString()
common.OptionMap["ImageStudioBannedWords"] = setting.ImageStudioBannedWordsToString()
common.OptionMap["ImageStudioSettings"] = `{"enabled":true,"default_model":"gpt-image-2","allow_open_original":true,"retention_minutes":30,"allowed_models":[],"model_settings":[]}`
common.OptionMap["ImageStudioRetentionMinutes"] = "30"
common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString()
@@ -503,6 +505,10 @@ func updateOptionMap(key string, value string) (err error) {
setting.ImageStudioSafetyPromptFromString(value)
case "ImageStudioBannedWords":
setting.ImageStudioBannedWordsFromString(value)
case "ImageStudioSettings":
// 由控制器/前端按 JSON 读取;这里仅保留在 OptionMap 中供运行时使用。
case "ImageStudioRetentionMinutes":
// 由图片制作运行时读取 OptionMap 并解析,避免再引入重复全局状态。
case "AutomaticDisableKeywords":
operation_setting.AutomaticDisableKeywordsFromString(value)
case "AutomaticDisableStatusCodes":