Files
cmautobuy/admin/service/ai_config.go
T

465 lines
16 KiB
Go

package service
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
type AIProviderInput struct {
ProviderID string
Name string
BaseURL string
Model string
TimeoutSeconds int
MaxConcurrency int
ConfidenceThresholdBPS int
}
type AIProviderListResult struct {
Items []model.AIProviderConfig
SecretStoreError string
}
type AIEndpointPolicy struct {
allowedHosts map[string]bool
resolver *net.Resolver
}
func NewAIEndpointPolicy(allowedHosts []string) AIEndpointPolicy {
policy := AIEndpointPolicy{allowedHosts: map[string]bool{}, resolver: net.DefaultResolver}
for _, host := range allowedHosts {
if value := strings.ToLower(strings.TrimSpace(host)); value != "" {
policy.allowedHosts[value] = true
}
}
return policy
}
func ListAIProviderConfigs(db *sql.DB, actor *model.User, secrets AISecretStore) (AIProviderListResult, error) {
if actor == nil || !actor.IsAdmin() {
return AIProviderListResult{}, ErrAdminRequired
}
items, err := repository.ListAIProviders(db)
if err != nil {
return AIProviderListResult{}, err
}
result := AIProviderListResult{Items: items}
for i := range result.Items {
configured, suffix, statusErr := secrets.Status(result.Items[i].ProviderID)
if statusErr != nil {
result.SecretStoreError = statusErr.Error()
break
}
result.Items[i].SecretConfigured = configured
result.Items[i].SecretSuffix = suffix
}
return result, nil
}
func SaveAIProviderConfig(db *sql.DB, actor *model.User, input AIProviderInput, policy AIEndpointPolicy, now time.Time) (string, error) {
if actor == nil || !actor.IsAdmin() {
return "", ErrAdminRequired
}
item, err := validateAIProviderInput(input, policy)
if err != nil {
return "", err
}
item.ProviderID = strings.TrimSpace(input.ProviderID)
if item.ProviderID == "" {
item.ProviderID, err = randomID("AIP-", 16)
if err != nil {
return "", fmt.Errorf("生成 AI 服务商编号失败: %w", err)
}
}
at := now.UTC().Format(model.TimeLayout)
item.UpdatedByUserID, item.UpdatedAt = actor.UserID, at
tx, err := db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
action := "update"
details := map[string]any{"fields": []string{"name", "base_url", "model", "timeout_seconds", "max_concurrency", "confidence_threshold_bps"}}
if input.ProviderID == "" {
action = "create"
item.CreatedByUserID, item.CreatedAt = actor.UserID, at
if err := repository.InsertAIProvider(tx, item); err != nil {
return "", aiProviderValidationError(err)
}
} else {
found, err := repository.UpdateAIProvider(tx, item)
if err != nil {
return "", aiProviderValidationError(err)
}
if !found {
return "", &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
}
}
if err := insertAIConfigAudit(tx, item.ProviderID, action, details, actor.UserID, at); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return item.ProviderID, nil
}
func SetAIProviderSecret(db *sql.DB, actor *model.User, secrets AISecretStore, providerID, apiKey string, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
providerID = strings.TrimSpace(providerID)
if err := invalidateAIProviderForSecretChange(db, actor, providerID, now); err != nil {
return err
}
if err := secrets.Set(providerID, apiKey); err != nil {
return err
}
return insertAIConfigAudit(db, providerID, "secret_replace", map[string]any{"secret": "configured"}, actor.UserID, now.UTC().Format(model.TimeLayout))
}
func ClearAIProviderSecret(db *sql.DB, actor *model.User, secrets AISecretStore, providerID string, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
providerID = strings.TrimSpace(providerID)
if err := invalidateAIProviderForSecretChange(db, actor, providerID, now); err != nil {
return err
}
if err := secrets.Clear(providerID); err != nil {
return err
}
return insertAIConfigAudit(db, providerID, "secret_clear", map[string]any{"secret": "cleared"}, actor.UserID, now.UTC().Format(model.TimeLayout))
}
func invalidateAIProviderForSecretChange(db *sql.DB, actor *model.User, providerID string, now time.Time) error {
if providerID == "" {
return &validationError{field: "provider_id", message: "AI 服务商编号不能为空"}
}
found, err := repository.InvalidateAIProviderTest(db, providerID, actor.UserID, now.UTC().Format(model.TimeLayout))
if err != nil {
return err
}
if !found {
return &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
}
return nil
}
type AIHTTPDoer interface {
Do(*http.Request) (*http.Response, error)
}
func TestAIProviderConnection(ctx context.Context, db *sql.DB, actor *model.User, secrets AISecretStore,
providerID string, policy AIEndpointPolicy, client AIHTTPDoer, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
provider, err := repository.GetAIProvider(db, strings.TrimSpace(providerID))
if err != nil {
return err
}
if provider == nil {
return &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
}
secret, err := secrets.Get(provider.ProviderID)
if err != nil {
return err
}
if secret == "" {
return &validationError{field: "api_key", message: "请先保存 API Key"}
}
if err := policy.ValidateResolved(ctx, provider.BaseURL); err != nil {
return err
}
if client == nil {
client = NewSafeAIHTTPClient(policy, time.Duration(provider.TimeoutSeconds)*time.Second)
}
testErr := callAIHealthCheck(ctx, client, *provider, secret)
status, message, fingerprint := "succeeded", "连接正常", aiProviderFingerprint(*provider)
if testErr != nil {
status, message, fingerprint = "failed", safeAIError(testErr), ""
}
at := now.UTC().Format(model.TimeLayout)
found, recordErr := repository.RecordAIProviderTest(db, provider.ProviderID, status, message, at, fingerprint, actor.UserID)
if recordErr != nil {
return recordErr
}
if !found {
return &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
}
if err := insertAIConfigAudit(db, provider.ProviderID, "test_"+status, map[string]any{"status": status}, actor.UserID, at); err != nil {
return err
}
if testErr != nil {
return &validationError{field: "connection", message: "连接测试失败:" + message}
}
return nil
}
func EnableAIProviderConfig(db *sql.DB, actor *model.User, secrets AISecretStore, providerID string, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := repository.LockAIProviders(tx); err != nil {
return err
}
provider, err := repository.GetAIProvider(tx, strings.TrimSpace(providerID))
if err != nil {
return err
}
if provider == nil {
return &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
}
configured, _, err := secrets.Status(provider.ProviderID)
if err != nil {
return err
}
if !configured {
return &validationError{field: "api_key", message: "请先保存 API Key 并测试连接"}
}
if provider.LastTestStatus != "succeeded" || provider.LastTestFingerprint != aiProviderFingerprint(*provider) {
return &validationError{field: "connection", message: "当前配置尚未通过连接测试,不能启用"}
}
at := now.UTC().Format(model.TimeLayout)
found, err := repository.EnableAIProvider(tx, provider.ProviderID, actor.UserID, at)
if err != nil {
return err
}
if !found {
return &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
}
if err := insertAIConfigAudit(tx, provider.ProviderID, "enable", map[string]any{"enabled": true}, actor.UserID, at); err != nil {
return err
}
return tx.Commit()
}
func DisableAIProviderConfig(db *sql.DB, actor *model.User, providerID string, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
at := now.UTC().Format(model.TimeLayout)
found, err := repository.DisableAIProvider(db, strings.TrimSpace(providerID), actor.UserID, at)
if err != nil {
return err
}
if !found {
return &validationError{field: "provider_id", message: "AI 服务商不存在,请刷新页面后重试"}
}
return insertAIConfigAudit(db, strings.TrimSpace(providerID), "disable", map[string]any{"enabled": false}, actor.UserID, at)
}
func validateAIProviderInput(input AIProviderInput, policy AIEndpointPolicy) (model.AIProviderConfig, error) {
item := model.AIProviderConfig{Name: strings.TrimSpace(input.Name), Model: strings.TrimSpace(input.Model),
TimeoutSeconds: input.TimeoutSeconds, MaxConcurrency: input.MaxConcurrency,
ConfidenceThresholdBPS: input.ConfidenceThresholdBPS}
if item.Name == "" || utf8.RuneCountInString(item.Name) > 191 {
return item, &validationError{field: "name", message: "服务商名称不能为空且最多 191 个字符"}
}
if item.Model == "" || utf8.RuneCountInString(item.Model) > 191 {
return item, &validationError{field: "model", message: "模型名称不能为空且最多 191 个字符"}
}
baseURL, err := policy.ValidateSyntax(input.BaseURL)
if err != nil {
return item, err
}
item.BaseURL = baseURL
if item.TimeoutSeconds < 1 || item.TimeoutSeconds > 120 {
return item, &validationError{field: "timeout_seconds", message: "超时秒数必须在 1 到 120 之间"}
}
if item.MaxConcurrency < 1 || item.MaxConcurrency > 16 {
return item, &validationError{field: "max_concurrency", message: "最大并发必须在 1 到 16 之间"}
}
if item.ConfidenceThresholdBPS < 0 || item.ConfidenceThresholdBPS > 10000 {
return item, &validationError{field: "confidence_threshold", message: "自动写入置信度必须在 0% 到 100% 之间"}
}
return item, nil
}
// ParseConfidenceThresholdBPS 把页面百分比精确转换成基点,避免数据库存浮点数。
func ParseConfidenceThresholdBPS(raw string) (int, error) {
raw = strings.TrimSpace(raw)
parts := strings.Split(raw, ".")
if raw == "" || len(parts) > 2 {
return 0, &validationError{field: "confidence_threshold", message: "置信度必须是 0 到 100 的数字,最多两位小数"}
}
whole, err := strconv.Atoi(parts[0])
if err != nil || whole < 0 || whole > 100 {
return 0, &validationError{field: "confidence_threshold", message: "置信度必须在 0% 到 100% 之间"}
}
fraction := 0
if len(parts) == 2 {
if len(parts[1]) == 0 || len(parts[1]) > 2 {
return 0, &validationError{field: "confidence_threshold", message: "置信度最多保留两位小数"}
}
fraction, err = strconv.Atoi(parts[1] + strings.Repeat("0", 2-len(parts[1])))
if err != nil {
return 0, &validationError{field: "confidence_threshold", message: "置信度格式不正确"}
}
}
if whole == 100 && fraction != 0 {
return 0, &validationError{field: "confidence_threshold", message: "置信度不能超过 100%"}
}
return whole*100 + fraction, nil
}
func (p AIEndpointPolicy) ValidateSyntax(raw string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" {
return "", &validationError{field: "base_url", message: "Base URL 必须是完整的 HTTP 或 HTTPS 地址"}
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", &validationError{field: "base_url", message: "Base URL 只支持 HTTP 或 HTTPS"}
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", &validationError{field: "base_url", message: "Base URL 不能包含账号密码、查询参数或片段"}
}
host := strings.ToLower(parsed.Hostname())
if host == "localhost" || host == "metadata.google.internal" {
return "", &validationError{field: "base_url", message: "Base URL 指向了受保护的本机或元数据地址"}
}
if ip := net.ParseIP(host); ip != nil && !p.allowedHosts[host] && !isPublicAIIP(ip) {
return "", &validationError{field: "base_url", message: "Base URL 指向私有或本机地址,必须由部署配置显式允许"}
}
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawPath = strings.TrimRight(parsed.RawPath, "/")
return parsed.String(), nil
}
func (p AIEndpointPolicy) ValidateResolved(ctx context.Context, raw string) error {
normalized, err := p.ValidateSyntax(raw)
if err != nil {
return err
}
parsed, _ := url.Parse(normalized)
host := strings.ToLower(parsed.Hostname())
if p.allowedHosts[host] {
return nil
}
addresses, err := p.resolver.LookupIPAddr(ctx, host)
if err != nil || len(addresses) == 0 {
return &validationError{field: "base_url", message: "无法解析 AI 服务商地址"}
}
for _, address := range addresses {
if !isPublicAIIP(address.IP) {
return &validationError{field: "base_url", message: "AI 服务商域名解析到私有、本机或链路本地地址"}
}
}
return nil
}
func isPublicAIIP(ip net.IP) bool {
return ip != nil && !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast() &&
!ip.IsLinkLocalMulticast() && !ip.IsUnspecified() && !ip.IsMulticast()
}
func NewSafeAIHTTPClient(policy AIEndpointPolicy, timeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: timeout, KeepAlive: 30 * time.Second}
// 不使用环境代理。否则实际拨号只会校验代理地址,目标地址可能绕过 SSRF 拨号校验。
transport := &http.Transport{ForceAttemptHTTP2: true,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("AI 服务商网络地址无效")
}
if !policy.allowedHosts[strings.ToLower(host)] {
addresses, err := policy.resolver.LookupIPAddr(ctx, host)
if err != nil || len(addresses) == 0 {
return nil, fmt.Errorf("无法解析 AI 服务商地址")
}
for _, item := range addresses {
if !isPublicAIIP(item.IP) {
return nil, fmt.Errorf("AI 服务商地址不在允许范围")
}
}
}
return dialer.DialContext(ctx, network, address)
}}
return &http.Client{Transport: transport, Timeout: timeout, CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("AI 服务商重定向次数过多")
}
return policy.ValidateResolved(req.Context(), req.URL.String())
}}
}
func callAIHealthCheck(ctx context.Context, client AIHTTPDoer, provider model.AIProviderConfig, secret string) error {
payload, _ := json.Marshal(map[string]any{"model": provider.Model, "messages": []map[string]string{{"role": "user", "content": "Reply with OK."}}, "max_tokens": 1, "temperature": 0})
endpoint := strings.TrimRight(provider.BaseURL, "/") + "/chat/completions"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("准备连接测试失败")
}
req.Header.Set("Authorization", "Bearer "+secret)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("请求失败")
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 32<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("服务返回 HTTP %d", resp.StatusCode)
}
return nil
}
func aiProviderFingerprint(provider model.AIProviderConfig) string {
value := strings.Join([]string{provider.BaseURL, provider.Model, strconv.Itoa(provider.TimeoutSeconds),
strconv.Itoa(provider.MaxConcurrency), strconv.Itoa(provider.ConfidenceThresholdBPS)}, "\x00")
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func insertAIConfigAudit(q repository.Execer, providerID, action string, details any, actorID, at string) error {
raw, err := json.Marshal(details)
if err != nil {
return fmt.Errorf("准备 AI 配置审计失败: %w", err)
}
return repository.InsertAIProviderAudit(q, model.AIProviderAudit{ProviderID: providerID, Action: action,
DetailsJSON: string(raw), ActorUserID: actorID, CreatedAt: at})
}
func aiProviderValidationError(err error) error {
if errors.Is(err, repository.ErrAIProviderNameExists) {
return &validationError{field: "name", message: "服务商名称已经存在"}
}
return err
}
func safeAIError(err error) string {
message := strings.TrimSpace(err.Error())
if message == "" {
message = "未知错误"
}
runes := []rune(message)
if len(runes) > 200 {
message = string(runes[:200])
}
return message
}