feat: 实现工程管理模块

- 新增 Project ProjectStyleRecord Task 数据模型
- 新增 ProjectService 服务层,支持工程和风格的 CRUD 操作
- 新增工程相关 handler,实现前端所需的所有工程接口
- 数据库层新增 GetDB 方法,便于服务层获取数据库连接

涉及接口:
- GET /api/v1/projects - 获取工程列表
- POST /api/v1/projects - 创建工程
- GET /api/v1/projects/:projectId - 获取工程详情
- PUT /api/v1/projects/:projectId - 更新工程信息
- DELETE /api/v1/projects/:projectId - 删除工程
- GET /api/v1/projects/:projectId/style - 获取工程风格
- PUT /api/v1/projects/:projectId/style - 更新工程风格
- GET /api/v1/projects/:projectId/tasks - 获取工程任务列表
This commit is contained in:
2026-05-25 16:38:46 +08:00
parent a603f5548c
commit 8f622f6814
5 changed files with 633 additions and 0 deletions
+5
View File
@@ -18,3 +18,8 @@ func Init(dsn string, models ...any) error {
return DB.AutoMigrate(models...)
}
// GetDB 获取全局数据库实例。
func GetDB() *gorm.DB {
return DB
}
+280
View File
@@ -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))
}
+43
View File
@@ -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"`
}
+33
View File
@@ -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"`
}
+272
View File
@@ -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),
}
}