feat: 增加管理员 AI 模型配置 (#200)
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
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.Scheme != "https" || parsed.Host == "" {
|
||||
return "", &validationError{field: "base_url", message: "Base URL 必须是完整的 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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
)
|
||||
|
||||
func TestParseConfidenceThresholdBPS_精确转换(t *testing.T) {
|
||||
for raw, want := range map[string]int{"0": 0, "85": 8500, "85.5": 8550, "99.99": 9999, "100.00": 10000} {
|
||||
got, err := ParseConfidenceThresholdBPS(raw)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("%q => %d,%v,期望 %d", raw, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", "-1", "100.01", "1.234", "abc"} {
|
||||
if _, err := ParseConfidenceThresholdBPS(raw); err == nil {
|
||||
t.Errorf("非法置信度 %q 应被拒绝", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIEndpointPolicy_阻止凭据和内网地址(t *testing.T) {
|
||||
policy := NewAIEndpointPolicy(nil)
|
||||
for _, raw := range []string{
|
||||
"http://api.example.com/v1", "https://user:pass@api.example.com/v1",
|
||||
"https://127.0.0.1/v1", "https://169.254.169.254/latest", "https://localhost/v1",
|
||||
} {
|
||||
if _, err := policy.ValidateSyntax(raw); err == nil {
|
||||
t.Errorf("危险地址 %q 应被拒绝", raw)
|
||||
}
|
||||
}
|
||||
got, err := policy.ValidateSyntax("https://api.example.com/v1/")
|
||||
if err != nil || got != "https://api.example.com/v1" {
|
||||
t.Fatalf("公网 HTTPS 地址应通过并去掉尾斜杠: %q %v", got, err)
|
||||
}
|
||||
allowed := NewAIEndpointPolicy([]string{"10.0.0.8"})
|
||||
if _, err := allowed.ValidateSyntax("https://10.0.0.8/v1"); err != nil {
|
||||
t.Fatalf("部署允许的私有端点应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeAIHTTPClient_不使用环境代理(t *testing.T) {
|
||||
client := NewSafeAIHTTPClient(NewAIEndpointPolicy(nil), time.Second)
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("Transport 类型 = %T", client.Transport)
|
||||
}
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("AI HTTP 客户端不能使用环境代理,否则目标地址会绕过拨号阶段的 SSRF 校验")
|
||||
}
|
||||
}
|
||||
|
||||
type recordingAIHTTPDoer struct {
|
||||
request *http.Request
|
||||
status int
|
||||
}
|
||||
|
||||
func (d *recordingAIHTTPDoer) Do(request *http.Request) (*http.Response, error) {
|
||||
d.request = request
|
||||
return &http.Response{StatusCode: d.status, Body: io.NopCloser(strings.NewReader(`{"ok":true}`))}, nil
|
||||
}
|
||||
|
||||
func TestCallAIHealthCheck_最小请求且错误不泄露密钥(t *testing.T) {
|
||||
doer := &recordingAIHTTPDoer{status: http.StatusUnauthorized}
|
||||
const secret = "test-secret-never-log"
|
||||
err := callAIHealthCheck(context.Background(), doer, model.AIProviderConfig{
|
||||
BaseURL: "https://api.example.com/v1", Model: "test-model",
|
||||
}, secret)
|
||||
if err == nil || strings.Contains(err.Error(), secret) || !strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("错误必须脱敏且保留 HTTP 状态: %v", err)
|
||||
}
|
||||
if got := doer.request.Header.Get("Authorization"); got != "Bearer "+secret {
|
||||
t.Fatalf("测试请求未使用密钥: %q", got)
|
||||
}
|
||||
body, _ := io.ReadAll(doer.request.Body)
|
||||
text := string(body)
|
||||
for _, forbidden := range []string{"order_no", "syb_id", "address", secret} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Fatalf("最小测试请求包含业务数据或密钥 %q: %s", forbidden, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// AISecretStore 把 AI API Key 隔离在数据库、data 和发布目录之外。
|
||||
type AISecretStore interface {
|
||||
Get(providerID string) (string, error)
|
||||
Set(providerID, apiKey string) error
|
||||
Clear(providerID string) error
|
||||
Status(providerID string) (configured bool, suffix string, err error)
|
||||
}
|
||||
|
||||
type aiSecretFile struct {
|
||||
Version int `yaml:"version"`
|
||||
Providers map[string]string `yaml:"providers"`
|
||||
}
|
||||
|
||||
// FileAISecretStore 使用同目录临时文件 + fsync + 原子替换保存密钥。
|
||||
type FileAISecretStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewFileAISecretStore(path string) *FileAISecretStore {
|
||||
return &FileAISecretStore{path: strings.TrimSpace(path)}
|
||||
}
|
||||
|
||||
func (s *FileAISecretStore) Get(providerID string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := s.readLocked()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return data.Providers[strings.TrimSpace(providerID)], nil
|
||||
}
|
||||
|
||||
func (s *FileAISecretStore) Status(providerID string) (bool, string, error) {
|
||||
secret, err := s.Get(providerID)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if secret == "" {
|
||||
return false, "", nil
|
||||
}
|
||||
runes := []rune(secret)
|
||||
start := len(runes) - 4
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
return true, "••••" + string(runes[start:]), nil
|
||||
}
|
||||
|
||||
func (s *FileAISecretStore) Set(providerID, apiKey string) error {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if providerID == "" {
|
||||
return fmt.Errorf("服务商编号不能为空")
|
||||
}
|
||||
if utf8.RuneCountInString(apiKey) < 8 {
|
||||
return fmt.Errorf("API Key 至少需要 8 个字符")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := s.readLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data.Providers[providerID] = apiKey
|
||||
return s.writeLocked(data)
|
||||
}
|
||||
|
||||
func (s *FileAISecretStore) Clear(providerID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := s.readLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delete(data.Providers, strings.TrimSpace(providerID))
|
||||
return s.writeLocked(data)
|
||||
}
|
||||
|
||||
func (s *FileAISecretStore) readLocked() (aiSecretFile, error) {
|
||||
result := aiSecretFile{Version: 1, Providers: map[string]string{}}
|
||||
path, err := s.safePath()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("读取 AI 密钥文件失败")
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &result); err != nil {
|
||||
return aiSecretFile{}, fmt.Errorf("解析 AI 密钥文件失败")
|
||||
}
|
||||
if result.Providers == nil {
|
||||
result.Providers = map[string]string{}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *FileAISecretStore) writeLocked(data aiSecretFile) error {
|
||||
path, err := s.safePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("准备 AI 密钥目录失败")
|
||||
}
|
||||
raw, err := yaml.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("编码 AI 密钥文件失败")
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".ai-secrets-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 AI 密钥临时文件失败")
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("设置 AI 密钥临时文件权限失败")
|
||||
}
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("写入 AI 密钥临时文件失败")
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("同步 AI 密钥临时文件失败")
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("关闭 AI 密钥临时文件失败")
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("替换 AI 密钥文件失败")
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
return fmt.Errorf("设置 AI 密钥文件权限失败")
|
||||
}
|
||||
if dir, err := os.Open(filepath.Dir(path)); err == nil {
|
||||
_ = dir.Sync()
|
||||
_ = dir.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FileAISecretStore) safePath() (string, error) {
|
||||
if s == nil || s.path == "" {
|
||||
return "", fmt.Errorf("未配置 CMAUTOBUY_AI_SECRETS_PATH,不能保存或读取 AI 密钥")
|
||||
}
|
||||
if !filepath.IsAbs(s.path) {
|
||||
return "", fmt.Errorf("AI 密钥文件必须使用绝对路径")
|
||||
}
|
||||
abs, err := filepath.Abs(s.path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析 AI 密钥文件路径失败")
|
||||
}
|
||||
for _, root := range protectedAISecretRoots() {
|
||||
if pathWithin(abs, root) {
|
||||
return "", fmt.Errorf("AI 密钥文件不能位于仓库、data 或发布目录内")
|
||||
}
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func protectedAISecretRoots() []string {
|
||||
var roots []string
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
roots = append(roots, cwd)
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
roots = append(roots, filepath.Dir(exe))
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func pathWithin(path, root string) bool {
|
||||
path, root = filepath.Clean(path), filepath.Clean(root)
|
||||
rel, err := filepath.Rel(root, path)
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileAISecretStore_原子保存且不回显明文(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "ai-secrets.yaml")
|
||||
store := NewFileAISecretStore(path)
|
||||
const secret = "test-key-not-for-production"
|
||||
if err := store.Set("AI-1", secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configured, suffix, err := store.Status("AI-1")
|
||||
if err != nil || !configured || !strings.HasSuffix(suffix, "tion") || strings.Contains(suffix, secret) {
|
||||
t.Fatalf("密钥状态不安全: configured=%v suffix=%q err=%v", configured, suffix, err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || !strings.Contains(string(raw), secret) {
|
||||
t.Fatalf("独立密钥文件没有保存测试值: %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, _ := os.Stat(path)
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("密钥权限 = %o,期望 600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
if err := store.Clear("AI-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configured, _, err = store.Status("AI-1")
|
||||
if err != nil || configured {
|
||||
t.Fatalf("清除密钥失败: configured=%v err=%v", configured, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAISecretStore_拒绝仓库内和相对路径(t *testing.T) {
|
||||
for _, path := range []string{"relative.yaml", filepath.Join("data", "ai.yaml")} {
|
||||
store := NewFileAISecretStore(path)
|
||||
if err := store.Set("AI-1", "12345678"); err == nil {
|
||||
t.Fatalf("路径 %q 应被拒绝", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user