diff --git a/.agent/skills/database-migration/SKILL.md b/.agent/skills/database-migration/SKILL.md new file mode 100644 index 00000000..91e08797 --- /dev/null +++ b/.agent/skills/database-migration/SKILL.md @@ -0,0 +1,75 @@ +--- +name: "database-migration" +description: "Wavelet 项目专用:当新增或修改数据库表结构、索引、初始化数据、系统配置 seed、模板 seed、默认管理员、goose SQL 迁移、internal/db/migrator 或数据库升级流程时必须使用。本技能指导在 internal/db/migrator/goose 下编写 PostgreSQL/SQLite 双方言 SQL 迁移,并完成验证。" +--- + +# Wavelet 数据库升级操作指南 + +Wavelet 使用 `github.com/pressly/goose/v3` 执行 SQL 迁移。迁移入口是 `internal/db/migrator.Migrate()`,SQL 文件嵌入在二进制中。 + +## 基本规则 + +- SQL 迁移文件放在: + - `internal/db/migrator/goose/postgres/` + - `internal/db/migrator/goose/sqlite/` +- PostgreSQL 和 SQLite 必须使用同一个版本号、同一个语义文件名。 +- 迁移文件使用 goose SQL 标记: + +```sql +-- +goose Up +... + +-- +goose Down +... +``` + +- 不要把表结构、默认系统配置、默认模板、默认管理员初始化写回 Go 代码。 +- 不要添加物理外键;关系字段使用显式索引。 +- 数据库默认值应匹配 Go model 零值或业务兜底值。 +- 系统配置仍然保存字符串值;布尔值写 `"true"` / `"false"`,数字写十进制字符串,复杂结构写合法 JSON 字符串。 + +## 新增迁移流程 + +1. 先确认涉及的 Go model、读写路径和前端/接口消费方。 +2. 选择下一个递增版本号,格式建议 `YYYYMMDDNNNN`,例如: + +```text +202606090002_add_example_column.sql +``` + +3. 在 PostgreSQL 和 SQLite 目录各新增同名 SQL 文件。 +4. 写 `Up`: + - 表结构变更使用 SQL DDL。 + - 初始化/seed 数据使用 SQL `INSERT`。 + - 需要幂等时使用 `IF NOT EXISTS` 或 `ON CONFLICT ... DO NOTHING`。 +5. 写 `Down`: + - 能安全回滚的结构变更写反向 DDL。 + - seed 数据按 key/name 等稳定标识删除。 +6. 如果变更 API handler,运行 `make swagger`。 +7. 至少运行: + +```bash +go test ./internal/db/migrator +go test ./internal/model ./internal/apps/config ./internal/apps/admin/system_config +make code-check +``` + +## 方言注意事项 + +- PostgreSQL 自增主键用 `BIGSERIAL`;SQLite 自增主键用 `INTEGER PRIMARY KEY AUTOINCREMENT`。 +- PostgreSQL 时间类型优先 `TIMESTAMPTZ`;SQLite 使用 `DATETIME`。 +- PostgreSQL JSON 字段用 `JSONB`;SQLite 用 `JSON` 或 `TEXT`。 +- 两个方言目录的字段名、索引名、seed 数据语义必须保持一致。 + +## 修改默认系统配置 + +- 新增或调整系统配置 seed 时,更新两个方言的 SQL 文件。 +- `visibility` 使用常量语义:`0` 不公开,`1` 通过 `/api/v1/config/public` 返回。 +- 公共配置 API 直接返回所有 `visibility = 1` 的配置键值,不要在 handler 中重新硬编码 key 列表。 + +## 验证重点 + +- goose 能在空库上完整执行。 +- `system_configs`、默认 `admin`、内置模板能按预期初始化。 +- 新增表/列与 Go model 的列名、类型和默认值兼容。 +- 前端或接口消费的公共配置值仍按字符串解析。 diff --git a/AGENTS.md b/AGENTS.md index ad0ca2a5..6865d250 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,9 @@ specialized workflows still live in `.agent/skills/`. - `new-setting`: use when adding or changing startup config, database-backed system/business/public settings, `/admin/system` parameters, or `/admin/settings` graphical settings. +- `database-migration`: use when adding or changing database schema, indexes, + seed data, system config defaults, template defaults, default admin data, + goose SQL migrations, or the database upgrade flow. - Go skills: use the focused `go-*` skills for Go implementation details such as testing, error handling, packages, context, concurrency, logging, documentation, and review. @@ -73,7 +76,7 @@ Backend: - `internal/apps/`: feature modules and HTTP handlers. - `internal/model/`: GORM entities and model-level business methods. - `internal/db/`: PostgreSQL, Redis, ClickHouse, GORM logging, ID generation, - and AutoMigrate wiring. + and goose SQL migration wiring. - `internal/storage/`: S3-compatible storage and cache abstraction. - `internal/task/`: Asynq task framework; see `new-async-task` for changes. - `internal/service/`: complex business services when handlers/models are too @@ -163,7 +166,8 @@ Database: - Admin code should prefer `db.DB(ctx)` to get tracing-aware DB access. - Do not put complex SQL in handlers; move it to `internal/model/` or `internal/service/`. -- Use AutoMigrate wiring under `internal/db/migrator/`; do not add manual DDL. +- Use goose SQL migrations under `internal/db/migrator/goose/`; do not add + GORM AutoMigrate-based schema upgrades. - Do not create physical database foreign keys. Add explicit indexes for relation fields instead. - Database defaults must match Go model zero values (`nil`, `0`, `false`, `""`) @@ -181,7 +185,7 @@ Strict dependency guard: Admin module workflow: 1. Define or extend models in `internal/model/`. -2. Register AutoMigrate changes under `internal/db/migrator/`. +2. Add goose SQL migrations under `internal/db/migrator/goose/`. 3. Create `internal/apps/admin//routers.go` and optional `errs.go`. 4. Register routes in `internal/router/router.go`. 5. Run `make swagger`. diff --git a/go.mod b/go.mod index aab21e28..588f6336 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hibiken/asynq v0.25.1 + github.com/pressly/goose/v3 v3.15.1 github.com/redis/go-redis/extra/redisotel/v9 v9.16.0 github.com/redis/go-redis/v9 v9.16.0 github.com/shopspring/decimal v1.4.0 @@ -164,8 +165,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/clickhouse v0.7.0 // indirect gorm.io/driver/mysql v1.6.0 // indirect - modernc.org/libc v1.22.5 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect - modernc.org/sqlite v1.23.1 // indirect + modernc.org/libc v1.24.1 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.26.0 // indirect ) diff --git a/go.sum b/go.sum index 9b58df74..dc894274 100644 --- a/go.sum +++ b/go.sum @@ -196,6 +196,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= @@ -232,6 +234,8 @@ github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFu github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.15.1 h1:dKaJ1SdLvS/+HtS8PzFT0KBEtICC1jewLXM+b3emlv8= +github.com/pressly/goose/v3 v3.15.1/go.mod h1:0E3Yg/+EwYzO6Rz2P98MlClFgIcoujbVRs575yi3iIM= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= @@ -242,7 +246,6 @@ github.com/redis/go-redis/extra/redisotel/v9 v9.16.0 h1:+a9h9qxFXdf3gX0FXnDcz7X4 github.com/redis/go-redis/extra/redisotel/v9 v9.16.0/go.mod h1:EtTTC7vnKWgznfG6kBgl9ySLqd7NckRCFUBzVXdeHeI= github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -446,11 +449,23 @@ gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= gorm.io/plugin/opentelemetry v0.1.14 h1:xivP39t/0JgcceDl+BLwVAJHihjFEUj0ZocMSBwZ7ZY= gorm.io/plugin/opentelemetry v0.1.14/go.mod h1:ZAp4v5vU1CCcK9Oo8/va5rl6NStrzpSU+a70evd+W/g= -modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= -modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= -modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= +lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= +modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= +modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0= +modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= +modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM= +modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw= +modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/db/migrator/goose/postgres/202606090001_initial_schema.sql b/internal/db/migrator/goose/postgres/202606090001_initial_schema.sql new file mode 100644 index 00000000..44238e11 --- /dev/null +++ b/internal/db/migrator/goose/postgres/202606090001_initial_schema.sql @@ -0,0 +1,182 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS users ( + id BIGINT PRIMARY KEY, + username VARCHAR(64) UNIQUE, + password VARCHAR(255), + nickname VARCHAR(255), + email VARCHAR(255), + avatar_url VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + is_admin BOOLEAN DEFAULT FALSE, + bio VARCHAR(500), + phone VARCHAR(32), + gender VARCHAR(16), + website VARCHAR(255), + location VARCHAR(255), + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); +CREATE INDEX IF NOT EXISTS idx_users_is_active ON users (is_active); +CREATE INDEX IF NOT EXISTS idx_users_last_login_at ON users (last_login_at); +CREATE INDEX IF NOT EXISTS idx_users_created_at ON users (created_at); + +CREATE TABLE IF NOT EXISTS auth_sources ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(80) NOT NULL UNIQUE, + type VARCHAR(20) NOT NULL, + display_name VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT FALSE, + client_id VARCHAR(255), + client_secret VARCHAR(1024), + openid_discovery_url VARCHAR(1024), + scopes VARCHAR(255), + icon_url VARCHAR(1024), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_auth_sources_is_active ON auth_sources (is_active); + +CREATE TABLE IF NOT EXISTS external_accounts ( + id BIGSERIAL PRIMARY KEY, + auth_source_id BIGINT, + user_id BIGINT NOT NULL, + external_id VARCHAR(255) NOT NULL, + external_username VARCHAR(255), + email VARCHAR(255), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_external_accounts_auth_source_id ON external_accounts (auth_source_id); +CREATE INDEX IF NOT EXISTS idx_external_accounts_user_id ON external_accounts (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_external_accounts_source_external ON external_accounts (auth_source_id, external_id); + +CREATE TABLE IF NOT EXISTS system_configs ( + key VARCHAR(64) PRIMARY KEY, + value VARCHAR(255) NOT NULL, + type VARCHAR(32) NOT NULL DEFAULT 'system', + visibility INTEGER NOT NULL DEFAULT 0, + description VARCHAR(255), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS uploads ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_size BIGINT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + extension VARCHAR(50) NOT NULL, + hash VARCHAR(64), + storage_driver VARCHAR(50) NOT NULL, + type VARCHAR(50) NOT NULL, + status VARCHAR(20) NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads (user_id); +CREATE INDEX IF NOT EXISTS idx_uploads_file_path ON uploads (file_path); +CREATE INDEX IF NOT EXISTS idx_uploads_hash ON uploads (hash); +CREATE INDEX IF NOT EXISTS idx_uploads_type ON uploads (type); + +CREATE TABLE IF NOT EXISTS access_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + token_hash VARCHAR(64) NOT NULL UNIQUE, + masked_token VARCHAR(64) NOT NULL, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_access_tokens_user_id ON access_tokens (user_id); + +CREATE TABLE IF NOT EXISTS task_executions ( + id BIGINT PRIMARY KEY, + task_id VARCHAR(128) NOT NULL UNIQUE, + task_type VARCHAR(64) NOT NULL, + task_name VARCHAR(128), + status VARCHAR(32) NOT NULL, + retryable BOOLEAN NOT NULL DEFAULT FALSE, + max_retry INTEGER NOT NULL DEFAULT 0, + retry_count INTEGER NOT NULL DEFAULT 0, + log TEXT, + error_message TEXT, + result TEXT, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + duration BIGINT, + payload TEXT, + triggered_by VARCHAR(32) NOT NULL DEFAULT 'system', + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_task_executions_task_type ON task_executions (task_type); +CREATE INDEX IF NOT EXISTS idx_task_executions_status ON task_executions (status); +CREATE INDEX IF NOT EXISTS idx_task_executions_started_at ON task_executions (started_at); +CREATE INDEX IF NOT EXISTS idx_task_executions_created_at ON task_executions (created_at); + +CREATE TABLE IF NOT EXISTS templates ( + id BIGSERIAL PRIMARY KEY, + key VARCHAR(80) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'email', + subject VARCHAR(255), + content TEXT NOT NULL, + description VARCHAR(255), + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_templates_is_system ON templates (is_system); +CREATE INDEX IF NOT EXISTS idx_templates_created_at ON templates (created_at); +CREATE INDEX IF NOT EXISTS idx_templates_updated_at ON templates (updated_at); + +INSERT INTO system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES + ('cap_login_enabled', 'false', 'system', 1, '是否启用登录人机验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_auto_solve', 'true', 'system', 1, '打开页面后是否自动开始计算,关闭则需用户手动点击触发', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_count', '1', 'system', 0, '客户端需求解的 PoW 难题总数,默认 1,推荐 1~5', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_size', '32', 'system', 0, '人机验证盐值长度', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_difficulty', '4', 'system', 0, '人机验证 PoW 难度(目标前缀长度)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_ttl_seconds', '600', 'system', 0, '人机验证难题有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_token_ttl_seconds', '1200', 'system', 0, '人机验证兑换凭证有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('server_address', '', 'system', 0, '服务器地址(用于跨域源控制,不设定则允许任意源)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_host', '', 'system', 0, 'SMTP 服务器地址(例如 smtp.example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_port', '587', 'system', 0, 'SMTP 端口(例如 587 或 465)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_username', '', 'system', 0, 'SMTP 账户(如 sender@example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_password', '', 'system', 0, 'SMTP 访问凭证(授权码/密码)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('upload_allowed_extensions', 'jpg,png,webp', 'system', 1, '允许上传的图片扩展名(逗号分隔)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('site_name', 'Wavelet', 'system', 1, '系统平台的展示名称', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('password_login_enabled', 'true', 'system', 1, '是否允许使用账号密码登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('registration_enabled', 'true', 'system', 1, '控制普通用户是否可以自主注册(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('password_register_enabled', 'true', 'system', 1, '是否允许通过密码创建本地账号', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('oidc_login_enabled', 'true', 'system', 1, '是否允许使用第三方 OIDC 认证源登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('max_api_keys_per_user', '5', 'business', 1, '限制每个普通用户可以创建的 API Key 最大数量', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('email_login_verification_enabled', 'false', 'system', 1, '是否开启邮箱登录验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('email_register_verification_enabled', 'false', 'system', 1, '是否开启邮箱注册验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('menu_display_config', '{}', 'system', 1, '目录显示配置(JSON 字符串,格式为 {url: enabled})', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('search_engine_indexing_enabled', 'false', 'system', 1, '是否允许搜索引擎爬取/检索该站点(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +INSERT INTO users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at) +VALUES (1, 'admin', '12345678', 'Administrator', '', TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (username) DO NOTHING; + +INSERT INTO templates (key, name, type, subject, content, description, is_system, created_at, updated_at) VALUES + ('login_email', '登录验证码邮件', 'email', 'Wavelet 登录验证码', '

