refactor(api): expose project identity DTOs
This commit is contained in:
42
backend/internal/logic/projects/dto.go
Normal file
42
backend/internal/logic/projects/dto.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
20
backend/internal/logic/projects/ownership.go
Normal file
20
backend/internal/logic/projects/ownership.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user