Files
AI-Expert-Sidebar/internal/service/settings_svc.go
T
2026-04-01 15:29:35 +08:00

178 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package service 负责管理用户的本地 AI 配置(API Key、模型选择等)。
//
// 所有的配置都持久化存储在 settings.db 中。
// 这里的配置与 config.yaml 的职责有本质区别:
// - config.yaml 是"应用的公共回退配置"(开发者提供);
// - settings.db 是"用户自己配置的 API Key"(数据主权归用户)。
package service
import (
"fmt"
"strconv"
"AI-Expert-Sidebar/internal/config"
"AI-Expert-Sidebar/internal/crypto"
"AI-Expert-Sidebar/internal/database"
"AI-Expert-Sidebar/internal/models"
)
// SettingsDTO 是在前后端之间传递配置信息的"数据传输对象"。
// 它将散落在 settings.db KV 表里的各条记录聚合为一个结构体,方便前端 React 渲染表单。
type SettingsDTO struct {
AIProvider string `json:"ai_provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
SystemPrompt string `json:"system_prompt"`
MaxTokens int `json:"max_tokens"`
// UsePublicKey: true 表示用户不想用自己的 Key,而是使用软件内置的 Key。
UsePublicKey bool `json:"use_public_key"`
}
// GetSettings 从 settings.db 读取所有 AI 配置,并组装为 DTO 返回给前端。
// 如果发现 api_key 是被加密过的,会在此处进行解密。
func GetSettings() *SettingsDTO {
sdb := database.GetSettings()
if sdb == nil {
return defaultDTO()
}
// 将 KV 表一次性读取到 map 中
var rows []models.AppSetting
sdb.Find(&rows)
m := make(map[string]string, len(rows))
for _, r := range rows {
m[r.Key] = r.Value
}
// 尝试解密 API Key,若尚未配置或密文损坏,则返回空字符串
apiKey, _ := crypto.DecryptAPIKey(m["api_key_encrypted"])
maxTokens := 1024
if mt, err := strconv.Atoi(m["max_tokens"]); err == nil && mt > 0 {
maxTokens = mt
}
return &SettingsDTO{
AIProvider: strOr(m["ai_provider"], "deepseek"), // 默认厂商为 deepseek
BaseURL: m["base_url"],
APIKey: apiKey, // 传递给前端的是明文
Model: strOr(m["model"], "deepseek-chat"),
SystemPrompt: m["system_prompt"],
MaxTokens: maxTokens,
UsePublicKey: m["use_public_key"] != "false", // 默认 true
}
}
// SaveSettings 接收前端传来的 DTO 并持久化到 settings.db。
//
// # 数据安全
// 为了保护用户的 API Key,APIKey 字段在入库前会被强制进行 AES-256 加密,
// 所以数据库里只会写入 api_key_encrypted。
func SaveSettings(dto SettingsDTO) error {
sdb := database.GetSettings()
if sdb == nil {
return fmt.Errorf("settings DB not ready")
}
// 定义 UPSERT 闭包:使用 GORM 的原生 SQL,如果 key 已存在则更新 value,不存在则插入
upsert := func(k, v string) {
sdb.Exec("INSERT INTO app_settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", k, v)
}
upsert("ai_provider", dto.AIProvider)
upsert("base_url", dto.BaseURL)
upsert("model", dto.Model)
upsert("system_prompt", dto.SystemPrompt)
upsert("max_tokens", fmt.Sprintf("%d", dto.MaxTokens))
usePublic := "true"
if !dto.UsePublicKey {
usePublic = "false"
}
upsert("use_public_key", usePublic)
// 如果用户选择使用私有 Key,并且确实输入了 Key,则对其加密后存储。
// 如果前端传来了空字符串(可能是用户想清空),这里不会主动覆盖旧密文,
// 需要增强逻辑:本系统目前只有覆盖更新,不提供独立删除 Key 按钮,
// 留空即代表不更新原本缓存的 Key 密文。
if !dto.UsePublicKey && dto.APIKey != "" {
enc, err := crypto.EncryptAPIKey(dto.APIKey)
if err != nil {
return err
}
upsert("api_key_encrypted", enc)
}
return nil
}
// ResolveAIConfig 是 AI 调用的前置拦截器,决定最终到底使用哪个 API Key 和地址。
//
// # 动态回退策略
// 1. 如果用户勾选了 "使用公共线路" 或是没填过自己的 API Key:
// 直接短路,返回 config.yaml 里打包的公共 Key 和端点;
// 2. 如果用户提供了私有 Key,优先使用用户的 Key;
// 3. 对 BaseURL 进行缺省补全:如果选择了特定厂商(如 deepseek)但没填 URL
// 代码会自动填充标准官方 URL,极大简化了用户的配置门槛。
func ResolveAIConfig() AICallConfig {
// 默认配置回滚:来源于 config.yaml
base := AICallConfig{
BaseURL: "https://api.deepseek.com/chat/completions",
APIKey: config.Global.DeepSeek.APIKey,
Model: config.Global.DeepSeek.Model,
MaxTokens: config.Global.DeepSeek.MaxTokens,
}
dto := GetSettings()
if dto == nil {
return base
}
// 无论使用什么 Key,用户的自定义系统提示词均生效
base.SystemPrompt = dto.SystemPrompt
// 若用户指定使用公共线路,或用户的私钥实际上为空,触发降级
if dto.UsePublicKey || dto.APIKey == "" {
return base
}
providerURL := dto.BaseURL
if providerURL == "" {
// 常见厂商的默认 API 路由表补全
switch dto.AIProvider {
case "deepseek":
providerURL = "https://api.deepseek.com/chat/completions"
case "openai":
providerURL = "https://api.openai.com/v1/chat/completions"
case "grok":
providerURL = "https://api.x.ai/v1/chat/completions"
}
}
maxTok := dto.MaxTokens
if maxTok <= 0 {
maxTok = 1024
}
// 返回拼接好的终端可直接消费的 AI 配置
return AICallConfig{
BaseURL: strOr(providerURL, base.BaseURL),
APIKey: dto.APIKey,
Model: strOr(dto.Model, base.Model),
MaxTokens: maxTok,
SystemPrompt: dto.SystemPrompt,
}
}
// strOr 是一个简单的 fallback 辅助函数。
func strOr(v, def string) string {
if v == "" {
return def
}
return v
}
func defaultDTO() *SettingsDTO {
return &SettingsDTO{AIProvider: "deepseek", Model: "deepseek-chat", MaxTokens: 1024, UsePublicKey: true}
}