From 1fcbb3130114c0b010f41ce36e1c6237b1ae4b3f Mon Sep 17 00:00:00 2001 From: yanweidong Date: Tue, 21 Jul 2026 15:27:11 +0800 Subject: [PATCH] refactor(backend): split project write responsibilities --- backend/cmd/api/main.go | 23 +- .../internal/httpx/router_integration_test.go | 11 +- backend/internal/logic/files/handlers.go | 101 ++++++ backend/internal/logic/files/handlers_test.go | 96 ++++++ backend/internal/logic/files/service.go | 50 ++- backend/internal/logic/inbox/service.go | 1 + .../internal/logic/projects/cron_handlers.go | 98 ++++++ backend/internal/logic/projects/dto.go | 22 -- backend/internal/logic/projects/handlers.go | 287 +----------------- .../internal/logic/projects/handlers_test.go | 125 +------- .../internal/logic/projects/object_crud.go | 124 +------- .../internal/logic/projects/service_test.go | 89 ------ .../internal/logic/projects/tag_handlers.go | 82 +++++ .../logic/projects/write_handlers_test.go | 98 ++++++ backend/internal/logic/tasks/dto.go | 39 +++ backend/internal/logic/tasks/handlers.go | 153 ++++++++++ backend/internal/logic/tasks/handlers_test.go | 109 +++++++ backend/internal/logic/tasks/service.go | 166 +++++++++- 18 files changed, 1025 insertions(+), 649 deletions(-) create mode 100644 backend/internal/logic/files/handlers.go create mode 100644 backend/internal/logic/files/handlers_test.go create mode 100644 backend/internal/logic/projects/cron_handlers.go create mode 100644 backend/internal/logic/projects/tag_handlers.go create mode 100644 backend/internal/logic/projects/write_handlers_test.go create mode 100644 backend/internal/logic/tasks/dto.go create mode 100644 backend/internal/logic/tasks/handlers.go create mode 100644 backend/internal/logic/tasks/handlers_test.go diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 93bab31..096c073 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -9,6 +9,7 @@ import ( "senlinai-agent/backend/internal/logic/files" "senlinai-agent/backend/internal/logic/inbox" "senlinai-agent/backend/internal/logic/projects" + "senlinai-agent/backend/internal/logic/tasks" "senlinai-agent/backend/internal/models" ) @@ -21,10 +22,28 @@ func main() { } 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{})) 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 { log.Fatal(err) } diff --git a/backend/internal/httpx/router_integration_test.go b/backend/internal/httpx/router_integration_test.go index c0967cb..cf11306 100644 --- a/backend/internal/httpx/router_integration_test.go +++ b/backend/internal/httpx/router_integration_test.go @@ -6,16 +6,23 @@ import ( "github.com/stretchr/testify/require" "senlinai-agent/backend/internal/config" "senlinai-agent/backend/internal/httpx" + "senlinai-agent/backend/internal/logic/files" "senlinai-agent/backend/internal/logic/inbox" "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() { + projectService := projects.NewService() httpx.NewProtectedRouter( config.Config{Env: "test"}, 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{})), ) }) diff --git a/backend/internal/logic/files/handlers.go b/backend/internal/logic/files/handlers.go new file mode 100644 index 0000000..c0fc50b --- /dev/null +++ b/backend/internal/logic/files/handlers.go @@ -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", "资料创建失败") + } +} diff --git a/backend/internal/logic/files/handlers_test.go b/backend/internal/logic/files/handlers_test.go new file mode 100644 index 0000000..b7351f2 --- /dev/null +++ b/backend/internal/logic/files/handlers_test.go @@ -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 +} diff --git a/backend/internal/logic/files/service.go b/backend/internal/logic/files/service.go index 9c98c25..8bb827a 100644 --- a/backend/internal/logic/files/service.go +++ b/backend/internal/logic/files/service.go @@ -1,16 +1,21 @@ package files import ( + "errors" "fmt" "io" "os" "path/filepath" "strings" "time" + + "gorm.io/gorm" + "senlinai-agent/backend/internal/models" ) type Service struct { root string + db *gorm.DB } type StoredFile struct { @@ -19,10 +24,16 @@ type StoredFile struct { AbsolutePath string } -func NewService(root string) *Service { - return &Service{root: root} +// NewService 集中持有存储根目录;任何服务端本地路径都只能由该服务构造。 +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) { cleanName := filepath.Base(strings.ReplaceAll(strings.TrimSpace(originalName), "\\", "/")) 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 } + +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 +} diff --git a/backend/internal/logic/inbox/service.go b/backend/internal/logic/inbox/service.go index 2f838ef..1d587d7 100644 --- a/backend/internal/logic/inbox/service.go +++ b/backend/internal/logic/inbox/service.go @@ -77,6 +77,7 @@ func (s *Service) Confirm(itemID uint, selected []Suggestion) error { return err } sourceInboxItemID := item.ID + // Inbox 建议只有在用户确认后才创建正式对象,并把来源 ID 写入每个对象以保留可追溯性。 for _, suggestion := range selected { switch suggestion.Kind { case "task": diff --git a/backend/internal/logic/projects/cron_handlers.go b/backend/internal/logic/projects/cron_handlers.go new file mode 100644 index 0000000..07798b9 --- /dev/null +++ b/backend/internal/logic/projects/cron_handlers.go @@ -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 +} diff --git a/backend/internal/logic/projects/dto.go b/backend/internal/logic/projects/dto.go index 95c2a69..8968e53 100644 --- a/backend/internal/logic/projects/dto.go +++ b/backend/internal/logic/projects/dto.go @@ -34,28 +34,6 @@ type UpdateProjectRequest struct { 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 { Title string Schedule string diff --git a/backend/internal/logic/projects/handlers.go b/backend/internal/logic/projects/handlers.go index a7ab025..ff7d060 100644 --- a/backend/internal/logic/projects/handlers.go +++ b/backend/internal/logic/projects/handlers.go @@ -3,43 +3,30 @@ package projects import ( "errors" "net/http" - "strconv" - "strings" - "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/files" ) +// Handler 仅负责项目核心读写和工作区读取;任务、标签、资料、计划任务由各自 registrar 承担。 type Handler struct { - service *Service - fileService *files.Service + service *Service } -func NewHandler(service *Service, fileServices ...*files.Service) *Handler { - var fileService *files.Service - if len(fileServices) > 0 { - fileService = fileServices[0] - } - return &Handler{service: service, fileService: fileService} +// 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", h.createProject) router.GET("/projects", h.listProjects) router.GET("/projects/:id", h.getProject) router.PATCH("/projects/:id", h.updateProject) - router.GET("/projects/:id/dashboard", h.dashboard) 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) { @@ -115,29 +102,10 @@ func (h *Handler) updateProject(c *gin.Context) { 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) { userID, ok := auth.CurrentUserID(c) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效") return } projectIdentity, ok := httpx.IdentityParam(c, "id") @@ -152,226 +120,6 @@ func (h *Handler) workspace(c *gin.Context) { 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) { switch { case errors.Is(err, gorm.ErrRecordNotFound): @@ -384,24 +132,3 @@ func writeProjectError(c *gin.Context, err 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 -} diff --git a/backend/internal/logic/projects/handlers_test.go b/backend/internal/logic/projects/handlers_test.go index 2dc6785..2bb1bef 100644 --- a/backend/internal/logic/projects/handlers_test.go +++ b/backend/internal/logic/projects/handlers_test.go @@ -3,17 +3,14 @@ package projects import ( "bytes" "encoding/json" - "mime/multipart" "net/http" "net/http/httptest" - "path/filepath" "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/logic/files" "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) { t.Helper() newTestDB(t) @@ -339,13 +219,12 @@ func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgen service := NewService() project, err := service.CreateProject(1, "Alpha", "") require.NoError(t, err) - storageDir := t.TempDir() router := httpx.NewProtectedRouter( config.Config{Env: "test"}, 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 { diff --git a/backend/internal/logic/projects/object_crud.go b/backend/internal/logic/projects/object_crud.go index 3345259..a042a17 100644 --- a/backend/internal/logic/projects/object_crud.go +++ b/backend/internal/logic/projects/object_crud.go @@ -8,97 +8,6 @@ import ( "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) { if err := ensureProjectOwner(ownerID, projectID); err != nil { return nil, err @@ -155,44 +64,13 @@ func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) { 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 { var count int64 if err := models.DBService.Model(&models.SenlinAgentProject{}).Where("id = ? AND owner_id = ?", projectID, ownerID).Count(&count).Error; err != nil { return err } if count == 0 { - return errors.New("project not found") + return gorm.ErrRecordNotFound } 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 -} diff --git a/backend/internal/logic/projects/service_test.go b/backend/internal/logic/projects/service_test.go index 49ab100..11b07e0 100644 --- a/backend/internal/logic/projects/service_test.go +++ b/backend/internal/logic/projects/service_test.go @@ -203,73 +203,6 @@ func TestWorkspaceRejectsProjectOwnedByAnotherUser(t *testing.T) { 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) { newTestDB(t) service := NewService() @@ -297,28 +230,6 @@ func TestCreateProjectTagRejectsProjectOwnedByAnotherUser(t *testing.T) { 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) { database := newTestDB(t) service := NewService() diff --git a/backend/internal/logic/projects/tag_handlers.go b/backend/internal/logic/projects/tag_handlers.go new file mode 100644 index 0000000..c6c9b65 --- /dev/null +++ b/backend/internal/logic/projects/tag_handlers.go @@ -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 +} diff --git a/backend/internal/logic/projects/write_handlers_test.go b/backend/internal/logic/projects/write_handlers_test.go new file mode 100644 index 0000000..8bc1fdb --- /dev/null +++ b/backend/internal/logic/projects/write_handlers_test.go @@ -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, + ) +} diff --git a/backend/internal/logic/tasks/dto.go b/backend/internal/logic/tasks/dto.go new file mode 100644 index 0000000..22f8d7c --- /dev/null +++ b/backend/internal/logic/tasks/dto.go @@ -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 +} diff --git a/backend/internal/logic/tasks/handlers.go b/backend/internal/logic/tasks/handlers.go new file mode 100644 index 0000000..d1ba379 --- /dev/null +++ b/backend/internal/logic/tasks/handlers.go @@ -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", "任务操作失败") + } +} diff --git a/backend/internal/logic/tasks/handlers_test.go b/backend/internal/logic/tasks/handlers_test.go new file mode 100644 index 0000000..2cc765c --- /dev/null +++ b/backend/internal/logic/tasks/handlers_test.go @@ -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 +} diff --git a/backend/internal/logic/tasks/service.go b/backend/internal/logic/tasks/service.go index 90c045d..812c77c 100644 --- a/backend/internal/logic/tasks/service.go +++ b/backend/internal/logic/tasks/service.go @@ -3,12 +3,15 @@ package tasks import ( "errors" "fmt" + "strings" + "time" "gorm.io/gorm" "senlinai-agent/backend/internal/models" ) type Service struct { + db *gorm.DB } type LinkedObject struct { @@ -16,12 +19,24 @@ type LinkedObject struct { ObjectID uint `json:"object_id"` } -func NewService() *Service { - return &Service{} +// NewService 接受数据库或上层事务,确保任务写入、标签调整和分享检查使用同一依赖。 +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 { - return models.DBService.Transaction(func(tx *gorm.DB) error { + return s.database().Transaction(func(tx *gorm.DB) error { var task models.SenlinAgentTask if err := tx.First(&task, taskID).Error; err != nil { 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 { if objectType != "note" && objectType != "source" { 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 if err := tx.First(&task, taskID).Error; err != nil { 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) { 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 } if task.AssigneeID == nil || *task.AssigneeID != viewerID { return []LinkedObject{}, nil } var shares []models.SenlinAgentTaskShare - if err := models.DBService.Where("task_id = ?", taskID).Find(&shares).Error; err != nil { + if err := s.database().Where("task_id = ?", taskID).Find(&shares).Error; err != nil { return nil, err } objects := make([]LinkedObject, 0, len(shares)) @@ -85,6 +102,143 @@ func (s *Service) VisibleLinkedObjects(taskID uint, viewerID uint) ([]LinkedObje 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 { switch objectType { case "note":