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, "", heading) + } + content.WriteString("") + for _, change := range changes { + upstreamType := channelMonitorUpstreamTypeLabel(change.UpstreamType) + fmt.Fprintf( + &content, + "", + 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("
%s
%s(ID: %d)%s%s%s%s%s%s%s%s
") + } + if len(balanceWarnings) > 0 { + content.WriteString("

上游余额预警

") + content.WriteString("") + for _, heading := range []string{"渠道", "备注", "上游类型", "当前余额", "预警值"} { + fmt.Fprintf(&content, "", heading) + } + content.WriteString("") + for _, warning := range balanceWarnings { + upstreamType := channelMonitorUpstreamTypeLabel(warning.UpstreamType) + fmt.Fprintf( + &content, + "", + 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("
%s
%s(ID: %d)%s%s%s%s
") + } + if len(disabledChannels) > 0 { + content.WriteString("

渠道自动禁用

") + content.WriteString("

本次更新已自动禁用以下渠道:

") + content.WriteString("") + for _, heading := range []string{"渠道", "备注", "禁用原因"} { + fmt.Fprintf(&content, "", 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, + "", + html.EscapeString(channelName), + channelRatioMonitorEmailRemark(disabledChannel.ChannelRemark), + html.EscapeString(disabledChannel.Reason), + ) + } + content.WriteString("
%s
%s%s%s
") + } + if len(removedGroupMemberships) > 0 { + content.WriteString("

渠道移出分组

") + content.WriteString("

本次更新已解除以下渠道与分组的关联:

") + content.WriteString("") + for _, heading := range []string{"渠道", "备注", "移出分组"} { + fmt.Fprintf(&content, "", 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, + "", + html.EscapeString(channelName), + channelRatioMonitorEmailRemark(removal.ChannelRemark), + html.EscapeString(removal.Group), + ) + } + content.WriteString("
%s
%s%s%s
") + } + + 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, "", 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, + "", + html.EscapeString(channelName), + channelRatioMonitorEmailRemark(failure.ChannelRemark), + html.EscapeString(failure.Error), + ) + } + content.WriteString("
%s
%s%s%s
") + } + 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 ( + + + + 渠道成本 + + 按北京时间、当前渠道成本倍率和本地分组倍率估算;消费增加、退款按发生日抵减,调整倍率后历史金额会同步变化。 + + +
+
+
+ + +
+ +
+
+
+
+ ) +} + +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} + +
+ {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 内容 + +