From 5341b44cc550e9e80137550040701e1b2f1c08d1 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Tue, 21 Jul 2026 14:22:13 +0800 Subject: [PATCH] refactor(api): expose project identity DTOs --- backend/internal/logic/projects/dto.go | 42 +++++ backend/internal/logic/projects/handlers.go | 86 +++++++--- .../internal/logic/projects/handlers_test.go | 148 ++++++++++++++++++ backend/internal/logic/projects/ownership.go | 20 +++ backend/internal/logic/projects/service.go | 97 ++++++++++-- .../internal/logic/projects/service_test.go | 20 ++- 6 files changed, 378 insertions(+), 35 deletions(-) create mode 100644 backend/internal/logic/projects/dto.go create mode 100644 backend/internal/logic/projects/ownership.go diff --git a/backend/internal/logic/projects/dto.go b/backend/internal/logic/projects/dto.go new file mode 100644 index 0000000..c12fc53 --- /dev/null +++ b/backend/internal/logic/projects/dto.go @@ -0,0 +1,42 @@ +package projects + +import "senlinai-agent/backend/internal/models" + +// ProjectDTO 是项目 API 的稳定响应;ID 使用公开 identity,避免泄漏数据库自增主键。 +type ProjectDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Identifier string `json:"identifier"` + Icon string `json:"icon"` + Background string `json:"background"` + Description string `json:"description"` +} + +// CreateProjectRequest 描述创建项目时允许客户端写入的字段。 +type CreateProjectRequest struct { + Name string `json:"name"` + Identifier string `json:"identifier"` + Icon string `json:"icon"` + Background string `json:"background"` + Description string `json:"description"` +} + +// UpdateProjectRequest 使用指针区分“未提供”和“显式清空”,且只开放项目设置字段。 +type UpdateProjectRequest struct { + Name *string `json:"name"` + Identifier *string `json:"identifier"` + Icon *string `json:"icon"` + Background *string `json:"background"` + Description *string `json:"description"` +} + +func projectDTO(project models.SenlinAgentProject) ProjectDTO { + return ProjectDTO{ + ID: project.Identity, + Name: project.Name, + Identifier: project.Identifier, + Icon: project.Icon, + Background: project.Background, + Description: project.Description, + } +} diff --git a/backend/internal/logic/projects/handlers.go b/backend/internal/logic/projects/handlers.go index 7450654..8f8ff8b 100644 --- a/backend/internal/logic/projects/handlers.go +++ b/backend/internal/logic/projects/handlers.go @@ -1,12 +1,15 @@ 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" ) @@ -27,6 +30,8 @@ func NewHandler(service *Service, fileServices ...*files.Service) *Handler { 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) @@ -40,48 +45,76 @@ func (h *Handler) Register(router gin.IRouter) { func (h *Handler) createProject(c *gin.Context) { userID, ok := auth.CurrentUserID(c) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效") return } - var input struct { - Name string `json:"name"` - Identifier string `json:"identifier"` - Icon string `json:"icon"` - Background string `json:"background"` - Description string `json:"description"` - } + var input CreateProjectRequest if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效") return } - project, err := h.service.CreateProjectWithInput(userID, CreateProjectInput{ - Name: input.Name, - Identifier: input.Identifier, - Icon: input.Icon, - Background: input.Background, - Description: input.Description, - }) + project, err := h.service.CreateProjectWithInput(userID, input) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + writeProjectError(c, err) return } - c.JSON(http.StatusCreated, project) + c.JSON(http.StatusCreated, projectDTO(*project)) } func (h *Handler) listProjects(c *gin.Context) { userID, ok := auth.CurrentUserID(c) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效") return } projects, err := h.service.ListProjects(userID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"}) + httpx.Error(c, http.StatusInternalServerError, "internal_error", "项目列表加载失败") return } c.JSON(http.StatusOK, projects) } +func (h *Handler) getProject(c *gin.Context) { + userID, ok := auth.CurrentUserID(c) + if !ok { + httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效") + return + } + identity, ok := httpx.IdentityParam(c, "id") + if !ok { + return + } + project, err := h.service.GetProject(userID, identity) + if err != nil { + writeProjectError(c, err) + return + } + c.JSON(http.StatusOK, project) +} + +func (h *Handler) updateProject(c *gin.Context) { + userID, ok := auth.CurrentUserID(c) + if !ok { + httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效") + return + } + identity, ok := httpx.IdentityParam(c, "id") + if !ok { + return + } + var input UpdateProjectRequest + if err := c.ShouldBindJSON(&input); err != nil { + httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效") + return + } + if err := h.service.UpdateProject(userID, identity, input); err != nil { + writeProjectError(c, err) + return + } + c.Status(http.StatusNoContent) +} + func (h *Handler) dashboard(c *gin.Context) { userID, ok := auth.CurrentUserID(c) if !ok { @@ -340,6 +373,19 @@ func parseProjectID(c *gin.Context) (uint, bool) { return uint(projectID), true } +func writeProjectError(c *gin.Context, err error) { + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在") + case errors.Is(err, ErrProjectIdentifierConflict): + httpx.Error(c, http.StatusConflict, "conflict", "项目标识已存在") + case errors.Is(err, ErrProjectNameRequired), errors.Is(err, ErrProjectIdentifierRequired): + httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效") + default: + 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 { diff --git a/backend/internal/logic/projects/handlers_test.go b/backend/internal/logic/projects/handlers_test.go index 3af1c40..0120da4 100644 --- a/backend/internal/logic/projects/handlers_test.go +++ b/backend/internal/logic/projects/handlers_test.go @@ -41,6 +41,146 @@ func TestCreateProjectHandlerPersistsMetadata(t *testing.T) { require.Equal(t, "compass", project.Icon) require.Equal(t, "#165DFF", project.Background) require.Equal(t, "RSS exploration workspace", project.Description) + + var payload map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, project.Identity, payload["id"]) + require.ElementsMatch(t, []string{"id", "name", "identifier", "icon", "background", "description"}, mapKeys(payload)) +} + +func TestCreateProjectReturnsChineseInvalidRequestEnvelope(t *testing.T) { + router, _, _ := newProjectsHandlerTestRouter(t) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader([]byte(`{"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.StatusBadRequest, rec.Code) + var payload httpx.ErrorEnvelope + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, "invalid_request", payload.Error.Code) + require.Equal(t, "请求参数无效", payload.Error.Message) +} + +func TestCreateProjectReturnsConflictForDuplicateIdentifier(t *testing.T) { + router, project, _ := newProjectsHandlerTestRouter(t) + body, err := json.Marshal(gin.H{"name": "Another", "identifier": project.Identifier}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", 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.StatusConflict, rec.Code) + var payload httpx.ErrorEnvelope + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, "conflict", payload.Error.Code) + require.Equal(t, "项目标识已存在", payload.Error.Message) +} + +func TestGetProjectUsesOwnedIdentityDTO(t *testing.T) { + router, project, _ := newProjectsHandlerTestRouter(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+project.Identity, nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var payload map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, project.Identity, payload["id"]) + require.ElementsMatch(t, []string{"id", "name", "identifier", "icon", "background", "description"}, mapKeys(payload)) +} + +func TestListProjectsUsesIdentityDTO(t *testing.T) { + router, project, _ := newProjectsHandlerTestRouter(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var payload []map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Len(t, payload, 1) + require.Equal(t, project.Identity, payload[0]["id"]) + require.ElementsMatch(t, []string{"id", "name", "identifier", "icon", "background", "description"}, mapKeys(payload[0])) +} + +func TestUpdateProject(t *testing.T) { + t.Run("updates the allowed project fields by identity", func(t *testing.T) { + router, project, _ := newProjectsHandlerTestRouter(t) + body, err := json.Marshal(gin.H{ + "name": "Alpha Next", + "identifier": "ALPHA-NEXT", + "icon": "tree", + "background": "#0FC6C2", + "description": "更新后的项目说明", + }) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+project.Identity, 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.StatusNoContent, rec.Code) + require.Empty(t, rec.Body.String()) + var updated models.SenlinAgentProject + require.NoError(t, models.DBService.First(&updated, project.ID).Error) + require.Equal(t, "Alpha Next", updated.Name) + require.Equal(t, "ALPHA-NEXT", updated.Identifier) + require.Equal(t, "tree", updated.Icon) + require.Equal(t, "#0FC6C2", updated.Background) + require.Equal(t, "更新后的项目说明", updated.Description) + }) + + t.Run("returns not found when the identity is not owned by the current user", func(t *testing.T) { + router, _, _ := newProjectsHandlerTestRouter(t) + other, err := NewService().CreateProject(2, "Other", "") + require.NoError(t, err) + body, err := json.Marshal(gin.H{"name": "不可见项目"}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+other.Identity, 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.StatusNotFound, rec.Code) + var payload httpx.ErrorEnvelope + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, "not_found", payload.Error.Code) + require.Equal(t, "项目不存在", payload.Error.Message) + }) + + t.Run("returns conflict for another owned project identifier", func(t *testing.T) { + router, project, _ := newProjectsHandlerTestRouter(t) + _, err := NewService().CreateProjectWithInput(1, CreateProjectRequest{Name: "Beta", Identifier: "BETA"}) + require.NoError(t, err) + body, err := json.Marshal(gin.H{"identifier": "BETA"}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+project.Identity, 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.StatusConflict, rec.Code) + var payload httpx.ErrorEnvelope + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, "conflict", payload.Error.Code) + require.Equal(t, "项目标识已存在", payload.Error.Message) + }) } func TestCreateTaskHandlerPersistsTask(t *testing.T) { @@ -175,3 +315,11 @@ func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgen ) return router, project, storageDir } + +func mapKeys(value map[string]any) []string { + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + return keys +} diff --git a/backend/internal/logic/projects/ownership.go b/backend/internal/logic/projects/ownership.go new file mode 100644 index 0000000..f9f00d1 --- /dev/null +++ b/backend/internal/logic/projects/ownership.go @@ -0,0 +1,20 @@ +package projects + +import ( + "gorm.io/gorm" + "senlinai-agent/backend/internal/models" +) + +// FindOwnedProject 同时按 owner_id 与公开 identity 查询,防止仅凭可猜测标识越权访问项目。 +// 内部自增 ID 只在通过所有权校验后供关联查询使用,不进入 API 契约。 +func FindOwnedProject(userID uint, identity string) (*models.SenlinAgentProject, error) { + var project models.SenlinAgentProject + result := models.DBService.Where("owner_id = ? AND identity = ?", userID, identity).Limit(1).Find(&project) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, gorm.ErrRecordNotFound + } + return &project, nil +} diff --git a/backend/internal/logic/projects/service.go b/backend/internal/logic/projects/service.go index 20d02f8..60f2182 100644 --- a/backend/internal/logic/projects/service.go +++ b/backend/internal/logic/projects/service.go @@ -105,14 +105,6 @@ type ProjectWorkspace struct { CronPlans []CronPlan `json:"cronPlans"` } -type CreateProjectInput struct { - Name string - Identifier string - Icon string - Background string - Description string -} - type CreateTaskInput struct { Title string Description string @@ -147,18 +139,27 @@ func NewService() *Service { } func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) { - return s.CreateProjectWithInput(ownerID, CreateProjectInput{Name: name, Description: description}) + return s.CreateProjectWithInput(ownerID, CreateProjectRequest{Name: name, Description: description}) } -func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectInput) (*models.SenlinAgentProject, error) { +func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectRequest) (*models.SenlinAgentProject, error) { name := strings.TrimSpace(input.Name) if name == "" { - return nil, errors.New("project name is required") + return nil, ErrProjectNameRequired } identifier := strings.TrimSpace(input.Identifier) if identifier == "" { identifier = projectInitials(name) } + var count int64 + if err := models.DBService.Model(&models.SenlinAgentProject{}). + Where("owner_id = ? AND identifier = ?", ownerID, identifier). + Count(&count).Error; err != nil { + return nil, err + } + if count > 0 { + return nil, ErrProjectIdentifierConflict + } project := &models.SenlinAgentProject{ OwnerID: ownerID, Name: name, @@ -170,10 +171,78 @@ func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectInput) return project, models.DBService.Create(project).Error } -func (s *Service) ListProjects(ownerID uint) ([]models.SenlinAgentProject, error) { +// ListProjects 只返回当前所有者的公开 DTO,不让 handler 接触或序列化数据库模型。 +func (s *Service) ListProjects(ownerID uint) ([]ProjectDTO, error) { var projects []models.SenlinAgentProject - err := models.DBService.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error - return projects, err + if err := models.DBService.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error; err != nil { + return nil, err + } + result := make([]ProjectDTO, 0, len(projects)) + for _, project := range projects { + result = append(result, projectDTO(project)) + } + return result, nil +} + +// GetProject 使用公开 identity 和所有权边界读取单个项目。 +func (s *Service) GetProject(ownerID uint, identity string) (ProjectDTO, error) { + project, err := FindOwnedProject(ownerID, identity) + if err != nil { + return ProjectDTO{}, err + } + return projectDTO(*project), nil +} + +var ( + ErrProjectNameRequired = errors.New("project name is required") + ErrProjectIdentifierRequired = errors.New("project identifier is required") + ErrProjectIdentifierConflict = errors.New("project identifier already exists") +) + +// UpdateProject 仅更新项目设置白名单字段,查询和冲突检查均限定在当前所有者内。 +func (s *Service) UpdateProject(ownerID uint, identity string, input UpdateProjectRequest) error { + project, err := FindOwnedProject(ownerID, identity) + if err != nil { + return err + } + + updates := make(map[string]any, 5) + if input.Name != nil { + name := strings.TrimSpace(*input.Name) + if name == "" { + return ErrProjectNameRequired + } + updates["name"] = name + } + if input.Identifier != nil { + identifier := strings.TrimSpace(*input.Identifier) + if identifier == "" { + return ErrProjectIdentifierRequired + } + var count int64 + if err := models.DBService.Model(&models.SenlinAgentProject{}). + Where("owner_id = ? AND identifier = ? AND id <> ?", ownerID, identifier, project.ID). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + return ErrProjectIdentifierConflict + } + updates["identifier"] = identifier + } + if input.Icon != nil { + updates["icon"] = strings.TrimSpace(*input.Icon) + } + if input.Background != nil { + updates["background"] = strings.TrimSpace(*input.Background) + } + if input.Description != nil { + updates["description"] = strings.TrimSpace(*input.Description) + } + if len(updates) == 0 { + return nil + } + return models.DBService.Model(project).Updates(updates).Error } func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput) (*models.SenlinAgentTask, error) { diff --git a/backend/internal/logic/projects/service_test.go b/backend/internal/logic/projects/service_test.go index 739ea8a..891c55f 100644 --- a/backend/internal/logic/projects/service_test.go +++ b/backend/internal/logic/projects/service_test.go @@ -1,6 +1,7 @@ package projects import ( + "errors" "fmt" "testing" "time" @@ -11,6 +12,23 @@ import ( "senlinai-agent/backend/internal/models" ) +func TestFindOwnedProjectScopesIdentityByOwner(t *testing.T) { + newTestDB(t) + service := NewService() + owned, err := service.CreateProject(1, "Alpha", "") + require.NoError(t, err) + other, err := service.CreateProject(2, "Beta", "") + require.NoError(t, err) + + found, err := FindOwnedProject(1, owned.Identity) + + require.NoError(t, err) + require.Equal(t, owned.ID, found.ID) + + _, err = FindOwnedProject(1, other.Identity) + require.True(t, errors.Is(err, gorm.ErrRecordNotFound)) +} + func TestProjectTagsAreScopedToProject(t *testing.T) { newTestDB(t) service := NewService() @@ -34,7 +52,7 @@ func TestCreateProjectPersistsWorkspaceMetadata(t *testing.T) { newTestDB(t) service := NewService() - project, err := service.CreateProjectWithInput(1, CreateProjectInput{ + project, err := service.CreateProjectWithInput(1, CreateProjectRequest{ Name: "探索项目", Identifier: "EXP", Icon: "compass",