feat: add inbox capture workflow

This commit is contained in:
2026-07-18 15:59:34 +08:00
parent 18b2f30024
commit e6386bef91
3 changed files with 237 additions and 0 deletions

View File

@@ -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)
}

View File

@@ -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
})
}

View File

@@ -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
}