Wavelet 登录验证

您的登录验证码为:{{.Code}},5分钟内有效,请勿将验证码泄露给他人。

', '用户密码登录时发送的验证码邮件模板,支持变量:{{.Code}}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('register_email', '注册验证码邮件', 'email', 'Wavelet 注册验证码', '

Wavelet 注册验证

您的注册验证码为:{{.Code}},5分钟内有效,请勿泄露给他人。

', '用户注册时发送的验证码邮件模板,支持变量:{{.Code}}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS templates; +DROP TABLE IF EXISTS task_executions; +DROP TABLE IF EXISTS access_tokens; +DROP TABLE IF EXISTS uploads; +DROP TABLE IF EXISTS system_configs; +DROP TABLE IF EXISTS external_accounts; +DROP TABLE IF EXISTS auth_sources; +DROP TABLE IF EXISTS users; diff --git a/internal/db/migrator/goose/sqlite/202606090001_initial_schema.sql b/internal/db/migrator/goose/sqlite/202606090001_initial_schema.sql new file mode 100644 index 00000000..8b338fd7 --- /dev/null +++ b/internal/db/migrator/goose/sqlite/202606090001_initial_schema.sql @@ -0,0 +1,182 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS users ( + id BIGINT PRIMARY KEY, + username VARCHAR(64) UNIQUE, + password VARCHAR(255), + nickname VARCHAR(255), + email VARCHAR(255), + avatar_url VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + is_admin BOOLEAN DEFAULT FALSE, + bio VARCHAR(500), + phone VARCHAR(32), + gender VARCHAR(16), + website VARCHAR(255), + location VARCHAR(255), + last_login_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); +CREATE INDEX IF NOT EXISTS idx_users_is_active ON users (is_active); +CREATE INDEX IF NOT EXISTS idx_users_last_login_at ON users (last_login_at); +CREATE INDEX IF NOT EXISTS idx_users_created_at ON users (created_at); + +CREATE TABLE IF NOT EXISTS auth_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(80) NOT NULL UNIQUE, + type VARCHAR(20) NOT NULL, + display_name VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT FALSE, + client_id VARCHAR(255), + client_secret VARCHAR(1024), + openid_discovery_url VARCHAR(1024), + scopes VARCHAR(255), + icon_url VARCHAR(1024), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_auth_sources_is_active ON auth_sources (is_active); + +CREATE TABLE IF NOT EXISTS external_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + auth_source_id BIGINT, + user_id BIGINT NOT NULL, + external_id VARCHAR(255) NOT NULL, + external_username VARCHAR(255), + email VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_external_accounts_auth_source_id ON external_accounts (auth_source_id); +CREATE INDEX IF NOT EXISTS idx_external_accounts_user_id ON external_accounts (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_external_accounts_source_external ON external_accounts (auth_source_id, external_id); + +CREATE TABLE IF NOT EXISTS system_configs ( + key VARCHAR(64) PRIMARY KEY, + value VARCHAR(255) NOT NULL, + type VARCHAR(32) NOT NULL DEFAULT 'system', + visibility INTEGER NOT NULL DEFAULT 0, + description VARCHAR(255), + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS uploads ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_size BIGINT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + extension VARCHAR(50) NOT NULL, + hash VARCHAR(64), + storage_driver VARCHAR(50) NOT NULL, + type VARCHAR(50) NOT NULL, + status VARCHAR(20) NOT NULL, + metadata JSON, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads (user_id); +CREATE INDEX IF NOT EXISTS idx_uploads_file_path ON uploads (file_path); +CREATE INDEX IF NOT EXISTS idx_uploads_hash ON uploads (hash); +CREATE INDEX IF NOT EXISTS idx_uploads_type ON uploads (type); + +CREATE TABLE IF NOT EXISTS access_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + token_hash VARCHAR(64) NOT NULL UNIQUE, + masked_token VARCHAR(64) NOT NULL, + last_used_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_access_tokens_user_id ON access_tokens (user_id); + +CREATE TABLE IF NOT EXISTS task_executions ( + id BIGINT PRIMARY KEY, + task_id VARCHAR(128) NOT NULL UNIQUE, + task_type VARCHAR(64) NOT NULL, + task_name VARCHAR(128), + status VARCHAR(32) NOT NULL, + retryable BOOLEAN NOT NULL DEFAULT FALSE, + max_retry INTEGER NOT NULL DEFAULT 0, + retry_count INTEGER NOT NULL DEFAULT 0, + log TEXT, + error_message TEXT, + result TEXT, + started_at DATETIME, + finished_at DATETIME, + duration BIGINT, + payload TEXT, + triggered_by VARCHAR(32) NOT NULL DEFAULT 'system', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_task_executions_task_type ON task_executions (task_type); +CREATE INDEX IF NOT EXISTS idx_task_executions_status ON task_executions (status); +CREATE INDEX IF NOT EXISTS idx_task_executions_started_at ON task_executions (started_at); +CREATE INDEX IF NOT EXISTS idx_task_executions_created_at ON task_executions (created_at); + +CREATE TABLE IF NOT EXISTS templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key VARCHAR(80) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'email', + subject VARCHAR(255), + content TEXT NOT NULL, + description VARCHAR(255), + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_templates_is_system ON templates (is_system); +CREATE INDEX IF NOT EXISTS idx_templates_created_at ON templates (created_at); +CREATE INDEX IF NOT EXISTS idx_templates_updated_at ON templates (updated_at); + +INSERT INTO system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES + ('cap_login_enabled', 'false', 'system', 1, '是否启用登录人机验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_auto_solve', 'true', 'system', 1, '打开页面后是否自动开始计算,关闭则需用户手动点击触发', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_count', '1', 'system', 0, '客户端需求解的 PoW 难题总数,默认 1,推荐 1~5', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_size', '32', 'system', 0, '人机验证盐值长度', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_difficulty', '4', 'system', 0, '人机验证 PoW 难度(目标前缀长度)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_ttl_seconds', '600', 'system', 0, '人机验证难题有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_token_ttl_seconds', '1200', 'system', 0, '人机验证兑换凭证有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('server_address', '', 'system', 0, '服务器地址(用于跨域源控制,不设定则允许任意源)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_host', '', 'system', 0, 'SMTP 服务器地址(例如 smtp.example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_port', '587', 'system', 0, 'SMTP 端口(例如 587 或 465)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_username', '', 'system', 0, 'SMTP 账户(如 sender@example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_password', '', 'system', 0, 'SMTP 访问凭证(授权码/密码)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('upload_allowed_extensions', 'jpg,png,webp', 'system', 1, '允许上传的图片扩展名(逗号分隔)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('site_name', 'Wavelet', 'system', 1, '系统平台的展示名称', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('password_login_enabled', 'true', 'system', 1, '是否允许使用账号密码登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('registration_enabled', 'true', 'system', 1, '控制普通用户是否可以自主注册(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('password_register_enabled', 'true', 'system', 1, '是否允许通过密码创建本地账号', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('oidc_login_enabled', 'true', 'system', 1, '是否允许使用第三方 OIDC 认证源登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('max_api_keys_per_user', '5', 'business', 1, '限制每个普通用户可以创建的 API Key 最大数量', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('email_login_verification_enabled', 'false', 'system', 1, '是否开启邮箱登录验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('email_register_verification_enabled', 'false', 'system', 1, '是否开启邮箱注册验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('menu_display_config', '{}', 'system', 1, '目录显示配置(JSON 字符串,格式为 {url: enabled})', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('search_engine_indexing_enabled', 'false', 'system', 1, '是否允许搜索引擎爬取/检索该站点(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +INSERT INTO users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at) +VALUES (1, 'admin', '12345678', 'Administrator', '', TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (username) DO NOTHING; + +INSERT INTO templates (key, name, type, subject, content, description, is_system, created_at, updated_at) VALUES + ('login_email', '登录验证码邮件', 'email', 'Wavelet 登录验证码', '

Wavelet 登录验证

您的登录验证码为:{{.Code}},5分钟内有效,请勿将验证码泄露给他人。

', '用户密码登录时发送的验证码邮件模板,支持变量:{{.Code}}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('register_email', '注册验证码邮件', 'email', 'Wavelet 注册验证码', '

Wavelet 注册验证

您的注册验证码为:{{.Code}},5分钟内有效,请勿泄露给他人。

', '用户注册时发送的验证码邮件模板,支持变量:{{.Code}}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS templates; +DROP TABLE IF EXISTS task_executions; +DROP TABLE IF EXISTS access_tokens; +DROP TABLE IF EXISTS uploads; +DROP TABLE IF EXISTS system_configs; +DROP TABLE IF EXISTS external_accounts; +DROP TABLE IF EXISTS auth_sources; +DROP TABLE IF EXISTS users; diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index e6e713ea..1461727c 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -15,21 +15,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package migrator 提供数据库自动迁移功能 +// Package migrator 提供数据库迁移功能 package migrator import ( "context" + "embed" "log" - "time" - - "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/db" - "github.com/Rain-kl/Wavelet/internal/db/idgen" + "github.com/pressly/goose/v3" ) +// migrationFS contains SQL migrations under goose/. +// +//go:embed goose/postgres/*.sql goose/sqlite/*.sql +var migrationFS embed.FS + // dbType 返回当前数据库类型名称(用于日志输出) func dbType() string { if !config.Config.Database.Enabled { @@ -38,193 +41,39 @@ func dbType() string { return "PostgreSQL" } -// Migrate 执行数据库自动迁移,初始化系统配置、默认管理员和内置模板 +func gooseDialect() string { + if !config.Config.Database.Enabled { + return "sqlite3" + } + return "postgres" +} + +func migrationDir() string { + if !config.Config.Database.Enabled { + return "goose/sqlite" + } + return "goose/postgres" +} + +// Migrate 执行数据库迁移 func Migrate() { - if err := db.DB(context.Background()).AutoMigrate( - &model.User{}, - &model.AuthSource{}, - &model.ExternalAccount{}, - &model.SystemConfig{}, - &model.Upload{}, - &model.AccessToken{}, - &model.TaskExecution{}, - &model.Template{}, - ); err != nil { - log.Fatalf("[%s] auto migrate failed: %v\n", dbType(), err) + gormDB := db.DB(context.Background()) + if gormDB == nil { + log.Fatalf("[%s] database not initialized\n", dbType()) } - log.Printf("[%s] auto migrate success\n", dbType()) - // 初始化系统配置数据 - initSystemConfigs() - // 初始化默认管理员用户 - initDefaultAdmin() - // 初始化系统内置模板 - initTemplates() -} - -// ensureConfigKeyExists ensures a system config key exists in the database -func ensureConfigKeyExists(key, value, configType, description string, visibility int) { - tx := db.DB(context.Background()) - var cfg model.SystemConfig - if err := tx.Where("key = ?", key).First(&cfg).Error; err != nil { - newConfig := model.SystemConfig{ - Key: key, - Value: value, - Type: configType, - Visibility: visibility, - Description: description, - } - if err := tx.Create(&newConfig).Error; err != nil { - log.Printf("[%s] failed to create system config key %s: %v\n", dbType(), key, err) - } else { - log.Printf("[%s] initialized system config key %s\n", dbType(), key) - } - } -} - -// initSystemConfigs 初始化系统配置数据 -func initSystemConfigs() { - tx := db.DB(context.Background()) - - var count int64 - if err := tx.Model(&model.SystemConfig{}).Count(&count).Error; err != nil { - log.Printf("[%s] failed to check system_config table: %v\n", dbType(), err) - return - } - - if count > 0 { - ensureConfigKeyExists(model.ConfigKeyCapLoginEnabled, "false", "system", "是否启用登录人机验证(true/false)", model.ConfigVisibilityVisible) - ensureConfigKeyExists(model.ConfigKeyCapAutoSolve, "true", "system", "打开页面后是否自动开始计算,关闭则需用户手动点击触发", model.ConfigVisibilityVisible) - ensureConfigKeyExists(model.ConfigKeyCapChallengeCount, "1", "system", "客户端需求解的 PoW 难题总数,默认 1,推荐 1~5", model.ConfigVisibilityHidden) - ensureConfigKeyExists(model.ConfigKeyCapChallengeSize, "32", "system", "人机验证盐值长度", model.ConfigVisibilityHidden) - ensureConfigKeyExists(model.ConfigKeyCapChallengeDifficulty, "4", "system", "人机验证 PoW 难度(目标前缀长度)", model.ConfigVisibilityHidden) - ensureConfigKeyExists(model.ConfigKeyCapChallengeTTL, "600", "system", "人机验证难题有效时间(秒)", model.ConfigVisibilityHidden) - ensureConfigKeyExists(model.ConfigKeyCapTokenTTL, "1200", "system", "人机验证兑换凭证有效时间(秒)", model.ConfigVisibilityHidden) - ensureConfigKeyExists(model.ConfigKeyServerAddress, "", "system", "服务器地址(用于跨域源控制,不设定则允许任意源)", model.ConfigVisibilityHidden) - ensureConfigKeyExists(model.ConfigKeySMTPHost, "", "system", "SMTP 服务器地址(例如 smtp.example.com)", model.ConfigVisibilityHidden) - ensureConfigKeyExists(model.ConfigKeySMTPPort, "587", "system", "SMTP 端口(例如 587 或 465)", model.ConfigVisibilityHidden) - ensureConfigKeyExists(model.ConfigKeySMTPUsername, "", "system", "SMTP 账户(如 sender@example.com)", model.ConfigVisibilityHidden) - ensureConfigKeyExists(model.ConfigKeySMTPPassword, "", "system", "SMTP 访问凭证(授权码/密码)", model.ConfigVisibilityHidden) - ensureConfigKeyExists(model.ConfigKeyEmailLoginVerificationEnabled, "false", "system", "是否开启邮箱登录验证(true/false)", model.ConfigVisibilityVisible) - ensureConfigKeyExists(model.ConfigKeyEmailRegisterVerificationEnabled, "false", "system", "是否开启邮箱注册验证(true/false)", model.ConfigVisibilityVisible) - ensureConfigKeyExists(model.ConfigKeyMenuDisplayConfig, "{}", "system", "目录显示配置(JSON 字符串,格式为 {url: enabled})", model.ConfigVisibilityVisible) - ensureConfigKeyExists(model.ConfigKeySearchEngineIndexingEnabled, "false", "system", "是否允许搜索引擎爬取/检索该站点(true/false)", model.ConfigVisibilityVisible) - return - } - - defaultConfigs := []model.SystemConfig{ - {Key: model.ConfigKeyCapLoginEnabled, Value: "false", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否启用登录人机验证(true/false)"}, - {Key: model.ConfigKeyCapAutoSolve, Value: "true", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "打开页面后是否自动开始计算,关闭则需用户手动点击触发"}, - {Key: model.ConfigKeyCapChallengeCount, Value: "1", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "客户端需求解的 PoW 难题总数,默认 1,推荐 1~5"}, - {Key: model.ConfigKeyCapChallengeSize, Value: "32", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "人机验证盐值长度"}, - {Key: model.ConfigKeyCapChallengeDifficulty, Value: "4", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "人机验证 PoW 难度(目标前缀长度)"}, - {Key: model.ConfigKeyCapChallengeTTL, Value: "600", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "人机验证难题有效时间(秒)"}, - {Key: model.ConfigKeyCapTokenTTL, Value: "1200", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "人机验证兑换凭证有效时间(秒)"}, - {Key: model.ConfigKeyServerAddress, Value: "", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "服务器地址(用于跨域源控制,不设定则允许任意源)"}, - {Key: model.ConfigKeySMTPHost, Value: "", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "SMTP 服务器地址(例如 smtp.example.com)"}, - {Key: model.ConfigKeySMTPPort, Value: "587", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "SMTP 端口(例如 587 或 465)"}, - {Key: model.ConfigKeySMTPUsername, Value: "", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "SMTP 账户(如 sender@example.com)"}, - {Key: model.ConfigKeySMTPPassword, Value: "", Type: "system", Visibility: model.ConfigVisibilityHidden, Description: "SMTP 访问凭证(授权码/密码)"}, - {Key: model.ConfigKeyUploadAllowedExtensions, Value: "jpg,png,webp", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "允许上传的图片扩展名(逗号分隔)"}, - {Key: model.ConfigKeySiteName, Value: "Wavelet", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "系统平台的展示名称"}, - {Key: model.ConfigKeyPasswordLoginEnabled, Value: "true", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否允许使用账号密码登录"}, - {Key: model.ConfigKeyRegistrationEnabled, Value: "true", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "控制普通用户是否可以自主注册(true/false)"}, - {Key: model.ConfigKeyPasswordRegisterEnabled, Value: "true", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否允许通过密码创建本地账号"}, - {Key: model.ConfigKeyOIDCLoginEnabled, Value: "true", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否允许使用第三方 OIDC 认证源登录"}, - {Key: model.ConfigKeyMaxAPIKeysPerUser, Value: "5", Type: "business", Visibility: model.ConfigVisibilityVisible, Description: "限制每个普通用户可以创建的 API Key 最大数量"}, - {Key: model.ConfigKeyEmailLoginVerificationEnabled, Value: "false", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否开启邮箱登录验证(true/false)"}, - {Key: model.ConfigKeyEmailRegisterVerificationEnabled, Value: "false", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否开启邮箱注册验证(true/false)"}, - {Key: model.ConfigKeyMenuDisplayConfig, Value: "{}", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "目录显示配置(JSON 字符串,格式为 {url: enabled})"}, - {Key: model.ConfigKeySearchEngineIndexingEnabled, Value: "false", Type: "system", Visibility: model.ConfigVisibilityVisible, Description: "是否允许搜索引擎爬取/检索该站点(true/false)"}, - } - - if err := tx.Create(&defaultConfigs).Error; err != nil { - log.Printf("[%s] failed to create default system configs: %v\n", dbType(), err) - } else { - log.Printf("[%s] initialized %d default system configs\n", dbType(), len(defaultConfigs)) - } -} - -// initDefaultAdmin 初始化默认管理员用户 -func initDefaultAdmin() { - tx := db.DB(context.Background()) - - var count int64 - if err := tx.Model(&model.User{}).Where("username = ?", "admin").Count(&count).Error; err != nil { - log.Printf("[%s] failed to check default admin user: %v\n", dbType(), err) - return - } - - if count > 0 { - return - } - - adminUser := model.User{ - ID: idgen.NextUint64ID(), - Username: "admin", - Password: "12345678", // 密码使用明文存储 - Nickname: "Administrator", - AvatarURL: "", - IsActive: true, - IsAdmin: true, - LastLoginAt: time.Now(), - } - - if err := tx.Create(&adminUser).Error; err != nil { - log.Printf("[%s] failed to create default admin user: %v\n", dbType(), err) - } else { - log.Printf("[%s] default admin user created successfully (username: admin, password: 12345678)\n", dbType()) - } -} - -// initTemplates 初始化系统内置模板 -func initTemplates() { - tx := db.DB(context.Background()) - var count int64 - if err := tx.Model(&model.Template{}).Count(&count).Error; err != nil { - log.Printf("[%s] failed to check templates table: %v\n", dbType(), err) - return - } - - defaultTemplates := []model.Template{ - { - Key: "login_email", - Name: "登录验证码邮件", - Type: "email", - Subject: "Wavelet 登录验证码", - Content: "

Wavelet 登录验证

您的登录验证码为:{{.Code}},5分钟内有效,请勿将验证码泄露给他人。

", - Description: "用户密码登录时发送的验证码邮件模板,支持变量:{{.Code}}", - IsSystem: true, - }, - { - Key: "register_email", - Name: "注册验证码邮件", - Type: "email", - Subject: "Wavelet 注册验证码", - Content: "

Wavelet 注册验证

您的注册验证码为:{{.Code}},5分钟内有效,请勿泄露给他人。

", - Description: "用户注册时发送的验证码邮件模板,支持变量:{{.Code}}", - IsSystem: true, - }, - } - - if count > 0 { - // 确保系统预置模板存在 - for _, dt := range defaultTemplates { - var t model.Template - if err := tx.Where("key = ?", dt.Key).First(&t).Error; err != nil { - if err := tx.Create(&dt).Error; err != nil { - log.Printf("[%s] failed to create template key %s: %v\n", dbType(), dt.Key, err) - } else { - log.Printf("[%s] initialized template key %s\n", dbType(), dt.Key) - } - } - } - return - } - - if err := tx.Create(&defaultTemplates).Error; err != nil { - log.Printf("[%s] failed to create default templates: %v\n", dbType(), err) - } else { - log.Printf("[%s] initialized %d default templates\n", dbType(), len(defaultTemplates)) - } + sqlDB, err := gormDB.DB() + if err != nil { + log.Fatalf("[%s] load sql db failed: %v\n", dbType(), err) + } + + goose.SetBaseFS(migrationFS) + if err := goose.SetDialect(gooseDialect()); err != nil { + log.Fatalf("[%s] set goose dialect failed: %v\n", dbType(), err) + } + if err := goose.Up(sqlDB, migrationDir()); err != nil { + log.Fatalf("[%s] goose migrate failed: %v\n", dbType(), err) + } + + log.Printf("[%s] goose migrate success\n", dbType()) } diff --git a/internal/db/migrator/migrator_test.go b/internal/db/migrator/migrator_test.go new file mode 100644 index 00000000..0c8cb957 --- /dev/null +++ b/internal/db/migrator/migrator_test.go @@ -0,0 +1,69 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrator + +import ( + "testing" + + "github.com/Rain-kl/Wavelet/internal/config" + "github.com/Rain-kl/Wavelet/internal/db" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestMigrateInitializesSQLiteDatabase(t *testing.T) { + sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + }) + if err != nil { + t.Fatalf("gorm.Open(sqlite) error = %v", err) + } + + previousDBEnabled := config.Config.Database.Enabled + config.Config.Database.Enabled = false + db.SetDB(sqliteDB) + t.Cleanup(func() { + config.Config.Database.Enabled = previousDBEnabled + db.SetDB(nil) + }) + + Migrate() + + var systemConfigCount int64 + if err := sqliteDB.Table("system_configs").Count(&systemConfigCount).Error; err != nil { + t.Fatalf("Migrate() count system_configs error = %v", err) + } + if systemConfigCount != 23 { + t.Errorf("Migrate() system_configs count = %d, want %d", systemConfigCount, 23) + } + + var adminCount int64 + if err := sqliteDB.Table("users").Where("username = ?", "admin").Count(&adminCount).Error; err != nil { + t.Fatalf("Migrate() count admin user error = %v", err) + } + if adminCount != 1 { + t.Errorf("Migrate() admin user count = %d, want %d", adminCount, 1) + } + + var templateCount int64 + if err := sqliteDB.Table("templates").Count(&templateCount).Error; err != nil { + t.Fatalf("Migrate() count templates error = %v", err) + } + if templateCount != 2 { + t.Errorf("Migrate() templates count = %d, want %d", templateCount, 2) + } +}