feat: 无限画布 — infinite canvas with generation integration
Backend: - canvases table + owner-scoped CRUD under /admin/api/canvas (list/create/get/save/delete, 50 docs/user, 4MiB cap, JSON validation) Frontend: - /canvas full-screen workspace: DOM+CSS-transform infinite pan/zoom (GPU-composited world layer, viewport culling, rAF-batched pointer moves) - nodes: image (thumb display, natural-size correction), note (uncontrolled contenteditable), generation placeholder with edges - generate on canvas: prompt from panel or selected notes, selected image nodes as references, results auto-placed + bezier edges to sources - multi-canvas management, debounced autosave + keepalive flush, snapshot undo/redo (60 steps), marquee/keyboard interactions
This commit is contained in:
@@ -146,6 +146,8 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
bannedWordRepo := repo.NewBannedWordRepository(db)
|
||||
v1Svc.SetBannedWords(bannedWordRepo)
|
||||
userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo)
|
||||
canvasRepo := repo.NewCanvasRepository(db)
|
||||
canvasSvc := service.NewCanvasService(canvasRepo)
|
||||
|
||||
engine := router.New(cfg, authSvc, router.Handlers{
|
||||
Health: handler.NewHealthHandler(),
|
||||
@@ -166,6 +168,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
Announcement: handler.NewAnnouncementHandler(announcementSvc),
|
||||
Payment: handler.NewPaymentHandler(paymentSvc),
|
||||
BannedWords: handler.NewBannedWordsHandler(bannedWordRepo),
|
||||
Canvas: handler.NewCanvasHandler(canvasSvc),
|
||||
})
|
||||
|
||||
// Background self-healing sweep (quota recovery, cookie refresh, stale-pending
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/service"
|
||||
)
|
||||
|
||||
// CanvasHandler exposes the infinite-canvas CRUD under the session-authed
|
||||
// /admin/api group. Every operation is scoped to the logged-in user.
|
||||
type CanvasHandler struct {
|
||||
svc *service.CanvasService
|
||||
}
|
||||
|
||||
func NewCanvasHandler(svc *service.CanvasService) *CanvasHandler {
|
||||
return &CanvasHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List serves the canvas picker (id/title/updatedAt only).
|
||||
func (h *CanvasHandler) List(c *gin.Context) {
|
||||
items, err := h.svc.List(c.Request.Context(), currentUser(c).ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to list canvases"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// Create provisions a new canvas; title is optional.
|
||||
func (h *CanvasHandler) Create(c *gin.Context) {
|
||||
var body struct {
|
||||
Title *string `json:"title"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
// Empty body is fine — treat as no title.
|
||||
body.Title = nil
|
||||
}
|
||||
cv, err := h.svc.Create(c.Request.Context(), currentUser(c).ID, deref(body.Title))
|
||||
if err != nil {
|
||||
respondCanvasError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, canvasDoc(cv))
|
||||
}
|
||||
|
||||
// Get returns the full document blob (parsed by the client).
|
||||
func (h *CanvasHandler) Get(c *gin.Context) {
|
||||
cv, err := h.svc.Get(c.Request.Context(), currentUser(c).ID, c.Param("id"))
|
||||
if err != nil {
|
||||
respondCanvasError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, canvasDoc(cv))
|
||||
}
|
||||
|
||||
// Save replaces the whole document (autosave) and/or renames.
|
||||
func (h *CanvasHandler) Save(c *gin.Context) {
|
||||
var body struct {
|
||||
Title *string `json:"title"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Save(c.Request.Context(), currentUser(c).ID, c.Param("id"), body.Title, body.Data); err != nil {
|
||||
respondCanvasError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *CanvasHandler) Delete(c *gin.Context) {
|
||||
if err := h.svc.Delete(c.Request.Context(), currentUser(c).ID, c.Param("id")); err != nil {
|
||||
respondCanvasError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func canvasDoc(cv *model.Canvas) gin.H {
|
||||
return gin.H{"id": cv.ID, "title": cv.Title, "data": cv.Data, "updated_at": cv.UpdatedAt}
|
||||
}
|
||||
|
||||
func respondCanvasError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrCanvasNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrCanvasLimit),
|
||||
errors.Is(err, service.ErrCanvasTooLarge),
|
||||
errors.Is(err, service.ErrCanvasBadJSON):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "canvas operation failed"})
|
||||
}
|
||||
}
|
||||
|
||||
func deref(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type Handlers struct {
|
||||
Announcement *handler.AnnouncementHandler
|
||||
Payment *handler.PaymentHandler
|
||||
BannedWords *handler.BannedWordsHandler
|
||||
Canvas *handler.CanvasHandler
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.Engine {
|
||||
@@ -112,6 +113,12 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
userAuthed.GET("/pay/orders", handlers.Payment.MyOrders)
|
||||
userAuthed.GET("/pay/orders/:id", handlers.Payment.OrderStatus)
|
||||
userAuthed.POST("/pay/orders/:id/continue", handlers.Payment.ContinueOrder)
|
||||
// 无限画布 documents — session-authed, owner-scoped.
|
||||
userAuthed.GET("/canvas", handlers.Canvas.List)
|
||||
userAuthed.POST("/canvas", handlers.Canvas.Create)
|
||||
userAuthed.GET("/canvas/:id", handlers.Canvas.Get)
|
||||
userAuthed.PUT("/canvas/:id", handlers.Canvas.Save)
|
||||
userAuthed.DELETE("/canvas/:id", handlers.Canvas.Delete)
|
||||
}
|
||||
|
||||
authed := engine.Group("/admin/api")
|
||||
|
||||
@@ -250,6 +250,7 @@ func AutoMigrateModels() []any {
|
||||
&StatCounter{},
|
||||
&ConcurrencyGroup{},
|
||||
&Order{},
|
||||
&Canvas{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +290,18 @@ type ConcurrencyGroup struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Canvas is one user's infinite-canvas document. Data stores the whole
|
||||
// node/edge graph as a versioned JSON blob; saves are whole-document
|
||||
// replacements (last write wins), which matches the single-editor frontend.
|
||||
type Canvas struct {
|
||||
ID string `gorm:"primaryKey;size:40"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
Title string `gorm:"size:120;not null;default:'未命名画布'"`
|
||||
Data string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
// StatCounter is a persistent monotonic counter (key → value), independent of the
|
||||
// event_log (which is retention-pruned / clearable). Used for the dashboard
|
||||
// cumulative cards (total/success/failed/image/video/api) so they never reset.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"backend/internal/model"
|
||||
)
|
||||
|
||||
// CanvasRepository persists infinite-canvas documents. All queries are
|
||||
// userID-scoped — a canvas is only ever read or written by its owner.
|
||||
type CanvasRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCanvasRepository(db *gorm.DB) *CanvasRepository {
|
||||
return &CanvasRepository{db: db}
|
||||
}
|
||||
|
||||
// List returns the user's canvases newest-first, without the (potentially
|
||||
// large) Data blob — the picker only needs id/title/updatedAt.
|
||||
func (r *CanvasRepository) List(ctx context.Context, userID string) ([]model.Canvas, error) {
|
||||
var items []model.Canvas
|
||||
err := r.db.WithContext(ctx).
|
||||
Select("id", "user_id", "title", "updated_at").
|
||||
Where("user_id = ?", userID).
|
||||
Order("updated_at DESC").
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (r *CanvasRepository) Get(ctx context.Context, userID, id string) (*model.Canvas, error) {
|
||||
var c model.Canvas
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
First(&c).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (r *CanvasRepository) Create(ctx context.Context, c *model.Canvas) error {
|
||||
return r.db.WithContext(ctx).Create(c).Error
|
||||
}
|
||||
|
||||
// Update replaces title and/or the document blob. A no-match update reports
|
||||
// ErrRecordNotFound (gorm's Updates is silent about missing rows).
|
||||
func (r *CanvasRepository) Update(ctx context.Context, userID, id string, fields map[string]any) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&model.Canvas{}).
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Updates(fields)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *CanvasRepository) Delete(ctx context.Context, userID, id string) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Delete(&model.Canvas{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByUser backs the per-user document cap.
|
||||
func (r *CanvasRepository) CountByUser(ctx context.Context, userID string) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.Canvas{}).
|
||||
Where("user_id = ?", userID).
|
||||
Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/repo"
|
||||
)
|
||||
|
||||
// CanvasService owns the infinite-canvas documents (无限画布): whole-document
|
||||
// JSON blobs per user, written by the frontend's debounced autosave. The
|
||||
// service validates shape/size and enforces a per-user document cap so a
|
||||
// runaway client can't fan out unbounded rows.
|
||||
type CanvasService struct {
|
||||
canvases *repo.CanvasRepository
|
||||
}
|
||||
|
||||
var (
|
||||
ErrCanvasNotFound = errors.New("画布不存在")
|
||||
ErrCanvasLimit = errors.New("画布数量已达上限")
|
||||
ErrCanvasTooLarge = errors.New("画布数据过大")
|
||||
ErrCanvasBadJSON = errors.New("画布数据格式错误")
|
||||
)
|
||||
|
||||
const (
|
||||
maxCanvasesPerUser = 50
|
||||
maxCanvasDataBytes = 4 << 20 // 4 MiB per document
|
||||
maxCanvasTitle = 120
|
||||
)
|
||||
|
||||
func NewCanvasService(canvases *repo.CanvasRepository) *CanvasService {
|
||||
return &CanvasService{canvases: canvases}
|
||||
}
|
||||
|
||||
// ListSummary is the picker row — Data deliberately omitted.
|
||||
type ListSummary struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *CanvasService) List(ctx context.Context, userID string) ([]ListSummary, error) {
|
||||
items, err := s.canvases.List(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ListSummary, 0, len(items))
|
||||
for _, c := range items {
|
||||
out = append(out, ListSummary{ID: c.ID, Title: c.Title, UpdatedAt: c.UpdatedAt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Create provisions an empty canvas with a starter document so the frontend
|
||||
// can render immediately without a special "new" state.
|
||||
func (s *CanvasService) Create(ctx context.Context, userID, title string) (*model.Canvas, error) {
|
||||
if n, err := s.canvases.CountByUser(ctx, userID); err != nil {
|
||||
return nil, err
|
||||
} else if n >= maxCanvasesPerUser {
|
||||
return nil, ErrCanvasLimit
|
||||
}
|
||||
title = normalizeCanvasTitle(title)
|
||||
starter, _ := json.Marshal(map[string]any{
|
||||
"version": 1,
|
||||
"viewport": map[string]any{"x": 0, "y": 0, "scale": 1},
|
||||
"nodes": []any{},
|
||||
"edges": []any{},
|
||||
})
|
||||
c := &model.Canvas{
|
||||
ID: "cv-" + randomUpper(10),
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Data: string(starter),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := s.canvases.Create(ctx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *CanvasService) Get(ctx context.Context, userID, id string) (*model.Canvas, error) {
|
||||
c, err := s.canvases.Get(ctx, userID, id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrCanvasNotFound
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// Save replaces the document (and optionally renames). The blob must be valid
|
||||
// JSON with the expected envelope; size is capped before it hits the DB.
|
||||
func (s *CanvasService) Save(ctx context.Context, userID, id string, title *string, data string) error {
|
||||
data = strings.TrimSpace(data)
|
||||
if len(data) > maxCanvasDataBytes {
|
||||
return ErrCanvasTooLarge
|
||||
}
|
||||
if !json.Valid([]byte(data)) {
|
||||
return ErrCanvasBadJSON
|
||||
}
|
||||
fields := map[string]any{"data": data, "updated_at": time.Now()}
|
||||
if title != nil {
|
||||
fields["title"] = normalizeCanvasTitle(*title)
|
||||
}
|
||||
err := s.canvases.Update(ctx, userID, id, fields)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrCanvasNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *CanvasService) Delete(ctx context.Context, userID, id string) error {
|
||||
err := s.canvases.Delete(ctx, userID, id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrCanvasNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeCanvasTitle(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
t = "未命名画布"
|
||||
}
|
||||
runes := []rune(t)
|
||||
if len(runes) > maxCanvasTitle {
|
||||
t = string(runes[:maxCanvasTitle])
|
||||
}
|
||||
return t
|
||||
}
|
||||
Reference in New Issue
Block a user