From de49b4d483bbc89e268b4dde378ec2b79c24d9c7 Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 20 Aug 2026 23:03:15 +0800 Subject: [PATCH] feat: initialize Go schema and seed baseline (#6) --- .gitignore | 6 +- cmd/chorus-seed/main.go | 45 ++++++++ go.mod | 10 ++ go.sum | 6 ++ internal/config/config.go | 86 +++++++++++++++ internal/config/config_test.go | 52 +++++++++ internal/core/doc.go | 2 + internal/platform/doc.go | 2 + internal/seed/defaults.go | 48 +++++++++ internal/seed/defaults.json | 39 +++++++ internal/seed/defaults_test.go | 24 +++++ internal/seed/seed.go | 98 +++++++++++++++++ migrations/000001_mvp0_base.down.sql | 8 ++ migrations/000001_mvp0_base.up.sql | 155 +++++++++++++++++++++++++++ migrations/migrations_test.go | 40 +++++++ portal/main.go | 28 +++++ 16 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 cmd/chorus-seed/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/core/doc.go create mode 100644 internal/platform/doc.go create mode 100644 internal/seed/defaults.go create mode 100644 internal/seed/defaults.json create mode 100644 internal/seed/defaults_test.go create mode 100644 internal/seed/seed.go create mode 100644 migrations/000001_mvp0_base.down.sql create mode 100644 migrations/000001_mvp0_base.up.sql create mode 100644 migrations/migrations_test.go create mode 100644 portal/main.go diff --git a/.gitignore b/.gitignore index 4eb5cf4..534b893 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,9 @@ go.work.sum # env file .env - +*.env +*.env.ps1 +.env.* # Python 缓存(Harness 工具) __pycache__/ @@ -33,6 +35,8 @@ __pycache__/ # 本地运行产物 /data/ /uploads/ +/dist/ +/storage/ # Gitea 凭据,禁止提交 gitea.env diff --git a/cmd/chorus-seed/main.go b/cmd/chorus-seed/main.go new file mode 100644 index 0000000..753d5ac --- /dev/null +++ b/cmd/chorus-seed/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "strings" + "time" + + "git.ilapage.cn/OPC/chorus/internal/seed" + _ "github.com/go-sql-driver/mysql" +) + +func main() { + if err := run(); err != nil { + log.Printf("chorus seed failed: %v", err) + os.Exit(1) + } + log.Print("chorus seed completed") +} + +func run() error { + dsn := strings.TrimSpace(os.Getenv("CHORUS_DSN")) + if dsn == "" { + return fmt.Errorf("CHORUS_DSN is required") + } + db, err := sql.Open("mysql", dsn) + if err != nil { + return fmt.Errorf("open seed database: %w", err) + } + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + return fmt.Errorf("connect to seed database") + } + return seed.Run(ctx, db, seed.Options{ + UserEmail: os.Getenv("CHORUS_SEED_USER_EMAIL"), + UserPassword: os.Getenv("CHORUS_SEED_USER_PASSWORD"), + ProviderBaseURL: os.Getenv("CHORUS_SEED_PROVIDER_BASE_URL"), + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1012565 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.ilapage.cn/OPC/chorus + +go 1.26.5 + +require ( + github.com/go-sql-driver/mysql v1.10.0 + golang.org/x/crypto v0.55.0 +) + +require filippo.io/edwards25519 v1.2.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fa95b49 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8d47734 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,86 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strings" +) + +type Environment string + +const ( + Development Environment = "development" + Test Environment = "test" + Production Environment = "production" +) + +type Config struct { + Environment Environment + DBDSN string + ListenAddress string + StorageRoot string + SessionKey string + MasterKey string +} + +func Load() (Config, error) { + return LoadFromLookup(os.LookupEnv) +} + +func LoadFromLookup(lookup func(string) (string, bool)) (Config, error) { + read := func(name string) string { + value, _ := lookup(name) + return strings.TrimSpace(value) + } + + environment := Environment(read("CHORUS_ENV")) + if environment == "" { + environment = Development + } + if environment != Development && environment != Test && environment != Production { + return Config{}, fmt.Errorf("CHORUS_ENV must be development, test, or production") + } + + cfg := Config{ + Environment: environment, + DBDSN: read("CHORUS_DSN"), + ListenAddress: read("CHORUS_LISTEN_ADDRESS"), + StorageRoot: read("CHORUS_STORAGE_ROOT"), + SessionKey: read("CHORUS_SESSION_KEY"), + MasterKey: read("CHORUS_MASTER_KEY"), + } + if cfg.ListenAddress == "" { + cfg.ListenAddress = "127.0.0.1:8080" + } + + var missing []string + if cfg.DBDSN == "" { + missing = append(missing, "CHORUS_DSN") + } + if environment == Production { + for name, value := range map[string]string{ + "CHORUS_MASTER_KEY": cfg.MasterKey, + "CHORUS_SESSION_KEY": cfg.SessionKey, + "CHORUS_STORAGE_ROOT": cfg.StorageRoot, + } { + if value == "" { + missing = append(missing, name) + } + } + } + if len(missing) > 0 { + return Config{}, fmt.Errorf("missing required configuration: %s", strings.Join(missing, ", ")) + } + return cfg, nil +} + +func (c Config) ValidateProduction() error { + if c.Environment != Production { + return nil + } + if c.DBDSN == "" || c.MasterKey == "" || c.SessionKey == "" || c.StorageRoot == "" { + return errors.New("production configuration is incomplete") + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..90b1528 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,52 @@ +package config + +import ( + "strings" + "testing" +) + +func lookup(values map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + value, ok := values[key] + return value, ok + } +} + +func TestLoadDevelopmentRequiresDSNWithoutLeakingIt(t *testing.T) { + secret := "user:secret@tcp(localhost:3308)/chorus" + cfg, err := LoadFromLookup(lookup(map[string]string{ + "CHORUS_ENV": "development", + "CHORUS_DSN": secret, + })) + if err != nil { + t.Fatalf("LoadFromLookup() error = %v", err) + } + if cfg.DBDSN != secret || cfg.ListenAddress != "127.0.0.1:8080" { + t.Fatalf("unexpected config: %#v", cfg) + } +} + +func TestLoadProductionFailsClosed(t *testing.T) { + _, err := LoadFromLookup(lookup(map[string]string{ + "CHORUS_ENV": "production", + "CHORUS_DSN": "sensitive-dsn", + })) + if err == nil { + t.Fatal("LoadFromLookup() expected an error") + } + for _, name := range []string{"CHORUS_MASTER_KEY", "CHORUS_SESSION_KEY", "CHORUS_STORAGE_ROOT"} { + if !strings.Contains(err.Error(), name) { + t.Errorf("error %q does not identify %s", err, name) + } + } + if strings.Contains(err.Error(), "sensitive-dsn") { + t.Fatalf("error leaked DSN: %v", err) + } +} + +func TestLoadRejectsUnknownEnvironment(t *testing.T) { + _, err := LoadFromLookup(lookup(map[string]string{"CHORUS_ENV": "prod"})) + if err == nil { + t.Fatal("LoadFromLookup() expected an error") + } +} diff --git a/internal/core/doc.go b/internal/core/doc.go new file mode 100644 index 0000000..7432051 --- /dev/null +++ b/internal/core/doc.go @@ -0,0 +1,2 @@ +// Package core contains the framework-independent Chorus domain model and rules. +package core diff --git a/internal/platform/doc.go b/internal/platform/doc.go new file mode 100644 index 0000000..7842a78 --- /dev/null +++ b/internal/platform/doc.go @@ -0,0 +1,2 @@ +// Package platform contains infrastructure adapters used by Chorus applications. +package platform diff --git a/internal/seed/defaults.go b/internal/seed/defaults.go new file mode 100644 index 0000000..9e07eb4 --- /dev/null +++ b/internal/seed/defaults.go @@ -0,0 +1,48 @@ +package seed + +import ( + _ "embed" + "encoding/json" + "fmt" +) + +//go:embed defaults.json +var defaultsJSON []byte + +type Prompt struct { + Key string `json:"key"` + Kind string `json:"kind"` + APIType string `json:"api_type"` + Name string `json:"name"` + Version uint `json:"version"` + Template string `json:"template"` +} + +type Provider struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +type Model struct { + Name string `json:"name"` + ModelID string `json:"model_id"` + APIType string `json:"api_type"` + Kind string `json:"kind"` +} + +type Defaults struct { + Prompts []Prompt `json:"prompts"` + Provider Provider `json:"provider"` + Models []Model `json:"models"` +} + +func LoadDefaults() (Defaults, error) { + var defaults Defaults + if err := json.Unmarshal(defaultsJSON, &defaults); err != nil { + return Defaults{}, fmt.Errorf("decode embedded seed data: %w", err) + } + if len(defaults.Prompts) != 2 || len(defaults.Models) != 2 || defaults.Provider.Slug == "" { + return Defaults{}, fmt.Errorf("embedded seed data is incomplete") + } + return defaults, nil +} diff --git a/internal/seed/defaults.json b/internal/seed/defaults.json new file mode 100644 index 0000000..5bfe87c --- /dev/null +++ b/internal/seed/defaults.json @@ -0,0 +1,39 @@ +{ + "prompts": [ + { + "key": "mvp0-chat-default", + "kind": "text", + "api_type": "chat", + "name": "MVP-0 Chat Default", + "version": 1, + "template": "{{.UserPrompt}}" + }, + { + "key": "mvp0-images-edits-default", + "kind": "image", + "api_type": "images_edits", + "name": "MVP-0 Images Edits Default", + "version": 1, + "template": "用户要求:\n{{.UserPrompt}}\n\n图片说明:\n- 第 1 张图片是需要处理的主图。\n- 其余图片仅作为参考。\n- 仅按用户明确要求进行修改,不添加用户未要求的风格、文字、商品属性或场景。" + } + ], + "provider": { + "slug": "mvp0-mock", + "name": "MVP-0 Mock Provider" + }, + "models": [ + { + "name": "Mock Chat", + "model_id": "mock-chat", + "api_type": "chat", + "kind": "text" + }, + { + "name": "Mock Images Edits", + "model_id": "mock-images-edits", + "api_type": "images_edits", + "kind": "image" + } + ] +} + diff --git a/internal/seed/defaults_test.go b/internal/seed/defaults_test.go new file mode 100644 index 0000000..00df7a7 --- /dev/null +++ b/internal/seed/defaults_test.go @@ -0,0 +1,24 @@ +package seed + +import "testing" + +func TestDefaultsMatchConfirmedPrompts(t *testing.T) { + defaults, err := LoadDefaults() + if err != nil { + t.Fatalf("LoadDefaults() error = %v", err) + } + want := map[string]string{ + "mvp0-chat-default": "{{.UserPrompt}}", + "mvp0-images-edits-default": "用户要求:\n{{.UserPrompt}}\n\n图片说明:\n- 第 1 张图片是需要处理的主图。\n- 其余图片仅作为参考。\n- 仅按用户明确要求进行修改,不添加用户未要求的风格、文字、商品属性或场景。", + } + seen := make(map[string]bool) + for _, prompt := range defaults.Prompts { + if seen[prompt.Key] { + t.Fatalf("duplicate prompt key %q", prompt.Key) + } + seen[prompt.Key] = true + if prompt.Template != want[prompt.Key] { + t.Errorf("prompt %q does not match confirmed text", prompt.Key) + } + } +} diff --git a/internal/seed/seed.go b/internal/seed/seed.go new file mode 100644 index 0000000..f140d4b --- /dev/null +++ b/internal/seed/seed.go @@ -0,0 +1,98 @@ +package seed + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +type Options struct { + UserEmail string + UserPassword string + ProviderBaseURL string +} + +func Run(ctx context.Context, db *sql.DB, options Options) error { + options.UserEmail = strings.ToLower(strings.TrimSpace(options.UserEmail)) + options.ProviderBaseURL = strings.TrimSpace(options.ProviderBaseURL) + if options.UserEmail == "" || options.UserPassword == "" || options.ProviderBaseURL == "" { + return fmt.Errorf("seed options require user email, user password, and provider base URL") + } + parsedURL, err := url.Parse(options.ProviderBaseURL) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + return fmt.Errorf("seed provider base URL is invalid") + } + + defaults, err := LoadDefaults() + if err != nil { + return err + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(options.UserPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash seed user password: %w", err) + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin seed transaction: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, ` + INSERT INTO users (email, password_hash, display_name, status) + VALUES (?, ?, ?, 'active') + ON DUPLICATE KEY UPDATE email = VALUES(email)`, + options.UserEmail, string(passwordHash), "MVP-0 Test User"); err != nil { + return fmt.Errorf("seed user: %w", err) + } + + result, err := tx.ExecContext(ctx, ` + INSERT INTO providers (slug, name, base_url, auth_type, api_key_enc, enabled) + VALUES (?, ?, ?, 'none', NULL, TRUE) + ON DUPLICATE KEY UPDATE + id = LAST_INSERT_ID(id), name = VALUES(name), base_url = VALUES(base_url), + auth_type = 'none', api_key_enc = NULL, enabled = TRUE`, + defaults.Provider.Slug, defaults.Provider.Name, options.ProviderBaseURL) + if err != nil { + return fmt.Errorf("seed provider: %w", err) + } + providerID, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("read seeded provider id: %w", err) + } + + for _, model := range defaults.Models { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO provider_models + (provider_id, name, model_id, api_type, kind, extra_body, timeout_ms, weight, enabled) + VALUES (?, ?, ?, ?, ?, JSON_OBJECT(), 30000, 100, TRUE) + ON DUPLICATE KEY UPDATE + name = VALUES(name), kind = VALUES(kind), extra_body = JSON_OBJECT(), + timeout_ms = VALUES(timeout_ms), weight = VALUES(weight), enabled = TRUE`, + providerID, model.Name, model.ModelID, model.APIType, model.Kind); err != nil { + return fmt.Errorf("seed provider model %s: %w", model.ModelID, err) + } + } + + for _, prompt := range defaults.Prompts { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO prompt_templates + (template_key, kind, api_type, name, version, template_text, enabled) + VALUES (?, ?, ?, ?, ?, ?, TRUE) + ON DUPLICATE KEY UPDATE + kind = VALUES(kind), api_type = VALUES(api_type), name = VALUES(name), + version = VALUES(version), template_text = VALUES(template_text), enabled = TRUE`, + prompt.Key, prompt.Kind, prompt.APIType, prompt.Name, prompt.Version, prompt.Template); err != nil { + return fmt.Errorf("seed prompt %s: %w", prompt.Key, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit seed transaction: %w", err) + } + return nil +} diff --git a/migrations/000001_mvp0_base.down.sql b/migrations/000001_mvp0_base.down.sql new file mode 100644 index 0000000..375bb08 --- /dev/null +++ b/migrations/000001_mvp0_base.down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS generation_outputs; +DROP TABLE IF EXISTS generation_inputs; +DROP TABLE IF EXISTS generations; +DROP TABLE IF EXISTS prompt_templates; +DROP TABLE IF EXISTS provider_models; +DROP TABLE IF EXISTS providers; +DROP TABLE IF EXISTS users; + diff --git a/migrations/000001_mvp0_base.up.sql b/migrations/000001_mvp0_base.up.sql new file mode 100644 index 0000000..3a4dc2d --- /dev/null +++ b/migrations/000001_mvp0_base.up.sql @@ -0,0 +1,155 @@ +CREATE TABLE users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + email VARCHAR(320) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(120) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_users_email (email), + CONSTRAINT chk_users_status CHECK (status IN ('active', 'disabled')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE providers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + slug VARCHAR(80) NOT NULL, + name VARCHAR(120) NOT NULL, + base_url VARCHAR(2048) NOT NULL, + auth_type VARCHAR(16) NOT NULL DEFAULT 'bearer', + api_key_enc JSON NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_providers_slug (slug), + CONSTRAINT chk_providers_auth CHECK ( + (auth_type = 'none' AND api_key_enc IS NULL) OR + (auth_type = 'bearer' AND api_key_enc IS NOT NULL) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE provider_models ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + provider_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + model_id VARCHAR(191) NOT NULL, + api_type VARCHAR(32) NOT NULL, + kind VARCHAR(16) NOT NULL, + extra_body JSON NOT NULL, + timeout_ms INT UNSIGNED NOT NULL, + weight INT UNSIGNED NOT NULL DEFAULT 100, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_provider_models_binding (provider_id, model_id, api_type), + KEY idx_provider_models_route (kind, api_type, enabled), + CONSTRAINT fk_provider_models_provider FOREIGN KEY (provider_id) REFERENCES providers (id) ON DELETE CASCADE, + CONSTRAINT chk_provider_models_api_type CHECK (api_type IN ('chat', 'images', 'images_edits', 'gemini')), + CONSTRAINT chk_provider_models_kind CHECK (kind IN ('text', 'image')), + CONSTRAINT chk_provider_models_timeout CHECK (timeout_ms > 0), + CONSTRAINT chk_provider_models_weight CHECK (weight > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE prompt_templates ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + template_key VARCHAR(120) NOT NULL, + kind VARCHAR(16) NOT NULL, + api_type VARCHAR(32) NOT NULL, + name VARCHAR(120) NOT NULL, + version INT UNSIGNED NOT NULL, + template_text TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_prompt_templates_key (template_key), + KEY idx_prompt_templates_selection (kind, api_type, enabled, version), + CONSTRAINT chk_prompt_templates_kind CHECK (kind IN ('text', 'image')), + CONSTRAINT chk_prompt_templates_api_type CHECK (api_type IN ('chat', 'images', 'images_edits', 'gemini')), + CONSTRAINT chk_prompt_templates_version CHECK (version > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE generations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + provider_model_id BIGINT UNSIGNED NULL, + kind VARCHAR(16) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + idempotency_key VARCHAR(128) NOT NULL, + user_prompt TEXT NOT NULL, + rendered_prompt TEXT NOT NULL, + attempts JSON NOT NULL, + attempt_count INT UNSIGNED NOT NULL DEFAULT 0, + error_code VARCHAR(64) NULL, + error_message VARCHAR(1024) NULL, + lease_owner VARCHAR(128) NULL, + lease_token CHAR(36) NULL, + lease_until DATETIME(6) NULL, + started_at DATETIME(6) NULL, + completed_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_generations_user_idempotency (user_id, idempotency_key), + KEY idx_generations_queue (status, lease_until, created_at), + KEY idx_generations_user_history (user_id, created_at, id), + KEY idx_generations_provider_model (provider_model_id), + CONSTRAINT fk_generations_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT, + CONSTRAINT fk_generations_provider_model FOREIGN KEY (provider_model_id) REFERENCES provider_models (id) ON DELETE SET NULL, + CONSTRAINT chk_generations_kind CHECK (kind IN ('text', 'image')), + CONSTRAINT chk_generations_status CHECK (status IN ('pending', 'running', 'succeeded', 'failed')), + CONSTRAINT chk_generations_attempts CHECK ( + JSON_TYPE(attempts) = 'ARRAY' AND attempt_count = JSON_LENGTH(attempts) + ), + CONSTRAINT chk_generations_lease CHECK ( + (status = 'running' AND lease_owner IS NOT NULL AND lease_token IS NOT NULL AND lease_until IS NOT NULL) OR + (status <> 'running') + ), + CONSTRAINT chk_generations_error CHECK ( + (status = 'failed' AND error_code IS NOT NULL AND error_message IS NOT NULL) OR + (status <> 'failed') + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE generation_inputs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + generation_id BIGINT UNSIGNED NOT NULL, + position INT UNSIGNED NOT NULL, + role VARCHAR(16) NOT NULL, + original_name VARCHAR(255) NOT NULL, + mime_type VARCHAR(120) NOT NULL, + storage_key VARCHAR(512) NOT NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + width INT UNSIGNED NULL, + height INT UNSIGNED NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_generation_inputs_position (generation_id, position), + KEY idx_generation_inputs_generation (generation_id), + CONSTRAINT fk_generation_inputs_generation FOREIGN KEY (generation_id) REFERENCES generations (id) ON DELETE CASCADE, + CONSTRAINT chk_generation_inputs_role CHECK (role IN ('primary', 'reference')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE generation_outputs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + generation_id BIGINT UNSIGNED NOT NULL, + kind VARCHAR(16) NOT NULL, + text_content MEDIUMTEXT NULL, + storage_key VARCHAR(512) NULL, + thumbnail_storage_key VARCHAR(512) NULL, + mime_type VARCHAR(120) NULL, + size_bytes BIGINT UNSIGNED NULL, + width INT UNSIGNED NULL, + height INT UNSIGNED NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + KEY idx_generation_outputs_generation (generation_id, id), + CONSTRAINT fk_generation_outputs_generation FOREIGN KEY (generation_id) REFERENCES generations (id) ON DELETE CASCADE, + CONSTRAINT chk_generation_outputs_kind CHECK (kind IN ('text', 'image')), + CONSTRAINT chk_generation_outputs_content CHECK ( + (kind = 'text' AND text_content IS NOT NULL AND storage_key IS NULL AND thumbnail_storage_key IS NULL) OR + (kind = 'image' AND text_content IS NULL AND storage_key IS NOT NULL AND thumbnail_storage_key IS NOT NULL) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go new file mode 100644 index 0000000..094b42d --- /dev/null +++ b/migrations/migrations_test.go @@ -0,0 +1,40 @@ +package migrations + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestMigrationPairsAndProductionTables(t *testing.T) { + upFiles, err := filepath.Glob("*.up.sql") + if err != nil { + t.Fatal(err) + } + downFiles, err := filepath.Glob("*.down.sql") + if err != nil { + t.Fatal(err) + } + if len(upFiles) == 0 || len(upFiles) != len(downFiles) { + t.Fatalf("migration pairs mismatch: up=%d down=%d", len(upFiles), len(downFiles)) + } + + content, err := os.ReadFile(upFiles[0]) + if err != nil { + t.Fatal(err) + } + sql := string(content) + for _, table := range []string{"users", "providers", "provider_models", "prompt_templates", "generations", "generation_inputs", "generation_outputs"} { + pattern := regexp.MustCompile(`(?i)CREATE TABLE\s+` + regexp.QuoteMeta(table) + `\s*\(`) + if !pattern.MatchString(sql) { + t.Errorf("migration does not create %s", table) + } + } + for _, required := range []string{"rendered_prompt", "attempts", "error_code", "error_message", "lease_token", "uq_generations_user_idempotency", "idx_generations_queue"} { + if !strings.Contains(sql, required) { + t.Errorf("migration is missing %s", required) + } + } +} diff --git a/portal/main.go b/portal/main.go new file mode 100644 index 0000000..e67bf83 --- /dev/null +++ b/portal/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "log" + "os" + + "git.ilapage.cn/OPC/chorus/internal/config" +) + +func main() { + if err := run(); err != nil { + log.Printf("chorus portal configuration error: %v", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return err + } + if err := cfg.ValidateProduction(); err != nil { + return err + } + fmt.Printf("chorus portal skeleton configured for %s on %s\n", cfg.Environment, cfg.ListenAddress) + return nil +}