Files
moteva/server/internal/infrastructure/postgres/project_repository.go
T
root 44406b72db Initial commit: img-infinite-canvas AI design workbench MVP
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>
2026-07-07 23:15:37 +08:00

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
}