package ai import ( "errors" "fmt" "strings" "time" "senlinai-agent/backend/internal/logic/projects" "senlinai-agent/backend/internal/models" ) const ( aiSessionCreateAction = "ai_session_create" aiSessionCreateLimit = 20 defaultSessionStatus = "ready" ) var ( ErrInvalidSession = errors.New("invalid ai session") ) type sessionGateway interface { CheckRateLimit(userID uint, action string, limit int, window time.Duration) error SelectKey(userID uint) (SelectedKey, error) RecordCall(userID uint, provider string, usedKeyType string, action string, status string, errText string) error } // SessionService 只管理项目内普通 AI 会话;它不会把上下文自动转换为任务、笔记或资料。 type SessionService struct { gateway sessionGateway } func NewSessionService(gateway sessionGateway) *SessionService { return &SessionService{gateway: gateway} } // List 在项目 owner 校验后返回该项目的会话,内部自增 ID 不离开服务边界。 func (s *SessionService) List(userID uint, projectIdentity string) ([]models.SenlinAgentAISession, error) { project, err := projects.FindOwnedProject(userID, projectIdentity) if err != nil { return nil, err } var sessions []models.SenlinAgentAISession if err := models.DBService.Where("project_id = ?", project.ID).Order("updated_at desc, id desc").Find(&sessions).Error; err != nil { return nil, err } if sessions == nil { sessions = []models.SenlinAgentAISession{} } return sessions, nil } // Create 先校验项目,再限流,最后才选择 provider/key;会话创建不会生成任何正式业务对象。 func (s *SessionService) Create(userID uint, projectIdentity, title, context string) (*models.SenlinAgentAISession, error) { title = strings.TrimSpace(title) context = strings.TrimSpace(context) if title == "" { return nil, ErrInvalidSession } project, err := projects.FindOwnedProject(userID, projectIdentity) if err != nil { return nil, err } if s.gateway == nil { return nil, errors.New("ai gateway is required") } if err := s.gateway.CheckRateLimit(userID, aiSessionCreateAction, aiSessionCreateLimit, time.Hour); err != nil { if errors.Is(err, ErrAIRateLimited) { if auditErr := s.gateway.RecordCall(userID, "none", "none", aiSessionCreateAction, "failed", "ai_rate_limited"); auditErr != nil { return nil, fmt.Errorf("record ai rate limit failure: %w", auditErr) } return nil, ErrAIRateLimited } if auditErr := s.gateway.RecordCall(userID, "none", "none", aiSessionCreateAction, "failed", "rate_limit_check_failed"); auditErr != nil { return nil, fmt.Errorf("check rate limit: %v; record failure: %w", err, auditErr) } return nil, err } selected, err := s.gateway.SelectKey(userID) if err != nil { code := "provider_selection_failed" if errors.Is(err, ErrAIKeyMissing) { code = "ai_key_missing" } if auditErr := s.gateway.RecordCall(userID, "none", "none", aiSessionCreateAction, "failed", code); auditErr != nil { return nil, fmt.Errorf("select ai key: %v; record failure: %w", err, auditErr) } return nil, err } session := models.SenlinAgentAISession{ ProjectID: project.ID, CreatedBy: userID, Title: title, Context: context, Status: defaultSessionStatus, } if err := models.DBService.Create(&session).Error; err != nil { if auditErr := s.gateway.RecordCall(userID, selected.Provider, selected.KeyType, aiSessionCreateAction, "failed", "session_create_failed"); auditErr != nil { return nil, fmt.Errorf("create ai session: %v; record failure: %w", err, auditErr) } return nil, err } // ready 只表示会话入口已建立,不表示 provider 已回复或任何业务对象已创建。 if err := s.gateway.RecordCall(userID, selected.Provider, selected.KeyType, aiSessionCreateAction, defaultSessionStatus, ""); err != nil { return nil, fmt.Errorf("record ai session creation: %w", err) } return &session, nil } func aiSessionStatus(session models.SenlinAgentAISession) string { if status := strings.TrimSpace(session.Status); status != "" { return status } return defaultSessionStatus }