fix(backend): keep workspace completion metadata honest
This commit is contained in:
24
backend/internal/logic/projects/dashboard.go
Normal file
24
backend/internal/logic/projects/dashboard.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -17,27 +17,6 @@ type workspaceCounts struct {
|
|||||||
cronPlans 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 在确认项目归属后执行首屏聚合;后续子查询只接收已授权的内部主键。
|
// Workspace 在确认项目归属后执行首屏聚合;后续子查询只接收已授权的内部主键。
|
||||||
func (s *Service) Workspace(ownerID uint, projectIdentity string) (WorkspaceDTO, error) {
|
func (s *Service) Workspace(ownerID uint, projectIdentity string) (WorkspaceDTO, error) {
|
||||||
project, err := FindOwnedProject(ownerID, projectIdentity)
|
project, err := FindOwnedProject(ownerID, projectIdentity)
|
||||||
@@ -191,14 +170,10 @@ func (s *Service) workspaceTasks(projectID uint, projectIdentity string) ([]Work
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
completed := task.Status == "done"
|
// 当前模型没有真实完成时间字段,不能用更新时间伪装;CompletedAt 保持 nil。
|
||||||
var completedAt *time.Time
|
|
||||||
if completed {
|
|
||||||
completedAt = utcOptionalTime(&task.UpdatedAt)
|
|
||||||
}
|
|
||||||
item := WorkspaceTaskDTO{
|
item := WorkspaceTaskDTO{
|
||||||
ID: task.Identity, ProjectID: projectIdentity, Title: task.Title, Summary: task.Description,
|
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 {
|
if tag, ok := tags[task.ID]; ok {
|
||||||
item.TagID = &tag.ID
|
item.TagID = &tag.ID
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package projects
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) {
|
|||||||
require.Equal(t, "UI", workspace.Tasks[0].Tag)
|
require.Equal(t, "UI", workspace.Tasks[0].Tag)
|
||||||
require.Equal(t, nextRun.UTC(), *workspace.Tasks[0].Due)
|
require.Equal(t, nextRun.UTC(), *workspace.Tasks[0].Due)
|
||||||
require.Equal(t, createdAt.UTC(), workspace.Tasks[0].CreatedAt)
|
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.Len(t, workspace.AISessions, 1)
|
||||||
require.Equal(t, session.Identity, workspace.AISessions[0].ID)
|
require.Equal(t, session.Identity, workspace.AISessions[0].ID)
|
||||||
@@ -93,6 +94,34 @@ func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) {
|
|||||||
require.NotContains(t, string(payload), "duration")
|
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) {
|
func TestWorkspaceRejectsProjectOwnedByAnotherUserByIdentity(t *testing.T) {
|
||||||
newTestDB(t)
|
newTestDB(t)
|
||||||
service := NewService()
|
service := NewService()
|
||||||
|
|||||||
Reference in New Issue
Block a user