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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
57
model/channel_cache_selection_test.go
Normal file
57
model/channel_cache_selection_test.go
Normal 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 }
|
||||
26
model/log.go
26
model/log.go
@@ -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 {
|
||||
|
||||
40
model/log_dashboard_metrics_test.go
Normal file
40
model/log_dashboard_metrics_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user