feat: 增加管理员 AI 模型配置 (#200)
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user