343 lines
12 KiB
Go
343 lines
12 KiB
Go
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(¬eCount).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(¬es).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
|
||
}
|