refactor backend packages and global db service
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
24
backend/internal/logic/ai/sessions.go
Normal file
24
backend/internal/logic/ai/sessions.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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:
|
||||
@@ -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
|
||||
}
|
||||
24
backend/internal/logic/notes/service.go
Normal file
24
backend/internal/logic/notes/service.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
74
backend/internal/logic/projects/service.go
Normal file
74
backend/internal/logic/projects/service.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
135
backend/internal/logic/search/service.go
Normal file
135
backend/internal/logic/search/service.go
Normal file
@@ -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
|
||||
}
|
||||
52
backend/internal/logic/search/service_test.go
Normal file
52
backend/internal/logic/search/service_test.go
Normal file
@@ -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"])
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
18
backend/internal/models/ai_call_log.go
Normal file
18
backend/internal/models/ai_call_log.go
Normal file
@@ -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"
|
||||
}
|
||||
16
backend/internal/models/ai_key.go
Normal file
16
backend/internal/models/ai_key.go
Normal file
@@ -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"
|
||||
}
|
||||
17
backend/internal/models/ai_session.go
Normal file
17
backend/internal/models/ai_session.go
Normal file
@@ -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"
|
||||
}
|
||||
19
backend/internal/models/inbox_item.go
Normal file
19
backend/internal/models/inbox_item.go
Normal file
@@ -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"
|
||||
}
|
||||
39
backend/internal/models/new.go
Normal file
39
backend/internal/models/new.go
Normal file
@@ -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{},
|
||||
)
|
||||
}
|
||||
18
backend/internal/models/note.go
Normal file
18
backend/internal/models/note.go
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
16
backend/internal/models/project.go
Normal file
16
backend/internal/models/project.go
Normal file
@@ -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"
|
||||
}
|
||||
18
backend/internal/models/project_event.go
Normal file
18
backend/internal/models/project_event.go
Normal file
@@ -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"
|
||||
}
|
||||
21
backend/internal/models/source.go
Normal file
21
backend/internal/models/source.go
Normal file
@@ -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"
|
||||
}
|
||||
14
backend/internal/models/tag.go
Normal file
14
backend/internal/models/tag.go
Normal file
@@ -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"
|
||||
}
|
||||
22
backend/internal/models/task.go
Normal file
22
backend/internal/models/task.go
Normal file
@@ -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"
|
||||
}
|
||||
15
backend/internal/models/task_share.go
Normal file
15
backend/internal/models/task_share.go
Normal file
@@ -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"
|
||||
}
|
||||
17
backend/internal/models/user.go
Normal file
17
backend/internal/models/user.go
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -1,4 +1,4 @@
|
||||
package httpx
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
Reference in New Issue
Block a user