feat: add project action modals and APIs

This commit is contained in:
2026-07-20 21:45:36 +08:00
parent df12201caa
commit c1a144cb44
16 changed files with 877 additions and 23 deletions

View File

@@ -3,17 +3,25 @@ package projects
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"senlinai-agent/backend/internal/logic/auth"
"senlinai-agent/backend/internal/logic/files"
)
type Handler struct {
service *Service
service *Service
fileService *files.Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
func NewHandler(service *Service, fileServices ...*files.Service) *Handler {
var fileService *files.Service
if len(fileServices) > 0 {
fileService = fileServices[0]
}
return &Handler{service: service, fileService: fileService}
}
func (h *Handler) Register(router gin.IRouter) {
@@ -21,6 +29,9 @@ func (h *Handler) Register(router gin.IRouter) {
router.GET("/projects", h.listProjects)
router.GET("/projects/:id/dashboard", h.dashboard)
router.GET("/projects/:id/workspace", h.workspace)
router.POST("/projects/:id/tasks", h.createTask)
router.POST("/projects/:id/sources", h.uploadSource)
router.POST("/projects/:id/cron-plans", h.createCronPlan)
}
func (h *Handler) createProject(c *gin.Context) {
@@ -96,3 +107,149 @@ func (h *Handler) workspace(c *gin.Context) {
}
c.JSON(http.StatusOK, workspace)
}
func (h *Handler) createTask(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
projectID, ok := parseProjectID(c)
if !ok {
return
}
var input struct {
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
DueAt string `json:"dueAt"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
dueAt, err := parseOptionalTime(input.DueAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid dueAt"})
return
}
task, err := h.service.CreateTask(userID, projectID, CreateTaskInput{
Title: input.Title,
Description: input.Description,
Status: input.Status,
DueAt: dueAt,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, task)
}
func (h *Handler) uploadSource(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
if h.fileService == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "file service is not configured"})
return
}
projectID, ok := parseProjectID(c)
if !ok {
return
}
if err := ensureProjectOwner(userID, projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read file"})
return
}
defer file.Close()
stored, err := h.fileService.Save(projectID, fileHeader.Filename, file)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
return
}
title := c.PostForm("title")
if strings.TrimSpace(title) == "" {
title = stored.OriginalName
}
source, err := h.service.CreateFileSource(userID, projectID, CreateFileSourceInput{
Title: title,
FilePath: stored.RelativePath,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, source)
}
func (h *Handler) createCronPlan(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
projectID, ok := parseProjectID(c)
if !ok {
return
}
var input struct {
Title string `json:"title"`
Schedule string `json:"schedule"`
Enabled bool `json:"enabled"`
NextRunAt string `json:"nextRunAt"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
nextRunAt, err := parseOptionalTime(input.NextRunAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid nextRunAt"})
return
}
plan, err := h.service.CreateCronPlan(userID, projectID, CreateCronPlanInput{
Title: input.Title,
Schedule: input.Schedule,
Enabled: input.Enabled,
NextRunAt: nextRunAt,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, plan)
}
func parseProjectID(c *gin.Context) (uint, bool) {
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return 0, false
}
return uint(projectID), true
}
func parseOptionalTime(value string) (*time.Time, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, trimmed)
if err != nil {
return nil, err
}
return &parsed, nil
}

View File

@@ -0,0 +1,96 @@
package projects
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"senlinai-agent/backend/internal/config"
"senlinai-agent/backend/internal/httpx"
"senlinai-agent/backend/internal/logic/files"
"senlinai-agent/backend/internal/models"
)
func TestCreateTaskHandlerPersistsTask(t *testing.T) {
router, project, _ := newProjectsHandlerTestRouter(t)
body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tasks", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var task models.SenlinAgentTask
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "整理需求").First(&task).Error)
require.Equal(t, "open", task.Status)
require.NotNil(t, task.DueAt)
}
func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) {
router, project, storageDir := newProjectsHandlerTestRouter(t)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
require.NoError(t, writer.WriteField("title", "客户资料.txt"))
fileWriter, err := writer.CreateFormFile("file", "客户资料.txt")
require.NoError(t, err)
_, err = fileWriter.Write([]byte("hello"))
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/sources", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var source models.SenlinAgentSource
require.NoError(t, models.DBService.Where("project_id = ? AND kind = ?", project.ID, "file").First(&source).Error)
require.Equal(t, "客户资料.txt", source.Title)
require.NotEmpty(t, source.FilePath)
require.FileExists(t, filepath.Join(storageDir, source.FilePath))
}
func TestCreateCronPlanHandlerPersistsPlan(t *testing.T) {
router, project, _ := newProjectsHandlerTestRouter(t)
body, err := json.Marshal(gin.H{"title": "每日整理", "schedule": "0 9 * * *", "enabled": true, "nextRunAt": "2026-07-22T08:00:00Z"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/cron-plans", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var plan models.SenlinAgentCronPlan
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "每日整理").First(&plan).Error)
require.Equal(t, "0 9 * * *", plan.Schedule)
require.True(t, plan.Enabled)
require.NotNil(t, plan.NextRunAt)
}
func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgentProject, string) {
t.Helper()
newTestDB(t)
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
service := NewService()
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
storageDir := t.TempDir()
router := httpx.NewProtectedRouter(
config.Config{Env: "test"},
func(token string) (uint, error) { return 1, nil },
NewHandler(service, files.NewService(storageDir)),
)
return router, project, storageDir
}

View File

