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,582 @@
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/application"
|
||||
"img_infinite_canvas/internal/config"
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
"img_infinite_canvas/internal/infrastructure/backgroundremoval"
|
||||
cacheinfra "img_infinite_canvas/internal/infrastructure/cache"
|
||||
"img_infinite_canvas/internal/infrastructure/memory"
|
||||
"img_infinite_canvas/internal/infrastructure/objectrecognition"
|
||||
"img_infinite_canvas/internal/infrastructure/objectstorage"
|
||||
"img_infinite_canvas/internal/infrastructure/piagent"
|
||||
"img_infinite_canvas/internal/infrastructure/postgres"
|
||||
"img_infinite_canvas/internal/infrastructure/textextraction"
|
||||
"img_infinite_canvas/internal/infrastructure/websearch"
|
||||
authmodule "img_infinite_canvas/internal/modules/auth"
|
||||
jobmodule "img_infinite_canvas/internal/modules/job"
|
||||
realtimemodule "img_infinite_canvas/internal/modules/realtime"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type ServiceContext struct {
|
||||
Config config.Config
|
||||
DesignService *application.DesignService
|
||||
AuthService *authmodule.Service
|
||||
RealtimeBus realtimemodule.Bus
|
||||
jobQueue design.JobQueue
|
||||
jobWorker backgroundWorker
|
||||
outboxWorker backgroundWorker
|
||||
}
|
||||
|
||||
type backgroundWorker interface {
|
||||
Start() error
|
||||
Run() error
|
||||
Shutdown()
|
||||
}
|
||||
|
||||
func NewServiceContext(c config.Config) *ServiceContext {
|
||||
repo := newProjectRepository(c)
|
||||
cacheStore := newCacheStore(c)
|
||||
authService := newAuthService(c, cacheStore)
|
||||
repo = withCache(c, repo, cacheStore)
|
||||
realtimeBus := newRealtimeBus(c)
|
||||
repo = withRealtime(repo, realtimeBus)
|
||||
creativeAgent := newCreativeAgent(c)
|
||||
agentRunner := newAgentRunner(c)
|
||||
assets := newAssetStorage(c)
|
||||
researcher := newResearcher(c)
|
||||
textExtractor := newTextExtractor(c)
|
||||
service := application.NewDesignService(repo, agentRunner, assets, researcher, textExtractor, creativeAgent)
|
||||
service.SetBackgroundRemover(newBackgroundRemover(c))
|
||||
service.SetObjectRecognizer(newObjectRecognizer(c))
|
||||
if mockupAnalyzer, ok := creativeAgent.(design.MockupModelAnalyzer); ok {
|
||||
service.SetMockupModelAnalyzer(mockupAnalyzer)
|
||||
}
|
||||
service.SetTextExtractionCache(cacheStore, textExtractionCacheTTL(c))
|
||||
queue, worker := newJobQueue(c, service)
|
||||
service.SetJobQueue(queue)
|
||||
outboxWorker := newOutboxPublisher(c, realtimeBus)
|
||||
if err := service.EnsureSeedData(context.Background()); err != nil {
|
||||
logx.Must(err)
|
||||
}
|
||||
|
||||
return &ServiceContext{
|
||||
Config: c,
|
||||
DesignService: service,
|
||||
AuthService: authService,
|
||||
RealtimeBus: realtimeBus,
|
||||
jobQueue: queue,
|
||||
jobWorker: worker,
|
||||
outboxWorker: outboxWorker,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServiceContext) StartJobWorker() error {
|
||||
if s == nil || s.jobWorker == nil {
|
||||
return nil
|
||||
}
|
||||
return s.jobWorker.Start()
|
||||
}
|
||||
|
||||
func (s *ServiceContext) HasJobWorker() bool {
|
||||
return s != nil && s.jobWorker != nil
|
||||
}
|
||||
|
||||
func (s *ServiceContext) RunJobWorker() error {
|
||||
if s == nil || s.jobWorker == nil {
|
||||
return nil
|
||||
}
|
||||
return s.jobWorker.Run()
|
||||
}
|
||||
|
||||
func (s *ServiceContext) ShutdownJobWorker() {
|
||||
if s == nil || s.jobWorker == nil {
|
||||
return
|
||||
}
|
||||
s.jobWorker.Shutdown()
|
||||
}
|
||||
|
||||
func (s *ServiceContext) StartOutboxPublisher() error {
|
||||
if s == nil || s.outboxWorker == nil {
|
||||
return nil
|
||||
}
|
||||
return s.outboxWorker.Start()
|
||||
}
|
||||
|
||||
func (s *ServiceContext) ShutdownOutboxPublisher() {
|
||||
if s == nil || s.outboxWorker == nil {
|
||||
return
|
||||
}
|
||||
s.outboxWorker.Shutdown()
|
||||
}
|
||||
|
||||
func (s *ServiceContext) RealtimeFallbackPoll() time.Duration {
|
||||
if s == nil {
|
||||
return 10 * time.Second
|
||||
}
|
||||
interval := time.Duration(s.Config.Realtime.FallbackPollMillis) * time.Millisecond
|
||||
if interval <= 0 {
|
||||
return 10 * time.Second
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
func (s *ServiceContext) Close() {
|
||||
s.ShutdownJobWorker()
|
||||
s.ShutdownOutboxPublisher()
|
||||
if closer, ok := s.jobQueue.(interface{ Close() error }); ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
logx.Errorf("close job queue failed: %v", err)
|
||||
}
|
||||
}
|
||||
if s.RealtimeBus != nil {
|
||||
if err := s.RealtimeBus.Close(); err != nil {
|
||||
logx.Errorf("close realtime bus failed: %v", err)
|
||||
}
|
||||
}
|
||||
if s.AuthService != nil {
|
||||
if err := s.AuthService.Close(); err != nil {
|
||||
logx.Errorf("close auth service failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newAuthService(c config.Config, cacheStore cacheinfra.Store) *authmodule.Service {
|
||||
store := newAuthStore(c)
|
||||
opts := c.Auth
|
||||
codeTTL := time.Duration(opts.CodeTTLSeconds) * time.Second
|
||||
codeStore := authmodule.NewCacheCodeStore(cacheStore, codeTTL)
|
||||
humanVerifier := newAuthHumanVerifier(c)
|
||||
return authmodule.NewServiceWithCodeStoreEmailAndHumanVerifier(store, codeStore, newAuthEmailSender(c), humanVerifier, authmodule.Config{
|
||||
Region: opts.Region,
|
||||
RegionMode: opts.RegionMode,
|
||||
TokenSecret: opts.TokenSecret,
|
||||
TokenTTL: time.Duration(opts.TokenTTLSeconds) * time.Second,
|
||||
RefreshTokenTTL: time.Duration(opts.RefreshTTLSeconds) * time.Second,
|
||||
CodeTTL: time.Duration(opts.CodeTTLSeconds) * time.Second,
|
||||
DevReturnCode: opts.DevReturnCode,
|
||||
EmailFromName: opts.Email.FromName,
|
||||
EmailFromEmail: opts.Email.FromEmail,
|
||||
BrandName: opts.Email.BrandName,
|
||||
TurnstileRequired: opts.Turnstile.Enabled,
|
||||
TurnstileSiteKey: opts.Turnstile.SiteKey,
|
||||
GoogleClientID: opts.Google.ClientID,
|
||||
GoogleTokenInfo: opts.Google.TokenInfoURL,
|
||||
GoogleTimeout: time.Duration(opts.Google.TimeoutSeconds) * time.Second,
|
||||
WechatAppID: opts.Wechat.AppID,
|
||||
WechatAppSecret: opts.Wechat.AppSecret,
|
||||
WechatRedirect: opts.Wechat.RedirectURI,
|
||||
WechatScope: opts.Wechat.Scope,
|
||||
WechatAuthURL: opts.Wechat.AuthURL,
|
||||
WechatTokenURL: opts.Wechat.TokenURL,
|
||||
WechatTimeout: time.Duration(opts.Wechat.TimeoutSeconds) * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
func newAuthHumanVerifier(c config.Config) authmodule.HumanVerifier {
|
||||
opts := c.Auth.Turnstile
|
||||
if !opts.Enabled {
|
||||
return nil
|
||||
}
|
||||
secretKey := strings.TrimSpace(opts.SecretKey)
|
||||
if envName := strings.TrimSpace(opts.SecretKeyEnv); envName != "" {
|
||||
if envValue := strings.TrimSpace(os.Getenv(envName)); envValue != "" {
|
||||
secretKey = envValue
|
||||
}
|
||||
}
|
||||
verifier, err := authmodule.NewTurnstileVerifier(authmodule.TurnstileVerifierOptions{
|
||||
SecretKey: secretKey,
|
||||
SiteVerifyURL: opts.SiteVerifyURL,
|
||||
Timeout: time.Duration(opts.TimeoutSeconds) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
logx.Must(err)
|
||||
}
|
||||
return verifier
|
||||
}
|
||||
|
||||
func newAuthEmailSender(c config.Config) authmodule.EmailSender {
|
||||
opts := c.Auth.Email
|
||||
switch strings.ToLower(strings.TrimSpace(opts.Driver)) {
|
||||
case "", "none", "disabled":
|
||||
return nil
|
||||
case "smtp":
|
||||
sender, err := authmodule.NewSMTPSender(authmodule.SMTPOptions{
|
||||
Host: opts.Host,
|
||||
Port: opts.Port,
|
||||
Username: opts.Username,
|
||||
Password: opts.Password,
|
||||
UseTLS: opts.UseTLS,
|
||||
StartTLS: opts.StartTLS,
|
||||
InsecureSkipVerify: opts.InsecureSkipVerify,
|
||||
Timeout: time.Duration(opts.TimeoutSeconds) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
logx.Must(err)
|
||||
}
|
||||
return sender
|
||||
default:
|
||||
logx.Must(fmt.Errorf("unsupported auth email driver: %s", opts.Driver))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newAuthStore(c config.Config) authmodule.Store {
|
||||
switch c.Storage.Driver {
|
||||
case "", "memory":
|
||||
return authmodule.NewMemoryStore()
|
||||
case "postgres":
|
||||
if c.Storage.DataSource == "" {
|
||||
logx.Must(application.ErrMissingDataSource)
|
||||
}
|
||||
store, err := authmodule.NewPostgresStore(context.Background(), c.Storage.DataSource)
|
||||
if err != nil {
|
||||
logx.Must(err)
|
||||
}
|
||||
return store
|
||||
default:
|
||||
logx.Must(application.ErrUnsupportedStorage)
|
||||
return authmodule.NewMemoryStore()
|
||||
}
|
||||
}
|
||||
|
||||
func newResearcher(c config.Config) design.Researcher {
|
||||
opts := c.Agent.Research
|
||||
switch opts.Driver {
|
||||
case "", "duckduckgo":
|
||||
return websearch.NewDuckDuckGoResearcher(websearch.DuckDuckGoOptions{
|
||||
TimeoutSeconds: opts.TimeoutSeconds,
|
||||
MaxResults: opts.MaxResults,
|
||||
})
|
||||
case "none", "disabled":
|
||||
return nil
|
||||
default:
|
||||
logx.Must(application.ErrUnsupportedResearch)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newAgentRunner(c config.Config) design.AgentRunner {
|
||||
base := application.NewDeterministicAgent()
|
||||
creativeAgent := newCreativeAgent(c)
|
||||
switch c.Agent.Driver {
|
||||
case "", "pi":
|
||||
return piagent.NewDesignAgent(base, newImageGenerator(c), creativeAgent)
|
||||
case "deterministic":
|
||||
return base
|
||||
default:
|
||||
logx.Must(application.ErrUnsupportedAgent)
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
func newCreativeAgent(c config.Config) design.CreativeAgent {
|
||||
opts := c.Agent.Planner
|
||||
switch opts.Driver {
|
||||
case "", "vision":
|
||||
apiKey := resolveImageAPIKey(c.Agent.Image)
|
||||
return piagent.NewCreativeAgent(piagent.CreativeAgentOptions{
|
||||
BaseURL: c.Agent.Image.BaseURL,
|
||||
APIKey: apiKey,
|
||||
Model: opts.Model,
|
||||
TimeoutSeconds: opts.TimeoutSeconds,
|
||||
})
|
||||
case "none", "disabled":
|
||||
return nil
|
||||
default:
|
||||
logx.Must(application.ErrUnsupportedAgent)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newImageGenerator(c config.Config) piagent.ImageGenerator {
|
||||
opts := c.Agent.Image
|
||||
apiKey := resolveImageAPIKey(opts)
|
||||
client := piagent.NewImageClient(piagent.ImageClientOptions{
|
||||
BaseURL: opts.BaseURL,
|
||||
APIKey: apiKey,
|
||||
TimeoutSeconds: opts.TimeoutSeconds,
|
||||
})
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func newTextExtractor(c config.Config) design.TextExtractor {
|
||||
opts := c.Agent.Text
|
||||
switch opts.Driver {
|
||||
case "", "vision":
|
||||
apiKey := resolveImageAPIKey(c.Agent.Image)
|
||||
return textextraction.NewTextExtractionClient(textextraction.TextExtractionClientOptions{
|
||||
BaseURL: c.Agent.Image.BaseURL,
|
||||
APIKey: apiKey,
|
||||
Model: opts.Model,
|
||||
TimeoutSeconds: opts.TimeoutSeconds,
|
||||
})
|
||||
case "none", "disabled":
|
||||
return nil
|
||||
default:
|
||||
logx.Must(application.ErrUnsupportedAgent)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newObjectRecognizer(c config.Config) design.ObjectRecognizer {
|
||||
opts := c.Agent.Object
|
||||
switch opts.Driver {
|
||||
case "", "vision":
|
||||
apiKey := resolveImageAPIKey(c.Agent.Image)
|
||||
return objectrecognition.NewClient(objectrecognition.ClientOptions{
|
||||
BaseURL: c.Agent.Image.BaseURL,
|
||||
APIKey: apiKey,
|
||||
Model: opts.Model,
|
||||
TimeoutSeconds: opts.TimeoutSeconds,
|
||||
})
|
||||
case "none", "disabled":
|
||||
return nil
|
||||
default:
|
||||
logx.Must(application.ErrUnsupportedAgent)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newBackgroundRemover(c config.Config) design.BackgroundRemover {
|
||||
opts := c.Agent.Background
|
||||
switch opts.Driver {
|
||||
case "", "onnxruntime":
|
||||
return backgroundremoval.NewONNXRuntimeRemover(backgroundremoval.ONNXRuntimeOptions{
|
||||
ModelPath: opts.ModelPath,
|
||||
SharedLibraryPath: opts.SharedLibraryPath,
|
||||
ExecutionProvider: opts.ExecutionProvider,
|
||||
InputSize: opts.InputSize,
|
||||
TimeoutSeconds: opts.TimeoutSeconds,
|
||||
InputName: opts.InputName,
|
||||
OutputName: opts.OutputName,
|
||||
})
|
||||
case "none", "disabled":
|
||||
return nil
|
||||
default:
|
||||
logx.Must(application.ErrUnsupportedAgent)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func resolveImageAPIKey(opts config.ImageGenerationConfig) string {
|
||||
if opts.APIKeyEnv != "" {
|
||||
if value := strings.TrimSpace(os.Getenv(opts.APIKeyEnv)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(opts.APIKey)
|
||||
}
|
||||
|
||||
func newAssetStorage(c config.Config) design.AssetStorage {
|
||||
opts := c.ObjectStorage
|
||||
switch opts.Driver {
|
||||
case "", "memory":
|
||||
return objectstorage.NewMemoryStorage(opts.Bucket, opts.PublicBase, opts.ExpiresIn)
|
||||
case "minio", "s3", "aws", "r2":
|
||||
storage, err := objectstorage.NewS3Storage(context.Background(), objectstorage.S3Options{
|
||||
Driver: opts.Driver,
|
||||
Bucket: opts.Bucket,
|
||||
Endpoint: opts.Endpoint,
|
||||
Region: opts.Region,
|
||||
AccessKey: opts.AccessKey,
|
||||
SecretKey: opts.SecretKey,
|
||||
PublicBase: opts.PublicBase,
|
||||
UseSSL: opts.UseSSL,
|
||||
ExpiresIn: opts.ExpiresIn,
|
||||
})
|
||||
if err != nil {
|
||||
logx.Must(err)
|
||||
}
|
||||
return storage
|
||||
case "aliyun":
|
||||
storage, err := objectstorage.NewAliyunStorage(context.Background(), objectstorage.AliyunOptions{
|
||||
Bucket: opts.Bucket,
|
||||
Endpoint: opts.Endpoint,
|
||||
AccessKey: opts.AccessKey,
|
||||
SecretKey: opts.SecretKey,
|
||||
PublicBase: opts.PublicBase,
|
||||
ExpiresIn: opts.ExpiresIn,
|
||||
})
|
||||
if err != nil {
|
||||
logx.Must(err)
|
||||
}
|
||||
return storage
|
||||
default:
|
||||
logx.Must(application.ErrUnsupportedObjectStorage)
|
||||
return objectstorage.NewMemoryStorage(opts.Bucket, opts.PublicBase, opts.ExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func withCache(c config.Config, repo design.Repository, store cacheinfra.Store) design.Repository {
|
||||
ttl := time.Duration(c.Cache.TTLSeconds) * time.Second
|
||||
if ttl <= 0 {
|
||||
ttl = 5 * time.Minute
|
||||
}
|
||||
return cacheinfra.NewProjectRepository(repo, store, ttl)
|
||||
}
|
||||
|
||||
func withRealtime(repo design.Repository, bus realtimemodule.Bus) design.Repository {
|
||||
if bus == nil {
|
||||
return repo
|
||||
}
|
||||
return realtimemodule.NewProjectRepository(repo, bus)
|
||||
}
|
||||
|
||||
func textExtractionCacheTTL(c config.Config) time.Duration {
|
||||
ttl := time.Duration(c.Agent.Text.CacheTTLSeconds) * time.Second
|
||||
if ttl <= 0 {
|
||||
return 10 * time.Minute
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
func newCacheStore(c config.Config) cacheinfra.Store {
|
||||
switch c.Cache.Driver {
|
||||
case "", "memory":
|
||||
return cacheinfra.NewMemoryStore()
|
||||
case "redis":
|
||||
store, err := cacheinfra.NewRedisStore(context.Background(), cacheinfra.RedisOptions{
|
||||
Addr: c.Cache.Addr,
|
||||
Password: c.Cache.Password,
|
||||
DB: c.Cache.DB,
|
||||
})
|
||||
if err != nil {
|
||||
logx.Must(err)
|
||||
}
|
||||
return store
|
||||
default:
|
||||
logx.Must(cacheinfra.ErrUnsupportedDriver)
|
||||
return cacheinfra.NewMemoryStore()
|
||||
}
|
||||
}
|
||||
|
||||
func newRealtimeBus(c config.Config) realtimemodule.Bus {
|
||||
opts := c.Realtime
|
||||
driver := strings.ToLower(strings.TrimSpace(opts.Driver))
|
||||
switch driver {
|
||||
case "", "memory":
|
||||
return realtimemodule.NewMemoryBus()
|
||||
case "redis":
|
||||
if strings.TrimSpace(opts.Addr) == "" {
|
||||
opts.Addr = c.Cache.Addr
|
||||
}
|
||||
if strings.TrimSpace(opts.Password) == "" {
|
||||
opts.Password = c.Cache.Password
|
||||
}
|
||||
bus, err := realtimemodule.NewRedisBus(context.Background(), realtimemodule.RedisOptions{
|
||||
Addr: opts.Addr,
|
||||
Password: opts.Password,
|
||||
DB: opts.DB,
|
||||
Prefix: opts.ChannelPrefix,
|
||||
})
|
||||
if err != nil {
|
||||
logx.Must(err)
|
||||
}
|
||||
return bus
|
||||
case "none", "disabled":
|
||||
return nil
|
||||
default:
|
||||
logx.Must(fmt.Errorf("unsupported realtime driver: %s", opts.Driver))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newOutboxPublisher(c config.Config, bus realtimemodule.Bus) backgroundWorker {
|
||||
opts := c.Outbox
|
||||
driver := strings.ToLower(strings.TrimSpace(opts.Driver))
|
||||
switch driver {
|
||||
case "", "memory", "none", "disabled":
|
||||
return nil
|
||||
case "postgres":
|
||||
if strings.TrimSpace(opts.DataSource) == "" {
|
||||
opts.DataSource = c.Storage.DataSource
|
||||
}
|
||||
publisher, err := realtimemodule.NewOutboxPublisher(context.Background(), realtimemodule.OutboxOptions{
|
||||
DataSource: opts.DataSource,
|
||||
IntervalMillis: opts.PollMillis,
|
||||
BatchSize: opts.BatchSize,
|
||||
}, bus)
|
||||
if err != nil {
|
||||
logx.Must(err)
|
||||
}
|
||||
return publisher
|
||||
default:
|
||||
logx.Must(fmt.Errorf("unsupported outbox driver: %s", opts.Driver))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newJobQueue(c config.Config, processor design.JobProcessor) (design.JobQueue, backgroundWorker) {
|
||||
opts := c.JobQueue
|
||||
driver := strings.ToLower(strings.TrimSpace(opts.Driver))
|
||||
switch driver {
|
||||
case "", "memory", "inline", "none", "disabled":
|
||||
return nil, nil
|
||||
case "asynq", "redis":
|
||||
if strings.TrimSpace(opts.Addr) == "" {
|
||||
opts.Addr = c.Cache.Addr
|
||||
}
|
||||
if strings.TrimSpace(opts.Password) == "" {
|
||||
opts.Password = c.Cache.Password
|
||||
}
|
||||
if strings.TrimSpace(opts.Addr) == "" {
|
||||
opts.Addr = "localhost:6379"
|
||||
}
|
||||
asynqOpts := jobmodule.AsynqOptions{
|
||||
Addr: opts.Addr,
|
||||
Password: opts.Password,
|
||||
DB: opts.DB,
|
||||
Queue: opts.Queue,
|
||||
Concurrency: opts.Concurrency,
|
||||
MaxRetry: opts.MaxRetry,
|
||||
TimeoutSeconds: opts.TimeoutSeconds,
|
||||
ShutdownTimeoutSeconds: opts.ShutdownTimeoutSeconds,
|
||||
}
|
||||
queue, err := jobmodule.NewAsynqQueue(context.Background(), asynqOpts)
|
||||
if err != nil {
|
||||
logx.Must(err)
|
||||
}
|
||||
worker, err := jobmodule.NewAsynqWorker(asynqOpts, processor)
|
||||
if err != nil {
|
||||
logx.Must(err)
|
||||
}
|
||||
return queue, worker
|
||||
default:
|
||||
logx.Must(fmt.Errorf("unsupported job queue driver: %s", opts.Driver))
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func newProjectRepository(c config.Config) design.Repository {
|
||||
switch c.Storage.Driver {
|
||||
case "", "memory":
|
||||
return memory.NewProjectRepository()
|
||||
case "postgres":
|
||||
if c.Storage.DataSource == "" {
|
||||
logx.Must(application.ErrMissingDataSource)
|
||||
}
|
||||
repo, err := postgres.NewProjectRepository(context.Background(), c.Storage.DataSource)
|
||||
if err != nil {
|
||||
logx.Must(err)
|
||||
}
|
||||
return repo
|
||||
default:
|
||||
logx.Must(application.ErrUnsupportedStorage)
|
||||
return memory.NewProjectRepository()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"img_infinite_canvas/internal/config"
|
||||
)
|
||||
|
||||
func TestNewImageGeneratorReturnsNilWithoutAPIKey(t *testing.T) {
|
||||
generator := newImageGenerator(config.Config{})
|
||||
if generator != nil {
|
||||
t.Fatal("expected nil image generator without API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveImageAPIKeyUsesEnvBeforeConfig(t *testing.T) {
|
||||
t.Setenv("TEST_GPT_IMAGE_KEY", "env-key")
|
||||
|
||||
key := resolveImageAPIKey(config.ImageGenerationConfig{
|
||||
APIKeyEnv: "TEST_GPT_IMAGE_KEY",
|
||||
APIKey: "config-key",
|
||||
})
|
||||
if key != "env-key" {
|
||||
t.Fatalf("expected env key, got %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveImageAPIKeyFallsBackToConfig(t *testing.T) {
|
||||
const envName = "TEST_GPT_IMAGE_KEY_EMPTY"
|
||||
if err := os.Unsetenv(envName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key := resolveImageAPIKey(config.ImageGenerationConfig{
|
||||
APIKeyEnv: envName,
|
||||
APIKey: "config-key",
|
||||
})
|
||||
if key != "config-key" {
|
||||
t.Fatalf("expected config key, got %s", key)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user