refactor(backend): split project write responsibilities
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"senlinai-agent/backend/internal/logic/files"
|
"senlinai-agent/backend/internal/logic/files"
|
||||||
"senlinai-agent/backend/internal/logic/inbox"
|
"senlinai-agent/backend/internal/logic/inbox"
|
||||||
"senlinai-agent/backend/internal/logic/projects"
|
"senlinai-agent/backend/internal/logic/projects"
|
||||||
|
"senlinai-agent/backend/internal/logic/tasks"
|
||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,10 +22,28 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authService := auth.NewService(cfg.AuthSecret)
|
authService := auth.NewService(cfg.AuthSecret)
|
||||||
projectHandler := projects.NewHandler(projects.NewService(), files.NewService(cfg.StorageDir))
|
projectService := projects.NewService()
|
||||||
|
fileService := files.NewService(cfg.StorageDir, models.DBService)
|
||||||
|
taskService := tasks.NewService(models.DBService)
|
||||||
|
projectHandler := projects.NewHandler(projectService)
|
||||||
|
tagHandler := projects.NewTagHandler(projectService)
|
||||||
|
cronHandler := projects.NewCronHandler(projectService)
|
||||||
|
taskHandler := tasks.NewHandler(taskService)
|
||||||
|
fileHandler := files.NewHandler(fileService)
|
||||||
inboxHandler := inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{}))
|
inboxHandler := inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{}))
|
||||||
authHandler := auth.NewHandler(authService)
|
authHandler := auth.NewHandler(authService)
|
||||||
appRouter := httpx.NewProtectedRouter(cfg, authService.VerifySession, authHandler, projectHandler, inboxHandler)
|
// search 与 AI 当前只有 service,尚无 HTTP registrar;后续功能任务应在实现真实契约后从此处注入,不能注册伪端点。
|
||||||
|
appRouter := httpx.NewProtectedRouter(
|
||||||
|
cfg,
|
||||||
|
authService.VerifySession,
|
||||||
|
authHandler,
|
||||||
|
projectHandler,
|
||||||
|
tagHandler,
|
||||||
|
cronHandler,
|
||||||
|
taskHandler,
|
||||||
|
fileHandler,
|
||||||
|
inboxHandler,
|
||||||
|
)
|
||||||
if err := appRouter.Run(":" + cfg.Port); err != nil {
|
if err := appRouter.Run(":" + cfg.Port); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,23 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"senlinai-agent/backend/internal/config"
|
"senlinai-agent/backend/internal/config"
|
||||||
"senlinai-agent/backend/internal/httpx"
|
"senlinai-agent/backend/internal/httpx"
|
||||||
|
"senlinai-agent/backend/internal/logic/files"
|
||||||
"senlinai-agent/backend/internal/logic/inbox"
|
"senlinai-agent/backend/internal/logic/inbox"
|
||||||
"senlinai-agent/backend/internal/logic/projects"
|
"senlinai-agent/backend/internal/logic/projects"
|
||||||
|
"senlinai-agent/backend/internal/logic/tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProjectAndInboxRoutesRegisterTogether(t *testing.T) {
|
func TestBackendFeatureRoutesRegisterTogether(t *testing.T) {
|
||||||
require.NotPanics(t, func() {
|
require.NotPanics(t, func() {
|
||||||
|
projectService := projects.NewService()
|
||||||
httpx.NewProtectedRouter(
|
httpx.NewProtectedRouter(
|
||||||
config.Config{Env: "test"},
|
config.Config{Env: "test"},
|
||||||
func(token string) (uint, error) { return 1, nil },
|
func(token string) (uint, error) { return 1, nil },
|
||||||
projects.NewHandler(projects.NewService()),
|
projects.NewHandler(projectService),
|
||||||
|
projects.NewTagHandler(projectService),
|
||||||
|
projects.NewCronHandler(projectService),
|
||||||
|
tasks.NewHandler(tasks.NewService()),
|
||||||
|
files.NewHandler(files.NewService(t.TempDir())),
|
||||||
inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{})),
|
inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{})),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
101
backend/internal/logic/files/handlers.go
Normal file
101
backend/internal/logic/files/handlers.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"senlinai-agent/backend/internal/httpx"
|
||||||
|
"senlinai-agent/backend/internal/logic/auth"
|
||||||
|
"senlinai-agent/backend/internal/logic/projects"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceDTO 是文件资料写接口的稳定响应,仅包含可公开的相对存储路径。
|
||||||
|
type SourceDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"projectId"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
FilePath string `json:"filePath"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler 负责 multipart 边界,文件名清理和本地路径构造始终委托给 Service。
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler 创建文件资料 HTTP registrar。
|
||||||
|
func NewHandler(service *Service) *Handler {
|
||||||
|
return &Handler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 将文件资料上传接口注册到上层提供的 /api/v1 路由组。
|
||||||
|
func (h *Handler) Register(router gin.IRouter) {
|
||||||
|
router.POST("/projects/:id/sources", h.upload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) upload(c *gin.Context) {
|
||||||
|
userID, ok := auth.CurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectIdentity, ok := httpx.IdentityParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 所有权必须在写磁盘前校验,防止越权请求留下孤立文件。
|
||||||
|
project, err := projects.FindOwnedProject(userID, projectIdentity)
|
||||||
|
if err != nil {
|
||||||
|
writeSourceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_request", "请选择要上传的文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_request", "文件读取失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// 先由文件服务保存内容,再用其返回的相对路径创建资料记录;handler 不拼接任何本地路径。
|
||||||
|
stored, err := h.service.Save(project.ID, fileHeader.Filename, file)
|
||||||
|
if err != nil {
|
||||||
|
httpx.Error(c, http.StatusInternalServerError, "internal_error", "文件保存失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source, err := h.service.CreateSource(userID, project, c.PostForm("title"), stored)
|
||||||
|
if err != nil {
|
||||||
|
writeSourceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, sourceDTO(*source))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceDTO(source models.SenlinAgentSource) SourceDTO {
|
||||||
|
return SourceDTO{
|
||||||
|
ID: source.Identity, ProjectID: source.ProjectIdentity, Kind: source.Kind,
|
||||||
|
Title: source.Title, FilePath: source.FilePath,
|
||||||
|
CreatedAt: source.CreatedAt.UTC(), UpdatedAt: source.UpdatedAt.UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSourceError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在")
|
||||||
|
case errors.Is(err, ErrSourceTitleRequired), errors.Is(err, ErrSourcePathRequired):
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_request", "资料参数无效")
|
||||||
|
default:
|
||||||
|
httpx.Error(c, http.StatusInternalServerError, "internal_error", "资料创建失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
96
backend/internal/logic/files/handlers_test.go
Normal file
96
backend/internal/logic/files/handlers_test.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"senlinai-agent/backend/internal/config"
|
||||||
|
"senlinai-agent/backend/internal/httpx"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileRegistrarSavesBeforeCreatingIdentitySourceDTO(t *testing.T) {
|
||||||
|
router, database, project, storageRoot := newFileHandlerTestRouter(t, 1, 1)
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
require.NoError(t, writer.WriteField("title", "客户资料"))
|
||||||
|
part, err := writer.CreateFormFile("file", "客户资料.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = part.Write([]byte("private content"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/sources", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.Header.Set("Authorization", "Bearer test-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String())
|
||||||
|
var source models.SenlinAgentSource
|
||||||
|
require.NoError(t, database.Where("project_id = ?", project.ID).First(&source).Error)
|
||||||
|
require.FileExists(t, filepath.Join(storageRoot, filepath.FromSlash(source.FilePath)))
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||||
|
require.Equal(t, source.Identity, payload["id"])
|
||||||
|
require.Equal(t, project.Identity, payload["projectId"])
|
||||||
|
require.Equal(t, filepath.ToSlash(source.FilePath), payload["filePath"])
|
||||||
|
require.NotContains(t, payload["filePath"], storageRoot)
|
||||||
|
require.NotContains(t, payload, "AbsolutePath")
|
||||||
|
require.NotContains(t, rec.Body.String(), storageRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileRegistrarChecksOwnershipBeforeWritingFile(t *testing.T) {
|
||||||
|
router, database, project, storageRoot := newFileHandlerTestRouter(t, 1, 2)
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, err := writer.CreateFormFile("file", "hidden.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = part.Write([]byte("must not be stored"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/sources", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.Header.Set("Authorization", "Bearer test-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String())
|
||||||
|
var count int64
|
||||||
|
require.NoError(t, database.Model(&models.SenlinAgentSource{}).Count(&count).Error)
|
||||||
|
require.Zero(t, count)
|
||||||
|
entries, err := os.ReadDir(storageRoot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFileHandlerTestRouter(t *testing.T, currentUserID, ownerID uint) (*gin.Engine, *gorm.DB, models.SenlinAgentProject, string) {
|
||||||
|
t.Helper()
|
||||||
|
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{TranslateError: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, models.AutoMigrate(database))
|
||||||
|
models.DBService = database
|
||||||
|
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"}).Error)
|
||||||
|
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "other@example.com", DisplayName: "Other", PasswordHash: "hash"}).Error)
|
||||||
|
project := models.SenlinAgentProject{OwnerID: ownerID, Name: "Files", Identifier: fmt.Sprintf("FILES-%d", ownerID)}
|
||||||
|
require.NoError(t, database.Create(&project).Error)
|
||||||
|
storageRoot := t.TempDir()
|
||||||
|
router := httpx.NewProtectedRouter(
|
||||||
|
config.Config{Env: "test"},
|
||||||
|
func(string) (uint, error) { return currentUserID, nil },
|
||||||
|
NewHandler(NewService(storageRoot, database)),
|
||||||
|
)
|
||||||
|
return router, database, project, storageRoot
|
||||||
|
}
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
package files
|
package files
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
root string
|
root string
|
||||||
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoredFile struct {
|
type StoredFile struct {
|
||||||
@@ -19,10 +24,16 @@ type StoredFile struct {
|
|||||||
AbsolutePath string
|
AbsolutePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(root string) *Service {
|
// NewService 集中持有存储根目录;任何服务端本地路径都只能由该服务构造。
|
||||||
return &Service{root: root}
|
func NewService(root string, databases ...*gorm.DB) *Service {
|
||||||
|
var database *gorm.DB
|
||||||
|
if len(databases) > 0 {
|
||||||
|
database = databases[0]
|
||||||
|
}
|
||||||
|
return &Service{root: root, db: database}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save 清理客户端文件名,并只返回供持久化的相对路径;绝对路径不得进入 API DTO。
|
||||||
func (s *Service) Save(projectID uint, originalName string, content io.Reader) (StoredFile, error) {
|
func (s *Service) Save(projectID uint, originalName string, content io.Reader) (StoredFile, error) {
|
||||||
cleanName := filepath.Base(strings.ReplaceAll(strings.TrimSpace(originalName), "\\", "/"))
|
cleanName := filepath.Base(strings.ReplaceAll(strings.TrimSpace(originalName), "\\", "/"))
|
||||||
if cleanName == "." || cleanName == "" {
|
if cleanName == "." || cleanName == "" {
|
||||||
@@ -43,3 +54,38 @@ func (s *Service) Save(projectID uint, originalName string, content io.Reader) (
|
|||||||
}
|
}
|
||||||
return StoredFile{OriginalName: cleanName, RelativePath: relative, AbsolutePath: absolute}, nil
|
return StoredFile{OriginalName: cleanName, RelativePath: relative, AbsolutePath: absolute}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) database() *gorm.DB {
|
||||||
|
if s.db != nil {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
return models.DBService
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrSourceTitleRequired = errors.New("source title is required")
|
||||||
|
ErrSourcePathRequired = errors.New("source file path is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateSource 只持久化 Save 产生的相对路径,不接受 handler 自行拼接本地路径。
|
||||||
|
func (s *Service) CreateSource(ownerID uint, project *models.SenlinAgentProject, title string, stored StoredFile) (*models.SenlinAgentSource, error) {
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
if title == "" {
|
||||||
|
title = stored.OriginalName
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
return nil, ErrSourceTitleRequired
|
||||||
|
}
|
||||||
|
relativePath := filepath.ToSlash(strings.TrimSpace(stored.RelativePath))
|
||||||
|
if relativePath == "" || filepath.IsAbs(relativePath) {
|
||||||
|
return nil, ErrSourcePathRequired
|
||||||
|
}
|
||||||
|
source := &models.SenlinAgentSource{
|
||||||
|
ProjectID: project.ID, ProjectIdentity: project.Identity, CreatedBy: ownerID,
|
||||||
|
Kind: "file", Title: title, FilePath: relativePath,
|
||||||
|
}
|
||||||
|
if err := s.database().Create(source).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ func (s *Service) Confirm(itemID uint, selected []Suggestion) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
sourceInboxItemID := item.ID
|
sourceInboxItemID := item.ID
|
||||||
|
// Inbox 建议只有在用户确认后才创建正式对象,并把来源 ID 写入每个对象以保留可追溯性。
|
||||||
for _, suggestion := range selected {
|
for _, suggestion := range selected {
|
||||||
switch suggestion.Kind {
|
switch suggestion.Kind {
|
||||||
case "task":
|
case "task":
|
||||||
|
|||||||
98
backend/internal/logic/projects/cron_handlers.go
Normal file
98
backend/internal/logic/projects/cron_handlers.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"senlinai-agent/backend/internal/httpx"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CronPlanDTO 是计划任务管理响应;MVP 只管理提醒元数据,不执行自主 Agent。
|
||||||
|
type CronPlanDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"projectId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Schedule string `json:"schedule"`
|
||||||
|
NextRunAt *time.Time `json:"nextRunAt"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastResult string `json:"lastResult"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CronHandler 单独承载计划任务写接口。
|
||||||
|
type CronHandler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCronHandler 创建计划任务 HTTP registrar。
|
||||||
|
func NewCronHandler(service *Service) *CronHandler {
|
||||||
|
return &CronHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 注册项目内计划任务创建接口。
|
||||||
|
func (h *CronHandler) Register(router gin.IRouter) {
|
||||||
|
router.POST("/projects/:id/cron-plans", h.create)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CronHandler) create(c *gin.Context) {
|
||||||
|
userID, project, ok := ownedProjectFromRequest(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Schedule string `json:"schedule"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
NextRunAt string `json:"nextRunAt"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextRunAt, err := parseCronOptionalTime(input.NextRunAt)
|
||||||
|
if err != nil {
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_request", "下次运行时间格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plan, err := h.service.CreateCronPlan(userID, project.ID, CreateCronPlanInput{
|
||||||
|
Title: input.Title, Schedule: input.Schedule, Enabled: input.Enabled, NextRunAt: nextRunAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_request", "计划任务参数无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, cronPlanDTO(*plan))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronPlanDTO(plan models.SenlinAgentCronPlan) CronPlanDTO {
|
||||||
|
return CronPlanDTO{
|
||||||
|
ID: plan.Identity, ProjectID: plan.ProjectIdentity, Title: plan.Title, Schedule: plan.Schedule,
|
||||||
|
NextRunAt: projectUTCOptionalTime(plan.NextRunAt), Enabled: plan.Enabled, LastResult: plan.LastResult,
|
||||||
|
CreatedAt: plan.CreatedAt.UTC(), UpdatedAt: plan.UpdatedAt.UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCronOptionalTime(value string) (*time.Time, error) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(time.RFC3339, trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parsed = parsed.UTC()
|
||||||
|
return &parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectUTCOptionalTime(value *time.Time) *time.Time {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := value.UTC()
|
||||||
|
return &result
|
||||||
|
}
|
||||||
@@ -34,28 +34,6 @@ type UpdateProjectRequest struct {
|
|||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateTaskInput struct {
|
|
||||||
Title string
|
|
||||||
Description string
|
|
||||||
Status string
|
|
||||||
DueAt *time.Time
|
|
||||||
Tag string
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateTaskInput struct {
|
|
||||||
Title string
|
|
||||||
Description string
|
|
||||||
Status string
|
|
||||||
Completed bool
|
|
||||||
NextProjectID uint
|
|
||||||
Tag string
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateFileSourceInput struct {
|
|
||||||
Title string
|
|
||||||
FilePath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateCronPlanInput struct {
|
type CreateCronPlanInput struct {
|
||||||
Title string
|
Title string
|
||||||
Schedule string
|
Schedule string
|
||||||
|
|||||||
@@ -3,43 +3,30 @@ package projects
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"senlinai-agent/backend/internal/httpx"
|
"senlinai-agent/backend/internal/httpx"
|
||||||
"senlinai-agent/backend/internal/logic/auth"
|
"senlinai-agent/backend/internal/logic/auth"
|
||||||
"senlinai-agent/backend/internal/logic/files"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Handler 仅负责项目核心读写和工作区读取;任务、标签、资料、计划任务由各自 registrar 承担。
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
service *Service
|
service *Service
|
||||||
fileService *files.Service
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(service *Service, fileServices ...*files.Service) *Handler {
|
// NewHandler 创建项目核心 HTTP registrar。
|
||||||
var fileService *files.Service
|
func NewHandler(service *Service) *Handler {
|
||||||
if len(fileServices) > 0 {
|
return &Handler{service: service}
|
||||||
fileService = fileServices[0]
|
|
||||||
}
|
|
||||||
return &Handler{service: service, fileService: fileService}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register 将项目核心接口注册到上层提供的 /api/v1 路由组。
|
||||||
func (h *Handler) Register(router gin.IRouter) {
|
func (h *Handler) Register(router gin.IRouter) {
|
||||||
router.POST("/projects", h.createProject)
|
router.POST("/projects", h.createProject)
|
||||||
router.GET("/projects", h.listProjects)
|
router.GET("/projects", h.listProjects)
|
||||||
router.GET("/projects/:id", h.getProject)
|
router.GET("/projects/:id", h.getProject)
|
||||||
router.PATCH("/projects/:id", h.updateProject)
|
router.PATCH("/projects/:id", h.updateProject)
|
||||||
router.GET("/projects/:id/dashboard", h.dashboard)
|
|
||||||
router.GET("/projects/:id/workspace", h.workspace)
|
router.GET("/projects/:id/workspace", h.workspace)
|
||||||
router.GET("/projects/:id/tags", h.listTags)
|
|
||||||
router.POST("/projects/:id/tasks", h.createTask)
|
|
||||||
router.PATCH("/projects/:id/tasks/:taskID", h.updateTask)
|
|
||||||
router.POST("/projects/:id/sources", h.uploadSource)
|
|
||||||
router.POST("/projects/:id/cron-plans", h.createCronPlan)
|
|
||||||
router.POST("/projects/:id/tags", h.createTag)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) createProject(c *gin.Context) {
|
func (h *Handler) createProject(c *gin.Context) {
|
||||||
@@ -115,29 +102,10 @@ func (h *Handler) updateProject(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) workspace(c *gin.Context) {
|
func (h *Handler) workspace(c *gin.Context) {
|
||||||
userID, ok := auth.CurrentUserID(c)
|
userID, ok := auth.CurrentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
projectIdentity, ok := httpx.IdentityParam(c, "id")
|
projectIdentity, ok := httpx.IdentityParam(c, "id")
|
||||||
@@ -152,226 +120,6 @@ func (h *Handler) workspace(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, workspace)
|
c.JSON(http.StatusOK, workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) listTags(c *gin.Context) {
|
|
||||||
userID, ok := auth.CurrentUserID(c)
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
projectID, ok := parseProjectID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tags, err := h.service.ListProjectTags(userID, projectID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) createTask(c *gin.Context) {
|
|
||||||
userID, ok := auth.CurrentUserID(c)
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
projectID, ok := parseProjectID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
DueAt string `json:"dueAt"`
|
|
||||||
Tag string `json:"tag"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dueAt, err := parseOptionalTime(input.DueAt)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid dueAt"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
task, err := h.service.CreateTask(userID, projectID, CreateTaskInput{
|
|
||||||
Title: input.Title,
|
|
||||||
Description: input.Description,
|
|
||||||
Status: input.Status,
|
|
||||||
DueAt: dueAt,
|
|
||||||
Tag: input.Tag,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusCreated, task)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) updateTask(c *gin.Context) {
|
|
||||||
userID, ok := auth.CurrentUserID(c)
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
projectID, ok := parseProjectID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
taskID, ok := parseTaskID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Completed bool `json:"completed"`
|
|
||||||
NextProjectID uint `json:"nextProjectId"`
|
|
||||||
Tag string `json:"tag"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
task, err := h.service.UpdateTask(userID, projectID, taskID, UpdateTaskInput{
|
|
||||||
Title: input.Title,
|
|
||||||
Description: input.Description,
|
|
||||||
Status: input.Status,
|
|
||||||
Completed: input.Completed,
|
|
||||||
NextProjectID: input.NextProjectID,
|
|
||||||
Tag: input.Tag,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, task)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) uploadSource(c *gin.Context) {
|
|
||||||
userID, ok := auth.CurrentUserID(c)
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if h.fileService == nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "file service is not configured"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
projectID, ok := parseProjectID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := ensureProjectOwner(userID, projectID); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fileHeader, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file, err := fileHeader.Open()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read file"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
stored, err := h.fileService.Save(projectID, fileHeader.Filename, file)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
title := c.PostForm("title")
|
|
||||||
if strings.TrimSpace(title) == "" {
|
|
||||||
title = stored.OriginalName
|
|
||||||
}
|
|
||||||
source, err := h.service.CreateFileSource(userID, projectID, CreateFileSourceInput{
|
|
||||||
Title: title,
|
|
||||||
FilePath: stored.RelativePath,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusCreated, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) createCronPlan(c *gin.Context) {
|
|
||||||
userID, ok := auth.CurrentUserID(c)
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
projectID, ok := parseProjectID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Schedule string `json:"schedule"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
NextRunAt string `json:"nextRunAt"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
nextRunAt, err := parseOptionalTime(input.NextRunAt)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid nextRunAt"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
plan, err := h.service.CreateCronPlan(userID, projectID, CreateCronPlanInput{
|
|
||||||
Title: input.Title,
|
|
||||||
Schedule: input.Schedule,
|
|
||||||
Enabled: input.Enabled,
|
|
||||||
NextRunAt: nextRunAt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusCreated, plan)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) createTag(c *gin.Context) {
|
|
||||||
userID, ok := auth.CurrentUserID(c)
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
projectID, ok := parseProjectID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tag, err := h.service.CreateProjectTag(userID, projectID, input.Name)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusCreated, tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseProjectID(c *gin.Context) (uint, bool) {
|
|
||||||
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return uint(projectID), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeProjectError(c *gin.Context, err error) {
|
func writeProjectError(c *gin.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
@@ -384,24 +132,3 @@ func writeProjectError(c *gin.Context, err error) {
|
|||||||
httpx.Error(c, http.StatusInternalServerError, "internal_error", "项目操作失败")
|
httpx.Error(c, http.StatusInternalServerError, "internal_error", "项目操作失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseTaskID(c *gin.Context) (uint, bool) {
|
|
||||||
taskID, err := strconv.ParseUint(c.Param("taskID"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task id"})
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return uint(taskID), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseOptionalTime(value string) (*time.Time, error) {
|
|
||||||
trimmed := strings.TrimSpace(value)
|
|
||||||
if trimmed == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
parsed, err := time.Parse(time.RFC3339, trimmed)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &parsed, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,17 +3,14 @@ package projects
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"senlinai-agent/backend/internal/config"
|
"senlinai-agent/backend/internal/config"
|
||||||
"senlinai-agent/backend/internal/httpx"
|
"senlinai-agent/backend/internal/httpx"
|
||||||
"senlinai-agent/backend/internal/logic/files"
|
|
||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -215,123 +212,6 @@ func TestUpdateProject(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTaskHandlerPersistsTask(t *testing.T) {
|
|
||||||
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
||||||
body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z", "tag": "需求"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1/tasks", bytes.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer test-token")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, rec.Code)
|
|
||||||
var task models.SenlinAgentTask
|
|
||||||
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "整理需求").First(&task).Error)
|
|
||||||
require.Equal(t, "open", task.Status)
|
|
||||||
require.NotNil(t, task.DueAt)
|
|
||||||
require.NotNil(t, task.TagID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) {
|
|
||||||
router, project, storageDir := newProjectsHandlerTestRouter(t)
|
|
||||||
body := &bytes.Buffer{}
|
|
||||||
writer := multipart.NewWriter(body)
|
|
||||||
require.NoError(t, writer.WriteField("title", "客户资料.txt"))
|
|
||||||
fileWriter, err := writer.CreateFormFile("file", "客户资料.txt")
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = fileWriter.Write([]byte("hello"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, writer.Close())
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1/sources", body)
|
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
||||||
req.Header.Set("Authorization", "Bearer test-token")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, rec.Code)
|
|
||||||
var source models.SenlinAgentSource
|
|
||||||
require.NoError(t, models.DBService.Where("project_id = ? AND kind = ?", project.ID, "file").First(&source).Error)
|
|
||||||
require.Equal(t, "客户资料.txt", source.Title)
|
|
||||||
require.NotEmpty(t, source.FilePath)
|
|
||||||
require.FileExists(t, filepath.Join(storageDir, source.FilePath))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateCronPlanHandlerPersistsPlan(t *testing.T) {
|
|
||||||
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
||||||
body, err := json.Marshal(gin.H{"title": "每日整理", "schedule": "0 9 * * *", "enabled": true, "nextRunAt": "2026-07-22T08:00:00Z"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1/cron-plans", bytes.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer test-token")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, rec.Code)
|
|
||||||
var plan models.SenlinAgentCronPlan
|
|
||||||
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "每日整理").First(&plan).Error)
|
|
||||||
require.Equal(t, "0 9 * * *", plan.Schedule)
|
|
||||||
require.True(t, plan.Enabled)
|
|
||||||
require.NotNil(t, plan.NextRunAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTagHandlerPersistsProjectTag(t *testing.T) {
|
|
||||||
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
||||||
body, err := json.Marshal(gin.H{"name": "Design"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1/tags", bytes.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer test-token")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, rec.Code)
|
|
||||||
var tag models.SenlinAgentTag
|
|
||||||
require.NoError(t, models.DBService.Where("project_id = ? AND name = ?", project.ID, "Design").First(&tag).Error)
|
|
||||||
require.Equal(t, project.ID, tag.ProjectID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListTagsHandlerReturnsProjectTags(t *testing.T) {
|
|
||||||
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
||||||
require.NoError(t, models.DBService.Create(&models.SenlinAgentTag{ProjectID: project.ID, Name: "Design"}).Error)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/1/tags", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer test-token")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, rec.Code)
|
|
||||||
var tags []models.SenlinAgentTag
|
|
||||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &tags))
|
|
||||||
require.Len(t, tags, 1)
|
|
||||||
require.Equal(t, "Design", tags[0].Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateTaskHandlerPersistsTagAndStatus(t *testing.T) {
|
|
||||||
router, project, _ := newProjectsHandlerTestRouter(t)
|
|
||||||
task := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: 1, Title: "Draft", Status: "open"}
|
|
||||||
require.NoError(t, models.DBService.Create(&task).Error)
|
|
||||||
body, err := json.Marshal(gin.H{"title": "Draft v2", "description": "Updated", "completed": true, "tag": "Important"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/1/tasks/1", bytes.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer test-token")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, rec.Code)
|
|
||||||
var updated models.SenlinAgentTask
|
|
||||||
require.NoError(t, models.DBService.First(&updated, task.ID).Error)
|
|
||||||
require.Equal(t, "Draft v2", updated.Title)
|
|
||||||
require.Equal(t, "done", updated.Status)
|
|
||||||
require.NotNil(t, updated.TagID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgentProject, string) {
|
func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgentProject, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
newTestDB(t)
|
newTestDB(t)
|
||||||
@@ -339,13 +219,12 @@ func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgen
|
|||||||
service := NewService()
|
service := NewService()
|
||||||
project, err := service.CreateProject(1, "Alpha", "")
|
project, err := service.CreateProject(1, "Alpha", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
storageDir := t.TempDir()
|
|
||||||
router := httpx.NewProtectedRouter(
|
router := httpx.NewProtectedRouter(
|
||||||
config.Config{Env: "test"},
|
config.Config{Env: "test"},
|
||||||
func(token string) (uint, error) { return 1, nil },
|
func(token string) (uint, error) { return 1, nil },
|
||||||
NewHandler(service, files.NewService(storageDir)),
|
NewHandler(service),
|
||||||
)
|
)
|
||||||
return router, project, storageDir
|
return router, project, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapKeys(value map[string]any) []string {
|
func mapKeys(value map[string]any) []string {
|
||||||
|
|||||||
@@ -8,97 +8,6 @@ import (
|
|||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput) (*models.SenlinAgentTask, error) {
|
|
||||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
title := strings.TrimSpace(input.Title)
|
|
||||||
if title == "" {
|
|
||||||
return nil, errors.New("task title is required")
|
|
||||||
}
|
|
||||||
status := strings.TrimSpace(input.Status)
|
|
||||||
if status == "" {
|
|
||||||
status = "open"
|
|
||||||
}
|
|
||||||
tagID, err := s.findOrCreateTagID(projectID, input.Tag)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
task := &models.SenlinAgentTask{
|
|
||||||
ProjectID: projectID, CreatedBy: ownerID, TagID: tagID, Title: title,
|
|
||||||
Description: strings.TrimSpace(input.Description), Status: status, DueAt: input.DueAt,
|
|
||||||
}
|
|
||||||
return task, models.DBService.Create(task).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) UpdateTask(ownerID uint, projectID uint, taskID uint, input UpdateTaskInput) (*models.SenlinAgentTask, error) {
|
|
||||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var task models.SenlinAgentTask
|
|
||||||
if err := models.DBService.Where("id = ? AND project_id = ?", taskID, projectID).First(&task).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
nextProjectID := input.NextProjectID
|
|
||||||
if nextProjectID == 0 {
|
|
||||||
nextProjectID = task.ProjectID
|
|
||||||
}
|
|
||||||
if err := ensureProjectOwner(ownerID, nextProjectID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
title := strings.TrimSpace(input.Title)
|
|
||||||
if title == "" {
|
|
||||||
return nil, errors.New("task title is required")
|
|
||||||
}
|
|
||||||
tagID, err := s.findOrCreateTagID(nextProjectID, input.Tag)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
projectIdentity, err := projectIdentity(nextProjectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tagIdentity, err := optionalTagIdentity(tagID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
status := strings.TrimSpace(input.Status)
|
|
||||||
if status == "" {
|
|
||||||
if input.Completed {
|
|
||||||
status = "done"
|
|
||||||
} else {
|
|
||||||
status = "open"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
task.ProjectID = nextProjectID
|
|
||||||
task.ProjectIdentity = projectIdentity
|
|
||||||
task.Title = title
|
|
||||||
task.Description = strings.TrimSpace(input.Description)
|
|
||||||
task.Status = status
|
|
||||||
task.TagID = tagID
|
|
||||||
task.TagIdentity = tagIdentity
|
|
||||||
if err := models.DBService.Save(&task).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &task, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) {
|
|
||||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
title := strings.TrimSpace(input.Title)
|
|
||||||
if title == "" {
|
|
||||||
return nil, errors.New("source title is required")
|
|
||||||
}
|
|
||||||
filePath := strings.TrimSpace(input.FilePath)
|
|
||||||
if filePath == "" {
|
|
||||||
return nil, errors.New("source file path is required")
|
|
||||||
}
|
|
||||||
source := &models.SenlinAgentSource{ProjectID: projectID, CreatedBy: ownerID, Kind: "file", Title: title, FilePath: filePath}
|
|
||||||
return source, models.DBService.Create(source).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronPlanInput) (*models.SenlinAgentCronPlan, error) {
|
func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronPlanInput) (*models.SenlinAgentCronPlan, error) {
|
||||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -155,44 +64,13 @@ func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
|
|||||||
return tags, err
|
return tags, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) findOrCreateTagID(projectID uint, name string) (*uint, error) {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
if name == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
tag, err := s.CreateTag(projectID, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &tag.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureProjectOwner(ownerID uint, projectID uint) error {
|
func ensureProjectOwner(ownerID uint, projectID uint) error {
|
||||||
var count int64
|
var count int64
|
||||||
if err := models.DBService.Model(&models.SenlinAgentProject{}).Where("id = ? AND owner_id = ?", projectID, ownerID).Count(&count).Error; err != nil {
|
if err := models.DBService.Model(&models.SenlinAgentProject{}).Where("id = ? AND owner_id = ?", projectID, ownerID).Count(&count).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return errors.New("project not found")
|
return gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func projectIdentity(projectID uint) (string, error) {
|
|
||||||
var project models.SenlinAgentProject
|
|
||||||
if err := models.DBService.Select("identity").First(&project, projectID).Error; err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return project.Identity, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func optionalTagIdentity(tagID *uint) (*string, error) {
|
|
||||||
if tagID == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var tag models.SenlinAgentTag
|
|
||||||
if err := models.DBService.Select("identity").First(&tag, *tagID).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &tag.Identity, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -203,73 +203,6 @@ func TestWorkspaceRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
|
|
||||||
database := newTestDB(t)
|
|
||||||
service := NewService()
|
|
||||||
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
|
|
||||||
project, err := service.CreateProject(1, "Alpha", "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
dueAt := time.Date(2026, 7, 21, 9, 30, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
task, err := service.CreateTask(1, project.ID, CreateTaskInput{
|
|
||||||
Title: "Follow up with client",
|
|
||||||
Description: "整理客户反馈并形成行动项",
|
|
||||||
Status: "",
|
|
||||||
DueAt: &dueAt,
|
|
||||||
Tag: "客户反馈",
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, project.ID, task.ProjectID)
|
|
||||||
require.Equal(t, uint(1), task.CreatedBy)
|
|
||||||
require.Equal(t, "Follow up with client", task.Title)
|
|
||||||
require.Equal(t, "open", task.Status)
|
|
||||||
require.NotNil(t, task.DueAt)
|
|
||||||
require.NotNil(t, task.TagID)
|
|
||||||
workspace, err := service.Workspace(1, project.Identity)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, workspace.Tasks, 1)
|
|
||||||
require.Equal(t, "Follow up with client", workspace.Tasks[0].Title)
|
|
||||||
require.Equal(t, "客户反馈", workspace.Tasks[0].Tag)
|
|
||||||
require.Equal(t, []string{"客户反馈"}, workspaceTagNames(workspace.Tags))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
|
||||||
newTestDB(t)
|
|
||||||
service := NewService()
|
|
||||||
project, err := service.CreateProject(2, "Beta", "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = service.CreateTask(1, project.ID, CreateTaskInput{Title: "Nope"})
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateTaskPersistsTagAndCompletion(t *testing.T) {
|
|
||||||
newTestDB(t)
|
|
||||||
service := NewService()
|
|
||||||
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
|
|
||||||
project, err := service.CreateProject(1, "Alpha", "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
task, err := service.CreateTask(1, project.ID, CreateTaskInput{Title: "Draft proposal", Tag: "客户"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
updated, err := service.UpdateTask(1, project.ID, task.ID, UpdateTaskInput{
|
|
||||||
Title: "Draft proposal v2",
|
|
||||||
Description: "Updated scope",
|
|
||||||
Completed: true,
|
|
||||||
Tag: "重要",
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "Draft proposal v2", updated.Title)
|
|
||||||
require.Equal(t, "done", updated.Status)
|
|
||||||
workspace, err := service.Workspace(1, project.Identity)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "重要", workspace.Tasks[0].Tag)
|
|
||||||
require.Equal(t, []string{"客户", "重要"}, workspaceTagNames(workspace.Tags))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateProjectTagAddsTagToOwnedProject(t *testing.T) {
|
func TestCreateProjectTagAddsTagToOwnedProject(t *testing.T) {
|
||||||
newTestDB(t)
|
newTestDB(t)
|
||||||
service := NewService()
|
service := NewService()
|
||||||
@@ -297,28 +230,6 @@ func TestCreateProjectTagRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateFileSourceAddsSourceToOwnedProject(t *testing.T) {
|
|
||||||
database := newTestDB(t)
|
|
||||||
service := NewService()
|
|
||||||
project, err := service.CreateProject(1, "Alpha", "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
source, err := service.CreateFileSource(1, project.ID, CreateFileSourceInput{
|
|
||||||
Title: "客户访谈.pdf",
|
|
||||||
FilePath: "projects/1/客户访谈.pdf",
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, project.ID, source.ProjectID)
|
|
||||||
require.Equal(t, uint(1), source.CreatedBy)
|
|
||||||
require.Equal(t, "file", source.Kind)
|
|
||||||
require.Equal(t, "客户访谈.pdf", source.Title)
|
|
||||||
require.Equal(t, "projects/1/客户访谈.pdf", source.FilePath)
|
|
||||||
var count int64
|
|
||||||
require.NoError(t, database.Model(&models.SenlinAgentSource{}).Where("project_id = ? AND kind = ?", project.ID, "file").Count(&count).Error)
|
|
||||||
require.Equal(t, int64(1), count)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateCronPlanAddsPlanToOwnedProject(t *testing.T) {
|
func TestCreateCronPlanAddsPlanToOwnedProject(t *testing.T) {
|
||||||
database := newTestDB(t)
|
database := newTestDB(t)
|
||||||
service := NewService()
|
service := NewService()
|
||||||
|
|||||||
82
backend/internal/logic/projects/tag_handlers.go
Normal file
82
backend/internal/logic/projects/tag_handlers.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"senlinai-agent/backend/internal/httpx"
|
||||||
|
"senlinai-agent/backend/internal/logic/auth"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TagHandler 单独承载项目内标签接口,避免项目核心 handler 混入对象写职责。
|
||||||
|
type TagHandler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTagHandler 创建项目标签 HTTP registrar。
|
||||||
|
func NewTagHandler(service *Service) *TagHandler {
|
||||||
|
return &TagHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 注册项目范围内的标签读写接口。
|
||||||
|
func (h *TagHandler) Register(router gin.IRouter) {
|
||||||
|
router.GET("/projects/:id/tags", h.list)
|
||||||
|
router.POST("/projects/:id/tags", h.create)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TagHandler) list(c *gin.Context) {
|
||||||
|
userID, project, ok := ownedProjectFromRequest(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags, err := h.service.ListProjectTags(userID, project.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeProjectError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := make([]WorkspaceTagDTO, 0, len(tags))
|
||||||
|
for _, tag := range tags {
|
||||||
|
result = append(result, WorkspaceTagDTO{ID: tag.Identity, Name: tag.Name})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TagHandler) create(c *gin.Context) {
|
||||||
|
userID, project, ok := ownedProjectFromRequest(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag, err := h.service.CreateProjectTag(userID, project.ID, input.Name)
|
||||||
|
if err != nil {
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_request", "标签名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, WorkspaceTagDTO{ID: tag.Identity, Name: tag.Name})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownedProjectFromRequest(c *gin.Context) (uint, *models.SenlinAgentProject, bool) {
|
||||||
|
userID, ok := auth.CurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效")
|
||||||
|
return 0, nil, false
|
||||||
|
}
|
||||||
|
identity, ok := httpx.IdentityParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return 0, nil, false
|
||||||
|
}
|
||||||
|
// 标签接口先以 owner_id + identity 锁定项目,避免形成全局标签或越权读写。
|
||||||
|
project, err := FindOwnedProject(userID, identity)
|
||||||
|
if err != nil {
|
||||||
|
writeProjectError(c, err)
|
||||||
|
return 0, nil, false
|
||||||
|
}
|
||||||
|
return userID, project, true
|
||||||
|
}
|
||||||
98
backend/internal/logic/projects/write_handlers_test.go
Normal file
98
backend/internal/logic/projects/write_handlers_test.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"senlinai-agent/backend/internal/config"
|
||||||
|
"senlinai-agent/backend/internal/httpx"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTagRegistrarUsesIdentityAndListsOnlyProjectTags(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"}).Error)
|
||||||
|
first := models.SenlinAgentProject{OwnerID: 1, Name: "Alpha", Identifier: "ALPHA"}
|
||||||
|
second := models.SenlinAgentProject{OwnerID: 1, Name: "Beta", Identifier: "BETA"}
|
||||||
|
require.NoError(t, database.Create(&first).Error)
|
||||||
|
require.NoError(t, database.Create(&second).Error)
|
||||||
|
require.NoError(t, database.Create(&models.SenlinAgentTag{ProjectID: second.ID, Name: "其他项目"}).Error)
|
||||||
|
router := newProjectWriteHandlerRouter(t, 1, NewTagHandler(NewService()))
|
||||||
|
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+first.Identity+"/tags", bytes.NewBufferString(`{"name":"设计"}`))
|
||||||
|
createReq.Header.Set("Content-Type", "application/json")
|
||||||
|
createReq.Header.Set("Authorization", "Bearer test-token")
|
||||||
|
createRec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(createRec, createReq)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusCreated, createRec.Code, createRec.Body.String())
|
||||||
|
var created map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(createRec.Body.Bytes(), &created))
|
||||||
|
require.NotEmpty(t, created["id"])
|
||||||
|
require.Equal(t, "设计", created["name"])
|
||||||
|
require.ElementsMatch(t, []string{"id", "name"}, mapKeys(created))
|
||||||
|
|
||||||
|
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+first.Identity+"/tags", nil)
|
||||||
|
listReq.Header.Set("Authorization", "Bearer test-token")
|
||||||
|
listRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(listRec, listReq)
|
||||||
|
require.Equal(t, http.StatusOK, listRec.Code, listRec.Body.String())
|
||||||
|
var listed []WorkspaceTagDTO
|
||||||
|
require.NoError(t, json.Unmarshal(listRec.Body.Bytes(), &listed))
|
||||||
|
require.Len(t, listed, 1)
|
||||||
|
require.Equal(t, "设计", listed[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagRegistrarRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"}).Error)
|
||||||
|
other := models.SenlinAgentProject{OwnerID: 2, Name: "Private", Identifier: "PRIVATE"}
|
||||||
|
require.NoError(t, database.Create(&other).Error)
|
||||||
|
router := newProjectWriteHandlerRouter(t, 1, NewTagHandler(NewService()))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+other.Identity+"/tags", bytes.NewBufferString(`{"name":"越权"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer test-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronRegistrarCreatesCamelCaseIdentityDTO(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"}).Error)
|
||||||
|
project := models.SenlinAgentProject{OwnerID: 1, Name: "Alpha", Identifier: "ALPHA"}
|
||||||
|
require.NoError(t, database.Create(&project).Error)
|
||||||
|
router := newProjectWriteHandlerRouter(t, 1, NewCronHandler(NewService()))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/cron-plans", bytes.NewBufferString(`{"title":"每日整理","schedule":"0 9 * * *","enabled":true,"nextRunAt":"2026-07-22T08:00:00Z"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer test-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String())
|
||||||
|
var plan models.SenlinAgentCronPlan
|
||||||
|
require.NoError(t, database.Where("project_id = ?", project.ID).First(&plan).Error)
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||||
|
require.Equal(t, plan.Identity, payload["id"])
|
||||||
|
require.Equal(t, project.Identity, payload["projectId"])
|
||||||
|
require.Equal(t, "2026-07-22T08:00:00Z", payload["nextRunAt"])
|
||||||
|
require.NotContains(t, payload, "ProjectID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProjectWriteHandlerRouter(t *testing.T, currentUserID uint, registrar httpx.RouteRegistrar) *gin.Engine {
|
||||||
|
t.Helper()
|
||||||
|
return httpx.NewProtectedRouter(
|
||||||
|
config.Config{Env: "test"},
|
||||||
|
func(string) (uint, error) { return currentUserID, nil },
|
||||||
|
registrar,
|
||||||
|
)
|
||||||
|
}
|
||||||
39
backend/internal/logic/tasks/dto.go
Normal file
39
backend/internal/logic/tasks/dto.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package tasks
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// TaskDTO 是任务写接口的稳定响应,所有关联 ID 都使用公开 identity。
|
||||||
|
type TaskDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"projectId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
DueAt *time.Time `json:"dueAt"`
|
||||||
|
AssigneeID *string `json:"assigneeId"`
|
||||||
|
TagID *string `json:"tagId"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
SourceInboxItemID *string `json:"sourceInboxItemId"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTaskInput 描述创建任务时允许写入的项目内字段。
|
||||||
|
type CreateTaskInput struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Status string
|
||||||
|
DueAt *time.Time
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTaskInput 使用目标项目 identity 表达移动,避免 API 接触内部自增 ID。
|
||||||
|
type UpdateTaskInput struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Status string
|
||||||
|
Completed bool
|
||||||
|
NextProjectIdentity string
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
153
backend/internal/logic/tasks/handlers.go
Normal file
153
backend/internal/logic/tasks/handlers.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package tasks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"senlinai-agent/backend/internal/httpx"
|
||||||
|
"senlinai-agent/backend/internal/logic/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler 只注册任务写接口,项目核心 handler 不再承担任务职责。
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler 创建任务 HTTP registrar。
|
||||||
|
func NewHandler(service *Service) *Handler {
|
||||||
|
return &Handler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 将任务写接口注册到上层提供的 /api/v1 路由组。
|
||||||
|
func (h *Handler) Register(router gin.IRouter) {
|
||||||
|
router.POST("/projects/:id/tasks", h.create)
|
||||||
|
router.PATCH("/projects/:id/tasks/:taskId", h.update)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createTaskRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
DueAt string `json:"dueAt"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateTaskRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
NextProjectID string `json:"nextProjectId"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) create(c *gin.Context) {
|
||||||
|
userID, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectIdentity, ok := httpx.IdentityParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input createTaskRequest
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dueAt, err := parseOptionalTime(input.DueAt)
|
||||||
|
if err != nil {
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_request", "截止时间格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.service.Create(userID, projectIdentity, CreateTaskInput{
|
||||||
|
Title: input.Title, Description: input.Description, Status: input.Status, DueAt: dueAt, Tag: input.Tag,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeTaskError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) update(c *gin.Context) {
|
||||||
|
userID, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectIdentity, ok := httpx.IdentityParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskIdentity, ok := httpx.IdentityParam(c, "taskId")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input updateTaskRequest
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.NextProjectID) != "" {
|
||||||
|
if _, ok := parseIdentity(input.NextProjectID); !ok {
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_identity", "nextProjectId 必须是 UUIDv7")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task, err := h.service.Update(userID, projectIdentity, taskIdentity, UpdateTaskInput{
|
||||||
|
Title: input.Title, Description: input.Description, Status: input.Status, Completed: input.Completed,
|
||||||
|
NextProjectIdentity: input.NextProjectID, Tag: input.Tag,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeTaskError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentUser(c *gin.Context) (uint, bool) {
|
||||||
|
userID, ok := auth.CurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return userID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalTime(value string) (*time.Time, error) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(time.RFC3339, trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parsed = parsed.UTC()
|
||||||
|
return &parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIdentity(value string) (string, bool) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
identity, err := uuid.Parse(trimmed)
|
||||||
|
if err != nil || identity.Version() != 7 || identity.Variant() != uuid.RFC4122 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return identity.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTaskError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
httpx.Error(c, http.StatusNotFound, "not_found", "任务或项目不存在")
|
||||||
|
case errors.Is(err, ErrTaskTitleRequired):
|
||||||
|
httpx.Error(c, http.StatusBadRequest, "invalid_request", "任务标题不能为空")
|
||||||
|
default:
|
||||||
|
httpx.Error(c, http.StatusInternalServerError, "internal_error", "任务操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
109
backend/internal/logic/tasks/handlers_test.go
Normal file
109
backend/internal/logic/tasks/handlers_test.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package tasks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"senlinai-agent/backend/internal/config"
|
||||||
|
"senlinai-agent/backend/internal/httpx"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTaskRegistrarCreatesIdentityDTOForOwnedProject(t *testing.T) {
|
||||||
|
router, database, project := newTaskHandlerTestRouter(t, 1)
|
||||||
|
body := bytes.NewBufferString(`{"title":"整理访谈","description":"提取行动项","dueAt":"2026-07-22T08:00:00Z","tag":"客户"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/tasks", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer test-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String())
|
||||||
|
var task models.SenlinAgentTask
|
||||||
|
require.NoError(t, database.Where("project_id = ?", project.ID).First(&task).Error)
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||||
|
require.Equal(t, task.Identity, payload["id"])
|
||||||
|
require.Equal(t, project.Identity, payload["projectId"])
|
||||||
|
require.Equal(t, "客户", payload["tag"])
|
||||||
|
require.NotEmpty(t, payload["tagId"])
|
||||||
|
require.NotEmpty(t, payload["createdAt"])
|
||||||
|
require.NotContains(t, payload, "ID")
|
||||||
|
require.NotContains(t, payload, "ProjectID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskRegistrarMovesTaskByIdentityAndClearsForeignProjectTag(t *testing.T) {
|
||||||
|
router, database, first := newTaskHandlerTestRouter(t, 1)
|
||||||
|
second := models.SenlinAgentProject{OwnerID: 1, Name: "Beta", Identifier: "BETA"}
|
||||||
|
require.NoError(t, database.Create(&second).Error)
|
||||||
|
tag := models.SenlinAgentTag{ProjectID: first.ID, Name: "仅 Alpha"}
|
||||||
|
require.NoError(t, database.Create(&tag).Error)
|
||||||
|
task := models.SenlinAgentTask{ProjectID: first.ID, CreatedBy: 1, TagID: &tag.ID, Title: "迁移任务", Status: "open"}
|
||||||
|
require.NoError(t, database.Create(&task).Error)
|
||||||
|
note := models.SenlinAgentNote{ProjectID: first.ID, CreatedBy: 1, Title: "旧项目资料", Markdown: "仅可在 Alpha 分享"}
|
||||||
|
require.NoError(t, database.Create(¬e).Error)
|
||||||
|
require.NoError(t, NewService(database).ShareObject(task.ID, "note", note.ID))
|
||||||
|
body := bytes.NewBufferString(fmt.Sprintf(`{"title":"迁移任务","description":"已移动","completed":false,"nextProjectId":%q}`, second.Identity))
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+first.Identity+"/tasks/"+task.Identity, body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer test-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
require.NoError(t, database.First(&task, task.ID).Error)
|
||||||
|
require.Equal(t, second.ID, task.ProjectID)
|
||||||
|
require.Nil(t, task.TagID)
|
||||||
|
var shareCount int64
|
||||||
|
require.NoError(t, database.Model(&models.SenlinAgentTaskShare{}).Where("task_id = ?", task.ID).Count(&shareCount).Error)
|
||||||
|
require.Zero(t, shareCount, "移动项目后不能保留旧项目的显式分享")
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||||
|
require.Equal(t, second.Identity, payload["projectId"])
|
||||||
|
require.Nil(t, payload["tagId"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskRegistrarRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||||
|
router, database, _ := newTaskHandlerTestRouter(t, 1)
|
||||||
|
other := models.SenlinAgentProject{OwnerID: 2, Name: "Private", Identifier: "PRIVATE"}
|
||||||
|
require.NoError(t, database.Create(&other).Error)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+other.Identity+"/tasks", bytes.NewBufferString(`{"title":"越权"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer test-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String())
|
||||||
|
var count int64
|
||||||
|
require.NoError(t, database.Model(&models.SenlinAgentTask{}).Where("project_id = ?", other.ID).Count(&count).Error)
|
||||||
|
require.Zero(t, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTaskHandlerTestRouter(t *testing.T, currentUserID uint) (*gin.Engine, *gorm.DB, models.SenlinAgentProject) {
|
||||||
|
t.Helper()
|
||||||
|
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{TranslateError: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, models.AutoMigrate(database))
|
||||||
|
models.DBService = database
|
||||||
|
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"}).Error)
|
||||||
|
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "other@example.com", DisplayName: "Other", PasswordHash: "hash"}).Error)
|
||||||
|
project := models.SenlinAgentProject{OwnerID: 1, Name: "Alpha", Identifier: "ALPHA"}
|
||||||
|
require.NoError(t, database.Create(&project).Error)
|
||||||
|
router := httpx.NewProtectedRouter(
|
||||||
|
config.Config{Env: "test"},
|
||||||
|
func(string) (uint, error) { return currentUserID, nil },
|
||||||
|
NewHandler(NewService(database)),
|
||||||
|
)
|
||||||
|
return router, database, project
|
||||||
|
}
|
||||||
@@ -3,12 +3,15 @@ package tasks
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
type LinkedObject struct {
|
type LinkedObject struct {
|
||||||
@@ -16,12 +19,24 @@ type LinkedObject struct {
|
|||||||
ObjectID uint `json:"object_id"`
|
ObjectID uint `json:"object_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService() *Service {
|
// NewService 接受数据库或上层事务,确保任务写入、标签调整和分享检查使用同一依赖。
|
||||||
return &Service{}
|
func NewService(databases ...*gorm.DB) *Service {
|
||||||
|
var database *gorm.DB
|
||||||
|
if len(databases) > 0 {
|
||||||
|
database = databases[0]
|
||||||
|
}
|
||||||
|
return &Service{db: database}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) database() *gorm.DB {
|
||||||
|
if s.db != nil {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
return models.DBService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Assign(taskID uint, assigneeID uint) error {
|
func (s *Service) Assign(taskID uint, assigneeID uint) error {
|
||||||
return models.DBService.Transaction(func(tx *gorm.DB) error {
|
return s.database().Transaction(func(tx *gorm.DB) error {
|
||||||
var task models.SenlinAgentTask
|
var task models.SenlinAgentTask
|
||||||
if err := tx.First(&task, taskID).Error; err != nil {
|
if err := tx.First(&task, taskID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -40,11 +55,12 @@ func (s *Service) Assign(taskID uint, assigneeID uint) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShareObject 只允许显式分享 note/source,并在同一事务中验证关联对象属于任务所在项目。
|
||||||
func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) error {
|
func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) error {
|
||||||
if objectType != "note" && objectType != "source" {
|
if objectType != "note" && objectType != "source" {
|
||||||
return errors.New("unsupported shared object type")
|
return errors.New("unsupported shared object type")
|
||||||
}
|
}
|
||||||
return models.DBService.Transaction(func(tx *gorm.DB) error {
|
return s.database().Transaction(func(tx *gorm.DB) error {
|
||||||
var task models.SenlinAgentTask
|
var task models.SenlinAgentTask
|
||||||
if err := tx.First(&task, taskID).Error; err != nil {
|
if err := tx.First(&task, taskID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -66,16 +82,17 @@ func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) err
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VisibleLinkedObjects 对被指派人也只返回显式分享记录,不因任务可见而扩大关联对象权限。
|
||||||
func (s *Service) VisibleLinkedObjects(taskID uint, viewerID uint) ([]LinkedObject, error) {
|
func (s *Service) VisibleLinkedObjects(taskID uint, viewerID uint) ([]LinkedObject, error) {
|
||||||
var task models.SenlinAgentTask
|
var task models.SenlinAgentTask
|
||||||
if err := models.DBService.First(&task, taskID).Error; err != nil {
|
if err := s.database().First(&task, taskID).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if task.AssigneeID == nil || *task.AssigneeID != viewerID {
|
if task.AssigneeID == nil || *task.AssigneeID != viewerID {
|
||||||
return []LinkedObject{}, nil
|
return []LinkedObject{}, nil
|
||||||
}
|
}
|
||||||
var shares []models.SenlinAgentTaskShare
|
var shares []models.SenlinAgentTaskShare
|
||||||
if err := models.DBService.Where("task_id = ?", taskID).Find(&shares).Error; err != nil {
|
if err := s.database().Where("task_id = ?", taskID).Find(&shares).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
objects := make([]LinkedObject, 0, len(shares))
|
objects := make([]LinkedObject, 0, len(shares))
|
||||||
@@ -85,6 +102,143 @@ func (s *Service) VisibleLinkedObjects(taskID uint, viewerID uint) ([]LinkedObje
|
|||||||
return objects, nil
|
return objects, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ErrTaskTitleRequired = errors.New("task title is required")
|
||||||
|
|
||||||
|
// Create 在同一事务中校验项目所有权、解析项目内标签并创建任务。
|
||||||
|
func (s *Service) Create(ownerID uint, projectIdentity string, input CreateTaskInput) (TaskDTO, error) {
|
||||||
|
var result TaskDTO
|
||||||
|
err := s.database().Transaction(func(tx *gorm.DB) error {
|
||||||
|
project, err := findOwnedProject(tx, ownerID, projectIdentity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(input.Title)
|
||||||
|
if title == "" {
|
||||||
|
return ErrTaskTitleRequired
|
||||||
|
}
|
||||||
|
status := strings.TrimSpace(input.Status)
|
||||||
|
if status == "" {
|
||||||
|
status = "open"
|
||||||
|
}
|
||||||
|
tagID, tagIdentity, tagName, err := findOrCreateProjectTag(tx, project.ID, input.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
task := models.SenlinAgentTask{
|
||||||
|
ProjectID: project.ID, CreatedBy: ownerID, TagID: tagID,
|
||||||
|
Title: title, Description: strings.TrimSpace(input.Description), Status: status, DueAt: utcOptionalTime(input.DueAt),
|
||||||
|
}
|
||||||
|
if err := tx.Create(&task).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result = makeTaskDTO(task, project.Identity, tagIdentity, tagName)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 同时按项目和任务 identity 查询;移动项目时重新校验所有权,并重新解析目标项目标签。
|
||||||
|
func (s *Service) Update(ownerID uint, projectIdentity, taskIdentity string, input UpdateTaskInput) (TaskDTO, error) {
|
||||||
|
var result TaskDTO
|
||||||
|
err := s.database().Transaction(func(tx *gorm.DB) error {
|
||||||
|
currentProject, err := findOwnedProject(tx, ownerID, projectIdentity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var task models.SenlinAgentTask
|
||||||
|
if err := tx.Where("identity = ? AND project_id = ?", taskIdentity, currentProject.ID).First(&task).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
targetProject := currentProject
|
||||||
|
if strings.TrimSpace(input.NextProjectIdentity) != "" && input.NextProjectIdentity != currentProject.Identity {
|
||||||
|
targetProject, err = findOwnedProject(tx, ownerID, input.NextProjectIdentity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(input.Title)
|
||||||
|
if title == "" {
|
||||||
|
return ErrTaskTitleRequired
|
||||||
|
}
|
||||||
|
status := strings.TrimSpace(input.Status)
|
||||||
|
if status == "" {
|
||||||
|
if input.Completed {
|
||||||
|
status = "done"
|
||||||
|
} else {
|
||||||
|
status = "open"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签只能在目标项目内重新解析;未提供标签时会清空旧项目标签,不能跨项目沿用。
|
||||||
|
tagID, tagIdentity, tagName, err := findOrCreateProjectTag(tx, targetProject.ID, input.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
task.ProjectID = targetProject.ID
|
||||||
|
task.ProjectIdentity = targetProject.Identity
|
||||||
|
task.Title = title
|
||||||
|
task.Description = strings.TrimSpace(input.Description)
|
||||||
|
task.Status = status
|
||||||
|
task.TagID = tagID
|
||||||
|
task.TagIdentity = tagIdentity
|
||||||
|
if targetProject.ID != currentProject.ID {
|
||||||
|
// 现有分享都在原项目边界内;移动后必须清空,避免旧项目 note/source 继续对被指派人可见。
|
||||||
|
if err := tx.Where("task_id = ?", task.ID).Delete(&models.SenlinAgentTaskShare{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Save(&task).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result = makeTaskDTO(task, targetProject.Identity, tagIdentity, tagName)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func findOwnedProject(tx *gorm.DB, ownerID uint, identity string) (*models.SenlinAgentProject, error) {
|
||||||
|
var project models.SenlinAgentProject
|
||||||
|
if err := tx.Where("owner_id = ? AND identity = ?", ownerID, identity).First(&project).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &project, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findOrCreateProjectTag(tx *gorm.DB, projectID uint, value string) (*uint, *string, string, error) {
|
||||||
|
name := strings.TrimSpace(value)
|
||||||
|
if name == "" {
|
||||||
|
return nil, nil, "", nil
|
||||||
|
}
|
||||||
|
var tag models.SenlinAgentTag
|
||||||
|
err := tx.Where("project_id = ? AND name = ?", projectID, name).First(&tag).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
tag = models.SenlinAgentTag{ProjectID: projectID, Name: name}
|
||||||
|
if err := tx.Create(&tag).Error; err != nil {
|
||||||
|
return nil, nil, "", err
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, nil, "", err
|
||||||
|
}
|
||||||
|
return &tag.ID, &tag.Identity, tag.Name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTaskDTO(task models.SenlinAgentTask, projectIdentity string, tagIdentity *string, tagName string) TaskDTO {
|
||||||
|
return TaskDTO{
|
||||||
|
ID: task.Identity, ProjectID: projectIdentity, Title: task.Title, Description: task.Description,
|
||||||
|
Status: task.Status, Completed: task.Status == "done", DueAt: utcOptionalTime(task.DueAt),
|
||||||
|
AssigneeID: task.AssigneeIdentity, TagID: tagIdentity, Tag: tagName,
|
||||||
|
SourceInboxItemID: task.SourceInboxItemIdentity, CreatedAt: task.CreatedAt.UTC(), UpdatedAt: task.UpdatedAt.UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func utcOptionalTime(value *time.Time) *time.Time {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := value.UTC()
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectType string, objectID uint) error {
|
func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectType string, objectID uint) error {
|
||||||
switch objectType {
|
switch objectType {
|
||||||
case "note":
|
case "note":
|
||||||
|
|||||||
Reference in New Issue
Block a user