feat: add files notes and search
This commit is contained in:
45
backend/internal/files/service.go
Normal file
45
backend/internal/files/service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
29
backend/internal/files/service_test.go
Normal file
29
backend/internal/files/service_test.go
Normal file
@@ -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/")
|
||||||
|
}
|
||||||
26
backend/internal/notes/service.go
Normal file
26
backend/internal/notes/service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
61
backend/internal/search/service.go
Normal file
61
backend/internal/search/service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
26
backend/internal/search/service_test.go
Normal file
26
backend/internal/search/service_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
19
backend/migrations/0001_search_indexes.sql
Normal file
19
backend/migrations/0001_search_indexes.sql
Normal file
@@ -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, '')));
|
||||||
Reference in New Issue
Block a user