feat: initialize Go schema and seed baseline (#6)
This commit is contained in:
+5
-1
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package core contains the framework-independent Chorus domain model and rules.
|
||||
package core
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package platform contains infrastructure adapters used by Chorus applications.
|
||||
package platform
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user