Files
agent/backend/internal/logic/tasks/service.go

291 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.SenlinAgentTask
if err := tx.First(&task, taskID).Error; err != nil {
return err
}
var assignee models.SenlinAgentUser
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.SenlinAgentProjectEvent{
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 只允许显式分享 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 s.database().Transaction(func(tx *gorm.DB) error {
task, err := lockTaskByID(tx, taskID)
if err != nil {
return err
}
if err := ensureSharedObjectInProject(tx, task.ProjectID, objectType, objectID); err != nil {
return err
}
if err := tx.Create(&models.SenlinAgentTaskShare{TaskID: taskID, ObjectType: objectType, ObjectID: objectID}).Error; err != nil {
return err
}
return tx.Create(&models.SenlinAgentProjectEvent{
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.SenlinAgentTask
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 := 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.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 {
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 {
// 现有分享都在原项目边界内;移动后必须清空,避免旧项目 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 lockTaskByID(tx *gorm.DB, taskID uint) (*models.SenlinAgentTask, error) {
var task models.SenlinAgentTask
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.SenlinAgentTask, error) {
var task models.SenlinAgentTask
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.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
}
candidate := models.SenlinAgentTag{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.SenlinAgentTag
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.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":
var count int64
if err := tx.Model(&models.SenlinAgentNote{}).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")
}
case "source":
var count int64
if err := tx.Model(&models.SenlinAgentSource{}).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
}