280 lines
8.7 KiB
Go
280 lines
8.7 KiB
Go
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.SaDocument{}).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
|
|
}
|