fix(workbench): complete profile and channel flows
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -16,6 +19,8 @@ func NewHandler(service *Service) *Handler {
|
||||
|
||||
func (h *Handler) Register(router gin.IRouter) {
|
||||
router.POST("/auth/login", h.login)
|
||||
router.GET("/auth/me", h.me)
|
||||
router.PATCH("/auth/me", h.updateMe)
|
||||
}
|
||||
|
||||
func (h *Handler) login(c *gin.Context) {
|
||||
@@ -32,5 +37,72 @@ func (h *Handler) login(c *gin.Context) {
|
||||
abortWithError(c, http.StatusUnauthorized, "invalid_credentials", "邮箱或密码错误")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
userID, err := h.service.VerifySession(token)
|
||||
if err != nil {
|
||||
abortWithError(c, http.StatusInternalServerError, "internal_error", "登录状态创建失败")
|
||||
return
|
||||
}
|
||||
user, err := h.service.CurrentUser(userID)
|
||||
if err != nil {
|
||||
abortWithError(c, http.StatusInternalServerError, "internal_error", "用户资料加载失败")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"token": token, "user": currentUserDTO(*user)})
|
||||
}
|
||||
|
||||
type CurrentUserDTO struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
func (h *Handler) me(c *gin.Context) {
|
||||
userID, ok := CurrentUserID(c)
|
||||
if !ok {
|
||||
abortWithError(c, http.StatusUnauthorized, "unauthorized", "请先登录后再操作")
|
||||
return
|
||||
}
|
||||
user, err := h.service.CurrentUser(userID)
|
||||
if err != nil {
|
||||
writeProfileError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, currentUserDTO(*user))
|
||||
}
|
||||
|
||||
func (h *Handler) updateMe(c *gin.Context) {
|
||||
userID, ok := CurrentUserID(c)
|
||||
if !ok {
|
||||
abortWithError(c, http.StatusUnauthorized, "unauthorized", "请先登录后再操作")
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
abortWithError(c, http.StatusBadRequest, "invalid_request", "个人资料参数无效")
|
||||
return
|
||||
}
|
||||
user, err := h.service.UpdateCurrentUser(userID, input.DisplayName, input.CurrentPassword, input.NewPassword)
|
||||
if err != nil {
|
||||
writeProfileError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, currentUserDTO(*user))
|
||||
}
|
||||
|
||||
func currentUserDTO(user models.SaUser) CurrentUserDTO {
|
||||
return CurrentUserDTO{Email: user.Email, DisplayName: user.DisplayName}
|
||||
}
|
||||
|
||||
func writeProfileError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
abortWithError(c, http.StatusNotFound, "not_found", "用户不存在")
|
||||
case errors.Is(err, ErrProfileNameRequired), errors.Is(err, ErrPasswordTooShort), errors.Is(err, ErrCurrentPassword):
|
||||
abortWithError(c, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
default:
|
||||
abortWithError(c, http.StatusInternalServerError, "internal_error", "个人资料更新失败")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ type Service struct {
|
||||
sessionTTL time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
ErrProfileNameRequired = errors.New("display name is required")
|
||||
ErrCurrentPassword = errors.New("current password is incorrect")
|
||||
ErrPasswordTooShort = errors.New("new password must be at least 8 characters")
|
||||
)
|
||||
|
||||
func NewService(secret string) *Service {
|
||||
return &Service{secret: secret, inviteTokenTTL: 7 * 24 * time.Hour, sessionTTL: 24 * time.Hour}
|
||||
}
|
||||
@@ -86,6 +92,46 @@ func (s *Service) VerifySession(token string) (uint, error) {
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// CurrentUser returns the authenticated user's profile without exposing password hashes.
|
||||
func (s *Service) CurrentUser(userID uint) (*models.SaUser, error) {
|
||||
var user models.SaUser
|
||||
if err := models.DBService.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateCurrentUser updates the small MVP profile surface. A password change always
|
||||
// requires the current password so a stolen unlocked session cannot silently rotate it.
|
||||
func (s *Service) UpdateCurrentUser(userID uint, displayName, currentPassword, newPassword string) (*models.SaUser, error) {
|
||||
user, err := s.CurrentUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := strings.TrimSpace(displayName)
|
||||
if name == "" {
|
||||
return nil, ErrProfileNameRequired
|
||||
}
|
||||
updates := map[string]any{"display_name": name}
|
||||
if strings.TrimSpace(newPassword) != "" {
|
||||
if len(newPassword) < 8 {
|
||||
return nil, ErrPasswordTooShort
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
|
||||
return nil, ErrCurrentPassword
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates["password_hash"] = string(hash)
|
||||
}
|
||||
if err := models.DBService.Model(user).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.CurrentUser(userID)
|
||||
}
|
||||
|
||||
type tokenPayload struct {
|
||||
Type string `json:"type"`
|
||||
Subject string `json:"subject"`
|
||||
|
||||
@@ -139,7 +139,10 @@ func (h *Handler) create(c *gin.Context, folder bool) {
|
||||
input := CreateInput{Name: request.Name, ParentIdentity: request.ParentID, Markdown: request.Markdown}
|
||||
if request.SourceInboxItemID != "" {
|
||||
var inbox models.SaInboxItem
|
||||
if err := h.service.database().Select("id").Where("identity = ?", request.SourceInboxItemID).First(&inbox).Error; err != nil {
|
||||
if err := h.service.database().Select("id").Where(
|
||||
"identity = ? AND project_id = ? AND created_by = ?",
|
||||
request.SourceInboxItemID, projectID, userID,
|
||||
).First(&inbox).Error; err != nil {
|
||||
writeError(c, gorm.ErrRecordNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
51
backend/internal/logic/projects/channel_handlers.go
Normal file
51
backend/internal/logic/projects/channel_handlers.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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 补充工作区导航所需的项目摘要和未读计数。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user