refactor(api): expose project identity DTOs

This commit is contained in:
2026-07-21 14:22:13 +08:00
parent 4bfeec8d2b
commit 5341b44cc5
6 changed files with 378 additions and 35 deletions

View File

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

View File

@@ -1,12 +1,15 @@
package projects package projects
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
"senlinai-agent/backend/internal/httpx"
"senlinai-agent/backend/internal/logic/auth" "senlinai-agent/backend/internal/logic/auth"
"senlinai-agent/backend/internal/logic/files" "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) { func (h *Handler) Register(router gin.IRouter) {
router.POST("/projects", h.createProject) router.POST("/projects", h.createProject)
router.GET("/projects", h.listProjects) router.GET("/projects", h.listProjects)
router.GET("/projects/:id", h.getProject)
router.PATCH("/projects/:id", h.updateProject)
router.GET("/projects/:id/dashboard", h.dashboard) router.GET("/projects/:id/dashboard", h.dashboard)
router.GET("/projects/:id/workspace", h.workspace) router.GET("/projects/:id/workspace", h.workspace)
router.GET("/projects/:id/tags", h.listTags) router.GET("/projects/:id/tags", h.listTags)
@@ -40,48 +45,76 @@ func (h *Handler) Register(router gin.IRouter) {
func (h *Handler) createProject(c *gin.Context) { func (h *Handler) createProject(c *gin.Context) {
userID, ok := auth.CurrentUserID(c) userID, ok := auth.CurrentUserID(c)
if !ok { if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效")
return return
} }
var input struct { var input CreateProjectRequest
Name string `json:"name"`
Identifier string `json:"identifier"`
Icon string `json:"icon"`
Background string `json:"background"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效")
return return
} }
project, err := h.service.CreateProjectWithInput(userID, CreateProjectInput{ project, err := h.service.CreateProjectWithInput(userID, input)
Name: input.Name,
Identifier: input.Identifier,
Icon: input.Icon,
Background: input.Background,
Description: input.Description,
})
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) writeProjectError(c, err)
return return
} }
c.JSON(http.StatusCreated, project) c.JSON(http.StatusCreated, projectDTO(*project))
} }
func (h *Handler) listProjects(c *gin.Context) { func (h *Handler) listProjects(c *gin.Context) {
userID, ok := auth.CurrentUserID(c) userID, ok := auth.CurrentUserID(c)
if !ok { if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效")
return return
} }
projects, err := h.service.ListProjects(userID) projects, err := h.service.ListProjects(userID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"}) httpx.Error(c, http.StatusInternalServerError, "internal_error", "项目列表加载失败")
return return
} }
c.JSON(http.StatusOK, projects) 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) { func (h *Handler) dashboard(c *gin.Context) {
userID, ok := auth.CurrentUserID(c) userID, ok := auth.CurrentUserID(c)
if !ok { if !ok {
@@ -340,6 +373,19 @@ func parseProjectID(c *gin.Context) (uint, bool) {
return uint(projectID), true 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) { func parseTaskID(c *gin.Context) (uint, bool) {
taskID, err := strconv.ParseUint(c.Param("taskID"), 10, 64) taskID, err := strconv.ParseUint(c.Param("taskID"), 10, 64)
if err != nil { if err != nil {

View File

@@ -41,6 +41,146 @@ func TestCreateProjectHandlerPersistsMetadata(t *testing.T) {
require.Equal(t, "compass", project.Icon) require.Equal(t, "compass", project.Icon)
require.Equal(t, "#165DFF", project.Background) require.Equal(t, "#165DFF", project.Background)
require.Equal(t, "RSS exploration workspace", project.Description) 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) { func TestCreateTaskHandlerPersistsTask(t *testing.T) {
@@ -175,3 +315,11 @@ func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgen
) )
return router, project, storageDir 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
}

View File

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

View File

@@ -105,14 +105,6 @@ type ProjectWorkspace struct {
CronPlans []CronPlan `json:"cronPlans"` CronPlans []CronPlan `json:"cronPlans"`
} }
type CreateProjectInput struct {
Name string
Identifier string
Icon string
Background string
Description string
}
type CreateTaskInput struct { type CreateTaskInput struct {
Title string Title string
Description string Description string
@@ -147,18 +139,27 @@ func NewService() *Service {
} }
func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) { 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) name := strings.TrimSpace(input.Name)
if name == "" { if name == "" {
return nil, errors.New("project name is required") return nil, ErrProjectNameRequired
} }
identifier := strings.TrimSpace(input.Identifier) identifier := strings.TrimSpace(input.Identifier)
if identifier == "" { if identifier == "" {
identifier = projectInitials(name) 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{ project := &models.SenlinAgentProject{
OwnerID: ownerID, OwnerID: ownerID,
Name: name, Name: name,
@@ -170,10 +171,78 @@ func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectInput)
return project, models.DBService.Create(project).Error 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 var projects []models.SenlinAgentProject
err := models.DBService.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error if err := models.DBService.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error; err != nil {
return projects, err 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) { func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput) (*models.SenlinAgentTask, error) {

View File

@@ -1,6 +1,7 @@
package projects package projects
import ( import (
"errors"
"fmt" "fmt"
"testing" "testing"
"time" "time"
@@ -11,6 +12,23 @@ import (
"senlinai-agent/backend/internal/models" "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) { func TestProjectTagsAreScopedToProject(t *testing.T) {
newTestDB(t) newTestDB(t)
service := NewService() service := NewService()
@@ -34,7 +52,7 @@ func TestCreateProjectPersistsWorkspaceMetadata(t *testing.T) {
newTestDB(t) newTestDB(t)
service := NewService() service := NewService()
project, err := service.CreateProjectWithInput(1, CreateProjectInput{ project, err := service.CreateProjectWithInput(1, CreateProjectRequest{
Name: "探索项目", Name: "探索项目",
Identifier: "EXP", Identifier: "EXP",
Icon: "compass", Icon: "compass",