diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 0fdbf7b..c75cdc9 100755 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -18,3 +18,8 @@ func Init(dsn string, models ...any) error { return DB.AutoMigrate(models...) } + +// GetDB 获取全局数据库实例。 +func GetDB() *gorm.DB { + return DB +} diff --git a/backend/internal/handler/project.go b/backend/internal/handler/project.go new file mode 100644 index 0000000..11a39de --- /dev/null +++ b/backend/internal/handler/project.go @@ -0,0 +1,280 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + + "gen2d/internal/logger" + "gen2d/internal/model" + "gen2d/internal/service" + + "github.com/gin-gonic/gin" +) + +// CreateProjectRequest 创建工程请求。 +type CreateProjectRequest struct { + Name string `json:"name" binding:"required,min=1,max=100"` + Style map[string]string `json:"style"` +} + +// UpdateProjectRequest 更新工程请求。 +type UpdateProjectRequest struct { + Name string `json:"name" binding:"omitempty,min=1,max=100"` +} + +// UpdateStyleRequest 更新风格请求。 +type UpdateStyleRequest struct { + KvPairs map[string]string `json:"kvPairs"` +} + +// CreateProject 创建工程。 +func CreateProject(c *gin.Context) { + userID := c.GetUint("userID") + if userID == 0 { + c.JSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "未认证")) + return + } + + var req CreateProjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "参数错误: "+err.Error())) + return + } + + projectSvc := service.GetProjectService() + proj, err := projectSvc.CreateProject(c.Request.Context(), userID, req.Name, req.Style) + if err != nil { + logger.FromCtx(c.Request.Context()).Error("create project failed", "error", err) + c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "创建工程失败")) + return + } + + c.JSON(http.StatusCreated, model.OK(proj)) +} + +// ListProjects 获取工程列表。 +func ListProjects(c *gin.Context) { + userID := c.GetUint("userID") + if userID == 0 { + c.JSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "未认证")) + return + } + + pageStr := c.DefaultQuery("page", "1") + pageSizeStr := c.DefaultQuery("pageSize", "20") + + page, err := strconv.Atoi(pageStr) + if err != nil || page < 1 { + page = 1 + } + + pageSize, err := strconv.Atoi(pageSizeStr) + if err != nil || pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + projectSvc := service.GetProjectService() + result, err := projectSvc.ListProjects(c.Request.Context(), userID, page, pageSize) + if err != nil { + logger.FromCtx(c.Request.Context()).Error("list projects failed", "error", err) + c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "获取工程列表失败")) + return + } + + c.JSON(http.StatusOK, model.OK(result)) +} + +// GetProject 获取工程详情。 +func GetProject(c *gin.Context) { + userID := c.GetUint("userID") + if userID == 0 { + c.JSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "未认证")) + return + } + + projectIDStr := c.Param("projectId") + projectID, err := strconv.ParseUint(projectIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "无效的工程ID")) + return + } + + projectSvc := service.GetProjectService() + proj, err := projectSvc.GetProject(c.Request.Context(), userID, uint(projectID)) + if err != nil { + logger.FromCtx(c.Request.Context()).Error("get project failed", "error", err) + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, model.Fail(http.StatusNotFound, "工程不存在")) + return + } + c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "获取工程详情失败")) + return + } + + c.JSON(http.StatusOK, model.OK(proj)) +} + +// UpdateProject 更新工程信息。 +func UpdateProject(c *gin.Context) { + userID := c.GetUint("userID") + if userID == 0 { + c.JSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "未认证")) + return + } + + projectIDStr := c.Param("projectId") + projectID, err := strconv.ParseUint(projectIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "无效的工程ID")) + return + } + + var req UpdateProjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "参数错误: "+err.Error())) + return + } + + projectSvc := service.GetProjectService() + proj, err := projectSvc.UpdateProject(c.Request.Context(), userID, uint(projectID), req.Name) + if err != nil { + logger.FromCtx(c.Request.Context()).Error("update project failed", "error", err) + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, model.Fail(http.StatusNotFound, "工程不存在")) + return + } + c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "更新工程信息失败")) + return + } + + c.JSON(http.StatusOK, model.OK(proj)) +} + +// DeleteProject 删除工程。 +func DeleteProject(c *gin.Context) { + userID := c.GetUint("userID") + if userID == 0 { + c.JSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "未认证")) + return + } + + projectIDStr := c.Param("projectId") + projectID, err := strconv.ParseUint(projectIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "无效的工程ID")) + return + } + + projectSvc := service.GetProjectService() + err = projectSvc.DeleteProject(c.Request.Context(), userID, uint(projectID)) + if err != nil { + logger.FromCtx(c.Request.Context()).Error("delete project failed", "error", err) + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, model.Fail(http.StatusNotFound, "工程不存在")) + return + } + c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "删除工程失败")) + return + } + + c.JSON(http.StatusOK, model.OK(nil)) +} + +// GetStyle 获取工程风格。 +func GetStyle(c *gin.Context) { + userID := c.GetUint("userID") + if userID == 0 { + c.JSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "未认证")) + return + } + + projectIDStr := c.Param("projectId") + projectID, err := strconv.ParseUint(projectIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "无效的工程ID")) + return + } + + projectSvc := service.GetProjectService() + style, err := projectSvc.GetStyle(c.Request.Context(), userID, uint(projectID)) + if err != nil { + logger.FromCtx(c.Request.Context()).Error("get style failed", "error", err) + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, model.Fail(http.StatusNotFound, "工程不存在")) + return + } + c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "获取工程风格失败")) + return + } + + c.JSON(http.StatusOK, model.OK(map[string]interface{}{ + "kvPairs": style, + })) +} + +// UpdateStyle 更新工程风格。 +func UpdateStyle(c *gin.Context) { + userID := c.GetUint("userID") + if userID == 0 { + c.JSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "未认证")) + return + } + + projectIDStr := c.Param("projectId") + projectID, err := strconv.ParseUint(projectIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "无效的工程ID")) + return + } + + var req UpdateStyleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "参数错误: "+err.Error())) + return + } + + projectSvc := service.GetProjectService() + err = projectSvc.UpdateStyle(c.Request.Context(), userID, uint(projectID), req.KvPairs) + if err != nil { + logger.FromCtx(c.Request.Context()).Error("update style failed", "error", err) + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, model.Fail(http.StatusNotFound, "工程不存在")) + return + } + c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "更新工程风格失败")) + return + } + + c.JSON(http.StatusOK, model.OK(nil)) +} + +// GetProjectTasks 获取工程下的任务列表。 +func GetProjectTasks(c *gin.Context) { + userID := c.GetUint("userID") + if userID == 0 { + c.JSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, "未认证")) + return + } + + projectIDStr := c.Param("projectId") + projectID, err := strconv.ParseUint(projectIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, model.Fail(http.StatusBadRequest, "无效的工程ID")) + return + } + + projectSvc := service.GetProjectService() + tasks, err := projectSvc.GetTasks(c.Request.Context(), userID, uint(projectID)) + if err != nil { + logger.FromCtx(c.Request.Context()).Error("get tasks failed", "error", err) + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, model.Fail(http.StatusNotFound, "工程不存在")) + return + } + c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "获取任务列表失败")) + return + } + + c.JSON(http.StatusOK, model.OK(tasks)) +} diff --git a/backend/internal/model/project.go b/backend/internal/model/project.go new file mode 100644 index 0000000..d298c3f --- /dev/null +++ b/backend/internal/model/project.go @@ -0,0 +1,43 @@ +package model + +import "time" + +// Project 工程数据模型,对应数据库表。 +type Project struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index" json:"-"` // 用户ID,用于权限控制 + Name string `gorm:"size:100;not null" json:"name"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` +} + +// ProjectStyleRecord 工程风格记录,用于存储键值对。 +type ProjectStyleRecord struct { + ID uint `gorm:"primaryKey" json:"id"` + ProjectID uint `gorm:"index" json:"-"` + Key string `gorm:"size:50;not null" json:"-"` + Value string `gorm:"size:100;not null" json:"-"` + CreatedAt time.Time `json:"-"` +} + +// ProjectResponse 工程响应,包含风格键值对和元数据。 +type ProjectResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Style *ProjectStyleResponse `json:"style"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` + TaskCount int `json:"taskCount,omitempty"` + LastActivityAt string `json:"lastActivityAt,omitempty"` +} + +// ProjectStyleResponse 工程风格响应。 +type ProjectStyleResponse struct { + KvPairs map[string]string `json:"kvPairs"` +} + +// ProjectsListResponse 工程列表响应。 +type ProjectsListResponse struct { + Total int `json:"total"` + Projects []ProjectResponse `json:"projects"` +} diff --git a/backend/internal/model/task.go b/backend/internal/model/task.go new file mode 100644 index 0000000..a62ab60 --- /dev/null +++ b/backend/internal/model/task.go @@ -0,0 +1,33 @@ +package model + +import "time" + +// Task 任务数据模型,对应数据库表。 +type Task struct { + ID uint `gorm:"primaryKey" json:"id"` + ProjectID uint `gorm:"index" json:"-"` + Prompt string `gorm:"type:text" json:"prompt"` + AssetType string `gorm:"size:50" json:"assetType"` + Status string `gorm:"size:20;default:'pending'" json:"status"` + Stage string `gorm:"size:50" json:"stage,omitempty"` + Progress int `gorm:"default:0" json:"progress"` + RetryCount int `gorm:"default:0" json:"retryCount"` + Error string `gorm:"type:text" json:"error,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` +} + +// TaskResponse 任务响应。 +type TaskResponse struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + Prompt string `json:"prompt"` + AssetType string `json:"assetType"` + Status string `json:"status"` + Stage string `json:"stage,omitempty"` + Progress int `json:"progress"` + RetryCount int `json:"retryCount"` + Error string `json:"error,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` +} diff --git a/backend/internal/service/project.go b/backend/internal/service/project.go new file mode 100644 index 0000000..14138f3 --- /dev/null +++ b/backend/internal/service/project.go @@ -0,0 +1,272 @@ +package service + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "gen2d/internal/model" + "gorm.io/gorm" +) + +var ( + projectService *ProjectService + projectServiceOnce sync.Once +) + +// ProjectService 工程管理服务。 +type ProjectService struct { + db *gorm.DB +} + +// InitProjectService 初始化工程管理服务。 +func InitProjectService(db *gorm.DB) { + projectServiceOnce.Do(func() { + projectService = &ProjectService{db: db} + }) +} + +// GetProjectService 获取工程管理服务实例。 +func GetProjectService() *ProjectService { + return projectService +} + +// CreateProject 创建工程。 +func (s *ProjectService) CreateProject(ctx context.Context, userID uint, name string, style map[string]string) (*model.ProjectResponse, error) { + project := &model.Project{ + UserID: userID, + Name: name, + CreatedAt: time.Now(), + } + + if err := s.db.WithContext(ctx).Create(project).Error; err != nil { + return nil, fmt.Errorf("create project failed: %w", err) + } + + // 保存风格键值对 + if err := s.saveStyle(ctx, project.ID, style); err != nil { + // 回滚工程创建 + s.db.WithContext(ctx).Delete(project) + return nil, fmt.Errorf("save style failed: %w", err) + } + + return s.toProjectResponse(project, style), nil +} + +// ListProjects 获取用户工程列表。 +func (s *ProjectService) ListProjects(ctx context.Context, userID uint, page, pageSize int) (*model.ProjectsListResponse, error) { + var projects []model.Project + var total int64 + + offset := (page - 1) * pageSize + + if err := s.db.WithContext(ctx).Model(&model.Project{}).Where("user_id = ?", userID).Count(&total).Error; err != nil { + return nil, fmt.Errorf("count projects failed: %w", err) + } + + if err := s.db.WithContext(ctx). + Where("user_id = ?", userID). + Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(&projects).Error; err != nil { + return nil, fmt.Errorf("list projects failed: %w", err) + } + + response := &model.ProjectsListResponse{ + Total: int(total), + Projects: make([]model.ProjectResponse, len(projects)), + } + + for i, p := range projects { + style, _ := s.getStyle(ctx, p.ID) + response.Projects[i] = *s.toProjectResponse(&p, style) + } + + return response, nil +} + +// GetProject 获取工程详情。 +func (s *ProjectService) GetProject(ctx context.Context, userID uint, projectID uint) (*model.ProjectResponse, error) { + var project model.Project + if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", projectID, userID).First(&project).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("project not found") + } + return nil, fmt.Errorf("get project failed: %w", err) + } + + style, _ := s.getStyle(ctx, project.ID) + return s.toProjectResponse(&project, style), nil +} + +// UpdateProject 更新工程信息。 +func (s *ProjectService) UpdateProject(ctx context.Context, userID uint, projectID uint, name string) (*model.ProjectResponse, error) { + var project model.Project + if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", projectID, userID).First(&project).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("project not found") + } + return nil, fmt.Errorf("get project failed: %w", err) + } + + project.Name = name + + if err := s.db.WithContext(ctx).Save(&project).Error; err != nil { + return nil, fmt.Errorf("update project failed: %w", err) + } + + style, _ := s.getStyle(ctx, project.ID) + return s.toProjectResponse(&project, style), nil +} + +// DeleteProject 删除工程。 +func (s *ProjectService) DeleteProject(ctx context.Context, userID uint, projectID uint) error { + var project model.Project + if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", projectID, userID).First(&project).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("project not found") + } + return fmt.Errorf("get project failed: %w", err) + } + + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 删除风格记录 + if err := tx.Where("project_id = ?", projectID).Delete(&model.ProjectStyleRecord{}).Error; err != nil { + return fmt.Errorf("delete style records failed: %w", err) + } + + // TODO: 删除任务和素材记录 + // TODO: 删除七牛云对象 + + // 删除工程 + if err := tx.Delete(&project).Error; err != nil { + return fmt.Errorf("delete project failed: %w", err) + } + + return nil + }) +} + +// GetStyle 获取工程风格。 +func (s *ProjectService) GetStyle(ctx context.Context, userID uint, projectID uint) (map[string]string, error) { + var project model.Project + if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", projectID, userID).First(&project).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("project not found") + } + return nil, fmt.Errorf("get project failed: %w", err) + } + + style, _ := s.getStyle(ctx, project.ID) + return style, nil +} + +// UpdateStyle 更新工程风格。 +func (s *ProjectService) UpdateStyle(ctx context.Context, userID uint, projectID uint, kvPairs map[string]string) error { + var project model.Project + if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", projectID, userID).First(&project).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("project not found") + } + return fmt.Errorf("get project failed: %w", err) + } + + return s.saveStyle(ctx, projectID, kvPairs) +} + +// GetTasks 获取工程下的任务列表。 +func (s *ProjectService) GetTasks(ctx context.Context, userID uint, projectID uint) ([]model.TaskResponse, error) { + var project model.Project + if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", projectID, userID).First(&project).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("project not found") + } + return nil, fmt.Errorf("get project failed: %w", err) + } + + var tasks []model.Task + if err := s.db.WithContext(ctx). + Where("project_id = ?", projectID). + Order("created_at DESC"). + Find(&tasks).Error; err != nil { + return nil, fmt.Errorf("get tasks failed: %w", err) + } + + response := make([]model.TaskResponse, len(tasks)) + for i, t := range tasks { + response[i] = model.TaskResponse{ + ID: fmt.Sprintf("%d", t.ID), + ProjectID: fmt.Sprintf("%d", t.ProjectID), + Prompt: t.Prompt, + AssetType: t.AssetType, + Status: t.Status, + Stage: t.Stage, + Progress: t.Progress, + RetryCount: t.RetryCount, + Error: t.Error, + CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + return response, nil +} + +func (s *ProjectService) saveStyle(ctx context.Context, projectID uint, style map[string]string) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 删除现有风格记录 + if err := tx.Where("project_id = ?", projectID).Delete(&model.ProjectStyleRecord{}).Error; err != nil { + return err + } + + // 插入新记录 + for key, value := range style { + record := &model.ProjectStyleRecord{ + ProjectID: projectID, + Key: key, + Value: value, + } + if err := tx.Create(record).Error; err != nil { + return err + } + } + + return nil + }) +} + +func (s *ProjectService) getStyle(ctx context.Context, projectID uint) (map[string]string, error) { + var records []model.ProjectStyleRecord + if err := s.db.WithContext(ctx).Where("project_id = ?", projectID).Find(&records).Error; err != nil { + return nil, err + } + + style := make(map[string]string) + for _, r := range records { + style[r.Key] = r.Value + } + + return style, nil +} + +func (s *ProjectService) toProjectResponse(project *model.Project, style map[string]string) *model.ProjectResponse { + // 获取任务数量 + var taskCount int64 + s.db.Model(&model.Task{}).Where("project_id = ?", project.ID).Count(&taskCount) + + styleResp := &model.ProjectStyleResponse{ + KvPairs: style, + } + + return &model.ProjectResponse{ + ID: fmt.Sprintf("%d", project.ID), + Name: project.Name, + Style: styleResp, + CreatedAt: project.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: project.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + TaskCount: int(taskCount), + } +}