refactor(backend): isolate workspace aggregation

This commit is contained in:
2026-07-21 14:58:25 +08:00
parent 14a140985c
commit a53f047efe
8 changed files with 818 additions and 734 deletions

View File

@@ -0,0 +1,342 @@
package projects
import (
"errors"
"strings"
"time"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
type workspaceCounts struct {
inbox int64
tasks int64
aiSessions int64
notesSources int64
cronPlans int64
}
// Dashboard 保留旧概览查询给尚未迁移的 handler所有统计仍严格限定项目所有者。
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return Dashboard{}, err
}
dashboard := Dashboard{ProjectID: projectID}
if err := models.DBService.Model(&models.SenlinAgentInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&dashboard.PendingInboxCount).Error; err != nil {
return dashboard, err
}
if err := models.DBService.Model(&models.SenlinAgentTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&dashboard.OpenTaskCount).Error; err != nil {
return dashboard, err
}
if err := models.DBService.Model(&models.SenlinAgentNote{}).Where("project_id = ?", projectID).Count(&dashboard.RecentNoteCount).Error; err != nil {
return dashboard, err
}
if err := models.DBService.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", projectID).Count(&dashboard.RecentSessionCount).Error; err != nil {
return dashboard, err
}
return dashboard, nil
}
// 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
}
notesSources, err := s.workspaceNotesSources(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,
NotesSources: notesSources,
CronPlans: cronPlans,
}, nil
}
func (s *Service) workspaceCounts(projectID uint) (workspaceCounts, error) {
var counts workspaceCounts
if err := models.DBService.Model(&models.SenlinAgentInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&counts.inbox).Error; err != nil {
return counts, err
}
if err := models.DBService.Model(&models.SenlinAgentTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&counts.tasks).Error; err != nil {
return counts, err
}
if err := models.DBService.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", projectID).Count(&counts.aiSessions).Error; err != nil {
return counts, err
}
var noteCount int64
if err := models.DBService.Model(&models.SenlinAgentNote{}).Where("project_id = ?", projectID).Count(&noteCount).Error; err != nil {
return counts, err
}
var sourceCount int64
if err := models.DBService.Model(&models.SenlinAgentSource{}).Where("project_id = ?", projectID).Count(&sourceCount).Error; err != nil {
return counts, err
}
counts.notesSources = noteCount + sourceCount
if err := models.DBService.Model(&models.SenlinAgentCronPlan{}).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.SenlinAgentTag
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.notesSources + counts.cronPlans
channels := []WorkspaceChannelDTO{
{ID: projectIdentity + ":overview", ProjectID: projectIdentity, Type: "overview", Title: "Overview", Icon: "home", Count: &total, SortOrder: 1},
{ID: projectIdentity + ":inbox", ProjectID: projectIdentity, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: &counts.inbox, SortOrder: 2},
{ID: projectIdentity + ":tasks", ProjectID: projectIdentity, Type: "tasks", Title: "Work Plan", Icon: "list", Count: &counts.tasks, SortOrder: 3},
{ID: projectIdentity + ":ai", ProjectID: projectIdentity, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: &counts.aiSessions, SortOrder: 4},
{ID: projectIdentity + ":notes", ProjectID: projectIdentity, Type: "notes_sources", Title: "Notes & Sources", Icon: "file", Count: &counts.notesSources, SortOrder: 5},
{ID: projectIdentity + ":cron", ProjectID: projectIdentity, Type: "cron", Title: "Cron Plans", Icon: "clock", Count: &counts.cronPlans, SortOrder: 6},
}
var customChannels []models.SenlinAgentProjectChannel
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, URL: channel.URL, SortOrder: channel.SortOrder,
})
}
return channels, nil
}
func (s *Service) workspaceInbox(projectID uint, projectIdentity string) ([]WorkspaceInboxDTO, error) {
var records []models.SenlinAgentInboxItem
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.SenlinAgentTask
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
}
completed := task.Status == "done"
var completedAt *time.Time
if completed {
completedAt = utcOptionalTime(&task.UpdatedAt)
}
item := WorkspaceTaskDTO{
ID: task.Identity, ProjectID: projectIdentity, Title: task.Title, Summary: task.Description,
Completed: completed, Owner: owner, Due: utcOptionalTime(task.DueAt), CreatedAt: task.CreatedAt.UTC(), CompletedAt: completedAt,
}
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.SenlinAgentTask) (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.SenlinAgentTag
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.SenlinAgentAISession
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) workspaceNotesSources(projectID uint, projectIdentity string) ([]WorkspaceNoteSourceDTO, error) {
var notes []models.SenlinAgentNote
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&notes).Error; err != nil {
return nil, err
}
items := make([]WorkspaceNoteSourceDTO, 0, len(notes))
for _, note := range notes {
items = append(items, WorkspaceNoteSourceDTO{
ID: note.Identity, ProjectID: projectIdentity, Kind: "note", Title: note.Title,
UpdatedAt: note.UpdatedAt.UTC(), Source: "Markdown",
})
}
var sources []models.SenlinAgentSource
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sources).Error; err != nil {
return nil, err
}
for _, source := range sources {
items = append(items, WorkspaceNoteSourceDTO{
ID: source.Identity, ProjectID: projectIdentity, Kind: source.Kind, Title: source.Title,
UpdatedAt: source.UpdatedAt.UTC(), Source: sourceDisplayName(source),
})
}
return items, nil
}
func (s *Service) workspaceCronPlans(projectID uint, projectIdentity string) ([]WorkspaceCronPlanDTO, error) {
var plans []models.SenlinAgentCronPlan
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: defaultString(plan.LastResult, "Not run yet"), Owner: owner,
})
}
return items, nil
}
func utcOptionalTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
utc := value.UTC()
return &utc
}
func sourceDisplayName(source models.SenlinAgentSource) string {
switch source.Kind {
case "file":
return "Attachment"
case "link":
return "URL"
default:
return source.Kind
}
}
func userDisplayName(assigneeID *uint, createdBy uint) (string, error) {
userID := createdBy
if assigneeID != nil && *assigneeID != 0 {
userID = *assigneeID
}
var user models.SenlinAgentUser
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
}