diff --git a/AGENTS.md b/AGENTS.md index 07bae41..68ba584 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,9 @@ - 使用 Go、Gin、Gorm 和 PostgreSQL。 - 后端模块集中放在 `backend/internal` 下,并保持职责聚焦。 - 使用模块路径 `senlinai-agent/backend`。 +- 业务逻辑包统一放在 `backend/internal/logic` 下,例如 `ai`、`auth`、`files`、`inbox`、`notes`、`projects`、`search`、`tasks`。 +- Gorm 模型和数据库连接入口统一放在 `backend/internal/models`,不要恢复独立的 `domain` 或 `db` 包。 +- 模型结构体使用 `SenlinAgent` 前缀,数据库表名使用 `senlin_agent_` 前缀。 - 所有服务端本地文件路径构造必须集中在文件服务中。 - 不允许 HTTP handler 直接构造存储路径。 - 项目标签必须限定在项目内;MVP 阶段不要引入全局标签体系。 diff --git a/README.md b/README.md index 3f21f87..2726e79 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ backend/etc/agent.dev.yaml - `env`: 运行环境,例如 `development`。 - `port`: API 监听端口,默认开发值为 `8080`。 -- `database_url`: PostgreSQL 连接串。不要提交真实生产或测试凭据。 +- `dsn`: PostgreSQL 连接串。不要提交真实生产或测试凭据。 - `storage_dir`: 服务端本地文件存储目录。 - `auth_secret`: 邀请 token 和 session token 的 HMAC 密钥。 - `system_ai_key`: 可选的系统级 AI provider fallback key。 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 0707b17..e7349a6 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -3,30 +3,27 @@ package main import ( "log" - "senlinai-agent/backend/internal/auth" "senlinai-agent/backend/internal/config" - "senlinai-agent/backend/internal/db" - "senlinai-agent/backend/internal/domain" - "senlinai-agent/backend/internal/httpx" - "senlinai-agent/backend/internal/inbox" - "senlinai-agent/backend/internal/projects" + "senlinai-agent/backend/internal/logic/auth" + "senlinai-agent/backend/internal/logic/inbox" + "senlinai-agent/backend/internal/logic/projects" + "senlinai-agent/backend/internal/models" + "senlinai-agent/backend/internal/router" ) func main() { cfg := config.Load() - database, err := db.Open(cfg.DatabaseURL) + + err := models.New(cfg.DSN) if err != nil { log.Fatal(err) } - if err := domain.AutoMigrate(database); err != nil { - log.Fatal(err) - } - authService := auth.NewService(database, cfg.AuthSecret) - projectHandler := projects.NewHandler(projects.NewService(database)) - inboxHandler := inbox.NewHandler(inbox.NewService(database, inbox.StaticAnalyzer{})) - router := httpx.NewProtectedRouter(cfg, authService.VerifySession, projectHandler, inboxHandler) - if err := router.Run(":" + cfg.Port); err != nil { + authService := auth.NewService(cfg.AuthSecret) + projectHandler := projects.NewHandler(projects.NewService()) + inboxHandler := inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{})) + appRouter := router.NewProtectedRouter(cfg, authService.VerifySession, projectHandler, inboxHandler) + if err := appRouter.Run(":" + cfg.Port); err != nil { log.Fatal(err) } } diff --git a/backend/etc/agent.dev.yaml b/backend/etc/agent.dev.yaml index 5659213..5781b92 100644 --- a/backend/etc/agent.dev.yaml +++ b/backend/etc/agent.dev.yaml @@ -1,6 +1,6 @@ env: development port: "8080" -database_url: "postgres://agent:agent@localhost:5432/agent?sslmode=disable" +dsn: "postgres://postgres:Weidong2023~!@8.137.107.29:19432/agent_dev?sslmode=disable" storage_dir: "./data/files" auth_secret: "development-auth-secret-change-me" system_ai_key: "" diff --git a/backend/internal/ai/sessions.go b/backend/internal/ai/sessions.go deleted file mode 100644 index 2ea793a..0000000 --- a/backend/internal/ai/sessions.go +++ /dev/null @@ -1,26 +0,0 @@ -package ai - -import ( - "errors" - "strings" - - "gorm.io/gorm" - "senlinai-agent/backend/internal/domain" -) - -type SessionService struct { - db *gorm.DB -} - -func NewSessionService(database *gorm.DB) *SessionService { - return &SessionService{db: database} -} - -func (s *SessionService) Create(projectID uint, userID uint, title string) (*domain.AISession, error) { - title = strings.TrimSpace(title) - if title == "" { - return nil, errors.New("session title is required") - } - session := &domain.AISession{ProjectID: projectID, CreatedBy: userID, Title: title} - return session, s.db.Create(session).Error -} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9d1bd1b..d9d6f34 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -11,7 +11,7 @@ import ( type Config struct { Env string `yaml:"env"` Port string `yaml:"port"` - DatabaseURL string `yaml:"database_url"` + DSN string `yaml:"dsn"` StorageDir string `yaml:"storage_dir"` AuthSecret string `yaml:"auth_secret"` SystemAIKey string `yaml:"system_ai_key"` diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 3c8fb98..1e10237 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -19,7 +19,7 @@ func TestLoadFromDirDefaultsToDevYAML(t *testing.T) { require.NoError(t, err) require.Equal(t, "development", cfg.Env) require.Equal(t, "18080", cfg.Port) - require.Equal(t, "postgres://dev", cfg.DatabaseURL) + require.Equal(t, "postgres://dev", cfg.DSN) require.Equal(t, "./dev-files", cfg.StorageDir) require.Equal(t, "dev-auth", cfg.AuthSecret) require.Equal(t, "dev-system", cfg.SystemAIKey) @@ -37,7 +37,7 @@ func TestLoadFromDirUsesSENLINAppMode(t *testing.T) { require.NoError(t, err) require.Equal(t, "production", cfg.Env) require.Equal(t, "80", cfg.Port) - require.Equal(t, "postgres://prod", cfg.DatabaseURL) + require.Equal(t, "postgres://prod", cfg.DSN) require.Equal(t, "/data/files", cfg.StorageDir) require.Equal(t, "prod-auth", cfg.AuthSecret) require.Equal(t, "prod-system", cfg.SystemAIKey) @@ -48,7 +48,7 @@ func writeConfig(t *testing.T, dir string, name string, env string, port string, t.Helper() content := []byte("env: " + env + "\n" + "port: \"" + port + "\"\n" + - "database_url: \"" + databaseURL + "\"\n" + + "dsn: \"" + databaseURL + "\"\n" + "storage_dir: \"" + storageDir + "\"\n" + "auth_secret: \"" + authSecret + "\"\n" + "system_ai_key: \"" + systemAIKey + "\"\n" + diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go deleted file mode 100644 index b549de1..0000000 --- a/backend/internal/db/db.go +++ /dev/null @@ -1,10 +0,0 @@ -package db - -import ( - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -func Open(databaseURL string) (*gorm.DB, error) { - return gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) -} diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go deleted file mode 100644 index 717f167..0000000 --- a/backend/internal/domain/models.go +++ /dev/null @@ -1,141 +0,0 @@ -package domain - -import ( - "time" - - "gorm.io/gorm" -) - -type User struct { - ID uint `gorm:"primaryKey"` - Email string `gorm:"uniqueIndex;not null"` - DisplayName string `gorm:"not null"` - PasswordHash string `gorm:"not null"` - Role string `gorm:"not null;default:user"` - CreatedAt time.Time - UpdatedAt time.Time -} - -type Project struct { - ID uint `gorm:"primaryKey"` - OwnerID uint `gorm:"index;not null"` - Name string `gorm:"not null"` - Description string - CreatedAt time.Time - UpdatedAt time.Time -} - -type InboxItem struct { - ID uint `gorm:"primaryKey"` - ProjectID uint `gorm:"index;not null"` - CreatedBy uint `gorm:"index;not null"` - SourceType string `gorm:"not null"` - Title string - Body string - Status string `gorm:"not null;default:open"` - CreatedAt time.Time - UpdatedAt time.Time -} - -type Task struct { - ID uint `gorm:"primaryKey"` - ProjectID uint `gorm:"index;not null"` - CreatedBy uint `gorm:"index;not null"` - AssigneeID *uint `gorm:"index"` - SourceInboxItemID *uint `gorm:"index"` - Title string `gorm:"not null"` - Description string - Status string `gorm:"not null;default:open"` - SortOrder int `gorm:"not null;default:0"` - DueAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time -} - -type Note struct { - ID uint `gorm:"primaryKey"` - ProjectID uint `gorm:"index;not null"` - CreatedBy uint `gorm:"index;not null"` - SourceInboxItemID *uint `gorm:"index"` - Title string `gorm:"not null"` - Markdown string `gorm:"type:text"` - CreatedAt time.Time - UpdatedAt time.Time -} - -type Source struct { - ID uint `gorm:"primaryKey"` - ProjectID uint `gorm:"index;not null"` - CreatedBy uint `gorm:"index;not null"` - SourceInboxItemID *uint `gorm:"index"` - Kind string `gorm:"not null"` - Title string `gorm:"not null"` - URL string - FilePath string - ContentText string `gorm:"type:text"` - CreatedAt time.Time - UpdatedAt time.Time -} - -type AISession struct { - ID uint `gorm:"primaryKey"` - ProjectID uint `gorm:"index;not null"` - CreatedBy uint `gorm:"index;not null"` - Title string `gorm:"not null"` - Context string `gorm:"type:text"` - CreatedAt time.Time - UpdatedAt time.Time -} - -type Tag struct { - ID uint `gorm:"primaryKey"` - ProjectID uint `gorm:"index;not null"` - Name string `gorm:"not null"` - CreatedAt time.Time -} - -type ProjectEvent struct { - ID uint `gorm:"primaryKey"` - ProjectID uint `gorm:"index;not null"` - ActorID uint `gorm:"index;not null"` - EventType string `gorm:"not null"` - EntityType string `gorm:"not null"` - EntityID uint `gorm:"not null"` - Summary string `gorm:"not null"` - CreatedAt time.Time -} - -type AIKey struct { - ID uint `gorm:"primaryKey"` - UserID uint `gorm:"uniqueIndex;not null"` - Provider string `gorm:"not null"` - EncryptedAPIKey string `gorm:"not null"` - CreatedAt time.Time - UpdatedAt time.Time -} - -type AICallLog struct { - ID uint `gorm:"primaryKey"` - UserID uint `gorm:"index;not null"` - Provider string `gorm:"not null"` - UsedKeyType string `gorm:"not null"` - Action string `gorm:"not null"` - Status string `gorm:"not null"` - Error string - CreatedAt time.Time -} - -type TaskShare struct { - ID uint `gorm:"primaryKey"` - TaskID uint `gorm:"index;not null"` - ObjectType string `gorm:"not null"` - ObjectID uint `gorm:"not null"` - CreatedAt time.Time -} - -func AutoMigrate(database *gorm.DB) error { - return database.AutoMigrate( - &User{}, &Project{}, &InboxItem{}, &Task{}, &Note{}, &Source{}, - &AISession{}, &Tag{}, &ProjectEvent{}, &AIKey{}, &AICallLog{}, &TaskShare{}, - ) -} diff --git a/backend/internal/domain/models_test.go b/backend/internal/domain/models_test.go deleted file mode 100644 index 0099d2e..0000000 --- a/backend/internal/domain/models_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package domain - -import ( - "testing" - - "github.com/stretchr/testify/require" - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -func TestAutoMigrateCreatesCoreTables(t *testing.T) { - database, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) - require.NoError(t, err) - - require.NoError(t, AutoMigrate(database)) - - for _, table := range []string{ - "users", "projects", "inbox_items", "tasks", "notes", "sources", - "ai_sessions", "tags", "project_events", "ai_keys", "ai_call_logs", "task_shares", - } { - require.True(t, database.Migrator().HasTable(table), "missing table %s", table) - } -} diff --git a/backend/internal/ai/gateway.go b/backend/internal/logic/ai/gateway.go similarity index 81% rename from backend/internal/ai/gateway.go rename to backend/internal/logic/ai/gateway.go index 63f68da..f1fdd4d 100644 --- a/backend/internal/ai/gateway.go +++ b/backend/internal/logic/ai/gateway.go @@ -10,13 +10,11 @@ import ( "io" "time" - "gorm.io/gorm" "gorm.io/gorm/clause" - "senlinai-agent/backend/internal/domain" + "senlinai-agent/backend/internal/models" ) type Gateway struct { - db *gorm.DB systemKey string encryptionSecret string } @@ -27,12 +25,12 @@ type SelectedKey struct { KeyType string } -func NewGateway(database *gorm.DB, systemKey string) *Gateway { - return NewGatewayWithSecret(database, systemKey, "development-ai-key-secret-change-me") +func NewGateway(systemKey string) *Gateway { + return NewGatewayWithSecret(systemKey, "development-ai-key-secret-change-me") } -func NewGatewayWithSecret(database *gorm.DB, systemKey string, encryptionSecret string) *Gateway { - return &Gateway{db: database, systemKey: systemKey, encryptionSecret: encryptionSecret} +func NewGatewayWithSecret(systemKey string, encryptionSecret string) *Gateway { + return &Gateway{systemKey: systemKey, encryptionSecret: encryptionSecret} } func (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error { @@ -40,16 +38,16 @@ func (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error if err != nil { return err } - key := domain.AIKey{UserID: userID, Provider: provider, EncryptedAPIKey: encrypted} - return g.db.Clauses(clause.OnConflict{ + key := models.SenlinAgentAIKey{UserID: userID, Provider: provider, EncryptedAPIKey: encrypted} + return models.DBService.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "user_id"}}, DoUpdates: clause.AssignmentColumns([]string{"provider", "encrypted_api_key", "updated_at"}), }).Create(&key).Error } func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) { - var userKey domain.AIKey - if err := g.db.Where("user_id = ?", userID).First(&userKey).Error; err == nil { + var userKey models.SenlinAgentAIKey + if err := models.DBService.Where("user_id = ?", userID).First(&userKey).Error; err == nil { apiKey, err := decryptAPIKey(userKey.EncryptedAPIKey, g.encryptionSecret) if err != nil { return SelectedKey{}, err @@ -63,7 +61,7 @@ func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) { } func (g *Gateway) RecordCall(userID uint, provider string, usedKeyType string, action string, status string, errText string) error { - return g.db.Create(&domain.AICallLog{ + return models.DBService.Create(&models.SenlinAgentAICallLog{ UserID: userID, Provider: provider, UsedKeyType: usedKeyType, @@ -78,7 +76,7 @@ func (g *Gateway) CheckRateLimit(userID uint, action string, limit int, window t return nil } var count int64 - if err := g.db.Model(&domain.AICallLog{}). + if err := models.DBService.Model(&models.SenlinAgentAICallLog{}). Where("user_id = ? AND action = ? AND created_at >= ?", userID, action, time.Now().Add(-window)). Count(&count).Error; err != nil { return err diff --git a/backend/internal/ai/gateway_test.go b/backend/internal/logic/ai/gateway_test.go similarity index 77% rename from backend/internal/ai/gateway_test.go rename to backend/internal/logic/ai/gateway_test.go index 38776f3..e66f6fb 100644 --- a/backend/internal/ai/gateway_test.go +++ b/backend/internal/logic/ai/gateway_test.go @@ -8,13 +8,13 @@ import ( "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" - "senlinai-agent/backend/internal/domain" + "senlinai-agent/backend/internal/models" ) func TestSelectKeyPrefersUserKey(t *testing.T) { database := newTestDB(t) - require.NoError(t, database.Create(&domain.AIKey{UserID: 3, Provider: "openai", EncryptedAPIKey: "user-key"}).Error) - gateway := NewGateway(database, "system-key") + require.NoError(t, database.Create(&models.SenlinAgentAIKey{UserID: 3, Provider: "openai", EncryptedAPIKey: "user-key"}).Error) + gateway := NewGateway("system-key") selected, err := gateway.SelectKey(3) @@ -25,11 +25,11 @@ func TestSelectKeyPrefersUserKey(t *testing.T) { func TestSaveUserKeyEncryptsStoredKey(t *testing.T) { database := newTestDB(t) - gateway := NewGatewayWithSecret(database, "system-key", "test-encryption-secret") + gateway := NewGatewayWithSecret("system-key", "test-encryption-secret") require.NoError(t, gateway.SaveUserKey(3, "openai", "user-key")) - var stored domain.AIKey + var stored models.SenlinAgentAIKey require.NoError(t, database.Where("user_id = ?", 3).First(&stored).Error) require.NotEqual(t, "user-key", stored.EncryptedAPIKey) require.Contains(t, stored.EncryptedAPIKey, "v1:") @@ -39,8 +39,8 @@ func TestSaveUserKeyEncryptsStoredKey(t *testing.T) { } func TestSelectKeyFallsBackToSystemKey(t *testing.T) { - database := newTestDB(t) - gateway := NewGateway(database, "system-key") + newTestDB(t) + gateway := NewGateway("system-key") selected, err := gateway.SelectKey(3) @@ -50,8 +50,8 @@ func TestSelectKeyFallsBackToSystemKey(t *testing.T) { } func TestSelectKeyReturnsErrorWhenNoKeyAvailable(t *testing.T) { - database := newTestDB(t) - gateway := NewGateway(database, "") + newTestDB(t) + gateway := NewGateway("") _, err := gateway.SelectKey(3) @@ -60,11 +60,11 @@ func TestSelectKeyReturnsErrorWhenNoKeyAvailable(t *testing.T) { func TestRecordCallStoresAuditFields(t *testing.T) { database := newTestDB(t) - gateway := NewGateway(database, "system-key") + gateway := NewGateway("system-key") require.NoError(t, gateway.RecordCall(3, "openai", "system", "inbox_analyze", "failed", "rate limited")) - var log domain.AICallLog + var log models.SenlinAgentAICallLog require.NoError(t, database.First(&log).Error) require.Equal(t, uint(3), log.UserID) require.Equal(t, "openai", log.Provider) @@ -75,8 +75,8 @@ func TestRecordCallStoresAuditFields(t *testing.T) { } func TestCheckRateLimitRejectsCallsOverWindow(t *testing.T) { - database := newTestDB(t) - gateway := NewGateway(database, "system-key") + newTestDB(t) + gateway := NewGateway("system-key") require.NoError(t, gateway.RecordCall(3, "openai", "system", "inbox_analyze", "succeeded", "")) err := gateway.CheckRateLimit(3, "inbox_analyze", 1, time.Hour) @@ -85,8 +85,8 @@ func TestCheckRateLimitRejectsCallsOverWindow(t *testing.T) { } func TestCreateAISession(t *testing.T) { - database := newTestDB(t) - service := NewSessionService(database) + newTestDB(t) + service := NewSessionService() session, err := service.Create(7, 3, "报价分析") @@ -100,6 +100,7 @@ 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)) + require.NoError(t, models.AutoMigrate(database)) + models.DBService = database return database } diff --git a/backend/internal/logic/ai/sessions.go b/backend/internal/logic/ai/sessions.go new file mode 100644 index 0000000..f48c02c --- /dev/null +++ b/backend/internal/logic/ai/sessions.go @@ -0,0 +1,24 @@ +package ai + +import ( + "errors" + "strings" + + "senlinai-agent/backend/internal/models" +) + +type SessionService struct { +} + +func NewSessionService() *SessionService { + return &SessionService{} +} + +func (s *SessionService) Create(projectID uint, userID uint, title string) (*models.SenlinAgentAISession, error) { + title = strings.TrimSpace(title) + if title == "" { + return nil, errors.New("session title is required") + } + session := &models.SenlinAgentAISession{ProjectID: projectID, CreatedBy: userID, Title: title} + return session, models.DBService.Create(session).Error +} diff --git a/backend/internal/auth/middleware.go b/backend/internal/logic/auth/middleware.go similarity index 100% rename from backend/internal/auth/middleware.go rename to backend/internal/logic/auth/middleware.go diff --git a/backend/internal/auth/service.go b/backend/internal/logic/auth/service.go similarity index 87% rename from backend/internal/auth/service.go rename to backend/internal/logic/auth/service.go index 061baf1..eff5cb3 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/logic/auth/service.go @@ -12,19 +12,17 @@ import ( "time" "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" - "senlinai-agent/backend/internal/domain" + "senlinai-agent/backend/internal/models" ) type Service struct { - db *gorm.DB secret string inviteTokenTTL time.Duration sessionTTL time.Duration } -func NewService(database *gorm.DB, secret string) *Service { - return &Service{db: database, secret: secret, inviteTokenTTL: 7 * 24 * time.Hour, sessionTTL: 24 * time.Hour} +func NewService(secret string) *Service { + return &Service{secret: secret, inviteTokenTTL: 7 * 24 * time.Hour, sessionTTL: 24 * time.Hour} } func (s *Service) CreateInvite(adminID uint, email string) (string, error) { @@ -40,7 +38,7 @@ func (s *Service) CreateInvite(adminID uint, email string) (string, error) { }) } -func (s *Service) RegisterWithInvite(token, email, displayName, password string) (*domain.User, error) { +func (s *Service) RegisterWithInvite(token, email, displayName, password string) (*models.SenlinAgentUser, error) { payload, err := s.verifyToken(token, "invite") if err != nil { return nil, err @@ -52,18 +50,18 @@ func (s *Service) RegisterWithInvite(token, email, displayName, password string) if err != nil { return nil, err } - user := &domain.User{ + user := &models.SenlinAgentUser{ Email: payload.Subject, DisplayName: strings.TrimSpace(displayName), PasswordHash: string(hash), Role: "user", } - return user, s.db.Create(user).Error + return user, models.DBService.Create(user).Error } func (s *Service) Login(email, password string) (string, error) { - var user domain.User - if err := s.db.Where("email = ?", strings.ToLower(strings.TrimSpace(email))).First(&user).Error; err != nil { + var user models.SenlinAgentUser + if err := models.DBService.Where("email = ?", strings.ToLower(strings.TrimSpace(email))).First(&user).Error; err != nil { return "", errors.New("invalid credentials") } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { diff --git a/backend/internal/auth/service_test.go b/backend/internal/logic/auth/service_test.go similarity index 82% rename from backend/internal/auth/service_test.go rename to backend/internal/logic/auth/service_test.go index d99751e..7c0c0a7 100644 --- a/backend/internal/auth/service_test.go +++ b/backend/internal/logic/auth/service_test.go @@ -7,12 +7,12 @@ import ( "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" - "senlinai-agent/backend/internal/domain" + "senlinai-agent/backend/internal/models" ) func TestRegisterWithInviteCreatesUser(t *testing.T) { - database := newTestDB(t) - service := NewService(database, "test-secret") + newTestDB(t) + service := NewService("test-secret") token, err := service.CreateInvite(1, "lead@example.com") require.NoError(t, err) @@ -25,8 +25,8 @@ func TestRegisterWithInviteCreatesUser(t *testing.T) { } func TestRegisterWithInviteRejectsWrongEmail(t *testing.T) { - database := newTestDB(t) - service := NewService(database, "test-secret") + newTestDB(t) + service := NewService("test-secret") token, err := service.CreateInvite(1, "lead@example.com") require.NoError(t, err) @@ -36,8 +36,8 @@ func TestRegisterWithInviteRejectsWrongEmail(t *testing.T) { } func TestLoginRejectsInvalidPassword(t *testing.T) { - database := newTestDB(t) - service := NewService(database, "test-secret") + newTestDB(t) + service := NewService("test-secret") token, err := service.CreateInvite(1, "lead@example.com") require.NoError(t, err) _, err = service.RegisterWithInvite(token, "lead@example.com", "Lead", "password123") @@ -49,8 +49,8 @@ func TestLoginRejectsInvalidPassword(t *testing.T) { } func TestLoginReturnsSignedSessionToken(t *testing.T) { - database := newTestDB(t) - service := NewService(database, "test-secret") + newTestDB(t) + service := NewService("test-secret") token, err := service.CreateInvite(1, "lead@example.com") require.NoError(t, err) user, err := service.RegisterWithInvite(token, "lead@example.com", "Lead", "password123") @@ -66,8 +66,8 @@ func TestLoginReturnsSignedSessionToken(t *testing.T) { } func TestVerifySessionRejectsForgeableLegacyToken(t *testing.T) { - database := newTestDB(t) - service := NewService(database, "test-secret") + newTestDB(t) + service := NewService("test-secret") _, err := service.VerifySession("user:1") @@ -78,6 +78,7 @@ 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)) + require.NoError(t, models.AutoMigrate(database)) + models.DBService = database return database } diff --git a/backend/internal/files/service.go b/backend/internal/logic/files/service.go similarity index 100% rename from backend/internal/files/service.go rename to backend/internal/logic/files/service.go diff --git a/backend/internal/files/service_test.go b/backend/internal/logic/files/service_test.go similarity index 100% rename from backend/internal/files/service_test.go rename to backend/internal/logic/files/service_test.go diff --git a/backend/internal/inbox/handlers.go b/backend/internal/logic/inbox/handlers.go similarity index 98% rename from backend/internal/inbox/handlers.go rename to backend/internal/logic/inbox/handlers.go index d0d6ca5..7a433af 100644 --- a/backend/internal/inbox/handlers.go +++ b/backend/internal/logic/inbox/handlers.go @@ -5,7 +5,7 @@ import ( "strconv" "github.com/gin-gonic/gin" - "senlinai-agent/backend/internal/auth" + "senlinai-agent/backend/internal/logic/auth" ) type Handler struct { diff --git a/backend/internal/inbox/service.go b/backend/internal/logic/inbox/service.go similarity index 52% rename from backend/internal/inbox/service.go rename to backend/internal/logic/inbox/service.go index 636c543..2f838ef 100644 --- a/backend/internal/inbox/service.go +++ b/backend/internal/logic/inbox/service.go @@ -4,7 +4,7 @@ import ( "errors" "gorm.io/gorm" - "senlinai-agent/backend/internal/domain" + "senlinai-agent/backend/internal/models" ) type CaptureInput struct { @@ -22,34 +22,33 @@ type Suggestion struct { } type Analyzer interface { - Analyze(item domain.InboxItem, userID uint) ([]Suggestion, error) + Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error) } type StaticAnalyzer struct { Suggestions []Suggestion } -func (a StaticAnalyzer) Analyze(item domain.InboxItem, userID uint) ([]Suggestion, error) { +func (a StaticAnalyzer) Analyze(item models.SenlinAgentInboxItem, 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 NewService(analyzer Analyzer) *Service { + return &Service{analyzer: analyzer} } -func (s *Service) Capture(input CaptureInput) (*domain.InboxItem, error) { +func (s *Service) Capture(input CaptureInput) (*models.SenlinAgentInboxItem, 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{ + item := &models.SenlinAgentInboxItem{ ProjectID: input.ProjectID, CreatedBy: input.UserID, SourceType: input.SourceType, @@ -57,12 +56,12 @@ func (s *Service) Capture(input CaptureInput) (*domain.InboxItem, error) { Body: input.Body, Status: "open", } - return item, s.db.Create(item).Error + return item, models.DBService.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 { + var item models.SenlinAgentInboxItem + if err := models.DBService.First(&item, itemID).Error; err != nil { return nil, err } if s.analyzer == nil { @@ -72,8 +71,8 @@ func (s *Service) Analyze(itemID uint, userID uint) ([]Suggestion, error) { } func (s *Service) Confirm(itemID uint, selected []Suggestion) error { - return s.db.Transaction(func(tx *gorm.DB) error { - var item domain.InboxItem + return models.DBService.Transaction(func(tx *gorm.DB) error { + var item models.SenlinAgentInboxItem if err := tx.First(&item, itemID).Error; err != nil { return err } @@ -81,15 +80,15 @@ func (s *Service) Confirm(itemID uint, selected []Suggestion) error { for _, suggestion := range selected { switch suggestion.Kind { case "task": - if err := tx.Create(&domain.Task{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body}).Error; err != nil { + if err := tx.Create(&models.SenlinAgentTask{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, 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, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body}).Error; err != nil { + if err := tx.Create(&models.SenlinAgentNote{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, 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, SourceInboxItemID: &sourceInboxItemID, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil { + if err := tx.Create(&models.SenlinAgentSource{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil { return err } default: diff --git a/backend/internal/inbox/service_test.go b/backend/internal/logic/inbox/service_test.go similarity index 82% rename from backend/internal/inbox/service_test.go rename to backend/internal/logic/inbox/service_test.go index f7aefdf..4c1f238 100644 --- a/backend/internal/inbox/service_test.go +++ b/backend/internal/logic/inbox/service_test.go @@ -7,12 +7,12 @@ import ( "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" - "senlinai-agent/backend/internal/domain" + "senlinai-agent/backend/internal/models" ) func TestAnalyzeReturnsSuggestionsWithoutCreatingObjects(t *testing.T) { database := newTestDB(t) - service := NewService(database, StaticAnalyzer{ + service := NewService(StaticAnalyzer{ Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}}, }) item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"}) @@ -23,13 +23,13 @@ func TestAnalyzeReturnsSuggestionsWithoutCreatingObjects(t *testing.T) { require.NoError(t, err) require.Len(t, suggestions, 1) var count int64 - require.NoError(t, database.Model(&domain.Task{}).Count(&count).Error) + require.NoError(t, database.Model(&models.SenlinAgentTask{}).Count(&count).Error) require.Equal(t, int64(0), count) } func TestConfirmCreatesSelectedObjectsAndKeepsInboxItem(t *testing.T) { database := newTestDB(t) - service := NewService(database, StaticAnalyzer{ + service := NewService(StaticAnalyzer{ Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}}, }) item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"}) @@ -40,13 +40,13 @@ func TestConfirmCreatesSelectedObjectsAndKeepsInboxItem(t *testing.T) { err = service.Confirm(item.ID, suggestions) require.NoError(t, err) - var tasks []domain.Task + var tasks []models.SenlinAgentTask require.NoError(t, database.Find(&tasks).Error) require.Len(t, tasks, 1) require.Equal(t, "跟进报价", tasks[0].Title) require.NotNil(t, tasks[0].SourceInboxItemID) require.Equal(t, item.ID, *tasks[0].SourceInboxItemID) - var reloaded domain.InboxItem + var reloaded models.SenlinAgentInboxItem require.NoError(t, database.First(&reloaded, item.ID).Error) require.Equal(t, "processed", reloaded.Status) } @@ -55,6 +55,7 @@ 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)) + require.NoError(t, models.AutoMigrate(database)) + models.DBService = database return database } diff --git a/backend/internal/logic/notes/service.go b/backend/internal/logic/notes/service.go new file mode 100644 index 0000000..a557396 --- /dev/null +++ b/backend/internal/logic/notes/service.go @@ -0,0 +1,24 @@ +package notes + +import ( + "errors" + "strings" + + "senlinai-agent/backend/internal/models" +) + +type Service struct { +} + +func NewService() *Service { + return &Service{} +} + +func (s *Service) CreateNote(projectID uint, userID uint, title string, markdown string) (*models.SenlinAgentNote, error) { + title = strings.TrimSpace(title) + if title == "" { + return nil, errors.New("note title is required") + } + note := &models.SenlinAgentNote{ProjectID: projectID, CreatedBy: userID, Title: title, Markdown: markdown} + return note, models.DBService.Create(note).Error +} diff --git a/backend/internal/projects/handlers.go b/backend/internal/logic/projects/handlers.go similarity index 97% rename from backend/internal/projects/handlers.go rename to backend/internal/logic/projects/handlers.go index 8aa825b..b397e19 100644 --- a/backend/internal/projects/handlers.go +++ b/backend/internal/logic/projects/handlers.go @@ -5,7 +5,7 @@ import ( "strconv" "github.com/gin-gonic/gin" - "senlinai-agent/backend/internal/auth" + "senlinai-agent/backend/internal/logic/auth" ) type Handler struct { diff --git a/backend/internal/logic/projects/service.go b/backend/internal/logic/projects/service.go new file mode 100644 index 0000000..80ffc3f --- /dev/null +++ b/backend/internal/logic/projects/service.go @@ -0,0 +1,74 @@ +package projects + +import ( + "errors" + "strings" + + "senlinai-agent/backend/internal/models" +) + +type Service struct { +} + +type Dashboard struct { + ProjectID uint `json:"project_id"` + PendingInboxCount int64 `json:"pending_inbox_count"` + OpenTaskCount int64 `json:"open_task_count"` + RecentNoteCount int64 `json:"recent_note_count"` + RecentSessionCount int64 `json:"recent_session_count"` +} + +func NewService() *Service { + return &Service{} +} + +func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("project name is required") + } + project := &models.SenlinAgentProject{OwnerID: ownerID, Name: name, Description: description} + return project, models.DBService.Create(project).Error +} + +func (s *Service) ListProjects(ownerID uint) ([]models.SenlinAgentProject, error) { + var projects []models.SenlinAgentProject + err := models.DBService.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error + return projects, err +} + +func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("tag name is required") + } + tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name} + return tag, models.DBService.Create(tag).Error +} + +func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) { + var tags []models.SenlinAgentTag + err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error + return tags, err +} + +func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) { + var project models.SenlinAgentProject + if err := models.DBService.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; err != nil { + return Dashboard{}, err + } + dashboard := Dashboard{ProjectID: projectID} + if err := models.DBService.Model(&models.SenlinAgentInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&dashboard.PendingInboxCount).Error; err != nil { + return dashboard, err + } + if err := models.DBService.Model(&models.SenlinAgentTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&dashboard.OpenTaskCount).Error; err != nil { + return dashboard, err + } + if err := models.DBService.Model(&models.SenlinAgentNote{}).Where("project_id = ?", projectID).Count(&dashboard.RecentNoteCount).Error; err != nil { + return dashboard, err + } + if err := models.DBService.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", projectID).Count(&dashboard.RecentSessionCount).Error; err != nil { + return dashboard, err + } + return dashboard, nil +} diff --git a/backend/internal/projects/service_test.go b/backend/internal/logic/projects/service_test.go similarity index 66% rename from backend/internal/projects/service_test.go rename to backend/internal/logic/projects/service_test.go index d936523..a8a95b1 100644 --- a/backend/internal/projects/service_test.go +++ b/backend/internal/logic/projects/service_test.go @@ -7,12 +7,12 @@ import ( "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" - "senlinai-agent/backend/internal/domain" + "senlinai-agent/backend/internal/models" ) func TestProjectTagsAreScopedToProject(t *testing.T) { - database := newTestDB(t) - service := NewService(database) + newTestDB(t) + service := NewService() first, err := service.CreateProject(1, "Alpha", "") require.NoError(t, err) second, err := service.CreateProject(1, "Beta", "") @@ -31,15 +31,15 @@ func TestProjectTagsAreScopedToProject(t *testing.T) { func TestDashboardCountsOnlyRequestedProject(t *testing.T) { database := newTestDB(t) - service := NewService(database) + service := NewService() first, err := service.CreateProject(1, "Alpha", "") require.NoError(t, err) second, err := service.CreateProject(1, "Beta", "") require.NoError(t, err) - require.NoError(t, database.Create(&domain.InboxItem{ProjectID: first.ID, CreatedBy: 1, SourceType: "text", Status: "open"}).Error) - require.NoError(t, database.Create(&domain.InboxItem{ProjectID: second.ID, CreatedBy: 1, SourceType: "text", Status: "open"}).Error) - require.NoError(t, database.Create(&domain.Task{ProjectID: first.ID, CreatedBy: 1, Title: "A", Status: "open"}).Error) - require.NoError(t, database.Create(&domain.Task{ProjectID: second.ID, CreatedBy: 1, Title: "B", Status: "open"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentInboxItem{ProjectID: first.ID, CreatedBy: 1, SourceType: "text", Status: "open"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentInboxItem{ProjectID: second.ID, CreatedBy: 1, SourceType: "text", Status: "open"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: first.ID, CreatedBy: 1, Title: "A", Status: "open"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: second.ID, CreatedBy: 1, Title: "B", Status: "open"}).Error) dashboard, err := service.Dashboard(1, first.ID) @@ -49,8 +49,8 @@ func TestDashboardCountsOnlyRequestedProject(t *testing.T) { } func TestDashboardRejectsProjectOwnedByAnotherUser(t *testing.T) { - database := newTestDB(t) - service := NewService(database) + newTestDB(t) + service := NewService() project, err := service.CreateProject(2, "Beta", "") require.NoError(t, err) @@ -63,6 +63,7 @@ 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)) + require.NoError(t, models.AutoMigrate(database)) + models.DBService = database return database } diff --git a/backend/internal/logic/search/service.go b/backend/internal/logic/search/service.go new file mode 100644 index 0000000..3dba1fc --- /dev/null +++ b/backend/internal/logic/search/service.go @@ -0,0 +1,135 @@ +package search + +import ( + "strings" + + "senlinai-agent/backend/internal/models" +) + +type Service struct { +} + +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() *Service { + return &Service{} +} + +func (s *Service) Search(userID uint, query string) ([]Result, error) { + query = strings.TrimSpace(query) + if query == "" { + return []Result{}, nil + } + if models.DBService.Dialector.Name() == "postgres" { + return s.searchPostgres(userID, query) + } + + like := "%" + query + "%" + results := []Result{} + var projects []models.SenlinAgentProject + if err := models.DBService.Where("owner_id = ? AND (name LIKE ? OR description LIKE ?)", userID, like, like).Find(&projects).Error; err != nil { + return nil, err + } + for _, project := range projects { + results = append(results, Result{Type: "project", ID: project.ID, ProjectID: project.ID, Title: project.Name, Snippet: project.Description}) + } + + var tasks []models.SenlinAgentTask + if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_tasks.project_id"). + Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_tasks.title LIKE ? OR senlin_agent_tasks.description LIKE ?)", userID, like, like). + Find(&tasks).Error; err != nil { + return nil, err + } + for _, task := range tasks { + results = append(results, Result{Type: "task", ID: task.ID, ProjectID: task.ProjectID, Title: task.Title, Snippet: task.Description}) + } + + var notes []models.SenlinAgentNote + if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_notes.project_id"). + Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_notes.title LIKE ? OR senlin_agent_notes.markdown LIKE ?)", userID, like, like). + Find(¬es).Error; err != nil { + return nil, err + } + for _, note := range notes { + results = append(results, Result{Type: "note", ID: note.ID, ProjectID: note.ProjectID, Title: note.Title, Snippet: note.Markdown}) + } + + var sources []models.SenlinAgentSource + if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_sources.project_id"). + Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_sources.title LIKE ? OR senlin_agent_sources.url LIKE ? OR senlin_agent_sources.content_text LIKE ?)", userID, like, like, like). + Find(&sources).Error; err != nil { + return nil, err + } + for _, source := range sources { + results = append(results, Result{Type: "source", ID: source.ID, ProjectID: source.ProjectID, Title: source.Title, Snippet: source.ContentText}) + } + + var inboxItems []models.SenlinAgentInboxItem + if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_inbox_items.project_id"). + Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_inbox_items.title LIKE ? OR senlin_agent_inbox_items.body LIKE ?)", userID, like, like). + Find(&inboxItems).Error; err != nil { + return nil, err + } + for _, item := range inboxItems { + results = append(results, Result{Type: "inbox", ID: item.ID, ProjectID: item.ProjectID, Title: item.Title, Snippet: item.Body}) + } + + var sessions []models.SenlinAgentAISession + if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_ai_sessions.project_id"). + Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_ai_sessions.title LIKE ? OR senlin_agent_ai_sessions.context LIKE ?)", userID, like, like). + Find(&sessions).Error; err != nil { + return nil, err + } + for _, session := range sessions { + results = append(results, Result{Type: "ai_session", ID: session.ID, ProjectID: session.ProjectID, Title: session.Title, Snippet: session.Context}) + } + return results, nil +} + +func (s *Service) searchPostgres(userID uint, query string) ([]Result, error) { + var results []Result + err := models.DBService.Raw(` + SELECT 'project' AS type, p.id, p.id AS project_id, p.name AS title, p.description AS snippet + FROM senlin_agent_projects p + WHERE p.owner_id = ? + AND to_tsvector('simple', coalesce(p.name, '') || ' ' || coalesce(p.description, '')) @@ plainto_tsquery('simple', ?) + UNION ALL + SELECT 'task' AS type, t.id, t.project_id, t.title, t.description AS snippet + FROM senlin_agent_tasks t + JOIN senlin_agent_projects p ON p.id = t.project_id + WHERE p.owner_id = ? + AND to_tsvector('simple', coalesce(t.title, '') || ' ' || coalesce(t.description, '')) @@ plainto_tsquery('simple', ?) + UNION ALL + SELECT 'note' AS type, n.id, n.project_id, n.title, n.markdown AS snippet + FROM senlin_agent_notes n + JOIN senlin_agent_projects p ON p.id = n.project_id + WHERE p.owner_id = ? + AND to_tsvector('simple', coalesce(n.title, '') || ' ' || coalesce(n.markdown, '')) @@ plainto_tsquery('simple', ?) + UNION ALL + SELECT 'source' AS type, s.id, s.project_id, s.title, s.content_text AS snippet + FROM senlin_agent_sources s + JOIN senlin_agent_projects p ON p.id = s.project_id + WHERE p.owner_id = ? + AND to_tsvector('simple', coalesce(s.title, '') || ' ' || coalesce(s.url, '') || ' ' || coalesce(s.content_text, '')) @@ plainto_tsquery('simple', ?) + UNION ALL + SELECT 'inbox' AS type, i.id, i.project_id, i.title, i.body AS snippet + FROM senlin_agent_inbox_items i + JOIN senlin_agent_projects p ON p.id = i.project_id + WHERE p.owner_id = ? + AND to_tsvector('simple', coalesce(i.title, '') || ' ' || coalesce(i.body, '')) @@ plainto_tsquery('simple', ?) + UNION ALL + SELECT 'ai_session' AS type, a.id, a.project_id, a.title, a.context AS snippet + FROM senlin_agent_ai_sessions a + JOIN senlin_agent_projects p ON p.id = a.project_id + WHERE p.owner_id = ? + AND to_tsvector('simple', coalesce(a.title, '') || ' ' || coalesce(a.context, '')) @@ plainto_tsquery('simple', ?) + LIMIT 50 + `, userID, query, userID, query, userID, query, userID, query, userID, query, userID, query).Scan(&results).Error + return results, err +} diff --git a/backend/internal/logic/search/service_test.go b/backend/internal/logic/search/service_test.go new file mode 100644 index 0000000..a6c5855 --- /dev/null +++ b/backend/internal/logic/search/service_test.go @@ -0,0 +1,52 @@ +package search + +import ( + "fmt" + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "senlinai-agent/backend/internal/models" +) + +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, models.AutoMigrate(database)) + models.DBService = database + require.NoError(t, database.Create(&models.SenlinAgentProject{ID: 1, OwnerID: 7, Name: "支付项目"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentNote{ProjectID: 1, CreatedBy: 7, Title: "接口方案", Markdown: "二维码支付回调设计"}).Error) + + service := NewService() + results, err := service.Search(7, "回调") + + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, "note", results[0].Type) +} + +func TestSearchFindsCoreProjectObjects(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, models.AutoMigrate(database)) + models.DBService = database + require.NoError(t, database.Create(&models.SenlinAgentProject{ID: 1, OwnerID: 7, Name: "支付项目"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: 1, CreatedBy: 7, Title: "回调任务", Description: "检查 webhook"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentSource{ProjectID: 1, CreatedBy: 7, Kind: "link", Title: "支付文档", URL: "https://example.com/pay", ContentText: "webhook 签名"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentInboxItem{ProjectID: 1, CreatedBy: 7, SourceType: "text", Title: "收集项", Body: "webhook 待整理"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentAISession{ProjectID: 1, CreatedBy: 7, Title: "AI 分析", Context: "webhook 问答"}).Error) + + service := NewService() + results, err := service.Search(7, "webhook") + + require.NoError(t, err) + types := make(map[string]bool) + for _, result := range results { + types[result.Type] = true + } + require.True(t, types["task"]) + require.True(t, types["source"]) + require.True(t, types["inbox"]) + require.True(t, types["ai_session"]) +} diff --git a/backend/internal/tasks/service.go b/backend/internal/logic/tasks/service.go similarity index 67% rename from backend/internal/tasks/service.go rename to backend/internal/logic/tasks/service.go index 9a9fc98..90c045d 100644 --- a/backend/internal/tasks/service.go +++ b/backend/internal/logic/tasks/service.go @@ -5,11 +5,10 @@ import ( "fmt" "gorm.io/gorm" - "senlinai-agent/backend/internal/domain" + "senlinai-agent/backend/internal/models" ) type Service struct { - db *gorm.DB } type LinkedObject struct { @@ -17,20 +16,20 @@ type LinkedObject struct { ObjectID uint `json:"object_id"` } -func NewService(database *gorm.DB) *Service { - return &Service{db: database} +func NewService() *Service { + return &Service{} } func (s *Service) Assign(taskID uint, assigneeID uint) error { - return s.db.Transaction(func(tx *gorm.DB) error { - var task domain.Task + return models.DBService.Transaction(func(tx *gorm.DB) error { + var task models.SenlinAgentTask if err := tx.First(&task, taskID).Error; err != nil { return err } if err := tx.Model(&task).Update("assignee_id", assigneeID).Error; err != nil { return err } - return tx.Create(&domain.ProjectEvent{ + return tx.Create(&models.SenlinAgentProjectEvent{ ProjectID: task.ProjectID, ActorID: task.CreatedBy, EventType: "task_assigned", @@ -45,18 +44,18 @@ func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) err if objectType != "note" && objectType != "source" { return errors.New("unsupported shared object type") } - return s.db.Transaction(func(tx *gorm.DB) error { - var task domain.Task + return models.DBService.Transaction(func(tx *gorm.DB) error { + var task models.SenlinAgentTask if err := tx.First(&task, taskID).Error; err != nil { return err } if err := ensureSharedObjectInProject(tx, task.ProjectID, objectType, objectID); err != nil { return err } - if err := tx.Create(&domain.TaskShare{TaskID: taskID, ObjectType: objectType, ObjectID: objectID}).Error; err != nil { + if err := tx.Create(&models.SenlinAgentTaskShare{TaskID: taskID, ObjectType: objectType, ObjectID: objectID}).Error; err != nil { return err } - return tx.Create(&domain.ProjectEvent{ + return tx.Create(&models.SenlinAgentProjectEvent{ ProjectID: task.ProjectID, ActorID: task.CreatedBy, EventType: "task_object_shared", @@ -68,15 +67,15 @@ func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) err } func (s *Service) VisibleLinkedObjects(taskID uint, viewerID uint) ([]LinkedObject, error) { - var task domain.Task - if err := s.db.First(&task, taskID).Error; err != nil { + var task models.SenlinAgentTask + if err := models.DBService.First(&task, taskID).Error; err != nil { return nil, err } if task.AssigneeID == nil || *task.AssigneeID != viewerID { return []LinkedObject{}, nil } - var shares []domain.TaskShare - if err := s.db.Where("task_id = ?", taskID).Find(&shares).Error; err != nil { + var shares []models.SenlinAgentTaskShare + if err := models.DBService.Where("task_id = ?", taskID).Find(&shares).Error; err != nil { return nil, err } objects := make([]LinkedObject, 0, len(shares)) @@ -90,7 +89,7 @@ func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectType string, switch objectType { case "note": var count int64 - if err := tx.Model(&domain.Note{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil { + if err := tx.Model(&models.SenlinAgentNote{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil { return err } if count == 0 { @@ -98,7 +97,7 @@ func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectType string, } case "source": var count int64 - if err := tx.Model(&domain.Source{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil { + if err := tx.Model(&models.SenlinAgentSource{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil { return err } if count == 0 { diff --git a/backend/internal/tasks/service_test.go b/backend/internal/logic/tasks/service_test.go similarity index 70% rename from backend/internal/tasks/service_test.go rename to backend/internal/logic/tasks/service_test.go index ac5b79e..74de8c7 100644 --- a/backend/internal/tasks/service_test.go +++ b/backend/internal/logic/tasks/service_test.go @@ -7,17 +7,17 @@ import ( "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" - "senlinai-agent/backend/internal/domain" + "senlinai-agent/backend/internal/models" ) func TestAssigneeOnlySeesExplicitlySharedObjects(t *testing.T) { database := newTestDB(t) assigneeID := uint(2) - task := domain.Task{ProjectID: 1, CreatedBy: 1, AssigneeID: &assigneeID, Title: "处理合同"} - note := domain.Note{ProjectID: 1, CreatedBy: 1, Title: "合同背景", Markdown: "只在共享后可见"} + task := models.SenlinAgentTask{ProjectID: 1, CreatedBy: 1, AssigneeID: &assigneeID, Title: "处理合同"} + note := models.SenlinAgentNote{ProjectID: 1, CreatedBy: 1, Title: "合同背景", Markdown: "只在共享后可见"} require.NoError(t, database.Create(&task).Error) require.NoError(t, database.Create(¬e).Error) - service := NewService(database) + service := NewService() before, err := service.VisibleLinkedObjects(task.ID, assigneeID) require.NoError(t, err) @@ -32,8 +32,8 @@ func TestAssigneeOnlySeesExplicitlySharedObjects(t *testing.T) { } func TestShareObjectRejectsUnsupportedType(t *testing.T) { - database := newTestDB(t) - service := NewService(database) + newTestDB(t) + service := NewService() err := service.ShareObject(1, "ai_session", 9) @@ -42,11 +42,11 @@ func TestShareObjectRejectsUnsupportedType(t *testing.T) { func TestShareObjectRejectsObjectFromAnotherProject(t *testing.T) { database := newTestDB(t) - task := domain.Task{ProjectID: 1, CreatedBy: 1, Title: "Review"} - note := domain.Note{ProjectID: 2, CreatedBy: 1, Title: "Other project", Markdown: "Private context"} + task := models.SenlinAgentTask{ProjectID: 1, CreatedBy: 1, Title: "Review"} + note := models.SenlinAgentNote{ProjectID: 2, CreatedBy: 1, Title: "Other project", Markdown: "Private context"} require.NoError(t, database.Create(&task).Error) require.NoError(t, database.Create(¬e).Error) - service := NewService(database) + service := NewService() err := service.ShareObject(task.ID, "note", note.ID) @@ -55,13 +55,13 @@ func TestShareObjectRejectsObjectFromAnotherProject(t *testing.T) { func TestAssignRecordsProjectEvent(t *testing.T) { database := newTestDB(t) - task := domain.Task{ProjectID: 7, CreatedBy: 1, Title: "安排评审"} + task := models.SenlinAgentTask{ProjectID: 7, CreatedBy: 1, Title: "安排评审"} require.NoError(t, database.Create(&task).Error) - service := NewService(database) + service := NewService() require.NoError(t, service.Assign(task.ID, 2)) - var event domain.ProjectEvent + var event models.SenlinAgentProjectEvent require.NoError(t, database.Where("project_id = ? AND entity_type = ? AND entity_id = ?", 7, "task", task.ID).First(&event).Error) require.Equal(t, "task_assigned", event.EventType) } @@ -70,6 +70,7 @@ 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)) + require.NoError(t, models.AutoMigrate(database)) + models.DBService = database return database } diff --git a/backend/internal/models/ai_call_log.go b/backend/internal/models/ai_call_log.go new file mode 100644 index 0000000..019b082 --- /dev/null +++ b/backend/internal/models/ai_call_log.go @@ -0,0 +1,18 @@ +package models + +import "time" + +type SenlinAgentAICallLog struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"index;not null"` + Provider string `gorm:"not null"` + UsedKeyType string `gorm:"not null"` + Action string `gorm:"not null"` + Status string `gorm:"not null"` + Error string + CreatedAt time.Time +} + +func (SenlinAgentAICallLog) TableName() string { + return "senlin_agent_ai_call_logs" +} diff --git a/backend/internal/models/ai_key.go b/backend/internal/models/ai_key.go new file mode 100644 index 0000000..980efb1 --- /dev/null +++ b/backend/internal/models/ai_key.go @@ -0,0 +1,16 @@ +package models + +import "time" + +type SenlinAgentAIKey struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"uniqueIndex;not null"` + Provider string `gorm:"not null"` + EncryptedAPIKey string `gorm:"not null"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SenlinAgentAIKey) TableName() string { + return "senlin_agent_ai_keys" +} diff --git a/backend/internal/models/ai_session.go b/backend/internal/models/ai_session.go new file mode 100644 index 0000000..9e55906 --- /dev/null +++ b/backend/internal/models/ai_session.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type SenlinAgentAISession struct { + ID uint `gorm:"primaryKey"` + ProjectID uint `gorm:"index;not null"` + CreatedBy uint `gorm:"index;not null"` + Title string `gorm:"not null"` + Context string `gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SenlinAgentAISession) TableName() string { + return "senlin_agent_ai_sessions" +} diff --git a/backend/internal/models/inbox_item.go b/backend/internal/models/inbox_item.go new file mode 100644 index 0000000..cc3c9f3 --- /dev/null +++ b/backend/internal/models/inbox_item.go @@ -0,0 +1,19 @@ +package models + +import "time" + +type SenlinAgentInboxItem struct { + ID uint `gorm:"primaryKey"` + ProjectID uint `gorm:"index;not null"` + CreatedBy uint `gorm:"index;not null"` + SourceType string `gorm:"not null"` + Title string + Body string + Status string `gorm:"not null;default:open"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SenlinAgentInboxItem) TableName() string { + return "senlin_agent_inbox_items" +} diff --git a/backend/internal/models/new.go b/backend/internal/models/new.go new file mode 100644 index 0000000..0e90f72 --- /dev/null +++ b/backend/internal/models/new.go @@ -0,0 +1,39 @@ +package models + +import ( + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +var DBService *gorm.DB + +func New(dsn string) error { + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + return err + } + + if err := AutoMigrate(db); err != nil { + return err + } + + DBService = db + return nil +} + +func AutoMigrate(database *gorm.DB) error { + return database.AutoMigrate( + &SenlinAgentUser{}, + &SenlinAgentProject{}, + &SenlinAgentInboxItem{}, + &SenlinAgentTask{}, + &SenlinAgentNote{}, + &SenlinAgentSource{}, + &SenlinAgentAISession{}, + &SenlinAgentTag{}, + &SenlinAgentProjectEvent{}, + &SenlinAgentAIKey{}, + &SenlinAgentAICallLog{}, + &SenlinAgentTaskShare{}, + ) +} diff --git a/backend/internal/models/note.go b/backend/internal/models/note.go new file mode 100644 index 0000000..1cba2d8 --- /dev/null +++ b/backend/internal/models/note.go @@ -0,0 +1,18 @@ +package models + +import "time" + +type SenlinAgentNote struct { + ID uint `gorm:"primaryKey"` + ProjectID uint `gorm:"index;not null"` + CreatedBy uint `gorm:"index;not null"` + SourceInboxItemID *uint `gorm:"index"` + Title string `gorm:"not null"` + Markdown string `gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SenlinAgentNote) TableName() string { + return "senlin_agent_notes" +} diff --git a/backend/internal/db/postgres_integration_test.go b/backend/internal/models/postgres_integration_test.go similarity index 52% rename from backend/internal/db/postgres_integration_test.go rename to backend/internal/models/postgres_integration_test.go index 63f0429..7a6a517 100644 --- a/backend/internal/db/postgres_integration_test.go +++ b/backend/internal/models/postgres_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package db +package models import ( "os" @@ -10,10 +10,13 @@ import ( ) func TestPostgresPing(t *testing.T) { - databaseURL := os.Getenv("DATABASE_URL") - require.NotEmpty(t, databaseURL, "DATABASE_URL is required for integration tests") + dsn := os.Getenv("DATABASE_DSN") + if dsn == "" { + dsn = os.Getenv("DATABASE_URL") + } + require.NotEmpty(t, dsn, "DATABASE_DSN or DATABASE_URL is required for integration tests") - database, err := Open(databaseURL) + database, err := Open(dsn) require.NoError(t, err) sqlDB, err := database.DB() require.NoError(t, err) diff --git a/backend/internal/models/project.go b/backend/internal/models/project.go new file mode 100644 index 0000000..c73ae48 --- /dev/null +++ b/backend/internal/models/project.go @@ -0,0 +1,16 @@ +package models + +import "time" + +type SenlinAgentProject struct { + ID uint `gorm:"primaryKey"` + OwnerID uint `gorm:"index;not null"` + Name string `gorm:"not null"` + Description string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SenlinAgentProject) TableName() string { + return "senlin_agent_projects" +} diff --git a/backend/internal/models/project_event.go b/backend/internal/models/project_event.go new file mode 100644 index 0000000..30b222f --- /dev/null +++ b/backend/internal/models/project_event.go @@ -0,0 +1,18 @@ +package models + +import "time" + +type SenlinAgentProjectEvent struct { + ID uint `gorm:"primaryKey"` + ProjectID uint `gorm:"index;not null"` + ActorID uint `gorm:"index;not null"` + EventType string `gorm:"not null"` + EntityType string `gorm:"not null"` + EntityID uint `gorm:"not null"` + Summary string `gorm:"not null"` + CreatedAt time.Time +} + +func (SenlinAgentProjectEvent) TableName() string { + return "senlin_agent_project_events" +} diff --git a/backend/internal/models/source.go b/backend/internal/models/source.go new file mode 100644 index 0000000..67cd8ae --- /dev/null +++ b/backend/internal/models/source.go @@ -0,0 +1,21 @@ +package models + +import "time" + +type SenlinAgentSource struct { + ID uint `gorm:"primaryKey"` + ProjectID uint `gorm:"index;not null"` + CreatedBy uint `gorm:"index;not null"` + SourceInboxItemID *uint `gorm:"index"` + Kind string `gorm:"not null"` + Title string `gorm:"not null"` + URL string + FilePath string + ContentText string `gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SenlinAgentSource) TableName() string { + return "senlin_agent_sources" +} diff --git a/backend/internal/models/tag.go b/backend/internal/models/tag.go new file mode 100644 index 0000000..4d36ef2 --- /dev/null +++ b/backend/internal/models/tag.go @@ -0,0 +1,14 @@ +package models + +import "time" + +type SenlinAgentTag struct { + ID uint `gorm:"primaryKey"` + ProjectID uint `gorm:"index;not null"` + Name string `gorm:"not null"` + CreatedAt time.Time +} + +func (SenlinAgentTag) TableName() string { + return "senlin_agent_tags" +} diff --git a/backend/internal/models/task.go b/backend/internal/models/task.go new file mode 100644 index 0000000..573e880 --- /dev/null +++ b/backend/internal/models/task.go @@ -0,0 +1,22 @@ +package models + +import "time" + +type SenlinAgentTask struct { + ID uint `gorm:"primaryKey"` + ProjectID uint `gorm:"index;not null"` + CreatedBy uint `gorm:"index;not null"` + AssigneeID *uint `gorm:"index"` + SourceInboxItemID *uint `gorm:"index"` + Title string `gorm:"not null"` + Description string + Status string `gorm:"not null;default:open"` + SortOrder int `gorm:"not null;default:0"` + DueAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SenlinAgentTask) TableName() string { + return "senlin_agent_tasks" +} diff --git a/backend/internal/models/task_share.go b/backend/internal/models/task_share.go new file mode 100644 index 0000000..a8754d1 --- /dev/null +++ b/backend/internal/models/task_share.go @@ -0,0 +1,15 @@ +package models + +import "time" + +type SenlinAgentTaskShare struct { + ID uint `gorm:"primaryKey"` + TaskID uint `gorm:"index;not null"` + ObjectType string `gorm:"not null"` + ObjectID uint `gorm:"not null"` + CreatedAt time.Time +} + +func (SenlinAgentTaskShare) TableName() string { + return "senlin_agent_task_shares" +} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go new file mode 100644 index 0000000..acb6089 --- /dev/null +++ b/backend/internal/models/user.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type SenlinAgentUser struct { + ID uint `gorm:"primaryKey"` + Email string `gorm:"uniqueIndex;not null"` + DisplayName string `gorm:"not null"` + PasswordHash string `gorm:"not null"` + Role string `gorm:"not null;default:user"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SenlinAgentUser) TableName() string { + return "senlin_agent_users" +} diff --git a/backend/internal/notes/service.go b/backend/internal/notes/service.go deleted file mode 100644 index 4536153..0000000 --- a/backend/internal/notes/service.go +++ /dev/null @@ -1,26 +0,0 @@ -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/projects/service.go b/backend/internal/projects/service.go deleted file mode 100644 index f453189..0000000 --- a/backend/internal/projects/service.go +++ /dev/null @@ -1,76 +0,0 @@ -package projects - -import ( - "errors" - "strings" - - "gorm.io/gorm" - "senlinai-agent/backend/internal/domain" -) - -type Service struct { - db *gorm.DB -} - -type Dashboard struct { - ProjectID uint `json:"project_id"` - PendingInboxCount int64 `json:"pending_inbox_count"` - OpenTaskCount int64 `json:"open_task_count"` - RecentNoteCount int64 `json:"recent_note_count"` - RecentSessionCount int64 `json:"recent_session_count"` -} - -func NewService(database *gorm.DB) *Service { - return &Service{db: database} -} - -func (s *Service) CreateProject(ownerID uint, name string, description string) (*domain.Project, error) { - name = strings.TrimSpace(name) - if name == "" { - return nil, errors.New("project name is required") - } - project := &domain.Project{OwnerID: ownerID, Name: name, Description: description} - return project, s.db.Create(project).Error -} - -func (s *Service) ListProjects(ownerID uint) ([]domain.Project, error) { - var projects []domain.Project - err := s.db.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error - return projects, err -} - -func (s *Service) CreateTag(projectID uint, name string) (*domain.Tag, error) { - name = strings.TrimSpace(name) - if name == "" { - return nil, errors.New("tag name is required") - } - tag := &domain.Tag{ProjectID: projectID, Name: name} - return tag, s.db.Create(tag).Error -} - -func (s *Service) ListTags(projectID uint) ([]domain.Tag, error) { - var tags []domain.Tag - err := s.db.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error - return tags, err -} - -func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) { - var project domain.Project - if err := s.db.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; err != nil { - return Dashboard{}, err - } - dashboard := Dashboard{ProjectID: projectID} - if err := s.db.Model(&domain.InboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&dashboard.PendingInboxCount).Error; err != nil { - return dashboard, err - } - if err := s.db.Model(&domain.Task{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&dashboard.OpenTaskCount).Error; err != nil { - return dashboard, err - } - if err := s.db.Model(&domain.Note{}).Where("project_id = ?", projectID).Count(&dashboard.RecentNoteCount).Error; err != nil { - return dashboard, err - } - if err := s.db.Model(&domain.AISession{}).Where("project_id = ?", projectID).Count(&dashboard.RecentSessionCount).Error; err != nil { - return dashboard, err - } - return dashboard, nil -} diff --git a/backend/internal/httpx/router.go b/backend/internal/router/router.go similarity index 94% rename from backend/internal/httpx/router.go rename to backend/internal/router/router.go index 0f62848..1f07676 100644 --- a/backend/internal/httpx/router.go +++ b/backend/internal/router/router.go @@ -1,11 +1,11 @@ -package httpx +package router import ( "net/http" "github.com/gin-gonic/gin" - "senlinai-agent/backend/internal/auth" "senlinai-agent/backend/internal/config" + "senlinai-agent/backend/internal/logic/auth" ) type RouteRegistrar interface { diff --git a/backend/internal/httpx/router_test.go b/backend/internal/router/router_test.go similarity index 98% rename from backend/internal/httpx/router_test.go rename to backend/internal/router/router_test.go index 4d7c2e7..2206f3a 100644 --- a/backend/internal/httpx/router_test.go +++ b/backend/internal/router/router_test.go @@ -1,4 +1,4 @@ -package httpx +package router import ( "net/http" diff --git a/backend/internal/search/service.go b/backend/internal/search/service.go deleted file mode 100644 index 2b8c880..0000000 --- a/backend/internal/search/service.go +++ /dev/null @@ -1,137 +0,0 @@ -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) - } - - like := "%" + query + "%" - results := []Result{} - var projects []domain.Project - if err := s.db.Where("owner_id = ? AND (name LIKE ? OR description LIKE ?)", userID, like, like).Find(&projects).Error; err != nil { - return nil, err - } - for _, project := range projects { - results = append(results, Result{Type: "project", ID: project.ID, ProjectID: project.ID, Title: project.Name, Snippet: project.Description}) - } - - var tasks []domain.Task - if err := s.db.Joins("JOIN projects ON projects.id = tasks.project_id"). - Where("projects.owner_id = ? AND (tasks.title LIKE ? OR tasks.description LIKE ?)", userID, like, like). - Find(&tasks).Error; err != nil { - return nil, err - } - for _, task := range tasks { - results = append(results, Result{Type: "task", ID: task.ID, ProjectID: task.ProjectID, Title: task.Title, Snippet: task.Description}) - } - - 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, like, like). - Find(¬es).Error; err != nil { - return nil, err - } - for _, note := range notes { - results = append(results, Result{Type: "note", ID: note.ID, ProjectID: note.ProjectID, Title: note.Title, Snippet: note.Markdown}) - } - - var sources []domain.Source - if err := s.db.Joins("JOIN projects ON projects.id = sources.project_id"). - Where("projects.owner_id = ? AND (sources.title LIKE ? OR sources.url LIKE ? OR sources.content_text LIKE ?)", userID, like, like, like). - Find(&sources).Error; err != nil { - return nil, err - } - for _, source := range sources { - results = append(results, Result{Type: "source", ID: source.ID, ProjectID: source.ProjectID, Title: source.Title, Snippet: source.ContentText}) - } - - var inboxItems []domain.InboxItem - if err := s.db.Joins("JOIN projects ON projects.id = inbox_items.project_id"). - Where("projects.owner_id = ? AND (inbox_items.title LIKE ? OR inbox_items.body LIKE ?)", userID, like, like). - Find(&inboxItems).Error; err != nil { - return nil, err - } - for _, item := range inboxItems { - results = append(results, Result{Type: "inbox", ID: item.ID, ProjectID: item.ProjectID, Title: item.Title, Snippet: item.Body}) - } - - var sessions []domain.AISession - if err := s.db.Joins("JOIN projects ON projects.id = ai_sessions.project_id"). - Where("projects.owner_id = ? AND (ai_sessions.title LIKE ? OR ai_sessions.context LIKE ?)", userID, like, like). - Find(&sessions).Error; err != nil { - return nil, err - } - for _, session := range sessions { - results = append(results, Result{Type: "ai_session", ID: session.ID, ProjectID: session.ProjectID, Title: session.Title, Snippet: session.Context}) - } - return results, nil -} - -func (s *Service) searchPostgres(userID uint, query string) ([]Result, error) { - var results []Result - err := s.db.Raw(` - SELECT 'project' AS type, projects.id, projects.id AS project_id, projects.name AS title, projects.description AS snippet - FROM projects - WHERE projects.owner_id = ? - AND to_tsvector('simple', coalesce(projects.name, '') || ' ' || coalesce(projects.description, '')) @@ plainto_tsquery('simple', ?) - UNION ALL - SELECT 'task' AS type, tasks.id, tasks.project_id, tasks.title, tasks.description AS snippet - FROM tasks - JOIN projects ON projects.id = tasks.project_id - WHERE projects.owner_id = ? - AND to_tsvector('simple', coalesce(tasks.title, '') || ' ' || coalesce(tasks.description, '')) @@ plainto_tsquery('simple', ?) - UNION ALL - 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', ?) - UNION ALL - SELECT 'source' AS type, sources.id, sources.project_id, sources.title, sources.content_text AS snippet - FROM sources - JOIN projects ON projects.id = sources.project_id - WHERE projects.owner_id = ? - AND to_tsvector('simple', coalesce(sources.title, '') || ' ' || coalesce(sources.url, '') || ' ' || coalesce(sources.content_text, '')) @@ plainto_tsquery('simple', ?) - UNION ALL - SELECT 'inbox' AS type, inbox_items.id, inbox_items.project_id, inbox_items.title, inbox_items.body AS snippet - FROM inbox_items - JOIN projects ON projects.id = inbox_items.project_id - WHERE projects.owner_id = ? - AND to_tsvector('simple', coalesce(inbox_items.title, '') || ' ' || coalesce(inbox_items.body, '')) @@ plainto_tsquery('simple', ?) - UNION ALL - SELECT 'ai_session' AS type, ai_sessions.id, ai_sessions.project_id, ai_sessions.title, ai_sessions.context AS snippet - FROM ai_sessions - JOIN projects ON projects.id = ai_sessions.project_id - WHERE projects.owner_id = ? - AND to_tsvector('simple', coalesce(ai_sessions.title, '') || ' ' || coalesce(ai_sessions.context, '')) @@ plainto_tsquery('simple', ?) - LIMIT 50 - `, userID, query, userID, query, userID, query, userID, query, userID, query, userID, query).Scan(&results).Error - return results, err -} diff --git a/backend/internal/search/service_test.go b/backend/internal/search/service_test.go deleted file mode 100644 index 864499f..0000000 --- a/backend/internal/search/service_test.go +++ /dev/null @@ -1,50 +0,0 @@ -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) -} - -func TestSearchFindsCoreProjectObjects(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.Task{ProjectID: 1, CreatedBy: 7, Title: "回调任务", Description: "检查 webhook"}).Error) - require.NoError(t, database.Create(&domain.Source{ProjectID: 1, CreatedBy: 7, Kind: "link", Title: "支付文档", URL: "https://example.com/pay", ContentText: "webhook 签名"}).Error) - require.NoError(t, database.Create(&domain.InboxItem{ProjectID: 1, CreatedBy: 7, SourceType: "text", Title: "收集项", Body: "webhook 待整理"}).Error) - require.NoError(t, database.Create(&domain.AISession{ProjectID: 1, CreatedBy: 7, Title: "AI 分析", Context: "webhook 问答"}).Error) - - service := NewService(database) - results, err := service.Search(7, "webhook") - - require.NoError(t, err) - types := make(map[string]bool) - for _, result := range results { - types[result.Type] = true - } - require.True(t, types["task"]) - require.True(t, types["source"]) - require.True(t, types["inbox"]) - require.True(t, types["ai_session"]) -} diff --git a/backend/migrations/0001_search_indexes.sql b/backend/migrations/0001_search_indexes.sql index 67f2e12..c299f10 100644 --- a/backend/migrations/0001_search_indexes.sql +++ b/backend/migrations/0001_search_indexes.sql @@ -1,23 +1,23 @@ -CREATE INDEX IF NOT EXISTS idx_projects_search -ON projects +CREATE INDEX IF NOT EXISTS idx_senlin_agent_projects_search +ON senlin_agent_projects USING gin (to_tsvector('simple', coalesce(name, '') || ' ' || coalesce(description, ''))); -CREATE INDEX IF NOT EXISTS idx_notes_search -ON notes +CREATE INDEX IF NOT EXISTS idx_senlin_agent_notes_search +ON senlin_agent_notes USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(markdown, ''))); -CREATE INDEX IF NOT EXISTS idx_sources_search -ON sources +CREATE INDEX IF NOT EXISTS idx_senlin_agent_sources_search +ON senlin_agent_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 +CREATE INDEX IF NOT EXISTS idx_senlin_agent_inbox_items_search +ON senlin_agent_inbox_items USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(body, ''))); -CREATE INDEX IF NOT EXISTS idx_tasks_search -ON tasks +CREATE INDEX IF NOT EXISTS idx_senlin_agent_tasks_search +ON senlin_agent_tasks USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(description, ''))); -CREATE INDEX IF NOT EXISTS idx_ai_sessions_search -ON ai_sessions +CREATE INDEX IF NOT EXISTS idx_senlin_agent_ai_sessions_search +ON senlin_agent_ai_sessions USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(context, ''))); diff --git a/docs/mvp-verification.md b/docs/mvp-verification.md index 5dcc6e2..f7792b9 100644 --- a/docs/mvp-verification.md +++ b/docs/mvp-verification.md @@ -19,7 +19,7 @@ Run: ```powershell Set-Location backend -go test -tags integration ./internal/db -run TestPostgresPing -v +go test -tags integration ./internal/models -run TestPostgresPing -v ``` Expected: `TestPostgresPing` passes.