package projects import ( "errors" "strings" "time" "gorm.io/gorm" "senlinai-agent/backend/internal/models" ) type workspaceCounts struct { inbox int64 tasks int64 aiSessions int64 documents int64 cronPlans int64 } // Workspace 在确认项目归属后执行首屏聚合;后续子查询只接收已授权的内部主键。 func (s *Service) Workspace(ownerID uint, projectIdentity string) (WorkspaceDTO, error) { project, err := FindOwnedProject(ownerID, projectIdentity) if err != nil { return WorkspaceDTO{}, err } counts, err := s.workspaceCounts(project.ID) if err != nil { return WorkspaceDTO{}, err } tags, err := s.workspaceTags(project.ID) if err != nil { return WorkspaceDTO{}, err } channels, err := s.workspaceChannels(project.ID, project.Identity, counts) if err != nil { return WorkspaceDTO{}, err } inboxItems, err := s.workspaceInbox(project.ID, project.Identity) if err != nil { return WorkspaceDTO{}, err } tasks, err := s.workspaceTasks(project.ID, project.Identity) if err != nil { return WorkspaceDTO{}, err } aiSessions, err := s.workspaceAISessions(project.ID, project.Identity) if err != nil { return WorkspaceDTO{}, err } documents, err := s.workspaceDocuments(project.ID, project.Identity) if err != nil { return WorkspaceDTO{}, err } cronPlans, err := s.workspaceCronPlans(project.ID, project.Identity) if err != nil { return WorkspaceDTO{}, err } return WorkspaceDTO{ Project: WorkspaceProjectDTO{ ID: project.Identity, Name: project.Name, Identifier: project.Identifier, Icon: project.Icon, Background: project.Background, Description: project.Description, Initials: defaultString(project.Identifier, projectInitials(project.Name)), UnreadCount: counts.inbox, }, Channels: channels, Tags: tags, RecentSessions: aiSessions, Inbox: inboxItems, Tasks: tasks, AISessions: aiSessions, Documents: documents, CronPlans: cronPlans, }, nil } func (s *Service) workspaceCounts(projectID uint) (workspaceCounts, error) { var counts workspaceCounts if err := models.DBService.Model(&models.SaInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&counts.inbox).Error; err != nil { return counts, err } if err := models.DBService.Model(&models.SaTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&counts.tasks).Error; err != nil { return counts, err } if err := models.DBService.Model(&models.SaAISession{}).Where("project_id = ?", projectID).Count(&counts.aiSessions).Error; err != nil { return counts, err } if err := models.DBService.Model(&models.SaDocumentTree{}).Where("project_id = ?", projectID).Count(&counts.documents).Error; err != nil { return counts, err } if err := models.DBService.Model(&models.SaCronPlan{}).Where("project_id = ?", projectID).Count(&counts.cronPlans).Error; err != nil { return counts, err } return counts, nil } func (s *Service) workspaceTags(projectID uint) ([]WorkspaceTagDTO, error) { var tags []models.SaTag if err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error; err != nil { return nil, err } items := make([]WorkspaceTagDTO, 0, len(tags)) for _, tag := range tags { items = append(items, WorkspaceTagDTO{ID: tag.Identity, Name: tag.Name}) } return items, nil } func (s *Service) workspaceChannels(projectID uint, projectIdentity string, counts workspaceCounts) ([]WorkspaceChannelDTO, error) { total := counts.inbox + counts.tasks + counts.aiSessions + counts.documents + counts.cronPlans channels := []WorkspaceChannelDTO{ {ID: projectIdentity + ":overview", ProjectID: projectIdentity, Type: "overview", Title: "Overview", Icon: "home", Count: total, URL: "", SortOrder: 1}, {ID: projectIdentity + ":inbox", ProjectID: projectIdentity, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: counts.inbox, URL: "", SortOrder: 2}, {ID: projectIdentity + ":tasks", ProjectID: projectIdentity, Type: "tasks", Title: "Work Plan", Icon: "list", Count: counts.tasks, URL: "", SortOrder: 3}, {ID: projectIdentity + ":ai", ProjectID: projectIdentity, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: counts.aiSessions, URL: "", SortOrder: 4}, {ID: projectIdentity + ":documents", ProjectID: projectIdentity, Type: "documents", Title: "Documents", Icon: "file", Count: counts.documents, URL: "", SortOrder: 5}, {ID: projectIdentity + ":cron", ProjectID: projectIdentity, Type: "cron", Title: "Cron Plans", Icon: "clock", Count: counts.cronPlans, URL: "", SortOrder: 6}, } var customChannels []models.SaProjectChannel if err := models.DBService.Where("project_id = ?", projectID).Order("sort_order asc, id asc").Find(&customChannels).Error; err != nil { return nil, err } for _, channel := range customChannels { channels = append(channels, WorkspaceChannelDTO{ ID: channel.Identity, ProjectID: projectIdentity, Type: "custom_link", Title: channel.Title, Icon: channel.Icon, Count: 0, URL: channel.URL, SortOrder: channel.SortOrder, }) } return channels, nil } func (s *Service) workspaceInbox(projectID uint, projectIdentity string) ([]WorkspaceInboxDTO, error) { var records []models.SaInboxItem if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&records).Error; err != nil { return nil, err } items := make([]WorkspaceInboxDTO, 0, len(records)) for _, record := range records { items = append(items, WorkspaceInboxDTO{ ID: record.Identity, ProjectID: projectIdentity, Source: record.SourceType, Title: record.Title, Summary: record.Body, Status: record.Status, Time: record.UpdatedAt.UTC(), }) } return items, nil } func (s *Service) workspaceTasks(projectID uint, projectIdentity string) ([]WorkspaceTaskDTO, error) { var tasks []models.SaTask if err := models.DBService.Where("project_id = ?", projectID).Order("CASE WHEN status = 'done' THEN 1 ELSE 0 END asc").Order("sort_order asc, updated_at desc, id desc").Limit(50).Find(&tasks).Error; err != nil { return nil, err } tags, err := workspaceTaskTags(projectID, tasks) if err != nil { return nil, err } items := make([]WorkspaceTaskDTO, 0, len(tasks)) for _, task := range tasks { owner, err := userDisplayName(task.AssigneeID, task.CreatedBy) if err != nil { return nil, err } // 当前模型没有真实完成时间字段,不能用更新时间伪装;CompletedAt 保持 nil。 item := WorkspaceTaskDTO{ ID: task.Identity, ProjectID: projectIdentity, Title: task.Title, Summary: task.Description, Completed: task.Status == "done", Owner: owner, Due: utcOptionalTime(task.DueAt), CreatedAt: task.CreatedAt.UTC(), } if tag, ok := tags[task.ID]; ok { item.TagID = &tag.ID item.Tag = tag.Name } items = append(items, item) } return items, nil } // workspaceTaskTags 同时限定 tag 主键和项目主键,避免损坏数据把其他项目标签带入任务响应。 func workspaceTaskTags(projectID uint, tasks []models.SaTask) (map[uint]WorkspaceTagDTO, error) { tagIDs := make([]uint, 0, len(tasks)) for _, task := range tasks { if task.TagID != nil { tagIDs = append(tagIDs, *task.TagID) } } result := make(map[uint]WorkspaceTagDTO) if len(tagIDs) == 0 { return result, nil } var tags []models.SaTag if err := models.DBService.Where("project_id = ? AND id IN ?", projectID, tagIDs).Find(&tags).Error; err != nil { return nil, err } byID := make(map[uint]WorkspaceTagDTO, len(tags)) for _, tag := range tags { byID[tag.ID] = WorkspaceTagDTO{ID: tag.Identity, Name: tag.Name} } for _, task := range tasks { if task.TagID != nil { if tag, ok := byID[*task.TagID]; ok { result[task.ID] = tag } } } return result, nil } func (s *Service) workspaceAISessions(projectID uint, projectIdentity string) ([]WorkspaceAISessionDTO, error) { var sessions []models.SaAISession if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sessions).Error; err != nil { return nil, err } items := make([]WorkspaceAISessionDTO, 0, len(sessions)) for _, session := range sessions { items = append(items, WorkspaceAISessionDTO{ ID: session.Identity, ProjectID: projectIdentity, Title: session.Title, Summary: session.Context, UpdatedAt: session.UpdatedAt.UTC(), References: []string{}, }) } return items, nil } func (s *Service) workspaceDocuments(projectID uint, projectIdentity string) ([]WorkspaceDocumentDTO, error) { var documents []models.SaDocumentTree if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&documents).Error; err != nil { return nil, err } items := make([]WorkspaceDocumentDTO, 0, len(documents)) for _, document := range documents { items = append(items, WorkspaceDocumentDTO{ ID: document.Identity, ProjectID: projectIdentity, Kind: document.Kind, Name: document.Name, Extension: document.Extension, MimeType: document.MimeType, UpdatedAt: document.UpdatedAt.UTC(), }) } return items, nil } func (s *Service) workspaceCronPlans(projectID uint, projectIdentity string) ([]WorkspaceCronPlanDTO, error) { var plans []models.SaCronPlan if err := models.DBService.Where("project_id = ?", projectID).Order("enabled desc, updated_at desc, id desc").Limit(50).Find(&plans).Error; err != nil { return nil, err } items := make([]WorkspaceCronPlanDTO, 0, len(plans)) for _, plan := range plans { owner, err := userDisplayName(nil, plan.CreatedBy) if err != nil { return nil, err } items = append(items, WorkspaceCronPlanDTO{ ID: plan.Identity, ProjectID: projectIdentity, Title: plan.Title, Schedule: plan.Schedule, NextRun: utcOptionalTime(plan.NextRunAt), Enabled: plan.Enabled, LastResult: strings.TrimSpace(plan.LastResult), Owner: owner, }) } return items, nil } func utcOptionalTime(value *time.Time) *time.Time { if value == nil { return nil } utc := value.UTC() return &utc } func userDisplayName(assigneeID *uint, createdBy uint) (string, error) { userID := createdBy if assigneeID != nil && *assigneeID != 0 { userID = *assigneeID } var user models.SaUser if err := models.DBService.Select("display_name").Where("id = ?", userID).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return "未知用户", nil } return "", err } return user.DisplayName, nil } func defaultString(value string, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value }