fix(workbench): complete profile and channel flows

This commit is contained in:
2026-07-24 14:46:38 +08:00
parent d7eb20a85f
commit f430566976
21 changed files with 563 additions and 151 deletions

View File

@@ -0,0 +1,51 @@
package projects
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"senlinai-agent/backend/internal/httpx"
)
// ChannelHandler owns project-scoped custom-link writes.
type ChannelHandler struct {
service *Service
}
func NewChannelHandler(service *Service) *ChannelHandler {
return &ChannelHandler{service: service}
}
func (h *ChannelHandler) Register(router gin.IRouter) {
router.POST("/projects/:id/channels", h.create)
}
func (h *ChannelHandler) create(c *gin.Context) {
userID, project, ok := ownedProjectFromRequest(c)
if !ok {
return
}
var input CreateProjectChannelInput
if err := c.ShouldBindJSON(&input); err != nil {
httpx.Error(c, http.StatusBadRequest, "invalid_request", "频道参数无效")
return
}
channel, err := h.service.CreateProjectChannel(userID, project.ID, input)
if err != nil {
switch {
case errors.Is(err, ErrChannelTitleRequired), errors.Is(err, ErrChannelURLRequired), errors.Is(err, ErrChannelURLInvalid):
httpx.Error(c, http.StatusBadRequest, "invalid_request", "频道名称和链接地址不能为空")
case errors.Is(err, gorm.ErrRecordNotFound):
httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在")
default:
httpx.Error(c, http.StatusInternalServerError, "internal_error", "频道创建失败")
}
return
}
c.JSON(http.StatusCreated, WorkspaceChannelDTO{
ID: channel.Identity, ProjectID: project.Identity, Type: "custom_link", Title: channel.Title,
Icon: channel.Icon, URL: channel.URL, SortOrder: channel.SortOrder,
})
}

View File

@@ -41,17 +41,23 @@ type CreateCronPlanInput struct {
NextRunAt *time.Time
}
type CreateProjectChannelInput struct {
Title string `json:"title"`
Icon string `json:"icon"`
URL string `json:"url"`
}
// WorkspaceDTO 是项目工作区首屏聚合响应,所有数据库对象均以公开 identity 关联。
type WorkspaceDTO struct {
Project WorkspaceProjectDTO `json:"project"`
Channels []WorkspaceChannelDTO `json:"channels"`
Tags []WorkspaceTagDTO `json:"tags"`
RecentSessions []WorkspaceAISessionDTO `json:"recentSessions"`
Inbox []WorkspaceInboxDTO `json:"inbox"`
Tasks []WorkspaceTaskDTO `json:"tasks"`
AISessions []WorkspaceAISessionDTO `json:"aiSessions"`
Documents []WorkspaceDocumentDTO `json:"documents"`
CronPlans []WorkspaceCronPlanDTO `json:"cronPlans"`
Project WorkspaceProjectDTO `json:"project"`
Channels []WorkspaceChannelDTO `json:"channels"`
Tags []WorkspaceTagDTO `json:"tags"`
RecentSessions []WorkspaceAISessionDTO `json:"recentSessions"`
Inbox []WorkspaceInboxDTO `json:"inbox"`
Tasks []WorkspaceTaskDTO `json:"tasks"`
AISessions []WorkspaceAISessionDTO `json:"aiSessions"`
Documents []WorkspaceDocumentDTO `json:"documents"`
CronPlans []WorkspaceCronPlanDTO `json:"cronPlans"`
}
// WorkspaceProjectDTO 补充工作区导航所需的项目摘要和未读计数。

View File

@@ -2,6 +2,7 @@ package projects
import (
"errors"
"net/url"
"strings"
"gorm.io/gorm"
@@ -64,8 +65,40 @@ var (
ErrProjectNameRequired = errors.New("project name is required")
ErrProjectIdentifierRequired = errors.New("project identifier is required")
ErrProjectIdentifierConflict = errors.New("project identifier already exists")
ErrChannelTitleRequired = errors.New("channel title is required")
ErrChannelURLRequired = errors.New("channel url is required")
ErrChannelURLInvalid = errors.New("channel url must use http or https")
)
func (s *Service) CreateProjectChannel(ownerID, projectID uint, input CreateProjectChannelInput) (*models.SaProjectChannel, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, ErrChannelTitleRequired
}
address := strings.TrimSpace(input.URL)
if address == "" {
return nil, ErrChannelURLRequired
}
parsedURL, err := url.ParseRequestURI(address)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" {
return nil, ErrChannelURLInvalid
}
icon := strings.TrimSpace(input.Icon)
if icon == "" {
icon = "link"
}
var previous models.SaProjectChannel
_ = models.DBService.Where("project_id = ?", projectID).Order("sort_order DESC").First(&previous).Error
channel := &models.SaProjectChannel{ProjectID: projectID, Title: title, Icon: icon, URL: address, SortOrder: previous.SortOrder + 1}
if err := models.DBService.Create(channel).Error; err != nil {
return nil, err
}
return channel, nil
}
// projectWriteError 将 Postgres/SQLite 经 Gorm 翻译后的唯一约束错误收敛为稳定业务冲突。
func projectWriteError(err error) error {
if errors.Is(err, gorm.ErrDuplicatedKey) {

View File

@@ -110,6 +110,25 @@ func TestCreateProjectPersistsWorkspaceMetadata(t *testing.T) {
require.Equal(t, "RSS 采集和线索沉淀", workspace.Project.Description)
}
func TestCreateProjectChannelRequiresOwnedProjectAndSafeURL(t *testing.T) {
newTestDB(t)
service := NewService()
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
channel, err := service.CreateProjectChannel(1, project.ID, CreateProjectChannelInput{
Title: "Research", URL: "https://example.com/research",
})
require.NoError(t, err)
require.Equal(t, "link", channel.Icon)
require.Equal(t, 1, channel.SortOrder)
_, err = service.CreateProjectChannel(1, project.ID, CreateProjectChannelInput{Title: "Unsafe", URL: "javascript:alert(1)"})
require.ErrorIs(t, err, ErrChannelURLInvalid)
_, err = service.CreateProjectChannel(2, project.ID, CreateProjectChannelInput{Title: "Foreign", URL: "https://example.com"})
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
}
func TestWorkspaceMatchesFrontendContract(t *testing.T) {
database := newTestDB(t)
service := NewService()