Files
sub2api/backend/internal/service/channel_monitor_validate.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

255 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}