package tasks import ( "errors" "fmt" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" "senlinai-agent/backend/internal/models" ) type Service struct { db *gorm.DB } type LinkedObject struct { ObjectType string `json:"objectType"` ObjectID string `json:"objectId"` } // 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 } // Assign 在同一事务内由用户 identity 解析内部主键,并同步两个指派字段,避免 DTO 返回陈旧 identity。 func (s *Service) Assign(taskID uint, assigneeIdentity string) error { return s.database().Transaction(func(tx *gorm.DB) error { var task models.SaTask if err := tx.First(&task, taskID).Error; err != nil { return err } var assignee models.SaUser if err := tx.Where("identity = ?", assigneeIdentity).First(&assignee).Error; err != nil { return err } if err := tx.Model(&task).Updates(map[string]any{ "assignee_id": assignee.ID, "assignee_identity": assignee.Identity, }).Error; err != nil { return err } return tx.Create(&models.SaProjectEvent{ ProjectID: task.ProjectID, ActorID: task.CreatedBy, EventType: "task_assigned", EntityType: "task", EntityID: task.ID, Summary: fmt.Sprintf("Task assigned to user %s", assignee.Identity), }).Error }) } // ShareObject only allows an explicitly shared document from the task project. func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) error { if objectType != "document" { return errors.New("unsupported shared object type") } return s.database().Transaction(func(tx *gorm.DB) error { task, err := lockTaskByID(tx, taskID) if err != nil { return err } if err := ensureSharedObjectInProject(tx, task.ProjectID, objectID); err != nil { return err } if err := tx.Create(&models.SaTaskShare{TaskID: taskID, ObjectType: objectType, ObjectID: objectID}).Error; err != nil { return err } return tx.Create(&models.SaProjectEvent{ ProjectID: task.ProjectID, ActorID: task.CreatedBy, EventType: "task_object_shared", EntityType: "task", EntityID: task.ID, Summary: fmt.Sprintf("Shared %s %d with task", objectType, objectID), }).Error }) } // VisibleLinkedObjects 对被指派人也只返回显式分享记录,不因任务可见而扩大关联对象权限。 func (s *Service) VisibleLinkedObjects(taskID uint, viewerID uint) ([]LinkedObject, error) { var task models.SaTask 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.SaTaskShare if err := s.database().Where("task_id = ?", taskID).Find(&shares).Error; err != nil { return nil, err } objects := make([]LinkedObject, 0, len(shares)) for _, share := range shares { objects = append(objects, LinkedObject{ObjectType: share.ObjectType, ObjectID: share.ObjectIdentity}) } 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.SaTask{ 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 { task, err := lockTaskByIdentity(tx, taskIdentity) if err != nil { return err } currentProject, err := findOwnedProject(tx, ownerID, projectIdentity) if err != nil { return err } if task.ProjectID != currentProject.ID { return gorm.ErrRecordNotFound } 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 { // 现有分享都在原项目边界内;移动后必须清空,避免旧项目 document 继续对被指派人可见。 if err := tx.Where("task_id = ?", task.ID).Delete(&models.SaTaskShare{}).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 lockTaskByID(tx *gorm.DB, taskID uint) (*models.SaTask, error) { var task models.SaTask if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, taskID).Error; err != nil { return nil, err } return &task, nil } func lockTaskByIdentity(tx *gorm.DB, taskIdentity string) (*models.SaTask, error) { var task models.SaTask if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", taskIdentity).First(&task).Error; err != nil { return nil, err } return &task, nil } func findOwnedProject(tx *gorm.DB, ownerID uint, identity string) (*models.SaProject, error) { var project models.SaProject 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 } candidate := models.SaTag{ProjectID: projectID, Name: name} if err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "project_id"}, {Name: "name"}}, DoNothing: true, }).Create(&candidate).Error; err != nil { return nil, nil, "", err } var tag models.SaTag if err := tx.Where("project_id = ? AND name = ?", projectID, name).First(&tag).Error; err != nil { return nil, nil, "", err } return &tag.ID, &tag.Identity, tag.Name, nil } func makeTaskDTO(task models.SaTask, 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, objectID uint) error { var count int64 if err := tx.Model(&models.SaDocumentTree{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil { return err } if count == 0 { return errors.New("shared object not found in task project") } return nil }