diff --git a/.agents/downstream.md b/.agents/downstream.md
new file mode 100644
index 000000000000..12046805ea2b
--- /dev/null
+++ b/.agents/downstream.md
@@ -0,0 +1,65 @@
+# Downstream Fork Rules
+
+This repository is a downstream fork of the official project. Keeping the
+working tree compatible with `upstream/main` and minimizing future merge
+conflicts are primary engineering constraints.
+
+## Upstream Baseline
+
+- Treat `upstream/main` as the official source baseline and `origin/main` as
+ the downstream branch.
+- A path that exists in `upstream/main` is upstream-owned even when the
+ downstream branch has already modified it. A path introduced only in the
+ downstream branch is downstream-owned.
+- Before changing existing code, compare the relevant files or history with
+ `upstream/main` when the upstream remote is available.
+- If the upstream remote or baseline is unavailable, do not make broad
+ refactors based on assumptions about ownership; state the limitation.
+
+## Change Placement
+
+- Leave upstream-owned files unchanged by default. Before editing one, first
+ determine whether the requirement can be met without that edit.
+- Prefer existing configuration, extension points, registration tables,
+ interfaces, and middleware hooks.
+- Prefer adding a new file within the existing package or feature for
+ downstream behavior. Keep the existing architecture and ownership model.
+- Do not duplicate substantial upstream logic only to avoid touching an
+ upstream-owned file.
+- A required bug fix or integration may modify an upstream-owned file. When
+ that is necessary, keep the change to the smallest possible number of
+ files and hunks, and isolate downstream behavior behind a narrow hook or
+ adapter where practical.
+
+## Conflict Avoidance
+
+- Do not perform unrelated cleanup, refactoring, renaming, moving, import
+ reordering, whole-file formatting, dependency upgrades, or generated-file
+ changes as part of a feature or bug fix.
+- Preserve upstream APIs, naming, layout, and behavior unless the task
+ explicitly requires a change.
+- Keep upstream synchronization commits separate from downstream feature
+ commits.
+- When resolving an upstream merge, preserve the upstream implementation
+ first, then reapply the smallest downstream integration necessary.
+
+## Downstream Language
+
+- All downstream-only user-facing text MUST use Simplified Chinese literals,
+ including frontend UI copy and backend API messages or notifications.
+- Do not add frontend or backend i18n keys, translation calls, static
+ translation keys, locale messages, or locale entries for downstream-only
+ features.
+- Do not modify locale files for downstream-only features.
+- Preserve the official project's existing internationalization behavior when
+ editing upstream-owned features. This downstream-only exception overrides
+ the root i18n rules only for code and features owned by this fork.
+
+## Review Before Completion
+
+- Inspect `git diff --stat` and `git diff --check` before finishing.
+- Identify every existing upstream-owned file changed by the task and record
+ why the change was necessary.
+- Prefer solutions that could be accepted upstream without introducing
+ fork-specific coupling, even when the final implementation remains in this
+ downstream repository.
diff --git a/AGENTS.md b/AGENTS.md
index 8f41dcf72c86..973e91dd94e5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,8 @@
DO NOT send optional commentary
+Before making changes in this downstream fork, you MUST read and follow `.agents/downstream.md`.
+
## Overview
This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI providers (OpenAI, Claude, Gemini, Azure, AWS Bedrock, etc.) behind a unified API, with user management, billing, rate limiting, and an admin dashboard.
diff --git a/controller/audit.go b/controller/audit.go
index 36080724f8f4..81ecc6abaef1 100644
--- a/controller/audit.go
+++ b/controller/audit.go
@@ -51,9 +51,32 @@ var auditContentTemplates = map[string]string{
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",
}
-// auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。
+// channelMonitorAuditContentTemplates 是自定义渠道监控功能的固定中文日志模板。
+// 该页面仅供内部使用,不跟随系统语言切换。
+var channelMonitorAuditContentTemplates = map[string]string{
+ "channel.status_update": "已将渠道 ${id} 的状态更新为 ${status}",
+ "channel.monitor_smart_schedule_config_update": "已更新渠道 ${id} 的智能调度设置",
+ "channel.monitor_group_ratio_sync": "已根据成本倍率 ${cost_ratio}(上游倍率 ${upstream_ratio} × 换算系数 ${conversion_factor})和分组系数 ${coefficient},将分组 ${group} 的倍率更新为 ${ratio}",
+ "channel.monitor_group_ratio_update": "已将分组 ${group} 的倍率更新为 ${ratio}",
+ "channel.monitor_group_channels_update": "已更新分组 ${group} 的关联渠道(新增 ${added_count} 个,移除 ${removed_count} 个)",
+ "channel.monitor_ratio_update": "已将渠道 ${id} 的倍率更新为 ${ratio}",
+ "channel.monitor_ratio_update_run": "已启动上游倍率更新任务 ${task_id}",
+ "channel.monitor_upstream_config_update": "已更新渠道 ${id} 的上游配置(${upstream_type_label},成本换算:${cost_conversion},换算系数 ${conversion_factor})",
+ "channel.monitor_upstream_ratio_fetch": "已获取渠道 ${id} 的上游倍率 ${ratio},换算后成本倍率 ${cost_ratio}(系数 ${conversion_factor})",
+ "channel.monitor_upstream_balance_fetch": "已获取渠道 ${id} 的上游余额 ${balance}",
+ "channel.monitor_upstream_group_apply": "已将上游分组 ${group} 应用于渠道 ${id}(已更新 ${keys_updated} 个令牌,上游倍率 ${ratio},成本倍率 ${cost_ratio})",
+ "channel.monitor_smart_schedule_run": "已启动智能调度任务 ${task_id}",
+ "channel.monitor_order_update": "已更新 ${channel_count} 个监控渠道的自定义顺序",
+ "channel.monitor_settings_update": "已更新渠道监控设置",
+}
+
+// auditContentEN 渲染日志兜底文本;渠道监控使用固定中文,其余操作使用英文基线。
+// 未登记的 action 退回 action 本身。
func auditContentEN(action string, params map[string]interface{}) string {
- tmpl, ok := auditContentTemplates[action]
+ tmpl, ok := channelMonitorAuditContentTemplates[action]
+ if !ok {
+ tmpl, ok = auditContentTemplates[action]
+ }
if !ok {
return action
}
diff --git a/controller/channel-test.go b/controller/channel-test.go
index 4ba3698bd54c..977cb6172bef 100644
--- a/controller/channel-test.go
+++ b/controller/channel-test.go
@@ -957,7 +957,7 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse
// disable channel
if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
- processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
+ processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError, false)
summary.Disabled++
}
diff --git a/controller/channel_monitor_cost.go b/controller/channel_monitor_cost.go
new file mode 100644
index 000000000000..697b0c71eadf
--- /dev/null
+++ b/controller/channel_monitor_cost.go
@@ -0,0 +1,222 @@
+package controller
+
+import (
+ "context"
+ "errors"
+ "math"
+ "net/http"
+ "sort"
+ "strconv"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
+
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ channelMonitorCostDefaultDays = 30
+ channelMonitorCostMaxDays = 90
+ channelMonitorCostDaySeconds = int64(24 * 60 * 60)
+ channelMonitorCostOffset = int64(8 * 60 * 60)
+)
+
+type channelMonitorCostDay struct {
+ Date string `json:"date"`
+ StartAt int64 `json:"start_at"`
+ CostCNY float64 `json:"cost_cny"`
+}
+
+type channelMonitorCostChannel struct {
+ ChannelId int `json:"channel_id"`
+ ChannelName string `json:"channel_name"`
+ CostCNY float64 `json:"cost_cny"`
+}
+
+type channelMonitorCostCoverage struct {
+ IncludedChannelCount int `json:"included_channel_count"`
+ UnresolvedChannelCount int `json:"unresolved_channel_count"`
+ FreeGroupChannelCount int `json:"free_group_channel_count"`
+}
+
+type channelMonitorCostOverview struct {
+ Days int `json:"days"`
+ GeneratedAt int64 `json:"generated_at"`
+ TodayCostCNY float64 `json:"today_cost_cny"`
+ YesterdayCostCNY float64 `json:"yesterday_cost_cny"`
+ TotalCostCNY float64 `json:"total_cost_cny"`
+ Coverage channelMonitorCostCoverage `json:"coverage"`
+ Items []channelMonitorCostDay `json:"items"`
+ Channels []channelMonitorCostChannel `json:"channels"`
+}
+
+func GetChannelMonitorCostOverview(c *gin.Context) {
+ days := channelMonitorCostDefaultDays
+ if rawDays := c.Query("days"); rawDays != "" {
+ parsedDays, err := strconv.Atoi(rawDays)
+ if err != nil || parsedDays < 1 || parsedDays > channelMonitorCostMaxDays {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "统计天数必须在 1 到 90 之间"})
+ return
+ }
+ days = parsedDays
+ }
+
+ overview, err := getChannelMonitorCostOverview(c.Request.Context(), days, common.GetTimestamp())
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, overview)
+}
+
+func getChannelMonitorCostOverview(ctx context.Context, days int, now int64) (channelMonitorCostOverview, error) {
+ todayStart := channelMonitorCostDayStart(now)
+ startTimestamp := todayStart - int64(days-1)*channelMonitorCostDaySeconds
+ endTimestamp := todayStart + channelMonitorCostDaySeconds
+
+ quotas, err := model.GetChannelMonitorDailyQuotas(ctx, startTimestamp, endTimestamp)
+ if err != nil {
+ return channelMonitorCostOverview{}, err
+ }
+ monitors, err := model.GetChannelRatioMonitors()
+ if err != nil {
+ return channelMonitorCostOverview{}, err
+ }
+ channels, err := model.GetAllChannelsForMonitor()
+ if err != nil {
+ return channelMonitorCostOverview{}, err
+ }
+
+ costRatios := make(map[int]float64, len(monitors))
+ for _, monitor := range monitors {
+ if monitor.UpdatedTime == 0 {
+ continue
+ }
+ conversion, parseErr := service.ParseChannelMonitorCostConversion(monitor.CostConversion)
+ if parseErr != nil || conversion.Mode == service.ChannelMonitorCostConversionNone {
+ continue
+ }
+ costRatio, _, ratioErr := service.CalculateChannelMonitorCostRatio(monitor.Ratio, conversion)
+ if ratioErr != nil || !validChannelMonitorCostValue(costRatio) || costRatio < 0 {
+ continue
+ }
+ costRatios[monitor.ChannelId] = costRatio
+ }
+
+ channelNames := make(map[int]string, len(channels))
+ for _, channel := range channels {
+ channelNames[channel.Id] = channel.Name
+ }
+ groupRatios := ratio_setting.GetGroupRatioCopy()
+ quotaPerUnit := common.QuotaPerUnit
+ if math.IsNaN(quotaPerUnit) || math.IsInf(quotaPerUnit, 0) || quotaPerUnit <= 0 {
+ return channelMonitorCostOverview{}, errors.New("额度单位配置无效,无法回算渠道成本")
+ }
+ dailyCosts := make(map[int64]float64, days)
+ channelCosts := make(map[int]float64)
+ includedChannels := make(map[int]struct{})
+ unresolvedChannels := make(map[int]struct{})
+ freeGroupChannels := make(map[int]struct{})
+
+ for _, quota := range quotas {
+ if quota.Quota == 0 {
+ continue
+ }
+ costRatio, configured := costRatios[quota.ChannelId]
+ if !configured {
+ unresolvedChannels[quota.ChannelId] = struct{}{}
+ continue
+ }
+ groupRatio, exists := groupRatios[quota.Group]
+ if !exists {
+ groupRatio = 1
+ }
+ if math.IsNaN(groupRatio) || math.IsInf(groupRatio, 0) || groupRatio <= 0 {
+ freeGroupChannels[quota.ChannelId] = struct{}{}
+ continue
+ }
+
+ baseCostUSD := float64(quota.Quota) / quotaPerUnit / groupRatio
+ costCNY := baseCostUSD * costRatio
+ if !validChannelMonitorCostValue(costCNY) {
+ common.SysError("渠道监控成本统计跳过异常成本值")
+ continue
+ }
+ dailyCosts[quota.DayStart] += costCNY
+ channelCosts[quota.ChannelId] += costCNY
+ includedChannels[quota.ChannelId] = struct{}{}
+ }
+
+ items := make([]channelMonitorCostDay, 0, days)
+ totalCostCNY := 0.0
+ for dayStart := startTimestamp; dayStart < endTimestamp; dayStart += channelMonitorCostDaySeconds {
+ costCNY := dailyCosts[dayStart]
+ if !validChannelMonitorCostValue(costCNY) {
+ common.SysError("渠道监控成本统计跳过异常每日汇总值")
+ costCNY = 0
+ }
+ items = append(items, channelMonitorCostDay{
+ Date: channelMonitorCostDate(dayStart),
+ StartAt: dayStart,
+ CostCNY: costCNY,
+ })
+ totalCostCNY += costCNY
+ }
+
+ costChannels := make([]channelMonitorCostChannel, 0, len(channelCosts))
+ for channelId, costCNY := range channelCosts {
+ if !validChannelMonitorCostValue(costCNY) || costCNY == 0 {
+ continue
+ }
+ channelName := channelNames[channelId]
+ if channelName == "" {
+ channelName = "已删除渠道"
+ }
+ costChannels = append(costChannels, channelMonitorCostChannel{
+ ChannelId: channelId,
+ ChannelName: channelName,
+ CostCNY: costCNY,
+ })
+ }
+ sort.Slice(costChannels, func(i int, j int) bool {
+ if costChannels[i].CostCNY == costChannels[j].CostCNY {
+ return costChannels[i].ChannelId < costChannels[j].ChannelId
+ }
+ return costChannels[i].CostCNY > costChannels[j].CostCNY
+ })
+
+ overview := channelMonitorCostOverview{
+ Days: days,
+ GeneratedAt: now,
+ TotalCostCNY: totalCostCNY,
+ Coverage: channelMonitorCostCoverage{
+ IncludedChannelCount: len(includedChannels),
+ UnresolvedChannelCount: len(unresolvedChannels),
+ FreeGroupChannelCount: len(freeGroupChannels),
+ },
+ Items: items,
+ Channels: costChannels,
+ }
+ if len(items) > 0 {
+ overview.TodayCostCNY = items[len(items)-1].CostCNY
+ }
+ if len(items) > 1 {
+ overview.YesterdayCostCNY = items[len(items)-2].CostCNY
+ }
+ return overview, nil
+}
+
+func channelMonitorCostDayStart(timestamp int64) int64 {
+ return ((timestamp+channelMonitorCostOffset)/channelMonitorCostDaySeconds)*channelMonitorCostDaySeconds - channelMonitorCostOffset
+}
+
+func channelMonitorCostDate(dayStart int64) string {
+ return time.Unix(dayStart+channelMonitorCostOffset, 0).UTC().Format("2006-01-02")
+}
+
+func validChannelMonitorCostValue(value float64) bool {
+ return !math.IsNaN(value) && !math.IsInf(value, 0)
+}
diff --git a/controller/channel_monitor_cost_test.go b/controller/channel_monitor_cost_test.go
new file mode 100644
index 000000000000..20c4c223d86a
--- /dev/null
+++ b/controller/channel_monitor_cost_test.go
@@ -0,0 +1,90 @@
+package controller
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetChannelMonitorCostOverviewAggregatesBeijingDaysAndCoverage(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+
+ originalQuotaPerUnit := common.QuotaPerUnit
+ common.QuotaPerUnit = 500_000
+ t.Cleanup(func() {
+ common.QuotaPerUnit = originalQuotaPerUnit
+ })
+
+ originalGroupRatios := ratio_setting.GroupRatio2JSONString()
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"vip":2,"free":0}`))
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatios))
+ })
+
+ require.NoError(t, db.Create(&[]model.Channel{
+ {Id: 1, Name: "已配置渠道", Key: "key-1", Group: "vip"},
+ {Id: 2, Name: "未配置换算", Key: "key-2", Group: "vip"},
+ {Id: 3, Name: "免费分组渠道", Key: "key-3", Group: "free"},
+ }).Error)
+ costConversion, err := service.MarshalChannelMonitorCostConversion(service.ChannelMonitorCostConversion{
+ Mode: service.ChannelMonitorCostConversionRecharge,
+ PaidCNY: 10,
+ CreditedUSD: 2,
+ })
+ require.NoError(t, err)
+ require.NoError(t, db.Create(&[]model.ChannelRatioMonitor{
+ {ChannelId: 1, Ratio: 0.5, UpdatedTime: 1, CostConversion: costConversion},
+ {ChannelId: 2, Ratio: 1, UpdatedTime: 1},
+ {ChannelId: 3, Ratio: 0.5, UpdatedTime: 1, CostConversion: costConversion},
+ }).Error)
+
+ yesterday := time.Date(2026, 7, 21, 15, 58, 0, 0, time.UTC).Unix()
+ today := time.Date(2026, 7, 21, 16, 0, 0, 0, time.UTC).Unix()
+ require.NoError(t, db.Create(&[]model.Log{
+ {CreatedAt: yesterday, Type: model.LogTypeConsume, ChannelId: 1, Group: "vip", Quota: 1_000_000},
+ {CreatedAt: yesterday + 60, Type: model.LogTypeRefund, ChannelId: 1, Group: "vip", Quota: 250_000},
+ {CreatedAt: today, Type: model.LogTypeConsume, ChannelId: 1, Group: "vip", Quota: 500_000},
+ {CreatedAt: today + 60, Type: model.LogTypeRefund, ChannelId: 1, Group: "vip", Quota: 750_000},
+ {CreatedAt: today, Type: model.LogTypeConsume, ChannelId: 2, Group: "vip", Quota: 500_000},
+ {CreatedAt: today, Type: model.LogTypeConsume, ChannelId: 3, Group: "free", Quota: 500_000},
+ }).Error)
+
+ now := time.Date(2026, 7, 22, 4, 0, 0, 0, time.UTC).Unix()
+ overview, err := getChannelMonitorCostOverview(context.Background(), 2, now)
+ require.NoError(t, err)
+ require.Len(t, overview.Items, 2)
+ assert.Equal(t, "2026-07-21", overview.Items[0].Date)
+ assert.Equal(t, "2026-07-22", overview.Items[1].Date)
+ assert.InDelta(t, 1.875, overview.YesterdayCostCNY, 1e-9)
+ assert.InDelta(t, -0.625, overview.TodayCostCNY, 1e-9)
+ assert.InDelta(t, 1.25, overview.TotalCostCNY, 1e-9)
+ assert.Equal(t, 1, overview.Coverage.IncludedChannelCount)
+ assert.Equal(t, 1, overview.Coverage.UnresolvedChannelCount)
+ assert.Equal(t, 1, overview.Coverage.FreeGroupChannelCount)
+ require.Len(t, overview.Channels, 1)
+ assert.Equal(t, 1, overview.Channels[0].ChannelId)
+ assert.Equal(t, "已配置渠道", overview.Channels[0].ChannelName)
+ assert.InDelta(t, 1.25, overview.Channels[0].CostCNY, 1e-9)
+}
+
+func TestGetChannelMonitorCostOverviewRejectsInvalidDays(t *testing.T) {
+ setupChannelMonitorControllerTestDB(t)
+ for _, days := range []string{"0", "91", "invalid"} {
+ t.Run(days, func(t *testing.T) {
+ ctx, recorder := newChannelMonitorControllerContext(t, "GET", "/api/channel_monitor/cost?days="+days, nil)
+
+ GetChannelMonitorCostOverview(ctx)
+
+ assert.Equal(t, 400, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "统计天数必须在 1 到 90 之间")
+ })
+ }
+}
diff --git a/controller/channel_monitor_group_membership.go b/controller/channel_monitor_group_membership.go
new file mode 100644
index 000000000000..fb3b81a9b2fe
--- /dev/null
+++ b/controller/channel_monitor_group_membership.go
@@ -0,0 +1,69 @@
+package controller
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+type channelMonitorGroupMembershipUpdateRequest struct {
+ Group string `json:"group"`
+ ChannelIds []int `json:"channel_ids"`
+}
+
+func UpdateChannelMonitorGroupChannels(c *gin.Context) {
+ var request channelMonitorGroupMembershipUpdateRequest
+ if err := common.DecodeJson(c.Request.Body, &request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的参数"})
+ return
+ }
+ request.Group = strings.TrimSpace(request.Group)
+ if request.Group == "" || utf8.RuneCountInString(request.Group) > 64 || strings.ContainsAny(request.Group, ",\r\n") {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "分组名称无效"})
+ return
+ }
+ for _, channelId := range request.ChannelIds {
+ if channelId <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "渠道 ID 必须为正整数"})
+ return
+ }
+ }
+
+ result, err := model.ReplaceChannelMonitorGroupMembers(request.Group, request.ChannelIds)
+ if err != nil {
+ if errors.Is(err, model.ErrChannelMonitorGroupInvalid) ||
+ errors.Is(err, model.ErrChannelMonitorGroupChannelInvalid) ||
+ errors.Is(err, model.ErrChannelMonitorGroupChannelNotFound) ||
+ errors.Is(err, model.ErrChannelMonitorGroupMembershipRequired) ||
+ errors.Is(err, model.ErrChannelMonitorGroupMembershipListTooLong) {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ common.ApiError(c, err)
+ return
+ }
+
+ if len(result.AddedChannelIds) > 0 || len(result.RemovedChannelIds) > 0 {
+ model.InitChannelCache()
+ }
+ recordManageAudit(c, "channel.monitor_group_channels_update", map[string]interface{}{
+ "group": result.Group,
+ "channel_count": len(result.ChannelIds),
+ "channel_ids": result.ChannelIds,
+ "added_count": len(result.AddedChannelIds),
+ "added_channel_ids": result.AddedChannelIds,
+ "removed_count": len(result.RemovedChannelIds),
+ "removed_channel_ids": result.RemovedChannelIds,
+ })
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": result,
+ })
+}
diff --git a/controller/channel_monitor_upstream_version.go b/controller/channel_monitor_upstream_version.go
new file mode 100644
index 000000000000..80f73c9c37c9
--- /dev/null
+++ b/controller/channel_monitor_upstream_version.go
@@ -0,0 +1,54 @@
+package controller
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+type channelMonitorUpstreamVersionRequest struct {
+ BaseURL string `json:"base_url"`
+}
+
+// FetchChannelMonitorSub2APIUpstreamVersion returns the public Sub2API build
+// version without requiring either supported credential mode.
+func FetchChannelMonitorSub2APIUpstreamVersion(c *gin.Context) {
+ channelId, err := strconv.Atoi(c.Param("id"))
+ if err != nil || channelId <= 0 {
+ common.ApiErrorMsg(c, "无效的渠道 ID")
+ return
+ }
+ channel, err := model.GetChannelById(channelId, false)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ var request channelMonitorUpstreamVersionRequest
+ if err := common.DecodeJson(c.Request.Body, &request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的参数"})
+ return
+ }
+ if strings.TrimSpace(request.BaseURL) == "" {
+ common.ApiError(c, errors.New("请输入上游面板地址"))
+ return
+ }
+
+ result, err := service.FetchSub2APIUpstreamVersion(
+ c.Request.Context(),
+ request.BaseURL,
+ channel.GetSetting().Proxy,
+ )
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, result)
+}
diff --git a/controller/channel_ratio_monitor.go b/controller/channel_ratio_monitor.go
new file mode 100644
index 000000000000..5dd97a04415a
--- /dev/null
+++ b/controller/channel_ratio_monitor.go
@@ -0,0 +1,1436 @@
+package controller
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "net/http"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+const (
+ maxChannelMonitorRatio = 1_000_000
+ maxChannelMonitorBalanceThreshold = 1_000_000_000_000
+)
+
+type channelRatioUpdateRequest struct {
+ Ratio *float64 `json:"ratio"`
+ Remark string `json:"remark"`
+}
+
+type groupRatioUpdateRequest struct {
+ Group string `json:"group"`
+ Ratio *float64 `json:"ratio"`
+}
+
+type groupRatioSyncRequest struct {
+ Group string `json:"group"`
+ Coefficient *float64 `json:"coefficient"`
+}
+
+type channelSmartScheduleConfigUpdateRequest struct {
+ Excluded *bool `json:"excluded"`
+ Reset bool `json:"reset"`
+}
+
+type channelMonitorUpstreamRequest struct {
+ Type string `json:"type"`
+ BaseURL string `json:"base_url"`
+ Group string `json:"group"`
+ AuthType string `json:"auth_type"`
+ UserId int `json:"user_id"`
+ AccessToken string `json:"access_token"`
+ Account string `json:"account"`
+ Password string `json:"password"`
+ SingleChannelAction string `json:"single_channel_action"`
+ MultipleChannelsAction string `json:"multiple_channels_action"`
+ BalanceWarningThreshold json.RawMessage `json:"balance_warning_threshold"`
+ BalanceAutoDisableThreshold json.RawMessage `json:"balance_auto_disable_threshold"`
+ RatioSyncEnabled *bool `json:"ratio_sync_enabled"`
+ BalanceSyncEnabled *bool `json:"balance_sync_enabled"`
+ CostConversion *service.ChannelMonitorCostConversion `json:"cost_conversion"`
+ CustomConfig *service.ChannelMonitorCustomUpstreamConfig `json:"custom_config"`
+}
+
+type channelMonitorUpstreamConfig struct {
+ Type string `json:"type"`
+ BaseURL string `json:"base_url"`
+ Group string `json:"group"`
+ AuthType string `json:"auth_type"`
+ UserId int `json:"user_id"`
+ HasAccessToken bool `json:"has_access_token"`
+ Account string `json:"account"`
+ HasPassword bool `json:"has_password"`
+ SingleChannelAction string `json:"single_channel_action"`
+ MultipleChannelsAction string `json:"multiple_channels_action"`
+ BalanceWarningThreshold *float64 `json:"balance_warning_threshold"`
+ BalanceAutoDisableThreshold *float64 `json:"balance_auto_disable_threshold"`
+ RatioSyncEnabled bool `json:"ratio_sync_enabled"`
+ BalanceSyncEnabled bool `json:"balance_sync_enabled"`
+ CostConversion service.ChannelMonitorCostConversion `json:"cost_conversion"`
+ CustomConfig *service.ChannelMonitorCustomUpstreamConfig `json:"custom_config,omitempty"`
+}
+
+type channelMonitorItem struct {
+ Id int `json:"id"`
+ Name string `json:"name"`
+ Type int `json:"type"`
+ Status int `json:"status"`
+ Priority int64 `json:"priority"`
+ Weight int `json:"weight"`
+ BaseURL string `json:"base_url"`
+ Models string `json:"models"`
+ TestModel *string `json:"test_model"`
+ Groups []string `json:"groups"`
+ Ratio *float64 `json:"ratio"`
+ PreviousRatio *float64 `json:"previous_ratio"`
+ CostRatio *float64 `json:"cost_ratio"`
+ PreviousCostRatio *float64 `json:"previous_cost_ratio"`
+ ConversionFactor *float64 `json:"conversion_factor"`
+ Remark string `json:"remark"`
+ ChannelRemark string `json:"channel_remark"`
+ UpdatedTime int64 `json:"updated_time"`
+ UpdatedBy int `json:"updated_by"`
+ UpdatedByUsername string `json:"updated_by_username"`
+ LastFetchStatus string `json:"last_fetch_status"`
+ LastFetchError string `json:"last_fetch_error"`
+ LastFetchTime int64 `json:"last_fetch_time"`
+ ConsecutiveFailures int `json:"consecutive_failures"`
+ UpstreamBalance *float64 `json:"upstream_balance"`
+ LastBalanceTime int64 `json:"last_balance_time"`
+ LastBalanceError string `json:"last_balance_error"`
+ SmartScheduleExcluded bool `json:"smart_schedule_excluded"`
+ LastScheduleStatus string `json:"last_schedule_status"`
+ LastScheduleError string `json:"last_schedule_error"`
+ LastScheduleScore *float64 `json:"last_schedule_score"`
+ LastSchedulePriority int64 `json:"last_schedule_priority"`
+ LastScheduleWeight uint `json:"last_schedule_weight"`
+ LastScheduleTime int64 `json:"last_schedule_time"`
+ Upstream *channelMonitorUpstreamConfig `json:"upstream"`
+}
+
+func validateChannelMonitorRatio(ratio *float64) bool {
+ return ratio != nil && !math.IsNaN(*ratio) && !math.IsInf(*ratio, 0) && *ratio >= 0 && *ratio <= maxChannelMonitorRatio
+}
+
+func channelMonitorUpstreamFromModel(monitor model.ChannelRatioMonitor) *channelMonitorUpstreamConfig {
+ if monitor.UpstreamType == "" {
+ return nil
+ }
+ costConversion, err := service.ParseChannelMonitorCostConversion(monitor.CostConversion)
+ if err != nil {
+ costConversion = service.ChannelMonitorCostConversion{Mode: service.ChannelMonitorCostConversionNone}
+ }
+ var customConfig *service.ChannelMonitorCustomUpstreamConfig
+ if monitor.UpstreamType == service.CustomUpstreamType {
+ parsed, parseErr := service.ParseChannelMonitorCustomUpstreamConfig(monitor.CustomUpstreamConfig)
+ if parseErr == nil {
+ sanitized := service.SanitizeChannelMonitorCustomUpstreamConfig(parsed)
+ customConfig = &sanitized
+ }
+ }
+ return &channelMonitorUpstreamConfig{
+ Type: monitor.UpstreamType,
+ BaseURL: monitor.UpstreamBaseURL,
+ Group: monitor.UpstreamGroup,
+ AuthType: monitor.UpstreamAuthType,
+ UserId: monitor.UpstreamUserId,
+ HasAccessToken: monitor.UpstreamAccessToken != "",
+ Account: monitor.UpstreamAccount,
+ HasPassword: monitor.UpstreamPassword != "",
+ SingleChannelAction: normalizeChannelMonitorPolicyAction(monitor.SingleChannelAction),
+ MultipleChannelsAction: normalizeChannelMonitorPolicyAction(monitor.MultipleChannelsAction),
+ BalanceWarningThreshold: monitor.BalanceWarningThreshold,
+ BalanceAutoDisableThreshold: monitor.BalanceAutoDisableThreshold,
+ RatioSyncEnabled: !monitor.UpstreamRatioSyncDisabled,
+ BalanceSyncEnabled: !monitor.UpstreamBalanceSyncDisabled,
+ CostConversion: costConversion,
+ CustomConfig: customConfig,
+ }
+}
+
+func channelMonitorCostRatioFromModel(monitor model.ChannelRatioMonitor, upstreamRatio float64) (float64, float64, error) {
+ costConversion, err := service.ParseChannelMonitorCostConversion(monitor.CostConversion)
+ if err != nil {
+ return 0, 0, err
+ }
+ return service.CalculateChannelMonitorCostRatio(upstreamRatio, costConversion)
+}
+
+func channelMonitorCostConversionLabel(config service.ChannelMonitorCostConversion) string {
+ switch config.Mode {
+ case service.ChannelMonitorCostConversionRecharge:
+ return "充值换算"
+ case service.ChannelMonitorCostConversionSubscription:
+ return "订阅换算"
+ default:
+ return "不换算"
+ }
+}
+
+func channelMonitorUpstreamTypeLabel(upstreamType string) string {
+ switch upstreamType {
+ case service.NewAPIUpstreamType:
+ return "New API"
+ case service.Sub2APIUpstreamType:
+ return "Sub2API"
+ case service.CustomUpstreamType:
+ return "自定义上游"
+ default:
+ return upstreamType
+ }
+}
+
+func resolveChannelMonitorBalanceThreshold(raw json.RawMessage, existing *float64, invalidMessage string) (*float64, error) {
+ if len(raw) == 0 {
+ if existing == nil {
+ return nil, nil
+ }
+ value := *existing
+ return &value, nil
+ }
+ if strings.TrimSpace(string(raw)) == "null" {
+ return nil, nil
+ }
+
+ var threshold float64
+ if err := common.Unmarshal(raw, &threshold); err != nil ||
+ math.IsNaN(threshold) || math.IsInf(threshold, 0) ||
+ threshold < 0 || threshold > maxChannelMonitorBalanceThreshold {
+ return nil, errors.New(invalidMessage)
+ }
+ return &threshold, nil
+}
+
+func resolveChannelMonitorUpstreamRequest(channel *model.Channel, request channelMonitorUpstreamRequest, requireGroup bool) (service.ChannelMonitorUpstreamConfig, error) {
+ request.Type = strings.TrimSpace(request.Type)
+ if request.Type == "" {
+ request.Type = service.NewAPIUpstreamType
+ }
+ request.Group = strings.TrimSpace(request.Group)
+ if (requireGroup && request.Type != service.CustomUpstreamType && request.Group == "") || utf8.RuneCountInString(request.Group) > 64 {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("上游分组名称无效")
+ }
+
+ baseURL := strings.TrimSpace(request.BaseURL)
+ if baseURL == "" {
+ baseURL = channel.GetBaseURL()
+ }
+ var normalizedBaseURL string
+ var err error
+ if request.Type == service.CustomUpstreamType {
+ normalizedBaseURL, err = service.NormalizeChannelMonitorCustomBaseURL(baseURL)
+ } else {
+ normalizedBaseURL, err = service.NormalizeNewAPIBaseURL(baseURL)
+ }
+ if err != nil {
+ return service.ChannelMonitorUpstreamConfig{}, err
+ }
+
+ costConversion := service.ChannelMonitorCostConversion{Mode: service.ChannelMonitorCostConversionNone}
+ if request.CostConversion != nil {
+ costConversion, err = service.NormalizeChannelMonitorCostConversion(*request.CostConversion)
+ if err != nil {
+ return service.ChannelMonitorUpstreamConfig{}, err
+ }
+ }
+
+ request.AuthType = strings.TrimSpace(request.AuthType)
+ config := service.ChannelMonitorUpstreamConfig{
+ Type: request.Type,
+ BaseURL: normalizedBaseURL,
+ Group: request.Group,
+ AuthType: request.AuthType,
+ Proxy: channel.GetSetting().Proxy,
+ SkipBalance: request.BalanceSyncEnabled != nil && !*request.BalanceSyncEnabled,
+ CostConversion: costConversion,
+ }
+ switch request.Type {
+ case service.NewAPIUpstreamType:
+ if request.AuthType != service.NewAPIUpstreamAuthPublic && request.AuthType != service.NewAPIUpstreamAuthUser {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("New API 认证方式无效")
+ }
+ if request.AuthType == service.NewAPIUpstreamAuthPublic {
+ return config, nil
+ }
+ if request.UserId <= 0 {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("上游用户 ID 必须大于 0")
+ }
+ config.UserID = request.UserId
+ config.AccessToken = strings.TrimSpace(request.AccessToken)
+ if utf8.RuneCountInString(config.AccessToken) > 4096 {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("上游访问令牌过长")
+ }
+ if config.AccessToken == "" {
+ monitor, findErr := model.GetChannelRatioMonitor(channel.Id)
+ if findErr != nil && !errors.Is(findErr, gorm.ErrRecordNotFound) {
+ return service.ChannelMonitorUpstreamConfig{}, findErr
+ }
+ if findErr == nil &&
+ monitor.UpstreamType == config.Type &&
+ monitor.UpstreamBaseURL == config.BaseURL &&
+ monitor.UpstreamAuthType == config.AuthType &&
+ monitor.UpstreamUserId == config.UserID {
+ config.AccessToken = monitor.UpstreamAccessToken
+ }
+ }
+ if config.AccessToken == "" {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("上游访问令牌不能为空")
+ }
+ return config, nil
+ case service.Sub2APIUpstreamType:
+ if request.AuthType == service.Sub2APIAuthAPIKey {
+ if len(channel.GetKeys()) == 0 {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("Sub2API API Key 认证需要先在渠道中配置上游 API Key")
+ }
+ config.ChannelKeys = channel.GetKeys()
+ return config, nil
+ }
+ if request.AuthType == service.Sub2APIAuthAccount {
+ config.Account = strings.TrimSpace(request.Account)
+ if config.Account == "" {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("Sub2API 登录邮箱不能为空")
+ }
+ if utf8.RuneCountInString(config.Account) > 320 {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("Sub2API 登录邮箱过长")
+ }
+ config.Password = request.Password
+ if utf8.RuneCountInString(config.Password) > 4096 {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("Sub2API 登录密码过长")
+ }
+ if config.Password == "" {
+ monitor, findErr := model.GetChannelRatioMonitor(channel.Id)
+ if findErr != nil && !errors.Is(findErr, gorm.ErrRecordNotFound) {
+ return service.ChannelMonitorUpstreamConfig{}, findErr
+ }
+ if findErr == nil &&
+ monitor.UpstreamType == config.Type &&
+ monitor.UpstreamBaseURL == config.BaseURL &&
+ monitor.UpstreamAuthType == config.AuthType &&
+ monitor.UpstreamAccount == config.Account {
+ config.Password = monitor.UpstreamPassword
+ }
+ }
+ if config.Password == "" {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("Sub2API 登录密码不能为空")
+ }
+ return config, nil
+ }
+ if request.AuthType != service.Sub2APIAuthToken {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("Sub2API 认证方式无效")
+ }
+ config.AccessToken = strings.TrimSpace(request.AccessToken)
+ if utf8.RuneCountInString(config.AccessToken) > 4096 {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("Sub2API Token 过长")
+ }
+ if config.AccessToken == "" {
+ monitor, findErr := model.GetChannelRatioMonitor(channel.Id)
+ if findErr != nil && !errors.Is(findErr, gorm.ErrRecordNotFound) {
+ return service.ChannelMonitorUpstreamConfig{}, findErr
+ }
+ if findErr == nil &&
+ monitor.UpstreamType == config.Type &&
+ monitor.UpstreamBaseURL == config.BaseURL &&
+ monitor.UpstreamAuthType == config.AuthType {
+ config.AccessToken = monitor.UpstreamAccessToken
+ }
+ }
+ if config.AccessToken == "" {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("Sub2API Token 不能为空")
+ }
+ return config, nil
+ case service.CustomUpstreamType:
+ config.AuthType = service.CustomUpstreamAuthType
+ var existingConfig *service.ChannelMonitorCustomUpstreamConfig
+ monitor, findErr := model.GetChannelRatioMonitor(channel.Id)
+ if findErr != nil && !errors.Is(findErr, gorm.ErrRecordNotFound) {
+ return service.ChannelMonitorUpstreamConfig{}, findErr
+ }
+ if findErr == nil && monitor.UpstreamType == service.CustomUpstreamType && monitor.UpstreamBaseURL == normalizedBaseURL {
+ parsed, parseErr := service.ParseChannelMonitorCustomUpstreamConfig(monitor.CustomUpstreamConfig)
+ if parseErr == nil {
+ existingConfig = &parsed
+ }
+ }
+ if request.CustomConfig == nil {
+ if existingConfig == nil {
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("自定义上游配置不能为空")
+ }
+ config.CustomConfig = *existingConfig
+ return config, nil
+ }
+ customConfig, normalizeErr := service.NormalizeChannelMonitorCustomUpstreamConfigWithExisting(*request.CustomConfig, existingConfig)
+ if normalizeErr != nil {
+ return service.ChannelMonitorUpstreamConfig{}, normalizeErr
+ }
+ config.CustomConfig = customConfig
+ return config, nil
+ default:
+ return service.ChannelMonitorUpstreamConfig{}, errors.New("上游类型无效")
+ }
+}
+
+func getChannelMonitorOperator(c *gin.Context) (int, string) {
+ operatorId := c.GetInt("id")
+ operatorUsername := c.GetString("username")
+ if operatorUsername == "" {
+ operatorUsername, _ = model.GetUsernameById(operatorId, false)
+ }
+ return operatorId, operatorUsername
+}
+
+func GetChannelMonitorOverview(c *gin.Context) {
+ channels, err := model.GetAllChannelsForMonitor()
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ monitors, err := model.GetChannelRatioMonitors()
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ monitorByChannel := make(map[int]model.ChannelRatioMonitor, len(monitors))
+ for _, monitor := range monitors {
+ monitorByChannel[monitor.ChannelId] = monitor
+ }
+
+ groupRatios := ratio_setting.GetGroupRatioCopy()
+ channelOrder := getChannelMonitorChannelOrder(channels)
+ items := make([]channelMonitorItem, 0, len(channels))
+ for _, channel := range channels {
+ groups := channel.GetGroups()
+ for _, group := range groups {
+ if _, exists := groupRatios[group]; !exists {
+ groupRatios[group] = 1
+ }
+ }
+ channelRemark := ""
+ if channel.Remark != nil {
+ channelRemark = strings.TrimSpace(*channel.Remark)
+ }
+ item := channelMonitorItem{
+ Id: channel.Id,
+ Name: channel.Name,
+ Type: channel.Type,
+ Status: channel.Status,
+ Priority: channel.GetPriority(),
+ Weight: channel.GetWeight(),
+ BaseURL: channel.GetBaseURL(),
+ Models: channel.Models,
+ TestModel: channel.TestModel,
+ Groups: groups,
+ ChannelRemark: channelRemark,
+ }
+ if monitor, exists := monitorByChannel[channel.Id]; exists {
+ item.LastFetchStatus = monitor.LastFetchStatus
+ item.LastFetchError = monitor.LastFetchError
+ item.LastFetchTime = monitor.LastFetchTime
+ item.ConsecutiveFailures = monitor.ConsecutiveFailures
+ item.UpstreamBalance = monitor.UpstreamBalance
+ item.LastBalanceTime = monitor.LastBalanceTime
+ item.LastBalanceError = monitor.LastBalanceError
+ item.SmartScheduleExcluded = monitor.SmartScheduleExcluded
+ item.LastScheduleStatus = monitor.LastScheduleStatus
+ item.LastScheduleError = monitor.LastScheduleError
+ item.LastScheduleScore = monitor.LastScheduleScore
+ item.LastSchedulePriority = monitor.LastSchedulePriority
+ item.LastScheduleWeight = monitor.LastScheduleWeight
+ item.LastScheduleTime = monitor.LastScheduleTime
+ if monitor.UpdatedTime > 0 {
+ item.Ratio = &monitor.Ratio
+ item.PreviousRatio = monitor.PreviousRatio
+ costRatio, factor, conversionErr := channelMonitorCostRatioFromModel(monitor, monitor.Ratio)
+ if conversionErr == nil {
+ item.CostRatio = &costRatio
+ item.ConversionFactor = &factor
+ if monitor.PreviousRatio != nil {
+ previousCostRatio, _, previousErr := channelMonitorCostRatioFromModel(monitor, *monitor.PreviousRatio)
+ if previousErr == nil {
+ item.PreviousCostRatio = &previousCostRatio
+ }
+ }
+ }
+ item.Remark = monitor.Remark
+ item.UpdatedTime = monitor.UpdatedTime
+ item.UpdatedBy = monitor.UpdatedBy
+ item.UpdatedByUsername = monitor.UpdatedByUsername
+ }
+ item.Upstream = channelMonitorUpstreamFromModel(monitor)
+ }
+ items = append(items, item)
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": gin.H{
+ "channels": items,
+ "channel_order": channelOrder,
+ "group_ratios": groupRatios,
+ "group_coefficients": getChannelMonitorGroupCoefficients(),
+ "settings": getChannelMonitorSettings(),
+ },
+ })
+}
+
+func UpdateChannelMonitorSmartScheduleConfig(c *gin.Context) {
+ channelId, err := strconv.Atoi(c.Param("id"))
+ if err != nil || channelId <= 0 {
+ common.ApiErrorMsg(c, "无效的渠道 ID")
+ return
+ }
+ if _, err := model.GetChannelById(channelId, false); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ var request channelSmartScheduleConfigUpdateRequest
+ if err := common.DecodeJson(c.Request.Body, &request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的参数"})
+ return
+ }
+ if request.Excluded == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供要更新的调度设置"})
+ return
+ }
+
+ options := model.ChannelSmartScheduleConfigOptions{Excluded: *request.Excluded}
+ reset := !options.Excluded && request.Reset
+ if reset {
+ priority := int64(0)
+ weight := uint(channelMonitorSmartScheduleMinWeight)
+ options.Priority = &priority
+ options.Weight = &weight
+ }
+ monitor, err := model.SaveChannelSmartScheduleConfig(channelId, options)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if reset {
+ model.InitChannelCache()
+ }
+ recordManageAudit(c, "channel.monitor_smart_schedule_config_update", map[string]interface{}{
+ "id": channelId, "excluded": options.Excluded, "reset": reset,
+ })
+ common.ApiSuccess(c, gin.H{
+ "excluded": monitor.SmartScheduleExcluded,
+ })
+}
+
+func SyncChannelMonitorGroupRatio(c *gin.Context) {
+ var request groupRatioSyncRequest
+ if err := common.DecodeJson(c.Request.Body, &request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的参数"})
+ return
+ }
+ request.Group = strings.TrimSpace(request.Group)
+ if request.Group == "" || utf8.RuneCountInString(request.Group) > 64 {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "分组名称无效"})
+ return
+ }
+ if !validateChannelMonitorRatio(request.Coefficient) {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "系数必须在 0 到 1000000 之间"})
+ return
+ }
+
+ channels, err := model.GetAllChannelsForMonitor()
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ monitors, err := model.GetChannelRatioMonitors()
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ monitorByChannel := make(map[int]model.ChannelRatioMonitor, len(monitors))
+ for _, monitor := range monitors {
+ monitorByChannel[monitor.ChannelId] = monitor
+ }
+
+ highestUpstreamRatio := -1.0
+ highestCostRatio := -1.0
+ highestConversionFactor := 1.0
+ for _, channel := range channels {
+ if channel.Status != common.ChannelStatusEnabled {
+ continue
+ }
+ associated := false
+ for _, group := range channel.GetGroups() {
+ if group == request.Group {
+ associated = true
+ break
+ }
+ }
+ if !associated {
+ continue
+ }
+ monitor, exists := monitorByChannel[channel.Id]
+ if !exists || monitor.UpdatedTime <= 0 {
+ continue
+ }
+ costRatio, factor, conversionErr := channelMonitorCostRatioFromModel(monitor, monitor.Ratio)
+ if conversionErr != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "success": false,
+ "message": fmt.Sprintf("渠道 %s(ID %d)倍率换算失败:%s", channel.Name, channel.Id, conversionErr.Error()),
+ })
+ return
+ }
+ if costRatio > highestCostRatio {
+ highestCostRatio = costRatio
+ highestUpstreamRatio = monitor.Ratio
+ highestConversionFactor = factor
+ }
+ }
+ if highestCostRatio < 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "该分组没有已记录倍率的启用渠道"})
+ return
+ }
+ targetRatio := highestCostRatio * *request.Coefficient
+ if !validateChannelMonitorRatio(&targetRatio) {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "成本倍率乘以系数后的结果超出范围"})
+ return
+ }
+
+ groupRatios := ratio_setting.GetGroupRatioCopy()
+ groupRatios[request.Group] = targetRatio
+ coefficients := getChannelMonitorGroupCoefficients()
+ coefficients[request.Group] = *request.Coefficient
+ groupRatioBytes, err := common.Marshal(groupRatios)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ coefficientBytes, err := common.Marshal(coefficients)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if err := model.UpdateOptionsBulk(map[string]string{
+ "GroupRatio": string(groupRatioBytes),
+ channelMonitorGroupCoefficientsOption: string(coefficientBytes),
+ }); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ recordManageAudit(c, "channel.monitor_group_ratio_sync", map[string]interface{}{
+ "group": request.Group, "upstream_ratio": highestUpstreamRatio,
+ "conversion_factor": highestConversionFactor, "cost_ratio": highestCostRatio,
+ "coefficient": *request.Coefficient, "ratio": targetRatio,
+ })
+ common.ApiSuccess(c, gin.H{
+ "group": request.Group, "upstream_ratio": highestUpstreamRatio,
+ "conversion_factor": highestConversionFactor, "cost_ratio": highestCostRatio,
+ "coefficient": *request.Coefficient, "ratio": targetRatio,
+ })
+}
+
+func UpdateChannelMonitorRatio(c *gin.Context) {
+ channelId, err := strconv.Atoi(c.Param("id"))
+ if err != nil || channelId <= 0 {
+ common.ApiErrorMsg(c, "无效的渠道 ID")
+ return
+ }
+ if _, err := model.GetChannelById(channelId, false); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ var request channelRatioUpdateRequest
+ if err := common.DecodeJson(c.Request.Body, &request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的参数"})
+ return
+ }
+ request.Remark = strings.TrimSpace(request.Remark)
+ if !validateChannelMonitorRatio(request.Ratio) {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "倍率必须在 0 到 1000000 之间"})
+ return
+ }
+ if utf8.RuneCountInString(request.Remark) > 255 {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "备注不能超过 255 个字符"})
+ return
+ }
+
+ operatorId, operatorUsername := getChannelMonitorOperator(c)
+ monitor, created, changed, err := model.UpdateChannelRatioMonitor(
+ channelId,
+ *request.Ratio,
+ request.Remark,
+ operatorId,
+ operatorUsername,
+ )
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ recordManageAudit(c, "channel.monitor_ratio_update", map[string]interface{}{
+ "id": channelId, "ratio": *request.Ratio, "changed": changed,
+ })
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": gin.H{
+ "monitor": monitor,
+ "created": created,
+ "changed": changed,
+ },
+ })
+}
+
+func SaveChannelMonitorUpstreamConfig(c *gin.Context) {
+ channelId, err := strconv.Atoi(c.Param("id"))
+ if err != nil || channelId <= 0 {
+ common.ApiErrorMsg(c, "无效的渠道 ID")
+ return
+ }
+ channel, err := model.GetChannelById(channelId, true)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ var request channelMonitorUpstreamRequest
+ if err := common.DecodeJson(c.Request.Body, &request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的参数"})
+ return
+ }
+ config, err := resolveChannelMonitorUpstreamRequest(channel, request, true)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ existingMonitor, findErr := model.GetChannelRatioMonitor(channelId)
+ if findErr != nil && !errors.Is(findErr, gorm.ErrRecordNotFound) {
+ common.ApiError(c, findErr)
+ return
+ }
+ hasExistingMonitor := findErr == nil
+ if request.CostConversion == nil && hasExistingMonitor {
+ config.CostConversion, err = service.ParseChannelMonitorCostConversion(existingMonitor.CostConversion)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ }
+ ratioSyncEnabled := true
+ balanceSyncEnabled := true
+ if hasExistingMonitor {
+ ratioSyncEnabled = !existingMonitor.UpstreamRatioSyncDisabled
+ balanceSyncEnabled = !existingMonitor.UpstreamBalanceSyncDisabled
+ }
+ if request.RatioSyncEnabled != nil {
+ ratioSyncEnabled = *request.RatioSyncEnabled
+ }
+ if request.BalanceSyncEnabled != nil {
+ balanceSyncEnabled = *request.BalanceSyncEnabled
+ }
+
+ singleChannelAction := strings.TrimSpace(request.SingleChannelAction)
+ multipleChannelAction := strings.TrimSpace(request.MultipleChannelsAction)
+ if singleChannelAction == "" || multipleChannelAction == "" {
+ if hasExistingMonitor {
+ if singleChannelAction == "" {
+ singleChannelAction = normalizeChannelMonitorPolicyAction(existingMonitor.SingleChannelAction)
+ }
+ if multipleChannelAction == "" {
+ multipleChannelAction = normalizeChannelMonitorPolicyAction(existingMonitor.MultipleChannelsAction)
+ }
+ }
+ }
+ if singleChannelAction == "" {
+ singleChannelAction = channelMonitorPolicyActionNone
+ }
+ if multipleChannelAction == "" {
+ multipleChannelAction = channelMonitorPolicyActionNone
+ }
+ if normalizeChannelMonitorPolicyAction(singleChannelAction) != singleChannelAction ||
+ singleChannelAction == channelMonitorPolicyActionRemoveFromGroup {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "单渠道处理策略无效"})
+ return
+ }
+ if normalizeChannelMonitorPolicyAction(multipleChannelAction) != multipleChannelAction {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "多渠道处理策略无效"})
+ return
+ }
+ var existingBalanceWarningThreshold *float64
+ if hasExistingMonitor {
+ existingBalanceWarningThreshold = existingMonitor.BalanceWarningThreshold
+ }
+ balanceWarningThreshold, err := resolveChannelMonitorBalanceThreshold(
+ request.BalanceWarningThreshold,
+ existingBalanceWarningThreshold,
+ "余额预警值无效",
+ )
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ var existingBalanceAutoDisableThreshold *float64
+ if hasExistingMonitor {
+ existingBalanceAutoDisableThreshold = existingMonitor.BalanceAutoDisableThreshold
+ }
+ balanceAutoDisableThreshold, err := resolveChannelMonitorBalanceThreshold(
+ request.BalanceAutoDisableThreshold,
+ existingBalanceAutoDisableThreshold,
+ "余额自动禁用阈值无效",
+ )
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ if hasExistingMonitor && existingMonitor.UpdatedTime > 0 {
+ if _, _, err := service.CalculateChannelMonitorCostRatio(existingMonitor.Ratio, config.CostConversion); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ }
+ costConversion, err := service.MarshalChannelMonitorCostConversion(config.CostConversion)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ conversionFactor, err := service.ChannelMonitorCostConversionFactor(config.CostConversion)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ customConfig := ""
+ if config.Type == service.CustomUpstreamType {
+ if config.CustomConfig.Ratio.Source == service.ChannelMonitorCustomSourceFixed {
+ if _, _, err := service.CalculateChannelMonitorCostRatio(*config.CustomConfig.Ratio.FixedValue, config.CostConversion); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ }
+ customConfig, err = service.MarshalChannelMonitorCustomUpstreamConfig(config.CustomConfig)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ }
+
+ monitor, err := model.SaveChannelRatioUpstreamConfig(
+ channelId,
+ config.Type,
+ config.BaseURL,
+ config.Group,
+ config.AuthType,
+ config.UserID,
+ config.AccessToken,
+ model.ChannelRatioUpstreamOptions{
+ SingleChannelAction: singleChannelAction,
+ MultipleChannelsAction: multipleChannelAction,
+ BalanceWarningThreshold: balanceWarningThreshold,
+ BalanceAutoDisableThreshold: balanceAutoDisableThreshold,
+ RatioSyncEnabled: ratioSyncEnabled,
+ BalanceSyncEnabled: balanceSyncEnabled,
+ CostConversion: costConversion,
+ CustomUpstreamConfig: customConfig,
+ UpstreamAccount: config.Account,
+ UpstreamPassword: config.Password,
+ },
+ )
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ balanceAutoDisabled := false
+ if config.Type == service.CustomUpstreamType {
+ operatorId, operatorUsername := getChannelMonitorOperator(c)
+ if config.CustomConfig.Ratio.Source == service.ChannelMonitorCustomSourceFixed {
+ monitor, _, _, err = model.UpdateChannelRatioMonitorFromUpstream(
+ channelId,
+ *config.CustomConfig.Ratio.FixedValue,
+ "已应用自定义上游固定倍率",
+ operatorId,
+ operatorUsername,
+ )
+ if err != nil {
+ common.ApiError(c, fmt.Errorf("自定义上游配置已保存,但固定倍率写入失败: %w", err))
+ return
+ }
+ }
+ if config.CustomConfig.Balance.Source == service.ChannelMonitorCustomSourceFixed {
+ if err := model.RecordChannelRatioMonitorBalance(channelId, config.CustomConfig.Balance.FixedValue, ""); err != nil {
+ common.ApiError(c, fmt.Errorf("自定义上游配置已保存,但固定余额写入失败: %w", err))
+ return
+ }
+ monitor, err = model.GetChannelRatioMonitor(channelId)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ balanceAutoDisabled, err = autoDisableChannelMonitorForLowBalance(monitor, channel, *config.CustomConfig.Balance.FixedValue)
+ if err != nil {
+ common.ApiError(c, fmt.Errorf("自定义上游配置已保存,但余额自动禁用失败: %w", err))
+ return
+ }
+ if balanceAutoDisabled {
+ model.InitChannelCache()
+ service.ResetProxyClientCache()
+ }
+ }
+ }
+ auditDetails := map[string]interface{}{
+ "id": channelId, "upstream_type": config.Type, "upstream_type_label": channelMonitorUpstreamTypeLabel(config.Type), "group": config.Group, "auth_type": config.AuthType,
+ "single_channel_action": singleChannelAction, "multiple_channels_action": multipleChannelAction,
+ "balance_warning_threshold": balanceWarningThreshold,
+ "balance_auto_disable_threshold": balanceAutoDisableThreshold,
+ "balance_auto_disabled": balanceAutoDisabled,
+ "ratio_sync_enabled": ratioSyncEnabled, "balance_sync_enabled": balanceSyncEnabled,
+ "cost_conversion": channelMonitorCostConversionLabel(config.CostConversion),
+ "conversion_factor": conversionFactor,
+ }
+ if config.Type == service.CustomUpstreamType {
+ auditDetails["custom_ratio_source"] = config.CustomConfig.Ratio.Source
+ auditDetails["custom_balance_source"] = config.CustomConfig.Balance.Source
+ }
+ recordManageAudit(c, "channel.monitor_upstream_config_update", auditDetails)
+ common.ApiSuccess(c, channelMonitorUpstreamFromModel(monitor))
+}
+
+func ListChannelMonitorUpstreamGroups(c *gin.Context) {
+ channelId, err := strconv.Atoi(c.Param("id"))
+ if err != nil || channelId <= 0 {
+ common.ApiErrorMsg(c, "无效的渠道 ID")
+ return
+ }
+ channel, err := model.GetChannelById(channelId, true)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ var request channelMonitorUpstreamRequest
+ if err := common.DecodeJson(c.Request.Body, &request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的参数"})
+ return
+ }
+ config, err := resolveChannelMonitorUpstreamRequest(channel, request, false)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ if config.Type == service.Sub2APIUpstreamType {
+ if config.AuthType != service.Sub2APIAuthToken && config.AuthType != service.Sub2APIAuthAccount {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "success": false,
+ "message": "Sub2API API Key 认证不支持获取或应用分组,请手动填写分组或切换为账号密码或 Token 认证",
+ })
+ return
+ }
+ }
+ if config.Type == service.CustomUpstreamType {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "success": false,
+ "message": "自定义上游不支持自动获取分组,请手动填写上游分组",
+ })
+ return
+ }
+
+ result, fetchErr := service.FetchChannelMonitorUpstreamGroups(c.Request.Context(), config, channel.GetKeys())
+ if fetchErr != nil {
+ common.ApiError(c, fetchErr)
+ return
+ }
+ common.ApiSuccess(c, result)
+}
+
+func TestChannelMonitorUpstreamConfig(c *gin.Context) {
+ channelId, err := strconv.Atoi(c.Param("id"))
+ if err != nil || channelId <= 0 {
+ common.ApiErrorMsg(c, "无效的渠道 ID")
+ return
+ }
+ channel, err := model.GetChannelById(channelId, true)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ var request channelMonitorUpstreamRequest
+ if err := common.DecodeJson(c.Request.Body, &request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的参数"})
+ return
+ }
+ config, err := resolveChannelMonitorUpstreamRequest(channel, request, true)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ if config.Type != service.CustomUpstreamType && request.RatioSyncEnabled != nil && !*request.RatioSyncEnabled {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "上游倍率同步已关闭,无需测试获取"})
+ return
+ }
+ if config.Type == service.Sub2APIUpstreamType {
+ config.ChannelKeys = channel.GetKeys()
+ }
+ config.CustomDebug = config.Type == service.CustomUpstreamType
+ result, err := service.FetchChannelMonitorUpstreamGroupRatio(c.Request.Context(), config)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, result)
+}
+
+type channelMonitorFetchOutcome struct {
+ Result service.NewAPIGroupRatioResult
+ Monitor model.ChannelRatioMonitor
+ Created bool
+ Changed bool
+ BalanceRecorded bool
+}
+
+func fetchAndRecordChannelMonitorUpstreamRatio(ctx context.Context, monitor model.ChannelRatioMonitor, channelKeys []string, proxyURL string, operatorId int, operatorUsername string) (outcome channelMonitorFetchOutcome, err error) {
+ if monitor.UpstreamType != service.NewAPIUpstreamType && monitor.UpstreamType != service.Sub2APIUpstreamType && monitor.UpstreamType != service.CustomUpstreamType {
+ return outcome, errors.New("请先保存上游配置")
+ }
+ if monitor.UpstreamRatioSyncDisabled {
+ return outcome, errors.New("该渠道已关闭上游倍率同步")
+ }
+ defer func() {
+ if err == nil {
+ return
+ }
+ if statusErr := model.RecordChannelRatioMonitorFetchFailure(monitor.ChannelId, err.Error()); statusErr != nil {
+ err = fmt.Errorf("%w(记录失败状态失败:%v)", err, statusErr)
+ }
+ }()
+ if monitor.UpstreamType == service.Sub2APIUpstreamType {
+ switch monitor.UpstreamAuthType {
+ case service.Sub2APIAuthAPIKey:
+ if len(channelKeys) == 0 {
+ return outcome, errors.New("Sub2API API Key 认证需要当前渠道配置上游 API Key")
+ }
+ case service.Sub2APIAuthToken:
+ if monitor.UpstreamAccessToken == "" {
+ return outcome, errors.New("请重新保存 Sub2API Token 配置")
+ }
+ case service.Sub2APIAuthAccount:
+ if monitor.UpstreamAccount == "" || monitor.UpstreamPassword == "" {
+ return outcome, errors.New("请重新保存 Sub2API 账号密码配置")
+ }
+ default:
+ return outcome, errors.New("Sub2API 认证方式无效")
+ }
+ }
+ costConversion, err := service.ParseChannelMonitorCostConversion(monitor.CostConversion)
+ if err != nil {
+ return outcome, err
+ }
+ customConfig := service.ChannelMonitorCustomUpstreamConfig{}
+ if monitor.UpstreamType == service.CustomUpstreamType {
+ customConfig, err = service.ParseChannelMonitorCustomUpstreamConfig(monitor.CustomUpstreamConfig)
+ if err != nil {
+ return outcome, err
+ }
+ }
+
+ result, fetchErr := service.FetchChannelMonitorUpstreamGroupRatio(ctx, service.ChannelMonitorUpstreamConfig{
+ Type: monitor.UpstreamType,
+ BaseURL: monitor.UpstreamBaseURL,
+ Group: monitor.UpstreamGroup,
+ AuthType: monitor.UpstreamAuthType,
+ UserID: monitor.UpstreamUserId,
+ AccessToken: monitor.UpstreamAccessToken,
+ Account: monitor.UpstreamAccount,
+ Password: monitor.UpstreamPassword,
+ ChannelKeys: channelKeys,
+ Proxy: proxyURL,
+ SkipBalance: monitor.UpstreamBalanceSyncDisabled,
+ CostConversion: costConversion,
+ CustomConfig: customConfig,
+ })
+ outcome.Result = result
+ if result.Balance.Amount != nil || strings.TrimSpace(result.Balance.Error) != "" {
+ if balanceErr := model.RecordChannelRatioMonitorBalance(
+ monitor.ChannelId,
+ result.Balance.Amount,
+ result.Balance.Error,
+ ); balanceErr != nil {
+ return outcome, fmt.Errorf("记录上游余额失败: %w", balanceErr)
+ }
+ outcome.BalanceRecorded = result.Balance.Amount != nil
+ }
+ if fetchErr != nil {
+ return outcome, fetchErr
+ }
+
+ upstreamName := channelMonitorUpstreamTypeLabel(monitor.UpstreamType)
+ remark := fmt.Sprintf("从上游 %s 获取倍率", upstreamName)
+ if strings.TrimSpace(monitor.UpstreamGroup) != "" {
+ remark += fmt.Sprintf("(分组 %s)", monitor.UpstreamGroup)
+ }
+ updatedMonitor, created, changed, err := model.UpdateChannelRatioMonitorFromUpstream(
+ monitor.ChannelId,
+ result.Ratio,
+ remark,
+ operatorId,
+ operatorUsername,
+ )
+ if err != nil {
+ return outcome, err
+ }
+ outcome.Monitor = updatedMonitor
+ outcome.Created = created
+ outcome.Changed = changed
+ return outcome, nil
+}
+
+func fetchAndRecordChannelMonitorUpstreamBalance(ctx context.Context, monitor model.ChannelRatioMonitor, channelKeys []string, proxyURL string) (result service.ChannelMonitorUpstreamBalanceResult, err error) {
+ if monitor.UpstreamType != service.NewAPIUpstreamType && monitor.UpstreamType != service.Sub2APIUpstreamType && monitor.UpstreamType != service.CustomUpstreamType {
+ return result, errors.New("请先保存上游配置")
+ }
+ if monitor.UpstreamBalanceSyncDisabled {
+ return result, errors.New("该渠道已关闭上游余额同步")
+ }
+
+ customConfig := service.ChannelMonitorCustomUpstreamConfig{}
+ if monitor.UpstreamType == service.CustomUpstreamType {
+ customConfig, err = service.ParseChannelMonitorCustomUpstreamConfig(monitor.CustomUpstreamConfig)
+ if err != nil {
+ return result, err
+ }
+ }
+ result, fetchErr := service.FetchChannelMonitorUpstreamBalance(
+ ctx,
+ service.ChannelMonitorUpstreamConfig{
+ Type: monitor.UpstreamType,
+ BaseURL: monitor.UpstreamBaseURL,
+ AuthType: monitor.UpstreamAuthType,
+ UserID: monitor.UpstreamUserId,
+ AccessToken: monitor.UpstreamAccessToken,
+ Account: monitor.UpstreamAccount,
+ Password: monitor.UpstreamPassword,
+ ChannelKeys: channelKeys,
+ Proxy: proxyURL,
+ CustomConfig: customConfig,
+ },
+ )
+ if fetchErr == nil && result.Amount == nil {
+ fetchErr = errors.New("上游未返回余额")
+ }
+ if fetchErr != nil {
+ if recordErr := model.RecordChannelRatioMonitorBalance(monitor.ChannelId, nil, fetchErr.Error()); recordErr != nil {
+ fetchErr = fmt.Errorf("%w(记录余额失败状态失败:%v)", fetchErr, recordErr)
+ }
+ return result, fetchErr
+ }
+ if err := model.RecordChannelRatioMonitorBalance(monitor.ChannelId, result.Amount, ""); err != nil {
+ return result, err
+ }
+ return result, nil
+}
+
+func autoDisableChannelMonitorForLowBalance(monitor model.ChannelRatioMonitor, channel *model.Channel, balance float64) (bool, error) {
+ if monitor.BalanceAutoDisableThreshold == nil || channel == nil ||
+ channel.Id != monitor.ChannelId || channel.Status != common.ChannelStatusEnabled ||
+ balance >= *monitor.BalanceAutoDisableThreshold {
+ return false, nil
+ }
+ reason := fmt.Sprintf(
+ "渠道监控:上游余额 %s 低于自动禁用阈值 %s",
+ strconv.FormatFloat(balance, 'f', -1, 64),
+ strconv.FormatFloat(*monitor.BalanceAutoDisableThreshold, 'f', -1, 64),
+ )
+ if model.UpdateChannelStatus(channel.Id, "", common.ChannelStatusAutoDisabled, reason) {
+ channel.Status = common.ChannelStatusAutoDisabled
+ return true, nil
+ }
+ storedChannel, err := model.GetChannelById(channel.Id, true)
+ if err != nil {
+ return false, fmt.Errorf("余额低于自动禁用阈值,但读取渠道状态失败: %w", err)
+ }
+ if storedChannel.Status == common.ChannelStatusEnabled {
+ return false, errors.New("余额低于自动禁用阈值,但渠道禁用失败")
+ }
+ channel.Status = storedChannel.Status
+ return false, nil
+}
+
+func FetchChannelMonitorUpstreamRatio(c *gin.Context) {
+ channelId, err := strconv.Atoi(c.Param("id"))
+ if err != nil || channelId <= 0 {
+ common.ApiErrorMsg(c, "无效的渠道 ID")
+ return
+ }
+ channel, err := model.GetChannelById(channelId, true)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ monitor, err := model.GetChannelRatioMonitor(channelId)
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ common.ApiErrorMsg(c, "请先保存上游配置")
+ return
+ }
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if monitor.UpstreamRatioSyncDisabled {
+ common.ApiErrorMsg(c, "该渠道已关闭上游倍率同步")
+ return
+ }
+ operatorId, operatorUsername := getChannelMonitorOperator(c)
+ outcome, err := fetchAndRecordChannelMonitorUpstreamRatio(c.Request.Context(), monitor, channel.GetKeys(), channel.GetSetting().Proxy, operatorId, operatorUsername)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ balanceAutoDisabled := false
+ if outcome.BalanceRecorded && outcome.Result.Balance.Amount != nil {
+ balanceAutoDisabled, err = autoDisableChannelMonitorForLowBalance(monitor, channel, *outcome.Result.Balance.Amount)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if balanceAutoDisabled {
+ model.InitChannelCache()
+ service.ResetProxyClientCache()
+ }
+ }
+ recordManageAudit(c, "channel.monitor_upstream_ratio_fetch", map[string]interface{}{
+ "id": channelId, "upstream_type": monitor.UpstreamType, "group": monitor.UpstreamGroup,
+ "ratio": outcome.Result.Ratio, "cost_ratio": outcome.Result.CostRatio,
+ "conversion_factor": outcome.Result.ConversionFactor, "changed": outcome.Changed,
+ "balance_auto_disabled": balanceAutoDisabled,
+ })
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": gin.H{
+ "result": outcome.Result,
+ "monitor": outcome.Monitor,
+ "created": outcome.Created,
+ "changed": outcome.Changed,
+ "balance_auto_disabled": balanceAutoDisabled,
+ },
+ })
+}
+
+func FetchChannelMonitorUpstreamBalance(c *gin.Context) {
+ channelId, err := strconv.Atoi(c.Param("id"))
+ if err != nil || channelId <= 0 {
+ common.ApiErrorMsg(c, "无效的渠道 ID")
+ return
+ }
+ channel, err := model.GetChannelById(channelId, true)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ monitor, err := model.GetChannelRatioMonitor(channelId)
+ if errors.Is(err, gorm.ErrRecordNotFound) || monitor.UpstreamType == "" {
+ common.ApiErrorMsg(c, "请先保存上游配置")
+ return
+ }
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if monitor.UpstreamBalanceSyncDisabled {
+ common.ApiErrorMsg(c, "该渠道已关闭上游余额同步")
+ return
+ }
+
+ result, err := fetchAndRecordChannelMonitorUpstreamBalance(c.Request.Context(), monitor, channel.GetKeys(), channel.GetSetting().Proxy)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ balanceAutoDisabled, err := autoDisableChannelMonitorForLowBalance(monitor, channel, *result.Amount)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if balanceAutoDisabled {
+ model.InitChannelCache()
+ service.ResetProxyClientCache()
+ }
+ recordManageAudit(c, "channel.monitor_upstream_balance_fetch", map[string]interface{}{
+ "id": channelId, "upstream_type": monitor.UpstreamType, "balance": *result.Amount,
+ "balance_auto_disabled": balanceAutoDisabled,
+ })
+ common.ApiSuccess(c, result)
+}
+
+func ApplyChannelMonitorUpstreamGroup(c *gin.Context) {
+ channelId, err := strconv.Atoi(c.Param("id"))
+ if err != nil || channelId <= 0 {
+ common.ApiErrorMsg(c, "无效的渠道 ID")
+ return
+ }
+ channel, err := model.GetChannelById(channelId, true)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ monitor, err := model.GetChannelRatioMonitor(channelId)
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ common.ApiErrorMsg(c, "请先保存上游配置")
+ return
+ }
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ costConversion, err := service.ParseChannelMonitorCostConversion(monitor.CostConversion)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ applyResult, applyErr := service.ApplyChannelMonitorUpstreamGroup(
+ c.Request.Context(),
+ service.ChannelMonitorUpstreamConfig{
+ Type: monitor.UpstreamType,
+ BaseURL: monitor.UpstreamBaseURL,
+ Group: monitor.UpstreamGroup,
+ AuthType: monitor.UpstreamAuthType,
+ UserID: monitor.UpstreamUserId,
+ AccessToken: monitor.UpstreamAccessToken,
+ Account: monitor.UpstreamAccount,
+ Password: monitor.UpstreamPassword,
+ Proxy: channel.GetSetting().Proxy,
+ CostConversion: costConversion,
+ },
+ channel.GetKeys(),
+ )
+ if applyErr != nil {
+ if applyResult.KeysUpdated > 0 {
+ applyErr = fmt.Errorf("已切换 %d 个上游令牌,但后续操作失败: %w", applyResult.KeysUpdated, applyErr)
+ }
+ if statusErr := model.RecordChannelRatioMonitorFetchFailure(channelId, applyErr.Error()); statusErr != nil {
+ applyErr = fmt.Errorf("%w(记录失败状态失败:%v)", applyErr, statusErr)
+ }
+ common.ApiError(c, applyErr)
+ return
+ }
+
+ upstreamName := "New API"
+ if monitor.UpstreamType == service.Sub2APIUpstreamType {
+ upstreamName = "Sub2API"
+ }
+ operatorId, operatorUsername := getChannelMonitorOperator(c)
+ remark := fmt.Sprintf(
+ "已将 %d 个上游 %s 令牌切换到分组 %s",
+ applyResult.KeysUpdated,
+ upstreamName,
+ monitor.UpstreamGroup,
+ )
+ updatedMonitor, created, changed, err := model.UpdateChannelRatioMonitorFromUpstream(
+ channelId,
+ applyResult.Result.Ratio,
+ remark,
+ operatorId,
+ operatorUsername,
+ )
+ if err != nil {
+ common.ApiError(c, fmt.Errorf("上游令牌已切换,但记录本地倍率失败: %w", err))
+ return
+ }
+ recordManageAudit(c, "channel.monitor_upstream_group_apply", map[string]interface{}{
+ "id": channelId,
+ "upstream_type": monitor.UpstreamType,
+ "group": monitor.UpstreamGroup,
+ "keys_updated": applyResult.KeysUpdated,
+ "ratio": applyResult.Result.Ratio,
+ "cost_ratio": applyResult.Result.CostRatio,
+ "conversion_factor": applyResult.Result.ConversionFactor,
+ "changed": changed,
+ })
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": gin.H{
+ "result": applyResult.Result,
+ "keys_updated": applyResult.KeysUpdated,
+ "monitor": updatedMonitor,
+ "created": created,
+ "changed": changed,
+ },
+ })
+}
+
+func GetChannelMonitorHistory(c *gin.Context) {
+ channelId, err := strconv.Atoi(c.Param("id"))
+ if err != nil || channelId <= 0 {
+ common.ApiErrorMsg(c, "无效的渠道 ID")
+ return
+ }
+ if _, err := model.GetChannelById(channelId, false); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ pageInfo := common.GetPageQuery(c)
+ history, total, err := model.GetChannelRatioHistory(channelId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ pageInfo.SetTotal(int(total))
+ pageInfo.SetItems(history)
+ common.ApiSuccess(c, pageInfo)
+}
+
+func UpdateChannelMonitorGroupRatio(c *gin.Context) {
+ var request groupRatioUpdateRequest
+ if err := common.DecodeJson(c.Request.Body, &request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的参数"})
+ return
+ }
+ request.Group = strings.TrimSpace(request.Group)
+ if request.Group == "" || utf8.RuneCountInString(request.Group) > 64 {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "分组名称无效"})
+ return
+ }
+ if !validateChannelMonitorRatio(request.Ratio) {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "倍率必须在 0 到 1000000 之间"})
+ return
+ }
+
+ groupRatios := ratio_setting.GetGroupRatioCopy()
+ groupRatios[request.Group] = *request.Ratio
+ jsonBytes, err := common.Marshal(groupRatios)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if err := model.UpdateOptionsBulk(map[string]string{"GroupRatio": string(jsonBytes)}); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ recordManageAudit(c, "channel.monitor_group_ratio_update", map[string]interface{}{
+ "group": request.Group,
+ "ratio": *request.Ratio,
+ })
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": gin.H{
+ "group": request.Group,
+ "ratio": *request.Ratio,
+ },
+ })
+}
diff --git a/controller/channel_ratio_monitor_performance.go b/controller/channel_ratio_monitor_performance.go
new file mode 100644
index 000000000000..ad8b019cab52
--- /dev/null
+++ b/controller/channel_ratio_monitor_performance.go
@@ -0,0 +1,136 @@
+package controller
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ defaultChannelMonitorPerformanceMinutes = 15
+ minChannelMonitorPerformanceMinutes = 1
+ maxChannelMonitorPerformanceMinutes = 1440
+)
+
+func getChannelMonitorPerformanceMinutes(c *gin.Context) (int, bool) {
+ minutes := defaultChannelMonitorPerformanceMinutes
+ if rawMinutes := c.Query("minutes"); rawMinutes != "" {
+ parsedMinutes, err := strconv.Atoi(rawMinutes)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "性能与成功率统计范围必须在 1 到 1440 分钟之间"})
+ return 0, false
+ }
+ minutes = parsedMinutes
+ }
+ if minutes < minChannelMonitorPerformanceMinutes || minutes > maxChannelMonitorPerformanceMinutes {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "性能与成功率统计范围必须在 1 到 1440 分钟之间"})
+ return 0, false
+ }
+ return minutes, true
+}
+
+func GetChannelMonitorPerformance(c *gin.Context) {
+ minutes, ok := getChannelMonitorPerformanceMinutes(c)
+ if !ok {
+ return
+ }
+ generatedAt := time.Now().Unix()
+ metrics, err := model.GetChannelMonitorPerformanceMetrics(
+ c.Request.Context(),
+ generatedAt-int64(minutes*60),
+ )
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ successMetricsAvailable := common.LogConsumeEnabled && constant.ErrorLogEnabled
+ successMetrics := make([]model.ChannelMonitorSuccessMetric, 0)
+ groupSuccessMetrics := make([]model.ChannelMonitorGroupSuccessMetric, 0)
+ if successMetricsAvailable {
+ successMetrics, groupSuccessMetrics, err = model.GetChannelMonitorSuccessMetrics(
+ c.Request.Context(),
+ generatedAt-int64(minutes*60),
+ )
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ }
+ common.ApiSuccess(c, gin.H{
+ "range_minutes": minutes,
+ "generated_at": generatedAt,
+ "items": metrics,
+ "success_metrics_available": successMetricsAvailable,
+ "success_items": successMetrics,
+ "group_success_items": groupSuccessMetrics,
+ })
+}
+
+func GetChannelMonitorSuccessDetail(c *gin.Context) {
+ minutes, ok := getChannelMonitorPerformanceMinutes(c)
+ if !ok {
+ return
+ }
+
+ generatedAt := time.Now().Unix()
+ successMetricsAvailable := common.LogConsumeEnabled && constant.ErrorLogEnabled
+ if !successMetricsAvailable {
+ common.ApiSuccess(c, gin.H{
+ "range_minutes": minutes,
+ "generated_at": generatedAt,
+ "success_metrics_available": false,
+ "scope": "",
+ "detail": model.ChannelMonitorSuccessDetail{
+ ChannelItems: make([]model.ChannelMonitorChannelSuccessMetric, 0),
+ FailureCategories: make([]model.ChannelMonitorFailureCategory, 0),
+ },
+ })
+ return
+ }
+
+ rawChannelId := strings.TrimSpace(c.Query("channel_id"))
+ group := strings.TrimSpace(c.Query("group"))
+ if (rawChannelId == "" && group == "") || (rawChannelId != "" && group != "") {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "成功率明细必须指定一个渠道或分组"})
+ return
+ }
+
+ filter := model.ChannelMonitorSuccessFilter{}
+ scope := "group"
+ if rawChannelId != "" {
+ channelId, err := strconv.Atoi(rawChannelId)
+ if err != nil || channelId <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "渠道 ID 无效"})
+ return
+ }
+ filter.ChannelId = channelId
+ filter.ModelName = strings.TrimSpace(c.Query("model_name"))
+ scope = "channel"
+ } else {
+ filter.Group = group
+ }
+
+ detail, err := model.GetChannelMonitorSuccessDetail(
+ c.Request.Context(),
+ generatedAt-int64(minutes*60),
+ filter,
+ )
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, gin.H{
+ "range_minutes": minutes,
+ "generated_at": generatedAt,
+ "success_metrics_available": true,
+ "scope": scope,
+ "detail": detail,
+ })
+}
diff --git a/controller/channel_ratio_monitor_performance_test.go b/controller/channel_ratio_monitor_performance_test.go
new file mode 100644
index 000000000000..38d7bfe188a1
--- /dev/null
+++ b/controller/channel_ratio_monitor_performance_test.go
@@ -0,0 +1,143 @@
+package controller
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+type channelMonitorPerformanceAPIResponse struct {
+ Success bool `json:"success"`
+ Data struct {
+ RangeMinutes int `json:"range_minutes"`
+ Items []model.ChannelMonitorPerformanceMetric `json:"items"`
+ SuccessMetricsAvailable bool `json:"success_metrics_available"`
+ SuccessItems []model.ChannelMonitorSuccessMetric `json:"success_items"`
+ GroupSuccessItems []model.ChannelMonitorGroupSuccessMetric `json:"group_success_items"`
+ } `json:"data"`
+}
+
+func TestGetChannelMonitorPerformanceReturnsUsageLogMetrics(t *testing.T) {
+ originalLogDB := model.LOG_DB
+ originalLogDatabaseType := common.LogDatabaseType()
+ originalLogConsumeEnabled := common.LogConsumeEnabled
+ originalErrorLogEnabled := constant.ErrorLogEnabled
+ t.Cleanup(func() {
+ model.LOG_DB = originalLogDB
+ common.SetLogDatabaseType(originalLogDatabaseType)
+ common.LogConsumeEnabled = originalLogConsumeEnabled
+ constant.ErrorLogEnabled = originalErrorLogEnabled
+ })
+ common.LogConsumeEnabled = true
+ constant.ErrorLogEnabled = true
+
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "performance-api.db")), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, sqlDB.Close())
+ })
+ require.NoError(t, db.AutoMigrate(&model.Log{}))
+ model.LOG_DB = db
+ common.SetLogDatabaseType(common.DatabaseTypeSQLite)
+ require.NoError(t, db.Create(&model.Log{
+ ChannelId: 7,
+ ModelName: "test-model",
+ CreatedAt: time.Now().Unix(),
+ Type: model.LogTypeConsume,
+ IsStream: true,
+ Group: "vip",
+ CompletionTokens: 120,
+ UseTime: 4,
+ Other: `{"frt":1500}`,
+ }).Error)
+ require.NoError(t, db.Create(&model.Log{
+ ChannelId: 7,
+ ModelName: "test-model",
+ CreatedAt: time.Now().Unix(),
+ Type: model.LogTypeError,
+ IsRetryAttempt: true,
+ Group: "vip",
+ Content: "status_code=503, upstream unavailable",
+ Other: `{"status_code":503,"error_type":"upstream_error","error_code":"bad_response_status_code"}`,
+ }).Error)
+
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/channel_monitor/performance?minutes=30", nil)
+
+ GetChannelMonitorPerformance(context)
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ var response channelMonitorPerformanceAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.True(t, response.Success)
+ assert.Equal(t, 30, response.Data.RangeMinutes)
+ require.Len(t, response.Data.Items, 1)
+ assert.Equal(t, 7, response.Data.Items[0].ChannelId)
+ require.NotNil(t, response.Data.Items[0].AverageFirstTokenMs)
+ assert.InDelta(t, 1500, *response.Data.Items[0].AverageFirstTokenMs, 0.001)
+ require.NotNil(t, response.Data.Items[0].AverageTPS)
+ assert.InDelta(t, 30, *response.Data.Items[0].AverageTPS, 0.001)
+ assert.True(t, response.Data.SuccessMetricsAvailable)
+ require.Len(t, response.Data.SuccessItems, 1)
+ assert.Equal(t, int64(1), response.Data.SuccessItems[0].ActualSuccessCount)
+ assert.Equal(t, int64(1), response.Data.SuccessItems[0].ActualFailureCount)
+ assert.InDelta(t, 0.5, response.Data.SuccessItems[0].ActualSuccessRate, 0.001)
+ assert.Equal(t, int64(1), response.Data.SuccessItems[0].FinalSampleCount)
+ assert.InDelta(t, 1, response.Data.SuccessItems[0].FinalSuccessRate, 0.001)
+ require.Len(t, response.Data.GroupSuccessItems, 1)
+ assert.Equal(t, "vip", response.Data.GroupSuccessItems[0].Group)
+ assert.InDelta(t, 0.5, response.Data.GroupSuccessItems[0].ActualSuccessRate, 0.001)
+ assert.InDelta(t, 1, response.Data.GroupSuccessItems[0].FinalSuccessRate, 0.001)
+
+ detailRecorder := httptest.NewRecorder()
+ detailContext, _ := gin.CreateTestContext(detailRecorder)
+ detailContext.Request = httptest.NewRequest(http.MethodGet, "/api/channel_monitor/success/detail?minutes=30&channel_id=7&model_name=test-model", nil)
+ GetChannelMonitorSuccessDetail(detailContext)
+
+ assert.Equal(t, http.StatusOK, detailRecorder.Code)
+ var detailResponse struct {
+ Success bool `json:"success"`
+ Data struct {
+ SuccessMetricsAvailable bool `json:"success_metrics_available"`
+ Detail model.ChannelMonitorSuccessDetail `json:"detail"`
+ } `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(detailRecorder.Body.Bytes(), &detailResponse))
+ assert.True(t, detailResponse.Success)
+ assert.True(t, detailResponse.Data.SuccessMetricsAvailable)
+ assert.Equal(t, int64(1), detailResponse.Data.Detail.Summary.ActualFailureCount)
+ require.Len(t, detailResponse.Data.Detail.FailureCategories, 1)
+ assert.Equal(t, 503, detailResponse.Data.Detail.FailureCategories[0].StatusCode)
+}
+
+func TestGetChannelMonitorPerformanceRejectsInvalidRange(t *testing.T) {
+ for _, minutes := range []string{"0", "1441", "invalid"} {
+ t.Run(minutes, func(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/channel_monitor/performance?minutes="+minutes, nil)
+
+ GetChannelMonitorPerformance(context)
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "性能与成功率统计范围必须在 1 到 1440 分钟之间")
+ })
+ }
+}
diff --git a/controller/channel_ratio_monitor_policy.go b/controller/channel_ratio_monitor_policy.go
new file mode 100644
index 000000000000..013d036546fb
--- /dev/null
+++ b/controller/channel_ratio_monitor_policy.go
@@ -0,0 +1,299 @@
+package controller
+
+import (
+ "context"
+ "sort"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
+)
+
+const channelMonitorRatioEpsilon = 1e-9
+
+type channelMonitorPolicyPlan struct {
+ GroupRatioUpdates map[string]float64
+ GroupMembershipRemovals []model.ChannelMonitorGroupMembershipRemoval
+ DisableChannelIds []int
+ SkippedGroupCount int
+}
+
+type channelMonitorPolicyInput struct {
+ CostRatio float64
+ SingleChannelAction string
+ MultipleChannelsAction string
+}
+
+type channelMonitorPolicyMember struct {
+ ChannelId int
+ Target float64
+ SingleChannelAction string
+ MultipleChannelsAction string
+}
+
+type channelMonitorPolicyGroup struct {
+ Name string
+ CurrentRatio float64
+ Coefficient float64
+ ChannelIds []int
+}
+
+type channelMonitorPolicyMembership struct {
+ ChannelId int
+ Group string
+}
+
+func collectChannelMonitorPolicyMembers(
+ group channelMonitorPolicyGroup,
+ policyInputs map[int]channelMonitorPolicyInput,
+ disabledChannelIds map[int]struct{},
+ removedMemberships map[channelMonitorPolicyMembership]struct{},
+) ([]channelMonitorPolicyMember, bool) {
+ members := make([]channelMonitorPolicyMember, 0, len(group.ChannelIds))
+ for _, channelId := range group.ChannelIds {
+ if _, disabled := disabledChannelIds[channelId]; disabled {
+ continue
+ }
+ if _, removed := removedMemberships[channelMonitorPolicyMembership{ChannelId: channelId, Group: group.Name}]; removed {
+ continue
+ }
+ input, exists := policyInputs[channelId]
+ if !exists {
+ return nil, false
+ }
+ target := input.CostRatio * group.Coefficient
+ if !validateChannelMonitorRatio(&target) {
+ return nil, false
+ }
+ members = append(members, channelMonitorPolicyMember{
+ ChannelId: channelId,
+ Target: target,
+ SingleChannelAction: normalizeChannelMonitorPolicyAction(input.SingleChannelAction),
+ MultipleChannelsAction: normalizeChannelMonitorPolicyAction(input.MultipleChannelsAction),
+ })
+ }
+ return members, true
+}
+
+func planChannelMonitorPolicyActions(
+ channels []*model.Channel,
+ policyInputs map[int]channelMonitorPolicyInput,
+ groupRatios map[string]float64,
+ coefficients map[string]float64,
+) channelMonitorPolicyPlan {
+ plan := channelMonitorPolicyPlan{GroupRatioUpdates: make(map[string]float64)}
+ hasPolicy := false
+ for _, input := range policyInputs {
+ if normalizeChannelMonitorPolicyAction(input.SingleChannelAction) != channelMonitorPolicyActionNone ||
+ normalizeChannelMonitorPolicyAction(input.MultipleChannelsAction) != channelMonitorPolicyActionNone {
+ hasPolicy = true
+ break
+ }
+ }
+ if !hasPolicy {
+ return plan
+ }
+
+ channelIdsByGroup := make(map[string][]int)
+ channelGroupCounts := make(map[int]int, len(channels))
+ for _, channel := range channels {
+ seenGroups := make(map[string]struct{})
+ for _, group := range channel.GetGroups() {
+ if group == "" {
+ continue
+ }
+ if _, exists := seenGroups[group]; exists {
+ continue
+ }
+ seenGroups[group] = struct{}{}
+ channelGroupCounts[channel.Id]++
+ if channel.Status == common.ChannelStatusEnabled {
+ channelIdsByGroup[group] = append(channelIdsByGroup[group], channel.Id)
+ }
+ }
+ }
+ groupNames := make([]string, 0, len(channelIdsByGroup))
+ for group := range channelIdsByGroup {
+ groupNames = append(groupNames, group)
+ }
+ sort.Strings(groupNames)
+
+ groups := make([]channelMonitorPolicyGroup, 0, len(groupNames))
+ for _, group := range groupNames {
+ currentRatio, exists := groupRatios[group]
+ if !exists {
+ currentRatio = 1
+ }
+ if !validateChannelMonitorRatio(¤tRatio) {
+ plan.SkippedGroupCount++
+ continue
+ }
+ sort.Ints(channelIdsByGroup[group])
+ groups = append(groups, channelMonitorPolicyGroup{
+ Name: group,
+ CurrentRatio: currentRatio,
+ Coefficient: getChannelMonitorGroupCoefficient(coefficients, group),
+ ChannelIds: channelIdsByGroup[group],
+ })
+ }
+
+ disableChannelIds := make(map[int]struct{})
+ removedMemberships := make(map[channelMonitorPolicyMembership]struct{})
+ for {
+ nextDisableChannelIds := make(map[int]struct{})
+ for _, group := range groups {
+ members, complete := collectChannelMonitorPolicyMembers(group, policyInputs, disableChannelIds, removedMemberships)
+ if !complete || len(members) == 0 {
+ continue
+ }
+ if len(members) == 1 {
+ member := members[0]
+ if member.Target-group.CurrentRatio > channelMonitorRatioEpsilon &&
+ member.SingleChannelAction == channelMonitorPolicyActionDisableChannel {
+ nextDisableChannelIds[member.ChannelId] = struct{}{}
+ }
+ continue
+ }
+ for _, member := range members {
+ if member.Target-group.CurrentRatio > channelMonitorRatioEpsilon &&
+ member.MultipleChannelsAction == channelMonitorPolicyActionDisableChannel {
+ nextDisableChannelIds[member.ChannelId] = struct{}{}
+ }
+ }
+ }
+ if len(nextDisableChannelIds) > 0 {
+ for channelId := range nextDisableChannelIds {
+ disableChannelIds[channelId] = struct{}{}
+ }
+ continue
+ }
+
+ removedOne := false
+ for _, group := range groups {
+ members, complete := collectChannelMonitorPolicyMembers(group, policyInputs, disableChannelIds, removedMemberships)
+ if !complete || len(members) <= 1 {
+ continue
+ }
+ for _, member := range members {
+ if member.Target-group.CurrentRatio <= channelMonitorRatioEpsilon ||
+ member.MultipleChannelsAction != channelMonitorPolicyActionRemoveFromGroup ||
+ channelGroupCounts[member.ChannelId] <= 1 {
+ continue
+ }
+ membership := channelMonitorPolicyMembership{ChannelId: member.ChannelId, Group: group.Name}
+ removedMemberships[membership] = struct{}{}
+ channelGroupCounts[member.ChannelId]--
+ removedOne = true
+ break
+ }
+ if removedOne {
+ break
+ }
+ }
+ if !removedOne {
+ break
+ }
+ }
+
+ for _, group := range groups {
+ members, complete := collectChannelMonitorPolicyMembers(group, policyInputs, disableChannelIds, removedMemberships)
+ if !complete {
+ plan.SkippedGroupCount++
+ continue
+ }
+ switch len(members) {
+ case 0:
+ case 1:
+ member := members[0]
+ if member.Target-group.CurrentRatio > channelMonitorRatioEpsilon &&
+ member.SingleChannelAction == channelMonitorPolicyActionUpdateGroupRatio {
+ plan.GroupRatioUpdates[group.Name] = member.Target
+ }
+ default:
+ for _, member := range members {
+ if member.Target-group.CurrentRatio <= channelMonitorRatioEpsilon ||
+ member.MultipleChannelsAction != channelMonitorPolicyActionUpdateGroupRatio {
+ continue
+ }
+ if currentTarget, exists := plan.GroupRatioUpdates[group.Name]; !exists || member.Target > currentTarget {
+ plan.GroupRatioUpdates[group.Name] = member.Target
+ }
+ }
+ }
+ }
+
+ plan.GroupMembershipRemovals = make([]model.ChannelMonitorGroupMembershipRemoval, 0, len(removedMemberships))
+ for membership := range removedMemberships {
+ if _, disabled := disableChannelIds[membership.ChannelId]; disabled {
+ continue
+ }
+ plan.GroupMembershipRemovals = append(plan.GroupMembershipRemovals, model.ChannelMonitorGroupMembershipRemoval{
+ ChannelId: membership.ChannelId,
+ Group: membership.Group,
+ })
+ }
+ sort.Slice(plan.GroupMembershipRemovals, func(i, j int) bool {
+ if plan.GroupMembershipRemovals[i].ChannelId != plan.GroupMembershipRemovals[j].ChannelId {
+ return plan.GroupMembershipRemovals[i].ChannelId < plan.GroupMembershipRemovals[j].ChannelId
+ }
+ return plan.GroupMembershipRemovals[i].Group < plan.GroupMembershipRemovals[j].Group
+ })
+ plan.DisableChannelIds = make([]int, 0, len(disableChannelIds))
+ for channelId := range disableChannelIds {
+ plan.DisableChannelIds = append(plan.DisableChannelIds, channelId)
+ }
+ sort.Ints(plan.DisableChannelIds)
+ return plan
+}
+
+func applyChannelMonitorPolicyPlan(ctx context.Context, plan channelMonitorPolicyPlan) (groupsUpdated int, removedMemberships []model.ChannelMonitorGroupMembershipRemoval, disabledChannelIds []int, groupUpdateFailed bool, err error) {
+ if len(plan.GroupRatioUpdates) > 0 {
+ groupRatios := ratio_setting.GetGroupRatioCopy()
+ for group, targetRatio := range plan.GroupRatioUpdates {
+ currentRatio, exists := groupRatios[group]
+ if !exists {
+ currentRatio = 1
+ }
+ if targetRatio-currentRatio <= channelMonitorRatioEpsilon {
+ continue
+ }
+ groupRatios[group] = targetRatio
+ groupsUpdated++
+ }
+ if groupsUpdated > 0 {
+ groupRatioBytes, marshalErr := common.Marshal(groupRatios)
+ if marshalErr != nil {
+ return 0, nil, nil, true, marshalErr
+ }
+ if updateErr := model.UpdateOptionsBulk(map[string]string{"GroupRatio": string(groupRatioBytes)}); updateErr != nil {
+ return 0, nil, nil, true, updateErr
+ }
+ }
+ }
+
+ if len(plan.GroupMembershipRemovals) > 0 {
+ if ctx != nil && ctx.Err() != nil {
+ return groupsUpdated, nil, nil, false, ctx.Err()
+ }
+ removedMemberships, err = model.RemoveChannelMonitorGroupMemberships(plan.GroupMembershipRemovals)
+ if err != nil {
+ return groupsUpdated, nil, nil, false, err
+ }
+ }
+
+ disabledChannelIds = make([]int, 0, len(plan.DisableChannelIds))
+ for _, channelId := range plan.DisableChannelIds {
+ if ctx != nil && ctx.Err() != nil {
+ return groupsUpdated, removedMemberships, disabledChannelIds, false, ctx.Err()
+ }
+ if model.UpdateChannelStatus(channelId, "", common.ChannelStatusAutoDisabled, "渠道监控:成本倍率高于分组倍率") {
+ disabledChannelIds = append(disabledChannelIds, channelId)
+ }
+ }
+ if len(removedMemberships) > 0 || len(disabledChannelIds) > 0 {
+ model.InitChannelCache()
+ service.ResetProxyClientCache()
+ }
+ return groupsUpdated, removedMemberships, disabledChannelIds, false, nil
+}
diff --git a/controller/channel_ratio_monitor_schedule.go b/controller/channel_ratio_monitor_schedule.go
new file mode 100644
index 000000000000..9452de32c131
--- /dev/null
+++ b/controller/channel_ratio_monitor_schedule.go
@@ -0,0 +1,754 @@
+package controller
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "net/http"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ channelMonitorSmartScheduleTaskType = "channel_smart_schedule"
+ channelMonitorSmartScheduleMinWeight = 10
+ channelMonitorSmartScheduleMaxWeight = 100
+ channelMonitorSmartScheduleWeightStep = 5
+ channelMonitorSmartScheduleMinWeightChange = 10
+ channelMonitorSmartScheduleMaxWeightChange = 20
+ maxChannelSmartScheduleTaskFailureDetails = 100
+)
+
+type channelSmartScheduleTaskHandler struct{}
+
+type channelSmartScheduleTaskPayload struct {
+ ForceReset bool `json:"force_reset,omitempty"`
+}
+
+type channelSmartSchedulePerformance struct {
+ FirstTokenSampleCount int
+ TPSSampleCount int
+ FirstTokenTotalMs float64
+ TPSTotal float64
+ AverageFirstTokenMs *float64
+ AverageTPS *float64
+ StabilitySuccessCount int64
+ StabilityFailureCount int64
+ StabilitySampleCount int64
+ Stability *float64
+}
+
+type channelSmartScheduleCandidate struct {
+ ChannelId int
+ CurrentPriority int64
+ CurrentWeight uint
+ Ratio *float64
+ FirstTokenMs *float64
+ TPS *float64
+ FirstTokenSampleCount int
+ TPSSampleCount int
+ StabilitySampleCount int64
+ Stability *float64
+ StabilityAvailable bool
+}
+
+type channelSmartSchedulePlanItem struct {
+ ChannelId int
+ Score float64
+ CurrentPriority int64
+ CurrentWeight uint
+ TargetPriority int64
+ TargetWeight uint
+}
+
+type channelSmartSchedulePlan struct {
+ Items []channelSmartSchedulePlanItem
+ Skipped map[int]string
+}
+
+type channelSmartScheduleTaskFailure struct {
+ ChannelId int `json:"channel_id"`
+ ChannelName string `json:"channel_name"`
+ Error string `json:"error"`
+}
+
+type channelSmartScheduleTaskResult struct {
+ Strategy string `json:"strategy"`
+ StabilityEnabled bool `json:"stability_enabled"`
+ ForceReset bool `json:"force_reset"`
+ ApplyMode string `json:"apply_mode"`
+ Model string `json:"model"`
+ Models []string `json:"models,omitempty"`
+ PerformanceMinutes int `json:"performance_minutes"`
+ MinSamples int `json:"min_samples"`
+ Total int `json:"total"`
+ Planned int `json:"planned"`
+ Updated int `json:"updated"`
+ Unchanged int `json:"unchanged"`
+ Skipped int `json:"skipped"`
+ Failed int `json:"failed"`
+ Failures []channelSmartScheduleTaskFailure `json:"failures,omitempty"`
+ FailureDetailsTruncated bool `json:"failure_details_truncated,omitempty"`
+}
+
+func init() {
+ service.RegisterSystemTaskHandler(channelSmartScheduleTaskHandler{})
+}
+
+func (channelSmartScheduleTaskHandler) Type() string {
+ return channelMonitorSmartScheduleTaskType
+}
+
+func (channelSmartScheduleTaskHandler) Enabled() bool {
+ return getChannelMonitorSettings().SmartScheduleEnabled
+}
+
+func (channelSmartScheduleTaskHandler) Interval() time.Duration {
+ minutes := getChannelMonitorSettings().SmartScheduleIntervalMinutes
+ if minutes <= 0 {
+ minutes = defaultChannelMonitorSmartScheduleInterval
+ }
+ return time.Duration(minutes) * time.Minute
+}
+
+func (channelSmartScheduleTaskHandler) NewPayload() any { return nil }
+
+func (channelSmartScheduleTaskHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
+ payload := channelSmartScheduleTaskPayload{}
+ if err := task.DecodePayload(&payload); err != nil {
+ finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, channelSmartScheduleTaskResult{}, err)
+ return
+ }
+ summary, err := runChannelSmartScheduleOnce(
+ ctx,
+ service.NewSystemTaskProgressReporter(task, runnerID),
+ payload.ForceReset,
+ )
+ if err != nil {
+ finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, summary, err)
+ return
+ }
+ finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
+}
+
+func RunChannelMonitorSmartSchedule(c *gin.Context) {
+ task, created, err := service.EnqueueSystemTask(channelMonitorSmartScheduleTaskType, nil)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ recordManageAudit(c, "channel.monitor_smart_schedule_run", map[string]interface{}{
+ "created": created,
+ "task_id": task.TaskID,
+ })
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": gin.H{
+ "created": created,
+ "task": task.ToResponse(),
+ },
+ })
+}
+
+func (result *channelSmartScheduleTaskResult) recordFailure(channelId int, channelName string, failure error) {
+ result.Failed++
+ if len(result.Failures) >= maxChannelSmartScheduleTaskFailureDetails {
+ result.FailureDetailsTruncated = true
+ return
+ }
+ message := "智能调度更新失败"
+ if failure != nil && failure.Error() != "" {
+ message = failure.Error()
+ }
+ messageRunes := []rune(message)
+ if len(messageRunes) > 255 {
+ message = string(messageRunes[:255])
+ }
+ nameRunes := []rune(channelName)
+ if len(nameRunes) > 128 {
+ channelName = string(nameRunes[:128])
+ }
+ result.Failures = append(result.Failures, channelSmartScheduleTaskFailure{
+ ChannelId: channelId,
+ ChannelName: channelName,
+ Error: message,
+ })
+}
+
+func runChannelSmartScheduleOnce(ctx context.Context, reportProgress func(processed, total int), forceReset bool) (channelSmartScheduleTaskResult, error) {
+ if reportProgress == nil {
+ reportProgress = func(int, int) {}
+ }
+ settings := getChannelMonitorSettings()
+ result := channelSmartScheduleTaskResult{
+ Strategy: settings.SmartScheduleStrategy,
+ StabilityEnabled: settings.SmartScheduleStabilityEnabled,
+ ForceReset: forceReset,
+ ApplyMode: settings.SmartScheduleApplyMode,
+ Model: settings.SmartScheduleModel,
+ Models: settings.SmartScheduleModels,
+ PerformanceMinutes: settings.SmartSchedulePerformanceMinutes,
+ MinSamples: settings.SmartScheduleMinSamples,
+ }
+
+ channels, err := model.GetAllChannelsForMonitor()
+ if err != nil {
+ return result, err
+ }
+ result.Total = len(channels)
+ monitors, err := model.GetChannelRatioMonitors()
+ if err != nil {
+ return result, err
+ }
+ monitorByChannel := make(map[int]model.ChannelRatioMonitor, len(monitors))
+ for _, monitor := range monitors {
+ monitorByChannel[monitor.ChannelId] = monitor
+ }
+ channelCacheDirty := false
+ defer func() {
+ if channelCacheDirty {
+ model.InitChannelCache()
+ }
+ }()
+ if forceReset {
+ channelIds := make([]int, 0, len(channels))
+ for _, channel := range channels {
+ if channel.Status != common.ChannelStatusEnabled || monitorByChannel[channel.Id].SmartScheduleExcluded {
+ continue
+ }
+ channelIds = append(channelIds, channel.Id)
+ }
+ if err := model.ResetChannelSmartSchedulePriorityWeight(channelIds, channelMonitorSmartScheduleMinWeight); err != nil {
+ return result, err
+ }
+ channelCacheDirty = len(channelIds) > 0
+ }
+ needsPerformance := settings.SmartScheduleStrategy == channelMonitorSmartScheduleStrategyFirstToken ||
+ settings.SmartScheduleStrategy == channelMonitorSmartScheduleStrategyTPS ||
+ settings.SmartScheduleStrategy == channelMonitorSmartScheduleStrategySmart
+ needsRatio := settings.SmartScheduleStrategy == channelMonitorSmartScheduleStrategyRatio ||
+ settings.SmartScheduleStrategy == channelMonitorSmartScheduleStrategySmart
+ needsStability := settings.SmartScheduleStabilityEnabled
+ var metrics []model.ChannelMonitorPerformanceMetric
+ if needsPerformance {
+ metrics, err = model.GetChannelMonitorPerformanceMetrics(
+ ctx,
+ time.Now().Unix()-int64(settings.SmartSchedulePerformanceMinutes*60),
+ )
+ if err != nil {
+ return result, err
+ }
+ }
+ stabilityAvailable := common.LogConsumeEnabled && constant.ErrorLogEnabled
+ var stabilityMetrics []model.ChannelMonitorStabilityMetric
+ if needsStability && stabilityAvailable {
+ stabilityMetrics, err = model.GetChannelMonitorStabilityMetrics(
+ ctx,
+ time.Now().Unix()-int64(settings.SmartSchedulePerformanceMinutes*60),
+ )
+ if err != nil {
+ return result, err
+ }
+ }
+
+ selectedModelByChannel := make(map[int]string, len(channels))
+ if len(settings.SmartScheduleModels) > 0 {
+ for _, channel := range channels {
+ selectedModelByChannel[channel.Id] = channelSmartSchedulePreferredModel(
+ channel.GetModels(),
+ settings.SmartScheduleModels,
+ )
+ }
+ }
+
+ performanceByChannel := make(map[int]*channelSmartSchedulePerformance)
+ for _, metric := range metrics {
+ if len(settings.SmartScheduleModels) > 0 && metric.ModelName != selectedModelByChannel[metric.ChannelId] {
+ continue
+ }
+ performance := performanceByChannel[metric.ChannelId]
+ if performance == nil {
+ performance = &channelSmartSchedulePerformance{}
+ performanceByChannel[metric.ChannelId] = performance
+ }
+ if metric.AverageFirstTokenMs != nil && metric.FirstTokenSampleCount > 0 {
+ performance.FirstTokenSampleCount += metric.FirstTokenSampleCount
+ performance.FirstTokenTotalMs += *metric.AverageFirstTokenMs * float64(metric.FirstTokenSampleCount)
+ }
+ if metric.AverageTPS != nil && metric.TPSSampleCount > 0 {
+ performance.TPSSampleCount += metric.TPSSampleCount
+ performance.TPSTotal += *metric.AverageTPS * float64(metric.TPSSampleCount)
+ }
+ }
+ for _, metric := range stabilityMetrics {
+ if len(settings.SmartScheduleModels) > 0 && metric.ModelName != selectedModelByChannel[metric.ChannelId] {
+ continue
+ }
+ performance := performanceByChannel[metric.ChannelId]
+ if performance == nil {
+ performance = &channelSmartSchedulePerformance{}
+ performanceByChannel[metric.ChannelId] = performance
+ }
+ performance.StabilitySuccessCount += metric.SuccessCount
+ performance.StabilityFailureCount += metric.FailureCount
+ }
+ for _, performance := range performanceByChannel {
+ if performance.FirstTokenSampleCount > 0 {
+ value := performance.FirstTokenTotalMs / float64(performance.FirstTokenSampleCount)
+ performance.AverageFirstTokenMs = &value
+ }
+ if performance.TPSSampleCount > 0 {
+ value := performance.TPSTotal / float64(performance.TPSSampleCount)
+ performance.AverageTPS = &value
+ }
+ performance.StabilitySampleCount = performance.StabilitySuccessCount + performance.StabilityFailureCount
+ if performance.StabilitySampleCount > 0 {
+ value := float64(performance.StabilitySuccessCount) / float64(performance.StabilitySampleCount)
+ performance.Stability = &value
+ }
+ }
+
+ now := common.GetTimestamp()
+ candidates := make([]channelSmartScheduleCandidate, 0, len(channels))
+ statusUpdates := make([]model.ChannelSmartScheduleResultUpdate, 0, len(channels))
+ channelById := make(map[int]*model.Channel, len(channels))
+ for _, channel := range channels {
+ channelById[channel.Id] = channel
+ monitor := monitorByChannel[channel.Id]
+ currentPriority := channel.GetPriority()
+ currentWeight := uint(channel.GetWeight())
+ if forceReset && channel.Status == common.ChannelStatusEnabled && !monitor.SmartScheduleExcluded {
+ currentPriority = 0
+ currentWeight = channelMonitorSmartScheduleMinWeight
+ }
+ if channel.Status != common.ChannelStatusEnabled {
+ statusUpdates = append(statusUpdates, channelSmartScheduleStatusUpdate(
+ channel.Id,
+ model.ChannelSmartScheduleStatusSkipped,
+ "渠道未启用",
+ nil,
+ currentPriority,
+ currentWeight,
+ now,
+ ))
+ result.Skipped++
+ continue
+ }
+
+ if monitor.SmartScheduleExcluded {
+ statusUpdates = append(statusUpdates, channelSmartScheduleStatusUpdate(
+ channel.Id,
+ model.ChannelSmartScheduleStatusSkipped,
+ "已设为不参与智能调度",
+ nil,
+ currentPriority,
+ currentWeight,
+ now,
+ ))
+ result.Skipped++
+ continue
+ }
+ if len(settings.SmartScheduleModels) > 0 && selectedModelByChannel[channel.Id] == "" && (needsPerformance || needsStability) {
+ statusUpdates = append(statusUpdates, channelSmartScheduleStatusUpdate(
+ channel.Id,
+ model.ChannelSmartScheduleStatusSkipped,
+ "渠道不支持已配置的基准模型",
+ nil,
+ currentPriority,
+ currentWeight,
+ now,
+ ))
+ result.Skipped++
+ continue
+ }
+
+ var ratio *float64
+ if monitor.UpdatedTime > 0 && validateChannelMonitorRatio(&monitor.Ratio) {
+ value, _, conversionErr := channelMonitorCostRatioFromModel(monitor, monitor.Ratio)
+ if conversionErr != nil && needsRatio {
+ statusUpdates = append(statusUpdates, channelSmartScheduleStatusUpdate(
+ channel.Id,
+ model.ChannelSmartScheduleStatusSkipped,
+ "成本倍率换算失败:"+conversionErr.Error(),
+ nil,
+ currentPriority,
+ currentWeight,
+ now,
+ ))
+ result.Skipped++
+ continue
+ }
+ if conversionErr == nil {
+ ratio = &value
+ }
+ }
+ performance := performanceByChannel[channel.Id]
+ candidate := channelSmartScheduleCandidate{
+ ChannelId: channel.Id,
+ CurrentPriority: currentPriority,
+ CurrentWeight: currentWeight,
+ Ratio: ratio,
+ StabilityAvailable: stabilityAvailable,
+ }
+ if performance != nil {
+ candidate.FirstTokenMs = performance.AverageFirstTokenMs
+ candidate.TPS = performance.AverageTPS
+ candidate.FirstTokenSampleCount = performance.FirstTokenSampleCount
+ candidate.TPSSampleCount = performance.TPSSampleCount
+ candidate.Stability = performance.Stability
+ candidate.StabilitySampleCount = performance.StabilitySampleCount
+ }
+ candidates = append(candidates, candidate)
+ }
+
+ plan := planChannelSmartSchedule(
+ candidates,
+ settings.SmartScheduleStrategy,
+ settings.SmartScheduleStabilityEnabled,
+ settings.SmartScheduleApplyMode,
+ settings.SmartScheduleMinSamples,
+ forceReset,
+ )
+ result.Planned = len(plan.Items)
+ for _, candidate := range candidates {
+ reason, skipped := plan.Skipped[candidate.ChannelId]
+ if !skipped {
+ continue
+ }
+ statusUpdates = append(statusUpdates, channelSmartScheduleStatusUpdate(
+ candidate.ChannelId,
+ model.ChannelSmartScheduleStatusSkipped,
+ reason,
+ nil,
+ candidate.CurrentPriority,
+ candidate.CurrentWeight,
+ now,
+ ))
+ result.Skipped++
+ }
+
+ processed := result.Skipped
+ for _, item := range plan.Items {
+ select {
+ case <-ctx.Done():
+ return result, ctx.Err()
+ default:
+ }
+
+ var priority *int64
+ if item.TargetPriority != item.CurrentPriority {
+ value := item.TargetPriority
+ priority = &value
+ }
+ var weight *uint
+ if item.TargetWeight != item.CurrentWeight {
+ value := item.TargetWeight
+ weight = &value
+ }
+ if priority == nil && weight == nil {
+ result.Unchanged++
+ score := item.Score
+ statusUpdates = append(statusUpdates, channelSmartScheduleStatusUpdate(
+ item.ChannelId,
+ model.ChannelSmartScheduleStatusSucceeded,
+ "",
+ &score,
+ item.TargetPriority,
+ item.TargetWeight,
+ now,
+ ))
+ processed++
+ reportProgress(processed, result.Total)
+ continue
+ }
+
+ if err := model.UpdateChannelSmartSchedulePriorityWeight(item.ChannelId, priority, weight); err != nil {
+ channelName := ""
+ if channel := channelById[item.ChannelId]; channel != nil {
+ channelName = channel.Name
+ }
+ result.recordFailure(item.ChannelId, channelName, err)
+ statusUpdates = append(statusUpdates, channelSmartScheduleStatusUpdate(
+ item.ChannelId,
+ model.ChannelSmartScheduleStatusFailed,
+ err.Error(),
+ nil,
+ item.CurrentPriority,
+ item.CurrentWeight,
+ now,
+ ))
+ } else {
+ result.Updated++
+ channelCacheDirty = true
+ score := item.Score
+ statusUpdates = append(statusUpdates, channelSmartScheduleStatusUpdate(
+ item.ChannelId,
+ model.ChannelSmartScheduleStatusSucceeded,
+ "",
+ &score,
+ item.TargetPriority,
+ item.TargetWeight,
+ now,
+ ))
+ }
+ processed++
+ reportProgress(processed, result.Total)
+ }
+
+ if err := model.SaveChannelSmartScheduleResults(statusUpdates); err != nil {
+ return result, err
+ }
+ reportProgress(result.Total, result.Total)
+ return result, nil
+}
+
+func channelSmartSchedulePreferredModel(availableModels []string, preferredModels []string) string {
+ availableModelSet := make(map[string]struct{}, len(availableModels))
+ for _, modelName := range availableModels {
+ modelName = strings.TrimSpace(modelName)
+ if modelName != "" {
+ availableModelSet[modelName] = struct{}{}
+ }
+ }
+ for _, modelName := range preferredModels {
+ modelName = strings.TrimSpace(modelName)
+ if _, supported := availableModelSet[modelName]; supported {
+ return modelName
+ }
+ }
+ return ""
+}
+
+func channelSmartScheduleStatusUpdate(channelId int, status string, message string, score *float64, priority int64, weight uint, updatedTime int64) model.ChannelSmartScheduleResultUpdate {
+ return model.ChannelSmartScheduleResultUpdate{
+ ChannelId: channelId,
+ Status: status,
+ Error: message,
+ Score: score,
+ Priority: priority,
+ Weight: weight,
+ Time: updatedTime,
+ }
+}
+
+func planChannelSmartSchedule(candidates []channelSmartScheduleCandidate, strategy string, stabilityEnabled bool, applyMode string, minSamples int, forceReset bool) channelSmartSchedulePlan {
+ plan := channelSmartSchedulePlan{
+ Skipped: make(map[int]string),
+ }
+ if minSamples <= 0 {
+ minSamples = defaultChannelMonitorSmartScheduleSamples
+ }
+
+ type cohort struct {
+ Candidates []channelSmartScheduleCandidate
+ }
+ cohorts := make(map[int64]*cohort)
+ for _, candidate := range candidates {
+ if reason := channelSmartScheduleCandidateSkipReason(candidate, strategy, stabilityEnabled, minSamples); reason != "" {
+ plan.Skipped[candidate.ChannelId] = reason
+ continue
+ }
+ var key int64
+ if applyMode == channelMonitorSmartScheduleApplyWeight && !forceReset {
+ key = candidate.CurrentPriority
+ }
+ scheduleCohort := cohorts[key]
+ if scheduleCohort == nil {
+ scheduleCohort = &cohort{}
+ cohorts[key] = scheduleCohort
+ }
+ scheduleCohort.Candidates = append(scheduleCohort.Candidates, candidate)
+ }
+
+ for _, scheduleCohort := range cohorts {
+ if len(scheduleCohort.Candidates) < 2 {
+ reason := "可调渠道不足 2 个"
+ if applyMode == channelMonitorSmartScheduleApplyWeight && !forceReset {
+ reason = "同优先级可调渠道不足 2 个"
+ }
+ for _, candidate := range scheduleCohort.Candidates {
+ plan.Skipped[candidate.ChannelId] = reason
+ }
+ continue
+ }
+ ratioMin, ratioMax := math.Inf(1), math.Inf(-1)
+ firstTokenMin, firstTokenMax := math.Inf(1), math.Inf(-1)
+ tpsMin, tpsMax := math.Inf(1), math.Inf(-1)
+ for _, candidate := range scheduleCohort.Candidates {
+ if candidate.Ratio != nil {
+ ratioMin = math.Min(ratioMin, *candidate.Ratio)
+ ratioMax = math.Max(ratioMax, *candidate.Ratio)
+ }
+ if candidate.FirstTokenMs != nil {
+ firstTokenMin = math.Min(firstTokenMin, *candidate.FirstTokenMs)
+ firstTokenMax = math.Max(firstTokenMax, *candidate.FirstTokenMs)
+ }
+ if candidate.TPS != nil {
+ tpsMin = math.Min(tpsMin, *candidate.TPS)
+ tpsMax = math.Max(tpsMax, *candidate.TPS)
+ }
+ }
+
+ items := make([]channelSmartSchedulePlanItem, 0, len(scheduleCohort.Candidates))
+ for _, candidate := range scheduleCohort.Candidates {
+ ratioScore := 0.0
+ if candidate.Ratio != nil {
+ ratioScore = channelSmartScheduleLowerIsBetterScore(*candidate.Ratio, ratioMin, ratioMax)
+ }
+ firstTokenScore := 0.0
+ if candidate.FirstTokenMs != nil {
+ firstTokenScore = channelSmartScheduleLowerIsBetterScore(*candidate.FirstTokenMs, firstTokenMin, firstTokenMax)
+ }
+ tpsScore := 0.0
+ if candidate.TPS != nil {
+ tpsScore = channelSmartScheduleHigherIsBetterScore(*candidate.TPS, tpsMin, tpsMax)
+ }
+ stabilityScore := 0.0
+ if candidate.Stability != nil {
+ stabilityScore = channelSmartScheduleHigherIsBetterScore(*candidate.Stability, 0, 1)
+ }
+
+ scoreTotal := 0.0
+ scoreCount := 0
+ switch strategy {
+ case channelMonitorSmartScheduleStrategyRatio:
+ scoreTotal = ratioScore
+ scoreCount = 1
+ case channelMonitorSmartScheduleStrategyFirstToken:
+ scoreTotal = firstTokenScore
+ scoreCount = 1
+ case channelMonitorSmartScheduleStrategyTPS:
+ scoreTotal = tpsScore
+ scoreCount = 1
+ case channelMonitorSmartScheduleStrategySmart:
+ scoreTotal = ratioScore + firstTokenScore + tpsScore
+ scoreCount = 3
+ default:
+ continue
+ }
+ if stabilityEnabled {
+ scoreTotal += stabilityScore
+ scoreCount++
+ }
+ score := scoreTotal / float64(scoreCount)
+ targetWeight := uint(math.Round((channelMonitorSmartScheduleMinWeight+score*(channelMonitorSmartScheduleMaxWeight-channelMonitorSmartScheduleMinWeight))/channelMonitorSmartScheduleWeightStep) * channelMonitorSmartScheduleWeightStep)
+ if targetWeight < channelMonitorSmartScheduleMinWeight {
+ targetWeight = channelMonitorSmartScheduleMinWeight
+ } else if targetWeight > channelMonitorSmartScheduleMaxWeight {
+ targetWeight = channelMonitorSmartScheduleMaxWeight
+ }
+ if !forceReset {
+ targetWeight = channelSmartScheduleDampedWeight(candidate.CurrentWeight, targetWeight)
+ }
+ targetPriority := candidate.CurrentPriority
+ if forceReset && applyMode == channelMonitorSmartScheduleApplyWeight {
+ targetPriority = 0
+ }
+ items = append(items, channelSmartSchedulePlanItem{
+ ChannelId: candidate.ChannelId,
+ Score: score,
+ CurrentPriority: candidate.CurrentPriority,
+ CurrentWeight: candidate.CurrentWeight,
+ TargetPriority: targetPriority,
+ TargetWeight: targetWeight,
+ })
+ }
+
+ sort.Slice(items, func(i int, j int) bool {
+ if math.Abs(items[i].Score-items[j].Score) > channelMonitorRatioEpsilon {
+ return items[i].Score > items[j].Score
+ }
+ return items[i].ChannelId < items[j].ChannelId
+ })
+ if applyMode == channelMonitorSmartScheduleApplyPriorityWeight {
+ priorities := []int64{100, 90, 80}
+ for index := range items {
+ tier := index * len(priorities) / len(items)
+ if tier >= len(priorities) {
+ tier = len(priorities) - 1
+ }
+ items[index].TargetPriority = priorities[tier]
+ }
+ }
+ plan.Items = append(plan.Items, items...)
+ }
+
+ sort.Slice(plan.Items, func(i int, j int) bool {
+ return plan.Items[i].ChannelId < plan.Items[j].ChannelId
+ })
+ return plan
+}
+
+func channelSmartScheduleCandidateSkipReason(candidate channelSmartScheduleCandidate, strategy string, stabilityEnabled bool, minSamples int) string {
+ if stabilityEnabled && !candidate.StabilityAvailable {
+ return "稳定性统计不可用,请开启消费日志和 ERROR_LOG_ENABLED"
+ }
+ if strategy == channelMonitorSmartScheduleStrategyRatio || strategy == channelMonitorSmartScheduleStrategySmart {
+ if candidate.Ratio == nil {
+ return "未记录成本倍率"
+ }
+ }
+ if strategy == channelMonitorSmartScheduleStrategyFirstToken || strategy == channelMonitorSmartScheduleStrategySmart {
+ if candidate.FirstTokenMs == nil || candidate.FirstTokenSampleCount < minSamples {
+ return fmt.Sprintf("首字样本不足(%d/%d)", candidate.FirstTokenSampleCount, minSamples)
+ }
+ }
+ if strategy == channelMonitorSmartScheduleStrategyTPS || strategy == channelMonitorSmartScheduleStrategySmart {
+ if candidate.TPS == nil || candidate.TPSSampleCount < minSamples {
+ return fmt.Sprintf("TPS 样本不足(%d/%d)", candidate.TPSSampleCount, minSamples)
+ }
+ }
+ if stabilityEnabled {
+ if candidate.Stability == nil || candidate.StabilitySampleCount < int64(minSamples) {
+ return fmt.Sprintf("稳定性样本不足(%d/%d)", candidate.StabilitySampleCount, minSamples)
+ }
+ }
+ return ""
+}
+
+func channelSmartScheduleLowerIsBetterScore(value float64, minimum float64, maximum float64) float64 {
+ if maximum-minimum <= channelMonitorRatioEpsilon {
+ return 1
+ }
+ return (maximum - value) / (maximum - minimum)
+}
+
+func channelSmartScheduleHigherIsBetterScore(value float64, minimum float64, maximum float64) float64 {
+ if maximum-minimum <= channelMonitorRatioEpsilon {
+ return 1
+ }
+ return (value - minimum) / (maximum - minimum)
+}
+
+func channelSmartScheduleDampedWeight(current uint, target uint) uint {
+ if current == 0 {
+ return target
+ }
+ if current > target {
+ difference := current - target
+ if difference < channelMonitorSmartScheduleMinWeightChange {
+ return current
+ }
+ if difference > channelMonitorSmartScheduleMaxWeightChange {
+ return current - channelMonitorSmartScheduleMaxWeightChange
+ }
+ return target
+ }
+ difference := target - current
+ if difference < channelMonitorSmartScheduleMinWeightChange {
+ return current
+ }
+ if difference > channelMonitorSmartScheduleMaxWeightChange {
+ return current + channelMonitorSmartScheduleMaxWeightChange
+ }
+ return target
+}
diff --git a/controller/channel_ratio_monitor_schedule_test.go b/controller/channel_ratio_monitor_schedule_test.go
new file mode 100644
index 000000000000..d7504dc093f3
--- /dev/null
+++ b/controller/channel_ratio_monitor_schedule_test.go
@@ -0,0 +1,427 @@
+package controller
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestChannelSmartSchedulePreferredModelUsesConfiguredOrder(t *testing.T) {
+ assert.Equal(t, "model-b", channelSmartSchedulePreferredModel(
+ []string{"model-a", " model-b "},
+ []string{"model-c", "model-b", "model-a"},
+ ))
+ assert.Empty(t, channelSmartSchedulePreferredModel(
+ []string{"model-a"},
+ []string{"model-b", "model-c"},
+ ))
+}
+
+func TestRunChannelSmartScheduleUsesFirstSupportedModelPerChannel(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorSmartScheduleEnabledOption: "true",
+ channelMonitorSmartScheduleStrategyOption: channelMonitorSmartScheduleStrategyFirstToken,
+ channelMonitorSmartScheduleApplyModeOption: channelMonitorSmartScheduleApplyWeight,
+ channelMonitorSmartScheduleModelsOption: `["model-b","model-a"]`,
+ channelMonitorSmartScheduleSamplesOption: "1",
+ })
+ priority := int64(0)
+ weight := uint(50)
+ channels := []model.Channel{
+ {Id: 101, Name: "supports both", Group: "vip", Models: "model-a,model-b", Status: common.ChannelStatusEnabled, Priority: &priority, Weight: &weight},
+ {Id: 102, Name: "fallback", Group: "vip", Models: "model-a", Status: common.ChannelStatusEnabled, Priority: &priority, Weight: &weight},
+ {Id: 103, Name: "unsupported", Group: "vip", Models: "model-c", Status: common.ChannelStatusEnabled, Priority: &priority, Weight: &weight},
+ }
+ require.NoError(t, db.Create(&channels).Error)
+ now := time.Now().Unix()
+ require.NoError(t, db.Create(&[]model.Log{
+ {ChannelId: 101, ModelName: "model-a", CreatedAt: now, Type: model.LogTypeConsume, IsStream: true, Other: `{"frt":100}`},
+ {ChannelId: 101, ModelName: "model-b", CreatedAt: now, Type: model.LogTypeConsume, IsStream: true, Other: `{"frt":1000}`},
+ {ChannelId: 102, ModelName: "model-a", CreatedAt: now, Type: model.LogTypeConsume, IsStream: true, Other: `{"frt":100}`},
+ }).Error)
+
+ result, err := runChannelSmartScheduleOnce(context.Background(), nil, false)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"model-b", "model-a"}, result.Models)
+ assert.Equal(t, "model-b", result.Model)
+ assert.Equal(t, 2, result.Updated)
+ assert.Equal(t, 1, result.Skipped)
+
+ first, err := model.GetChannelById(101, false)
+ require.NoError(t, err)
+ second, err := model.GetChannelById(102, false)
+ require.NoError(t, err)
+ assert.Equal(t, 30, first.GetWeight())
+ assert.Equal(t, 70, second.GetWeight())
+
+ unsupportedMonitor, err := model.GetChannelRatioMonitor(103)
+ require.NoError(t, err)
+ assert.Equal(t, model.ChannelSmartScheduleStatusSkipped, unsupportedMonitor.LastScheduleStatus)
+ assert.Equal(t, "渠道不支持已配置的基准模型", unsupportedMonitor.LastScheduleError)
+}
+
+func TestRunChannelSmartScheduleUsesConvertedCostRatioAcrossGroups(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorSmartScheduleEnabledOption: "true",
+ channelMonitorSmartScheduleStrategyOption: channelMonitorSmartScheduleStrategyRatio,
+ channelMonitorSmartScheduleApplyModeOption: channelMonitorSmartScheduleApplyWeight,
+ })
+ priority := int64(0)
+ weight := uint(50)
+ channels := []model.Channel{
+ {Id: 1, Name: "cheap raw", Group: "vip", Status: common.ChannelStatusEnabled, Priority: &priority, Weight: &weight},
+ {Id: 2, Name: "cheap cost", Group: "standard", Status: common.ChannelStatusEnabled, Priority: &priority, Weight: &weight},
+ }
+ require.NoError(t, db.Create(&channels).Error)
+ require.NoError(t, db.Create(&[]model.ChannelRatioMonitor{
+ {
+ ChannelId: 1, Ratio: 0.5, UpdatedTime: 1,
+ CostConversion: `{"mode":"recharge","paid_cny":400,"credited_usd":100}`,
+ },
+ {ChannelId: 2, Ratio: 1, UpdatedTime: 1},
+ }).Error)
+
+ result, err := runChannelSmartScheduleOnce(context.Background(), nil, false)
+ require.NoError(t, err)
+ assert.Equal(t, 2, result.Updated)
+
+ first, err := model.GetChannelById(1, false)
+ require.NoError(t, err)
+ second, err := model.GetChannelById(2, false)
+ require.NoError(t, err)
+ assert.Equal(t, 30, first.GetWeight())
+ assert.Equal(t, 70, second.GetWeight())
+
+ firstMonitor, err := model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ secondMonitor, err := model.GetChannelRatioMonitor(2)
+ require.NoError(t, err)
+ require.NotNil(t, firstMonitor.LastScheduleScore)
+ require.NotNil(t, secondMonitor.LastScheduleScore)
+ assert.InDelta(t, 0, *firstMonitor.LastScheduleScore, 1e-9)
+ assert.InDelta(t, 1, *secondMonitor.LastScheduleScore, 1e-9)
+}
+
+func TestRunChannelSmartScheduleForceResetSetsBaselineBeforePlanning(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorSmartScheduleEnabledOption: "true",
+ channelMonitorSmartScheduleStrategyOption: channelMonitorSmartScheduleStrategyRatio,
+ channelMonitorSmartScheduleApplyModeOption: channelMonitorSmartScheduleApplyWeight,
+ })
+ priority := int64(100)
+ weight := uint(90)
+ channels := []model.Channel{
+ {Id: 11, Name: "best", Group: "vip", Models: "model-a", Status: common.ChannelStatusEnabled, Priority: &priority, Weight: &weight},
+ {Id: 12, Name: "worst", Group: "vip", Models: "model-a", Status: common.ChannelStatusEnabled, Priority: &priority, Weight: &weight},
+ {Id: 13, Name: "missing ratio", Group: "vip", Models: "model-a", Status: common.ChannelStatusEnabled, Priority: &priority, Weight: &weight},
+ {Id: 14, Name: "excluded", Group: "vip", Models: "model-a", Status: common.ChannelStatusEnabled, Priority: &priority, Weight: &weight},
+ }
+ require.NoError(t, db.Create(&channels).Error)
+ require.NoError(t, db.Create(&[]model.ChannelRatioMonitor{
+ {ChannelId: 11, Ratio: 1, UpdatedTime: 1},
+ {ChannelId: 12, Ratio: 3, UpdatedTime: 1},
+ {ChannelId: 13},
+ {ChannelId: 14, Ratio: 2, UpdatedTime: 1, SmartScheduleExcluded: true},
+ }).Error)
+ abilities := make([]model.Ability, 0, len(channels))
+ for _, channel := range channels {
+ abilities = append(abilities, model.Ability{
+ Group: "vip",
+ Model: "model-a",
+ ChannelId: channel.Id,
+ Enabled: true,
+ Priority: &priority,
+ Weight: weight,
+ })
+ }
+ require.NoError(t, db.Create(&abilities).Error)
+
+ result, err := runChannelSmartScheduleOnce(context.Background(), nil, true)
+ require.NoError(t, err)
+ assert.Equal(t, 1, result.Updated)
+ assert.Equal(t, 1, result.Unchanged)
+ assert.Equal(t, 2, result.Skipped)
+
+ expected := map[int]struct {
+ priority int64
+ weight uint
+ }{
+ 11: {priority: 0, weight: 100},
+ 12: {priority: 0, weight: 10},
+ 13: {priority: 0, weight: 10},
+ 14: {priority: 100, weight: 90},
+ }
+ for channelId, target := range expected {
+ var channel model.Channel
+ require.NoError(t, db.First(&channel, "id = ?", channelId).Error)
+ assert.Equal(t, target.priority, channel.GetPriority())
+ assert.Equal(t, int(target.weight), channel.GetWeight())
+
+ var ability model.Ability
+ require.NoError(t, db.First(&ability, "channel_id = ?", channelId).Error)
+ require.NotNil(t, ability.Priority)
+ assert.Equal(t, target.priority, *ability.Priority)
+ assert.Equal(t, target.weight, ability.Weight)
+ }
+
+ monitor, err := model.GetChannelRatioMonitor(13)
+ require.NoError(t, err)
+ assert.Equal(t, model.ChannelSmartScheduleStatusSkipped, monitor.LastScheduleStatus)
+ assert.Equal(t, int64(0), monitor.LastSchedulePriority)
+ assert.Equal(t, uint(10), monitor.LastScheduleWeight)
+}
+
+func TestRunChannelSmartScheduleForceResetKeepsBaselineWhenCohortIsTooSmall(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorSmartScheduleEnabledOption: "true",
+ channelMonitorSmartScheduleStrategyOption: channelMonitorSmartScheduleStrategyRatio,
+ channelMonitorSmartScheduleApplyModeOption: channelMonitorSmartScheduleApplyWeight,
+ })
+ firstPriority := int64(100)
+ firstWeight := uint(80)
+ secondPriority := int64(90)
+ secondWeight := uint(70)
+ channels := []model.Channel{
+ {Id: 21, Name: "only candidate", Group: "vip", Status: common.ChannelStatusEnabled, Priority: &firstPriority, Weight: &firstWeight},
+ {Id: 22, Name: "missing ratio", Group: "vip", Status: common.ChannelStatusEnabled, Priority: &secondPriority, Weight: &secondWeight},
+ }
+ require.NoError(t, db.Create(&channels).Error)
+ require.NoError(t, db.Create(&[]model.ChannelRatioMonitor{
+ {ChannelId: 21, Ratio: 1, UpdatedTime: 1},
+ {ChannelId: 22},
+ }).Error)
+
+ result, err := runChannelSmartScheduleOnce(context.Background(), nil, true)
+ require.NoError(t, err)
+ assert.Zero(t, result.Updated)
+ assert.Equal(t, 2, result.Skipped)
+
+ for channelId, expected := range map[int]struct {
+ priority int64
+ weight int
+ }{
+ 21: {priority: 0, weight: 10},
+ 22: {priority: 0, weight: 10},
+ } {
+ var channel model.Channel
+ require.NoError(t, db.First(&channel, "id = ?", channelId).Error)
+ assert.Equal(t, expected.priority, channel.GetPriority())
+ assert.Equal(t, expected.weight, channel.GetWeight())
+ }
+}
+
+func TestPlanChannelSmartScheduleWeightOnlyKeepsPriorityCohorts(t *testing.T) {
+ ratioOne := 1.0
+ ratioTwo := 2.0
+ ratioThree := 3.0
+ plan := planChannelSmartSchedule([]channelSmartScheduleCandidate{
+ {ChannelId: 1, CurrentPriority: 0, Ratio: &ratioOne},
+ {ChannelId: 2, CurrentPriority: 0, Ratio: &ratioTwo},
+ {ChannelId: 3, CurrentPriority: 10, Ratio: &ratioThree},
+ {ChannelId: 4, CurrentPriority: 10, Ratio: &ratioOne},
+ }, channelMonitorSmartScheduleStrategyRatio, false, channelMonitorSmartScheduleApplyWeight, 5, false)
+
+ require.Len(t, plan.Items, 4)
+ assert.Empty(t, plan.Skipped)
+
+ items := make(map[int]channelSmartSchedulePlanItem, len(plan.Items))
+ for _, item := range plan.Items {
+ items[item.ChannelId] = item
+ }
+ assert.Equal(t, int64(0), items[1].TargetPriority)
+ assert.Equal(t, uint(100), items[1].TargetWeight)
+ assert.Equal(t, int64(0), items[2].TargetPriority)
+ assert.Equal(t, uint(10), items[2].TargetWeight)
+ assert.Equal(t, int64(10), items[3].TargetPriority)
+ assert.Equal(t, uint(10), items[3].TargetWeight)
+ assert.Equal(t, int64(10), items[4].TargetPriority)
+ assert.Equal(t, uint(100), items[4].TargetWeight)
+}
+
+func TestPlanChannelSmartSchedulePriorityWeightUsesQualityTiersAndDamping(t *testing.T) {
+ ratioOne := 1.0
+ ratioTwo := 2.0
+ ratioThree := 3.0
+ plan := planChannelSmartSchedule([]channelSmartScheduleCandidate{
+ {ChannelId: 1, CurrentPriority: 0, CurrentWeight: 50, Ratio: &ratioOne},
+ {ChannelId: 2, CurrentPriority: 0, CurrentWeight: 50, Ratio: &ratioTwo},
+ {ChannelId: 3, CurrentPriority: 0, CurrentWeight: 50, Ratio: &ratioThree},
+ }, channelMonitorSmartScheduleStrategyRatio, false, channelMonitorSmartScheduleApplyPriorityWeight, 5, false)
+
+ require.Len(t, plan.Items, 3)
+ items := make(map[int]channelSmartSchedulePlanItem, len(plan.Items))
+ for _, item := range plan.Items {
+ items[item.ChannelId] = item
+ }
+ assert.Equal(t, int64(100), items[1].TargetPriority)
+ assert.Equal(t, uint(70), items[1].TargetWeight)
+ assert.Equal(t, int64(90), items[2].TargetPriority)
+ assert.Equal(t, uint(50), items[2].TargetWeight)
+ assert.Equal(t, int64(80), items[3].TargetPriority)
+ assert.Equal(t, uint(30), items[3].TargetWeight)
+}
+
+func TestPlanChannelSmartScheduleRequiresConfiguredSamples(t *testing.T) {
+ ratio := 1.0
+ firstToken := 1000.0
+ tps := 30.0
+ plan := planChannelSmartSchedule([]channelSmartScheduleCandidate{
+ {
+ ChannelId: 1,
+ Ratio: &ratio,
+ FirstTokenMs: &firstToken,
+ TPS: &tps,
+ FirstTokenSampleCount: 5,
+ TPSSampleCount: 5,
+ },
+ {
+ ChannelId: 2,
+ Ratio: &ratio,
+ FirstTokenMs: &firstToken,
+ TPS: &tps,
+ FirstTokenSampleCount: 4,
+ TPSSampleCount: 5,
+ },
+ }, channelMonitorSmartScheduleStrategyFirstToken, false, channelMonitorSmartScheduleApplyWeight, 5, false)
+
+ assert.Empty(t, plan.Items)
+ assert.Equal(t, "同优先级可调渠道不足 2 个", plan.Skipped[1])
+ assert.Equal(t, "首字样本不足(4/5)", plan.Skipped[2])
+}
+
+func TestPlanChannelSmartScheduleSmartAddsStabilityWhenEnabled(t *testing.T) {
+ ratioLow := 1.0
+ ratioHigh := 2.0
+ firstTokenFast := 300.0
+ firstTokenSlow := 900.0
+ tpsSlow := 10.0
+ tpsFast := 30.0
+ stabilityLower := 0.80
+ stabilityHigher := 1.0
+ plan := planChannelSmartSchedule([]channelSmartScheduleCandidate{
+ {
+ ChannelId: 1, Ratio: &ratioLow,
+ FirstTokenMs: &firstTokenFast, FirstTokenSampleCount: 5,
+ TPS: &tpsSlow, TPSSampleCount: 5,
+ Stability: &stabilityLower, StabilitySampleCount: 5, StabilityAvailable: true,
+ },
+ {
+ ChannelId: 2, Ratio: &ratioHigh,
+ FirstTokenMs: &firstTokenSlow, FirstTokenSampleCount: 5,
+ TPS: &tpsFast, TPSSampleCount: 5,
+ Stability: &stabilityHigher, StabilitySampleCount: 5, StabilityAvailable: true,
+ },
+ }, channelMonitorSmartScheduleStrategySmart, false, channelMonitorSmartScheduleApplyWeight, 5, false)
+
+ require.Len(t, plan.Items, 2)
+ items := make(map[int]channelSmartSchedulePlanItem, len(plan.Items))
+ for _, item := range plan.Items {
+ items[item.ChannelId] = item
+ }
+ assert.Equal(t, uint(70), items[1].TargetWeight)
+ assert.Equal(t, uint(40), items[2].TargetWeight)
+
+ plan = planChannelSmartSchedule([]channelSmartScheduleCandidate{
+ {
+ ChannelId: 1, Ratio: &ratioLow,
+ FirstTokenMs: &firstTokenFast, FirstTokenSampleCount: 5,
+ TPS: &tpsSlow, TPSSampleCount: 5,
+ Stability: &stabilityLower, StabilitySampleCount: 5, StabilityAvailable: true,
+ },
+ {
+ ChannelId: 2, Ratio: &ratioHigh,
+ FirstTokenMs: &firstTokenSlow, FirstTokenSampleCount: 5,
+ TPS: &tpsFast, TPSSampleCount: 5,
+ Stability: &stabilityHigher, StabilitySampleCount: 5, StabilityAvailable: true,
+ },
+ }, channelMonitorSmartScheduleStrategySmart, true, channelMonitorSmartScheduleApplyWeight, 5, false)
+ require.Len(t, plan.Items, 2)
+ items = make(map[int]channelSmartSchedulePlanItem, len(plan.Items))
+ for _, item := range plan.Items {
+ items[item.ChannelId] = item
+ }
+ assert.Equal(t, uint(75), items[1].TargetWeight)
+ assert.Equal(t, uint(55), items[2].TargetWeight)
+}
+
+func TestPlanChannelSmartScheduleCombinesStabilityWithSelectedStrategy(t *testing.T) {
+ ratio := 1.0
+ stableRate := 0.99
+ unstableRate := 0.80
+ plan := planChannelSmartSchedule([]channelSmartScheduleCandidate{
+ {ChannelId: 1, Ratio: &ratio, Stability: &stableRate, StabilitySampleCount: 100, StabilityAvailable: true},
+ {ChannelId: 2, Ratio: &ratio, Stability: &unstableRate, StabilitySampleCount: 100, StabilityAvailable: true},
+ }, channelMonitorSmartScheduleStrategyRatio, true, channelMonitorSmartScheduleApplyWeight, 5, false)
+
+ require.Len(t, plan.Items, 2)
+ items := make(map[int]channelSmartSchedulePlanItem, len(plan.Items))
+ for _, item := range plan.Items {
+ items[item.ChannelId] = item
+ }
+ assert.Equal(t, uint(100), items[1].TargetWeight)
+ assert.Equal(t, uint(90), items[2].TargetWeight)
+
+ plan = planChannelSmartSchedule([]channelSmartScheduleCandidate{
+ {ChannelId: 3, Ratio: &ratio},
+ {ChannelId: 4, Ratio: &ratio},
+ }, channelMonitorSmartScheduleStrategyRatio, true, channelMonitorSmartScheduleApplyWeight, 5, false)
+ assert.Empty(t, plan.Items)
+ assert.Equal(t, "稳定性统计不可用,请开启消费日志和 ERROR_LOG_ENABLED", plan.Skipped[3])
+ assert.Equal(t, "稳定性统计不可用,请开启消费日志和 ERROR_LOG_ENABLED", plan.Skipped[4])
+
+ plan = planChannelSmartSchedule([]channelSmartScheduleCandidate{
+ {ChannelId: 3, Ratio: &ratio},
+ {ChannelId: 4, Ratio: &ratio},
+ }, channelMonitorSmartScheduleStrategyRatio, false, channelMonitorSmartScheduleApplyWeight, 5, false)
+ require.Len(t, plan.Items, 2)
+ assert.Empty(t, plan.Skipped)
+}
+
+func TestPlanChannelSmartScheduleForceResetRecalculatesPriorityAndWeight(t *testing.T) {
+ ratioLow := 1.0
+ ratioHigh := 3.0
+ plan := planChannelSmartSchedule([]channelSmartScheduleCandidate{
+ {ChannelId: 1, CurrentPriority: 100, CurrentWeight: 90, Ratio: &ratioLow},
+ {ChannelId: 2, CurrentPriority: 80, CurrentWeight: 90, Ratio: &ratioHigh},
+ }, channelMonitorSmartScheduleStrategyRatio, false, channelMonitorSmartScheduleApplyWeight, 5, true)
+
+ require.Len(t, plan.Items, 2)
+ assert.Empty(t, plan.Skipped)
+ items := make(map[int]channelSmartSchedulePlanItem, len(plan.Items))
+ for _, item := range plan.Items {
+ items[item.ChannelId] = item
+ }
+ assert.Equal(t, int64(0), items[1].TargetPriority)
+ assert.Equal(t, uint(100), items[1].TargetWeight)
+ assert.Equal(t, int64(0), items[2].TargetPriority)
+ assert.Equal(t, uint(10), items[2].TargetWeight)
+
+ ratioMiddle := 2.0
+ plan = planChannelSmartSchedule([]channelSmartScheduleCandidate{
+ {ChannelId: 1, CurrentPriority: 0, CurrentWeight: 10, Ratio: &ratioLow},
+ {ChannelId: 2, CurrentPriority: 0, CurrentWeight: 10, Ratio: &ratioMiddle},
+ {ChannelId: 3, CurrentPriority: 0, CurrentWeight: 100, Ratio: &ratioHigh},
+ }, channelMonitorSmartScheduleStrategyRatio, false, channelMonitorSmartScheduleApplyPriorityWeight, 5, true)
+
+ require.Len(t, plan.Items, 3)
+ items = make(map[int]channelSmartSchedulePlanItem, len(plan.Items))
+ for _, item := range plan.Items {
+ items[item.ChannelId] = item
+ }
+ assert.Equal(t, int64(100), items[1].TargetPriority)
+ assert.Equal(t, uint(100), items[1].TargetWeight)
+ assert.Equal(t, int64(90), items[2].TargetPriority)
+ assert.Equal(t, uint(55), items[2].TargetWeight)
+ assert.Equal(t, int64(80), items[3].TargetPriority)
+ assert.Equal(t, uint(10), items[3].TargetWeight)
+}
diff --git a/controller/channel_ratio_monitor_settings.go b/controller/channel_ratio_monitor_settings.go
new file mode 100644
index 000000000000..3f0b97881687
--- /dev/null
+++ b/controller/channel_ratio_monitor_settings.go
@@ -0,0 +1,618 @@
+package controller
+
+import (
+ "errors"
+ "fmt"
+ "math"
+ "net/http"
+ "net/mail"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ channelMonitorAutoUpdateIntervalOption = "ChannelMonitorAutoUpdateIntervalMinutes"
+ channelMonitorAutoUpdateRetryCountOption = "ChannelMonitorAutoUpdateRetryCount"
+ channelMonitorAutoDisableOnUpdateFailureOption = "ChannelMonitorAutoDisableOnUpdateFailure"
+ channelMonitorEmailNotificationOption = "ChannelMonitorEmailNotificationEnabled"
+ channelMonitorNotificationEmailOption = "ChannelMonitorNotificationEmail"
+ channelMonitorGroupCoefficientsOption = "ChannelMonitorGroupCoefficients"
+ channelMonitorChannelOrderOption = "ChannelMonitorChannelOrder"
+ channelMonitorSmartScheduleEnabledOption = "ChannelMonitorSmartScheduleEnabled"
+ channelMonitorSmartScheduleIntervalOption = "ChannelMonitorSmartScheduleIntervalMinutes"
+ channelMonitorSmartScheduleStrategyOption = "ChannelMonitorSmartScheduleStrategy"
+ channelMonitorSmartScheduleStabilityOption = "ChannelMonitorSmartScheduleStabilityEnabled"
+ channelMonitorSmartScheduleApplyModeOption = "ChannelMonitorSmartScheduleApplyMode"
+ channelMonitorSmartScheduleRangeOption = "ChannelMonitorSmartSchedulePerformanceMinutes"
+ channelMonitorSmartScheduleModelOption = "ChannelMonitorSmartScheduleModel"
+ channelMonitorSmartScheduleModelsOption = "ChannelMonitorSmartScheduleModels"
+ channelMonitorSmartScheduleSamplesOption = "ChannelMonitorSmartScheduleMinSamples"
+ channelMonitorPolicyActionNone = "none"
+ channelMonitorPolicyActionUpdateGroupRatio = "update_group_ratio"
+ channelMonitorPolicyActionDisableChannel = "disable_channel"
+ channelMonitorPolicyActionRemoveFromGroup = "remove_from_group"
+ channelMonitorSmartScheduleStrategyRatio = "ratio"
+ channelMonitorSmartScheduleStrategyFirstToken = "first_token"
+ channelMonitorSmartScheduleStrategyTPS = "tps"
+ channelMonitorSmartScheduleStrategySmart = "smart"
+ legacyChannelMonitorSmartScheduleStrategyStability = "stability"
+ channelMonitorSmartScheduleApplyWeight = "weight"
+ channelMonitorSmartScheduleApplyPriorityWeight = "priority_weight"
+ maxChannelMonitorAutoUpdateIntervalMinutes = 525600
+ maxChannelMonitorAutoUpdateRetryCount = 10
+ maxChannelMonitorNotificationEmailLength = 254
+ maxChannelMonitorChannelOrderCount = 100000
+ maxChannelMonitorSmartScheduleModelLength = 255
+ maxChannelMonitorSmartScheduleModelCount = 100
+ maxChannelMonitorSmartScheduleMinSamples = 100000
+ defaultChannelMonitorAutoUpdateRetryCount = 2
+ defaultChannelMonitorGroupCoefficient = 1
+ defaultChannelMonitorSmartScheduleInterval = 10
+ defaultChannelMonitorSmartScheduleRange = 60
+ defaultChannelMonitorSmartScheduleSamples = 5
+)
+
+type channelMonitorSettings struct {
+ AutoUpdateIntervalMinutes int `json:"auto_update_interval_minutes"`
+ AutoUpdateRetryCount int `json:"auto_update_retry_count"`
+ AutoDisableOnUpdateFailure bool `json:"auto_disable_on_update_failure"`
+ EmailNotificationEnabled bool `json:"email_notification_enabled"`
+ NotificationEmail string `json:"notification_email"`
+ SmartScheduleEnabled bool `json:"smart_schedule_enabled"`
+ SmartScheduleIntervalMinutes int `json:"smart_schedule_interval_minutes"`
+ SmartScheduleStrategy string `json:"smart_schedule_strategy"`
+ SmartScheduleStabilityEnabled bool `json:"smart_schedule_stability_enabled"`
+ SmartScheduleApplyMode string `json:"smart_schedule_apply_mode"`
+ SmartSchedulePerformanceMinutes int `json:"smart_schedule_performance_minutes"`
+ SmartScheduleModel string `json:"smart_schedule_model"`
+ SmartScheduleModels []string `json:"smart_schedule_models"`
+ SmartScheduleMinSamples int `json:"smart_schedule_min_samples"`
+ SmartScheduleForceResetTaskCreated *bool `json:"smart_schedule_force_reset_task_created,omitempty"`
+ SmartScheduleForceResetTaskId string `json:"smart_schedule_force_reset_task_id,omitempty"`
+ SmartScheduleForceResetTaskError string `json:"smart_schedule_force_reset_task_error,omitempty"`
+}
+
+type channelMonitorSettingsUpdateRequest struct {
+ AutoUpdateIntervalMinutes *int `json:"auto_update_interval_minutes"`
+ AutoUpdateRetryCount *int `json:"auto_update_retry_count"`
+ AutoDisableOnUpdateFailure *bool `json:"auto_disable_on_update_failure"`
+ EmailNotificationEnabled *bool `json:"email_notification_enabled"`
+ NotificationEmail *string `json:"notification_email"`
+ SmartScheduleEnabled *bool `json:"smart_schedule_enabled"`
+ SmartScheduleIntervalMinutes *int `json:"smart_schedule_interval_minutes"`
+ SmartScheduleStrategy *string `json:"smart_schedule_strategy"`
+ SmartScheduleStabilityEnabled *bool `json:"smart_schedule_stability_enabled"`
+ SmartScheduleApplyMode *string `json:"smart_schedule_apply_mode"`
+ SmartSchedulePerformanceMinutes *int `json:"smart_schedule_performance_minutes"`
+ SmartScheduleModel *string `json:"smart_schedule_model"`
+ SmartScheduleModels *[]string `json:"smart_schedule_models"`
+ SmartScheduleMinSamples *int `json:"smart_schedule_min_samples"`
+ SmartScheduleForceReset *bool `json:"smart_schedule_force_reset"`
+}
+
+type channelMonitorOrderUpdateRequest struct {
+ ChannelIds *[]int `json:"channel_ids"`
+}
+
+func getChannelMonitorSettings() channelMonitorSettings {
+ common.OptionMapRWMutex.RLock()
+ rawInterval := common.OptionMap[channelMonitorAutoUpdateIntervalOption]
+ rawRetryCount := common.OptionMap[channelMonitorAutoUpdateRetryCountOption]
+ rawAutoDisableOnUpdateFailure := common.OptionMap[channelMonitorAutoDisableOnUpdateFailureOption]
+ rawEmailNotificationEnabled := common.OptionMap[channelMonitorEmailNotificationOption]
+ rawNotificationEmail := common.OptionMap[channelMonitorNotificationEmailOption]
+ rawSmartScheduleEnabled := common.OptionMap[channelMonitorSmartScheduleEnabledOption]
+ rawSmartScheduleInterval := common.OptionMap[channelMonitorSmartScheduleIntervalOption]
+ rawSmartScheduleStrategy := common.OptionMap[channelMonitorSmartScheduleStrategyOption]
+ rawSmartScheduleStabilityEnabled := common.OptionMap[channelMonitorSmartScheduleStabilityOption]
+ rawSmartScheduleApplyMode := common.OptionMap[channelMonitorSmartScheduleApplyModeOption]
+ rawSmartScheduleRange := common.OptionMap[channelMonitorSmartScheduleRangeOption]
+ rawSmartScheduleModel := common.OptionMap[channelMonitorSmartScheduleModelOption]
+ rawSmartScheduleModels, hasSmartScheduleModels := common.OptionMap[channelMonitorSmartScheduleModelsOption]
+ rawSmartScheduleSamples := common.OptionMap[channelMonitorSmartScheduleSamplesOption]
+ common.OptionMapRWMutex.RUnlock()
+
+ interval, err := strconv.Atoi(rawInterval)
+ if err != nil || interval < 0 || interval > maxChannelMonitorAutoUpdateIntervalMinutes {
+ interval = 0
+ }
+ retryCount, err := strconv.Atoi(rawRetryCount)
+ if err != nil || retryCount < 0 || retryCount > maxChannelMonitorAutoUpdateRetryCount {
+ retryCount = defaultChannelMonitorAutoUpdateRetryCount
+ }
+ autoDisableOnUpdateFailure, err := strconv.ParseBool(rawAutoDisableOnUpdateFailure)
+ if err != nil {
+ autoDisableOnUpdateFailure = false
+ }
+ notificationEmail, err := normalizeChannelMonitorNotificationEmail(rawNotificationEmail)
+ if err != nil {
+ notificationEmail = ""
+ }
+ emailNotificationEnabled, err := strconv.ParseBool(rawEmailNotificationEnabled)
+ if err != nil {
+ emailNotificationEnabled = false
+ }
+ smartScheduleEnabled, err := strconv.ParseBool(rawSmartScheduleEnabled)
+ if err != nil {
+ smartScheduleEnabled = false
+ }
+ smartScheduleStabilityEnabled, err := strconv.ParseBool(rawSmartScheduleStabilityEnabled)
+ if err != nil {
+ smartScheduleStabilityEnabled = strings.TrimSpace(rawSmartScheduleStrategy) == legacyChannelMonitorSmartScheduleStrategyStability
+ }
+ smartScheduleInterval, err := strconv.Atoi(rawSmartScheduleInterval)
+ if err != nil || smartScheduleInterval <= 0 || smartScheduleInterval > maxChannelMonitorAutoUpdateIntervalMinutes {
+ smartScheduleInterval = defaultChannelMonitorSmartScheduleInterval
+ }
+ smartScheduleRange, err := strconv.Atoi(rawSmartScheduleRange)
+ if err != nil || !isChannelMonitorPerformanceRangeSupported(smartScheduleRange) {
+ smartScheduleRange = defaultChannelMonitorSmartScheduleRange
+ }
+ smartScheduleSamples, err := strconv.Atoi(rawSmartScheduleSamples)
+ if err != nil || smartScheduleSamples <= 0 || smartScheduleSamples > maxChannelMonitorSmartScheduleMinSamples {
+ smartScheduleSamples = defaultChannelMonitorSmartScheduleSamples
+ }
+ smartScheduleModels := make([]string, 0)
+ modelsConfigured := false
+ if hasSmartScheduleModels {
+ var storedModels []string
+ if common.UnmarshalJsonStr(rawSmartScheduleModels, &storedModels) == nil && storedModels != nil {
+ normalizedModels, normalizeErr := normalizeChannelMonitorSmartScheduleModels(storedModels)
+ if normalizeErr == nil {
+ smartScheduleModels = normalizedModels
+ modelsConfigured = true
+ }
+ }
+ }
+ if !modelsConfigured {
+ legacyModels, normalizeErr := normalizeChannelMonitorSmartScheduleModels([]string{rawSmartScheduleModel})
+ if normalizeErr == nil {
+ smartScheduleModels = legacyModels
+ }
+ }
+ smartScheduleModel := ""
+ if len(smartScheduleModels) > 0 {
+ smartScheduleModel = smartScheduleModels[0]
+ }
+ return channelMonitorSettings{
+ AutoUpdateIntervalMinutes: interval,
+ AutoUpdateRetryCount: retryCount,
+ AutoDisableOnUpdateFailure: autoDisableOnUpdateFailure,
+ EmailNotificationEnabled: emailNotificationEnabled,
+ NotificationEmail: notificationEmail,
+ SmartScheduleEnabled: smartScheduleEnabled,
+ SmartScheduleIntervalMinutes: smartScheduleInterval,
+ SmartScheduleStrategy: normalizeChannelMonitorSmartScheduleStrategy(rawSmartScheduleStrategy),
+ SmartScheduleStabilityEnabled: smartScheduleStabilityEnabled,
+ SmartScheduleApplyMode: normalizeChannelMonitorSmartScheduleApplyMode(rawSmartScheduleApplyMode),
+ SmartSchedulePerformanceMinutes: smartScheduleRange,
+ SmartScheduleModel: smartScheduleModel,
+ SmartScheduleModels: smartScheduleModels,
+ SmartScheduleMinSamples: smartScheduleSamples,
+ }
+}
+
+func normalizeChannelMonitorSmartScheduleModels(models []string) ([]string, error) {
+ if len(models) > maxChannelMonitorSmartScheduleModelCount {
+ return nil, fmt.Errorf("智能调度基准模型不能超过 %d 个", maxChannelMonitorSmartScheduleModelCount)
+ }
+ normalizedModels := make([]string, 0, len(models))
+ seenModels := make(map[string]struct{}, len(models))
+ for _, modelName := range models {
+ modelName = strings.TrimSpace(modelName)
+ if modelName == "" {
+ continue
+ }
+ if utf8.RuneCountInString(modelName) > maxChannelMonitorSmartScheduleModelLength {
+ return nil, fmt.Errorf("智能调度基准模型不能超过 %d 个字符", maxChannelMonitorSmartScheduleModelLength)
+ }
+ if _, exists := seenModels[modelName]; exists {
+ continue
+ }
+ seenModels[modelName] = struct{}{}
+ normalizedModels = append(normalizedModels, modelName)
+ }
+ return normalizedModels, nil
+}
+
+func normalizeChannelMonitorSmartScheduleStrategy(strategy string) string {
+ strategy = strings.TrimSpace(strategy)
+ switch strategy {
+ case channelMonitorSmartScheduleStrategyRatio,
+ channelMonitorSmartScheduleStrategyFirstToken,
+ channelMonitorSmartScheduleStrategyTPS,
+ channelMonitorSmartScheduleStrategySmart:
+ return strategy
+ default:
+ return channelMonitorSmartScheduleStrategySmart
+ }
+}
+
+func normalizeChannelMonitorSmartScheduleApplyMode(mode string) string {
+ switch strings.TrimSpace(mode) {
+ case channelMonitorSmartScheduleApplyWeight,
+ channelMonitorSmartScheduleApplyPriorityWeight:
+ return strings.TrimSpace(mode)
+ default:
+ return channelMonitorSmartScheduleApplyWeight
+ }
+}
+
+func isChannelMonitorPerformanceRangeSupported(minutes int) bool {
+ switch minutes {
+ case 15, 60, 360, 1440:
+ return true
+ default:
+ return false
+ }
+}
+
+func normalizeChannelMonitorNotificationEmail(value string) (string, error) {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return "", nil
+ }
+ if utf8.RuneCountInString(value) > maxChannelMonitorNotificationEmailLength {
+ return "", fmt.Errorf("通知邮箱不能超过 %d 个字符", maxChannelMonitorNotificationEmailLength)
+ }
+ address, err := mail.ParseAddress(value)
+ if err != nil || address.Name != "" || address.Address != value {
+ return "", errors.New("请输入有效的通知邮箱")
+ }
+ return address.Address, nil
+}
+
+func normalizeChannelMonitorPolicyAction(action string) string {
+ switch action {
+ case channelMonitorPolicyActionUpdateGroupRatio,
+ channelMonitorPolicyActionDisableChannel,
+ channelMonitorPolicyActionRemoveFromGroup:
+ return action
+ default:
+ return channelMonitorPolicyActionNone
+ }
+}
+
+func getChannelMonitorGroupCoefficients() map[string]float64 {
+ common.OptionMapRWMutex.RLock()
+ rawCoefficients := common.OptionMap[channelMonitorGroupCoefficientsOption]
+ common.OptionMapRWMutex.RUnlock()
+
+ coefficients := make(map[string]float64)
+ if rawCoefficients == "" || common.UnmarshalJsonStr(rawCoefficients, &coefficients) != nil {
+ return map[string]float64{}
+ }
+ if coefficients == nil {
+ return map[string]float64{}
+ }
+ for group, coefficient := range coefficients {
+ if group == "" || math.IsNaN(coefficient) || math.IsInf(coefficient, 0) || coefficient < 0 || coefficient > maxChannelMonitorRatio {
+ delete(coefficients, group)
+ }
+ }
+ return coefficients
+}
+
+func getChannelMonitorGroupCoefficient(coefficients map[string]float64, group string) float64 {
+ coefficient, exists := coefficients[group]
+ if !exists || !validateChannelMonitorRatio(&coefficient) {
+ return defaultChannelMonitorGroupCoefficient
+ }
+ return coefficient
+}
+
+func normalizeChannelMonitorChannelOrder(channels []*model.Channel, channelIds []int) []int {
+ availableChannelIds := make(map[int]struct{}, len(channels))
+ for _, channel := range channels {
+ availableChannelIds[channel.Id] = struct{}{}
+ }
+
+ orderedChannelIds := make([]int, 0, len(channels))
+ seenChannelIds := make(map[int]struct{}, len(channels))
+ for _, channelId := range channelIds {
+ if _, exists := availableChannelIds[channelId]; !exists {
+ continue
+ }
+ if _, exists := seenChannelIds[channelId]; exists {
+ continue
+ }
+ orderedChannelIds = append(orderedChannelIds, channelId)
+ seenChannelIds[channelId] = struct{}{}
+ }
+ for _, channel := range channels {
+ if _, exists := seenChannelIds[channel.Id]; exists {
+ continue
+ }
+ orderedChannelIds = append(orderedChannelIds, channel.Id)
+ }
+ return orderedChannelIds
+}
+
+func getChannelMonitorChannelOrder(channels []*model.Channel) []int {
+ common.OptionMapRWMutex.RLock()
+ rawChannelOrder := common.OptionMap[channelMonitorChannelOrderOption]
+ common.OptionMapRWMutex.RUnlock()
+
+ var channelIds []int
+ if rawChannelOrder != "" && common.UnmarshalJsonStr(rawChannelOrder, &channelIds) != nil {
+ channelIds = nil
+ }
+ return normalizeChannelMonitorChannelOrder(channels, channelIds)
+}
+
+func UpdateChannelMonitorChannelOrder(c *gin.Context) {
+ var request channelMonitorOrderUpdateRequest
+ if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.ChannelIds == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的参数"})
+ return
+ }
+ if len(*request.ChannelIds) > maxChannelMonitorChannelOrderCount {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "渠道排序数量过多"})
+ return
+ }
+
+ channels, err := model.GetAllChannelsForMonitor()
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ availableChannelIds := make(map[int]struct{}, len(channels))
+ for _, channel := range channels {
+ availableChannelIds[channel.Id] = struct{}{}
+ }
+ seenChannelIds := make(map[int]struct{}, len(*request.ChannelIds))
+ for _, channelId := range *request.ChannelIds {
+ if _, exists := availableChannelIds[channelId]; !exists {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "success": false,
+ "message": fmt.Sprintf("渠道 %d 不存在,请刷新后重试", channelId),
+ })
+ return
+ }
+ if _, exists := seenChannelIds[channelId]; exists {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "渠道排序中存在重复渠道"})
+ return
+ }
+ seenChannelIds[channelId] = struct{}{}
+ }
+
+ channelOrder := normalizeChannelMonitorChannelOrder(channels, *request.ChannelIds)
+ channelOrderBytes, err := common.Marshal(channelOrder)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if err := model.UpdateOptionsBulk(map[string]string{
+ channelMonitorChannelOrderOption: string(channelOrderBytes),
+ }); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ recordManageAudit(c, "channel.monitor_order_update", map[string]interface{}{
+ "channel_count": len(channelOrder),
+ })
+ common.ApiSuccess(c, gin.H{"channel_order": channelOrder})
+}
+
+func UpdateChannelMonitorSettings(c *gin.Context) {
+ var request channelMonitorSettingsUpdateRequest
+ if err := common.DecodeJson(c.Request.Body, &request); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的参数"})
+ return
+ }
+ if request.AutoUpdateIntervalMinutes == nil &&
+ request.AutoUpdateRetryCount == nil &&
+ request.AutoDisableOnUpdateFailure == nil &&
+ request.EmailNotificationEnabled == nil &&
+ request.NotificationEmail == nil &&
+ request.SmartScheduleEnabled == nil &&
+ request.SmartScheduleIntervalMinutes == nil &&
+ request.SmartScheduleStrategy == nil &&
+ request.SmartScheduleStabilityEnabled == nil &&
+ request.SmartScheduleApplyMode == nil &&
+ request.SmartSchedulePerformanceMinutes == nil &&
+ request.SmartScheduleModel == nil &&
+ request.SmartScheduleModels == nil &&
+ request.SmartScheduleMinSamples == nil &&
+ request.SmartScheduleForceReset == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供要更新的设置"})
+ return
+ }
+ settings := getChannelMonitorSettings()
+ smartScheduleWasEnabled := settings.SmartScheduleEnabled
+ values := make(map[string]string, 14)
+ if request.AutoUpdateIntervalMinutes != nil && (*request.AutoUpdateIntervalMinutes < 0 ||
+ *request.AutoUpdateIntervalMinutes > maxChannelMonitorAutoUpdateIntervalMinutes) {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "success": false,
+ "message": "自动更新间隔必须在 0 到 525600 分钟之间",
+ })
+ return
+ }
+ if request.AutoUpdateIntervalMinutes != nil {
+ settings.AutoUpdateIntervalMinutes = *request.AutoUpdateIntervalMinutes
+ values[channelMonitorAutoUpdateIntervalOption] = strconv.Itoa(settings.AutoUpdateIntervalMinutes)
+ }
+ if request.AutoUpdateRetryCount != nil && (*request.AutoUpdateRetryCount < 0 ||
+ *request.AutoUpdateRetryCount > maxChannelMonitorAutoUpdateRetryCount) {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "success": false,
+ "message": "失败重试次数必须在 0 到 10 次之间",
+ })
+ return
+ }
+ if request.AutoUpdateRetryCount != nil {
+ settings.AutoUpdateRetryCount = *request.AutoUpdateRetryCount
+ values[channelMonitorAutoUpdateRetryCountOption] = strconv.Itoa(settings.AutoUpdateRetryCount)
+ }
+ if request.AutoDisableOnUpdateFailure != nil {
+ settings.AutoDisableOnUpdateFailure = *request.AutoDisableOnUpdateFailure
+ values[channelMonitorAutoDisableOnUpdateFailureOption] = strconv.FormatBool(settings.AutoDisableOnUpdateFailure)
+ }
+ if request.EmailNotificationEnabled != nil {
+ settings.EmailNotificationEnabled = *request.EmailNotificationEnabled
+ values[channelMonitorEmailNotificationOption] = strconv.FormatBool(settings.EmailNotificationEnabled)
+ }
+ if request.NotificationEmail != nil {
+ notificationEmail, err := normalizeChannelMonitorNotificationEmail(*request.NotificationEmail)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ settings.NotificationEmail = notificationEmail
+ values[channelMonitorNotificationEmailOption] = notificationEmail
+ }
+ if settings.EmailNotificationEnabled && settings.NotificationEmail == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "开启邮件通知时请填写通知邮箱"})
+ return
+ }
+ if request.SmartScheduleEnabled != nil {
+ settings.SmartScheduleEnabled = *request.SmartScheduleEnabled
+ values[channelMonitorSmartScheduleEnabledOption] = strconv.FormatBool(settings.SmartScheduleEnabled)
+ }
+ if request.SmartScheduleIntervalMinutes != nil && (*request.SmartScheduleIntervalMinutes <= 0 ||
+ *request.SmartScheduleIntervalMinutes > maxChannelMonitorAutoUpdateIntervalMinutes) {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "success": false,
+ "message": "智能调度间隔必须在 1 到 525600 分钟之间",
+ })
+ return
+ }
+ if request.SmartScheduleIntervalMinutes != nil {
+ settings.SmartScheduleIntervalMinutes = *request.SmartScheduleIntervalMinutes
+ values[channelMonitorSmartScheduleIntervalOption] = strconv.Itoa(settings.SmartScheduleIntervalMinutes)
+ }
+ if request.SmartScheduleStrategy != nil {
+ strategy := strings.TrimSpace(*request.SmartScheduleStrategy)
+ if normalizeChannelMonitorSmartScheduleStrategy(strategy) != strategy {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "智能调度方式无效"})
+ return
+ }
+ settings.SmartScheduleStrategy = strategy
+ values[channelMonitorSmartScheduleStrategyOption] = strategy
+ }
+ if request.SmartScheduleStabilityEnabled != nil {
+ settings.SmartScheduleStabilityEnabled = *request.SmartScheduleStabilityEnabled
+ values[channelMonitorSmartScheduleStabilityOption] = strconv.FormatBool(settings.SmartScheduleStabilityEnabled)
+ }
+ if request.SmartScheduleApplyMode != nil {
+ mode := strings.TrimSpace(*request.SmartScheduleApplyMode)
+ if normalizeChannelMonitorSmartScheduleApplyMode(mode) != mode {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "智能调度调整方式无效"})
+ return
+ }
+ settings.SmartScheduleApplyMode = mode
+ values[channelMonitorSmartScheduleApplyModeOption] = mode
+ }
+ if request.SmartSchedulePerformanceMinutes != nil &&
+ !isChannelMonitorPerformanceRangeSupported(*request.SmartSchedulePerformanceMinutes) {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "智能调度统计范围无效"})
+ return
+ }
+ if request.SmartSchedulePerformanceMinutes != nil {
+ settings.SmartSchedulePerformanceMinutes = *request.SmartSchedulePerformanceMinutes
+ values[channelMonitorSmartScheduleRangeOption] = strconv.Itoa(settings.SmartSchedulePerformanceMinutes)
+ }
+ var requestedSmartScheduleModels []string
+ updateSmartScheduleModels := false
+ if request.SmartScheduleModels != nil {
+ requestedSmartScheduleModels = *request.SmartScheduleModels
+ updateSmartScheduleModels = true
+ } else if request.SmartScheduleModel != nil {
+ requestedSmartScheduleModels = []string{*request.SmartScheduleModel}
+ updateSmartScheduleModels = true
+ }
+ if updateSmartScheduleModels {
+ smartScheduleModels, err := normalizeChannelMonitorSmartScheduleModels(requestedSmartScheduleModels)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ serializedModels, err := common.Marshal(smartScheduleModels)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ smartScheduleModel := ""
+ if len(smartScheduleModels) > 0 {
+ smartScheduleModel = smartScheduleModels[0]
+ }
+ settings.SmartScheduleModel = smartScheduleModel
+ settings.SmartScheduleModels = smartScheduleModels
+ values[channelMonitorSmartScheduleModelOption] = smartScheduleModel
+ values[channelMonitorSmartScheduleModelsOption] = string(serializedModels)
+ }
+ if request.SmartScheduleMinSamples != nil && (*request.SmartScheduleMinSamples <= 0 ||
+ *request.SmartScheduleMinSamples > maxChannelMonitorSmartScheduleMinSamples) {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "智能调度最少样本数必须在 1 到 100000 之间"})
+ return
+ }
+ if request.SmartScheduleMinSamples != nil {
+ settings.SmartScheduleMinSamples = *request.SmartScheduleMinSamples
+ values[channelMonitorSmartScheduleSamplesOption] = strconv.Itoa(settings.SmartScheduleMinSamples)
+ }
+ forceResetSmartSchedule := request.SmartScheduleForceReset != nil && *request.SmartScheduleForceReset
+ resetSmartScheduleChannels := request.SmartScheduleEnabled != nil &&
+ *request.SmartScheduleEnabled && !smartScheduleWasEnabled && !forceResetSmartSchedule
+ resetChannelCount := 0
+ if resetSmartScheduleChannels {
+ var err error
+ resetChannelCount, err = model.ExcludeAllChannelsFromSmartSchedule()
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ }
+ if err := model.UpdateOptionsBulk(values); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ forceResetTaskCreated := false
+ forceResetTaskId := ""
+ forceResetTaskError := ""
+ if forceResetSmartSchedule {
+ task, created, err := service.EnqueueSystemTask(
+ channelMonitorSmartScheduleTaskType,
+ channelSmartScheduleTaskPayload{ForceReset: true},
+ )
+ forceResetTaskCreated = created
+ if err != nil {
+ forceResetTaskError = err.Error()
+ } else {
+ forceResetTaskId = task.TaskID
+ }
+ settings.SmartScheduleForceResetTaskCreated = &forceResetTaskCreated
+ settings.SmartScheduleForceResetTaskId = forceResetTaskId
+ settings.SmartScheduleForceResetTaskError = forceResetTaskError
+ }
+ recordManageAudit(c, "channel.monitor_settings_update", map[string]interface{}{
+ "auto_update_interval_minutes": settings.AutoUpdateIntervalMinutes,
+ "auto_update_retry_count": settings.AutoUpdateRetryCount,
+ "auto_disable_on_update_failure": settings.AutoDisableOnUpdateFailure,
+ "email_notification_enabled": settings.EmailNotificationEnabled,
+ "notification_email_configured": settings.NotificationEmail != "",
+ "smart_schedule_enabled": settings.SmartScheduleEnabled,
+ "smart_schedule_interval_minutes": settings.SmartScheduleIntervalMinutes,
+ "smart_schedule_strategy": settings.SmartScheduleStrategy,
+ "smart_schedule_stability_enabled": settings.SmartScheduleStabilityEnabled,
+ "smart_schedule_apply_mode": settings.SmartScheduleApplyMode,
+ "smart_schedule_performance_minutes": settings.SmartSchedulePerformanceMinutes,
+ "smart_schedule_model": settings.SmartScheduleModel,
+ "smart_schedule_models": settings.SmartScheduleModels,
+ "smart_schedule_min_samples": settings.SmartScheduleMinSamples,
+ "smart_schedule_channels_reset": resetSmartScheduleChannels,
+ "smart_schedule_reset_channel_count": resetChannelCount,
+ "smart_schedule_force_reset": forceResetSmartSchedule,
+ "smart_schedule_force_reset_created": forceResetTaskCreated,
+ "smart_schedule_force_reset_task_id": forceResetTaskId,
+ "smart_schedule_force_reset_error": forceResetTaskError,
+ })
+ common.ApiSuccess(c, settings)
+}
diff --git a/controller/channel_ratio_monitor_task.go b/controller/channel_ratio_monitor_task.go
new file mode 100644
index 000000000000..a80dd59c21a4
--- /dev/null
+++ b/controller/channel_ratio_monitor_task.go
@@ -0,0 +1,649 @@
+package controller
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "html"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/logger"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
+
+ "github.com/gin-gonic/gin"
+)
+
+type channelRatioMonitorTaskHandler struct{}
+
+const maxChannelRatioMonitorTaskFailureDetails = 100
+
+type channelRatioMonitorTaskResult struct {
+ Total int `json:"total"`
+ Updated int `json:"updated"`
+ Changed int `json:"changed"`
+ BalanceUpdated int `json:"balance_updated"`
+ BalanceWarnings int `json:"balance_warnings,omitempty"`
+ Skipped int `json:"skipped,omitempty"`
+ Failed int `json:"failed"`
+ GroupsUpdated int `json:"groups_updated"`
+ GroupMembershipsRemoved int `json:"group_memberships_removed"`
+ GroupUpdateFailed bool `json:"group_update_failed,omitempty"`
+ ChannelsDisabled int `json:"channels_disabled"`
+ GroupsSkipped int `json:"groups_skipped"`
+ Retried int `json:"retried,omitempty"`
+ RecoveredAfterRetry int `json:"recovered_after_retry,omitempty"`
+ Failures []channelRatioMonitorTaskFailure `json:"failures,omitempty"`
+ FailureDetailsTruncated bool `json:"failure_details_truncated,omitempty"`
+ EmailStatus string `json:"email_status,omitempty"`
+ EmailError string `json:"email_error,omitempty"`
+}
+
+type channelRatioMonitorTaskFailure struct {
+ ChannelId int `json:"channel_id"`
+ ChannelName string `json:"channel_name"`
+ ChannelRemark string `json:"-"`
+ Error string `json:"error"`
+}
+
+func (result *channelRatioMonitorTaskResult) recordFailure(channelId int, channelName string, channelRemark string, failure error) {
+ result.Failed++
+ if len(result.Failures) >= maxChannelRatioMonitorTaskFailureDetails {
+ result.FailureDetailsTruncated = true
+ return
+ }
+
+ nameRunes := []rune(strings.TrimSpace(channelName))
+ if len(nameRunes) > 128 {
+ nameRunes = nameRunes[:128]
+ }
+ remarkRunes := []rune(strings.TrimSpace(channelRemark))
+ if len(remarkRunes) > 255 {
+ remarkRunes = remarkRunes[:255]
+ }
+ errorMessage := "上游同步失败"
+ if failure != nil && strings.TrimSpace(failure.Error()) != "" {
+ errorMessage = strings.TrimSpace(failure.Error())
+ }
+ errorRunes := []rune(errorMessage)
+ if len(errorRunes) > 255 {
+ errorMessage = string(errorRunes[:255])
+ }
+ result.Failures = append(result.Failures, channelRatioMonitorTaskFailure{
+ ChannelId: channelId,
+ ChannelName: string(nameRunes),
+ ChannelRemark: string(remarkRunes),
+ Error: errorMessage,
+ })
+}
+
+type channelRatioMonitorEmailChange struct {
+ ChannelId int
+ ChannelName string
+ ChannelRemark string
+ UpstreamType string
+ UpstreamGroup string
+ OldRatio float64
+ NewRatio float64
+ ConversionFactor float64
+ OldCostRatio float64
+ NewCostRatio float64
+}
+
+type channelRatioMonitorBalanceWarning struct {
+ ChannelId int
+ ChannelName string
+ ChannelRemark string
+ UpstreamType string
+ Balance float64
+ Threshold float64
+}
+
+type channelRatioMonitorDisabledChannel struct {
+ ChannelId int
+ ChannelName string
+ ChannelRemark string
+ Reason string
+}
+
+type channelRatioMonitorRemovedGroupMembership struct {
+ ChannelId int
+ ChannelName string
+ ChannelRemark string
+ Group string
+}
+
+func ListChannelMonitorTasks(c *gin.Context) {
+ taskType := model.SystemTaskTypeChannelRatioMonitor
+ switch c.DefaultQuery("kind", "ratio") {
+ case "ratio":
+ case "schedule":
+ taskType = channelMonitorSmartScheduleTaskType
+ default:
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "定时任务类型无效"})
+ return
+ }
+ pageInfo := common.GetPageQuery(c)
+ tasks, total, err := model.GetChannelMonitorTasksByType(taskType, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ responses := make([]model.SystemTaskResponse, 0, len(tasks))
+ for _, task := range tasks {
+ responses = append(responses, task.ToResponse())
+ }
+ pageInfo.SetTotal(int(total))
+ pageInfo.SetItems(responses)
+ common.ApiSuccess(c, pageInfo)
+}
+
+func RunChannelMonitorRatioUpdate(c *gin.Context) {
+ task, created, err := service.EnqueueSystemTask(model.SystemTaskTypeChannelRatioMonitor, nil)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ recordManageAudit(c, "channel.monitor_ratio_update_run", map[string]interface{}{
+ "created": created,
+ "task_id": task.TaskID,
+ })
+ common.ApiSuccess(c, gin.H{
+ "created": created,
+ "task": task.ToResponse(),
+ })
+}
+
+func (channelRatioMonitorTaskHandler) Type() string {
+ return model.SystemTaskTypeChannelRatioMonitor
+}
+
+func (channelRatioMonitorTaskHandler) Enabled() bool {
+ return getChannelMonitorSettings().AutoUpdateIntervalMinutes > 0
+}
+
+func (channelRatioMonitorTaskHandler) Interval() time.Duration {
+ minutes := getChannelMonitorSettings().AutoUpdateIntervalMinutes
+ if minutes <= 0 {
+ minutes = 1
+ }
+ return time.Duration(minutes) * time.Minute
+}
+
+func (channelRatioMonitorTaskHandler) NewPayload() any { return nil }
+
+func (channelRatioMonitorTaskHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
+ summary, err := runChannelRatioMonitorTaskOnce(ctx, service.NewSystemTaskProgressReporter(task, runnerID), common.SendEmail)
+ if err != nil {
+ finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, summary, err)
+ return
+ }
+ finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, summary, nil)
+}
+
+func runChannelRatioMonitorTaskOnce(ctx context.Context, reportProgress func(processed, total int), sendEmail func(subject string, receiver string, content string) error) (summary channelRatioMonitorTaskResult, taskErr error) {
+ if reportProgress == nil {
+ reportProgress = func(int, int) {}
+ }
+ settings := getChannelMonitorSettings()
+ emailChanges := make([]channelRatioMonitorEmailChange, 0)
+ balanceWarnings := make([]channelRatioMonitorBalanceWarning, 0)
+ disabledChannels := make([]channelRatioMonitorDisabledChannel, 0)
+ removedGroupMemberships := make([]channelRatioMonitorRemovedGroupMembership, 0)
+ channelStatusChanged := false
+ defer func() {
+ if channelStatusChanged {
+ model.InitChannelCache()
+ service.ResetProxyClientCache()
+ }
+ }()
+ defer func() {
+ shouldNotify := len(emailChanges) > 0 || len(balanceWarnings) > 0 || len(disabledChannels) > 0 || len(removedGroupMemberships) > 0 || summary.Failed > 0 || summary.GroupUpdateFailed || taskErr != nil
+ if !shouldNotify || !settings.EmailNotificationEnabled || settings.NotificationEmail == "" {
+ return
+ }
+ if err := sendChannelRatioMonitorNotificationEmail(settings.NotificationEmail, emailChanges, balanceWarnings, disabledChannels, removedGroupMemberships, summary, taskErr, sendEmail); err != nil {
+ summary.EmailStatus = "failed"
+ errorMessage := err.Error()
+ errorRunes := []rune(errorMessage)
+ if len(errorRunes) > 255 {
+ errorMessage = string(errorRunes[:255])
+ }
+ summary.EmailError = errorMessage
+ logger.LogWarn(ctx, fmt.Sprintf("channel ratio monitor: notification email failed: %v", err))
+ return
+ }
+ summary.EmailStatus = "sent"
+ if len(balanceWarnings) == 0 {
+ return
+ }
+ channelIds := make([]int, 0, len(balanceWarnings))
+ for _, warning := range balanceWarnings {
+ channelIds = append(channelIds, warning.ChannelId)
+ }
+ if err := model.MarkChannelRatioMonitorBalanceAlertsNotified(channelIds); err != nil {
+ if taskErr == nil {
+ taskErr = fmt.Errorf("记录余额预警通知状态失败: %w", err)
+ } else {
+ taskErr = fmt.Errorf("%w(记录余额预警通知状态失败:%v)", taskErr, err)
+ }
+ logger.LogWarn(ctx, fmt.Sprintf("channel ratio monitor: balance alert state update failed: %v", err))
+ }
+ }()
+
+ monitors, err := model.GetChannelRatioMonitors()
+ if err != nil {
+ return summary, err
+ }
+
+ configured := make([]model.ChannelRatioMonitor, 0, len(monitors))
+ for _, monitor := range monitors {
+ if monitor.UpstreamType == service.NewAPIUpstreamType || monitor.UpstreamType == service.Sub2APIUpstreamType || monitor.UpstreamType == service.CustomUpstreamType {
+ configured = append(configured, monitor)
+ }
+ }
+ summary = channelRatioMonitorTaskResult{Total: len(configured)}
+ policyInputs := make(map[int]channelMonitorPolicyInput, len(configured))
+ for index, monitor := range configured {
+ select {
+ case <-ctx.Done():
+ return summary, ctx.Err()
+ default:
+ }
+ if monitor.UpstreamRatioSyncDisabled && monitor.UpstreamBalanceSyncDisabled {
+ summary.Skipped++
+ reportProgress(index+1, summary.Total)
+ continue
+ }
+
+ channel, err := model.GetChannelById(monitor.ChannelId, true)
+ if err != nil {
+ summary.recordFailure(monitor.ChannelId, "", "", err)
+ if statusErr := model.RecordChannelRatioMonitorFetchFailure(monitor.ChannelId, err.Error()); statusErr != nil {
+ logger.LogWarn(ctx, fmt.Sprintf("channel ratio monitor: channel_id=%d failure status update failed: %v", monitor.ChannelId, statusErr))
+ }
+ logger.LogWarn(ctx, fmt.Sprintf("channel ratio monitor: channel_id=%d lookup failed: %v", monitor.ChannelId, err))
+ reportProgress(index+1, summary.Total)
+ continue
+ }
+ channelRemark := ""
+ if channel.Remark != nil {
+ channelRemark = strings.TrimSpace(*channel.Remark)
+ }
+
+ var outcome channelMonitorFetchOutcome
+ var recordedBalance *float64
+ ratioUpdated := false
+ syncSkipped := false
+ retriesUsed := 0
+ for attempt := 0; attempt <= settings.AutoUpdateRetryCount; attempt++ {
+ if attempt > 0 {
+ select {
+ case <-ctx.Done():
+ return summary, ctx.Err()
+ default:
+ }
+
+ refreshedMonitor, refreshErr := model.GetChannelRatioMonitor(monitor.ChannelId)
+ if refreshErr != nil {
+ err = fmt.Errorf("重试前重新读取上游配置失败: %w", refreshErr)
+ break
+ }
+ monitor = refreshedMonitor
+ retriesUsed++
+ summary.Retried++
+ }
+
+ if monitor.UpstreamRatioSyncDisabled && monitor.UpstreamBalanceSyncDisabled {
+ syncSkipped = true
+ err = nil
+ break
+ }
+ ratioUpdated = false
+ if !monitor.UpstreamRatioSyncDisabled {
+ outcome, err = fetchAndRecordChannelMonitorUpstreamRatio(ctx, monitor, channel.GetKeys(), channel.GetSetting().Proxy, 0, "系统自动更新")
+ ratioUpdated = err == nil
+ if outcome.BalanceRecorded && outcome.Result.Balance.Amount != nil {
+ balance := *outcome.Result.Balance.Amount
+ recordedBalance = &balance
+ }
+ } else {
+ var balanceResult service.ChannelMonitorUpstreamBalanceResult
+ balanceResult, err = fetchAndRecordChannelMonitorUpstreamBalance(ctx, monitor, channel.GetKeys(), channel.GetSetting().Proxy)
+ if balanceResult.Amount != nil {
+ balance := *balanceResult.Amount
+ recordedBalance = &balance
+ }
+ }
+ if err == nil ||
+ attempt == settings.AutoUpdateRetryCount ||
+ errors.Is(err, service.ErrChannelMonitorUpstreamAuthentication) {
+ break
+ }
+ logger.LogWarn(ctx, fmt.Sprintf(
+ "channel ratio monitor: channel_id=%d attempt=%d failed, retrying %d/%d: %v",
+ monitor.ChannelId,
+ attempt+1,
+ attempt+1,
+ settings.AutoUpdateRetryCount,
+ err,
+ ))
+ }
+ if syncSkipped {
+ summary.Skipped++
+ reportProgress(index+1, summary.Total)
+ continue
+ }
+ if recordedBalance != nil {
+ balance := *recordedBalance
+ summary.BalanceUpdated++
+ balanceAutoDisabled, disableErr := autoDisableChannelMonitorForLowBalance(monitor, channel, balance)
+ if disableErr != nil {
+ if err == nil {
+ err = disableErr
+ } else {
+ err = fmt.Errorf("%w(余额自动禁用失败:%v)", err, disableErr)
+ }
+ }
+ if balanceAutoDisabled {
+ summary.ChannelsDisabled++
+ channelStatusChanged = true
+ }
+ if monitor.BalanceWarningThreshold != nil &&
+ balance < *monitor.BalanceWarningThreshold &&
+ !monitor.BalanceAlertNotified {
+ summary.BalanceWarnings++
+ balanceWarnings = append(balanceWarnings, channelRatioMonitorBalanceWarning{
+ ChannelId: monitor.ChannelId,
+ ChannelName: channel.Name,
+ ChannelRemark: channelRemark,
+ UpstreamType: monitor.UpstreamType,
+ Balance: balance,
+ Threshold: *monitor.BalanceWarningThreshold,
+ })
+ }
+ }
+ if err != nil {
+ failureErr := err
+ if retriesUsed > 0 {
+ failureErr = fmt.Errorf("重试 %d 次后仍失败: %w", retriesUsed, err)
+ }
+ summary.recordFailure(monitor.ChannelId, channel.Name, channelRemark, failureErr)
+ if settings.AutoDisableOnUpdateFailure && channel.Status == common.ChannelStatusEnabled &&
+ model.UpdateChannelStatus(channel.Id, "", common.ChannelStatusAutoDisabled, "渠道监控:上游倍率或余额更新失败") {
+ summary.ChannelsDisabled++
+ channelStatusChanged = true
+ disabledChannels = append(disabledChannels, channelRatioMonitorDisabledChannel{
+ ChannelId: channel.Id,
+ ChannelName: channel.Name,
+ ChannelRemark: channelRemark,
+ Reason: "上游倍率或余额更新失败",
+ })
+ }
+ logger.LogWarn(ctx, fmt.Sprintf("channel ratio monitor: channel_id=%d update failed: %v", monitor.ChannelId, failureErr))
+ } else {
+ summary.Updated++
+ if retriesUsed > 0 {
+ summary.RecoveredAfterRetry++
+ }
+ if ratioUpdated {
+ policyInputs[monitor.ChannelId] = channelMonitorPolicyInput{
+ CostRatio: outcome.Result.CostRatio,
+ SingleChannelAction: monitor.SingleChannelAction,
+ MultipleChannelsAction: monitor.MultipleChannelsAction,
+ }
+ if outcome.Changed {
+ summary.Changed++
+ emailChanges = append(emailChanges, channelRatioMonitorEmailChange{
+ ChannelId: monitor.ChannelId,
+ ChannelName: channel.Name,
+ ChannelRemark: channelRemark,
+ UpstreamType: monitor.UpstreamType,
+ UpstreamGroup: monitor.UpstreamGroup,
+ OldRatio: monitor.Ratio,
+ NewRatio: outcome.Result.Ratio,
+ ConversionFactor: outcome.Result.ConversionFactor,
+ OldCostRatio: monitor.Ratio * outcome.Result.ConversionFactor,
+ NewCostRatio: outcome.Result.CostRatio,
+ })
+ }
+ }
+ }
+ reportProgress(index+1, summary.Total)
+ }
+ channels, err := model.GetAllChannelsForMonitor()
+ if err != nil {
+ return summary, err
+ }
+ plan := planChannelMonitorPolicyActions(
+ channels,
+ policyInputs,
+ ratio_setting.GetGroupRatioCopy(),
+ getChannelMonitorGroupCoefficients(),
+ )
+ summary.GroupsSkipped = plan.SkippedGroupCount
+ groupsUpdated, removedMemberships, disabledChannelIds, groupUpdateFailed, err := applyChannelMonitorPolicyPlan(ctx, plan)
+ summary.GroupsUpdated = groupsUpdated
+ summary.GroupMembershipsRemoved = len(removedMemberships)
+ summary.ChannelsDisabled += len(disabledChannelIds)
+ summary.GroupUpdateFailed = groupUpdateFailed
+ if err != nil {
+ return summary, err
+ }
+ if len(removedMemberships) > 0 || len(disabledChannelIds) > 0 {
+ channelNames := make(map[int]string, len(channels))
+ channelRemarks := make(map[int]string, len(channels))
+ for _, channel := range channels {
+ channelNames[channel.Id] = channel.Name
+ if channel.Remark != nil {
+ channelRemarks[channel.Id] = strings.TrimSpace(*channel.Remark)
+ }
+ }
+ for _, removal := range removedMemberships {
+ removedGroupMemberships = append(removedGroupMemberships, channelRatioMonitorRemovedGroupMembership{
+ ChannelId: removal.ChannelId,
+ ChannelName: channelNames[removal.ChannelId],
+ ChannelRemark: channelRemarks[removal.ChannelId],
+ Group: removal.Group,
+ })
+ }
+ for _, channelId := range disabledChannelIds {
+ disabledChannels = append(disabledChannels, channelRatioMonitorDisabledChannel{
+ ChannelId: channelId,
+ ChannelName: channelNames[channelId],
+ ChannelRemark: channelRemarks[channelId],
+ Reason: "成本倍率高于分组倍率",
+ })
+ }
+ }
+ return summary, nil
+}
+
+func channelRatioMonitorEmailRemark(remark string) string {
+ remark = strings.TrimSpace(remark)
+ if remark == "" {
+ return "-"
+ }
+ return html.EscapeString(remark)
+}
+
+func sendChannelRatioMonitorNotificationEmail(receiver string, changes []channelRatioMonitorEmailChange, balanceWarnings []channelRatioMonitorBalanceWarning, disabledChannels []channelRatioMonitorDisabledChannel, removedGroupMemberships []channelRatioMonitorRemovedGroupMembership, summary channelRatioMonitorTaskResult, taskErr error, sendEmail func(subject string, receiver string, content string) error) error {
+ if sendEmail == nil {
+ return fmt.Errorf("邮件发送器未初始化")
+ }
+
+ var content strings.Builder
+ content.WriteString("
渠道监控定时更新检测到以下变化或异常:
")
+ if len(changes) > 0 {
+ content.WriteString("渠道倍率变更 ")
+ content.WriteString("")
+ for _, heading := range []string{"渠道", "备注", "上游类型", "上游分组", "原上游倍率", "新上游倍率", "换算系数", "原成本倍率", "新成本倍率"} {
+ fmt.Fprintf(&content, "%s ", heading)
+ }
+ content.WriteString(" ")
+ for _, change := range changes {
+ upstreamType := channelMonitorUpstreamTypeLabel(change.UpstreamType)
+ fmt.Fprintf(
+ &content,
+ "%s(ID: %d) %s %s %s %s %s %s %s %s ",
+ html.EscapeString(change.ChannelName),
+ change.ChannelId,
+ channelRatioMonitorEmailRemark(change.ChannelRemark),
+ html.EscapeString(upstreamType),
+ html.EscapeString(change.UpstreamGroup),
+ strconv.FormatFloat(change.OldRatio, 'f', -1, 64),
+ strconv.FormatFloat(change.NewRatio, 'f', -1, 64),
+ strconv.FormatFloat(change.ConversionFactor, 'f', -1, 64),
+ strconv.FormatFloat(change.OldCostRatio, 'f', -1, 64),
+ strconv.FormatFloat(change.NewCostRatio, 'f', -1, 64),
+ )
+ }
+ content.WriteString("
")
+ }
+ if len(balanceWarnings) > 0 {
+ content.WriteString("上游余额预警 ")
+ content.WriteString("")
+ for _, heading := range []string{"渠道", "备注", "上游类型", "当前余额", "预警值"} {
+ fmt.Fprintf(&content, "%s ", heading)
+ }
+ content.WriteString(" ")
+ for _, warning := range balanceWarnings {
+ upstreamType := channelMonitorUpstreamTypeLabel(warning.UpstreamType)
+ fmt.Fprintf(
+ &content,
+ "%s(ID: %d) %s %s %s %s ",
+ html.EscapeString(warning.ChannelName),
+ warning.ChannelId,
+ channelRatioMonitorEmailRemark(warning.ChannelRemark),
+ html.EscapeString(upstreamType),
+ strconv.FormatFloat(warning.Balance, 'f', -1, 64),
+ strconv.FormatFloat(warning.Threshold, 'f', -1, 64),
+ )
+ }
+ content.WriteString("
")
+ }
+ if len(disabledChannels) > 0 {
+ content.WriteString("渠道自动禁用 ")
+ content.WriteString("本次更新已自动禁用以下渠道:
")
+ content.WriteString("")
+ for _, heading := range []string{"渠道", "备注", "禁用原因"} {
+ fmt.Fprintf(&content, "%s ", heading)
+ }
+ content.WriteString(" ")
+ for _, disabledChannel := range disabledChannels {
+ channelName := fmt.Sprintf("渠道 ID %d", disabledChannel.ChannelId)
+ if disabledChannel.ChannelName != "" {
+ channelName = fmt.Sprintf("%s(ID: %d)", disabledChannel.ChannelName, disabledChannel.ChannelId)
+ }
+ fmt.Fprintf(
+ &content,
+ "%s %s %s ",
+ html.EscapeString(channelName),
+ channelRatioMonitorEmailRemark(disabledChannel.ChannelRemark),
+ html.EscapeString(disabledChannel.Reason),
+ )
+ }
+ content.WriteString("
")
+ }
+ if len(removedGroupMemberships) > 0 {
+ content.WriteString("渠道移出分组 ")
+ content.WriteString("本次更新已解除以下渠道与分组的关联:
")
+ content.WriteString("")
+ for _, heading := range []string{"渠道", "备注", "移出分组"} {
+ fmt.Fprintf(&content, "%s ", heading)
+ }
+ content.WriteString(" ")
+ for _, removal := range removedGroupMemberships {
+ channelName := fmt.Sprintf("渠道 ID %d", removal.ChannelId)
+ if removal.ChannelName != "" {
+ channelName = fmt.Sprintf("%s(ID: %d)", removal.ChannelName, removal.ChannelId)
+ }
+ fmt.Fprintf(
+ &content,
+ "%s %s %s ",
+ html.EscapeString(channelName),
+ channelRatioMonitorEmailRemark(removal.ChannelRemark),
+ html.EscapeString(removal.Group),
+ )
+ }
+ content.WriteString("
")
+ }
+
+ if summary.Failed > 0 {
+ content.WriteString("上游同步失败 ")
+ fmt.Fprintf(&content, "共 %d 个渠道在重试后仍未更新成功。
", summary.Failed)
+ if len(summary.Failures) > 0 {
+ content.WriteString("")
+ for _, heading := range []string{"渠道", "备注", "失败原因"} {
+ fmt.Fprintf(&content, "%s ", heading)
+ }
+ content.WriteString(" ")
+ for _, failure := range summary.Failures {
+ channelName := fmt.Sprintf("渠道 ID %d", failure.ChannelId)
+ if failure.ChannelName != "" {
+ channelName = fmt.Sprintf("%s(ID: %d)", failure.ChannelName, failure.ChannelId)
+ }
+ fmt.Fprintf(
+ &content,
+ "%s %s %s ",
+ html.EscapeString(channelName),
+ channelRatioMonitorEmailRemark(failure.ChannelRemark),
+ html.EscapeString(failure.Error),
+ )
+ }
+ content.WriteString("
")
+ }
+ if summary.FailureDetailsTruncated {
+ fmt.Fprintf(&content, "失败渠道较多,邮件仅展示前 %d 条明细。
", len(summary.Failures))
+ }
+ }
+
+ if summary.GroupUpdateFailed {
+ content.WriteString("分组倍率更新失败 ")
+ content.WriteString("自动写入分组倍率失败,请检查定时更新记录和服务日志。
")
+ if taskErr != nil {
+ fmt.Fprintf(&content, "失败原因:%s
", html.EscapeString(taskErr.Error()))
+ }
+ } else if taskErr != nil {
+ content.WriteString("定时更新任务失败 ")
+ fmt.Fprintf(&content, "失败原因:%s
", html.EscapeString(taskErr.Error()))
+ }
+
+ failureCount := summary.Failed
+ if summary.GroupUpdateFailed {
+ failureCount++
+ } else if taskErr != nil {
+ failureCount++
+ }
+ subject := fmt.Sprintf("渠道监控:%d 个渠道的上游倍率发生变化", len(changes))
+ if len(balanceWarnings) > 0 || len(disabledChannels) > 0 || len(removedGroupMemberships) > 0 {
+ parts := make([]string, 0, 5)
+ if len(changes) > 0 {
+ parts = append(parts, fmt.Sprintf("%d 个倍率变更", len(changes)))
+ }
+ if len(balanceWarnings) > 0 {
+ parts = append(parts, fmt.Sprintf("%d 个余额预警", len(balanceWarnings)))
+ }
+ if len(disabledChannels) > 0 {
+ parts = append(parts, fmt.Sprintf("%d 个渠道自动禁用", len(disabledChannels)))
+ }
+ if len(removedGroupMemberships) > 0 {
+ parts = append(parts, fmt.Sprintf("%d 个渠道移出分组", len(removedGroupMemberships)))
+ }
+ if failureCount > 0 {
+ parts = append(parts, fmt.Sprintf("%d 项更新失败", failureCount))
+ }
+ subject = "渠道监控:" + strings.Join(parts, ",")
+ } else if len(changes) > 0 && failureCount > 0 {
+ subject = fmt.Sprintf("渠道监控:%d 个倍率变更,%d 项更新失败", len(changes), failureCount)
+ } else if failureCount > 0 {
+ subject = fmt.Sprintf("渠道监控:%d 项更新失败", failureCount)
+ }
+ return sendEmail(subject, receiver, content.String())
+}
diff --git a/controller/channel_ratio_monitor_test.go b/controller/channel_ratio_monitor_test.go
new file mode 100644
index 000000000000..ca676a2f6c68
--- /dev/null
+++ b/controller/channel_ratio_monitor_test.go
@@ -0,0 +1,3017 @@
+package controller
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
+ "github.com/QuantumNous/new-api/setting/system_setting"
+
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+type channelMonitorSettingsAPIResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ Data channelMonitorSettings `json:"data"`
+}
+
+type channelMonitorGroupSyncAPIResponse struct {
+ Success bool `json:"success"`
+ Data struct {
+ Group string `json:"group"`
+ UpstreamRatio float64 `json:"upstream_ratio"`
+ CostRatio float64 `json:"cost_ratio"`
+ ConversionFactor float64 `json:"conversion_factor"`
+ Coefficient float64 `json:"coefficient"`
+ Ratio float64 `json:"ratio"`
+ } `json:"data"`
+}
+
+type channelMonitorUpstreamConfigAPIResponse struct {
+ Success bool `json:"success"`
+ Data channelMonitorUpstreamConfig `json:"data"`
+}
+
+type channelMonitorUpstreamGroupsAPIResponse struct {
+ Success bool `json:"success"`
+ Data service.ChannelMonitorUpstreamGroupsResult `json:"data"`
+}
+
+type channelMonitorUpstreamGroupApplyAPIResponse struct {
+ Success bool `json:"success"`
+ Data struct {
+ Result service.NewAPIGroupRatioResult `json:"result"`
+ KeysUpdated int `json:"keys_updated"`
+ Changed bool `json:"changed"`
+ } `json:"data"`
+}
+
+type channelMonitorUpstreamBalanceAPIResponse struct {
+ Success bool `json:"success"`
+ Data service.ChannelMonitorUpstreamBalanceResult `json:"data"`
+}
+
+type channelMonitorOverviewAPIResponse struct {
+ Success bool `json:"success"`
+ Data struct {
+ Channels []channelMonitorItem `json:"channels"`
+ ChannelOrder []int `json:"channel_order"`
+ } `json:"data"`
+}
+
+type channelMonitorOrderAPIResponse struct {
+ Success bool `json:"success"`
+ Data struct {
+ ChannelOrder []int `json:"channel_order"`
+ } `json:"data"`
+}
+
+type channelMonitorTaskRunAPIResponse struct {
+ Success bool `json:"success"`
+ Data struct {
+ Created bool `json:"created"`
+ Task model.SystemTaskResponse `json:"task"`
+ } `json:"data"`
+}
+
+func useChannelMonitorOptionMap(t *testing.T, values map[string]string) {
+ t.Helper()
+ common.OptionMapRWMutex.Lock()
+ original := common.OptionMap
+ common.OptionMap = make(map[string]string, len(values))
+ for key, value := range values {
+ common.OptionMap[key] = value
+ }
+ common.OptionMapRWMutex.Unlock()
+ t.Cleanup(func() {
+ common.OptionMapRWMutex.Lock()
+ common.OptionMap = original
+ common.OptionMapRWMutex.Unlock()
+ })
+}
+
+func setupChannelMonitorControllerTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ originalDB := model.DB
+ originalLogDB := model.LOG_DB
+ originalMainDatabaseType := common.MainDatabaseType()
+ originalLogDatabaseType := common.LogDatabaseType()
+ originalMemoryCacheEnabled := common.MemoryCacheEnabled
+ originalRedisEnabled := common.RedisEnabled
+
+ gin.SetMode(gin.TestMode)
+ common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
+ common.MemoryCacheEnabled = false
+ common.RedisEnabled = false
+ dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ require.NoError(t, err)
+ model.DB = db
+ model.LOG_DB = db
+ require.NoError(t, db.AutoMigrate(
+ &model.Option{},
+ &model.User{},
+ &model.Log{},
+ &model.Channel{},
+ &model.Ability{},
+ &model.ChannelRatioMonitor{},
+ &model.ChannelRatioHistory{},
+ &model.SystemTask{},
+ ))
+
+ t.Cleanup(func() {
+ model.DB = originalDB
+ model.LOG_DB = originalLogDB
+ common.SetDatabaseTypes(originalMainDatabaseType, originalLogDatabaseType)
+ common.MemoryCacheEnabled = originalMemoryCacheEnabled
+ common.RedisEnabled = originalRedisEnabled
+ sqlDB, sqlErr := db.DB()
+ if sqlErr == nil {
+ require.NoError(t, sqlDB.Close())
+ }
+ })
+ return db
+}
+
+func disableChannelMonitorSSRFProtection(t *testing.T) {
+ t.Helper()
+ fetchSetting := system_setting.GetFetchSetting()
+ originalFetchSetting := *fetchSetting
+ fetchSetting.EnableSSRFProtection = false
+ service.InitHttpClient()
+ t.Cleanup(func() {
+ *fetchSetting = originalFetchSetting
+ service.InitHttpClient()
+ })
+}
+
+func newChannelMonitorControllerContext(t *testing.T, method string, target string, body any) (*gin.Context, *httptest.ResponseRecorder) {
+ t.Helper()
+ payload, err := common.Marshal(body)
+ require.NoError(t, err)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(method, target, bytes.NewReader(payload))
+ ctx.Request.Header.Set("Content-Type", "application/json")
+ ctx.Set("id", 1)
+ ctx.Set("username", "root")
+ return ctx, recorder
+}
+
+func TestChannelMonitorSettingsDefaultAndTaskInterval(t *testing.T) {
+ tests := []struct {
+ name string
+ values map[string]string
+ wantInterval int
+ wantRetryCount int
+ wantAutoDisable bool
+ wantEmailEnabled bool
+ wantEnabled bool
+ wantTaskInterval time.Duration
+ }{
+ {
+ name: "missing values are disabled",
+ values: map[string]string{},
+ wantRetryCount: defaultChannelMonitorAutoUpdateRetryCount,
+ wantTaskInterval: time.Minute,
+ },
+ {
+ name: "valid values",
+ values: map[string]string{
+ channelMonitorAutoUpdateIntervalOption: "30",
+ channelMonitorAutoUpdateRetryCountOption: "4",
+ channelMonitorAutoDisableOnUpdateFailureOption: "true",
+ channelMonitorEmailNotificationOption: "true",
+ channelMonitorNotificationEmailOption: "alerts@example.com",
+ },
+ wantInterval: 30,
+ wantRetryCount: 4,
+ wantAutoDisable: true,
+ wantEmailEnabled: true,
+ wantEnabled: true,
+ wantTaskInterval: 30 * time.Minute,
+ },
+ {
+ name: "invalid values use safe defaults",
+ values: map[string]string{
+ channelMonitorAutoUpdateIntervalOption: "525601",
+ channelMonitorAutoUpdateRetryCountOption: "11",
+ channelMonitorAutoDisableOnUpdateFailureOption: "invalid",
+ channelMonitorEmailNotificationOption: "invalid",
+ channelMonitorNotificationEmailOption: "invalid",
+ },
+ wantRetryCount: defaultChannelMonitorAutoUpdateRetryCount,
+ wantTaskInterval: time.Minute,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ useChannelMonitorOptionMap(t, test.values)
+ settings := getChannelMonitorSettings()
+ assert.Equal(t, test.wantInterval, settings.AutoUpdateIntervalMinutes)
+ assert.Equal(t, test.wantRetryCount, settings.AutoUpdateRetryCount)
+ assert.Equal(t, test.wantAutoDisable, settings.AutoDisableOnUpdateFailure)
+ assert.Equal(t, test.wantEmailEnabled, settings.EmailNotificationEnabled)
+ if test.name == "valid values" {
+ assert.Equal(t, "alerts@example.com", settings.NotificationEmail)
+ } else {
+ assert.Empty(t, settings.NotificationEmail)
+ }
+
+ handler := channelRatioMonitorTaskHandler{}
+ assert.Equal(t, test.wantEnabled, handler.Enabled())
+ assert.Equal(t, test.wantTaskInterval, handler.Interval())
+ assert.Equal(t, model.SystemTaskTypeChannelRatioMonitor, handler.Type())
+ })
+ }
+}
+
+func TestChannelSmartScheduleHandlerUsesSavedSwitchAndInterval(t *testing.T) {
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorSmartScheduleEnabledOption: "true",
+ channelMonitorSmartScheduleIntervalOption: "25",
+ channelMonitorSmartScheduleStrategyOption: channelMonitorSmartScheduleStrategyTPS,
+ channelMonitorSmartScheduleStabilityOption: "true",
+ })
+
+ settings := getChannelMonitorSettings()
+ assert.True(t, settings.SmartScheduleEnabled)
+ assert.Equal(t, 25, settings.SmartScheduleIntervalMinutes)
+ assert.Equal(t, channelMonitorSmartScheduleStrategyTPS, settings.SmartScheduleStrategy)
+ assert.True(t, settings.SmartScheduleStabilityEnabled)
+ assert.Equal(t, channelMonitorSmartScheduleApplyWeight, settings.SmartScheduleApplyMode)
+ assert.Equal(t, defaultChannelMonitorSmartScheduleRange, settings.SmartSchedulePerformanceMinutes)
+ assert.Equal(t, defaultChannelMonitorSmartScheduleSamples, settings.SmartScheduleMinSamples)
+
+ handler := channelSmartScheduleTaskHandler{}
+ assert.True(t, handler.Enabled())
+ assert.Equal(t, 25*time.Minute, handler.Interval())
+ assert.Equal(t, channelMonitorSmartScheduleTaskType, handler.Type())
+}
+
+func TestLegacyStabilityStrategyMigratesToStabilitySwitch(t *testing.T) {
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorSmartScheduleStrategyOption: legacyChannelMonitorSmartScheduleStrategyStability,
+ })
+
+ settings := getChannelMonitorSettings()
+ assert.Equal(t, channelMonitorSmartScheduleStrategySmart, settings.SmartScheduleStrategy)
+ assert.True(t, settings.SmartScheduleStabilityEnabled)
+}
+
+func TestChannelSmartScheduleSettingsReadOrderedModelsAndLegacyFallback(t *testing.T) {
+ tests := []struct {
+ name string
+ values map[string]string
+ wantModel string
+ wantModels []string
+ }{
+ {
+ name: "ordered models take precedence",
+ values: map[string]string{
+ channelMonitorSmartScheduleModelOption: "legacy-model",
+ channelMonitorSmartScheduleModelsOption: `["model-b","model-a"]`,
+ },
+ wantModel: "model-b",
+ wantModels: []string{"model-b", "model-a"},
+ },
+ {
+ name: "empty ordered list does not restore legacy model",
+ values: map[string]string{
+ channelMonitorSmartScheduleModelOption: "legacy-model",
+ channelMonitorSmartScheduleModelsOption: `[]`,
+ },
+ wantModels: []string{},
+ },
+ {
+ name: "legacy model becomes one item list",
+ values: map[string]string{
+ channelMonitorSmartScheduleModelOption: "gpt-4o-mini",
+ },
+ wantModel: "gpt-4o-mini",
+ wantModels: []string{"gpt-4o-mini"},
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ useChannelMonitorOptionMap(t, test.values)
+
+ settings := getChannelMonitorSettings()
+ assert.Equal(t, test.wantModel, settings.SmartScheduleModel)
+ assert.Equal(t, test.wantModels, settings.SmartScheduleModels)
+ })
+ }
+}
+
+func TestUpdateChannelMonitorSettingsValidatesAndPersists(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{})
+
+ tooManySmartScheduleModels := make([]string, maxChannelMonitorSmartScheduleModelCount+1)
+ invalidRequests := []map[string]any{
+ {},
+ {"auto_update_interval_minutes": -1},
+ {"auto_update_interval_minutes": maxChannelMonitorAutoUpdateIntervalMinutes + 1},
+ {"auto_update_retry_count": -1},
+ {"auto_update_retry_count": maxChannelMonitorAutoUpdateRetryCount + 1},
+ {"email_notification_enabled": true},
+ {"notification_email": "invalid"},
+ {"notification_email": strings.Repeat("a", maxChannelMonitorNotificationEmailLength) + "@example.com"},
+ {"smart_schedule_interval_minutes": 0},
+ {"smart_schedule_strategy": "invalid"},
+ {"smart_schedule_strategy": "stability"},
+ {"smart_schedule_apply_mode": "invalid"},
+ {"smart_schedule_performance_minutes": 30},
+ {"smart_schedule_model": strings.Repeat("m", maxChannelMonitorSmartScheduleModelLength+1)},
+ {"smart_schedule_models": []string{strings.Repeat("m", maxChannelMonitorSmartScheduleModelLength+1)}},
+ {"smart_schedule_models": tooManySmartScheduleModels},
+ {"smart_schedule_min_samples": 0},
+ {"smart_schedule_min_samples": maxChannelMonitorSmartScheduleMinSamples + 1},
+ }
+ for _, request := range invalidRequests {
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/settings", request)
+ UpdateChannelMonitorSettings(ctx)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ }
+
+ request := map[string]any{
+ "auto_update_interval_minutes": 15,
+ "auto_update_retry_count": 3,
+ "auto_disable_on_update_failure": true,
+ "email_notification_enabled": true,
+ "notification_email": "alerts@example.com",
+ "smart_schedule_enabled": true,
+ "smart_schedule_interval_minutes": 10,
+ "smart_schedule_strategy": channelMonitorSmartScheduleStrategySmart,
+ "smart_schedule_stability_enabled": true,
+ "smart_schedule_apply_mode": channelMonitorSmartScheduleApplyPriorityWeight,
+ "smart_schedule_performance_minutes": 360,
+ "smart_schedule_model": "legacy-model",
+ "smart_schedule_models": []string{" claude-3-5-sonnet ", "gpt-4o-mini", "claude-3-5-sonnet"},
+ "smart_schedule_min_samples": 8,
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/settings", request)
+ UpdateChannelMonitorSettings(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response channelMonitorSettingsAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ assert.Equal(t, 15, response.Data.AutoUpdateIntervalMinutes)
+ assert.Equal(t, 3, response.Data.AutoUpdateRetryCount)
+ assert.True(t, response.Data.AutoDisableOnUpdateFailure)
+ assert.True(t, response.Data.EmailNotificationEnabled)
+ assert.Equal(t, "alerts@example.com", response.Data.NotificationEmail)
+ assert.True(t, response.Data.SmartScheduleEnabled)
+ assert.Equal(t, 10, response.Data.SmartScheduleIntervalMinutes)
+ assert.Equal(t, channelMonitorSmartScheduleStrategySmart, response.Data.SmartScheduleStrategy)
+ assert.True(t, response.Data.SmartScheduleStabilityEnabled)
+ assert.Equal(t, channelMonitorSmartScheduleApplyPriorityWeight, response.Data.SmartScheduleApplyMode)
+ assert.Equal(t, 360, response.Data.SmartSchedulePerformanceMinutes)
+ assert.Equal(t, "claude-3-5-sonnet", response.Data.SmartScheduleModel)
+ assert.Equal(t, []string{"claude-3-5-sonnet", "gpt-4o-mini"}, response.Data.SmartScheduleModels)
+ assert.Equal(t, 8, response.Data.SmartScheduleMinSamples)
+
+ var option model.Option
+ require.NoError(t, db.Where("key = ?", channelMonitorAutoUpdateIntervalOption).First(&option).Error)
+ assert.Equal(t, "15", option.Value)
+ option = model.Option{}
+ require.NoError(t, db.Where("key = ?", channelMonitorAutoUpdateRetryCountOption).First(&option).Error)
+ assert.Equal(t, "3", option.Value)
+ option = model.Option{}
+ require.NoError(t, db.Where("key = ?", channelMonitorAutoDisableOnUpdateFailureOption).First(&option).Error)
+ assert.Equal(t, "true", option.Value)
+ option = model.Option{}
+ require.NoError(t, db.Where("key = ?", channelMonitorEmailNotificationOption).First(&option).Error)
+ assert.Equal(t, "true", option.Value)
+ option = model.Option{}
+ require.NoError(t, db.Where("key = ?", channelMonitorNotificationEmailOption).First(&option).Error)
+ assert.Equal(t, "alerts@example.com", option.Value)
+ option = model.Option{}
+ require.NoError(t, db.Where("key = ?", channelMonitorSmartScheduleEnabledOption).First(&option).Error)
+ assert.Equal(t, "true", option.Value)
+ option = model.Option{}
+ require.NoError(t, db.Where("key = ?", channelMonitorSmartScheduleStrategyOption).First(&option).Error)
+ assert.Equal(t, channelMonitorSmartScheduleStrategySmart, option.Value)
+ option = model.Option{}
+ require.NoError(t, db.Where("key = ?", channelMonitorSmartScheduleStabilityOption).First(&option).Error)
+ assert.Equal(t, "true", option.Value)
+ option = model.Option{}
+ require.NoError(t, db.Where("key = ?", channelMonitorSmartScheduleModelOption).First(&option).Error)
+ assert.Equal(t, "claude-3-5-sonnet", option.Value)
+ option = model.Option{}
+ require.NoError(t, db.Where("key = ?", channelMonitorSmartScheduleModelsOption).First(&option).Error)
+ assert.JSONEq(t, `["claude-3-5-sonnet","gpt-4o-mini"]`, option.Value)
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/settings", map[string]any{
+ "email_notification_enabled": false,
+ "notification_email": "",
+ })
+ UpdateChannelMonitorSettings(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.Equal(t, 15, response.Data.AutoUpdateIntervalMinutes)
+ assert.Equal(t, 3, response.Data.AutoUpdateRetryCount)
+ assert.True(t, response.Data.AutoDisableOnUpdateFailure)
+ assert.False(t, response.Data.EmailNotificationEnabled)
+ assert.Empty(t, response.Data.NotificationEmail)
+ assert.True(t, response.Data.SmartScheduleStabilityEnabled)
+ assert.Equal(t, "claude-3-5-sonnet", response.Data.SmartScheduleModel)
+ assert.Equal(t, []string{"claude-3-5-sonnet", "gpt-4o-mini"}, response.Data.SmartScheduleModels)
+}
+
+func TestEnablingChannelSmartScheduleExcludesEveryChannel(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{})
+ require.NoError(t, db.Create([]model.Channel{
+ {Id: 41, Name: "configured channel", Status: common.ChannelStatusEnabled, Group: "vip"},
+ {Id: 42, Name: "new channel", Status: common.ChannelStatusEnabled, Group: "vip"},
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 41,
+ Ratio: 1.25,
+ UpdatedTime: 100,
+ SmartScheduleExcluded: false,
+ }).Error)
+
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/settings", map[string]any{
+ "smart_schedule_enabled": true,
+ })
+ UpdateChannelMonitorSettings(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ for _, channelId := range []int{41, 42} {
+ var monitor model.ChannelRatioMonitor
+ require.NoError(t, db.Where("channel_id = ?", channelId).First(&monitor).Error)
+ assert.True(t, monitor.SmartScheduleExcluded)
+ }
+ var configuredMonitor model.ChannelRatioMonitor
+ require.NoError(t, db.Where("channel_id = ?", 41).First(&configuredMonitor).Error)
+ assert.Equal(t, 1.25, configuredMonitor.Ratio)
+
+ require.NoError(t, db.Model(&model.ChannelRatioMonitor{}).
+ Where("channel_id = ?", 41).
+ Update("smart_schedule_excluded", false).Error)
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/settings", map[string]any{
+ "smart_schedule_enabled": true,
+ "smart_schedule_interval_minutes": 20,
+ })
+ UpdateChannelMonitorSettings(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.NoError(t, db.Where("channel_id = ?", 41).First(&configuredMonitor).Error)
+ assert.False(t, configuredMonitor.SmartScheduleExcluded)
+
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/settings", map[string]any{
+ "smart_schedule_enabled": false,
+ })
+ UpdateChannelMonitorSettings(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/settings", map[string]any{
+ "smart_schedule_enabled": true,
+ })
+ UpdateChannelMonitorSettings(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.NoError(t, db.Where("channel_id = ?", 41).First(&configuredMonitor).Error)
+ assert.True(t, configuredMonitor.SmartScheduleExcluded)
+}
+
+func TestForceResetSmartScheduleQueuesOneTimeTaskAndKeepsParticipation(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{})
+ require.NoError(t, db.Create([]model.Channel{
+ {Id: 51, Name: "first", Status: common.ChannelStatusEnabled, Group: "vip"},
+ {Id: 52, Name: "second", Status: common.ChannelStatusEnabled, Group: "vip"},
+ }).Error)
+ require.NoError(t, db.Create(&[]model.ChannelRatioMonitor{
+ {ChannelId: 51, SmartScheduleExcluded: false},
+ {ChannelId: 52, SmartScheduleExcluded: false},
+ }).Error)
+
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/settings", map[string]any{
+ "smart_schedule_enabled": true,
+ "smart_schedule_force_reset": true,
+ })
+ UpdateChannelMonitorSettings(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response channelMonitorSettingsAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ require.NotNil(t, response.Data.SmartScheduleForceResetTaskCreated)
+ assert.True(t, *response.Data.SmartScheduleForceResetTaskCreated)
+ assert.NotEmpty(t, response.Data.SmartScheduleForceResetTaskId)
+ assert.Empty(t, response.Data.SmartScheduleForceResetTaskError)
+
+ for _, channelId := range []int{51, 52} {
+ monitor, err := model.GetChannelRatioMonitor(channelId)
+ require.NoError(t, err)
+ assert.False(t, monitor.SmartScheduleExcluded)
+ }
+
+ task, err := model.GetActiveSystemTask(channelMonitorSmartScheduleTaskType)
+ require.NoError(t, err)
+ require.NotNil(t, task)
+ assert.Equal(t, response.Data.SmartScheduleForceResetTaskId, task.TaskID)
+ payload := channelSmartScheduleTaskPayload{}
+ require.NoError(t, task.DecodePayload(&payload))
+ assert.True(t, payload.ForceReset)
+
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/settings", map[string]any{
+ "smart_schedule_force_reset": true,
+ })
+ UpdateChannelMonitorSettings(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.NotNil(t, response.Data.SmartScheduleForceResetTaskCreated)
+ assert.False(t, *response.Data.SmartScheduleForceResetTaskCreated)
+ assert.Equal(t, task.TaskID, response.Data.SmartScheduleForceResetTaskId)
+}
+
+func TestUpdateChannelSmartScheduleConfigNeedsOnlyParticipationFlag(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ priority := int64(90)
+ weight := uint(75)
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 43,
+ Name: "multi-group channel",
+ Status: common.ChannelStatusEnabled,
+ Group: "default,vip",
+ Priority: &priority,
+ Weight: &weight,
+ }).Error)
+
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/43/schedule", map[string]any{
+ "excluded": false,
+ })
+ ctx.Params = gin.Params{{Key: "id", Value: "43"}}
+ UpdateChannelMonitorSmartScheduleConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response struct {
+ Success bool `json:"success"`
+ Data struct {
+ Excluded bool `json:"excluded"`
+ } `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ assert.True(t, response.Success)
+ assert.False(t, response.Data.Excluded)
+ monitor, err := model.GetChannelRatioMonitor(43)
+ require.NoError(t, err)
+ assert.False(t, monitor.SmartScheduleExcluded)
+ var channel model.Channel
+ require.NoError(t, db.First(&channel, "id = ?", 43).Error)
+ assert.Equal(t, priority, channel.GetPriority())
+ assert.Equal(t, int(weight), channel.GetWeight())
+}
+
+func TestUpdateChannelSmartScheduleConfigResetAlwaysUpdatesPriorityAndWeight(t *testing.T) {
+ tests := []struct {
+ name string
+ applyMode string
+ }{
+ {
+ name: "weight only",
+ applyMode: channelMonitorSmartScheduleApplyWeight,
+ },
+ {
+ name: "priority and weight",
+ applyMode: channelMonitorSmartScheduleApplyPriorityWeight,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorSmartScheduleApplyModeOption: test.applyMode,
+ })
+ priority := int64(100)
+ weight := uint(80)
+ channel := model.Channel{
+ Id: 44,
+ Name: "scheduled channel",
+ Status: common.ChannelStatusEnabled,
+ Group: "vip",
+ Models: "model-a",
+ Priority: &priority,
+ Weight: &weight,
+ }
+ require.NoError(t, db.Create(&channel).Error)
+ require.NoError(t, db.Create(&model.Ability{
+ Group: "vip",
+ Model: "model-a",
+ ChannelId: channel.Id,
+ Enabled: true,
+ Priority: &priority,
+ Weight: weight,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: channel.Id,
+ SmartScheduleExcluded: true,
+ }).Error)
+
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/44/schedule", map[string]any{
+ "excluded": false,
+ "reset": true,
+ })
+ ctx.Params = gin.Params{{Key: "id", Value: "44"}}
+ UpdateChannelMonitorSmartScheduleConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var storedChannel model.Channel
+ require.NoError(t, db.First(&storedChannel, "id = ?", channel.Id).Error)
+ assert.Equal(t, int64(0), storedChannel.GetPriority())
+ assert.Equal(t, channelMonitorSmartScheduleMinWeight, storedChannel.GetWeight())
+
+ var ability model.Ability
+ require.NoError(t, db.First(&ability, "channel_id = ?", channel.Id).Error)
+ require.NotNil(t, ability.Priority)
+ assert.Equal(t, int64(0), *ability.Priority)
+ assert.Equal(t, uint(channelMonitorSmartScheduleMinWeight), ability.Weight)
+
+ monitor, err := model.GetChannelRatioMonitor(channel.Id)
+ require.NoError(t, err)
+ assert.False(t, monitor.SmartScheduleExcluded)
+ })
+ }
+}
+
+func TestRunChannelMonitorRatioUpdateReusesActiveTask(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{})
+
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPost, "/api/channel_monitor/ratio/run", nil)
+ RunChannelMonitorRatioUpdate(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ var firstResponse channelMonitorTaskRunAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &firstResponse))
+ require.True(t, firstResponse.Success)
+ assert.True(t, firstResponse.Data.Created)
+ assert.Equal(t, model.SystemTaskTypeChannelRatioMonitor, firstResponse.Data.Task.Type)
+ assert.Equal(t, model.SystemTaskStatusPending, firstResponse.Data.Task.Status)
+
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPost, "/api/channel_monitor/ratio/run", nil)
+ RunChannelMonitorRatioUpdate(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ var secondResponse channelMonitorTaskRunAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &secondResponse))
+ require.True(t, secondResponse.Success)
+ assert.False(t, secondResponse.Data.Created)
+ assert.Equal(t, firstResponse.Data.Task.TaskID, secondResponse.Data.Task.TaskID)
+
+ var taskCount int64
+ require.NoError(t, db.Model(&model.SystemTask{}).
+ Where("type = ?", model.SystemTaskTypeChannelRatioMonitor).
+ Count(&taskCount).Error)
+ assert.EqualValues(t, 1, taskCount)
+}
+
+func TestChannelMonitorOverviewIncludesLastFetchFailure(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{})
+ testModel := "gpt-4.1-mini"
+ channelRemark := "临时渠道,晚高峰可能波动"
+ upstreamBalance := 18.75
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 9,
+ Name: "failed upstream",
+ Key: "secret",
+ Remark: &channelRemark,
+ Status: common.ChannelStatusEnabled,
+ Models: "gpt-4.1-mini,gpt-4.1",
+ TestModel: &testModel,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 9,
+ LastFetchStatus: model.ChannelRatioFetchStatusFailed,
+ LastFetchError: "upstream timeout",
+ LastFetchTime: 123456,
+ ConsecutiveFailures: 3,
+ UpstreamBalance: &upstreamBalance,
+ LastBalanceTime: 123400,
+ LastBalanceError: "balance refresh timeout",
+ }).Error)
+
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodGet, "/api/channel_monitor/", nil)
+ GetChannelMonitorOverview(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response channelMonitorOverviewAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ require.Len(t, response.Data.Channels, 1)
+ assert.Equal(t, []int{9}, response.Data.ChannelOrder)
+ item := response.Data.Channels[0]
+ assert.Equal(t, channelRemark, item.ChannelRemark)
+ assert.Equal(t, "gpt-4.1-mini,gpt-4.1", item.Models)
+ assert.Equal(t, &testModel, item.TestModel)
+ assert.Equal(t, model.ChannelRatioFetchStatusFailed, item.LastFetchStatus)
+ assert.Equal(t, "upstream timeout", item.LastFetchError)
+ assert.EqualValues(t, 123456, item.LastFetchTime)
+ assert.Equal(t, 3, item.ConsecutiveFailures)
+ require.NotNil(t, item.UpstreamBalance)
+ assert.InDelta(t, upstreamBalance, *item.UpstreamBalance, 1e-9)
+ assert.EqualValues(t, 123400, item.LastBalanceTime)
+ assert.Equal(t, "balance refresh timeout", item.LastBalanceError)
+}
+
+func TestUpdateChannelMonitorChannelOrderPersistsNormalizedOrder(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{})
+ highPriority := int64(30)
+ middlePriority := int64(20)
+ lowPriority := int64(10)
+ require.NoError(t, db.Create(&[]model.Channel{
+ {Id: 1, Name: "one", Key: "secret-1", Priority: &highPriority},
+ {Id: 2, Name: "two", Key: "secret-2", Priority: &middlePriority},
+ {Id: 3, Name: "three", Key: "secret-3", Priority: &lowPriority},
+ }).Error)
+
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/order", map[string]any{
+ "channel_ids": []int{3, 1},
+ })
+ UpdateChannelMonitorChannelOrder(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response channelMonitorOrderAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ assert.Equal(t, []int{3, 1, 2}, response.Data.ChannelOrder)
+
+ common.OptionMapRWMutex.RLock()
+ storedChannelOrder := common.OptionMap[channelMonitorChannelOrderOption]
+ common.OptionMapRWMutex.RUnlock()
+ var channelOrder []int
+ require.NoError(t, common.UnmarshalJsonStr(storedChannelOrder, &channelOrder))
+ assert.Equal(t, []int{3, 1, 2}, channelOrder)
+
+ invalidRequests := []map[string]any{
+ {"channel_ids": []int{1, 1}},
+ {"channel_ids": []int{999}},
+ {},
+ }
+ for _, request := range invalidRequests {
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/order", request)
+ UpdateChannelMonitorChannelOrder(ctx)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ }
+}
+
+func TestSaveChannelMonitorUpstreamConfigPersistsChannelPolicies(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ baseURL := "https://upstream.example"
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 10,
+ Name: "stable",
+ Key: "secret",
+ Group: "vip",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+
+ request := map[string]any{
+ "type": service.NewAPIUpstreamType,
+ "base_url": "https://upstream.example",
+ "group": "vip",
+ "auth_type": service.NewAPIUpstreamAuthPublic,
+ "single_channel_action": channelMonitorPolicyActionUpdateGroupRatio,
+ "multiple_channels_action": channelMonitorPolicyActionDisableChannel,
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/10/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "10"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response channelMonitorUpstreamConfigAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ assert.Equal(t, channelMonitorPolicyActionUpdateGroupRatio, response.Data.SingleChannelAction)
+ assert.Equal(t, channelMonitorPolicyActionDisableChannel, response.Data.MultipleChannelsAction)
+
+ monitor, err := model.GetChannelRatioMonitor(10)
+ require.NoError(t, err)
+ assert.Equal(t, channelMonitorPolicyActionUpdateGroupRatio, monitor.SingleChannelAction)
+ assert.Equal(t, channelMonitorPolicyActionDisableChannel, monitor.MultipleChannelsAction)
+
+ delete(request, "single_channel_action")
+ delete(request, "multiple_channels_action")
+ request["group"] = "standard"
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/10/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "10"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ monitor, err = model.GetChannelRatioMonitor(10)
+ require.NoError(t, err)
+ assert.Equal(t, "standard", monitor.UpstreamGroup)
+ assert.Equal(t, channelMonitorPolicyActionUpdateGroupRatio, monitor.SingleChannelAction)
+ assert.Equal(t, channelMonitorPolicyActionDisableChannel, monitor.MultipleChannelsAction)
+
+ request["single_channel_action"] = "invalid"
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/10/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "10"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+
+ request["single_channel_action"] = channelMonitorPolicyActionRemoveFromGroup
+ request["multiple_channels_action"] = channelMonitorPolicyActionRemoveFromGroup
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/10/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "10"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+
+ request["single_channel_action"] = channelMonitorPolicyActionNone
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/10/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "10"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ monitor, err = model.GetChannelRatioMonitor(10)
+ require.NoError(t, err)
+ assert.Equal(t, channelMonitorPolicyActionRemoveFromGroup, monitor.MultipleChannelsAction)
+}
+
+func TestSaveChannelMonitorUpstreamConfigManagesBalanceThresholds(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ baseURL := "https://upstream.example"
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 11,
+ Name: "balance alert",
+ Key: "secret",
+ Group: "vip",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+
+ request := map[string]any{
+ "type": service.NewAPIUpstreamType,
+ "base_url": baseURL,
+ "group": "vip",
+ "auth_type": service.NewAPIUpstreamAuthPublic,
+ "balance_warning_threshold": 20.5,
+ "balance_auto_disable_threshold": 10.25,
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/11/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "11"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ var response channelMonitorUpstreamConfigAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.NotNil(t, response.Data.BalanceWarningThreshold)
+ assert.Equal(t, 20.5, *response.Data.BalanceWarningThreshold)
+ require.NotNil(t, response.Data.BalanceAutoDisableThreshold)
+ assert.Equal(t, 10.25, *response.Data.BalanceAutoDisableThreshold)
+
+ delete(request, "balance_warning_threshold")
+ delete(request, "balance_auto_disable_threshold")
+ request["group"] = "standard"
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/11/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "11"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ monitor, err := model.GetChannelRatioMonitor(11)
+ require.NoError(t, err)
+ require.NotNil(t, monitor.BalanceWarningThreshold)
+ assert.Equal(t, 20.5, *monitor.BalanceWarningThreshold)
+ require.NotNil(t, monitor.BalanceAutoDisableThreshold)
+ assert.Equal(t, 10.25, *monitor.BalanceAutoDisableThreshold)
+
+ request["balance_warning_threshold"] = nil
+ request["balance_auto_disable_threshold"] = nil
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/11/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "11"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ monitor, err = model.GetChannelRatioMonitor(11)
+ require.NoError(t, err)
+ assert.Nil(t, monitor.BalanceWarningThreshold)
+ assert.Nil(t, monitor.BalanceAutoDisableThreshold)
+
+ for _, field := range []string{"balance_warning_threshold", "balance_auto_disable_threshold"} {
+ for _, invalidThreshold := range []any{-0.01, maxChannelMonitorBalanceThreshold + 1, "not-a-number"} {
+ request[field] = invalidThreshold
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/11/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "11"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ }
+ request[field] = nil
+ }
+}
+
+func TestSaveChannelMonitorUpstreamConfigAppliesCostConversion(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ baseURL := "https://upstream.example"
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 15,
+ Name: "converted upstream",
+ Key: "secret",
+ Group: "vip",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+
+ request := map[string]any{
+ "type": service.NewAPIUpstreamType,
+ "base_url": baseURL,
+ "group": "vip",
+ "auth_type": service.NewAPIUpstreamAuthPublic,
+ "cost_conversion": map[string]any{
+ "mode": service.ChannelMonitorCostConversionRecharge,
+ "paid_cny": 100,
+ "credited_usd": 200,
+ },
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/15/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "15"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var configResponse channelMonitorUpstreamConfigAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &configResponse))
+ assert.Equal(t, service.ChannelMonitorCostConversionRecharge, configResponse.Data.CostConversion.Mode)
+ assert.Equal(t, 100.0, configResponse.Data.CostConversion.PaidCNY)
+ assert.Equal(t, 200.0, configResponse.Data.CostConversion.CreditedUSD)
+
+ monitor, err := model.GetChannelRatioMonitor(15)
+ require.NoError(t, err)
+ storedConversion, err := service.ParseChannelMonitorCostConversion(monitor.CostConversion)
+ require.NoError(t, err)
+ assert.Equal(t, service.ChannelMonitorCostConversionRecharge, storedConversion.Mode)
+
+ _, _, _, err = model.UpdateChannelRatioMonitorFromUpstream(15, 0.8, "first fetch", 1, "root")
+ require.NoError(t, err)
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodGet, "/api/channel_monitor", nil)
+ GetChannelMonitorOverview(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ var overviewResponse channelMonitorOverviewAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &overviewResponse))
+ require.Len(t, overviewResponse.Data.Channels, 1)
+ require.NotNil(t, overviewResponse.Data.Channels[0].CostRatio)
+ require.NotNil(t, overviewResponse.Data.Channels[0].ConversionFactor)
+ assert.InDelta(t, 0.4, *overviewResponse.Data.Channels[0].CostRatio, 1e-9)
+ assert.InDelta(t, 0.5, *overviewResponse.Data.Channels[0].ConversionFactor, 1e-9)
+
+ delete(request, "cost_conversion")
+ request["group"] = "standard"
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/15/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "15"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ monitor, err = model.GetChannelRatioMonitor(15)
+ require.NoError(t, err)
+ storedConversion, err = service.ParseChannelMonitorCostConversion(monitor.CostConversion)
+ require.NoError(t, err)
+ assert.Equal(t, service.ChannelMonitorCostConversionRecharge, storedConversion.Mode)
+
+ request["cost_conversion"] = map[string]any{"mode": service.ChannelMonitorCostConversionNone}
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/15/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "15"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ monitor, err = model.GetChannelRatioMonitor(15)
+ require.NoError(t, err)
+ storedConversion, err = service.ParseChannelMonitorCostConversion(monitor.CostConversion)
+ require.NoError(t, err)
+ assert.Equal(t, service.ChannelMonitorCostConversionNone, storedConversion.Mode)
+
+ request["cost_conversion"] = map[string]any{
+ "mode": service.ChannelMonitorCostConversionRecharge,
+ "paid_cny": 100,
+ "credited_usd": 0,
+ }
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/15/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "15"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+}
+
+func TestSaveChannelMonitorCustomUpstreamConfigAppliesFixedValues(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ baseURL := "https://custom.example/api"
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 28,
+ Name: "fixed custom upstream",
+ Key: "secret",
+ Group: "vip",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+
+ request := map[string]any{
+ "type": service.CustomUpstreamType,
+ "base_url": baseURL,
+ "group": "",
+ "auth_type": service.CustomUpstreamAuthType,
+ "custom_config": map[string]any{
+ "version": 1,
+ "ratio": map[string]any{
+ "source": service.ChannelMonitorCustomSourceFixed,
+ "fixed_value": 0.75,
+ },
+ "balance": map[string]any{
+ "source": service.ChannelMonitorCustomSourceFixed,
+ "fixed_value": 25.5,
+ },
+ },
+ "cost_conversion": map[string]any{
+ "mode": service.ChannelMonitorCostConversionRecharge,
+ "paid_cny": 100,
+ "credited_usd": 200,
+ },
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/28/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "28"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+
+ var response channelMonitorUpstreamConfigAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.NotNil(t, response.Data.CustomConfig)
+ assert.Equal(t, service.CustomUpstreamType, response.Data.Type)
+ assert.Equal(t, service.ChannelMonitorCustomSourceFixed, response.Data.CustomConfig.Ratio.Source)
+ require.NotNil(t, response.Data.CustomConfig.Ratio.FixedValue)
+ assert.Equal(t, 0.75, *response.Data.CustomConfig.Ratio.FixedValue)
+
+ monitor, err := model.GetChannelRatioMonitor(28)
+ require.NoError(t, err)
+ assert.Equal(t, 0.75, monitor.Ratio)
+ assert.NotZero(t, monitor.UpdatedTime)
+ require.NotNil(t, monitor.UpstreamBalance)
+ assert.Equal(t, 25.5, *monitor.UpstreamBalance)
+ storedConfig, err := service.ParseChannelMonitorCustomUpstreamConfig(monitor.CustomUpstreamConfig)
+ require.NoError(t, err)
+ assert.Equal(t, service.ChannelMonitorCustomSourceFixed, storedConfig.Balance.Source)
+
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodGet, "/api/channel_monitor", nil)
+ GetChannelMonitorOverview(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ var overviewResponse channelMonitorOverviewAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &overviewResponse))
+ require.Len(t, overviewResponse.Data.Channels, 1)
+ require.NotNil(t, overviewResponse.Data.Channels[0].CostRatio)
+ assert.InDelta(t, 0.375, *overviewResponse.Data.Channels[0].CostRatio, 1e-9)
+}
+
+func TestChannelMonitorCustomUpstreamTestUsesUnsavedHTTPConfig(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ disableChannelMonitorSSRFProtection(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/account", r.URL.Path)
+ assert.Equal(t, "vip", r.URL.Query().Get("group"))
+ assert.Equal(t, "Bearer unsaved-secret", r.Header.Get("Authorization"))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":{"ratio":"1.25","balance":42},"authorization":"Bearer unsaved-secret"}`))
+ }))
+ defer server.Close()
+
+ baseURL := server.URL
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 29,
+ Name: "unsaved custom upstream",
+ Key: "unused",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+
+ request := map[string]any{
+ "type": service.CustomUpstreamType,
+ "base_url": server.URL,
+ "group": "",
+ "auth_type": service.CustomUpstreamAuthType,
+ "ratio_sync_enabled": false,
+ "balance_sync_enabled": true,
+ "custom_config": map[string]any{
+ "version": 1,
+ "ratio": map[string]any{
+ "source": service.ChannelMonitorCustomSourceHTTP,
+ "request": map[string]any{
+ "method": http.MethodGet,
+ "path": "/account",
+ "body_type": service.ChannelMonitorCustomBodyNone,
+ "query": []map[string]any{
+ {"key": "group", "value": "vip"},
+ },
+ "headers": []map[string]any{
+ {"key": "Authorization", "value": "Bearer unsaved-secret", "secret": true},
+ },
+ },
+ "result": map[string]any{
+ "response_type": service.ChannelMonitorCustomResponseJSON,
+ "value_path": "data.ratio",
+ "multiplier": 1,
+ },
+ },
+ "balance": map[string]any{
+ "source": service.ChannelMonitorCustomSourceHTTP,
+ "result": map[string]any{
+ "response_type": service.ChannelMonitorCustomResponseJSON,
+ "value_path": "data.balance",
+ "multiplier": 1,
+ },
+ },
+ "balance_reuse_ratio_request": true,
+ },
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPost, "/api/channel_monitor/channel/29/upstream/test", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "29"}}
+ TestChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+
+ var response struct {
+ Success bool `json:"success"`
+ Data service.NewAPIGroupRatioResult `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ assert.Equal(t, 1.25, response.Data.Ratio)
+ require.NotNil(t, response.Data.Balance.Amount)
+ assert.Equal(t, 42.0, *response.Data.Balance.Amount)
+ require.NotNil(t, response.Data.Debug)
+ assert.NotContains(t, response.Data.Debug.ResponsePreview, "unsaved-secret")
+ assert.Contains(t, response.Data.Debug.ResponsePreview, "[REDACTED]")
+
+ _, err := model.GetChannelRatioMonitor(29)
+ assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
+}
+
+func TestChannelMonitorCustomUpstreamTestReusesSavedSecret(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ disableChannelMonitorSSRFProtection(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "Bearer saved-secret", r.Header.Get("Authorization"))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"ratio":0.8}`))
+ }))
+ defer server.Close()
+
+ baseURL := server.URL
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 30,
+ Name: "saved custom upstream",
+ Key: "unused",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+ balance := 12.0
+ customConfig := service.ChannelMonitorCustomUpstreamConfig{
+ Version: 1,
+ Ratio: service.ChannelMonitorCustomMetricConfig{
+ Source: service.ChannelMonitorCustomSourceHTTP,
+ Request: &service.ChannelMonitorCustomRequestConfig{
+ Method: http.MethodGet,
+ Path: "/ratio",
+ BodyType: service.ChannelMonitorCustomBodyNone,
+ Headers: []service.ChannelMonitorCustomKeyValue{
+ {Key: "Authorization", Value: "Bearer saved-secret", Secret: true},
+ },
+ },
+ Result: &service.ChannelMonitorCustomResultConfig{
+ ResponseType: service.ChannelMonitorCustomResponseJSON,
+ ValuePath: "ratio",
+ Multiplier: 1,
+ },
+ },
+ Balance: service.ChannelMonitorCustomMetricConfig{
+ Source: service.ChannelMonitorCustomSourceFixed,
+ FixedValue: &balance,
+ },
+ }
+ saveRequest := map[string]any{
+ "type": service.CustomUpstreamType,
+ "base_url": server.URL,
+ "group": "",
+ "auth_type": service.CustomUpstreamAuthType,
+ "custom_config": customConfig,
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/30/upstream", saveRequest)
+ ctx.Params = gin.Params{{Key: "id", Value: "30"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+
+ var saveResponse channelMonitorUpstreamConfigAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &saveResponse))
+ require.NotNil(t, saveResponse.Data.CustomConfig)
+ require.NotNil(t, saveResponse.Data.CustomConfig.Ratio.Request)
+ require.Len(t, saveResponse.Data.CustomConfig.Ratio.Request.Headers, 1)
+ savedHeader := saveResponse.Data.CustomConfig.Ratio.Request.Headers[0]
+ assert.Empty(t, savedHeader.Value)
+ assert.True(t, savedHeader.HasValue)
+ assert.NotContains(t, recorder.Body.String(), "saved-secret")
+
+ testRequest := map[string]any{
+ "type": service.CustomUpstreamType,
+ "base_url": server.URL,
+ "group": "",
+ "auth_type": service.CustomUpstreamAuthType,
+ "custom_config": saveResponse.Data.CustomConfig,
+ }
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPost, "/api/channel_monitor/channel/30/upstream/test", testRequest)
+ ctx.Params = gin.Params{{Key: "id", Value: "30"}}
+ TestChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+
+ var testResponse struct {
+ Success bool `json:"success"`
+ Data service.NewAPIGroupRatioResult `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &testResponse))
+ require.True(t, testResponse.Success)
+ assert.Equal(t, 0.8, testResponse.Data.Ratio)
+}
+
+func TestSaveChannelMonitorCustomFixedBalanceUsesAutoDisableThreshold(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+
+ tests := []struct {
+ name string
+ channelId int
+ balance float64
+ threshold float64
+ wantStatus int
+ }{
+ {name: "below threshold", channelId: 31, balance: 2, threshold: 3, wantStatus: common.ChannelStatusAutoDisabled},
+ {name: "equal to threshold", channelId: 32, balance: 3, threshold: 3, wantStatus: common.ChannelStatusEnabled},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ require.NoError(t, db.Create(&model.Channel{
+ Id: test.channelId, Name: test.name, Group: "vip", Models: "model-a", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.Ability{
+ Group: "vip", Model: "model-a", ChannelId: test.channelId, Enabled: true,
+ }).Error)
+ ratio := 1.0
+ request := map[string]any{
+ "type": service.CustomUpstreamType,
+ "base_url": "https://custom.example",
+ "auth_type": service.CustomUpstreamAuthType,
+ "balance_auto_disable_threshold": test.threshold,
+ "custom_config": service.ChannelMonitorCustomUpstreamConfig{
+ Version: 1,
+ Ratio: service.ChannelMonitorCustomMetricConfig{
+ Source: service.ChannelMonitorCustomSourceFixed, FixedValue: &ratio,
+ },
+ Balance: service.ChannelMonitorCustomMetricConfig{
+ Source: service.ChannelMonitorCustomSourceFixed, FixedValue: &test.balance,
+ },
+ },
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, fmt.Sprintf("/api/channel_monitor/channel/%d/upstream", test.channelId), request)
+ ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(test.channelId)}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+
+ monitor, err := model.GetChannelRatioMonitor(test.channelId)
+ require.NoError(t, err)
+ require.NotNil(t, monitor.UpstreamBalance)
+ assert.Equal(t, test.balance, *monitor.UpstreamBalance)
+ channel, err := model.GetChannelById(test.channelId, true)
+ require.NoError(t, err)
+ assert.Equal(t, test.wantStatus, channel.Status)
+ var ability model.Ability
+ require.NoError(t, db.First(&ability, "channel_id = ?", test.channelId).Error)
+ assert.Equal(t, test.wantStatus == common.ChannelStatusEnabled, ability.Enabled)
+ })
+ }
+}
+
+func TestSaveChannelMonitorUpstreamConfigManagesSyncCapabilities(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ baseURL := "https://upstream.example"
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 12,
+ Name: "custom upstream",
+ Key: "secret",
+ Group: "vip",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+
+ request := map[string]any{
+ "type": service.NewAPIUpstreamType,
+ "base_url": baseURL,
+ "group": "vip",
+ "auth_type": service.NewAPIUpstreamAuthPublic,
+ "ratio_sync_enabled": false,
+ "balance_sync_enabled": false,
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/12/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "12"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response channelMonitorUpstreamConfigAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ assert.False(t, response.Data.RatioSyncEnabled)
+ assert.False(t, response.Data.BalanceSyncEnabled)
+ monitor, err := model.GetChannelRatioMonitor(12)
+ require.NoError(t, err)
+ assert.True(t, monitor.UpstreamRatioSyncDisabled)
+ assert.True(t, monitor.UpstreamBalanceSyncDisabled)
+
+ delete(request, "ratio_sync_enabled")
+ delete(request, "balance_sync_enabled")
+ request["group"] = "standard"
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/12/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "12"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ monitor, err = model.GetChannelRatioMonitor(12)
+ require.NoError(t, err)
+ assert.True(t, monitor.UpstreamRatioSyncDisabled)
+ assert.True(t, monitor.UpstreamBalanceSyncDisabled)
+
+ request["ratio_sync_enabled"] = true
+ request["balance_sync_enabled"] = true
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/12/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "12"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ monitor, err = model.GetChannelRatioMonitor(12)
+ require.NoError(t, err)
+ assert.False(t, monitor.UpstreamRatioSyncDisabled)
+ assert.False(t, monitor.UpstreamBalanceSyncDisabled)
+}
+
+func TestSaveChannelMonitorSub2APIConfigPersistsToken(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ baseURL := "https://upstream.example"
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 13,
+ Name: "session-bound upstream",
+ Key: "secret",
+ Group: "vip",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+
+ request := map[string]any{
+ "type": service.Sub2APIUpstreamType,
+ "base_url": baseURL,
+ "group": "vip",
+ "auth_type": service.Sub2APIAuthToken,
+ "access_token": "jwt-token",
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/13/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "13"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ monitor, err := model.GetChannelRatioMonitor(13)
+ require.NoError(t, err)
+ assert.Equal(t, "jwt-token", monitor.UpstreamAccessToken)
+ assert.NotContains(t, recorder.Body.String(), "jwt-token")
+}
+
+func TestSaveChannelMonitorSub2APIConfigPersistsAccountPassword(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ baseURL := "https://upstream.example"
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 15,
+ Name: "account upstream",
+ Key: "secret",
+ Group: "vip",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+
+ request := map[string]any{
+ "type": service.Sub2APIUpstreamType,
+ "base_url": baseURL,
+ "group": "vip",
+ "auth_type": service.Sub2APIAuthAccount,
+ "account": "monitor@example.com",
+ "password": "secret-password",
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/15/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "15"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ monitor, err := model.GetChannelRatioMonitor(15)
+ require.NoError(t, err)
+ assert.Equal(t, service.Sub2APIAuthAccount, monitor.UpstreamAuthType)
+ assert.Equal(t, "monitor@example.com", monitor.UpstreamAccount)
+ assert.Equal(t, "secret-password", monitor.UpstreamPassword)
+ assert.Empty(t, monitor.UpstreamAccessToken)
+ assert.Contains(t, recorder.Body.String(), `"account":"monitor@example.com"`)
+ assert.Contains(t, recorder.Body.String(), `"has_password":true`)
+ assert.NotContains(t, recorder.Body.String(), "secret-password")
+
+ request["password"] = ""
+ request["group"] = "standard"
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/15/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "15"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ monitor, err = model.GetChannelRatioMonitor(15)
+ require.NoError(t, err)
+ assert.Equal(t, "secret-password", monitor.UpstreamPassword)
+}
+
+func TestSaveChannelMonitorSub2APIConfigAllowsChannelKeyOnly(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ baseURL := "https://upstream.example"
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 14,
+ Name: "api-key-only upstream",
+ Key: "sk-direct",
+ Group: "vip",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+
+ request := map[string]any{
+ "type": service.Sub2APIUpstreamType,
+ "base_url": baseURL,
+ "group": "vip",
+ "auth_type": service.Sub2APIAuthAPIKey,
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/channel/14/upstream", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "14"}}
+ SaveChannelMonitorUpstreamConfig(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ monitor, err := model.GetChannelRatioMonitor(14)
+ require.NoError(t, err)
+ assert.Equal(t, service.Sub2APIUpstreamType, monitor.UpstreamType)
+ assert.Equal(t, service.Sub2APIAuthAPIKey, monitor.UpstreamAuthType)
+ assert.Empty(t, monitor.UpstreamAccessToken)
+ assert.Contains(t, recorder.Body.String(), `"has_access_token":false`)
+}
+
+func TestListChannelMonitorUpstreamGroupsUsesSavedSub2APIToken(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ disableChannelMonitorSSRFProtection(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ assert.Equal(t, "Bearer jwt-token", r.Header.Get("Authorization"))
+ switch r.URL.Path {
+ case "/api/v1/groups/available":
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":[{"id":7,"name":"vip","rate_multiplier":1.25}]}`))
+ case "/api/v1/groups/rates":
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
+ case "/api/v1/keys":
+ assert.Equal(t, "secret", r.URL.Query().Get("search"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[{"id":99,"key":"secret","group_id":7}],"total":1,"page":1,"page_size":1000,"pages":1}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ baseURL := server.URL
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 20,
+ Name: "sub2api",
+ Key: "secret",
+ Group: "vip",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 20,
+ UpstreamType: service.Sub2APIUpstreamType,
+ UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip",
+ UpstreamAuthType: service.Sub2APIAuthToken,
+ UpstreamAccessToken: "jwt-token",
+ }).Error)
+
+ request := map[string]any{
+ "type": service.Sub2APIUpstreamType,
+ "base_url": server.URL,
+ "group": "vip",
+ "auth_type": service.Sub2APIAuthToken,
+ "access_token": "",
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPost, "/api/channel_monitor/channel/20/upstream/groups", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "20"}}
+ ListChannelMonitorUpstreamGroups(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response channelMonitorUpstreamGroupsAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ require.Len(t, response.Data.Groups, 1)
+ assert.Equal(t, "vip", response.Data.Groups[0].Name)
+ assert.Equal(t, 1.25, response.Data.Groups[0].Ratio)
+ assert.Equal(t, "vip", response.Data.AppliedGroup)
+ assert.Empty(t, response.Data.AppliedGroupError)
+
+ monitor, err := model.GetChannelRatioMonitor(20)
+ require.NoError(t, err)
+ assert.Equal(t, "jwt-token", monitor.UpstreamAccessToken)
+}
+
+func TestListChannelMonitorUpstreamGroupsAcceptsUnsavedSub2APIToken(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ disableChannelMonitorSSRFProtection(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ assert.Equal(t, "Bearer jwt-token", r.Header.Get("Authorization"))
+ switch r.URL.Path {
+ case "/api/v1/groups/available":
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":[{"id":7,"name":"vip","rate_multiplier":1.25}]}`))
+ case "/api/v1/groups/rates":
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
+ case "/api/v1/keys":
+ assert.Equal(t, "secret", r.URL.Query().Get("search"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[{"id":99,"key":"secret","group_id":7}],"total":1,"page":1,"page_size":1000,"pages":1}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ baseURL := server.URL
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 21,
+ Name: "unconfigured sub2api",
+ Key: "secret",
+ Group: "vip",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+
+ request := map[string]any{
+ "type": service.Sub2APIUpstreamType,
+ "base_url": server.URL,
+ "group": "",
+ "auth_type": service.Sub2APIAuthToken,
+ "access_token": "jwt-token",
+ "balance_sync_enabled": false,
+ }
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPost, "/api/channel_monitor/channel/21/upstream/groups", request)
+ ctx.Params = gin.Params{{Key: "id", Value: "21"}}
+ ListChannelMonitorUpstreamGroups(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response channelMonitorUpstreamGroupsAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ require.Len(t, response.Data.Groups, 1)
+ assert.Equal(t, "vip", response.Data.Groups[0].Name)
+ assert.Equal(t, "vip", response.Data.AppliedGroup)
+
+ _, err := model.GetChannelRatioMonitor(21)
+ assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
+}
+
+func TestApplyChannelMonitorUpstreamGroupUpdatesRemoteTokenAndRecordsRatio(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ disableChannelMonitorSSRFProtection(t)
+
+ updatedGroup := ""
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ assert.Equal(t, "Bearer dashboard-token", r.Header.Get("Authorization"))
+ assert.Equal(t, "42", r.Header.Get("New-Api-User"))
+ switch r.URL.Path {
+ case "/api/user/self/groups":
+ _, _ = w.Write([]byte(`{"success":true,"data":{"vip":{"ratio":1.4}}}`))
+ case "/api/token/search":
+ assert.Equal(t, "sk-channel", r.URL.Query().Get("token"))
+ _, _ = w.Write([]byte(`{"success":true,"data":{"items":[{"id":31,"name":"channel","expired_time":-1,"remain_quota":0,"unlimited_quota":true,"model_limits_enabled":false,"model_limits":"","allow_ips":null,"group":"default","cross_group_retry":false}]}}`))
+ case "/api/token/":
+ var request struct {
+ Group string `json:"group"`
+ }
+ require.NoError(t, common.DecodeJson(r.Body, &request))
+ updatedGroup = request.Group
+ _, _ = w.Write([]byte(`{"success":true,"message":""}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ baseURL := server.URL
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 22,
+ Name: "new-api",
+ Key: "sk-channel",
+ Group: "vip",
+ BaseURL: &baseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 22,
+ Ratio: 1,
+ UpdatedTime: 1,
+ UpstreamType: service.NewAPIUpstreamType,
+ UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip",
+ UpstreamAuthType: service.NewAPIUpstreamAuthUser,
+ UpstreamUserId: 42,
+ UpstreamAccessToken: "dashboard-token",
+ }).Error)
+
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPost, "/api/channel_monitor/channel/22/upstream/group/apply", nil)
+ ctx.Params = gin.Params{{Key: "id", Value: "22"}}
+ ApplyChannelMonitorUpstreamGroup(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response channelMonitorUpstreamGroupApplyAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success, recorder.Body.String())
+ assert.Equal(t, "vip", updatedGroup)
+ assert.Equal(t, 1, response.Data.KeysUpdated)
+ assert.True(t, response.Data.Changed)
+ assert.InDelta(t, 1.4, response.Data.Result.Ratio, 1e-9)
+ assert.NotContains(t, recorder.Body.String(), "dashboard-token")
+ assert.NotContains(t, recorder.Body.String(), "sk-channel")
+
+ monitor, err := model.GetChannelRatioMonitor(22)
+ require.NoError(t, err)
+ assert.InDelta(t, 1.4, monitor.Ratio, 1e-9)
+ assert.Equal(t, model.ChannelRatioFetchStatusSucceeded, monitor.LastFetchStatus)
+ assert.Contains(t, monitor.Remark, "切换到分组 vip")
+}
+
+func TestFetchChannelMonitorUpstreamBalanceRecordsSnapshotAndAutoDisables(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{})
+ disableChannelMonitorSSRFProtection(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/user/self":
+ assert.Equal(t, "Bearer dashboard-token", r.Header.Get("Authorization"))
+ assert.Equal(t, "42", r.Header.Get("New-Api-User"))
+ _, _ = w.Write([]byte(`{"success":true,"data":{"quota":1750000}}`))
+ case "/api/status":
+ _, _ = w.Write([]byte(`{"success":true,"data":{"quota_per_unit":500000}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 23, Name: "balance", Key: "secret", Group: "vip", Models: "model-a", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.Ability{
+ Group: "vip", Model: "model-a", ChannelId: 23, Enabled: true,
+ }).Error)
+ autoDisableThreshold := 4.0
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 23,
+ UpstreamType: service.NewAPIUpstreamType,
+ UpstreamBaseURL: server.URL,
+ UpstreamAuthType: service.NewAPIUpstreamAuthUser,
+ UpstreamUserId: 42,
+ UpstreamAccessToken: "dashboard-token",
+ BalanceAutoDisableThreshold: &autoDisableThreshold,
+ }).Error)
+
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPost, "/api/channel_monitor/channel/23/upstream/balance/fetch", nil)
+ ctx.Params = gin.Params{{Key: "id", Value: "23"}}
+ FetchChannelMonitorUpstreamBalance(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response channelMonitorUpstreamBalanceAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success, recorder.Body.String())
+ require.NotNil(t, response.Data.Amount)
+ assert.InDelta(t, 3.5, *response.Data.Amount, 1e-9)
+
+ monitor, err := model.GetChannelRatioMonitor(23)
+ require.NoError(t, err)
+ require.NotNil(t, monitor.UpstreamBalance)
+ assert.InDelta(t, 3.5, *monitor.UpstreamBalance, 1e-9)
+ assert.NotZero(t, monitor.LastBalanceTime)
+ assert.Empty(t, monitor.LastBalanceError)
+ channel, err := model.GetChannelById(23, true)
+ require.NoError(t, err)
+ assert.Equal(t, common.ChannelStatusAutoDisabled, channel.Status)
+ assert.Contains(t, channel.GetOtherInfo()["status_reason"], "上游余额 3.5 低于自动禁用阈值 4")
+ var ability model.Ability
+ require.NoError(t, db.First(&ability, "channel_id = ?", 23).Error)
+ assert.False(t, ability.Enabled)
+}
+
+func TestFetchChannelMonitorUpstreamRatioAutoDisablesFromRecordedBalance(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{})
+ disableChannelMonitorSSRFProtection(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/user/self/groups":
+ _, _ = w.Write([]byte(`{"success":true,"data":{"vip":{"ratio":1.25}}}`))
+ case "/api/user/self":
+ _, _ = w.Write([]byte(`{"success":true,"data":{"quota":350}}`))
+ case "/api/status":
+ _, _ = w.Write([]byte(`{"success":true,"data":{"quota_per_unit":100}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 25, Name: "ratio and balance", Key: "secret", Group: "vip", Models: "model-a", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.Ability{
+ Group: "vip", Model: "model-a", ChannelId: 25, Enabled: true,
+ }).Error)
+ autoDisableThreshold := 4.0
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 25,
+ Ratio: 1,
+ UpstreamType: service.NewAPIUpstreamType,
+ UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip",
+ UpstreamAuthType: service.NewAPIUpstreamAuthUser,
+ UpstreamUserId: 42,
+ UpstreamAccessToken: "dashboard-token",
+ BalanceAutoDisableThreshold: &autoDisableThreshold,
+ }).Error)
+
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPost, "/api/channel_monitor/channel/25/upstream/fetch", nil)
+ ctx.Params = gin.Params{{Key: "id", Value: "25"}}
+ FetchChannelMonitorUpstreamRatio(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), `"balance_auto_disabled":true`)
+
+ monitor, err := model.GetChannelRatioMonitor(25)
+ require.NoError(t, err)
+ assert.Equal(t, 1.25, monitor.Ratio)
+ require.NotNil(t, monitor.UpstreamBalance)
+ assert.Equal(t, 3.5, *monitor.UpstreamBalance)
+ channel, err := model.GetChannelById(25, true)
+ require.NoError(t, err)
+ assert.Equal(t, common.ChannelStatusAutoDisabled, channel.Status)
+ var ability model.Ability
+ require.NoError(t, db.First(&ability, "channel_id = ?", 25).Error)
+ assert.False(t, ability.Enabled)
+}
+
+func TestManualUpstreamRefreshSkipsDisabledCapabilities(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ var upstreamRequests atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ upstreamRequests.Add(1)
+ http.Error(w, "unsupported", http.StatusNotFound)
+ }))
+ defer server.Close()
+
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 24, Name: "custom upstream", Key: "secret", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 24,
+ UpstreamType: service.NewAPIUpstreamType,
+ UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip",
+ UpstreamAuthType: service.NewAPIUpstreamAuthPublic,
+ UpstreamRatioSyncDisabled: true,
+ UpstreamBalanceSyncDisabled: true,
+ }).Error)
+
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPost, "/api/channel_monitor/channel/24/upstream/fetch", nil)
+ ctx.Params = gin.Params{{Key: "id", Value: "24"}}
+ FetchChannelMonitorUpstreamRatio(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "该渠道已关闭上游倍率同步")
+
+ ctx, recorder = newChannelMonitorControllerContext(t, http.MethodPost, "/api/channel_monitor/channel/24/upstream/balance/fetch", nil)
+ ctx.Params = gin.Params{{Key: "id", Value: "24"}}
+ FetchChannelMonitorUpstreamBalance(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "该渠道已关闭上游余额同步")
+ assert.Zero(t, upstreamRequests.Load())
+}
+
+func TestResolveChannelMonitorUpstreamRequestDoesNotReuseCredentialsAcrossHosts(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ oldBaseURL := "https://old.example"
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 21,
+ Name: "secure",
+ Key: "secret",
+ BaseURL: &oldBaseURL,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 21,
+ UpstreamType: service.NewAPIUpstreamType,
+ UpstreamBaseURL: oldBaseURL,
+ UpstreamAuthType: service.NewAPIUpstreamAuthUser,
+ UpstreamUserId: 7,
+ UpstreamAccessToken: "saved-token",
+ }).Error)
+ channel, err := model.GetChannelById(21, false)
+ require.NoError(t, err)
+
+ _, err = resolveChannelMonitorUpstreamRequest(channel, channelMonitorUpstreamRequest{
+ Type: service.NewAPIUpstreamType,
+ BaseURL: "https://new.example",
+ Group: "vip",
+ AuthType: service.NewAPIUpstreamAuthUser,
+ UserId: 7,
+ }, true)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "访问令牌")
+}
+
+func TestResolveChannelMonitorUpstreamRequestIncludesChannelProxy(t *testing.T) {
+ channel := &model.Channel{Id: 21}
+ channel.SetSetting(dto.ChannelSettings{Proxy: "socks5://127.0.0.1:1080"})
+
+ config, err := resolveChannelMonitorUpstreamRequest(channel, channelMonitorUpstreamRequest{
+ Type: service.NewAPIUpstreamType,
+ BaseURL: "https://upstream.example",
+ Group: "vip",
+ AuthType: service.NewAPIUpstreamAuthPublic,
+ }, true)
+ require.NoError(t, err)
+ assert.Equal(t, "socks5://127.0.0.1:1080", config.Proxy)
+}
+
+func TestPlanChannelMonitorPolicyActions(t *testing.T) {
+ enabledChannel := func(id int, group string) *model.Channel {
+ return &model.Channel{Id: id, Group: group, Status: common.ChannelStatusEnabled}
+ }
+
+ t.Run("single channel update uses coefficient", func(t *testing.T) {
+ plan := planChannelMonitorPolicyActions(
+ []*model.Channel{enabledChannel(1, "vip")},
+ map[int]channelMonitorPolicyInput{
+ 1: {CostRatio: 1.2, SingleChannelAction: channelMonitorPolicyActionUpdateGroupRatio},
+ },
+ map[string]float64{"vip": 1},
+ map[string]float64{"vip": 1.1},
+ )
+ require.Contains(t, plan.GroupRatioUpdates, "vip")
+ assert.InDelta(t, 1.32, plan.GroupRatioUpdates["vip"], 1e-9)
+ assert.Empty(t, plan.DisableChannelIds)
+ })
+
+ t.Run("disabled peers use single channel policy", func(t *testing.T) {
+ disabled := &model.Channel{Id: 2, Group: "vip", Status: common.ChannelStatusManuallyDisabled}
+ plan := planChannelMonitorPolicyActions(
+ []*model.Channel{enabledChannel(1, "vip"), disabled},
+ map[int]channelMonitorPolicyInput{
+ 1: {CostRatio: 1.25, SingleChannelAction: channelMonitorPolicyActionDisableChannel},
+ 2: {CostRatio: 9, SingleChannelAction: channelMonitorPolicyActionUpdateGroupRatio},
+ },
+ map[string]float64{"vip": 1},
+ nil,
+ )
+ assert.Equal(t, []int{1}, plan.DisableChannelIds)
+ })
+
+ t.Run("multiple channel update uses highest target", func(t *testing.T) {
+ plan := planChannelMonitorPolicyActions(
+ []*model.Channel{enabledChannel(1, "vip"), enabledChannel(2, "vip")},
+ map[int]channelMonitorPolicyInput{
+ 1: {CostRatio: 1.1, MultipleChannelsAction: channelMonitorPolicyActionUpdateGroupRatio},
+ 2: {CostRatio: 1.4, MultipleChannelsAction: channelMonitorPolicyActionUpdateGroupRatio},
+ },
+ map[string]float64{"vip": 1},
+ map[string]float64{"vip": 1.2},
+ )
+ require.Contains(t, plan.GroupRatioUpdates, "vip")
+ assert.InDelta(t, 1.68, plan.GroupRatioUpdates["vip"], 1e-9)
+ })
+
+ t.Run("multiple channel policies apply per channel", func(t *testing.T) {
+ plan := planChannelMonitorPolicyActions(
+ []*model.Channel{
+ enabledChannel(1, "vip"),
+ enabledChannel(2, "vip"),
+ enabledChannel(3, "vip"),
+ },
+ map[int]channelMonitorPolicyInput{
+ 1: {CostRatio: 1.1, MultipleChannelsAction: channelMonitorPolicyActionNone},
+ 2: {CostRatio: 1.3, MultipleChannelsAction: channelMonitorPolicyActionDisableChannel},
+ 3: {CostRatio: 1.25, MultipleChannelsAction: channelMonitorPolicyActionUpdateGroupRatio},
+ },
+ map[string]float64{"vip": 1},
+ nil,
+ )
+ assert.Equal(t, []int{2}, plan.DisableChannelIds)
+ require.Contains(t, plan.GroupRatioUpdates, "vip")
+ assert.InDelta(t, 1.25, plan.GroupRatioUpdates["vip"], 1e-9)
+ })
+
+ t.Run("temporary channel is disabled then stable channel uses single policy", func(t *testing.T) {
+ plan := planChannelMonitorPolicyActions(
+ []*model.Channel{enabledChannel(1, "vip"), enabledChannel(2, "vip")},
+ map[int]channelMonitorPolicyInput{
+ 1: {
+ CostRatio: 1.2,
+ SingleChannelAction: channelMonitorPolicyActionUpdateGroupRatio,
+ MultipleChannelsAction: channelMonitorPolicyActionUpdateGroupRatio,
+ },
+ 2: {
+ CostRatio: 1.5,
+ SingleChannelAction: channelMonitorPolicyActionDisableChannel,
+ MultipleChannelsAction: channelMonitorPolicyActionDisableChannel,
+ },
+ },
+ map[string]float64{"vip": 1},
+ nil,
+ )
+ assert.Equal(t, []int{2}, plan.DisableChannelIds)
+ require.Contains(t, plan.GroupRatioUpdates, "vip")
+ assert.InDelta(t, 1.2, plan.GroupRatioUpdates["vip"], 1e-9)
+ })
+
+ t.Run("disabling a channel re-evaluates its other groups", func(t *testing.T) {
+ plan := planChannelMonitorPolicyActions(
+ []*model.Channel{
+ enabledChannel(1, "vip,team"),
+ enabledChannel(2, "vip"),
+ enabledChannel(3, "team"),
+ },
+ map[int]channelMonitorPolicyInput{
+ 1: {
+ CostRatio: 1.5,
+ MultipleChannelsAction: channelMonitorPolicyActionDisableChannel,
+ },
+ 2: {CostRatio: 1.1},
+ 3: {
+ CostRatio: 2.5,
+ SingleChannelAction: channelMonitorPolicyActionUpdateGroupRatio,
+ },
+ },
+ map[string]float64{"vip": 1, "team": 2},
+ nil,
+ )
+ assert.Equal(t, []int{1}, plan.DisableChannelIds)
+ require.Contains(t, plan.GroupRatioUpdates, "team")
+ assert.InDelta(t, 2.5, plan.GroupRatioUpdates["team"], 1e-9)
+ })
+
+ t.Run("removing a channel re-evaluates the remaining single channel", func(t *testing.T) {
+ plan := planChannelMonitorPolicyActions(
+ []*model.Channel{enabledChannel(1, "vip,backup"), enabledChannel(2, "vip")},
+ map[int]channelMonitorPolicyInput{
+ 1: {
+ CostRatio: 1.5,
+ MultipleChannelsAction: channelMonitorPolicyActionRemoveFromGroup,
+ },
+ 2: {
+ CostRatio: 1.25,
+ SingleChannelAction: channelMonitorPolicyActionUpdateGroupRatio,
+ },
+ },
+ map[string]float64{"vip": 1, "backup": 2},
+ nil,
+ )
+ assert.Equal(t, []model.ChannelMonitorGroupMembershipRemoval{{ChannelId: 1, Group: "vip"}}, plan.GroupMembershipRemovals)
+ require.Contains(t, plan.GroupRatioUpdates, "vip")
+ assert.InDelta(t, 1.25, plan.GroupRatioUpdates["vip"], 1e-9)
+ assert.Empty(t, plan.DisableChannelIds)
+ })
+
+ t.Run("disable policy takes precedence over membership removal", func(t *testing.T) {
+ plan := planChannelMonitorPolicyActions(
+ []*model.Channel{enabledChannel(1, "vip,team"), enabledChannel(2, "vip")},
+ map[int]channelMonitorPolicyInput{
+ 1: {
+ CostRatio: 1.5,
+ SingleChannelAction: channelMonitorPolicyActionDisableChannel,
+ MultipleChannelsAction: channelMonitorPolicyActionRemoveFromGroup,
+ },
+ 2: {CostRatio: 1},
+ },
+ map[string]float64{"vip": 1, "team": 1},
+ nil,
+ )
+ assert.Equal(t, []int{1}, plan.DisableChannelIds)
+ assert.Empty(t, plan.GroupMembershipRemovals)
+ })
+
+ t.Run("membership removal keeps the channel's only group", func(t *testing.T) {
+ plan := planChannelMonitorPolicyActions(
+ []*model.Channel{enabledChannel(1, "vip"), enabledChannel(2, "vip")},
+ map[int]channelMonitorPolicyInput{
+ 1: {CostRatio: 1.5, MultipleChannelsAction: channelMonitorPolicyActionRemoveFromGroup},
+ 2: {CostRatio: 1},
+ },
+ map[string]float64{"vip": 1},
+ nil,
+ )
+ assert.Empty(t, plan.GroupMembershipRemovals)
+ assert.Empty(t, plan.DisableChannelIds)
+ assert.Empty(t, plan.GroupRatioUpdates)
+ })
+
+ t.Run("incomplete current ratios skip group actions", func(t *testing.T) {
+ plan := planChannelMonitorPolicyActions(
+ []*model.Channel{enabledChannel(1, "vip"), enabledChannel(2, "vip")},
+ map[int]channelMonitorPolicyInput{
+ 1: {CostRatio: 1.5, MultipleChannelsAction: channelMonitorPolicyActionDisableChannel},
+ },
+ map[string]float64{"vip": 1},
+ nil,
+ )
+ assert.Empty(t, plan.DisableChannelIds)
+ assert.Empty(t, plan.GroupRatioUpdates)
+ assert.Equal(t, 1, plan.SkippedGroupCount)
+ })
+}
+
+func TestApplyChannelMonitorPolicyPlanMarksGroupUpdateFailure(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ require.NoError(t, db.Migrator().DropTable(&model.Option{}))
+
+ groupsUpdated, removedMemberships, disabledChannelIds, groupUpdateFailed, err := applyChannelMonitorPolicyPlan(
+ context.Background(),
+ channelMonitorPolicyPlan{GroupRatioUpdates: map[string]float64{"monitor-test": 2}},
+ )
+
+ require.Error(t, err)
+ assert.Zero(t, groupsUpdated)
+ assert.Empty(t, removedMemberships)
+ assert.Empty(t, disabledChannelIds)
+ assert.True(t, groupUpdateFailed)
+}
+
+func TestSyncChannelMonitorGroupRatioUsesHighestEnabledChannel(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{"GroupRatio": `{"vip":1}`})
+ originalGroupRatios := ratio_setting.GroupRatio2JSONString()
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"vip":1}`))
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatios))
+ })
+
+ channels := []model.Channel{
+ {Id: 1, Name: "first", Key: "first-key", Group: "vip", Status: common.ChannelStatusEnabled},
+ {Id: 2, Name: "highest", Key: "highest-key", Group: "vip", Status: common.ChannelStatusEnabled},
+ {Id: 3, Name: "disabled", Key: "disabled-key", Group: "vip", Status: common.ChannelStatusManuallyDisabled},
+ }
+ require.NoError(t, db.Create(&channels).Error)
+ monitors := []model.ChannelRatioMonitor{
+ {ChannelId: 1, Ratio: 1.2, UpdatedTime: 1, CostConversion: `{"mode":"recharge","paid_cny":200,"credited_usd":100}`},
+ {ChannelId: 2, Ratio: 1.5, UpdatedTime: 1},
+ {ChannelId: 3, Ratio: 9, UpdatedTime: 1},
+ }
+ require.NoError(t, db.Create(&monitors).Error)
+
+ ctx, recorder := newChannelMonitorControllerContext(t, http.MethodPut, "/api/channel_monitor/group/sync", map[string]any{
+ "group": "vip", "coefficient": 1.1,
+ })
+ SyncChannelMonitorGroupRatio(ctx)
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var response channelMonitorGroupSyncAPIResponse
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ assert.Equal(t, "vip", response.Data.Group)
+ assert.InDelta(t, 1.2, response.Data.UpstreamRatio, 1e-9)
+ assert.InDelta(t, 2, response.Data.ConversionFactor, 1e-9)
+ assert.InDelta(t, 2.4, response.Data.CostRatio, 1e-9)
+ assert.InDelta(t, 1.1, response.Data.Coefficient, 1e-9)
+ assert.InDelta(t, 2.64, response.Data.Ratio, 1e-9)
+ assert.InDelta(t, 2.64, ratio_setting.GetGroupRatio("vip"), 1e-9)
+ assert.InDelta(t, 1.1, getChannelMonitorGroupCoefficients()["vip"], 1e-9)
+}
+
+func TestRunChannelRatioMonitorTaskRespectsPerChannelSyncCapabilities(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorAutoUpdateRetryCountOption: "0",
+ })
+ disableChannelMonitorSSRFProtection(t)
+
+ var ratioRequests atomic.Int32
+ var balanceRequests atomic.Int32
+ var statusRequests atomic.Int32
+ var unexpectedRequests atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/user/self/groups":
+ ratioRequests.Add(1)
+ assert.Equal(t, "42", r.Header.Get("New-Api-User"))
+ assert.Equal(t, "Bearer ratio-token", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"success":true,"data":{"vip":{"ratio":1.25}}}`))
+ case "/api/user/self":
+ balanceRequests.Add(1)
+ assert.Equal(t, "43", r.Header.Get("New-Api-User"))
+ assert.Equal(t, "Bearer balance-token", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"success":true,"data":{"quota":500}}`))
+ case "/api/status":
+ statusRequests.Add(1)
+ _, _ = w.Write([]byte(`{"success":true,"data":{"quota_per_unit":100}}`))
+ default:
+ unexpectedRequests.Add(1)
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ channels := []model.Channel{
+ {Id: 1, Name: "ratio only", Key: "ratio-key", Group: "vip", Status: common.ChannelStatusEnabled},
+ {Id: 2, Name: "balance only", Key: "balance-key", Group: "vip", Status: common.ChannelStatusEnabled},
+ {Id: 3, Name: "fully disabled", Key: "disabled-key", Group: "vip", Status: common.ChannelStatusEnabled},
+ }
+ require.NoError(t, db.Create(&channels).Error)
+ monitors := []model.ChannelRatioMonitor{
+ {
+ ChannelId: 1, Ratio: 1, UpdatedTime: 1,
+ UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthUser,
+ UpstreamUserId: 42, UpstreamAccessToken: "ratio-token",
+ UpstreamBalanceSyncDisabled: true,
+ },
+ {
+ ChannelId: 2,
+ UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthUser,
+ UpstreamUserId: 43, UpstreamAccessToken: "balance-token",
+ UpstreamRatioSyncDisabled: true,
+ },
+ {
+ ChannelId: 3,
+ UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthUser,
+ UpstreamUserId: 44, UpstreamAccessToken: "disabled-token",
+ UpstreamRatioSyncDisabled: true, UpstreamBalanceSyncDisabled: true,
+ },
+ }
+ require.NoError(t, db.Create(&monitors).Error)
+
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 3, summary.Total)
+ assert.Equal(t, 2, summary.Updated)
+ assert.Equal(t, 1, summary.Changed)
+ assert.Equal(t, 1, summary.BalanceUpdated)
+ assert.Equal(t, 1, summary.Skipped)
+ assert.Zero(t, summary.Failed)
+ assert.EqualValues(t, 1, ratioRequests.Load())
+ assert.EqualValues(t, 1, balanceRequests.Load())
+ assert.EqualValues(t, 1, statusRequests.Load())
+ assert.Zero(t, unexpectedRequests.Load())
+
+ ratioMonitor, err := model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ assert.InDelta(t, 1.25, ratioMonitor.Ratio, 1e-9)
+ assert.Nil(t, ratioMonitor.UpstreamBalance)
+ assert.Empty(t, ratioMonitor.LastBalanceError)
+
+ balanceMonitor, err := model.GetChannelRatioMonitor(2)
+ require.NoError(t, err)
+ assert.Zero(t, balanceMonitor.UpdatedTime)
+ require.NotNil(t, balanceMonitor.UpstreamBalance)
+ assert.InDelta(t, 5, *balanceMonitor.UpstreamBalance, 1e-9)
+}
+
+func TestRunChannelRatioMonitorTaskUpdatesCustomFixedSources(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorAutoUpdateRetryCountOption: "0",
+ })
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 1, Name: "custom fixed", Group: "vip", Status: common.ChannelStatusEnabled,
+ }).Error)
+ ratio := 0.8
+ balance := 12.5
+ autoDisableThreshold := 13.0
+ customConfig, err := service.MarshalChannelMonitorCustomUpstreamConfig(service.ChannelMonitorCustomUpstreamConfig{
+ Ratio: service.ChannelMonitorCustomMetricConfig{
+ Source: service.ChannelMonitorCustomSourceFixed, FixedValue: &ratio,
+ },
+ Balance: service.ChannelMonitorCustomMetricConfig{
+ Source: service.ChannelMonitorCustomSourceFixed, FixedValue: &balance,
+ },
+ })
+ require.NoError(t, err)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 1, Ratio: 0.5, UpdatedTime: 1,
+ UpstreamType: service.CustomUpstreamType, UpstreamBaseURL: "https://custom.example",
+ UpstreamAuthType: service.CustomUpstreamAuthType, CustomUpstreamConfig: customConfig,
+ BalanceAutoDisableThreshold: &autoDisableThreshold,
+ }).Error)
+
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.Total)
+ assert.Equal(t, 1, summary.Updated)
+ assert.Equal(t, 1, summary.Changed)
+ assert.Equal(t, 1, summary.BalanceUpdated)
+ assert.Equal(t, 1, summary.ChannelsDisabled)
+ assert.Zero(t, summary.Failed)
+
+ monitor, err := model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ assert.Equal(t, ratio, monitor.Ratio)
+ require.NotNil(t, monitor.UpstreamBalance)
+ assert.Equal(t, balance, *monitor.UpstreamBalance)
+ channel, err := model.GetChannelById(1, true)
+ require.NoError(t, err)
+ assert.Equal(t, common.ChannelStatusAutoDisabled, channel.Status)
+}
+
+func TestRunChannelRatioMonitorTaskUsesCostRatioForGroupPolicy(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ disableChannelMonitorSSRFProtection(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ "GroupRatio": `{"vip":0.4}`,
+ channelMonitorAutoUpdateRetryCountOption: "0",
+ })
+ originalGroupRatios := ratio_setting.GroupRatio2JSONString()
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"vip":0.4}`))
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatios))
+ })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/user/self/groups", r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"data":{"vip":{"ratio":1.2}}}`))
+ }))
+ defer server.Close()
+
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 1, Name: "converted", Key: "secret", Group: "vip", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 1, Ratio: 1, UpdatedTime: 1,
+ UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthUser,
+ UpstreamUserId: 42, UpstreamAccessToken: "dashboard-token",
+ UpstreamBalanceSyncDisabled: true,
+ SingleChannelAction: channelMonitorPolicyActionUpdateGroupRatio,
+ MultipleChannelsAction: channelMonitorPolicyActionNone,
+ CostConversion: `{"mode":"recharge","paid_cny":100,"credited_usd":200}`,
+ }).Error)
+
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.Updated)
+ assert.Equal(t, 1, summary.Changed)
+ assert.Equal(t, 1, summary.GroupsUpdated)
+ assert.InDelta(t, 0.6, ratio_setting.GetGroupRatio("vip"), 1e-9)
+
+ monitor, err := model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ assert.InDelta(t, 1.2, monitor.Ratio, 1e-9)
+}
+
+func TestRunChannelRatioMonitorTaskContinuesAfterFailure(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{})
+ disableChannelMonitorSSRFProtection(t)
+
+ var failingRequestCount atomic.Int32
+ failingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ failingRequestCount.Add(1)
+ w.WriteHeader(http.StatusBadGateway)
+ }))
+ defer failingServer.Close()
+ successServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"group_ratio":{"vip":1.25}}`))
+ }))
+ defer successServer.Close()
+
+ channels := []model.Channel{
+ {Id: 1, Name: "failing disabled", Key: "first-key", Group: "vip", Status: common.ChannelStatusManuallyDisabled},
+ {Id: 2, Name: "successful", Key: "second-key", Group: "vip", Status: common.ChannelStatusEnabled},
+ }
+ require.NoError(t, db.Create(&channels).Error)
+ monitors := []model.ChannelRatioMonitor{
+ {ChannelId: 1, UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: failingServer.URL, UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthPublic},
+ {ChannelId: 2, UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: successServer.URL, UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthPublic},
+ }
+ require.NoError(t, db.Create(&monitors).Error)
+
+ progress := make([][2]int, 0, 2)
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), func(processed, total int) {
+ progress = append(progress, [2]int{processed, total})
+ }, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 2, summary.Total)
+ assert.Equal(t, 1, summary.Updated)
+ assert.Equal(t, 1, summary.Failed)
+ assert.Equal(t, 2, summary.Retried)
+ assert.Zero(t, summary.RecoveredAfterRetry)
+ require.Len(t, summary.Failures, 1)
+ assert.Equal(t, 1, summary.Failures[0].ChannelId)
+ assert.Equal(t, "failing disabled", summary.Failures[0].ChannelName)
+ assert.Contains(t, summary.Failures[0].Error, "重试 2 次后仍失败")
+ assert.Contains(t, summary.Failures[0].Error, "502 Bad Gateway")
+ assert.False(t, summary.FailureDetailsTruncated)
+ assert.Equal(t, [][2]int{{1, 2}, {2, 2}}, progress)
+ assert.EqualValues(t, 6, failingRequestCount.Load())
+
+ failedMonitor, err := model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ assert.Equal(t, model.ChannelRatioFetchStatusFailed, failedMonitor.LastFetchStatus)
+ assert.NotEmpty(t, failedMonitor.LastFetchError)
+ assert.NotZero(t, failedMonitor.LastFetchTime)
+ assert.Equal(t, 3, failedMonitor.ConsecutiveFailures)
+
+ monitor, err := model.GetChannelRatioMonitor(2)
+ require.NoError(t, err)
+ assert.InDelta(t, 1.25, monitor.Ratio, 1e-9)
+ assert.Equal(t, "系统自动更新", monitor.UpdatedByUsername)
+ assert.NotZero(t, monitor.UpdatedTime)
+ assert.Equal(t, model.ChannelRatioFetchStatusSucceeded, monitor.LastFetchStatus)
+ assert.Empty(t, monitor.LastFetchError)
+ assert.Zero(t, monitor.ConsecutiveFailures)
+}
+
+func TestRunChannelRatioMonitorTaskDoesNotRetrySub2APIAuthenticationFailure(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorAutoUpdateRetryCountOption: "2",
+ })
+ disableChannelMonitorSSRFProtection(t)
+
+ var requestCount atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestCount.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ assert.Equal(t, "/api/v1/groups/available", r.URL.Path)
+ assert.Equal(t, "Bearer jwt-token", r.Header.Get("Authorization"))
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"code":401,"message":"token expired","data":null}`))
+ }))
+ defer server.Close()
+
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 1, Name: "session bound", Key: "test-key", Group: "vip", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 1, UpstreamType: service.Sub2APIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.Sub2APIAuthToken,
+ UpstreamAccessToken: "jwt-token",
+ }).Error)
+
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.Failed)
+ assert.Zero(t, summary.Retried)
+ require.Len(t, summary.Failures, 1)
+ assert.Contains(t, summary.Failures[0].Error, "token expired")
+ assert.NotContains(t, summary.Failures[0].Error, "重试")
+ assert.EqualValues(t, 1, requestCount.Load())
+
+ monitor, err := model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ assert.Equal(t, 1, monitor.ConsecutiveFailures)
+}
+
+func TestRunChannelRatioMonitorTaskUsesSub2APIAccountPassword(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ disableChannelMonitorSSRFProtection(t)
+
+ var loginRequests atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/v1/auth/login":
+ loginRequests.Add(1)
+ var request struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+ }
+ require.NoError(t, common.DecodeJson(r.Body, &request))
+ assert.Equal(t, "monitor@example.com", request.Email)
+ assert.Equal(t, "secret-password", request.Password)
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"auto-jwt","expires_in":3600}}`))
+ case "/api/v1/groups/available":
+ assert.Equal(t, "Bearer auto-jwt", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":[{"id":7,"name":"vip","rate_multiplier":1.25}]}`))
+ case "/api/v1/groups/rates":
+ assert.Equal(t, "Bearer auto-jwt", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"7":1.5}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 1, Name: "account upstream", Key: "test-key", Group: "vip", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 1, UpstreamType: service.Sub2APIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.Sub2APIAuthAccount,
+ UpstreamAccount: "monitor@example.com", UpstreamPassword: "secret-password",
+ UpstreamBalanceSyncDisabled: true,
+ }).Error)
+
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.Updated)
+ assert.Zero(t, summary.Failed)
+ assert.EqualValues(t, 1, loginRequests.Load())
+
+ monitor, err := model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ assert.InDelta(t, 1.5, monitor.Ratio, 1e-9)
+ assert.Equal(t, model.ChannelRatioFetchStatusSucceeded, monitor.LastFetchStatus)
+}
+
+func TestRunChannelRatioMonitorTaskRecoversAfterRetry(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorAutoUpdateRetryCountOption: "2",
+ channelMonitorAutoDisableOnUpdateFailureOption: "true",
+ })
+ disableChannelMonitorSSRFProtection(t)
+
+ var requestCount atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ if requestCount.Add(1) <= 4 {
+ w.WriteHeader(http.StatusBadGateway)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"group_ratio":{"vip":1.25}}`))
+ }))
+ defer server.Close()
+
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 1, Name: "recovers", Key: "test-key", Group: "vip", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 1, UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthPublic,
+ }).Error)
+
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.Updated)
+ assert.Zero(t, summary.Failed)
+ assert.Equal(t, 2, summary.Retried)
+ assert.Equal(t, 1, summary.RecoveredAfterRetry)
+ assert.EqualValues(t, 5, requestCount.Load())
+
+ monitor, err := model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ assert.InDelta(t, 1.25, monitor.Ratio, 1e-9)
+ assert.Equal(t, model.ChannelRatioFetchStatusSucceeded, monitor.LastFetchStatus)
+ assert.Zero(t, monitor.ConsecutiveFailures)
+ channel, err := model.GetChannelById(1, true)
+ require.NoError(t, err)
+ assert.Equal(t, common.ChannelStatusEnabled, channel.Status)
+}
+
+func TestSendChannelRatioMonitorNotificationEmailIncludesChannelRemarks(t *testing.T) {
+ var content string
+ err := sendChannelRatioMonitorNotificationEmail(
+ "alerts@example.com",
+ []channelRatioMonitorEmailChange{{
+ ChannelId: 1, ChannelName: "ratio", ChannelRemark: "<倍率备注 & 一>",
+ UpstreamType: service.NewAPIUpstreamType, UpstreamGroup: "vip",
+ OldRatio: 1, NewRatio: 1.2, ConversionFactor: 1, OldCostRatio: 1, NewCostRatio: 1.2,
+ }},
+ []channelRatioMonitorBalanceWarning{{
+ ChannelId: 2, ChannelName: "balance", ChannelRemark: "<余额备注 & 二>",
+ UpstreamType: service.NewAPIUpstreamType, Balance: 5, Threshold: 10,
+ }},
+ []channelRatioMonitorDisabledChannel{{
+ ChannelId: 3, ChannelName: "disabled", ChannelRemark: "<禁用备注 & 三>", Reason: "测试禁用",
+ }},
+ []channelRatioMonitorRemovedGroupMembership{{
+ ChannelId: 4, ChannelName: "removed", ChannelRemark: "<移组备注 & 四>", Group: "vip",
+ }},
+ channelRatioMonitorTaskResult{
+ Failed: 1,
+ Failures: []channelRatioMonitorTaskFailure{{
+ ChannelId: 5, ChannelName: "failed", ChannelRemark: "<失败备注 & 五>", Error: "测试失败",
+ }},
+ },
+ nil,
+ func(_ string, _ string, gotContent string) error {
+ content = gotContent
+ return nil
+ },
+ )
+ require.NoError(t, err)
+ assert.Equal(t, 5, strings.Count(content, ">备注"))
+ for _, remark := range []string{
+ "<倍率备注 & 一>",
+ "<余额备注 & 二>",
+ "<禁用备注 & 三>",
+ "<移组备注 & 四>",
+ "<失败备注 & 五>",
+ } {
+ assert.Contains(t, content, remark)
+ }
+}
+
+func TestRunChannelRatioMonitorTaskEmailsRatioChanges(t *testing.T) {
+ tests := []struct {
+ name string
+ emailEnabled bool
+ sendError error
+ wantEmailStatus string
+ wantEmailCalls int
+ }{
+ {name: "sent", emailEnabled: true, wantEmailStatus: "sent", wantEmailCalls: 1},
+ {name: "send failure remains visible", emailEnabled: true, sendError: errors.New("smtp unavailable"), wantEmailStatus: "failed", wantEmailCalls: 1},
+ {name: "disabled", emailEnabled: false},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ emailEnabled := "false"
+ if test.emailEnabled {
+ emailEnabled = "true"
+ }
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorEmailNotificationOption: emailEnabled,
+ channelMonitorNotificationEmailOption: "alerts@example.com",
+ })
+ disableChannelMonitorSSRFProtection(t)
+ channelRemark := ""
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"group_ratio":{"vip":1.25}}`))
+ }))
+ defer server.Close()
+
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 1,
+ Name: "",
+ Key: "secret",
+ Group: "vip",
+ Remark: &channelRemark,
+ Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 1,
+ Ratio: 1,
+ UpdatedTime: 1,
+ UpstreamType: service.NewAPIUpstreamType,
+ UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip",
+ UpstreamAuthType: service.NewAPIUpstreamAuthPublic,
+ }).Error)
+
+ var subject string
+ var receiver string
+ var content string
+ emailCalls := 0
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, func(gotSubject string, gotReceiver string, gotContent string) error {
+ emailCalls++
+ subject = gotSubject
+ receiver = gotReceiver
+ content = gotContent
+ return test.sendError
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.Changed)
+ assert.Equal(t, test.wantEmailStatus, summary.EmailStatus)
+ assert.Equal(t, test.wantEmailCalls, emailCalls)
+ if test.wantEmailCalls > 0 {
+ assert.Contains(t, subject, "1 个渠道")
+ assert.Equal(t, "alerts@example.com", receiver)
+ assert.Contains(t, content, "<Primary & API>")
+ assert.Contains(t, content, "<Primary remark & billing>")
+ assert.Contains(t, content, "vip")
+ assert.Contains(t, content, ">1<")
+ assert.Contains(t, content, ">1.25<")
+ }
+ if test.sendError == nil || !test.emailEnabled {
+ assert.Empty(t, summary.EmailError)
+ } else {
+ assert.Contains(t, summary.EmailError, test.sendError.Error())
+ }
+
+ monitor, err := model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ assert.InDelta(t, 1.25, monitor.Ratio, 1e-9)
+ })
+ }
+}
+
+func TestRunChannelRatioMonitorTaskEmailsRatioPolicyAutoDisable(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ "GroupRatio": `{"vip":1}`,
+ channelMonitorAutoUpdateRetryCountOption: "0",
+ channelMonitorEmailNotificationOption: "true",
+ channelMonitorNotificationEmailOption: "alerts@example.com",
+ })
+ disableChannelMonitorSSRFProtection(t)
+ originalGroupRatios := ratio_setting.GroupRatio2JSONString()
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"vip":1}`))
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatios))
+ })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"group_ratio":{"vip":1.25}}`))
+ }))
+ defer server.Close()
+
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 1, Name: "", Key: "secret", Group: "vip", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 1, Ratio: 1, UpdatedTime: 1,
+ UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthPublic,
+ UpstreamBalanceSyncDisabled: true,
+ SingleChannelAction: channelMonitorPolicyActionDisableChannel,
+ }).Error)
+
+ var subject string
+ var receiver string
+ var content string
+ emailCalls := 0
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, func(gotSubject string, gotReceiver string, gotContent string) error {
+ emailCalls++
+ subject = gotSubject
+ receiver = gotReceiver
+ content = gotContent
+ return nil
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.Changed)
+ assert.Equal(t, 1, summary.ChannelsDisabled)
+ assert.Equal(t, "sent", summary.EmailStatus)
+ assert.Equal(t, 1, emailCalls)
+ assert.Contains(t, subject, "1 个渠道自动禁用")
+ assert.Equal(t, "alerts@example.com", receiver)
+ assert.Contains(t, content, "渠道自动禁用")
+ assert.Contains(t, content, "<Disabled & API>(ID: 1)")
+ assert.Contains(t, content, "成本倍率高于分组倍率")
+
+ channel, err := model.GetChannelById(1, true)
+ require.NoError(t, err)
+ assert.Equal(t, common.ChannelStatusAutoDisabled, channel.Status)
+}
+
+func TestRunChannelRatioMonitorTaskEmailsRatioPolicyGroupRemoval(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ "GroupRatio": `{"vip":1}`,
+ channelMonitorAutoUpdateRetryCountOption: "0",
+ channelMonitorEmailNotificationOption: "true",
+ channelMonitorNotificationEmailOption: "alerts@example.com",
+ })
+ disableChannelMonitorSSRFProtection(t)
+ originalGroupRatios := ratio_setting.GroupRatio2JSONString()
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"vip":1}`))
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatios))
+ })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"group_ratio":{"vip":1.25}}`))
+ }))
+ defer server.Close()
+
+ channels := []model.Channel{
+ {Id: 1, Name: "", Key: "secret", Group: "vip,backup", Models: "model-a", Status: common.ChannelStatusEnabled},
+ {Id: 2, Name: "stable", Key: "secret", Group: "vip", Models: "model-b", Status: common.ChannelStatusEnabled},
+ }
+ require.NoError(t, db.Create(&channels).Error)
+ for i := range channels {
+ require.NoError(t, channels[i].AddAbilities(nil))
+ }
+ require.NoError(t, db.Create(&[]model.ChannelRatioMonitor{
+ {
+ ChannelId: 1, Ratio: 1, UpdatedTime: 1,
+ UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthPublic,
+ UpstreamBalanceSyncDisabled: true,
+ MultipleChannelsAction: channelMonitorPolicyActionRemoveFromGroup,
+ },
+ {
+ ChannelId: 2, Ratio: 1, UpdatedTime: 1,
+ UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthPublic,
+ UpstreamBalanceSyncDisabled: true,
+ },
+ }).Error)
+
+ var subject string
+ var content string
+ emailCalls := 0
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, func(gotSubject string, _ string, gotContent string) error {
+ emailCalls++
+ subject = gotSubject
+ content = gotContent
+ return nil
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, 2, summary.Changed)
+ assert.Equal(t, 1, summary.GroupMembershipsRemoved)
+ assert.Equal(t, "sent", summary.EmailStatus)
+ assert.Equal(t, 1, emailCalls)
+ assert.Contains(t, subject, "1 个渠道移出分组")
+ assert.Contains(t, content, "渠道移出分组")
+ assert.Contains(t, content, "<Removed & API>(ID: 1)")
+ assert.Contains(t, content, ">vip<")
+
+ channel, err := model.GetChannelById(1, true)
+ require.NoError(t, err)
+ assert.Equal(t, "backup", channel.Group)
+ var abilities []model.Ability
+ require.NoError(t, db.Where("channel_id = ?", 1).Find(&abilities).Error)
+ require.Len(t, abilities, 1)
+ assert.Equal(t, "backup", abilities[0].Group)
+}
+
+func TestRunChannelRatioMonitorTaskRefreshesBalanceAndDeduplicatesLowBalanceEmail(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorAutoUpdateRetryCountOption: "0",
+ channelMonitorEmailNotificationOption: "true",
+ channelMonitorNotificationEmailOption: "alerts@example.com",
+ })
+ disableChannelMonitorSSRFProtection(t)
+
+ var upstreamQuota atomic.Int64
+ upstreamQuota.Store(500)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/user/self/groups":
+ assert.Equal(t, "Bearer dashboard-token", r.Header.Get("Authorization"))
+ assert.Equal(t, "42", r.Header.Get("New-Api-User"))
+ _, _ = w.Write([]byte(`{"success":true,"data":{"vip":{"ratio":1.25}}}`))
+ case "/api/user/self":
+ assert.Equal(t, "Bearer dashboard-token", r.Header.Get("Authorization"))
+ assert.Equal(t, "42", r.Header.Get("New-Api-User"))
+ _, _ = fmt.Fprintf(w, `{"success":true,"data":{"quota":%d}}`, upstreamQuota.Load())
+ case "/api/status":
+ _, _ = w.Write([]byte(`{"success":true,"data":{"quota_per_unit":100}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ threshold := 10.0
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 1, Name: "", Key: "secret", Group: "vip", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 1,
+ Ratio: 1.25,
+ UpdatedTime: 1,
+ UpstreamType: service.NewAPIUpstreamType,
+ UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip",
+ UpstreamAuthType: service.NewAPIUpstreamAuthUser,
+ UpstreamUserId: 42,
+ UpstreamAccessToken: "dashboard-token",
+ BalanceWarningThreshold: &threshold,
+ }).Error)
+
+ emailCalls := 0
+ var emailSendError error
+ var subject string
+ var content string
+ sendEmail := func(gotSubject string, receiver string, gotContent string) error {
+ emailCalls++
+ subject = gotSubject
+ content = gotContent
+ assert.Equal(t, "alerts@example.com", receiver)
+ return emailSendError
+ }
+
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, sendEmail)
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.Updated)
+ assert.Equal(t, 1, summary.BalanceUpdated)
+ assert.Equal(t, 1, summary.BalanceWarnings)
+ assert.Equal(t, "sent", summary.EmailStatus)
+ assert.Equal(t, 1, emailCalls)
+ assert.Contains(t, subject, "1 个余额预警")
+ assert.Contains(t, content, "上游余额预警")
+ assert.Contains(t, content, "<Balance & API>")
+ assert.Contains(t, content, ">5<")
+ assert.Contains(t, content, ">10<")
+ monitor, err := model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ require.NotNil(t, monitor.UpstreamBalance)
+ assert.Equal(t, 5.0, *monitor.UpstreamBalance)
+ assert.True(t, monitor.BalanceAlertNotified)
+
+ summary, err = runChannelRatioMonitorTaskOnce(context.Background(), nil, sendEmail)
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.BalanceUpdated)
+ assert.Zero(t, summary.BalanceWarnings)
+ assert.Empty(t, summary.EmailStatus)
+ assert.Equal(t, 1, emailCalls)
+
+ upstreamQuota.Store(1500)
+ summary, err = runChannelRatioMonitorTaskOnce(context.Background(), nil, sendEmail)
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.BalanceUpdated)
+ assert.Zero(t, summary.BalanceWarnings)
+ assert.Equal(t, 1, emailCalls)
+ monitor, err = model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ require.NotNil(t, monitor.UpstreamBalance)
+ assert.Equal(t, 15.0, *monitor.UpstreamBalance)
+ assert.False(t, monitor.BalanceAlertNotified)
+
+ upstreamQuota.Store(400)
+ emailSendError = errors.New("smtp unavailable")
+ summary, err = runChannelRatioMonitorTaskOnce(context.Background(), nil, sendEmail)
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.BalanceWarnings)
+ assert.Equal(t, "failed", summary.EmailStatus)
+ assert.Contains(t, summary.EmailError, "smtp unavailable")
+ assert.Equal(t, 2, emailCalls)
+ monitor, err = model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ assert.False(t, monitor.BalanceAlertNotified)
+
+ emailSendError = nil
+ summary, err = runChannelRatioMonitorTaskOnce(context.Background(), nil, sendEmail)
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.BalanceUpdated)
+ assert.Equal(t, 1, summary.BalanceWarnings)
+ assert.Equal(t, "sent", summary.EmailStatus)
+ assert.Equal(t, 3, emailCalls)
+ monitor, err = model.GetChannelRatioMonitor(1)
+ require.NoError(t, err)
+ assert.True(t, monitor.BalanceAlertNotified)
+}
+
+func TestRunChannelRatioMonitorTaskEmailsUpdateFailures(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorAutoUpdateRetryCountOption: "0",
+ channelMonitorEmailNotificationOption: "true",
+ channelMonitorNotificationEmailOption: "alerts@example.com",
+ })
+ disableChannelMonitorSSRFProtection(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadGateway)
+ }))
+ defer server.Close()
+
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 1, Name: "", Key: "test-key", Group: "vip", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 1, UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthPublic,
+ }).Error)
+
+ var subject string
+ var receiver string
+ var content string
+ emailCalls := 0
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, func(gotSubject string, gotReceiver string, gotContent string) error {
+ emailCalls++
+ subject = gotSubject
+ receiver = gotReceiver
+ content = gotContent
+ return nil
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.Failed)
+ assert.Equal(t, "sent", summary.EmailStatus)
+ assert.Equal(t, 1, emailCalls)
+ assert.Contains(t, subject, "1 项更新失败")
+ assert.Equal(t, "alerts@example.com", receiver)
+ assert.Contains(t, content, "上游同步失败")
+ assert.Contains(t, content, "<Failing & API>")
+ assert.Contains(t, content, "502 Bad Gateway")
+ channel, err := model.GetChannelById(1, true)
+ require.NoError(t, err)
+ assert.Equal(t, common.ChannelStatusEnabled, channel.Status)
+}
+
+func TestRunChannelRatioMonitorTaskAutoDisablesChannelAfterUpdateFailure(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ channelMonitorAutoUpdateRetryCountOption: "0",
+ channelMonitorAutoDisableOnUpdateFailureOption: "true",
+ channelMonitorEmailNotificationOption: "true",
+ channelMonitorNotificationEmailOption: "alerts@example.com",
+ })
+ disableChannelMonitorSSRFProtection(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadGateway)
+ }))
+ defer server.Close()
+
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 1, Name: "", Key: "test-key", Group: "vip", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 1, UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthPublic,
+ }).Error)
+
+ var subject string
+ var content string
+ emailCalls := 0
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, func(gotSubject string, _ string, gotContent string) error {
+ emailCalls++
+ subject = gotSubject
+ content = gotContent
+ return nil
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, 1, summary.Failed)
+ assert.Equal(t, 1, summary.ChannelsDisabled)
+ assert.Equal(t, "sent", summary.EmailStatus)
+ assert.Equal(t, 1, emailCalls)
+ assert.Contains(t, subject, "1 个渠道自动禁用")
+ assert.Contains(t, subject, "1 项更新失败")
+ assert.Contains(t, content, "渠道自动禁用")
+ assert.Contains(t, content, "<Auto Disabled & API>(ID: 1)")
+ assert.Contains(t, content, "上游倍率或余额更新失败")
+
+ channel, err := model.GetChannelById(1, true)
+ require.NoError(t, err)
+ assert.Equal(t, common.ChannelStatusAutoDisabled, channel.Status)
+}
+
+func TestRunChannelRatioMonitorTaskEmailsGroupUpdateFailure(t *testing.T) {
+ db := setupChannelMonitorControllerTestDB(t)
+ useChannelMonitorOptionMap(t, map[string]string{
+ "GroupRatio": `{"vip":1}`,
+ channelMonitorEmailNotificationOption: "true",
+ channelMonitorNotificationEmailOption: "alerts@example.com",
+ })
+ originalGroupRatios := ratio_setting.GroupRatio2JSONString()
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"vip":1}`))
+ t.Cleanup(func() {
+ require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatios))
+ })
+ disableChannelMonitorSSRFProtection(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"group_ratio":{"vip":1.25}}`))
+ }))
+ defer server.Close()
+
+ require.NoError(t, db.Create(&model.Channel{
+ Id: 1, Name: "stable", Key: "test-key", Group: "vip", Status: common.ChannelStatusEnabled,
+ }).Error)
+ require.NoError(t, db.Create(&model.ChannelRatioMonitor{
+ ChannelId: 1, Ratio: 1.25, UpdatedTime: 1,
+ UpstreamType: service.NewAPIUpstreamType, UpstreamBaseURL: server.URL,
+ UpstreamGroup: "vip", UpstreamAuthType: service.NewAPIUpstreamAuthPublic,
+ SingleChannelAction: channelMonitorPolicyActionUpdateGroupRatio,
+ }).Error)
+ require.NoError(t, db.Migrator().DropTable(&model.Option{}))
+
+ var subject string
+ var content string
+ emailCalls := 0
+ summary, err := runChannelRatioMonitorTaskOnce(context.Background(), nil, func(gotSubject string, _ string, gotContent string) error {
+ emailCalls++
+ subject = gotSubject
+ content = gotContent
+ return nil
+ })
+
+ require.Error(t, err)
+ assert.True(t, summary.GroupUpdateFailed)
+ assert.Equal(t, "sent", summary.EmailStatus)
+ assert.Equal(t, 1, emailCalls)
+ assert.Contains(t, subject, "1 项更新失败")
+ assert.Contains(t, content, "分组倍率更新失败")
+ assert.Contains(t, content, "失败原因")
+}
diff --git a/controller/relay.go b/controller/relay.go
index 6e91ccb60506..43041113009c 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -23,6 +23,7 @@ import (
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
"github.com/bytedance/gopkg/util/gopool"
@@ -122,6 +123,17 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
newAPIError = types.NewError(err, types.ErrorCodeGenRelayInfoFailed)
return
}
+ if relayInfo.RelayMode == relayconstant.RelayModeImagesGenerations {
+ if _, enabled := ratio_setting.GetImageRatio(relayInfo.OriginModelName); !enabled {
+ newAPIError = types.NewErrorWithStatusCode(
+ errors.New("image generation is currently not supported"),
+ types.ErrorCodeInvalidRequest,
+ http.StatusOK,
+ types.ErrOptionWithSkipRetry(),
+ )
+ return
+ }
+ }
needSensitiveCheck := setting.ShouldCheckPromptSensitive()
needCountToken := constant.CountToken
@@ -187,10 +199,13 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}
relayInfo.RetryIndex = 0
relayInfo.LastError = nil
+ retryBudget := newRelayRetryBudget()
+ retryRouting := newRelayRetryRouting()
+ finalRetryLogPending := false
- for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
+ for retryParam.GetRetry() <= common.RetryTimes {
relayInfo.RetryIndex = retryParam.GetRetry()
- channel, channelErr := getChannel(c, relayInfo, retryParam)
+ channel, channelErr := getChannel(c, relayInfo, retryParam, retryRouting)
if channelErr != nil {
logger.LogError(c, channelErr.Error())
newAPIError = channelErr
@@ -229,12 +244,30 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
newAPIError = service.NormalizeViolationFeeError(newAPIError)
relayInfo.LastError = newAPIError
- processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
+ shouldRetry := prepareNextRelayAttempt(c, relayInfo.RelayMode, newAPIError, retryParam, &retryBudget)
+ if shouldRetry {
+ selectedGroup := retryParam.TokenGroup
+ if selectedGroup == "auto" {
+ selectedGroup = common.GetContextKeyString(c, constant.ContextKeyAutoGroup)
+ }
+ retryRouting.recordFailure(channel.Id, selectedGroup)
+ }
+ processChannelError(c,
+ *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey,
+ common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()),
+ newAPIError, shouldRetry)
+ finalRetryLogPending = shouldRetry
- if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) {
+ if !shouldRetry {
break
}
}
+ if newAPIError != nil && finalRetryLogPending {
+ processChannelError(c,
+ *types.NewChannelError(c.GetInt("channel_id"), c.GetInt("channel_type"), c.GetString("channel_name"),
+ common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey), "", false),
+ newAPIError, false)
+ }
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
@@ -290,7 +323,7 @@ func fastTokenCountMetaForPricing(request dto.Request) *types.TokenCountMeta {
return meta
}
-func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service.RetryParam) (*model.Channel, *types.NewAPIError) {
+func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service.RetryParam, retryRoutings ...*relayRetryRouting) (*model.Channel, *types.NewAPIError) {
if info.ChannelMeta == nil {
autoBan := c.GetBool("auto_ban")
autoBanInt := 1
@@ -304,7 +337,14 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
AutoBan: &autoBanInt,
}, nil
}
- channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam)
+ var retryRouting *relayRetryRouting
+ if len(retryRoutings) > 0 {
+ retryRouting = retryRoutings[len(retryRoutings)-1]
+ }
+ channel, selectGroup, err := retryRouting.selectChannel(c, retryParam)
+ if retryParam.TokenGroup == "auto" && channel != nil && selectGroup != "" {
+ common.SetContextKey(c, constant.ContextKeyAutoGroup, selectGroup)
+ }
info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)
@@ -354,7 +394,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
return operation_setting.ShouldRetryByStatusCode(code)
}
-func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
+func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError, isRetryAttempt bool) {
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error())))
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
@@ -370,7 +410,10 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
tokenName := c.GetString("token_name")
modelName := c.GetString("original_model")
tokenId := c.GetInt("token_id")
- userGroup := c.GetString("group")
+ usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
+ if autoGroup := common.GetContextKeyString(c, constant.ContextKeyAutoGroup); autoGroup != "" {
+ usingGroup = autoGroup
+ }
channelId := c.GetInt("channel_id")
other := make(map[string]interface{})
if c.Request != nil && c.Request.URL != nil {
@@ -396,7 +439,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
startTime = time.Now()
}
useTimeSeconds := int(time.Since(startTime).Seconds())
- model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
+ model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), usingGroup, other, isRetryAttempt)
}
}
@@ -501,6 +544,7 @@ func RelayTask(c *gin.Context) {
var result *relay.TaskSubmitResult
var taskErr *dto.TaskError
+ finalRetryLogPending := false
defer func() {
if taskErr != nil && relayInfo.Billing != nil {
relayInfo.Billing.Refund(c)
@@ -552,18 +596,31 @@ func RelayTask(c *gin.Context) {
if taskErr == nil {
break
}
+ shouldRetry := shouldRetryTaskRelay(c, channel.Id, taskErr, common.RetryTimes-retryParam.GetRetry())
if !taskErr.LocalError {
processChannelError(c,
*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey,
common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()),
- types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode))
+ types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode),
+ shouldRetry)
+ finalRetryLogPending = shouldRetry
}
- if !shouldRetryTaskRelay(c, channel.Id, taskErr, common.RetryTimes-retryParam.GetRetry()) {
+ if !shouldRetry {
break
}
}
+ if taskErr != nil && finalRetryLogPending {
+ finalTaskError := taskErr.Error
+ if finalTaskError == nil {
+ finalTaskError = errors.New(taskErr.Message)
+ }
+ processChannelError(c,
+ *types.NewChannelError(c.GetInt("channel_id"), c.GetInt("channel_type"), c.GetString("channel_name"),
+ common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey), "", false),
+ types.NewOpenAIError(finalTaskError, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode), false)
+ }
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
diff --git a/controller/relay_error_log_test.go b/controller/relay_error_log_test.go
new file mode 100644
index 000000000000..23ea4a829f0a
--- /dev/null
+++ b/controller/relay_error_log_test.go
@@ -0,0 +1,78 @@
+package controller
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/types"
+
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func TestProcessChannelErrorPersistsRetryAttempt(t *testing.T) {
+ originalDB := model.DB
+ originalLogDB := model.LOG_DB
+ originalErrorLogEnabled := constant.ErrorLogEnabled
+ originalRedisEnabled := common.RedisEnabled
+ originalLogDatabaseType := common.LogDatabaseType()
+ t.Cleanup(func() {
+ model.DB = originalDB
+ model.LOG_DB = originalLogDB
+ constant.ErrorLogEnabled = originalErrorLogEnabled
+ common.RedisEnabled = originalRedisEnabled
+ common.SetLogDatabaseType(originalLogDatabaseType)
+ })
+
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "logs.db")), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, sqlDB.Close())
+ })
+ require.NoError(t, db.AutoMigrate(&model.Log{}))
+ require.NoError(t, db.Exec("CREATE TABLE users (id INTEGER PRIMARY KEY, setting TEXT, deleted_at DATETIME)").Error)
+ require.NoError(t, db.Exec("INSERT INTO users (id, setting) VALUES (?, ?)", 1, "{}").Error)
+ model.DB = db
+ model.LOG_DB = db
+ constant.ErrorLogEnabled = true
+ common.RedisEnabled = false
+ common.SetLogDatabaseType(common.DatabaseTypeSQLite)
+
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+ c.Set("id", 1)
+ c.Set("username", "user")
+ c.Set("token_name", "test-token")
+ c.Set("original_model", "gpt-test")
+ c.Set("token_id", 7)
+ c.Set("group", "default")
+ c.Set("channel_id", 9)
+ c.Set("channel_name", "test-channel")
+ c.Set("channel_type", 1)
+ c.Set("use_channel", []string{"9"})
+ c.Set(common.RequestIdKey, "retry-request")
+ common.SetContextKey(c, constant.ContextKeyUsingGroup, "vip")
+ common.SetContextKey(c, constant.ContextKeyAutoGroup, "standard")
+
+ apiErr := types.NewOpenAIError(errors.New("temporary upstream failure"), types.ErrorCodeBadResponseStatusCode, http.StatusServiceUnavailable)
+ processChannelError(c, *types.NewChannelError(9, 1, "test-channel", false, "", false), apiErr, true)
+
+ var logs []model.Log
+ require.NoError(t, db.Find(&logs).Error)
+ require.Len(t, logs, 1)
+ assert.True(t, logs[0].IsRetryAttempt)
+ assert.Equal(t, "standard", logs[0].Group)
+ assert.Contains(t, logs[0].Content, "temporary upstream failure")
+}
diff --git a/controller/relay_retry.go b/controller/relay_retry.go
new file mode 100644
index 000000000000..aaaeb324b843
--- /dev/null
+++ b/controller/relay_retry.go
@@ -0,0 +1,67 @@
+package controller
+
+import (
+ "github.com/QuantumNous/new-api/common"
+ relayconstant "github.com/QuantumNous/new-api/relay/constant"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/types"
+ "github.com/gin-gonic/gin"
+)
+
+var (
+ retry400UpstreamFailedTimes = max(0, common.GetEnvOrDefault("RETRY_400_UPSTREAM_FAILED_TIMES", 1))
+ retry503Times = max(0, common.GetEnvOrDefault("RETRY_503_TIMES", 1))
+ retry524Times = max(0, common.GetEnvOrDefault("RETRY_524_TIMES", 1))
+)
+
+type relayRetryBudget struct {
+ retry400UpstreamFailedRemaining int
+ retry503Remaining int
+ retry524Remaining int
+}
+
+func newRelayRetryBudget() relayRetryBudget {
+ return relayRetryBudget{
+ retry400UpstreamFailedRemaining: retry400UpstreamFailedTimes,
+ retry503Remaining: retry503Times,
+ retry524Remaining: retry524Times,
+ }
+}
+
+func prepareNextRelayAttempt(
+ c *gin.Context,
+ relayMode int,
+ apiError *types.NewAPIError,
+ retryParam *service.RetryParam,
+ retryBudget *relayRetryBudget,
+) bool {
+ if apiError == nil {
+ return false
+ }
+
+ if relayMode == relayconstant.RelayModeChatCompletions || relayMode == relayconstant.RelayModeResponses {
+ var remaining *int
+ switch {
+ case apiError.StatusCode == 400 && apiError.Error() == "Upstream request failed":
+ remaining = &retryBudget.retry400UpstreamFailedRemaining
+ case apiError.StatusCode == 503:
+ remaining = &retryBudget.retry503Remaining
+ case apiError.StatusCode == 524:
+ remaining = &retryBudget.retry524Remaining
+ }
+ if remaining != nil && *remaining > 0 {
+ *remaining = *remaining - 1
+ // Consume a pending auto-group reset without spending a normal retry.
+ retryIndex := retryParam.GetRetry()
+ retryParam.IncreaseRetry()
+ retryParam.SetRetry(retryIndex)
+ return true
+ }
+ }
+
+ if !shouldRetry(c, apiError, common.RetryTimes-retryParam.GetRetry()) {
+ return false
+ }
+ retryParam.IncreaseRetry()
+ return true
+}
diff --git a/controller/relay_retry_routing.go b/controller/relay_retry_routing.go
new file mode 100644
index 000000000000..fb4c63b95713
--- /dev/null
+++ b/controller/relay_retry_routing.go
@@ -0,0 +1,131 @@
+package controller
+
+import (
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+)
+
+type relayRetryChannel struct {
+ id int
+ group string
+}
+
+type relayRetryRouting struct {
+ attempts map[int]int
+ excluded map[int]struct{}
+ excludedOrder []relayRetryChannel
+ next *relayRetryChannel
+}
+
+func newRelayRetryRouting() *relayRetryRouting {
+ return &relayRetryRouting{
+ attempts: make(map[int]int),
+ excluded: make(map[int]struct{}),
+ }
+}
+
+func (routing *relayRetryRouting) recordFailure(channelID int, group string) {
+ if routing == nil || channelID <= 0 {
+ return
+ }
+ attempts := routing.attempts[channelID] + 1
+ routing.attempts[channelID] = attempts
+ channel := relayRetryChannel{id: channelID, group: group}
+ if attempts == 1 {
+ routing.next = &channel
+ return
+ }
+ routing.next = nil
+ routing.exclude(channel)
+}
+
+func (routing *relayRetryRouting) exclude(channel relayRetryChannel) {
+ if routing == nil || channel.id <= 0 {
+ return
+ }
+ if _, exists := routing.excluded[channel.id]; exists {
+ return
+ }
+ routing.excluded[channel.id] = struct{}{}
+ routing.excludedOrder = append(routing.excludedOrder, channel)
+}
+
+func (routing *relayRetryRouting) markUnavailable(channel relayRetryChannel) {
+ if routing == nil {
+ return
+ }
+ routing.next = nil
+ routing.attempts[channel.id] = 2
+ routing.exclude(channel)
+}
+
+func (routing *relayRetryRouting) takeNext() (relayRetryChannel, bool) {
+ if routing == nil || routing.next == nil {
+ return relayRetryChannel{}, false
+ }
+ channel := *routing.next
+ routing.next = nil
+ return channel, true
+}
+
+func (routing *relayRetryRouting) selectionOptions() (model.ChannelSelectionOptions, bool) {
+ if routing == nil || len(routing.excludedOrder) == 0 {
+ return model.ChannelSelectionOptions{}, false
+ }
+ channelIDs := make([]int, 0, len(routing.excludedOrder))
+ for _, channel := range routing.excludedOrder {
+ channelIDs = append(channelIDs, channel.id)
+ }
+ return model.ChannelSelectionOptions{ExcludedChannelIds: channelIDs}, true
+}
+
+func (routing *relayRetryRouting) restartFromFirst() (relayRetryChannel, bool) {
+ if routing == nil || len(routing.excludedOrder) == 0 {
+ return relayRetryChannel{}, false
+ }
+ first := routing.excludedOrder[0]
+ routing.attempts = make(map[int]int)
+ routing.excluded = make(map[int]struct{})
+ routing.excludedOrder = nil
+ routing.next = nil
+ return first, true
+}
+
+func (routing *relayRetryRouting) selectChannel(c *gin.Context, retryParam *service.RetryParam) (*model.Channel, string, error) {
+ if routing == nil {
+ return service.CacheGetRandomSatisfiedChannel(retryParam)
+ }
+ if retryChannel, ok := routing.takeNext(); ok {
+ channel, err := model.CacheGetChannel(retryChannel.id)
+ if err == nil && channel != nil && channel.Status == common.ChannelStatusEnabled {
+ return channel, retryChannel.group, nil
+ }
+ routing.markUnavailable(retryChannel)
+ }
+
+ selectionOptions, hasExcludedChannels := routing.selectionOptions()
+ if !hasExcludedChannels {
+ return service.CacheGetRandomSatisfiedChannel(retryParam)
+ }
+ channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam, selectionOptions)
+ if err != nil || channel != nil {
+ return channel, selectGroup, err
+ }
+
+ retryChannel, ok := routing.restartFromFirst()
+ if !ok {
+ return nil, selectGroup, nil
+ }
+ if retryParam.TokenGroup == "auto" {
+ common.SetContextKey(c, constant.ContextKeyAutoGroupIndex, 0)
+ common.SetContextKey(c, constant.ContextKeyAutoGroupRetryIndex, 0)
+ }
+ channel, err = model.CacheGetChannel(retryChannel.id)
+ if err == nil && channel != nil && channel.Status == common.ChannelStatusEnabled {
+ return channel, retryChannel.group, nil
+ }
+ return service.CacheGetRandomSatisfiedChannel(retryParam)
+}
diff --git a/controller/relay_retry_routing_test.go b/controller/relay_retry_routing_test.go
new file mode 100644
index 000000000000..6de26e89693d
--- /dev/null
+++ b/controller/relay_retry_routing_test.go
@@ -0,0 +1,55 @@
+package controller
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRelayRetryRoutingRetriesOnceThenCyclesChannels(t *testing.T) {
+ routing := newRelayRetryRouting()
+
+ routing.recordFailure(26, "vip")
+ next, ok := routing.takeNext()
+ require.True(t, ok)
+ assert.Equal(t, relayRetryChannel{id: 26, group: "vip"}, next)
+
+ routing.recordFailure(26, "vip")
+ options, ok := routing.selectionOptions()
+ require.True(t, ok)
+ assert.Equal(t, []int{26}, options.ExcludedChannelIds)
+
+ routing.recordFailure(7, "vip")
+ next, ok = routing.takeNext()
+ require.True(t, ok)
+ assert.Equal(t, relayRetryChannel{id: 7, group: "vip"}, next)
+
+ routing.recordFailure(7, "vip")
+ options, ok = routing.selectionOptions()
+ require.True(t, ok)
+ assert.Equal(t, []int{26, 7}, options.ExcludedChannelIds)
+
+ next, ok = routing.restartFromFirst()
+ require.True(t, ok)
+ assert.Equal(t, relayRetryChannel{id: 26, group: "vip"}, next)
+ _, ok = routing.selectionOptions()
+ assert.False(t, ok)
+
+ routing.recordFailure(26, "vip")
+ next, ok = routing.takeNext()
+ require.True(t, ok)
+ assert.Equal(t, relayRetryChannel{id: 26, group: "vip"}, next)
+}
+
+func TestRelayRetryRoutingFallsBackToOnlyChannel(t *testing.T) {
+ routing := newRelayRetryRouting()
+ routing.recordFailure(26, "vip")
+ _, ok := routing.takeNext()
+ require.True(t, ok)
+ routing.recordFailure(26, "vip")
+
+ next, ok := routing.restartFromFirst()
+ require.True(t, ok)
+ assert.Equal(t, relayRetryChannel{id: 26, group: "vip"}, next)
+}
diff --git a/controller/relay_retry_test.go b/controller/relay_retry_test.go
new file mode 100644
index 000000000000..71258ee2f3c2
--- /dev/null
+++ b/controller/relay_retry_test.go
@@ -0,0 +1,158 @@
+package controller
+
+import (
+ "errors"
+ "fmt"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ relayconstant "github.com/QuantumNous/new-api/relay/constant"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/operation_setting"
+ "github.com/QuantumNous/new-api/types"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPrepareNextRelayAttemptScopesDedicatedRetries(t *testing.T) {
+ tests := []struct {
+ name string
+ relayMode int
+ statusCode int
+ message string
+ budget relayRetryBudget
+ want bool
+ wantBudget relayRetryBudget
+ }{
+ {name: "400 upstream failed", relayMode: relayconstant.RelayModeResponses, statusCode: 400, message: "Upstream request failed", budget: relayRetryBudget{retry400UpstreamFailedRemaining: 1, retry503Remaining: 1, retry524Remaining: 1}, want: true, wantBudget: relayRetryBudget{retry503Remaining: 1, retry524Remaining: 1}},
+ {name: "400 upstream failed chat completions", relayMode: relayconstant.RelayModeChatCompletions, statusCode: 400, message: "Upstream request failed", budget: relayRetryBudget{retry400UpstreamFailedRemaining: 1}, want: true},
+ {name: "400 upstream failed disabled", relayMode: relayconstant.RelayModeResponses, statusCode: 400, message: "Upstream request failed", budget: relayRetryBudget{}, want: false},
+ {name: "other 400", relayMode: relayconstant.RelayModeResponses, statusCode: 400, message: "Unsupported parameter: max_output_tokens", budget: relayRetryBudget{retry400UpstreamFailedRemaining: 1}, want: false, wantBudget: relayRetryBudget{retry400UpstreamFailedRemaining: 1}},
+ {name: "400 upstream failed image generation", relayMode: relayconstant.RelayModeImagesGenerations, statusCode: 400, message: "Upstream request failed", budget: relayRetryBudget{retry400UpstreamFailedRemaining: 1}, want: false, wantBudget: relayRetryBudget{retry400UpstreamFailedRemaining: 1}},
+ {name: "503 chat completions", relayMode: relayconstant.RelayModeChatCompletions, statusCode: 503, budget: relayRetryBudget{retry400UpstreamFailedRemaining: 1, retry503Remaining: 1, retry524Remaining: 1}, want: true, wantBudget: relayRetryBudget{retry400UpstreamFailedRemaining: 1, retry524Remaining: 1}},
+ {name: "503 responses", relayMode: relayconstant.RelayModeResponses, statusCode: 503, budget: relayRetryBudget{retry503Remaining: 1}, want: true},
+ {name: "503 disabled", relayMode: relayconstant.RelayModeChatCompletions, statusCode: 503, budget: relayRetryBudget{}, want: false},
+ {name: "524 chat completions", relayMode: relayconstant.RelayModeChatCompletions, statusCode: 524, budget: relayRetryBudget{retry400UpstreamFailedRemaining: 1, retry503Remaining: 1, retry524Remaining: 1}, want: true, wantBudget: relayRetryBudget{retry400UpstreamFailedRemaining: 1, retry503Remaining: 1}},
+ {name: "524 responses", relayMode: relayconstant.RelayModeResponses, statusCode: 524, budget: relayRetryBudget{retry524Remaining: 1}, want: true},
+ {name: "524 disabled", relayMode: relayconstant.RelayModeChatCompletions, statusCode: 524, budget: relayRetryBudget{}, want: false},
+ {name: "image generation", relayMode: relayconstant.RelayModeImagesGenerations, statusCode: 503, budget: relayRetryBudget{retry503Remaining: 1}, want: false, wantBudget: relayRetryBudget{retry503Remaining: 1}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Set("specific_channel_id", "2")
+ retry := 0
+ retryParam := &service.RetryParam{Retry: &retry}
+ message := tt.message
+ if message == "" {
+ message = "upstream unavailable"
+ }
+ apiErr := types.NewOpenAIError(errors.New(message), types.ErrorCodeBadResponseStatusCode, tt.statusCode)
+
+ require.Equal(t, tt.want, prepareNextRelayAttempt(c, tt.relayMode, apiErr, retryParam, &tt.budget))
+ require.Equal(t, tt.wantBudget, tt.budget)
+ })
+ }
+}
+
+func TestPrepareNextRelayAttemptClearsPendingAutoGroupResetForDedicatedRetries(t *testing.T) {
+ tests := []struct {
+ statusCode int
+ message string
+ }{
+ {statusCode: 400, message: "Upstream request failed"},
+ {statusCode: 503, message: "upstream unavailable"},
+ {statusCode: 524, message: "upstream timeout"},
+ }
+ for _, tt := range tests {
+ t.Run(fmt.Sprintf("status %d", tt.statusCode), func(t *testing.T) {
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ retry := 2
+ retryParam := &service.RetryParam{Retry: &retry}
+ retryParam.ResetRetryNextTry()
+ budget := relayRetryBudget{retry400UpstreamFailedRemaining: 1, retry503Remaining: 1, retry524Remaining: 1}
+ apiErr := types.NewOpenAIError(errors.New(tt.message), types.ErrorCodeBadResponseStatusCode, tt.statusCode)
+
+ require.True(t, prepareNextRelayAttempt(c, relayconstant.RelayModeResponses, apiErr, retryParam, &budget))
+ require.Equal(t, 2, retryParam.GetRetry())
+
+ retryParam.IncreaseRetry()
+ require.Equal(t, 3, retryParam.GetRetry())
+ })
+ }
+}
+
+func TestPrepareNextRelayAttemptFallsBackToConfiguredSystemRetry(t *testing.T) {
+ originalRetryTimes := common.RetryTimes
+ originalRanges := operation_setting.AutomaticRetryStatusCodeRanges
+ t.Cleanup(func() {
+ common.RetryTimes = originalRetryTimes
+ operation_setting.AutomaticRetryStatusCodeRanges = originalRanges
+ })
+ common.RetryTimes = 2
+ operation_setting.AutomaticRetryStatusCodeRanges = []operation_setting.StatusCodeRange{
+ {Start: 400, End: 400},
+ {Start: 503, End: 503},
+ {Start: 524, End: 524},
+ }
+
+ tests := []struct {
+ name string
+ statusCode int
+ message string
+ }{
+ {name: "400 upstream failed", statusCode: 400, message: "Upstream request failed"},
+ {name: "503", statusCode: 503, message: "upstream unavailable"},
+ {name: "524", statusCode: 524, message: "upstream timeout"},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ retry := 0
+ retryParam := &service.RetryParam{Retry: &retry}
+ budget := relayRetryBudget{
+ retry400UpstreamFailedRemaining: 1,
+ retry503Remaining: 1,
+ retry524Remaining: 1,
+ }
+ apiErr := types.NewOpenAIError(errors.New(test.message), types.ErrorCodeBadResponseStatusCode, test.statusCode)
+
+ require.True(t, prepareNextRelayAttempt(c, relayconstant.RelayModeResponses, apiErr, retryParam, &budget))
+ require.Zero(t, retryParam.GetRetry())
+ require.True(t, prepareNextRelayAttempt(c, relayconstant.RelayModeResponses, apiErr, retryParam, &budget))
+ require.Equal(t, 1, retryParam.GetRetry())
+ })
+ }
+}
+
+func TestPrepareNextRelayAttemptStopsWhenSystemRetryDoesNotIncludeStatus(t *testing.T) {
+ originalRetryTimes := common.RetryTimes
+ originalRanges := operation_setting.AutomaticRetryStatusCodeRanges
+ t.Cleanup(func() {
+ common.RetryTimes = originalRetryTimes
+ operation_setting.AutomaticRetryStatusCodeRanges = originalRanges
+ })
+ common.RetryTimes = 2
+ operation_setting.AutomaticRetryStatusCodeRanges = nil
+
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ retry := 0
+ retryParam := &service.RetryParam{Retry: &retry}
+ budget := relayRetryBudget{}
+ apiErr := types.NewOpenAIError(errors.New("upstream unavailable"), types.ErrorCodeBadResponseStatusCode, 503)
+
+ require.False(t, prepareNextRelayAttempt(c, relayconstant.RelayModeResponses, apiErr, retryParam, &budget))
+ require.Zero(t, retryParam.GetRetry())
+}
+
+func TestShouldRetry502StillUsesDefaultBudget(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ apiErr := types.NewOpenAIError(errors.New("bad gateway"), types.ErrorCodeBadResponseStatusCode, 502)
+
+ require.True(t, shouldRetry(c, apiErr, 1))
+ require.False(t, shouldRetry(c, apiErr, 0))
+}
diff --git a/controller/system_task_handlers.go b/controller/system_task_handlers.go
index c31059d148da..10e36f98ecc1 100644
--- a/controller/system_task_handlers.go
+++ b/controller/system_task_handlers.go
@@ -13,13 +13,14 @@ import (
)
// RegisterScheduledSystemTasks wires the periodic channel test, upstream model
-// update, and async task polling (Midjourney / Suno / video) jobs into the
+// and ratio updates, and async task polling (Midjourney / Suno / video) into the
// system task framework so a DB lease dedups execution across multiple master
// instances and each run is recorded as one task row. Call this before
// service.StartSystemTaskRunner.
func RegisterScheduledSystemTasks() {
service.RegisterSystemTaskHandler(channelTestHandler{})
service.RegisterSystemTaskHandler(modelUpdateHandler{})
+ service.RegisterSystemTaskHandler(channelRatioMonitorTaskHandler{})
service.RegisterSystemTaskHandler(midjourneyPollHandler{})
service.RegisterSystemTaskHandler(asyncTaskPollHandler{})
}
diff --git a/model/ability.go b/model/ability.go
index e67b28301e02..aaaaa7c473de 100644
--- a/model/ability.go
+++ b/model/ability.go
@@ -60,14 +60,14 @@ func GetAllEnableAbilities() []Ability {
return abilities
}
-func getPriority(group string, model string, retry int) (int, error) {
+func getPriority(group string, model string, retry int, options ChannelSelectionOptions) (int, error) {
var priorities []int
- err := DB.Model(&Ability{}).
+ priorityQuery := DB.Model(&Ability{}).
Select("DISTINCT(priority)").
- Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true).
- Order("priority DESC"). // 按优先级降序排序
- Pluck("priority", &priorities).Error // Pluck用于将查询的结果直接扫描到一个切片中
+ Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true)
+ priorityQuery = applyChannelSelectionOptions(priorityQuery, options)
+ err := priorityQuery.Order("priority DESC").Pluck("priority", &priorities).Error
if err != nil {
// 处理错误
@@ -90,26 +90,39 @@ func getPriority(group string, model string, retry int) (int, error) {
return priorityToUse, nil
}
-func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
- maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true)
- channelQuery := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = (?)", group, model, true, maxPrioritySubQuery)
+func getChannelQuery(group string, model string, retry int, options ChannelSelectionOptions) (*gorm.DB, error) {
+ maxPrioritySubQuery := applyChannelSelectionOptions(
+ DB.Model(&Ability{}).Select("MAX(priority)").Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true),
+ options,
+ )
+ channelQuery := applyChannelSelectionOptions(
+ DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = (?)", group, model, true, maxPrioritySubQuery),
+ options,
+ )
if retry != 0 {
- priority, err := getPriority(group, model, retry)
+ priority, err := getPriority(group, model, retry, options)
if err != nil {
return nil, err
} else {
- channelQuery = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = ?", group, model, true, priority)
+ channelQuery = applyChannelSelectionOptions(
+ DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = ?", group, model, true, priority),
+ options,
+ )
}
}
return channelQuery, nil
}
-func GetChannel(group string, model string, retry int, requestPath string) (*Channel, error) {
+func GetChannel(group string, model string, retry int, requestPath string, options ...ChannelSelectionOptions) (*Channel, error) {
var abilities []Ability
var err error = nil
- channelQuery, err := getChannelQuery(group, model, retry)
+ selectionOptions := channelSelectionOptions(options)
+ if selectionOptions.HasExcludedChannels() {
+ retry = 0
+ }
+ channelQuery, err := getChannelQuery(group, model, retry, selectionOptions)
if err != nil {
return nil, err
}
diff --git a/model/channel_cache.go b/model/channel_cache.go
index 81923017d79c..7c2e53c5b4a0 100644
--- a/model/channel_cache.go
+++ b/model/channel_cache.go
@@ -111,10 +111,14 @@ func SyncChannelCache(frequency int) {
}
}
-func GetRandomSatisfiedChannel(group string, model string, retry int, requestPath string) (*Channel, error) {
+func GetRandomSatisfiedChannel(group string, model string, retry int, requestPath string, options ...ChannelSelectionOptions) (*Channel, error) {
+ selectionOptions := channelSelectionOptions(options)
+ if selectionOptions.HasExcludedChannels() {
+ retry = 0
+ }
// if memory cache is disabled, get channel directly from database
if !common.MemoryCacheEnabled {
- return GetChannel(group, model, retry, requestPath)
+ return GetChannel(group, model, retry, requestPath, selectionOptions)
}
channelSyncLock.RLock()
@@ -122,11 +126,13 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat
// First, try to find channels with the exact model name.
channels := filterChannelsByRequestPathAndModel(group2model2channels[group][model], requestPath, model)
+ channels = filterChannelIDsBySelectionOptions(channels, selectionOptions)
// If no channels found, try to find channels with the normalized model name.
if len(channels) == 0 {
normalizedModel := ratio_setting.FormatMatchingModelName(model)
channels = filterChannelsByRequestPathAndModel(group2model2channels[group][normalizedModel], requestPath, model)
+ channels = filterChannelIDsBySelectionOptions(channels, selectionOptions)
}
if len(channels) == 0 {
diff --git a/model/channel_monitor_cost.go b/model/channel_monitor_cost.go
new file mode 100644
index 000000000000..a8a786098b60
--- /dev/null
+++ b/model/channel_monitor_cost.go
@@ -0,0 +1,87 @@
+package model
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/QuantumNous/new-api/common"
+)
+
+const (
+ channelMonitorCostDaySeconds int64 = 24 * 60 * 60
+ channelMonitorCostTimezoneOffsetSeconds int64 = 8 * 60 * 60
+)
+
+// ChannelMonitorDailyQuota is the net logged quota for one channel/group/day.
+// Consume logs increase it and refund logs decrease it.
+type ChannelMonitorDailyQuota struct {
+ DayStart int64
+ ChannelId int
+ Group string
+ Quota int64
+}
+
+type channelMonitorDailyQuotaRow struct {
+ DayBucket int64 `gorm:"column:day_bucket"`
+ ChannelId int `gorm:"column:channel_id"`
+ Group string `gorm:"column:group_name"`
+ Quota int64 `gorm:"column:quota"`
+}
+
+// GetChannelMonitorDailyQuotas aggregates consume/refund logs by Beijing day.
+// The caller converts quota into an upstream cost because that conversion is
+// channel-monitor configuration, not an intrinsic property of a log row.
+func GetChannelMonitorDailyQuotas(ctx context.Context, startTimestamp int64, endTimestamp int64) ([]ChannelMonitorDailyQuota, error) {
+ if startTimestamp >= endTimestamp {
+ return []ChannelMonitorDailyQuota{}, nil
+ }
+
+ dayBucket := channelMonitorCostDayBucketSQL()
+ groupColumn := channelMonitorLogGroupColumn()
+ selectColumns := fmt.Sprintf(
+ "%s AS day_bucket, channel_id, %s AS group_name, "+
+ "SUM(CASE WHEN type = %d THEN quota WHEN type = %d THEN -quota ELSE 0 END) AS quota",
+ dayBucket,
+ groupColumn,
+ LogTypeConsume,
+ LogTypeRefund,
+ )
+ groupColumns := dayBucket + ", channel_id, " + groupColumn
+
+ var rows []channelMonitorDailyQuotaRow
+ err := LOG_DB.WithContext(ctx).
+ Model(&Log{}).
+ Select(selectColumns).
+ Where("channel_id > ?", 0).
+ Where("type IN ?", []int{LogTypeConsume, LogTypeRefund}).
+ Where("created_at >= ? AND created_at < ?", startTimestamp, endTimestamp).
+ Group(groupColumns).
+ Scan(&rows).Error
+ if err != nil {
+ return nil, err
+ }
+
+ items := make([]ChannelMonitorDailyQuota, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, ChannelMonitorDailyQuota{
+ DayStart: row.DayBucket*channelMonitorCostDaySeconds - channelMonitorCostTimezoneOffsetSeconds,
+ ChannelId: row.ChannelId,
+ Group: row.Group,
+ Quota: row.Quota,
+ })
+ }
+ return items, nil
+}
+
+func channelMonitorCostDayBucketSQL() string {
+ const offset = channelMonitorCostTimezoneOffsetSeconds
+ switch {
+ case common.UsingLogDatabase(common.DatabaseTypeMySQL):
+ return fmt.Sprintf("FLOOR((created_at + %d) / %d)", offset, channelMonitorCostDaySeconds)
+ case common.UsingLogDatabase(common.DatabaseTypeClickHouse):
+ return fmt.Sprintf("intDiv(created_at + %d, %d)", offset, channelMonitorCostDaySeconds)
+ default:
+ // SQLite and PostgreSQL both use integer division when both operands are integers.
+ return fmt.Sprintf("(created_at + %d) / %d", offset, channelMonitorCostDaySeconds)
+ }
+}
diff --git a/model/channel_monitor_group_membership.go b/model/channel_monitor_group_membership.go
new file mode 100644
index 000000000000..2e70bd8106a6
--- /dev/null
+++ b/model/channel_monitor_group_membership.go
@@ -0,0 +1,246 @@
+package model
+
+import (
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+ "unicode/utf8"
+
+ "gorm.io/gorm"
+)
+
+var (
+ ErrChannelMonitorGroupInvalid = errors.New("分组名称无效")
+ ErrChannelMonitorGroupChannelInvalid = errors.New("渠道 ID 必须为正整数")
+ ErrChannelMonitorGroupChannelNotFound = errors.New("渠道不存在")
+ ErrChannelMonitorGroupMembershipRequired = errors.New("渠道必须至少属于一个分组")
+ ErrChannelMonitorGroupMembershipListTooLong = errors.New("关联分组名称合计不能超过 64 个字符")
+)
+
+type ChannelMonitorGroupMembershipUpdate struct {
+ Group string `json:"group"`
+ ChannelIds []int `json:"channel_ids"`
+ AddedChannelIds []int `json:"added_channel_ids"`
+ RemovedChannelIds []int `json:"removed_channel_ids"`
+}
+
+type ChannelMonitorGroupMembershipRemoval struct {
+ ChannelId int `json:"channel_id"`
+ Group string `json:"group"`
+}
+
+func ReplaceChannelMonitorGroupMembers(group string, channelIds []int) (ChannelMonitorGroupMembershipUpdate, error) {
+ group = strings.TrimSpace(group)
+ result := ChannelMonitorGroupMembershipUpdate{Group: group}
+ if group == "" || utf8.RuneCountInString(group) > 64 || strings.ContainsAny(group, ",\r\n") {
+ return result, ErrChannelMonitorGroupInvalid
+ }
+
+ selectedChannelIds := make(map[int]struct{}, len(channelIds))
+ for _, channelId := range channelIds {
+ if channelId <= 0 {
+ return result, ErrChannelMonitorGroupChannelInvalid
+ }
+ selectedChannelIds[channelId] = struct{}{}
+ }
+ result.ChannelIds = make([]int, 0, len(selectedChannelIds))
+ for channelId := range selectedChannelIds {
+ result.ChannelIds = append(result.ChannelIds, channelId)
+ }
+ sort.Ints(result.ChannelIds)
+
+ err := DB.Transaction(func(tx *gorm.DB) error {
+ var channels []Channel
+ if err := lockForUpdate(tx).Order("id ASC").Find(&channels).Error; err != nil {
+ return err
+ }
+
+ knownChannelIds := make(map[int]struct{}, len(channels))
+ for i := range channels {
+ knownChannelIds[channels[i].Id] = struct{}{}
+ }
+ for _, channelId := range result.ChannelIds {
+ if _, exists := knownChannelIds[channelId]; !exists {
+ return fmt.Errorf("%w(ID %d)", ErrChannelMonitorGroupChannelNotFound, channelId)
+ }
+ }
+
+ for i := range channels {
+ channel := &channels[i]
+ groups := make([]string, 0)
+ seenGroups := make(map[string]struct{})
+ hasTargetGroup := false
+ for _, existingGroup := range strings.Split(channel.Group, ",") {
+ existingGroup = strings.TrimSpace(existingGroup)
+ if existingGroup == "" {
+ continue
+ }
+ if _, exists := seenGroups[existingGroup]; exists {
+ continue
+ }
+ seenGroups[existingGroup] = struct{}{}
+ groups = append(groups, existingGroup)
+ if existingGroup == group {
+ hasTargetGroup = true
+ }
+ }
+
+ _, shouldHaveTargetGroup := selectedChannelIds[channel.Id]
+ if hasTargetGroup == shouldHaveTargetGroup {
+ continue
+ }
+
+ if shouldHaveTargetGroup {
+ groups = append(groups, group)
+ } else {
+ remainingGroups := groups[:0]
+ for _, existingGroup := range groups {
+ if existingGroup != group {
+ remainingGroups = append(remainingGroups, existingGroup)
+ }
+ }
+ groups = remainingGroups
+ if len(groups) == 0 {
+ return fmt.Errorf(
+ "无法从分组 %s 移除渠道 %s(ID %d),%w",
+ group,
+ channel.Name,
+ channel.Id,
+ ErrChannelMonitorGroupMembershipRequired,
+ )
+ }
+ }
+
+ serializedGroups := strings.Join(groups, ",")
+ if utf8.RuneCountInString(serializedGroups) > 64 {
+ return fmt.Errorf(
+ "渠道 %s(ID %d)的%w",
+ channel.Name,
+ channel.Id,
+ ErrChannelMonitorGroupMembershipListTooLong,
+ )
+ }
+ if err := tx.Model(&Channel{}).Where("id = ?", channel.Id).Update("group", serializedGroups).Error; err != nil {
+ return err
+ }
+ channel.Group = serializedGroups
+ if err := channel.UpdateAbilities(tx); err != nil {
+ return err
+ }
+
+ if shouldHaveTargetGroup {
+ result.AddedChannelIds = append(result.AddedChannelIds, channel.Id)
+ } else {
+ result.RemovedChannelIds = append(result.RemovedChannelIds, channel.Id)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ result.AddedChannelIds = nil
+ result.RemovedChannelIds = nil
+ return result, err
+ }
+ return result, nil
+}
+
+func RemoveChannelMonitorGroupMemberships(removals []ChannelMonitorGroupMembershipRemoval) ([]ChannelMonitorGroupMembershipRemoval, error) {
+ requestedByChannel := make(map[int]map[string]struct{})
+ channelIds := make([]int, 0)
+ for _, removal := range removals {
+ removal.Group = strings.TrimSpace(removal.Group)
+ if removal.ChannelId <= 0 {
+ return nil, ErrChannelMonitorGroupChannelInvalid
+ }
+ if removal.Group == "" || utf8.RuneCountInString(removal.Group) > 64 || strings.ContainsAny(removal.Group, ",\r\n") {
+ return nil, ErrChannelMonitorGroupInvalid
+ }
+ groups, exists := requestedByChannel[removal.ChannelId]
+ if !exists {
+ groups = make(map[string]struct{})
+ requestedByChannel[removal.ChannelId] = groups
+ channelIds = append(channelIds, removal.ChannelId)
+ }
+ groups[removal.Group] = struct{}{}
+ }
+ if len(channelIds) == 0 {
+ return nil, nil
+ }
+ sort.Ints(channelIds)
+
+ applied := make([]ChannelMonitorGroupMembershipRemoval, 0, len(removals))
+ err := DB.Transaction(func(tx *gorm.DB) error {
+ var channels []Channel
+ if err := lockForUpdate(tx).Where("id IN ?", channelIds).Order("id ASC").Find(&channels).Error; err != nil {
+ return err
+ }
+ if len(channels) != len(channelIds) {
+ knownChannelIds := make(map[int]struct{}, len(channels))
+ for i := range channels {
+ knownChannelIds[channels[i].Id] = struct{}{}
+ }
+ for _, channelId := range channelIds {
+ if _, exists := knownChannelIds[channelId]; !exists {
+ return fmt.Errorf("%w(ID %d)", ErrChannelMonitorGroupChannelNotFound, channelId)
+ }
+ }
+ }
+
+ for i := range channels {
+ channel := &channels[i]
+ requestedGroups := requestedByChannel[channel.Id]
+ groups := make([]string, 0)
+ seenGroups := make(map[string]struct{})
+ removedGroups := make([]string, 0, len(requestedGroups))
+ for _, existingGroup := range strings.Split(channel.Group, ",") {
+ existingGroup = strings.TrimSpace(existingGroup)
+ if existingGroup == "" {
+ continue
+ }
+ if _, exists := seenGroups[existingGroup]; exists {
+ continue
+ }
+ seenGroups[existingGroup] = struct{}{}
+ if _, shouldRemove := requestedGroups[existingGroup]; shouldRemove {
+ removedGroups = append(removedGroups, existingGroup)
+ continue
+ }
+ groups = append(groups, existingGroup)
+ }
+ if len(removedGroups) == 0 {
+ continue
+ }
+ if len(groups) == 0 {
+ return fmt.Errorf(
+ "无法移除渠道 %s(ID %d)的分组关联,%w",
+ channel.Name,
+ channel.Id,
+ ErrChannelMonitorGroupMembershipRequired,
+ )
+ }
+
+ channel.Group = strings.Join(groups, ",")
+ if err := tx.Model(&Channel{}).Where("id = ?", channel.Id).Update("group", channel.Group).Error; err != nil {
+ return err
+ }
+ if err := channel.UpdateAbilities(tx); err != nil {
+ return err
+ }
+ for _, group := range removedGroups {
+ applied = append(applied, ChannelMonitorGroupMembershipRemoval{ChannelId: channel.Id, Group: group})
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ sort.Slice(applied, func(i, j int) bool {
+ if applied[i].ChannelId != applied[j].ChannelId {
+ return applied[i].ChannelId < applied[j].ChannelId
+ }
+ return applied[i].Group < applied[j].Group
+ })
+ return applied, nil
+}
diff --git a/model/channel_monitor_group_membership_test.go b/model/channel_monitor_group_membership_test.go
new file mode 100644
index 000000000000..1dc4ffb55218
--- /dev/null
+++ b/model/channel_monitor_group_membership_test.go
@@ -0,0 +1,167 @@
+package model
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func resetChannelMonitorGroupMembershipTables(t *testing.T) {
+ t.Helper()
+ require.NoError(t, DB.AutoMigrate(&Channel{}, &Ability{}))
+ for _, value := range []interface{}{&Ability{}, &Channel{}} {
+ require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(value).Error)
+ }
+ t.Cleanup(func() {
+ for _, value := range []interface{}{&Ability{}, &Channel{}} {
+ require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(value).Error)
+ }
+ })
+}
+
+func TestReplaceChannelMonitorGroupMembersUpdatesChannelsAndAbilities(t *testing.T) {
+ resetChannelMonitorGroupMembershipTables(t)
+
+ channels := []Channel{
+ {Id: 101, Name: "add-member", Key: "secret", Status: common.ChannelStatusEnabled, Group: "default", Models: "model-a"},
+ {Id: 102, Name: "remove-member", Key: "secret", Status: common.ChannelStatusEnabled, Group: "vip,backup", Models: "model-b"},
+ {Id: 103, Name: "keep-member", Key: "secret", Status: common.ChannelStatusEnabled, Group: "vip", Models: "model-c"},
+ }
+ require.NoError(t, DB.Create(&channels).Error)
+ for i := range channels {
+ require.NoError(t, channels[i].AddAbilities(nil))
+ }
+
+ result, err := ReplaceChannelMonitorGroupMembers("vip", []int{103, 101, 101})
+ require.NoError(t, err)
+ assert.Equal(t, []int{101, 103}, result.ChannelIds)
+ assert.Equal(t, []int{101}, result.AddedChannelIds)
+ assert.Equal(t, []int{102}, result.RemovedChannelIds)
+
+ var storedChannels []Channel
+ require.NoError(t, DB.Order("id ASC").Find(&storedChannels).Error)
+ require.Len(t, storedChannels, 3)
+ assert.Equal(t, "default,vip", storedChannels[0].Group)
+ assert.Equal(t, "backup", storedChannels[1].Group)
+ assert.Equal(t, "vip", storedChannels[2].Group)
+
+ var addedAbilities []Ability
+ require.NoError(t, DB.Where("channel_id = ?", 101).Order(commonGroupCol+" ASC").Find(&addedAbilities).Error)
+ require.Len(t, addedAbilities, 2)
+ assert.Equal(t, "default", addedAbilities[0].Group)
+ assert.Equal(t, "vip", addedAbilities[1].Group)
+
+ var removedAbilities []Ability
+ require.NoError(t, DB.Where("channel_id = ?", 102).Find(&removedAbilities).Error)
+ require.Len(t, removedAbilities, 1)
+ assert.Equal(t, "backup", removedAbilities[0].Group)
+}
+
+func TestReplaceChannelMonitorGroupMembersRollsBackWhenRemovalWouldLeaveNoGroup(t *testing.T) {
+ resetChannelMonitorGroupMembershipTables(t)
+
+ channels := []Channel{
+ {Id: 201, Name: "would-add", Key: "secret", Status: common.ChannelStatusEnabled, Group: "default", Models: "model-a"},
+ {Id: 202, Name: "only-vip", Key: "secret", Status: common.ChannelStatusEnabled, Group: "vip", Models: "model-b"},
+ }
+ require.NoError(t, DB.Create(&channels).Error)
+ for i := range channels {
+ require.NoError(t, channels[i].AddAbilities(nil))
+ }
+
+ _, err := ReplaceChannelMonitorGroupMembers("vip", []int{201})
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrChannelMonitorGroupMembershipRequired))
+
+ var storedChannels []Channel
+ require.NoError(t, DB.Order("id ASC").Find(&storedChannels).Error)
+ require.Len(t, storedChannels, 2)
+ assert.Equal(t, "default", storedChannels[0].Group)
+ assert.Equal(t, "vip", storedChannels[1].Group)
+
+ var abilities []Ability
+ require.NoError(t, DB.Order("channel_id ASC").Find(&abilities).Error)
+ require.Len(t, abilities, 2)
+ assert.Equal(t, 201, abilities[0].ChannelId)
+ assert.Equal(t, "default", abilities[0].Group)
+ assert.Equal(t, 202, abilities[1].ChannelId)
+ assert.Equal(t, "vip", abilities[1].Group)
+}
+
+func TestRemoveChannelMonitorGroupMembershipsUpdatesChannelsAndAbilities(t *testing.T) {
+ resetChannelMonitorGroupMembershipTables(t)
+
+ channels := []Channel{
+ {Id: 301, Name: "multi-group", Key: "secret", Status: common.ChannelStatusEnabled, Group: "vip,backup,team", Models: "model-a"},
+ {Id: 302, Name: "second", Key: "secret", Status: common.ChannelStatusEnabled, Group: "vip,backup", Models: "model-b"},
+ }
+ require.NoError(t, DB.Create(&channels).Error)
+ for i := range channels {
+ require.NoError(t, channels[i].AddAbilities(nil))
+ }
+
+ applied, err := RemoveChannelMonitorGroupMemberships([]ChannelMonitorGroupMembershipRemoval{
+ {ChannelId: 302, Group: "vip"},
+ {ChannelId: 301, Group: "team"},
+ {ChannelId: 301, Group: "vip"},
+ {ChannelId: 301, Group: "vip"},
+ })
+ require.NoError(t, err)
+ assert.Equal(t, []ChannelMonitorGroupMembershipRemoval{
+ {ChannelId: 301, Group: "team"},
+ {ChannelId: 301, Group: "vip"},
+ {ChannelId: 302, Group: "vip"},
+ }, applied)
+
+ var storedChannels []Channel
+ require.NoError(t, DB.Order("id ASC").Find(&storedChannels).Error)
+ require.Len(t, storedChannels, 2)
+ assert.Equal(t, "backup", storedChannels[0].Group)
+ assert.Equal(t, "backup", storedChannels[1].Group)
+
+ var abilities []Ability
+ require.NoError(t, DB.Order("channel_id ASC").Find(&abilities).Error)
+ require.Len(t, abilities, 2)
+ assert.Equal(t, "backup", abilities[0].Group)
+ assert.Equal(t, "backup", abilities[1].Group)
+}
+
+func TestRemoveChannelMonitorGroupMembershipsRollsBackWhenRemovalWouldLeaveNoGroup(t *testing.T) {
+ resetChannelMonitorGroupMembershipTables(t)
+
+ channels := []Channel{
+ {Id: 401, Name: "safe-first", Key: "secret", Status: common.ChannelStatusEnabled, Group: "vip,backup", Models: "model-a"},
+ {Id: 402, Name: "only-vip", Key: "secret", Status: common.ChannelStatusEnabled, Group: "vip", Models: "model-b"},
+ }
+ require.NoError(t, DB.Create(&channels).Error)
+ for i := range channels {
+ require.NoError(t, channels[i].AddAbilities(nil))
+ }
+
+ _, err := RemoveChannelMonitorGroupMemberships([]ChannelMonitorGroupMembershipRemoval{
+ {ChannelId: 401, Group: "vip"},
+ {ChannelId: 402, Group: "vip"},
+ })
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrChannelMonitorGroupMembershipRequired))
+
+ var storedChannels []Channel
+ require.NoError(t, DB.Order("id ASC").Find(&storedChannels).Error)
+ require.Len(t, storedChannels, 2)
+ assert.Equal(t, "vip,backup", storedChannels[0].Group)
+ assert.Equal(t, "vip", storedChannels[1].Group)
+
+ var abilities []Ability
+ require.NoError(t, DB.Order("channel_id ASC").Order(commonGroupCol+" ASC").Find(&abilities).Error)
+ require.Len(t, abilities, 3)
+ assert.Equal(t, 401, abilities[0].ChannelId)
+ assert.Equal(t, "backup", abilities[0].Group)
+ assert.Equal(t, 401, abilities[1].ChannelId)
+ assert.Equal(t, "vip", abilities[1].Group)
+ assert.Equal(t, 402, abilities[2].ChannelId)
+ assert.Equal(t, "vip", abilities[2].Group)
+}
diff --git a/model/channel_monitor_performance.go b/model/channel_monitor_performance.go
new file mode 100644
index 000000000000..3591e92b65e3
--- /dev/null
+++ b/model/channel_monitor_performance.go
@@ -0,0 +1,211 @@
+package model
+
+import (
+ "context"
+ "math"
+ "sort"
+
+ "github.com/QuantumNous/new-api/common"
+)
+
+type ChannelMonitorPerformanceMetric struct {
+ ChannelId int `json:"channel_id"`
+ ModelName string `json:"model_name"`
+ SampleCount int `json:"sample_count"`
+ FirstTokenSampleCount int `json:"first_token_sample_count"`
+ TPSSampleCount int `json:"tps_sample_count"`
+ AverageFirstTokenMs *float64 `json:"average_first_token_ms"`
+ AverageTPS *float64 `json:"average_tps"`
+ LatestFirstTokenMs *float64 `json:"latest_first_token_ms"`
+ LatestTPS *float64 `json:"latest_tps"`
+ LastUsedTime int64 `json:"last_used_time"`
+}
+
+type ChannelMonitorStabilityMetric struct {
+ ChannelId int `json:"channel_id"`
+ ModelName string `json:"model_name"`
+ SuccessCount int64 `json:"success_count"`
+ FailureCount int64 `json:"failure_count"`
+ SampleCount int64 `json:"sample_count"`
+ SuccessRate float64 `json:"success_rate"`
+}
+
+type channelMonitorPerformanceLog struct {
+ ChannelId int
+ ModelName string
+ CompletionTokens int
+ UseTime int
+ Other string
+ CreatedAt int64
+}
+
+type channelMonitorPerformanceLogOther struct {
+ FirstResponseTime *float64 `json:"frt"`
+}
+
+type channelMonitorPerformanceAggregate struct {
+ channelId int
+ modelName string
+ sampleCount int
+ firstTokenSampleCount int
+ tpsSampleCount int
+ firstTokenTotalMs float64
+ tpsTotal float64
+ latestFirstTokenMs float64
+ latestTPS float64
+ hasLatestFirstToken bool
+ hasLatestTPS bool
+ lastUsedTime int64
+}
+
+// GetChannelMonitorPerformanceMetrics aggregates the same per-request timing
+// values shown by usage logs: other.frt and completion_tokens / use_time.
+func GetChannelMonitorPerformanceMetrics(ctx context.Context, startTimestamp int64) ([]ChannelMonitorPerformanceMetric, error) {
+ rows, err := LOG_DB.WithContext(ctx).
+ Model(&Log{}).
+ Select("channel_id, model_name, completion_tokens, use_time, other, created_at").
+ Where("type = ?", LogTypeConsume).
+ Where("is_stream = ?", true).
+ Where("channel_id > ?", 0).
+ Where("model_name <> ?", "").
+ Where("created_at >= ?", startTimestamp).
+ Rows()
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ type performanceKey struct {
+ channelId int
+ modelName string
+ }
+ aggregates := make(map[performanceKey]*channelMonitorPerformanceAggregate)
+ for rows.Next() {
+ var log channelMonitorPerformanceLog
+ if err := rows.Scan(
+ &log.ChannelId,
+ &log.ModelName,
+ &log.CompletionTokens,
+ &log.UseTime,
+ &log.Other,
+ &log.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+
+ var firstTokenMs *float64
+ if log.Other != "" {
+ var other channelMonitorPerformanceLogOther
+ if err := common.UnmarshalJsonStr(log.Other, &other); err == nil &&
+ other.FirstResponseTime != nil &&
+ *other.FirstResponseTime > 0 &&
+ !math.IsNaN(*other.FirstResponseTime) &&
+ !math.IsInf(*other.FirstResponseTime, 0) {
+ firstTokenMs = other.FirstResponseTime
+ }
+ }
+
+ var tps *float64
+ if log.UseTime > 0 && log.CompletionTokens > 0 {
+ value := float64(log.CompletionTokens) / float64(log.UseTime)
+ if !math.IsNaN(value) && !math.IsInf(value, 0) {
+ tps = &value
+ }
+ }
+ if firstTokenMs == nil && tps == nil {
+ continue
+ }
+
+ key := performanceKey{channelId: log.ChannelId, modelName: log.ModelName}
+ aggregate, exists := aggregates[key]
+ if !exists {
+ aggregate = &channelMonitorPerformanceAggregate{
+ channelId: log.ChannelId,
+ modelName: log.ModelName,
+ }
+ aggregates[key] = aggregate
+ }
+ aggregate.sampleCount++
+ if firstTokenMs != nil {
+ aggregate.firstTokenSampleCount++
+ aggregate.firstTokenTotalMs += *firstTokenMs
+ }
+ if tps != nil {
+ aggregate.tpsSampleCount++
+ aggregate.tpsTotal += *tps
+ }
+ if log.CreatedAt >= aggregate.lastUsedTime {
+ aggregate.lastUsedTime = log.CreatedAt
+ aggregate.hasLatestFirstToken = firstTokenMs != nil
+ aggregate.hasLatestTPS = tps != nil
+ if firstTokenMs != nil {
+ aggregate.latestFirstTokenMs = *firstTokenMs
+ }
+ if tps != nil {
+ aggregate.latestTPS = *tps
+ }
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ metrics := make([]ChannelMonitorPerformanceMetric, 0, len(aggregates))
+ for _, aggregate := range aggregates {
+ metric := ChannelMonitorPerformanceMetric{
+ ChannelId: aggregate.channelId,
+ ModelName: aggregate.modelName,
+ SampleCount: aggregate.sampleCount,
+ FirstTokenSampleCount: aggregate.firstTokenSampleCount,
+ TPSSampleCount: aggregate.tpsSampleCount,
+ LastUsedTime: aggregate.lastUsedTime,
+ }
+ if aggregate.firstTokenSampleCount > 0 {
+ value := aggregate.firstTokenTotalMs / float64(aggregate.firstTokenSampleCount)
+ metric.AverageFirstTokenMs = &value
+ }
+ if aggregate.tpsSampleCount > 0 {
+ value := aggregate.tpsTotal / float64(aggregate.tpsSampleCount)
+ metric.AverageTPS = &value
+ }
+ if aggregate.hasLatestFirstToken {
+ value := aggregate.latestFirstTokenMs
+ metric.LatestFirstTokenMs = &value
+ }
+ if aggregate.hasLatestTPS {
+ value := aggregate.latestTPS
+ metric.LatestTPS = &value
+ }
+ metrics = append(metrics, metric)
+ }
+ sort.Slice(metrics, func(i int, j int) bool {
+ if metrics[i].ModelName == metrics[j].ModelName {
+ return metrics[i].ChannelId < metrics[j].ChannelId
+ }
+ return metrics[i].ModelName < metrics[j].ModelName
+ })
+ return metrics, nil
+}
+
+// GetChannelMonitorStabilityMetrics measures upstream attempt stability from
+// the shared channel-monitor success aggregation. Retry-attempt errors are
+// included so a channel failure is still counted when a later fallback channel
+// succeeds.
+func GetChannelMonitorStabilityMetrics(ctx context.Context, startTimestamp int64) ([]ChannelMonitorStabilityMetric, error) {
+ channelMetrics, _, err := GetChannelMonitorSuccessMetrics(ctx, startTimestamp)
+ if err != nil {
+ return nil, err
+ }
+ metrics := make([]ChannelMonitorStabilityMetric, 0, len(channelMetrics))
+ for _, metric := range channelMetrics {
+ metrics = append(metrics, ChannelMonitorStabilityMetric{
+ ChannelId: metric.ChannelId,
+ ModelName: metric.ModelName,
+ SuccessCount: metric.ActualSuccessCount,
+ FailureCount: metric.ActualFailureCount,
+ SampleCount: metric.ActualSampleCount,
+ SuccessRate: metric.ActualSuccessRate,
+ })
+ }
+ return metrics, nil
+}
diff --git a/model/channel_monitor_performance_test.go b/model/channel_monitor_performance_test.go
new file mode 100644
index 000000000000..83f89299e6d1
--- /dev/null
+++ b/model/channel_monitor_performance_test.go
@@ -0,0 +1,137 @@
+package model
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func TestGetChannelMonitorPerformanceMetricsUsesUsageLogTimingRules(t *testing.T) {
+ originalLogDB := LOG_DB
+ originalLogDatabaseType := common.LogDatabaseType()
+ t.Cleanup(func() {
+ LOG_DB = originalLogDB
+ common.SetLogDatabaseType(originalLogDatabaseType)
+ })
+
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "performance.db")), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, sqlDB.Close())
+ })
+ require.NoError(t, db.AutoMigrate(&Log{}))
+ LOG_DB = db
+ common.SetLogDatabaseType(common.DatabaseTypeSQLite)
+
+ logs := []*Log{
+ {ChannelId: 1, ModelName: "model-a", CreatedAt: 101, Type: LogTypeConsume, IsStream: true, CompletionTokens: 100, UseTime: 10, Other: `{"frt":1000}`},
+ {ChannelId: 1, ModelName: "model-a", CreatedAt: 102, Type: LogTypeConsume, IsStream: true, CompletionTokens: 90, UseTime: 3, Other: `{"frt":3000}`},
+ {ChannelId: 1, ModelName: "model-b", CreatedAt: 103, Type: LogTypeConsume, IsStream: true, Other: `{"frt":500}`},
+ {ChannelId: 2, ModelName: "model-a", CreatedAt: 104, Type: LogTypeConsume, IsStream: true, CompletionTokens: 40, UseTime: 2, Other: "not-json"},
+ {ChannelId: 1, ModelName: "non-stream", CreatedAt: 105, Type: LogTypeConsume, CompletionTokens: 100, UseTime: 1, Other: `{"frt":100}`},
+ {ChannelId: 1, ModelName: "error-log", CreatedAt: 106, Type: LogTypeError, IsStream: true, CompletionTokens: 100, UseTime: 1, Other: `{"frt":100}`},
+ {ChannelId: 1, ModelName: "too-old", CreatedAt: 99, Type: LogTypeConsume, IsStream: true, CompletionTokens: 100, UseTime: 1, Other: `{"frt":100}`},
+ {ChannelId: 0, ModelName: "no-channel", CreatedAt: 107, Type: LogTypeConsume, IsStream: true, CompletionTokens: 100, UseTime: 1, Other: `{"frt":100}`},
+ {ChannelId: 1, ModelName: "", CreatedAt: 108, Type: LogTypeConsume, IsStream: true, CompletionTokens: 100, UseTime: 1, Other: `{"frt":100}`},
+ }
+ require.NoError(t, db.Create(&logs).Error)
+
+ metrics, err := GetChannelMonitorPerformanceMetrics(context.Background(), 100)
+ require.NoError(t, err)
+ require.Len(t, metrics, 3)
+
+ assert.Equal(t, "model-a", metrics[0].ModelName)
+ assert.Equal(t, 1, metrics[0].ChannelId)
+ assert.Equal(t, 2, metrics[0].SampleCount)
+ assert.Equal(t, 2, metrics[0].FirstTokenSampleCount)
+ assert.Equal(t, 2, metrics[0].TPSSampleCount)
+ require.NotNil(t, metrics[0].AverageFirstTokenMs)
+ assert.InDelta(t, 2000, *metrics[0].AverageFirstTokenMs, 0.001)
+ require.NotNil(t, metrics[0].AverageTPS)
+ assert.InDelta(t, 20, *metrics[0].AverageTPS, 0.001)
+ require.NotNil(t, metrics[0].LatestFirstTokenMs)
+ assert.InDelta(t, 3000, *metrics[0].LatestFirstTokenMs, 0.001)
+ require.NotNil(t, metrics[0].LatestTPS)
+ assert.InDelta(t, 30, *metrics[0].LatestTPS, 0.001)
+ assert.Equal(t, int64(102), metrics[0].LastUsedTime)
+
+ assert.Equal(t, "model-a", metrics[1].ModelName)
+ assert.Equal(t, 2, metrics[1].ChannelId)
+ assert.Nil(t, metrics[1].AverageFirstTokenMs)
+ require.NotNil(t, metrics[1].AverageTPS)
+ assert.InDelta(t, 20, *metrics[1].AverageTPS, 0.001)
+
+ assert.Equal(t, "model-b", metrics[2].ModelName)
+ assert.Equal(t, 1, metrics[2].ChannelId)
+ assert.Equal(t, 1, metrics[2].FirstTokenSampleCount)
+ assert.Equal(t, 0, metrics[2].TPSSampleCount)
+ require.NotNil(t, metrics[2].AverageFirstTokenMs)
+ assert.InDelta(t, 500, *metrics[2].AverageFirstTokenMs, 0.001)
+ assert.Nil(t, metrics[2].AverageTPS)
+}
+
+func TestGetChannelMonitorStabilityMetricsCountsSuccessesAndRetryFailures(t *testing.T) {
+ originalLogDB := LOG_DB
+ originalLogDatabaseType := common.LogDatabaseType()
+ t.Cleanup(func() {
+ LOG_DB = originalLogDB
+ common.SetLogDatabaseType(originalLogDatabaseType)
+ })
+
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "stability.db")), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, sqlDB.Close())
+ })
+ require.NoError(t, db.AutoMigrate(&Log{}))
+ LOG_DB = db
+ common.SetLogDatabaseType(common.DatabaseTypeSQLite)
+
+ logs := make([]*Log, 0, 18)
+ for range 8 {
+ logs = append(logs, &Log{ChannelId: 1, ModelName: "model-a", CreatedAt: 101, Type: LogTypeConsume})
+ }
+ logs = append(logs,
+ &Log{ChannelId: 1, ModelName: "model-a", CreatedAt: 102, Type: LogTypeError},
+ &Log{ChannelId: 1, ModelName: "model-a", CreatedAt: 103, Type: LogTypeError, IsRetryAttempt: true},
+ )
+ for range 2 {
+ logs = append(logs, &Log{ChannelId: 2, ModelName: "model-a", CreatedAt: 104, Type: LogTypeConsume})
+ }
+ for range 3 {
+ logs = append(logs, &Log{ChannelId: 2, ModelName: "model-a", CreatedAt: 105, Type: LogTypeError})
+ }
+ logs = append(logs,
+ &Log{ChannelId: 1, ModelName: "model-a", CreatedAt: 99, Type: LogTypeError},
+ &Log{ChannelId: 1, ModelName: "model-a", CreatedAt: 106, Type: LogTypeManage},
+ &Log{ChannelId: 0, ModelName: "model-a", CreatedAt: 107, Type: LogTypeError},
+ )
+ require.NoError(t, db.Create(&logs).Error)
+
+ metrics, err := GetChannelMonitorStabilityMetrics(context.Background(), 100)
+ require.NoError(t, err)
+ require.Len(t, metrics, 2)
+
+ assert.Equal(t, 1, metrics[0].ChannelId)
+ assert.Equal(t, int64(8), metrics[0].SuccessCount)
+ assert.Equal(t, int64(2), metrics[0].FailureCount)
+ assert.Equal(t, int64(10), metrics[0].SampleCount)
+ assert.InDelta(t, 0.8, metrics[0].SuccessRate, 0.0001)
+
+ assert.Equal(t, 2, metrics[1].ChannelId)
+ assert.Equal(t, int64(2), metrics[1].SuccessCount)
+ assert.Equal(t, int64(3), metrics[1].FailureCount)
+ assert.Equal(t, int64(5), metrics[1].SampleCount)
+ assert.InDelta(t, 0.4, metrics[1].SuccessRate, 0.0001)
+}
diff --git a/model/channel_monitor_success.go b/model/channel_monitor_success.go
new file mode 100644
index 000000000000..0f416fda6b1f
--- /dev/null
+++ b/model/channel_monitor_success.go
@@ -0,0 +1,413 @@
+package model
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+
+ "gorm.io/gorm"
+)
+
+type ChannelMonitorSuccessSummary struct {
+ ActualSuccessCount int64 `json:"actual_success_count"`
+ ActualFailureCount int64 `json:"actual_failure_count"`
+ ActualSampleCount int64 `json:"actual_sample_count"`
+ ActualSuccessRate float64 `json:"actual_success_rate"`
+ FinalSuccessCount int64 `json:"final_success_count"`
+ FinalFailureCount int64 `json:"final_failure_count"`
+ FinalSampleCount int64 `json:"final_sample_count"`
+ FinalSuccessRate float64 `json:"final_success_rate"`
+}
+
+type ChannelMonitorSuccessMetric struct {
+ ChannelId int `json:"channel_id"`
+ ModelName string `json:"model_name"`
+ ChannelMonitorSuccessSummary
+}
+
+type ChannelMonitorGroupSuccessMetric struct {
+ Group string `json:"group"`
+ ChannelMonitorSuccessSummary
+}
+
+type ChannelMonitorChannelSuccessMetric struct {
+ ChannelId int `json:"channel_id"`
+ ChannelMonitorSuccessSummary
+}
+
+type ChannelMonitorFailureCategory struct {
+ ChannelId int `json:"channel_id"`
+ StatusCode int `json:"status_code"`
+ ErrorType string `json:"error_type"`
+ ErrorCode string `json:"error_code"`
+ SampleContent string `json:"sample_content"`
+ ActualCount int64 `json:"actual_count"`
+ FinalCount int64 `json:"final_count"`
+ LastOccurred int64 `json:"last_occurred_at"`
+}
+
+type ChannelMonitorSuccessDetail struct {
+ Summary ChannelMonitorSuccessSummary `json:"summary"`
+ ChannelItems []ChannelMonitorChannelSuccessMetric `json:"channel_items"`
+ FailureCategories []ChannelMonitorFailureCategory `json:"failure_categories"`
+}
+
+type ChannelMonitorSuccessFilter struct {
+ ChannelId int
+ ModelName string
+ Group string
+}
+
+type channelMonitorSuccessCounts struct {
+ actualSuccess int64
+ actualFailure int64
+ finalSuccess int64
+ finalFailure int64
+}
+
+type channelMonitorSuccessRow struct {
+ ChannelId int
+ ModelName string
+ GroupName string `gorm:"column:group_name"`
+ Type int
+ IsRetryAttempt *bool
+ Count int64
+}
+
+func channelMonitorLogGroupColumn() string {
+ if logGroupCol != "" {
+ return logGroupCol
+ }
+ if common.UsingLogDatabase(common.DatabaseTypePostgreSQL) {
+ return `"group"`
+ }
+ return "`group`"
+}
+
+func applyChannelMonitorSuccessFilter(tx *gorm.DB, filter ChannelMonitorSuccessFilter) *gorm.DB {
+ if filter.ChannelId > 0 {
+ tx = tx.Where("channel_id = ?", filter.ChannelId)
+ }
+ if filter.ModelName != "" {
+ tx = tx.Where("model_name = ?", filter.ModelName)
+ }
+ if filter.Group != "" {
+ tx = tx.Where(channelMonitorLogGroupColumn()+" = ?", filter.Group)
+ }
+ return tx
+}
+
+func getChannelMonitorSuccessRows(ctx context.Context, startTimestamp int64, filter ChannelMonitorSuccessFilter) ([]channelMonitorSuccessRow, error) {
+ groupColumn := channelMonitorLogGroupColumn()
+ selectColumns := "channel_id, model_name, " + groupColumn + " AS group_name, type, is_retry_attempt, COUNT(*) AS count"
+ groupColumns := "channel_id, model_name, " + groupColumn + ", type, is_retry_attempt"
+ query := LOG_DB.WithContext(ctx).
+ Model(&Log{}).
+ Select(selectColumns).
+ Where("type IN ?", []int{LogTypeConsume, LogTypeError}).
+ Where("channel_id > ?", 0).
+ Where("created_at >= ?", startTimestamp)
+ query = applyChannelMonitorSuccessFilter(query, filter)
+
+ var rows []channelMonitorSuccessRow
+ err := query.Group(groupColumns).Scan(&rows).Error
+ return rows, err
+}
+
+func (counts *channelMonitorSuccessCounts) add(logType int, isRetryAttempt bool, count int64) {
+ if logType == LogTypeConsume {
+ counts.actualSuccess += count
+ counts.finalSuccess += count
+ return
+ }
+ counts.actualFailure += count
+ if !isRetryAttempt {
+ counts.finalFailure += count
+ }
+}
+
+func (counts channelMonitorSuccessCounts) summary() ChannelMonitorSuccessSummary {
+ actualSampleCount := counts.actualSuccess + counts.actualFailure
+ finalSampleCount := counts.finalSuccess + counts.finalFailure
+ summary := ChannelMonitorSuccessSummary{
+ ActualSuccessCount: counts.actualSuccess,
+ ActualFailureCount: counts.actualFailure,
+ ActualSampleCount: actualSampleCount,
+ FinalSuccessCount: counts.finalSuccess,
+ FinalFailureCount: counts.finalFailure,
+ FinalSampleCount: finalSampleCount,
+ }
+ if actualSampleCount > 0 {
+ summary.ActualSuccessRate = float64(counts.actualSuccess) / float64(actualSampleCount)
+ }
+ if finalSampleCount > 0 {
+ summary.FinalSuccessRate = float64(counts.finalSuccess) / float64(finalSampleCount)
+ }
+ return summary
+}
+
+// GetChannelMonitorSuccessMetrics reports upstream-call success and the final
+// user-visible outcome. Retry-attempt errors affect actual calls but are
+// excluded from final outcomes.
+func GetChannelMonitorSuccessMetrics(ctx context.Context, startTimestamp int64) ([]ChannelMonitorSuccessMetric, []ChannelMonitorGroupSuccessMetric, error) {
+ rows, err := getChannelMonitorSuccessRows(ctx, startTimestamp, ChannelMonitorSuccessFilter{})
+ if err != nil {
+ return nil, nil, err
+ }
+
+ type channelKey struct {
+ channelId int
+ modelName string
+ }
+ channelCounts := make(map[channelKey]*channelMonitorSuccessCounts)
+ groupCounts := make(map[string]*channelMonitorSuccessCounts)
+ for _, row := range rows {
+ isRetryAttempt := row.IsRetryAttempt != nil && *row.IsRetryAttempt
+ if strings.TrimSpace(row.ModelName) != "" {
+ key := channelKey{channelId: row.ChannelId, modelName: row.ModelName}
+ counts := channelCounts[key]
+ if counts == nil {
+ counts = &channelMonitorSuccessCounts{}
+ channelCounts[key] = counts
+ }
+ counts.add(row.Type, isRetryAttempt, row.Count)
+ }
+
+ group := strings.TrimSpace(row.GroupName)
+ if group == "" {
+ continue
+ }
+ counts := groupCounts[group]
+ if counts == nil {
+ counts = &channelMonitorSuccessCounts{}
+ groupCounts[group] = counts
+ }
+ counts.add(row.Type, isRetryAttempt, row.Count)
+ }
+
+ channelMetrics := make([]ChannelMonitorSuccessMetric, 0, len(channelCounts))
+ for key, counts := range channelCounts {
+ channelMetrics = append(channelMetrics, ChannelMonitorSuccessMetric{
+ ChannelId: key.channelId,
+ ModelName: key.modelName,
+ ChannelMonitorSuccessSummary: counts.summary(),
+ })
+ }
+ sort.Slice(channelMetrics, func(i int, j int) bool {
+ if channelMetrics[i].ModelName == channelMetrics[j].ModelName {
+ return channelMetrics[i].ChannelId < channelMetrics[j].ChannelId
+ }
+ return channelMetrics[i].ModelName < channelMetrics[j].ModelName
+ })
+
+ groupMetrics := make([]ChannelMonitorGroupSuccessMetric, 0, len(groupCounts))
+ for group, counts := range groupCounts {
+ groupMetrics = append(groupMetrics, ChannelMonitorGroupSuccessMetric{
+ Group: group,
+ ChannelMonitorSuccessSummary: counts.summary(),
+ })
+ }
+ sort.Slice(groupMetrics, func(i int, j int) bool {
+ return groupMetrics[i].Group < groupMetrics[j].Group
+ })
+ return channelMetrics, groupMetrics, nil
+}
+
+// GetChannelMonitorSuccessDetail returns the selected scope's totals and
+// per-channel breakdown. Channel scopes also include categorized failures.
+func GetChannelMonitorSuccessDetail(ctx context.Context, startTimestamp int64, filter ChannelMonitorSuccessFilter) (ChannelMonitorSuccessDetail, error) {
+ rows, err := getChannelMonitorSuccessRows(ctx, startTimestamp, filter)
+ if err != nil {
+ return ChannelMonitorSuccessDetail{}, err
+ }
+
+ totalCounts := channelMonitorSuccessCounts{}
+ channelCounts := make(map[int]*channelMonitorSuccessCounts)
+ for _, row := range rows {
+ if filter.Group == "" && strings.TrimSpace(row.ModelName) == "" {
+ continue
+ }
+ isRetryAttempt := row.IsRetryAttempt != nil && *row.IsRetryAttempt
+ totalCounts.add(row.Type, isRetryAttempt, row.Count)
+ counts := channelCounts[row.ChannelId]
+ if counts == nil {
+ counts = &channelMonitorSuccessCounts{}
+ channelCounts[row.ChannelId] = counts
+ }
+ counts.add(row.Type, isRetryAttempt, row.Count)
+ }
+
+ channelItems := make([]ChannelMonitorChannelSuccessMetric, 0, len(channelCounts))
+ for channelId, counts := range channelCounts {
+ channelItems = append(channelItems, ChannelMonitorChannelSuccessMetric{
+ ChannelId: channelId,
+ ChannelMonitorSuccessSummary: counts.summary(),
+ })
+ }
+ sort.Slice(channelItems, func(i int, j int) bool {
+ return channelItems[i].ChannelId < channelItems[j].ChannelId
+ })
+
+ failureCategories := make([]ChannelMonitorFailureCategory, 0)
+ if filter.ChannelId > 0 {
+ failureCategories, err = getChannelMonitorFailureCategories(ctx, startTimestamp, filter)
+ if err != nil {
+ return ChannelMonitorSuccessDetail{}, err
+ }
+ }
+ return ChannelMonitorSuccessDetail{
+ Summary: totalCounts.summary(),
+ ChannelItems: channelItems,
+ FailureCategories: failureCategories,
+ }, nil
+}
+
+func getChannelMonitorFailureCategories(ctx context.Context, startTimestamp int64, filter ChannelMonitorSuccessFilter) ([]ChannelMonitorFailureCategory, error) {
+ type failureRow struct {
+ ChannelId int
+ ModelName string
+ Content string
+ Other string
+ IsRetryAttempt *bool
+ Count int64
+ LastOccurred int64 `gorm:"column:last_occurred_at"`
+ }
+ query := LOG_DB.WithContext(ctx).
+ Model(&Log{}).
+ Select("channel_id, model_name, content, MAX(other) AS other, is_retry_attempt, COUNT(*) AS count, MAX(created_at) AS last_occurred_at").
+ Where("type = ?", LogTypeError).
+ Where("channel_id > ?", 0).
+ Where("created_at >= ?", startTimestamp)
+ query = applyChannelMonitorSuccessFilter(query, filter)
+
+ var rows []failureRow
+ err := query.
+ Group("channel_id, model_name, content, is_retry_attempt").
+ Scan(&rows).Error
+ if err != nil {
+ return nil, err
+ }
+
+ type categoryKey struct {
+ channelId int
+ statusCode int
+ errorType string
+ errorCode string
+ content string
+ }
+ categories := make(map[categoryKey]*ChannelMonitorFailureCategory)
+ for _, row := range rows {
+ if filter.Group == "" && strings.TrimSpace(row.ModelName) == "" {
+ continue
+ }
+ statusCode, errorType, errorCode := channelMonitorFailureIdentity(row.Content, row.Other)
+ sampleContent := channelMonitorFailureSampleContent(row.Content)
+ fallbackContent := ""
+ if statusCode == 0 && errorType == "" && errorCode == "" {
+ fallbackContent = sampleContent
+ }
+ key := categoryKey{
+ channelId: row.ChannelId,
+ statusCode: statusCode,
+ errorType: errorType,
+ errorCode: errorCode,
+ content: fallbackContent,
+ }
+ category := categories[key]
+ if category == nil {
+ category = &ChannelMonitorFailureCategory{
+ ChannelId: row.ChannelId,
+ StatusCode: statusCode,
+ ErrorType: errorType,
+ ErrorCode: errorCode,
+ SampleContent: sampleContent,
+ }
+ categories[key] = category
+ }
+ category.ActualCount += row.Count
+ isRetryAttempt := row.IsRetryAttempt != nil && *row.IsRetryAttempt
+ if !isRetryAttempt {
+ category.FinalCount += row.Count
+ }
+ if row.LastOccurred >= category.LastOccurred {
+ category.LastOccurred = row.LastOccurred
+ category.SampleContent = sampleContent
+ }
+ }
+
+ result := make([]ChannelMonitorFailureCategory, 0, len(categories))
+ for _, category := range categories {
+ result = append(result, *category)
+ }
+ sort.Slice(result, func(i int, j int) bool {
+ if result[i].ChannelId != result[j].ChannelId {
+ return result[i].ChannelId < result[j].ChannelId
+ }
+ if result[i].ActualCount != result[j].ActualCount {
+ return result[i].ActualCount > result[j].ActualCount
+ }
+ if result[i].StatusCode != result[j].StatusCode {
+ return result[i].StatusCode < result[j].StatusCode
+ }
+ if result[i].ErrorCode != result[j].ErrorCode {
+ return result[i].ErrorCode < result[j].ErrorCode
+ }
+ return result[i].ErrorType < result[j].ErrorType
+ })
+ return result, nil
+}
+
+func channelMonitorFailureIdentity(content string, other string) (int, string, string) {
+ otherValues := make(map[string]interface{})
+ if strings.TrimSpace(other) != "" {
+ _ = common.UnmarshalJsonStr(other, &otherValues)
+ }
+ statusCode := channelMonitorFailureStatusCode(otherValues["status_code"])
+ if statusCode == 0 && strings.HasPrefix(content, "status_code=") {
+ rawStatus := strings.TrimPrefix(content, "status_code=")
+ if end := strings.IndexAny(rawStatus, ", \t\r\n"); end >= 0 {
+ rawStatus = rawStatus[:end]
+ }
+ statusCode, _ = strconv.Atoi(rawStatus)
+ }
+ errorType := channelMonitorFailureValue(otherValues["error_type"])
+ errorCode := channelMonitorFailureValue(otherValues["error_code"])
+ return statusCode, errorType, errorCode
+}
+
+func channelMonitorFailureStatusCode(value interface{}) int {
+ switch typedValue := value.(type) {
+ case int:
+ return typedValue
+ case int64:
+ return int(typedValue)
+ case float64:
+ return int(typedValue)
+ case string:
+ statusCode, _ := strconv.Atoi(strings.TrimSpace(typedValue))
+ return statusCode
+ default:
+ return 0
+ }
+}
+
+func channelMonitorFailureValue(value interface{}) string {
+ if value == nil {
+ return ""
+ }
+ return strings.TrimSpace(fmt.Sprint(value))
+}
+
+func channelMonitorFailureSampleContent(content string) string {
+ const maxLength = 500
+ trimmedContent := strings.TrimSpace(content)
+ runes := []rune(trimmedContent)
+ if len(runes) <= maxLength {
+ return trimmedContent
+ }
+ return string(runes[:maxLength]) + "..."
+}
diff --git a/model/channel_monitor_success_test.go b/model/channel_monitor_success_test.go
new file mode 100644
index 000000000000..91ef7c2c7f96
--- /dev/null
+++ b/model/channel_monitor_success_test.go
@@ -0,0 +1,141 @@
+package model
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func TestGetChannelMonitorSuccessMetricsDistinguishesActualAndFinalResults(t *testing.T) {
+ originalLogDB := LOG_DB
+ originalLogDatabaseType := common.LogDatabaseType()
+ t.Cleanup(func() {
+ LOG_DB = originalLogDB
+ common.SetLogDatabaseType(originalLogDatabaseType)
+ initCol()
+ })
+
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "channel-success.db")), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, sqlDB.Close())
+ })
+ require.NoError(t, db.AutoMigrate(&Log{}))
+ LOG_DB = db
+ common.SetLogDatabaseType(common.DatabaseTypeSQLite)
+ initCol()
+
+ logs := []*Log{
+ {ChannelId: 1, ModelName: "model-a", Group: "vip", CreatedAt: 101, Type: LogTypeConsume},
+ {ChannelId: 1, ModelName: "model-a", Group: "vip", CreatedAt: 102, Type: LogTypeConsume},
+ {ChannelId: 1, ModelName: "model-a", Group: "vip", CreatedAt: 103, Type: LogTypeError, IsRetryAttempt: true, Content: "status_code=503, upstream unavailable", Other: `{"status_code":503,"error_type":"upstream_error","error_code":"bad_response_status_code"}`},
+ {ChannelId: 1, ModelName: "model-a", Group: "vip", CreatedAt: 104, Type: LogTypeError, Content: "status_code=429, rate limited", Other: `{"status_code":"429","error_type":"rate_limit","error_code":"rate_limit_exceeded"}`},
+ {ChannelId: 2, ModelName: "model-b", Group: "vip", CreatedAt: 105, Type: LogTypeError, IsRetryAttempt: true, Content: "status_code=503, another upstream failure", Other: `{"error_type":"upstream_error","error_code":"bad_response_status_code"}`},
+ {ChannelId: 2, ModelName: "model-b", Group: "standard", CreatedAt: 106, Type: LogTypeConsume},
+ {ChannelId: 1, ModelName: "model-a", Group: "vip", CreatedAt: 99, Type: LogTypeError},
+ {ChannelId: 0, ModelName: "model-a", Group: "vip", CreatedAt: 107, Type: LogTypeError},
+ {ChannelId: 1, ModelName: "model-a", Group: "vip", CreatedAt: 108, Type: LogTypeManage},
+ }
+ require.NoError(t, db.Create(&logs).Error)
+
+ channelMetrics, groupMetrics, err := GetChannelMonitorSuccessMetrics(context.Background(), 100)
+ require.NoError(t, err)
+ require.Len(t, channelMetrics, 2)
+
+ assert.Equal(t, 1, channelMetrics[0].ChannelId)
+ assert.Equal(t, "model-a", channelMetrics[0].ModelName)
+ assert.Equal(t, int64(2), channelMetrics[0].ActualSuccessCount)
+ assert.Equal(t, int64(2), channelMetrics[0].ActualFailureCount)
+ assert.Equal(t, int64(4), channelMetrics[0].ActualSampleCount)
+ assert.InDelta(t, 0.5, channelMetrics[0].ActualSuccessRate, 0.0001)
+ assert.Equal(t, int64(2), channelMetrics[0].FinalSuccessCount)
+ assert.Equal(t, int64(1), channelMetrics[0].FinalFailureCount)
+ assert.Equal(t, int64(3), channelMetrics[0].FinalSampleCount)
+ assert.InDelta(t, 2.0/3.0, channelMetrics[0].FinalSuccessRate, 0.0001)
+
+ assert.Equal(t, 2, channelMetrics[1].ChannelId)
+ assert.Equal(t, "model-b", channelMetrics[1].ModelName)
+ assert.Equal(t, int64(1), channelMetrics[1].ActualSuccessCount)
+ assert.Equal(t, int64(1), channelMetrics[1].ActualFailureCount)
+ assert.InDelta(t, 0.5, channelMetrics[1].ActualSuccessRate, 0.0001)
+ assert.Equal(t, int64(1), channelMetrics[1].FinalSuccessCount)
+ assert.Zero(t, channelMetrics[1].FinalFailureCount)
+ assert.InDelta(t, 1, channelMetrics[1].FinalSuccessRate, 0.0001)
+
+ require.Len(t, groupMetrics, 2)
+ assert.Equal(t, "standard", groupMetrics[0].Group)
+ assert.Equal(t, int64(1), groupMetrics[0].ActualSampleCount)
+ assert.InDelta(t, 1, groupMetrics[0].ActualSuccessRate, 0.0001)
+ assert.InDelta(t, 1, groupMetrics[0].FinalSuccessRate, 0.0001)
+
+ assert.Equal(t, "vip", groupMetrics[1].Group)
+ assert.Equal(t, int64(2), groupMetrics[1].ActualSuccessCount)
+ assert.Equal(t, int64(3), groupMetrics[1].ActualFailureCount)
+ assert.Equal(t, int64(5), groupMetrics[1].ActualSampleCount)
+ assert.InDelta(t, 0.4, groupMetrics[1].ActualSuccessRate, 0.0001)
+ assert.Equal(t, int64(2), groupMetrics[1].FinalSuccessCount)
+ assert.Equal(t, int64(1), groupMetrics[1].FinalFailureCount)
+ assert.Equal(t, int64(3), groupMetrics[1].FinalSampleCount)
+ assert.InDelta(t, 2.0/3.0, groupMetrics[1].FinalSuccessRate, 0.0001)
+
+ channelDetail, err := GetChannelMonitorSuccessDetail(context.Background(), 100, ChannelMonitorSuccessFilter{
+ ChannelId: 1,
+ ModelName: "model-a",
+ })
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), channelDetail.Summary.ActualSuccessCount)
+ assert.Equal(t, int64(2), channelDetail.Summary.ActualFailureCount)
+ assert.Equal(t, int64(1), channelDetail.Summary.FinalFailureCount)
+ require.Len(t, channelDetail.ChannelItems, 1)
+ require.Len(t, channelDetail.FailureCategories, 2)
+ assert.Equal(t, 429, channelDetail.FailureCategories[0].StatusCode)
+ assert.Equal(t, "rate_limit_exceeded", channelDetail.FailureCategories[0].ErrorCode)
+ assert.Equal(t, int64(1), channelDetail.FailureCategories[0].ActualCount)
+ assert.Equal(t, int64(1), channelDetail.FailureCategories[0].FinalCount)
+ assert.Equal(t, 503, channelDetail.FailureCategories[1].StatusCode)
+ assert.Equal(t, int64(1), channelDetail.FailureCategories[1].ActualCount)
+ assert.Zero(t, channelDetail.FailureCategories[1].FinalCount)
+
+ groupDetail, err := GetChannelMonitorSuccessDetail(context.Background(), 100, ChannelMonitorSuccessFilter{Group: "vip"})
+ require.NoError(t, err)
+ assert.Equal(t, int64(5), groupDetail.Summary.ActualSampleCount)
+ assert.Equal(t, int64(3), groupDetail.Summary.FinalSampleCount)
+ require.Len(t, groupDetail.ChannelItems, 2)
+ assert.Equal(t, 1, groupDetail.ChannelItems[0].ChannelId)
+ assert.Equal(t, int64(4), groupDetail.ChannelItems[0].ActualSampleCount)
+ assert.Equal(t, 2, groupDetail.ChannelItems[1].ChannelId)
+ assert.Equal(t, int64(1), groupDetail.ChannelItems[1].ActualFailureCount)
+ assert.Zero(t, groupDetail.ChannelItems[1].FinalSampleCount)
+ assert.Empty(t, groupDetail.FailureCategories)
+
+ require.NoError(t, db.Create(&Log{
+ ChannelId: 1,
+ ModelName: "model-a",
+ Group: "vip",
+ CreatedAt: 110,
+ Type: LogTypeError,
+ IsRetryAttempt: true,
+ Content: "status_code=503, second unavailable response",
+ Other: `{"status_code":503,"error_type":"upstream_error","error_code":"bad_response_status_code"}`,
+ }).Error)
+ mergedDetail, err := GetChannelMonitorSuccessDetail(context.Background(), 100, ChannelMonitorSuccessFilter{
+ ChannelId: 1,
+ ModelName: "model-a",
+ })
+ require.NoError(t, err)
+ require.Len(t, mergedDetail.FailureCategories, 2)
+ assert.Equal(t, 503, mergedDetail.FailureCategories[0].StatusCode)
+ assert.Equal(t, int64(2), mergedDetail.FailureCategories[0].ActualCount)
+ assert.Zero(t, mergedDetail.FailureCategories[0].FinalCount)
+ assert.Equal(t, int64(110), mergedDetail.FailureCategories[0].LastOccurred)
+ assert.Contains(t, mergedDetail.FailureCategories[0].SampleContent, "second unavailable")
+}
diff --git a/model/channel_ratio_monitor.go b/model/channel_ratio_monitor.go
new file mode 100644
index 000000000000..dd88015160e6
--- /dev/null
+++ b/model/channel_ratio_monitor.go
@@ -0,0 +1,515 @@
+package model
+
+import (
+ "errors"
+ "math"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+
+ "gorm.io/gorm"
+)
+
+const (
+ ChannelRatioFetchStatusSucceeded = "succeeded"
+ ChannelRatioFetchStatusFailed = "failed"
+ ChannelSmartScheduleStatusSucceeded = "succeeded"
+ ChannelSmartScheduleStatusSkipped = "skipped"
+ ChannelSmartScheduleStatusFailed = "failed"
+)
+
+type ChannelRatioMonitor struct {
+ Id int `json:"id"`
+ ChannelId int `json:"channel_id" gorm:"uniqueIndex;not null"`
+ Ratio float64 `json:"ratio" gorm:"not null"`
+ PreviousRatio *float64 `json:"previous_ratio"`
+ Remark string `json:"remark" gorm:"type:varchar(255);default:''"`
+ UpdatedTime int64 `json:"updated_time" gorm:"bigint;index"`
+ UpdatedBy int `json:"updated_by" gorm:"index"`
+ UpdatedByUsername string `json:"updated_by_username" gorm:"type:varchar(64);default:''"`
+ LastFetchStatus string `json:"last_fetch_status" gorm:"type:varchar(16);index"`
+ LastFetchError string `json:"last_fetch_error" gorm:"type:varchar(255)"`
+ LastFetchTime int64 `json:"last_fetch_time" gorm:"bigint;index"`
+ ConsecutiveFailures int `json:"consecutive_failures"`
+ UpstreamBalance *float64 `json:"upstream_balance"`
+ LastBalanceTime int64 `json:"last_balance_time" gorm:"bigint"`
+ LastBalanceError string `json:"last_balance_error" gorm:"type:varchar(255)"`
+ BalanceWarningThreshold *float64 `json:"balance_warning_threshold"`
+ BalanceAutoDisableThreshold *float64 `json:"balance_auto_disable_threshold"`
+ BalanceAlertNotified bool `json:"balance_alert_notified"`
+ UpstreamType string `json:"upstream_type" gorm:"type:varchar(32)"`
+ UpstreamBaseURL string `json:"upstream_base_url" gorm:"type:text"`
+ UpstreamGroup string `json:"upstream_group" gorm:"type:varchar(64)"`
+ UpstreamAuthType string `json:"upstream_auth_type" gorm:"type:varchar(16)"`
+ UpstreamUserId int `json:"upstream_user_id"`
+ UpstreamAccessToken string `json:"-" gorm:"type:text"`
+ UpstreamAccount string `json:"-" gorm:"type:varchar(320)"`
+ UpstreamPassword string `json:"-" gorm:"type:text"`
+ CostConversion string `json:"-" gorm:"type:text"`
+ CustomUpstreamConfig string `json:"-" gorm:"type:text"`
+ UpstreamRatioSyncDisabled bool `json:"-"`
+ UpstreamBalanceSyncDisabled bool `json:"-"`
+ SingleChannelAction string `json:"single_channel_action" gorm:"type:varchar(32)"`
+ MultipleChannelsAction string `json:"multiple_channels_action" gorm:"type:varchar(32)"`
+ SmartScheduleExcluded bool `json:"smart_schedule_excluded"`
+ LastScheduleStatus string `json:"last_schedule_status" gorm:"type:varchar(16);index"`
+ LastScheduleError string `json:"last_schedule_error" gorm:"type:varchar(255)"`
+ LastScheduleScore *float64 `json:"last_schedule_score"`
+ LastSchedulePriority int64 `json:"last_schedule_priority" gorm:"bigint"`
+ LastScheduleWeight uint `json:"last_schedule_weight"`
+ LastScheduleTime int64 `json:"last_schedule_time" gorm:"bigint;index"`
+}
+
+type ChannelRatioUpstreamOptions struct {
+ SingleChannelAction string
+ MultipleChannelsAction string
+ BalanceWarningThreshold *float64
+ BalanceAutoDisableThreshold *float64
+ RatioSyncEnabled bool
+ BalanceSyncEnabled bool
+ CostConversion string
+ CustomUpstreamConfig string
+ UpstreamAccount string
+ UpstreamPassword string
+}
+
+type ChannelSmartScheduleConfigOptions struct {
+ Excluded bool
+ Priority *int64
+ Weight *uint
+}
+
+type ChannelSmartScheduleResultUpdate struct {
+ ChannelId int
+ Status string
+ Error string
+ Score *float64
+ Priority int64
+ Weight uint
+ Time int64
+}
+
+type ChannelRatioHistory struct {
+ Id int `json:"id"`
+ ChannelId int `json:"channel_id" gorm:"index;not null"`
+ OldRatio float64 `json:"old_ratio" gorm:"not null"`
+ NewRatio float64 `json:"new_ratio" gorm:"not null"`
+ Remark string `json:"remark" gorm:"type:varchar(255);default:''"`
+ CreatedTime int64 `json:"created_time" gorm:"bigint;index"`
+ OperatorId int `json:"operator_id" gorm:"index"`
+ OperatorUsername string `json:"operator_username" gorm:"type:varchar(64);default:''"`
+}
+
+func GetAllChannelsForMonitor() ([]*Channel, error) {
+ var channels []*Channel
+ err := resolveChannelSortOptions(false, nil).Apply(DB).
+ Omit("key").
+ Find(&channels).Error
+ return channels, err
+}
+
+func GetChannelRatioMonitors() ([]ChannelRatioMonitor, error) {
+ var monitors []ChannelRatioMonitor
+ err := DB.Find(&monitors).Error
+ return monitors, err
+}
+
+func GetChannelRatioMonitor(channelId int) (ChannelRatioMonitor, error) {
+ var monitor ChannelRatioMonitor
+ err := DB.Where("channel_id = ?", channelId).First(&monitor).Error
+ return monitor, err
+}
+
+func SaveChannelRatioUpstreamConfig(channelId int, upstreamType string, baseURL string, group string, authType string, userId int, accessToken string, options ChannelRatioUpstreamOptions) (monitor ChannelRatioMonitor, err error) {
+ err = DB.Transaction(func(tx *gorm.DB) error {
+ findErr := lockForUpdate(tx).Where("channel_id = ?", channelId).First(&monitor).Error
+ if errors.Is(findErr, gorm.ErrRecordNotFound) {
+ monitor = ChannelRatioMonitor{ChannelId: channelId}
+ } else if findErr != nil {
+ return findErr
+ }
+ upstreamAccountChanged := monitor.UpstreamType != upstreamType ||
+ monitor.UpstreamBaseURL != baseURL ||
+ monitor.UpstreamAuthType != authType ||
+ monitor.UpstreamUserId != userId ||
+ monitor.UpstreamAccessToken != accessToken ||
+ monitor.UpstreamAccount != options.UpstreamAccount ||
+ monitor.UpstreamPassword != options.UpstreamPassword ||
+ monitor.CustomUpstreamConfig != options.CustomUpstreamConfig
+ balanceWarningThresholdChanged :=
+ (monitor.BalanceWarningThreshold == nil) != (options.BalanceWarningThreshold == nil) ||
+ (monitor.BalanceWarningThreshold != nil && options.BalanceWarningThreshold != nil &&
+ *monitor.BalanceWarningThreshold != *options.BalanceWarningThreshold)
+ balanceSyncChanged := monitor.UpstreamBalanceSyncDisabled != !options.BalanceSyncEnabled
+
+ monitor.UpstreamType = upstreamType
+ monitor.UpstreamBaseURL = baseURL
+ monitor.UpstreamGroup = group
+ monitor.UpstreamAuthType = authType
+ monitor.UpstreamUserId = userId
+ monitor.UpstreamAccessToken = accessToken
+ monitor.UpstreamAccount = options.UpstreamAccount
+ monitor.UpstreamPassword = options.UpstreamPassword
+ monitor.CostConversion = options.CostConversion
+ monitor.CustomUpstreamConfig = options.CustomUpstreamConfig
+ monitor.UpstreamRatioSyncDisabled = !options.RatioSyncEnabled
+ monitor.UpstreamBalanceSyncDisabled = !options.BalanceSyncEnabled
+ monitor.SingleChannelAction = options.SingleChannelAction
+ monitor.MultipleChannelsAction = options.MultipleChannelsAction
+ if options.BalanceWarningThreshold == nil {
+ monitor.BalanceWarningThreshold = nil
+ } else {
+ value := *options.BalanceWarningThreshold
+ monitor.BalanceWarningThreshold = &value
+ }
+ if options.BalanceAutoDisableThreshold == nil {
+ monitor.BalanceAutoDisableThreshold = nil
+ } else {
+ value := *options.BalanceAutoDisableThreshold
+ monitor.BalanceAutoDisableThreshold = &value
+ }
+ if upstreamAccountChanged {
+ monitor.UpstreamBalance = nil
+ monitor.LastBalanceTime = 0
+ monitor.LastBalanceError = ""
+ }
+ if upstreamAccountChanged || balanceWarningThresholdChanged || balanceSyncChanged {
+ monitor.BalanceAlertNotified = false
+ }
+ return tx.Save(&monitor).Error
+ })
+ return monitor, err
+}
+
+func SaveChannelSmartScheduleConfig(channelId int, options ChannelSmartScheduleConfigOptions) (monitor ChannelRatioMonitor, err error) {
+ err = DB.Transaction(func(tx *gorm.DB) error {
+ findErr := lockForUpdate(tx).Where("channel_id = ?", channelId).First(&monitor).Error
+ if errors.Is(findErr, gorm.ErrRecordNotFound) {
+ monitor = ChannelRatioMonitor{ChannelId: channelId}
+ } else if findErr != nil {
+ return findErr
+ }
+
+ monitor.SmartScheduleExcluded = options.Excluded
+ if err := tx.Save(&monitor).Error; err != nil {
+ return err
+ }
+ return updateChannelSmartSchedulePriorityWeightTx(tx, channelId, options.Priority, options.Weight)
+ })
+ return monitor, err
+}
+
+func ExcludeAllChannelsFromSmartSchedule() (int, error) {
+ channelIds := make([]int, 0)
+ err := DB.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Model(&Channel{}).Pluck("id", &channelIds).Error; err != nil {
+ return err
+ }
+
+ for _, channelId := range channelIds {
+ var monitor ChannelRatioMonitor
+ findErr := lockForUpdate(tx).Where("channel_id = ?", channelId).First(&monitor).Error
+ if errors.Is(findErr, gorm.ErrRecordNotFound) {
+ monitor = ChannelRatioMonitor{
+ ChannelId: channelId,
+ SmartScheduleExcluded: true,
+ }
+ if err := tx.Create(&monitor).Error; err != nil {
+ return err
+ }
+ continue
+ }
+ if findErr != nil {
+ return findErr
+ }
+ if monitor.SmartScheduleExcluded {
+ continue
+ }
+ if err := tx.Model(&monitor).Update("smart_schedule_excluded", true).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ return len(channelIds), err
+}
+
+func SaveChannelSmartScheduleResults(results []ChannelSmartScheduleResultUpdate) error {
+ if len(results) == 0 {
+ return nil
+ }
+ return DB.Transaction(func(tx *gorm.DB) error {
+ for _, result := range results {
+ var monitor ChannelRatioMonitor
+ findErr := lockForUpdate(tx).Where("channel_id = ?", result.ChannelId).First(&monitor).Error
+ if errors.Is(findErr, gorm.ErrRecordNotFound) {
+ monitor = ChannelRatioMonitor{ChannelId: result.ChannelId}
+ } else if findErr != nil {
+ return findErr
+ }
+
+ message := strings.TrimSpace(result.Error)
+ messageRunes := []rune(message)
+ if len(messageRunes) > 255 {
+ message = string(messageRunes[:255])
+ }
+ updatedTime := result.Time
+ if updatedTime <= 0 {
+ updatedTime = common.GetTimestamp()
+ }
+ monitor.LastScheduleStatus = result.Status
+ monitor.LastScheduleError = message
+ monitor.LastScheduleScore = result.Score
+ monitor.LastSchedulePriority = result.Priority
+ monitor.LastScheduleWeight = result.Weight
+ monitor.LastScheduleTime = updatedTime
+ if err := tx.Save(&monitor).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+func UpdateChannelSmartSchedulePriorityWeight(channelId int, priority *int64, weight *uint) error {
+ return DB.Transaction(func(tx *gorm.DB) error {
+ return updateChannelSmartSchedulePriorityWeightTx(tx, channelId, priority, weight)
+ })
+}
+
+func updateChannelSmartSchedulePriorityWeightTx(tx *gorm.DB, channelId int, priority *int64, weight *uint) error {
+ channelUpdates := make(map[string]any, 2)
+ abilityUpdates := make(map[string]any, 2)
+ if priority != nil {
+ channelUpdates["priority"] = *priority
+ abilityUpdates["priority"] = *priority
+ }
+ if weight != nil {
+ channelUpdates["weight"] = *weight
+ abilityUpdates["weight"] = *weight
+ }
+ if len(channelUpdates) == 0 {
+ return nil
+ }
+
+ result := tx.Model(&Channel{}).Where("id = ?", channelId).Updates(channelUpdates)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ var count int64
+ if err := tx.Model(&Channel{}).Where("id = ?", channelId).Count(&count).Error; err != nil {
+ return err
+ }
+ if count == 0 {
+ return gorm.ErrRecordNotFound
+ }
+ }
+ return tx.Model(&Ability{}).Where("channel_id = ?", channelId).Updates(abilityUpdates).Error
+}
+
+func ResetChannelSmartSchedulePriorityWeight(channelIds []int, weight uint) error {
+ if len(channelIds) == 0 {
+ return nil
+ }
+
+ const batchSize = 500
+ updates := map[string]any{
+ "priority": int64(0),
+ "weight": weight,
+ }
+ return DB.Transaction(func(tx *gorm.DB) error {
+ for start := 0; start < len(channelIds); start += batchSize {
+ end := min(start+batchSize, len(channelIds))
+ batch := channelIds[start:end]
+ if err := tx.Model(&Channel{}).Where("id IN ?", batch).Updates(updates).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&Ability{}).Where("channel_id IN ?", batch).Updates(updates).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+func UpdateChannelRatioMonitor(channelId int, ratio float64, remark string, operatorId int, operatorUsername string) (monitor ChannelRatioMonitor, created bool, changed bool, err error) {
+ return updateChannelRatioMonitor(channelId, ratio, remark, operatorId, operatorUsername, false)
+}
+
+func UpdateChannelRatioMonitorFromUpstream(channelId int, ratio float64, remark string, operatorId int, operatorUsername string) (monitor ChannelRatioMonitor, created bool, changed bool, err error) {
+ return updateChannelRatioMonitor(channelId, ratio, remark, operatorId, operatorUsername, true)
+}
+
+func RecordChannelRatioMonitorFetchFailure(channelId int, fetchError string) error {
+ message := strings.TrimSpace(fetchError)
+ if message == "" {
+ message = "上游倍率获取失败"
+ }
+ messageRunes := []rune(message)
+ if len(messageRunes) > 255 {
+ message = string(messageRunes[:255])
+ }
+
+ return DB.Transaction(func(tx *gorm.DB) error {
+ var monitor ChannelRatioMonitor
+ findErr := lockForUpdate(tx).Where("channel_id = ?", channelId).First(&monitor).Error
+ if errors.Is(findErr, gorm.ErrRecordNotFound) {
+ monitor = ChannelRatioMonitor{ChannelId: channelId}
+ } else if findErr != nil {
+ return findErr
+ }
+
+ monitor.LastFetchStatus = ChannelRatioFetchStatusFailed
+ monitor.LastFetchError = message
+ monitor.LastFetchTime = common.GetTimestamp()
+ if monitor.ConsecutiveFailures < 0 {
+ monitor.ConsecutiveFailures = 0
+ }
+ monitor.ConsecutiveFailures++
+ return tx.Save(&monitor).Error
+ })
+}
+
+func RecordChannelRatioMonitorBalance(channelId int, balance *float64, fetchError string) error {
+ message := strings.TrimSpace(fetchError)
+ if balance != nil && (math.IsNaN(*balance) || math.IsInf(*balance, 0)) {
+ balance = nil
+ message = "上游余额不是有效数字"
+ }
+ messageRunes := []rune(message)
+ if len(messageRunes) > 255 {
+ message = string(messageRunes[:255])
+ }
+ if balance == nil && message == "" {
+ return nil
+ }
+
+ return DB.Transaction(func(tx *gorm.DB) error {
+ var monitor ChannelRatioMonitor
+ findErr := lockForUpdate(tx).Where("channel_id = ?", channelId).First(&monitor).Error
+ if errors.Is(findErr, gorm.ErrRecordNotFound) {
+ monitor = ChannelRatioMonitor{ChannelId: channelId}
+ } else if findErr != nil {
+ return findErr
+ }
+
+ if balance != nil {
+ value := *balance
+ monitor.UpstreamBalance = &value
+ monitor.LastBalanceTime = common.GetTimestamp()
+ monitor.LastBalanceError = ""
+ if monitor.BalanceWarningThreshold == nil || value >= *monitor.BalanceWarningThreshold {
+ monitor.BalanceAlertNotified = false
+ }
+ } else {
+ monitor.LastBalanceError = message
+ }
+ return tx.Save(&monitor).Error
+ })
+}
+
+func MarkChannelRatioMonitorBalanceAlertsNotified(channelIds []int) error {
+ if len(channelIds) == 0 {
+ return nil
+ }
+ return DB.Model(&ChannelRatioMonitor{}).
+ Where("channel_id IN ?", channelIds).
+ Update("balance_alert_notified", true).Error
+}
+
+func updateChannelRatioMonitor(channelId int, ratio float64, remark string, operatorId int, operatorUsername string, fetchedFromUpstream bool) (monitor ChannelRatioMonitor, created bool, changed bool, err error) {
+ err = DB.Transaction(func(tx *gorm.DB) error {
+ query := lockForUpdate(tx).Where("channel_id = ?", channelId)
+ findErr := query.First(&monitor).Error
+ now := common.GetTimestamp()
+ if errors.Is(findErr, gorm.ErrRecordNotFound) {
+ monitor = ChannelRatioMonitor{
+ ChannelId: channelId,
+ Ratio: ratio,
+ Remark: remark,
+ UpdatedTime: now,
+ UpdatedBy: operatorId,
+ UpdatedByUsername: operatorUsername,
+ }
+ if fetchedFromUpstream {
+ monitor.LastFetchStatus = ChannelRatioFetchStatusSucceeded
+ monitor.LastFetchTime = now
+ }
+ created = true
+ return tx.Create(&monitor).Error
+ }
+ if findErr != nil {
+ return findErr
+ }
+
+ if monitor.UpdatedTime == 0 {
+ monitor.Ratio = ratio
+ monitor.Remark = remark
+ monitor.UpdatedTime = now
+ monitor.UpdatedBy = operatorId
+ monitor.UpdatedByUsername = operatorUsername
+ if fetchedFromUpstream {
+ monitor.LastFetchStatus = ChannelRatioFetchStatusSucceeded
+ monitor.LastFetchError = ""
+ monitor.LastFetchTime = now
+ monitor.ConsecutiveFailures = 0
+ }
+ return tx.Save(&monitor).Error
+ }
+
+ changed = math.Abs(monitor.Ratio-ratio) > 1e-9
+ if changed {
+ history := ChannelRatioHistory{
+ ChannelId: channelId,
+ OldRatio: monitor.Ratio,
+ NewRatio: ratio,
+ Remark: remark,
+ CreatedTime: common.GetTimestamp(),
+ OperatorId: operatorId,
+ OperatorUsername: operatorUsername,
+ }
+ if err := tx.Create(&history).Error; err != nil {
+ return err
+ }
+ previousRatio := monitor.Ratio
+ monitor.PreviousRatio = &previousRatio
+ }
+
+ monitor.Ratio = ratio
+ monitor.Remark = remark
+ monitor.UpdatedTime = now
+ monitor.UpdatedBy = operatorId
+ monitor.UpdatedByUsername = operatorUsername
+ if fetchedFromUpstream {
+ monitor.LastFetchStatus = ChannelRatioFetchStatusSucceeded
+ monitor.LastFetchError = ""
+ monitor.LastFetchTime = now
+ monitor.ConsecutiveFailures = 0
+ }
+ return tx.Save(&monitor).Error
+ })
+ return monitor, created, changed, err
+}
+
+func GetChannelRatioHistory(channelId int, startIdx int, num int) (history []ChannelRatioHistory, total int64, err error) {
+ query := DB.Model(&ChannelRatioHistory{}).Where("channel_id = ?", channelId)
+ if err = query.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ err = query.Order("created_time desc, id desc").Limit(num).Offset(startIdx).Find(&history).Error
+ return history, total, err
+}
+
+func GetChannelRatioMonitorTasks(startIdx int, num int) (tasks []*SystemTask, total int64, err error) {
+ return GetChannelMonitorTasksByType(SystemTaskTypeChannelRatioMonitor, startIdx, num)
+}
+
+func GetChannelMonitorTasksByType(taskType string, startIdx int, num int) (tasks []*SystemTask, total int64, err error) {
+ query := DB.Model(&SystemTask{}).Where("type = ?", taskType)
+ if err = query.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error
+ return tasks, total, err
+}
diff --git a/model/channel_ratio_monitor_test.go b/model/channel_ratio_monitor_test.go
new file mode 100644
index 000000000000..74e6209e5a34
--- /dev/null
+++ b/model/channel_ratio_monitor_test.go
@@ -0,0 +1,460 @@
+package model
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func resetChannelRatioMonitorTables(t *testing.T) {
+ t.Helper()
+ require.NoError(t, DB.AutoMigrate(&Channel{}, &ChannelRatioMonitor{}, &ChannelRatioHistory{}))
+ for _, value := range []interface{}{&ChannelRatioHistory{}, &ChannelRatioMonitor{}, &Channel{}} {
+ require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(value).Error)
+ }
+ t.Cleanup(func() {
+ for _, value := range []interface{}{&ChannelRatioHistory{}, &ChannelRatioMonitor{}, &Channel{}} {
+ require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(value).Error)
+ }
+ })
+}
+
+func TestUpdateChannelRatioMonitorTracksOnlyRatioChanges(t *testing.T) {
+ resetChannelRatioMonitorTables(t)
+
+ monitor, created, changed, err := UpdateChannelRatioMonitor(10, 1.1, "baseline", 1, "root")
+ require.NoError(t, err)
+ assert.True(t, created)
+ assert.False(t, changed)
+ assert.Equal(t, 1.1, monitor.Ratio)
+ assert.Nil(t, monitor.PreviousRatio)
+
+ monitor, created, changed, err = UpdateChannelRatioMonitor(10, 1.1, "remark only", 1, "root")
+ require.NoError(t, err)
+ assert.False(t, created)
+ assert.False(t, changed)
+ assert.Equal(t, "remark only", monitor.Remark)
+ assert.Nil(t, monitor.PreviousRatio)
+
+ monitor, created, changed, err = UpdateChannelRatioMonitor(10, 1.25, "upstream changed", 2, "operator")
+ require.NoError(t, err)
+ assert.False(t, created)
+ assert.True(t, changed)
+ require.NotNil(t, monitor.PreviousRatio)
+ assert.Equal(t, 1.1, *monitor.PreviousRatio)
+ assert.Equal(t, 1.25, monitor.Ratio)
+
+ monitor, created, changed, err = UpdateChannelRatioMonitor(10, 1.25, "confirmed", 2, "operator")
+ require.NoError(t, err)
+ assert.False(t, created)
+ assert.False(t, changed)
+ require.NotNil(t, monitor.PreviousRatio)
+ assert.Equal(t, 1.1, *monitor.PreviousRatio)
+
+ history, total, err := GetChannelRatioHistory(10, 0, 100)
+ require.NoError(t, err)
+ require.Len(t, history, 1)
+ assert.EqualValues(t, 1, total)
+ assert.Equal(t, 1.1, history[0].OldRatio)
+ assert.Equal(t, 1.25, history[0].NewRatio)
+ assert.Equal(t, "upstream changed", history[0].Remark)
+ assert.Equal(t, 2, history[0].OperatorId)
+}
+
+func TestChannelRatioMonitorFetchStatusTracksFailureAndRecovery(t *testing.T) {
+ resetChannelRatioMonitorTables(t)
+
+ _, _, _, err := UpdateChannelRatioMonitor(10, 1.1, "manual baseline", 1, "root")
+ require.NoError(t, err)
+ require.NoError(t, RecordChannelRatioMonitorFetchFailure(10, "upstream timeout"))
+
+ monitor, err := GetChannelRatioMonitor(10)
+ require.NoError(t, err)
+ assert.Equal(t, ChannelRatioFetchStatusFailed, monitor.LastFetchStatus)
+ assert.Equal(t, "upstream timeout", monitor.LastFetchError)
+ assert.NotZero(t, monitor.LastFetchTime)
+ assert.Equal(t, 1, monitor.ConsecutiveFailures)
+
+ require.NoError(t, RecordChannelRatioMonitorFetchFailure(10, "upstream returned 502"))
+ monitor, err = GetChannelRatioMonitor(10)
+ require.NoError(t, err)
+ assert.Equal(t, 2, monitor.ConsecutiveFailures)
+ assert.Equal(t, "upstream returned 502", monitor.LastFetchError)
+
+ _, _, _, err = UpdateChannelRatioMonitor(10, 1.2, "manual correction", 1, "root")
+ require.NoError(t, err)
+ monitor, err = GetChannelRatioMonitor(10)
+ require.NoError(t, err)
+ assert.Equal(t, ChannelRatioFetchStatusFailed, monitor.LastFetchStatus)
+ assert.Equal(t, 2, monitor.ConsecutiveFailures)
+
+ monitor, _, changed, err := UpdateChannelRatioMonitorFromUpstream(10, 1.2, "upstream recovered", 0, "系统自动更新")
+ require.NoError(t, err)
+ assert.False(t, changed)
+ assert.Equal(t, ChannelRatioFetchStatusSucceeded, monitor.LastFetchStatus)
+ assert.Empty(t, monitor.LastFetchError)
+ assert.NotZero(t, monitor.LastFetchTime)
+ assert.Zero(t, monitor.ConsecutiveFailures)
+}
+
+func TestChannelRatioMonitorBalanceKeepsLastValueWhenRefreshFails(t *testing.T) {
+ resetChannelRatioMonitorTables(t)
+
+ balance := 12.75
+ require.NoError(t, RecordChannelRatioMonitorBalance(10, &balance, ""))
+ monitor, err := GetChannelRatioMonitor(10)
+ require.NoError(t, err)
+ require.NotNil(t, monitor.UpstreamBalance)
+ assert.InDelta(t, balance, *monitor.UpstreamBalance, 1e-9)
+ assert.NotZero(t, monitor.LastBalanceTime)
+ assert.Empty(t, monitor.LastBalanceError)
+ lastBalanceTime := monitor.LastBalanceTime
+
+ require.NoError(t, RecordChannelRatioMonitorBalance(10, nil, "upstream timeout"))
+ monitor, err = GetChannelRatioMonitor(10)
+ require.NoError(t, err)
+ require.NotNil(t, monitor.UpstreamBalance)
+ assert.InDelta(t, balance, *monitor.UpstreamBalance, 1e-9)
+ assert.Equal(t, lastBalanceTime, monitor.LastBalanceTime)
+ assert.Equal(t, "upstream timeout", monitor.LastBalanceError)
+}
+
+func TestChannelRatioMonitorBalanceAlertResetsAfterRecoveryOrThresholdChange(t *testing.T) {
+ resetChannelRatioMonitorTables(t)
+
+ threshold := 10.0
+ autoDisableThreshold := 5.0
+ _, err := SaveChannelRatioUpstreamConfig(
+ 10,
+ "new_api",
+ "https://upstream.example",
+ "vip",
+ "user",
+ 7,
+ "dashboard-token",
+ ChannelRatioUpstreamOptions{
+ SingleChannelAction: "none",
+ MultipleChannelsAction: "none",
+ BalanceWarningThreshold: &threshold,
+ BalanceAutoDisableThreshold: &autoDisableThreshold,
+ RatioSyncEnabled: true,
+ BalanceSyncEnabled: true,
+ },
+ )
+ require.NoError(t, err)
+
+ lowBalance := 5.0
+ require.NoError(t, RecordChannelRatioMonitorBalance(10, &lowBalance, ""))
+ require.NoError(t, MarkChannelRatioMonitorBalanceAlertsNotified([]int{10}))
+
+ monitor, err := GetChannelRatioMonitor(10)
+ require.NoError(t, err)
+ assert.True(t, monitor.BalanceAlertNotified)
+ require.NotNil(t, monitor.BalanceAutoDisableThreshold)
+ assert.Equal(t, autoDisableThreshold, *monitor.BalanceAutoDisableThreshold)
+
+ stillLowBalance := 9.99
+ require.NoError(t, RecordChannelRatioMonitorBalance(10, &stillLowBalance, ""))
+ monitor, err = GetChannelRatioMonitor(10)
+ require.NoError(t, err)
+ assert.True(t, monitor.BalanceAlertNotified)
+
+ recoveredBalance := threshold
+ require.NoError(t, RecordChannelRatioMonitorBalance(10, &recoveredBalance, ""))
+ monitor, err = GetChannelRatioMonitor(10)
+ require.NoError(t, err)
+ assert.False(t, monitor.BalanceAlertNotified)
+
+ require.NoError(t, MarkChannelRatioMonitorBalanceAlertsNotified([]int{10}))
+ newThreshold := 12.0
+ monitor, err = SaveChannelRatioUpstreamConfig(
+ 10,
+ "new_api",
+ "https://upstream.example",
+ "vip",
+ "user",
+ 7,
+ "dashboard-token",
+ ChannelRatioUpstreamOptions{
+ SingleChannelAction: "none",
+ MultipleChannelsAction: "none",
+ BalanceWarningThreshold: &newThreshold,
+ BalanceAutoDisableThreshold: &autoDisableThreshold,
+ RatioSyncEnabled: true,
+ BalanceSyncEnabled: true,
+ },
+ )
+ require.NoError(t, err)
+ assert.False(t, monitor.BalanceAlertNotified)
+ require.NotNil(t, monitor.BalanceWarningThreshold)
+ assert.Equal(t, newThreshold, *monitor.BalanceWarningThreshold)
+}
+
+func TestChannelRatioUpstreamConfigDoesNotCreateFalseBaseline(t *testing.T) {
+ resetChannelRatioMonitorTables(t)
+
+ monitor, err := SaveChannelRatioUpstreamConfig(11, "new_api", "https://upstream.example", "vip", "user", 7, "dashboard-token", ChannelRatioUpstreamOptions{
+ SingleChannelAction: "update_group_ratio",
+ MultipleChannelsAction: "disable_channel",
+ RatioSyncEnabled: true,
+ BalanceSyncEnabled: true,
+ CostConversion: `{"mode":"recharge","paid_cny":100,"credited_usd":200}`,
+ })
+ require.NoError(t, err)
+ assert.Zero(t, monitor.UpdatedTime)
+ assert.Equal(t, "dashboard-token", monitor.UpstreamAccessToken)
+ assert.Equal(t, "update_group_ratio", monitor.SingleChannelAction)
+ assert.Equal(t, "disable_channel", monitor.MultipleChannelsAction)
+ assert.JSONEq(t, `{"mode":"recharge","paid_cny":100,"credited_usd":200}`, monitor.CostConversion)
+ serialized, err := common.Marshal(monitor)
+ require.NoError(t, err)
+ assert.NotContains(t, string(serialized), "dashboard-token")
+
+ monitor, created, changed, err := UpdateChannelRatioMonitor(11, 0.8, "first fetch", 1, "root")
+ require.NoError(t, err)
+ assert.False(t, created)
+ assert.False(t, changed)
+ assert.Equal(t, 0.8, monitor.Ratio)
+ assert.Nil(t, monitor.PreviousRatio)
+ assert.Equal(t, "vip", monitor.UpstreamGroup)
+ assert.Equal(t, "dashboard-token", monitor.UpstreamAccessToken)
+ assert.JSONEq(t, `{"mode":"recharge","paid_cny":100,"credited_usd":200}`, monitor.CostConversion)
+
+ history, total, err := GetChannelRatioHistory(11, 0, 100)
+ require.NoError(t, err)
+ assert.Empty(t, history)
+ assert.Zero(t, total)
+
+ upstreamBalance := 9.5
+ require.NoError(t, RecordChannelRatioMonitorBalance(11, &upstreamBalance, ""))
+ monitor, err = SaveChannelRatioUpstreamConfig(11, "new_api", "https://upstream.example", "public", "public", 0, "", ChannelRatioUpstreamOptions{
+ SingleChannelAction: "update_group_ratio",
+ MultipleChannelsAction: "disable_channel",
+ RatioSyncEnabled: true,
+ BalanceSyncEnabled: true,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 0.8, monitor.Ratio)
+ assert.NotZero(t, monitor.UpdatedTime)
+ assert.Empty(t, monitor.UpstreamAccessToken)
+ assert.Nil(t, monitor.UpstreamBalance)
+ assert.Zero(t, monitor.LastBalanceTime)
+ assert.Empty(t, monitor.LastBalanceError)
+}
+
+func TestChannelRatioUpstreamConfigStoresCustomConfig(t *testing.T) {
+ resetChannelRatioMonitorTables(t)
+ customConfig := `{"version":1,"ratio":{"source":"fixed","fixed_value":0.8},"balance":{"source":"fixed","fixed_value":20}}`
+
+ monitor, err := SaveChannelRatioUpstreamConfig(12, "custom", "https://custom.example", "", "custom", 0, "", ChannelRatioUpstreamOptions{
+ RatioSyncEnabled: true,
+ BalanceSyncEnabled: true,
+ CustomUpstreamConfig: customConfig,
+ })
+ require.NoError(t, err)
+ assert.JSONEq(t, customConfig, monitor.CustomUpstreamConfig)
+
+ serialized, err := common.Marshal(monitor)
+ require.NoError(t, err)
+ assert.NotContains(t, string(serialized), "custom_upstream_config")
+}
+
+func TestChannelRatioUpstreamTokenIsNotSerialized(t *testing.T) {
+ resetChannelRatioMonitorTables(t)
+
+ monitor, err := SaveChannelRatioUpstreamConfig(
+ 12,
+ "sub2api",
+ "https://upstream.example",
+ "vip",
+ "token",
+ 0,
+ "stored-access-token",
+ ChannelRatioUpstreamOptions{
+ SingleChannelAction: "none",
+ MultipleChannelsAction: "none",
+ RatioSyncEnabled: true,
+ BalanceSyncEnabled: true,
+ },
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "stored-access-token", monitor.UpstreamAccessToken)
+
+ serialized, err := common.Marshal(monitor)
+ require.NoError(t, err)
+ assert.NotContains(t, string(serialized), "stored-access-token")
+
+ monitor, err = SaveChannelRatioUpstreamConfig(12, "new_api", "https://upstream.example", "public", "public", 0, "", ChannelRatioUpstreamOptions{
+ SingleChannelAction: "none",
+ MultipleChannelsAction: "none",
+ RatioSyncEnabled: true,
+ BalanceSyncEnabled: true,
+ })
+ require.NoError(t, err)
+ assert.Empty(t, monitor.UpstreamAccessToken)
+}
+
+func TestGetAllChannelsForMonitorIncludesDisabledChannelsWithoutKeys(t *testing.T) {
+ resetChannelRatioMonitorTables(t)
+
+ highPriority := int64(10)
+ lowPriority := int64(5)
+ channels := []Channel{
+ {Id: 21, Name: "enabled", Key: "enabled-secret", Status: common.ChannelStatusEnabled, Priority: &highPriority},
+ {Id: 22, Name: "disabled", Key: "disabled-secret", Status: common.ChannelStatusManuallyDisabled, Priority: &lowPriority},
+ }
+ require.NoError(t, DB.Create(&channels).Error)
+
+ result, err := GetAllChannelsForMonitor()
+ require.NoError(t, err)
+ require.Len(t, result, 2)
+ assert.Equal(t, 21, result[0].Id)
+ assert.Equal(t, common.ChannelStatusEnabled, result[0].Status)
+ assert.Empty(t, result[0].Key)
+ assert.Equal(t, 22, result[1].Id)
+ assert.Equal(t, common.ChannelStatusManuallyDisabled, result[1].Status)
+ assert.Empty(t, result[1].Key)
+}
+
+func TestGetChannelRatioMonitorTasksFiltersOrdersAndPaginatesRuns(t *testing.T) {
+ require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&SystemTask{}).Error)
+ t.Cleanup(func() {
+ require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&SystemTask{}).Error)
+ })
+
+ tasks := []SystemTask{
+ {TaskID: "other-task", Type: SystemTaskTypeChannelTest, Status: SystemTaskStatusSucceeded},
+ {TaskID: "monitor-oldest", Type: SystemTaskTypeChannelRatioMonitor, Status: SystemTaskStatusSucceeded},
+ {TaskID: "monitor-middle", Type: SystemTaskTypeChannelRatioMonitor, Status: SystemTaskStatusFailed},
+ {TaskID: "monitor-newest", Type: SystemTaskTypeChannelRatioMonitor, Status: SystemTaskStatusSucceeded},
+ }
+ require.NoError(t, DB.Create(&tasks).Error)
+
+ result, total, err := GetChannelRatioMonitorTasks(0, 2)
+ require.NoError(t, err)
+ assert.EqualValues(t, 3, total)
+ require.Len(t, result, 2)
+ assert.Equal(t, "monitor-newest", result[0].TaskID)
+ assert.Equal(t, "monitor-middle", result[1].TaskID)
+
+ result, total, err = GetChannelRatioMonitorTasks(2, 2)
+ require.NoError(t, err)
+ assert.EqualValues(t, 3, total)
+ require.Len(t, result, 1)
+ assert.Equal(t, "monitor-oldest", result[0].TaskID)
+}
+
+func TestChannelSmartScheduleConfigAndResultPersistWithoutRatioBaseline(t *testing.T) {
+ resetChannelRatioMonitorTables(t)
+
+ monitor, err := SaveChannelSmartScheduleConfig(31, ChannelSmartScheduleConfigOptions{Excluded: false})
+ require.NoError(t, err)
+ assert.Zero(t, monitor.UpdatedTime)
+ assert.False(t, monitor.SmartScheduleExcluded)
+
+ score := 0.82
+ require.NoError(t, SaveChannelSmartScheduleResults([]ChannelSmartScheduleResultUpdate{
+ {
+ ChannelId: 31,
+ Status: ChannelSmartScheduleStatusSucceeded,
+ Score: &score,
+ Priority: 100,
+ Weight: 80,
+ Time: 123,
+ },
+ }))
+
+ monitor, err = GetChannelRatioMonitor(31)
+ require.NoError(t, err)
+ assert.Zero(t, monitor.UpdatedTime)
+ assert.Equal(t, ChannelSmartScheduleStatusSucceeded, monitor.LastScheduleStatus)
+ require.NotNil(t, monitor.LastScheduleScore)
+ assert.InDelta(t, score, *monitor.LastScheduleScore, 1e-9)
+ assert.Equal(t, int64(100), monitor.LastSchedulePriority)
+ assert.Equal(t, uint(80), monitor.LastScheduleWeight)
+ assert.Equal(t, int64(123), monitor.LastScheduleTime)
+}
+
+func TestDropLegacyChannelSmartScheduleGroupColumnPreservesMonitorData(t *testing.T) {
+ resetChannelRatioMonitorTables(t)
+ require.NoError(t, dropLegacyChannelSmartScheduleGroupColumn())
+ t.Cleanup(func() {
+ require.NoError(t, dropLegacyChannelSmartScheduleGroupColumn())
+ })
+
+ require.NoError(t, DB.Create(&ChannelRatioMonitor{
+ ChannelId: 33,
+ Ratio: 1.25,
+ UpdatedTime: 100,
+ }).Error)
+ legacyModel := &channelRatioMonitorLegacyScheduleGroup{}
+ require.NoError(t, DB.Migrator().AddColumn(legacyModel, "SmartScheduleGroup"))
+ require.True(t, DB.Migrator().HasColumn(legacyModel, "SmartScheduleGroup"))
+ require.NoError(t, DB.Table("channel_ratio_monitors").
+ Where("channel_id = ?", 33).
+ Update("smart_schedule_group", "vip").Error)
+
+ require.NoError(t, dropLegacyChannelSmartScheduleGroupColumn())
+ assert.False(t, DB.Migrator().HasColumn(legacyModel, "SmartScheduleGroup"))
+ monitor, err := GetChannelRatioMonitor(33)
+ require.NoError(t, err)
+ assert.Equal(t, 1.25, monitor.Ratio)
+ assert.Equal(t, int64(100), monitor.UpdatedTime)
+}
+
+func TestChannelSmartSchedulePriorityWeightUpdatesKeepAbilitiesInSync(t *testing.T) {
+ resetChannelRatioMonitorTables(t)
+ require.NoError(t, DB.AutoMigrate(&Ability{}))
+ t.Cleanup(func() {
+ require.NoError(t, DB.Where("channel_id = ?", 32).Delete(&Ability{}).Error)
+ })
+
+ priority := int64(0)
+ weight := uint(0)
+ channel := Channel{
+ Id: 32,
+ Name: "scheduled-channel",
+ Key: "secret",
+ Status: common.ChannelStatusEnabled,
+ Group: "vip",
+ Models: "model-a",
+ Priority: &priority,
+ Weight: &weight,
+ }
+ require.NoError(t, DB.Create(&channel).Error)
+ require.NoError(t, DB.Create(&Ability{
+ Group: "vip",
+ Model: "model-a",
+ ChannelId: channel.Id,
+ Enabled: true,
+ Priority: &priority,
+ Weight: weight,
+ }).Error)
+
+ targetPriority := int64(100)
+ targetWeight := uint(75)
+ require.NoError(t, UpdateChannelSmartSchedulePriorityWeight(channel.Id, &targetPriority, &targetWeight))
+
+ var storedChannel Channel
+ require.NoError(t, DB.Where("id = ?", channel.Id).First(&storedChannel).Error)
+ assert.Equal(t, targetPriority, storedChannel.GetPriority())
+ assert.Equal(t, int(targetWeight), storedChannel.GetWeight())
+
+ var ability Ability
+ require.NoError(t, DB.Where("channel_id = ?", channel.Id).First(&ability).Error)
+ require.NotNil(t, ability.Priority)
+ assert.Equal(t, targetPriority, *ability.Priority)
+ assert.Equal(t, targetWeight, ability.Weight)
+
+ require.NoError(t, ResetChannelSmartSchedulePriorityWeight([]int{channel.Id}, 10))
+ require.NoError(t, DB.Where("id = ?", channel.Id).First(&storedChannel).Error)
+ assert.Equal(t, int64(0), storedChannel.GetPriority())
+ assert.Equal(t, 10, storedChannel.GetWeight())
+ require.NoError(t, DB.Where("channel_id = ?", channel.Id).First(&ability).Error)
+ require.NotNil(t, ability.Priority)
+ assert.Equal(t, int64(0), *ability.Priority)
+ assert.Equal(t, uint(10), ability.Weight)
+}
diff --git a/model/channel_selection_options.go b/model/channel_selection_options.go
new file mode 100644
index 000000000000..4102e3f365a7
--- /dev/null
+++ b/model/channel_selection_options.go
@@ -0,0 +1,44 @@
+package model
+
+import "gorm.io/gorm"
+
+// ChannelSelectionOptions carries request-scoped channel exclusions without
+// changing the existing selector call sites.
+type ChannelSelectionOptions struct {
+ ExcludedChannelIds []int
+}
+
+func (options ChannelSelectionOptions) HasExcludedChannels() bool {
+ return len(options.ExcludedChannelIds) > 0
+}
+
+func channelSelectionOptions(options []ChannelSelectionOptions) ChannelSelectionOptions {
+ if len(options) == 0 {
+ return ChannelSelectionOptions{}
+ }
+ return options[len(options)-1]
+}
+
+func filterChannelIDsBySelectionOptions(channelIDs []int, options ChannelSelectionOptions) []int {
+ if len(channelIDs) == 0 || !options.HasExcludedChannels() {
+ return channelIDs
+ }
+ excluded := make(map[int]struct{}, len(options.ExcludedChannelIds))
+ for _, channelID := range options.ExcludedChannelIds {
+ excluded[channelID] = struct{}{}
+ }
+ filtered := make([]int, 0, len(channelIDs))
+ for _, channelID := range channelIDs {
+ if _, ok := excluded[channelID]; !ok {
+ filtered = append(filtered, channelID)
+ }
+ }
+ return filtered
+}
+
+func applyChannelSelectionOptions(query *gorm.DB, options ChannelSelectionOptions) *gorm.DB {
+ if query == nil || !options.HasExcludedChannels() {
+ return query
+ }
+ return query.Where("channel_id NOT IN ?", options.ExcludedChannelIds)
+}
diff --git a/model/channel_selection_options_test.go b/model/channel_selection_options_test.go
new file mode 100644
index 000000000000..f9a9dcab991c
--- /dev/null
+++ b/model/channel_selection_options_test.go
@@ -0,0 +1,94 @@
+package model
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetRandomSatisfiedChannelExcludesFailedChannels(t *testing.T) {
+ originalMemoryCacheEnabled := common.MemoryCacheEnabled
+ channelSyncLock.Lock()
+ originalGroup2Model2Channels := group2model2channels
+ originalChannelsIDM := channelsIDM
+ originalAdvancedConfigs := channel2advancedCustomConfig
+ priority100 := int64(100)
+ priority90 := int64(90)
+ priority80 := int64(80)
+ weight := uint(10)
+ group2model2channels = map[string]map[string][]int{
+ "vip": {"model-a": {1, 2, 3}},
+ }
+ channelsIDM = map[int]*Channel{
+ 1: {Id: 1, Status: common.ChannelStatusEnabled, Priority: &priority100, Weight: &weight},
+ 2: {Id: 2, Status: common.ChannelStatusEnabled, Priority: &priority90, Weight: &weight},
+ 3: {Id: 3, Status: common.ChannelStatusEnabled, Priority: &priority80, Weight: &weight},
+ }
+ channel2advancedCustomConfig = nil
+ channelSyncLock.Unlock()
+ common.MemoryCacheEnabled = true
+ t.Cleanup(func() {
+ common.MemoryCacheEnabled = originalMemoryCacheEnabled
+ channelSyncLock.Lock()
+ group2model2channels = originalGroup2Model2Channels
+ channelsIDM = originalChannelsIDM
+ channel2advancedCustomConfig = originalAdvancedConfigs
+ channelSyncLock.Unlock()
+ })
+
+ channel, err := GetRandomSatisfiedChannel("vip", "model-a", 5, "", ChannelSelectionOptions{ExcludedChannelIds: []int{1}})
+ require.NoError(t, err)
+ require.NotNil(t, channel)
+ assert.Equal(t, 2, channel.Id)
+
+ channel, err = GetRandomSatisfiedChannel("vip", "model-a", 0, "", ChannelSelectionOptions{ExcludedChannelIds: []int{1, 2}})
+ require.NoError(t, err)
+ require.NotNil(t, channel)
+ assert.Equal(t, 3, channel.Id)
+
+ channel, err = GetRandomSatisfiedChannel("vip", "model-a", 0, "", ChannelSelectionOptions{ExcludedChannelIds: []int{1, 2, 3}})
+ require.NoError(t, err)
+ assert.Nil(t, channel)
+}
+
+func TestGetRandomSatisfiedChannelExcludesFailedChannelsWithoutMemoryCache(t *testing.T) {
+ originalMemoryCacheEnabled := common.MemoryCacheEnabled
+ common.MemoryCacheEnabled = false
+ t.Cleanup(func() {
+ common.MemoryCacheEnabled = originalMemoryCacheEnabled
+ })
+
+ channelIDs := []int{9101, 9102, 9103}
+ require.NoError(t, DB.Where("channel_id IN ?", channelIDs).Delete(&Ability{}).Error)
+ require.NoError(t, DB.Where("id IN ?", channelIDs).Delete(&Channel{}).Error)
+ t.Cleanup(func() {
+ require.NoError(t, DB.Where("channel_id IN ?", channelIDs).Delete(&Ability{}).Error)
+ require.NoError(t, DB.Where("id IN ?", channelIDs).Delete(&Channel{}).Error)
+ })
+
+ priority100 := int64(100)
+ priority90 := int64(90)
+ priority80 := int64(80)
+ weight := uint(10)
+ require.NoError(t, DB.Create(&[]Channel{
+ {Id: 9101, Name: "first", Group: "vip", Models: "model-a", Status: common.ChannelStatusEnabled, Priority: &priority100, Weight: &weight},
+ {Id: 9102, Name: "second", Group: "vip", Models: "model-a", Status: common.ChannelStatusEnabled, Priority: &priority90, Weight: &weight},
+ {Id: 9103, Name: "third", Group: "vip", Models: "model-a", Status: common.ChannelStatusEnabled, Priority: &priority80, Weight: &weight},
+ }).Error)
+ require.NoError(t, DB.Create(&[]Ability{
+ {Group: "vip", Model: "model-a", ChannelId: 9101, Enabled: true, Priority: &priority100, Weight: weight},
+ {Group: "vip", Model: "model-a", ChannelId: 9102, Enabled: true, Priority: &priority90, Weight: weight},
+ {Group: "vip", Model: "model-a", ChannelId: 9103, Enabled: true, Priority: &priority80, Weight: weight},
+ }).Error)
+
+ channel, err := GetRandomSatisfiedChannel("vip", "model-a", 5, "", ChannelSelectionOptions{ExcludedChannelIds: []int{9101}})
+ require.NoError(t, err)
+ require.NotNil(t, channel)
+ assert.Equal(t, 9102, channel.Id)
+
+ channel, err = GetRandomSatisfiedChannel("vip", "model-a", 5, "", ChannelSelectionOptions{ExcludedChannelIds: channelIDs})
+ require.NoError(t, err)
+ assert.Nil(t, channel)
+}
diff --git a/model/log.go b/model/log.go
index 506bd504b686..8a1263e32035 100644
--- a/model/log.go
+++ b/model/log.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "strconv"
"strings"
"time"
@@ -70,6 +71,7 @@ type Log struct {
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
UseTime int `json:"use_time" gorm:"default:0"`
IsStream bool `json:"is_stream"`
+ IsRetryAttempt bool `json:"-"`
ChannelId int `json:"channel" gorm:"index"`
ChannelName string `json:"channel_name" gorm:"->"`
TokenId int `json:"token_id" gorm:"default:0;index"`
@@ -118,6 +120,31 @@ func formatUserLogs(logs []*Log, startIdx int) {
logs[i].ChannelName = ""
var otherMap map[string]interface{}
otherMap, _ = common.StrToMap(logs[i].Other)
+ if logs[i].Type == LogTypeError {
+ statusCode := 0
+ if otherMap != nil {
+ switch value := otherMap["status_code"].(type) {
+ case int:
+ statusCode = value
+ case int64:
+ statusCode = int(value)
+ case float64:
+ statusCode = int(value)
+ case string:
+ statusCode, _ = strconv.Atoi(strings.TrimSpace(value))
+ }
+ }
+ if statusCode == 0 && strings.HasPrefix(logs[i].Content, "status_code=") {
+ value := strings.TrimPrefix(logs[i].Content, "status_code=")
+ if end := strings.IndexAny(value, ", \t\r\n"); end >= 0 {
+ value = value[:end]
+ }
+ statusCode, _ = strconv.Atoi(value)
+ }
+ if statusCode >= 100 && statusCode <= 599 {
+ logs[i].Content = fmt.Sprintf("status_code=%d", statusCode)
+ }
+ }
if otherMap != nil {
// Remove admin-only debug fields.
delete(otherMap, "admin_info")
@@ -131,12 +158,16 @@ func formatUserLogs(logs []*Log, startIdx int) {
assignDisplayLogIds(logs, startIdx)
}
+func userVisibleLogs(tx *gorm.DB) *gorm.DB {
+ return tx.Where("(logs.is_retry_attempt = ? OR logs.is_retry_attempt IS NULL)", false)
+}
+
func GetLogByTokenId(tokenId int) (logs []*Log, err error) {
order := "id desc"
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
order = clickHouseLogOrder("")
}
- err = LOG_DB.Model(&Log{}).Where("token_id = ?", tokenId).Order(order).Limit(common.MaxRecentItems).Find(&logs).Error
+ err = userVisibleLogs(LOG_DB.Model(&Log{})).Where("token_id = ?", tokenId).Order(order).Limit(common.MaxRecentItems).Find(&logs).Error
formatUserLogs(logs, 0)
return logs, err
}
@@ -280,7 +311,7 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s
}
func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
- isStream bool, group string, other map[string]interface{}) {
+ isStream bool, group string, other map[string]interface{}, isRetryAttempt bool) {
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, common.LocalLogPreview(content)))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
@@ -308,6 +339,7 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
TokenId: tokenId,
UseTime: useTimeSeconds,
IsStream: isStream,
+ IsRetryAttempt: isRetryAttempt,
Group: group,
Ip: func() string {
if needRecordIp {
@@ -564,9 +596,9 @@ const logSearchCountLimit = 10000
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {
- tx = LOG_DB.Where("logs.user_id = ?", userId)
+ tx = userVisibleLogs(LOG_DB).Where("logs.user_id = ?", userId)
} else {
- tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType)
+ tx = userVisibleLogs(LOG_DB).Where("logs.user_id = ? and logs.type = ?", userId, logType)
}
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
diff --git a/model/log_format_test.go b/model/log_format_test.go
index f580dda637af..857e4b1e838c 100644
--- a/model/log_format_test.go
+++ b/model/log_format_test.go
@@ -8,6 +8,32 @@ import (
"github.com/stretchr/testify/require"
)
+func TestFormatUserLogsShowsOnlyStatusCodeForRelayErrors(t *testing.T) {
+ logs := []*Log{
+ {
+ Type: LogTypeError,
+ Content: "status_code=503, 服务暂时不可用,请稍后重试",
+ Other: common.MapToJsonStr(map[string]interface{}{"status_code": 503}),
+ },
+ {
+ Type: LogTypeError,
+ Content: "status_code=524, upstream timeout",
+ Other: "{}",
+ },
+ {
+ Type: LogTypeConsume,
+ Content: "正常消费日志",
+ Other: "{}",
+ },
+ }
+
+ formatUserLogs(logs, 0)
+
+ require.Equal(t, "status_code=503", logs[0].Content)
+ require.Equal(t, "status_code=524", logs[1].Content)
+ require.Equal(t, "正常消费日志", logs[2].Content)
+}
+
// TestFormatUserLogsStripsQuotaSaturation verifies the admin-only quota
// saturation marker (nested under other.admin_info) is removed for non-admin
// log views, since formatUserLogs strips the whole admin_info object.
diff --git a/model/log_visibility_test.go b/model/log_visibility_test.go
new file mode 100644
index 000000000000..2226106a1abb
--- /dev/null
+++ b/model/log_visibility_test.go
@@ -0,0 +1,117 @@
+package model
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func TestUserLogQueriesHideRetryAttempts(t *testing.T) {
+ originalLogDB := LOG_DB
+ originalLogDatabaseType := common.LogDatabaseType()
+ t.Cleanup(func() {
+ LOG_DB = originalLogDB
+ common.SetLogDatabaseType(originalLogDatabaseType)
+ })
+
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "logs.db")), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ require.NoError(t, sqlDB.Close())
+ })
+ require.NoError(t, db.AutoMigrate(&Log{}))
+ LOG_DB = db
+ common.SetLogDatabaseType(common.DatabaseTypeSQLite)
+
+ logs := []*Log{
+ {
+ UserId: 1,
+ CreatedAt: 1,
+ Type: LogTypeError,
+ Content: "status_code=503, temporary upstream failure",
+ TokenId: 7,
+ RequestId: "request-retried-successfully",
+ Other: `{"status_code":503}`,
+ IsRetryAttempt: true,
+ },
+ {
+ UserId: 1,
+ CreatedAt: 2,
+ Type: LogTypeConsume,
+ Content: "final success",
+ TokenId: 7,
+ RequestId: "request-retried-successfully",
+ Other: `{}`,
+ },
+ {
+ UserId: 1,
+ CreatedAt: 3,
+ Type: LogTypeError,
+ Content: "status_code=502, retryable upstream failure",
+ TokenId: 7,
+ RequestId: "request-final-failure",
+ Other: `{"status_code":502}`,
+ IsRetryAttempt: true,
+ },
+ {
+ UserId: 1,
+ CreatedAt: 4,
+ Type: LogTypeError,
+ Content: "status_code=500, final upstream failure",
+ TokenId: 7,
+ RequestId: "request-final-failure",
+ Other: `{"status_code":500}`,
+ },
+ }
+ require.NoError(t, db.Create(&logs).Error)
+
+ userLogs, total, err := GetUserLogs(1, LogTypeUnknown, 0, 0, "", "", 0, 10, "", "", "")
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), total)
+ require.Len(t, userLogs, 2)
+ assert.ElementsMatch(t,
+ []string{"request-retried-successfully", "request-final-failure"},
+ []string{userLogs[0].RequestId, userLogs[1].RequestId},
+ )
+ for _, log := range userLogs {
+ assert.NotContains(t, log.Content, "temporary upstream failure")
+ }
+
+ userErrorLogs, errorTotal, err := GetUserLogs(1, LogTypeError, 0, 0, "", "", 0, 10, "", "", "")
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), errorTotal)
+ require.Len(t, userErrorLogs, 1)
+ assert.Equal(t, "request-final-failure", userErrorLogs[0].RequestId)
+ assert.Equal(t, "status_code=500", userErrorLogs[0].Content)
+
+ tokenLogs, err := GetLogByTokenId(7)
+ require.NoError(t, err)
+ assert.Len(t, tokenLogs, 2)
+
+ adminLogs, adminTotal, err := GetAllLogs(LogTypeUnknown, 0, 0, "", "", "", 0, 10, 0, "", "", "")
+ require.NoError(t, err)
+ assert.Equal(t, int64(4), adminTotal)
+ require.Len(t, adminLogs, 4)
+ retryAttemptCount := 0
+ for _, log := range adminLogs {
+ if log.IsRetryAttempt {
+ retryAttemptCount++
+ }
+ }
+ assert.Equal(t, 2, retryAttemptCount)
+ assert.Contains(t, adminLogs[3].Content, "temporary upstream failure")
+ assert.Contains(t, adminLogs[0].Content, "final upstream failure")
+}
+
+func TestClickHouseRetryAttemptColumn(t *testing.T) {
+ assert.Contains(t, clickHouseLogCreateTableSQL(0), "is_retry_attempt UInt8 DEFAULT 0")
+ assert.Equal(t, "ALTER TABLE logs ADD COLUMN IF NOT EXISTS is_retry_attempt UInt8 DEFAULT 0", clickHouseLogRetryAttemptColumnSQL)
+}
diff --git a/model/main.go b/model/main.go
index 76f98a59c307..f4730780c5cd 100644
--- a/model/main.go
+++ b/model/main.go
@@ -297,12 +297,17 @@ func migrateDB() error {
&SystemInstance{},
&SystemTask{},
&SystemTaskLock{},
+ &ChannelRatioMonitor{},
+ &ChannelRatioHistory{},
&CasbinRule{},
&AuthzRole{},
)
if err != nil {
return err
}
+ if err := dropLegacyChannelSmartScheduleGroupColumn(); err != nil {
+ return err
+ }
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
@@ -351,6 +356,8 @@ func migrateDBFast() error {
{&SystemInstance{}, "SystemInstance"},
{&SystemTask{}, "SystemTask"},
{&SystemTaskLock{}, "SystemTaskLock"},
+ {&ChannelRatioMonitor{}, "ChannelRatioMonitor"},
+ {&ChannelRatioHistory{}, "ChannelRatioHistory"},
}
// 动态计算migration数量,确保errChan缓冲区足够大
errChan := make(chan error, len(migrations))
@@ -375,6 +382,9 @@ func migrateDBFast() error {
return err
}
}
+ if err := dropLegacyChannelSmartScheduleGroupColumn(); err != nil {
+ return err
+ }
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
@@ -388,6 +398,24 @@ func migrateDBFast() error {
return nil
}
+// SQLite needs the removed field in the migration schema while GORM rebuilds
+// the table for DropColumn. This type is not part of the runtime data model.
+type channelRatioMonitorLegacyScheduleGroup struct {
+ SmartScheduleGroup string `gorm:"type:varchar(64)"`
+}
+
+func (channelRatioMonitorLegacyScheduleGroup) TableName() string {
+ return "channel_ratio_monitors"
+}
+
+func dropLegacyChannelSmartScheduleGroupColumn() error {
+ legacyModel := &channelRatioMonitorLegacyScheduleGroup{}
+ if !DB.Migrator().HasTable(legacyModel) || !DB.Migrator().HasColumn(legacyModel, "SmartScheduleGroup") {
+ return nil
+ }
+ return DB.Migrator().DropColumn(legacyModel, "SmartScheduleGroup")
+}
+
func migrateLOGDB() error {
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
return migrateClickHouseLogDB()
@@ -400,9 +428,14 @@ func migrateClickHouseLogDB() error {
if err := LOG_DB.Exec(clickHouseLogCreateTableSQL(ttlDays)).Error; err != nil {
return err
}
+ if err := LOG_DB.Exec(clickHouseLogRetryAttemptColumnSQL).Error; err != nil {
+ return err
+ }
return syncClickHouseLogTTL(ttlDays)
}
+const clickHouseLogRetryAttemptColumnSQL = "ALTER TABLE logs ADD COLUMN IF NOT EXISTS is_retry_attempt UInt8 DEFAULT 0"
+
func clickHouseLogTTLDays() int {
ttlDays := common.GetEnvOrDefault("LOG_SQL_CLICKHOUSE_TTL_DAYS", 0)
if ttlDays < 0 {
@@ -442,6 +475,7 @@ CREATE TABLE IF NOT EXISTS logs (
completion_tokens Int32 DEFAULT 0,
use_time Int32 DEFAULT 0,
is_stream UInt8 DEFAULT 0,
+ is_retry_attempt UInt8 DEFAULT 0,
channel_id Int32 DEFAULT 0,
token_id Int32 DEFAULT 0,
`+"`group`"+` String DEFAULT '',
diff --git a/model/system_task.go b/model/system_task.go
index c811409b487d..8b3c3f959078 100644
--- a/model/system_task.go
+++ b/model/system_task.go
@@ -16,11 +16,12 @@ const (
SystemTaskStatusSucceeded SystemTaskStatus = "succeeded"
SystemTaskStatusFailed SystemTaskStatus = "failed"
- SystemTaskTypeLogCleanup = "log_cleanup"
- SystemTaskTypeChannelTest = "channel_test"
- SystemTaskTypeModelUpdate = "model_update"
- SystemTaskTypeMidjourneyPoll = "midjourney_poll"
- SystemTaskTypeAsyncTaskPoll = "async_task_poll"
+ SystemTaskTypeLogCleanup = "log_cleanup"
+ SystemTaskTypeChannelTest = "channel_test"
+ SystemTaskTypeModelUpdate = "model_update"
+ SystemTaskTypeChannelRatioMonitor = "channel_ratio_monitor"
+ SystemTaskTypeMidjourneyPoll = "midjourney_poll"
+ SystemTaskTypeAsyncTaskPoll = "async_task_poll"
)
var ErrSystemTaskLockLost = errors.New("system task lock lost")
diff --git a/router/api-router.go b/router/api-router.go
index 83f9259b2132..6016df0c16a9 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -228,6 +228,7 @@ func SetApiRouter(router *gin.Engine) {
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
}
registerChannelRoutes(apiRouter)
+ registerChannelMonitorRoutes(apiRouter)
registerAuthzRoutes(apiRouter)
tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth())
diff --git a/router/channel-monitor-router.go b/router/channel-monitor-router.go
new file mode 100644
index 000000000000..aa07e7b2ba01
--- /dev/null
+++ b/router/channel-monitor-router.go
@@ -0,0 +1,37 @@
+package router
+
+import (
+ "github.com/QuantumNous/new-api/controller"
+ "github.com/QuantumNous/new-api/middleware"
+
+ "github.com/gin-gonic/gin"
+)
+
+func registerChannelMonitorRoutes(apiRouter *gin.RouterGroup) {
+ monitorRoute := apiRouter.Group("/channel_monitor")
+ monitorRoute.Use(middleware.RootAuth())
+ {
+ monitorRoute.GET("/", controller.GetChannelMonitorOverview)
+ monitorRoute.GET("/cost", controller.GetChannelMonitorCostOverview)
+ monitorRoute.GET("/performance", controller.GetChannelMonitorPerformance)
+ monitorRoute.GET("/success/detail", controller.GetChannelMonitorSuccessDetail)
+ monitorRoute.GET("/tasks", controller.ListChannelMonitorTasks)
+ monitorRoute.PUT("/settings", controller.UpdateChannelMonitorSettings)
+ monitorRoute.POST("/ratio/run", controller.RunChannelMonitorRatioUpdate)
+ monitorRoute.POST("/schedule/run", controller.RunChannelMonitorSmartSchedule)
+ monitorRoute.PUT("/order", controller.UpdateChannelMonitorChannelOrder)
+ monitorRoute.PUT("/channel/:id", controller.UpdateChannelMonitorRatio)
+ monitorRoute.PUT("/channel/:id/schedule", controller.UpdateChannelMonitorSmartScheduleConfig)
+ monitorRoute.GET("/channel/:id/history", controller.GetChannelMonitorHistory)
+ monitorRoute.PUT("/channel/:id/upstream", controller.SaveChannelMonitorUpstreamConfig)
+ monitorRoute.POST("/channel/:id/upstream/groups", controller.ListChannelMonitorUpstreamGroups)
+ monitorRoute.POST("/channel/:id/upstream/version", controller.FetchChannelMonitorSub2APIUpstreamVersion)
+ monitorRoute.POST("/channel/:id/upstream/test", controller.TestChannelMonitorUpstreamConfig)
+ monitorRoute.POST("/channel/:id/upstream/fetch", controller.FetchChannelMonitorUpstreamRatio)
+ monitorRoute.POST("/channel/:id/upstream/balance/fetch", controller.FetchChannelMonitorUpstreamBalance)
+ monitorRoute.POST("/channel/:id/upstream/group/apply", controller.ApplyChannelMonitorUpstreamGroup)
+ monitorRoute.PUT("/group", controller.UpdateChannelMonitorGroupRatio)
+ monitorRoute.PUT("/group/channels", controller.UpdateChannelMonitorGroupChannels)
+ monitorRoute.PUT("/group/sync", controller.SyncChannelMonitorGroupRatio)
+ }
+}
diff --git a/service/channel_monitor_cost_conversion.go b/service/channel_monitor_cost_conversion.go
new file mode 100644
index 000000000000..2313744f19ad
--- /dev/null
+++ b/service/channel_monitor_cost_conversion.go
@@ -0,0 +1,148 @@
+package service
+
+import (
+ "errors"
+ "math"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+)
+
+const (
+ ChannelMonitorCostConversionNone = "none"
+ ChannelMonitorCostConversionRecharge = "recharge"
+ ChannelMonitorCostConversionSubscription = "subscription"
+
+ ChannelMonitorSubscriptionPeriodDay = "day"
+ ChannelMonitorSubscriptionPeriodWeek = "week"
+ ChannelMonitorSubscriptionPeriodMonth = "month"
+
+ maxChannelMonitorCostAmount = 1_000_000_000_000
+)
+
+type ChannelMonitorCostConversion struct {
+ Mode string `json:"mode"`
+ PaidCNY float64 `json:"paid_cny,omitempty"`
+ CreditedUSD float64 `json:"credited_usd,omitempty"`
+ SubscriptionPeriod string `json:"subscription_period,omitempty"`
+ SubscriptionPriceCNY float64 `json:"subscription_price_cny,omitempty"`
+ SubscriptionDailyUSD float64 `json:"subscription_daily_usd,omitempty"`
+}
+
+func NormalizeChannelMonitorCostConversion(config ChannelMonitorCostConversion) (ChannelMonitorCostConversion, error) {
+ config.Mode = strings.TrimSpace(config.Mode)
+ if config.Mode == "" {
+ config.Mode = ChannelMonitorCostConversionNone
+ }
+
+ switch config.Mode {
+ case ChannelMonitorCostConversionNone:
+ return ChannelMonitorCostConversion{Mode: ChannelMonitorCostConversionNone}, nil
+ case ChannelMonitorCostConversionRecharge:
+ if !validChannelMonitorCostAmount(config.PaidCNY) {
+ return ChannelMonitorCostConversion{}, errors.New("实付人民币金额必须大于 0 且不能超过 1000000000000")
+ }
+ if !validChannelMonitorCostAmount(config.CreditedUSD) {
+ return ChannelMonitorCostConversion{}, errors.New("到账美元额度必须大于 0 且不能超过 1000000000000")
+ }
+ return ChannelMonitorCostConversion{
+ Mode: ChannelMonitorCostConversionRecharge,
+ PaidCNY: config.PaidCNY,
+ CreditedUSD: config.CreditedUSD,
+ }, nil
+ case ChannelMonitorCostConversionSubscription:
+ config.SubscriptionPeriod = strings.TrimSpace(config.SubscriptionPeriod)
+ if _, err := channelMonitorSubscriptionDays(config.SubscriptionPeriod); err != nil {
+ return ChannelMonitorCostConversion{}, err
+ }
+ if !validChannelMonitorCostAmount(config.SubscriptionPriceCNY) {
+ return ChannelMonitorCostConversion{}, errors.New("订阅价格必须大于 0 且不能超过 1000000000000")
+ }
+ if !validChannelMonitorCostAmount(config.SubscriptionDailyUSD) {
+ return ChannelMonitorCostConversion{}, errors.New("每日美元额度必须大于 0 且不能超过 1000000000000")
+ }
+ return ChannelMonitorCostConversion{
+ Mode: ChannelMonitorCostConversionSubscription,
+ SubscriptionPeriod: config.SubscriptionPeriod,
+ SubscriptionPriceCNY: config.SubscriptionPriceCNY,
+ SubscriptionDailyUSD: config.SubscriptionDailyUSD,
+ }, nil
+ default:
+ return ChannelMonitorCostConversion{}, errors.New("倍率换算方式无效")
+ }
+}
+
+func ParseChannelMonitorCostConversion(raw string) (ChannelMonitorCostConversion, error) {
+ if strings.TrimSpace(raw) == "" {
+ return ChannelMonitorCostConversion{Mode: ChannelMonitorCostConversionNone}, nil
+ }
+ var config ChannelMonitorCostConversion
+ if err := common.UnmarshalJsonStr(raw, &config); err != nil {
+ return ChannelMonitorCostConversion{}, errors.New("倍率换算配置格式无效")
+ }
+ return NormalizeChannelMonitorCostConversion(config)
+}
+
+func MarshalChannelMonitorCostConversion(config ChannelMonitorCostConversion) (string, error) {
+ normalized, err := NormalizeChannelMonitorCostConversion(config)
+ if err != nil {
+ return "", err
+ }
+ data, err := common.Marshal(normalized)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+func ChannelMonitorCostConversionFactor(config ChannelMonitorCostConversion) (float64, error) {
+ normalized, err := NormalizeChannelMonitorCostConversion(config)
+ if err != nil {
+ return 0, err
+ }
+
+ factor := 1.0
+ switch normalized.Mode {
+ case ChannelMonitorCostConversionRecharge:
+ factor = normalized.PaidCNY / normalized.CreditedUSD
+ case ChannelMonitorCostConversionSubscription:
+ days, _ := channelMonitorSubscriptionDays(normalized.SubscriptionPeriod)
+ factor = normalized.SubscriptionPriceCNY / (normalized.SubscriptionDailyUSD * float64(days))
+ }
+ if math.IsNaN(factor) || math.IsInf(factor, 0) || factor <= 0 || factor > maxUpstreamGroupRatio {
+ return 0, errors.New("倍率换算系数必须大于 0 且不能超过 1000000")
+ }
+ return factor, nil
+}
+
+func CalculateChannelMonitorCostRatio(upstreamRatio float64, config ChannelMonitorCostConversion) (float64, float64, error) {
+ if math.IsNaN(upstreamRatio) || math.IsInf(upstreamRatio, 0) || upstreamRatio < 0 || upstreamRatio > maxUpstreamGroupRatio {
+ return 0, 0, errors.New("上游倍率必须在 0 到 1000000 之间")
+ }
+ factor, err := ChannelMonitorCostConversionFactor(config)
+ if err != nil {
+ return 0, 0, err
+ }
+ costRatio := upstreamRatio * factor
+ if math.IsNaN(costRatio) || math.IsInf(costRatio, 0) || costRatio < 0 || costRatio > maxUpstreamGroupRatio {
+ return 0, 0, errors.New("换算后的成本倍率必须在 0 到 1000000 之间")
+ }
+ return costRatio, factor, nil
+}
+
+func validChannelMonitorCostAmount(value float64) bool {
+ return !math.IsNaN(value) && !math.IsInf(value, 0) && value > 0 && value <= maxChannelMonitorCostAmount
+}
+
+func channelMonitorSubscriptionDays(period string) (int, error) {
+ switch period {
+ case ChannelMonitorSubscriptionPeriodDay:
+ return 1, nil
+ case ChannelMonitorSubscriptionPeriodWeek:
+ return 7, nil
+ case ChannelMonitorSubscriptionPeriodMonth:
+ return 30, nil
+ default:
+ return 0, errors.New("订阅周期必须是天、周或月")
+ }
+}
diff --git a/service/channel_monitor_cost_conversion_test.go b/service/channel_monitor_cost_conversion_test.go
new file mode 100644
index 000000000000..901f359202b1
--- /dev/null
+++ b/service/channel_monitor_cost_conversion_test.go
@@ -0,0 +1,130 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCalculateChannelMonitorCostRatio(t *testing.T) {
+ tests := []struct {
+ name string
+ config ChannelMonitorCostConversion
+ wantFactor float64
+ wantRatio float64
+ }{
+ {
+ name: "no conversion",
+ config: ChannelMonitorCostConversion{Mode: ChannelMonitorCostConversionNone},
+ wantFactor: 1,
+ wantRatio: 0.8,
+ },
+ {
+ name: "recharge",
+ config: ChannelMonitorCostConversion{
+ Mode: ChannelMonitorCostConversionRecharge,
+ PaidCNY: 100,
+ CreditedUSD: 200,
+ },
+ wantFactor: 0.5,
+ wantRatio: 0.4,
+ },
+ {
+ name: "daily subscription",
+ config: ChannelMonitorCostConversion{
+ Mode: ChannelMonitorCostConversionSubscription,
+ SubscriptionPeriod: ChannelMonitorSubscriptionPeriodDay,
+ SubscriptionPriceCNY: 10,
+ SubscriptionDailyUSD: 20,
+ },
+ wantFactor: 0.5,
+ wantRatio: 0.4,
+ },
+ {
+ name: "weekly subscription",
+ config: ChannelMonitorCostConversion{
+ Mode: ChannelMonitorCostConversionSubscription,
+ SubscriptionPeriod: ChannelMonitorSubscriptionPeriodWeek,
+ SubscriptionPriceCNY: 70,
+ SubscriptionDailyUSD: 20,
+ },
+ wantFactor: 0.5,
+ wantRatio: 0.4,
+ },
+ {
+ name: "monthly subscription uses thirty days",
+ config: ChannelMonitorCostConversion{
+ Mode: ChannelMonitorCostConversionSubscription,
+ SubscriptionPeriod: ChannelMonitorSubscriptionPeriodMonth,
+ SubscriptionPriceCNY: 300,
+ SubscriptionDailyUSD: 20,
+ },
+ wantFactor: 0.5,
+ wantRatio: 0.4,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ ratio, factor, err := CalculateChannelMonitorCostRatio(0.8, test.config)
+ require.NoError(t, err)
+ assert.InDelta(t, test.wantFactor, factor, 1e-9)
+ assert.InDelta(t, test.wantRatio, ratio, 1e-9)
+ })
+ }
+}
+
+func TestNormalizeChannelMonitorCostConversionRejectsInvalidValues(t *testing.T) {
+ tests := []ChannelMonitorCostConversion{
+ {Mode: "unknown"},
+ {Mode: ChannelMonitorCostConversionRecharge, PaidCNY: 0, CreditedUSD: 100},
+ {Mode: ChannelMonitorCostConversionRecharge, PaidCNY: 100, CreditedUSD: 0},
+ {
+ Mode: ChannelMonitorCostConversionSubscription,
+ SubscriptionPeriod: "year",
+ SubscriptionPriceCNY: 100,
+ SubscriptionDailyUSD: 10,
+ },
+ {
+ Mode: ChannelMonitorCostConversionSubscription,
+ SubscriptionPeriod: ChannelMonitorSubscriptionPeriodMonth,
+ SubscriptionPriceCNY: 0,
+ SubscriptionDailyUSD: 10,
+ },
+ }
+
+ for _, config := range tests {
+ _, err := NormalizeChannelMonitorCostConversion(config)
+ assert.Error(t, err)
+ }
+}
+
+func TestChannelMonitorCostConversionRoundTrip(t *testing.T) {
+ raw, err := MarshalChannelMonitorCostConversion(ChannelMonitorCostConversion{
+ Mode: ChannelMonitorCostConversionRecharge,
+ PaidCNY: 100,
+ CreditedUSD: 200,
+ })
+ require.NoError(t, err)
+
+ parsed, err := ParseChannelMonitorCostConversion(raw)
+ require.NoError(t, err)
+ assert.Equal(t, ChannelMonitorCostConversionRecharge, parsed.Mode)
+ assert.Equal(t, 100.0, parsed.PaidCNY)
+ assert.Equal(t, 200.0, parsed.CreditedUSD)
+
+ parsed, err = ParseChannelMonitorCostConversion("")
+ require.NoError(t, err)
+ assert.Equal(t, ChannelMonitorCostConversionNone, parsed.Mode)
+}
+
+func TestCalculateChannelMonitorCostRatioRejectsOverflow(t *testing.T) {
+ _, _, err := CalculateChannelMonitorCostRatio(2, ChannelMonitorCostConversion{
+ Mode: ChannelMonitorCostConversionRecharge,
+ PaidCNY: 1_000_000,
+ CreditedUSD: 1,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "成本倍率")
+}
diff --git a/service/channel_monitor_custom_upstream.go b/service/channel_monitor_custom_upstream.go
new file mode 100644
index 000000000000..f0ee9bd16822
--- /dev/null
+++ b/service/channel_monitor_custom_upstream.go
@@ -0,0 +1,699 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "net/textproto"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/tidwall/gjson"
+ "golang.org/x/net/http/httpguts"
+)
+
+const (
+ CustomUpstreamType = "custom"
+ CustomUpstreamAuthType = "custom"
+
+ ChannelMonitorCustomSourceFixed = "fixed"
+ ChannelMonitorCustomSourceHTTP = "http"
+
+ ChannelMonitorCustomBodyNone = "none"
+ ChannelMonitorCustomBodyJSON = "json"
+ ChannelMonitorCustomBodyForm = "form"
+
+ ChannelMonitorCustomResponseJSON = "json"
+ ChannelMonitorCustomResponseText = "text"
+
+ channelMonitorCustomConfigVersion = 1
+ maxChannelMonitorCustomBaseURL = 2048
+ maxChannelMonitorCustomEntries = 32
+ maxChannelMonitorCustomKeyLength = 256
+ maxChannelMonitorCustomValueLength = 8192
+ maxChannelMonitorCustomBodyBytes = 48 << 10
+ // Leave headroom under MySQL's 64 KB TEXT limit without a dialect-specific column type.
+ maxChannelMonitorCustomConfigBytes = 60 << 10
+ maxChannelMonitorCustomPathLength = 2048
+ maxChannelMonitorCustomResultPath = 512
+ maxChannelMonitorCustomPreviewRunes = 2048
+ maxChannelMonitorCustomBalance = 1_000_000_000_000_000
+)
+
+type ChannelMonitorCustomKeyValue struct {
+ Key string `json:"key"`
+ Value string `json:"value,omitempty"`
+ Secret bool `json:"secret,omitempty"`
+ HasValue bool `json:"has_value,omitempty"`
+}
+
+type ChannelMonitorCustomRequestConfig struct {
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Query []ChannelMonitorCustomKeyValue `json:"query,omitempty"`
+ Headers []ChannelMonitorCustomKeyValue `json:"headers,omitempty"`
+ BodyType string `json:"body_type"`
+ Body string `json:"body,omitempty"`
+ BodySecret bool `json:"body_secret,omitempty"`
+ HasBody bool `json:"has_body,omitempty"`
+ Form []ChannelMonitorCustomKeyValue `json:"form,omitempty"`
+}
+
+type ChannelMonitorCustomResultConfig struct {
+ ResponseType string `json:"response_type"`
+ ValuePath string `json:"value_path,omitempty"`
+ Multiplier float64 `json:"multiplier"`
+}
+
+type ChannelMonitorCustomMetricConfig struct {
+ Source string `json:"source"`
+ FixedValue *float64 `json:"fixed_value,omitempty"`
+ Request *ChannelMonitorCustomRequestConfig `json:"request,omitempty"`
+ Result *ChannelMonitorCustomResultConfig `json:"result,omitempty"`
+}
+
+type ChannelMonitorCustomUpstreamConfig struct {
+ Version int `json:"version"`
+ Ratio ChannelMonitorCustomMetricConfig `json:"ratio"`
+ Balance ChannelMonitorCustomMetricConfig `json:"balance"`
+ BalanceReuseRatioRequest bool `json:"balance_reuse_ratio_request,omitempty"`
+}
+
+type ChannelMonitorCustomRequestDebug struct {
+ StatusCode int `json:"status_code"`
+ DurationMs int64 `json:"duration_ms"`
+ ResponsePreview string `json:"response_preview,omitempty"`
+}
+
+type channelMonitorCustomHTTPResponse struct {
+ body []byte
+ debug *ChannelMonitorCustomRequestDebug
+}
+
+func NormalizeChannelMonitorCustomUpstreamConfig(config ChannelMonitorCustomUpstreamConfig) (ChannelMonitorCustomUpstreamConfig, error) {
+ return normalizeChannelMonitorCustomUpstreamConfig(config, nil)
+}
+
+func NormalizeChannelMonitorCustomUpstreamConfigWithExisting(config ChannelMonitorCustomUpstreamConfig, existing *ChannelMonitorCustomUpstreamConfig) (ChannelMonitorCustomUpstreamConfig, error) {
+ return normalizeChannelMonitorCustomUpstreamConfig(config, existing)
+}
+
+func ParseChannelMonitorCustomUpstreamConfig(raw string) (ChannelMonitorCustomUpstreamConfig, error) {
+ if strings.TrimSpace(raw) == "" {
+ return ChannelMonitorCustomUpstreamConfig{}, errors.New("自定义上游配置为空")
+ }
+ var config ChannelMonitorCustomUpstreamConfig
+ if err := common.UnmarshalJsonStr(raw, &config); err != nil {
+ return ChannelMonitorCustomUpstreamConfig{}, errors.New("自定义上游配置格式无效")
+ }
+ return NormalizeChannelMonitorCustomUpstreamConfig(config)
+}
+
+func MarshalChannelMonitorCustomUpstreamConfig(config ChannelMonitorCustomUpstreamConfig) (string, error) {
+ normalized, err := NormalizeChannelMonitorCustomUpstreamConfig(config)
+ if err != nil {
+ return "", err
+ }
+ data, err := common.Marshal(normalized)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+func SanitizeChannelMonitorCustomUpstreamConfig(config ChannelMonitorCustomUpstreamConfig) ChannelMonitorCustomUpstreamConfig {
+ sanitized := config
+ sanitizeChannelMonitorCustomMetric := func(metric *ChannelMonitorCustomMetricConfig) {
+ if metric.Request == nil {
+ return
+ }
+ requestCopy := *metric.Request
+ requestCopy.Query = sanitizeChannelMonitorCustomValues(requestCopy.Query)
+ requestCopy.Headers = sanitizeChannelMonitorCustomValues(requestCopy.Headers)
+ requestCopy.Form = sanitizeChannelMonitorCustomValues(requestCopy.Form)
+ if requestCopy.BodySecret {
+ requestCopy.HasBody = requestCopy.HasBody || requestCopy.Body != ""
+ requestCopy.Body = ""
+ }
+ metric.Request = &requestCopy
+ }
+ sanitizeChannelMonitorCustomMetric(&sanitized.Ratio)
+ sanitizeChannelMonitorCustomMetric(&sanitized.Balance)
+ return sanitized
+}
+
+func NormalizeChannelMonitorCustomBaseURL(raw string) (string, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", errors.New("请输入自定义接口基础地址")
+ }
+ if len(raw) > maxChannelMonitorCustomBaseURL {
+ return "", errors.New("自定义接口基础地址不能超过 2048 个字符")
+ }
+ parsed, err := url.Parse(raw)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return "", errors.New("自定义接口基础地址无效")
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return "", errors.New("自定义接口基础地址仅支持 HTTP 或 HTTPS")
+ }
+ if parsed.User != nil {
+ return "", errors.New("自定义接口基础地址不能包含账号密码")
+ }
+ if parsed.RawQuery != "" || parsed.Fragment != "" {
+ return "", errors.New("自定义接口基础地址不能包含查询参数或片段")
+ }
+ parsed.Path = strings.TrimRight(parsed.Path, "/")
+ parsed.RawPath = ""
+ return strings.TrimRight(parsed.String(), "/"), nil
+}
+
+func normalizeChannelMonitorCustomUpstreamConfig(config ChannelMonitorCustomUpstreamConfig, existing *ChannelMonitorCustomUpstreamConfig) (ChannelMonitorCustomUpstreamConfig, error) {
+ if config.Version == 0 {
+ config.Version = channelMonitorCustomConfigVersion
+ }
+ if config.Version != channelMonitorCustomConfigVersion {
+ return ChannelMonitorCustomUpstreamConfig{}, errors.New("不支持的自定义上游配置版本")
+ }
+
+ var existingRatio *ChannelMonitorCustomMetricConfig
+ var existingBalance *ChannelMonitorCustomMetricConfig
+ if existing != nil {
+ existingRatio = &existing.Ratio
+ existingBalance = &existing.Balance
+ }
+ ratio, err := normalizeChannelMonitorCustomMetric(config.Ratio, existingRatio, true, false)
+ if err != nil {
+ return ChannelMonitorCustomUpstreamConfig{}, fmt.Errorf("自定义倍率配置无效: %w", err)
+ }
+ balance, err := normalizeChannelMonitorCustomMetric(config.Balance, existingBalance, false, config.BalanceReuseRatioRequest)
+ if err != nil {
+ return ChannelMonitorCustomUpstreamConfig{}, fmt.Errorf("自定义余额配置无效: %w", err)
+ }
+ if config.BalanceReuseRatioRequest && (ratio.Source != ChannelMonitorCustomSourceHTTP || balance.Source != ChannelMonitorCustomSourceHTTP) {
+ return ChannelMonitorCustomUpstreamConfig{}, errors.New("只有倍率和余额都使用接口查询时才能复用倍率接口")
+ }
+ if config.BalanceReuseRatioRequest {
+ balance.Request = nil
+ }
+ normalized := ChannelMonitorCustomUpstreamConfig{
+ Version: channelMonitorCustomConfigVersion,
+ Ratio: ratio,
+ Balance: balance,
+ BalanceReuseRatioRequest: config.BalanceReuseRatioRequest,
+ }
+ encoded, err := common.Marshal(normalized)
+ if err != nil {
+ return ChannelMonitorCustomUpstreamConfig{}, errors.New("自定义上游配置序列化失败")
+ }
+ if len(encoded) > maxChannelMonitorCustomConfigBytes {
+ return ChannelMonitorCustomUpstreamConfig{}, errors.New("自定义上游配置总大小不能超过 60 KB")
+ }
+ return normalized, nil
+}
+
+func normalizeChannelMonitorCustomMetric(metric ChannelMonitorCustomMetricConfig, existing *ChannelMonitorCustomMetricConfig, ratio bool, reuseRequest bool) (ChannelMonitorCustomMetricConfig, error) {
+ metric.Source = strings.TrimSpace(metric.Source)
+ if metric.Source == "" {
+ metric.Source = ChannelMonitorCustomSourceFixed
+ }
+ switch metric.Source {
+ case ChannelMonitorCustomSourceFixed:
+ if metric.FixedValue == nil {
+ return ChannelMonitorCustomMetricConfig{}, errors.New("固定值不能为空")
+ }
+ value := *metric.FixedValue
+ if math.IsNaN(value) || math.IsInf(value, 0) {
+ return ChannelMonitorCustomMetricConfig{}, errors.New("固定值必须是有效数字")
+ }
+ if ratio && (value < 0 || value > maxUpstreamGroupRatio) {
+ return ChannelMonitorCustomMetricConfig{}, errors.New("固定倍率必须在 0 到 1000000 之间")
+ }
+ if !ratio && math.Abs(value) > maxChannelMonitorCustomBalance {
+ return ChannelMonitorCustomMetricConfig{}, errors.New("固定余额绝对值不能超过 1000000000000000")
+ }
+ return ChannelMonitorCustomMetricConfig{Source: metric.Source, FixedValue: &value}, nil
+ case ChannelMonitorCustomSourceHTTP:
+ var existingRequest *ChannelMonitorCustomRequestConfig
+ if existing != nil {
+ existingRequest = existing.Request
+ }
+ var request *ChannelMonitorCustomRequestConfig
+ if !reuseRequest {
+ if metric.Request == nil {
+ return ChannelMonitorCustomMetricConfig{}, errors.New("接口请求配置不能为空")
+ }
+ normalizedRequest, err := normalizeChannelMonitorCustomRequest(*metric.Request, existingRequest)
+ if err != nil {
+ return ChannelMonitorCustomMetricConfig{}, err
+ }
+ request = &normalizedRequest
+ }
+ if metric.Result == nil {
+ return ChannelMonitorCustomMetricConfig{}, errors.New("接口结果配置不能为空")
+ }
+ result, err := normalizeChannelMonitorCustomResult(*metric.Result)
+ if err != nil {
+ return ChannelMonitorCustomMetricConfig{}, err
+ }
+ return ChannelMonitorCustomMetricConfig{Source: metric.Source, Request: request, Result: &result}, nil
+ default:
+ return ChannelMonitorCustomMetricConfig{}, errors.New("数据来源必须是固定输入或接口查询")
+ }
+}
+
+func normalizeChannelMonitorCustomRequest(request ChannelMonitorCustomRequestConfig, existing *ChannelMonitorCustomRequestConfig) (ChannelMonitorCustomRequestConfig, error) {
+ request.Method = strings.ToUpper(strings.TrimSpace(request.Method))
+ if request.Method == "" {
+ request.Method = http.MethodGet
+ }
+ if request.Method != http.MethodGet && request.Method != http.MethodPost {
+ return ChannelMonitorCustomRequestConfig{}, errors.New("请求方式仅支持 GET 或 POST")
+ }
+ request.Path = strings.TrimSpace(request.Path)
+ if request.Path == "" || len(request.Path) > maxChannelMonitorCustomPathLength {
+ return ChannelMonitorCustomRequestConfig{}, errors.New("接口路径不能为空且不能超过 2048 个字符")
+ }
+ parsedPath, err := url.Parse(request.Path)
+ if err != nil ||
+ parsedPath.IsAbs() ||
+ parsedPath.Host != "" ||
+ parsedPath.RawQuery != "" ||
+ parsedPath.Fragment != "" ||
+ strings.ContainsAny(parsedPath.Path, "?#") {
+ return ChannelMonitorCustomRequestConfig{}, errors.New("接口路径必须是没有查询参数的相对路径")
+ }
+ request.Path = "/" + strings.TrimLeft(parsedPath.Path, "/")
+
+ var existingQuery []ChannelMonitorCustomKeyValue
+ var existingHeaders []ChannelMonitorCustomKeyValue
+ var existingForm []ChannelMonitorCustomKeyValue
+ if existing != nil {
+ existingQuery = existing.Query
+ existingHeaders = existing.Headers
+ existingForm = existing.Form
+ }
+ request.Query, err = normalizeChannelMonitorCustomValues(request.Query, existingQuery, "查询参数", false)
+ if err != nil {
+ return ChannelMonitorCustomRequestConfig{}, err
+ }
+ request.Headers, err = normalizeChannelMonitorCustomValues(request.Headers, existingHeaders, "请求头", true)
+ if err != nil {
+ return ChannelMonitorCustomRequestConfig{}, err
+ }
+
+ request.BodyType = strings.TrimSpace(request.BodyType)
+ if request.BodyType == "" {
+ request.BodyType = ChannelMonitorCustomBodyNone
+ }
+ if request.Method == http.MethodGet && request.BodyType != ChannelMonitorCustomBodyNone {
+ return ChannelMonitorCustomRequestConfig{}, errors.New("GET 请求不能配置请求体")
+ }
+ switch request.BodyType {
+ case ChannelMonitorCustomBodyNone:
+ request.Body = ""
+ request.BodySecret = false
+ request.HasBody = false
+ request.Form = nil
+ case ChannelMonitorCustomBodyJSON:
+ request.Form = nil
+ if request.BodySecret && request.Body == "" && request.HasBody && existing != nil && existing.BodySecret {
+ request.Body = existing.Body
+ }
+ if len(request.Body) == 0 || len(request.Body) > maxChannelMonitorCustomBodyBytes {
+ return ChannelMonitorCustomRequestConfig{}, errors.New("JSON 请求体不能为空且不能超过 49152 字节")
+ }
+ var decoded any
+ if err := common.Unmarshal([]byte(request.Body), &decoded); err != nil {
+ return ChannelMonitorCustomRequestConfig{}, errors.New("JSON 请求体格式无效")
+ }
+ request.HasBody = true
+ case ChannelMonitorCustomBodyForm:
+ request.Body = ""
+ request.BodySecret = false
+ request.HasBody = false
+ request.Form, err = normalizeChannelMonitorCustomValues(request.Form, existingForm, "表单参数", false)
+ if err != nil {
+ return ChannelMonitorCustomRequestConfig{}, err
+ }
+ default:
+ return ChannelMonitorCustomRequestConfig{}, errors.New("请求体类型无效")
+ }
+ return request, nil
+}
+
+func normalizeChannelMonitorCustomValues(values []ChannelMonitorCustomKeyValue, existing []ChannelMonitorCustomKeyValue, label string, header bool) ([]ChannelMonitorCustomKeyValue, error) {
+ if len(values) > maxChannelMonitorCustomEntries {
+ return nil, fmt.Errorf("%s不能超过 %d 项", label, maxChannelMonitorCustomEntries)
+ }
+ normalized := make([]ChannelMonitorCustomKeyValue, 0, len(values))
+ seen := make(map[string]struct{}, len(values))
+ for _, item := range values {
+ item.Key = strings.TrimSpace(item.Key)
+ if item.Key == "" || len(item.Key) > maxChannelMonitorCustomKeyLength {
+ return nil, fmt.Errorf("%s名称不能为空且不能超过 %d 个字符", label, maxChannelMonitorCustomKeyLength)
+ }
+ if header {
+ canonicalKey := textproto.CanonicalMIMEHeaderKey(item.Key)
+ if canonicalKey == "" || !httpguts.ValidHeaderFieldName(canonicalKey) || isBlockedChannelMonitorCustomHeader(canonicalKey) {
+ return nil, fmt.Errorf("请求头 %s 不允许配置", item.Key)
+ }
+ item.Key = canonicalKey
+ }
+ key := strings.ToLower(item.Key)
+ if _, exists := seen[key]; exists {
+ return nil, fmt.Errorf("%s %s 重复", label, item.Key)
+ }
+ seen[key] = struct{}{}
+ item.Secret = item.Secret || isChannelMonitorCustomSensitiveKey(item.Key)
+ if item.Secret && item.Value == "" && item.HasValue {
+ for _, saved := range existing {
+ if strings.EqualFold(strings.TrimSpace(saved.Key), item.Key) && saved.Value != "" {
+ item.Value = saved.Value
+ break
+ }
+ }
+ }
+ invalidValue := len(item.Value) > maxChannelMonitorCustomValueLength || strings.ContainsAny(item.Value, "\r\n")
+ if header {
+ invalidValue = invalidValue || !httpguts.ValidHeaderFieldValue(item.Value)
+ }
+ if invalidValue {
+ return nil, fmt.Errorf("%s %s 的值无效或过长", label, item.Key)
+ }
+ if item.Secret && item.Value == "" {
+ return nil, fmt.Errorf("敏感%s %s 的值不能为空", label, item.Key)
+ }
+ item.HasValue = item.Value != ""
+ normalized = append(normalized, item)
+ }
+ return normalized, nil
+}
+
+func normalizeChannelMonitorCustomResult(result ChannelMonitorCustomResultConfig) (ChannelMonitorCustomResultConfig, error) {
+ result.ResponseType = strings.TrimSpace(result.ResponseType)
+ if result.ResponseType == "" {
+ result.ResponseType = ChannelMonitorCustomResponseJSON
+ }
+ result.ValuePath = strings.TrimSpace(result.ValuePath)
+ if result.ResponseType == ChannelMonitorCustomResponseJSON {
+ if result.ValuePath == "" || len(result.ValuePath) > maxChannelMonitorCustomResultPath {
+ return ChannelMonitorCustomResultConfig{}, errors.New("JSON 取值路径不能为空且不能超过 512 个字符")
+ }
+ } else if result.ResponseType == ChannelMonitorCustomResponseText {
+ result.ValuePath = ""
+ } else {
+ return ChannelMonitorCustomResultConfig{}, errors.New("响应格式必须是 JSON 或纯文本")
+ }
+ if result.Multiplier == 0 {
+ result.Multiplier = 1
+ }
+ if math.IsNaN(result.Multiplier) || math.IsInf(result.Multiplier, 0) || result.Multiplier <= 0 || result.Multiplier > maxUpstreamGroupRatio {
+ return ChannelMonitorCustomResultConfig{}, errors.New("结果乘数必须大于 0 且不能超过 1000000")
+ }
+ return result, nil
+}
+
+func sanitizeChannelMonitorCustomValues(values []ChannelMonitorCustomKeyValue) []ChannelMonitorCustomKeyValue {
+ if len(values) == 0 {
+ return nil
+ }
+ sanitized := make([]ChannelMonitorCustomKeyValue, len(values))
+ copy(sanitized, values)
+ for index := range sanitized {
+ if sanitized[index].Secret {
+ sanitized[index].HasValue = sanitized[index].HasValue || sanitized[index].Value != ""
+ sanitized[index].Value = ""
+ }
+ }
+ return sanitized
+}
+
+func isBlockedChannelMonitorCustomHeader(key string) bool {
+ switch strings.ToLower(key) {
+ case "host", "content-length", "connection", "proxy-connection", "transfer-encoding", "upgrade", "te", "trailer":
+ return true
+ default:
+ return false
+ }
+}
+
+func isChannelMonitorCustomSensitiveKey(key string) bool {
+ key = strings.ToLower(strings.TrimSpace(key))
+ return strings.Contains(key, "authorization") ||
+ strings.Contains(key, "token") ||
+ strings.Contains(key, "secret") ||
+ strings.Contains(key, "api-key") ||
+ strings.Contains(key, "api_key") ||
+ strings.Contains(key, "apikey") ||
+ strings.Contains(key, "password") ||
+ strings.Contains(key, "passwd") ||
+ strings.Contains(key, "cookie") ||
+ strings.Contains(key, "credential") ||
+ strings.Contains(key, "session")
+}
+
+func fetchChannelMonitorCustomUpstreamRatio(ctx context.Context, client *http.Client, baseURL string, config ChannelMonitorCustomUpstreamConfig, skipBalance bool, includeDebug bool) (NewAPIGroupRatioResult, error) {
+ normalized, err := NormalizeChannelMonitorCustomUpstreamConfig(config)
+ if err != nil {
+ return NewAPIGroupRatioResult{}, err
+ }
+ result := NewAPIGroupRatioResult{}
+ var ratioResponse channelMonitorCustomHTTPResponse
+ var balanceErr error
+ if normalized.Ratio.Source == ChannelMonitorCustomSourceFixed {
+ result.Ratio = *normalized.Ratio.FixedValue
+ result.Endpoint = "固定输入"
+ } else {
+ ratioResponse, err = requestChannelMonitorCustomUpstream(ctx, client, baseURL, *normalized.Ratio.Request, includeDebug)
+ if err == nil {
+ result.Ratio, err = extractChannelMonitorCustomValue(ratioResponse.body, *normalized.Ratio.Result)
+ result.Endpoint = normalized.Ratio.Request.Path
+ result.Debug = ratioResponse.debug
+ }
+ }
+
+ if !skipBalance {
+ var balanceResult ChannelMonitorUpstreamBalanceResult
+ balanceResult, balanceErr = fetchChannelMonitorCustomBalanceWithResponse(ctx, client, baseURL, normalized, ratioResponse, includeDebug)
+ if balanceErr != nil {
+ result.Balance.Error = balanceErr.Error()
+ } else {
+ result.Balance = balanceResult
+ }
+ }
+ if err != nil {
+ return result, fmt.Errorf("自定义倍率更新失败: %w", err)
+ }
+ if result.Ratio < 0 || result.Ratio > maxUpstreamGroupRatio || math.IsNaN(result.Ratio) || math.IsInf(result.Ratio, 0) {
+ return result, errors.New("自定义倍率必须在 0 到 1000000 之间")
+ }
+ if balanceErr != nil {
+ return result, balanceErr
+ }
+ return result, nil
+}
+
+func fetchChannelMonitorCustomUpstreamBalance(ctx context.Context, client *http.Client, baseURL string, config ChannelMonitorCustomUpstreamConfig, includeDebug bool) (ChannelMonitorUpstreamBalanceResult, error) {
+ normalized, err := NormalizeChannelMonitorCustomUpstreamConfig(config)
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ return fetchChannelMonitorCustomBalanceWithResponse(ctx, client, baseURL, normalized, channelMonitorCustomHTTPResponse{}, includeDebug)
+}
+
+func fetchChannelMonitorCustomBalanceWithResponse(ctx context.Context, client *http.Client, baseURL string, config ChannelMonitorCustomUpstreamConfig, ratioResponse channelMonitorCustomHTTPResponse, includeDebug bool) (ChannelMonitorUpstreamBalanceResult, error) {
+ if config.Balance.Source == ChannelMonitorCustomSourceFixed {
+ value := *config.Balance.FixedValue
+ return ChannelMonitorUpstreamBalanceResult{Amount: &value, Endpoint: "固定输入"}, nil
+ }
+
+ requestConfig := config.Balance.Request
+ response := channelMonitorCustomHTTPResponse{}
+ if config.BalanceReuseRatioRequest {
+ requestConfig = config.Ratio.Request
+ response = ratioResponse
+ }
+ if requestConfig == nil {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("自定义余额接口配置为空")
+ }
+ var err error
+ if len(response.body) == 0 {
+ response, err = requestChannelMonitorCustomUpstream(ctx, client, baseURL, *requestConfig, includeDebug)
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, fmt.Errorf("自定义余额更新失败: %w", err)
+ }
+ }
+ value, err := extractChannelMonitorCustomValue(response.body, *config.Balance.Result)
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, fmt.Errorf("自定义余额更新失败: %w", err)
+ }
+ if math.IsNaN(value) || math.IsInf(value, 0) || math.Abs(value) > maxChannelMonitorCustomBalance {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("自定义余额不是有效数字或绝对值过大")
+ }
+ return ChannelMonitorUpstreamBalanceResult{
+ Amount: &value,
+ Endpoint: requestConfig.Path,
+ Debug: response.debug,
+ }, nil
+}
+
+func requestChannelMonitorCustomUpstream(ctx context.Context, client *http.Client, baseURL string, config ChannelMonitorCustomRequestConfig, includeDebug bool) (channelMonitorCustomHTTPResponse, error) {
+ requestURL := strings.TrimRight(baseURL, "/") + config.Path
+ parsedURL, err := url.Parse(requestURL)
+ if err != nil {
+ return channelMonitorCustomHTTPResponse{}, errors.New("自定义接口地址无效")
+ }
+ query := parsedURL.Query()
+ for _, item := range config.Query {
+ query.Add(item.Key, item.Value)
+ }
+ parsedURL.RawQuery = query.Encode()
+ requestURL = parsedURL.String()
+ validationURL := *parsedURL
+ validationURL.RawQuery = ""
+ if err := ValidateSSRFProtectedFetchURL(validationURL.String()); err != nil {
+ return channelMonitorCustomHTTPResponse{}, err
+ }
+
+ var body io.Reader
+ contentType := ""
+ switch config.BodyType {
+ case ChannelMonitorCustomBodyJSON:
+ body = bytes.NewBufferString(config.Body)
+ contentType = "application/json"
+ case ChannelMonitorCustomBodyForm:
+ form := make(url.Values, len(config.Form))
+ for _, item := range config.Form {
+ form.Add(item.Key, item.Value)
+ }
+ body = strings.NewReader(form.Encode())
+ contentType = "application/x-www-form-urlencoded"
+ }
+
+ requestContext, cancel := context.WithTimeout(ctx, upstreamGroupRatioTimeout)
+ defer cancel()
+ request, err := http.NewRequestWithContext(requestContext, config.Method, requestURL, body)
+ if err != nil {
+ return channelMonitorCustomHTTPResponse{}, errors.New(redactChannelMonitorCustomText(err.Error(), config))
+ }
+ for _, item := range config.Headers {
+ request.Header.Set(item.Key, item.Value)
+ }
+ if contentType != "" && request.Header.Get("Content-Type") == "" {
+ request.Header.Set("Content-Type", contentType)
+ }
+ if request.Header.Get("Accept") == "" {
+ request.Header.Set("Accept", "application/json, text/plain;q=0.9")
+ }
+
+ startedAt := time.Now()
+ response, err := client.Do(request)
+ if err != nil {
+ return channelMonitorCustomHTTPResponse{}, errors.New(redactChannelMonitorCustomText(err.Error(), config))
+ }
+ defer response.Body.Close()
+ responseBody, err := io.ReadAll(io.LimitReader(response.Body, maxUpstreamGroupRatioResponseBytes+1))
+ if err != nil {
+ return channelMonitorCustomHTTPResponse{}, err
+ }
+ if len(responseBody) > maxUpstreamGroupRatioResponseBytes {
+ return channelMonitorCustomHTTPResponse{}, errors.New("自定义接口响应超过 1 MB")
+ }
+ preview := redactChannelMonitorCustomResponsePreview(responseBody, config)
+ debug := &ChannelMonitorCustomRequestDebug{
+ StatusCode: response.StatusCode,
+ DurationMs: time.Since(startedAt).Milliseconds(),
+ ResponsePreview: preview,
+ }
+ if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
+ message := fmt.Sprintf("接口返回 %s", response.Status)
+ if preview != "" {
+ message += ":" + preview
+ }
+ return channelMonitorCustomHTTPResponse{body: responseBody, debug: debug}, errors.New(message)
+ }
+ if !includeDebug {
+ debug = nil
+ }
+ return channelMonitorCustomHTTPResponse{body: responseBody, debug: debug}, nil
+}
+
+func extractChannelMonitorCustomValue(body []byte, config ChannelMonitorCustomResultConfig) (float64, error) {
+ var rawValue string
+ if config.ResponseType == ChannelMonitorCustomResponseJSON {
+ var decoded any
+ if err := common.Unmarshal(body, &decoded); err != nil {
+ return 0, errors.New("接口响应不是有效 JSON")
+ }
+ value := gjson.GetBytes(body, config.ValuePath)
+ if !value.Exists() || value.Type == gjson.Null {
+ return 0, fmt.Errorf("结果路径 %q 不存在", config.ValuePath)
+ }
+ switch value.Type {
+ case gjson.Number:
+ rawValue = value.Raw
+ case gjson.String:
+ rawValue = value.String()
+ default:
+ return 0, fmt.Errorf("结果路径 %q 的值不是数字", config.ValuePath)
+ }
+ } else {
+ rawValue = strings.TrimSpace(string(body))
+ }
+ value, err := strconv.ParseFloat(strings.TrimSpace(rawValue), 64)
+ if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
+ return 0, errors.New("接口提取结果不是有效数字")
+ }
+ value *= config.Multiplier
+ if math.IsNaN(value) || math.IsInf(value, 0) {
+ return 0, errors.New("接口结果乘以结果乘数后不是有效数字")
+ }
+ return value, nil
+}
+
+func redactChannelMonitorCustomResponsePreview(body []byte, config ChannelMonitorCustomRequestConfig) string {
+ preview := strings.TrimSpace(string(body))
+ if config.BodySecret {
+ if preview == "" {
+ return ""
+ }
+ return "[响应预览已隐藏]"
+ }
+ preview = redactChannelMonitorCustomText(preview, config)
+ previewRunes := []rune(preview)
+ if len(previewRunes) > maxChannelMonitorCustomPreviewRunes {
+ preview = string(previewRunes[:maxChannelMonitorCustomPreviewRunes]) + "..."
+ }
+ return preview
+}
+
+func redactChannelMonitorCustomText(value string, config ChannelMonitorCustomRequestConfig) string {
+ for _, values := range [][]ChannelMonitorCustomKeyValue{config.Query, config.Headers, config.Form} {
+ for _, item := range values {
+ if !item.Secret || item.Value == "" {
+ continue
+ }
+ value = strings.ReplaceAll(value, item.Value, "[REDACTED]")
+ value = strings.ReplaceAll(value, url.QueryEscape(item.Value), "[REDACTED]")
+ if strings.EqualFold(item.Key, "Authorization") {
+ parts := strings.Fields(item.Value)
+ if len(parts) == 2 {
+ value = strings.ReplaceAll(value, parts[1], "[REDACTED]")
+ value = strings.ReplaceAll(value, url.QueryEscape(parts[1]), "[REDACTED]")
+ }
+ }
+ }
+ }
+ if config.BodySecret && config.Body != "" {
+ value = strings.ReplaceAll(value, config.Body, "[REDACTED]")
+ }
+ return value
+}
diff --git a/service/channel_monitor_custom_upstream_test.go b/service/channel_monitor_custom_upstream_test.go
new file mode 100644
index 000000000000..b7293447c21a
--- /dev/null
+++ b/service/channel_monitor_custom_upstream_test.go
@@ -0,0 +1,391 @@
+package service
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/QuantumNous/new-api/setting/system_setting"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func useChannelMonitorCustomTestFetchSettings(t *testing.T) {
+ t.Helper()
+ fetchSetting := system_setting.GetFetchSetting()
+ original := *fetchSetting
+ t.Cleanup(func() {
+ *fetchSetting = original
+ })
+ fetchSetting.EnableSSRFProtection = false
+}
+
+func TestNormalizeChannelMonitorCustomBaseURLPreservesPath(t *testing.T) {
+ baseURL, err := NormalizeChannelMonitorCustomBaseURL(" https://example.com/panel/v1/ ")
+ require.NoError(t, err)
+ assert.Equal(t, "https://example.com/panel/v1", baseURL)
+}
+
+func TestNormalizeChannelMonitorCustomBaseURLRejectsOversizedValue(t *testing.T) {
+ _, err := NormalizeChannelMonitorCustomBaseURL("https://example.com/" + strings.Repeat("a", maxChannelMonitorCustomBaseURL))
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "不能超过 2048")
+}
+
+func TestNormalizeChannelMonitorCustomRequestRejectsEncodedQueryDelimiter(t *testing.T) {
+ balance := 0.0
+ _, err := NormalizeChannelMonitorCustomUpstreamConfig(ChannelMonitorCustomUpstreamConfig{
+ Ratio: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceHTTP,
+ Request: &ChannelMonitorCustomRequestConfig{
+ Method: http.MethodGet,
+ Path: "/ratio%3Ftoken=secret",
+ BodyType: ChannelMonitorCustomBodyNone,
+ },
+ Result: &ChannelMonitorCustomResultConfig{
+ ResponseType: ChannelMonitorCustomResponseJSON,
+ ValuePath: "ratio",
+ Multiplier: 1,
+ },
+ },
+ Balance: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceFixed,
+ FixedValue: &balance,
+ },
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "没有查询参数的相对路径")
+}
+
+func TestNormalizeChannelMonitorCustomRequestRejectsInvalidHeader(t *testing.T) {
+ balance := 0.0
+ _, err := NormalizeChannelMonitorCustomUpstreamConfig(ChannelMonitorCustomUpstreamConfig{
+ Ratio: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceHTTP,
+ Request: &ChannelMonitorCustomRequestConfig{
+ Method: http.MethodGet,
+ Path: "/ratio",
+ BodyType: ChannelMonitorCustomBodyNone,
+ Headers: []ChannelMonitorCustomKeyValue{
+ {Key: "Invalid Header", Value: "value"},
+ },
+ },
+ Result: &ChannelMonitorCustomResultConfig{
+ ResponseType: ChannelMonitorCustomResponseJSON,
+ ValuePath: "ratio",
+ Multiplier: 1,
+ },
+ },
+ Balance: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceFixed,
+ FixedValue: &balance,
+ },
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "请求头 Invalid Header 不允许配置")
+}
+
+func TestChannelMonitorCustomConfigPreservesAndSanitizesSecrets(t *testing.T) {
+ fixedBalance := 20.0
+ existing, err := NormalizeChannelMonitorCustomUpstreamConfig(ChannelMonitorCustomUpstreamConfig{
+ Ratio: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceHTTP,
+ Request: &ChannelMonitorCustomRequestConfig{
+ Method: http.MethodPost,
+ Path: "/api/ratio",
+ BodyType: ChannelMonitorCustomBodyJSON,
+ Body: `{"token":"body-secret"}`,
+ BodySecret: true,
+ Headers: []ChannelMonitorCustomKeyValue{
+ {Key: "Authorization", Value: "Bearer secret"},
+ },
+ },
+ Result: &ChannelMonitorCustomResultConfig{
+ ResponseType: ChannelMonitorCustomResponseJSON,
+ ValuePath: "data.ratio",
+ Multiplier: 1,
+ },
+ },
+ Balance: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceFixed,
+ FixedValue: &fixedBalance,
+ },
+ })
+ require.NoError(t, err)
+
+ publicConfig := SanitizeChannelMonitorCustomUpstreamConfig(existing)
+ require.Len(t, publicConfig.Ratio.Request.Headers, 1)
+ assert.Empty(t, publicConfig.Ratio.Request.Headers[0].Value)
+ assert.True(t, publicConfig.Ratio.Request.Headers[0].Secret)
+ assert.True(t, publicConfig.Ratio.Request.Headers[0].HasValue)
+ assert.Empty(t, publicConfig.Ratio.Request.Body)
+ assert.True(t, publicConfig.Ratio.Request.BodySecret)
+ assert.True(t, publicConfig.Ratio.Request.HasBody)
+
+ merged, err := NormalizeChannelMonitorCustomUpstreamConfigWithExisting(publicConfig, &existing)
+ require.NoError(t, err)
+ assert.Equal(t, "Bearer secret", merged.Ratio.Request.Headers[0].Value)
+ assert.JSONEq(t, `{"token":"body-secret"}`, merged.Ratio.Request.Body)
+}
+
+func TestNormalizeChannelMonitorCustomConfigRejectsOversizedDocument(t *testing.T) {
+ query := make([]ChannelMonitorCustomKeyValue, 8)
+ for index := range query {
+ query[index] = ChannelMonitorCustomKeyValue{
+ Key: "parameter" + strconv.Itoa(index),
+ Value: strings.Repeat("x", maxChannelMonitorCustomValueLength),
+ }
+ }
+ balance := 0.0
+ _, err := NormalizeChannelMonitorCustomUpstreamConfig(ChannelMonitorCustomUpstreamConfig{
+ Ratio: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceHTTP,
+ Request: &ChannelMonitorCustomRequestConfig{
+ Method: http.MethodGet,
+ Path: "/ratio",
+ BodyType: ChannelMonitorCustomBodyNone,
+ Query: query,
+ },
+ Result: &ChannelMonitorCustomResultConfig{
+ ResponseType: ChannelMonitorCustomResponseJSON,
+ ValuePath: "ratio",
+ Multiplier: 1,
+ },
+ },
+ Balance: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceFixed,
+ FixedValue: &balance,
+ },
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "总大小不能超过 60 KB")
+}
+
+func TestChannelMonitorCustomRequestRedactsSecrets(t *testing.T) {
+ useChannelMonitorCustomTestFetchSettings(t)
+
+ t.Run("network error", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ client := server.Client()
+ baseURL := server.URL
+ server.Close()
+
+ _, err := requestChannelMonitorCustomUpstream(context.Background(), client, baseURL, ChannelMonitorCustomRequestConfig{
+ Method: http.MethodGet,
+ Path: "/ratio",
+ BodyType: ChannelMonitorCustomBodyNone,
+ Query: []ChannelMonitorCustomKeyValue{
+ {Key: "access_token", Value: "query-secret", Secret: true},
+ },
+ }, true)
+ require.Error(t, err)
+ assert.NotContains(t, err.Error(), "query-secret")
+ assert.Contains(t, err.Error(), "[REDACTED]")
+ })
+
+ t.Run("sensitive body response", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "body-secret", http.StatusBadRequest)
+ }))
+ defer server.Close()
+
+ _, err := requestChannelMonitorCustomUpstream(context.Background(), server.Client(), server.URL, ChannelMonitorCustomRequestConfig{
+ Method: http.MethodPost,
+ Path: "/ratio",
+ BodyType: ChannelMonitorCustomBodyJSON,
+ Body: `{"token":"body-secret"}`,
+ BodySecret: true,
+ }, true)
+ require.Error(t, err)
+ assert.NotContains(t, err.Error(), "body-secret")
+ assert.Contains(t, err.Error(), "响应预览已隐藏")
+ })
+}
+
+func TestFetchChannelMonitorCustomUpstreamRatioReusesRequest(t *testing.T) {
+ useChannelMonitorCustomTestFetchSettings(t)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/account", r.URL.Path)
+ assert.Equal(t, "vip", r.URL.Query().Get("group"))
+ assert.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":{"ratio":"2","balance":1234},"token":"Bearer secret"}`))
+ }))
+ defer server.Close()
+
+ config := ChannelMonitorCustomUpstreamConfig{
+ Ratio: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceHTTP,
+ Request: &ChannelMonitorCustomRequestConfig{
+ Method: http.MethodGet,
+ Path: "/account",
+ BodyType: ChannelMonitorCustomBodyNone,
+ Query: []ChannelMonitorCustomKeyValue{
+ {Key: "group", Value: "vip"},
+ },
+ Headers: []ChannelMonitorCustomKeyValue{
+ {Key: "Authorization", Value: "Bearer secret", Secret: true},
+ },
+ },
+ Result: &ChannelMonitorCustomResultConfig{
+ ResponseType: ChannelMonitorCustomResponseJSON,
+ ValuePath: "data.ratio",
+ Multiplier: 0.5,
+ },
+ },
+ Balance: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceHTTP,
+ Result: &ChannelMonitorCustomResultConfig{
+ ResponseType: ChannelMonitorCustomResponseJSON,
+ ValuePath: "data.balance",
+ Multiplier: 0.01,
+ },
+ },
+ BalanceReuseRatioRequest: true,
+ }
+
+ result, err := fetchChannelMonitorCustomUpstreamRatio(context.Background(), server.Client(), server.URL, config, false, true)
+ require.NoError(t, err)
+ assert.Equal(t, 1.0, result.Ratio)
+ require.NotNil(t, result.Balance.Amount)
+ assert.InDelta(t, 12.34, *result.Balance.Amount, 1e-9)
+ require.NotNil(t, result.Debug)
+ assert.Equal(t, http.StatusOK, result.Debug.StatusCode)
+ assert.NotContains(t, result.Debug.ResponsePreview, "Bearer secret")
+ assert.Contains(t, result.Debug.ResponsePreview, "[REDACTED]")
+}
+
+func TestFetchChannelMonitorCustomUpstreamRatioStillReturnsIndependentBalance(t *testing.T) {
+ useChannelMonitorCustomTestFetchSettings(t)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/ratio" {
+ http.Error(w, "ratio unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ _, _ = w.Write([]byte(`{"balance":30}`))
+ }))
+ defer server.Close()
+
+ config := ChannelMonitorCustomUpstreamConfig{
+ Ratio: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceHTTP,
+ Request: &ChannelMonitorCustomRequestConfig{Method: http.MethodGet, Path: "/ratio", BodyType: ChannelMonitorCustomBodyNone},
+ Result: &ChannelMonitorCustomResultConfig{ResponseType: ChannelMonitorCustomResponseJSON, ValuePath: "ratio", Multiplier: 1},
+ },
+ Balance: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceHTTP,
+ Request: &ChannelMonitorCustomRequestConfig{Method: http.MethodGet, Path: "/balance", BodyType: ChannelMonitorCustomBodyNone},
+ Result: &ChannelMonitorCustomResultConfig{ResponseType: ChannelMonitorCustomResponseJSON, ValuePath: "balance", Multiplier: 1},
+ },
+ }
+
+ result, err := fetchChannelMonitorCustomUpstreamRatio(context.Background(), server.Client(), server.URL, config, false, false)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "503")
+ require.NotNil(t, result.Balance.Amount)
+ assert.Equal(t, 30.0, *result.Balance.Amount)
+}
+
+func TestFetchChannelMonitorCustomUpstreamRatioFailsWhenBalanceFails(t *testing.T) {
+ useChannelMonitorCustomTestFetchSettings(t)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/ratio" {
+ _, _ = w.Write([]byte(`{"ratio":1.2}`))
+ return
+ }
+ http.Error(w, "balance unavailable", http.StatusServiceUnavailable)
+ }))
+ defer server.Close()
+
+ result, err := fetchChannelMonitorCustomUpstreamRatio(context.Background(), server.Client(), server.URL, ChannelMonitorCustomUpstreamConfig{
+ Ratio: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceHTTP,
+ Request: &ChannelMonitorCustomRequestConfig{Method: http.MethodGet, Path: "/ratio", BodyType: ChannelMonitorCustomBodyNone},
+ Result: &ChannelMonitorCustomResultConfig{ResponseType: ChannelMonitorCustomResponseJSON, ValuePath: "ratio", Multiplier: 1},
+ },
+ Balance: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceHTTP,
+ Request: &ChannelMonitorCustomRequestConfig{Method: http.MethodGet, Path: "/balance", BodyType: ChannelMonitorCustomBodyNone},
+ Result: &ChannelMonitorCustomResultConfig{ResponseType: ChannelMonitorCustomResponseJSON, ValuePath: "balance", Multiplier: 1},
+ },
+ }, false, false)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "自定义余额更新失败")
+ assert.Equal(t, 1.2, result.Ratio)
+ assert.Contains(t, result.Balance.Error, "503")
+}
+
+func TestFetchChannelMonitorCustomFixedValues(t *testing.T) {
+ ratio := 0.8
+ balance := -5.0
+ result, err := fetchChannelMonitorCustomUpstreamRatio(context.Background(), http.DefaultClient, "https://example.com", ChannelMonitorCustomUpstreamConfig{
+ Ratio: ChannelMonitorCustomMetricConfig{Source: ChannelMonitorCustomSourceFixed, FixedValue: &ratio},
+ Balance: ChannelMonitorCustomMetricConfig{Source: ChannelMonitorCustomSourceFixed, FixedValue: &balance},
+ }, false, false)
+ require.NoError(t, err)
+ assert.Equal(t, ratio, result.Ratio)
+ require.NotNil(t, result.Balance.Amount)
+ assert.Equal(t, balance, *result.Balance.Amount)
+}
+
+func TestFetchChannelMonitorCustomUpstreamUsesChannelProxy(t *testing.T) {
+ fetchSetting := system_setting.GetFetchSetting()
+ originalFetchSetting := *fetchSetting
+ t.Cleanup(func() {
+ *fetchSetting = originalFetchSetting
+ ResetProxyClientCache()
+ })
+ fetchSetting.EnableSSRFProtection = true
+ fetchSetting.AllowPrivateIp = true
+ fetchSetting.DomainFilterMode = false
+ fetchSetting.IpFilterMode = false
+ fetchSetting.DomainList = nil
+ fetchSetting.IpList = nil
+ fetchSetting.AllowedPorts = []string{"80"}
+ fetchSetting.ApplyIPFilterForDomain = true
+ ResetProxyClientCache()
+
+ var requestCount atomic.Int32
+ proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestCount.Add(1)
+ assert.Equal(t, "93.184.216.34", r.URL.Host)
+ assert.Equal(t, "/metrics", r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"ratio":0.9}`))
+ }))
+ defer proxyServer.Close()
+
+ balance := 0.0
+ result, err := FetchChannelMonitorUpstreamGroupRatio(context.Background(), ChannelMonitorUpstreamConfig{
+ Type: CustomUpstreamType,
+ BaseURL: "http://93.184.216.34",
+ Proxy: proxyServer.URL,
+ SkipBalance: true,
+ CustomConfig: ChannelMonitorCustomUpstreamConfig{
+ Ratio: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceHTTP,
+ Request: &ChannelMonitorCustomRequestConfig{
+ Method: http.MethodGet,
+ Path: "/metrics",
+ BodyType: ChannelMonitorCustomBodyNone,
+ },
+ Result: &ChannelMonitorCustomResultConfig{
+ ResponseType: ChannelMonitorCustomResponseJSON,
+ ValuePath: "ratio",
+ Multiplier: 1,
+ },
+ },
+ Balance: ChannelMonitorCustomMetricConfig{
+ Source: ChannelMonitorCustomSourceFixed,
+ FixedValue: &balance,
+ },
+ },
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 0.9, result.Ratio)
+ assert.EqualValues(t, 1, requestCount.Load())
+}
diff --git a/service/channel_monitor_sub2api_auth.go b/service/channel_monitor_sub2api_auth.go
new file mode 100644
index 000000000000..777dcc9197e7
--- /dev/null
+++ b/service/channel_monitor_sub2api_auth.go
@@ -0,0 +1,264 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+)
+
+const (
+ maxSub2APIAccountLength = 320
+ maxSub2APIPasswordLength = 4096
+)
+
+type sub2APIAccountTokenCacheEntry struct {
+ accessToken string
+ expiresAt time.Time
+}
+
+type sub2APIAccountTokenCall struct {
+ done chan struct{}
+ accessToken string
+ err error
+}
+
+var sub2APIAccountTokenCache = struct {
+ sync.Mutex
+ tokens map[[32]byte]sub2APIAccountTokenCacheEntry
+ pending map[[32]byte]*sub2APIAccountTokenCall
+}{
+ tokens: make(map[[32]byte]sub2APIAccountTokenCacheEntry),
+ pending: make(map[[32]byte]*sub2APIAccountTokenCall),
+}
+
+type sub2APIAccountLoginRequest struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+ TurnstileToken string `json:"turnstile_token"`
+}
+
+type sub2APIAccountLoginResult struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int `json:"expires_in"`
+ Requires2FA bool `json:"requires_2fa"`
+}
+
+func normalizeSub2APIAccountConfig(config Sub2APIGroupRatioConfig) (string, string, string, error) {
+ baseURL, err := normalizeSub2APIBaseURL(config.BaseURL)
+ if err != nil {
+ return "", "", "", err
+ }
+ account := strings.TrimSpace(config.Account)
+ if account == "" {
+ return "", "", "", errors.New("请输入 Sub2API 登录邮箱")
+ }
+ if len([]rune(account)) > maxSub2APIAccountLength {
+ return "", "", "", errors.New("Sub2API 登录邮箱过长")
+ }
+ if config.Password == "" {
+ return "", "", "", errors.New("请输入 Sub2API 登录密码")
+ }
+ if len([]rune(config.Password)) > maxSub2APIPasswordLength {
+ return "", "", "", errors.New("Sub2API 登录密码过长")
+ }
+ return baseURL, account, config.Password, nil
+}
+
+func sub2APIAccountTokenCacheKey(baseURL string, account string, password string, proxy string) [32]byte {
+ return sha256.Sum256([]byte(baseURL + "\x00" + account + "\x00" + password + "\x00" + strings.TrimSpace(proxy)))
+}
+
+func resolveSub2APIAccountTokenConfig(ctx context.Context, client *http.Client, config Sub2APIGroupRatioConfig, validateURL func(string) error) (Sub2APIGroupRatioConfig, error) {
+ baseURL, account, password, err := normalizeSub2APIAccountConfig(config)
+ if err != nil {
+ return Sub2APIGroupRatioConfig{}, err
+ }
+ cacheKey := sub2APIAccountTokenCacheKey(baseURL, account, password, config.Proxy)
+
+ for {
+ now := time.Now()
+ sub2APIAccountTokenCache.Lock()
+ for key, entry := range sub2APIAccountTokenCache.tokens {
+ if !now.Before(entry.expiresAt) {
+ delete(sub2APIAccountTokenCache.tokens, key)
+ }
+ }
+ if entry, ok := sub2APIAccountTokenCache.tokens[cacheKey]; ok {
+ sub2APIAccountTokenCache.Unlock()
+ config.BaseURL = baseURL
+ config.AuthType = Sub2APIAuthToken
+ config.AccessToken = entry.accessToken
+ return config, nil
+ }
+ if call, ok := sub2APIAccountTokenCache.pending[cacheKey]; ok {
+ sub2APIAccountTokenCache.Unlock()
+ select {
+ case <-ctx.Done():
+ return Sub2APIGroupRatioConfig{}, ctx.Err()
+ case <-call.done:
+ if call.err != nil {
+ return Sub2APIGroupRatioConfig{}, call.err
+ }
+ config.BaseURL = baseURL
+ config.AuthType = Sub2APIAuthToken
+ config.AccessToken = call.accessToken
+ return config, nil
+ }
+ }
+ call := &sub2APIAccountTokenCall{done: make(chan struct{})}
+ sub2APIAccountTokenCache.pending[cacheKey] = call
+ sub2APIAccountTokenCache.Unlock()
+
+ accessToken, expiresIn, loginErr := loginSub2APIAccount(ctx, client, baseURL, account, password, validateURL)
+ if loginErr != nil {
+ loginErr = redactUpstreamGroupRatioSecrets(loginErr, account, password)
+ }
+ sub2APIAccountTokenCache.Lock()
+ delete(sub2APIAccountTokenCache.pending, cacheKey)
+ call.accessToken = accessToken
+ call.err = loginErr
+ if loginErr == nil {
+ ttl := 5 * time.Minute
+ if expiresIn > 0 {
+ const maxTokenCacheTTL = 24 * time.Hour
+ ttl = time.Duration(expiresIn) * time.Second
+ if ttl <= 0 || ttl > maxTokenCacheTTL {
+ ttl = maxTokenCacheTTL
+ }
+ safetyWindow := time.Minute
+ if ttl <= 2*time.Minute {
+ safetyWindow = ttl / 4
+ }
+ ttl -= safetyWindow
+ if ttl <= 0 {
+ ttl = time.Second
+ }
+ }
+ sub2APIAccountTokenCache.tokens[cacheKey] = sub2APIAccountTokenCacheEntry{
+ accessToken: accessToken,
+ expiresAt: time.Now().Add(ttl),
+ }
+ }
+ close(call.done)
+ sub2APIAccountTokenCache.Unlock()
+ if loginErr != nil {
+ return Sub2APIGroupRatioConfig{}, loginErr
+ }
+
+ config.BaseURL = baseURL
+ config.AuthType = Sub2APIAuthToken
+ config.AccessToken = accessToken
+ return config, nil
+ }
+}
+
+func invalidateSub2APIAccountToken(config Sub2APIGroupRatioConfig) {
+ baseURL, account, password, err := normalizeSub2APIAccountConfig(config)
+ if err != nil {
+ return
+ }
+ cacheKey := sub2APIAccountTokenCacheKey(baseURL, account, password, config.Proxy)
+ sub2APIAccountTokenCache.Lock()
+ delete(sub2APIAccountTokenCache.tokens, cacheKey)
+ sub2APIAccountTokenCache.Unlock()
+}
+
+func loginSub2APIAccount(ctx context.Context, client *http.Client, baseURL string, account string, password string, validateURL func(string) error) (string, int, error) {
+ requestBody, err := common.Marshal(sub2APIAccountLoginRequest{
+ Email: account,
+ Password: password,
+ })
+ if err != nil {
+ return "", 0, errors.New("Sub2API 登录请求生成失败")
+ }
+ requestURL := baseURL + "/api/v1/auth/login"
+ if validateURL != nil {
+ if err := validateURL(requestURL); err != nil {
+ return "", 0, err
+ }
+ }
+ requestContext, cancel := context.WithTimeout(ctx, upstreamGroupRatioTimeout)
+ defer cancel()
+ httpRequest, err := http.NewRequestWithContext(requestContext, http.MethodPost, requestURL, bytes.NewReader(requestBody))
+ if err != nil {
+ return "", 0, err
+ }
+ httpRequest.Header.Set("Accept", "application/json")
+ httpRequest.Header.Set("Content-Type", "application/json")
+
+ response, err := client.Do(httpRequest)
+ if err != nil {
+ return "", 0, fmt.Errorf("Sub2API 账号密码自动登录失败: %w", err)
+ }
+ defer response.Body.Close()
+ responseBody, err := io.ReadAll(io.LimitReader(response.Body, maxUpstreamGroupRatioResponseBytes+1))
+ if err != nil {
+ return "", 0, fmt.Errorf("Sub2API 账号密码自动登录失败: %w", err)
+ }
+ if len(responseBody) > maxUpstreamGroupRatioResponseBytes {
+ return "", 0, errors.New("Sub2API 登录响应过大")
+ }
+ bodyText := strings.ToLower(string(responseBody))
+ cloudflareChallenge := strings.EqualFold(strings.TrimSpace(response.Header.Get("cf-mitigated")), "challenge") ||
+ ((response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusServiceUnavailable) &&
+ (strings.Contains(bodyText, "/cdn-cgi/challenge-platform") ||
+ strings.Contains(bodyText, "cf-chl-") ||
+ (strings.Contains(bodyText, "cloudflare") && strings.Contains(bodyText, "challenge"))))
+ if cloudflareChallenge {
+ return "", 0, &channelMonitorUpstreamAuthenticationError{cause: errors.New("Sub2API 账号密码自动登录触发了 Cloudflare 人机验证,无法进行无人值守登录,请改用手动 Token")}
+ }
+
+ var payload sub2APIResponse
+ if err := common.Unmarshal(responseBody, &payload); err != nil {
+ if response.StatusCode == http.StatusForbidden {
+ return "", 0, &channelMonitorUpstreamAuthenticationError{cause: errors.New("Sub2API 账号密码自动登录被上游拒绝;如果启用了 Cloudflare Turnstile、WAF 人机验证或其他验证码,无法进行无人值守登录,请改用手动 Token")}
+ }
+ return "", 0, fmt.Errorf("Sub2API 登录响应格式无效: 上游返回 %s", response.Status)
+ }
+ if response.StatusCode != http.StatusOK || payload.Code != 0 {
+ reason := strings.ToUpper(strings.TrimSpace(payload.Reason))
+ message := strings.TrimSpace(payload.Message)
+ lowerMessage := strings.ToLower(message)
+ if strings.Contains(reason, "TOTP") || strings.Contains(reason, "2FA") ||
+ strings.Contains(lowerMessage, "totp") || strings.Contains(lowerMessage, "2fa") ||
+ strings.Contains(message, "两步验证") || strings.Contains(message, "二次验证") {
+ return "", 0, &channelMonitorUpstreamAuthenticationError{cause: errors.New("Sub2API 账号已开启 TOTP 两步验证,无法仅凭账号密码自动登录,请改用手动 Token 或未启用两步验证的专用账号")}
+ }
+ if strings.Contains(reason, "TURNSTILE") || strings.Contains(reason, "CAPTCHA") ||
+ strings.Contains(lowerMessage, "turnstile") || strings.Contains(lowerMessage, "captcha") ||
+ strings.Contains(message, "验证码") {
+ return "", 0, &channelMonitorUpstreamAuthenticationError{cause: errors.New("上游已开启 Cloudflare Turnstile 或验证码,账号密码无法完成无人值守登录,请改用手动 Token 或为监控使用未启用交互验证的专用账号")}
+ }
+ if message == "" {
+ message = response.Status
+ }
+ upstreamErr := fmt.Errorf("Sub2API 账号密码自动登录失败: %w", upstreamGroupRatioMessage(message))
+ if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden ||
+ payload.Code == http.StatusUnauthorized || payload.Code == http.StatusForbidden || reason == "INVALID_CREDENTIALS" {
+ return "", 0, &channelMonitorUpstreamAuthenticationError{cause: upstreamErr}
+ }
+ return "", 0, upstreamErr
+ }
+
+ var result sub2APIAccountLoginResult
+ if err := common.Unmarshal(payload.Data, &result); err != nil {
+ return "", 0, errors.New("Sub2API 登录响应格式无效")
+ }
+ if result.Requires2FA {
+ return "", 0, &channelMonitorUpstreamAuthenticationError{cause: errors.New("Sub2API 账号已开启 TOTP 两步验证,无法仅凭账号密码自动登录,请改用手动 Token 或未启用两步验证的专用账号")}
+ }
+ accessToken := strings.TrimSpace(result.AccessToken)
+ if accessToken == "" {
+ return "", 0, errors.New("Sub2API 登录成功但未返回访问 Token")
+ }
+ return accessToken, result.ExpiresIn, nil
+}
diff --git a/service/channel_monitor_sub2api_auth_test.go b/service/channel_monitor_sub2api_auth_test.go
new file mode 100644
index 000000000000..398dd4107548
--- /dev/null
+++ b/service/channel_monitor_sub2api_auth_test.go
@@ -0,0 +1,169 @@
+package service
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func resetSub2APIAccountTokenCache(t *testing.T) {
+ t.Helper()
+ reset := func() {
+ sub2APIAccountTokenCache.Lock()
+ sub2APIAccountTokenCache.tokens = make(map[[32]byte]sub2APIAccountTokenCacheEntry)
+ sub2APIAccountTokenCache.pending = make(map[[32]byte]*sub2APIAccountTokenCall)
+ sub2APIAccountTokenCache.Unlock()
+ }
+ reset()
+ t.Cleanup(reset)
+}
+
+func TestFetchSub2APIAccountLogsInAndCachesToken(t *testing.T) {
+ resetSub2APIAccountTokenCache(t)
+ var loginRequests atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/v1/auth/login":
+ loginRequests.Add(1)
+ var request sub2APIAccountLoginRequest
+ require.NoError(t, common.DecodeJson(r.Body, &request))
+ assert.Equal(t, "monitor@example.com", request.Email)
+ assert.Equal(t, "secret-password", request.Password)
+ assert.Empty(t, request.TurnstileToken)
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"auto-jwt","expires_in":3600,"token_type":"Bearer"}}`))
+ case "/api/v1/groups/available":
+ assert.Equal(t, "Bearer auto-jwt", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":[{"id":7,"name":"vip","rate_multiplier":1.25}]}`))
+ case "/api/v1/groups/rates":
+ assert.Equal(t, "Bearer auto-jwt", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"7":1.75}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ config := Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: Sub2APIAuthAccount,
+ Account: "monitor@example.com",
+ Password: "secret-password",
+ SkipBalance: true,
+ }
+ for range 2 {
+ result, err := fetchSub2APIGroupRatio(context.Background(), server.Client(), config, nil)
+ require.NoError(t, err)
+ assert.InDelta(t, 1.75, result.Ratio, 1e-9)
+ }
+ assert.EqualValues(t, 1, loginRequests.Load())
+}
+
+func TestFetchSub2APIAccountRefreshesRejectedCachedToken(t *testing.T) {
+ resetSub2APIAccountTokenCache(t)
+ var loginRequests atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/v1/auth/login":
+ token := "expired-jwt"
+ if loginRequests.Add(1) == 2 {
+ token = "fresh-jwt"
+ }
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"` + token + `","expires_in":3600}}`))
+ case "/api/v1/groups/available":
+ if r.Header.Get("Authorization") == "Bearer expired-jwt" {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"code":401,"message":"token expired","data":null}`))
+ return
+ }
+ assert.Equal(t, "Bearer fresh-jwt", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":[{"id":7,"name":"vip","rate_multiplier":1.5}]}`))
+ case "/api/v1/groups/rates":
+ assert.Equal(t, "Bearer fresh-jwt", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ result, err := fetchSub2APIGroupRatio(context.Background(), server.Client(), Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: Sub2APIAuthAccount,
+ Account: "monitor@example.com",
+ Password: "secret-password",
+ SkipBalance: true,
+ }, nil)
+ require.NoError(t, err)
+ assert.InDelta(t, 1.5, result.Ratio, 1e-9)
+ assert.EqualValues(t, 2, loginRequests.Load())
+}
+
+func TestFetchSub2APIAccountExplainsInteractiveLoginBlockers(t *testing.T) {
+ tests := []struct {
+ name string
+ statusCode int
+ body string
+ contains string
+ }{
+ {
+ name: "turnstile",
+ statusCode: http.StatusBadRequest,
+ body: `{"code":400,"message":"turnstile verification failed","reason":"TURNSTILE_VERIFICATION_FAILED"}`,
+ contains: "Turnstile",
+ },
+ {
+ name: "totp",
+ statusCode: http.StatusOK,
+ body: `{"code":0,"message":"success","data":{"requires_2fa":true,"temp_token":"temporary"}}`,
+ contains: "TOTP",
+ },
+ {
+ name: "cloudflare challenge",
+ statusCode: http.StatusServiceUnavailable,
+ body: `cloudflare challenge`,
+ contains: "Cloudflare",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ resetSub2APIAccountTokenCache(t)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/auth/login" {
+ http.NotFound(w, r)
+ return
+ }
+ if test.name == "cloudflare challenge" {
+ w.Header().Set("cf-mitigated", "challenge")
+ }
+ w.WriteHeader(test.statusCode)
+ _, _ = w.Write([]byte(test.body))
+ }))
+ defer server.Close()
+
+ _, err := fetchSub2APIGroupRatio(context.Background(), server.Client(), Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: Sub2APIAuthAccount,
+ Account: "monitor@example.com",
+ Password: "secret-password",
+ SkipBalance: true,
+ }, nil)
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrChannelMonitorUpstreamAuthentication)
+ assert.True(t, strings.Contains(err.Error(), test.contains), err.Error())
+ assert.NotContains(t, err.Error(), "secret-password")
+ })
+ }
+}
diff --git a/service/channel_monitor_upstream_version.go b/service/channel_monitor_upstream_version.go
new file mode 100644
index 000000000000..9c50b625c8f0
--- /dev/null
+++ b/service/channel_monitor_upstream_version.go
@@ -0,0 +1,96 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/QuantumNous/new-api/common"
+)
+
+const (
+ channelMonitorSub2APIPublicSettingsEndpoint = "/api/v1/settings/public"
+ channelMonitorUpstreamVersionTimeout = 10 * time.Second
+ channelMonitorUpstreamVersionBodyBytes = 1 << 20
+)
+
+type ChannelMonitorUpstreamVersionResult struct {
+ Version string `json:"version"`
+ Endpoint string `json:"endpoint"`
+}
+
+type channelMonitorSub2APIPublicSettingsResponse struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data struct {
+ Version string `json:"version"`
+ } `json:"data"`
+}
+
+// FetchSub2APIUpstreamVersion reads the public build version without requiring
+// either a Sub2API API Key or a legacy JWT token.
+func FetchSub2APIUpstreamVersion(ctx context.Context, baseURL string, proxyURL string) (ChannelMonitorUpstreamVersionResult, error) {
+ normalizedBaseURL, err := NormalizeNewAPIBaseURL(baseURL)
+ if err != nil {
+ return ChannelMonitorUpstreamVersionResult{}, err
+ }
+ client, err := NewSSRFProtectedHTTPClientWithProxy(proxyURL)
+ if err != nil {
+ return ChannelMonitorUpstreamVersionResult{}, err
+ }
+
+ requestURL := normalizedBaseURL + channelMonitorSub2APIPublicSettingsEndpoint
+ if err := ValidateSSRFProtectedFetchURL(requestURL); err != nil {
+ return ChannelMonitorUpstreamVersionResult{}, err
+ }
+ requestContext, cancel := context.WithTimeout(ctx, channelMonitorUpstreamVersionTimeout)
+ defer cancel()
+ request, err := http.NewRequestWithContext(requestContext, http.MethodGet, requestURL, nil)
+ if err != nil {
+ return ChannelMonitorUpstreamVersionResult{}, fmt.Errorf("读取 Sub2API 版本失败: %w", err)
+ }
+ request.Header.Set("Accept", "application/json")
+ response, err := client.Do(request)
+ if err != nil {
+ return ChannelMonitorUpstreamVersionResult{}, fmt.Errorf("读取 Sub2API 版本失败: %w", err)
+ }
+ defer response.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(response.Body, channelMonitorUpstreamVersionBodyBytes+1))
+ if err != nil {
+ return ChannelMonitorUpstreamVersionResult{}, fmt.Errorf("读取 Sub2API 版本失败: %w", err)
+ }
+ if len(body) > channelMonitorUpstreamVersionBodyBytes {
+ return ChannelMonitorUpstreamVersionResult{}, errors.New("Sub2API 版本响应过大")
+ }
+ if response.StatusCode != http.StatusOK {
+ return ChannelMonitorUpstreamVersionResult{}, fmt.Errorf("读取 Sub2API 版本失败: 上游返回 %s", response.Status)
+ }
+
+ var payload channelMonitorSub2APIPublicSettingsResponse
+ if err := common.Unmarshal(body, &payload); err != nil {
+ return ChannelMonitorUpstreamVersionResult{}, errors.New("Sub2API 版本响应格式无效")
+ }
+ if payload.Code != 0 {
+ message := strings.TrimSpace(payload.Message)
+ if message == "" {
+ message = "上游返回错误"
+ }
+ return ChannelMonitorUpstreamVersionResult{}, fmt.Errorf("读取 Sub2API 版本失败: %s", message)
+ }
+ version := strings.TrimSpace(payload.Data.Version)
+ if version == "" {
+ return ChannelMonitorUpstreamVersionResult{}, errors.New("Sub2API 未返回版本号")
+ }
+ if utf8.RuneCountInString(version) > 64 {
+ return ChannelMonitorUpstreamVersionResult{}, errors.New("Sub2API 版本号过长")
+ }
+ return ChannelMonitorUpstreamVersionResult{
+ Version: version,
+ Endpoint: channelMonitorSub2APIPublicSettingsEndpoint,
+ }, nil
+}
diff --git a/service/channel_monitor_upstream_version_test.go b/service/channel_monitor_upstream_version_test.go
new file mode 100644
index 000000000000..36e6fb74e893
--- /dev/null
+++ b/service/channel_monitor_upstream_version_test.go
@@ -0,0 +1,74 @@
+package service
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/setting/system_setting"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFetchSub2APIUpstreamVersionReadsPublicSettings(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodGet, r.Method)
+ assert.Equal(t, channelMonitorSub2APIPublicSettingsEndpoint, r.URL.Path)
+ assert.Empty(t, r.Header.Get("Authorization"))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"version":"0.1.161"}}`))
+ }))
+ defer server.Close()
+
+ fetchSetting := system_setting.GetFetchSetting()
+ originalFetchSetting := *fetchSetting
+ originalHTTPClient := httpClient
+ originalProtectedHTTPClient := ssrfProtectedHTTPClient
+ t.Cleanup(func() {
+ *fetchSetting = originalFetchSetting
+ httpClient = originalHTTPClient
+ ssrfProtectedHTTPClient = originalProtectedHTTPClient
+ })
+ fetchSetting.EnableSSRFProtection = false
+ httpClient = server.Client()
+
+ result, err := FetchSub2APIUpstreamVersion(context.Background(), server.URL, "")
+ require.NoError(t, err)
+ assert.Equal(t, "0.1.161", result.Version)
+ assert.Equal(t, channelMonitorSub2APIPublicSettingsEndpoint, result.Endpoint)
+}
+
+func TestFetchSub2APIUpstreamVersionUsesChannelProxy(t *testing.T) {
+ fetchSetting := system_setting.GetFetchSetting()
+ originalFetchSetting := *fetchSetting
+ t.Cleanup(func() {
+ *fetchSetting = originalFetchSetting
+ })
+ fetchSetting.EnableSSRFProtection = true
+ fetchSetting.AllowPrivateIp = true
+ fetchSetting.DomainFilterMode = false
+ fetchSetting.IpFilterMode = false
+ fetchSetting.DomainList = nil
+ fetchSetting.IpList = nil
+ fetchSetting.AllowedPorts = []string{"80"}
+ fetchSetting.ApplyIPFilterForDomain = true
+ ResetProxyClientCache()
+ t.Cleanup(ResetProxyClientCache)
+
+ proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "93.184.216.34", r.URL.Host)
+ assert.Equal(t, channelMonitorSub2APIPublicSettingsEndpoint, r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"version":"0.1.162"}}`))
+ }))
+ defer proxyServer.Close()
+
+ result, err := FetchSub2APIUpstreamVersion(
+ context.Background(),
+ "http://93.184.216.34",
+ proxyServer.URL,
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "0.1.162", result.Version)
+}
diff --git a/service/channel_ratio_monitor.go b/service/channel_ratio_monitor.go
new file mode 100644
index 000000000000..ab22a635d0cd
--- /dev/null
+++ b/service/channel_ratio_monitor.go
@@ -0,0 +1,1675 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+)
+
+const (
+ NewAPIUpstreamType = "new_api"
+ NewAPIUpstreamAuthPublic = "public"
+ NewAPIUpstreamAuthUser = "user"
+ Sub2APIUpstreamType = "sub2api"
+ // Sub2APIAuthAPIKey is for versions exposing /v1/sub2api/billing (v0.1.157+).
+ Sub2APIAuthAPIKey = "api_key"
+ // Sub2APIAuthAccount logs in through /api/v1/auth/login and caches the returned JWT.
+ Sub2APIAuthAccount = "account"
+ // Sub2APIAuthToken is for legacy versions where the panel JWT can call /api/v1/* directly.
+ Sub2APIAuthToken = "token"
+
+ maxUpstreamGroupRatioResponseBytes = 1 << 20
+ maxUpstreamGroupRatio = 1_000_000
+ upstreamGroupRatioTimeout = 15 * time.Second
+ upstreamGroupApplyTimeout = 30 * time.Second
+)
+
+var ErrChannelMonitorUpstreamAuthentication = errors.New("channel monitor upstream authentication failed")
+
+type channelMonitorUpstreamAuthenticationError struct {
+ cause error
+}
+
+func (err *channelMonitorUpstreamAuthenticationError) Error() string {
+ return err.cause.Error()
+}
+
+func (err *channelMonitorUpstreamAuthenticationError) Unwrap() error {
+ return err.cause
+}
+
+func (err *channelMonitorUpstreamAuthenticationError) Is(target error) bool {
+ return target == ErrChannelMonitorUpstreamAuthentication
+}
+
+// ChannelMonitorUpstreamConfig contains the credentials needed to read a
+// group multiplier from a configured upstream panel.
+type ChannelMonitorUpstreamConfig struct {
+ Type string
+ BaseURL string
+ Group string
+ AuthType string
+ UserID int
+ AccessToken string
+ Account string
+ Password string
+ ChannelKeys []string
+ Proxy string
+ SkipBalance bool
+ CostConversion ChannelMonitorCostConversion
+ CustomConfig ChannelMonitorCustomUpstreamConfig
+ CustomDebug bool
+}
+
+type NewAPIGroupRatioConfig struct {
+ BaseURL string
+ Group string
+ AuthType string
+ UserID int
+ AccessToken string
+}
+
+type NewAPIGroupRatioResult struct {
+ Ratio float64 `json:"ratio"`
+ CostRatio float64 `json:"cost_ratio"`
+ ConversionFactor float64 `json:"conversion_factor"`
+ Endpoint string `json:"endpoint"`
+ Balance ChannelMonitorUpstreamBalanceResult `json:"balance"`
+ Debug *ChannelMonitorCustomRequestDebug `json:"debug,omitempty"`
+}
+
+type ChannelMonitorUpstreamBalanceResult struct {
+ Amount *float64 `json:"amount"`
+ Endpoint string `json:"endpoint,omitempty"`
+ Error string `json:"error,omitempty"`
+ Debug *ChannelMonitorCustomRequestDebug `json:"debug,omitempty"`
+}
+
+type ChannelMonitorUpstreamGroup struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Ratio float64 `json:"ratio"`
+ Endpoint string `json:"-"`
+}
+
+type ChannelMonitorUpstreamGroupsResult struct {
+ Groups []ChannelMonitorUpstreamGroup `json:"groups"`
+ Balance ChannelMonitorUpstreamBalanceResult `json:"balance"`
+ AppliedGroup string `json:"applied_group,omitempty"`
+ AppliedGroupError string `json:"applied_group_error,omitempty"`
+}
+
+func sortChannelMonitorUpstreamGroups(groups []ChannelMonitorUpstreamGroup) {
+ sort.Slice(groups, func(i, j int) bool {
+ if groups[i].Ratio != groups[j].Ratio {
+ return groups[i].Ratio < groups[j].Ratio
+ }
+ if groups[i].Name != groups[j].Name {
+ return groups[i].Name < groups[j].Name
+ }
+ return groups[i].ID < groups[j].ID
+ })
+}
+
+type ChannelMonitorUpstreamGroupApplyResult struct {
+ Result NewAPIGroupRatioResult `json:"result"`
+ KeysUpdated int `json:"keys_updated"`
+}
+
+type Sub2APIGroupRatioConfig struct {
+ BaseURL string
+ Group string
+ AuthType string
+ AccessToken string
+ Account string
+ Password string
+ Proxy string
+ ChannelKeys []string
+ SkipBalance bool
+}
+
+type newAPIGroupRatioEntry struct {
+ Ratio json.RawMessage `json:"ratio"`
+}
+
+type newAPIUserGroupsResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ Data map[string]newAPIGroupRatioEntry `json:"data"`
+}
+
+type newAPIPricingResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ GroupRatio map[string]json.RawMessage `json:"group_ratio"`
+}
+
+type newAPIUserSelfResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ Data struct {
+ Quota json.RawMessage `json:"quota"`
+ } `json:"data"`
+}
+
+type newAPIStatusResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ Data struct {
+ QuotaPerUnit json.RawMessage `json:"quota_per_unit"`
+ } `json:"data"`
+}
+
+type newAPIUpstreamToken struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ ExpiredTime int64 `json:"expired_time"`
+ RemainQuota int `json:"remain_quota"`
+ UnlimitedQuota bool `json:"unlimited_quota"`
+ ModelLimitsEnabled bool `json:"model_limits_enabled"`
+ ModelLimits string `json:"model_limits"`
+ AllowIPs *string `json:"allow_ips"`
+ Group string `json:"group"`
+ CrossGroupRetry bool `json:"cross_group_retry"`
+}
+
+type newAPIUpstreamTokenPage struct {
+ Items []newAPIUpstreamToken `json:"items"`
+}
+
+type newAPIUpstreamTokenListResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ Data newAPIUpstreamTokenPage `json:"data"`
+}
+
+type newAPIUpstreamTokenUpdateResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
+
+func NormalizeNewAPIBaseURL(value string) (string, error) {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return "", errors.New("请输入上游面板地址")
+ }
+ if len(value) > 2048 {
+ return "", errors.New("上游面板地址过长")
+ }
+
+ parsed, err := url.Parse(value)
+ if err != nil {
+ return "", fmt.Errorf("上游面板地址无效: %w", err)
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return "", errors.New("上游面板地址必须使用 HTTP 或 HTTPS")
+ }
+ if parsed.Host == "" {
+ return "", errors.New("上游面板地址缺少主机名")
+ }
+ if parsed.User != nil {
+ return "", errors.New("上游面板地址不能包含账号密码")
+ }
+ if parsed.RawQuery != "" || parsed.Fragment != "" {
+ return "", errors.New("上游面板地址不能包含查询参数或片段")
+ }
+
+ parsed.Path = strings.TrimRight(parsed.Path, "/")
+ if strings.HasSuffix(parsed.Path, "/v1") {
+ parsed.Path = strings.TrimSuffix(parsed.Path, "/v1")
+ }
+ parsed.RawPath = ""
+ return strings.TrimRight(parsed.String(), "/"), nil
+}
+
+func FetchChannelMonitorUpstreamGroupRatio(ctx context.Context, config ChannelMonitorUpstreamConfig) (NewAPIGroupRatioResult, error) {
+ client, err := NewSSRFProtectedHTTPClientWithProxy(config.Proxy)
+ if err != nil {
+ return NewAPIGroupRatioResult{}, err
+ }
+ var result NewAPIGroupRatioResult
+ switch config.Type {
+ case NewAPIUpstreamType:
+ newAPIConfig := NewAPIGroupRatioConfig{
+ BaseURL: config.BaseURL,
+ Group: config.Group,
+ AuthType: config.AuthType,
+ UserID: config.UserID,
+ AccessToken: config.AccessToken,
+ }
+ result, err = fetchNewAPIGroupRatio(ctx, client, newAPIConfig, ValidateSSRFProtectedFetchURL)
+ if err != nil {
+ return result, err
+ }
+ if !config.SkipBalance {
+ balance, balanceErr := fetchNewAPIUpstreamBalance(ctx, client, newAPIConfig, ValidateSSRFProtectedFetchURL)
+ if balanceErr != nil {
+ result.Balance.Error = balanceErr.Error()
+ } else {
+ result.Balance = balance
+ }
+ }
+ case Sub2APIUpstreamType:
+ result, err = fetchSub2APIGroupRatio(ctx, client, Sub2APIGroupRatioConfig{
+ BaseURL: config.BaseURL,
+ Group: config.Group,
+ AuthType: config.AuthType,
+ AccessToken: config.AccessToken,
+ Account: config.Account,
+ Password: config.Password,
+ Proxy: config.Proxy,
+ ChannelKeys: config.ChannelKeys,
+ SkipBalance: config.SkipBalance,
+ }, ValidateSSRFProtectedFetchURL)
+ if err != nil {
+ return result, err
+ }
+ case CustomUpstreamType:
+ result, err = fetchChannelMonitorCustomUpstreamRatio(
+ ctx,
+ client,
+ config.BaseURL,
+ config.CustomConfig,
+ config.SkipBalance,
+ config.CustomDebug,
+ )
+ if err != nil {
+ return result, err
+ }
+ default:
+ return NewAPIGroupRatioResult{}, errors.New("不支持的上游类型")
+ }
+ return applyChannelMonitorCostConversion(result, config.CostConversion)
+}
+
+func applyChannelMonitorCostConversion(result NewAPIGroupRatioResult, config ChannelMonitorCostConversion) (NewAPIGroupRatioResult, error) {
+ costRatio, factor, err := CalculateChannelMonitorCostRatio(result.Ratio, config)
+ if err != nil {
+ return result, err
+ }
+ result.CostRatio = costRatio
+ result.ConversionFactor = factor
+ return result, nil
+}
+
+func FetchChannelMonitorUpstreamBalance(ctx context.Context, config ChannelMonitorUpstreamConfig) (ChannelMonitorUpstreamBalanceResult, error) {
+ client, err := NewSSRFProtectedHTTPClientWithProxy(config.Proxy)
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ switch config.Type {
+ case NewAPIUpstreamType:
+ return fetchNewAPIUpstreamBalance(ctx, client, NewAPIGroupRatioConfig{
+ BaseURL: config.BaseURL,
+ AuthType: config.AuthType,
+ UserID: config.UserID,
+ AccessToken: config.AccessToken,
+ }, ValidateSSRFProtectedFetchURL)
+ case Sub2APIUpstreamType:
+ return fetchSub2APIUpstreamBalance(ctx, client, Sub2APIGroupRatioConfig{
+ BaseURL: config.BaseURL,
+ AuthType: config.AuthType,
+ AccessToken: config.AccessToken,
+ Account: config.Account,
+ Password: config.Password,
+ Proxy: config.Proxy,
+ ChannelKeys: config.ChannelKeys,
+ }, ValidateSSRFProtectedFetchURL)
+ case CustomUpstreamType:
+ return fetchChannelMonitorCustomUpstreamBalance(
+ ctx,
+ client,
+ config.BaseURL,
+ config.CustomConfig,
+ config.CustomDebug,
+ )
+ default:
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("不支持的上游类型")
+ }
+}
+
+func fetchNewAPIUpstreamBalance(ctx context.Context, client *http.Client, config NewAPIGroupRatioConfig, validateURL func(string) error) (ChannelMonitorUpstreamBalanceResult, error) {
+ config, _, err := normalizeNewAPIGroupRatioConfig(config)
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ if config.AuthType != NewAPIUpstreamAuthUser {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("New API 公开认证无法获取上游余额")
+ }
+
+ requestContext, cancel := context.WithTimeout(ctx, upstreamGroupRatioTimeout)
+ defer cancel()
+ userBody, err := requestNewAPIUser(
+ requestContext,
+ client,
+ http.MethodGet,
+ config.BaseURL+"/api/user/self",
+ nil,
+ config,
+ "读取用户余额",
+ validateURL,
+ )
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ var userResponse newAPIUserSelfResponse
+ if err := common.Unmarshal(userBody, &userResponse); err != nil || len(userResponse.Data.Quota) == 0 {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("New API 用户余额响应格式无效")
+ }
+ if !userResponse.Success {
+ return ChannelMonitorUpstreamBalanceResult{}, upstreamGroupRatioMessage(userResponse.Message)
+ }
+ var quota float64
+ if err := common.Unmarshal(userResponse.Data.Quota, "a); err != nil || math.IsNaN(quota) || math.IsInf(quota, 0) {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("New API 用户余额不是有效数字")
+ }
+
+ statusURL := config.BaseURL + "/api/status"
+ if validateURL != nil {
+ if err := validateURL(statusURL); err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ }
+ statusRequest, err := http.NewRequestWithContext(requestContext, http.MethodGet, statusURL, nil)
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ statusRequest.Header.Set("Accept", "application/json")
+ statusHTTPResponse, err := client.Do(statusRequest)
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, fmt.Errorf("New API 读取额度换算配置失败: %w", err)
+ }
+ defer statusHTTPResponse.Body.Close()
+ statusBody, err := io.ReadAll(io.LimitReader(statusHTTPResponse.Body, maxUpstreamGroupRatioResponseBytes+1))
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, fmt.Errorf("New API 读取额度换算配置失败: %w", err)
+ }
+ if len(statusBody) > maxUpstreamGroupRatioResponseBytes {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("New API 上游响应过大")
+ }
+ if statusHTTPResponse.StatusCode != http.StatusOK {
+ return ChannelMonitorUpstreamBalanceResult{}, fmt.Errorf("New API 读取额度换算配置失败: 上游返回 %s", statusHTTPResponse.Status)
+ }
+ var statusResponse newAPIStatusResponse
+ if err := common.Unmarshal(statusBody, &statusResponse); err != nil || len(statusResponse.Data.QuotaPerUnit) == 0 {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("New API 额度换算配置响应格式无效")
+ }
+ if !statusResponse.Success {
+ return ChannelMonitorUpstreamBalanceResult{}, upstreamGroupRatioMessage(statusResponse.Message)
+ }
+ var quotaPerUnit float64
+ if err := common.Unmarshal(statusResponse.Data.QuotaPerUnit, "aPerUnit); err != nil || math.IsNaN(quotaPerUnit) || math.IsInf(quotaPerUnit, 0) || quotaPerUnit <= 0 {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("New API quota_per_unit 不是有效数字")
+ }
+
+ amount := quota / quotaPerUnit
+ if math.IsNaN(amount) || math.IsInf(amount, 0) {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("New API 上游余额换算失败")
+ }
+ return ChannelMonitorUpstreamBalanceResult{
+ Amount: &amount,
+ Endpoint: "/api/user/self",
+ }, nil
+}
+
+func FetchChannelMonitorUpstreamGroups(ctx context.Context, config ChannelMonitorUpstreamConfig, channelKeys []string) (ChannelMonitorUpstreamGroupsResult, error) {
+ client, err := NewSSRFProtectedHTTPClientWithProxy(config.Proxy)
+ if err != nil {
+ return ChannelMonitorUpstreamGroupsResult{}, err
+ }
+ switch config.Type {
+ case NewAPIUpstreamType:
+ newAPIConfig := NewAPIGroupRatioConfig{
+ BaseURL: config.BaseURL,
+ AuthType: config.AuthType,
+ UserID: config.UserID,
+ AccessToken: config.AccessToken,
+ }
+ result, err := fetchNewAPIUpstreamGroups(ctx, client, newAPIConfig, ValidateSSRFProtectedFetchURL)
+ if err != nil || config.AuthType != NewAPIUpstreamAuthUser || len(channelKeys) == 0 {
+ return result, err
+ }
+ appliedGroup, appliedGroupErr := fetchNewAPIUpstreamKeyGroup(ctx, client, newAPIConfig, channelKeys, ValidateSSRFProtectedFetchURL)
+ if appliedGroupErr != nil {
+ secrets := []string{config.AccessToken}
+ for _, channelKey := range channelKeys {
+ secrets = append(secrets, channelKey, url.QueryEscape(channelKey))
+ }
+ result.AppliedGroupError = redactUpstreamGroupRatioSecrets(appliedGroupErr, secrets...).Error()
+ } else {
+ result.AppliedGroup = appliedGroup
+ }
+ return result, nil
+ case Sub2APIUpstreamType:
+ return fetchSub2APIUpstreamGroups(ctx, client, Sub2APIGroupRatioConfig{
+ BaseURL: config.BaseURL,
+ AuthType: config.AuthType,
+ AccessToken: config.AccessToken,
+ Account: config.Account,
+ Password: config.Password,
+ Proxy: config.Proxy,
+ SkipBalance: config.SkipBalance,
+ }, channelKeys, ValidateSSRFProtectedFetchURL)
+ default:
+ return ChannelMonitorUpstreamGroupsResult{}, errors.New("不支持的上游类型")
+ }
+}
+
+func normalizeChannelMonitorKeys(channelKeys []string) ([]string, error) {
+ keys := make([]string, 0, len(channelKeys))
+ seen := make(map[string]struct{}, len(channelKeys))
+ for _, channelKey := range channelKeys {
+ channelKey = strings.TrimSpace(channelKey)
+ if channelKey == "" {
+ continue
+ }
+ if len([]rune(channelKey)) > 4096 {
+ return nil, errors.New("渠道上游令牌过长")
+ }
+ if _, exists := seen[channelKey]; exists {
+ continue
+ }
+ seen[channelKey] = struct{}{}
+ keys = append(keys, channelKey)
+ }
+ return keys, nil
+}
+
+func ApplyChannelMonitorUpstreamGroup(ctx context.Context, config ChannelMonitorUpstreamConfig, channelKeys []string) (ChannelMonitorUpstreamGroupApplyResult, error) {
+ client, err := NewSSRFProtectedHTTPClientWithProxy(config.Proxy)
+ if err != nil {
+ return ChannelMonitorUpstreamGroupApplyResult{}, err
+ }
+ result, err := applyChannelMonitorUpstreamGroup(ctx, client, config, channelKeys, ValidateSSRFProtectedFetchURL)
+ if err != nil {
+ return result, err
+ }
+ result.Result, err = applyChannelMonitorCostConversion(result.Result, config.CostConversion)
+ return result, err
+}
+
+func applyChannelMonitorUpstreamGroup(ctx context.Context, client *http.Client, config ChannelMonitorUpstreamConfig, channelKeys []string, validateURL func(string) error) (ChannelMonitorUpstreamGroupApplyResult, error) {
+ keys, err := normalizeChannelMonitorKeys(channelKeys)
+ if err != nil {
+ return ChannelMonitorUpstreamGroupApplyResult{}, err
+ }
+ if len(keys) == 0 {
+ return ChannelMonitorUpstreamGroupApplyResult{}, errors.New("当前渠道没有可应用分组的上游令牌")
+ }
+
+ requestContext, cancel := context.WithTimeout(ctx, upstreamGroupApplyTimeout)
+ defer cancel()
+
+ var result ChannelMonitorUpstreamGroupApplyResult
+ var applyErr error
+ switch config.Type {
+ case NewAPIUpstreamType:
+ result, applyErr = applyNewAPIUpstreamGroup(requestContext, client, config, keys, validateURL)
+ case Sub2APIUpstreamType:
+ result, applyErr = applySub2APIUpstreamGroup(requestContext, client, config, keys, validateURL)
+ case CustomUpstreamType:
+ return ChannelMonitorUpstreamGroupApplyResult{}, errors.New("自定义上游不支持自动切换分组,请手动修改上游配置")
+ default:
+ applyErr = errors.New("不支持的上游类型")
+ }
+ if applyErr == nil {
+ return result, nil
+ }
+ accessToken := strings.TrimSpace(config.AccessToken)
+ secrets := []string{
+ accessToken,
+ strings.TrimPrefix(accessToken, "Bearer "),
+ }
+ for _, key := range keys {
+ secrets = append(secrets, key, url.QueryEscape(key))
+ }
+ return result, redactUpstreamGroupRatioSecrets(applyErr, secrets...)
+}
+
+func FetchNewAPIGroupRatio(ctx context.Context, config NewAPIGroupRatioConfig) (NewAPIGroupRatioResult, error) {
+ client := GetSSRFProtectedHTTPClient()
+ if client == nil {
+ return NewAPIGroupRatioResult{}, errors.New("上游请求客户端未初始化")
+ }
+ return fetchNewAPIGroupRatio(ctx, client, config, ValidateSSRFProtectedFetchURL)
+}
+
+func fetchNewAPIGroupRatio(ctx context.Context, client *http.Client, config NewAPIGroupRatioConfig, validateURL func(string) error) (NewAPIGroupRatioResult, error) {
+ config, endpoints, err := normalizeNewAPIGroupRatioConfig(config)
+ if err != nil {
+ return NewAPIGroupRatioResult{}, err
+ }
+ config.Group = strings.TrimSpace(config.Group)
+ if config.Group == "" {
+ return NewAPIGroupRatioResult{}, errors.New("请输入上游分组")
+ }
+ if config.Group == "auto" {
+ return NewAPIGroupRatioResult{}, errors.New("上游自动分组没有固定倍率,无法用于倍率监控")
+ }
+
+ requestContext, cancel := context.WithTimeout(ctx, upstreamGroupRatioTimeout)
+ defer cancel()
+
+ errorsByEndpoint := make([]string, 0, len(endpoints))
+ for _, endpoint := range endpoints {
+ ratios, fetchErr := fetchNewAPIGroupRatiosEndpoint(requestContext, client, config, endpoint, validateURL)
+ if fetchErr == nil {
+ if ratio, exists := ratios[config.Group]; exists {
+ return NewAPIGroupRatioResult{Ratio: ratio, Endpoint: endpoint}, nil
+ }
+ fetchErr = fmt.Errorf("上游未返回分组 %q", config.Group)
+ }
+ errorsByEndpoint = append(errorsByEndpoint, endpoint+": "+fetchErr.Error())
+ }
+ return NewAPIGroupRatioResult{}, errors.New(strings.Join(errorsByEndpoint, "; "))
+}
+
+func normalizeNewAPIGroupRatioConfig(config NewAPIGroupRatioConfig) (NewAPIGroupRatioConfig, []string, error) {
+ baseURL, err := NormalizeNewAPIBaseURL(config.BaseURL)
+ if err != nil {
+ return NewAPIGroupRatioConfig{}, nil, err
+ }
+ config.BaseURL = baseURL
+ config.AuthType = strings.TrimSpace(config.AuthType)
+ switch config.AuthType {
+ case NewAPIUpstreamAuthPublic:
+ return config, []string{"/api/pricing", "/api/user/groups"}, nil
+ case NewAPIUpstreamAuthUser:
+ if config.UserID <= 0 || strings.TrimSpace(config.AccessToken) == "" {
+ return NewAPIGroupRatioConfig{}, nil, errors.New("请输入上游用户 ID 和访问令牌")
+ }
+ return config, []string{"/api/user/self/groups"}, nil
+ default:
+ return NewAPIGroupRatioConfig{}, nil, errors.New("不支持的上游认证方式")
+ }
+}
+
+func fetchNewAPIUpstreamGroups(ctx context.Context, client *http.Client, config NewAPIGroupRatioConfig, validateURL func(string) error) (ChannelMonitorUpstreamGroupsResult, error) {
+ config, endpoints, err := normalizeNewAPIGroupRatioConfig(config)
+ if err != nil {
+ return ChannelMonitorUpstreamGroupsResult{}, err
+ }
+ requestContext, cancel := context.WithTimeout(ctx, upstreamGroupRatioTimeout)
+ defer cancel()
+
+ errorsByEndpoint := make([]string, 0, len(endpoints))
+ for _, endpoint := range endpoints {
+ ratios, fetchErr := fetchNewAPIGroupRatiosEndpoint(requestContext, client, config, endpoint, validateURL)
+ if fetchErr != nil {
+ errorsByEndpoint = append(errorsByEndpoint, endpoint+": "+fetchErr.Error())
+ continue
+ }
+ groups := make([]ChannelMonitorUpstreamGroup, 0, len(ratios))
+ for name, ratio := range ratios {
+ groups = append(groups, ChannelMonitorUpstreamGroup{Name: name, Ratio: ratio, Endpoint: endpoint})
+ }
+ sortChannelMonitorUpstreamGroups(groups)
+ return ChannelMonitorUpstreamGroupsResult{Groups: groups}, nil
+ }
+ return ChannelMonitorUpstreamGroupsResult{}, errors.New(strings.Join(errorsByEndpoint, "; "))
+}
+
+func fetchNewAPIUpstreamKeyGroup(ctx context.Context, client *http.Client, config NewAPIGroupRatioConfig, channelKeys []string, validateURL func(string) error) (string, error) {
+ config, _, err := normalizeNewAPIGroupRatioConfig(config)
+ if err != nil {
+ return "", err
+ }
+ if config.AuthType != NewAPIUpstreamAuthUser {
+ return "", errors.New("New API 公开认证无法读取 API Key 当前分组")
+ }
+ keys, err := normalizeChannelMonitorKeys(channelKeys)
+ if err != nil {
+ return "", err
+ }
+ if len(keys) == 0 {
+ return "", errors.New("当前渠道没有可匹配的 API Key")
+ }
+
+ requestContext, cancel := context.WithTimeout(ctx, upstreamGroupApplyTimeout)
+ defer cancel()
+ appliedGroup := ""
+ for index, channelKey := range keys {
+ token, findErr := findNewAPIUpstreamToken(requestContext, client, config, channelKey, validateURL)
+ if findErr != nil {
+ return "", fmt.Errorf("读取第 %d 个上游 API Key 当前分组失败: %w", index+1, findErr)
+ }
+ group := strings.TrimSpace(token.Group)
+ if group == "" {
+ return "", fmt.Errorf("第 %d 个上游 API Key 没有设置分组", index+1)
+ }
+ if appliedGroup == "" {
+ appliedGroup = group
+ continue
+ }
+ if appliedGroup != group {
+ return "", errors.New("当前渠道的多个上游 API Key 使用了不同分组,未自动选择")
+ }
+ }
+ return appliedGroup, nil
+}
+
+func fetchNewAPIGroupRatiosEndpoint(ctx context.Context, client *http.Client, config NewAPIGroupRatioConfig, endpoint string, validateURL func(string) error) (map[string]float64, error) {
+ requestURL := config.BaseURL + endpoint
+ if validateURL != nil {
+ if err := validateURL(requestURL); err != nil {
+ return nil, err
+ }
+ }
+ httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ httpRequest.Header.Set("Accept", "application/json")
+ if config.AuthType == NewAPIUpstreamAuthUser {
+ accessToken := strings.TrimSpace(config.AccessToken)
+ accessToken = strings.TrimPrefix(accessToken, "Bearer ")
+ httpRequest.Header.Set("Authorization", "Bearer "+accessToken)
+ httpRequest.Header.Set("New-Api-User", strconv.Itoa(config.UserID))
+ }
+
+ response, err := client.Do(httpRequest)
+ if err != nil {
+ return nil, err
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("上游返回 %s", response.Status)
+ }
+
+ body, err := io.ReadAll(io.LimitReader(response.Body, maxUpstreamGroupRatioResponseBytes+1))
+ if err != nil {
+ return nil, err
+ }
+ if len(body) > maxUpstreamGroupRatioResponseBytes {
+ return nil, errors.New("上游响应过大")
+ }
+
+ rawRatios := make(map[string]json.RawMessage)
+ if endpoint == "/api/pricing" {
+ var payload newAPIPricingResponse
+ if err := common.Unmarshal(body, &payload); err != nil {
+ return nil, errors.New("上游价格响应格式无效")
+ }
+ if !payload.Success {
+ return nil, upstreamGroupRatioMessage(payload.Message)
+ }
+ rawRatios = payload.GroupRatio
+ } else {
+ var payload newAPIUserGroupsResponse
+ if err := common.Unmarshal(body, &payload); err != nil {
+ return nil, errors.New("上游分组响应格式无效")
+ }
+ if !payload.Success {
+ return nil, upstreamGroupRatioMessage(payload.Message)
+ }
+ for name, entry := range payload.Data {
+ rawRatios[name] = entry.Ratio
+ }
+ }
+ if len(rawRatios) == 0 {
+ return nil, errors.New("上游未返回可用分组")
+ }
+
+ ratios := make(map[string]float64, len(rawRatios))
+ for name, rawRatio := range rawRatios {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ ratio, parseErr := parseUpstreamGroupRatio(rawRatio)
+ if parseErr != nil {
+ // New API intentionally reports the automatic group as "自动" because
+ // it has no fixed multiplier. Skip it without hiding malformed ratios
+ // returned for ordinary groups.
+ if name == "auto" {
+ continue
+ }
+ return nil, fmt.Errorf("上游分组 %q: %w", name, parseErr)
+ }
+ ratios[name] = ratio
+ }
+ if len(ratios) == 0 {
+ return nil, errors.New("上游未返回可用分组")
+ }
+ return ratios, nil
+}
+
+func applyNewAPIUpstreamGroup(ctx context.Context, client *http.Client, config ChannelMonitorUpstreamConfig, channelKeys []string, validateURL func(string) error) (ChannelMonitorUpstreamGroupApplyResult, error) {
+ if config.AuthType != NewAPIUpstreamAuthUser {
+ return ChannelMonitorUpstreamGroupApplyResult{}, errors.New("New API 应用上游分组需要使用用户认证")
+ }
+ groupConfig, _, err := normalizeNewAPIGroupRatioConfig(NewAPIGroupRatioConfig{
+ BaseURL: config.BaseURL,
+ Group: strings.TrimSpace(config.Group),
+ AuthType: config.AuthType,
+ UserID: config.UserID,
+ AccessToken: config.AccessToken,
+ })
+ if err != nil {
+ return ChannelMonitorUpstreamGroupApplyResult{}, err
+ }
+ if groupConfig.Group == "" {
+ return ChannelMonitorUpstreamGroupApplyResult{}, errors.New("请输入上游分组")
+ }
+
+ ratioResult, err := fetchNewAPIGroupRatio(ctx, client, groupConfig, validateURL)
+ if err != nil {
+ return ChannelMonitorUpstreamGroupApplyResult{}, err
+ }
+ result := ChannelMonitorUpstreamGroupApplyResult{Result: ratioResult}
+ for index, channelKey := range channelKeys {
+ token, findErr := findNewAPIUpstreamToken(ctx, client, groupConfig, channelKey, validateURL)
+ if findErr != nil {
+ return result, fmt.Errorf("查找第 %d 个上游令牌失败: %w", index+1, findErr)
+ }
+ token.Group = groupConfig.Group
+ if updateErr := updateNewAPIUpstreamToken(ctx, client, groupConfig, token, validateURL); updateErr != nil {
+ return result, fmt.Errorf("更新第 %d 个上游令牌失败: %w", index+1, updateErr)
+ }
+ result.KeysUpdated++
+ }
+ return result, nil
+}
+
+func findNewAPIUpstreamToken(ctx context.Context, client *http.Client, config NewAPIGroupRatioConfig, channelKey string, validateURL func(string) error) (newAPIUpstreamToken, error) {
+ query := url.Values{}
+ query.Set("p", "1")
+ query.Set("page_size", "2")
+ query.Set("token", channelKey)
+ responseBody, err := requestNewAPIUser(
+ ctx,
+ client,
+ http.MethodGet,
+ config.BaseURL+"/api/token/search?"+query.Encode(),
+ nil,
+ config,
+ "查找上游令牌",
+ validateURL,
+ )
+ if err != nil {
+ return newAPIUpstreamToken{}, err
+ }
+ var response newAPIUpstreamTokenListResponse
+ if err := common.Unmarshal(responseBody, &response); err != nil {
+ return newAPIUpstreamToken{}, errors.New("New API 上游令牌响应格式无效")
+ }
+ if !response.Success {
+ return newAPIUpstreamToken{}, upstreamGroupRatioMessage(response.Message)
+ }
+ if len(response.Data.Items) == 0 {
+ return newAPIUpstreamToken{}, errors.New("New API 未找到与当前渠道 Key 对应的上游令牌")
+ }
+ if len(response.Data.Items) > 1 {
+ return newAPIUpstreamToken{}, errors.New("New API 返回了多个匹配的上游令牌")
+ }
+ return response.Data.Items[0], nil
+}
+
+func updateNewAPIUpstreamToken(ctx context.Context, client *http.Client, config NewAPIGroupRatioConfig, token newAPIUpstreamToken, validateURL func(string) error) error {
+ requestBody, err := common.Marshal(token)
+ if err != nil {
+ return err
+ }
+ responseBody, err := requestNewAPIUser(
+ ctx,
+ client,
+ http.MethodPut,
+ config.BaseURL+"/api/token/",
+ requestBody,
+ config,
+ "更新上游令牌分组",
+ validateURL,
+ )
+ if err != nil {
+ return err
+ }
+ var response newAPIUpstreamTokenUpdateResponse
+ if err := common.Unmarshal(responseBody, &response); err != nil {
+ return errors.New("New API 更新令牌响应格式无效")
+ }
+ if !response.Success {
+ return upstreamGroupRatioMessage(response.Message)
+ }
+ return nil
+}
+
+func requestNewAPIUser(ctx context.Context, client *http.Client, method string, requestURL string, body []byte, config NewAPIGroupRatioConfig, operation string, validateURL func(string) error) ([]byte, error) {
+ if validateURL != nil {
+ if err := validateURL(requestURL); err != nil {
+ return nil, err
+ }
+ }
+
+ var requestBody io.Reader
+ if len(body) > 0 {
+ requestBody = bytes.NewReader(body)
+ }
+ httpRequest, err := http.NewRequestWithContext(ctx, method, requestURL, requestBody)
+ if err != nil {
+ return nil, err
+ }
+ httpRequest.Header.Set("Accept", "application/json")
+ if len(body) > 0 {
+ httpRequest.Header.Set("Content-Type", "application/json")
+ }
+ accessToken := strings.TrimPrefix(strings.TrimSpace(config.AccessToken), "Bearer ")
+ httpRequest.Header.Set("Authorization", "Bearer "+accessToken)
+ httpRequest.Header.Set("New-Api-User", strconv.Itoa(config.UserID))
+
+ response, err := client.Do(httpRequest)
+ if err != nil {
+ return nil, fmt.Errorf("New API %s失败: %w", operation, err)
+ }
+ defer response.Body.Close()
+ responseBody, err := io.ReadAll(io.LimitReader(response.Body, maxUpstreamGroupRatioResponseBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("New API %s失败: %w", operation, err)
+ }
+ if len(responseBody) > maxUpstreamGroupRatioResponseBytes {
+ return nil, errors.New("New API 上游响应过大")
+ }
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("New API %s失败: 上游返回 %s", operation, response.Status)
+ }
+ return responseBody, nil
+}
+
+type sub2APIGroupRatioEntry struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ RateMultiplier json.RawMessage `json:"rate_multiplier"`
+}
+
+type sub2APIResponse struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Reason string `json:"reason"`
+ Data json.RawMessage `json:"data"`
+}
+
+type sub2APIKeyEntry struct {
+ ID int64 `json:"id"`
+ Key string `json:"key"`
+ GroupID *int64 `json:"group_id"`
+ IPWhitelist []string `json:"ip_whitelist"`
+ IPBlacklist []string `json:"ip_blacklist"`
+}
+
+type sub2APIKeyPage struct {
+ Items []sub2APIKeyEntry `json:"items"`
+}
+
+type sub2APIKeyUpdateRequest struct {
+ GroupID int64 `json:"group_id"`
+ IPWhitelist []string `json:"ip_whitelist"`
+ IPBlacklist []string `json:"ip_blacklist"`
+}
+
+type sub2APIUserProfile struct {
+ Balance float64 `json:"balance"`
+}
+
+type sub2APIKeyBillingResponse struct {
+ Object string `json:"object"`
+ SchemaVersion int `json:"schema_version"`
+ BillingScope string `json:"billing_scope"`
+ EffectiveRateMultiplier json.RawMessage `json:"effective_rate_multiplier"`
+}
+
+type sub2APIUsageResponse struct {
+ Mode string `json:"mode"`
+ Balance *float64 `json:"balance"`
+}
+
+func FetchSub2APIGroupRatio(ctx context.Context, config Sub2APIGroupRatioConfig) (NewAPIGroupRatioResult, error) {
+ client := GetSSRFProtectedHTTPClient()
+ if client == nil {
+ return NewAPIGroupRatioResult{}, errors.New("上游请求客户端未初始化")
+ }
+ return fetchSub2APIGroupRatio(ctx, client, config, ValidateSSRFProtectedFetchURL)
+}
+
+func fetchSub2APIGroupRatio(ctx context.Context, client *http.Client, config Sub2APIGroupRatioConfig, validateURL func(string) error) (NewAPIGroupRatioResult, error) {
+ group := strings.TrimSpace(config.Group)
+ if group == "" {
+ return NewAPIGroupRatioResult{}, errors.New("请输入上游分组")
+ }
+ config.Group = group
+ switch strings.TrimSpace(config.AuthType) {
+ case Sub2APIAuthAccount:
+ tokenConfig, err := resolveSub2APIAccountTokenConfig(ctx, client, config, validateURL)
+ if err != nil {
+ return NewAPIGroupRatioResult{}, err
+ }
+ result, fetchErr := fetchSub2APIGroupRatio(ctx, client, tokenConfig, validateURL)
+ if !errors.Is(fetchErr, ErrChannelMonitorUpstreamAuthentication) {
+ return result, fetchErr
+ }
+ invalidateSub2APIAccountToken(config)
+ tokenConfig, err = resolveSub2APIAccountTokenConfig(ctx, client, config, validateURL)
+ if err != nil {
+ return NewAPIGroupRatioResult{}, err
+ }
+ return fetchSub2APIGroupRatio(ctx, client, tokenConfig, validateURL)
+ case Sub2APIAuthAPIKey:
+ baseURL, err := normalizeSub2APIBaseURL(config.BaseURL)
+ if err != nil {
+ return NewAPIGroupRatioResult{}, err
+ }
+ config.BaseURL = baseURL
+ keys, err := normalizeChannelMonitorKeys(config.ChannelKeys)
+ if err != nil {
+ return NewAPIGroupRatioResult{}, err
+ }
+ if len(keys) == 0 {
+ return NewAPIGroupRatioResult{}, errors.New("Sub2API API Key 认证需要当前渠道配置上游 API Key")
+ }
+ config.ChannelKeys = keys
+ result, err := fetchSub2APIKeyGroupRatio(ctx, client, config, validateURL)
+ if err != nil {
+ return result, redactUpstreamGroupRatioSecrets(err, keys...)
+ }
+ if !config.SkipBalance {
+ balance, balanceErr := fetchSub2APIKeyBalance(ctx, client, config, validateURL)
+ if balanceErr != nil {
+ result.Balance.Error = redactUpstreamGroupRatioSecrets(balanceErr, keys...).Error()
+ } else {
+ result.Balance = balance
+ }
+ }
+ return result, nil
+ case Sub2APIAuthToken:
+ baseURL, accessToken, err := normalizeSub2APITokenConfig(config)
+ if err != nil {
+ return NewAPIGroupRatioResult{}, err
+ }
+ config.BaseURL = baseURL
+ config.AccessToken = accessToken
+ groupsResult, fetchErr := fetchSub2APIUpstreamGroups(ctx, client, config, nil, validateURL)
+ result := NewAPIGroupRatioResult{Balance: groupsResult.Balance}
+ if fetchErr != nil {
+ return result, fetchErr
+ }
+ for _, entry := range groupsResult.Groups {
+ if entry.Name == group || entry.ID == group {
+ result.Ratio = entry.Ratio
+ result.Endpoint = entry.Endpoint
+ return result, nil
+ }
+ }
+ return result, fmt.Errorf("Sub2API 当前账号不可见分组 %q", group)
+ default:
+ return NewAPIGroupRatioResult{}, errors.New("Sub2API 认证方式无效")
+ }
+}
+
+func fetchSub2APIKeyGroupRatio(ctx context.Context, client *http.Client, config Sub2APIGroupRatioConfig, validateURL func(string) error) (NewAPIGroupRatioResult, error) {
+ requestContext, cancel := context.WithTimeout(ctx, upstreamGroupRatioTimeout)
+ defer cancel()
+ result := NewAPIGroupRatioResult{Endpoint: "/v1/sub2api/billing"}
+ var resolvedRatio *float64
+ for _, channelKey := range config.ChannelKeys {
+ body, err := requestSub2APIKeyEndpoint(
+ requestContext,
+ client,
+ config.BaseURL+"/v1/sub2api/billing",
+ channelKey,
+ "读取渠道 API Key 倍率",
+ validateURL,
+ )
+ if err != nil {
+ return result, err
+ }
+
+ var payload sub2APIKeyBillingResponse
+ if err := common.Unmarshal(body, &payload); err != nil || len(payload.EffectiveRateMultiplier) == 0 {
+ return result, errors.New("Sub2API API Key 倍率响应格式无效")
+ }
+ if payload.Object != "" && payload.Object != "sub2api.key_billing" {
+ return result, errors.New("Sub2API API Key 倍率响应对象无效")
+ }
+ ratio, parseErr := parseUpstreamGroupRatio(payload.EffectiveRateMultiplier)
+ if parseErr != nil {
+ return result, fmt.Errorf("Sub2API API Key 倍率: %w", parseErr)
+ }
+ if resolvedRatio == nil {
+ value := ratio
+ resolvedRatio = &value
+ continue
+ }
+ if math.Abs(*resolvedRatio-ratio) > 1e-9 {
+ return result, fmt.Errorf("Sub2API 当前渠道的多个 API Key 返回了不同倍率(%.6g 和 %.6g)", *resolvedRatio, ratio)
+ }
+ }
+ if resolvedRatio == nil {
+ return result, errors.New("Sub2API API Key 认证没有可用的渠道 API Key")
+ }
+ result.Ratio = *resolvedRatio
+ return result, nil
+}
+
+func fetchSub2APIKeyBalance(ctx context.Context, client *http.Client, config Sub2APIGroupRatioConfig, validateURL func(string) error) (ChannelMonitorUpstreamBalanceResult, error) {
+ if len(config.ChannelKeys) == 0 {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("Sub2API API Key 认证没有可用的渠道 API Key")
+ }
+ requestContext, cancel := context.WithTimeout(ctx, upstreamGroupRatioTimeout)
+ defer cancel()
+ body, err := requestSub2APIKeyEndpoint(
+ requestContext,
+ client,
+ config.BaseURL+"/v1/usage",
+ config.ChannelKeys[0],
+ "读取渠道 API Key 余额",
+ validateURL,
+ )
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ var payload sub2APIUsageResponse
+ if err := common.Unmarshal(body, &payload); err != nil || payload.Mode == "quota_limited" ||
+ payload.Balance == nil ||
+ math.IsNaN(*payload.Balance) || math.IsInf(*payload.Balance, 0) {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("Sub2API API Key 余额响应中没有钱包余额")
+ }
+ return ChannelMonitorUpstreamBalanceResult{
+ Amount: payload.Balance,
+ Endpoint: "/v1/usage",
+ }, nil
+}
+
+func requestSub2APIKeyEndpoint(ctx context.Context, client *http.Client, requestURL string, channelKey string, operation string, validateURL func(string) error) ([]byte, error) {
+ if validateURL != nil {
+ if err := validateURL(requestURL); err != nil {
+ return nil, err
+ }
+ }
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ request.Header.Set("Accept", "application/json")
+ key := strings.TrimSpace(channelKey)
+ if len(key) >= len("Bearer ") && strings.EqualFold(key[:len("Bearer ")], "Bearer ") {
+ key = strings.TrimSpace(key[len("Bearer "):])
+ }
+ if key == "" {
+ return nil, errors.New("Sub2API API Key 不能为空")
+ }
+ request.Header.Set("Authorization", "Bearer "+key)
+
+ response, err := client.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("Sub2API %s失败: %w", operation, err)
+ }
+ defer response.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(response.Body, maxUpstreamGroupRatioResponseBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("Sub2API %s失败: %w", operation, err)
+ }
+ if len(body) > maxUpstreamGroupRatioResponseBytes {
+ return nil, errors.New("Sub2API 上游响应过大")
+ }
+ if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
+ return nil, &channelMonitorUpstreamAuthenticationError{cause: fmt.Errorf("Sub2API %s认证失败: 上游返回 %s", operation, response.Status)}
+ }
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("Sub2API %s失败: 上游返回 %s", operation, response.Status)
+ }
+ return body, nil
+}
+
+func fetchSub2APIUpstreamGroups(ctx context.Context, client *http.Client, config Sub2APIGroupRatioConfig, channelKeys []string, validateURL func(string) error) (ChannelMonitorUpstreamGroupsResult, error) {
+ authType := strings.TrimSpace(config.AuthType)
+ if authType == Sub2APIAuthAccount {
+ tokenConfig, err := resolveSub2APIAccountTokenConfig(ctx, client, config, validateURL)
+ if err != nil {
+ return ChannelMonitorUpstreamGroupsResult{}, err
+ }
+ result, fetchErr := fetchSub2APIUpstreamGroups(ctx, client, tokenConfig, channelKeys, validateURL)
+ if !errors.Is(fetchErr, ErrChannelMonitorUpstreamAuthentication) {
+ return result, fetchErr
+ }
+ invalidateSub2APIAccountToken(config)
+ tokenConfig, err = resolveSub2APIAccountTokenConfig(ctx, client, config, validateURL)
+ if err != nil {
+ return ChannelMonitorUpstreamGroupsResult{}, err
+ }
+ return fetchSub2APIUpstreamGroups(ctx, client, tokenConfig, channelKeys, validateURL)
+ }
+ if authType == Sub2APIAuthAPIKey {
+ return ChannelMonitorUpstreamGroupsResult{}, errors.New("Sub2API API Key 认证不支持获取上游分组,请切换为 Token(旧版)认证")
+ }
+ if authType != Sub2APIAuthToken {
+ return ChannelMonitorUpstreamGroupsResult{}, errors.New("Sub2API 认证方式无效")
+ }
+ baseURL, accessToken, err := normalizeSub2APITokenConfig(config)
+ if err != nil {
+ return ChannelMonitorUpstreamGroupsResult{}, err
+ }
+ config.BaseURL = baseURL
+ config.AccessToken = accessToken
+ timeout := upstreamGroupRatioTimeout
+ if len(channelKeys) > 0 {
+ timeout = upstreamGroupApplyTimeout
+ }
+ requestContext, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ result, err := fetchSub2APIUpstreamGroupsWithToken(requestContext, client, baseURL, accessToken, validateURL)
+ if err != nil {
+ return result, redactUpstreamGroupRatioSecrets(err, accessToken)
+ }
+ if !config.SkipBalance {
+ balance, balanceErr := fetchSub2APIUpstreamBalanceWithToken(requestContext, client, baseURL, accessToken, validateURL)
+ if balanceErr != nil {
+ result.Balance.Error = redactUpstreamGroupRatioSecrets(balanceErr, accessToken).Error()
+ } else {
+ result.Balance = balance
+ }
+ }
+ if len(channelKeys) > 0 {
+ appliedGroupID, appliedGroupErr := fetchSub2APIUpstreamKeyGroupWithToken(
+ requestContext,
+ client,
+ baseURL,
+ accessToken,
+ channelKeys,
+ validateURL,
+ )
+ if appliedGroupErr != nil {
+ secrets := []string{accessToken}
+ for _, channelKey := range channelKeys {
+ secrets = append(secrets, channelKey, url.QueryEscape(channelKey))
+ }
+ result.AppliedGroupError = redactUpstreamGroupRatioSecrets(appliedGroupErr, secrets...).Error()
+ } else {
+ result.AppliedGroup = appliedGroupID
+ for _, group := range result.Groups {
+ if group.ID == appliedGroupID {
+ result.AppliedGroup = group.Name
+ break
+ }
+ }
+ }
+ }
+ return result, nil
+}
+
+func fetchSub2APIUpstreamKeyGroupWithToken(ctx context.Context, client *http.Client, baseURL string, accessToken string, channelKeys []string, validateURL func(string) error) (string, error) {
+ keys, err := normalizeChannelMonitorKeys(channelKeys)
+ if err != nil {
+ return "", err
+ }
+ if len(keys) == 0 {
+ return "", errors.New("当前渠道没有可匹配的 API Key")
+ }
+
+ var appliedGroupID int64
+ for index, channelKey := range keys {
+ apiKey, findErr := findSub2APIKey(ctx, client, baseURL, accessToken, channelKey, validateURL)
+ if findErr != nil {
+ return "", fmt.Errorf("读取第 %d 个上游 API Key 当前分组失败: %w", index+1, findErr)
+ }
+ if apiKey.GroupID == nil || *apiKey.GroupID <= 0 {
+ return "", fmt.Errorf("第 %d 个上游 API Key 没有设置分组", index+1)
+ }
+ if appliedGroupID == 0 {
+ appliedGroupID = *apiKey.GroupID
+ continue
+ }
+ if appliedGroupID != *apiKey.GroupID {
+ return "", errors.New("当前渠道的多个上游 API Key 使用了不同分组,未自动选择")
+ }
+ }
+ return strconv.FormatInt(appliedGroupID, 10), nil
+}
+
+func fetchSub2APIUpstreamBalance(ctx context.Context, client *http.Client, config Sub2APIGroupRatioConfig, validateURL func(string) error) (ChannelMonitorUpstreamBalanceResult, error) {
+ switch strings.TrimSpace(config.AuthType) {
+ case Sub2APIAuthAccount:
+ tokenConfig, err := resolveSub2APIAccountTokenConfig(ctx, client, config, validateURL)
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ result, fetchErr := fetchSub2APIUpstreamBalance(ctx, client, tokenConfig, validateURL)
+ if !errors.Is(fetchErr, ErrChannelMonitorUpstreamAuthentication) {
+ return result, fetchErr
+ }
+ invalidateSub2APIAccountToken(config)
+ tokenConfig, err = resolveSub2APIAccountTokenConfig(ctx, client, config, validateURL)
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ return fetchSub2APIUpstreamBalance(ctx, client, tokenConfig, validateURL)
+ case Sub2APIAuthAPIKey:
+ baseURL, err := normalizeSub2APIBaseURL(config.BaseURL)
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ config.BaseURL = baseURL
+ keys, err := normalizeChannelMonitorKeys(config.ChannelKeys)
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ if len(keys) == 0 {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("Sub2API API Key 认证需要当前渠道配置上游 API Key")
+ }
+ config.ChannelKeys = keys
+ balance, err := fetchSub2APIKeyBalance(ctx, client, config, validateURL)
+ return balance, redactUpstreamGroupRatioSecrets(err, keys...)
+ case Sub2APIAuthToken:
+ baseURL, accessToken, err := normalizeSub2APITokenConfig(config)
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ return fetchSub2APIUpstreamBalanceWithToken(ctx, client, baseURL, accessToken, validateURL)
+ default:
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("Sub2API 认证方式无效")
+ }
+}
+
+func fetchSub2APIUpstreamBalanceWithToken(ctx context.Context, client *http.Client, baseURL string, accessToken string, validateURL func(string) error) (ChannelMonitorUpstreamBalanceResult, error) {
+ requestContext, cancel := context.WithTimeout(ctx, upstreamGroupRatioTimeout)
+ defer cancel()
+ result, err := fetchSub2APIProfileBalance(requestContext, client, baseURL, accessToken, validateURL)
+ if err != nil {
+ return result, redactUpstreamGroupRatioSecrets(err, accessToken)
+ }
+ return result, nil
+}
+
+func fetchSub2APIProfileBalance(ctx context.Context, client *http.Client, baseURL string, accessToken string, validateURL func(string) error) (ChannelMonitorUpstreamBalanceResult, error) {
+ profileData, err := requestSub2API(
+ ctx,
+ client,
+ http.MethodGet,
+ baseURL+"/api/v1/user/profile",
+ nil,
+ accessToken,
+ "读取上游余额",
+ validateURL,
+ )
+ if err != nil {
+ return ChannelMonitorUpstreamBalanceResult{}, err
+ }
+ var profile sub2APIUserProfile
+ if err := common.Unmarshal(profileData, &profile); err != nil || math.IsNaN(profile.Balance) || math.IsInf(profile.Balance, 0) {
+ return ChannelMonitorUpstreamBalanceResult{}, errors.New("Sub2API 用户余额响应格式无效")
+ }
+ amount := profile.Balance
+ return ChannelMonitorUpstreamBalanceResult{
+ Amount: &amount,
+ Endpoint: "/api/v1/user/profile",
+ }, nil
+}
+
+func normalizeSub2APIBaseURL(value string) (string, error) {
+ return NormalizeNewAPIBaseURL(value)
+}
+
+func normalizeSub2APITokenConfig(config Sub2APIGroupRatioConfig) (string, string, error) {
+ baseURL, err := NormalizeNewAPIBaseURL(config.BaseURL)
+ if err != nil {
+ return "", "", err
+ }
+ accessToken := strings.TrimSpace(config.AccessToken)
+ if len(accessToken) >= len("Bearer ") && strings.EqualFold(accessToken[:len("Bearer ")], "Bearer ") {
+ accessToken = strings.TrimSpace(accessToken[len("Bearer "):])
+ }
+ if accessToken == "" {
+ return "", "", errors.New("请输入 Sub2API Token(旧版)")
+ }
+ if len([]rune(accessToken)) > 4096 {
+ return "", "", errors.New("Sub2API Token 过长")
+ }
+ return baseURL, accessToken, nil
+}
+
+func fetchSub2APIUpstreamGroupsWithToken(ctx context.Context, client *http.Client, baseURL string, accessToken string, validateURL func(string) error) (ChannelMonitorUpstreamGroupsResult, error) {
+ result := ChannelMonitorUpstreamGroupsResult{}
+ groupsData, err := requestSub2API(
+ ctx,
+ client,
+ http.MethodGet,
+ baseURL+"/api/v1/groups/available",
+ nil,
+ accessToken,
+ "读取可用分组",
+ validateURL,
+ )
+ if err != nil {
+ return result, err
+ }
+ var groups []sub2APIGroupRatioEntry
+ if err := common.Unmarshal(groupsData, &groups); err != nil {
+ return result, errors.New("Sub2API 可用分组响应格式无效")
+ }
+
+ ratesData, err := requestSub2API(
+ ctx,
+ client,
+ http.MethodGet,
+ baseURL+"/api/v1/groups/rates",
+ nil,
+ accessToken,
+ "读取用户专属倍率",
+ validateURL,
+ )
+ if err != nil {
+ return result, err
+ }
+ rates := make(map[string]json.RawMessage)
+ if len(ratesData) > 0 && string(ratesData) != "null" {
+ if err := common.Unmarshal(ratesData, &rates); err != nil {
+ return result, errors.New("Sub2API 用户专属倍率响应格式无效")
+ }
+ }
+ result.Groups = make([]ChannelMonitorUpstreamGroup, 0, len(groups))
+ for _, entry := range groups {
+ groupID := strconv.FormatInt(entry.ID, 10)
+ name := strings.TrimSpace(entry.Name)
+ if name == "" {
+ name = groupID
+ }
+ rawRatio := entry.RateMultiplier
+ endpoint := "/api/v1/groups/available"
+ if userRatio, exists := rates[groupID]; exists {
+ rawRatio = userRatio
+ endpoint = "/api/v1/groups/rates"
+ }
+ if len(rawRatio) == 0 {
+ return result, fmt.Errorf("Sub2API 未返回分组 %q 的倍率", name)
+ }
+ ratio, parseErr := parseUpstreamGroupRatio(rawRatio)
+ if parseErr != nil {
+ return result, fmt.Errorf("Sub2API 分组 %q: %w", name, parseErr)
+ }
+ result.Groups = append(result.Groups, ChannelMonitorUpstreamGroup{
+ ID: groupID,
+ Name: name,
+ Ratio: ratio,
+ Endpoint: endpoint,
+ })
+ }
+ if len(result.Groups) == 0 {
+ return result, errors.New("Sub2API 当前账号没有可用分组")
+ }
+ sortChannelMonitorUpstreamGroups(result.Groups)
+ return result, nil
+}
+
+func applySub2APIUpstreamGroup(ctx context.Context, client *http.Client, config ChannelMonitorUpstreamConfig, channelKeys []string, validateURL func(string) error) (result ChannelMonitorUpstreamGroupApplyResult, err error) {
+ authType := strings.TrimSpace(config.AuthType)
+ if authType == Sub2APIAuthAccount {
+ accountConfig := Sub2APIGroupRatioConfig{
+ BaseURL: config.BaseURL,
+ Group: config.Group,
+ AuthType: config.AuthType,
+ Account: config.Account,
+ Password: config.Password,
+ Proxy: config.Proxy,
+ }
+ tokenConfig, resolveErr := resolveSub2APIAccountTokenConfig(ctx, client, accountConfig, validateURL)
+ if resolveErr != nil {
+ return result, resolveErr
+ }
+ config.AuthType = Sub2APIAuthToken
+ config.AccessToken = tokenConfig.AccessToken
+ result, applyErr := applySub2APIUpstreamGroup(ctx, client, config, channelKeys, validateURL)
+ if !errors.Is(applyErr, ErrChannelMonitorUpstreamAuthentication) {
+ return result, applyErr
+ }
+ invalidateSub2APIAccountToken(accountConfig)
+ tokenConfig, resolveErr = resolveSub2APIAccountTokenConfig(ctx, client, accountConfig, validateURL)
+ if resolveErr != nil {
+ return result, resolveErr
+ }
+ config.AccessToken = tokenConfig.AccessToken
+ return applySub2APIUpstreamGroup(ctx, client, config, channelKeys, validateURL)
+ }
+ if authType == Sub2APIAuthAPIKey {
+ return result, errors.New("Sub2API API Key 认证不支持应用上游分组,请切换为 Token(旧版)认证")
+ }
+ if authType != Sub2APIAuthToken {
+ return result, errors.New("Sub2API 认证方式无效")
+ }
+ baseURL, accessToken, err := normalizeSub2APITokenConfig(Sub2APIGroupRatioConfig{
+ BaseURL: config.BaseURL,
+ Group: config.Group,
+ AuthType: config.AuthType,
+ AccessToken: config.AccessToken,
+ })
+ if err != nil {
+ return result, err
+ }
+ group := strings.TrimSpace(config.Group)
+ if group == "" {
+ return result, errors.New("请输入上游分组")
+ }
+
+ defer func() {
+ if err == nil {
+ return
+ }
+ secrets := []string{accessToken}
+ for _, channelKey := range channelKeys {
+ secrets = append(secrets, channelKey, url.QueryEscape(channelKey))
+ }
+ err = redactUpstreamGroupRatioSecrets(err, secrets...)
+ }()
+
+ groupsResult, err := fetchSub2APIUpstreamGroupsWithToken(ctx, client, baseURL, accessToken, validateURL)
+ if err != nil {
+ return result, err
+ }
+
+ var targetGroup ChannelMonitorUpstreamGroup
+ for _, entry := range groupsResult.Groups {
+ if entry.Name == group || entry.ID == group {
+ targetGroup = entry
+ break
+ }
+ }
+ if targetGroup.ID == "" {
+ return result, fmt.Errorf("Sub2API 当前账号不可见分组 %q", group)
+ }
+ targetGroupID, err := strconv.ParseInt(targetGroup.ID, 10, 64)
+ if err != nil || targetGroupID <= 0 {
+ return result, errors.New("Sub2API 上游分组 ID 无效")
+ }
+ result.Result.Ratio = targetGroup.Ratio
+ result.Result.Endpoint = targetGroup.Endpoint
+
+ for index, channelKey := range channelKeys {
+ apiKey, findErr := findSub2APIKey(ctx, client, baseURL, accessToken, channelKey, validateURL)
+ if findErr != nil {
+ return result, fmt.Errorf("查找第 %d 个 Sub2API API Key 失败: %w", index+1, findErr)
+ }
+ if updateErr := updateSub2APIKeyGroup(ctx, client, baseURL, accessToken, apiKey, targetGroupID, validateURL); updateErr != nil {
+ return result, fmt.Errorf("更新第 %d 个 Sub2API API Key 失败: %w", index+1, updateErr)
+ }
+ result.KeysUpdated++
+ }
+ return result, nil
+}
+
+func findSub2APIKey(ctx context.Context, client *http.Client, baseURL string, accessToken string, channelKey string, validateURL func(string) error) (sub2APIKeyEntry, error) {
+ query := url.Values{}
+ query.Set("page", "1")
+ query.Set("page_size", "1000")
+ query.Set("search", channelKey)
+ keysData, err := requestSub2API(
+ ctx,
+ client,
+ http.MethodGet,
+ baseURL+"/api/v1/keys?"+query.Encode(),
+ nil,
+ accessToken,
+ "查找 API Key",
+ validateURL,
+ )
+ if err != nil {
+ return sub2APIKeyEntry{}, err
+ }
+ var page sub2APIKeyPage
+ if err := common.Unmarshal(keysData, &page); err != nil {
+ return sub2APIKeyEntry{}, errors.New("Sub2API API Key 列表响应格式无效")
+ }
+ for _, apiKey := range page.Items {
+ if strings.TrimSpace(apiKey.Key) == channelKey {
+ return apiKey, nil
+ }
+ }
+ return sub2APIKeyEntry{}, errors.New("Sub2API 未找到与当前渠道 Key 对应的 API Key")
+}
+
+func updateSub2APIKeyGroup(ctx context.Context, client *http.Client, baseURL string, accessToken string, apiKey sub2APIKeyEntry, groupID int64, validateURL func(string) error) error {
+ requestBody, err := common.Marshal(sub2APIKeyUpdateRequest{
+ GroupID: groupID,
+ IPWhitelist: apiKey.IPWhitelist,
+ IPBlacklist: apiKey.IPBlacklist,
+ })
+ if err != nil {
+ return err
+ }
+ _, err = requestSub2API(
+ ctx,
+ client,
+ http.MethodPut,
+ baseURL+"/api/v1/keys/"+strconv.FormatInt(apiKey.ID, 10),
+ requestBody,
+ accessToken,
+ "更新 API Key 分组",
+ validateURL,
+ )
+ return err
+}
+
+func requestSub2API(ctx context.Context, client *http.Client, method string, requestURL string, body []byte, accessToken string, operation string, validateURL func(string) error) (json.RawMessage, error) {
+ if validateURL != nil {
+ if err := validateURL(requestURL); err != nil {
+ return nil, err
+ }
+ }
+
+ var requestBody io.Reader
+ if len(body) > 0 {
+ requestBody = bytes.NewReader(body)
+ }
+ httpRequest, err := http.NewRequestWithContext(ctx, method, requestURL, requestBody)
+ if err != nil {
+ return nil, err
+ }
+ httpRequest.Header.Set("Accept", "application/json")
+ if len(body) > 0 {
+ httpRequest.Header.Set("Content-Type", "application/json")
+ }
+ if accessToken != "" {
+ accessToken = strings.TrimSpace(accessToken)
+ if len(accessToken) >= len("Bearer ") && strings.EqualFold(accessToken[:len("Bearer ")], "Bearer ") {
+ accessToken = strings.TrimSpace(accessToken[len("Bearer "):])
+ }
+ httpRequest.Header.Set("Authorization", "Bearer "+accessToken)
+ }
+
+ response, err := client.Do(httpRequest)
+ if err != nil {
+ return nil, fmt.Errorf("Sub2API %s失败: %w", operation, err)
+ }
+ defer response.Body.Close()
+ responseBody, err := io.ReadAll(io.LimitReader(response.Body, maxUpstreamGroupRatioResponseBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("Sub2API %s失败: %w", operation, err)
+ }
+ if len(responseBody) > maxUpstreamGroupRatioResponseBytes {
+ return nil, errors.New("Sub2API 上游响应过大")
+ }
+
+ var payload sub2APIResponse
+ if err := common.Unmarshal(responseBody, &payload); err != nil {
+ if response.StatusCode != http.StatusOK {
+ upstreamErr := fmt.Errorf("Sub2API %s失败: 上游返回 %s", operation, response.Status)
+ if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
+ return nil, &channelMonitorUpstreamAuthenticationError{cause: upstreamErr}
+ }
+ return nil, upstreamErr
+ }
+ return nil, fmt.Errorf("Sub2API %s响应格式无效", operation)
+ }
+ if response.StatusCode != http.StatusOK || payload.Code != 0 {
+ message := strings.TrimSpace(payload.Message)
+ if message == "" {
+ message = response.Status
+ }
+ upstreamErr := fmt.Errorf("Sub2API %s失败: %w", operation, upstreamGroupRatioMessage(message))
+ if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden ||
+ payload.Code == http.StatusUnauthorized || payload.Code == http.StatusForbidden {
+ return nil, &channelMonitorUpstreamAuthenticationError{cause: upstreamErr}
+ }
+ return nil, upstreamErr
+ }
+ return payload.Data, nil
+}
+
+func parseUpstreamGroupRatio(raw json.RawMessage) (float64, error) {
+ var ratio float64
+ if err := common.Unmarshal(raw, &ratio); err != nil {
+ var value string
+ if stringErr := common.Unmarshal(raw, &value); stringErr != nil {
+ return 0, errors.New("上游分组倍率不是数字")
+ }
+ parsed, parseErr := strconv.ParseFloat(strings.TrimSpace(value), 64)
+ if parseErr != nil {
+ return 0, errors.New("上游分组倍率不是数字")
+ }
+ ratio = parsed
+ }
+ if math.IsNaN(ratio) || math.IsInf(ratio, 0) || ratio < 0 || ratio > maxUpstreamGroupRatio {
+ return 0, errors.New("上游分组倍率超出范围")
+ }
+ return ratio, nil
+}
+
+func upstreamGroupRatioMessage(message string) error {
+ message = strings.TrimSpace(message)
+ if message == "" {
+ return errors.New("上游请求失败")
+ }
+ if len(message) > 256 {
+ runes := []rune(message)
+ if len(runes) > 256 {
+ message = string(runes[:256])
+ }
+ }
+ return errors.New(message)
+}
+
+func redactUpstreamGroupRatioSecrets(err error, secrets ...string) error {
+ if err == nil {
+ return nil
+ }
+ authenticationFailure := errors.Is(err, ErrChannelMonitorUpstreamAuthentication)
+ message := err.Error()
+ for _, secret := range secrets {
+ secret = strings.TrimSpace(secret)
+ if secret != "" {
+ message = strings.ReplaceAll(message, secret, "[REDACTED]")
+ }
+ }
+ redactedErr := errors.New(message)
+ if authenticationFailure {
+ return &channelMonitorUpstreamAuthenticationError{cause: redactedErr}
+ }
+ return redactedErr
+}
diff --git a/service/channel_ratio_monitor_test.go b/service/channel_ratio_monitor_test.go
new file mode 100644
index 000000000000..1d6581cb04fc
--- /dev/null
+++ b/service/channel_ratio_monitor_test.go
@@ -0,0 +1,640 @@
+package service
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/setting/system_setting"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNormalizeNewAPIBaseURL(t *testing.T) {
+ tests := []struct {
+ name string
+ value string
+ want string
+ wantErr bool
+ }{
+ {name: "root", value: " https://example.com/ ", want: "https://example.com"},
+ {name: "openai suffix", value: "https://example.com/panel/v1/", want: "https://example.com/panel"},
+ {name: "panel path", value: "https://example.com/new-api", want: "https://example.com/new-api"},
+ {name: "missing scheme", value: "example.com", wantErr: true},
+ {name: "credentials", value: "https://user:pass@example.com", wantErr: true},
+ {name: "query", value: "https://example.com?token=secret", wantErr: true},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got, err := NormalizeNewAPIBaseURL(test.value)
+ if test.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, test.want, got)
+ })
+ }
+}
+
+func TestFetchNewAPIGroupRatioFromPublicPricing(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/pricing", r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"group_ratio":{"vip":0.75}}`))
+ }))
+ defer server.Close()
+
+ result, err := fetchNewAPIGroupRatio(context.Background(), server.Client(), NewAPIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: NewAPIUpstreamAuthPublic,
+ }, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 0.75, result.Ratio)
+ assert.Equal(t, "/api/pricing", result.Endpoint)
+}
+
+func TestFetchChannelMonitorUpstreamGroupRatioUsesChannelProxy(t *testing.T) {
+ fetchSetting := system_setting.GetFetchSetting()
+ originalFetchSetting := *fetchSetting
+ t.Cleanup(func() {
+ *fetchSetting = originalFetchSetting
+ })
+ fetchSetting.EnableSSRFProtection = true
+ fetchSetting.AllowPrivateIp = true
+ fetchSetting.DomainFilterMode = false
+ fetchSetting.IpFilterMode = false
+ fetchSetting.DomainList = nil
+ fetchSetting.IpList = nil
+ fetchSetting.AllowedPorts = []string{"80"}
+ fetchSetting.ApplyIPFilterForDomain = true
+ ResetProxyClientCache()
+ t.Cleanup(ResetProxyClientCache)
+
+ var requestCount atomic.Int32
+ proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestCount.Add(1)
+ assert.Equal(t, "93.184.216.34", r.URL.Host)
+ assert.Equal(t, "/api/pricing", r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"group_ratio":{"vip":0.75}}`))
+ }))
+ defer proxyServer.Close()
+
+ result, err := FetchChannelMonitorUpstreamGroupRatio(context.Background(), ChannelMonitorUpstreamConfig{
+ Type: NewAPIUpstreamType,
+ BaseURL: "http://93.184.216.34",
+ Group: "vip",
+ AuthType: NewAPIUpstreamAuthPublic,
+ Proxy: proxyServer.URL,
+ SkipBalance: true,
+ CostConversion: ChannelMonitorCostConversion{
+ Mode: ChannelMonitorCostConversionRecharge,
+ PaidCNY: 100,
+ CreditedUSD: 200,
+ },
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 0.75, result.Ratio)
+ assert.Equal(t, 0.5, result.ConversionFactor)
+ assert.Equal(t, 0.375, result.CostRatio)
+ assert.EqualValues(t, 1, requestCount.Load())
+}
+
+func TestFetchNewAPIGroupRatioFallsBackToPublicUserGroups(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/pricing":
+ _, _ = w.Write([]byte(`{"success":true,"group_ratio":{}}`))
+ case "/api/user/groups":
+ _, _ = w.Write([]byte(`{"success":true,"data":{"vip":{"ratio":"0.8"}}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ result, err := fetchNewAPIGroupRatio(context.Background(), server.Client(), NewAPIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: NewAPIUpstreamAuthPublic,
+ }, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 0.8, result.Ratio)
+ assert.Equal(t, "/api/user/groups", result.Endpoint)
+}
+
+func TestFetchNewAPIGroupRatioUsesAuthenticatedUserGroups(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/user/self/groups", r.URL.Path)
+ assert.Equal(t, "Bearer dashboard-token", r.Header.Get("Authorization"))
+ assert.Equal(t, "42", r.Header.Get("New-Api-User"))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"data":{"auto":{"ratio":"自动"},"vip":{"ratio":1.25}}}`))
+ }))
+ defer server.Close()
+
+ result, err := fetchNewAPIGroupRatio(context.Background(), server.Client(), NewAPIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: NewAPIUpstreamAuthUser,
+ UserID: 42,
+ AccessToken: "Bearer dashboard-token",
+ }, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 1.25, result.Ratio)
+ assert.Equal(t, "/api/user/self/groups", result.Endpoint)
+}
+
+func TestFetchNewAPIUpstreamBalanceConvertsQuotaToUSD(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/user/self":
+ assert.Equal(t, "Bearer dashboard-token", r.Header.Get("Authorization"))
+ assert.Equal(t, "42", r.Header.Get("New-Api-User"))
+ _, _ = w.Write([]byte(`{"success":true,"data":{"quota":6250000}}`))
+ case "/api/status":
+ _, _ = w.Write([]byte(`{"success":true,"data":{"quota_per_unit":500000}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ result, err := fetchNewAPIUpstreamBalance(context.Background(), server.Client(), NewAPIGroupRatioConfig{
+ BaseURL: server.URL,
+ AuthType: NewAPIUpstreamAuthUser,
+ UserID: 42,
+ AccessToken: "dashboard-token",
+ }, nil)
+ require.NoError(t, err)
+ require.NotNil(t, result.Amount)
+ assert.InDelta(t, 12.5, *result.Amount, 1e-9)
+ assert.Equal(t, "/api/user/self", result.Endpoint)
+}
+
+func TestFetchNewAPIUpstreamKeyGroupUsesChannelAPIKey(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/token/search", r.URL.Path)
+ assert.Equal(t, "sk-channel", r.URL.Query().Get("token"))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"data":{"items":[{"id":31,"name":"channel","group":"vip"}]}}`))
+ }))
+ defer server.Close()
+
+ group, err := fetchNewAPIUpstreamKeyGroup(context.Background(), server.Client(), NewAPIGroupRatioConfig{
+ BaseURL: server.URL,
+ AuthType: NewAPIUpstreamAuthUser,
+ UserID: 42,
+ AccessToken: "dashboard-token",
+ }, []string{"sk-channel"}, nil)
+ require.NoError(t, err)
+ assert.Equal(t, "vip", group)
+}
+
+func TestFetchSub2APITokenReadsGroupsRatesAndBalance(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "Bearer legacy-jwt", r.Header.Get("Authorization"))
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/v1/groups/available":
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":[{"id":7,"name":"vip","rate_multiplier":1.25}]}`))
+ case "/api/v1/groups/rates":
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"7":1.75}}`))
+ case "/api/v1/user/profile":
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"balance":7.25}}`))
+ case "/api/v1/auth/refresh":
+ t.Fatal("legacy token mode must not call refresh endpoint")
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ result, err := fetchSub2APIGroupRatio(context.Background(), server.Client(), Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: Sub2APIAuthToken,
+ AccessToken: "legacy-jwt",
+ }, nil)
+ require.NoError(t, err)
+ assert.InDelta(t, 1.75, result.Ratio, 1e-9)
+ assert.Equal(t, "/api/v1/groups/rates", result.Endpoint)
+ require.NotNil(t, result.Balance.Amount)
+ assert.InDelta(t, 7.25, *result.Balance.Amount, 1e-9)
+}
+
+func TestFetchNewAPIGroupRatioRejectsAutomaticGroupWithoutFixedRatio(t *testing.T) {
+ _, err := fetchNewAPIGroupRatio(context.Background(), http.DefaultClient, NewAPIGroupRatioConfig{
+ BaseURL: "https://example.com",
+ Group: "auto",
+ AuthType: NewAPIUpstreamAuthPublic,
+ }, nil)
+
+ require.EqualError(t, err, "上游自动分组没有固定倍率,无法用于倍率监控")
+}
+
+func TestFetchNewAPIUpstreamGroupsReturnsSortedOptions(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/pricing", r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"success":true,"group_ratio":{"alpha":1.25,"zeta":"0.8"}}`))
+ }))
+ defer server.Close()
+
+ result, err := fetchNewAPIUpstreamGroups(context.Background(), server.Client(), NewAPIGroupRatioConfig{
+ BaseURL: server.URL,
+ AuthType: NewAPIUpstreamAuthPublic,
+ }, nil)
+ require.NoError(t, err)
+ require.Len(t, result.Groups, 2)
+ assert.Equal(t, "zeta", result.Groups[0].Name)
+ assert.Equal(t, 0.8, result.Groups[0].Ratio)
+ assert.Equal(t, "alpha", result.Groups[1].Name)
+ assert.Equal(t, 1.25, result.Groups[1].Ratio)
+}
+
+func TestFetchNewAPIGroupRatioRejectsInvalidRatio(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ }{
+ {name: "missing", body: `{"success":true,"data":{}}`},
+ {name: "not numeric", body: `{"success":true,"data":{"vip":{"ratio":"auto"}}}`},
+ {name: "out of range", body: `{"success":true,"data":{"vip":{"ratio":1000001}}}`},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(test.body))
+ }))
+ defer server.Close()
+
+ _, err := fetchNewAPIGroupRatio(context.Background(), server.Client(), NewAPIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: NewAPIUpstreamAuthUser,
+ UserID: 42,
+ AccessToken: "dashboard-token",
+ }, nil)
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestApplyNewAPIUpstreamGroupUpdatesAllChannelTokens(t *testing.T) {
+ updatedTokenIDs := make([]int, 0, 2)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ assert.Equal(t, "Bearer dashboard-token", r.Header.Get("Authorization"))
+ assert.Equal(t, "42", r.Header.Get("New-Api-User"))
+ switch r.URL.Path {
+ case "/api/user/self/groups":
+ assert.Equal(t, http.MethodGet, r.Method)
+ _, _ = w.Write([]byte(`{"success":true,"data":{"vip":{"ratio":1.5}}}`))
+ case "/api/token/search":
+ assert.Equal(t, http.MethodGet, r.Method)
+ switch r.URL.Query().Get("token") {
+ case "sk-first":
+ _, _ = w.Write([]byte(`{"success":true,"data":{"items":[{"id":11,"name":"first","expired_time":-1,"remain_quota":100,"unlimited_quota":false,"model_limits_enabled":true,"model_limits":"gpt-4o","allow_ips":"127.0.0.1","group":"default","cross_group_retry":true}]}}`))
+ case "sk-second":
+ _, _ = w.Write([]byte(`{"success":true,"data":{"items":[{"id":12,"name":"second","expired_time":123,"remain_quota":0,"unlimited_quota":true,"model_limits_enabled":false,"model_limits":"","allow_ips":null,"group":"default","cross_group_retry":false}]}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ case "/api/token/":
+ assert.Equal(t, http.MethodPut, r.Method)
+ var token newAPIUpstreamToken
+ require.NoError(t, common.DecodeJson(r.Body, &token))
+ assert.Equal(t, "vip", token.Group)
+ if token.ID == 11 {
+ require.NotNil(t, token.AllowIPs)
+ assert.Equal(t, "127.0.0.1", *token.AllowIPs)
+ assert.True(t, token.ModelLimitsEnabled)
+ assert.True(t, token.CrossGroupRetry)
+ }
+ updatedTokenIDs = append(updatedTokenIDs, token.ID)
+ _, _ = w.Write([]byte(`{"success":true,"message":""}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ result, err := applyChannelMonitorUpstreamGroup(context.Background(), server.Client(), ChannelMonitorUpstreamConfig{
+ Type: NewAPIUpstreamType,
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: NewAPIUpstreamAuthUser,
+ UserID: 42,
+ AccessToken: "dashboard-token",
+ }, []string{"sk-first", "sk-second", "sk-first"}, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 1.5, result.Result.Ratio)
+ assert.Equal(t, "/api/user/self/groups", result.Result.Endpoint)
+ assert.Equal(t, 2, result.KeysUpdated)
+ assert.Equal(t, []int{11, 12}, updatedTokenIDs)
+}
+
+func TestApplyNewAPIUpstreamGroupRequiresUserAuthentication(t *testing.T) {
+ result, err := applyChannelMonitorUpstreamGroup(context.Background(), http.DefaultClient, ChannelMonitorUpstreamConfig{
+ Type: NewAPIUpstreamType,
+ BaseURL: "https://example.com",
+ Group: "vip",
+ AuthType: NewAPIUpstreamAuthPublic,
+ }, []string{"sk-test"}, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "用户认证")
+ assert.Zero(t, result.KeysUpdated)
+}
+
+func TestApplySub2APITokenUpdatesMatchingAPIKey(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/v1/groups/available":
+ assert.Equal(t, "Bearer legacy-jwt", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":[{"id":7,"name":"vip","rate_multiplier":1.25}]}`))
+ case "/api/v1/groups/rates":
+ assert.Equal(t, "Bearer legacy-jwt", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"7":1.75}}`))
+ case "/api/v1/keys":
+ assert.Equal(t, http.MethodGet, r.Method)
+ assert.Equal(t, "sk-sub2api", r.URL.Query().Get("search"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"items":[{"id":99,"key":"sk-sub2api","ip_whitelist":["10.0.0.1"],"ip_blacklist":["192.0.2.1"]}],"total":1,"page":1,"page_size":100,"pages":1}}`))
+ case "/api/v1/keys/99":
+ assert.Equal(t, http.MethodPut, r.Method)
+ assert.Equal(t, "Bearer legacy-jwt", r.Header.Get("Authorization"))
+ var request sub2APIKeyUpdateRequest
+ require.NoError(t, common.DecodeJson(r.Body, &request))
+ assert.Equal(t, int64(7), request.GroupID)
+ assert.Equal(t, []string{"10.0.0.1"}, request.IPWhitelist)
+ assert.Equal(t, []string{"192.0.2.1"}, request.IPBlacklist)
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":99,"group_id":7}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ result, err := applyChannelMonitorUpstreamGroup(context.Background(), server.Client(), ChannelMonitorUpstreamConfig{
+ Type: Sub2APIUpstreamType,
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: Sub2APIAuthToken,
+ AccessToken: "legacy-jwt",
+ }, []string{"sk-sub2api"}, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 1, result.KeysUpdated)
+ assert.Equal(t, 1.75, result.Result.Ratio)
+ assert.Equal(t, "/api/v1/groups/rates", result.Result.Endpoint)
+}
+
+func TestFetchSub2APITokenCanSkipBalance(t *testing.T) {
+ var balanceRequests atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/v1/groups/available":
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":[{"id":7,"name":"vip","rate_multiplier":1.375}]}`))
+ case "/api/v1/groups/rates":
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
+ case "/api/v1/user/profile":
+ balanceRequests.Add(1)
+ http.Error(w, "unsupported", http.StatusNotFound)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ result, err := fetchSub2APIGroupRatio(context.Background(), server.Client(), Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: Sub2APIAuthToken,
+ AccessToken: "legacy-jwt",
+ SkipBalance: true,
+ }, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 1.375, result.Ratio)
+ assert.Nil(t, result.Balance.Amount)
+ assert.Empty(t, result.Balance.Error)
+ assert.Zero(t, balanceRequests.Load())
+}
+
+func TestFetchSub2APITokenPrefersUserRateAndCanMatchGroupID(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/v1/groups/available":
+ assert.Equal(t, "Bearer legacy-jwt", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":[{"id":42,"name":"standard","rate_multiplier":"0.625"}]}`))
+ case "/api/v1/groups/rates":
+ assert.Equal(t, "Bearer legacy-jwt", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"42":1.75}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ result, err := fetchSub2APIGroupRatio(context.Background(), server.Client(), Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "42",
+ AuthType: Sub2APIAuthToken,
+ AccessToken: "legacy-jwt",
+ }, nil)
+ require.NoError(t, err)
+ assert.Equal(t, 1.75, result.Ratio)
+ assert.Equal(t, "/api/v1/groups/rates", result.Endpoint)
+}
+
+func TestFetchSub2APIUpstreamGroupsMergesUserRates(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/v1/groups/available":
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":[{"id":9,"name":"alpha","rate_multiplier":1.2},{"id":3,"name":"zeta","rate_multiplier":0.8}]}`))
+ case "/api/v1/groups/rates":
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"9":1.75}}`))
+ case "/api/v1/user/profile":
+ assert.Equal(t, "Bearer legacy-jwt", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"balance":23.5}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ result, err := fetchSub2APIUpstreamGroups(context.Background(), server.Client(), Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ AuthType: Sub2APIAuthToken,
+ AccessToken: "legacy-jwt",
+ }, nil, nil)
+ require.NoError(t, err)
+ require.Len(t, result.Groups, 2)
+ assert.Equal(t, "3", result.Groups[0].ID)
+ assert.Equal(t, "zeta", result.Groups[0].Name)
+ assert.Equal(t, 0.8, result.Groups[0].Ratio)
+ assert.Equal(t, "/api/v1/groups/available", result.Groups[0].Endpoint)
+ assert.Equal(t, "9", result.Groups[1].ID)
+ assert.Equal(t, "alpha", result.Groups[1].Name)
+ assert.Equal(t, 1.75, result.Groups[1].Ratio)
+ assert.Equal(t, "/api/v1/groups/rates", result.Groups[1].Endpoint)
+ require.NotNil(t, result.Balance.Amount)
+ assert.InDelta(t, 23.5, *result.Balance.Amount, 1e-9)
+ assert.Equal(t, "/api/v1/user/profile", result.Balance.Endpoint)
+
+}
+
+func TestFetchSub2APITokenClassifiesAuthenticationFailure(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(`{"code":401,"message":"token expired"}`))
+ }))
+ defer server.Close()
+
+ _, err := fetchSub2APIGroupRatio(context.Background(), server.Client(), Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: Sub2APIAuthToken,
+ AccessToken: "legacy-jwt",
+ SkipBalance: true,
+ }, nil)
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrChannelMonitorUpstreamAuthentication)
+}
+
+func TestFetchSub2APIGroupRatioUsesChannelKeyBillingAndUsage(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/v1/sub2api/billing":
+ assert.Equal(t, "Bearer sk-direct", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"object":"sub2api.key_billing","schema_version":1,"billing_scope":"token","effective_rate_multiplier":1.375}`))
+ case "/v1/usage":
+ assert.Equal(t, "Bearer sk-direct", r.Header.Get("Authorization"))
+ _, _ = w.Write([]byte(`{"mode":"unrestricted","balance":12.5}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ result, err := fetchSub2APIGroupRatio(context.Background(), server.Client(), Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: Sub2APIAuthAPIKey,
+ ChannelKeys: []string{"sk-direct"},
+ }, nil)
+ require.NoError(t, err)
+ assert.InDelta(t, 1.375, result.Ratio, 1e-9)
+ require.NotNil(t, result.Balance.Amount)
+ assert.InDelta(t, 12.5, *result.Balance.Amount, 1e-9)
+ assert.Equal(t, "/v1/usage", result.Balance.Endpoint)
+}
+
+func TestFetchSub2APIGroupRatioAPIKeyModeDoesNotUseTokenBranch(t *testing.T) {
+ var directRequests atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/v1/sub2api/billing":
+ directRequests.Add(1)
+ http.NotFound(w, r)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ _, err := fetchSub2APIGroupRatio(context.Background(), server.Client(), Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: Sub2APIAuthAPIKey,
+ ChannelKeys: []string{"sk-old-version"},
+ SkipBalance: true,
+ }, nil)
+ require.Error(t, err)
+ assert.EqualValues(t, 1, directRequests.Load())
+ assert.Contains(t, err.Error(), "404")
+}
+
+func TestFetchSub2APIGroupRatioAPIKeyModeReportsAuthenticationFailure(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.URL.Path == "/v1/sub2api/billing" {
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{"error":{"type":"permission_error","message":"invalid API key"}}`))
+ return
+ }
+ http.NotFound(w, r)
+ }))
+ defer server.Close()
+
+ _, err := fetchSub2APIGroupRatio(context.Background(), server.Client(), Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: Sub2APIAuthAPIKey,
+ ChannelKeys: []string{"sk-invalid"},
+ SkipBalance: true,
+ }, nil)
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrChannelMonitorUpstreamAuthentication)
+ assert.NotContains(t, err.Error(), "sk-invalid")
+}
+
+func TestFetchSub2APIUpstreamBalanceAPIKeyModeRequiresWalletBalance(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/v1/usage":
+ _, _ = w.Write([]byte(`{"mode":"quota_limited","remaining":100,"balance":999}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ _, err := fetchSub2APIUpstreamBalance(context.Background(), server.Client(), Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ AuthType: Sub2APIAuthAPIKey,
+ ChannelKeys: []string{"sk-quota"},
+ }, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "没有钱包余额")
+}
+
+func TestFetchSub2APIGroupRatioRejectsDifferentChannelKeyBillingRatios(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/sub2api/billing" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ ratio := "1.0"
+ if r.Header.Get("Authorization") == "Bearer sk-two" {
+ ratio = "1.2"
+ }
+ _, _ = w.Write([]byte(`{"object":"sub2api.key_billing","effective_rate_multiplier":` + ratio + `}`))
+ }))
+ defer server.Close()
+
+ _, err := fetchSub2APIGroupRatio(context.Background(), server.Client(), Sub2APIGroupRatioConfig{
+ BaseURL: server.URL,
+ Group: "vip",
+ AuthType: Sub2APIAuthAPIKey,
+ ChannelKeys: []string{"sk-one", "sk-two"},
+ SkipBalance: true,
+ }, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "多个 API Key")
+}
diff --git a/service/channel_select.go b/service/channel_select.go
index 24c4e252bfb3..9a50dcfb5fea 100644
--- a/service/channel_select.go
+++ b/service/channel_select.go
@@ -81,11 +81,16 @@ func (p *RetryParam) ResetRetryNextTry() {
//
// Retry=3: GroupB, priority1 (startRetryIndex=2, priorityRetry=1)
// 分组B, 优先级1
-func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, error) {
+func CacheGetRandomSatisfiedChannel(param *RetryParam, options ...model.ChannelSelectionOptions) (*model.Channel, string, error) {
var channel *model.Channel
var err error
selectGroup := param.TokenGroup
userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup)
+ selectionOptions := model.ChannelSelectionOptions{}
+ if len(options) > 0 {
+ selectionOptions = options[len(options)-1]
+ }
+ hasExcludedChannels := selectionOptions.HasExcludedChannels()
if param.TokenGroup == "auto" {
if len(setting.GetAutoGroups()) == 0 {
@@ -116,7 +121,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
}
logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry)
- channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry, param.RequestPath)
+ channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry, param.RequestPath, selectionOptions)
if channel == nil {
// Current group has no available channel for this model, try next group
// 当前分组没有该模型的可用渠道,尝试下一个分组
@@ -124,9 +129,11 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
// 重置状态以尝试下一个分组
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1)
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupRetryIndex, 0)
- // Reset retry counter so outer loop can continue for next group
- // 重置重试计数器,以便外层循环可以为下一个分组继续
- param.SetRetry(0)
+ if !hasExcludedChannels {
+ // Reset retry counter so outer loop can continue for next group.
+ // 重置重试计数器,以便外层循环可以为下一个分组继续。
+ param.SetRetry(0)
+ }
continue
}
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, autoGroup)
@@ -142,10 +149,12 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
// 本次请求仍使用当前分组,但下次重试将使用下一个分组
logger.LogDebug(param.Ctx, "Current group %s retries exhausted (priorityRetry=%d >= RetryTimes=%d), preparing switch to next group for next retry", autoGroup, priorityRetry, common.RetryTimes)
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1)
- // Reset retry counter so outer loop can continue for next group
- // 重置重试计数器,以便外层循环可以为下一个分组继续
- param.SetRetry(0)
- param.ResetRetryNextTry()
+ if !hasExcludedChannels {
+ // Reset retry counter so outer loop can continue for next group.
+ // 重置重试计数器,以便外层循环可以为下一个分组继续。
+ param.SetRetry(0)
+ param.ResetRetryNextTry()
+ }
} else {
// Stay in current group, save current state
// 保持在当前分组,保存当前状态
@@ -154,7 +163,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
break
}
} else {
- channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry(), param.RequestPath)
+ channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry(), param.RequestPath, selectionOptions)
if err != nil {
return nil, param.TokenGroup, err
}
diff --git a/service/protected_fetch_client.go b/service/protected_fetch_client.go
index 9d1d4cc87871..d98cbf5f2206 100644
--- a/service/protected_fetch_client.go
+++ b/service/protected_fetch_client.go
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"strconv"
+ "strings"
"sync"
"time"
@@ -34,6 +35,10 @@ type ssrfProtectedRoundTripper struct {
transports map[string]*http.Transport
}
+type ssrfProtectedProxyRoundTripper struct {
+ base http.RoundTripper
+}
+
func currentFetchProtection() (*common.SSRFProtection, bool, error) {
fetchSetting := system_setting.GetFetchSetting()
if !fetchSetting.EnableSSRFProtection {
@@ -59,6 +64,43 @@ func newProtectedFetchHTTPClient() *http.Client {
return newProtectedFetchHTTPClientWithDialer(nil, nil, nil)
}
+// NewSSRFProtectedHTTPClientWithProxy returns a protected client that uses the
+// same proxy transport as normal channel relay requests.
+func NewSSRFProtectedHTTPClientWithProxy(proxyURL string) (*http.Client, error) {
+ proxyURL = strings.TrimSpace(proxyURL)
+ if proxyURL == "" {
+ client := GetSSRFProtectedHTTPClient()
+ if client == nil {
+ return nil, fmt.Errorf("上游请求客户端未初始化")
+ }
+ return client, nil
+ }
+
+ proxyClient, err := NewProxyHttpClient(proxyURL)
+ if err != nil {
+ return nil, fmt.Errorf("创建渠道代理客户端失败: %w", err)
+ }
+ baseTransport := proxyClient.Transport
+ if baseTransport == nil {
+ baseTransport = http.DefaultTransport
+ }
+ return &http.Client{
+ Transport: &ssrfProtectedProxyRoundTripper{base: baseTransport},
+ CheckRedirect: checkProtectedFetchRedirect,
+ Timeout: proxyClient.Timeout,
+ }, nil
+}
+
+func (t *ssrfProtectedProxyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ if req == nil || req.URL == nil {
+ return nil, fmt.Errorf("invalid request")
+ }
+ if err := ValidateSSRFProtectedFetchURL(req.URL.String()); err != nil {
+ return nil, err
+ }
+ return t.base.RoundTrip(req)
+}
+
func newProtectedFetchHTTPClientWithDialer(resolver ssrfResolver, dialContext func(ctx context.Context, network, address string) (net.Conn, error), getProtection func() (*common.SSRFProtection, bool, error)) *http.Client {
return newProtectedFetchHTTPClientWithProxy(resolver, dialContext, getProtection, http.ProxyFromEnvironment)
}
diff --git a/service/protected_fetch_client_test.go b/service/protected_fetch_client_test.go
index 3aa49c9ac79f..68ad6a5d6d07 100644
--- a/service/protected_fetch_client_test.go
+++ b/service/protected_fetch_client_test.go
@@ -213,6 +213,13 @@ func TestGetSSRFProtectedHTTPClientFallsBackToDefaultClientWhenProtectionDisable
require.Same(t, expected, GetSSRFProtectedHTTPClient())
}
+func TestNewSSRFProtectedHTTPClientWithProxyRejectsInvalidProxy(t *testing.T) {
+ client, err := NewSSRFProtectedHTTPClientWithProxy("not-a-proxy")
+ require.Error(t, err)
+ require.Nil(t, client)
+ require.Contains(t, err.Error(), "创建渠道代理客户端失败")
+}
+
func TestProtectedFetchRoundTripperUsesConfiguredProxy(t *testing.T) {
configureSSRFTestFetchSetting(t)
proxyURL := mustParseURL(t, "http://127.0.0.1:3128")
diff --git a/setting/operation_setting/status_code_ranges.go b/setting/operation_setting/status_code_ranges.go
index 14cfacad71ed..441858d25224 100644
--- a/setting/operation_setting/status_code_ranges.go
+++ b/setting/operation_setting/status_code_ranges.go
@@ -30,7 +30,6 @@ var AutomaticRetryStatusCodeRanges = []StatusCodeRange{
var alwaysSkipRetryStatusCodes = map[int]struct{}{
504: {},
- 524: {},
}
var alwaysSkipRetryCodes = map[types.ErrorCode]struct{}{
diff --git a/setting/operation_setting/status_code_ranges_test.go b/setting/operation_setting/status_code_ranges_test.go
index 4e292a3681a9..aff7d87121b6 100644
--- a/setting/operation_setting/status_code_ranges_test.go
+++ b/setting/operation_setting/status_code_ranges_test.go
@@ -63,7 +63,7 @@ func TestShouldRetryByStatusCode(t *testing.T) {
require.True(t, ShouldRetryByStatusCode(429))
require.True(t, ShouldRetryByStatusCode(500))
require.False(t, ShouldRetryByStatusCode(504))
- require.False(t, ShouldRetryByStatusCode(524))
+ require.True(t, ShouldRetryByStatusCode(524))
require.False(t, ShouldRetryByStatusCode(400))
require.False(t, ShouldRetryByStatusCode(200))
}
@@ -82,6 +82,6 @@ func TestShouldRetryByStatusCode_DefaultMatchesLegacyBehavior(t *testing.T) {
func TestIsAlwaysSkipRetryStatusCode(t *testing.T) {
require.True(t, IsAlwaysSkipRetryStatusCode(504))
- require.True(t, IsAlwaysSkipRetryStatusCode(524))
+ require.False(t, IsAlwaysSkipRetryStatusCode(524))
require.False(t, IsAlwaysSkipRetryStatusCode(500))
}
diff --git a/web/default/src/components/ui/combobox.tsx b/web/default/src/components/ui/combobox.tsx
index 78c73a6e1f7c..e54613a1b2d6 100644
--- a/web/default/src/components/ui/combobox.tsx
+++ b/web/default/src/components/ui/combobox.tsx
@@ -144,6 +144,7 @@ function ComboboxInput({
size='icon-xs'
variant='ghost'
render={ }
+ nativeButton
data-slot='input-group-button'
className='group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent'
disabled={disabled}
diff --git a/web/default/src/features/channel-monitor/api.ts b/web/default/src/features/channel-monitor/api.ts
new file mode 100644
index 000000000000..bcd69dcbfda1
--- /dev/null
+++ b/web/default/src/features/channel-monitor/api.ts
@@ -0,0 +1,376 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { api, type ApiRequestConfig } from '@/lib/api'
+
+import type {
+ ChannelMonitorApplyGroupResult,
+ ChannelMonitorApiResponse,
+ ChannelMonitorCostOverview,
+ ChannelMonitorFetchResult,
+ ChannelMonitorGroupChannelsUpdateResult,
+ ChannelMonitorGroupRatioSyncResult,
+ ChannelMonitorOverview,
+ ChannelMonitorPerformanceRangeMinutes,
+ ChannelMonitorPerformanceResult,
+ ChannelMonitorSettings,
+ ChannelMonitorSmartScheduleConfig,
+ ChannelMonitorSuccessDetailResult,
+ ChannelMonitorTaskRunResult,
+ ChannelMonitorTaskPage,
+ ChannelMonitorTaskKind,
+ ChannelMonitorUpstreamBalanceResult,
+ ChannelMonitorUpstreamConfig,
+ ChannelMonitorUpstreamGroupsResult,
+ ChannelMonitorUpstreamRequest,
+ ChannelMonitorUpstreamVersionResult,
+ ChannelRatioHistoryPage,
+ NewAPIGroupRatioResult,
+} from './types'
+
+const channelMonitorRequestConfig = (
+ config: ApiRequestConfig = {}
+): ApiRequestConfig => ({
+ ...config,
+ skipBusinessError: true,
+ skipErrorHandler: true,
+})
+
+function ensureChannelMonitorSuccess(
+ response: ChannelMonitorApiResponse
+) {
+ if (!response.success) {
+ throw new Error(response.message || '渠道监控请求失败')
+ }
+ return response
+}
+
+export async function getChannelMonitorOverview() {
+ const response = await api.get<
+ ChannelMonitorApiResponse
+ >('/api/channel_monitor/', channelMonitorRequestConfig())
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function getChannelMonitorCostOverview(days: number) {
+ const response = await api.get<
+ ChannelMonitorApiResponse
+ >(
+ '/api/channel_monitor/cost',
+ channelMonitorRequestConfig({ params: { days } })
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function getChannelMonitorPerformance(
+ minutes: ChannelMonitorPerformanceRangeMinutes
+) {
+ const response = await api.get<
+ ChannelMonitorApiResponse
+ >(
+ '/api/channel_monitor/performance',
+ channelMonitorRequestConfig({ params: { minutes } })
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function getChannelMonitorSuccessDetail(request: {
+ minutes: ChannelMonitorPerformanceRangeMinutes
+ channelId?: number
+ modelName?: string
+ groupName?: string
+}) {
+ const response = await api.get<
+ ChannelMonitorApiResponse
+ >(
+ '/api/channel_monitor/success/detail',
+ channelMonitorRequestConfig({
+ params: {
+ minutes: request.minutes,
+ channel_id: request.channelId,
+ model_name: request.modelName,
+ group: request.groupName,
+ },
+ })
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function updateChannelMonitorChannelOrder(channelIds: number[]) {
+ const response = await api.put<
+ ChannelMonitorApiResponse<{ channel_order: number[] }>
+ >(
+ '/api/channel_monitor/order',
+ {
+ channel_ids: channelIds,
+ },
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function getChannelMonitorTasks(
+ page: number,
+ pageSize: number,
+ kind: ChannelMonitorTaskKind
+) {
+ const response = await api.get<
+ ChannelMonitorApiResponse
+ >(
+ '/api/channel_monitor/tasks',
+ channelMonitorRequestConfig({
+ params: { p: page, page_size: pageSize, kind },
+ })
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function runChannelMonitorSmartSchedule() {
+ const response = await api.post<
+ ChannelMonitorApiResponse
+ >(
+ '/api/channel_monitor/schedule/run',
+ undefined,
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function runChannelMonitorRatioUpdate() {
+ const response = await api.post<
+ ChannelMonitorApiResponse
+ >('/api/channel_monitor/ratio/run', undefined, channelMonitorRequestConfig())
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function updateChannelMonitorSmartScheduleConfig(request: {
+ channelId: number
+ excluded: boolean
+ reset?: boolean
+}) {
+ const response = await api.put<
+ ChannelMonitorApiResponse
+ >(
+ `/api/channel_monitor/channel/${request.channelId}/schedule`,
+ {
+ excluded: request.excluded,
+ reset: request.reset,
+ },
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function updateChannelMonitorRatio(request: {
+ channelId: number
+ ratio: number
+ remark: string
+}) {
+ const response = await api.put(
+ `/api/channel_monitor/channel/${request.channelId}`,
+ {
+ ratio: request.ratio,
+ remark: request.remark,
+ },
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function getChannelMonitorHistory(channelId: number) {
+ const response = await api.get<
+ ChannelMonitorApiResponse
+ >(
+ `/api/channel_monitor/channel/${channelId}/history`,
+ channelMonitorRequestConfig({ params: { p: 1, page_size: 100 } })
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function updateChannelMonitorGroupRatio(request: {
+ group: string
+ ratio: number
+}) {
+ const response = await api.put(
+ '/api/channel_monitor/group',
+ request,
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function updateChannelMonitorGroupChannels(request: {
+ group: string
+ channelIds: number[]
+}) {
+ const response = await api.put<
+ ChannelMonitorApiResponse
+ >(
+ '/api/channel_monitor/group/channels',
+ {
+ group: request.group,
+ channel_ids: request.channelIds,
+ },
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function syncChannelMonitorGroupRatio(request: {
+ group: string
+ coefficient: number
+}) {
+ const response = await api.put<
+ ChannelMonitorApiResponse
+ >('/api/channel_monitor/group/sync', request, channelMonitorRequestConfig())
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function updateChannelMonitorSettings(
+ settings: ChannelMonitorSettings & {
+ smart_schedule_force_reset?: boolean
+ }
+) {
+ const response = await api.put<
+ ChannelMonitorApiResponse
+ >('/api/channel_monitor/settings', settings, channelMonitorRequestConfig())
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function getChannelMonitorAvailableGroups() {
+ const response = await api.get>(
+ '/api/group/',
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function updateMonitoredChannelStatus(request: {
+ channelId: number
+ status: number
+}) {
+ const response = await api.post>(
+ `/api/channel/${request.channelId}/status`,
+ { status: request.status },
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function updateMonitoredChannelGroups(request: {
+ channelId: number
+ groups: string[]
+}) {
+ const response = await api.put>(
+ '/api/channel/',
+ { id: request.channelId, group: request.groups.join(',') },
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function saveChannelMonitorUpstreamConfig(request: {
+ channelId: number
+ config: ChannelMonitorUpstreamRequest
+}) {
+ const response = await api.put<
+ ChannelMonitorApiResponse
+ >(
+ `/api/channel_monitor/channel/${request.channelId}/upstream`,
+ request.config,
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function fetchChannelMonitorSub2APIUpstreamVersion(request: {
+ channelId: number
+ baseUrl: string
+}) {
+ const response = await api.post<
+ ChannelMonitorApiResponse
+ >(
+ `/api/channel_monitor/channel/${request.channelId}/upstream/version`,
+ {
+ base_url: request.baseUrl,
+ },
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function testChannelMonitorUpstreamConfig(request: {
+ channelId: number
+ config: ChannelMonitorUpstreamRequest
+}) {
+ const response = await api.post<
+ ChannelMonitorApiResponse
+ >(
+ `/api/channel_monitor/channel/${request.channelId}/upstream/test`,
+ request.config,
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function listChannelMonitorUpstreamGroups(request: {
+ channelId: number
+ config: ChannelMonitorUpstreamRequest
+}) {
+ const response = await api.post<
+ ChannelMonitorApiResponse
+ >(
+ `/api/channel_monitor/channel/${request.channelId}/upstream/groups`,
+ request.config,
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function fetchChannelMonitorUpstreamRatio(channelId: number) {
+ const response = await api.post<
+ ChannelMonitorApiResponse
+ >(
+ `/api/channel_monitor/channel/${channelId}/upstream/fetch`,
+ undefined,
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function fetchChannelMonitorUpstreamBalance(channelId: number) {
+ const response = await api.post<
+ ChannelMonitorApiResponse
+ >(
+ `/api/channel_monitor/channel/${channelId}/upstream/balance/fetch`,
+ undefined,
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
+
+export async function applyChannelMonitorUpstreamGroup(channelId: number) {
+ const response = await api.post<
+ ChannelMonitorApiResponse
+ >(
+ `/api/channel_monitor/channel/${channelId}/upstream/group/apply`,
+ undefined,
+ channelMonitorRequestConfig()
+ )
+ return ensureChannelMonitorSuccess(response.data)
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-channel-view.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-channel-view.tsx
new file mode 100644
index 000000000000..7b0c8b636861
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-channel-view.tsx
@@ -0,0 +1,535 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import {
+ Edit02Icon,
+ HistoryIcon,
+ Layers01Icon,
+ PowerOffIcon,
+ PowerServiceIcon,
+ Refresh01Icon,
+ Settings02Icon,
+ TestTubeIcon,
+} from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyTitle,
+} from '@/components/ui/empty'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Spinner } from '@/components/ui/spinner'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
+import { CHANNEL_STATUS } from '@/features/channels/constants'
+import { formatTimestampToDate } from '@/lib/format'
+import { cn } from '@/lib/utils'
+
+import { getChannelMonitorStatusLabel } from '../constants'
+import { formatMonitorRatio } from '../lib/format'
+import type {
+ ChannelMonitorChannelPerformance,
+ ChannelMonitorItem,
+ ChannelMonitorSuccessSummary,
+} from '../types'
+import { ChannelMonitorFetchStatus } from './channel-monitor-fetch-status'
+import {
+ ChannelMonitorFirstTokenValue,
+ ChannelMonitorTPSValue,
+} from './channel-monitor-performance-value'
+import { ChannelMonitorSmartScheduleCell } from './channel-monitor-smart-schedule-cell'
+import { ChannelMonitorStatusBadge } from './channel-monitor-status-badge'
+import { ChannelMonitorSuccessRateValue } from './channel-monitor-success-rate-value'
+import { GroupRatioValue } from './group-ratio-value'
+import { RatioChangeBadge } from './ratio-change-badge'
+
+type ChannelMonitorChannelViewProps = {
+ channels: ChannelMonitorItem[]
+ groupRatios: Record
+ groupCoefficients: Record
+ performanceByChannel: Map
+ successByChannel: Map
+ successMetricsAvailable: boolean
+ performanceRangeLabel: string
+ performanceLoading: boolean
+ performanceError: boolean
+ onFetchUpstreamBalance: (channel: ChannelMonitorItem) => void
+ onFetchUpstreamRatio: (channel: ChannelMonitorItem) => void
+ onToggleStatus: (channel: ChannelMonitorItem) => void
+ onTestConnection: (channel: ChannelMonitorItem) => void
+ onEditRatio: (channel: ChannelMonitorItem) => void
+ onEditGroups: (channel: ChannelMonitorItem) => void
+ onConfigureUpstream: (channel: ChannelMonitorItem) => void
+ onViewHistory: (channel: ChannelMonitorItem) => void
+ onOpenSuccessDetail: (channel: ChannelMonitorItem) => void
+ onUpdateSmartSchedule: (
+ channel: ChannelMonitorItem,
+ excluded: boolean
+ ) => void
+ smartScheduleEnabled: boolean
+ fetchingBalanceChannelId: number | null
+ fetchingRatioChannelId: number | null
+ updatingStatusChannelId: number | null
+ updatingSmartScheduleChannelId: number | null
+}
+
+type ChannelActionButtonProps = {
+ label: string
+ icon: React.ComponentProps['icon']
+ onClick: () => void
+ disabled?: boolean
+ loading?: boolean
+ className?: string
+ size?: 'icon-xs' | 'icon-sm'
+}
+
+type ChannelPerformanceCellProps = {
+ performance: ChannelMonitorChannelPerformance | undefined
+ loading: boolean
+ error: boolean
+}
+
+type ChannelUpstreamBalanceCellProps = {
+ channel: ChannelMonitorItem
+}
+
+const upstreamBalanceFormatter = new Intl.NumberFormat(undefined, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 4,
+})
+
+function ChannelActionButton(props: ChannelActionButtonProps) {
+ return (
+
+
+ {props.loading ? : }
+
+ }
+ />
+ {props.label}
+
+ )
+}
+
+function ChannelPerformanceCell(props: ChannelPerformanceCellProps) {
+ if (props.loading) {
+ return
+ }
+ if (props.error) {
+ return 加载失败
+ }
+ if (!props.performance) {
+ return 暂无样本
+ }
+ return (
+
+
+ 首字
+
+
+
+ TPS
+
+
+
+ {props.performance.sample_count} 次请求
+
+
+ )
+}
+
+function ChannelUpstreamBalanceCell(props: ChannelUpstreamBalanceCellProps) {
+ if (!props.channel.upstream) {
+ return -
+ }
+ if (!props.channel.upstream.balance_sync_enabled) {
+ return 余额同步已关闭
+ }
+ if (props.channel.upstream_balance == null) {
+ if (props.channel.last_balance_error) {
+ return (
+
+ 无法获取
+
+ )
+ }
+ return 暂无
+ }
+
+ const titleParts: string[] = []
+ if (props.channel.last_balance_time > 0) {
+ titleParts.push(
+ `最后更新:${formatTimestampToDate(props.channel.last_balance_time)}`
+ )
+ }
+ if (props.channel.last_balance_error) {
+ titleParts.push(`最近更新失败:${props.channel.last_balance_error}`)
+ }
+ const warningThreshold =
+ props.channel.upstream?.balance_warning_threshold ?? null
+ const balanceWarning =
+ warningThreshold != null &&
+ props.channel.upstream_balance < warningThreshold
+ if (warningThreshold != null) {
+ titleParts.push(
+ `余额预警值:${upstreamBalanceFormatter.format(warningThreshold)}`
+ )
+ }
+ return (
+
+
+ {upstreamBalanceFormatter.format(props.channel.upstream_balance)}
+
+ {balanceWarning ? (
+ 低于预警值
+ ) : null}
+ {props.channel.last_balance_error ? (
+ 更新失败
+ ) : null}
+
+ )
+}
+
+export function ChannelMonitorChannelView(
+ props: ChannelMonitorChannelViewProps
+) {
+ if (props.channels.length === 0) {
+ return (
+
+
+ 当前筛选下没有渠道
+ 切换上游类型或调整搜索条件
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {props.smartScheduleEnabled ? : null}
+
+
+
+
+
+ 渠道
+ 上游余额
+ 成本倍率
+ 倍率更新状态
+ 关联分组
+ 性能({props.performanceRangeLabel})
+
+ 成功率({props.performanceRangeLabel})
+
+ {props.smartScheduleEnabled ? (
+ 智能调度
+ ) : null}
+ 更新时间
+ 操作
+
+
+
+ {props.channels.map((channel) => {
+ const channelEnabled = channel.status === CHANNEL_STATUS.ENABLED
+ const successMetric = props.successByChannel.get(channel.id)
+ const channelStatusLabel = `渠道状态:${getChannelMonitorStatusLabel(channel.status)}`
+ return (
+
+
+
+
+
+
+ {channel.name}
+
+ {!channelEnabled && (
+
+ )}
+
+ {channel.channel_remark && (
+
+ 备注:{channel.channel_remark}
+
+ )}
+
+ ID {channel.id}
+
+
+
+
+
+ {channel.upstream?.balance_sync_enabled ? (
+ props.onFetchUpstreamBalance(channel)}
+ disabled={
+ props.fetchingBalanceChannelId !== null ||
+ props.fetchingRatioChannelId !== null
+ }
+ loading={props.fetchingBalanceChannelId === channel.id}
+ size='icon-xs'
+ />
+ ) : null}
+
+
+
+
+
+ {channel.upstream?.ratio_sync_enabled ? (
+
props.onFetchUpstreamRatio(channel)}
+ disabled={
+ props.fetchingBalanceChannelId !== null ||
+ props.fetchingRatioChannelId !== null
+ }
+ loading={props.fetchingRatioChannelId === channel.id}
+ size='icon-xs'
+ />
+ ) : null}
+
+
+
+ {formatMonitorRatio(channel.cost_ratio)}
+
+
+
+ {channel.upstream ? (
+
+ {channel.conversion_factor != null &&
+ Math.abs(channel.conversion_factor - 1) > 1e-9 ? (
+
+ 上游 {formatMonitorRatio(channel.ratio)} × 换算{' '}
+ {formatMonitorRatio(channel.conversion_factor)}
+
+ ) : null}
+
+ 上游分组:{channel.upstream.group}
+
+ {!channel.upstream.ratio_sync_enabled ? (
+
+ 倍率同步已关闭
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+
+
+ {channel.groups.length === 0 ? (
+ -
+ ) : (
+
+ {channel.groups.map((group) => {
+ const groupRatio = props.groupRatios[group] ?? 1
+ const coefficient = props.groupCoefficients[group] ?? 1
+ return (
+
+ {group} ×{' '}
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+
+ props.onOpenSuccessDetail(channel)}
+ detailLabel={`查看 ${channel.name} 的成功率明细`}
+ />
+
+ {props.smartScheduleEnabled ? (
+
+
+ props.onUpdateSmartSchedule(channel, excluded)
+ }
+ />
+
+ ) : null}
+
+ {channel.updated_time > 0 ? (
+
+
+ {formatTimestampToDate(channel.updated_time)}
+
+ {channel.updated_by_username && (
+
+ {channel.updated_by_username}
+
+ )}
+
+ ) : (
+ -
+ )}
+
+
+
+ props.onToggleStatus(channel)}
+ disabled={props.updatingStatusChannelId !== null}
+ loading={props.updatingStatusChannelId === channel.id}
+ className={
+ channel.status === CHANNEL_STATUS.ENABLED
+ ? 'text-destructive hover:text-destructive'
+ : 'text-success hover:text-success'
+ }
+ />
+ props.onTestConnection(channel)}
+ />
+ props.onEditRatio(channel)}
+ />
+ props.onEditGroups(channel)}
+ />
+ props.onConfigureUpstream(channel)}
+ />
+ props.onViewHistory(channel)}
+ />
+
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-cost-conversion-fields.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-cost-conversion-fields.tsx
new file mode 100644
index 000000000000..35b3eaa1dac9
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-cost-conversion-fields.tsx
@@ -0,0 +1,333 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useWatch, type UseFormReturn } from 'react-hook-form'
+
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from '@/components/ui/input-group'
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
+
+import {
+ getChannelMonitorConversionFactor,
+ getChannelMonitorCostRatio,
+} from '../lib/cost-conversion'
+import { formatMonitorRatio } from '../lib/format'
+import {
+ MAX_COST_CONVERSION_AMOUNT,
+ type UpstreamConfigFormValues,
+} from '../lib/schema'
+import type { ChannelMonitorCostConversion } from '../types'
+
+type ChannelMonitorCostConversionFieldsProps = {
+ form: UseFormReturn
+ upstreamRatio: number | null
+}
+
+export function ChannelMonitorCostConversionFields(
+ props: ChannelMonitorCostConversionFieldsProps
+) {
+ const mode = useWatch({
+ control: props.form.control,
+ name: 'costConversionMode',
+ })
+ const rechargePaidCny = Number(
+ useWatch({ control: props.form.control, name: 'rechargePaidCny' })
+ )
+ const rechargeCreditedUsd = Number(
+ useWatch({ control: props.form.control, name: 'rechargeCreditedUsd' })
+ )
+ const subscriptionPeriod = useWatch({
+ control: props.form.control,
+ name: 'subscriptionPeriod',
+ })
+ const subscriptionPriceCny = Number(
+ useWatch({ control: props.form.control, name: 'subscriptionPriceCny' })
+ )
+ const subscriptionDailyUsd = Number(
+ useWatch({ control: props.form.control, name: 'subscriptionDailyUsd' })
+ )
+
+ let config: ChannelMonitorCostConversion = { mode: 'none' }
+ if (mode === 'recharge') {
+ config = {
+ mode,
+ paid_cny: rechargePaidCny,
+ credited_usd: rechargeCreditedUsd,
+ }
+ } else if (mode === 'subscription') {
+ config = {
+ mode,
+ subscription_period: subscriptionPeriod,
+ subscription_price_cny: subscriptionPriceCny,
+ subscription_daily_usd: subscriptionDailyUsd,
+ }
+ }
+ const conversionFactor = getChannelMonitorConversionFactor(config)
+ const costRatio = getChannelMonitorCostRatio(props.upstreamRatio, config)
+
+ return (
+
+
(
+
+ 倍率换算
+
+ {
+ const nextValue = values.find(
+ (value) => value !== field.value
+ )
+ if (
+ nextValue !== 'none' &&
+ nextValue !== 'recharge' &&
+ nextValue !== 'subscription'
+ ) {
+ return
+ }
+ field.onChange(nextValue)
+ }}
+ variant='outline'
+ spacing={2}
+ className='grid w-full grid-cols-3'
+ >
+
+ 不换算
+
+
+ 充值
+
+
+ 订阅
+
+
+
+
+
+ )}
+ />
+
+ {mode === 'recharge' ? (
+
+ (
+
+ 实付金额
+
+
+ ¥
+
+
+
+
+
+ )}
+ />
+ (
+
+ 到账额度
+
+
+ $
+
+
+
+
+
+ )}
+ />
+
+ ) : null}
+
+ {mode === 'subscription' ? (
+ <>
+ (
+
+ 订阅周期
+
+ {
+ const nextValue = values.find(
+ (value) => value !== field.value
+ )
+ if (
+ nextValue !== 'day' &&
+ nextValue !== 'week' &&
+ nextValue !== 'month'
+ ) {
+ return
+ }
+ field.onChange(nextValue)
+ }}
+ variant='outline'
+ spacing={2}
+ className='grid w-full grid-cols-3'
+ >
+
+ 天
+
+
+ 周
+
+
+ 月(30 天)
+
+
+
+
+
+ )}
+ />
+
+ (
+
+ 订阅价格
+
+
+ ¥
+
+
+
+
+
+ )}
+ />
+ (
+
+ 每日额度
+
+
+ $
+
+
+
+
+
+ )}
+ />
+
+ >
+ ) : null}
+
+
+
+ 换算系数
+
+ {formatMonitorRatio(conversionFactor)}
+
+
+
+ 上游倍率
+
+ {formatMonitorRatio(props.upstreamRatio)}
+
+
+
+ 成本倍率
+
+ {formatMonitorRatio(costRatio)}
+
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-cost-history-dialog.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-cost-history-dialog.tsx
new file mode 100644
index 000000000000..03a3208f4407
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-cost-history-dialog.tsx
@@ -0,0 +1,362 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { ChartLineData01Icon, MoneyBag02Icon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useQuery } from '@tanstack/react-query'
+import { VChart } from '@visactor/react-vchart'
+import { useMemo, useState, type ReactNode } from 'react'
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from '@/components/ui/empty'
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { useChartTheme } from '@/lib/use-chart-theme'
+import { VCHART_OPTION } from '@/lib/vchart'
+
+import { getChannelMonitorCostOverview } from '../api'
+import { formatChannelMonitorCost } from '../lib/format'
+import type { ChannelMonitorCostOverview } from '../types'
+
+const COST_HISTORY_RANGE_OPTIONS = [
+ { value: '7', label: '近 7 天' },
+ { value: '30', label: '近 30 天' },
+ { value: '90', label: '近 90 天' },
+]
+
+type ChannelMonitorCostHistoryDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function ChannelMonitorCostHistoryDialog(
+ props: ChannelMonitorCostHistoryDialogProps
+) {
+ const [days, setDays] = useState(30)
+ const query = useQuery({
+ queryKey: ['channel-monitor', 'cost', days],
+ queryFn: () => getChannelMonitorCostOverview(days),
+ enabled: props.open,
+ staleTime: 30_000,
+ })
+
+ return (
+
+
+
+ 渠道成本
+
+ 按北京时间、当前渠道成本倍率和本地分组倍率估算;消费增加、退款按发生日抵减,调整倍率后历史金额会同步变化。
+
+
+
+
+
+
+ {
+ switch (value) {
+ case '7':
+ setDays(7)
+ break
+ case '30':
+ setDays(30)
+ break
+ case '90':
+ setDays(90)
+ break
+ }
+ }}
+ >
+
+
+
+
+
+ {COST_HISTORY_RANGE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function CostSummary(props: {
+ overview: ChannelMonitorCostOverview | undefined
+ loading: boolean
+}) {
+ if (props.loading) {
+ return
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+function CostSummaryValue(props: { label: string; value: number | undefined }) {
+ return (
+
+ {props.label}
+
+ {formatChannelMonitorCost(props.value)}
+
+
+ )
+}
+
+function CostHistoryContent(props: {
+ loading: boolean
+ error: boolean
+ overview: ChannelMonitorCostOverview | undefined
+}) {
+ let content: ReactNode
+ if (props.loading) {
+ content = (
+
+
+
+
+ )
+ } else if (props.error || !props.overview) {
+ content = (
+
+
+
+
+
+ 成本统计加载失败
+ 请稍后重试
+
+
+ )
+ } else if (props.overview.coverage.included_channel_count === 0) {
+ content = (
+
+
+
+
+
+ 暂无可回算的成本
+
+ 为渠道配置充值或订阅换算并获取上游倍率后,消费日志将纳入成本统计。
+
+
+
+ )
+ } else {
+ content =
+ }
+ return content
+}
+
+function CostHistoryData(props: { overview: ChannelMonitorCostOverview }) {
+ const { resolvedTheme, themeReady } = useChartTheme()
+ const chartSpec = useMemo(
+ () => ({
+ type: 'bar' as const,
+ data: [
+ {
+ id: 'channel-cost',
+ values: props.overview.items.map((item) => ({
+ date: item.date,
+ cost: item.cost_cny,
+ })),
+ },
+ ],
+ xField: 'date',
+ yField: 'cost',
+ bar: {
+ style: {
+ cornerRadius: [4, 4, 0, 0],
+ },
+ },
+ legends: { visible: false },
+ tooltip: {
+ mark: {
+ title: { value: (datum: { date: string }) => datum.date },
+ content: [
+ {
+ key: '预估成本',
+ value: (datum: { cost: number }) =>
+ formatChannelMonitorCost(datum.cost),
+ },
+ ],
+ },
+ },
+ axes: [
+ {
+ orient: 'bottom',
+ label: { autoHide: true },
+ tick: { visible: false },
+ },
+ {
+ orient: 'left',
+ label: {
+ formatMethod: (value: number | string) =>
+ formatChannelMonitorCost(Number(value)),
+ },
+ },
+ ],
+ }),
+ [props.overview.items]
+ )
+
+ const coverage = props.overview.coverage
+ return (
+
+
+ {themeReady && (
+
+ )}
+
+
+
+
+
+
+ 日期
+ 预估成本
+
+
+
+ {[...props.overview.items].reverse().map((item) => (
+
+ {item.date}
+
+ {formatChannelMonitorCost(item.cost_cny)}
+
+
+ ))}
+
+
+
+ {props.overview.channels.length > 0 && (
+
+
+
+
+ 渠道
+ 区间预估成本
+
+
+
+ {props.overview.channels.map((channel) => (
+
+
+ {channel.channel_name}
+
+
+ {formatChannelMonitorCost(channel.cost_cny)}
+
+
+ ))}
+
+
+
+ )}
+
+ )
+}
+
+function CostCoverage(props: {
+ coverage: ChannelMonitorCostOverview['coverage']
+}) {
+ const values = [`已纳入 ${props.coverage.included_channel_count} 个渠道`]
+ if (props.coverage.unresolved_channel_count > 0) {
+ values.push(`暂无法回算 ${props.coverage.unresolved_channel_count} 个`)
+ }
+ if (props.coverage.free_group_channel_count > 0) {
+ values.push(`免费分组未纳入 ${props.coverage.free_group_channel_count} 个`)
+ }
+ return (
+
+
+ {values.join(';')}
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-custom-key-value-editor.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-custom-key-value-editor.tsx
new file mode 100644
index 000000000000..7eefbfde0972
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-custom-key-value-editor.tsx
@@ -0,0 +1,203 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import {
+ useFieldArray,
+ useWatch,
+ type FieldPath,
+ type UseFormReturn,
+} from 'react-hook-form'
+
+import { Button } from '@/components/ui/button'
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { Switch } from '@/components/ui/switch'
+
+import {
+ MAX_CUSTOM_UPSTREAM_ENTRIES,
+ type UpstreamConfigFormValues,
+} from '../lib/schema'
+
+type CustomMetricName = 'ratio' | 'balance'
+type CustomKeyValueArrayName =
+ | `customConfig.${CustomMetricName}.request.query`
+ | `customConfig.${CustomMetricName}.request.headers`
+ | `customConfig.${CustomMetricName}.request.form`
+
+type ChannelMonitorCustomKeyValueEditorProps = {
+ form: UseFormReturn
+ name: CustomKeyValueArrayName
+ label: string
+}
+
+type ChannelMonitorCustomKeyValueRowProps =
+ ChannelMonitorCustomKeyValueEditorProps & {
+ index: number
+ onRemove: () => void
+ }
+
+function fieldName(value: string): FieldPath {
+ return value as FieldPath
+}
+
+function ChannelMonitorCustomKeyValueRow(
+ props: ChannelMonitorCustomKeyValueRowProps
+) {
+ const secret = useWatch({
+ control: props.form.control,
+ name: fieldName(`${props.name}.${props.index}.secret`),
+ })
+ const hasValue = useWatch({
+ control: props.form.control,
+ name: fieldName(`${props.name}.${props.index}.hasValue`),
+ })
+
+ return (
+
+ (
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+ 敏感
+
+ )}
+ />
+
+
+
+
+ )
+}
+
+export function ChannelMonitorCustomKeyValueEditor(
+ props: ChannelMonitorCustomKeyValueEditorProps
+) {
+ const entries = useFieldArray<
+ UpstreamConfigFormValues,
+ CustomKeyValueArrayName
+ >({
+ control: props.form.control,
+ name: props.name,
+ })
+
+ return (
+
+
+ {props.label}
+ = MAX_CUSTOM_UPSTREAM_ENTRIES}
+ onClick={() =>
+ entries.append({
+ key: '',
+ value: '',
+ secret: false,
+ hasValue: false,
+ })
+ }
+ >
+
+ 添加
+
+
+ {entries.fields.length === 0 ? (
+
未配置
+ ) : (
+
+ {entries.fields.map((entry, index) => (
+ entries.remove(index)}
+ />
+ ))}
+
+ )}
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-custom-upstream-fields.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-custom-upstream-fields.tsx
new file mode 100644
index 000000000000..877f44aa837d
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-custom-upstream-fields.tsx
@@ -0,0 +1,487 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { useWatch, type UseFormReturn } from 'react-hook-form'
+
+import { FieldLegend, FieldSet } from '@/components/ui/field'
+import {
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from '@/components/ui/input-group'
+import { Switch } from '@/components/ui/switch'
+import { Textarea } from '@/components/ui/textarea'
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
+
+import {
+ MAX_CUSTOM_UPSTREAM_BALANCE,
+ type UpstreamConfigFormValues,
+} from '../lib/schema'
+import { ChannelMonitorCustomKeyValueEditor } from './channel-monitor-custom-key-value-editor'
+
+type CustomMetricName = 'ratio' | 'balance'
+
+type ChannelMonitorCustomUpstreamFieldsProps = {
+ form: UseFormReturn
+}
+
+type CustomMetricFieldsProps = ChannelMonitorCustomUpstreamFieldsProps & {
+ metric: CustomMetricName
+ reuseRequest: boolean
+}
+
+type CustomRequestFieldsProps = ChannelMonitorCustomUpstreamFieldsProps & {
+ metric: CustomMetricName
+ showRequest: boolean
+}
+
+function CustomRequestFields(props: CustomRequestFieldsProps) {
+ const prefix = `customConfig.${props.metric}` as const
+ const method = useWatch({
+ control: props.form.control,
+ name: `${prefix}.request.method`,
+ })
+ const bodyType = useWatch({
+ control: props.form.control,
+ name: `${prefix}.request.bodyType`,
+ })
+ const responseType = useWatch({
+ control: props.form.control,
+ name: `${prefix}.result.responseType`,
+ })
+ const bodySecret = useWatch({
+ control: props.form.control,
+ name: `${prefix}.request.bodySecret`,
+ })
+ const hasBody = props.form.getValues(`${prefix}.request.hasBody`)
+
+ return (
+
+ {props.showRequest ? (
+ <>
+
+ (
+
+ 请求方式
+
+ {
+ const value = values.find(
+ (item) => item !== field.value
+ )
+ if (value !== 'GET' && value !== 'POST') return
+ field.onChange(value)
+ if (value === 'GET') {
+ props.form.setValue(
+ `${prefix}.request.bodyType`,
+ 'none',
+ { shouldValidate: true }
+ )
+ }
+ }}
+ variant='outline'
+ spacing={2}
+ className='grid w-full grid-cols-2'
+ >
+
+ GET
+
+
+ POST
+
+
+
+
+
+ )}
+ />
+ (
+
+ 接口路径
+
+
+
+
+
+ )}
+ />
+
+
+
+
+
+ {method === 'POST' ? (
+ <>
+
(
+
+ 请求体
+
+ {
+ const value = values.find(
+ (item) => item !== field.value
+ )
+ if (
+ value !== 'none' &&
+ value !== 'json' &&
+ value !== 'form'
+ ) {
+ return
+ }
+ field.onChange(value)
+ }}
+ variant='outline'
+ spacing={2}
+ className='grid w-full grid-cols-3'
+ >
+
+ 无
+
+
+ JSON
+
+
+ 表单
+
+
+
+
+
+ )}
+ />
+ {bodyType === 'json' ? (
+
+ (
+
+ JSON 内容
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+
+ 敏感请求体
+
+
+ )}
+ />
+
+ ) : null}
+ {bodyType === 'form' ? (
+
+ ) : null}
+ >
+ ) : null}
+ >
+ ) : null}
+
+
+ (
+
+ 响应格式
+
+ {
+ const value = values.find((item) => item !== field.value)
+ if (value !== 'json' && value !== 'text') return
+ field.onChange(value)
+ }}
+ variant='outline'
+ spacing={2}
+ className='grid w-full grid-cols-2'
+ >
+
+ JSON
+
+
+ 文本
+
+
+
+
+
+ )}
+ />
+ (
+
+ JSON 取值路径
+
+
+
+
+
+ )}
+ />
+ (
+
+ 结果乘数
+
+
+ ×
+
+
+
+
+
+ )}
+ />
+
+
+ )
+}
+
+function CustomMetricFields(props: CustomMetricFieldsProps) {
+ const prefix = `customConfig.${props.metric}` as const
+ const source = useWatch({
+ control: props.form.control,
+ name: `${prefix}.source`,
+ })
+ const isRatio = props.metric === 'ratio'
+
+ return (
+
+
+ {isRatio ? '上游倍率来源' : '上游余额来源'}
+
+ (
+
+
+ {
+ const value = values.find((item) => item !== field.value)
+ if (value !== 'fixed' && value !== 'http') return
+ field.onChange(value)
+ if (value === 'fixed') {
+ props.form.setValue(
+ 'customConfig.balanceReuseRatioRequest',
+ false,
+ { shouldValidate: true }
+ )
+ }
+ }}
+ variant='outline'
+ spacing={2}
+ className='grid w-full grid-cols-2'
+ >
+
+ 固定输入
+
+
+ 接口查询
+
+
+
+
+
+ )}
+ />
+
+ {source === 'fixed' ? (
+ (
+
+ {isRatio ? '固定倍率' : '固定余额'}
+
+
+
+ 保存后立即写入渠道监控。
+
+
+ )}
+ />
+ ) : (
+
+ )}
+
+ )
+}
+
+export function ChannelMonitorCustomUpstreamFields(
+ props: ChannelMonitorCustomUpstreamFieldsProps
+) {
+ const ratioSource = useWatch({
+ control: props.form.control,
+ name: 'customConfig.ratio.source',
+ })
+ const balanceSource = useWatch({
+ control: props.form.control,
+ name: 'customConfig.balance.source',
+ })
+ const reuseRequest = useWatch({
+ control: props.form.control,
+ name: 'customConfig.balanceReuseRatioRequest',
+ })
+ const canReuseRequest = ratioSource === 'http' && balanceSource === 'http'
+
+ return (
+
+
+ {canReuseRequest ? (
+
(
+
+
+ 余额复用倍率接口
+
+ 只发送一次请求,余额使用独立的取值路径和结果乘数。
+
+
+
+
+
+
+ )}
+ />
+ ) : null}
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-fetch-status.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-fetch-status.tsx
new file mode 100644
index 000000000000..e8cc8749f982
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-fetch-status.tsx
@@ -0,0 +1,90 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { Alert02Icon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+
+import { Badge } from '@/components/ui/badge'
+import { formatTimestampToDate } from '@/lib/format'
+
+import type { ChannelMonitorItem } from '../types'
+
+type ChannelMonitorFetchStatusProps = {
+ channel: Pick<
+ ChannelMonitorItem,
+ | 'last_fetch_status'
+ | 'last_fetch_time'
+ | 'consecutive_failures'
+ | 'upstream'
+ >
+}
+
+export function ChannelMonitorFetchStatus(
+ props: ChannelMonitorFetchStatusProps
+) {
+ if (props.channel.upstream && !props.channel.upstream.ratio_sync_enabled) {
+ return 倍率同步已关闭
+ }
+
+ if (props.channel.last_fetch_status === 'failed') {
+ const failureCount = Math.max(1, props.channel.consecutive_failures)
+
+ return (
+
+
+
+
+ 更新失败
+
+
+ 连续失败 {failureCount} 次
+
+
+ {props.channel.last_fetch_time > 0 && (
+
+ 最后尝试:{formatTimestampToDate(props.channel.last_fetch_time)}
+
+ )}
+
+ )
+ }
+
+ if (props.channel.last_fetch_status === 'succeeded') {
+ return (
+
+
+ 更新成功
+
+ {props.channel.last_fetch_time > 0 && (
+
+ {formatTimestampToDate(props.channel.last_fetch_time)}
+
+ )}
+
+ )
+ }
+
+ if (props.channel.upstream) {
+ return 等待首次更新
+ }
+
+ return 未配置上游
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-group-view.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-group-view.tsx
new file mode 100644
index 000000000000..44f62dbf8c36
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-group-view.tsx
@@ -0,0 +1,371 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import {
+ Edit02Icon,
+ LinkSquare01Icon,
+ Refresh01Icon,
+ Settings02Icon,
+} from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useMemo } from 'react'
+
+import {
+ Alert,
+ AlertAction,
+ AlertDescription,
+ AlertTitle,
+} from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyTitle,
+} from '@/components/ui/empty'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
+import { CHANNEL_STATUS } from '@/features/channels/constants'
+import { cn } from '@/lib/utils'
+
+import { getChannelMonitorStatusLabel } from '../constants'
+import { formatMonitorRatio, getChannelGroupTargetRatio } from '../lib/format'
+import type {
+ ChannelMonitorGroupSuccessMetric,
+ GroupMonitorItem,
+} from '../types'
+import { ChannelMonitorStatusBadge } from './channel-monitor-status-badge'
+import { ChannelMonitorSuccessRateValue } from './channel-monitor-success-rate-value'
+
+type ChannelMonitorGroupViewProps = {
+ groups: GroupMonitorItem[]
+ successByGroup: Map
+ successMetricsAvailable: boolean
+ successLoading: boolean
+ successError: boolean
+ successRangeLabel: string
+ onOpenSuccessDetail: (
+ group: GroupMonitorItem,
+ mode: 'actual' | 'final'
+ ) => void
+ onOpenScheduleSettings: () => void
+ onEditChannels: (group: GroupMonitorItem) => void
+ onEditGroup: (group: GroupMonitorItem) => void
+ onSyncGroup: (group: GroupMonitorItem) => void
+}
+
+export function ChannelMonitorGroupView(props: ChannelMonitorGroupViewProps) {
+ const groupsWithSortedChannels = useMemo(
+ () =>
+ props.groups
+ .map((group) => ({
+ group,
+ channels: [...group.channels].sort((leftChannel, rightChannel) => {
+ const leftEnabled = leftChannel.status === CHANNEL_STATUS.ENABLED
+ const rightEnabled = rightChannel.status === CHANNEL_STATUS.ENABLED
+ if (leftEnabled !== rightEnabled) return leftEnabled ? -1 : 1
+
+ const leftRatio =
+ leftChannel.cost_ratio != null &&
+ Number.isFinite(leftChannel.cost_ratio)
+ ? leftChannel.cost_ratio
+ : null
+ const rightRatio =
+ rightChannel.cost_ratio != null &&
+ Number.isFinite(rightChannel.cost_ratio)
+ ? rightChannel.cost_ratio
+ : null
+
+ if (leftRatio != null && rightRatio != null) {
+ const ratioOrder = leftRatio - rightRatio
+ if (ratioOrder !== 0) return ratioOrder
+ } else if (leftRatio != null) {
+ return -1
+ } else if (rightRatio != null) {
+ return 1
+ }
+
+ const nameOrder = leftChannel.name.localeCompare(rightChannel.name)
+ return nameOrder !== 0
+ ? nameOrder
+ : leftChannel.id - rightChannel.id
+ }),
+ }))
+ .sort((leftGroup, rightGroup) => {
+ const ratioOrder = leftGroup.group.ratio - rightGroup.group.ratio
+ if (ratioOrder !== 0) return ratioOrder
+ return leftGroup.group.name.localeCompare(rightGroup.group.name)
+ }),
+ [props.groups]
+ )
+
+ if (props.groups.length === 0) {
+ return (
+
+
+ 没有匹配的分组
+ 换个关键词试试
+
+
+ )
+ }
+
+ return (
+
+
+ 智能调度设置
+
+ 所有分组统一使用智能调度中选择的调度方式和统计规则。
+
+
+
+
+ 统计设置
+
+
+
+
+
+
+
+
+ 分组
+ 分组倍率
+
+ 真实调用成功率({props.successRangeLabel})
+
+
+ 最终结果成功率({props.successRangeLabel})
+
+ 关联渠道与成本倍率
+ 操作
+
+
+
+ {groupsWithSortedChannels.map((groupEntry) => {
+ const group = groupEntry.group
+ const successMetric = props.successByGroup.get(group.name)
+ const enabledChannelCount = group.channels.filter(
+ (channel) => channel.status === CHANNEL_STATUS.ENABLED
+ ).length
+ let highestTargetRatio: number | null = null
+ for (const channel of group.channels) {
+ if (channel.status !== CHANNEL_STATUS.ENABLED) continue
+ const targetRatio = getChannelGroupTargetRatio(
+ channel.cost_ratio,
+ group.coefficient
+ )
+ if (
+ targetRatio != null &&
+ (highestTargetRatio == null ||
+ targetRatio > highestTargetRatio)
+ ) {
+ highestTargetRatio = targetRatio
+ }
+ }
+ let groupRatioClassName = 'text-foreground'
+ if (highestTargetRatio != null) {
+ if (Math.abs(group.ratio - highestTargetRatio) <= 1e-9) {
+ groupRatioClassName = 'text-amber-600 dark:text-amber-400'
+ } else if (group.ratio < highestTargetRatio) {
+ groupRatioClassName = 'text-destructive'
+ } else {
+ groupRatioClassName = 'text-emerald-600 dark:text-emerald-400'
+ }
+ }
+ return (
+
+
+
+ {group.name}
+
+ {group.channels.length} 个渠道 · {enabledChannelCount}{' '}
+ 个启用
+
+
+
+
+
+
+ {formatMonitorRatio(group.ratio)}
+
+
+ 系数 × {formatMonitorRatio(group.coefficient)}
+
+
+
+
+ props.onOpenSuccessDetail(group, 'actual')}
+ detailLabel={`查看 ${group.name} 分组的真实调用成功率明细`}
+ />
+
+
+ props.onOpenSuccessDetail(group, 'final')}
+ detailLabel={`查看 ${group.name} 分组的最终结果成功率明细`}
+ />
+
+
+ {group.channels.length === 0 ? (
+ -
+ ) : (
+
+ {groupEntry.channels.map((channel) => {
+ const channelEnabled =
+ channel.status === CHANNEL_STATUS.ENABLED
+ const ratio = formatMonitorRatio(channel.cost_ratio)
+ const upstreamRatio = formatMonitorRatio(
+ channel.ratio
+ )
+ const conversionFactor = formatMonitorRatio(
+ channel.conversion_factor
+ )
+
+ return (
+
+
+
+ {channel.name}
+
+ ×
+
+ {ratio}
+
+
+ {!channelEnabled && (
+
+ )}
+
+ )
+ })}
+
+ )}
+
+
+
+
+ props.onEditChannels(group)}
+ aria-label='管理关联渠道'
+ >
+
+
+ }
+ />
+ 管理关联渠道
+
+
+ props.onSyncGroup(group)}
+ aria-label='按最高成本倍率更新'
+ >
+
+
+ }
+ />
+ 按最高成本倍率更新
+
+
+ props.onEditGroup(group)}
+ aria-label='修改分组倍率'
+ >
+
+
+ }
+ />
+ 修改分组倍率
+
+
+
+
+ )
+ })}
+
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-model-performance-view.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-model-performance-view.tsx
new file mode 100644
index 000000000000..47e390d0d9a3
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-model-performance-view.tsx
@@ -0,0 +1,327 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { ArrowLeft01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useMemo, useState } from 'react'
+
+import { Button } from '@/components/ui/button'
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyTitle,
+} from '@/components/ui/empty'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { CHANNEL_STATUS } from '@/features/channels/constants'
+import { formatTimestampToDate } from '@/lib/format'
+import { cn } from '@/lib/utils'
+
+import { getChannelMonitorStatusLabel } from '../constants'
+import { formatMonitorRatio } from '../lib/format'
+import type {
+ ChannelMonitorItem,
+ ChannelMonitorPerformanceMetric,
+ ChannelMonitorSuccessMetric,
+} from '../types'
+import {
+ ChannelMonitorFirstTokenValue,
+ ChannelMonitorTPSValue,
+} from './channel-monitor-performance-value'
+import { ChannelMonitorStatusBadge } from './channel-monitor-status-badge'
+import { ChannelMonitorSuccessRateValue } from './channel-monitor-success-rate-value'
+
+const PERFORMANCE_PAGE_SIZE = 20
+
+type ChannelMonitorModelPerformanceViewProps = {
+ channels: ChannelMonitorItem[]
+ metrics: ChannelMonitorPerformanceMetric[]
+ successMetrics: ChannelMonitorSuccessMetric[]
+ successMetricsAvailable: boolean
+ selectedModel: string
+ search: string
+ isLoading: boolean
+ isError: boolean
+ onOpenSuccessDetail: (channel: ChannelMonitorItem, modelName: string) => void
+}
+
+export function ChannelMonitorModelPerformanceView(
+ props: ChannelMonitorModelPerformanceViewProps
+) {
+ const [page, setPage] = useState(1)
+ const rows = useMemo(() => {
+ const metricByChannel = new Map(
+ props.metrics
+ .filter((metric) => metric.model_name === props.selectedModel)
+ .map((metric) => [metric.channel_id, metric])
+ )
+ const successMetricByChannel = new Map(
+ props.successMetrics
+ .filter((metric) => metric.model_name === props.selectedModel)
+ .map((metric) => [metric.channel_id, metric])
+ )
+ const normalizedSearch = props.search.trim().toLocaleLowerCase()
+ return props.channels
+ .filter((channel) => {
+ if (!normalizedSearch) return true
+ return (
+ channel.name.toLocaleLowerCase().includes(normalizedSearch) ||
+ String(channel.id).includes(normalizedSearch)
+ )
+ })
+ .sort((first, second) => {
+ const firstEnabled = first.status === CHANNEL_STATUS.ENABLED
+ const secondEnabled = second.status === CHANNEL_STATUS.ENABLED
+ if (firstEnabled !== secondEnabled) return firstEnabled ? -1 : 1
+
+ const firstRatio =
+ first.cost_ratio != null && Number.isFinite(first.cost_ratio)
+ ? first.cost_ratio
+ : null
+ const secondRatio =
+ second.cost_ratio != null && Number.isFinite(second.cost_ratio)
+ ? second.cost_ratio
+ : null
+ if (firstRatio != null && secondRatio != null) {
+ const ratioOrder = firstRatio - secondRatio
+ if (ratioOrder !== 0) return ratioOrder
+ } else if (firstRatio != null) {
+ return -1
+ } else if (secondRatio != null) {
+ return 1
+ }
+
+ const nameOrder = first.name.localeCompare(second.name)
+ return nameOrder !== 0 ? nameOrder : first.id - second.id
+ })
+ .map((channel) => ({
+ channel,
+ metric: metricByChannel.get(channel.id) ?? null,
+ successMetric: successMetricByChannel.get(channel.id) ?? null,
+ }))
+ }, [
+ props.channels,
+ props.metrics,
+ props.search,
+ props.selectedModel,
+ props.successMetrics,
+ ])
+
+ if (props.isLoading) {
+ return
+ }
+ if (props.isError) {
+ return (
+
+
+ 模型性能加载失败
+ 请刷新后重试
+
+
+ )
+ }
+ if (!props.selectedModel) {
+ return (
+
+
+ 暂无模型性能记录
+
+ 当前时间范围内没有可用的流式请求样本
+
+
+
+ )
+ }
+ if (rows.length === 0) {
+ return (
+
+
+ 没有匹配的渠道
+ 当前搜索条件下没有可展示的渠道
+
+
+ )
+ }
+
+ const totalPages = Math.max(1, Math.ceil(rows.length / PERFORMANCE_PAGE_SIZE))
+ const currentPage = Math.min(page, totalPages)
+ const visibleRows = rows.slice(
+ (currentPage - 1) * PERFORMANCE_PAGE_SIZE,
+ currentPage * PERFORMANCE_PAGE_SIZE
+ )
+
+ return (
+
+
+
+
+
+ 排名
+ 渠道
+ 成本倍率
+ 平均首字
+ 平均 TPS
+
+ 成功率
+
+ 有效样本
+ 最后请求
+
+
+
+ {visibleRows.map((row, rowIndex) => {
+ const channelEnabled =
+ row.channel.status === CHANNEL_STATUS.ENABLED
+ const channelStatusLabel = `渠道状态:${getChannelMonitorStatusLabel(row.channel.status)}`
+ return (
+
+
+ {row.metric || row.successMetric
+ ? (currentPage - 1) * PERFORMANCE_PAGE_SIZE + rowIndex + 1
+ : '-'}
+
+
+
+
+
+
+ {row.channel.name}
+
+ {!channelEnabled && (
+
+ )}
+
+ ID {row.channel.id}
+
+
+
+
+
+
+ {formatMonitorRatio(row.channel.cost_ratio)}
+
+
+
+
+
+
+
+
+
+
+ props.onOpenSuccessDetail(
+ row.channel,
+ props.selectedModel
+ )
+ }
+ detailLabel={`查看 ${row.channel.name} 的 ${props.selectedModel} 成功率明细`}
+ />
+
+
+
+ {row.metric ? (
+ <>
+
{row.metric.sample_count} 次请求
+
+ 首字
+
+
+
+ TPS
+
+
+ >
+ ) : (
+
暂无样本
+ )}
+
+
+
+ {row.metric
+ ? formatTimestampToDate(row.metric.last_used_time)
+ : '-'}
+
+
+ )
+ })}
+
+
+
+ {totalPages > 1 && (
+
+ setPage(Math.max(1, currentPage - 1))}
+ disabled={currentPage <= 1}
+ >
+
+
+
+ 第 {currentPage} / {totalPages} 页
+
+ setPage(Math.min(totalPages, currentPage + 1))}
+ disabled={currentPage >= totalPages}
+ >
+
+
+
+ )}
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-order-dialog.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-order-dialog.tsx
new file mode 100644
index 000000000000..a1f388a7a967
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-order-dialog.tsx
@@ -0,0 +1,279 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import {
+ ArrowDown01Icon,
+ ArrowUp01Icon,
+ DragDropVerticalIcon,
+ Refresh01Icon,
+} from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useState, type DragEvent } from 'react'
+import { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Spinner } from '@/components/ui/spinner'
+import { cn } from '@/lib/utils'
+
+import { updateChannelMonitorChannelOrder } from '../api'
+import { handleChannelMonitorMutationError } from '../lib/error'
+import { orderChannelsByCustomOrder } from '../lib/sort'
+import type { ChannelMonitorItem } from '../types'
+
+type ChannelMonitorOrderDialogProps = {
+ channels: ChannelMonitorItem[]
+ channelOrder: number[]
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+type DropPosition = 'before' | 'after'
+
+function reorderChannels(
+ channels: ChannelMonitorItem[],
+ sourceId: number,
+ targetId: number,
+ position: DropPosition
+) {
+ if (sourceId === targetId) return channels
+ const sourceChannel = channels.find((channel) => channel.id === sourceId)
+ if (!sourceChannel) return channels
+
+ const reorderedChannels = channels.filter(
+ (channel) => channel.id !== sourceId
+ )
+ let targetIndex = reorderedChannels.findIndex(
+ (channel) => channel.id === targetId
+ )
+ if (targetIndex < 0) return channels
+ if (position === 'after') targetIndex += 1
+ reorderedChannels.splice(targetIndex, 0, sourceChannel)
+ return reorderedChannels
+}
+
+export function ChannelMonitorOrderDialog(
+ props: ChannelMonitorOrderDialogProps
+) {
+ const queryClient = useQueryClient()
+ const [orderedChannels, setOrderedChannels] = useState(() =>
+ orderChannelsByCustomOrder(props.channels, props.channelOrder)
+ )
+ const [draggedChannelId, setDraggedChannelId] = useState(null)
+ const [dragOverChannelId, setDragOverChannelId] = useState(
+ null
+ )
+ const [dropPosition, setDropPosition] = useState('before')
+ const mutation = useMutation({
+ mutationFn: updateChannelMonitorChannelOrder,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: () => {
+ toast.success('渠道自定义顺序已保存')
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ props.onOpenChange(false)
+ },
+ })
+
+ const resetDragState = () => {
+ setDraggedChannelId(null)
+ setDragOverChannelId(null)
+ setDropPosition('before')
+ }
+
+ const handleDragStart = (
+ event: DragEvent,
+ channelId: number
+ ) => {
+ setDraggedChannelId(channelId)
+ event.dataTransfer.effectAllowed = 'move'
+ event.dataTransfer.setData('text/plain', String(channelId))
+ }
+
+ const handleDragOver = (
+ event: DragEvent,
+ channelId: number
+ ) => {
+ event.preventDefault()
+ if (draggedChannelId == null || draggedChannelId === channelId) return
+ const rect = event.currentTarget.getBoundingClientRect()
+ setDragOverChannelId(channelId)
+ setDropPosition(
+ event.clientY - rect.top > rect.height / 2 ? 'after' : 'before'
+ )
+ event.dataTransfer.dropEffect = 'move'
+ }
+
+ const handleDrop = (event: DragEvent, channelId: number) => {
+ event.preventDefault()
+ const sourceId = Number(
+ draggedChannelId ?? event.dataTransfer.getData('text/plain')
+ )
+ if (Number.isInteger(sourceId) && sourceId > 0) {
+ setOrderedChannels((channels) =>
+ reorderChannels(channels, sourceId, channelId, dropPosition)
+ )
+ }
+ resetDragState()
+ }
+
+ const moveChannel = (channelId: number, offset: -1 | 1) => {
+ setOrderedChannels((channels) => {
+ const sourceIndex = channels.findIndex(
+ (channel) => channel.id === channelId
+ )
+ const targetIndex = sourceIndex + offset
+ if (
+ sourceIndex < 0 ||
+ targetIndex < 0 ||
+ targetIndex >= channels.length
+ ) {
+ return channels
+ }
+ const reorderedChannels = [...channels]
+ const targetChannel = reorderedChannels[targetIndex]
+ reorderedChannels[targetIndex] = reorderedChannels[sourceIndex]
+ reorderedChannels[sourceIndex] = targetChannel
+ return reorderedChannels
+ })
+ }
+
+ return (
+
+
+
+ 自定义渠道顺序
+
+ 拖动渠道或使用上下按钮调整顺序。该顺序仅影响渠道监控页面,不会修改渠道优先级和路由策略。
+
+
+
+
+ {orderedChannels.map((channel, index) => {
+ const isDragging = channel.id === draggedChannelId
+ const isDropTarget =
+ channel.id === dragOverChannelId &&
+ draggedChannelId != null &&
+ draggedChannelId !== channel.id
+ return (
+
handleDragOver(event, channel.id)}
+ onDrop={(event) => handleDrop(event, channel.id)}
+ className={cn(
+ 'bg-card flex items-center gap-3 rounded-lg border p-2.5 transition-colors',
+ isDragging && 'opacity-50',
+ isDropTarget &&
+ dropPosition === 'before' &&
+ 'border-t-primary border-t-2',
+ isDropTarget &&
+ dropPosition === 'after' &&
+ 'border-b-primary border-b-2'
+ )}
+ >
+
1}
+ onDragStart={(event) => handleDragStart(event, channel.id)}
+ onDragEnd={resetDragState}
+ className='text-muted-foreground hover:text-foreground flex size-8 shrink-0 cursor-grab items-center justify-center rounded-md active:cursor-grabbing'
+ aria-label={`拖动渠道 ${channel.name}`}
+ >
+
+
+
+ {index + 1}
+
+
+
+ {channel.name}
+
+
+ ID {channel.id}
+
+
+
+ moveChannel(channel.id, -1)}
+ disabled={index === 0 || mutation.isPending}
+ aria-label={`上移渠道 ${channel.name}`}
+ >
+
+
+ moveChannel(channel.id, 1)}
+ disabled={
+ index === orderedChannels.length - 1 || mutation.isPending
+ }
+ aria-label={`下移渠道 ${channel.name}`}
+ >
+
+
+
+
+ )
+ })}
+
+
+
+
setOrderedChannels([...props.channels])}
+ disabled={mutation.isPending}
+ >
+
+ 恢复默认顺序
+
+
+ props.onOpenChange(false)}
+ disabled={mutation.isPending}
+ >
+ 取消
+
+
+ mutation.mutate(orderedChannels.map((channel) => channel.id))
+ }
+ disabled={mutation.isPending}
+ >
+ {mutation.isPending && }
+ 保存顺序
+
+
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-performance-value.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-performance-value.tsx
new file mode 100644
index 000000000000..181aefbe2a03
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-performance-value.tsx
@@ -0,0 +1,70 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { textColorMap } from '@/components/status-badge'
+import {
+ getFirstResponseTimeColor,
+ getThroughputColor,
+} from '@/features/usage-logs/lib/format'
+import { formatUseTime } from '@/lib/format'
+import { cn } from '@/lib/utils'
+
+type ChannelMonitorPerformanceValueProps = {
+ value: number | null
+ className?: string
+}
+
+export function ChannelMonitorFirstTokenValue(
+ props: ChannelMonitorPerformanceValueProps
+) {
+ if (props.value == null || !Number.isFinite(props.value)) {
+ return -
+ }
+ const variant = getFirstResponseTimeColor(props.value / 1000)
+ return (
+
+ {formatUseTime(props.value / 1000)}
+
+ )
+}
+
+export function ChannelMonitorTPSValue(
+ props: ChannelMonitorPerformanceValueProps
+) {
+ if (props.value == null || !Number.isFinite(props.value)) {
+ return -
+ }
+ const variant = getThroughputColor(props.value)
+ return (
+
+ {Math.round(props.value)} t/s
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-settings-dialog.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-settings-dialog.tsx
new file mode 100644
index 000000000000..a6e12c6409e7
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-settings-dialog.tsx
@@ -0,0 +1,349 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useForm, useWatch, type Resolver } from 'react-hook-form'
+import { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from '@/components/ui/input-group'
+import { Spinner } from '@/components/ui/spinner'
+import { Switch } from '@/components/ui/switch'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+
+import { updateChannelMonitorSettings } from '../api'
+import { handleChannelMonitorMutationError } from '../lib/error'
+import {
+ createChannelMonitorSettingsSchema,
+ MAX_AUTO_UPDATE_INTERVAL_MINUTES,
+ MAX_AUTO_UPDATE_RETRY_COUNT,
+ type ChannelMonitorSettingsFormValues,
+} from '../lib/schema'
+import type { ChannelMonitorSettings } from '../types'
+import { ChannelMonitorSmartScheduleFields } from './channel-monitor-smart-schedule-fields'
+
+export type ChannelMonitorSettingsSection = 'monitor' | 'schedule'
+
+type ChannelMonitorSettingsDialogProps = {
+ settings: ChannelMonitorSettings
+ modelOptions: string[]
+ initialSection: ChannelMonitorSettingsSection
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function ChannelMonitorSettingsDialog(
+ props: ChannelMonitorSettingsDialogProps
+) {
+ const queryClient = useQueryClient()
+ let smartScheduleModels = props.settings.smart_schedule_models ?? []
+ if (smartScheduleModels.length === 0 && props.settings.smart_schedule_model) {
+ smartScheduleModels = [props.settings.smart_schedule_model]
+ }
+ const form = useForm({
+ resolver: zodResolver(
+ createChannelMonitorSettingsSchema()
+ ) as Resolver,
+ defaultValues: {
+ autoUpdateIntervalMinutes: props.settings.auto_update_interval_minutes,
+ autoUpdateRetryCount: props.settings.auto_update_retry_count,
+ autoDisableOnUpdateFailure:
+ props.settings.auto_disable_on_update_failure ?? false,
+ emailNotificationEnabled: props.settings.email_notification_enabled,
+ notificationEmail: props.settings.notification_email,
+ smartScheduleEnabled: props.settings.smart_schedule_enabled,
+ smartScheduleIntervalMinutes:
+ props.settings.smart_schedule_interval_minutes,
+ smartScheduleStrategy: props.settings.smart_schedule_strategy,
+ smartScheduleStabilityEnabled:
+ props.settings.smart_schedule_stability_enabled ?? false,
+ smartScheduleApplyMode: props.settings.smart_schedule_apply_mode,
+ smartSchedulePerformanceMinutes:
+ props.settings.smart_schedule_performance_minutes,
+ smartScheduleModels,
+ smartScheduleMinSamples: props.settings.smart_schedule_min_samples,
+ smartScheduleForceReset: false,
+ },
+ })
+ const emailNotificationEnabled = useWatch({
+ control: form.control,
+ name: 'emailNotificationEnabled',
+ })
+ const mutation = useMutation({
+ mutationFn: updateChannelMonitorSettings,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: (response) => {
+ if (response.data.smart_schedule_force_reset_task_error) {
+ toast.error(
+ `设置已保存,但无法创建重算任务:${response.data.smart_schedule_force_reset_task_error}`
+ )
+ } else if (
+ response.data.smart_schedule_force_reset_task_created === true
+ ) {
+ toast.success('设置已保存,强制重算任务已创建')
+ } else if (
+ response.data.smart_schedule_force_reset_task_created === false
+ ) {
+ toast.warning(
+ '设置已保存,但已有智能调度任务正在运行,本次强制重算未排队'
+ )
+ } else {
+ toast.success('渠道监控设置已保存')
+ }
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ props.onOpenChange(false)
+ },
+ })
+ const handleSubmit = form.handleSubmit((values) => {
+ mutation.mutate({
+ auto_update_interval_minutes: values.autoUpdateIntervalMinutes,
+ auto_update_retry_count: values.autoUpdateRetryCount,
+ auto_disable_on_update_failure: values.autoDisableOnUpdateFailure,
+ email_notification_enabled: values.emailNotificationEnabled,
+ notification_email: values.notificationEmail,
+ smart_schedule_enabled: values.smartScheduleEnabled,
+ smart_schedule_interval_minutes: values.smartScheduleIntervalMinutes,
+ smart_schedule_strategy: values.smartScheduleStrategy,
+ smart_schedule_stability_enabled: values.smartScheduleStabilityEnabled,
+ smart_schedule_apply_mode: values.smartScheduleApplyMode,
+ smart_schedule_performance_minutes:
+ values.smartSchedulePerformanceMinutes,
+ smart_schedule_model: values.smartScheduleModels[0] ?? '',
+ smart_schedule_models: values.smartScheduleModels,
+ smart_schedule_min_samples: values.smartScheduleMinSamples,
+ smart_schedule_force_reset: values.smartScheduleForceReset,
+ })
+ })
+
+ return (
+
+
+
+ 渠道监控设置
+
+ 设置上游倍率更新、通知和智能调度规则
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-smart-schedule-cell.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-smart-schedule-cell.tsx
new file mode 100644
index 000000000000..24011cde0eff
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-smart-schedule-cell.tsx
@@ -0,0 +1,152 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { useState } from 'react'
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Badge } from '@/components/ui/badge'
+import { Spinner } from '@/components/ui/spinner'
+import { Switch } from '@/components/ui/switch'
+import { formatTimestampToDate } from '@/lib/format'
+
+import type { ChannelMonitorItem } from '../types'
+
+type ChannelMonitorSmartScheduleCellProps = {
+ channel: ChannelMonitorItem
+ pending: boolean
+ onUpdate: (excluded: boolean) => void
+}
+
+export function ChannelMonitorSmartScheduleCell(
+ props: ChannelMonitorSmartScheduleCellProps
+) {
+ const [resetConfirmationOpen, setResetConfirmationOpen] = useState(false)
+ const participating = !props.channel.smart_schedule_excluded
+
+ let statusContent = (
+ 等待首次调度
+ )
+ if (props.channel.last_schedule_status === 'succeeded') {
+ statusContent = (
+
+ 已调度
+ {props.channel.last_schedule_score != null && (
+
+ 得分 {(props.channel.last_schedule_score * 100).toFixed(1)}
+
+ )}
+
+ {formatTimestampToDate(props.channel.last_schedule_time)}
+
+
+ )
+ } else if (props.channel.last_schedule_status === 'skipped') {
+ statusContent = (
+
+ 已跳过
+
+ {props.channel.last_schedule_error || '暂不满足调度条件'}
+
+
+ )
+ } else if (props.channel.last_schedule_status === 'failed') {
+ statusContent = (
+
+ 失败
+
+ {props.channel.last_schedule_error || '更新优先级或权重失败'}
+
+
+ )
+ }
+
+ return (
+
+
+
+ 优先级 {props.channel.priority}
+
+
+ 权重 {props.channel.weight}
+
+ {props.pending && }
+
+
+
+
+ {
+ if (checked) {
+ setResetConfirmationOpen(true)
+ } else {
+ props.onUpdate(true)
+ }
+ }}
+ aria-label={`${participating ? '停止' : '启用'} ${props.channel.name} 的智能调度`}
+ />
+ 参与调度
+
+
+
+ {statusContent}
+
+
+
+
+ 确认参与调度?
+
+ 启用“{props.channel.name}”参与智能调度将把优先级重置为
+ 0、权重重置为 10。
+
+
+
+ 取消
+ {
+ props.onUpdate(false)
+ setResetConfirmationOpen(false)
+ }}
+ >
+ 确认
+
+
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-smart-schedule-fields.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-smart-schedule-fields.tsx
new file mode 100644
index 000000000000..f5ed21b8afb5
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-smart-schedule-fields.tsx
@@ -0,0 +1,507 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import {
+ ArrowDown01Icon,
+ ArrowUp01Icon,
+ Delete02Icon,
+} from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useMemo } from 'react'
+import type { UseFormReturn } from 'react-hook-form'
+
+import { MultiSelect } from '@/components/multi-select'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from '@/components/ui/input-group'
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Switch } from '@/components/ui/switch'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
+
+import {
+ MAX_AUTO_UPDATE_INTERVAL_MINUTES,
+ MAX_SMART_SCHEDULE_MIN_SAMPLES,
+ type ChannelMonitorSettingsFormValues,
+} from '../lib/schema'
+
+const SCHEDULE_STRATEGY_OPTIONS = [
+ {
+ value: 'smart',
+ label: '智能调度',
+ description: '综合成本倍率、首字和 TPS',
+ },
+ {
+ value: 'ratio',
+ label: '按成本倍率',
+ description: '倍率越低,调度得分越高',
+ },
+ {
+ value: 'first_token',
+ label: '按首字',
+ description: '平均首字时间越低,调度得分越高',
+ },
+ {
+ value: 'tps',
+ label: '按 TPS',
+ description: '平均 TPS 越高,调度得分越高',
+ },
+] as const
+
+const APPLY_MODE_OPTIONS = [
+ {
+ value: 'weight',
+ label: '只调整权重',
+ description: '保留现有优先级,只在同优先级内调整流量',
+ },
+ {
+ value: 'priority_weight',
+ label: '优先级分层 + 权重',
+ description: '按得分分为 100、90、80 三档,再调整权重',
+ },
+] as const
+
+const PERFORMANCE_RANGE_OPTIONS = [
+ { value: '15', label: '近 15 分钟' },
+ { value: '60', label: '近 1 小时' },
+ { value: '360', label: '近 6 小时' },
+ { value: '1440', label: '近 24 小时' },
+]
+
+type ChannelMonitorSmartScheduleFieldsProps = {
+ form: UseFormReturn
+ modelOptions: string[]
+}
+
+function reorderSmartScheduleModels(
+ models: string[],
+ sourceIndex: number,
+ offset: -1 | 1
+) {
+ const targetIndex = sourceIndex + offset
+ if (targetIndex < 0 || targetIndex >= models.length) return models
+ const nextModels = [...models]
+ const [modelName] = nextModels.splice(sourceIndex, 1)
+ if (modelName === undefined) return models
+ nextModels.splice(targetIndex, 0, modelName)
+ return nextModels
+}
+
+export function ChannelMonitorSmartScheduleFields(
+ props: ChannelMonitorSmartScheduleFieldsProps
+) {
+ const modelOptions = useMemo(
+ () => props.modelOptions.map((model) => ({ value: model, label: model })),
+ [props.modelOptions]
+ )
+
+ return (
+
+
(
+
+
+ 智能调度
+
+ 定时按照统一调度方式调整参与渠道的优先级、权重
+
+
+
+
+
+
+ )}
+ />
+
+ (
+
+ 调度方式
+ value !== null && field.onChange(value)}
+ >
+
+
+
+
+
+
+
+ {SCHEDULE_STRATEGY_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ {
+ SCHEDULE_STRATEGY_OPTIONS.find(
+ (option) => option.value === field.value
+ )?.description
+ }
+
+
+
+ )}
+ />
+
+ (
+
+
+ 按稳定性
+ 成功率越高,调度得分越高
+
+
+
+
+
+ )}
+ />
+
+ (
+
+ 调度间隔
+
+
+
+ 分钟
+
+
+
+
+ )}
+ />
+
+ (
+
+ 调整方式
+ value !== null && field.onChange(value)}
+ >
+
+
+
+
+
+
+
+ {APPLY_MODE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ {
+ APPLY_MODE_OPTIONS.find(
+ (option) => option.value === field.value
+ )?.description
+ }
+
+
+
+ )}
+ />
+
+ (
+
+
+ field.onChange(checked === true)}
+ aria-label='强制重置优先级和权重'
+ />
+
+
+
+ 强制重置优先级和权重
+
+
+ 保存后,根据当前日志重新计算所有符合条件的参与渠道,并立即应用优先级和权重。此操作仅执行一次。
+
+
+
+ )}
+ />
+
+
+ (
+
+ 统计范围
+ {
+ if (value !== null) field.onChange(Number(value))
+ }}
+ >
+
+
+
+
+
+
+
+ {PERFORMANCE_RANGE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+ )}
+ />
+
+ (
+
+ 最少样本
+
+
+
+ 次
+
+
+
+
+ )}
+ />
+
+
+ (
+
+ 基准模型优先级
+
+
+
+
+ 每个渠道按下列顺序使用其支持的第一个模型;未选择时汇总全部模型
+
+ {field.value.length > 0 && (
+
+ {field.value.map((modelName, index) => (
+
+
+ {index + 1}
+
+
+ {modelName}
+
+
+
+
+ field.onChange(
+ reorderSmartScheduleModels(
+ field.value,
+ index,
+ -1
+ )
+ )
+ }
+ aria-label={`上移模型 ${modelName}`}
+ >
+
+
+ }
+ />
+ 上移
+
+
+
+ field.onChange(
+ reorderSmartScheduleModels(
+ field.value,
+ index,
+ 1
+ )
+ )
+ }
+ aria-label={`下移模型 ${modelName}`}
+ >
+
+
+ }
+ />
+ 下移
+
+
+
+ field.onChange(
+ field.value.filter(
+ (_, modelIndex) => modelIndex !== index
+ )
+ )
+ }
+ aria-label={`移除模型 ${modelName}`}
+ >
+
+
+ }
+ />
+ 移除
+
+
+
+ ))}
+
+ )}
+
+
+ )}
+ />
+
+
+ 调度规则
+
+ 启用的调度指标等权计算;开启按稳定性后,还要求稳定性达到最少样本。关闭总开关后保留当前优先级和权重。稳定性按成功调用数
+ ÷(成功调用数 +
+ 渠道错误数)计算,重试中的渠道错误也会计入;需要同时开启消费日志和
+ ERROR_LOG_ENABLED。
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-status-badge.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-status-badge.tsx
new file mode 100644
index 000000000000..c84a1ed8ce97
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-status-badge.tsx
@@ -0,0 +1,62 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { Badge } from '@/components/ui/badge'
+import { CHANNEL_STATUS } from '@/features/channels/constants'
+import { cn } from '@/lib/utils'
+
+import { getChannelMonitorStatusLabel } from '../constants'
+
+type ChannelMonitorStatusBadgeProps = {
+ status: number
+ className?: string
+}
+
+export function ChannelMonitorStatusBadge(
+ props: ChannelMonitorStatusBadgeProps
+) {
+ const label = getChannelMonitorStatusLabel(props.status)
+
+ if (props.status === CHANNEL_STATUS.MANUAL_DISABLED) {
+ return (
+
+ {label}
+
+ )
+ }
+
+ if (props.status === CHANNEL_STATUS.AUTO_DISABLED) {
+ return (
+
+ {label}
+
+ )
+ }
+
+ return (
+
+ {label}
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-success-detail-dialog.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-success-detail-dialog.tsx
new file mode 100644
index 000000000000..ff7e82acb6f0
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-success-detail-dialog.tsx
@@ -0,0 +1,509 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import {
+ Alert02Icon,
+ Analytics01Icon,
+ Refresh01Icon,
+} from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useQuery } from '@tanstack/react-query'
+import { useMemo, type ReactNode } from 'react'
+
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from '@/components/ui/empty'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { CHANNEL_STATUS } from '@/features/channels/constants'
+import { formatTimestampToDate } from '@/lib/format'
+import { cn } from '@/lib/utils'
+
+import { getChannelMonitorSuccessDetail } from '../api'
+import type {
+ ChannelMonitorFailureCategory,
+ ChannelMonitorItem,
+ ChannelMonitorPerformanceRangeMinutes,
+ ChannelMonitorSuccessDetailTarget,
+ ChannelMonitorSuccessMode,
+ ChannelMonitorSuccessSummary,
+} from '../types'
+
+type ChannelMonitorSuccessDetailDialogProps = {
+ target: ChannelMonitorSuccessDetailTarget
+ channels: ChannelMonitorItem[]
+ rangeMinutes: ChannelMonitorPerformanceRangeMinutes
+ rangeLabel: string
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+type SuccessModeSummary = {
+ successCount: number
+ failureCount: number
+ sampleCount: number
+ successRate: number
+}
+
+const percentFormatter = new Intl.NumberFormat(undefined, {
+ style: 'percent',
+ maximumFractionDigits: 2,
+})
+
+function getModeSummary(
+ summary: ChannelMonitorSuccessSummary,
+ mode: ChannelMonitorSuccessMode
+): SuccessModeSummary {
+ if (mode === 'final') {
+ return {
+ successCount: summary.final_success_count,
+ failureCount: summary.final_failure_count,
+ sampleCount: summary.final_sample_count,
+ successRate: summary.final_success_rate,
+ }
+ }
+ return {
+ successCount: summary.actual_success_count,
+ failureCount: summary.actual_failure_count,
+ sampleCount: summary.actual_sample_count,
+ successRate: summary.actual_success_rate,
+ }
+}
+
+function getRateClassName(rate: number) {
+ if (rate >= 0.9) return 'text-success'
+ if (rate >= 0.7) return 'text-warning'
+ return 'text-destructive'
+}
+
+function SummaryValue(props: {
+ label: string
+ value: ReactNode
+ valueClassName?: string
+}) {
+ return (
+
+ {props.label}
+
+ {props.value}
+
+
+ )
+}
+
+function FailureCategoryBadges(props: {
+ category: ChannelMonitorFailureCategory
+}) {
+ const hasIdentity =
+ props.category.status_code > 0 ||
+ props.category.error_type !== '' ||
+ props.category.error_code !== ''
+ if (!hasIdentity) {
+ return 其他错误
+ }
+ return (
+
+ {props.category.status_code > 0 ? (
+ HTTP {props.category.status_code}
+ ) : null}
+ {props.category.error_code ? (
+
+ {props.category.error_code}
+
+ ) : null}
+ {props.category.error_type ? (
+
+ {props.category.error_type}
+
+ ) : null}
+
+ )
+}
+
+export function ChannelMonitorSuccessDetailDialog(
+ props: ChannelMonitorSuccessDetailDialogProps
+) {
+ const query = useQuery({
+ queryKey: [
+ 'channel-monitor-success-detail',
+ props.rangeMinutes,
+ props.target.scope,
+ props.target.scope === 'channel'
+ ? props.target.channelId
+ : props.target.groupName,
+ props.target.scope === 'channel' ? props.target.modelName : undefined,
+ ],
+ queryFn: () => {
+ if (props.target.scope === 'channel') {
+ return getChannelMonitorSuccessDetail({
+ minutes: props.rangeMinutes,
+ channelId: props.target.channelId,
+ modelName: props.target.modelName,
+ })
+ }
+ return getChannelMonitorSuccessDetail({
+ minutes: props.rangeMinutes,
+ groupName: props.target.groupName,
+ })
+ },
+ })
+ const detail = query.data?.data.detail
+ const modeSummary = detail
+ ? getModeSummary(detail.summary, props.target.mode)
+ : null
+ const groupChannelRows = useMemo(() => {
+ if (props.target.scope !== 'group') return []
+ const groupName = props.target.groupName
+ const metricByChannel = new Map(
+ (detail?.channel_items ?? []).map((metric) => [metric.channel_id, metric])
+ )
+ const channelById = new Map(
+ props.channels.map((channel) => [channel.id, channel])
+ )
+ const channelIds = new Set(
+ props.channels
+ .filter((channel) => channel.groups.includes(groupName))
+ .map((channel) => channel.id)
+ )
+ for (const metric of detail?.channel_items ?? []) {
+ channelIds.add(metric.channel_id)
+ }
+ return [...channelIds]
+ .map((channelId) => ({
+ channelId,
+ channel: channelById.get(channelId) ?? null,
+ metric: metricByChannel.get(channelId) ?? null,
+ }))
+ .sort((first, second) => {
+ const firstEnabled = first.channel?.status === CHANNEL_STATUS.ENABLED
+ const secondEnabled = second.channel?.status === CHANNEL_STATUS.ENABLED
+ if (firstEnabled !== secondEnabled) return firstEnabled ? -1 : 1
+ const firstName = first.channel?.name ?? `渠道 ${first.channelId}`
+ const secondName = second.channel?.name ?? `渠道 ${second.channelId}`
+ const nameOrder = firstName.localeCompare(secondName)
+ return nameOrder !== 0 ? nameOrder : first.channelId - second.channelId
+ })
+ }, [detail?.channel_items, props.channels, props.target])
+ const failureCategories = useMemo(() => {
+ if (!detail || props.target.scope !== 'channel') return []
+ return (detail.failure_categories ?? []).filter((category) => {
+ if (props.target.mode === 'final') return category.final_count > 0
+ return category.actual_count > 0
+ })
+ }, [detail, props.target])
+
+ const modeLabel =
+ props.target.mode === 'actual' ? '真实调用口径' : '最终结果口径'
+ let title = ''
+ let description = `${props.rangeLabel} · ${modeLabel}`
+ if (props.target.scope === 'channel') {
+ title = `${props.target.channelName} 成功率明细`
+ if (props.target.modelName) {
+ description += ` · 模型 ${props.target.modelName}`
+ }
+ } else {
+ title = `${props.target.groupName} 分组成功率明细`
+ }
+
+ let content: ReactNode
+ if (query.isLoading) {
+ content = (
+
+
+
+
+ )
+ } else if (query.isError) {
+ content = (
+
+
+
+
+
+ 成功率明细加载失败
+ 网络或服务暂时不可用
+
+
+ query.refetch()}
+ disabled={query.isFetching}
+ >
+
+ 重新加载
+
+
+
+ )
+ } else if (!query.data?.data.success_metrics_available) {
+ content = (
+
+
+
+
+
+ 成功率统计不可用
+ 需要同时开启消费日志和错误日志
+
+
+ )
+ } else if (!detail || !modeSummary || modeSummary.sampleCount <= 0) {
+ content = (
+
+
+
+
+
+ 暂无成功率样本
+ 当前时间范围内没有可统计的请求
+
+
+ )
+ } else {
+ content = (
+
+
+
+ 0 ? 'text-destructive' : undefined
+ }
+ />
+
+
+
+ {props.target.scope === 'group' ? (
+
+
渠道明细
+
+
+
+
+ 渠道
+ 成功
+ 失败
+ 样本
+ 成功率
+
+
+
+ {groupChannelRows.map((row) => {
+ const rowSummary = row.metric
+ ? getModeSummary(row.metric, props.target.mode)
+ : null
+ const channelEnabled =
+ row.channel?.status === CHANNEL_STATUS.ENABLED
+ return (
+
+
+
+
+
+ {row.channel?.name ?? `渠道 #${row.channelId}`}
+
+ {row.channel ? (
+
+ {channelEnabled ? '已启用' : '已停用'}
+
+ ) : null}
+
+
+ ID {row.channelId}
+
+
+
+
+ {rowSummary?.successCount ?? 0}
+
+ 0 &&
+ 'text-destructive'
+ )}
+ >
+ {rowSummary?.failureCount ?? 0}
+
+
+ {rowSummary?.sampleCount ?? 0}
+
+ 0
+ ? getRateClassName(rowSummary.successRate)
+ : 'text-muted-foreground'
+ )}
+ >
+ {rowSummary && rowSummary.sampleCount > 0
+ ? percentFormatter.format(rowSummary.successRate)
+ : '-'}
+
+
+ )
+ })}
+
+
+
+
+ ) : (
+
+
失败报错分类
+ {failureCategories.length === 0 ? (
+
+
+ 没有失败报错
+
+ 当前统计范围内未记录失败调用
+
+
+
+ ) : (
+
+
+
+
+ 错误分类
+ 报错示例
+ 失败次数
+ 失败占比
+ 最近发生
+
+
+
+ {failureCategories.map((category) => {
+ const failureCount =
+ props.target.mode === 'final'
+ ? category.final_count
+ : category.actual_count
+ const ratio = failureCount / modeSummary.failureCount
+ return (
+
+
+
+
+
+
+ {category.sample_content || '未记录错误内容'}
+
+
+
+
+
+ {failureCount} 次
+
+ {category.actual_count !==
+ category.final_count ? (
+
+ 最终失败 {category.final_count} 次
+
+ ) : null}
+
+
+
+ {percentFormatter.format(ratio)}
+
+
+ {category.last_occurred_at > 0
+ ? formatTimestampToDate(category.last_occurred_at)
+ : '-'}
+
+
+ )
+ })}
+
+
+
+ )}
+
+ )}
+
+ )
+ }
+
+ return (
+
+
+
+ {title}
+ {description}
+
+ {content}
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-success-rate-value.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-success-rate-value.tsx
new file mode 100644
index 000000000000..d5cabc1a64b4
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-success-rate-value.tsx
@@ -0,0 +1,106 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { ViewIcon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+
+import { Button } from '@/components/ui/button'
+import { Skeleton } from '@/components/ui/skeleton'
+import { cn } from '@/lib/utils'
+
+type ChannelMonitorSuccessRateValueProps = {
+ rate: number | null | undefined
+ successCount: number | null | undefined
+ sampleCount: number | null | undefined
+ available: boolean
+ loading: boolean
+ error: boolean
+ onClick?: () => void
+ detailLabel?: string
+}
+
+const percentFormatter = new Intl.NumberFormat(undefined, {
+ style: 'percent',
+ maximumFractionDigits: 2,
+})
+
+export function ChannelMonitorSuccessRateValue(
+ props: ChannelMonitorSuccessRateValueProps
+) {
+ if (props.loading) {
+ return
+ }
+ if (props.error) {
+ return 加载失败
+ }
+ if (!props.available) {
+ return 日志未开启
+ }
+ if (
+ props.rate == null ||
+ !Number.isFinite(props.rate) ||
+ props.sampleCount == null ||
+ props.sampleCount <= 0
+ ) {
+ return 暂无样本
+ }
+
+ let rateClassName = 'text-destructive'
+ if (props.rate >= 0.9) {
+ rateClassName = 'text-success'
+ } else if (props.rate >= 0.7) {
+ rateClassName = 'text-warning'
+ }
+ const successCount = props.successCount ?? 0
+ const value = (
+
+
+ {percentFormatter.format(props.rate)}
+
+
+ {successCount} / {props.sampleCount} 次
+
+
+ )
+ if (!props.onClick) {
+ return value
+ }
+ const detailLabel = props.detailLabel ?? '查看成功率明细'
+ return (
+
+ {value}
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-monitor-task-history-dialog.tsx b/web/default/src/features/channel-monitor/components/channel-monitor-task-history-dialog.tsx
new file mode 100644
index 000000000000..8801f89dd032
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-monitor-task-history-dialog.tsx
@@ -0,0 +1,670 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import {
+ Alert02Icon,
+ ArrowLeft01Icon,
+ ArrowRight01Icon,
+ CloudDownloadIcon,
+ HistoryIcon,
+ Refresh01Icon,
+ WorkflowSquare06Icon,
+} from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import {
+ keepPreviousData,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from '@tanstack/react-query'
+import { Fragment, useEffect, useState, type ReactNode } from 'react'
+import { toast } from 'sonner'
+
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from '@/components/ui/empty'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Spinner } from '@/components/ui/spinner'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
+import { formatTimestampToDate } from '@/lib/format'
+import { cn } from '@/lib/utils'
+
+import {
+ getChannelMonitorTasks,
+ runChannelMonitorRatioUpdate,
+ runChannelMonitorSmartSchedule,
+} from '../api'
+import { handleChannelMonitorMutationError } from '../lib/error'
+import type {
+ ChannelMonitorSmartScheduleStrategy,
+ ChannelMonitorTask,
+ ChannelMonitorTaskKind,
+ ChannelMonitorTaskStatus,
+} from '../types'
+
+const TASK_PAGE_SIZE = 20
+const ACTIVE_REFRESH_INTERVAL_MS = 5000
+
+const STATUS_LABELS: Record = {
+ pending: '待执行',
+ running: '执行中',
+ succeeded: '成功',
+ failed: '失败',
+}
+
+const SMART_SCHEDULE_STRATEGY_LABELS: Record<
+ ChannelMonitorSmartScheduleStrategy | 'stability',
+ string
+> = {
+ ratio: '按成本倍率',
+ first_token: '按首字',
+ tps: '按 TPS',
+ stability: '按稳定性',
+ smart: '智能调度',
+}
+
+const STATUS_STYLES: Record = {
+ pending:
+ 'bg-amber-500/10 text-amber-700 dark:text-amber-300 dark:bg-amber-500/15',
+ running: 'bg-sky-500/10 text-sky-700 dark:text-sky-300 dark:bg-sky-500/15',
+ succeeded:
+ 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 dark:bg-emerald-500/15',
+ failed: '',
+}
+
+type ChannelMonitorTaskHistoryDialogProps = {
+ initialKind: ChannelMonitorTaskKind
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+function isActiveTask(task: ChannelMonitorTask) {
+ return task.status === 'pending' || task.status === 'running'
+}
+
+function formatTaskDuration(task: ChannelMonitorTask) {
+ if (isActiveTask(task)) return task.status === 'running' ? '执行中' : '-'
+
+ const seconds = Math.max(0, task.updated_at - task.created_at)
+ if (seconds < 1) return '< 1 秒'
+ if (seconds < 60) return `${seconds} 秒`
+ const minutes = Math.floor(seconds / 60)
+ const remainingSeconds = seconds % 60
+ if (minutes < 60) return `${minutes} 分 ${remainingSeconds} 秒`
+ const hours = Math.floor(minutes / 60)
+ return `${hours} 小时 ${minutes % 60} 分`
+}
+
+function ChannelTaskStatusBadge(props: { task: ChannelMonitorTask }) {
+ const partiallyFailed =
+ props.task.status === 'succeeded' &&
+ ((props.task.result?.failed ?? 0) > 0 ||
+ props.task.result?.email_status === 'failed')
+ const label = partiallyFailed ? '部分失败' : STATUS_LABELS[props.task.status]
+ const className = partiallyFailed
+ ? 'bg-amber-500/10 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
+ : STATUS_STYLES[props.task.status]
+
+ return (
+
+ {label}
+
+ )
+}
+
+function FailureDot(props: { label: string }) {
+ return (
+
+ )
+}
+
+function ChannelTaskProgress(props: {
+ task: ChannelMonitorTask
+ failuresExpanded: boolean
+ onToggleFailures: () => void
+}) {
+ const result = props.task.result
+ if (result) {
+ const failures = result.failures ?? []
+ if (props.task.type === 'channel_smart_schedule') {
+ return (
+
+
+ 更新 {result.updated}
+
+
+ 保持 {result.unchanged ?? 0}
+
+
+ 跳过 {result.skipped ?? 0}
+
+ 0 && 'text-destructive'
+ )}
+ >
+ 失败 {result.failed}
+ {result.failed > 0 && }
+
+ {failures.length > 0 && (
+
+
+ {props.failuresExpanded ? '收起失败原因' : '查看失败原因'}
+
+ )}
+
+ )
+ }
+ return (
+
+
+ 成功 {result.updated} / {result.total}
+
+
+ 变化 {result.changed ?? 0}
+
+
+ 余额 {result.balance_updated ?? 0}
+
+ {(result.balance_warnings ?? 0) > 0 && (
+
+ 余额预警 {result.balance_warnings}
+
+ )}
+ {(result.skipped ?? 0) > 0 && (
+
+ 已跳过 {result.skipped}
+
+ )}
+ 0 && 'text-destructive'
+ )}
+ >
+ 失败 {result.failed}
+ {result.failed > 0 && }
+
+ {(result.retried ?? 0) > 0 && (
+
+ 重试 {result.retried}
+
+ )}
+ {(result.recovered_after_retry ?? 0) > 0 && (
+
+ 重试恢复 {result.recovered_after_retry}
+
+ )}
+ {result.email_status === 'sent' && 邮件 已发送 }
+ {result.email_status === 'failed' && (
+
+ 邮件 发送失败
+
+ )}
+ {failures.length > 0 && (
+
+
+ {props.failuresExpanded ? '收起失败原因' : '查看失败原因'}
+
+ )}
+
+ )
+ }
+
+ const state = props.task.state
+ if (!state) return -
+ return (
+
+ 已处理 {state.processed} / {state.total}({state.progress}%)
+
+ )
+}
+
+function ChannelTaskPolicyResult(props: { task: ChannelMonitorTask }) {
+ const result = props.task.result
+ if (!result) return -
+ if (props.task.type === 'channel_smart_schedule') {
+ const applyModeLabel =
+ result.apply_mode === 'priority_weight'
+ ? '优先级分层 + 权重'
+ : '只调整权重'
+ let configuredModels = result.models ?? []
+ if (configuredModels.length === 0 && result.model) {
+ configuredModels = [result.model]
+ }
+ const modelSummary =
+ configuredModels.length > 0
+ ? `模型优先级 ${configuredModels.join(' → ')}`
+ : '全部模型汇总'
+ return (
+
+
+ {result.strategy
+ ? SMART_SCHEDULE_STRATEGY_LABELS[result.strategy]
+ : '智能调度'}{' '}
+ · {applyModeLabel}
+ {result.stability_enabled ? ' · 按稳定性' : ''}
+ {result.force_reset ? ' · 强制重算' : ''}
+
+
+ {modelSummary} · {result.performance_minutes ?? 0} 分钟
+
+
+ )
+ }
+ return (
+
+
+ 更新分组 {result.groups_updated ?? 0}
+ {result.group_update_failed && }
+
+ 移出分组 {result.group_memberships_removed ?? 0}
+ 禁用渠道 {result.channels_disabled ?? 0}
+ 跳过分组 {result.groups_skipped ?? 0}
+
+ )
+}
+
+export function ChannelMonitorTaskHistoryDialog(
+ props: ChannelMonitorTaskHistoryDialogProps
+) {
+ const queryClient = useQueryClient()
+ const [kind, setKind] = useState(props.initialKind)
+ const [page, setPage] = useState(1)
+ const [expandedFailureTaskId, setExpandedFailureTaskId] = useState<
+ string | null
+ >(null)
+ const ratioUpdateMutation = useMutation({
+ mutationFn: runChannelMonitorRatioUpdate,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: (response) => {
+ toast.success(
+ response.data.created
+ ? '倍率更新任务已创建'
+ : '已有倍率更新任务正在执行'
+ )
+ setKind('ratio')
+ setPage(1)
+ setExpandedFailureTaskId(null)
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({
+ queryKey: ['channel-monitor-task-history'],
+ })
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ queryClient.invalidateQueries({ queryKey: ['channels'] })
+ },
+ })
+ const smartScheduleMutation = useMutation({
+ mutationFn: runChannelMonitorSmartSchedule,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: (response) => {
+ toast.success(
+ response.data.created
+ ? '智能调度任务已创建'
+ : '已有智能调度任务正在执行'
+ )
+ setKind('schedule')
+ setPage(1)
+ setExpandedFailureTaskId(null)
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({
+ queryKey: ['channel-monitor-task-history'],
+ })
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ queryClient.invalidateQueries({ queryKey: ['channels'] })
+ },
+ })
+ const query = useQuery({
+ queryKey: ['channel-monitor-task-history', kind, page, TASK_PAGE_SIZE],
+ queryFn: () => getChannelMonitorTasks(page, TASK_PAGE_SIZE, kind),
+ enabled: props.open,
+ placeholderData: keepPreviousData,
+ staleTime: 30 * 1000,
+ refetchInterval: (result) =>
+ result.state.data?.data.items.some(isActiveTask)
+ ? ACTIVE_REFRESH_INTERVAL_MS
+ : false,
+ })
+ const tasks = query.data?.data.items ?? []
+ const total = query.data?.data.total ?? 0
+ const totalPages = Math.max(1, Math.ceil(total / TASK_PAGE_SIZE))
+ const rangeStart = total === 0 ? 0 : (page - 1) * TASK_PAGE_SIZE + 1
+ const rangeEnd = Math.min(page * TASK_PAGE_SIZE, total)
+ const latestCompletedScheduleTime =
+ kind === 'schedule'
+ ? tasks.reduce(
+ (latest, task) =>
+ isActiveTask(task) ? latest : Math.max(latest, task.updated_at),
+ 0
+ )
+ : 0
+
+ useEffect(() => {
+ if (latestCompletedScheduleTime <= 0) return
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ queryClient.invalidateQueries({ queryKey: ['channels'] })
+ }, [latestCompletedScheduleTime, queryClient])
+
+ let content: ReactNode
+ if (query.isLoading) {
+ content = (
+
+ {['first', 'second', 'third', 'fourth'].map((key) => (
+
+ ))}
+
+ )
+ } else if (query.isError) {
+ content = (
+
+
+
+
+
+ 定时任务记录加载失败
+
+ {query.error instanceof Error ? query.error.message : '请稍后重试'}
+
+
+
+ query.refetch()}>
+
+ 重试
+
+
+
+ )
+ } else if (tasks.length === 0) {
+ content = (
+
+
+
+
+
+
+ {kind === 'schedule' ? '暂无智能调度记录' : '暂无倍率更新记录'}
+
+
+ {kind === 'schedule'
+ ? '开启智能调度或手动执行后,任务会在这里留下记录。'
+ : '开启自动更新或手动执行后,任务会在这里留下记录。'}
+
+
+
+ )
+ } else {
+ content = (
+
+
+
+ 执行时间
+ 状态
+ 执行结果
+ 规则与策略
+ 耗时
+ 错误
+
+
+
+ {tasks.map((task) => {
+ const failures = task.result?.failures ?? []
+ const failuresExpanded =
+ expandedFailureTaskId === task.task_id && failures.length > 0
+ return (
+
+
+
+ {formatTimestampToDate(task.created_at)}
+
+
+
+
+
+
+ setExpandedFailureTaskId((current) =>
+ current === task.task_id ? null : task.task_id
+ )
+ }
+ />
+
+
+
+
+
+ {formatTaskDuration(task)}
+
+
+ {task.error || '-'}
+
+
+ {failuresExpanded && (
+
+
+
+ {failures.map((failure) => (
+
+
+
+ {failure.channel_name
+ ? `${failure.channel_name}(ID ${failure.channel_id})`
+ : `渠道 ID ${failure.channel_id}`}
+
+
+ {failure.error ||
+ (task.type === 'channel_smart_schedule'
+ ? '智能调度更新失败'
+ : '上游倍率获取失败')}
+
+
+ ))}
+ {task.result?.failure_details_truncated && (
+
+ 失败渠道较多,仅显示前 {failures.length} 条明细
+
+ )}
+
+
+
+ )}
+
+ )
+ })}
+
+
+ )
+ }
+
+ return (
+
+
+
+ 定时任务记录
+
+ 查看上游倍率与余额更新、智能调度的执行结果,也可以立即执行任务。
+
+
+
+
+ {
+ const nextKind = values.find((value) => value !== kind)
+ if (nextKind !== 'ratio' && nextKind !== 'schedule') return
+ setKind(nextKind)
+ setPage(1)
+ setExpandedFailureTaskId(null)
+ }}
+ variant='outline'
+ size='sm'
+ spacing={0}
+ aria-label='选择定时任务类型'
+ >
+ 倍率与余额
+ 智能调度
+
+
+ 显示 {rangeStart}-{rangeEnd},共 {total} 条
+
+
+
+ ratioUpdateMutation.mutate()}
+ disabled={ratioUpdateMutation.isPending}
+ >
+ {ratioUpdateMutation.isPending ? (
+
+ ) : (
+
+ )}
+ 立即更新倍率和余额
+
+ smartScheduleMutation.mutate()}
+ disabled={smartScheduleMutation.isPending}
+ >
+ {smartScheduleMutation.isPending ? (
+
+ ) : (
+
+ )}
+ 执行智能调度
+
+ query.refetch()}
+ disabled={query.isFetching}
+ >
+
+ 刷新
+
+
+
+
+ {content}
+
+ {total > 0 && (
+
+ setPage((current) => Math.max(1, current - 1))}
+ disabled={page <= 1 || query.isFetching}
+ >
+
+
+
+ 第 {page} / {totalPages} 页
+
+
+ setPage((current) => Math.min(totalPages, current + 1))
+ }
+ disabled={page >= totalPages || query.isFetching}
+ >
+
+
+
+ )}
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/channel-ratio-history-dialog.tsx b/web/default/src/features/channel-monitor/components/channel-ratio-history-dialog.tsx
new file mode 100644
index 000000000000..44670096cd12
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/channel-ratio-history-dialog.tsx
@@ -0,0 +1,163 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { HistoryIcon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useQuery } from '@tanstack/react-query'
+import type { ReactNode } from 'react'
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from '@/components/ui/empty'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { formatTimestampToDate } from '@/lib/format'
+
+import { getChannelMonitorHistory } from '../api'
+import { formatChangePercent, formatMonitorRatio } from '../lib/format'
+import type { ChannelMonitorItem } from '../types'
+
+type ChannelRatioHistoryPanelProps = {
+ channel: ChannelMonitorItem
+}
+
+type ChannelRatioHistoryDialogProps = ChannelRatioHistoryPanelProps & {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function ChannelRatioHistoryDialog(
+ props: ChannelRatioHistoryDialogProps
+) {
+ return (
+
+
+
+ 上游倍率变更历史
+
+ {props.channel.name} · ID {props.channel.id}
+
+
+
+
+
+ )
+}
+
+export function ChannelRatioHistoryPanel(props: ChannelRatioHistoryPanelProps) {
+ const query = useQuery({
+ queryKey: ['channel-monitor-history', props.channel.id],
+ queryFn: () => getChannelMonitorHistory(props.channel.id),
+ })
+ const history = query.data?.data.items ?? []
+
+ let historyContent: ReactNode
+ if (query.isLoading) {
+ historyContent = (
+
+ {['first', 'second', 'third', 'fourth'].map((key) => (
+
+ ))}
+
+ )
+ } else if (history.length === 0) {
+ historyContent = (
+
+
+
+
+
+ 暂无上游倍率变更
+
+ 首次记录作为基准值,倍率发生变化后才会生成历史。
+
+
+
+ )
+ } else {
+ historyContent = (
+
+
+
+ 时间
+ 上游倍率变更
+ 操作人
+ 备注
+
+
+
+ {history.map((item) => {
+ const percent =
+ item.old_ratio === 0
+ ? null
+ : ((item.new_ratio - item.old_ratio) / item.old_ratio) * 100
+ return (
+
+
+ {formatTimestampToDate(item.created_time)}
+
+
+
+ {formatMonitorRatio(item.old_ratio)}
+ →
+
+ {formatMonitorRatio(item.new_ratio)}
+
+
+ {formatChangePercent(percent)}
+
+
+
+
+ {item.operator_username || `#${item.operator_id}`}
+
+
+ {item.remark || '-'}
+
+
+ )
+ })}
+
+
+ )
+ }
+
+ return (
+
+ {historyContent}
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/edit-channel-groups-dialog.tsx b/web/default/src/features/channel-monitor/components/edit-channel-groups-dialog.tsx
new file mode 100644
index 000000000000..4a49ddd84085
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/edit-channel-groups-dialog.tsx
@@ -0,0 +1,184 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { zodResolver } from '@hookform/resolvers/zod'
+import { Refresh01Icon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useMemo } from 'react'
+import { useForm, type Resolver } from 'react-hook-form'
+import { toast } from 'sonner'
+
+import { MultiSelect } from '@/components/multi-select'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Spinner } from '@/components/ui/spinner'
+
+import {
+ getChannelMonitorAvailableGroups,
+ updateMonitoredChannelGroups,
+} from '../api'
+import { handleChannelMonitorMutationError } from '../lib/error'
+import {
+ createChannelGroupsSchema,
+ type ChannelGroupsFormValues,
+} from '../lib/schema'
+import type { ChannelMonitorItem } from '../types'
+
+type EditChannelGroupsDialogProps = {
+ channel: ChannelMonitorItem
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function EditChannelGroupsDialog(props: EditChannelGroupsDialogProps) {
+ const queryClient = useQueryClient()
+ const schema = createChannelGroupsSchema()
+ const form = useForm({
+ resolver: zodResolver(schema) as Resolver,
+ defaultValues: { groups: props.channel.groups },
+ })
+ const groupsQuery = useQuery({
+ queryKey: ['channel-monitor-available-groups'],
+ queryFn: getChannelMonitorAvailableGroups,
+ staleTime: 60 * 1000,
+ })
+ const groupOptions = useMemo(() => {
+ const groups = new Set([
+ ...props.channel.groups,
+ ...(groupsQuery.data?.data ?? []),
+ ])
+ return [...groups]
+ .sort((first, second) => first.localeCompare(second))
+ .map((group) => ({ value: group, label: group }))
+ }, [groupsQuery.data?.data, props.channel.groups])
+ const mutation = useMutation({
+ mutationFn: updateMonitoredChannelGroups,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: () => {
+ toast.success('渠道关联分组已更新')
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ queryClient.invalidateQueries({ queryKey: ['channels'] })
+ props.onOpenChange(false)
+ },
+ })
+ const handleSubmit = form.handleSubmit((values) => {
+ mutation.mutate({ channelId: props.channel.id, groups: values.groups })
+ })
+
+ return (
+
+
+
+ 更改关联分组
+
+ {props.channel.name} · ID {props.channel.id}
+
+
+
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/edit-channel-ratio-dialog.tsx b/web/default/src/features/channel-monitor/components/edit-channel-ratio-dialog.tsx
new file mode 100644
index 000000000000..2a0c13d0b8fd
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/edit-channel-ratio-dialog.tsx
@@ -0,0 +1,165 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useForm, type Resolver } from 'react-hook-form'
+import { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { Spinner } from '@/components/ui/spinner'
+import { Textarea } from '@/components/ui/textarea'
+
+import { updateChannelMonitorRatio } from '../api'
+import { handleChannelMonitorMutationError } from '../lib/error'
+import { formatMonitorRatio } from '../lib/format'
+import {
+ createChannelRatioSchema,
+ type ChannelRatioFormValues,
+} from '../lib/schema'
+import type { ChannelMonitorItem } from '../types'
+
+type EditChannelRatioDialogProps = {
+ channel: ChannelMonitorItem
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function EditChannelRatioDialog(props: EditChannelRatioDialogProps) {
+ const queryClient = useQueryClient()
+ const schema = createChannelRatioSchema()
+ const form = useForm({
+ resolver: zodResolver(schema) as Resolver,
+ defaultValues: {
+ ratio: props.channel.ratio ?? 1,
+ remark: props.channel.remark ?? '',
+ },
+ })
+
+ const mutation = useMutation({
+ mutationFn: updateChannelMonitorRatio,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: () => {
+ toast.success('上游原始倍率已保存')
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ queryClient.invalidateQueries({
+ queryKey: ['channel-monitor-history', props.channel.id],
+ })
+ props.onOpenChange(false)
+ },
+ })
+
+ const handleSubmit = form.handleSubmit((values) => {
+ mutation.mutate({
+ channelId: props.channel.id,
+ ratio: values.ratio,
+ remark: values.remark.trim(),
+ })
+ })
+
+ return (
+
+
+
+ 修改上游原始倍率
+
+ {props.channel.name} · ID {props.channel.id}
+
+
+
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/edit-group-channels-dialog.tsx b/web/default/src/features/channel-monitor/components/edit-group-channels-dialog.tsx
new file mode 100644
index 000000000000..cae51db9abdb
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/edit-group-channels-dialog.tsx
@@ -0,0 +1,340 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { Search01Icon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useMemo, useState } from 'react'
+import { toast } from 'sonner'
+
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from '@/components/ui/input-group'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { Spinner } from '@/components/ui/spinner'
+import { CHANNEL_STATUS } from '@/features/channels/constants'
+import { cn } from '@/lib/utils'
+
+import { updateChannelMonitorGroupChannels } from '../api'
+import { handleChannelMonitorMutationError } from '../lib/error'
+import type { ChannelMonitorItem, GroupMonitorItem } from '../types'
+import { ChannelMonitorStatusBadge } from './channel-monitor-status-badge'
+
+type EditGroupChannelsDialogProps = {
+ group: GroupMonitorItem
+ channels: ChannelMonitorItem[]
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function EditGroupChannelsDialog(props: EditGroupChannelsDialogProps) {
+ const queryClient = useQueryClient()
+ const [search, setSearch] = useState('')
+ const originalChannelIds = useMemo(
+ () => new Set(props.group.channels.map((channel) => channel.id)),
+ [props.group.channels]
+ )
+ const [selectedChannelIds, setSelectedChannelIds] = useState(
+ () => new Set(originalChannelIds)
+ )
+ const normalizedSearch = search.trim().toLocaleLowerCase()
+ const visibleChannels = useMemo(
+ () =>
+ [...props.channels]
+ .filter((channel) => {
+ if (!normalizedSearch) return true
+ return (
+ channel.name.toLocaleLowerCase().includes(normalizedSearch) ||
+ String(channel.id).includes(normalizedSearch) ||
+ channel.groups.some((group) =>
+ group.toLocaleLowerCase().includes(normalizedSearch)
+ )
+ )
+ })
+ .sort((leftChannel, rightChannel) => {
+ const leftIsMember = originalChannelIds.has(leftChannel.id)
+ const rightIsMember = originalChannelIds.has(rightChannel.id)
+ if (leftIsMember !== rightIsMember) return leftIsMember ? -1 : 1
+
+ const leftEnabled = leftChannel.status === CHANNEL_STATUS.ENABLED
+ const rightEnabled = rightChannel.status === CHANNEL_STATUS.ENABLED
+ if (leftEnabled !== rightEnabled) return leftEnabled ? -1 : 1
+
+ const nameOrder = leftChannel.name.localeCompare(rightChannel.name)
+ return nameOrder !== 0 ? nameOrder : leftChannel.id - rightChannel.id
+ }),
+ [normalizedSearch, originalChannelIds, props.channels]
+ )
+ const lockedRemovalChannelIds = useMemo(
+ () =>
+ new Set(
+ props.channels
+ .filter(
+ (channel) =>
+ originalChannelIds.has(channel.id) &&
+ channel.groups.every(
+ (group) => !group || group === props.group.name
+ )
+ )
+ .map((channel) => channel.id)
+ ),
+ [originalChannelIds, props.channels, props.group.name]
+ )
+ const tooLongToAddChannelIds = useMemo(
+ () =>
+ new Set(
+ props.channels
+ .filter((channel) => {
+ const serializedGroups = [...channel.groups, props.group.name].join(
+ ','
+ )
+ return (
+ !originalChannelIds.has(channel.id) &&
+ [...serializedGroups].length > 64
+ )
+ })
+ .map((channel) => channel.id)
+ ),
+ [originalChannelIds, props.channels, props.group.name]
+ )
+ const addedCount = [...selectedChannelIds].filter(
+ (channelId) => !originalChannelIds.has(channelId)
+ ).length
+ const removedCount = [...originalChannelIds].filter(
+ (channelId) => !selectedChannelIds.has(channelId)
+ ).length
+ const hasChanges = addedCount > 0 || removedCount > 0
+
+ const mutation = useMutation({
+ mutationFn: updateChannelMonitorGroupChannels,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: (response) => {
+ const added = response.data.added_channel_ids.length
+ const removed = response.data.removed_channel_ids.length
+ toast.success(`分组渠道已更新:新增 ${added} 个,移除 ${removed} 个`)
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ queryClient.invalidateQueries({ queryKey: ['channels'] })
+ props.onOpenChange(false)
+ },
+ })
+
+ const setChannelSelected = (channelId: number, selected: boolean) => {
+ if (!selected && lockedRemovalChannelIds.has(channelId)) return
+ if (selected && tooLongToAddChannelIds.has(channelId)) return
+ setSelectedChannelIds((current) => {
+ const next = new Set(current)
+ if (selected) {
+ next.add(channelId)
+ } else {
+ next.delete(channelId)
+ }
+ return next
+ })
+ }
+
+ const selectVisibleChannels = () => {
+ setSelectedChannelIds((current) => {
+ const next = new Set(current)
+ for (const channel of visibleChannels) {
+ if (!tooLongToAddChannelIds.has(channel.id)) next.add(channel.id)
+ }
+ return next
+ })
+ }
+
+ const clearVisibleChannels = () => {
+ setSelectedChannelIds((current) => {
+ const next = new Set(current)
+ for (const channel of visibleChannels) {
+ if (!lockedRemovalChannelIds.has(channel.id)) next.delete(channel.id)
+ }
+ return next
+ })
+ }
+
+ const saveChannels = () => {
+ mutation.mutate({
+ group: props.group.name,
+ channelIds: [...selectedChannelIds].sort((left, right) => left - right),
+ })
+ }
+
+ return (
+
+
+
+ 管理分组渠道
+
+ {props.group.name} · 这里只调整分组关联,不会删除渠道本身
+
+
+
+
+
+
+
+
+
+ setSearch(event.target.value)}
+ placeholder='搜索渠道、ID 或分组'
+ aria-label='搜索渠道'
+ />
+
+
+
+ 全选可见
+
+
+ 清除可见
+
+
+
+
+
+ 已选 {selectedChannelIds.size} 个渠道
+ {addedCount > 0 && (
+ 新增 {addedCount}
+ )}
+ {removedCount > 0 && (
+ 移除 {removedCount}
+ )}
+
+
+
+ {visibleChannels.length === 0 ? (
+
+ 没有匹配的渠道
+
+ ) : (
+
+ {visibleChannels.map((channel) => {
+ const selected = selectedChannelIds.has(channel.id)
+ const removalLocked = lockedRemovalChannelIds.has(channel.id)
+ const tooLongToAdd = tooLongToAddChannelIds.has(channel.id)
+ const disabled =
+ mutation.isPending || removalLocked || tooLongToAdd
+ const otherGroups = channel.groups.filter(
+ (group) => group && group !== props.group.name
+ )
+ let membershipDescription =
+ otherGroups.length > 0
+ ? `其他分组:${otherGroups.join('、')}`
+ : '暂无其他分组'
+ let membershipDescriptionClassName =
+ 'text-muted-foreground truncate text-xs'
+ if (removalLocked) {
+ membershipDescription = '唯一分组,请先为该渠道添加其他分组'
+ membershipDescriptionClassName =
+ 'text-xs text-amber-600 dark:text-amber-400'
+ } else if (tooLongToAdd) {
+ membershipDescription =
+ '添加后关联分组名称合计将超过 64 个字符'
+ membershipDescriptionClassName = 'text-destructive text-xs'
+ }
+
+ return (
+
+
+ setChannelSelected(channel.id, checked)
+ }
+ aria-label={`${selected ? '从分组移除' : '添加到分组'}渠道 ${channel.name}`}
+ />
+
+
+
+ {channel.name}
+
+
+ ID {channel.id}
+
+
+ {originalChannelIds.has(channel.id) && (
+ 当前成员
+ )}
+
+
+ {membershipDescription}
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+ props.onOpenChange(false)}
+ disabled={mutation.isPending}
+ >
+ 取消
+
+
+ {mutation.isPending && }
+ 保存关联
+
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/edit-group-ratio-dialog.tsx b/web/default/src/features/channel-monitor/components/edit-group-ratio-dialog.tsx
new file mode 100644
index 000000000000..ba6496b26846
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/edit-group-ratio-dialog.tsx
@@ -0,0 +1,135 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useForm, type Resolver } from 'react-hook-form'
+import { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { Spinner } from '@/components/ui/spinner'
+
+import { updateChannelMonitorGroupRatio } from '../api'
+import { handleChannelMonitorMutationError } from '../lib/error'
+import {
+ createGroupRatioSchema,
+ type GroupRatioFormValues,
+} from '../lib/schema'
+import type { GroupMonitorItem } from '../types'
+
+type EditGroupRatioDialogProps = {
+ group: GroupMonitorItem
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function EditGroupRatioDialog(props: EditGroupRatioDialogProps) {
+ const queryClient = useQueryClient()
+ const schema = createGroupRatioSchema()
+ const form = useForm({
+ resolver: zodResolver(schema) as Resolver,
+ defaultValues: { ratio: props.group.ratio },
+ })
+
+ const mutation = useMutation({
+ mutationFn: updateChannelMonitorGroupRatio,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: () => {
+ toast.success('分组倍率已保存')
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ props.onOpenChange(false)
+ },
+ })
+
+ const handleSubmit = form.handleSubmit((values) => {
+ mutation.mutate({ group: props.group.name, ratio: values.ratio })
+ })
+
+ return (
+
+
+
+ 修改分组倍率
+
+ {props.group.name} · 影响 {props.group.channels.length} 个关联渠道
+
+
+
+
+
+ (
+
+ 分组倍率
+
+
+
+
+
+ )}
+ />
+
+
+ props.onOpenChange(false)}
+ disabled={mutation.isPending}
+ >
+ 取消
+
+
+ {mutation.isPending && }
+ 保存
+
+
+
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/group-ratio-value.tsx b/web/default/src/features/channel-monitor/components/group-ratio-value.tsx
new file mode 100644
index 000000000000..7afbde712e88
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/group-ratio-value.tsx
@@ -0,0 +1,61 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { cn } from '@/lib/utils'
+
+import { formatMonitorRatio, getChannelGroupTargetRatio } from '../lib/format'
+
+type GroupRatioValueProps = {
+ groupRatio: number
+ costRatio: number | null
+ coefficient: number
+}
+
+export function GroupRatioValue(props: GroupRatioValueProps) {
+ const expectedGroupRatio = getChannelGroupTargetRatio(
+ props.costRatio,
+ props.coefficient
+ )
+ let colorClassName = 'text-foreground'
+ let statusLabel = '暂时无法比较'
+
+ if (expectedGroupRatio != null) {
+ if (Math.abs(props.groupRatio - expectedGroupRatio) <= 1e-9) {
+ colorClassName = 'text-warning'
+ statusLabel = '等于成本倍率乘以分组系数'
+ } else if (props.groupRatio < expectedGroupRatio) {
+ colorClassName = 'text-destructive'
+ statusLabel = '低于成本倍率乘以分组系数'
+ } else {
+ colorClassName = 'text-success'
+ statusLabel = '高于成本倍率乘以分组系数'
+ }
+ }
+
+ const formattedRatio = formatMonitorRatio(props.groupRatio)
+
+ return (
+
+ {formattedRatio}
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/ratio-change-badge.tsx b/web/default/src/features/channel-monitor/components/ratio-change-badge.tsx
new file mode 100644
index 000000000000..5750539b53ef
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/ratio-change-badge.tsx
@@ -0,0 +1,51 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { StatusBadge } from '@/components/status-badge'
+
+import { formatChangePercent, getRatioChange } from '../lib/format'
+
+type RatioChangeBadgeProps = {
+ current: number | null
+ previous: number | null
+}
+
+export function RatioChangeBadge(props: RatioChangeBadgeProps) {
+ const change = getRatioChange(props.current, props.previous)
+
+ if (change.direction === 'baseline') {
+ return (
+
+ )
+ }
+ if (change.direction === 'same') {
+ return
+ }
+
+ return (
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/sync-group-ratio-dialog.tsx b/web/default/src/features/channel-monitor/components/sync-group-ratio-dialog.tsx
new file mode 100644
index 000000000000..70beb1c30e0d
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/sync-group-ratio-dialog.tsx
@@ -0,0 +1,191 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useForm, type Resolver } from 'react-hook-form'
+import { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from '@/components/ui/input-group'
+import { Spinner } from '@/components/ui/spinner'
+import { CHANNEL_STATUS } from '@/features/channels/constants'
+
+import { syncChannelMonitorGroupRatio } from '../api'
+import { handleChannelMonitorMutationError } from '../lib/error'
+import { formatMonitorRatio } from '../lib/format'
+import {
+ createGroupRatioSyncSchema,
+ MAX_MONITOR_RATIO,
+ type GroupRatioSyncFormValues,
+} from '../lib/schema'
+import type { GroupMonitorItem } from '../types'
+
+type SyncGroupRatioDialogProps = {
+ group: GroupMonitorItem
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function SyncGroupRatioDialog(props: SyncGroupRatioDialogProps) {
+ const queryClient = useQueryClient()
+ let highestCostRatio: number | null = null
+ for (const channel of props.group.channels) {
+ if (
+ channel.status !== CHANNEL_STATUS.ENABLED ||
+ channel.cost_ratio == null
+ ) {
+ continue
+ }
+ if (highestCostRatio == null || channel.cost_ratio > highestCostRatio) {
+ highestCostRatio = channel.cost_ratio
+ }
+ }
+
+ const form = useForm({
+ resolver: zodResolver(
+ createGroupRatioSyncSchema(highestCostRatio)
+ ) as Resolver,
+ defaultValues: { coefficient: props.group.coefficient },
+ })
+ const mutation = useMutation({
+ mutationFn: syncChannelMonitorGroupRatio,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: (response) => {
+ toast.success(
+ `分组倍率已更新为 ${formatMonitorRatio(response.data.ratio)}`
+ )
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ props.onOpenChange(false)
+ },
+ })
+ const coefficient = Number(form.watch('coefficient'))
+ const targetRatio =
+ highestCostRatio == null || !Number.isFinite(coefficient)
+ ? null
+ : highestCostRatio * coefficient
+ const handleSubmit = form.handleSubmit((values) => {
+ mutation.mutate({
+ group: props.group.name,
+ coefficient: values.coefficient,
+ })
+ })
+
+ return (
+
+
+
+ 按最高成本倍率更新
+ {props.group.name}
+
+
+
+
+
+
+ 最高成本倍率
+
+ {formatMonitorRatio(highestCostRatio)}
+
+
+
+ 更新后分组倍率
+
+ {formatMonitorRatio(targetRatio)}
+
+
+
+
+ (
+
+ 系数
+
+
+ ×
+
+
+
+
+ 最终分组倍率 = 最高成本倍率 × 系数
+
+
+
+ )}
+ />
+
+
+ props.onOpenChange(false)}
+ disabled={mutation.isPending}
+ >
+ 取消
+
+
+ {mutation.isPending && }
+ 保存系数并更新
+
+
+
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/components/upstream-config-dialog.tsx b/web/default/src/features/channel-monitor/components/upstream-config-dialog.tsx
new file mode 100644
index 000000000000..451d8aaf49f1
--- /dev/null
+++ b/web/default/src/features/channel-monitor/components/upstream-config-dialog.tsx
@@ -0,0 +1,1365 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { zodResolver } from '@hookform/resolvers/zod'
+import {
+ ClipboardPasteIcon,
+ Copy01Icon,
+ LinkSquare01Icon,
+ Refresh01Icon,
+ TestTubeIcon,
+ Tick02Icon,
+} from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useMemo, useState } from 'react'
+import { useForm, useWatch, type Resolver } from 'react-hook-form'
+import { toast } from 'sonner'
+
+import { PasswordInput } from '@/components/password-input'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import {
+ Combobox,
+ ComboboxCollection,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+} from '@/components/ui/combobox'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Spinner } from '@/components/ui/spinner'
+import { Switch } from '@/components/ui/switch'
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
+import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
+
+import {
+ applyChannelMonitorUpstreamGroup,
+ fetchChannelMonitorSub2APIUpstreamVersion,
+ listChannelMonitorUpstreamGroups,
+ saveChannelMonitorUpstreamConfig,
+ testChannelMonitorUpstreamConfig,
+} from '../api'
+import {
+ createChannelMonitorCustomFormConfig,
+ createChannelMonitorCustomRequestConfig,
+} from '../lib/custom-upstream'
+import { handleChannelMonitorMutationError } from '../lib/error'
+import { formatMonitorRatio } from '../lib/format'
+import {
+ createUpstreamConfigSchema,
+ MAX_BALANCE_THRESHOLD,
+ type UpstreamConfigFormValues,
+} from '../lib/schema'
+import type {
+ ChannelMonitorItem,
+ ChannelMonitorCostConversion,
+ ChannelMonitorPolicyAction,
+ ChannelMonitorUpstreamGroup,
+ ChannelMonitorUpstreamRequest,
+ NewAPIGroupRatioResult,
+} from '../types'
+import { ChannelMonitorCostConversionFields } from './channel-monitor-cost-conversion-fields'
+import { ChannelMonitorCustomUpstreamFields } from './channel-monitor-custom-upstream-fields'
+
+type UpstreamConfigDialogProps = {
+ channel: ChannelMonitorItem
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+const SINGLE_CHANNEL_ACTION_OPTIONS = [
+ { value: 'none', label: '仅记录' },
+ { value: 'update_group_ratio', label: '更新分组倍率' },
+ { value: 'disable_channel', label: '禁用此渠道' },
+] satisfies Array<{ value: ChannelMonitorPolicyAction; label: string }>
+
+const MULTIPLE_CHANNELS_ACTION_OPTIONS = [
+ { value: 'none', label: '仅记录' },
+ { value: 'update_group_ratio', label: '参与更新分组倍率' },
+ { value: 'disable_channel', label: '禁用此渠道' },
+ { value: 'remove_from_group', label: '移除当前渠道' },
+] satisfies Array<{ value: ChannelMonitorPolicyAction; label: string }>
+
+const MULTIPLE_CHANNELS_ACTION_DESCRIPTIONS: Record<
+ ChannelMonitorPolicyAction,
+ string
+> = {
+ none: '目标倍率高于当前分组倍率时仅记录结果',
+ update_group_ratio: '更新时采用参与渠道中的最高目标倍率',
+ disable_channel: '目标倍率高于当前分组倍率时禁用此渠道',
+ remove_from_group: '仅解除当前分组关联;若这是渠道的唯一分组则不会移除',
+}
+
+const SUB2API_ACCESS_TOKEN_COMMAND =
+ "copy(localStorage.getItem('auth_token') || '')"
+
+function createUpstreamRequest(
+ values: UpstreamConfigFormValues
+): ChannelMonitorUpstreamRequest {
+ const userAuthentication =
+ values.upstreamType === 'new_api' && values.authType === 'user'
+ const sub2APITokenAuthentication =
+ values.upstreamType === 'sub2api' && values.authType === 'token'
+ const sub2APIAccountAuthentication =
+ values.upstreamType === 'sub2api' && values.authType === 'account'
+ let costConversion: ChannelMonitorCostConversion = { mode: 'none' }
+ if (values.costConversionMode === 'recharge') {
+ costConversion = {
+ mode: 'recharge',
+ paid_cny: values.rechargePaidCny,
+ credited_usd: values.rechargeCreditedUsd,
+ }
+ } else if (values.costConversionMode === 'subscription') {
+ costConversion = {
+ mode: 'subscription',
+ subscription_period: values.subscriptionPeriod,
+ subscription_price_cny: values.subscriptionPriceCny,
+ subscription_daily_usd: values.subscriptionDailyUsd,
+ }
+ }
+ return {
+ type: values.upstreamType,
+ base_url: values.baseUrl.trim(),
+ group: values.group.trim(),
+ auth_type: values.authType,
+ user_id: userAuthentication ? values.userId : 0,
+ access_token:
+ userAuthentication || sub2APITokenAuthentication
+ ? values.accessToken.trim()
+ : '',
+ account: sub2APIAccountAuthentication ? values.account.trim() : '',
+ password: sub2APIAccountAuthentication ? values.password : '',
+ single_channel_action: values.singleChannelAction,
+ multiple_channels_action: values.multipleChannelsAction,
+ balance_warning_threshold: values.balanceWarningThreshold,
+ balance_auto_disable_threshold: values.balanceAutoDisableThreshold,
+ ratio_sync_enabled: values.ratioSyncEnabled,
+ balance_sync_enabled: values.balanceSyncEnabled,
+ cost_conversion: costConversion,
+ custom_config:
+ values.upstreamType === 'custom'
+ ? createChannelMonitorCustomRequestConfig(values.customConfig)
+ : undefined,
+ }
+}
+
+export function UpstreamConfigDialog(props: UpstreamConfigDialogProps) {
+ const queryClient = useQueryClient()
+ const { copyToClipboard } = useCopyToClipboard({
+ successMessage: '提取 Token 命令已复制',
+ errorMessage: '复制提取命令失败',
+ })
+ const [testResult, setTestResult] = useState(
+ null
+ )
+ const [upstreamVersion, setUpstreamVersion] = useState(null)
+ const savedUpstream = props.channel.upstream
+ const savedCostConversion: ChannelMonitorCostConversion =
+ savedUpstream?.cost_conversion ?? { mode: 'none' }
+ const initialGroup = savedUpstream?.group || ''
+ const [upstreamGroups, setUpstreamGroups] = useState<
+ ChannelMonitorUpstreamGroup[]
+ >([])
+ const [groupInputValue, setGroupInputValue] = useState(initialGroup)
+ const [groupComboboxOpen, setGroupComboboxOpen] = useState(false)
+ const savedCredential: Parameters[0] =
+ savedUpstream
+ ? {
+ type: savedUpstream.type,
+ baseUrl: savedUpstream.base_url,
+ authType: savedUpstream.auth_type,
+ hasAccessToken: savedUpstream.has_access_token,
+ account: savedUpstream.account || '',
+ hasPassword: savedUpstream.has_password,
+ }
+ : null
+ const schema = createUpstreamConfigSchema(savedCredential)
+ const form = useForm({
+ resolver: zodResolver(schema) as Resolver,
+ defaultValues: {
+ upstreamType: savedUpstream?.type || 'new_api',
+ baseUrl: props.channel.upstream?.base_url || props.channel.base_url,
+ group: initialGroup,
+ authType: props.channel.upstream?.auth_type || 'public',
+ userId: props.channel.upstream?.user_id || 0,
+ accessToken: '',
+ account: savedUpstream?.account || '',
+ password: '',
+ singleChannelAction: savedUpstream?.single_channel_action || 'none',
+ multipleChannelsAction: savedUpstream?.multiple_channels_action || 'none',
+ ratioSyncEnabled: savedUpstream?.ratio_sync_enabled ?? true,
+ balanceSyncEnabled: savedUpstream?.balance_sync_enabled ?? true,
+ balanceWarningThreshold: savedUpstream?.balance_warning_threshold ?? null,
+ balanceAutoDisableThreshold:
+ savedUpstream?.balance_auto_disable_threshold ?? null,
+ costConversionMode: savedCostConversion.mode,
+ rechargePaidCny:
+ savedCostConversion.mode === 'recharge'
+ ? savedCostConversion.paid_cny
+ : 1,
+ rechargeCreditedUsd:
+ savedCostConversion.mode === 'recharge'
+ ? savedCostConversion.credited_usd
+ : 1,
+ subscriptionPeriod:
+ savedCostConversion.mode === 'subscription'
+ ? savedCostConversion.subscription_period
+ : 'month',
+ subscriptionPriceCny:
+ savedCostConversion.mode === 'subscription'
+ ? savedCostConversion.subscription_price_cny
+ : 1,
+ subscriptionDailyUsd:
+ savedCostConversion.mode === 'subscription'
+ ? savedCostConversion.subscription_daily_usd
+ : 1,
+ customConfig: createChannelMonitorCustomFormConfig(
+ savedUpstream?.custom_config
+ ),
+ },
+ })
+ const upstreamType = useWatch({ control: form.control, name: 'upstreamType' })
+ const baseUrl = useWatch({ control: form.control, name: 'baseUrl' })
+ const authType = useWatch({ control: form.control, name: 'authType' })
+ const accessToken = useWatch({ control: form.control, name: 'accessToken' })
+ const account = useWatch({ control: form.control, name: 'account' })
+ const password = useWatch({ control: form.control, name: 'password' })
+ const ratioSyncEnabled = useWatch({
+ control: form.control,
+ name: 'ratioSyncEnabled',
+ })
+ const balanceSyncEnabled = useWatch({
+ control: form.control,
+ name: 'balanceSyncEnabled',
+ })
+ const multipleChannelsAction = useWatch({
+ control: form.control,
+ name: 'multipleChannelsAction',
+ })
+ const needsUserAuthentication =
+ upstreamType === 'new_api' && authType === 'user'
+ const isSub2API = upstreamType === 'sub2api'
+ const isCustom = upstreamType === 'custom'
+ const needsSub2APIToken = isSub2API && authType === 'token'
+ const needsSub2APIAccount = isSub2API && authType === 'account'
+ const hasMatchingSavedAccessToken =
+ savedCredential?.hasAccessToken === true &&
+ savedCredential.type === upstreamType &&
+ savedCredential.authType === authType
+ const hasSub2APIToken =
+ hasMatchingSavedAccessToken || accessToken.trim().length > 0
+ const hasMatchingSavedPassword =
+ savedCredential?.hasPassword === true &&
+ savedCredential.type === upstreamType &&
+ savedCredential.baseUrl === baseUrl &&
+ savedCredential.authType === authType &&
+ savedCredential.account === account.trim()
+ const hasSub2APIAccountCredential =
+ account.trim().length > 0 &&
+ (hasMatchingSavedPassword || password.length > 0)
+ const canApplyGroup =
+ !isCustom &&
+ (needsUserAuthentication ||
+ (needsSub2APIToken && hasSub2APIToken) ||
+ (needsSub2APIAccount && hasSub2APIAccountCredential))
+ const canLoadGroups =
+ !isCustom &&
+ (!isSub2API ||
+ (needsSub2APIToken && hasSub2APIToken) ||
+ (needsSub2APIAccount && hasSub2APIAccountCredential))
+ const authDescription =
+ authType === 'public'
+ ? '无需账号,读取公开分组倍率'
+ : '读取指定用户的实际分组倍率'
+ let sub2APIAuthDescription = '使用当前渠道配置的 API Key 读取新版倍率和余额'
+ if (authType === 'account') {
+ sub2APIAuthDescription = '使用登录邮箱和密码自动获取并缓存访问 Token'
+ } else if (authType === 'token') {
+ sub2APIAuthDescription = '使用手动获取的旧版 Token 读取倍率、余额和分组'
+ }
+ let applyGroupDescription =
+ '应用分组会保存配置,并将当前渠道的全部上游令牌切换到该分组'
+ if (!canApplyGroup) {
+ if (needsSub2APIAccount) {
+ applyGroupDescription = '应用分组需要先填写登录邮箱和密码'
+ } else if (isSub2API) {
+ applyGroupDescription = '应用分组需要先填写手动 Token'
+ } else {
+ applyGroupDescription = '应用分组需要先选择用户认证'
+ }
+ }
+ let upstreamTypeDescription = '读取 New API 分组倍率'
+ if (isSub2API) {
+ if (authType === 'api_key') {
+ upstreamTypeDescription = '使用当前渠道 API Key 读取新版倍率和余额'
+ } else if (authType === 'account') {
+ upstreamTypeDescription = '自动登录 Sub2API 后读取倍率、余额和分组'
+ } else {
+ upstreamTypeDescription = '使用手动 Token 读取倍率、余额和分组'
+ }
+ } else if (isCustom) {
+ upstreamTypeDescription = '通过固定值或自定义接口读取倍率和余额'
+ }
+ let groupSourceDescription = '从 New API 获取可用分组,也可直接填写名称'
+ if (isSub2API) {
+ if (authType === 'api_key') {
+ groupSourceDescription =
+ 'API Key 认证不提供分组列表,请直接填写分组名称或数字 ID'
+ } else if (authType === 'account') {
+ groupSourceDescription =
+ '账号密码会自动换取 Token,可获取可用分组,也可直接填写分组名称或数字 ID'
+ } else {
+ groupSourceDescription =
+ '手动 Token 可获取可用分组,也可直接填写分组名称或数字 ID'
+ }
+ } else if (isCustom) {
+ groupSourceDescription = '自定义上游分组为可选项,仅用于展示和记录'
+ }
+ const upstreamGroupByName = useMemo(
+ () => new Map(upstreamGroups.map((group) => [group.name, group])),
+ [upstreamGroups]
+ )
+ const upstreamGroupItems = useMemo(() => {
+ const names = upstreamGroups.map((group) => group.name)
+ const customGroup = groupInputValue.trim()
+ if (customGroup && !names.includes(customGroup)) names.push(customGroup)
+ return names
+ }, [groupInputValue, upstreamGroups])
+
+ const saveMutation = useMutation({
+ mutationFn: saveChannelMonitorUpstreamConfig,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: () => {
+ toast.success('上游配置已保存')
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ props.onOpenChange(false)
+ },
+ })
+ const testMutation = useMutation({
+ mutationFn: testChannelMonitorUpstreamConfig,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: (response) => {
+ setTestResult(response.data)
+ if (response.data.balance.error) {
+ toast.warning('上游倍率获取成功,但余额获取失败')
+ } else {
+ toast.success('上游倍率获取成功')
+ }
+ },
+ })
+ const versionMutation = useMutation({
+ mutationFn: fetchChannelMonitorSub2APIUpstreamVersion,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: (response) => {
+ setUpstreamVersion(response.data.version)
+ toast.success(`上游版本:${response.data.version}`)
+ },
+ })
+ const groupsMutation = useMutation({
+ mutationFn: (values: UpstreamConfigFormValues) => {
+ const config = createUpstreamRequest(values)
+ return listChannelMonitorUpstreamGroups({
+ channelId: props.channel.id,
+ config,
+ })
+ },
+ onError: handleChannelMonitorMutationError,
+ onSuccess: (response) => {
+ setUpstreamGroups(response.data.groups)
+ const appliedGroup = response.data.applied_group?.trim()
+ if (appliedGroup) {
+ form.setValue('group', appliedGroup, {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
+ setGroupInputValue(appliedGroup)
+ }
+ toast.success(
+ appliedGroup
+ ? `已获取 ${response.data.groups.length} 个上游分组,并自动选中 ${appliedGroup}`
+ : `已获取 ${response.data.groups.length} 个上游分组`
+ )
+ if (response.data.applied_group_error) {
+ toast.warning(response.data.applied_group_error)
+ }
+ },
+ })
+ const applyGroupMutation = useMutation({
+ mutationFn: async (values: UpstreamConfigFormValues) => {
+ await saveChannelMonitorUpstreamConfig({
+ channelId: props.channel.id,
+ config: createUpstreamRequest(values),
+ })
+ try {
+ const response = await applyChannelMonitorUpstreamGroup(
+ props.channel.id
+ )
+ return { success: true as const, response }
+ } catch (applyError) {
+ return { success: false as const, applyError }
+ }
+ },
+ onError: handleChannelMonitorMutationError,
+ onSuccess: (result, values) => {
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ if (!result.success) {
+ const errorMessage =
+ result.applyError instanceof Error && result.applyError.message
+ ? `:${result.applyError.message}`
+ : ''
+ toast.error(`上游配置已保存,但切换上游令牌分组失败${errorMessage}`)
+ return
+ }
+
+ queryClient.invalidateQueries({
+ queryKey: ['channel-monitor-history', props.channel.id],
+ })
+ toast.success(
+ `已将 ${result.response.data.keys_updated} 个上游令牌切换到分组 ${values.group.trim()},上游倍率 ${formatMonitorRatio(result.response.data.result.ratio)},成本倍率 ${formatMonitorRatio(result.response.data.result.cost_ratio)}`
+ )
+ props.onOpenChange(false)
+ },
+ })
+
+ const requireGroup = (values: UpstreamConfigFormValues) => {
+ if (values.upstreamType === 'custom') return true
+ if (values.group.trim()) return true
+ form.setError('group', {
+ type: 'manual',
+ message: '请输入上游分组',
+ })
+ return false
+ }
+
+ const handleSave = form.handleSubmit((values) => {
+ if (!requireGroup(values)) return
+ saveMutation.mutate({
+ channelId: props.channel.id,
+ config: createUpstreamRequest(values),
+ })
+ })
+ const handleTest = form.handleSubmit((values) => {
+ if (!requireGroup(values)) return
+ testMutation.mutate({
+ channelId: props.channel.id,
+ config: createUpstreamRequest(values),
+ })
+ })
+ const handleLoadGroups = form.handleSubmit((values) => {
+ groupsMutation.mutate(values)
+ })
+ const handleApplyGroup = form.handleSubmit((values) => {
+ if (!requireGroup(values)) return
+ applyGroupMutation.mutate(values)
+ })
+ const handleOpenSub2APILogin = () => {
+ const value = form.getValues('baseUrl').trim()
+ try {
+ const loginUrl = new URL(value)
+ if (loginUrl.protocol !== 'http:' && loginUrl.protocol !== 'https:') {
+ throw new Error('invalid protocol')
+ }
+ let basePath = loginUrl.pathname.replace(/\/+$/, '')
+ if (basePath.endsWith('/v1')) {
+ basePath = basePath.slice(0, -3)
+ }
+ loginUrl.pathname = `${basePath}/login`
+ loginUrl.search = ''
+ loginUrl.hash = ''
+ form.clearErrors('baseUrl')
+ window.open(loginUrl.toString(), '_blank', 'noopener,noreferrer')
+ } catch {
+ form.setError('baseUrl', { message: '请输入有效的面板地址' })
+ }
+ }
+ const handlePasteAccessToken = async () => {
+ if (!navigator.clipboard?.readText) {
+ toast.error('当前浏览器不支持读取剪贴板')
+ return
+ }
+ try {
+ const accessToken = (await navigator.clipboard.readText()).trim()
+ if (!accessToken) {
+ toast.error('剪贴板中没有访问令牌')
+ return
+ }
+ form.setValue('accessToken', accessToken, {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
+ toast.success('Token 已粘贴')
+ } catch {
+ toast.error('读取剪贴板失败,请手动粘贴')
+ }
+ }
+ const handleFetchVersion = () => {
+ const value = baseUrl.trim()
+ if (!value) return
+ setUpstreamVersion(null)
+ versionMutation.mutate({ channelId: props.channel.id, baseUrl: value })
+ }
+ const pending =
+ saveMutation.isPending ||
+ testMutation.isPending ||
+ groupsMutation.isPending ||
+ applyGroupMutation.isPending ||
+ versionMutation.isPending
+
+ return (
+
+
+
+ 上游配置与策略
+
+ {props.channel.name} · ID {props.channel.id}
+
+
+
+
+
+ (
+
+ 上游类型
+
+ {
+ const nextValue = values.find(
+ (value) => value !== field.value
+ )
+ if (
+ nextValue !== 'new_api' &&
+ nextValue !== 'sub2api' &&
+ nextValue !== 'custom'
+ ) {
+ return
+ }
+ field.onChange(nextValue)
+ let nextAuthType: UpstreamConfigFormValues['authType'] =
+ 'public'
+ if (nextValue === 'sub2api') {
+ nextAuthType = 'api_key'
+ } else if (nextValue === 'custom') {
+ nextAuthType = 'custom'
+ }
+ form.setValue('authType', nextAuthType, {
+ shouldValidate: true,
+ })
+ form.setValue('accessToken', '')
+ form.setValue('account', '')
+ form.setValue('password', '')
+ setUpstreamGroups([])
+ setTestResult(null)
+ setUpstreamVersion(null)
+ }}
+ variant='outline'
+ spacing={2}
+ className='grid w-full grid-cols-3'
+ >
+
+ New API
+
+
+ Sub2API
+
+
+ 自定义
+
+
+
+ {upstreamTypeDescription}
+
+
+ )}
+ />
+ (
+
+
+ {isCustom ? '接口基础地址' : '面板地址'}
+
+
+ {
+ field.onChange(event)
+ setUpstreamGroups([])
+ setTestResult(null)
+ setUpstreamVersion(null)
+ }}
+ name={field.name}
+ ref={field.ref}
+ />
+
+
+ {isCustom
+ ? '倍率和余额接口路径会拼接到该地址,渠道代理同样生效'
+ : '填写面板根地址,末尾的 /v1 会自动移除'}
+
+
+
+ )}
+ />
+
+ (
+
+ 上游分组
+ {
+ setGroupComboboxOpen(open)
+ setGroupInputValue(open ? '' : field.value)
+ }}
+ onInputValueChange={setGroupInputValue}
+ onValueChange={(value) => {
+ if (value === null) return
+ field.onChange(value)
+ setGroupInputValue(value)
+ }}
+ >
+
+
+ {
+ const customGroup = groupInputValue.trim()
+ if (customGroup) {
+ field.onChange(customGroup)
+ setGroupInputValue(customGroup)
+ } else {
+ setGroupInputValue(field.value)
+ }
+ field.onBlur()
+ }}
+ />
+
+ {!isCustom ? (
+ <>
+
+ {groupsMutation.isPending ? (
+
+ ) : (
+
+ )}
+ 获取分组
+
+
+ {applyGroupMutation.isPending ? (
+
+ ) : (
+
+ )}
+ 应用分组
+
+ >
+ ) : null}
+
+
+
+
+ {(groupName: string) => {
+ const group = upstreamGroupByName.get(groupName)
+ return (
+
+
+
+ {group
+ ? group.name
+ : `使用“${groupName}”`}
+
+ {group && (
+
+ × {formatMonitorRatio(group.ratio)}
+
+ )}
+
+
+ )
+ }}
+
+
+ 没有可选分组,可直接输入
+
+
+
+ {groupSourceDescription}
+ {!isCustom ? `;${applyGroupDescription}` : ''}
+
+
+
+ )}
+ />
+
+ {isCustom ? (
+
+ ) : null}
+
+
+
+
+
(
+
+
+ 倍率同步
+
+ 关闭后,定时任务和渠道列表不再获取上游倍率
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ 余额同步
+
+ 关闭后,定时任务和渠道列表不再获取上游余额
+
+
+
+
+
+
+ )}
+ />
+
+
+
+ (
+
+ 余额预警值
+
+ {
+ const value = event.target.value
+ field.onChange(value === '' ? null : Number(value))
+ }}
+ name={field.name}
+ ref={field.ref}
+ />
+
+
+ {balanceSyncEnabled
+ ? '定时更新余额低于此值时标红;开启邮件通知后首次进入低余额状态会发送预警,余额恢复后可再次预警'
+ : '余额同步已关闭,不会请求上游余额或触发余额预警'}
+
+
+
+ )}
+ />
+ (
+
+ 余额自动禁用阈值
+
+ {
+ const value = event.target.value
+ field.onChange(value === '' ? null : Number(value))
+ }}
+ name={field.name}
+ ref={field.ref}
+ />
+
+
+ {balanceSyncEnabled
+ ? '余额更新成功后,启用中的渠道余额低于此值会被自动禁用;余额恢复后不会自动启用'
+ : '余额同步已关闭,不会触发余额自动禁用'}
+
+
+
+ )}
+ />
+
+
+
+ (
+
+ 仅剩此渠道时
+
+ value !== null && field.onChange(value)
+ }
+ >
+
+
+
+
+
+
+
+ {SINGLE_CHANNEL_ACTION_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ 目标倍率高于当前分组倍率时执行
+
+
+
+ )}
+ />
+ (
+
+ 存在多个渠道时
+
+ value !== null && field.onChange(value)
+ }
+ >
+
+
+
+
+
+
+
+ {MULTIPLE_CHANNELS_ACTION_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ {
+ MULTIPLE_CHANNELS_ACTION_DESCRIPTIONS[
+ multipleChannelsAction
+ ]
+ }
+
+
+
+ )}
+ />
+
+
+ {upstreamType === 'new_api' ? (
+ (
+
+ 认证方式
+
+ {
+ const nextValue = values.find(
+ (value) => value !== field.value
+ )
+ if (
+ nextValue === 'public' ||
+ nextValue === 'user'
+ ) {
+ field.onChange(nextValue)
+ form.setValue('accessToken', '')
+ setUpstreamGroups([])
+ setTestResult(null)
+ }
+ }}
+ variant='outline'
+ spacing={2}
+ className='grid w-full grid-cols-2'
+ >
+
+ 公开接口
+
+
+ 用户认证
+
+
+
+ {authDescription}
+
+
+ )}
+ />
+ ) : null}
+
+ {isSub2API ? (
+ (
+
+ 认证方式
+
+ {
+ const nextValue = values.find(
+ (value) => value !== field.value
+ )
+ if (
+ nextValue !== 'api_key' &&
+ nextValue !== 'account' &&
+ nextValue !== 'token'
+ ) {
+ return
+ }
+ field.onChange(nextValue)
+ form.setValue('accessToken', '')
+ form.setValue('password', '')
+ setUpstreamGroups([])
+ setTestResult(null)
+ setUpstreamVersion(null)
+ }}
+ variant='outline'
+ spacing={2}
+ className='grid w-full grid-cols-3'
+ >
+
+ API Key(新版)
+
+
+ 账号密码
+
+
+ 手动 Token
+
+
+
+
+ {sub2APIAuthDescription}
+
+
+
+ {versionMutation.isPending ? (
+
+ ) : (
+
+ )}
+ 获取上游版本
+
+ {needsSub2APIToken ? (
+ <>
+
+
+ 打开上游登录
+
+
+ void copyToClipboard(
+ SUB2API_ACCESS_TOKEN_COMMAND
+ )
+ }
+ disabled={pending}
+ >
+
+ 复制控制台命令
+
+ >
+ ) : null}
+ {upstreamVersion ? (
+
+ 当前版本:{upstreamVersion}
+
+ ) : null}
+
+
+
+ )}
+ />
+ ) : null}
+
+ {needsUserAuthentication ? (
+
+ ) : null}
+
+ {needsSub2APIAccount ? (
+
+ ) : null}
+
+ {needsSub2APIToken ? (
+ (
+
+ Sub2API 手动 Token
+
+
+
+
+
void handlePasteAccessToken()}
+ disabled={pending}
+ className='shrink-0'
+ >
+
+ 粘贴
+
+
+
+ 适用于上游开启 Turnstile、Cloudflare 人机验证或 TOTP
+ 的情况;登录后执行已复制的控制台命令,再点击“粘贴”
+
+
+
+ )}
+ />
+ ) : null}
+
+ {testResult && (
+
+
+ 测试成功
+
+
+ 上游倍率 {formatMonitorRatio(testResult.ratio)} · 换算系数{' '}
+ {formatMonitorRatio(testResult.conversion_factor)} ·
+ 成本倍率 {formatMonitorRatio(testResult.cost_ratio)} ·{' '}
+ {testResult.endpoint}
+
+ {isCustom && testResult.balance.amount != null ? (
+
+ 上游余额 {formatMonitorRatio(testResult.balance.amount)}{' '}
+ · {testResult.balance.endpoint || '固定输入'}
+
+ ) : null}
+ {isCustom && testResult.balance.error ? (
+
+ 余额获取失败:{testResult.balance.error}
+
+ ) : null}
+ {isCustom && testResult.debug ? (
+
+ HTTP {testResult.debug.status_code} ·{' '}
+ {testResult.debug.duration_ms} ms
+
+ ) : null}
+ {isCustom && testResult.debug?.response_preview ? (
+
+ {testResult.debug.response_preview}
+
+ ) : null}
+ {isCustom &&
+ testResult.balance.debug &&
+ testResult.balance.endpoint !== testResult.endpoint ? (
+ <>
+
+ 余额接口 HTTP {testResult.balance.debug.status_code} ·{' '}
+ {testResult.balance.debug.duration_ms} ms
+
+ {testResult.balance.debug.response_preview ? (
+
+ {testResult.balance.debug.response_preview}
+
+ ) : null}
+ >
+ ) : null}
+
+
+ )}
+
+
+ props.onOpenChange(false)}
+ disabled={pending}
+ >
+ 取消
+
+ {ratioSyncEnabled || isCustom ? (
+
+ {testMutation.isPending ? (
+
+ ) : (
+
+ )}
+ 测试获取
+
+ ) : null}
+
+ {saveMutation.isPending && (
+
+ )}
+ 保存
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/constants.ts b/web/default/src/features/channel-monitor/constants.ts
new file mode 100644
index 000000000000..2b574fdd3f1c
--- /dev/null
+++ b/web/default/src/features/channel-monitor/constants.ts
@@ -0,0 +1,65 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import type {
+ ChannelMonitorPolicyAction,
+ ChannelMonitorUpstreamAuthType,
+ ChannelMonitorUpstreamType,
+} from './types'
+
+export const CHANNEL_MONITOR_STATUS_LABELS: Partial> = {
+ 0: '未知',
+ 1: '已启用',
+ 2: '手动禁用',
+ 3: '系统禁用',
+}
+
+export function getChannelMonitorStatusLabel(status: number): string {
+ return CHANNEL_MONITOR_STATUS_LABELS[status] ?? '未知状态'
+}
+
+export const CHANNEL_MONITOR_POLICY_ACTION_LABELS: Record<
+ ChannelMonitorPolicyAction,
+ string
+> = {
+ none: '仅记录',
+ update_group_ratio: '更新分组倍率',
+ disable_channel: '禁用渠道',
+ remove_from_group: '移除当前渠道',
+}
+
+export const CHANNEL_MONITOR_UPSTREAM_TYPE_LABELS: Record<
+ ChannelMonitorUpstreamType,
+ string
+> = {
+ new_api: 'New API',
+ sub2api: 'Sub2API',
+ custom: '自定义上游',
+}
+
+export const CHANNEL_MONITOR_UPSTREAM_AUTH_LABELS: Record<
+ ChannelMonitorUpstreamAuthType,
+ string
+> = {
+ public: '公开接口',
+ user: '账号登录',
+ api_key: 'API Key(新版)',
+ account: '账号密码登录',
+ token: '手动 Token',
+ custom: '自定义请求',
+}
diff --git a/web/default/src/features/channel-monitor/icon.tsx b/web/default/src/features/channel-monitor/icon.tsx
new file mode 100644
index 000000000000..fe7b42784d37
--- /dev/null
+++ b/web/default/src/features/channel-monitor/icon.tsx
@@ -0,0 +1,29 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { Analytics01Icon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+
+type ChannelMonitorIconProps = Omit<
+ React.ComponentProps,
+ 'icon'
+>
+
+export function ChannelMonitorIcon(props: ChannelMonitorIconProps) {
+ return
+}
diff --git a/web/default/src/features/channel-monitor/index.tsx b/web/default/src/features/channel-monitor/index.tsx
new file mode 100644
index 000000000000..4dbff0426e37
--- /dev/null
+++ b/web/default/src/features/channel-monitor/index.tsx
@@ -0,0 +1,1239 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import {
+ Analytics01Icon,
+ ArrangeIcon,
+ ChartLineData01Icon,
+ HistoryIcon,
+ Layers01Icon,
+ MoneyBag02Icon,
+ Refresh01Icon,
+ Search01Icon,
+ Settings02Icon,
+ TestTubeIcon,
+} from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { lazy, Suspense, useMemo, useState, type ReactNode } from 'react'
+import { toast } from 'sonner'
+
+import { SectionPageLayout } from '@/components/layout'
+import { Button } from '@/components/ui/button'
+import {
+ Card,
+ CardAction,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyTitle,
+} from '@/components/ui/empty'
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from '@/components/ui/input-group'
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
+import { ChannelTestDialogForChannel } from '@/features/channels/components/dialogs/channel-test-dialog'
+import { CHANNEL_STATUS } from '@/features/channels/constants'
+
+import {
+ fetchChannelMonitorUpstreamBalance,
+ fetchChannelMonitorUpstreamRatio,
+ getChannelMonitorCostOverview,
+ getChannelMonitorOverview,
+ getChannelMonitorPerformance,
+ updateChannelMonitorSmartScheduleConfig,
+ updateMonitoredChannelStatus,
+} from './api'
+import { ChannelMonitorChannelView } from './components/channel-monitor-channel-view'
+import { ChannelMonitorGroupView } from './components/channel-monitor-group-view'
+import { ChannelMonitorModelPerformanceView } from './components/channel-monitor-model-performance-view'
+import { ChannelMonitorOrderDialog } from './components/channel-monitor-order-dialog'
+import {
+ ChannelMonitorSettingsDialog,
+ type ChannelMonitorSettingsSection,
+} from './components/channel-monitor-settings-dialog'
+import { ChannelMonitorSuccessDetailDialog } from './components/channel-monitor-success-detail-dialog'
+import { ChannelMonitorTaskHistoryDialog } from './components/channel-monitor-task-history-dialog'
+import { ChannelRatioHistoryDialog } from './components/channel-ratio-history-dialog'
+import { EditChannelGroupsDialog } from './components/edit-channel-groups-dialog'
+import { EditChannelRatioDialog } from './components/edit-channel-ratio-dialog'
+import { EditGroupChannelsDialog } from './components/edit-group-channels-dialog'
+import { EditGroupRatioDialog } from './components/edit-group-ratio-dialog'
+import { SyncGroupRatioDialog } from './components/sync-group-ratio-dialog'
+import { UpstreamConfigDialog } from './components/upstream-config-dialog'
+import { handleChannelMonitorMutationError } from './lib/error'
+import { formatChannelMonitorCost, formatMonitorRatio } from './lib/format'
+import { sortChannelMonitorItems } from './lib/sort'
+import type {
+ ChannelMonitorChannelPerformance,
+ ChannelMonitorItem,
+ ChannelMonitorPerformanceMetric,
+ ChannelMonitorPerformanceRangeMinutes,
+ ChannelMonitorSettings,
+ ChannelMonitorSortMode,
+ ChannelMonitorGroupSuccessMetric,
+ ChannelMonitorSuccessDetailTarget,
+ ChannelMonitorSuccessMetric,
+ ChannelMonitorSuccessSummary,
+ ChannelMonitorUpstreamType,
+ GroupMonitorItem,
+} from './types'
+
+const LazyChannelMonitorCostHistoryDialog = lazy(() =>
+ import('./components/channel-monitor-cost-history-dialog').then((module) => ({
+ default: module.ChannelMonitorCostHistoryDialog,
+ }))
+)
+const LazyChannelBatchTestDialog = lazy(() =>
+ import('@/features/channels/components/dialogs/channel-batch-test-dialog').then(
+ (module) => ({ default: module.ChannelBatchTestDialog })
+ )
+)
+
+type MonitorView = 'channels' | 'groups' | 'models'
+type ChannelUpstreamFilter = 'all' | ChannelMonitorUpstreamType
+type ChannelDialogType =
+ | 'ratio'
+ | 'groups'
+ | 'upstream'
+ | 'history'
+ | 'connection_test'
+type ChannelDialogState = {
+ channelId: number
+ type: ChannelDialogType
+}
+
+const EMPTY_CHANNELS: ChannelMonitorItem[] = []
+const EMPTY_CHANNEL_ORDER: number[] = []
+const EMPTY_GROUP_RATIOS: Record = {}
+const EMPTY_GROUP_COEFFICIENTS: Record = {}
+const EMPTY_PERFORMANCE_METRICS: ChannelMonitorPerformanceMetric[] = []
+const EMPTY_SUCCESS_METRICS: ChannelMonitorSuccessMetric[] = []
+const EMPTY_GROUP_SUCCESS_METRICS: ChannelMonitorGroupSuccessMetric[] = []
+const DEFAULT_CHANNEL_MONITOR_SETTINGS: ChannelMonitorSettings = {
+ auto_update_interval_minutes: 0,
+ auto_update_retry_count: 2,
+ auto_disable_on_update_failure: false,
+ email_notification_enabled: false,
+ notification_email: '',
+ smart_schedule_enabled: false,
+ smart_schedule_interval_minutes: 10,
+ smart_schedule_strategy: 'smart',
+ smart_schedule_stability_enabled: false,
+ smart_schedule_apply_mode: 'weight',
+ smart_schedule_performance_minutes: 60,
+ smart_schedule_model: '',
+ smart_schedule_models: [],
+ smart_schedule_min_samples: 5,
+}
+const CHANNEL_MONITOR_SORT_STORAGE_KEY = 'channel-monitor:channel-sort'
+const CHANNEL_MONITOR_PERFORMANCE_RANGE_STORAGE_KEY =
+ 'channel-monitor:performance-range:v1'
+const DEFAULT_CHANNEL_MONITOR_PERFORMANCE_MINUTES = 15
+const MIN_CHANNEL_MONITOR_PERFORMANCE_MINUTES = 1
+const MAX_CHANNEL_MONITOR_PERFORMANCE_MINUTES = 1440
+const CHANNEL_MONITOR_SORT_OPTIONS: Array<{
+ value: ChannelMonitorSortMode
+ label: string
+}> = [
+ { value: 'custom', label: '自定义顺序' },
+ { value: 'channel_asc', label: '渠道名称:升序' },
+ { value: 'channel_desc', label: '渠道名称:降序' },
+ { value: 'ratio_desc', label: '成本倍率:从高到低' },
+ { value: 'ratio_asc', label: '成本倍率:从低到高' },
+ { value: 'first_token_asc', label: '首字:从低到高' },
+ { value: 'first_token_desc', label: '首字:从高到低' },
+ { value: 'tps_desc', label: 'TPS:从高到低' },
+ { value: 'tps_asc', label: 'TPS:从低到高' },
+]
+export function ChannelMonitor() {
+ const queryClient = useQueryClient()
+ const [view, setView] = useState('channels')
+ const [upstreamFilter, setUpstreamFilter] =
+ useState('all')
+ const [search, setSearch] = useState('')
+ const [performanceRangeMinutes, setPerformanceRangeMinutes] =
+ useState(() => {
+ try {
+ const storedMinutes = Number(
+ localStorage.getItem(CHANNEL_MONITOR_PERFORMANCE_RANGE_STORAGE_KEY)
+ )
+ if (
+ Number.isInteger(storedMinutes) &&
+ storedMinutes >= MIN_CHANNEL_MONITOR_PERFORMANCE_MINUTES &&
+ storedMinutes <= MAX_CHANNEL_MONITOR_PERFORMANCE_MINUTES
+ ) {
+ return storedMinutes
+ }
+ } catch {}
+ return DEFAULT_CHANNEL_MONITOR_PERFORMANCE_MINUTES
+ })
+ const [performanceRangeInput, setPerformanceRangeInput] = useState(() =>
+ String(performanceRangeMinutes)
+ )
+ const [performanceModelFilter, setPerformanceModelFilter] = useState('')
+ const [settingsOpen, setSettingsOpen] = useState(false)
+ const [settingsSection, setSettingsSection] =
+ useState('monitor')
+ const [taskHistoryOpen, setTaskHistoryOpen] = useState(false)
+ const [costHistoryOpen, setCostHistoryOpen] = useState(false)
+ const [batchTestOpen, setBatchTestOpen] = useState(false)
+ const [orderDialogOpen, setOrderDialogOpen] = useState(false)
+ const [successDetailTarget, setSuccessDetailTarget] =
+ useState(null)
+ const [channelSortMode, setChannelSortMode] =
+ useState(() => {
+ const storedSortMode = localStorage.getItem(
+ CHANNEL_MONITOR_SORT_STORAGE_KEY
+ )
+ switch (storedSortMode) {
+ case 'custom':
+ case 'channel_asc':
+ case 'channel_desc':
+ case 'ratio_asc':
+ case 'ratio_desc':
+ case 'first_token_asc':
+ case 'first_token_desc':
+ case 'tps_asc':
+ case 'tps_desc':
+ return storedSortMode
+ default:
+ return 'ratio_asc'
+ }
+ })
+ const [channelDialog, setChannelDialog] = useState(
+ null
+ )
+ const [editingGroup, setEditingGroup] = useState(
+ null
+ )
+ const [editingGroupChannels, setEditingGroupChannels] =
+ useState(null)
+ const [syncingGroup, setSyncingGroup] = useState(
+ null
+ )
+
+ const query = useQuery({
+ queryKey: ['channel-monitor'],
+ queryFn: getChannelMonitorOverview,
+ })
+ const performanceQuery = useQuery({
+ queryKey: ['channel-monitor-performance', performanceRangeMinutes],
+ queryFn: () => getChannelMonitorPerformance(performanceRangeMinutes),
+ refetchInterval: 60_000,
+ })
+ const costQuery = useQuery({
+ queryKey: ['channel-monitor', 'cost', 2],
+ queryFn: () => getChannelMonitorCostOverview(2),
+ refetchInterval: 60_000,
+ })
+ const ratioFetchMutation = useMutation({
+ mutationFn: fetchChannelMonitorUpstreamRatio,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: (response, channelId) => {
+ toast.success(
+ `已获取上游倍率 ${formatMonitorRatio(response.data.result.ratio)},成本倍率 ${formatMonitorRatio(response.data.result.cost_ratio)}`
+ )
+ queryClient.invalidateQueries({
+ queryKey: ['channel-monitor-history', channelId],
+ })
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ },
+ })
+ const balanceFetchMutation = useMutation({
+ mutationFn: fetchChannelMonitorUpstreamBalance,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: (response) => {
+ const balance = response.data.amount
+ toast.success(
+ balance == null
+ ? '上游未返回余额'
+ : `已更新上游余额:${balance.toLocaleString(undefined, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 4,
+ })}`
+ )
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ },
+ })
+ const statusMutation = useMutation({
+ mutationFn: updateMonitoredChannelStatus,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: (_response, request) => {
+ toast.success(
+ request.status === CHANNEL_STATUS.ENABLED ? '渠道已启用' : '渠道已禁用'
+ )
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ queryClient.invalidateQueries({ queryKey: ['channels'] })
+ },
+ })
+ const smartScheduleConfigMutation = useMutation({
+ mutationFn: updateChannelMonitorSmartScheduleConfig,
+ onError: handleChannelMonitorMutationError,
+ onSuccess: () => {
+ toast.success('渠道调度设置已保存')
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ['channel-monitor'] })
+ },
+ })
+ const overview = query.data?.data
+ const channels = overview?.channels ?? EMPTY_CHANNELS
+ const channelOrder = overview?.channel_order ?? EMPTY_CHANNEL_ORDER
+ const groupRatios = overview?.group_ratios ?? EMPTY_GROUP_RATIOS
+ const groupCoefficients =
+ overview?.group_coefficients ?? EMPTY_GROUP_COEFFICIENTS
+ const settings = overview?.settings ?? DEFAULT_CHANNEL_MONITOR_SETTINGS
+ const performanceMetrics =
+ performanceQuery.data?.data.items ?? EMPTY_PERFORMANCE_METRICS
+ const successMetrics =
+ performanceQuery.data?.data.success_items ?? EMPTY_SUCCESS_METRICS
+ const groupSuccessMetrics =
+ performanceQuery.data?.data.group_success_items ??
+ EMPTY_GROUP_SUCCESS_METRICS
+ const successMetricsAvailable =
+ performanceQuery.data?.data.success_metrics_available ?? false
+ const dialogChannel =
+ channels.find((channel) => channel.id === channelDialog?.channelId) ?? null
+ const autoUpdateIntervalMinutes = settings.auto_update_interval_minutes
+ const autoUpdateLabel =
+ autoUpdateIntervalMinutes > 0
+ ? `自动更新:每 ${autoUpdateIntervalMinutes} 分钟 · 失败重试 ${settings.auto_update_retry_count} 次`
+ : '自动更新:已关闭'
+ const smartScheduleLabel = settings.smart_schedule_enabled
+ ? `智能调度:每 ${settings.smart_schedule_interval_minutes} 分钟`
+ : '智能调度:已关闭'
+ const performanceRangeLabel = `近${performanceRangeMinutes}分钟`
+ const parsedPerformanceRangeMinutes = Number(performanceRangeInput)
+ const isPerformanceRangeInputValid =
+ Number.isInteger(parsedPerformanceRangeMinutes) &&
+ parsedPerformanceRangeMinutes >= MIN_CHANNEL_MONITOR_PERFORMANCE_MINUTES &&
+ parsedPerformanceRangeMinutes <= MAX_CHANNEL_MONITOR_PERFORMANCE_MINUTES
+
+ const applyPerformanceRange = () => {
+ if (!isPerformanceRangeInputValid) {
+ toast.error('统计范围必须是 1 到 1440 之间的整数分钟')
+ setPerformanceRangeInput(String(performanceRangeMinutes))
+ return
+ }
+ if (parsedPerformanceRangeMinutes === performanceRangeMinutes) return
+ setPerformanceRangeMinutes(parsedPerformanceRangeMinutes)
+ try {
+ localStorage.setItem(
+ CHANNEL_MONITOR_PERFORMANCE_RANGE_STORAGE_KEY,
+ String(parsedPerformanceRangeMinutes)
+ )
+ } catch {}
+ }
+
+ const groups = useMemo(() => {
+ const groupNames = new Set(Object.keys(groupRatios))
+ for (const channel of channels) {
+ for (const group of channel.groups) groupNames.add(group)
+ }
+ return [...groupNames]
+ .sort((a, b) => a.localeCompare(b))
+ .map((name) => ({
+ name,
+ ratio: groupRatios[name] ?? 1,
+ coefficient: groupCoefficients[name] ?? 1,
+ channels: channels.filter((channel) => channel.groups.includes(name)),
+ }))
+ }, [channels, groupCoefficients, groupRatios])
+
+ const normalizedSearch = search.trim().toLocaleLowerCase()
+ const matchingChannels = useMemo(
+ () =>
+ channels.filter((channel) => {
+ if (
+ upstreamFilter !== 'all' &&
+ channel.upstream?.type !== upstreamFilter
+ ) {
+ return false
+ }
+ if (!normalizedSearch) return true
+ return (
+ channel.name.toLocaleLowerCase().includes(normalizedSearch) ||
+ String(channel.id).includes(normalizedSearch) ||
+ channel.groups.some((group) =>
+ group.toLocaleLowerCase().includes(normalizedSearch)
+ )
+ )
+ }),
+ [channels, normalizedSearch, upstreamFilter]
+ )
+ const filteredGroups = useMemo(() => {
+ if (!normalizedSearch) return groups
+ return groups.filter(
+ (group) =>
+ group.name.toLocaleLowerCase().includes(normalizedSearch) ||
+ group.channels.some((channel) =>
+ channel.name.toLocaleLowerCase().includes(normalizedSearch)
+ )
+ )
+ }, [groups, normalizedSearch])
+ const performanceByChannel = useMemo(() => {
+ type PerformanceAggregate = {
+ sampleCount: number
+ firstTokenSampleCount: number
+ tpsSampleCount: number
+ firstTokenTotalMs: number
+ tpsTotal: number
+ lastUsedTime: number
+ }
+ const aggregates = new Map()
+ for (const metric of performanceMetrics) {
+ const aggregate = aggregates.get(metric.channel_id) ?? {
+ sampleCount: 0,
+ firstTokenSampleCount: 0,
+ tpsSampleCount: 0,
+ firstTokenTotalMs: 0,
+ tpsTotal: 0,
+ lastUsedTime: 0,
+ }
+ aggregate.sampleCount += metric.sample_count
+ if (
+ metric.average_first_token_ms != null &&
+ metric.first_token_sample_count > 0
+ ) {
+ aggregate.firstTokenSampleCount += metric.first_token_sample_count
+ aggregate.firstTokenTotalMs +=
+ metric.average_first_token_ms * metric.first_token_sample_count
+ }
+ if (metric.average_tps != null && metric.tps_sample_count > 0) {
+ aggregate.tpsSampleCount += metric.tps_sample_count
+ aggregate.tpsTotal += metric.average_tps * metric.tps_sample_count
+ }
+ aggregate.lastUsedTime = Math.max(
+ aggregate.lastUsedTime,
+ metric.last_used_time
+ )
+ aggregates.set(metric.channel_id, aggregate)
+ }
+
+ const result = new Map()
+ for (const [channelId, aggregate] of aggregates) {
+ result.set(channelId, {
+ sample_count: aggregate.sampleCount,
+ first_token_sample_count: aggregate.firstTokenSampleCount,
+ tps_sample_count: aggregate.tpsSampleCount,
+ average_first_token_ms:
+ aggregate.firstTokenSampleCount > 0
+ ? aggregate.firstTokenTotalMs / aggregate.firstTokenSampleCount
+ : null,
+ average_tps:
+ aggregate.tpsSampleCount > 0
+ ? aggregate.tpsTotal / aggregate.tpsSampleCount
+ : null,
+ last_used_time: aggregate.lastUsedTime,
+ })
+ }
+ return result
+ }, [performanceMetrics])
+ const successByChannel = useMemo(() => {
+ const result = new Map()
+ for (const metric of successMetrics) {
+ const summary = result.get(metric.channel_id) ?? {
+ actual_success_count: 0,
+ actual_failure_count: 0,
+ actual_sample_count: 0,
+ actual_success_rate: 0,
+ final_success_count: 0,
+ final_failure_count: 0,
+ final_sample_count: 0,
+ final_success_rate: 0,
+ }
+ summary.actual_success_count += metric.actual_success_count
+ summary.actual_failure_count += metric.actual_failure_count
+ summary.actual_sample_count =
+ summary.actual_success_count + summary.actual_failure_count
+ summary.actual_success_rate =
+ summary.actual_sample_count > 0
+ ? summary.actual_success_count / summary.actual_sample_count
+ : 0
+ summary.final_success_count += metric.final_success_count
+ summary.final_failure_count += metric.final_failure_count
+ summary.final_sample_count =
+ summary.final_success_count + summary.final_failure_count
+ summary.final_success_rate =
+ summary.final_sample_count > 0
+ ? summary.final_success_count / summary.final_sample_count
+ : 0
+ result.set(metric.channel_id, summary)
+ }
+ return result
+ }, [successMetrics])
+ const successByGroup = useMemo(
+ () => new Map(groupSuccessMetrics.map((metric) => [metric.group, metric])),
+ [groupSuccessMetrics]
+ )
+ const filteredChannels = useMemo(
+ () =>
+ sortChannelMonitorItems(
+ matchingChannels,
+ channelSortMode,
+ channelOrder,
+ performanceByChannel
+ ),
+ [channelOrder, channelSortMode, matchingChannels, performanceByChannel]
+ )
+ const performanceModelOptions = useMemo(
+ () =>
+ [
+ ...new Set([
+ ...performanceMetrics.map((metric) => metric.model_name),
+ ...successMetrics.map((metric) => metric.model_name),
+ ]),
+ ]
+ .sort((first, second) => first.localeCompare(second))
+ .map((modelName) => ({ value: modelName, label: modelName })),
+ [performanceMetrics, successMetrics]
+ )
+ const smartScheduleModelOptions = useMemo(() => {
+ const models = new Set(
+ performanceMetrics.map((metric) => metric.model_name).filter(Boolean)
+ )
+ for (const channel of channels) {
+ for (const model of channel.models.split(',')) {
+ const modelName = model.trim()
+ if (modelName) models.add(modelName)
+ }
+ }
+ for (const modelName of settings.smart_schedule_models ?? []) {
+ if (modelName) models.add(modelName)
+ }
+ if (
+ (settings.smart_schedule_models?.length ?? 0) === 0 &&
+ settings.smart_schedule_model
+ ) {
+ models.add(settings.smart_schedule_model)
+ }
+ return [...models].sort((first, second) => first.localeCompare(second))
+ }, [
+ channels,
+ performanceMetrics,
+ settings.smart_schedule_model,
+ settings.smart_schedule_models,
+ ])
+ const activePerformanceModel = performanceModelOptions.some(
+ (option) => option.value === performanceModelFilter
+ )
+ ? performanceModelFilter
+ : (performanceModelOptions[0]?.value ?? '')
+
+ const recordedCount = channels.filter(
+ (channel) => channel.cost_ratio != null
+ ).length
+ const costOverview = costQuery.data?.data
+ let costDescription = costOverview
+ ? `昨日 ${formatChannelMonitorCost(costOverview.yesterday_cost_cny)} · 当前倍率估算`
+ : '按北京时间和当前倍率估算'
+ if (costQuery.isError) {
+ costDescription = '成本统计加载失败'
+ } else if (costOverview?.coverage.unresolved_channel_count) {
+ costDescription = `${costOverview.coverage.unresolved_channel_count} 个有用量渠道暂无法回算`
+ } else if (costOverview?.coverage.included_channel_count === 0) {
+ costDescription = '暂无可回算的成本数据'
+ }
+ const newAPIChannelCount = channels.filter(
+ (channel) => channel.upstream?.type === 'new_api'
+ ).length
+ const sub2APIChannelCount = channels.filter(
+ (channel) => channel.upstream?.type === 'sub2api'
+ ).length
+ const customUpstreamChannelCount = channels.filter(
+ (channel) => channel.upstream?.type === 'custom'
+ ).length
+
+ let pageContent: ReactNode
+ if (query.isLoading) {
+ pageContent =
+ } else if (query.isError) {
+ pageContent = (
+
+
+ 渠道监控加载失败
+ 请刷新后重试
+
+
+ )
+ } else {
+ pageContent = (
+
+
+
+
+ ) : (
+ formatChannelMonitorCost(costOverview?.today_cost_cny)
+ )
+ }
+ description={costDescription}
+ icon={MoneyBag02Icon}
+ action={{
+ label: '查看每日成本',
+ icon: HistoryIcon,
+ onClick: () => setCostHistoryOpen(true),
+ }}
+ />
+
+
+
+
setView(value as MonitorView)}
+ className='gap-4'
+ >
+
+
+
+
+ 渠道 {channels.length}
+
+
+
+ 分组 {groups.length}
+
+
+
+ 模型性能 {performanceModelOptions.length}
+
+
+
+
+ {view === 'channels' && (
+
{
+ const nextValue = values.find(
+ (value) => value !== upstreamFilter
+ )
+ if (
+ nextValue !== 'all' &&
+ nextValue !== 'new_api' &&
+ nextValue !== 'sub2api' &&
+ nextValue !== 'custom'
+ ) {
+ return
+ }
+ setUpstreamFilter(nextValue)
+ }}
+ variant='outline'
+ size='sm'
+ spacing={0}
+ aria-label='按上游类型筛选渠道'
+ className='grid w-full grid-cols-2 sm:w-auto sm:grid-cols-4'
+ >
+
+ 全部 {channels.length}
+
+
+ New API {newAPIChannelCount}
+
+
+ Sub2API {sub2APIChannelCount}
+
+
+ 自定义 {customUpstreamChannelCount}
+
+
+ )}
+
+ {view === 'channels' && (
+
+ {
+ if (value === null) return
+ setChannelSortMode(value)
+ localStorage.setItem(
+ CHANNEL_MONITOR_SORT_STORAGE_KEY,
+ value
+ )
+ }}
+ >
+
+
+
+
+
+ {CHANNEL_MONITOR_SORT_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ {channelSortMode === 'custom' && (
+ setOrderDialogOpen(true)}
+ className='shrink-0'
+ >
+
+ 调整顺序
+
+ )}
+
+ )}
+
+ {view === 'models' && (
+
+ {
+ if (value !== null) setPerformanceModelFilter(value)
+ }}
+ >
+
+
+
+
+
+ {performanceModelOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ )}
+
+
+ 近
+
+ setPerformanceRangeInput(event.target.value)
+ }
+ onBlur={applyPerformanceRange}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') event.currentTarget.blur()
+ }}
+ aria-label='性能与成功率统计范围(分钟)'
+ aria-invalid={!isPerformanceRangeInputValid}
+ className='min-w-0 text-right font-mono'
+ />
+ 分钟
+
+
+
+
+
+
+ setSearch(event.target.value)}
+ placeholder={
+ view === 'models' ? '搜索渠道' : '搜索渠道或分组'
+ }
+ aria-label={view === 'models' ? '搜索渠道' : '搜索渠道或分组'}
+ />
+
+
+
+
+
+
+ balanceFetchMutation.mutate(channel.id)
+ }
+ onFetchUpstreamRatio={(channel) =>
+ ratioFetchMutation.mutate(channel.id)
+ }
+ onTestConnection={(channel) =>
+ setChannelDialog({
+ channelId: channel.id,
+ type: 'connection_test',
+ })
+ }
+ onToggleStatus={(channel) =>
+ statusMutation.mutate({
+ channelId: channel.id,
+ status:
+ channel.status === CHANNEL_STATUS.ENABLED
+ ? CHANNEL_STATUS.MANUAL_DISABLED
+ : CHANNEL_STATUS.ENABLED,
+ })
+ }
+ onEditRatio={(channel) =>
+ setChannelDialog({ channelId: channel.id, type: 'ratio' })
+ }
+ onEditGroups={(channel) =>
+ setChannelDialog({ channelId: channel.id, type: 'groups' })
+ }
+ onConfigureUpstream={(channel) =>
+ setChannelDialog({ channelId: channel.id, type: 'upstream' })
+ }
+ onViewHistory={(channel) =>
+ setChannelDialog({ channelId: channel.id, type: 'history' })
+ }
+ onOpenSuccessDetail={(channel) =>
+ setSuccessDetailTarget({
+ scope: 'channel',
+ mode: 'actual',
+ channelId: channel.id,
+ channelName: channel.name,
+ })
+ }
+ onUpdateSmartSchedule={(channel, excluded) =>
+ smartScheduleConfigMutation.mutate({
+ channelId: channel.id,
+ excluded,
+ reset: !excluded,
+ })
+ }
+ smartScheduleEnabled={settings.smart_schedule_enabled}
+ fetchingBalanceChannelId={
+ balanceFetchMutation.isPending
+ ? balanceFetchMutation.variables
+ : null
+ }
+ fetchingRatioChannelId={
+ ratioFetchMutation.isPending
+ ? ratioFetchMutation.variables
+ : null
+ }
+ updatingStatusChannelId={
+ statusMutation.isPending
+ ? (statusMutation.variables?.channelId ?? null)
+ : null
+ }
+ updatingSmartScheduleChannelId={
+ smartScheduleConfigMutation.isPending
+ ? (smartScheduleConfigMutation.variables?.channelId ?? null)
+ : null
+ }
+ />
+
+
+
+ setSuccessDetailTarget({
+ scope: 'group',
+ mode,
+ groupName: group.name,
+ })
+ }
+ onOpenScheduleSettings={() => {
+ setSettingsSection('schedule')
+ setSettingsOpen(true)
+ }}
+ onEditChannels={setEditingGroupChannels}
+ onEditGroup={setEditingGroup}
+ onSyncGroup={setSyncingGroup}
+ />
+
+
+
+ setSuccessDetailTarget({
+ scope: 'channel',
+ mode: 'actual',
+ channelId: channel.id,
+ channelName: channel.name,
+ modelName,
+ })
+ }
+ />
+
+
+
+ )
+ }
+
+ return (
+ <>
+
+ 渠道监控
+
+
+ setBatchTestOpen(true)}
+ aria-label='渠道连通性测试'
+ >
+
+
+ }
+ />
+
+ 批量测试渠道,或对单个渠道和模型进行并发循环测试
+
+
+
+ setTaskHistoryOpen(true)}
+ aria-label='定时任务记录'
+ >
+
+
+ }
+ />
+ 定时任务记录
+
+
+ {
+ setSettingsSection('monitor')
+ setSettingsOpen(true)
+ }}
+ aria-label='渠道监控设置'
+ >
+
+
+ }
+ />
+
+ {autoUpdateLabel};{smartScheduleLabel}
+
+
+
+ {
+ query.refetch()
+ performanceQuery.refetch()
+ costQuery.refetch()
+ }}
+ disabled={
+ query.isFetching ||
+ performanceQuery.isFetching ||
+ costQuery.isFetching
+ }
+ aria-label='刷新'
+ >
+
+
+ }
+ />
+ 刷新
+
+
+ {pageContent}
+
+
+ {dialogChannel && channelDialog?.type === 'ratio' && (
+ {
+ if (!open) setChannelDialog(null)
+ }}
+ />
+ )}
+ {dialogChannel && channelDialog?.type === 'groups' && (
+ {
+ if (!open) setChannelDialog(null)
+ }}
+ />
+ )}
+ {dialogChannel && channelDialog?.type === 'upstream' && (
+ {
+ if (!open) setChannelDialog(null)
+ }}
+ />
+ )}
+ {dialogChannel && channelDialog?.type === 'history' && (
+ {
+ if (!open) setChannelDialog(null)
+ }}
+ />
+ )}
+ {dialogChannel && channelDialog?.type === 'connection_test' && (
+ {
+ if (!open) setChannelDialog(null)
+ }}
+ />
+ )}
+ {editingGroup && (
+ {
+ if (!open) setEditingGroup(null)
+ }}
+ />
+ )}
+ {editingGroupChannels && (
+ {
+ if (!open) setEditingGroupChannels(null)
+ }}
+ />
+ )}
+ {syncingGroup && (
+ {
+ if (!open) setSyncingGroup(null)
+ }}
+ />
+ )}
+ {settingsOpen && (
+
+ )}
+ {taskHistoryOpen && (
+
+ )}
+ {costHistoryOpen && (
+
+
+
+ )}
+ {batchTestOpen && (
+
+
+
+ )}
+ {orderDialogOpen && (
+
+ )}
+ {successDetailTarget && (
+ {
+ if (!open) setSuccessDetailTarget(null)
+ }}
+ />
+ )}
+ >
+ )
+}
+
+type MonitorStatCardProps = {
+ label: string
+ value: ReactNode
+ description: string
+ icon: React.ComponentProps['icon']
+ action?: {
+ label: string
+ icon: React.ComponentProps['icon']
+ onClick: () => void
+ }
+}
+
+function MonitorStatCard(props: MonitorStatCardProps) {
+ return (
+
+
+ {props.label}
+ {props.value}
+
+
+
+
+
+ {props.action && (
+
+
+
+
+ }
+ />
+ {props.action.label}
+
+ )}
+
+
+ {props.description}
+
+
+ )
+}
+
+function ChannelMonitorSkeleton() {
+ return (
+
+
+
+
+ )
+}
diff --git a/web/default/src/features/channel-monitor/lib/cost-conversion.ts b/web/default/src/features/channel-monitor/lib/cost-conversion.ts
new file mode 100644
index 000000000000..396b4c6d9675
--- /dev/null
+++ b/web/default/src/features/channel-monitor/lib/cost-conversion.ts
@@ -0,0 +1,52 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import type { ChannelMonitorCostConversion } from '../types'
+
+export const CHANNEL_MONITOR_SUBSCRIPTION_DAYS = {
+ day: 1,
+ week: 7,
+ month: 30,
+} as const
+
+export function getChannelMonitorConversionFactor(
+ config: ChannelMonitorCostConversion
+): number | null {
+ let factor = 1
+ if (config.mode === 'recharge') {
+ factor = config.paid_cny / config.credited_usd
+ } else if (config.mode === 'subscription') {
+ factor =
+ config.subscription_price_cny /
+ (config.subscription_daily_usd *
+ CHANNEL_MONITOR_SUBSCRIPTION_DAYS[config.subscription_period])
+ }
+ return Number.isFinite(factor) && factor > 0 ? factor : null
+}
+
+export function getChannelMonitorCostRatio(
+ upstreamRatio: number | null | undefined,
+ config: ChannelMonitorCostConversion
+): number | null {
+ if (upstreamRatio == null || !Number.isFinite(upstreamRatio)) return null
+ const factor = getChannelMonitorConversionFactor(config)
+ if (factor == null) return null
+ const costRatio = upstreamRatio * factor
+ return Number.isFinite(costRatio) ? costRatio : null
+}
diff --git a/web/default/src/features/channel-monitor/lib/custom-upstream.ts b/web/default/src/features/channel-monitor/lib/custom-upstream.ts
new file mode 100644
index 000000000000..0ee5995ab146
--- /dev/null
+++ b/web/default/src/features/channel-monitor/lib/custom-upstream.ts
@@ -0,0 +1,137 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import type {
+ ChannelMonitorCustomKeyValue,
+ ChannelMonitorCustomMetricConfig,
+ ChannelMonitorCustomRequestConfig,
+ ChannelMonitorCustomUpstreamConfig,
+} from '../types'
+import type { UpstreamConfigFormValues } from './schema'
+
+type CustomMetricFormValue = UpstreamConfigFormValues['customConfig']['ratio']
+type CustomRequestFormValue = CustomMetricFormValue['request']
+
+function createCustomRequestFormValue(
+ request: ChannelMonitorCustomRequestConfig | undefined
+): CustomRequestFormValue {
+ return {
+ method: request?.method ?? 'GET',
+ path: request?.path ?? '/api/monitor',
+ query: (request?.query ?? []).map(toFormKeyValue),
+ headers: (request?.headers ?? []).map(toFormKeyValue),
+ bodyType: request?.body_type ?? 'none',
+ body: request?.body ?? '',
+ bodySecret: request?.body_secret ?? false,
+ hasBody: request?.has_body ?? false,
+ form: (request?.form ?? []).map(toFormKeyValue),
+ }
+}
+
+function createCustomMetricFormValue(
+ metric: ChannelMonitorCustomMetricConfig | undefined,
+ fixedValue: number,
+ valuePath: string
+): CustomMetricFormValue {
+ return {
+ source: metric?.source ?? 'fixed',
+ fixedValue: metric?.fixed_value ?? fixedValue,
+ request: createCustomRequestFormValue(metric?.request),
+ result: {
+ responseType: metric?.result?.response_type ?? 'json',
+ valuePath: metric?.result?.value_path ?? valuePath,
+ multiplier: metric?.result?.multiplier ?? 1,
+ },
+ }
+}
+
+function toFormKeyValue(value: ChannelMonitorCustomKeyValue) {
+ return {
+ key: value.key,
+ value: value.value ?? '',
+ secret: value.secret ?? false,
+ hasValue: value.has_value ?? false,
+ }
+}
+
+function toAPIKeyValue(
+ value: CustomRequestFormValue['query'][number]
+): ChannelMonitorCustomKeyValue {
+ return {
+ key: value.key.trim(),
+ value: value.value,
+ secret: value.secret,
+ has_value: value.hasValue,
+ }
+}
+
+function toAPIRequest(
+ request: CustomRequestFormValue
+): ChannelMonitorCustomRequestConfig {
+ return {
+ method: request.method,
+ path: request.path.trim(),
+ query: request.query.map(toAPIKeyValue),
+ headers: request.headers.map(toAPIKeyValue),
+ body_type: request.bodyType,
+ body: request.body,
+ body_secret: request.bodySecret,
+ has_body: request.hasBody,
+ form: request.form.map(toAPIKeyValue),
+ }
+}
+
+function toAPIMetric(
+ metric: CustomMetricFormValue,
+ omitRequest: boolean
+): ChannelMonitorCustomMetricConfig {
+ if (metric.source === 'fixed') {
+ return { source: 'fixed', fixed_value: metric.fixedValue }
+ }
+ return {
+ source: 'http',
+ request: omitRequest ? undefined : toAPIRequest(metric.request),
+ result: {
+ response_type: metric.result.responseType,
+ value_path: metric.result.valuePath.trim(),
+ multiplier: metric.result.multiplier,
+ },
+ }
+}
+
+export function createChannelMonitorCustomFormConfig(
+ config: ChannelMonitorCustomUpstreamConfig | undefined
+): UpstreamConfigFormValues['customConfig'] {
+ return {
+ version: 1,
+ ratio: createCustomMetricFormValue(config?.ratio, 1, 'data.ratio'),
+ balance: createCustomMetricFormValue(config?.balance, 0, 'data.balance'),
+ balanceReuseRatioRequest: config?.balance_reuse_ratio_request ?? false,
+ }
+}
+
+export function createChannelMonitorCustomRequestConfig(
+ config: UpstreamConfigFormValues['customConfig']
+): ChannelMonitorCustomUpstreamConfig {
+ return {
+ version: 1,
+ ratio: toAPIMetric(config.ratio, false),
+ balance: toAPIMetric(config.balance, config.balanceReuseRatioRequest),
+ balance_reuse_ratio_request: config.balanceReuseRatioRequest,
+ }
+}
diff --git a/web/default/src/features/channel-monitor/lib/error.ts b/web/default/src/features/channel-monitor/lib/error.ts
new file mode 100644
index 000000000000..a24dd9efc187
--- /dev/null
+++ b/web/default/src/features/channel-monitor/lib/error.ts
@@ -0,0 +1,44 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { AxiosError } from 'axios'
+import { toast } from 'sonner'
+
+type ChannelMonitorErrorResponse = {
+ message?: string
+ title?: string
+}
+
+export function handleChannelMonitorMutationError(error: unknown) {
+ if (error instanceof AxiosError) {
+ const response = error.response?.data as
+ | ChannelMonitorErrorResponse
+ | undefined
+ toast.error(
+ response?.message ||
+ response?.title ||
+ error.message ||
+ '渠道监控请求失败'
+ )
+ return
+ }
+
+ toast.error(
+ error instanceof Error && error.message ? error.message : '渠道监控请求失败'
+ )
+}
diff --git a/web/default/src/features/channel-monitor/lib/format.ts b/web/default/src/features/channel-monitor/lib/format.ts
new file mode 100644
index 000000000000..007a1c893c5b
--- /dev/null
+++ b/web/default/src/features/channel-monitor/lib/format.ts
@@ -0,0 +1,85 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+const channelMonitorCostFormatter = new Intl.NumberFormat('zh-CN', {
+ style: 'currency',
+ currency: 'CNY',
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+})
+
+export function formatMonitorRatio(value: number | null | undefined): string {
+ if (value == null || !Number.isFinite(value)) return '-'
+ return value.toLocaleString(undefined, {
+ maximumFractionDigits: 6,
+ useGrouping: false,
+ })
+}
+
+export function formatChannelMonitorCost(
+ value: number | null | undefined
+): string {
+ if (value == null || !Number.isFinite(value)) return '-'
+ return channelMonitorCostFormatter.format(Math.abs(value) < 0.005 ? 0 : value)
+}
+
+export function getRatioChange(
+ current: number | null,
+ previous: number | null
+): { direction: 'up' | 'down' | 'same' | 'baseline'; percent: number | null } {
+ if (current == null || previous == null) {
+ return { direction: 'baseline', percent: null }
+ }
+ if (Math.abs(current - previous) <= 1e-9) {
+ return { direction: 'same', percent: 0 }
+ }
+ if (previous === 0) {
+ return {
+ direction: current > previous ? 'up' : 'down',
+ percent: null,
+ }
+ }
+ return {
+ direction: current > previous ? 'up' : 'down',
+ percent: ((current - previous) / previous) * 100,
+ }
+}
+
+export function formatChangePercent(percent: number | null): string {
+ if (percent == null || !Number.isFinite(percent)) return '-'
+ const prefix = percent > 0 ? '+' : ''
+ return `${prefix}${percent.toFixed(2)}%`
+}
+
+export function getChannelGroupTargetRatio(
+ upstreamRatio: number | null,
+ coefficient: number
+): number | null {
+ if (upstreamRatio == null) return null
+ const target = upstreamRatio * coefficient
+ return Number.isFinite(target) ? target : null
+}
+
+export function isChannelGroupRatioSynced(
+ upstreamRatio: number | null,
+ coefficient: number,
+ groupRatio: number
+): boolean {
+ const target = getChannelGroupTargetRatio(upstreamRatio, coefficient)
+ return target != null && Math.abs(target - groupRatio) <= 1e-9
+}
diff --git a/web/default/src/features/channel-monitor/lib/schema.ts b/web/default/src/features/channel-monitor/lib/schema.ts
new file mode 100644
index 000000000000..73d35252346f
--- /dev/null
+++ b/web/default/src/features/channel-monitor/lib/schema.ts
@@ -0,0 +1,662 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import * as z from 'zod'
+
+import type {
+ ChannelMonitorPolicyAction,
+ ChannelMonitorSmartScheduleApplyMode,
+ ChannelMonitorSmartScheduleStrategy,
+ ChannelMonitorUpstreamAuthType,
+ ChannelMonitorUpstreamType,
+} from '../types'
+import { CHANNEL_MONITOR_SUBSCRIPTION_DAYS } from './cost-conversion'
+
+export const MAX_MONITOR_RATIO = 1_000_000
+export const MAX_BALANCE_THRESHOLD = 1_000_000_000_000
+export const MAX_COST_CONVERSION_AMOUNT = 1_000_000_000_000
+export const MAX_CUSTOM_UPSTREAM_BALANCE = 1_000_000_000_000_000
+export const MAX_CUSTOM_UPSTREAM_ENTRIES = 32
+export const MAX_CUSTOM_UPSTREAM_BODY_BYTES = 49_152
+export const MAX_AUTO_UPDATE_INTERVAL_MINUTES = 525_600
+export const MAX_AUTO_UPDATE_RETRY_COUNT = 10
+export const MAX_SMART_SCHEDULE_MIN_SAMPLES = 100_000
+export const MAX_SMART_SCHEDULE_MODEL_COUNT = 100
+
+const channelMonitorSmartScheduleApplyModes = [
+ 'weight',
+ 'priority_weight',
+] as const satisfies readonly ChannelMonitorSmartScheduleApplyMode[]
+
+const channelMonitorSmartScheduleStrategies = [
+ 'ratio',
+ 'first_token',
+ 'tps',
+ 'smart',
+] as const satisfies readonly ChannelMonitorSmartScheduleStrategy[]
+
+const channelMonitorPolicyActions = [
+ 'none',
+ 'update_group_ratio',
+ 'disable_channel',
+ 'remove_from_group',
+] as const satisfies readonly ChannelMonitorPolicyAction[]
+
+export function createChannelRatioSchema() {
+ return z.object({
+ ratio: z.coerce
+ .number()
+ .finite('倍率必须是有效数字')
+ .min(0, '倍率不能小于 0')
+ .max(MAX_MONITOR_RATIO, '倍率不能超过 1000000'),
+ remark: z.string().max(255, '备注不能超过 255 个字符'),
+ })
+}
+
+export function createGroupRatioSchema() {
+ return z.object({
+ ratio: z.coerce
+ .number()
+ .finite('倍率必须是有效数字')
+ .min(0, '倍率不能小于 0')
+ .max(MAX_MONITOR_RATIO, '倍率不能超过 1000000'),
+ })
+}
+
+export function createChannelMonitorSettingsSchema() {
+ return z
+ .object({
+ autoUpdateIntervalMinutes: z.coerce
+ .number()
+ .int('自动更新间隔必须是整数')
+ .min(0, '自动更新间隔不能小于 0')
+ .max(
+ MAX_AUTO_UPDATE_INTERVAL_MINUTES,
+ '自动更新间隔不能超过 525600 分钟'
+ ),
+ autoUpdateRetryCount: z.coerce
+ .number()
+ .int('失败重试次数必须是整数')
+ .min(0, '失败重试次数不能小于 0')
+ .max(MAX_AUTO_UPDATE_RETRY_COUNT, '失败重试次数不能超过 10 次'),
+ autoDisableOnUpdateFailure: z.boolean(),
+ emailNotificationEnabled: z.boolean(),
+ notificationEmail: z
+ .string()
+ .trim()
+ .max(254, '通知邮箱不能超过 254 个字符')
+ .refine(
+ (value) =>
+ value === '' || z.string().email().safeParse(value).success,
+ '请输入有效的通知邮箱'
+ ),
+ smartScheduleEnabled: z.boolean(),
+ smartScheduleIntervalMinutes: z.coerce
+ .number()
+ .int('智能调度间隔必须是整数')
+ .min(1, '智能调度间隔不能小于 1 分钟')
+ .max(
+ MAX_AUTO_UPDATE_INTERVAL_MINUTES,
+ '智能调度间隔不能超过 525600 分钟'
+ ),
+ smartScheduleStrategy: z.enum(channelMonitorSmartScheduleStrategies),
+ smartScheduleStabilityEnabled: z.boolean(),
+ smartScheduleApplyMode: z.enum(channelMonitorSmartScheduleApplyModes),
+ smartSchedulePerformanceMinutes: z.union([
+ z.literal(15),
+ z.literal(60),
+ z.literal(360),
+ z.literal(1440),
+ ]),
+ smartScheduleModels: z
+ .array(
+ z
+ .string()
+ .trim()
+ .min(1, '基准模型不能为空')
+ .max(255, '基准模型不能超过 255 个字符')
+ )
+ .max(MAX_SMART_SCHEDULE_MODEL_COUNT, '基准模型不能超过 100 个'),
+ smartScheduleMinSamples: z.coerce
+ .number()
+ .int('最少样本数必须是整数')
+ .min(1, '最少样本数不能小于 1')
+ .max(MAX_SMART_SCHEDULE_MIN_SAMPLES, '最少样本数不能超过 100000'),
+ smartScheduleForceReset: z.boolean(),
+ })
+ .superRefine((values, context) => {
+ if (values.emailNotificationEnabled && !values.notificationEmail) {
+ context.addIssue({
+ code: 'custom',
+ path: ['notificationEmail'],
+ message: '开启邮件通知时请填写通知邮箱',
+ })
+ }
+ })
+}
+
+export function createChannelGroupsSchema() {
+ return z.object({
+ groups: z
+ .array(
+ z
+ .string()
+ .trim()
+ .min(1, '分组名称不能为空')
+ .max(64, '单个分组名称不能超过 64 个字符')
+ )
+ .min(1, '请至少选择一个关联分组')
+ .refine(
+ (groups) => groups.join(',').length <= 64,
+ '关联分组名称合计不能超过 64 个字符'
+ ),
+ })
+}
+
+export function createGroupRatioSyncSchema(highestCostRatio: number | null) {
+ return z
+ .object({
+ coefficient: z.coerce
+ .number()
+ .finite('系数必须是有效数字')
+ .min(0, '系数不能小于 0')
+ .max(MAX_MONITOR_RATIO, '系数不能超过 1000000'),
+ })
+ .superRefine((values, context) => {
+ if (highestCostRatio == null) return
+ if (highestCostRatio * values.coefficient > MAX_MONITOR_RATIO) {
+ context.addIssue({
+ code: 'custom',
+ path: ['coefficient'],
+ message: '成本倍率乘以系数后的结果不能超过 1000000',
+ })
+ }
+ })
+}
+
+type SavedUpstreamCredential = {
+ type: ChannelMonitorUpstreamType
+ baseUrl: string
+ authType: ChannelMonitorUpstreamAuthType
+ hasAccessToken: boolean
+ account: string
+ hasPassword: boolean
+} | null
+
+const customKeyValueSchema = z.object({
+ key: z.string().trim().max(256, '名称不能超过 256 个字符'),
+ value: z.string().max(8192, '值不能超过 8192 个字符'),
+ secret: z.boolean(),
+ hasValue: z.boolean(),
+})
+
+const customRequestSchema = z.object({
+ method: z.enum(['GET', 'POST']),
+ path: z.string().trim().max(2048, '接口路径不能超过 2048 个字符'),
+ query: z
+ .array(customKeyValueSchema)
+ .max(MAX_CUSTOM_UPSTREAM_ENTRIES, '查询参数不能超过 32 项'),
+ headers: z
+ .array(customKeyValueSchema)
+ .max(MAX_CUSTOM_UPSTREAM_ENTRIES, '请求头不能超过 32 项'),
+ bodyType: z.enum(['none', 'json', 'form']),
+ body: z
+ .string()
+ .max(MAX_CUSTOM_UPSTREAM_BODY_BYTES, 'JSON 请求体不能超过 49152 字节'),
+ bodySecret: z.boolean(),
+ hasBody: z.boolean(),
+ form: z
+ .array(customKeyValueSchema)
+ .max(MAX_CUSTOM_UPSTREAM_ENTRIES, '表单参数不能超过 32 项'),
+})
+
+const customResultSchema = z.object({
+ responseType: z.enum(['json', 'text']),
+ valuePath: z.string().trim().max(512, 'JSON 取值路径不能超过 512 个字符'),
+ multiplier: z.coerce
+ .number()
+ .finite('结果乘数必须是有效数字')
+ .min(0, '结果乘数不能小于 0')
+ .max(MAX_MONITOR_RATIO, '结果乘数不能超过 1000000'),
+})
+
+const customMetricSchema = z.object({
+ source: z.enum(['fixed', 'http']),
+ fixedValue: z.coerce.number().finite('固定值必须是有效数字'),
+ request: customRequestSchema,
+ result: customResultSchema,
+})
+
+const customUpstreamConfigSchema = z.object({
+ version: z.literal(1),
+ ratio: customMetricSchema,
+ balance: customMetricSchema,
+ balanceReuseRatioRequest: z.boolean(),
+})
+
+type CustomMetricFormValue = z.infer
+
+function validateCustomEntries(
+ entries: z.infer[],
+ path: (string | number)[],
+ label: string,
+ context: z.RefinementCtx
+) {
+ const keys = new Set()
+ for (const [index, entry] of entries.entries()) {
+ const key = entry.key.trim()
+ if (!key) {
+ context.addIssue({
+ code: 'custom',
+ path: [...path, index, 'key'],
+ message: `${label}名称不能为空`,
+ })
+ continue
+ }
+ const normalizedKey = key.toLowerCase()
+ if (keys.has(normalizedKey)) {
+ context.addIssue({
+ code: 'custom',
+ path: [...path, index, 'key'],
+ message: `${label}名称不能重复`,
+ })
+ }
+ keys.add(normalizedKey)
+ if (entry.secret && !entry.value && !entry.hasValue) {
+ context.addIssue({
+ code: 'custom',
+ path: [...path, index, 'value'],
+ message: `敏感${label}的值不能为空`,
+ })
+ }
+ }
+}
+
+function validateCustomMetric(
+ metric: CustomMetricFormValue,
+ metricName: 'ratio' | 'balance',
+ reuseRequest: boolean,
+ context: z.RefinementCtx
+) {
+ const pathPrefix = ['customConfig', metricName]
+ if (metric.source === 'fixed') {
+ if (metricName === 'ratio') {
+ if (metric.fixedValue < 0 || metric.fixedValue > MAX_MONITOR_RATIO) {
+ context.addIssue({
+ code: 'custom',
+ path: [...pathPrefix, 'fixedValue'],
+ message: '固定倍率必须在 0 到 1000000 之间',
+ })
+ }
+ } else if (Math.abs(metric.fixedValue) > MAX_CUSTOM_UPSTREAM_BALANCE) {
+ context.addIssue({
+ code: 'custom',
+ path: [...pathPrefix, 'fixedValue'],
+ message: '固定余额绝对值不能超过 1000000000000000',
+ })
+ }
+ return
+ }
+
+ if (!reuseRequest) {
+ if (!metric.request.path.trim()) {
+ context.addIssue({
+ code: 'custom',
+ path: [...pathPrefix, 'request', 'path'],
+ message: '请输入接口路径',
+ })
+ }
+ let decodedPath = metric.request.path
+ try {
+ decodedPath = decodeURIComponent(metric.request.path)
+ } catch {
+ context.addIssue({
+ code: 'custom',
+ path: [...pathPrefix, 'request', 'path'],
+ message: '接口路径格式无效',
+ })
+ }
+ if (
+ decodedPath.includes('?') ||
+ decodedPath.includes('#') ||
+ /^https?:\/\//i.test(metric.request.path)
+ ) {
+ context.addIssue({
+ code: 'custom',
+ path: [...pathPrefix, 'request', 'path'],
+ message: '接口路径请填写不含查询参数的相对路径',
+ })
+ }
+ if (metric.request.method === 'GET' && metric.request.bodyType !== 'none') {
+ context.addIssue({
+ code: 'custom',
+ path: [...pathPrefix, 'request', 'bodyType'],
+ message: 'GET 请求不能配置请求体',
+ })
+ }
+ validateCustomEntries(
+ metric.request.query,
+ [...pathPrefix, 'request', 'query'],
+ '查询参数',
+ context
+ )
+ validateCustomEntries(
+ metric.request.headers,
+ [...pathPrefix, 'request', 'headers'],
+ '请求头',
+ context
+ )
+ if (metric.request.bodyType === 'json') {
+ const preservesSavedBody =
+ metric.request.bodySecret && metric.request.hasBody
+ if (!metric.request.body && !preservesSavedBody) {
+ context.addIssue({
+ code: 'custom',
+ path: [...pathPrefix, 'request', 'body'],
+ message: 'JSON 请求体不能为空',
+ })
+ } else if (metric.request.body) {
+ try {
+ JSON.parse(metric.request.body)
+ } catch {
+ context.addIssue({
+ code: 'custom',
+ path: [...pathPrefix, 'request', 'body'],
+ message: 'JSON 请求体格式无效',
+ })
+ }
+ }
+ }
+ if (metric.request.bodyType === 'form') {
+ validateCustomEntries(
+ metric.request.form,
+ [...pathPrefix, 'request', 'form'],
+ '表单参数',
+ context
+ )
+ }
+ }
+
+ if (
+ metric.result.responseType === 'json' &&
+ !metric.result.valuePath.trim()
+ ) {
+ context.addIssue({
+ code: 'custom',
+ path: [...pathPrefix, 'result', 'valuePath'],
+ message: '请输入 JSON 取值路径',
+ })
+ }
+ if (metric.result.multiplier <= 0) {
+ context.addIssue({
+ code: 'custom',
+ path: [...pathPrefix, 'result', 'multiplier'],
+ message: '结果乘数必须大于 0',
+ })
+ }
+}
+
+export function createUpstreamConfigSchema(
+ savedCredential: SavedUpstreamCredential
+) {
+ return z
+ .object({
+ upstreamType: z.enum(['new_api', 'sub2api', 'custom']),
+ baseUrl: z
+ .string()
+ .trim()
+ .min(1, '请输入上游地址')
+ .max(2048, '上游地址过长')
+ .url({ error: '请输入有效的上游地址' }),
+ group: z.string().trim().max(64, '上游分组不能超过 64 个字符'),
+ authType: z.enum([
+ 'public',
+ 'user',
+ 'api_key',
+ 'account',
+ 'token',
+ 'custom',
+ ]),
+ userId: z.coerce.number().int().min(0, '上游用户 ID 必须大于 0'),
+ accessToken: z.string().trim().max(4096, '访问令牌过长'),
+ account: z
+ .string()
+ .trim()
+ .max(320, 'Sub2API 登录邮箱过长')
+ .email('请输入有效的 Sub2API 登录邮箱')
+ .or(z.literal('')),
+ password: z.string().max(4096, 'Sub2API 登录密码过长'),
+ singleChannelAction: z.enum(channelMonitorPolicyActions),
+ multipleChannelsAction: z.enum(channelMonitorPolicyActions),
+ ratioSyncEnabled: z.boolean(),
+ balanceSyncEnabled: z.boolean(),
+ balanceWarningThreshold: z
+ .number()
+ .finite('余额预警值必须是有效数字')
+ .min(0, '余额预警值不能小于 0')
+ .max(MAX_BALANCE_THRESHOLD, '余额预警值不能超过 1000000000000')
+ .nullable(),
+ balanceAutoDisableThreshold: z
+ .number()
+ .finite('余额自动禁用阈值必须是有效数字')
+ .min(0, '余额自动禁用阈值不能小于 0')
+ .max(MAX_BALANCE_THRESHOLD, '余额自动禁用阈值不能超过 1000000000000')
+ .nullable(),
+ costConversionMode: z.enum(['none', 'recharge', 'subscription']),
+ rechargePaidCny: z.coerce
+ .number()
+ .finite('实付人民币金额必须是有效数字')
+ .min(0, '实付人民币金额不能小于 0')
+ .max(
+ MAX_COST_CONVERSION_AMOUNT,
+ '实付人民币金额不能超过 1000000000000'
+ ),
+ rechargeCreditedUsd: z.coerce
+ .number()
+ .finite('到账美元额度必须是有效数字')
+ .min(0, '到账美元额度不能小于 0')
+ .max(MAX_COST_CONVERSION_AMOUNT, '到账美元额度不能超过 1000000000000'),
+ subscriptionPeriod: z.enum(['day', 'week', 'month']),
+ subscriptionPriceCny: z.coerce
+ .number()
+ .finite('订阅价格必须是有效数字')
+ .min(0, '订阅价格不能小于 0')
+ .max(MAX_COST_CONVERSION_AMOUNT, '订阅价格不能超过 1000000000000'),
+ subscriptionDailyUsd: z.coerce
+ .number()
+ .finite('每日美元额度必须是有效数字')
+ .min(0, '每日美元额度不能小于 0')
+ .max(MAX_COST_CONVERSION_AMOUNT, '每日美元额度不能超过 1000000000000'),
+ customConfig: customUpstreamConfigSchema,
+ })
+ .superRefine((values, context) => {
+ if (values.costConversionMode === 'recharge') {
+ if (values.rechargePaidCny <= 0) {
+ context.addIssue({
+ code: 'custom',
+ path: ['rechargePaidCny'],
+ message: '实付人民币金额必须大于 0',
+ })
+ }
+ if (values.rechargeCreditedUsd <= 0) {
+ context.addIssue({
+ code: 'custom',
+ path: ['rechargeCreditedUsd'],
+ message: '到账美元额度必须大于 0',
+ })
+ }
+ const factor = values.rechargePaidCny / values.rechargeCreditedUsd
+ if (Number.isFinite(factor) && factor > MAX_MONITOR_RATIO) {
+ context.addIssue({
+ code: 'custom',
+ path: ['rechargePaidCny'],
+ message: '倍率换算系数不能超过 1000000',
+ })
+ }
+ }
+ if (values.costConversionMode === 'subscription') {
+ if (values.subscriptionPriceCny <= 0) {
+ context.addIssue({
+ code: 'custom',
+ path: ['subscriptionPriceCny'],
+ message: '订阅价格必须大于 0',
+ })
+ }
+ if (values.subscriptionDailyUsd <= 0) {
+ context.addIssue({
+ code: 'custom',
+ path: ['subscriptionDailyUsd'],
+ message: '每日美元额度必须大于 0',
+ })
+ }
+ const factor =
+ values.subscriptionPriceCny /
+ (values.subscriptionDailyUsd *
+ CHANNEL_MONITOR_SUBSCRIPTION_DAYS[values.subscriptionPeriod])
+ if (Number.isFinite(factor) && factor > MAX_MONITOR_RATIO) {
+ context.addIssue({
+ code: 'custom',
+ path: ['subscriptionPriceCny'],
+ message: '倍率换算系数不能超过 1000000',
+ })
+ }
+ }
+ if (values.upstreamType === 'custom') {
+ if (values.authType !== 'custom') {
+ context.addIssue({
+ code: 'custom',
+ path: ['authType'],
+ message: '自定义上游认证方式无效',
+ })
+ }
+ if (
+ values.customConfig.balanceReuseRatioRequest &&
+ (values.customConfig.ratio.source !== 'http' ||
+ values.customConfig.balance.source !== 'http')
+ ) {
+ context.addIssue({
+ code: 'custom',
+ path: ['customConfig', 'balanceReuseRatioRequest'],
+ message: '只有倍率和余额都使用接口查询时才能复用倍率接口',
+ })
+ }
+ validateCustomMetric(values.customConfig.ratio, 'ratio', false, context)
+ validateCustomMetric(
+ values.customConfig.balance,
+ 'balance',
+ values.customConfig.balanceReuseRatioRequest,
+ context
+ )
+ return
+ }
+ const hasSavedCredential =
+ savedCredential?.type === values.upstreamType &&
+ savedCredential.authType === values.authType
+ const hasSavedAccessToken =
+ hasSavedCredential && savedCredential?.hasAccessToken === true
+ if (values.upstreamType === 'new_api') {
+ if (values.authType !== 'public' && values.authType !== 'user') {
+ context.addIssue({
+ code: 'custom',
+ path: ['authType'],
+ message: '请选择 New API 认证方式',
+ })
+ return
+ }
+ if (values.authType === 'public') return
+ if (values.userId <= 0) {
+ context.addIssue({
+ code: 'custom',
+ path: ['userId'],
+ message: '上游用户 ID 必须大于 0',
+ })
+ }
+ if (!values.accessToken && !hasSavedAccessToken) {
+ context.addIssue({
+ code: 'custom',
+ path: ['accessToken'],
+ message: '请输入上游访问令牌',
+ })
+ }
+ return
+ }
+
+ if (values.authType === 'api_key') return
+ if (values.authType === 'account') {
+ if (!values.account) {
+ context.addIssue({
+ code: 'custom',
+ path: ['account'],
+ message: '请输入 Sub2API 登录邮箱',
+ })
+ }
+ const hasSavedPassword =
+ hasSavedCredential &&
+ savedCredential?.hasPassword === true &&
+ savedCredential.baseUrl === values.baseUrl &&
+ savedCredential.account === values.account
+ if (!values.password && !hasSavedPassword) {
+ context.addIssue({
+ code: 'custom',
+ path: ['password'],
+ message: '请输入 Sub2API 登录密码',
+ })
+ }
+ return
+ }
+ if (values.authType !== 'token') {
+ context.addIssue({
+ code: 'custom',
+ path: ['authType'],
+ message: '请选择 Sub2API 认证方式',
+ })
+ return
+ }
+ if (!values.accessToken && !hasSavedAccessToken) {
+ context.addIssue({
+ code: 'custom',
+ path: ['accessToken'],
+ message: '请输入 Sub2API Token(旧版访问令牌)',
+ })
+ }
+ })
+}
+
+export type ChannelRatioFormValues = z.infer<
+ ReturnType
+>
+
+export type GroupRatioFormValues = z.infer<
+ ReturnType
+>
+
+export type ChannelMonitorSettingsFormValues = z.infer<
+ ReturnType
+>
+
+export type ChannelGroupsFormValues = z.infer<
+ ReturnType
+>
+
+export type GroupRatioSyncFormValues = z.infer<
+ ReturnType
+>
+
+export type UpstreamConfigFormValues = z.infer<
+ ReturnType
+>
diff --git a/web/default/src/features/channel-monitor/lib/sort.ts b/web/default/src/features/channel-monitor/lib/sort.ts
new file mode 100644
index 000000000000..cdb0247e6b53
--- /dev/null
+++ b/web/default/src/features/channel-monitor/lib/sort.ts
@@ -0,0 +1,122 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { CHANNEL_STATUS } from '@/features/channels/constants'
+
+import type {
+ ChannelMonitorChannelPerformance,
+ ChannelMonitorItem,
+ ChannelMonitorSortMode,
+} from '../types'
+
+function compareChannelEnabledStatus(
+ first: ChannelMonitorItem,
+ second: ChannelMonitorItem
+) {
+ const firstEnabled = first.status === CHANNEL_STATUS.ENABLED
+ const secondEnabled = second.status === CHANNEL_STATUS.ENABLED
+ if (firstEnabled === secondEnabled) return 0
+ return firstEnabled ? -1 : 1
+}
+
+function compareChannelNames(
+ first: ChannelMonitorItem,
+ second: ChannelMonitorItem
+) {
+ const nameComparison = first.name.localeCompare(second.name, 'zh-CN', {
+ numeric: true,
+ sensitivity: 'base',
+ })
+ return nameComparison || first.id - second.id
+}
+
+export function orderChannelsByCustomOrder(
+ channels: ChannelMonitorItem[],
+ channelOrder: number[]
+) {
+ const channelById = new Map(channels.map((channel) => [channel.id, channel]))
+ const orderedChannels: ChannelMonitorItem[] = []
+ for (const channelId of channelOrder) {
+ const channel = channelById.get(channelId)
+ if (!channel) continue
+ orderedChannels.push(channel)
+ channelById.delete(channelId)
+ }
+ for (const channel of channels) {
+ if (channelById.has(channel.id)) orderedChannels.push(channel)
+ }
+ return orderedChannels
+}
+
+export function sortChannelMonitorItems(
+ channels: ChannelMonitorItem[],
+ sortMode: ChannelMonitorSortMode,
+ channelOrder: number[],
+ performanceByChannel: ReadonlyMap
+) {
+ if (sortMode === 'custom') {
+ return orderChannelsByCustomOrder(channels, channelOrder).sort(
+ compareChannelEnabledStatus
+ )
+ }
+
+ return [...channels].sort((first, second) => {
+ const statusComparison = compareChannelEnabledStatus(first, second)
+ if (statusComparison !== 0) return statusComparison
+
+ if (sortMode === 'channel_asc' || sortMode === 'channel_desc') {
+ const comparison = compareChannelNames(first, second)
+ return sortMode === 'channel_asc' ? comparison : -comparison
+ }
+
+ if (sortMode === 'ratio_asc' || sortMode === 'ratio_desc') {
+ if (first.cost_ratio == null && second.cost_ratio == null) {
+ return compareChannelNames(first, second)
+ }
+ if (first.cost_ratio == null) return 1
+ if (second.cost_ratio == null) return -1
+ const ratioComparison = first.cost_ratio - second.cost_ratio
+ if (ratioComparison !== 0) {
+ return sortMode === 'ratio_asc' ? ratioComparison : -ratioComparison
+ }
+ return compareChannelNames(first, second)
+ }
+
+ const firstPerformance = performanceByChannel.get(first.id)
+ const secondPerformance = performanceByChannel.get(second.id)
+ const firstTokenSort =
+ sortMode === 'first_token_asc' || sortMode === 'first_token_desc'
+ const firstValue = firstTokenSort
+ ? firstPerformance?.average_first_token_ms
+ : firstPerformance?.average_tps
+ const secondValue = firstTokenSort
+ ? secondPerformance?.average_first_token_ms
+ : secondPerformance?.average_tps
+ if (firstValue == null && secondValue == null) {
+ return compareChannelNames(first, second)
+ }
+ if (firstValue == null) return 1
+ if (secondValue == null) return -1
+ const performanceComparison = firstValue - secondValue
+ if (performanceComparison !== 0) {
+ const ascending = sortMode === 'first_token_asc' || sortMode === 'tps_asc'
+ return ascending ? performanceComparison : -performanceComparison
+ }
+ return compareChannelNames(first, second)
+ })
+}
diff --git a/web/default/src/features/channel-monitor/types.ts b/web/default/src/features/channel-monitor/types.ts
new file mode 100644
index 000000000000..924fd27743b0
--- /dev/null
+++ b/web/default/src/features/channel-monitor/types.ts
@@ -0,0 +1,528 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+export type ChannelMonitorItem = {
+ id: number
+ name: string
+ type: number
+ status: number
+ priority: number
+ weight: number
+ base_url: string
+ models: string
+ test_model: string | null
+ groups: string[]
+ ratio: number | null
+ previous_ratio: number | null
+ cost_ratio: number | null
+ previous_cost_ratio: number | null
+ conversion_factor: number | null
+ remark: string
+ channel_remark: string
+ updated_time: number
+ updated_by: number
+ updated_by_username: string
+ last_fetch_status: '' | 'succeeded' | 'failed'
+ last_fetch_error: string
+ last_fetch_time: number
+ consecutive_failures: number
+ upstream_balance: number | null
+ last_balance_time: number
+ last_balance_error: string
+ smart_schedule_excluded: boolean
+ last_schedule_status: '' | 'succeeded' | 'skipped' | 'failed'
+ last_schedule_error: string
+ last_schedule_score: number | null
+ last_schedule_priority: number
+ last_schedule_weight: number
+ last_schedule_time: number
+ upstream: ChannelMonitorUpstreamConfig | null
+}
+
+export type ChannelMonitorUpstreamType = 'new_api' | 'sub2api' | 'custom'
+
+export type ChannelMonitorUpstreamAuthType =
+ | 'public'
+ | 'user'
+ | 'api_key'
+ | 'account'
+ | 'token'
+ | 'custom'
+
+export type ChannelMonitorCustomSource = 'fixed' | 'http'
+export type ChannelMonitorCustomBodyType = 'none' | 'json' | 'form'
+export type ChannelMonitorCustomResponseType = 'json' | 'text'
+
+export type ChannelMonitorCustomKeyValue = {
+ key: string
+ value: string
+ secret: boolean
+ has_value: boolean
+}
+
+export type ChannelMonitorCustomRequestConfig = {
+ method: 'GET' | 'POST'
+ path: string
+ query: ChannelMonitorCustomKeyValue[]
+ headers: ChannelMonitorCustomKeyValue[]
+ body_type: ChannelMonitorCustomBodyType
+ body: string
+ body_secret: boolean
+ has_body: boolean
+ form: ChannelMonitorCustomKeyValue[]
+}
+
+export type ChannelMonitorCustomResultConfig = {
+ response_type: ChannelMonitorCustomResponseType
+ value_path: string
+ multiplier: number
+}
+
+export type ChannelMonitorCustomMetricConfig = {
+ source: ChannelMonitorCustomSource
+ fixed_value?: number
+ request?: ChannelMonitorCustomRequestConfig
+ result?: ChannelMonitorCustomResultConfig
+}
+
+export type ChannelMonitorCustomUpstreamConfig = {
+ version: 1
+ ratio: ChannelMonitorCustomMetricConfig
+ balance: ChannelMonitorCustomMetricConfig
+ balance_reuse_ratio_request: boolean
+}
+
+export type ChannelMonitorCostConversion =
+ | { mode: 'none' }
+ | {
+ mode: 'recharge'
+ paid_cny: number
+ credited_usd: number
+ }
+ | {
+ mode: 'subscription'
+ subscription_period: 'day' | 'week' | 'month'
+ subscription_price_cny: number
+ subscription_daily_usd: number
+ }
+
+export type ChannelMonitorUpstreamConfig = {
+ type: ChannelMonitorUpstreamType
+ base_url: string
+ group: string
+ auth_type: ChannelMonitorUpstreamAuthType
+ user_id: number
+ has_access_token: boolean
+ account: string
+ has_password: boolean
+ single_channel_action: ChannelMonitorPolicyAction
+ multiple_channels_action: ChannelMonitorPolicyAction
+ balance_warning_threshold: number | null
+ balance_auto_disable_threshold: number | null
+ ratio_sync_enabled: boolean
+ balance_sync_enabled: boolean
+ cost_conversion: ChannelMonitorCostConversion
+ custom_config?: ChannelMonitorCustomUpstreamConfig
+}
+
+export type ChannelMonitorUpstreamRequest = {
+ type: ChannelMonitorUpstreamType
+ base_url: string
+ group: string
+ auth_type: ChannelMonitorUpstreamAuthType
+ user_id: number
+ access_token: string
+ account: string
+ password: string
+ single_channel_action: ChannelMonitorPolicyAction
+ multiple_channels_action: ChannelMonitorPolicyAction
+ balance_warning_threshold: number | null
+ balance_auto_disable_threshold: number | null
+ ratio_sync_enabled: boolean
+ balance_sync_enabled: boolean
+ cost_conversion: ChannelMonitorCostConversion
+ custom_config?: ChannelMonitorCustomUpstreamConfig
+}
+
+export type ChannelMonitorCustomRequestDebug = {
+ status_code: number
+ duration_ms: number
+ response_preview?: string
+}
+
+export type ChannelMonitorUpstreamVersionResult = {
+ version: string
+ endpoint: string
+}
+
+export type NewAPIGroupRatioResult = {
+ ratio: number
+ cost_ratio: number
+ conversion_factor: number
+ endpoint: string
+ balance: ChannelMonitorUpstreamBalanceResult
+ debug?: ChannelMonitorCustomRequestDebug
+}
+
+export type ChannelMonitorUpstreamBalanceResult = {
+ amount: number | null
+ endpoint?: string
+ error?: string
+ debug?: ChannelMonitorCustomRequestDebug
+}
+
+export type ChannelMonitorUpstreamGroup = {
+ id?: string
+ name: string
+ ratio: number
+}
+
+export type ChannelMonitorUpstreamGroupsResult = {
+ groups: ChannelMonitorUpstreamGroup[]
+ balance: ChannelMonitorUpstreamBalanceResult
+ applied_group?: string
+ applied_group_error?: string
+}
+
+export type ChannelMonitorFetchResult = {
+ result: NewAPIGroupRatioResult
+ monitor: {
+ ratio: number
+ previous_ratio: number | null
+ updated_time: number
+ }
+ created: boolean
+ changed: boolean
+}
+
+export type ChannelMonitorApplyGroupResult = ChannelMonitorFetchResult & {
+ keys_updated: number
+}
+
+export type ChannelMonitorOverview = {
+ channels: ChannelMonitorItem[]
+ channel_order: number[]
+ group_ratios: Record
+ group_coefficients: Record
+ settings: ChannelMonitorSettings
+}
+
+export type ChannelMonitorCostDay = {
+ date: string
+ start_at: number
+ cost_cny: number
+}
+
+export type ChannelMonitorCostChannel = {
+ channel_id: number
+ channel_name: string
+ cost_cny: number
+}
+
+export type ChannelMonitorCostCoverage = {
+ included_channel_count: number
+ unresolved_channel_count: number
+ free_group_channel_count: number
+}
+
+export type ChannelMonitorCostOverview = {
+ days: number
+ generated_at: number
+ today_cost_cny: number
+ yesterday_cost_cny: number
+ total_cost_cny: number
+ coverage: ChannelMonitorCostCoverage
+ items: ChannelMonitorCostDay[]
+ channels: ChannelMonitorCostChannel[]
+}
+
+export type ChannelMonitorPerformanceRangeMinutes = number
+
+export type ChannelMonitorSmartSchedulePerformanceRangeMinutes =
+ | 15
+ | 60
+ | 360
+ | 1440
+
+export type ChannelMonitorPerformanceMetric = {
+ channel_id: number
+ model_name: string
+ sample_count: number
+ first_token_sample_count: number
+ tps_sample_count: number
+ average_first_token_ms: number | null
+ average_tps: number | null
+ latest_first_token_ms: number | null
+ latest_tps: number | null
+ last_used_time: number
+}
+
+export type ChannelMonitorSuccessSummary = {
+ actual_success_count: number
+ actual_failure_count: number
+ actual_sample_count: number
+ actual_success_rate: number
+ final_success_count: number
+ final_failure_count: number
+ final_sample_count: number
+ final_success_rate: number
+}
+
+export type ChannelMonitorSuccessMetric = ChannelMonitorSuccessSummary & {
+ channel_id: number
+ model_name: string
+}
+
+export type ChannelMonitorGroupSuccessMetric = ChannelMonitorSuccessSummary & {
+ group: string
+}
+
+export type ChannelMonitorChannelSuccessMetric =
+ ChannelMonitorSuccessSummary & {
+ channel_id: number
+ }
+
+export type ChannelMonitorFailureCategory = {
+ channel_id: number
+ status_code: number
+ error_type: string
+ error_code: string
+ sample_content: string
+ actual_count: number
+ final_count: number
+ last_occurred_at: number
+}
+
+export type ChannelMonitorSuccessDetail = {
+ summary: ChannelMonitorSuccessSummary
+ channel_items: ChannelMonitorChannelSuccessMetric[]
+ failure_categories: ChannelMonitorFailureCategory[]
+}
+
+export type ChannelMonitorSuccessDetailResult = {
+ range_minutes: ChannelMonitorPerformanceRangeMinutes
+ generated_at: number
+ success_metrics_available: boolean
+ scope: 'channel' | 'group' | ''
+ detail: ChannelMonitorSuccessDetail
+}
+
+export type ChannelMonitorSuccessMode = 'actual' | 'final'
+
+export type ChannelMonitorSuccessDetailTarget =
+ | {
+ scope: 'channel'
+ mode: 'actual'
+ channelId: number
+ channelName: string
+ modelName?: string
+ }
+ | {
+ scope: 'group'
+ mode: ChannelMonitorSuccessMode
+ groupName: string
+ }
+
+export type ChannelMonitorPerformanceResult = {
+ range_minutes: ChannelMonitorPerformanceRangeMinutes
+ generated_at: number
+ items: ChannelMonitorPerformanceMetric[]
+ success_metrics_available: boolean
+ success_items: ChannelMonitorSuccessMetric[]
+ group_success_items: ChannelMonitorGroupSuccessMetric[]
+}
+
+export type ChannelMonitorChannelPerformance = {
+ sample_count: number
+ first_token_sample_count: number
+ tps_sample_count: number
+ average_first_token_ms: number | null
+ average_tps: number | null
+ last_used_time: number
+}
+
+export type ChannelMonitorSortMode =
+ | 'custom'
+ | 'channel_asc'
+ | 'channel_desc'
+ | 'ratio_asc'
+ | 'ratio_desc'
+ | 'first_token_asc'
+ | 'first_token_desc'
+ | 'tps_asc'
+ | 'tps_desc'
+
+export type ChannelMonitorPolicyAction =
+ | 'none'
+ | 'update_group_ratio'
+ | 'disable_channel'
+ | 'remove_from_group'
+
+export type ChannelMonitorSettings = {
+ auto_update_interval_minutes: number
+ auto_update_retry_count: number
+ auto_disable_on_update_failure: boolean
+ email_notification_enabled: boolean
+ notification_email: string
+ smart_schedule_enabled: boolean
+ smart_schedule_interval_minutes: number
+ smart_schedule_strategy: ChannelMonitorSmartScheduleStrategy
+ smart_schedule_stability_enabled: boolean
+ smart_schedule_apply_mode: ChannelMonitorSmartScheduleApplyMode
+ smart_schedule_performance_minutes: ChannelMonitorSmartSchedulePerformanceRangeMinutes
+ smart_schedule_model: string
+ smart_schedule_models: string[]
+ smart_schedule_min_samples: number
+ smart_schedule_force_reset_task_created?: boolean
+ smart_schedule_force_reset_task_id?: string
+ smart_schedule_force_reset_task_error?: string
+}
+
+export type ChannelMonitorSmartScheduleStrategy =
+ | 'ratio'
+ | 'first_token'
+ | 'tps'
+ | 'smart'
+
+export type ChannelMonitorSmartScheduleApplyMode = 'weight' | 'priority_weight'
+
+export type ChannelMonitorSmartScheduleConfig = {
+ excluded: boolean
+}
+
+export type ChannelMonitorTaskRunResult = {
+ created: boolean
+ task: ChannelMonitorTask
+}
+
+export type ChannelMonitorGroupRatioSyncResult = {
+ group: string
+ upstream_ratio: number
+ cost_ratio: number
+ conversion_factor: number
+ coefficient: number
+ ratio: number
+}
+
+export type ChannelMonitorGroupChannelsUpdateResult = {
+ group: string
+ channel_ids: number[]
+ added_channel_ids: number[]
+ removed_channel_ids: number[]
+}
+
+export type ChannelMonitorTaskStatus =
+ | 'pending'
+ | 'running'
+ | 'succeeded'
+ | 'failed'
+
+export type ChannelMonitorTaskProgress = {
+ total: number
+ processed: number
+ progress: number
+}
+
+export type ChannelMonitorTaskResult = {
+ total: number
+ updated: number
+ changed?: number
+ balance_updated?: number
+ balance_warnings?: number
+ failed: number
+ groups_updated?: number
+ group_memberships_removed?: number
+ group_update_failed?: boolean
+ channels_disabled?: number
+ groups_skipped?: number
+ retried?: number
+ recovered_after_retry?: number
+ strategy?: ChannelMonitorSmartScheduleStrategy | 'stability'
+ stability_enabled?: boolean
+ force_reset?: boolean
+ apply_mode?: ChannelMonitorSmartScheduleApplyMode
+ model?: string
+ models?: string[]
+ performance_minutes?: number
+ min_samples?: number
+ planned?: number
+ unchanged?: number
+ skipped?: number
+ failures?: ChannelMonitorTaskFailure[]
+ failure_details_truncated?: boolean
+ email_status?: 'sent' | 'failed'
+ email_error?: string
+}
+
+export type ChannelMonitorTaskFailure = {
+ channel_id: number
+ channel_name: string
+ error: string
+}
+
+export type ChannelMonitorTask = {
+ id: number
+ task_id: string
+ type: 'channel_ratio_monitor' | 'channel_smart_schedule'
+ status: ChannelMonitorTaskStatus
+ state: ChannelMonitorTaskProgress | null
+ result: ChannelMonitorTaskResult | null
+ error: string
+ created_at: number
+ updated_at: number
+}
+
+export type ChannelMonitorTaskKind = 'ratio' | 'schedule'
+
+export type ChannelMonitorTaskPage = {
+ page: number
+ page_size: number
+ total: number
+ items: ChannelMonitorTask[]
+}
+
+export type ChannelRatioHistory = {
+ id: number
+ channel_id: number
+ old_ratio: number
+ new_ratio: number
+ remark: string
+ created_time: number
+ operator_id: number
+ operator_username: string
+}
+
+export type ChannelRatioHistoryPage = {
+ page: number
+ page_size: number
+ total: number
+ items: ChannelRatioHistory[]
+}
+
+export type ChannelMonitorApiResponse = {
+ success: boolean
+ message: string
+ data: T
+}
+
+export type GroupMonitorItem = {
+ name: string
+ ratio: number
+ coefficient: number
+ channels: ChannelMonitorItem[]
+}
diff --git a/web/default/src/features/channels/api.ts b/web/default/src/features/channels/api.ts
index 09bad1f94b44..b4b136b754c2 100644
--- a/web/default/src/features/channels/api.ts
+++ b/web/default/src/features/channels/api.ts
@@ -83,7 +83,7 @@ export type CodexCredentialRefreshResponse = {
export async function getChannels(
params: GetChannelsParams = {}
): Promise {
- const res = await api.get('/api/channel', { params })
+ const res = await api.get('/api/channel/', { params })
return res.data
}
@@ -120,7 +120,7 @@ export async function getChannelOps(): Promise {
export async function createChannel(
data: AddChannelRequest
): Promise<{ success: boolean; message?: string }> {
- const res = await api.post('/api/channel', data, channelActionConfig())
+ const res = await api.post('/api/channel/', data, channelActionConfig())
return res.data
}
diff --git a/web/default/src/features/channels/components/channels-dialogs.tsx b/web/default/src/features/channels/components/channels-dialogs.tsx
index 00786eedef6c..5d7afd887517 100644
--- a/web/default/src/features/channels/components/channels-dialogs.tsx
+++ b/web/default/src/features/channels/components/channels-dialogs.tsx
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useChannels } from './channels-provider'
import { BalanceQueryDialog } from './dialogs/balance-query-dialog'
+import { ChannelBatchTestDialog } from './dialogs/channel-batch-test-dialog'
import { ChannelTestDialog } from './dialogs/channel-test-dialog'
import { CopyChannelDialog } from './dialogs/copy-channel-dialog'
import { EditTagDialog } from './dialogs/edit-tag-dialog'
@@ -46,6 +47,12 @@ export function ChannelsDialogs() {
onOpenChange={(v) => !v && setOpen(null)}
/>
+ {/* Batch Test Channels Dialog */}
+ !v && setOpen(null)}
+ />
+
{/* Balance Query Dialog */}
.
For commercial licensing, please contact support@quantumnous.com
*/
+import { TestTubeIcon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
import { useQueryClient } from '@tanstack/react-query'
import {
Plus,
@@ -89,6 +91,11 @@ export function ChannelsPrimaryButtons() {
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
+ const canOperate = hasPermission(
+ currentUser,
+ ADMIN_PERMISSION_RESOURCES.CHANNEL,
+ ADMIN_PERMISSION_ACTIONS.OPERATE
+ )
const handleTagModeToggle = (checked: boolean) => {
localStorage.setItem('enable-tag-mode', String(checked))
@@ -171,6 +178,30 @@ export function ChannelsPrimaryButtons() {
)}
+
+ }>
+ {
+ if (!canOperate) return
+ setOpen('batch-test-channels')
+ }}
+ disabled={!canOperate}
+ aria-label='批量测试渠道'
+ >
+
+ 批量测试
+ 批量测试
+
+
+
+ {canOperate
+ ? '选择渠道和已定价模型进行批量连通性测试'
+ : '没有渠道操作权限'}
+
+
+
{/* More Actions */}
}>
diff --git a/web/default/src/features/channels/components/channels-provider.tsx b/web/default/src/features/channels/components/channels-provider.tsx
index 72d212a81446..c46ef8bb90bf 100644
--- a/web/default/src/features/channels/components/channels-provider.tsx
+++ b/web/default/src/features/channels/components/channels-provider.tsx
@@ -38,6 +38,7 @@ type DialogType =
| 'create-channel'
| 'update-channel'
| 'test-channel'
+ | 'batch-test-channels'
| 'balance-query'
| 'fetch-models'
| 'ollama-models'
diff --git a/web/default/src/features/channels/components/dialogs/channel-batch-test-dialog.tsx b/web/default/src/features/channels/components/dialogs/channel-batch-test-dialog.tsx
new file mode 100644
index 000000000000..56b5cca15428
--- /dev/null
+++ b/web/default/src/features/channels/components/dialogs/channel-batch-test-dialog.tsx
@@ -0,0 +1,1140 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { Alert02Icon, TestTubeIcon } from '@hugeicons/core-free-icons'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useMemo, useRef, useState } from 'react'
+import { toast } from 'sonner'
+
+import { Dialog } from '@/components/dialog'
+import { MultiSelect } from '@/components/multi-select'
+import {
+ Alert,
+ AlertAction,
+ AlertDescription,
+ AlertTitle,
+} from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Combobox,
+ ComboboxCollection,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+} from '@/components/ui/combobox'
+import {
+ Field,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+} from '@/components/ui/field'
+import { Input } from '@/components/ui/input'
+import {
+ Progress,
+ ProgressLabel,
+ ProgressValue,
+} from '@/components/ui/progress'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Spinner } from '@/components/ui/spinner'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
+import { getPricing } from '@/features/pricing/api'
+
+import { getChannels } from '../../api'
+import {
+ channelsQueryKeys,
+ formatResponseTime,
+ handleTestChannel,
+} from '../../lib'
+import type { Channel } from '../../types'
+
+type BatchTestChannel = Pick &
+ Partial>
+
+type ChannelBatchTestDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ channels?: ReadonlyArray
+ modelSelectionMode?: 'multiple' | 'single'
+ selectAllMode?: 'all' | 'enabled'
+ enableRepeatMode?: boolean
+}
+
+type BatchTestMode = 'batch' | 'repeat'
+
+type BatchTestTask = {
+ key: string
+ channelId: number
+ channelName: string
+ model: string
+ workerIndex?: number
+ iteration?: number
+}
+
+type BatchTestStatus = 'testing' | 'success' | 'error'
+
+type BatchTestResult = BatchTestTask & {
+ status: BatchTestStatus
+ responseTime?: number
+ error?: string
+ errorCode?: string
+}
+
+type BatchTestProgress = {
+ total: number
+ completed: number
+ success: number
+ failed: number
+}
+
+const CHANNEL_PAGE_SIZE = 100
+const BATCH_TEST_CONCURRENCY = 5
+const DEFAULT_REPEAT_CONCURRENCY = '3'
+const DEFAULT_REPEAT_ITERATIONS = '5'
+const MIN_REPEAT_CONCURRENCY = 1
+const MAX_REPEAT_CONCURRENCY = 20
+const MIN_REPEAT_ITERATIONS = 1
+const MAX_REPEAT_ITERATIONS = 50
+const MAX_REPEAT_REQUESTS = 200
+const POSITIVE_INTEGER_PATTERN = /^\d+$/
+const EMPTY_CHANNELS: BatchTestChannel[] = []
+const EMPTY_PRICED_MODELS: string[] = []
+
+async function getBatchTestChannels(): Promise {
+ const firstPage = await getChannels({ p: 1, page_size: CHANNEL_PAGE_SIZE })
+ if (!firstPage.success) {
+ throw new Error(firstPage.message || '获取渠道列表失败')
+ }
+
+ const firstPageData = firstPage.data
+ if (!firstPageData) return []
+
+ const channelMap = new Map(
+ firstPageData.items.map((channel) => [channel.id, channel])
+ )
+ const pageCount = Math.ceil(firstPageData.total / CHANNEL_PAGE_SIZE)
+ const remainingPages: number[] = []
+ for (let page = 2; page <= pageCount; page += 1) {
+ remainingPages.push(page)
+ }
+
+ const responses = await Promise.all(
+ remainingPages.map((page) =>
+ getChannels({ p: page, page_size: CHANNEL_PAGE_SIZE })
+ )
+ )
+ for (const response of responses) {
+ if (!response.success) {
+ throw new Error(response.message || '获取渠道列表失败')
+ }
+ for (const channel of response.data?.items ?? []) {
+ channelMap.set(channel.id, channel)
+ }
+ }
+
+ return [...channelMap.values()].sort((a, b) => a.id - b.id)
+}
+
+async function getPricedModelNames(): Promise {
+ const response = await getPricing()
+ if (!response.success) {
+ throw new Error(response.message || '获取定价模型失败')
+ }
+
+ return [
+ ...new Set(
+ response.data.map((model) => model.model_name.trim()).filter(Boolean)
+ ),
+ ].sort((a, b) => a.localeCompare(b))
+}
+
+function buildBatchTestTasks(
+ channels: readonly BatchTestChannel[],
+ models: string[]
+): BatchTestTask[] {
+ const tasks: BatchTestTask[] = []
+ for (const channel of channels) {
+ for (const model of models) {
+ tasks.push({
+ key: `${channel.id}::${model}`,
+ channelId: channel.id,
+ channelName: channel.name,
+ model,
+ })
+ }
+ }
+ return tasks
+}
+
+function buildRepeatTestWorkers(
+ channel: BatchTestChannel,
+ model: string,
+ concurrency: number,
+ iterations: number
+): BatchTestTask[][] {
+ return Array.from({ length: concurrency }, (_, workerOffset) => {
+ const workerIndex = workerOffset + 1
+ return Array.from({ length: iterations }, (_, iterationOffset) => {
+ const iteration = iterationOffset + 1
+ return {
+ key: `${channel.id}::${model}::${workerIndex}::${iteration}`,
+ channelId: channel.id,
+ channelName: channel.name,
+ model,
+ workerIndex,
+ iteration,
+ }
+ })
+ })
+}
+
+function parseBoundedInteger(
+ value: string,
+ minimum: number,
+ maximum: number
+): number | null {
+ const normalized = value.trim()
+ if (!POSITIVE_INTEGER_PATTERN.test(normalized)) return null
+
+ const parsed = Number(normalized)
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
+ return null
+ }
+ return parsed
+}
+
+async function runBatchTestTask(task: BatchTestTask): Promise {
+ let result: BatchTestResult | undefined
+ try {
+ await handleTestChannel(
+ task.channelId,
+ {
+ channelName: task.channelName,
+ testModel: task.model,
+ silent: true,
+ },
+ (success, responseTime, error, errorCode) => {
+ result = {
+ ...task,
+ status: success ? 'success' : 'error',
+ responseTime,
+ error,
+ errorCode,
+ }
+ }
+ )
+ } catch (error: unknown) {
+ return {
+ ...task,
+ status: 'error',
+ error: error instanceof Error ? error.message : '测试失败',
+ }
+ }
+
+ return (
+ result ?? {
+ ...task,
+ status: 'error',
+ error: '测试未返回结果',
+ }
+ )
+}
+
+function BatchTestStatusBadge(props: { status: BatchTestStatus }) {
+ if (props.status === 'testing') {
+ return (
+
+ 测试中
+
+ )
+ }
+ if (props.status === 'success') {
+ return (
+
+ 成功
+
+ )
+ }
+ return 失败
+}
+
+function BatchTestResultContent(props: { result: BatchTestResult }) {
+ if (props.result.status === 'testing') {
+ return 正在请求上游
+ }
+
+ if (!props.result.error) {
+ return 连通性正常
+ }
+
+ const errorCode = props.result.errorCode ? ` (${props.result.errorCode})` : ''
+ return (
+
+ {props.result.error}
+ {errorCode}
+
+ )
+}
+
+function formatBatchTestResponseTime(responseTime?: number): string {
+ if (typeof responseTime !== 'number') return '-'
+ if (responseTime === 0) return '0ms'
+ return formatResponseTime(responseTime)
+}
+
+function getErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : '加载失败,请稍后重试'
+}
+
+export function ChannelBatchTestDialog(props: ChannelBatchTestDialogProps) {
+ const queryClient = useQueryClient()
+ const stopRequestedRef = useRef(false)
+ const [testMode, setTestMode] = useState('batch')
+ const [selectedChannelIds, setSelectedChannelIds] = useState([])
+ const [selectedModels, setSelectedModels] = useState([])
+ const [repeatConcurrencyInput, setRepeatConcurrencyInput] = useState(
+ DEFAULT_REPEAT_CONCURRENCY
+ )
+ const [repeatIterationsInput, setRepeatIterationsInput] = useState(
+ DEFAULT_REPEAT_ITERATIONS
+ )
+ const [results, setResults] = useState>({})
+ const [progress, setProgress] = useState(null)
+ const [isTesting, setIsTesting] = useState(false)
+ const [isStopRequested, setIsStopRequested] = useState(false)
+ const isSingleModel = props.modelSelectionMode === 'single'
+ const repeatModeEnabled = Boolean(props.enableRepeatMode && isSingleModel)
+ const isRepeatMode = repeatModeEnabled && testMode === 'repeat'
+ const usesProvidedChannels = props.channels !== undefined
+
+ const channelsQuery = useQuery({
+ queryKey: ['channel-batch-test', 'channels'],
+ queryFn: getBatchTestChannels,
+ enabled: props.open && !usesProvidedChannels,
+ staleTime: 60_000,
+ })
+ const pricedModelsQuery = useQuery({
+ queryKey: ['channel-batch-test', 'priced-models'],
+ queryFn: getPricedModelNames,
+ enabled: props.open,
+ staleTime: 5 * 60_000,
+ })
+
+ const channels = props.channels ?? channelsQuery.data ?? EMPTY_CHANNELS
+ const pricedModels = pricedModelsQuery.data ?? EMPTY_PRICED_MODELS
+ const channelOptions = useMemo(
+ () =>
+ channels.map((channel) => ({
+ value: String(channel.id),
+ label: `#${channel.id} ${channel.name}`,
+ })),
+ [channels]
+ )
+ const selectedChannels = useMemo(() => {
+ const selectedIds = new Set(
+ selectedChannelIds.map((channelId) => Number(channelId))
+ )
+ return channels.filter((channel) => selectedIds.has(channel.id))
+ }, [channels, selectedChannelIds])
+ const repeatChannel = isRepeatMode ? selectedChannels[0] : undefined
+ const repeatModelNames = useMemo(() => {
+ if (!repeatChannel) return EMPTY_PRICED_MODELS
+ if (typeof repeatChannel.models !== 'string') return pricedModels
+
+ const configuredModels = new Set(
+ repeatChannel.models
+ .split(',')
+ .map((model) => model.trim())
+ .filter(Boolean)
+ )
+ return pricedModels.filter((model) => configuredModels.has(model))
+ }, [pricedModels, repeatChannel])
+ const selectableModels = isRepeatMode ? repeatModelNames : pricedModels
+ const modelOptions = useMemo(
+ () => pricedModels.map((model) => ({ value: model, label: model })),
+ [pricedModels]
+ )
+ const repeatConcurrency = parseBoundedInteger(
+ repeatConcurrencyInput,
+ MIN_REPEAT_CONCURRENCY,
+ MAX_REPEAT_CONCURRENCY
+ )
+ const repeatIterations = parseBoundedInteger(
+ repeatIterationsInput,
+ MIN_REPEAT_ITERATIONS,
+ MAX_REPEAT_ITERATIONS
+ )
+ const repeatRequestCount =
+ repeatConcurrency !== null && repeatIterations !== null
+ ? repeatConcurrency * repeatIterations
+ : 0
+ const repeatRequestLimitExceeded = repeatRequestCount > MAX_REPEAT_REQUESTS
+ const repeatConfigurationValid =
+ repeatConcurrency !== null &&
+ repeatIterations !== null &&
+ !repeatRequestLimitExceeded
+ const repeatWorkers = useMemo(() => {
+ if (
+ !isRepeatMode ||
+ !repeatChannel ||
+ selectedModels.length !== 1 ||
+ repeatConcurrency === null ||
+ repeatIterations === null ||
+ repeatRequestLimitExceeded
+ ) {
+ return []
+ }
+
+ return buildRepeatTestWorkers(
+ repeatChannel,
+ selectedModels[0],
+ repeatConcurrency,
+ repeatIterations
+ )
+ }, [
+ isRepeatMode,
+ repeatChannel,
+ repeatConcurrency,
+ repeatIterations,
+ repeatRequestLimitExceeded,
+ selectedModels,
+ ])
+ const tasks = useMemo(
+ () =>
+ isRepeatMode
+ ? repeatWorkers.flat()
+ : buildBatchTestTasks(selectedChannels, selectedModels),
+ [isRepeatMode, repeatWorkers, selectedChannels, selectedModels]
+ )
+ const visibleResults = useMemo(
+ () =>
+ tasks
+ .map((task) => results[task.key])
+ .filter((result): result is BatchTestResult => result !== undefined),
+ [results, tasks]
+ )
+ const latencyStats = useMemo(() => {
+ const responseTimes = visibleResults
+ .filter((result) => result.status !== 'testing')
+ .map((result) => result.responseTime)
+ .filter(
+ (responseTime): responseTime is number =>
+ typeof responseTime === 'number' && Number.isFinite(responseTime)
+ )
+ .sort((a, b) => a - b)
+ if (responseTimes.length === 0) return null
+
+ const totalResponseTime = responseTimes.reduce(
+ (total, responseTime) => total + responseTime,
+ 0
+ )
+ const p95Index = Math.ceil(responseTimes.length * 0.95) - 1
+ return {
+ average: Math.round(totalResponseTime / responseTimes.length),
+ fastest: responseTimes.at(0),
+ slowest: responseTimes.at(-1),
+ p95: responseTimes.at(p95Index),
+ sampleCount: responseTimes.length,
+ }
+ }, [visibleResults])
+ const progressPercent = progress
+ ? Math.round((progress.completed / progress.total) * 100)
+ : 0
+ const channelLoadError = usesProvidedChannels ? null : channelsQuery.error
+ const loadError = channelLoadError ?? pricedModelsQuery.error
+ const channelsLoading = !usesProvidedChannels && channelsQuery.isLoading
+ const optionsLoading = channelsLoading || pricedModelsQuery.isLoading
+
+ const clearResults = () => {
+ setResults({})
+ setProgress(null)
+ }
+
+ const resetDialog = () => {
+ stopRequestedRef.current = true
+ setTestMode('batch')
+ setSelectedChannelIds([])
+ setSelectedModels([])
+ setRepeatConcurrencyInput(DEFAULT_REPEAT_CONCURRENCY)
+ setRepeatIterationsInput(DEFAULT_REPEAT_ITERATIONS)
+ setResults({})
+ setProgress(null)
+ setIsTesting(false)
+ setIsStopRequested(false)
+ }
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open && isTesting) {
+ toast.error(
+ isRepeatMode
+ ? '并发循环测试进行中,请先停止测试'
+ : '批量测试进行中,请先停止测试'
+ )
+ return
+ }
+ if (!open) resetDialog()
+ props.onOpenChange(open)
+ }
+
+ const handleStartTest = async () => {
+ if (isRepeatMode && !repeatConfigurationValid) {
+ toast.error(
+ `请填写有效的并发与循环次数,总请求数不能超过 ${MAX_REPEAT_REQUESTS}`
+ )
+ return
+ }
+ if (tasks.length === 0) {
+ toast.error(
+ isRepeatMode
+ ? '请选择一个渠道和一个该渠道支持的已定价模型'
+ : '请至少选择一个渠道和一个已定价模型'
+ )
+ return
+ }
+
+ stopRequestedRef.current = false
+ setIsTesting(true)
+ setIsStopRequested(false)
+ setResults({})
+ setProgress({
+ total: tasks.length,
+ completed: 0,
+ success: 0,
+ failed: 0,
+ })
+
+ let completed = 0
+ let succeeded = 0
+ let failed = 0
+
+ try {
+ if (isRepeatMode) {
+ await Promise.all(
+ repeatWorkers.map(async (workerTasks) => {
+ for (const task of workerTasks) {
+ if (stopRequestedRef.current) break
+
+ setResults((current) => ({
+ ...current,
+ [task.key]: { ...task, status: 'testing' },
+ }))
+ const result = await runBatchTestTask(task)
+ completed += 1
+ if (result.status === 'success') succeeded += 1
+ failed = completed - succeeded
+ setResults((current) => ({
+ ...current,
+ [result.key]: result,
+ }))
+ setProgress({
+ total: tasks.length,
+ completed,
+ success: succeeded,
+ failed,
+ })
+ }
+ })
+ )
+ } else {
+ for (
+ let start = 0;
+ start < tasks.length;
+ start += BATCH_TEST_CONCURRENCY
+ ) {
+ if (stopRequestedRef.current) break
+
+ const batch = tasks.slice(start, start + BATCH_TEST_CONCURRENCY)
+ setResults((current) => {
+ const next = { ...current }
+ for (const task of batch) {
+ next[task.key] = { ...task, status: 'testing' }
+ }
+ return next
+ })
+
+ const batchResults = await Promise.all(batch.map(runBatchTestTask))
+ completed += batchResults.length
+ succeeded += batchResults.filter(
+ (result) => result.status === 'success'
+ ).length
+ failed = completed - succeeded
+
+ setResults((current) => {
+ const next = { ...current }
+ for (const result of batchResults) {
+ next[result.key] = result
+ }
+ return next
+ })
+ setProgress({
+ total: tasks.length,
+ completed,
+ success: succeeded,
+ failed,
+ })
+ }
+ }
+ } finally {
+ const stopped = stopRequestedRef.current && completed < tasks.length
+ setIsTesting(false)
+ setIsStopRequested(false)
+ stopRequestedRef.current = false
+ void queryClient.invalidateQueries({
+ queryKey: channelsQueryKeys.lists(),
+ })
+
+ if (stopped) {
+ toast.warning(
+ `${isRepeatMode ? '并发循环测试' : '批量测试'}已停止:完成 ${completed}/${tasks.length},成功 ${succeeded},失败 ${failed}`
+ )
+ } else {
+ toast.success(
+ `${isRepeatMode ? '并发循环测试' : '批量测试'}完成:成功 ${succeeded},失败 ${failed}`
+ )
+ }
+ }
+ }
+
+ const handleStopTest = () => {
+ if (!isTesting || isStopRequested) return
+ stopRequestedRef.current = true
+ setIsStopRequested(true)
+ }
+
+ const footer = (
+ <>
+ handleOpenChange(false)}
+ disabled={isTesting}
+ >
+ 关闭
+
+ {isTesting ? (
+
+ {isStopRequested && }
+ {isStopRequested ? '正在停止' : '停止测试'}
+
+ ) : (
+ void handleStartTest()}
+ disabled={
+ optionsLoading ||
+ Boolean(loadError) ||
+ tasks.length === 0 ||
+ (isRepeatMode && !repeatConfigurationValid)
+ }
+ >
+
+ {visibleResults.length > 0 ? '重新测试' : '开始测试'}
+
+ )}
+ >
+ )
+
+ let dialogDescription =
+ '选择渠道和已设置价格的模型,批量验证上游连通性。每个渠道都会测试每个已选模型。'
+ if (repeatModeEnabled) {
+ dialogDescription =
+ '可批量验证多个渠道,也可固定一个渠道和模型进行并发循环测试。测试会真实请求上游。'
+ } else if (isSingleModel) {
+ dialogDescription =
+ '选择多个渠道和一个已设置价格的模型,批量验证上游连通性。'
+ }
+
+ let modelDescription = `仅显示已设置价格的模型,共 ${pricedModels.length} 个。`
+ if (isRepeatMode) {
+ modelDescription = repeatChannel
+ ? `仅显示该渠道支持且已设置价格的模型,共 ${selectableModels.length} 个。`
+ : '请先选择渠道,再选择该渠道支持的模型。'
+ } else if (isSingleModel) {
+ modelDescription = `仅显示已设置价格的模型,共 ${pricedModels.length} 个,每次只能选择一个。`
+ }
+
+ let testPlanTitle = `将执行 ${tasks.length} 个测试组合`
+ let testPlanDescription = `${selectedChannels.length} 个渠道 × ${selectedModels.length} 个模型,最多同时发起 ${BATCH_TEST_CONCURRENCY} 个请求。`
+ if (isRepeatMode) {
+ testPlanTitle = `将执行 ${repeatRequestCount} 次测试请求`
+ testPlanDescription = `${repeatChannel?.name} · ${selectedModels[0]},${repeatConcurrencyInput} 个并发 × 每并发 ${repeatIterationsInput} 次循环。`
+ } else if (isSingleModel) {
+ testPlanTitle = `将测试 ${selectedChannels.length} 个渠道`
+ testPlanDescription = `统一使用 ${selectedModels[0]} 模型,最多同时发起 ${BATCH_TEST_CONCURRENCY} 个请求。`
+ }
+
+ return (
+
+ {repeatModeEnabled && (
+ {
+ const nextMode = values[0]
+ if (nextMode !== 'batch' && nextMode !== 'repeat') return
+ setTestMode(nextMode)
+ setSelectedChannelIds([])
+ setSelectedModels([])
+ clearResults()
+ }}
+ variant='outline'
+ spacing={0}
+ aria-label='选择连通性测试模式'
+ className='grid w-full grid-cols-2'
+ disabled={isTesting}
+ >
+
+ 批量渠道
+
+
+ 并发循环
+
+
+ )}
+
+ {loadError && (
+
+
+ 批量测试选项加载失败
+ {getErrorMessage(loadError)}
+
+ {
+ if (!usesProvidedChannels) void channelsQuery.refetch()
+ void pricedModelsQuery.refetch()
+ }}
+ >
+ 重试
+
+
+
+ )}
+
+
+
+
+
选择渠道
+ {!isRepeatMode && (
+
+ {
+ setSelectedChannelIds(
+ channels
+ .filter(
+ (channel) =>
+ props.selectAllMode === 'all' ||
+ channel.status === 1
+ )
+ .map((channel) => String(channel.id))
+ )
+ clearResults()
+ }}
+ disabled={isTesting || channels.length === 0}
+ >
+ {props.selectAllMode === 'all' ? '全选' : '全选启用渠道'}
+
+ {
+ setSelectedChannelIds([])
+ clearResults()
+ }}
+ disabled={isTesting || selectedChannelIds.length === 0}
+ >
+ 清空
+
+
+ )}
+
+ {channelsLoading && }
+ {!channelsLoading && isRepeatMode && (
+ option.label}
+ itemToStringValue={(option) => option.value}
+ value={
+ channelOptions.find(
+ (option) => option.value === selectedChannelIds[0]
+ ) ?? null
+ }
+ onValueChange={(option) => {
+ setSelectedChannelIds(option ? [option.value] : [])
+ setSelectedModels([])
+ clearResults()
+ }}
+ disabled={isTesting || Boolean(channelLoadError)}
+ >
+ 0}
+ disabled={isTesting || Boolean(channelLoadError)}
+ />
+
+
+
+ {(option: { value: string; label: string }) => (
+
+ {option.label}
+
+ )}
+
+
+ 没有匹配的渠道
+
+
+ )}
+ {!channelsLoading && !isRepeatMode && (
+ {
+ setSelectedChannelIds(values)
+ clearResults()
+ }}
+ placeholder='搜索并选择渠道'
+ emptyText='没有匹配的渠道'
+ disabled={isTesting || Boolean(channelLoadError)}
+ renderSelectedSummary={(values) => `已选 ${values.length} 个渠道`}
+ />
+ )}
+
+ {isRepeatMode
+ ? `共 ${channels.length} 个渠道,只能选择一个测试目标。`
+ : `共 ${channels.length} 个渠道,当前选择 ${selectedChannelIds.length} 个。`}
+
+
+
+
+
+
+ {isSingleModel ? '选择模型' : '选择已定价模型'}
+
+ {!isSingleModel && (
+
+ {
+ setSelectedModels(pricedModels)
+ clearResults()
+ }}
+ disabled={isTesting || pricedModels.length === 0}
+ >
+ 全选
+
+ {
+ setSelectedModels([])
+ clearResults()
+ }}
+ disabled={isTesting || selectedModels.length === 0}
+ >
+ 清空
+
+
+ )}
+
+ {pricedModelsQuery.isLoading && }
+ {!pricedModelsQuery.isLoading && isSingleModel && (
+ {
+ setSelectedModels(value ? [value] : [])
+ clearResults()
+ }}
+ disabled={
+ isTesting ||
+ Boolean(pricedModelsQuery.error) ||
+ (isRepeatMode && !repeatChannel)
+ }
+ >
+ 0}
+ disabled={
+ isTesting ||
+ Boolean(pricedModelsQuery.error) ||
+ (isRepeatMode && !repeatChannel)
+ }
+ />
+
+
+
+ {(model: string) => (
+
+ {model}
+
+ )}
+
+
+
+ {isRepeatMode
+ ? '该渠道没有已设置价格的模型'
+ : '没有已设置价格的模型'}
+
+
+
+ )}
+ {!pricedModelsQuery.isLoading && !isSingleModel && (
+ {
+ setSelectedModels(values)
+ clearResults()
+ }}
+ placeholder='搜索并选择模型'
+ emptyText='没有已设置价格的模型'
+ disabled={isTesting || Boolean(pricedModelsQuery.error)}
+ renderSelectedSummary={(values) => `已选 ${values.length} 个模型`}
+ copyChipOnClick
+ />
+ )}
+ {modelDescription}
+
+
+
+ {isRepeatMode && (
+
+
+ 并发数
+ {
+ setRepeatConcurrencyInput(event.target.value)
+ clearResults()
+ }}
+ disabled={isTesting}
+ aria-invalid={
+ repeatConcurrency === null || repeatRequestLimitExceeded
+ }
+ />
+
+ 同时运行的测试任务,范围 {MIN_REPEAT_CONCURRENCY}-
+ {MAX_REPEAT_CONCURRENCY}。
+
+
+
+
+ 每并发循环次数
+
+ {
+ setRepeatIterationsInput(event.target.value)
+ clearResults()
+ }}
+ disabled={isTesting}
+ aria-invalid={
+ repeatIterations === null || repeatRequestLimitExceeded
+ }
+ />
+
+ 每个并发任务顺序执行,范围 {MIN_REPEAT_ITERATIONS}-
+ {MAX_REPEAT_ITERATIONS} 次。
+
+
+
+ )}
+
+ {isRepeatMode && repeatRequestLimitExceeded && (
+
+
+ 总请求数超过限制
+
+ 当前为 {repeatRequestCount} 次,单次测试最多允许{' '}
+ {MAX_REPEAT_REQUESTS} 次请求。
+
+
+ )}
+
+ {selectedChannels.length > 0 && selectedModels.length > 0 && (
+
+
+ {testPlanTitle}
+ {testPlanDescription}
+
+ )}
+
+ {progress && (
+
+
+ 测试进度
+
+ {() => `${progress.completed}/${progress.total}`}
+
+
+
+ 完成 {progress.completed}
+
+ 成功 {progress.success}
+
+ 失败 {progress.failed}
+
+ {isRepeatMode && latencyStats && (
+
+
+
平均响应
+
+ {formatBatchTestResponseTime(latencyStats.average)}
+
+
+
+
最快 / 最慢
+
+ {formatBatchTestResponseTime(latencyStats.fastest)} /{' '}
+ {formatBatchTestResponseTime(latencyStats.slowest)}
+
+
+
+
P95
+
+ {formatBatchTestResponseTime(latencyStats.p95)}
+
+
+
+
有效样本
+
+ {latencyStats.sampleCount} 次
+
+
+
+ )}
+
+ )}
+
+ {visibleResults.length > 0 && (
+
+
+
+
+ {isRepeatMode && 轮次 }
+ 渠道
+ 模型
+ 状态
+ 响应时间
+ 结果
+
+
+
+ {visibleResults.map((result) => (
+
+ {isRepeatMode && (
+
+
+ 并发 {result.workerIndex}
+
+
+ 第 {result.iteration} 次
+
+
+ )}
+
+
+
+ {result.channelName}
+
+
+ #{result.channelId}
+
+
+
+
+ {result.model}
+
+
+
+
+
+ {formatBatchTestResponseTime(result.responseTime)}
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx
index a2d641d22c21..238604e49a05 100644
--- a/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx
+++ b/web/default/src/features/channels/components/dialogs/channel-test-dialog.tsx
@@ -105,8 +105,17 @@ type ChannelTestDialogProps = {
onOpenChange: (open: boolean) => void
}
+export type ChannelTestTarget = Pick<
+ Channel,
+ 'id' | 'name' | 'models' | 'test_model'
+>
+
+type ChannelTestDialogForChannelProps = ChannelTestDialogProps & {
+ channel: ChannelTestTarget
+}
+
type ChannelTestDialogContentProps = ChannelTestDialogProps & {
- currentRow: Channel
+ currentRow: ChannelTestTarget
}
type ModelRow = {
@@ -311,11 +320,23 @@ export function ChannelTestDialog({
}
return (
-
+ )
+}
+
+export function ChannelTestDialogForChannel(
+ props: ChannelTestDialogForChannelProps
+) {
+ return (
+
)
}
diff --git a/web/default/src/features/usage-logs/lib/format.ts b/web/default/src/features/usage-logs/lib/format.ts
index 526ca217f748..d65409ef0494 100644
--- a/web/default/src/features/usage-logs/lib/format.ts
+++ b/web/default/src/features/usage-logs/lib/format.ts
@@ -390,10 +390,34 @@ const AUDIT_TEMPLATES: Record = {
generic: '{{method}} {{route}}',
}
+// 渠道监控是内部自定义功能,操作日志固定使用中文,不跟随系统语言切换。
+const CHANNEL_MONITOR_AUDIT_TEMPLATES: Record = {
+ 'channel.status_update': '已将渠道 {{id}} 的状态更新为 {{status}}',
+ 'channel.monitor_smart_schedule_config_update':
+ '已更新渠道 {{id}} 的智能调度设置',
+ 'channel.monitor_group_ratio_sync':
+ '已根据上游倍率 {{upstream_ratio}} 和系数 {{coefficient}},将分组 {{group}} 的倍率更新为 {{ratio}}',
+ 'channel.monitor_group_ratio_update':
+ '已将分组 {{group}} 的倍率更新为 {{ratio}}',
+ 'channel.monitor_ratio_update': '已将渠道 {{id}} 的倍率更新为 {{ratio}}',
+ 'channel.monitor_ratio_update_run': '已启动上游倍率更新任务 {{task_id}}',
+ 'channel.monitor_upstream_config_update':
+ '已更新渠道 {{id}} 的上游配置({{upstream_type}})',
+ 'channel.monitor_upstream_ratio_fetch':
+ '已获取渠道 {{id}} 的上游倍率 {{ratio}}',
+ 'channel.monitor_upstream_balance_fetch':
+ '已获取渠道 {{id}} 的上游余额 {{balance}}',
+ 'channel.monitor_upstream_group_apply':
+ '已将上游分组 {{group}} 应用于渠道 {{id}}(已更新 {{keys_updated}} 个令牌,倍率 {{ratio}})',
+ 'channel.monitor_smart_schedule_run': '已启动智能调度任务 {{task_id}}',
+ 'channel.monitor_order_update':
+ '已更新 {{channel_count}} 个监控渠道的自定义顺序',
+ 'channel.monitor_settings_update': '已更新渠道监控设置',
+}
+
/**
- * Render the localized content of an audit/login log from its structured
- * `other.op` descriptor. Returns null when the log has no recognized action,
- * letting callers fall back to the raw `content` field.
+ * Render audit/login content from its structured `other.op` descriptor.
+ * Channel-monitor actions use fixed Chinese copy; other actions use i18n.
*/
export function renderAuditContent(
other: LogOtherData | null | undefined,
@@ -401,6 +425,14 @@ export function renderAuditContent(
): string | null {
const op = other?.op
if (!op?.action) return null
+ const fixedChineseTemplate = CHANNEL_MONITOR_AUDIT_TEMPLATES[op.action]
+ if (fixedChineseTemplate) {
+ const params = op.params ?? {}
+ return fixedChineseTemplate.replaceAll(/\{\{(\w+)\}\}/g, (_match, key) => {
+ const value = params[key]
+ return value == null ? '' : String(value)
+ })
+ }
const template = AUDIT_TEMPLATES[op.action]
if (!template) return null
return t(template, (op.params ?? {}) as Record)
diff --git a/web/default/src/hooks/use-sidebar-config.ts b/web/default/src/hooks/use-sidebar-config.ts
index a026e43ddce7..5c0fa9c48cba 100644
--- a/web/default/src/hooks/use-sidebar-config.ts
+++ b/web/default/src/hooks/use-sidebar-config.ts
@@ -108,6 +108,7 @@ const URL_TO_CONFIG_MAP: Record = {
'/wallet': { section: 'personal', module: 'topup' },
'/profile': { section: 'personal', module: 'personal' },
'/channels': { section: 'admin', module: 'channel' },
+ '/channel-monitor': { section: 'admin', module: 'channel' },
'/models': { section: 'admin', module: 'models' },
'/models/metadata': { section: 'admin', module: 'models' },
'/models/deployments': { section: 'admin', module: 'models' },
diff --git a/web/default/src/hooks/use-sidebar-data.ts b/web/default/src/hooks/use-sidebar-data.ts
index 40a0615aa347..c3d830b16a1c 100644
--- a/web/default/src/hooks/use-sidebar-data.ts
+++ b/web/default/src/hooks/use-sidebar-data.ts
@@ -36,7 +36,8 @@ import {
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
-import { type SidebarData } from '@/components/layout/types'
+import type { SidebarData } from '@/components/layout/types'
+import { ChannelMonitorIcon } from '@/features/channel-monitor/icon'
import { ROLE } from '@/lib/roles'
/**
@@ -124,6 +125,12 @@ export function useSidebarData(): SidebarData {
url: '/channels',
icon: Radio,
},
+ {
+ title: '渠道监控',
+ url: '/channel-monitor',
+ icon: ChannelMonitorIcon,
+ requiredRole: ROLE.SUPER_ADMIN,
+ },
{
title: t('Models'),
url: '/models/metadata',
diff --git a/web/default/src/routeTree.gen.ts b/web/default/src/routeTree.gen.ts
index e0add2a9b93d..262f06fadad0 100644
--- a/web/default/src/routeTree.gen.ts
+++ b/web/default/src/routeTree.gen.ts
@@ -49,6 +49,7 @@ import { Route as AuthenticatedModelsIndexRouteImport } from './routes/_authenti
import { Route as AuthenticatedKeysIndexRouteImport } from './routes/_authenticated/keys/index'
import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard/index'
import { Route as AuthenticatedChannelsIndexRouteImport } from './routes/_authenticated/channels/index'
+import { Route as AuthenticatedChannelMonitorIndexRouteImport } from './routes/_authenticated/channel-monitor/index'
import { Route as AuthenticatedUsageLogsSectionRouteImport } from './routes/_authenticated/usage-logs/$section'
import { Route as AuthenticatedModelsSectionRouteImport } from './routes/_authenticated/models/$section'
import { Route as AuthenticatedErrorsErrorRouteImport } from './routes/_authenticated/errors/$error'
@@ -280,6 +281,12 @@ const AuthenticatedChannelsIndexRoute =
path: '/channels/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
+const AuthenticatedChannelMonitorIndexRoute =
+ AuthenticatedChannelMonitorIndexRouteImport.update({
+ id: '/channel-monitor/',
+ path: '/channel-monitor/',
+ getParentRoute: () => AuthenticatedRouteRoute,
+ } as any)
const AuthenticatedUsageLogsSectionRoute =
AuthenticatedUsageLogsSectionRouteImport.update({
id: '/usage-logs/$section',
@@ -430,6 +437,7 @@ export interface FileRoutesByFullPath {
'/errors/$error': typeof AuthenticatedErrorsErrorRoute
'/models/$section': typeof AuthenticatedModelsSectionRoute
'/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute
+ '/channel-monitor/': typeof AuthenticatedChannelMonitorIndexRoute
'/channels/': typeof AuthenticatedChannelsIndexRoute
'/dashboard/': typeof AuthenticatedDashboardIndexRoute
'/keys/': typeof AuthenticatedKeysIndexRoute
@@ -489,6 +497,7 @@ export interface FileRoutesByTo {
'/errors/$error': typeof AuthenticatedErrorsErrorRoute
'/models/$section': typeof AuthenticatedModelsSectionRoute
'/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute
+ '/channel-monitor': typeof AuthenticatedChannelMonitorIndexRoute
'/channels': typeof AuthenticatedChannelsIndexRoute
'/dashboard': typeof AuthenticatedDashboardIndexRoute
'/keys': typeof AuthenticatedKeysIndexRoute
@@ -552,6 +561,7 @@ export interface FileRoutesById {
'/_authenticated/errors/$error': typeof AuthenticatedErrorsErrorRoute
'/_authenticated/models/$section': typeof AuthenticatedModelsSectionRoute
'/_authenticated/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute
+ '/_authenticated/channel-monitor/': typeof AuthenticatedChannelMonitorIndexRoute
'/_authenticated/channels/': typeof AuthenticatedChannelsIndexRoute
'/_authenticated/dashboard/': typeof AuthenticatedDashboardIndexRoute
'/_authenticated/keys/': typeof AuthenticatedKeysIndexRoute
@@ -614,6 +624,7 @@ export interface FileRouteTypes {
| '/errors/$error'
| '/models/$section'
| '/usage-logs/$section'
+ | '/channel-monitor/'
| '/channels/'
| '/dashboard/'
| '/keys/'
@@ -673,6 +684,7 @@ export interface FileRouteTypes {
| '/errors/$error'
| '/models/$section'
| '/usage-logs/$section'
+ | '/channel-monitor'
| '/channels'
| '/dashboard'
| '/keys'
@@ -735,6 +747,7 @@ export interface FileRouteTypes {
| '/_authenticated/errors/$error'
| '/_authenticated/models/$section'
| '/_authenticated/usage-logs/$section'
+ | '/_authenticated/channel-monitor/'
| '/_authenticated/channels/'
| '/_authenticated/dashboard/'
| '/_authenticated/keys/'
@@ -1068,6 +1081,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedChannelsIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
+ '/_authenticated/channel-monitor/': {
+ id: '/_authenticated/channel-monitor/'
+ path: '/channel-monitor'
+ fullPath: '/channel-monitor/'
+ preLoaderRoute: typeof AuthenticatedChannelMonitorIndexRouteImport
+ parentRoute: typeof AuthenticatedRouteRoute
+ }
'/_authenticated/usage-logs/$section': {
id: '/_authenticated/usage-logs/$section'
path: '/usage-logs/$section'
@@ -1302,6 +1322,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedErrorsErrorRoute: typeof AuthenticatedErrorsErrorRoute
AuthenticatedModelsSectionRoute: typeof AuthenticatedModelsSectionRoute
AuthenticatedUsageLogsSectionRoute: typeof AuthenticatedUsageLogsSectionRoute
+ AuthenticatedChannelMonitorIndexRoute: typeof AuthenticatedChannelMonitorIndexRoute
AuthenticatedChannelsIndexRoute: typeof AuthenticatedChannelsIndexRoute
AuthenticatedDashboardIndexRoute: typeof AuthenticatedDashboardIndexRoute
AuthenticatedKeysIndexRoute: typeof AuthenticatedKeysIndexRoute
@@ -1325,6 +1346,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedErrorsErrorRoute: AuthenticatedErrorsErrorRoute,
AuthenticatedModelsSectionRoute: AuthenticatedModelsSectionRoute,
AuthenticatedUsageLogsSectionRoute: AuthenticatedUsageLogsSectionRoute,
+ AuthenticatedChannelMonitorIndexRoute: AuthenticatedChannelMonitorIndexRoute,
AuthenticatedChannelsIndexRoute: AuthenticatedChannelsIndexRoute,
AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute,
AuthenticatedKeysIndexRoute: AuthenticatedKeysIndexRoute,
diff --git a/web/default/src/routes/_authenticated/channel-monitor/index.tsx b/web/default/src/routes/_authenticated/channel-monitor/index.tsx
new file mode 100644
index 000000000000..2ba42a1cdb3f
--- /dev/null
+++ b/web/default/src/routes/_authenticated/channel-monitor/index.tsx
@@ -0,0 +1,33 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { createFileRoute, redirect } from '@tanstack/react-router'
+
+import { ChannelMonitor } from '@/features/channel-monitor'
+import { ROLE } from '@/lib/roles'
+import { useAuthStore } from '@/stores/auth-store'
+
+export const Route = createFileRoute('/_authenticated/channel-monitor/')({
+ beforeLoad: () => {
+ const { auth } = useAuthStore.getState()
+ if (!auth.user || auth.user.role < ROLE.SUPER_ADMIN) {
+ throw redirect({ to: '/403' })
+ }
+ },
+ component: ChannelMonitor,
+})