Files
2026-08-14 21:50:48 +08:00

568 lines
21 KiB
Go
Raw Permalink 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 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:]
}