refactor backend packages and global db service
This commit is contained in:
141
backend/internal/logic/ai/gateway.go
Normal file
141
backend/internal/logic/ai/gateway.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type Gateway struct {
|
||||
systemKey string
|
||||
encryptionSecret string
|
||||
}
|
||||
|
||||
type SelectedKey struct {
|
||||
Provider string
|
||||
APIKey string
|
||||
KeyType string
|
||||
}
|
||||
|
||||
func NewGateway(systemKey string) *Gateway {
|
||||
return NewGatewayWithSecret(systemKey, "development-ai-key-secret-change-me")
|
||||
}
|
||||
|
||||
func NewGatewayWithSecret(systemKey string, encryptionSecret string) *Gateway {
|
||||
return &Gateway{systemKey: systemKey, encryptionSecret: encryptionSecret}
|
||||
}
|
||||
|
||||
func (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error {
|
||||
encrypted, err := encryptAPIKey(apiKey, g.encryptionSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 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
|
||||
}
|
||||
return SelectedKey{Provider: userKey.Provider, APIKey: apiKey, KeyType: "user"}, nil
|
||||
}
|
||||
if g.systemKey == "" {
|
||||
return SelectedKey{}, errors.New("no ai key available")
|
||||
}
|
||||
return SelectedKey{Provider: "openai", APIKey: g.systemKey, KeyType: "system"}, nil
|
||||
}
|
||||
|
||||
func (g *Gateway) RecordCall(userID uint, provider string, usedKeyType string, action string, status string, errText string) error {
|
||||
return models.DBService.Create(&models.SenlinAgentAICallLog{
|
||||
UserID: userID,
|
||||
Provider: provider,
|
||||
UsedKeyType: usedKeyType,
|
||||
Action: action,
|
||||
Status: status,
|
||||
Error: errText,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (g *Gateway) CheckRateLimit(userID uint, action string, limit int, window time.Duration) error {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
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
|
||||
}
|
||||
if count >= int64(limit) {
|
||||
return errors.New("ai rate limit exceeded")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encryptAPIKey(apiKey string, secret string) (string, error) {
|
||||
if apiKey == "" {
|
||||
return "", errors.New("api key is required")
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey(secret))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := gcm.Seal(nil, nonce, []byte(apiKey), nil)
|
||||
return "v1:" + base64.RawURLEncoding.EncodeToString(append(nonce, ciphertext...)), nil
|
||||
}
|
||||
|
||||
func decryptAPIKey(encrypted string, secret string) (string, error) {
|
||||
if len(encrypted) < 3 || encrypted[:3] != "v1:" {
|
||||
return encrypted, nil
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(encrypted[3:])
|
||||
if err != nil {
|
||||
return "", errors.New("invalid encrypted api key")
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey(secret))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", errors.New("invalid encrypted api key")
|
||||
}
|
||||
nonce := raw[:gcm.NonceSize()]
|
||||
ciphertext := raw[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid encrypted api key")
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func encryptionKey(secret string) []byte {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return sum[:]
|
||||
}
|
||||
106
backend/internal/logic/ai/gateway_test.go
Normal file
106
backend/internal/logic/ai/gateway_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestSelectKeyPrefersUserKey(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentAIKey{UserID: 3, Provider: "openai", EncryptedAPIKey: "user-key"}).Error)
|
||||
gateway := NewGateway("system-key")
|
||||
|
||||
selected, err := gateway.SelectKey(3)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user", selected.KeyType)
|
||||
require.Equal(t, "user-key", selected.APIKey)
|
||||
}
|
||||
|
||||
func TestSaveUserKeyEncryptsStoredKey(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
gateway := NewGatewayWithSecret("system-key", "test-encryption-secret")
|
||||
|
||||
require.NoError(t, gateway.SaveUserKey(3, "openai", "user-key"))
|
||||
|
||||
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:")
|
||||
selected, err := gateway.SelectKey(3)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user-key", selected.APIKey)
|
||||
}
|
||||
|
||||
func TestSelectKeyFallsBackToSystemKey(t *testing.T) {
|
||||
newTestDB(t)
|
||||
gateway := NewGateway("system-key")
|
||||
|
||||
selected, err := gateway.SelectKey(3)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "system", selected.KeyType)
|
||||
require.Equal(t, "system-key", selected.APIKey)
|
||||
}
|
||||
|
||||
func TestSelectKeyReturnsErrorWhenNoKeyAvailable(t *testing.T) {
|
||||
newTestDB(t)
|
||||
gateway := NewGateway("")
|
||||
|
||||
_, err := gateway.SelectKey(3)
|
||||
|
||||
require.ErrorContains(t, err, "no ai key available")
|
||||
}
|
||||
|
||||
func TestRecordCallStoresAuditFields(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
gateway := NewGateway("system-key")
|
||||
|
||||
require.NoError(t, gateway.RecordCall(3, "openai", "system", "inbox_analyze", "failed", "rate limited"))
|
||||
|
||||
var log models.SenlinAgentAICallLog
|
||||
require.NoError(t, database.First(&log).Error)
|
||||
require.Equal(t, uint(3), log.UserID)
|
||||
require.Equal(t, "openai", log.Provider)
|
||||
require.Equal(t, "system", log.UsedKeyType)
|
||||
require.Equal(t, "inbox_analyze", log.Action)
|
||||
require.Equal(t, "failed", log.Status)
|
||||
require.Equal(t, "rate limited", log.Error)
|
||||
}
|
||||
|
||||
func TestCheckRateLimitRejectsCallsOverWindow(t *testing.T) {
|
||||
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)
|
||||
|
||||
require.ErrorContains(t, err, "ai rate limit exceeded")
|
||||
}
|
||||
|
||||
func TestCreateAISession(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewSessionService()
|
||||
|
||||
session, err := service.Create(7, 3, "报价分析")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint(7), session.ProjectID)
|
||||
require.Equal(t, uint(3), session.CreatedBy)
|
||||
require.Equal(t, "报价分析", session.Title)
|
||||
}
|
||||
|
||||
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, 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
|
||||
}
|
||||
37
backend/internal/logic/auth/middleware.go
Normal file
37
backend/internal/logic/auth/middleware.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const CurrentUserIDKey = "currentUserID"
|
||||
|
||||
func RequireUser(tokenVerifier func(string) (uint, error)) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
token := strings.TrimPrefix(header, "Bearer ")
|
||||
if token == "" || token == header {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||
return
|
||||
}
|
||||
userID, err := tokenVerifier(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid bearer token"})
|
||||
return
|
||||
}
|
||||
c.Set(CurrentUserIDKey, userID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func CurrentUserID(c *gin.Context) (uint, bool) {
|
||||
value, ok := c.Get(CurrentUserIDKey)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
userID, ok := value.(uint)
|
||||
return userID, ok && userID > 0
|
||||
}
|
||||
136
backend/internal/logic/auth/service.go
Normal file
136
backend/internal/logic/auth/service.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
secret string
|
||||
inviteTokenTTL time.Duration
|
||||
sessionTTL time.Duration
|
||||
}
|
||||
|
||||
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) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||
if normalized == "" {
|
||||
return "", errors.New("email is required")
|
||||
}
|
||||
return s.signToken(tokenPayload{
|
||||
Type: "invite",
|
||||
Subject: normalized,
|
||||
IssuerID: adminID,
|
||||
ExpiresAt: time.Now().Add(s.inviteTokenTTL).Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) RegisterWithInvite(token, email, displayName, password string) (*models.SenlinAgentUser, error) {
|
||||
payload, err := s.verifyToken(token, "invite")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload.Subject != strings.ToLower(strings.TrimSpace(email)) {
|
||||
return nil, errors.New("invite email mismatch")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := &models.SenlinAgentUser{
|
||||
Email: payload.Subject,
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
PasswordHash: string(hash),
|
||||
Role: "user",
|
||||
}
|
||||
return user, models.DBService.Create(user).Error
|
||||
}
|
||||
|
||||
func (s *Service) Login(email, password string) (string, error) {
|
||||
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 {
|
||||
return "", errors.New("invalid credentials")
|
||||
}
|
||||
return s.signToken(tokenPayload{
|
||||
Type: "session",
|
||||
Subject: fmt.Sprintf("%d", user.ID),
|
||||
ExpiresAt: time.Now().Add(s.sessionTTL).Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) VerifySession(token string) (uint, error) {
|
||||
payload, err := s.verifyToken(token, "session")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var userID uint
|
||||
if _, err := fmt.Sscanf(payload.Subject, "%d", &userID); err != nil || userID == 0 {
|
||||
return 0, errors.New("invalid session subject")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
type tokenPayload struct {
|
||||
Type string `json:"type"`
|
||||
Subject string `json:"subject"`
|
||||
IssuerID uint `json:"issuer_id,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (s *Service) signToken(payload tokenPayload) (string, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(body)
|
||||
return encoded + "." + s.sign(encoded), nil
|
||||
}
|
||||
|
||||
func (s *Service) verifyToken(token string, expectedType string) (tokenPayload, error) {
|
||||
separator := strings.LastIndex(token, ".")
|
||||
if separator < 1 || separator == len(token)-1 {
|
||||
return tokenPayload{}, errors.New("invalid token")
|
||||
}
|
||||
encoded := token[:separator]
|
||||
signature := token[separator+1:]
|
||||
if !hmac.Equal([]byte(signature), []byte(s.sign(encoded))) {
|
||||
return tokenPayload{}, errors.New("invalid token signature")
|
||||
}
|
||||
body, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return tokenPayload{}, errors.New("invalid token payload")
|
||||
}
|
||||
var payload tokenPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return tokenPayload{}, errors.New("invalid token payload")
|
||||
}
|
||||
if payload.Type != expectedType {
|
||||
return tokenPayload{}, errors.New("invalid token type")
|
||||
}
|
||||
if payload.ExpiresAt <= time.Now().Unix() {
|
||||
return tokenPayload{}, errors.New("token expired")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *Service) sign(value string) string {
|
||||
mac := hmac.New(sha256.New, []byte(s.secret))
|
||||
mac.Write([]byte(value))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
84
backend/internal/logic/auth/service_test.go
Normal file
84
backend/internal/logic/auth/service_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestRegisterWithInviteCreatesUser(t *testing.T) {
|
||||
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")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "lead@example.com", user.Email)
|
||||
require.NotEmpty(t, user.PasswordHash)
|
||||
require.NotEqual(t, "password123", user.PasswordHash)
|
||||
}
|
||||
|
||||
func TestRegisterWithInviteRejectsWrongEmail(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewService("test-secret")
|
||||
token, err := service.CreateInvite(1, "lead@example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.RegisterWithInvite(token, "other@example.com", "Other", "password123")
|
||||
|
||||
require.ErrorContains(t, err, "invite email mismatch")
|
||||
}
|
||||
|
||||
func TestLoginRejectsInvalidPassword(t *testing.T) {
|
||||
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")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Login("lead@example.com", "wrong-password")
|
||||
|
||||
require.ErrorContains(t, err, "invalid credentials")
|
||||
}
|
||||
|
||||
func TestLoginReturnsSignedSessionToken(t *testing.T) {
|
||||
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")
|
||||
require.NoError(t, err)
|
||||
|
||||
sessionToken, err := service.Login("lead@example.com", "password123")
|
||||
require.NoError(t, err)
|
||||
userID, err := service.VerifySession(sessionToken)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.ID, userID)
|
||||
require.NotEqual(t, fmt.Sprintf("user:%d", user.ID), sessionToken)
|
||||
}
|
||||
|
||||
func TestVerifySessionRejectsForgeableLegacyToken(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewService("test-secret")
|
||||
|
||||
_, err := service.VerifySession("user:1")
|
||||
|
||||
require.ErrorContains(t, err, "invalid token")
|
||||
}
|
||||
|
||||
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, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
return database
|
||||
}
|
||||
45
backend/internal/logic/files/service.go
Normal file
45
backend/internal/logic/files/service.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
root string
|
||||
}
|
||||
|
||||
type StoredFile struct {
|
||||
OriginalName string
|
||||
RelativePath string
|
||||
AbsolutePath string
|
||||
}
|
||||
|
||||
func NewService(root string) *Service {
|
||||
return &Service{root: root}
|
||||
}
|
||||
|
||||
func (s *Service) Save(projectID uint, originalName string, content io.Reader) (StoredFile, error) {
|
||||
cleanName := filepath.Base(strings.ReplaceAll(strings.TrimSpace(originalName), "\\", "/"))
|
||||
if cleanName == "." || cleanName == "" {
|
||||
cleanName = "upload.bin"
|
||||
}
|
||||
relative := filepath.ToSlash(filepath.Join("projects", fmt.Sprint(projectID), fmt.Sprintf("%d-%s", time.Now().UnixNano(), cleanName)))
|
||||
absolute := filepath.Join(s.root, filepath.FromSlash(relative))
|
||||
if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil {
|
||||
return StoredFile{}, err
|
||||
}
|
||||
file, err := os.Create(absolute)
|
||||
if err != nil {
|
||||
return StoredFile{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.Copy(file, content); err != nil {
|
||||
return StoredFile{}, err
|
||||
}
|
||||
return StoredFile{OriginalName: cleanName, RelativePath: relative, AbsolutePath: absolute}, nil
|
||||
}
|
||||
29
backend/internal/logic/files/service_test.go
Normal file
29
backend/internal/logic/files/service_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSaveStoresFileUnderProjectDirectory(t *testing.T) {
|
||||
service := NewService(t.TempDir())
|
||||
|
||||
stored, err := service.Save(12, "brief.md", strings.NewReader("hello"))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "brief.md", stored.OriginalName)
|
||||
require.Contains(t, stored.RelativePath, "projects/12/")
|
||||
require.FileExists(t, stored.AbsolutePath)
|
||||
}
|
||||
|
||||
func TestSaveNeutralizesPathTraversal(t *testing.T) {
|
||||
service := NewService(t.TempDir())
|
||||
|
||||
stored, err := service.Save(12, "..\\..\\secret.txt", strings.NewReader("hello"))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "secret.txt", stored.OriginalName)
|
||||
require.Contains(t, stored.RelativePath, "projects/12/")
|
||||
}
|
||||
90
backend/internal/logic/inbox/handlers.go
Normal file
90
backend/internal/logic/inbox/handlers.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(router gin.IRouter) {
|
||||
router.POST("/projects/:projectID/inbox", h.capture)
|
||||
router.POST("/inbox/:id/analyze", h.analyze)
|
||||
router.POST("/inbox/:id/confirm", h.confirm)
|
||||
}
|
||||
|
||||
func (h *Handler) capture(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
projectID, err := strconv.ParseUint(c.Param("projectID"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
SourceType string `json:"source_type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
item, err := h.service.Capture(CaptureInput{ProjectID: uint(projectID), UserID: userID, SourceType: input.SourceType, Title: input.Title, Body: input.Body})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (h *Handler) analyze(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
itemID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
suggestions, err := h.service.Analyze(uint(itemID), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"suggestions": suggestions})
|
||||
}
|
||||
|
||||
func (h *Handler) confirm(c *gin.Context) {
|
||||
itemID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Suggestions []Suggestion `json:"suggestions"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if err := h.service.Confirm(uint(itemID), input.Suggestions); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
100
backend/internal/logic/inbox/service.go
Normal file
100
backend/internal/logic/inbox/service.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type CaptureInput struct {
|
||||
ProjectID uint
|
||||
UserID uint
|
||||
SourceType string
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
type Suggestion struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
type Analyzer interface {
|
||||
Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error)
|
||||
}
|
||||
|
||||
type StaticAnalyzer struct {
|
||||
Suggestions []Suggestion
|
||||
}
|
||||
|
||||
func (a StaticAnalyzer) Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error) {
|
||||
return a.Suggestions, nil
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
analyzer Analyzer
|
||||
}
|
||||
|
||||
func NewService(analyzer Analyzer) *Service {
|
||||
return &Service{analyzer: analyzer}
|
||||
}
|
||||
|
||||
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 := &models.SenlinAgentInboxItem{
|
||||
ProjectID: input.ProjectID,
|
||||
CreatedBy: input.UserID,
|
||||
SourceType: input.SourceType,
|
||||
Title: input.Title,
|
||||
Body: input.Body,
|
||||
Status: "open",
|
||||
}
|
||||
return item, models.DBService.Create(item).Error
|
||||
}
|
||||
|
||||
func (s *Service) Analyze(itemID uint, userID uint) ([]Suggestion, error) {
|
||||
var item models.SenlinAgentInboxItem
|
||||
if err := models.DBService.First(&item, itemID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.analyzer == nil {
|
||||
return []Suggestion{}, nil
|
||||
}
|
||||
return s.analyzer.Analyze(item, userID)
|
||||
}
|
||||
|
||||
func (s *Service) Confirm(itemID uint, selected []Suggestion) error {
|
||||
return models.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var item models.SenlinAgentInboxItem
|
||||
if err := tx.First(&item, itemID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
sourceInboxItemID := item.ID
|
||||
for _, suggestion := range selected {
|
||||
switch suggestion.Kind {
|
||||
case "task":
|
||||
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(&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(&models.SenlinAgentSource{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return errors.New("unsupported suggestion kind")
|
||||
}
|
||||
}
|
||||
return tx.Model(&item).Update("status", "processed").Error
|
||||
})
|
||||
}
|
||||
61
backend/internal/logic/inbox/service_test.go
Normal file
61
backend/internal/logic/inbox/service_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestAnalyzeReturnsSuggestionsWithoutCreatingObjects(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService(StaticAnalyzer{
|
||||
Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}},
|
||||
})
|
||||
item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"})
|
||||
require.NoError(t, err)
|
||||
|
||||
suggestions, err := service.Analyze(item.ID, 1)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, suggestions, 1)
|
||||
var count int64
|
||||
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(StaticAnalyzer{
|
||||
Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}},
|
||||
})
|
||||
item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"})
|
||||
require.NoError(t, err)
|
||||
|
||||
suggestions, err := service.Analyze(item.ID, 1)
|
||||
require.NoError(t, err)
|
||||
err = service.Confirm(item.ID, suggestions)
|
||||
|
||||
require.NoError(t, err)
|
||||
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 models.SenlinAgentInboxItem
|
||||
require.NoError(t, database.First(&reloaded, item.ID).Error)
|
||||
require.Equal(t, "processed", reloaded.Status)
|
||||
}
|
||||
|
||||
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, 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
|
||||
}
|
||||
78
backend/internal/logic/projects/handlers.go
Normal file
78
backend/internal/logic/projects/handlers.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(router gin.IRouter) {
|
||||
router.POST("/projects", h.createProject)
|
||||
router.GET("/projects", h.listProjects)
|
||||
router.GET("/projects/:id/dashboard", h.dashboard)
|
||||
}
|
||||
|
||||
func (h *Handler) createProject(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
project, err := h.service.CreateProject(userID, input.Name, input.Description)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, project)
|
||||
}
|
||||
|
||||
func (h *Handler) listProjects(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
projects, err := h.service.ListProjects(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, projects)
|
||||
}
|
||||
|
||||
func (h *Handler) dashboard(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
dashboard, err := h.service.Dashboard(userID, uint(projectID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load dashboard"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, dashboard)
|
||||
}
|
||||
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
|
||||
}
|
||||
69
backend/internal/logic/projects/service_test.go
Normal file
69
backend/internal/logic/projects/service_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestProjectTagsAreScopedToProject(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewService()
|
||||
first, err := service.CreateProject(1, "Alpha", "")
|
||||
require.NoError(t, err)
|
||||
second, err := service.CreateProject(1, "Beta", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.CreateTag(first.ID, "重要")
|
||||
require.NoError(t, err)
|
||||
_, err = service.CreateTag(second.ID, "重要")
|
||||
require.NoError(t, err)
|
||||
|
||||
firstTags, err := service.ListTags(first.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, firstTags, 1)
|
||||
require.Equal(t, first.ID, firstTags[0].ProjectID)
|
||||
}
|
||||
|
||||
func TestDashboardCountsOnlyRequestedProject(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
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(&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)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), dashboard.PendingInboxCount)
|
||||
require.Equal(t, int64(1), dashboard.OpenTaskCount)
|
||||
}
|
||||
|
||||
func TestDashboardRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewService()
|
||||
project, err := service.CreateProject(2, "Beta", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Dashboard(1, project.ID)
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
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, 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"])
|
||||
}
|
||||
108
backend/internal/logic/tasks/service.go
Normal file
108
backend/internal/logic/tasks/service.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
}
|
||||
|
||||
type LinkedObject struct {
|
||||
ObjectType string `json:"object_type"`
|
||||
ObjectID uint `json:"object_id"`
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) Assign(taskID uint, assigneeID uint) error {
|
||||
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(&models.SenlinAgentProjectEvent{
|
||||
ProjectID: task.ProjectID,
|
||||
ActorID: task.CreatedBy,
|
||||
EventType: "task_assigned",
|
||||
EntityType: "task",
|
||||
EntityID: task.ID,
|
||||
Summary: fmt.Sprintf("Task assigned to user %d", assigneeID),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) error {
|
||||
if objectType != "note" && objectType != "source" {
|
||||
return errors.New("unsupported shared object type")
|
||||
}
|
||||
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(&models.SenlinAgentTaskShare{TaskID: taskID, ObjectType: objectType, ObjectID: objectID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&models.SenlinAgentProjectEvent{
|
||||
ProjectID: task.ProjectID,
|
||||
ActorID: task.CreatedBy,
|
||||
EventType: "task_object_shared",
|
||||
EntityType: "task",
|
||||
EntityID: task.ID,
|
||||
Summary: fmt.Sprintf("Shared %s %d with task", objectType, objectID),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) VisibleLinkedObjects(taskID uint, viewerID uint) ([]LinkedObject, error) {
|
||||
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 []models.SenlinAgentTaskShare
|
||||
if err := models.DBService.Where("task_id = ?", taskID).Find(&shares).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objects := make([]LinkedObject, 0, len(shares))
|
||||
for _, share := range shares {
|
||||
objects = append(objects, LinkedObject{ObjectType: share.ObjectType, ObjectID: share.ObjectID})
|
||||
}
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectType string, objectID uint) error {
|
||||
switch objectType {
|
||||
case "note":
|
||||
var count int64
|
||||
if err := tx.Model(&models.SenlinAgentNote{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return errors.New("shared object not found in task project")
|
||||
}
|
||||
case "source":
|
||||
var count int64
|
||||
if err := tx.Model(&models.SenlinAgentSource{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return errors.New("shared object not found in task project")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
76
backend/internal/logic/tasks/service_test.go
Normal file
76
backend/internal/logic/tasks/service_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestAssigneeOnlySeesExplicitlySharedObjects(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
assigneeID := uint(2)
|
||||
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()
|
||||
|
||||
before, err := service.VisibleLinkedObjects(task.ID, assigneeID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, before)
|
||||
|
||||
require.NoError(t, service.ShareObject(task.ID, "note", note.ID))
|
||||
after, err := service.VisibleLinkedObjects(task.ID, assigneeID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, after, 1)
|
||||
require.Equal(t, "note", after[0].ObjectType)
|
||||
require.Equal(t, note.ID, after[0].ObjectID)
|
||||
}
|
||||
|
||||
func TestShareObjectRejectsUnsupportedType(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewService()
|
||||
|
||||
err := service.ShareObject(1, "ai_session", 9)
|
||||
|
||||
require.ErrorContains(t, err, "unsupported shared object type")
|
||||
}
|
||||
|
||||
func TestShareObjectRejectsObjectFromAnotherProject(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
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()
|
||||
|
||||
err := service.ShareObject(task.ID, "note", note.ID)
|
||||
|
||||
require.ErrorContains(t, err, "shared object not found in task project")
|
||||
}
|
||||
|
||||
func TestAssignRecordsProjectEvent(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
task := models.SenlinAgentTask{ProjectID: 7, CreatedBy: 1, Title: "安排评审"}
|
||||
require.NoError(t, database.Create(&task).Error)
|
||||
service := NewService()
|
||||
|
||||
require.NoError(t, service.Assign(task.ID, 2))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
return database
|
||||
}
|
||||
Reference in New Issue
Block a user