feat: 增加管理员 AI 模型配置 (#200)

This commit is contained in:
chengma
2026-08-14 09:49:11 +08:00
parent ad44f83ea6
commit 018da3f732
24 changed files with 1546 additions and 8 deletions
+460
View File
@@ -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
}
+88
View File
@@ -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)
}
}
}
+196
View File
@@ -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))
}
+48
View File
@@ -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)
}
}
}