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>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
)
|
||||
|
||||
const projectListKeyPrefix = "design:projects:list"
|
||||
|
||||
type ProjectRepository struct {
|
||||
next design.Repository
|
||||
store Store
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewProjectRepository(next design.Repository, store Store, ttl time.Duration) *ProjectRepository {
|
||||
return &ProjectRepository{
|
||||
next: next,
|
||||
store: store,
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) List(ctx context.Context) ([]design.ProjectSummary, error) {
|
||||
var projects []design.ProjectSummary
|
||||
ok, err := r.store.Get(ctx, projectListKey(ctx), &projects)
|
||||
if err == nil && ok {
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
projects, err = r.next.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = r.store.Set(ctx, projectListKey(ctx), projects, r.ttl)
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) ListPage(ctx context.Context, limit int64, offset int64) ([]design.ProjectSummary, error) {
|
||||
return r.next.ListPage(ctx, limit, offset)
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Get(ctx context.Context, id string) (design.Project, error) {
|
||||
var project design.Project
|
||||
ok, err := r.store.Get(ctx, projectKey(ctx, id), &project)
|
||||
if err == nil && ok {
|
||||
return project, nil
|
||||
}
|
||||
|
||||
project, err = r.next.Get(ctx, id)
|
||||
if err != nil {
|
||||
return design.Project{}, err
|
||||
}
|
||||
_ = r.store.Set(ctx, projectKey(ctx, id), project, r.ttl)
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Save(ctx context.Context, project design.Project) error {
|
||||
if err := r.next.Save(ctx, project); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = r.store.Set(ctx, projectKey(ctx, project.ID), project, r.ttl)
|
||||
_ = r.store.Delete(ctx, projectListKey(ctx))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) SaveCanvas(ctx context.Context, project design.Project, baseVersion string) error {
|
||||
next, ok := r.next.(design.CanvasRepository)
|
||||
if !ok {
|
||||
return r.Save(ctx, project)
|
||||
}
|
||||
if err := next.SaveCanvas(ctx, project, baseVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = r.store.Delete(ctx, projectKey(ctx, project.ID))
|
||||
_ = r.store.Delete(ctx, projectListKey(ctx))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Delete(ctx context.Context, id string) error {
|
||||
if err := r.next.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = r.store.Delete(ctx, projectKey(ctx, id))
|
||||
_ = r.store.Delete(ctx, projectListKey(ctx))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) SaveMockupBlob(ctx context.Context, blob design.MockupBlobData) error {
|
||||
next, ok := r.next.(design.MockupBlobRepository)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return next.SaveMockupBlob(ctx, blob)
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetMockupBlob(ctx context.Context, dataID string) (design.MockupBlobData, error) {
|
||||
next, ok := r.next.(design.MockupBlobRepository)
|
||||
if !ok {
|
||||
return design.MockupBlobData{}, design.ErrNotFound
|
||||
}
|
||||
return next.GetMockupBlob(ctx, dataID)
|
||||
}
|
||||
|
||||
func projectListKey(ctx context.Context) string {
|
||||
return fmt.Sprintf("%s:%s", projectListKeyPrefix, design.UserIDFromContext(ctx))
|
||||
}
|
||||
|
||||
func projectKey(ctx context.Context, id string) string {
|
||||
return fmt.Sprintf("design:projects:%s:%s", design.UserIDFromContext(ctx), id)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
"img_infinite_canvas/internal/infrastructure/memory"
|
||||
)
|
||||
|
||||
func TestProjectRepositoryCachesProjectReads(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewProjectRepository()
|
||||
store := NewMemoryStore()
|
||||
repo := NewProjectRepository(base, store, time.Minute)
|
||||
|
||||
project := design.Project{
|
||||
ID: "project-1",
|
||||
Title: "Original",
|
||||
Brief: "brief",
|
||||
Status: design.StatusReady,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := base.Save(ctx, project); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first, err := repo.Get(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project.Title = "Changed behind cache"
|
||||
if err := base.Save(ctx, project); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, err := repo.Get(ctx, project.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Title != first.Title {
|
||||
t.Fatalf("expected cached title %q, got %q", first.Title, second.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectRepositoryInvalidatesListOnSave(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewProjectRepository()
|
||||
repo := NewProjectRepository(base, NewMemoryStore(), time.Minute)
|
||||
|
||||
if err := repo.Save(ctx, design.Project{
|
||||
ID: "project-1",
|
||||
Title: "First",
|
||||
Brief: "brief",
|
||||
Status: design.StatusReady,
|
||||
UpdatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first, err := repo.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first) != 1 {
|
||||
t.Fatalf("expected first list length 1, got %d", len(first))
|
||||
}
|
||||
|
||||
if err := repo.Save(ctx, design.Project{
|
||||
ID: "project-2",
|
||||
Title: "Second",
|
||||
Brief: "brief",
|
||||
Status: design.StatusReady,
|
||||
UpdatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, err := repo.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(second) != 2 {
|
||||
t.Fatalf("expected invalidated list length 2, got %d", len(second))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type RedisOptions struct {
|
||||
Addr string
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
type RedisStore struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func NewRedisStore(ctx context.Context, opts RedisOptions) (*RedisStore, error) {
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: opts.Addr,
|
||||
Password: opts.Password,
|
||||
DB: opts.DB,
|
||||
})
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RedisStore{client: client}, nil
|
||||
}
|
||||
|
||||
func (s *RedisStore) Get(ctx context.Context, key string, target any) (bool, error) {
|
||||
value, err := s.client.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if err := json.Unmarshal(value, target); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *RedisStore) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.client.Set(ctx, key, data, ttl).Err()
|
||||
}
|
||||
|
||||
func (s *RedisStore) Delete(ctx context.Context, key string) error {
|
||||
return s.client.Del(ctx, key).Err()
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrUnsupportedDriver = errors.New("unsupported cache driver")
|
||||
|
||||
type Store interface {
|
||||
Get(ctx context.Context, key string, target any) (bool, error)
|
||||
Set(ctx context.Context, key string, value any, ttl time.Duration) error
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
type memoryItem struct {
|
||||
value []byte
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]memoryItem
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
items: make(map[string]memoryItem),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Get(ctx context.Context, key string, target any) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
item, ok := s.items[key]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if !item.expiresAt.IsZero() && time.Now().After(item.expiresAt) {
|
||||
s.mu.Lock()
|
||||
delete(s.items, key)
|
||||
s.mu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(item.value, target); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var expiresAt time.Time
|
||||
if ttl > 0 {
|
||||
expiresAt = time.Now().Add(ttl)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.items[key] = memoryItem{value: data, expiresAt: expiresAt}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Delete(ctx context.Context, key string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
delete(s.items, key)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user