package console // 第三方服务可用性探针。 // // 巡检器把一份「有效配置」(svc_config 默认字段 + 区域覆盖,解密后的明文)交给 probeService, // 由它按 provider(+category) 分派到具体探针,用**服务商官方的最轻量鉴权接口**验证凭据是否真的可用。 // // 三条设计原则: // 1. 证据分级不掺水:探针返回 ProbeCredential(真调通了) / ProbeReachable(只证明地址在线) / // ProbeNone(没探)。没覆盖的服务商一律如实返回 ProbeNone,绝不把「没探到」渲染成「是好的」。 // 2. 只读、免费、无副作用:一律选 list-models / issueToken / GetCallerIdentity 这类查询接口, // 不产生推理计费、不写任何数据。唯一例外是 Azure 翻译(没有纯查询的鉴权接口),发一个 4 字文本。 // 3. 401/403 才算凭据错。429(限流)、5xx(服务商自身故障) 说明凭据是被接受的,分别判为可用/服务商故障, // 避免把服务商抖动误报成「密钥失效」让运营去换密钥。 // // 新增服务商 = 在 probeService 的 switch 里加一个 case + 一个探针函数,无需改巡检器与前端。 import ( "bytes" "context" "crypto/hmac" "crypto/sha1" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ossSdk "github.com/aliyun/aliyun-oss-go-sdk/oss" cosSdk "github.com/tencentyun/cos-go-sdk-v5" "golang.org/x/oauth2/google" "yunyan/comm" ) // probeResult 一次探测的结论。Kind 标明证据强度(见 comm.ProbeNone/Reachable/Credential)。 type probeResult struct { Kind string Ok bool Msg string } func credOK(msg string) probeResult { return probeResult{comm.ProbeCredential, true, msg} } func credFail(msg string) probeResult { return probeResult{comm.ProbeCredential, false, msg} } func reachOK(msg string) probeResult { return probeResult{comm.ProbeReachable, true, msg} } func reachFail(msg string) probeResult { return probeResult{comm.ProbeReachable, false, msg} } // noProbe 没有可用探针时的诚实结论:Ok=true 只表示「未发现问题」,Kind=none 表示「也没验证过」。 // 巡检器据此把状态定为 warn 而非 ok,前端会显式提示「未探测」。 func noProbe(msg string) probeResult { return probeResult{comm.ProbeNone, true, msg} } // ============================== 分派入口 ============================== // probeService 按 provider(+类别) 选择探针。fields 为解密后的明文字段表。 // cats 是该服务的类别集合(一个服务可同属多类,如 GPT-4o 同属文本/多媒体大模型)。 func probeService(ctx context.Context, provider string, cats map[int32]bool, fields map[string]string) probeResult { // MCP 服务与 provider 无关(模板 provider 是 custom,实际由运营自填),先按类别拦下。 if cats[svcCatMCP] { return probeMCP(ctx, fields) } switch strings.ToLower(strings.TrimSpace(provider)) { case "openai": return probeOpenAICompat(ctx, fields, "https://api.openai.com/v1", "OpenAI") case "deepseek": return probeOpenAICompat(ctx, fields, "https://api.deepseek.com/v1", "DeepSeek") case "qwen": return probeOpenAICompat(ctx, fields, "https://dashscope.aliyuncs.com/compatible-mode/v1", "通义千问") case "anthropic": return probeAnthropic(ctx, fields) case "elevenlabs": return probeElevenLabs(ctx, fields) case "azure": return probeAzure(ctx, cats, fields) case "google": return probeGoogle(ctx, fields) case "aliyun_oss": return probeAliyunOSS(ctx, fields) case "tencent_cos": return probeTencentCOS(ctx, fields) case "alibaba": return probeAlibaba(ctx, fields) case "doubao": return probeDoubao(ctx, fields) case "volcengine", "bytedance": return probeVolcengine(fields) } // 未知/自定义服务商:能找到地址就做连通性探测,否则如实说没探。 if u := firstURLField(fields); u != "" { return httpReachable(ctx, u) } return noProbe("暂无该服务商(" + provider + ")的探针,且配置中无可探测地址;本次仅完成配置校验") } // ============================== HTTP 基础设施 ============================== // probeClient 探测专用 HTTP 客户端:连接不复用(每轮巡检间隔远大于 keep-alive,留着也是浪费), // 超时由调用方的 ctx 控制。 var probeClient = &http.Client{ Transport: &http.Transport{DisableKeepAlives: true}, } // doProbe 发一个请求并读回有限长度的响应体(截断防止把服务商的长错误页塞满日志/库)。 func doProbe(ctx context.Context, method, rawurl string, headers map[string]string, body []byte) (int, string, error) { var rdr io.Reader if body != nil { rdr = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, rawurl, rdr) if err != nil { return 0, "", err } for k, v := range headers { req.Header.Set(k, v) } resp, err := probeClient.Do(req) if err != nil { return 0, "", err } defer resp.Body.Close() buf, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return resp.StatusCode, strings.TrimSpace(string(buf)), nil } // classify 把 HTTP 状态码翻译成统一结论。svcName 用于组装人话消息。 // // 判定表(对齐本文件开头第 3 条原则): // // 2xx → 凭据有效 // 401/403 → 凭据无效/权限不足(这才是要运营去处理的) // 404/405 → 端点不存在,说明探针路径不适用于该部署,退回「未探测」而不是报故障 // 429 → 被限流,说明凭据是被接受的 → 判可用并提示 // 5xx → 服务商自身故障,凭据未知 func classify(status int, bodySnippet, svcName string) probeResult { switch { case status >= 200 && status < 300: return credOK(svcName + " 凭据有效 (HTTP " + strconv.Itoa(status) + ")") case status == 401 || status == 403: return credFail(svcName + " 拒绝凭据 (HTTP " + strconv.Itoa(status) + "): " + bodySnippet) case status == 404 || status == 405: return noProbe(svcName + " 探测端点不可用 (HTTP " + strconv.Itoa(status) + "),无法验证凭据;请人工确认") case status == 429: return credOK(svcName + " 凭据有效但当前被限流 (HTTP 429)") case status >= 500: return credFail(svcName + " 服务端故障 (HTTP " + strconv.Itoa(status) + "),凭据未能验证: " + bodySnippet) default: return credFail(svcName + " 返回异常 (HTTP " + strconv.Itoa(status) + "): " + bodySnippet) } } // httpReachable 纯连通性探测:能拿到任意响应即视为地址在线(含 4xx/5xx)。 // 与 conntest.go 的 testAPIReachable 同口径,但走 ctx 超时并标记为 ProbeReachable 证据等级。 func httpReachable(ctx context.Context, rawurl string) probeResult { status, _, err := doProbe(ctx, http.MethodGet, rawurl, nil, nil) if err != nil { return reachFail("地址不可达: " + err.Error()) } return reachOK("地址可达 (HTTP " + strconv.Itoa(status) + "),但未验证凭据") } // firstURLField 在字段表里找第一个像地址的值,用于给未覆盖的服务商兜底做连通性探测。 func firstURLField(fields map[string]string) string { for _, k := range []string{"url", "base_url", "endpoint", "api_url", "host"} { v := strings.TrimSpace(fields[k]) if strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") { return v } } return "" } // pick 取第一个非空字段值,按 keys 顺序优先。 func pick(fields map[string]string, keys ...string) string { for _, k := range keys { if v := strings.TrimSpace(fields[k]); v != "" { return v } } return "" } // ============================== OpenAI 兼容协议 ============================== // probeOpenAICompat 覆盖所有 OpenAI 兼容网关(OpenAI/DeepSeek/通义/火山方舟/自建中转)。 // // 两段式:先 GET {base}/models(只读免费);若该网关没开放 models 端点(404/405), // 退化为 POST {base}/chat/completions 发一个 max_tokens=1 的极小请求——只看鉴权是否被接受, // 内容不关心。第二段有极小的推理计费,但这是验证中转网关凭据的唯一可靠手段。 func probeOpenAICompat(ctx context.Context, fields map[string]string, defBase, svcName string) probeResult { key := pick(fields, "api_key", "token") if key == "" { return credFail(svcName + " 未配置 api_key,无法验证") } base := strings.TrimRight(pick(fields, "base_url", "endpoint"), "/") if base == "" { base = defBase } auth := map[string]string{"Authorization": "Bearer " + key} status, snippet, err := doProbe(ctx, http.MethodGet, base+"/models", auth, nil) if err != nil { return credFail(svcName + " 请求失败: " + err.Error()) } if res := classify(status, snippet, svcName); status != 404 && status != 405 { return res } // 退化路径:用最小推理请求验鉴权。 model := pick(fields, "model", "endpoint_id") if model == "" { return noProbe(svcName + " 网关未提供 /models 端点,且未配置 model,无法验证凭据") } body, _ := json.Marshal(map[string]any{ "model": model, "messages": []map[string]string{{"role": "user", "content": "ping"}}, "max_tokens": 1, }) status, snippet, err = doProbe(ctx, http.MethodPost, base+"/chat/completions", map[string]string{"Authorization": "Bearer " + key, "Content-Type": "application/json"}, body) if err != nil { return credFail(svcName + " 请求失败: " + err.Error()) } return classify(status, snippet, svcName) } // probeAnthropic Anthropic 用 x-api-key + 版本头,GET /v1/models 只读免费。 func probeAnthropic(ctx context.Context, fields map[string]string) probeResult { key := pick(fields, "api_key") if key == "" { return credFail("Anthropic 未配置 api_key,无法验证") } status, snippet, err := doProbe(ctx, http.MethodGet, "https://api.anthropic.com/v1/models", map[string]string{"x-api-key": key, "anthropic-version": "2023-06-01"}, nil) if err != nil { return credFail("Anthropic 请求失败: " + err.Error()) } return classify(status, snippet, "Anthropic") } // probeElevenLabs GET /v1/user 返回账号信息,是官方最轻的鉴权校验端点。 func probeElevenLabs(ctx context.Context, fields map[string]string) probeResult { key := pick(fields, "api_key") if key == "" { return credFail("ElevenLabs 未配置 api_key,无法验证") } status, snippet, err := doProbe(ctx, http.MethodGet, "https://api.elevenlabs.io/v1/user", map[string]string{"xi-api-key": key}, nil) if err != nil { return credFail("ElevenLabs 请求失败: " + err.Error()) } return classify(status, snippet, "ElevenLabs") } // ============================== 微软 Azure ============================== // probeAzure Azure 按服务族分两套鉴权域: // - 翻译(MT):走全局 api.cognitive.microsofttranslator.com,密钥 + 区域头; // - 语音(STT/TTS/AST/STS/录音识别):走区域化 {region}.api.cognitive.microsoft.com 的 issueToken, // 换取临时 token 成功即证明订阅密钥有效——官方推荐的密钥校验方式,免费无副作用。 func probeAzure(ctx context.Context, cats map[int32]bool, fields map[string]string) probeResult { key := pick(fields, "subscription_key", "api_key") if key == "" { return credFail("Azure 未配置 subscription_key,无法验证") } if cats[svcCatMT] { return probeAzureTranslator(ctx, key, pick(fields, "region")) } region := pick(fields, "region") if region == "" { return credFail("Azure 语音服务未配置 region,无法验证") } // region 可能被填成完整终结点(模板描述允许「区域/终结点」),此时直接用它拼 issueToken。 host := region + ".api.cognitive.microsoft.com" if strings.Contains(region, ".") { host = strings.TrimPrefix(strings.TrimPrefix(strings.TrimRight(region, "/"), "https://"), "http://") } status, snippet, err := doProbe(ctx, http.MethodPost, "https://"+host+"/sts/v1.0/issueToken", map[string]string{"Ocp-Apim-Subscription-Key": key, "Content-Length": "0"}, []byte{}) if err != nil { return credFail("Azure 语音请求失败: " + err.Error()) } return classify(status, snippet, "Azure 语音") } // probeAzureTranslator 翻译没有纯查询型鉴权接口,发一个 4 字文本的最小翻译请求(费用可忽略)。 func probeAzureTranslator(ctx context.Context, key, region string) probeResult { headers := map[string]string{ "Ocp-Apim-Subscription-Key": key, "Content-Type": "application/json", } if region != "" { headers["Ocp-Apim-Subscription-Region"] = region } body, _ := json.Marshal([]map[string]string{{"Text": "ping"}}) status, snippet, err := doProbe(ctx, http.MethodPost, "https://api.cognitive.microsofttranslator.com/translate?api-version=3.0&to=zh-Hans", headers, body) if err != nil { return credFail("Azure 翻译请求失败: " + err.Error()) } return classify(status, snippet, "Azure 翻译") } // ============================== Google ============================== // probeGoogle Google 两种凭据形态: // - json:服务账号密钥(Speech/翻译用)。用它签 JWT 去 OAuth2 换 access_token, // 换到即证明密钥有效且未被吊销——不调用任何计费 API。 // - api_key:Gemini 用。GET /v1beta/models?key= 只读免费。 func probeGoogle(ctx context.Context, fields map[string]string) probeResult { if raw := pick(fields, "json"); raw != "" { return probeGoogleServiceAccount(ctx, raw) } key := pick(fields, "api_key") if key == "" { return credFail("Google 未配置服务账号 JSON 或 api_key,无法验证") } endpoint := strings.TrimRight(pick(fields, "endpoint"), "/") if endpoint == "" { endpoint = "https://generativelanguage.googleapis.com/v1beta" } status, snippet, err := doProbe(ctx, http.MethodGet, endpoint+"/models?key="+url.QueryEscape(key), nil, nil) if err != nil { return credFail("Google 请求失败: " + err.Error()) } return classify(status, snippet, "Google") } // probeGoogleServiceAccount 字段值兼容两种存法:JSON 内容本身,或指向密钥文件的路径 // (sys/google 走的是路径,模板描述也写明「内容或路径」)。 func probeGoogleServiceAccount(ctx context.Context, raw string) probeResult { blob := []byte(raw) if !strings.HasPrefix(strings.TrimSpace(raw), "{") { b, err := os.ReadFile(strings.TrimSpace(raw)) if err != nil { return credFail("Google 服务账号密钥文件读取失败: " + err.Error()) } blob = b } cfg, err := google.JWTConfigFromJSON(blob, "https://www.googleapis.com/auth/cloud-platform") if err != nil { return credFail("Google 服务账号 JSON 解析失败: " + err.Error()) } if _, err = cfg.TokenSource(ctx).Token(); err != nil { return credFail("Google 服务账号换取令牌失败(密钥可能已吊销): " + err.Error()) } return credOK("Google 服务账号有效(已成功换取访问令牌)") } // ============================== 阿里云 ============================== // probeAlibaba 阿里云两种凭据形态: // - api_key:DashScope(录音识别/百炼),GET compatible-mode/v1/models 只读免费; // - AccessKey:其余(STT/TTS/MT/AST)。用 STS GetCallerIdentity 校验 AK/SK—— // 它是阿里云唯一免费、只读、不依赖具体产品开通状态的身份校验接口, // 用具体产品接口(如翻译)校验会把「产品未开通」误报成「密钥无效」。 func probeAlibaba(ctx context.Context, fields map[string]string) probeResult { if key := pick(fields, "api_key"); key != "" { status, snippet, err := doProbe(ctx, http.MethodGet, "https://dashscope.aliyuncs.com/compatible-mode/v1/models", map[string]string{"Authorization": "Bearer " + key}, nil) if err != nil { return credFail("阿里云 DashScope 请求失败: " + err.Error()) } return classify(status, snippet, "阿里云 DashScope") } ak, sk := pick(fields, "access_key_id"), pick(fields, "access_key_secret") if ak == "" || sk == "" { return credFail("阿里云未配置 AccessKey,无法验证") } return probeAliyunAK(ctx, ak, sk) } // probeAliyunAK 调用 STS GetCallerIdentity(RPC 风格签名 V1,HMAC-SHA1)校验 AK/SK。 func probeAliyunAK(ctx context.Context, ak, sk string) probeResult { q := url.Values{} q.Set("Action", "GetCallerIdentity") q.Set("Version", "2015-04-01") q.Set("Format", "JSON") q.Set("AccessKeyId", ak) q.Set("SignatureMethod", "HMAC-SHA1") q.Set("SignatureVersion", "1.0") q.Set("SignatureNonce", strconv.FormatInt(time.Now().UnixNano(), 10)) q.Set("Timestamp", time.Now().UTC().Format("2006-01-02T15:04:05Z")) // StringToSign = HTTPMethod & encode("/") & encode(规范化查询串);签名密钥是 SK + "&"。 stringToSign := "GET&" + aliyunEncode("/") + "&" + aliyunEncode(q.Encode()) mac := hmac.New(sha1.New, []byte(sk+"&")) mac.Write([]byte(stringToSign)) q.Set("Signature", base64.StdEncoding.EncodeToString(mac.Sum(nil))) status, snippet, err := doProbe(ctx, http.MethodGet, "https://sts.aliyuncs.com/?"+q.Encode(), nil, nil) if err != nil { return credFail("阿里云 STS 请求失败: " + err.Error()) } // 阿里云对签名/密钥错误返回 400 + 具体错误码,需要单独识别,否则会落进 classify 的兜底分支。 if status == 400 && (strings.Contains(snippet, "InvalidAccessKeyId") || strings.Contains(snippet, "SignatureDoesNotMatch") || strings.Contains(snippet, "Forbidden")) { return credFail("阿里云拒绝 AccessKey: " + snippet) } return classify(status, snippet, "阿里云") } // aliyunEncode 阿里云 RPC 签名要求的 percent-encode:在 RFC3986 基础上把 + * ~ 三处 // 与 Go 的 QueryEscape 对齐差异修正。 func aliyunEncode(s string) string { e := url.QueryEscape(s) e = strings.ReplaceAll(e, "+", "%20") e = strings.ReplaceAll(e, "*", "%2A") e = strings.ReplaceAll(e, "%7E", "~") return e } // probeAliyunOSS GetBucketInfo:只读、免费,同时验证 AK/SK 有效 + 桶存在 + 有访问权限。 func probeAliyunOSS(ctx context.Context, fields map[string]string) probeResult { ak, sk := pick(fields, "access_key_id"), pick(fields, "access_key_secret") bucket, endpoint := pick(fields, "bucket"), pick(fields, "endpoint") if ak == "" || sk == "" || bucket == "" || endpoint == "" { return credFail("阿里云 OSS 缺少 AccessKey/bucket/endpoint,无法验证") } client, err := ossSdk.New(endpoint, ak, sk, ossSdk.Timeout(5, 10)) if err != nil { return credFail("阿里云 OSS 客户端创建失败: " + err.Error()) } if _, err = client.GetBucketInfo(bucket); err != nil { return credFail("阿里云 OSS 访问存储桶失败: " + err.Error()) } return credOK("阿里云 OSS 凭据有效,存储桶 " + bucket + " 可访问") } // probeTencentCOS HEAD Bucket:只读、免费,验证 SecretId/Key + 桶存在 + 有权限。 func probeTencentCOS(ctx context.Context, fields map[string]string) probeResult { id, key := pick(fields, "secret_id"), pick(fields, "secret_key") bucket, region := pick(fields, "bucket"), pick(fields, "region") if id == "" || key == "" || bucket == "" || region == "" { return credFail("腾讯云 COS 缺少 SecretId/SecretKey/bucket/region,无法验证") } u, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com", bucket, region)) if err != nil { return credFail("腾讯云 COS 地址拼装失败: " + err.Error()) } client := cosSdk.NewClient(&cosSdk.BaseURL{BucketURL: u}, &http.Client{ Transport: &cosSdk.AuthorizationTransport{SecretID: id, SecretKey: key}, }) if _, err = client.Bucket.Head(ctx); err != nil { return credFail("腾讯云 COS 访问存储桶失败: " + err.Error()) } return credOK("腾讯云 COS 凭据有效,存储桶 " + bucket + " 可访问") } // ============================== 火山引擎 / 字节跳动 ============================== // probeDoubao 豆包有两套凭据:方舟大模型走 api_key(OpenAI 兼容),翻译走火山 AK/SK 签名。 func probeDoubao(ctx context.Context, fields map[string]string) probeResult { if pick(fields, "api_key") != "" { return probeOpenAICompat(ctx, fields, "https://ark.cn-beijing.volces.com/api/v3", "火山方舟") } return probeVolcengine(fields) } // probeVolcengine 火山/字节的语音族(STT/TTS/AST/STS/录音识别)与翻译: // 前者鉴权发生在 WebSocket 握手内、后者用火山 V4 签名,都没有免费只读的 REST 校验入口—— // 硬造一个「故意打错的请求看返回码」的探针极易把服务商的参数校验误判成密钥失效, // 所以这里如实返回「未探测」,由配置校验保证字段齐全,可用性交由业务侧真实调用暴露。 func probeVolcengine(fields map[string]string) probeResult { missing := []string{} for _, k := range []string{"appid", "token"} { if pick(fields, k) == "" { missing = append(missing, k) } } if len(missing) > 0 && pick(fields, "access_key") == "" { return credFail("火山引擎缺少必要凭据字段: " + strings.Join(missing, "/")) } return noProbe("火山引擎该服务走 WebSocket/V4 签名鉴权,无免费只读的校验接口,本次未验证凭据(配置字段已齐全)") } // ============================== MCP 服务 ============================== // probeMCP 按 MCP 传输类型探测: // - HTTP(type=0):发标准 JSON-RPC initialize 握手,服务端接受即证明可用; // - SSE(type=1) :SSE 端点是长连接流,握手不能用同一路径完成,只做连通性探测。 func probeMCP(ctx context.Context, fields map[string]string) probeResult { raw := pick(fields, "url") if raw == "" { return credFail("MCP 服务未配置 url") } if strings.TrimSpace(fields["type"]) == "1" { res := httpReachable(ctx, raw) if res.Ok { res.Msg = "SSE 端点可达(SSE 为长连接流,未做协议握手)" } return res } body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": map[string]any{ "protocolVersion": "2024-11-05", "capabilities": map[string]any{}, "clientInfo": map[string]string{"name": "starpivot-console-inspect", "version": "1.0"}, }, }) status, snippet, err := doProbe(ctx, http.MethodPost, raw, map[string]string{ "Content-Type": "application/json", "Accept": "application/json, text/event-stream", }, body) if err != nil { return credFail("MCP 服务请求失败: " + err.Error()) } if status >= 200 && status < 300 { // 握手通了但返回 JSON-RPC error,说明服务在线却拒绝了初始化,属于要人工看的异常。 if strings.Contains(snippet, `"error"`) { return credFail("MCP 服务握手被拒绝: " + snippet) } return credOK("MCP 服务握手成功 (initialize)") } return classify(status, snippet, "MCP 服务") }