175 lines
4.9 KiB
Go
175 lines
4.9 KiB
Go
package ai
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"io"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
"senlinai-agent/backend/internal/models"
|
|
)
|
|
|
|
type Gateway struct {
|
|
systemKey string
|
|
encryptionSecret string
|
|
now func() time.Time
|
|
}
|
|
|
|
type SelectedKey struct {
|
|
Provider string
|
|
APIKey string
|
|
KeyType string
|
|
}
|
|
|
|
var (
|
|
ErrAIKeyMissing = errors.New("no ai key available")
|
|
ErrAIRateLimited = errors.New("ai rate limit exceeded")
|
|
)
|
|
|
|
func NewGateway(systemKey string) *Gateway {
|
|
return NewGatewayWithSecret(systemKey, "development-ai-key-secret-change-me")
|
|
}
|
|
|
|
func NewGatewayWithSecret(systemKey string, encryptionSecret string) *Gateway {
|
|
return &Gateway{systemKey: systemKey, encryptionSecret: encryptionSecret, now: time.Now}
|
|
}
|
|
|
|
func (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error {
|
|
encrypted, err := encryptAPIKey(apiKey, g.encryptionSecret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := models.SaAIKey{UserID: userID, Provider: provider, EncryptedAPIKey: encrypted}
|
|
return models.DBService.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "user_id"}},
|
|
DoUpdates: clause.AssignmentColumns([]string{"provider", "encrypted_api_key", "updated_at"}),
|
|
}).Create(&key).Error
|
|
}
|
|
|
|
func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) {
|
|
var userKey models.SaAIKey
|
|
err := models.DBService.Where("user_id = ?", userID).First(&userKey).Error
|
|
if err == nil {
|
|
selected := SelectedKey{Provider: userKey.Provider, KeyType: "user"}
|
|
apiKey, err := decryptAPIKey(userKey.EncryptedAPIKey, g.encryptionSecret)
|
|
if err != nil {
|
|
return selected, err
|
|
}
|
|
selected.APIKey = apiKey
|
|
return selected, nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return SelectedKey{}, err
|
|
}
|
|
if g.systemKey == "" {
|
|
return SelectedKey{}, ErrAIKeyMissing
|
|
}
|
|
return SelectedKey{Provider: "openai", APIKey: g.systemKey, KeyType: "system"}, nil
|
|
}
|
|
|
|
func (g *Gateway) RecordCall(database *gorm.DB, userID uint, provider string, usedKeyType string, action string, status string, errText string) error {
|
|
if database == nil {
|
|
database = models.DBService
|
|
}
|
|
return database.Create(&models.SaAICallLog{
|
|
UserID: userID,
|
|
Provider: provider,
|
|
UsedKeyType: usedKeyType,
|
|
Action: action,
|
|
Status: status,
|
|
Error: errText,
|
|
}).Error
|
|
}
|
|
|
|
// ReserveRateLimit 以数据库单条 UPSERT 原子占用固定窗口配额。
|
|
// 配额在 provider/key 选择前占用,后续缺 key 或 provider 失败同样计入该窗口的尝试次数。
|
|
func (g *Gateway) ReserveRateLimit(userID uint, action string, limit int, window time.Duration) error {
|
|
if limit <= 0 {
|
|
return nil
|
|
}
|
|
currentTime := time.Now().UTC()
|
|
if g.now != nil {
|
|
currentTime = g.now().UTC()
|
|
}
|
|
windowStart := currentTime.Truncate(window)
|
|
bucket := models.SaAIRateBucket{
|
|
UserID: userID, Action: action, WindowStart: windowStart, Count: 1,
|
|
}
|
|
result := models.DBService.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "user_id"}, {Name: "action"}, {Name: "window_start"}},
|
|
DoUpdates: clause.Assignments(map[string]any{
|
|
"count": gorm.Expr("sa_ai_rate_buckets.count + 1"),
|
|
"updated_at": currentTime,
|
|
}),
|
|
Where: clause.Where{Exprs: []clause.Expression{
|
|
clause.Lt{Column: clause.Column{Table: "sa_ai_rate_buckets", Name: "count"}, Value: limit},
|
|
}},
|
|
}).Create(&bucket)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return ErrAIRateLimited
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func encryptAPIKey(apiKey string, secret string) (string, error) {
|
|
if apiKey == "" {
|
|
return "", errors.New("api key is required")
|
|
}
|
|
block, err := aes.NewCipher(encryptionKey(secret))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return "", err
|
|
}
|
|
ciphertext := gcm.Seal(nil, nonce, []byte(apiKey), nil)
|
|
return "v1:" + base64.RawURLEncoding.EncodeToString(append(nonce, ciphertext...)), nil
|
|
}
|
|
|
|
func decryptAPIKey(encrypted string, secret string) (string, error) {
|
|
if len(encrypted) < 3 || encrypted[:3] != "v1:" {
|
|
return encrypted, nil
|
|
}
|
|
raw, err := base64.RawURLEncoding.DecodeString(encrypted[3:])
|
|
if err != nil {
|
|
return "", errors.New("invalid encrypted api key")
|
|
}
|
|
block, err := aes.NewCipher(encryptionKey(secret))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(raw) < gcm.NonceSize() {
|
|
return "", errors.New("invalid encrypted api key")
|
|
}
|
|
nonce := raw[:gcm.NonceSize()]
|
|
ciphertext := raw[gcm.NonceSize():]
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return "", errors.New("invalid encrypted api key")
|
|
}
|
|
return string(plaintext), nil
|
|
}
|
|
|
|
func encryptionKey(secret string) []byte {
|
|
sum := sha256.Sum256([]byte(secret))
|
|
return sum[:]
|
|
}
|