296 lines
9.3 KiB
Go
296 lines
9.3 KiB
Go
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: "document", Title: subject + "整理笔记.md", Body: body},
|
||
{Kind: "document", Title: subject + "背景资料.md", 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 != "document") {
|
||
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 "document":
|
||
name := strings.TrimSpace(suggestion.Title)
|
||
if !strings.HasSuffix(strings.ToLower(name), ".md") {
|
||
name += ".md"
|
||
}
|
||
document := models.SaDocumentTree{
|
||
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, OwnerID: item.CreatedBy,
|
||
SourceInboxItemID: &sourceInboxItemID, Kind: models.DocumentKindFile, Name: name,
|
||
NormalizedName: strings.ToLower(name), Extension: ".md", MimeType: "text/markdown; charset=utf-8",
|
||
Size: int64(len([]byte(suggestion.Body))), Revision: 1, ScanStatus: "not_scanned",
|
||
}
|
||
if err := tx.Create(&document).Error; err != nil {
|
||
return err
|
||
}
|
||
return tx.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: suggestion.Body}).Error
|
||
default:
|
||
return ErrInvalidSuggestion
|
||
}
|
||
}
|