Files
sub2api/backend/internal/service/channel_monitor_validate.go
T
Randark c41ae19e52 fix(monitor): reject unusable quota data sources and invalid mode combos at write time
P2-2/P2-4/P2-5 from review:

- validateLinkedAccount/revalidateLinkedAccount now check
  monitorAccountQuotaCapability after the platform match, blocking combos
  that would permanently error at runtime: deepseek coding and
  custom-domain kimi coding (no quota endpoint), zhipu payg (no balance
  endpoint), anthropic/openai API-Key accounts (usage query requires
  oauth; anthropic setup-token still allowed via local estimation).
  gemini/grok/antigravity stay permissive. Quota mode surfaces
  CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE on edit; probe mode silently
  unbinds (same as platform mismatch).
- applyMonitorUpdate re-runs validateCheckMode on the effective
  provider+check_mode whenever either field is patched, so a
  provider-only update can no longer persist antigravity+probe (the
  check is conditioned on provider/check_mode presence so legacy illegal
  rows can still be renamed/disabled).
- normalizeMonitorPrimaryModel only substitutes the "quota" placeholder
  for pure quota mode; quota_probe with an empty model now fails with
  MissingPrimaryModel instead of probing model="quota". The quota branch
  also moves ahead of the grok default, so grok+quota now gets "quota"
  rather than "grok-4.5" (review L-item).

Tests: capability matrix (15 cases incl. anti-over-blocking rows),
linked-account wiring, revalidate quota/probe split, provider-only
update bypass + legacy-row rename, quota_probe missing-model create,
normalize matrix.
2026-08-18 10:28:28 +00: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 是否注册了探活 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)
}