From b7fb1969e61336b2b4fbb238de727f3268f1c527 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sat, 18 Jul 2026 16:03:06 +0800 Subject: [PATCH] feat: add files notes and search --- backend/internal/files/service.go | 45 ++++++++++++++++ backend/internal/files/service_test.go | 29 ++++++++++ backend/internal/notes/service.go | 26 +++++++++ backend/internal/search/service.go | 61 ++++++++++++++++++++++ backend/internal/search/service_test.go | 26 +++++++++ backend/migrations/0001_search_indexes.sql | 19 +++++++ 6 files changed, 206 insertions(+) create mode 100644 backend/internal/files/service.go create mode 100644 backend/internal/files/service_test.go create mode 100644 backend/internal/notes/service.go create mode 100644 backend/internal/search/service.go create mode 100644 backend/internal/search/service_test.go create mode 100644 backend/migrations/0001_search_indexes.sql diff --git a/backend/internal/files/service.go b/backend/internal/files/service.go new file mode 100644 index 0000000..9c98c25 --- /dev/null +++ b/backend/internal/files/service.go @@ -0,0 +1,45 @@ +package files + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +type Service struct { + root string +} + +type StoredFile struct { + OriginalName string + RelativePath string + AbsolutePath string +} + +func NewService(root string) *Service { + return &Service{root: root} +} + +func (s *Service) Save(projectID uint, originalName string, content io.Reader) (StoredFile, error) { + cleanName := filepath.Base(strings.ReplaceAll(strings.TrimSpace(originalName), "\\", "/")) + if cleanName == "." || cleanName == "" { + cleanName = "upload.bin" + } + relative := filepath.ToSlash(filepath.Join("projects", fmt.Sprint(projectID), fmt.Sprintf("%d-%s", time.Now().UnixNano(), cleanName))) + absolute := filepath.Join(s.root, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil { + return StoredFile{}, err + } + file, err := os.Create(absolute) + if err != nil { + return StoredFile{}, err + } + defer file.Close() + if _, err := io.Copy(file, content); err != nil { + return StoredFile{}, err + } + return StoredFile{OriginalName: cleanName, RelativePath: relative, AbsolutePath: absolute}, nil +} diff --git a/backend/internal/files/service_test.go b/backend/internal/files/service_test.go new file mode 100644 index 0000000..df05fa6 --- /dev/null +++ b/backend/internal/files/service_test.go @@ -0,0 +1,29 @@ +package files + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSaveStoresFileUnderProjectDirectory(t *testing.T) { + service := NewService(t.TempDir()) + + stored, err := service.Save(12, "brief.md", strings.NewReader("hello")) + + require.NoError(t, err) + require.Equal(t, "brief.md", stored.OriginalName) + require.Contains(t, stored.RelativePath, "projects/12/") + require.FileExists(t, stored.AbsolutePath) +} + +func TestSaveNeutralizesPathTraversal(t *testing.T) { + service := NewService(t.TempDir()) + + stored, err := service.Save(12, "..\\..\\secret.txt", strings.NewReader("hello")) + + require.NoError(t, err) + require.Equal(t, "secret.txt", stored.OriginalName) + require.Contains(t, stored.RelativePath, "projects/12/") +} diff --git a/backend/internal/notes/service.go b/backend/internal/notes/service.go new file mode 100644 index 0000000..4536153 --- /dev/null +++ b/backend/internal/notes/service.go @@ -0,0 +1,26 @@ +package notes + +import ( + "errors" + "strings" + + "gorm.io/gorm" + "senlinai-agent/backend/internal/domain" +) + +type Service struct { + db *gorm.DB +} + +func NewService(database *gorm.DB) *Service { + return &Service{db: database} +} + +func (s *Service) CreateNote(projectID uint, userID uint, title string, markdown string) (*domain.Note, error) { + title = strings.TrimSpace(title) + if title == "" { + return nil, errors.New("note title is required") + } + note := &domain.Note{ProjectID: projectID, CreatedBy: userID, Title: title, Markdown: markdown} + return note, s.db.Create(note).Error +} diff --git a/backend/internal/search/service.go b/backend/internal/search/service.go new file mode 100644 index 0000000..17c375a --- /dev/null +++ b/backend/internal/search/service.go @@ -0,0 +1,61 @@ +package search + +import ( + "strings" + + "gorm.io/gorm" + "senlinai-agent/backend/internal/domain" +) + +type Service struct { + db *gorm.DB +} + +type Result struct { + Type string `json:"type"` + ID uint `json:"id"` + ProjectID uint `json:"project_id"` + Title string `json:"title"` + Snippet string `json:"snippet"` +} + +func NewService(database *gorm.DB) *Service { + return &Service{db: database} +} + +func (s *Service) Search(userID uint, query string) ([]Result, error) { + query = strings.TrimSpace(query) + if query == "" { + return []Result{}, nil + } + if s.db.Dialector.Name() == "postgres" { + return s.searchPostgres(userID, query) + } + + var notes []domain.Note + if err := s.db.Joins("JOIN projects ON projects.id = notes.project_id"). + Where("projects.owner_id = ? AND (notes.title LIKE ? OR notes.markdown LIKE ?)", userID, "%"+query+"%", "%"+query+"%"). + Find(¬es).Error; err != nil { + return nil, err + } + results := make([]Result, 0, len(notes)) + for _, note := range notes { + results = append(results, Result{Type: "note", ID: note.ID, ProjectID: note.ProjectID, Title: note.Title, Snippet: note.Markdown}) + } + return results, nil +} + +func (s *Service) searchPostgres(userID uint, query string) ([]Result, error) { + var results []Result + err := s.db.Raw(` + SELECT 'note' AS type, notes.id, notes.project_id, notes.title, notes.markdown AS snippet + FROM notes + JOIN projects ON projects.id = notes.project_id + WHERE projects.owner_id = ? + AND to_tsvector('simple', coalesce(notes.title, '') || ' ' || coalesce(notes.markdown, '')) + @@ plainto_tsquery('simple', ?) + ORDER BY notes.updated_at DESC + LIMIT 50 + `, userID, query).Scan(&results).Error + return results, err +} diff --git a/backend/internal/search/service_test.go b/backend/internal/search/service_test.go new file mode 100644 index 0000000..6dfb967 --- /dev/null +++ b/backend/internal/search/service_test.go @@ -0,0 +1,26 @@ +package search + +import ( + "fmt" + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "senlinai-agent/backend/internal/domain" +) + +func TestSearchFindsNoteBody(t *testing.T) { + 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)) + require.NoError(t, database.Create(&domain.Project{ID: 1, OwnerID: 7, Name: "支付项目"}).Error) + require.NoError(t, database.Create(&domain.Note{ProjectID: 1, CreatedBy: 7, Title: "接口方案", Markdown: "二维码支付回调设计"}).Error) + + service := NewService(database) + results, err := service.Search(7, "回调") + + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, "note", results[0].Type) +} diff --git a/backend/migrations/0001_search_indexes.sql b/backend/migrations/0001_search_indexes.sql new file mode 100644 index 0000000..285c47f --- /dev/null +++ b/backend/migrations/0001_search_indexes.sql @@ -0,0 +1,19 @@ +CREATE INDEX IF NOT EXISTS idx_notes_search +ON notes +USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(markdown, ''))); + +CREATE INDEX IF NOT EXISTS idx_sources_search +ON sources +USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(url, '') || ' ' || coalesce(content_text, ''))); + +CREATE INDEX IF NOT EXISTS idx_inbox_items_search +ON inbox_items +USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(body, ''))); + +CREATE INDEX IF NOT EXISTS idx_tasks_search +ON tasks +USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(description, ''))); + +CREATE INDEX IF NOT EXISTS idx_ai_sessions_search +ON ai_sessions +USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(context, '')));