refactor(backend): split project write responsibilities

This commit is contained in:
2026-07-21 15:27:11 +08:00
parent 9e862f2a7f
commit 1fcbb31301
18 changed files with 1025 additions and 649 deletions

View File

@@ -3,12 +3,15 @@ package tasks
import (
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
type Service struct {
db *gorm.DB
}
type LinkedObject struct {
@@ -16,12 +19,24 @@ type LinkedObject struct {
ObjectID uint `json:"object_id"`
}
func NewService() *Service {
return &Service{}
// NewService 接受数据库或上层事务,确保任务写入、标签调整和分享检查使用同一依赖。
func NewService(databases ...*gorm.DB) *Service {
var database *gorm.DB
if len(databases) > 0 {
database = databases[0]
}
return &Service{db: database}
}
func (s *Service) database() *gorm.DB {
if s.db != nil {
return s.db
}
return models.DBService
}
func (s *Service) Assign(taskID uint, assigneeID uint) error {
return models.DBService.Transaction(func(tx *gorm.DB) error {
return s.database().Transaction(func(tx *gorm.DB) error {
var task models.SenlinAgentTask
if err := tx.First(&task, taskID).Error; err != nil {
return err
@@ -40,11 +55,12 @@ func (s *Service) Assign(taskID uint, assigneeID uint) error {
})
}
// ShareObject 只允许显式分享 note/source并在同一事务中验证关联对象属于任务所在项目。
func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) error {
if objectType != "note" && objectType != "source" {
return errors.New("unsupported shared object type")
}
return models.DBService.Transaction(func(tx *gorm.DB) error {
return s.database().Transaction(func(tx *gorm.DB) error {
var task models.SenlinAgentTask
if err := tx.First(&task, taskID).Error; err != nil {
return err
@@ -66,16 +82,17 @@ func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) err
})
}
// VisibleLinkedObjects 对被指派人也只返回显式分享记录,不因任务可见而扩大关联对象权限。
func (s *Service) VisibleLinkedObjects(taskID uint, viewerID uint) ([]LinkedObject, error) {
var task models.SenlinAgentTask
if err := models.DBService.First(&task, taskID).Error; err != nil {
if err := s.database().First(&task, taskID).Error; err != nil {
return nil, err
}
if task.AssigneeID == nil || *task.AssigneeID != viewerID {
return []LinkedObject{}, nil
}
var shares []models.SenlinAgentTaskShare
if err := models.DBService.Where("task_id = ?", taskID).Find(&shares).Error; err != nil {
if err := s.database().Where("task_id = ?", taskID).Find(&shares).Error; err != nil {
return nil, err
}
objects := make([]LinkedObject, 0, len(shares))
@@ -85,6 +102,143 @@ func (s *Service) VisibleLinkedObjects(taskID uint, viewerID uint) ([]LinkedObje
return objects, nil
}
var ErrTaskTitleRequired = errors.New("task title is required")
// Create 在同一事务中校验项目所有权、解析项目内标签并创建任务。
func (s *Service) Create(ownerID uint, projectIdentity string, input CreateTaskInput) (TaskDTO, error) {
var result TaskDTO
err := s.database().Transaction(func(tx *gorm.DB) error {
project, err := findOwnedProject(tx, ownerID, projectIdentity)
if err != nil {
return err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return ErrTaskTitleRequired
}
status := strings.TrimSpace(input.Status)
if status == "" {
status = "open"
}
tagID, tagIdentity, tagName, err := findOrCreateProjectTag(tx, project.ID, input.Tag)
if err != nil {
return err
}
task := models.SenlinAgentTask{
ProjectID: project.ID, CreatedBy: ownerID, TagID: tagID,
Title: title, Description: strings.TrimSpace(input.Description), Status: status, DueAt: utcOptionalTime(input.DueAt),
}
if err := tx.Create(&task).Error; err != nil {
return err
}
result = makeTaskDTO(task, project.Identity, tagIdentity, tagName)
return nil
})
return result, err
}
// Update 同时按项目和任务 identity 查询;移动项目时重新校验所有权,并重新解析目标项目标签。
func (s *Service) Update(ownerID uint, projectIdentity, taskIdentity string, input UpdateTaskInput) (TaskDTO, error) {
var result TaskDTO
err := s.database().Transaction(func(tx *gorm.DB) error {
currentProject, err := findOwnedProject(tx, ownerID, projectIdentity)
if err != nil {
return err
}
var task models.SenlinAgentTask
if err := tx.Where("identity = ? AND project_id = ?", taskIdentity, currentProject.ID).First(&task).Error; err != nil {
return err
}
targetProject := currentProject
if strings.TrimSpace(input.NextProjectIdentity) != "" && input.NextProjectIdentity != currentProject.Identity {
targetProject, err = findOwnedProject(tx, ownerID, input.NextProjectIdentity)
if err != nil {
return err
}
}
title := strings.TrimSpace(input.Title)
if title == "" {
return ErrTaskTitleRequired
}
status := strings.TrimSpace(input.Status)
if status == "" {
if input.Completed {
status = "done"
} else {
status = "open"
}
}
// 标签只能在目标项目内重新解析;未提供标签时会清空旧项目标签,不能跨项目沿用。
tagID, tagIdentity, tagName, err := findOrCreateProjectTag(tx, targetProject.ID, input.Tag)
if err != nil {
return err
}
task.ProjectID = targetProject.ID
task.ProjectIdentity = targetProject.Identity
task.Title = title
task.Description = strings.TrimSpace(input.Description)
task.Status = status
task.TagID = tagID
task.TagIdentity = tagIdentity
if targetProject.ID != currentProject.ID {
// 现有分享都在原项目边界内;移动后必须清空,避免旧项目 note/source 继续对被指派人可见。
if err := tx.Where("task_id = ?", task.ID).Delete(&models.SenlinAgentTaskShare{}).Error; err != nil {
return err
}
}
if err := tx.Save(&task).Error; err != nil {
return err
}
result = makeTaskDTO(task, targetProject.Identity, tagIdentity, tagName)
return nil
})
return result, err
}
func findOwnedProject(tx *gorm.DB, ownerID uint, identity string) (*models.SenlinAgentProject, error) {
var project models.SenlinAgentProject
if err := tx.Where("owner_id = ? AND identity = ?", ownerID, identity).First(&project).Error; err != nil {
return nil, err
}
return &project, nil
}
func findOrCreateProjectTag(tx *gorm.DB, projectID uint, value string) (*uint, *string, string, error) {
name := strings.TrimSpace(value)
if name == "" {
return nil, nil, "", nil
}
var tag models.SenlinAgentTag
err := tx.Where("project_id = ? AND name = ?", projectID, name).First(&tag).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
tag = models.SenlinAgentTag{ProjectID: projectID, Name: name}
if err := tx.Create(&tag).Error; err != nil {
return nil, nil, "", err
}
} else if err != nil {
return nil, nil, "", err
}
return &tag.ID, &tag.Identity, tag.Name, nil
}
func makeTaskDTO(task models.SenlinAgentTask, projectIdentity string, tagIdentity *string, tagName string) TaskDTO {
return TaskDTO{
ID: task.Identity, ProjectID: projectIdentity, Title: task.Title, Description: task.Description,
Status: task.Status, Completed: task.Status == "done", DueAt: utcOptionalTime(task.DueAt),
AssigneeID: task.AssigneeIdentity, TagID: tagIdentity, Tag: tagName,
SourceInboxItemID: task.SourceInboxItemIdentity, CreatedAt: task.CreatedAt.UTC(), UpdatedAt: task.UpdatedAt.UTC(),
}
}
func utcOptionalTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
result := value.UTC()
return &result
}
func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectType string, objectID uint) error {
switch objectType {
case "note":