Files
sub2api/backend/internal/service/channel_monitor_validate.go
T

255 lines
8.6 KiB
Go
Raw Normal View History

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 是否注册了探活 adapterantigravity 为 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 codingGetCodingPlanProvider 须识别为 kimi/zhipu
// deepseek coding、自定义域名 kimi coding 无法路由额度端点)
// - kimi/zhipu/deepseek payg:仅 kimi/deepseek 有公开余额端点(zhipu payg 无)
// - anthropicOAuth / Setup TokenAPI-Key 型无 usage 通道,永久 error
// - openaiOAuthAPI-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)
}