diff --git a/backend/internal/logic/projects/dashboard.go b/backend/internal/logic/projects/dashboard.go new file mode 100644 index 0000000..141428d --- /dev/null +++ b/backend/internal/logic/projects/dashboard.go @@ -0,0 +1,24 @@ +package projects + +import "senlinai-agent/backend/internal/models" + +// 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 +} diff --git a/backend/internal/logic/projects/workspace.go b/backend/internal/logic/projects/workspace.go index a1f3593..828bb25 100644 --- a/backend/internal/logic/projects/workspace.go +++ b/backend/internal/logic/projects/workspace.go @@ -17,27 +17,6 @@ type workspaceCounts struct { 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) @@ -191,14 +170,10 @@ func (s *Service) workspaceTasks(projectID uint, projectIdentity string) ([]Work if err != nil { return nil, err } - completed := task.Status == "done" - var completedAt *time.Time - if completed { - completedAt = utcOptionalTime(&task.UpdatedAt) - } + // 当前模型没有真实完成时间字段,不能用更新时间伪装;CompletedAt 保持 nil。 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, + 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 diff --git a/backend/internal/logic/projects/workspace_test.go b/backend/internal/logic/projects/workspace_test.go index 133dfe4..2312183 100644 --- a/backend/internal/logic/projects/workspace_test.go +++ b/backend/internal/logic/projects/workspace_test.go @@ -3,6 +3,7 @@ package projects import ( "encoding/json" "fmt" + "os" "testing" "time" @@ -63,7 +64,7 @@ func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) { require.Equal(t, "UI", workspace.Tasks[0].Tag) require.Equal(t, nextRun.UTC(), *workspace.Tasks[0].Due) require.Equal(t, createdAt.UTC(), workspace.Tasks[0].CreatedAt) - require.Equal(t, updatedAt.UTC(), *workspace.Tasks[0].CompletedAt) + require.Nil(t, workspace.Tasks[0].CompletedAt) require.Len(t, workspace.AISessions, 1) require.Equal(t, session.Identity, workspace.AISessions[0].ID) @@ -93,6 +94,34 @@ func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) { require.NotContains(t, string(payload), "duration") } +func TestWorkspaceDoneTaskDoesNotInventCompletedAt(t *testing.T) { + database := newTestDB(t) + service := NewService() + require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"}).Error) + project, err := service.CreateProject(1, "Alpha", "") + require.NoError(t, err) + createdAt := time.Date(2026, 7, 20, 1, 0, 0, 0, time.UTC) + updatedAt := createdAt.Add(2 * time.Hour) + require.NoError(t, database.Create(&models.SenlinAgentTask{ + ProjectID: project.ID, CreatedBy: 1, Title: "Done", Status: "done", CreatedAt: createdAt, UpdatedAt: updatedAt, + }).Error) + + workspace, err := service.Workspace(1, project.Identity) + + require.NoError(t, err) + require.Len(t, workspace.Tasks, 1) + require.Nil(t, workspace.Tasks[0].CompletedAt) + payload, err := json.Marshal(workspace.Tasks[0]) + require.NoError(t, err) + require.Contains(t, string(payload), `"completedAt":null`) +} + +func TestWorkspaceFileDoesNotOwnDashboard(t *testing.T) { + source, err := os.ReadFile("workspace.go") + require.NoError(t, err) + require.NotContains(t, string(source), "func (s *Service) Dashboard") +} + func TestWorkspaceRejectsProjectOwnedByAnotherUserByIdentity(t *testing.T) { newTestDB(t) service := NewService()