568 lines
21 KiB
Go
568 lines
21 KiB
Go
package dao
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"tcm-agent/internal/security/xkaes"
|
||
|
||
"gorm.io/driver/mysql"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/logger"
|
||
)
|
||
|
||
// ========================================================================
|
||
// 数据访问层(DAO)
|
||
// ========================================================================
|
||
// 本层负责直连萧康云医的 z_xk 库,按 PHP 端 AiRuntimeConfigService 的
|
||
// 完全一致规则,读取"当前激活平台/模型/API Key",并装配成 Go LLM 层
|
||
// 期望的 map[provider]LLMConfigEx 结构。
|
||
//
|
||
// 为什么不让 PHP 把配置推过来?
|
||
// - 单一可信源:DB 已经是配置中心,Go 直接读库避免双写不一致
|
||
// - 解耦:Go Agent 可独立部署,不依赖 PHP 进程在线
|
||
// - 实时性:DB 改完 1 分钟(TTL)内全集群生效
|
||
//
|
||
// 数据来源(z_xk 库):
|
||
// - xk_system_config:3 个 AI 配置键(当前激活的平台/密钥/模型)
|
||
// - xk_ai_platform:平台表(code → api_url)
|
||
// - xk_ai_model:模型表(platform_id → 默认模型 code)
|
||
// - xk_ai_api_key:密钥表(密文 api_key,需 AES-256-CBC 解密)
|
||
// ========================================================================
|
||
|
||
// DB 全局数据库连接句柄(Init 后非空)
|
||
var DB *gorm.DB
|
||
|
||
// 全局缓存:DB 读到的 LLM 配置 + 失效时间(TTL 60s)
|
||
//
|
||
// 为什么需要缓存?
|
||
// 每次外部请求都查 4 张表会有额外 RTT;AiRuntimeConfigService 也走 Redis 缓存
|
||
var (
|
||
cfgCache map[string]*LLMConfigFromDB
|
||
cfgCacheAt time.Time
|
||
cfgCacheTTL = 60 * time.Second
|
||
cfgCacheMutex sync.RWMutex
|
||
)
|
||
|
||
// LLMConfigFromDB DB 读出的单个供应商完整配置(解密后的明文 API Key)
|
||
//
|
||
// 字段含义与 llm.LLMConfigEx 对齐,由调用方(config_assembler)负责映射
|
||
type LLMConfigFromDB struct {
|
||
Provider string // 平台 code(spark/deepseek/qwen/...),小写
|
||
APIURL string // 平台 api_url
|
||
APIKey string // 解密后的明文密钥
|
||
APIKeyID int // 密钥记录 id(写入审计日志用)
|
||
Model string // 模型 code(如 spark-max / deepseek-chat)
|
||
PlatformID int // 平台 id
|
||
TimeoutSecs int // 超时秒(DB 暂无字段,回落到调用方默认)
|
||
}
|
||
|
||
// ActiveSelection DB 中"全局激活"的平台/密钥/模型组合
|
||
//
|
||
// 来源:xk_system_config 表的三个 key(由后台"模型配置"Tab 保存):
|
||
// - ai_active_provider :当前激活的平台 code(如 spark / deepseek / agent)
|
||
// - ai_active_api_key_id :当前激活的 API Key ID(可能不属于激活平台的默认 key)
|
||
// - ai_active_model :当前激活的模型 code(可能不是激活平台的默认模型)
|
||
//
|
||
// 与 LLMConfigFromDB 的关系:
|
||
// - LLMConfigFromDB 是"每个平台自己的默认 key/model"(平台级)
|
||
// - ActiveSelection 是"运维跨平台指定的激活组合"(全局级)
|
||
// - 实际生效规则:ActiveSelection 非空时优先用它,否则回落到平台默认
|
||
type ActiveSelection struct {
|
||
Provider string // 激活平台 code(小写);空表示未配置
|
||
APIKeyID int // 激活 API Key ID;0 表示未配置(回落平台默认)
|
||
Model string // 激活模型 code;空表示未配置(回落平台默认)
|
||
}
|
||
|
||
// ========================================================================
|
||
// z_xk 库表结构(按字段读取,不绑定 ORM 实体)
|
||
// ========================================================================
|
||
// 注意:xk_ai_* 三张表的 DDL 没有提交到 sql 仓库,
|
||
// 字段名是从 PHP Service 反推的,如果后续 DB 结构有出入,
|
||
// 调整下面的 Select 字段即可,不需要改 entity。
|
||
// ========================================================================
|
||
|
||
// PlatformRow 平台表行
|
||
type PlatformRow struct {
|
||
ID int `gorm:"column:id"`
|
||
Code string `gorm:"column:code"`
|
||
Name string `gorm:"column:name"`
|
||
APIURL string `gorm:"column:api_url"`
|
||
}
|
||
|
||
// TableName 指定表名(GORM 约定)
|
||
func (PlatformRow) TableName() string { return "xk_ai_platform" }
|
||
|
||
// ModelRow 模型表行
|
||
type ModelRow struct {
|
||
ID int `gorm:"column:id"`
|
||
PlatformID int `gorm:"column:platform_id"`
|
||
Code string `gorm:"column:code"`
|
||
Name string `gorm:"column:name"`
|
||
IsDefault int `gorm:"column:is_default"`
|
||
Sort int `gorm:"column:sort"`
|
||
}
|
||
|
||
// TableName 指定表名
|
||
func (ModelRow) TableName() string { return "xk_ai_model" }
|
||
|
||
// APIKeyRow 密钥表行
|
||
type APIKeyRow struct {
|
||
ID int `gorm:"column:id"`
|
||
PlatformID int `gorm:"column:platform_id"`
|
||
Name string `gorm:"column:name"`
|
||
APIKey string `gorm:"column:api_key"` // 密文(前缀 xk_ase_256_)
|
||
IsDefault int `gorm:"column:is_default"`
|
||
Sort int `gorm:"column:sort"`
|
||
}
|
||
|
||
// TableName 指定表名
|
||
func (APIKeyRow) TableName() string { return "xk_ai_api_key" }
|
||
|
||
// SystemConfigRow 系统配置表行
|
||
type SystemConfigRow struct {
|
||
ConfigKey string `gorm:"column:config_key"`
|
||
ConfigValue string `gorm:"column:config_value"`
|
||
}
|
||
|
||
// TableName 指定表名
|
||
func (SystemConfigRow) TableName() string { return "xk_system_config" }
|
||
|
||
// Init 连接 z_xk 库
|
||
//
|
||
// 参数:
|
||
// dsn - 形如 "user:pass@tcp(host:3306)/z_xk?charset=utf8mb4&parseTime=true&loc=Local"
|
||
// (注意:parseTime=true 必填,否则 GORM 无法把 DATETIME 转成 time.Time)
|
||
//
|
||
// 失败时返回错误,由 main 决定是否继续(建议:DB 不可用时降级用 yaml/env 配置)
|
||
func Init(dsn string) error {
|
||
if dsn == "" {
|
||
return errors.New("dao: DSN 为空")
|
||
}
|
||
var err error
|
||
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||
// 生产用 Error 级别,避免每条 SQL 都打日志;调试时改成 Info
|
||
Logger: logger.Default.LogMode(logger.Warn),
|
||
})
|
||
if err != nil {
|
||
// 关键:gorm.Open 失败时 DB 仍可能被赋值为部分初始化的 *gorm.DB,
|
||
// 内部 Statement.ConnPool 为 nil,后续调用 DB.Where 会 panic。
|
||
// 这里显式置 nil,让后续的 `if DB == nil` 检查能正确拦截。
|
||
DB = nil
|
||
return fmt.Errorf("dao: 连接 z_xk 库失败: %w", err)
|
||
}
|
||
// 验证连接 + 时区(PHP 端用 Asia/Shanghai)
|
||
sqlDB, err := DB.DB()
|
||
if err != nil {
|
||
DB = nil
|
||
return err
|
||
}
|
||
sqlDB.SetMaxIdleConns(5)
|
||
sqlDB.SetMaxOpenConns(20)
|
||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||
|
||
log.Printf("[DAO] z_xk 库连接成功 | DSN=%s", maskDSN(dsn))
|
||
return nil
|
||
}
|
||
|
||
// ========================================================================
|
||
// 核心:加载当前激活的 LLM 配置
|
||
// ========================================================================
|
||
|
||
// LoadLLMConfigsFromDB 从 z_xk 库读取所有可用供应商配置(带 60s 缓存)
|
||
//
|
||
// 复刻 PHP AiRuntimeConfigService 的解析逻辑:
|
||
// 1. 读 xk_system_config 拿当前激活的 provider(ai_active_provider)
|
||
// 2. 从 xk_ai_platform 读所有启用平台(不只激活那个,全部返回,让 yaml 的 routes 自由映射)
|
||
// 3. 对每个平台:
|
||
// - 查 xk_ai_api_key 的默认/激活 key
|
||
// - 用 xkaes.Decrypt 解密 api_key
|
||
// - 查 xk_ai_model 的默认模型
|
||
// 4. 装配成 map[provider]*LLMConfigFromDB 返回
|
||
//
|
||
// 参数:
|
||
// encryptKey - PHP 端 env('ENCRYPT_KEY') 的原值(用于 AES 解密)
|
||
//
|
||
// 失败处理:
|
||
// - 单个供应商失败不阻塞其他供应商,记日志后跳过
|
||
// - 整体失败(如 DB 连不上)返回 error
|
||
func LoadLLMConfigsFromDB(encryptKey string) (map[string]*LLMConfigFromDB, error) {
|
||
// 命中缓存直接返回(避免高频查询打 DB)
|
||
cfgCacheMutex.RLock()
|
||
if cfgCache != nil && time.Since(cfgCacheAt) < cfgCacheTTL {
|
||
out := make(map[string]*LLMConfigFromDB, len(cfgCache))
|
||
for k, v := range cfgCache {
|
||
out[k] = v
|
||
}
|
||
cfgCacheMutex.RUnlock()
|
||
return out, nil
|
||
}
|
||
cfgCacheMutex.RUnlock()
|
||
|
||
if DB == nil {
|
||
return nil, errors.New("dao: DB 未初始化,请先调用 Init()")
|
||
}
|
||
|
||
// 读所有启用的平台(status=1, deleted_at=0)
|
||
var platforms []PlatformRow
|
||
if err := DB.Where("status = 1 AND deleted_at = 0").
|
||
Order("sort DESC, id ASC").
|
||
Find(&platforms).Error; err != nil {
|
||
return nil, fmt.Errorf("dao: 查询 xk_ai_platform 失败: %w", err)
|
||
}
|
||
|
||
out := make(map[string]*LLMConfigFromDB, len(platforms))
|
||
for _, p := range platforms {
|
||
provider := strings.ToLower(strings.TrimSpace(p.Code))
|
||
if provider == "" {
|
||
continue
|
||
}
|
||
|
||
// 解密密钥失败时跳过该供应商,不阻塞其他
|
||
apiKey, apiKeyID, err := resolveAPIKey(p.ID, encryptKey)
|
||
if err != nil {
|
||
log.Printf("[DAO] 平台 %s(%s) 取密钥失败: %v(跳过)", p.Code, p.Name, err)
|
||
continue
|
||
}
|
||
|
||
// 取默认模型(失败时用空字符串,调用方回落 yaml 默认值)
|
||
model := resolveDefaultModel(p.ID)
|
||
|
||
out[provider] = &LLMConfigFromDB{
|
||
Provider: provider,
|
||
APIURL: p.APIURL,
|
||
APIKey: apiKey,
|
||
APIKeyID: apiKeyID,
|
||
Model: model,
|
||
PlatformID: p.ID,
|
||
}
|
||
log.Printf("[DAO] 加载平台: code=%s model=%s api_key_id=%d", provider, model, apiKeyID)
|
||
}
|
||
|
||
// 写缓存
|
||
cfgCacheMutex.Lock()
|
||
cfgCache = out
|
||
cfgCacheAt = time.Now()
|
||
cfgCacheMutex.Unlock()
|
||
|
||
return out, nil
|
||
}
|
||
|
||
// InvalidateCache 清空配置缓存(运维改完 DB 后可主动调用)
|
||
func InvalidateCache() {
|
||
cfgCacheMutex.Lock()
|
||
cfgCache = nil
|
||
cfgCacheMutex.Unlock()
|
||
}
|
||
|
||
// LoadActiveSelection 读取后台"模型配置"Tab 保存的全局激活组合
|
||
//
|
||
// 数据源:xk_system_config 表的三个 key:
|
||
// - ai_active_provider :激活平台 code(如 spark / deepseek / agent)
|
||
// - ai_active_api_key_id :激活 API Key ID(数字字符串)
|
||
// - ai_active_model :激活模型 code
|
||
//
|
||
// 返回值语义:
|
||
// - 三个字段全空 → 后台未配置,调用方应回落到 LLMConfigFromDB 的平台默认值
|
||
// - Provider 非空 → 用它覆盖 cfg.LLM.DefaultProvider,让 EnhanceService 路由到正确平台
|
||
// - APIKeyID/Model 非空 → 调用方据此到 LLMConfigFromDB[Provider] 里覆盖对应字段
|
||
//
|
||
// 失败处理:单 key 查询失败不影响其他 key(用空值兜底);DB 不可用返回错误
|
||
func LoadActiveSelection() (*ActiveSelection, error) {
|
||
if DB == nil {
|
||
return nil, errors.New("dao: DB 未初始化,请先调用 Init()")
|
||
}
|
||
// 一次性读三个 key(IN 查询比三条单查高效)
|
||
var rows []SystemConfigRow
|
||
keys := []string{"ai_active_provider", "ai_active_api_key_id", "ai_active_model"}
|
||
if err := DB.Where("config_key IN ?", keys).
|
||
Find(&rows).Error; err != nil {
|
||
return nil, fmt.Errorf("dao: 查询 xk_system_config 失败: %w", err)
|
||
}
|
||
sel := &ActiveSelection{}
|
||
for _, r := range rows {
|
||
switch r.ConfigKey {
|
||
case "ai_active_provider":
|
||
// 平台 code 小写化,与 LLMConfigFromDB.Provider 保持一致
|
||
sel.Provider = strings.ToLower(strings.TrimSpace(r.ConfigValue))
|
||
case "ai_active_api_key_id":
|
||
// 数字字符串转 int;非法值(如空串/字母)兜底为 0
|
||
n := 0
|
||
fmt.Sscanf(r.ConfigValue, "%d", &n)
|
||
sel.APIKeyID = n
|
||
case "ai_active_model":
|
||
sel.Model = strings.TrimSpace(r.ConfigValue)
|
||
}
|
||
}
|
||
return sel, nil
|
||
}
|
||
|
||
// ResolvedLLMConfig 解析后的最终 LLM 配置(已应用激活组合覆盖)
|
||
//
|
||
// 本结构是"按需调用 LLM"时使用的最终配置——
|
||
// 已经合并了三层数据源(平台默认 + 全局激活 + api_key_id 反查),
|
||
// 调用方拿到后可以直接据此创建/复用 LLMClient,无需再处理覆盖逻辑。
|
||
//
|
||
// 字段语义:
|
||
// - Provider :实际使用的供应商 code(小写)
|
||
// - APIURL :实际使用的 API 地址(来自平台表)
|
||
// - APIKey :实际使用的明文 API Key(已解密)
|
||
// - APIKeyID :实际使用的密钥 id(写入审计日志)
|
||
// - Model :实际使用的模型 code
|
||
// - Source :来源标记:"active"(激活组合) / "platform_default"(平台默认)
|
||
// 用于日志排查"为什么调用的是这个模型"
|
||
type ResolvedLLMConfig struct {
|
||
Provider string
|
||
APIURL string
|
||
APIKey string
|
||
APIKeyID int
|
||
Model string
|
||
Source string
|
||
}
|
||
|
||
// 全局缓存:LoadActiveLLMConfig 的解析结果(60s TTL,独立缓存锁)
|
||
//
|
||
// 为什么单独缓存:
|
||
// - LoadLLMConfigsFromDB 的缓存是"全量平台列表",命中后整个 map 共享;
|
||
// LoadActiveLLMConfig 是"最终生效的那一个",缓存粒度不同,不能复用同一把锁
|
||
// - 缓存中包含解密后的明文 APIKey,仅在内存中保存,不出日志/不落盘
|
||
var (
|
||
activeCfgCache *ResolvedLLMConfig
|
||
activeCfgCacheAt time.Time
|
||
activeCfgCacheMu sync.RWMutex
|
||
activeCfgCacheTTL = 60 * time.Second // 与 LoadLLMConfigsFromDB 同步:60s 内全集群一致
|
||
)
|
||
|
||
// LoadActiveLLMConfig 一次性解析出"当前实际生效"的 LLM 配置(带 60s 缓存)
|
||
//
|
||
// 解析规则(与 PHP AiRuntimeConfigService::resolve() 严格对齐):
|
||
// 1. 读 xk_system_config 拿 ai_active_provider / ai_active_api_key_id / ai_active_model
|
||
// 2. 若 active.Provider 为空或为 "agent"(PHP 中转标记)→ 回落到 cfg.LLM.DefaultProvider
|
||
// (agent 标记 Go 端不处理,PHP 自己会路由;Go 端按 yaml 默认 provider 兜底)
|
||
// 3. 从 xk_ai_platform 读该 provider 的 api_url,从 xk_ai_model 读默认 model
|
||
// 4. 若 active.APIKeyID > 0 → 按主键反查 key(跨平台指定场景),否则用平台默认 key
|
||
// 5. 若 active.Model 非空 → 覆盖 model
|
||
//
|
||
// 入参 fallbackProvider:
|
||
// - 当 active.Provider 为空或 agent 时,使用此值作为回落 provider
|
||
// - 一般传 cfg.LLM.DefaultProvider(来自 yaml/env)
|
||
//
|
||
// 入参 fallbackTimeoutSecs:
|
||
// - DB 中平台表没有 timeout 字段,这里从 yaml 透传,作为客户端创建时的 HTTP 超时
|
||
//
|
||
// 失败处理:
|
||
// - DB 不可用 → 返回 error,调用方应回落到 yaml/env 配置
|
||
// - 单个 provider 解析失败 → 返回 error(不同于 LoadLLMConfigsFromDB 的"跳过继续",
|
||
// 因为这里只查一个目标,失败就该报错让上层处理)
|
||
func LoadActiveLLMConfig(fallbackProvider string) (*ResolvedLLMConfig, error) {
|
||
// 1) 命中缓存直接返回(深拷贝,避免调用方误改全局缓存)
|
||
activeCfgCacheMu.RLock()
|
||
if activeCfgCache != nil && time.Since(activeCfgCacheAt) < activeCfgCacheTTL {
|
||
out := *activeCfgCache
|
||
activeCfgCacheMu.RUnlock()
|
||
return &out, nil
|
||
}
|
||
activeCfgCacheMu.RUnlock()
|
||
|
||
if DB == nil {
|
||
return nil, errors.New("dao: DB 未初始化,请先调用 Init()")
|
||
}
|
||
|
||
// 2) 读全局激活组合
|
||
active, err := LoadActiveSelection()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("dao: 读取激活组合失败: %w", err)
|
||
}
|
||
|
||
// 3) 决定最终使用的 provider
|
||
// 重要语义(2026-08-11 重构后):
|
||
// ai_active_provider 永远是真实 provider(spark/deepseek/...),
|
||
// "是否走 Go Agent 中转"由 PHP 端独立开关 ai_agent_via_agent 控制(PHP 自己处理路由),
|
||
// Go 端被调用时永远代表"我要用真实模型",所以这里不再判断 agent。
|
||
provider := active.Provider
|
||
source := "active"
|
||
if provider == "" {
|
||
// 后台未配置 ai_active_provider,回落 yaml/env 默认 provider
|
||
provider = strings.ToLower(strings.TrimSpace(fallbackProvider))
|
||
if provider == "" {
|
||
provider = "deepseek" // 兜底中的兜底,避免空 provider 导致后续查表失败
|
||
}
|
||
source = "platform_default"
|
||
}
|
||
|
||
// 4) 从 xk_ai_platform 查目标 provider
|
||
var platform PlatformRow
|
||
if err := DB.Where("code = ? AND status = 1 AND deleted_at = 0", provider).
|
||
First(&platform).Error; err != nil {
|
||
return nil, fmt.Errorf("dao: 查询 xk_ai_platform code=%s 失败: %w", provider, err)
|
||
}
|
||
|
||
// 5) 决定使用的 API Key(激活 key_id 优先,否则用平台默认 key)
|
||
var apiKeyRow APIKeyRow
|
||
if active.APIKeyID > 0 {
|
||
// 后台指定了具体 key_id(跨平台场景),按主键反查
|
||
if err := DB.Where("id = ? AND status = 1 AND deleted_at = 0", active.APIKeyID).
|
||
First(&apiKeyRow).Error; err != nil {
|
||
return nil, fmt.Errorf("dao: 按 id=%d 查 xk_ai_api_key 失败: %w", active.APIKeyID, err)
|
||
}
|
||
} else {
|
||
// 未指定 key_id,用平台默认 key(与 resolveAPIKey 同样排序规则)
|
||
if err := DB.Where("platform_id = ? AND status = 1 AND deleted_at = 0", platform.ID).
|
||
Order("is_default DESC, sort DESC, id ASC").
|
||
First(&apiKeyRow).Error; err != nil {
|
||
return nil, fmt.Errorf("dao: 查询 xk_ai_api_key platform_id=%d 失败: %w", platform.ID, err)
|
||
}
|
||
}
|
||
|
||
// 6) 解密 API Key
|
||
// 注意:本函数入参没有 encryptKey,从全局缓存取(main.go 启动时通过 SetEncryptKey 注入)
|
||
if globalEncryptKey == "" {
|
||
return nil, errors.New("dao: encryptKey 未注入,请先调用 dao.SetEncryptKey()")
|
||
}
|
||
plainKey, err := xkaes.Decrypt(apiKeyRow.APIKey, globalEncryptKey)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("dao: 解密 api_key(id=%d) 失败: %w", apiKeyRow.ID, err)
|
||
}
|
||
if plainKey == "" {
|
||
return nil, fmt.Errorf("dao: api_key(id=%d) 解密后为空", apiKeyRow.ID)
|
||
}
|
||
|
||
// 7) 决定使用的 Model(激活 model 优先,否则用平台默认模型)
|
||
model := active.Model
|
||
if model == "" {
|
||
model = resolveDefaultModel(platform.ID)
|
||
}
|
||
|
||
resolved := &ResolvedLLMConfig{
|
||
Provider: provider,
|
||
APIURL: platform.APIURL,
|
||
APIKey: plainKey,
|
||
APIKeyID: apiKeyRow.ID,
|
||
Model: model,
|
||
Source: source,
|
||
}
|
||
|
||
// 8) 写缓存
|
||
activeCfgCacheMu.Lock()
|
||
activeCfgCache = resolved
|
||
activeCfgCacheAt = time.Now()
|
||
activeCfgCacheMu.Unlock()
|
||
|
||
log.Printf("[DAO] 解析生效配置: provider=%s model=%s api_key_id=%d source=%s",
|
||
resolved.Provider, resolved.Model, resolved.APIKeyID, resolved.Source)
|
||
return resolved, nil
|
||
}
|
||
|
||
// InvalidateActiveLLMCache 主动失效"生效配置"缓存
|
||
//
|
||
// 使用场景:PHP 后台改完模型配置后调 /admin/invalidate-cache 接口
|
||
// 让 Go 端下一次请求时重新从 DB 读取最新配置
|
||
func InvalidateActiveLLMCache() {
|
||
activeCfgCacheMu.Lock()
|
||
activeCfgCache = nil
|
||
activeCfgCacheAt = time.Time{}
|
||
activeCfgCacheMu.Unlock()
|
||
log.Printf("[DAO] 生效配置缓存已主动失效")
|
||
}
|
||
|
||
// 全局 AES 主密钥(启动期由 main.go 通过 SetEncryptKey 注入)
|
||
//
|
||
// 为什么放全局而不是函数入参:
|
||
// - 调用链 EnhancerService → dao.LoadActiveLLMConfig 每次都要传 key 太冗长
|
||
// - 主密钥整个进程生命周期不变,放全局变量 + 启动期注入更符合实际使用模式
|
||
var globalEncryptKey string
|
||
|
||
// SetEncryptKey 注入 AES 主密钥(main.go 启动时调用一次)
|
||
//
|
||
// 与 PHP 端 .env ENCRYPT_KEY 同源,用于解密 xk_ai_api_key.api_key 字段
|
||
func SetEncryptKey(key string) {
|
||
globalEncryptKey = key
|
||
}
|
||
|
||
// ========================================================================
|
||
// 内部辅助
|
||
// ========================================================================
|
||
|
||
// resolveAPIKey 取平台的默认密钥(按 PHP 同样规则排序)并解密
|
||
//
|
||
// 排序规则(与 AiRuntimeConfigService 一致):
|
||
// 1. is_default=1 优先
|
||
// 2. sort 倒序
|
||
// 3. id 升序
|
||
// 返回:明文 API Key + 该 key 的 id(用于审计)
|
||
func resolveAPIKey(platformID int, encryptKey string) (string, int, error) {
|
||
var row APIKeyRow
|
||
err := DB.Where("platform_id = ? AND status = 1 AND deleted_at = 0", platformID).
|
||
Order("is_default DESC, sort DESC, id ASC").
|
||
First(&row).Error
|
||
if err != nil {
|
||
return "", 0, fmt.Errorf("查 xk_ai_api_key 失败: %w", err)
|
||
}
|
||
|
||
// 解密
|
||
plain, err := xkaes.Decrypt(row.APIKey, encryptKey)
|
||
if err != nil {
|
||
return "", 0, fmt.Errorf("解密 api_key(id=%d) 失败: %w", row.ID, err)
|
||
}
|
||
if plain == "" {
|
||
return "", 0, fmt.Errorf("api_key(id=%d) 解密后为空", row.ID)
|
||
}
|
||
return plain, row.ID, nil
|
||
}
|
||
|
||
// GetAPIKeyByID 按 id 取密钥并解密
|
||
//
|
||
// 用途:后台"模型配置"Tab 通过 ai_active_api_key_id 跨平台指定激活 key 时,
|
||
// Go 端需要按 id 直接取这条 key 的明文(与 resolveAPIKey 不同——后者按平台
|
||
// 取"默认 key",前者按 id 取"指定 key",可能不是同一个)。
|
||
//
|
||
// 失败处理:DB 错误或解密失败都返回 error,由调用方决定是否回落到平台默认 key
|
||
func GetAPIKeyByID(keyID int, encryptKey string) (string, error) {
|
||
if DB == nil {
|
||
return "", errors.New("dao: DB 未初始化")
|
||
}
|
||
if keyID <= 0 {
|
||
return "", errors.New("dao: keyID 非法")
|
||
}
|
||
var row APIKeyRow
|
||
if err := DB.Where("id = ? AND status = 1 AND deleted_at = 0", keyID).
|
||
First(&row).Error; err != nil {
|
||
return "", fmt.Errorf("查 xk_ai_api_key id=%d 失败: %w", keyID, err)
|
||
}
|
||
plain, err := xkaes.Decrypt(row.APIKey, encryptKey)
|
||
if err != nil {
|
||
return "", fmt.Errorf("解密 api_key(id=%d) 失败: %w", row.ID, err)
|
||
}
|
||
return plain, nil
|
||
}
|
||
|
||
// resolveDefaultModel 取平台的默认模型 code
|
||
//
|
||
// 排序:is_default DESC, sort DESC, id ASC(与 PHP 一致)
|
||
func resolveDefaultModel(platformID int) string {
|
||
var row ModelRow
|
||
err := DB.Where("platform_id = ? AND status = 1 AND deleted_at = 0", platformID).
|
||
Order("is_default DESC, sort DESC, id ASC").
|
||
First(&row).Error
|
||
if err != nil {
|
||
return "" // 失败返回空,由调用方回落默认值
|
||
}
|
||
return row.Code
|
||
}
|
||
|
||
// maskDSN 把 DSN 中的密码打码,避免日志泄露
|
||
//
|
||
// 例:root:secret@tcp(127.0.0.1:3306)/z_xk → root:***@tcp(127.0.0.1:3306)/z_xk
|
||
func maskDSN(dsn string) string {
|
||
i := strings.Index(dsn, ":")
|
||
j := strings.Index(dsn, "@")
|
||
if i < 0 || j < 0 || j <= i {
|
||
return dsn
|
||
}
|
||
return dsn[:i+1] + "***" + dsn[j:]
|
||
}
|