package service import ( "context" "net/url" "strings" ) // 渠道监控参数校验与归一化辅助函数。 // 校验失败一律返回 channel_monitor_const.go 中预定义的 Err* 错误,错误信息不含具体 IP/hostname,避免泄露内网拓扑。 // monitorProviders 渠道监控支持的全部 provider(与迁移 226 的 CHECK 约束一致)。 // 不再以 adapter 表为唯一来源:antigravity 没有探活 adapter,但支持配额模式。 // //nolint:gochecknoglobals // 静态查表,初始化后不变。 var monitorProviders = map[string]struct{}{ MonitorProviderOpenAI: {}, MonitorProviderAnthropic: {}, MonitorProviderGemini: {}, MonitorProviderGrok: {}, MonitorProviderAntigravity: {}, MonitorProviderKimi: {}, MonitorProviderZhipu: {}, MonitorProviderDeepseek: {}, } // probeCapableProviders 支持探活(probe / quota_probe)的 provider。 // antigravity 上游无 Chat/Responses 可打(仅 IDE 代理形态),只允许配额模式。 // //nolint:gochecknoglobals // 静态查表,初始化后不变。 var probeCapableProviders = map[string]struct{}{ MonitorProviderOpenAI: {}, MonitorProviderAnthropic: {}, MonitorProviderGemini: {}, MonitorProviderGrok: {}, MonitorProviderKimi: {}, MonitorProviderZhipu: {}, MonitorProviderDeepseek: {}, } // validateProvider 校验 provider 字符串。 func validateProvider(p string) error { if _, ok := monitorProviders[p]; !ok { return ErrChannelMonitorInvalidProvider } return nil } // providerSupportsProbe 该 provider 是否注册了探活 adapter(antigravity 为 false)。 func providerSupportsProbe(p string) bool { _, ok := probeCapableProviders[p] return ok } // defaultCheckMode 空串归一为 probe,保证存量数据与旧客户端兼容。 func defaultCheckMode(checkMode string) string { if strings.TrimSpace(checkMode) == "" { return MonitorCheckModeProbe } return strings.TrimSpace(checkMode) } // monitorCheckModeUsesQuota 该模式是否需要关联账号查配额。 func monitorCheckModeUsesQuota(checkMode string) bool { return checkMode == MonitorCheckModeQuota || checkMode == MonitorCheckModeQuotaProbe } // validateCheckMode 校验 check_mode 与 provider 的组合矩阵: // // provider | probe | quota | quota_probe // ------------------------+-------+-------+------------ // openai/anthropic/... | Y | Y | Y // antigravity(无 adapter)| N | Y | N func validateCheckMode(provider, checkMode string) error { checkMode = defaultCheckMode(checkMode) switch checkMode { case MonitorCheckModeProbe, MonitorCheckModeQuota, MonitorCheckModeQuotaProbe: default: return ErrChannelMonitorInvalidCheckMode } if checkMode != MonitorCheckModeQuota && !providerSupportsProbe(provider) { return ErrChannelMonitorInvalidCheckMode } return nil } // validateAPIMode 校验 provider 与 api_mode 的组合。 // responses 只对 OpenAI 有意义;其它 provider 使用 chat_completions 作为默认占位。 func validateAPIMode(provider, apiMode string) error { apiMode = defaultAPIMode(apiMode) switch apiMode { case MonitorAPIModeChatCompletions: return nil case MonitorAPIModeResponses: if provider == "" || provider == MonitorProviderOpenAI { return nil } return ErrChannelMonitorInvalidAPIMode default: return ErrChannelMonitorInvalidAPIMode } } // validateInterval 校验 interval_seconds 范围。 func validateInterval(sec int) error { if sec < monitorMinIntervalSeconds || sec > monitorMaxIntervalSeconds { return ErrChannelMonitorInvalidInterval } return nil } // validateJitter 校验 jitter_seconds(调度 ± 随机抖动): // 非负,且 interval - jitter 不得低于最小检测间隔,防止随机偏移后实际间隔过短打爆上游。 func validateJitter(jitterSec, intervalSec int) error { if jitterSec < 0 || intervalSec-jitterSec < monitorMinIntervalSeconds { return ErrChannelMonitorInvalidJitter } return nil } // validateEndpoint 校验 endpoint: // - scheme 强制 https(拒绝 http,避免明文凭证 + 部分 SSRF 利用面) // - 必须为 origin(无 path/query/fragment),防止用户填 https://api.openai.com/v1 // 导致 joinURL 拼出 /v1/v1/chat/completions // - hostname 不能是 localhost/metadata 等已知元数据 hostname // - 解析所有 IP,任一落在 loopback/RFC1918/link-local/ULA 段即拒绝(防 SSRF) // // 错误信息不暴露具体 IP / hostname,避免泄露内网拓扑。 func validateEndpoint(ep string) error { ep = strings.TrimSpace(ep) if ep == "" { return ErrChannelMonitorInvalidEndpoint } u, err := url.Parse(ep) if err != nil { return ErrChannelMonitorInvalidEndpoint } if u.Scheme != "https" { return ErrChannelMonitorEndpointScheme } if u.Host == "" { return ErrChannelMonitorInvalidEndpoint } if u.Path != "" && u.Path != "/" { return ErrChannelMonitorEndpointPath } if u.RawQuery != "" || u.Fragment != "" { return ErrChannelMonitorEndpointPath } hostname := u.Hostname() ctx, cancel := context.WithTimeout(context.Background(), monitorEndpointResolveTimeout) defer cancel() blocked, err := isPrivateOrLoopbackHost(ctx, hostname) if err != nil { return ErrChannelMonitorEndpointUnreachable } if blocked { return ErrChannelMonitorEndpointPrivate } return nil } // normalizeEndpoint 去除前后空白与末尾 `/`,保证存储统一为 origin。 // validateEndpoint 已确保格式合法(仅 origin),这里只做最终归一化。 func normalizeEndpoint(ep string) string { ep = strings.TrimSpace(ep) ep = strings.TrimRight(ep, "/") return ep } // normalizeModels 去除空白、重复模型名。保留输入顺序(map 的迭代顺序无关)。 func normalizeModels(in []string) []string { if len(in) == 0 { return []string{} } seen := make(map[string]struct{}, len(in)) out := make([]string, 0, len(in)) for _, m := range in { m = strings.TrimSpace(m) if m == "" { continue } if _, ok := seen[m]; ok { continue } seen[m] = struct{}{} out = append(out, m) } return out } // normalizeMonitorPrimaryModel applies provider/check_mode defaults: // - pure quota mode never sends requests: placeholder "quota" keeps // primary_model NOT NULL (history rows / timeline need no special-casing) // - quota_probe still sends a real probe request: empty model returns "" // so validateCreateParams / applyMonitorUpdate report // ErrChannelMonitorMissingPrimaryModel instead of probing model="quota" // - Grok probing (probe/quota_probe) defaults to the lightweight check model func normalizeMonitorPrimaryModel(provider, checkMode, model string) string { model = strings.TrimSpace(model) if model == "" && defaultCheckMode(checkMode) == MonitorCheckModeQuota { return MonitorDefaultQuotaModel } if model == "" && provider == MonitorProviderGrok { return MonitorDefaultGrokModel } return model } // monitorAccountQuotaCapability 校验关联账号能否充当配额数据源,与 // fetchUncached 的路由一一对应(coding→CN 额度端点 / payg→CN 余额端点 / // 其余→AccountUsageService)。在创建/更新期拦截注定运行期永久 error 的组合: // - kimi/zhipu/deepseek coding:GetCodingPlanProvider 须识别为 kimi/zhipu // (deepseek coding、自定义域名 kimi coding 无法路由额度端点) // - kimi/zhipu/deepseek payg:仅 kimi/deepseek 有公开余额端点(zhipu payg 无) // - anthropic:OAuth / Setup Token(API-Key 型无 usage 通道,永久 error) // - openai:OAuth(API-Key 型无 usage 通道) // - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行 func monitorAccountQuotaCapability(account *Account) error { switch account.Platform { case PlatformKimi, PlatformZhipu, PlatformDeepseek: if account.IsCodingPlan() { if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu { return ErrChannelMonitorAccountNotSupportable } return nil } if account.Platform == PlatformZhipu { return ErrChannelMonitorAccountNotSupportable } return nil case PlatformAnthropic: if account.Type == AccountTypeOAuth || account.Type == AccountTypeSetupToken { return nil } return ErrChannelMonitorAccountNotSupportable case PlatformOpenAI: if account.Type == AccountTypeOAuth { return nil } return ErrChannelMonitorAccountNotSupportable default: return nil } } // defaultAPIMode 空串归一为 chat_completions,保证历史数据与旧客户端兼容。 func defaultAPIMode(apiMode string) string { if strings.TrimSpace(apiMode) == "" { return MonitorAPIModeChatCompletions } return strings.TrimSpace(apiMode) }