@@ -99,6 +99,25 @@ type ProjectWorkspace struct {
CronPlans []CronPlan `json:"cronPlans"`
}
type CreateTaskInput struct {
Title string
Description string
Status string
DueAt *time.Time
}
type CreateFileSourceInput struct {
Title string
FilePath string
}
type CreateCronPlanInput struct {
Title string
Schedule string
Enabled bool
NextRunAt *time.Time
}
func NewService() *Service {
return &Service{}
}
@@ -118,6 +137,75 @@ func (s *Service) ListProjects(ownerID uint) ([]models.SenlinAgentProject, error
return projects, err
}
func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput) (*models.SenlinAgentTask, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("task title is required")
}
status := strings.TrimSpace(input.Status)
if status == "" {
status = "open"
}
task := &models.SenlinAgentTask{
ProjectID: projectID,
CreatedBy: ownerID,
Title: title,
Description: strings.TrimSpace(input.Description),
Status: status,
DueAt: input.DueAt,
}
return task, models.DBService.Create(task).Error
}
func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("source title is required")
}
filePath := strings.TrimSpace(input.FilePath)
if filePath == "" {
return nil, errors.New("source file path is required")
}
source := &models.SenlinAgentSource{
ProjectID: projectID,
CreatedBy: ownerID,
Kind: "file",
Title: title,
FilePath: filePath,
}
return source, models.DBService.Create(source).Error
}
func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronPlanInput) (*models.SenlinAgentCronPlan, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("cron plan title is required")
}
schedule := strings.TrimSpace(input.Schedule)
if schedule == "" {
return nil, errors.New("cron schedule is required")
}
plan := &models.SenlinAgentCronPlan{
ProjectID: projectID,
CreatedBy: ownerID,
Title: title,
Schedule: schedule,
Enabled: input.Enabled,
NextRunAt: input.NextRunAt,
LastResult: "Not run yet",
}
return plan, models.DBService.Create(plan).Error
}
func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
name = strings.TrimSpace(name)
if name == "" {
@@ -134,8 +222,7 @@ func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
}
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
var project models.SenlinAgentProject
if err := models.DBService.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; err != nil {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return Dashboard{}, err
}
dashboard := Dashboard{ProjectID: projectID}
@@ -212,6 +299,17 @@ func (s *Service) Workspace(ownerID uint, projectID uint) (ProjectWorkspace, err
}, nil
}
func ensureProjectOwner(ownerID uint, projectID uint) error {
var count int64
if err := models.DBService.Model(&models.SenlinAgentProject{}).Where("id = ? AND owner_id = ?", projectID, ownerID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return errors.New("project not found")
}
return nil
}
type workspaceCounts struct {
inbox int64
tasks int64

View File

@@ -127,6 +127,94 @@ func TestWorkspaceRejectsProjectOwnedByAnotherUser(t *testing.T) {
require.Error(t, err)
}
func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
database := newTestDB(t)
service := NewService()
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
dueAt := time.Date(2026, 7, 21, 9, 30, 0, 0, time.UTC)
task, err := service.CreateTask(1, project.ID, CreateTaskInput{
Title: "Follow up with client",
Description: "整理客户反馈并形成行动项",
Status: "",
DueAt: &dueAt,
})
require.NoError(t, err)
require.Equal(t, project.ID, task.ProjectID)
require.Equal(t, uint(1), task.CreatedBy)
require.Equal(t, "Follow up with client", task.Title)
require.Equal(t, "open", task.Status)
require.NotNil(t, task.DueAt)
workspace, err := service.Workspace(1, project.ID)
require.NoError(t, err)
require.Len(t, workspace.Tasks, 1)
require.Equal(t, "Follow up with client", workspace.Tasks[0].Title)
}
func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
newTestDB(t)
service := NewService()
project, err := service.CreateProject(2, "Beta", "")
require.NoError(t, err)
_, err = service.CreateTask(1, project.ID, CreateTaskInput{Title: "Nope"})
require.Error(t, err)
}
func TestCreateFileSourceAddsSourceToOwnedProject(t *testing.T) {
database := newTestDB(t)
service := NewService()
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
source, err := service.CreateFileSource(1, project.ID, CreateFileSourceInput{
Title: "客户访谈.pdf",
FilePath: "projects/1/客户访谈.pdf",
})
require.NoError(t, err)
require.Equal(t, project.ID, source.ProjectID)
require.Equal(t, uint(1), source.CreatedBy)
require.Equal(t, "file", source.Kind)
require.Equal(t, "客户访谈.pdf", source.Title)
require.Equal(t, "projects/1/客户访谈.pdf", source.FilePath)
var count int64
require.NoError(t, database.Model(&models.SenlinAgentSource{}).Where("project_id = ? AND kind = ?", project.ID, "file").Count(&count).Error)
require.Equal(t, int64(1), count)
}
func TestCreateCronPlanAddsPlanToOwnedProject(t *testing.T) {
database := newTestDB(t)
service := NewService()
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
nextRun := time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC)
plan, err := service.CreateCronPlan(1, project.ID, CreateCronPlanInput{
Title: "Daily inbox sweep",
Schedule: "0 9 * * *",
Enabled: true,
NextRunAt: &nextRun,
})
require.NoError(t, err)
require.Equal(t, project.ID, plan.ProjectID)
require.Equal(t, uint(1), plan.CreatedBy)
require.Equal(t, "Daily inbox sweep", plan.Title)
require.Equal(t, "0 9 * * *", plan.Schedule)
require.True(t, plan.Enabled)
require.NotNil(t, plan.NextRunAt)
workspace, err := service.Workspace(1, project.ID)
require.NoError(t, err)
require.Len(t, workspace.CronPlans, 1)
require.Equal(t, "Daily inbox sweep", workspace.CronPlans[0].Title)
}
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})