44406b72db
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
563 lines
16 KiB
Go
563 lines
16 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"img_infinite_canvas/internal/domain/design"
|
|
sqlcdb "img_infinite_canvas/internal/infrastructure/postgres/db"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
//go:embed schema.sql
|
|
var schemaSQL string
|
|
|
|
type ProjectRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewProjectRepository(ctx context.Context, dataSource string) (*ProjectRepository, error) {
|
|
pool, err := pgxpool.New(ctx, dataSource)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
|
|
pool.Close()
|
|
return nil, err
|
|
}
|
|
return &ProjectRepository{pool: pool}, nil
|
|
}
|
|
|
|
func (r *ProjectRepository) Close() {
|
|
r.pool.Close()
|
|
}
|
|
|
|
func (r *ProjectRepository) List(ctx context.Context) ([]design.ProjectSummary, error) {
|
|
rows, err := sqlcdb.New(r.pool).ListProjects(ctx, toPGUUID(design.UserIDFromContext(ctx)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items := make([]design.ProjectSummary, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, design.ProjectSummary{
|
|
ID: row.ID,
|
|
UserID: fromPGUUID(row.UserID),
|
|
Title: row.Title,
|
|
Brief: row.Brief,
|
|
Status: design.ProjectStatus(row.Status),
|
|
Thumbnail: row.Thumbnail,
|
|
UpdatedAt: row.UpdatedAt.Time,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (r *ProjectRepository) ListPage(ctx context.Context, limit int64, offset int64) ([]design.ProjectSummary, error) {
|
|
if limit <= 0 {
|
|
return []design.ProjectSummary{}, nil
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
rows, err := sqlcdb.New(r.pool).ListProjectsPage(ctx, sqlcdb.ListProjectsPageParams{
|
|
UserID: toPGUUID(design.UserIDFromContext(ctx)),
|
|
Limit: int32(limit),
|
|
Offset: int32(offset),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items := make([]design.ProjectSummary, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, design.ProjectSummary{
|
|
ID: row.ID,
|
|
UserID: fromPGUUID(row.UserID),
|
|
Title: row.Title,
|
|
Brief: row.Brief,
|
|
Status: design.ProjectStatus(row.Status),
|
|
Thumbnail: row.Thumbnail,
|
|
UpdatedAt: row.UpdatedAt.Time,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (r *ProjectRepository) Get(ctx context.Context, id string) (design.Project, error) {
|
|
q := sqlcdb.New(r.pool)
|
|
userID := design.UserIDFromContext(ctx)
|
|
pgUserID := toPGUUID(userID)
|
|
row, err := q.GetProject(ctx, sqlcdb.GetProjectParams{ID: id, UserID: pgUserID})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return design.Project{}, design.ErrNotFound
|
|
}
|
|
return design.Project{}, err
|
|
}
|
|
|
|
nodes, err := q.ListNodesByProject(ctx, sqlcdb.ListNodesByProjectParams{ProjectID: id, UserID: pgUserID})
|
|
if err != nil {
|
|
return design.Project{}, err
|
|
}
|
|
connections, err := q.ListConnectionsByProject(ctx, sqlcdb.ListConnectionsByProjectParams{ProjectID: id, UserID: pgUserID})
|
|
if err != nil {
|
|
return design.Project{}, err
|
|
}
|
|
messages, err := q.ListMessagesByProject(ctx, sqlcdb.ListMessagesByProjectParams{ProjectID: id, UserID: pgUserID})
|
|
if err != nil {
|
|
return design.Project{}, err
|
|
}
|
|
|
|
project := design.Project{
|
|
ID: row.ID,
|
|
UserID: fromPGUUID(row.UserID),
|
|
Title: row.Title,
|
|
Brief: row.Brief,
|
|
Status: design.ProjectStatus(row.Status),
|
|
Thumbnail: row.Thumbnail,
|
|
Version: row.Version,
|
|
SnapshotID: row.SnapshotID,
|
|
Canvas: row.Canvas,
|
|
LastThreadID: row.LastThreadID,
|
|
UpdatedAt: row.UpdatedAt.Time,
|
|
Viewport: design.Viewport{
|
|
X: row.ViewportX,
|
|
Y: row.ViewportY,
|
|
K: row.ViewportK,
|
|
},
|
|
Nodes: mapNodes(nodes),
|
|
Connections: mapConnections(connections),
|
|
Messages: mapMessages(messages),
|
|
}
|
|
if err := r.applyCurrentSnapshot(ctx, q, &project); err != nil {
|
|
return design.Project{}, err
|
|
}
|
|
return project, nil
|
|
}
|
|
|
|
func (r *ProjectRepository) Save(ctx context.Context, project design.Project) error {
|
|
project.UserID = repositoryUserID(ctx, project.UserID)
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
q := sqlcdb.New(r.pool).WithTx(tx)
|
|
if err := q.UpsertProject(ctx, sqlcdb.UpsertProjectParams{
|
|
ID: project.ID,
|
|
UserID: toPGUUID(project.UserID),
|
|
Title: project.Title,
|
|
Brief: project.Brief,
|
|
Status: string(project.Status),
|
|
Thumbnail: project.Thumbnail,
|
|
Canvas: project.Canvas,
|
|
Version: project.Version,
|
|
SnapshotID: project.SnapshotID,
|
|
LastThreadID: project.LastThreadID,
|
|
ViewportX: project.Viewport.X,
|
|
ViewportY: project.Viewport.Y,
|
|
ViewportK: project.Viewport.K,
|
|
UpdatedAt: pgtype.Timestamptz{Time: project.UpdatedAt, Valid: true},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := q.DeleteConnectionsByProject(ctx, sqlcdb.DeleteConnectionsByProjectParams{ProjectID: project.ID, UserID: toPGUUID(project.UserID)}); err != nil {
|
|
return err
|
|
}
|
|
if err := q.DeleteNodesByProject(ctx, sqlcdb.DeleteNodesByProjectParams{ProjectID: project.ID, UserID: toPGUUID(project.UserID)}); err != nil {
|
|
return err
|
|
}
|
|
if err := q.DeleteMessagesByProject(ctx, sqlcdb.DeleteMessagesByProjectParams{ProjectID: project.ID, UserID: toPGUUID(project.UserID)}); err != nil {
|
|
return err
|
|
}
|
|
|
|
for index, node := range project.Nodes {
|
|
if err := q.UpsertCanvasNode(ctx, sqlcdb.UpsertCanvasNodeParams{
|
|
ID: node.ID,
|
|
ProjectID: project.ID,
|
|
UserID: toPGUUID(project.UserID),
|
|
NodeType: string(node.Type),
|
|
Title: node.Title,
|
|
X: node.X,
|
|
Y: node.Y,
|
|
Width: node.Width,
|
|
Height: node.Height,
|
|
Content: node.Content,
|
|
Tone: node.Tone,
|
|
Status: node.Status,
|
|
SortOrder: int32(index),
|
|
ParentID: node.ParentID,
|
|
LayerRole: node.LayerRole,
|
|
FontSize: node.FontSize,
|
|
FontFamily: node.FontFamily,
|
|
FontWeight: node.FontWeight,
|
|
Color: node.Color,
|
|
TextAlign: node.TextAlign,
|
|
LineHeight: node.LineHeight,
|
|
LetterSpacing: node.LetterSpacing,
|
|
TextDecoration: node.TextDecoration,
|
|
TextTransform: node.TextTransform,
|
|
Opacity: node.Opacity,
|
|
FillColor: node.FillColor,
|
|
StrokeColor: node.StrokeColor,
|
|
StrokeWidth: node.StrokeWidth,
|
|
StrokeStyle: node.StrokeStyle,
|
|
FlipX: node.FlipX,
|
|
FlipY: node.FlipY,
|
|
Rotation: node.Rotation,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, connection := range project.Connections {
|
|
if err := q.CreateConnection(ctx, sqlcdb.CreateConnectionParams{
|
|
ID: connection.ID,
|
|
ProjectID: project.ID,
|
|
UserID: toPGUUID(project.UserID),
|
|
FromNodeID: connection.FromNodeID,
|
|
ToNodeID: connection.ToNodeID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, message := range project.Messages {
|
|
if err := q.CreateMessage(ctx, sqlcdb.CreateMessageParams{
|
|
ID: message.ID,
|
|
ProjectID: project.ID,
|
|
UserID: toPGUUID(project.UserID),
|
|
Role: message.Role,
|
|
MessageType: message.Type,
|
|
Title: message.Title,
|
|
Content: message.Content,
|
|
ThreadID: message.ThreadID,
|
|
ActionID: message.ActionID,
|
|
StepID: message.StepID,
|
|
ToolCallID: message.ToolCallID,
|
|
Name: message.Name,
|
|
ToolHint: message.ToolHint,
|
|
Status: message.Status,
|
|
CreatedAt: pgtype.Timestamptz{Time: message.CreatedAt, Valid: true},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := r.createCanvasSnapshot(ctx, q, project); err != nil {
|
|
return err
|
|
}
|
|
if err := insertProjectOutboxEvent(ctx, tx, project, "project.updated"); err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (r *ProjectRepository) SaveCanvas(ctx context.Context, project design.Project, baseVersion string) error {
|
|
project.UserID = repositoryUserID(ctx, project.UserID)
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
q := sqlcdb.New(r.pool).WithTx(tx)
|
|
locked, err := q.LockProject(ctx, sqlcdb.LockProjectParams{ID: project.ID, UserID: toPGUUID(project.UserID)})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return design.ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
baseVersion = strings.TrimSpace(baseVersion)
|
|
if baseVersion != "" && locked.Version != "" && baseVersion != locked.Version {
|
|
return fmt.Errorf("%w: expected %s but current version is %s", design.ErrConflict, baseVersion, locked.Version)
|
|
}
|
|
if err := r.createCanvasSnapshot(ctx, q, project); err != nil {
|
|
return err
|
|
}
|
|
if err := q.UpdateProjectCanvasSnapshot(ctx, sqlcdb.UpdateProjectCanvasSnapshotParams{
|
|
ID: project.ID,
|
|
UserID: toPGUUID(project.UserID),
|
|
Thumbnail: project.Thumbnail,
|
|
Canvas: project.Canvas,
|
|
Version: project.Version,
|
|
SnapshotID: project.SnapshotID,
|
|
ViewportX: project.Viewport.X,
|
|
ViewportY: project.Viewport.Y,
|
|
ViewportK: project.Viewport.K,
|
|
UpdatedAt: pgtype.Timestamptz{Time: project.UpdatedAt, Valid: true},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := insertProjectOutboxEvent(ctx, tx, project, "canvas.snapshot.created"); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (r *ProjectRepository) Delete(ctx context.Context, id string) error {
|
|
q := sqlcdb.New(r.pool)
|
|
userID := design.UserIDFromContext(ctx)
|
|
pgUserID := toPGUUID(userID)
|
|
project, err := q.GetProject(ctx, sqlcdb.GetProjectParams{ID: id, UserID: pgUserID})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return design.ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
qtx := sqlcdb.New(r.pool).WithTx(tx)
|
|
if err := qtx.DeleteProject(ctx, sqlcdb.DeleteProjectParams{ID: id, UserID: pgUserID}); err != nil {
|
|
return err
|
|
}
|
|
if err := insertProjectOutboxEvent(ctx, tx, design.Project{ID: project.ID, UserID: fromPGUUID(project.UserID), LastThreadID: project.LastThreadID, UpdatedAt: project.UpdatedAt.Time}, "project.deleted"); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (r *ProjectRepository) SaveMockupBlob(ctx context.Context, blob design.MockupBlobData) error {
|
|
if strings.TrimSpace(blob.DataID) == "" || len(blob.Data) == 0 {
|
|
return design.ErrInvalidInput
|
|
}
|
|
createdAt := blob.CreatedAt
|
|
if createdAt.IsZero() {
|
|
createdAt = time.Now()
|
|
}
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO mockup_blobs (data_id, width, height, data, created_at)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (data_id) DO UPDATE SET
|
|
width = EXCLUDED.width,
|
|
height = EXCLUDED.height,
|
|
data = EXCLUDED.data
|
|
`, blob.DataID, blob.Width, blob.Height, blob.Data, pgtype.Timestamptz{Time: createdAt, Valid: true})
|
|
return err
|
|
}
|
|
|
|
func (r *ProjectRepository) GetMockupBlob(ctx context.Context, dataID string) (design.MockupBlobData, error) {
|
|
dataID = strings.TrimSpace(dataID)
|
|
if dataID == "" {
|
|
return design.MockupBlobData{}, design.ErrNotFound
|
|
}
|
|
var blob design.MockupBlobData
|
|
var createdAt pgtype.Timestamptz
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT data_id, width, height, data, created_at
|
|
FROM mockup_blobs
|
|
WHERE data_id = $1
|
|
`, dataID).Scan(&blob.DataID, &blob.Width, &blob.Height, &blob.Data, &createdAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return design.MockupBlobData{}, design.ErrNotFound
|
|
}
|
|
return design.MockupBlobData{}, err
|
|
}
|
|
if createdAt.Valid {
|
|
blob.CreatedAt = createdAt.Time
|
|
}
|
|
blob.Data = append([]byte(nil), blob.Data...)
|
|
return blob, nil
|
|
}
|
|
|
|
func (r *ProjectRepository) applyCurrentSnapshot(ctx context.Context, q *sqlcdb.Queries, project *design.Project) error {
|
|
if project == nil || strings.TrimSpace(project.SnapshotID) == "" {
|
|
return nil
|
|
}
|
|
snapshot, err := q.GetCanvasSnapshot(ctx, sqlcdb.GetCanvasSnapshotParams{
|
|
ID: project.SnapshotID,
|
|
ProjectID: project.ID,
|
|
UserID: toPGUUID(project.UserID),
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
var viewport design.Viewport
|
|
var nodes []design.Node
|
|
var connections []design.Connection
|
|
if err := json.Unmarshal(snapshot.ViewportJson, &viewport); err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(snapshot.NodesJson, &nodes); err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(snapshot.ConnectionsJson, &connections); err != nil {
|
|
return err
|
|
}
|
|
project.Viewport = viewport
|
|
project.Nodes = nodes
|
|
project.Connections = connections
|
|
project.Canvas = snapshot.Canvas
|
|
project.Version = snapshot.Version
|
|
return nil
|
|
}
|
|
|
|
func (r *ProjectRepository) createCanvasSnapshot(ctx context.Context, q *sqlcdb.Queries, project design.Project) error {
|
|
if strings.TrimSpace(project.SnapshotID) == "" || strings.TrimSpace(project.Canvas) == "" {
|
|
return nil
|
|
}
|
|
viewport, err := json.Marshal(project.Viewport)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nodes, err := json.Marshal(project.Nodes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
connections, err := json.Marshal(project.Connections)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return q.CreateCanvasSnapshot(ctx, sqlcdb.CreateCanvasSnapshotParams{
|
|
ID: project.SnapshotID,
|
|
ProjectID: project.ID,
|
|
UserID: toPGUUID(project.UserID),
|
|
Version: project.Version,
|
|
Canvas: project.Canvas,
|
|
ViewportJson: viewport,
|
|
NodesJson: nodes,
|
|
ConnectionsJson: connections,
|
|
CreatedAt: pgtype.Timestamptz{Time: project.UpdatedAt, Valid: true},
|
|
})
|
|
}
|
|
|
|
func repositoryUserID(ctx context.Context, userID string) string {
|
|
userID = strings.TrimSpace(userID)
|
|
if userID != "" {
|
|
return design.NormalizeUserID(userID)
|
|
}
|
|
return design.UserIDFromContext(ctx)
|
|
}
|
|
|
|
func toPGUUID(userID string) pgtype.UUID {
|
|
parsed, err := uuid.Parse(design.NormalizeUserID(userID))
|
|
if err != nil {
|
|
parsed = uuid.MustParse(design.DefaultUserID)
|
|
}
|
|
return pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
|
|
func fromPGUUID(value pgtype.UUID) string {
|
|
if !value.Valid {
|
|
return design.DefaultUserID
|
|
}
|
|
return uuid.UUID(value.Bytes).String()
|
|
}
|
|
|
|
func insertProjectOutboxEvent(ctx context.Context, tx pgx.Tx, project design.Project, eventType string) error {
|
|
eventID := uuid.NewString()
|
|
createdAt := project.UpdatedAt
|
|
if createdAt.IsZero() {
|
|
createdAt = time.Now()
|
|
}
|
|
payload, err := json.Marshal(map[string]any{
|
|
"id": eventID,
|
|
"type": eventType,
|
|
"userId": project.UserID,
|
|
"projectId": project.ID,
|
|
"threadId": project.LastThreadID,
|
|
"version": project.Version,
|
|
"snapshotId": project.SnapshotID,
|
|
"createdAt": createdAt.UTC().Format(time.RFC3339Nano),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO outbox_events (id, user_id, aggregate_type, aggregate_id, event_type, thread_id, payload_json, status, created_at)
|
|
VALUES ($1, $2, 'project', $3, $4, $5, $6, 'pending', $7)
|
|
`, eventID, toPGUUID(project.UserID), project.ID, eventType, project.LastThreadID, payload, pgtype.Timestamptz{Time: createdAt, Valid: true})
|
|
return err
|
|
}
|
|
|
|
func mapNodes(rows []sqlcdb.CanvasNode) []design.Node {
|
|
nodes := make([]design.Node, 0, len(rows))
|
|
for _, row := range rows {
|
|
nodes = append(nodes, design.Node{
|
|
ID: row.ID,
|
|
Type: design.NodeType(row.NodeType),
|
|
Title: row.Title,
|
|
X: row.X,
|
|
Y: row.Y,
|
|
Width: row.Width,
|
|
Height: row.Height,
|
|
Content: row.Content,
|
|
Tone: row.Tone,
|
|
Status: row.Status,
|
|
ParentID: row.ParentID,
|
|
LayerRole: row.LayerRole,
|
|
FontSize: row.FontSize,
|
|
FontFamily: row.FontFamily,
|
|
FontWeight: row.FontWeight,
|
|
Color: row.Color,
|
|
TextAlign: row.TextAlign,
|
|
LineHeight: row.LineHeight,
|
|
LetterSpacing: row.LetterSpacing,
|
|
TextDecoration: row.TextDecoration,
|
|
TextTransform: row.TextTransform,
|
|
Opacity: row.Opacity,
|
|
FillColor: row.FillColor,
|
|
StrokeColor: row.StrokeColor,
|
|
StrokeWidth: row.StrokeWidth,
|
|
StrokeStyle: row.StrokeStyle,
|
|
FlipX: row.FlipX,
|
|
FlipY: row.FlipY,
|
|
Rotation: row.Rotation,
|
|
})
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
func mapConnections(rows []sqlcdb.CanvasConnection) []design.Connection {
|
|
connections := make([]design.Connection, 0, len(rows))
|
|
for _, row := range rows {
|
|
connections = append(connections, design.Connection{
|
|
ID: row.ID,
|
|
FromNodeID: row.FromNodeID,
|
|
ToNodeID: row.ToNodeID,
|
|
})
|
|
}
|
|
return connections
|
|
}
|
|
|
|
func mapMessages(rows []sqlcdb.AgentMessage) []design.Message {
|
|
messages := make([]design.Message, 0, len(rows))
|
|
for _, row := range rows {
|
|
messages = append(messages, design.Message{
|
|
ID: row.ID,
|
|
Role: row.Role,
|
|
Type: row.MessageType,
|
|
Title: row.Title,
|
|
Content: row.Content,
|
|
ThreadID: row.ThreadID,
|
|
ActionID: row.ActionID,
|
|
StepID: row.StepID,
|
|
ToolCallID: row.ToolCallID,
|
|
Name: row.Name,
|
|
ToolHint: row.ToolHint,
|
|
Status: row.Status,
|
|
CreatedAt: row.CreatedAt.Time,
|
|
})
|
|
}
|
|
return messages
|
|
}
|