refactor(backend): split project write responsibilities

This commit is contained in:
2026-07-21 15:27:11 +08:00
parent 9e862f2a7f
commit 1fcbb31301
18 changed files with 1025 additions and 649 deletions

View 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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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()

View 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
}

View 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,
)
}