初始提交:OCI 面板后端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:04 +08:00
commit b9a3e97e84
168 changed files with 31794 additions and 0 deletions
+641
View File
@@ -0,0 +1,641 @@
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log"
"sort"
"strings"
"sync"
"time"
"gorm.io/gorm"
"oci-portal/internal/aiwire"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
const (
aiLogRetention = 90 * 24 * time.Hour
aiLogMaxRows = 50000
aiLogCleanupTick = 24 * time.Hour
// aiFailThreshold 起连续失败次数触发熔断,退避 2^(n-阈值) 分钟,封顶 30 分钟
aiFailThreshold = 5
aiBackoffCap = 30 * time.Minute
// aiKeyTouchGap 是 LastUsedAt 的最小写库间隔,避免高频调用刷库
aiKeyTouchGap = time.Minute
// 内容日志(红线例外)约束:开启必须限时(上限 7 天),正文截断,短保留
aiContentLogMaxHours = 168
aiContentLogRetention = 7 * 24 * time.Hour
aiContentLogMaxRows = 10000
aiContentBodyLimit = 64 * 1024
)
var (
// ErrAiKeyInvalid 表示网关密钥不存在或已禁用。
ErrAiKeyInvalid = errors.New("无效或已禁用的 API 密钥")
// ErrAiUnknownModel 表示没有任何渠道支持请求的模型。
ErrAiUnknownModel = errors.New("未知模型:没有渠道提供该模型")
// ErrAiNoChannel 表示模型有渠道支持但当前全部不可用(禁用/熔断)。
ErrAiNoChannel = errors.New("暂无可用渠道,请稍后重试")
)
// AiGatewayService 是 AI 网关核心:密钥、渠道号池、模型缓存与调用编排。
type AiGatewayService struct {
db *gorm.DB
configs *OciConfigService
client oci.Client
wg sync.WaitGroup
// touchMu 保护各密钥的最近触达时间(内存节流,不追求跨实例精确)
touchMu sync.Mutex
lastTouch map[uint]time.Time
// onChannelsChanged 在渠道增删后触发,由 main 装配为探测任务同步钩子
onChannelsChanged func(context.Context)
}
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService {
return &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
}
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
func (s *AiGatewayService) SetOnChannelsChanged(fn func(context.Context)) {
s.onChannelsChanged = fn
}
func (s *AiGatewayService) fireChannelsChanged(ctx context.Context) {
if s.onChannelsChanged != nil {
s.onChannelsChanged(ctx)
}
}
// ---- 网关密钥 ----
// CreateKey 生成网关密钥并返回明文(仅此一次);customValue 非空时使用给定值,
// group 非空时该密钥只在同分组渠道内路由。
func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, group string) (string, *model.AiKey, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", nil, fmt.Errorf("密钥名称不能为空")
}
raw := strings.TrimSpace(customValue)
if raw == "" {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", nil, fmt.Errorf("generate key: %w", err)
}
raw = "sk-" + hex.EncodeToString(buf)
}
if len(raw) < 8 {
return "", nil, fmt.Errorf("自定义密钥至少 8 个字符")
}
key := &model.AiKey{Name: name, KeyHash: hashKey(raw), Tail: raw[len(raw)-4:], Group: strings.TrimSpace(group), Enabled: true}
if err := s.db.WithContext(ctx).Create(key).Error; err != nil {
return "", nil, fmt.Errorf("密钥名称或取值与现有密钥重复")
}
return raw, key, nil
}
func hashKey(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// Keys 列出全部密钥(不含任何明文信息)。
func (s *AiGatewayService) Keys(ctx context.Context) ([]model.AiKey, error) {
var keys []model.AiKey
err := s.db.WithContext(ctx).Order("id DESC").Find(&keys).Error
return keys, err
}
// UpdateKey 修改密钥名称 / 启用状态 / 分组(group 指针非空即覆盖,可置空)。
func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string, enabled *bool, group *string) error {
updates := map[string]any{}
if name = strings.TrimSpace(name); name != "" {
updates["name"] = name
}
if enabled != nil {
updates["enabled"] = *enabled
}
if group != nil {
updates["key_group"] = strings.TrimSpace(*group)
}
if len(updates) == 0 {
return nil
}
return s.db.WithContext(ctx).Model(&model.AiKey{}).Where("id = ?", id).Updates(updates).Error
}
// DeleteKey 删除密钥,立即使其失效。
func (s *AiGatewayService) DeleteKey(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.AiKey{}, id).Error
}
// VerifyKey 校验请求携带的密钥;通过后节流更新 LastUsedAt。
func (s *AiGatewayService) VerifyKey(ctx context.Context, raw string) (*model.AiKey, error) {
if raw == "" {
return nil, ErrAiKeyInvalid
}
var key model.AiKey
err := s.db.WithContext(ctx).Where("key_hash = ?", hashKey(raw)).First(&key).Error
if err != nil || !key.Enabled {
return nil, ErrAiKeyInvalid
}
s.touchKey(ctx, key.ID)
return &key, nil
}
// touchKey 更新最近使用时间,间隔小于 aiKeyTouchGap 时跳过写库。
func (s *AiGatewayService) touchKey(ctx context.Context, id uint) {
now := time.Now()
s.touchMu.Lock()
last, ok := s.lastTouch[id]
if ok && now.Sub(last) < aiKeyTouchGap {
s.touchMu.Unlock()
return
}
s.lastTouch[id] = now
s.touchMu.Unlock()
s.db.WithContext(ctx).Model(&model.AiKey{}).Where("id = ?", id).Update("last_used_at", now)
}
// ---- 渠道 ----
// ChannelInput 是创建 / 更新渠道的输入;Group 指针非空即覆盖分组(可置空)。
type ChannelInput struct {
OciConfigID uint `json:"ociConfigId"`
Region string `json:"region"`
Name string `json:"name"`
Group *string `json:"group"`
Enabled *bool `json:"enabled"`
Priority *int `json:"priority"`
Weight *int `json:"weight"`
}
// CreateChannel 新建渠道(租户×区域唯一);探测由前端随后显式触发。
func (s *AiGatewayService) CreateChannel(ctx context.Context, in ChannelInput) (*model.AiChannel, error) {
if in.OciConfigID == 0 || strings.TrimSpace(in.Region) == "" {
return nil, fmt.Errorf("渠道需要指定租户配置与区域")
}
cfg, err := s.configs.Get(ctx, in.OciConfigID)
if err != nil {
return nil, err
}
ch := &model.AiChannel{
Name: strings.TrimSpace(in.Name),
OciConfigID: in.OciConfigID,
Region: strings.TrimSpace(in.Region),
Enabled: true,
Priority: valueOr(in.Priority, 1),
Weight: valueOr(in.Weight, 1),
}
if in.Group != nil {
ch.Group = strings.TrimSpace(*in.Group)
}
if ch.Name == "" {
ch.Name = fmt.Sprintf("%s·%s", cfg.Alias, ch.Region)
}
if err := s.db.WithContext(ctx).Create(ch).Error; err != nil {
return nil, fmt.Errorf("该租户与区域的渠道已存在")
}
s.fireChannelsChanged(ctx)
return ch, nil
}
func valueOr(p *int, def int) int {
if p != nil {
return *p
}
return def
}
// Channels 列出全部渠道。
func (s *AiGatewayService) Channels(ctx context.Context) ([]model.AiChannel, error) {
var chs []model.AiChannel
err := s.db.WithContext(ctx).Order("priority ASC, id ASC").Find(&chs).Error
return chs, err
}
// UpdateChannel 修改渠道名称 / 分组 / 启停 / 优先级 / 权重。
func (s *AiGatewayService) UpdateChannel(ctx context.Context, id uint, in ChannelInput) error {
updates := map[string]any{}
if name := strings.TrimSpace(in.Name); name != "" {
updates["name"] = name
}
if in.Group != nil {
updates["channel_group"] = strings.TrimSpace(*in.Group)
}
if in.Enabled != nil {
updates["enabled"] = *in.Enabled
}
if in.Priority != nil {
updates["priority"] = *in.Priority
}
if in.Weight != nil {
updates["weight"] = *in.Weight
}
if len(updates) == 0 {
return nil
}
return s.db.WithContext(ctx).Model(&model.AiChannel{}).Where("id = ?", id).Updates(updates).Error
}
// DeleteChannel 删除渠道并清空其模型缓存。
func (s *AiGatewayService) DeleteChannel(ctx context.Context, id uint) error {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("channel_id = ?", id).Delete(&model.AiModelCache{}).Error; err != nil {
return err
}
return tx.Delete(&model.AiChannel{}, id).Error
})
if err == nil {
s.fireChannelsChanged(ctx)
}
return err
}
// ---- 探测与模型同步 ----
// ProbeChannel 探测渠道可用性:服务可见性 → 模型同步 → maxTokens=1 配额试调。
func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.AiChannel, error) {
var ch model.AiChannel
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
return nil, fmt.Errorf("渠道不存在")
}
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
if err != nil {
return nil, err
}
status, probeErr := s.probe(ctx, cred, &ch)
now := time.Now()
updates := map[string]any{"probe_status": status, "probe_error": probeErr, "last_probe_at": now}
if status == "ok" {
updates["fail_count"] = 0
updates["disabled_until"] = gorm.Expr("NULL")
}
if err := s.db.WithContext(ctx).Model(&ch).Updates(updates).Error; err != nil {
return nil, err
}
// 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值
var fresh model.AiChannel
if err := s.db.WithContext(ctx).First(&fresh, id).Error; err != nil {
return nil, err
}
return &fresh, nil
}
// probe 执行探测并返回 (状态, 错误摘要);同时完成模型缓存同步。
func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *model.AiChannel) (string, string) {
models, err := s.client.ListGenAiModels(ctx, cred, ch.Region)
if err != nil {
return classifyProbeErr(err), truncateErr(oci.CompactError(err))
}
if len(models) == 0 {
_ = s.replaceModels(ctx, ch.ID, nil)
return "no_service", "区域无可用模型(GenAI 服务不可用或未开放)"
}
if err := s.replaceModels(ctx, ch.ID, models); err != nil {
return "error", truncateErr(err.Error())
}
return s.probeChat(ctx, cred, ch, models)
}
// probeChat 按偏好挑选至多 3 个模型依次试调:部分模型元数据标 CHAT 但实际
// 不可对话(如 voice agent),遇 400/5xx 换下一个;401/403/404 属租户级直接定论。
func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) {
status, detail := "error", "无可试调对话模型"
for _, m := range probeCandidates(models) {
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name)
switch {
case code == 200 || code == 429:
return "ok", ""
case code == 401 || code == 403 || code == 404:
return "no_quota", truncateErr(oci.CompactError(err))
default:
status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
}
}
return status, detail
}
// probeCandidates 只取对话模型并按可靠度排序取前 3:主流文本模型优先,
// voice 等非常规形态殿后(embedding / rerank 已被能力筛选排除)。
func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
var sorted []oci.GenAiModel
for _, m := range models {
if m.Capability == "" || m.Capability == "CHAT" {
sorted = append(sorted, m)
}
}
sort.SliceStable(sorted, func(i, j int) bool {
return probeScore(sorted[i].Name) > probeScore(sorted[j].Name)
})
if len(sorted) > 3 {
sorted = sorted[:3]
}
return sorted
}
func probeScore(name string) int {
n := strings.ToLower(name)
switch {
case strings.Contains(n, "voice") || strings.Contains(n, "embed") || strings.Contains(n, "rerank"):
return -10
case strings.Contains(n, "llama") && !strings.Contains(n, "vision"):
return 5
case strings.Contains(n, "gemini") || strings.Contains(n, "gpt-oss"):
return 4
case strings.Contains(n, "command"):
return 3
case strings.Contains(n, "grok") && !strings.Contains(n, "multi-agent"):
return 2
default:
return 0
}
}
// classifyProbeErr 区分「区域无服务端点」与其他错误。
func classifyProbeErr(err error) string {
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "no such host") || strings.Contains(msg, "timeout") ||
strings.Contains(msg, "connection refused") || strings.Contains(msg, "dial tcp") {
return "no_service"
}
return "error"
}
func truncateErr(msg string) string {
if len(msg) > 500 {
return msg[:500]
}
return msg
}
// SyncModels 重新拉取渠道区域的模型列表并覆盖缓存。
func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiModelCache, error) {
var ch model.AiChannel
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
return nil, fmt.Errorf("渠道不存在")
}
cred, err := s.configs.credentialsByID(ctx, ch.OciConfigID)
if err != nil {
return nil, err
}
models, err := s.client.ListGenAiModels(ctx, cred, ch.Region)
if err != nil {
return nil, fmt.Errorf("同步模型失败:%s", oci.CompactError(err))
}
if err := s.replaceModels(ctx, id, models); err != nil {
return nil, err
}
return s.channelModels(ctx, id)
}
// replaceModels 以事务整组覆盖渠道模型缓存。
func (s *AiGatewayService) replaceModels(ctx context.Context, channelID uint, models []oci.GenAiModel) error {
rows := make([]model.AiModelCache, 0, len(models))
now := time.Now()
for _, m := range models {
rows = append(rows, model.AiModelCache{ChannelID: channelID, ModelOcid: m.Ocid, Name: m.Name, Vendor: m.Vendor,
Capability: m.Capability, SyncedAt: now, DeprecatedAt: m.Deprecated, RetiredAt: m.Retired})
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("channel_id = ?", channelID).Delete(&model.AiModelCache{}).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
}
func (s *AiGatewayService) channelModels(ctx context.Context, channelID uint) ([]model.AiModelCache, error) {
var rows []model.AiModelCache
err := s.db.WithContext(ctx).Where("channel_id = ?", channelID).Order("name ASC").Find(&rows).Error
return rows, err
}
// GatewayModels 聚合启用渠道的模型(按名称去重),供 /ai/v1/models;
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) {
q := s.db.WithContext(ctx).
Joins("JOIN ai_channels ON ai_channels.id = ai_model_caches.channel_id AND ai_channels.enabled = ?", true)
if group != "" {
q = q.Where("ai_channels.channel_group = ?", group)
}
var rows []model.AiModelCache
err := q.Order("ai_model_caches.name ASC").Find(&rows).Error
list := aiwire.ModelList{Object: "list", Data: []aiwire.Model{}}
if err != nil {
return list, err
}
seen := map[string]bool{}
for _, r := range rows {
if seen[r.Name] {
continue
}
seen[r.Name] = true
list.Data = append(list.Data, aiwire.Model{ID: r.Name, Object: "model", Created: r.SyncedAt.Unix(), OwnedBy: r.Vendor})
}
return list, nil
}
// DeprecatingModels 返回 within 窗口内即将退役或即将弃用的在池模型(按名称去重):
// 退役(TimeOnDemandRetired)才导致不可调用,单独标注;已过弃用日但未到退役日的
// 模型仍可正常调用,不再反复告警;已过退役日的在同步层剔除,不会出现在池中。
func (s *AiGatewayService) DeprecatingModels(ctx context.Context, within time.Duration) ([]string, error) {
now := time.Now()
deadline := now.Add(within)
var rows []model.AiModelCache
err := s.db.WithContext(ctx).
Where("(retired_at IS NOT NULL AND retired_at > ? AND retired_at <= ?) OR (deprecated_at IS NOT NULL AND deprecated_at >= ? AND deprecated_at <= ?)",
now, deadline, now, deadline).
Order("name ASC").Find(&rows).Error
if err != nil {
return nil, err
}
seen := map[string]bool{}
var out []string
for _, r := range rows {
if seen[r.Name] {
continue
}
seen[r.Name] = true
if r.RetiredAt != nil && r.RetiredAt.After(now) && !r.RetiredAt.After(deadline) {
out = append(out, fmt.Sprintf("%s(%s 退役,届时无法调用)", r.Name, r.RetiredAt.Format("2006-01-02")))
continue
}
out = append(out, fmt.Sprintf("%s(%s 宣布弃用,退役前仍可调用)", r.Name, r.DeprecatedAt.Format("2006-01-02")))
}
return out, nil
}
// ProbeAll 逐个探测全部渠道,返回状态汇总;供 AI 探测后台任务调用。
func (s *AiGatewayService) ProbeAll(ctx context.Context) (string, error) {
chs, err := s.Channels(ctx)
if err != nil {
return "", err
}
counts := map[string]int{}
var failures []string
for _, ch := range chs {
fresh, err := s.ProbeChannel(ctx, ch.ID)
if err != nil {
counts["error"]++
failures = append(failures, fmt.Sprintf("#%d %s", ch.ID, truncateErr(err.Error())))
continue
}
counts[fresh.ProbeStatus]++
}
msg := fmt.Sprintf("probed %d: %d ok, %d no_service, %d no_quota, %d error",
len(chs), counts["ok"], counts["no_service"], counts["no_quota"], counts["error"])
if len(failures) > 0 {
msg += "; " + strings.Join(failures, "; ")
}
return msg, nil
}
// ---- 调用日志 ----
// LogCall 落一条调用日志(仅元数据与用量,永不含请求 / 响应正文),返回落库 ID 供内容日志关联(失败为 0)。
func (s *AiGatewayService) LogCall(entry model.AiCallLog) uint {
entry.ErrMsg = truncateErr(entry.ErrMsg)
if err := s.db.Create(&entry).Error; err != nil {
log.Printf("ai call log: %v", err)
return 0
}
return entry.ID
}
// CallLogs 分页查询调用日志。
func (s *AiGatewayService) CallLogs(ctx context.Context, page, size int) ([]model.AiCallLog, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 200 {
size = 50
}
var total int64
q := s.db.WithContext(ctx).Model(&model.AiCallLog{})
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.AiCallLog
err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error
return rows, total, err
}
// ---- 内容日志(红线例外,按密钥显式限时开启) ----
// UpdateKeyContentLog 设置密钥内容日志窗口:hours=0 立即关闭,>0 从现在起开启 N 小时(上限 7 天)。
func (s *AiGatewayService) UpdateKeyContentLog(ctx context.Context, id uint, hours int) (*model.AiKey, error) {
if hours < 0 || hours > aiContentLogMaxHours {
return nil, fmt.Errorf("内容日志时长需在 0-%d 小时之间", aiContentLogMaxHours)
}
updates := map[string]any{"content_log_until": gorm.Expr("NULL")}
if hours > 0 {
updates["content_log_until"] = time.Now().Add(time.Duration(hours) * time.Hour)
}
if err := s.db.WithContext(ctx).Model(&model.AiKey{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return nil, err
}
// 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值
var fresh model.AiKey
if err := s.db.WithContext(ctx).First(&fresh, id).Error; err != nil {
return nil, err
}
return &fresh, nil
}
// LogContent 写一条内容日志(调用方已确认密钥开启且未过期);正文截断至 64KB。
func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
entry.RequestBody = truncateBody(entry.RequestBody)
entry.ResponseBody = truncateBody(entry.ResponseBody)
if err := s.db.Create(&entry).Error; err != nil {
log.Printf("ai content log: %v", err)
}
}
func truncateBody(s string) string {
if len(s) > aiContentBodyLimit {
return s[:aiContentBodyLimit]
}
return s
}
// ContentLogs 分页查询内容日志(keyID / callLogID 为 0 时不过滤)。
func (s *AiGatewayService) ContentLogs(ctx context.Context, keyID, callLogID uint, page, size int) ([]model.AiContentLog, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := s.db.WithContext(ctx).Model(&model.AiContentLog{})
if keyID > 0 {
q = q.Where("key_id = ?", keyID)
}
if callLogID > 0 {
q = q.Where("call_log_id = ?", callLogID)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.AiContentLog
err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error
return rows, total, err
}
// StartCleanup 启动调用日志周期清理:启动即清一次,之后每 24h 一次。
func (s *AiGatewayService) StartCleanup(ctx context.Context) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.cleanupOnce(ctx)
ticker := time.NewTicker(aiLogCleanupTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.cleanupOnce(ctx)
}
}
}()
}
func (s *AiGatewayService) cleanupOnce(ctx context.Context) {
s.cleanupTable(ctx, &model.AiCallLog{}, aiLogRetention, aiLogMaxRows, "ai log")
s.cleanupTable(ctx, &model.AiContentLog{}, aiContentLogRetention, aiContentLogMaxRows, "ai content log")
}
// cleanupTable 按保留期与行数上限清理日志表(超限删最旧)。
func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention time.Duration, maxRows int, tag string) {
cutoff := time.Now().Add(-retention)
if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil {
log.Printf("%s cleanup: %v", tag, err)
return
}
var total int64
if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil {
return
}
if overflow := int(total) - maxRows; overflow > 0 {
var ids []uint
s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(overflow).Pluck("id", &ids)
if len(ids) > 0 {
s.db.WithContext(ctx).Delete(m, ids)
}
}
}
// Wait 等待后台清理 goroutine 退出。
func (s *AiGatewayService) Wait() { s.wg.Wait() }