package inbox import ( "errors" "strings" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" "senlinai-agent/backend/internal/models" ) var ( ErrProjectAndUserRequired = errors.New("project and user are required") ErrSourceTypeRequired = errors.New("source type is required") ErrInvalidSuggestion = errors.New("invalid inbox suggestion") ErrSuggestionSelectionRequired = errors.New("suggestion selection is required") ErrSuggestionNotFound = errors.New("selected suggestion was not saved for inbox item") ErrInboxAlreadyConfirmed = errors.New("inbox item already confirmed") ) type CaptureInput struct { ProjectIdentity string UserID uint SourceType string Title string Body string } // Suggestion 是分析接口返回的候选草稿;ID 由服务端保存后生成,确认接口不再接受 kind/title/body。 type Suggestion struct { ID string `json:"id"` Kind string `json:"kind"` Title string `json:"title"` Body string `json:"body"` } type ConfirmResult struct { CreatedCount int `json:"createdCount"` } type Analyzer interface { Analyze(item models.SaInboxItem, userID uint) ([]Suggestion, error) } type StaticAnalyzer struct { Suggestions []Suggestion } func (a StaticAnalyzer) Analyze(item models.SaInboxItem, userID uint) ([]Suggestion, error) { if len(a.Suggestions) > 0 { return a.Suggestions, nil } subject := suggestionSubject(item) body := strings.TrimSpace(item.Body) if body == "" { body = strings.TrimSpace(item.Title) } // 默认分析器只把用户主动收集的内容整理成可审阅草稿;它不调用 provider,也不创建正式对象。 return []Suggestion{ {Kind: "task", Title: "跟进:" + subject, Body: body}, {Kind: "note", Title: subject + "整理笔记", Body: body}, {Kind: "source", Title: subject + "背景资料", Body: body}, }, nil } type Service struct { analyzer Analyzer db *gorm.DB } func NewService(analyzer Analyzer, databases ...*gorm.DB) *Service { var database *gorm.DB if len(databases) > 0 { database = databases[0] } return &Service{analyzer: analyzer, db: database} } func (s *Service) database() *gorm.DB { if s.db != nil { return s.db } return models.DBService } // Capture 先以 owner + project identity 校验项目边界,再创建归属于当前用户的原始 Inbox 条目。 func (s *Service) Capture(input CaptureInput) (*models.SaInboxItem, error) { if strings.TrimSpace(input.ProjectIdentity) == "" || input.UserID == 0 { return nil, ErrProjectAndUserRequired } sourceType := strings.TrimSpace(input.SourceType) if sourceType == "" { return nil, ErrSourceTypeRequired } var item models.SaInboxItem err := s.database().Transaction(func(tx *gorm.DB) error { var project models.SaProject if err := tx.Where("owner_id = ? AND identity = ?", input.UserID, input.ProjectIdentity).First(&project).Error; err != nil { return err } item = models.SaInboxItem{ ProjectID: project.ID, ProjectIdentity: project.Identity, CreatedBy: input.UserID, SourceType: sourceType, Title: strings.TrimSpace(input.Title), Body: strings.TrimSpace(input.Body), Status: "open", } return tx.Create(&item).Error }) return &item, err } // Analyze 只保存候选草稿,不创建任何任务、笔记或资料;重新分析会原子替换该条目的旧草稿。 func (s *Service) Analyze(itemIdentity string, userID uint) ([]Suggestion, error) { item, err := findOwnedInboxItem(s.database(), itemIdentity, userID, false) if err != nil { return nil, err } if item.Status != "open" { return nil, ErrInboxAlreadyConfirmed } generated := []Suggestion{} if s.analyzer != nil { generated, err = s.analyzer.Analyze(*item, userID) if err != nil { return nil, err } } normalized, err := normalizeSuggestions(generated) if err != nil { return nil, err } saved := make([]Suggestion, 0, len(normalized)) err = s.database().Transaction(func(tx *gorm.DB) error { locked, err := findOwnedInboxItem(tx, itemIdentity, userID, true) if err != nil { return err } if locked.Status != "open" { return ErrInboxAlreadyConfirmed } if err := tx.Where("inbox_item_id = ?", locked.ID).Delete(&models.SaInboxSuggestion{}).Error; err != nil { return err } for _, suggestion := range normalized { draft := models.SaInboxSuggestion{ InboxItemID: locked.ID, InboxItemIdentity: locked.Identity, CreatedBy: userID, Kind: suggestion.Kind, Title: suggestion.Title, Body: suggestion.Body, } if err := tx.Create(&draft).Error; err != nil { return err } saved = append(saved, Suggestion{ID: draft.Identity, Kind: draft.Kind, Title: draft.Title, Body: draft.Body}) } return nil }) return saved, err } // Confirm 只按服务端保存的建议 identity 创建对象;全部写入和 Inbox 状态变更位于同一事务中。 func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionIdentities []string) (ConfirmResult, error) { result := ConfirmResult{} err := s.database().Transaction(func(tx *gorm.DB) error { item, err := findOwnedInboxItem(tx, itemIdentity, userID, true) if err != nil { return err } if item.Status != "open" { result.CreatedCount = item.ConfirmedCount return nil } selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities) if err != nil { return err } var saved []models.SaInboxSuggestion if err := tx.Where("inbox_item_id = ? AND created_by = ? AND identity IN ?", item.ID, userID, selected).Find(&saved).Error; err != nil { return err } if len(saved) != len(selected) { return ErrSuggestionNotFound } byIdentity := make(map[string]models.SaInboxSuggestion, len(saved)) for _, suggestion := range saved { byIdentity[suggestion.Identity] = suggestion } for _, identity := range selected { suggestion, ok := byIdentity[identity] if !ok { return ErrSuggestionNotFound } if err := createConfirmedObject(tx, *item, suggestion); err != nil { return err } result.CreatedCount++ } return tx.Model(item).Updates(map[string]any{ "status": "processed", "confirmed_count": result.CreatedCount, }).Error }) return result, err } func findOwnedInboxItem(tx *gorm.DB, identity string, userID uint, lock bool) (*models.SaInboxItem, error) { var item models.SaInboxItem query := tx.Model(&models.SaInboxItem{}). Joins("JOIN sa_projects ON sa_projects.id = sa_inbox_items.project_id"). Where("sa_inbox_items.identity = ? AND sa_inbox_items.created_by = ? AND sa_projects.owner_id = ?", identity, userID, userID) if lock { query = query.Clauses(clause.Locking{Strength: "UPDATE"}) } if err := query.First(&item).Error; err != nil { return nil, err } return &item, nil } func normalizeSuggestions(suggestions []Suggestion) ([]Suggestion, error) { result := make([]Suggestion, 0, len(suggestions)) for _, suggestion := range suggestions { kind := strings.ToLower(strings.TrimSpace(suggestion.Kind)) title := strings.TrimSpace(suggestion.Title) if title == "" || (kind != "task" && kind != "note" && kind != "source") { return nil, ErrInvalidSuggestion } result = append(result, Suggestion{Kind: kind, Title: title, Body: strings.TrimSpace(suggestion.Body)}) } return result, nil } func suggestionSubject(item models.SaInboxItem) string { value := strings.TrimSpace(item.Title) if value == "" { value = strings.TrimSpace(item.Body) } if value == "" { return "Inbox 内容" } runes := []rune(value) if len(runes) > 40 { return string(runes[:40]) + "…" } return value } func normalizeSelectedIdentities(values []string) ([]string, error) { if len(values) == 0 { return nil, ErrSuggestionSelectionRequired } seen := make(map[string]struct{}, len(values)) result := make([]string, 0, len(values)) for _, value := range values { identity, err := uuid.Parse(strings.TrimSpace(value)) if err != nil || identity.Version() != 7 || identity.Variant() != uuid.RFC4122 { return nil, ErrSuggestionNotFound } normalized := identity.String() if _, exists := seen[normalized]; exists { continue } seen[normalized] = struct{}{} result = append(result, normalized) } return result, nil } func createConfirmedObject(tx *gorm.DB, item models.SaInboxItem, suggestion models.SaInboxSuggestion) error { sourceInboxItemID := item.ID switch suggestion.Kind { case "task": return tx.Create(&models.SaTask{ ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body, Status: "open", }).Error case "note": return tx.Create(&models.SaNote{ ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body, }).Error case "source": // 文本型 Inbox 资料不是上传文件,不构造或伪造任何本地路径。 return tx.Create(&models.SaSource{ ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Kind: "text", Title: suggestion.Title, ContentText: suggestion.Body, }).Error default: return ErrInvalidSuggestion } }