From e6386bef9133a900dbf4f12fdbcd02a48391fc1d Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sat, 18 Jul 2026 15:59:34 +0800 Subject: [PATCH] feat: add inbox capture workflow --- backend/internal/inbox/handlers.go | 79 +++++++++++++++++++ backend/internal/inbox/service.go | 100 +++++++++++++++++++++++++ backend/internal/inbox/service_test.go | 58 ++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 backend/internal/inbox/handlers.go create mode 100644 backend/internal/inbox/service.go create mode 100644 backend/internal/inbox/service_test.go diff --git a/backend/internal/inbox/handlers.go b/backend/internal/inbox/handlers.go new file mode 100644 index 0000000..1d866b5 --- /dev/null +++ b/backend/internal/inbox/handlers.go @@ -0,0 +1,79 @@ +package inbox + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) Register(router gin.IRouter) { + router.POST("/projects/:projectID/inbox", h.capture) + router.POST("/inbox/:id/analyze", h.analyze) + router.POST("/inbox/:id/confirm", h.confirm) +} + +func (h *Handler) capture(c *gin.Context) { + projectID, err := strconv.ParseUint(c.Param("projectID"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"}) + return + } + var input struct { + SourceType string `json:"source_type"` + Title string `json:"title"` + Body string `json:"body"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + item, err := h.service.Capture(CaptureInput{ProjectID: uint(projectID), UserID: 1, SourceType: input.SourceType, Title: input.Title, Body: input.Body}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, item) +} + +func (h *Handler) analyze(c *gin.Context) { + itemID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"}) + return + } + suggestions, err := h.service.Analyze(uint(itemID), 1) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"suggestions": suggestions}) +} + +func (h *Handler) confirm(c *gin.Context) { + itemID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"}) + return + } + var input struct { + Suggestions []Suggestion `json:"suggestions"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if err := h.service.Confirm(uint(itemID), input.Suggestions); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/inbox/service.go b/backend/internal/inbox/service.go new file mode 100644 index 0000000..da1059f --- /dev/null +++ b/backend/internal/inbox/service.go @@ -0,0 +1,100 @@ +package inbox + +import ( + "errors" + + "gorm.io/gorm" + "senlinai-agent/backend/internal/domain" +) + +type CaptureInput struct { + ProjectID uint + UserID uint + SourceType string + Title string + Body string +} + +type Suggestion struct { + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` +} + +type Analyzer interface { + Analyze(item domain.InboxItem, userID uint) ([]Suggestion, error) +} + +type StaticAnalyzer struct { + Suggestions []Suggestion +} + +func (a StaticAnalyzer) Analyze(item domain.InboxItem, userID uint) ([]Suggestion, error) { + return a.Suggestions, nil +} + +type Service struct { + db *gorm.DB + analyzer Analyzer +} + +func NewService(database *gorm.DB, analyzer Analyzer) *Service { + return &Service{db: database, analyzer: analyzer} +} + +func (s *Service) Capture(input CaptureInput) (*domain.InboxItem, error) { + if input.ProjectID == 0 || input.UserID == 0 { + return nil, errors.New("project and user are required") + } + if input.SourceType == "" { + return nil, errors.New("source type is required") + } + item := &domain.InboxItem{ + ProjectID: input.ProjectID, + CreatedBy: input.UserID, + SourceType: input.SourceType, + Title: input.Title, + Body: input.Body, + Status: "open", + } + return item, s.db.Create(item).Error +} + +func (s *Service) Analyze(itemID uint, userID uint) ([]Suggestion, error) { + var item domain.InboxItem + if err := s.db.First(&item, itemID).Error; err != nil { + return nil, err + } + if s.analyzer == nil { + return []Suggestion{}, nil + } + return s.analyzer.Analyze(item, userID) +} + +func (s *Service) Confirm(itemID uint, selected []Suggestion) error { + return s.db.Transaction(func(tx *gorm.DB) error { + var item domain.InboxItem + if err := tx.First(&item, itemID).Error; err != nil { + return err + } + for _, suggestion := range selected { + switch suggestion.Kind { + case "task": + if err := tx.Create(&domain.Task{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, Title: suggestion.Title, Description: suggestion.Body}).Error; err != nil { + return err + } + case "note": + if err := tx.Create(&domain.Note{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, Title: suggestion.Title, Markdown: suggestion.Body}).Error; err != nil { + return err + } + case "source": + if err := tx.Create(&domain.Source{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil { + return err + } + default: + return errors.New("unsupported suggestion kind") + } + } + return tx.Model(&item).Update("status", "processed").Error + }) +} diff --git a/backend/internal/inbox/service_test.go b/backend/internal/inbox/service_test.go new file mode 100644 index 0000000..8850a0c --- /dev/null +++ b/backend/internal/inbox/service_test.go @@ -0,0 +1,58 @@ +package inbox + +import ( + "fmt" + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "senlinai-agent/backend/internal/domain" +) + +func TestAnalyzeReturnsSuggestionsWithoutCreatingObjects(t *testing.T) { + database := newTestDB(t) + service := NewService(database, StaticAnalyzer{ + Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}}, + }) + item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"}) + require.NoError(t, err) + + suggestions, err := service.Analyze(item.ID, 1) + + require.NoError(t, err) + require.Len(t, suggestions, 1) + var count int64 + require.NoError(t, database.Model(&domain.Task{}).Count(&count).Error) + require.Equal(t, int64(0), count) +} + +func TestConfirmCreatesSelectedObjectsAndKeepsInboxItem(t *testing.T) { + database := newTestDB(t) + service := NewService(database, StaticAnalyzer{ + Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}}, + }) + item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"}) + require.NoError(t, err) + + suggestions, err := service.Analyze(item.ID, 1) + require.NoError(t, err) + err = service.Confirm(item.ID, suggestions) + + require.NoError(t, err) + var tasks []domain.Task + require.NoError(t, database.Find(&tasks).Error) + require.Len(t, tasks, 1) + require.Equal(t, "跟进报价", tasks[0].Title) + var reloaded domain.InboxItem + require.NoError(t, database.First(&reloaded, item.ID).Error) + require.Equal(t, "processed", reloaded.Status) +} + +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 +}