fix: complete audit security and frontend gaps

This commit is contained in:
2026-07-18 18:02:29 +08:00
parent 79feb20688
commit b7a84d31ea
28 changed files with 576 additions and 88 deletions

View File

@@ -3,6 +3,7 @@ package main
import (
"log"
"senlinai-agent/backend/internal/auth"
"senlinai-agent/backend/internal/config"
"senlinai-agent/backend/internal/db"
"senlinai-agent/backend/internal/domain"
@@ -21,10 +22,11 @@ func main() {
log.Fatal(err)
}
authService := auth.NewService(database, cfg.AuthSecret)
projectHandler := projects.NewHandler(projects.NewService(database))
inboxHandler := inbox.NewHandler(inbox.NewService(database, inbox.StaticAnalyzer{}))
router := httpx.NewRouter(cfg, projectHandler, inboxHandler)
if err := router.Run(":8080"); err != nil {
router := httpx.NewProtectedRouter(cfg, authService.VerifySession, projectHandler, inboxHandler)
if err := router.Run(":" + cfg.Port); err != nil {
log.Fatal(err)
}
}

View File

@@ -1,15 +1,24 @@
package ai
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"senlinai-agent/backend/internal/domain"
)
type Gateway struct {
db *gorm.DB
systemKey string
db *gorm.DB
systemKey string
encryptionSecret string
}
type SelectedKey struct {
@@ -19,13 +28,33 @@ type SelectedKey struct {
}
func NewGateway(database *gorm.DB, systemKey string) *Gateway {
return &Gateway{db: database, systemKey: systemKey}
return NewGatewayWithSecret(database, 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 (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error {
encrypted, err := encryptAPIKey(apiKey, g.encryptionSecret)
if err != nil {
return err
}
key := domain.AIKey{UserID: userID, Provider: provider, EncryptedAPIKey: encrypted}
return g.db.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 {
return SelectedKey{Provider: userKey.Provider, APIKey: userKey.EncryptedAPIKey, KeyType: "user"}, 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")
@@ -43,3 +72,72 @@ func (g *Gateway) RecordCall(userID uint, provider string, usedKeyType string, a
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 := g.db.Model(&domain.AICallLog{}).
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[:]
}

View File

@@ -3,6 +3,7 @@ package ai
import (
"fmt"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
@@ -22,6 +23,21 @@ func TestSelectKeyPrefersUserKey(t *testing.T) {
require.Equal(t, "user-key", selected.APIKey)
}
func TestSaveUserKeyEncryptsStoredKey(t *testing.T) {
database := newTestDB(t)
gateway := NewGatewayWithSecret(database, "system-key", "test-encryption-secret")
require.NoError(t, gateway.SaveUserKey(3, "openai", "user-key"))
var stored domain.AIKey
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) {
database := newTestDB(t)
gateway := NewGateway(database, "system-key")
@@ -58,6 +74,16 @@ func TestRecordCallStoresAuditFields(t *testing.T) {
require.Equal(t, "rate limited", log.Error)
}
func TestCheckRateLimitRejectsCallsOverWindow(t *testing.T) {
database := newTestDB(t)
gateway := NewGateway(database, "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) {
database := newTestDB(t)
service := NewSessionService(database)

View File

@@ -26,3 +26,12 @@ func RequireUser(tokenVerifier func(string) (uint, error)) gin.HandlerFunc {
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
}

View File

@@ -3,10 +3,13 @@ package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
@@ -14,12 +17,14 @@ import (
)
type Service struct {
db *gorm.DB
secret string
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}
return &Service{db: database, secret: secret, inviteTokenTTL: 7 * 24 * time.Hour, sessionTTL: 24 * time.Hour}
}
func (s *Service) CreateInvite(adminID uint, email string) (string, error) {
@@ -27,20 +32,20 @@ func (s *Service) CreateInvite(adminID uint, email string) (string, error) {
if normalized == "" {
return "", errors.New("email is required")
}
return normalized + "." + s.sign(normalized), nil
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) (*domain.User, error) {
separator := strings.LastIndex(token, ".")
if separator < 1 || separator == len(token)-1 {
return nil, errors.New("invalid invite token")
payload, err := s.verifyToken(token, "invite")
if err != nil {
return nil, err
}
inviteEmail := token[:separator]
signature := token[separator+1:]
if !hmac.Equal([]byte(signature), []byte(s.sign(inviteEmail))) {
return nil, errors.New("invalid invite signature")
}
if inviteEmail != strings.ToLower(strings.TrimSpace(email)) {
if payload.Subject != strings.ToLower(strings.TrimSpace(email)) {
return nil, errors.New("invite email mismatch")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
@@ -48,7 +53,7 @@ func (s *Service) RegisterWithInvite(token, email, displayName, password string)
return nil, err
}
user := &domain.User{
Email: inviteEmail,
Email: payload.Subject,
DisplayName: strings.TrimSpace(displayName),
PasswordHash: string(hash),
Role: "user",
@@ -64,7 +69,66 @@ func (s *Service) Login(email, password string) (string, error) {
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return "", errors.New("invalid credentials")
}
return fmt.Sprintf("user:%d", user.ID), nil
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 {

View File

@@ -48,6 +48,32 @@ func TestLoginRejectsInvalidPassword(t *testing.T) {
require.ErrorContains(t, err, "invalid credentials")
}
func TestLoginReturnsSignedSessionToken(t *testing.T) {
database := newTestDB(t)
service := NewService(database, "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) {
database := newTestDB(t)
service := NewService(database, "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{})

View File

@@ -3,18 +3,24 @@ package config
import "os"
type Config struct {
Env string
DatabaseURL string
StorageDir string
SystemAIKey string
Env string
Port string
DatabaseURL string
StorageDir string
AuthSecret string
SystemAIKey string
AIKeyEncryptionSecret string
}
func Load() Config {
return Config{
Env: getenv("APP_ENV", "development"),
DatabaseURL: getenv("DATABASE_URL", "postgres://agent:agent@localhost:5432/agent?sslmode=disable"),
StorageDir: getenv("STORAGE_DIR", "./data/files"),
SystemAIKey: os.Getenv("SYSTEM_AI_KEY"),
Env: getenv("APP_ENV", "development"),
Port: getenv("PORT", "8080"),
DatabaseURL: getenv("DATABASE_URL", "postgres://agent:agent@localhost:5432/agent?sslmode=disable"),
StorageDir: getenv("STORAGE_DIR", "./data/files"),
AuthSecret: getenv("AUTH_SECRET", "development-auth-secret-change-me"),
SystemAIKey: os.Getenv("SYSTEM_AI_KEY"),
AIKeyEncryptionSecret: getenv("AI_KEY_ENCRYPTION_SECRET", "development-ai-key-secret-change-me"),
}
}

View File

@@ -38,40 +38,43 @@ type InboxItem struct {
}
type Task struct {
ID uint `gorm:"primaryKey"`
ProjectID uint `gorm:"index;not null"`
CreatedBy uint `gorm:"index;not null"`
AssigneeID *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
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"`
Title string `gorm:"not null"`
Markdown string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
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"`
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
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 {

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"senlinai-agent/backend/internal/auth"
"senlinai-agent/backend/internal/config"
)
@@ -12,6 +13,14 @@ type RouteRegistrar interface {
}
func NewRouter(cfg config.Config, registrars ...RouteRegistrar) *gin.Engine {
return newRouter(cfg, nil, registrars...)
}
func NewProtectedRouter(cfg config.Config, tokenVerifier func(string) (uint, error), registrars ...RouteRegistrar) *gin.Engine {
return newRouter(cfg, tokenVerifier, registrars...)
}
func newRouter(cfg config.Config, tokenVerifier func(string) (uint, error), registrars ...RouteRegistrar) *gin.Engine {
if cfg.Env == "test" {
gin.SetMode(gin.TestMode)
}
@@ -23,6 +32,9 @@ func NewRouter(cfg config.Config, registrars ...RouteRegistrar) *gin.Engine {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
api := router.Group("/api")
if tokenVerifier != nil {
api.Use(auth.RequireUser(tokenVerifier))
}
for _, registrar := range registrars {
registrar.Register(api)
}

View File

@@ -5,6 +5,7 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"senlinai-agent/backend/internal/auth"
)
type Handler struct {
@@ -22,6 +23,11 @@ func (h *Handler) Register(router gin.IRouter) {
}
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"})
@@ -36,7 +42,7 @@ func (h *Handler) capture(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
item, err := h.service.Capture(CaptureInput{ProjectID: uint(projectID), UserID: 1, SourceType: input.SourceType, Title: input.Title, Body: input.Body})
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
@@ -45,12 +51,17 @@ func (h *Handler) capture(c *gin.Context) {
}
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), 1)
suggestions, err := h.service.Analyze(uint(itemID), userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return

View File

@@ -77,18 +77,19 @@ func (s *Service) Confirm(itemID uint, selected []Suggestion) error {
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(&domain.Task{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, Title: suggestion.Title, Description: suggestion.Body}).Error; err != nil {
if err := tx.Create(&domain.Task{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, Title: suggestion.Title, Markdown: suggestion.Body}).Error; err != nil {
if err := tx.Create(&domain.Note{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, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil {
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 {
return err
}
default:

View File

@@ -44,6 +44,8 @@ func TestConfirmCreatesSelectedObjectsAndKeepsInboxItem(t *testing.T) {
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
require.NoError(t, database.First(&reloaded, item.ID).Error)
require.Equal(t, "processed", reloaded.Status)

View File

@@ -5,6 +5,7 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"senlinai-agent/backend/internal/auth"
)
type Handler struct {
@@ -22,6 +23,11 @@ func (h *Handler) Register(router gin.IRouter) {
}
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"`
@@ -30,7 +36,7 @@ func (h *Handler) createProject(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
project, err := h.service.CreateProject(1, input.Name, input.Description)
project, err := h.service.CreateProject(userID, input.Name, input.Description)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -39,7 +45,12 @@ func (h *Handler) createProject(c *gin.Context) {
}
func (h *Handler) listProjects(c *gin.Context) {
projects, err := h.service.ListProjects(1)
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
@@ -48,12 +59,17 @@ func (h *Handler) listProjects(c *gin.Context) {
}
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(uint(projectID))
dashboard, err := h.service.Dashboard(userID, uint(projectID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load dashboard"})
return

View File

@@ -54,7 +54,11 @@ func (s *Service) ListTags(projectID uint) ([]domain.Tag, error) {
return tags, err
}
func (s *Service) Dashboard(projectID uint) (Dashboard, error) {
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

View File

@@ -41,13 +41,24 @@ func TestDashboardCountsOnlyRequestedProject(t *testing.T) {
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)
dashboard, err := service.Dashboard(first.ID)
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) {
database := newTestDB(t)
service := NewService(database)
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{})

View File

@@ -50,6 +50,9 @@ func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) err
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 {
return err
}
@@ -82,3 +85,25 @@ func (s *Service) VisibleLinkedObjects(taskID uint, viewerID uint) ([]LinkedObje
}
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(&domain.Note{}).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(&domain.Source{}).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
}

View File

@@ -40,6 +40,19 @@ func TestShareObjectRejectsUnsupportedType(t *testing.T) {
require.ErrorContains(t, err, "unsupported shared object type")
}
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"}
require.NoError(t, database.Create(&task).Error)
require.NoError(t, database.Create(&note).Error)
service := NewService(database)
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 := domain.Task{ProjectID: 7, CreatedBy: 1, Title: "安排评审"}