From 833d9f094f3a5d041540453bf8281b93c657d658 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sat, 18 Jul 2026 16:05:02 +0800 Subject: [PATCH] feat: add ai gateway foundation --- backend/internal/ai/gateway.go | 45 ++++++++++++++++ backend/internal/ai/gateway_test.go | 79 +++++++++++++++++++++++++++++ backend/internal/ai/sessions.go | 26 ++++++++++ 3 files changed, 150 insertions(+) create mode 100644 backend/internal/ai/gateway.go create mode 100644 backend/internal/ai/gateway_test.go create mode 100644 backend/internal/ai/sessions.go diff --git a/backend/internal/ai/gateway.go b/backend/internal/ai/gateway.go new file mode 100644 index 0000000..9567039 --- /dev/null +++ b/backend/internal/ai/gateway.go @@ -0,0 +1,45 @@ +package ai + +import ( + "errors" + + "gorm.io/gorm" + "senlinai-agent/backend/internal/domain" +) + +type Gateway struct { + db *gorm.DB + systemKey string +} + +type SelectedKey struct { + Provider string + APIKey string + KeyType string +} + +func NewGateway(database *gorm.DB, systemKey string) *Gateway { + return &Gateway{db: database, systemKey: systemKey} +} + +func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) { + var userKey domain.AIKey + if err := g.db.Where("user_id = ?", userID).First(&userKey).Error; err == nil { + return SelectedKey{Provider: userKey.Provider, APIKey: userKey.EncryptedAPIKey, KeyType: "user"}, nil + } + if g.systemKey == "" { + return SelectedKey{}, errors.New("no ai key available") + } + return SelectedKey{Provider: "openai", APIKey: g.systemKey, KeyType: "system"}, nil +} + +func (g *Gateway) RecordCall(userID uint, provider string, usedKeyType string, action string, status string, errText string) error { + return g.db.Create(&domain.AICallLog{ + UserID: userID, + Provider: provider, + UsedKeyType: usedKeyType, + Action: action, + Status: status, + Error: errText, + }).Error +} diff --git a/backend/internal/ai/gateway_test.go b/backend/internal/ai/gateway_test.go new file mode 100644 index 0000000..967c446 --- /dev/null +++ b/backend/internal/ai/gateway_test.go @@ -0,0 +1,79 @@ +package ai + +import ( + "fmt" + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "senlinai-agent/backend/internal/domain" +) + +func TestSelectKeyPrefersUserKey(t *testing.T) { + database := newTestDB(t) + require.NoError(t, database.Create(&domain.AIKey{UserID: 3, Provider: "openai", EncryptedAPIKey: "user-key"}).Error) + gateway := NewGateway(database, "system-key") + + selected, err := gateway.SelectKey(3) + + require.NoError(t, err) + require.Equal(t, "user", selected.KeyType) + require.Equal(t, "user-key", selected.APIKey) +} + +func TestSelectKeyFallsBackToSystemKey(t *testing.T) { + database := newTestDB(t) + gateway := NewGateway(database, "system-key") + + selected, err := gateway.SelectKey(3) + + require.NoError(t, err) + require.Equal(t, "system", selected.KeyType) + require.Equal(t, "system-key", selected.APIKey) +} + +func TestSelectKeyReturnsErrorWhenNoKeyAvailable(t *testing.T) { + database := newTestDB(t) + gateway := NewGateway(database, "") + + _, err := gateway.SelectKey(3) + + require.ErrorContains(t, err, "no ai key available") +} + +func TestRecordCallStoresAuditFields(t *testing.T) { + database := newTestDB(t) + gateway := NewGateway(database, "system-key") + + require.NoError(t, gateway.RecordCall(3, "openai", "system", "inbox_analyze", "failed", "rate limited")) + + var log domain.AICallLog + require.NoError(t, database.First(&log).Error) + require.Equal(t, uint(3), log.UserID) + require.Equal(t, "openai", log.Provider) + require.Equal(t, "system", log.UsedKeyType) + require.Equal(t, "inbox_analyze", log.Action) + require.Equal(t, "failed", log.Status) + require.Equal(t, "rate limited", log.Error) +} + +func TestCreateAISession(t *testing.T) { + database := newTestDB(t) + service := NewSessionService(database) + + session, err := service.Create(7, 3, "报价分析") + + require.NoError(t, err) + require.Equal(t, uint(7), session.ProjectID) + require.Equal(t, uint(3), session.CreatedBy) + require.Equal(t, "报价分析", session.Title) +} + +func newTestDB(t *testing.T) *gorm.DB { + t.Helper() + database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, domain.AutoMigrate(database)) + return database +} diff --git a/backend/internal/ai/sessions.go b/backend/internal/ai/sessions.go new file mode 100644 index 0000000..2ea793a --- /dev/null +++ b/backend/internal/ai/sessions.go @@ -0,0 +1,26 @@ +package ai + +import ( + "errors" + "strings" + + "gorm.io/gorm" + "senlinai-agent/backend/internal/domain" +) + +type SessionService struct { + db *gorm.DB +} + +func NewSessionService(database *gorm.DB) *SessionService { + return &SessionService{db: database} +} + +func (s *SessionService) Create(projectID uint, userID uint, title string) (*domain.AISession, error) { + title = strings.TrimSpace(title) + if title == "" { + return nil, errors.New("session title is required") + } + session := &domain.AISession{ProjectID: projectID, CreatedBy: userID, Title: title} + return session, s.db.Create(session).Error +}