Files
agent/backend/internal/logic/inbox/service.go

292 lines
9.4 KiB
Go
Raw Normal View History

2026-07-18 15:59:34 +08:00
package inbox
import (
"errors"
"strings"
2026-07-18 15:59:34 +08:00
"github.com/google/uuid"
2026-07-18 15:59:34 +08:00
"gorm.io/gorm"
"gorm.io/gorm/clause"
"senlinai-agent/backend/internal/models"
2026-07-18 15:59:34 +08:00
)
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")
)
2026-07-18 15:59:34 +08:00
type CaptureInput struct {
ProjectIdentity string
UserID uint
SourceType string
Title string
Body string
2026-07-18 15:59:34 +08:00
}
// Suggestion 是分析接口返回的候选草稿ID 由服务端保存后生成,确认接口不再接受 kind/title/body。
2026-07-18 15:59:34 +08:00
type Suggestion struct {
ID string `json:"id"`
2026-07-18 15:59:34 +08:00
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
}
type ConfirmResult struct {
CreatedCount int `json:"createdCount"`
}
2026-07-18 15:59:34 +08:00
type Analyzer interface {
Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error)
2026-07-18 15:59:34 +08:00
}
type StaticAnalyzer struct {
Suggestions []Suggestion
}
func (a StaticAnalyzer) Analyze(item models.SenlinAgentInboxItem, 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
2026-07-18 15:59:34 +08:00
}
type Service struct {
analyzer Analyzer
db *gorm.DB
2026-07-18 15:59:34 +08:00
}
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}
2026-07-18 15:59:34 +08:00
}
func (s *Service) database() *gorm.DB {
if s.db != nil {
return s.db
2026-07-18 15:59:34 +08:00
}
return models.DBService
}
// Capture 先以 owner + project identity 校验项目边界,再创建归属于当前用户的原始 Inbox 条目。
func (s *Service) Capture(input CaptureInput) (*models.SenlinAgentInboxItem, error) {
if strings.TrimSpace(input.ProjectIdentity) == "" || input.UserID == 0 {
return nil, ErrProjectAndUserRequired
2026-07-18 15:59:34 +08:00
}
sourceType := strings.TrimSpace(input.SourceType)
if sourceType == "" {
return nil, ErrSourceTypeRequired
2026-07-18 15:59:34 +08:00
}
var item models.SenlinAgentInboxItem
err := s.database().Transaction(func(tx *gorm.DB) error {
var project models.SenlinAgentProject
if err := tx.Where("owner_id = ? AND identity = ?", input.UserID, input.ProjectIdentity).First(&project).Error; err != nil {
return err
}
item = models.SenlinAgentInboxItem{
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
2026-07-18 15:59:34 +08:00
}
// Analyze 只保存候选草稿,不创建任何任务、笔记或资料;重新分析会原子替换该条目的旧草稿。
func (s *Service) Analyze(itemIdentity string, userID uint) ([]Suggestion, error) {
item, err := findOwnedInboxItem(s.database(), itemIdentity, userID, false)
if err != nil {
2026-07-18 15:59:34 +08:00
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
2026-07-18 15:59:34 +08:00
}
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.SenlinAgentInboxSuggestion{}).Error; err != nil {
return err
}
for _, suggestion := range normalized {
draft := models.SenlinAgentInboxSuggestion{
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
2026-07-18 15:59:34 +08:00
}
// 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 {
2026-07-18 15:59:34 +08:00
return err
}
if item.Status != "open" {
result.CreatedCount = item.ConfirmedCount
return nil
}
selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities)
if err != nil {
return err
}
var saved []models.SenlinAgentInboxSuggestion
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.SenlinAgentInboxSuggestion, len(saved))
for _, suggestion := range saved {
byIdentity[suggestion.Identity] = suggestion
}
for _, identity := range selected {
suggestion, ok := byIdentity[identity]
if !ok {
return ErrSuggestionNotFound
2026-07-18 15:59:34 +08:00
}
if err := createConfirmedObject(tx, *item, suggestion); err != nil {
return err
}
result.CreatedCount++
2026-07-18 15:59:34 +08:00
}
return tx.Model(item).Updates(map[string]any{
"status": "processed",
"confirmed_count": result.CreatedCount,
}).Error
2026-07-18 15:59:34 +08:00
})
return result, err
}
func findOwnedInboxItem(tx *gorm.DB, identity string, userID uint, lock bool) (*models.SenlinAgentInboxItem, error) {
var item models.SenlinAgentInboxItem
query := tx.Model(&models.SenlinAgentInboxItem{}).
Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_inbox_items.project_id").
Where("senlin_agent_inbox_items.identity = ? AND senlin_agent_inbox_items.created_by = ? AND senlin_agent_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.SenlinAgentInboxItem) 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.SenlinAgentInboxItem, suggestion models.SenlinAgentInboxSuggestion) error {
sourceInboxItemID := item.ID
switch suggestion.Kind {
case "task":
return tx.Create(&models.SenlinAgentTask{
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.SenlinAgentNote{
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.SenlinAgentSource{
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy,
SourceInboxItemID: &sourceInboxItemID, Kind: "text", Title: suggestion.Title, ContentText: suggestion.Body,
}).Error
default:
return ErrInvalidSuggestion
}
2026-07-18 15:59:34 +08:00
}