refactor(migration): delete pkg/migrator, move SQL to per-plugin embed

BREAKING: pkg/migrator/ deleted entirely. Migration SQL files are now
owned by each plugin in its own migrations/ directory.

Architecture:
- Delete pkg/migrator/ (26 global SQL files + ClickHouse migration)
- Move global SQL to per-plugin migrations/ with go:embed + Register()
- Rewrite cmd/app.go gooseEngine: uses Inject[DBService] for DB, iterates
  all plugin-registered MigrationEntry, runs goose.Up per entry
- core.MigrationEngine.Migrate signature changed: *Context instead of
  context.Context, so engine can resolve services via IoC

Per-plugin migration ownership:
  auth/             → w_access_tokens, w_auth_sources, w_external_accounts
  user/             → w_users (seed system user)
  admin/            → w_system_configs, w_templates (seeds)
  upload/           → w_uploads, w_upload_stats
  message_gateway/  → w_push_*, w_message_*
  risk_control/     → w_user_access_logs (PG + ClickHouse)
  driver_asynq_cron/  → w_schedules
  driver_asynq_worker/ → w_task_executions

Dependencies:
- cmd/banner.go: removed migration report display (migrations are automatic)
- cmd/reset_passwd.go: removed PreRun migrator.Migrate() call
- go.mod: clickhouse-go kept (used by plugins/infra/database/clickhouse.go)
This commit is contained in:
ryan
2026-08-28 12:19:25 +08:00
parent c9b702d234
commit 530a9dd3ee
79 changed files with 552 additions and 1534 deletions
+63 -7
View File
@@ -4,12 +4,13 @@
package cmd
import (
"context"
"fmt"
"log"
"time"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/pkg/config"
"github.com/Rain-kl/Wavelet/pkg/migrator"
"github.com/Rain-kl/Wavelet/plugins/domain/admin"
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
"github.com/Rain-kl/Wavelet/plugins/domain/cap"
@@ -25,6 +26,7 @@ import (
"github.com/Rain-kl/Wavelet/plugins/infra/database"
"github.com/Rain-kl/Wavelet/plugins/infra/logger"
"github.com/Rain-kl/Wavelet/plugins/infra/storage"
"github.com/pressly/goose/v3"
)
// newWaveletApp creates a core.App wired with Wavelet platform infrastructure, domain plugins, and profile drivers.
@@ -56,7 +58,7 @@ func newWaveletApp(profile core.Profile) *core.App {
system.New(),
)
// 3. Bind Goose migration engine (wraps pkg/migrator.Migrate)
// 3. Bind Goose migration engine
app.SetMigrationEngine(&gooseEngine{})
// 4. Mount runtime drivers for each aspect
@@ -69,11 +71,65 @@ func newWaveletApp(profile core.Profile) *core.App {
return app
}
// gooseEngine implements core.MigrationEngine by wrapping pkg/migrator.Migrate.
// It runs all centrally managed Goose SQL migrations during app startup.
// gooseEngine implements core.MigrationEngine by iterating all plugin-registered
// migration entries and applying each plugin's migrations against the shared DB.
//
// Each plugin owns its own `migrations/*.sql` directory, embedded via go:embed
// and registered via ctx.Migrations().Register(pluginID, embedFS). The engine
// resolves DBService from the IoC container and runs every entry in order.
type gooseEngine struct{}
func (e *gooseEngine) Migrate(_ context.Context, _ []core.MigrationEntry) error {
_ = migrator.Migrate()
func (e *gooseEngine) Migrate(ctx *core.Context, entries []core.MigrationEntry) error {
if len(entries) == 0 {
return nil
}
// Resolve DBService from the IoC container.
var dbSvc contracts.DBService
if err := core.Using[contracts.DBService](ctx, func(svc contracts.DBService) {
dbSvc = svc
}); err != nil {
return fmt.Errorf("migration: resolve DBService: %w", err)
}
gormDB := dbSvc.GORM()
if gormDB == nil {
return fmt.Errorf("migration: DBService.GORM() returned nil")
}
sqlDB, err := gormDB.DB()
if err != nil {
return fmt.Errorf("migration: get underlying %s from GORM: %w", dialectName(), err)
}
dialect := dialectName()
for _, entry := range entries {
log.Printf("[migrate] applying %s migrations (%s)", entry.PluginID, entry.Dir)
goose.SetBaseFS(entry.FS)
if err := goose.SetDialect(dialect); err != nil {
return fmt.Errorf("migration %s: set dialect %q: %w", entry.PluginID, dialect, err)
}
dir := entry.Dir
if dir == "" {
dir = "migrations"
}
if err := goose.Up(sqlDB, dir); err != nil {
return fmt.Errorf("migration %s: apply %w", entry.PluginID, err)
}
log.Printf("[migrate] %s migrations applied", entry.PluginID)
}
return nil
}
// dialectName returns the goose dialect based on the configured database engine.
func dialectName() string {
if !config.Config.Database.Enabled {
return "sqlite3"
}
return "postgres"
}
+1 -17
View File
@@ -14,14 +14,11 @@ import (
"github.com/Rain-kl/Wavelet/pkg/buildinfo"
"github.com/Rain-kl/Wavelet/pkg/config"
"github.com/Rain-kl/Wavelet/pkg/migrator"
)
//nolint:unused // startup banner formatting utilities
type startupState struct {
mode string
relationalDB migrator.Report
clickHouseDB migrator.Report
listensForHTTP bool
}
@@ -42,8 +39,6 @@ func formatStartupBanner(state startupState) string {
fmt.Sprintf(" Environment: %s", config.Config.App.Env),
fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()),
fmt.Sprintf(" Build time: %s", buildTime()),
fmt.Sprintf(" Database: %s", formatMigration(state.relationalDB)),
fmt.Sprintf(" Analytics: %s", formatMigration(state.clickHouseDB)),
}
if state.listensForHTTP {
lines = append(lines, fmt.Sprintf(" Listening: http://%s", config.Config.App.Addr))
@@ -57,15 +52,4 @@ func buildTime() string {
return "development build"
}
return buildinfo.BuildTime
}
func formatMigration(report migrator.Report) string {
if !report.Enabled {
return "disabled"
}
state := "up to date"
if report.Applied {
state = "upgraded"
}
return fmt.Sprintf("%s (version %d, %s)", report.Backend, report.Version, state)
}
}
+2 -12
View File
@@ -9,7 +9,6 @@ import (
"github.com/Rain-kl/Wavelet/pkg/buildinfo"
"github.com/Rain-kl/Wavelet/pkg/config"
"github.com/Rain-kl/Wavelet/pkg/migrator"
)
func TestFormatStartupBanner(t *testing.T) {
@@ -30,14 +29,7 @@ func TestFormatStartupBanner(t *testing.T) {
config.Config.App.Addr = ":3000"
banner := formatStartupBanner(startupState{
mode: "API",
relationalDB: migrator.Report{
Backend: "PostgreSQL",
Enabled: true,
Version: 202607150003,
Applied: true,
},
clickHouseDB: migrator.Report{Backend: "ClickHouse"},
mode: "API",
listensForHTTP: true,
})
@@ -45,8 +37,6 @@ func TestFormatStartupBanner(t *testing.T) {
"Wavelet v3.2.1",
"Environment: production",
"Build time: 2026-07-13T08:00:00Z",
"Database: PostgreSQL (version 202607150003, upgraded)",
"Analytics: disabled",
"Listening: http://:3000",
"Mode: API",
} {
@@ -54,4 +44,4 @@ func TestFormatStartupBanner(t *testing.T) {
t.Errorf("banner missing %q:\n%s", want, banner)
}
}
}
}
+3 -4
View File
@@ -13,7 +13,6 @@ import (
"os"
"strings"
"github.com/Rain-kl/Wavelet/pkg/migrator"
userdomain "github.com/Rain-kl/Wavelet/plugins/domain/user"
"github.com/Rain-kl/Wavelet/plugins/infra/database"
@@ -46,12 +45,12 @@ func generateRandomPassword(length int) (string, error) {
var resetPasswdCmd = &cobra.Command{
Use: "reset-passwd",
Short: "重置指定账号密码",
PreRun: func(_ *cobra.Command, _ []string) {
migrator.Migrate()
},
Run: func(_ *cobra.Command, _ []string) {
ctx := context.Background()
// Ensure database is initialized
database.DB(ctx)
var username string
if usernameFlag != "" {
username = usernameFlag
+3 -3
View File
@@ -212,7 +212,7 @@ func (a *App) ApplyPlugins() error {
}
// RunMigrations dispatches migration execution across all registered plugin migration entries.
func (a *App) RunMigrations(ctx context.Context) error {
func (a *App) RunMigrations() error {
entries := a.ctx.Migrations().Entries()
if len(entries) == 0 {
return nil
@@ -233,7 +233,7 @@ func (a *App) RunMigrations(ctx context.Context) error {
return nil
}
if err := engine.Migrate(ctx, entries); err != nil {
if err := engine.Migrate(a.ctx, entries); err != nil {
return fmt.Errorf("core: migration failed: %w", err)
}
@@ -275,7 +275,7 @@ func (a *App) Start(ctx ...context.Context) error {
}
// 2. Run migrations
if err := a.RunMigrations(baseCtx); err != nil {
if err := a.RunMigrations(); err != nil {
a.mu.Lock()
a.running = false
a.mu.Unlock()
+3 -3
View File
@@ -321,7 +321,7 @@ func TestAppStartDriverFailureRollback(t *testing.T) {
func TestAppMigrationEngineExecution(t *testing.T) {
var migratedEntries []extpoints.MigrationEntry
runner := core.MigrationRunner(func(ctx context.Context, entries []extpoints.MigrationEntry) error {
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
migratedEntries = entries
return nil
})
@@ -354,7 +354,7 @@ func TestAppMigrationEngineExecution(t *testing.T) {
func TestAppMigrationEngineFromIoCContainer(t *testing.T) {
var executed bool
runner := core.MigrationRunner(func(ctx context.Context, entries []extpoints.MigrationEntry) error {
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
executed = true
return nil
})
@@ -483,7 +483,7 @@ func TestAppIdempotencyAndErrorStates(t *testing.T) {
assert.False(t, app2.IsRunning())
// Migration failure
migFailRunner := core.MigrationRunner(func(ctx context.Context, entries []extpoints.MigrationEntry) error {
migFailRunner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
return errors.New("sql migrate error")
})
sqlFS := fstest.MapFS{
+5 -3
View File
@@ -96,15 +96,17 @@ const (
)
// MigrationEngine is the interface for executing database migrations across registered plugins.
// The ctx parameter is the root micro-kernel Context, allowing the engine to resolve
// services from the IoC container via core.Inject or core.Using.
type MigrationEngine interface {
Migrate(ctx context.Context, entries []MigrationEntry) error
Migrate(ctx *Context, entries []MigrationEntry) error
}
// MigrationRunner is a function adapter implementing MigrationEngine.
type MigrationRunner func(ctx context.Context, entries []MigrationEntry) error
type MigrationRunner func(ctx *Context, entries []MigrationEntry) error
// Migrate calls the underlying migration function.
func (fn MigrationRunner) Migrate(ctx context.Context, entries []MigrationEntry) error {
func (fn MigrationRunner) Migrate(ctx *Context, entries []MigrationEntry) error {
return fn(ctx, entries)
}
@@ -0,0 +1,326 @@
# 迁移脚本拆分执行计划
## 背景现状
| 维度 | 实际状态 |
|---|---|
| 总 SQL 文件 | 26 个全局 (`pkg/migrator/goose/`) + 5 个插件 (`plugins/domain/*/migrations/`) + 1 个 ClickHouse |
| 实际运行的迁移 | **仅 26 个全局文件**(通过 `gooseEngine` → `pkg/migrator.Migrate()`) |
| 插件注册的迁移 | 3 个 (`auth`, `user`, `message_gateway`) — 注册了但被 `gooseEngine` 丢弃 |
| 有迁移文件但未注册的插件 | `admin`(2 个文件,0 个调用) |
| 无迁移文件的插件 | `upload`, `risk_control`, `cap`, `driver_asynq_worker`, `driver_asynq_cron` |
| ClickHouse 迁移 | 1 个文件 (`w_user_access_logs`),通过 `pkg/migrator.MigrateClickHouse()` 单独运行 |
## 表所有者映射
以下列表基于"单一所有者原则",每个表精确映射到一个插件:
| 表名 | 所有者插件 | 涉及全局迁移 |
|---|---|---|
| `w_users` | `domain/user` | 20260609 (create), 20260614 (seed system user) |
| `w_access_tokens` | `domain/auth` | 20260609 (create), 20260610 (is_admin), 20260611 (rm last_used_at) |
| `w_auth_sources` | `domain/auth` | 20260609 (create) |
| `w_external_accounts` | `domain/auth` | 20260609 (create) |
| `w_system_configs` | `domain/admin` | 20260609→20260611 (rename+seeds×7), 20260613 (TEXT), 20260816 (log_db) |
| `w_templates` | `domain/admin` | 20260609→20260611 (rename) |
| `w_schedules` | `driver_asynq_cron` | 20260610 (create), 20260611 (identity), 20260614 (update cleanup) |
| `w_task_executions` | `driver_asynq_worker` | 20260609→20260611 (rename) |
| `w_uploads` | `domain/upload` | 20260609→20260611 (rename), 20260613 (access_mode), 20260617 (indexes), 20260618 (drop storage_driver) |
| `w_upload_stats` | `domain/upload` | 20260617 (create+backfill) |
| `w_push_events` | `domain/message_gateway` | 20260614 (create), 20260615 (task_type), 20260616 (cleanup) |
| `w_push_histories` | `domain/message_gateway` | 20260614 (create) |
| `w_push_channels` | `domain/message_gateway` | 20260614 (create) |
| `w_message_channels` | `domain/message_gateway` | 20260816 (create) |
| `w_message_bindings` | `domain/message_gateway` | 20260816 (create) |
| `w_message_pairing_codes` | `domain/message_gateway` | 20260816 (create) |
| `w_user_access_logs` | `domain/risk_control` | 20260816 (create) + ClickHouse |
## 执行步骤(共 8 步)
---
### 步骤 1:创建 Bootstrap 迁移(保留在 `pkg/migrator`)
**文件**:`pkg/migrator/goose/postgres/00001_bootstrap.sql`
将以下全局迁移合并为一个 bootstrap 文件:
- **`202606090001_initial_schema.sql`** → 创建 `users`, `auth_sources`, `external_accounts`, `access_tokens`, `system_configs`, `uploads`, `task_executions`, `templates`(全部无前缀旧名)
- **`202606110003_rename_tables_to_w_prefix.sql`** → 全部重命名为 `w_` 前缀
**合并后,bootstrap 文件直接创建带 `w_` 前缀的表**,不再需要 rename 步骤:
```sql
-- +goose Up
CREATE TABLE IF NOT EXISTS w_users (
id BIGINT PRIMARY KEY,
username VARCHAR(64) UNIQUE,
...
);
CREATE TABLE IF NOT EXISTS w_access_tokens (...);
CREATE TABLE IF NOT EXISTS w_auth_sources (...);
CREATE TABLE IF NOT EXISTS w_external_accounts (...);
CREATE TABLE IF NOT EXISTS w_system_configs (
key VARCHAR(64) PRIMARY KEY,
value TEXT NOT NULL,
...
);
CREATE TABLE IF NOT EXISTS w_uploads (...);
CREATE TABLE IF NOT EXISTS w_task_executions (...);
CREATE TABLE IF NOT EXISTS w_templates (...);
CREATE TABLE IF NOT EXISTS w_schedules (...);
```
> **为什么保留在 `pkg/migrator`**:这些是平台的"初始化基座"——无论哪些插件启用,这些表都存在。将 bootstrap 放到 `pkg/migrator` 之下回避了循环依赖问题(例如 `w_system_configs` 属于 admin,但 bootstrap 时 admin 插件尚未 apply)。
---
### 步骤 2:修复 `gooseEngine` 支持插件迁移
**文件**:`cmd/app.go`
```go
type gooseEngine struct{}
func (e *gooseEngine) Migrate(_ context.Context, entries []core.MigrationEntry) error {
// 1. 先跑 bootstrap(初始化基座)
_ = migrator.Migrate()
// 2. 再跑每个插件注册的迁移
for _, entry := range entries {
gormDB := database.DB(context.Background())
if gormDB == nil {
continue
}
sqlDB, err := gormDB.DB()
if err != nil {
return err
}
goose.SetBaseFS(entry.FS)
if err := goose.SetDialect(gooseDialect()); err != nil {
return err
}
dir := entry.Dir
if dir == "" {
dir = "migrations"
}
if err := goose.Up(sqlDB, dir); err != nil {
return fmt.Errorf("migrate %s: %w", entry.PluginID, err)
}
}
// 3. ClickHouse 迁移
_ = migrator.MigrateClickHouse()
return nil
}
```
依赖项:`gooseDialect()` 从 `pkg/migrator` 导出。
---
### 步骤 3:按表所有者拆分迁移到各插件
| 全局源文件 | 目标插件 | 迁移文件名 |
|---|---|---|
| `202606100002` (access_tokens is_admin) | `domain/auth` | `migrations/00002_add_access_token_is_admin.sql` |
| `202606110001` (drop last_used_at) | `domain/auth` | `migrations/00003_drop_access_token_last_used_at.sql` |
| `202606140003` (system user seed) | `domain/user` | `migrations/00002_seed_system_user.sql` |
| `202606110004` (file_access_whitelist seed) | `domain/admin` | `migrations/00003_seed_file_access_whitelist.sql` |
| `202606110005` (disk_cache configs seed) | `domain/admin` | `migrations/00004_seed_disk_cache_configs.sql` |
| `202606120002` (update_upstream_repo seed) | `domain/admin` | `migrations/00005_seed_upstream_repo_config.sql` |
| `202606130002` (system_configs value TEXT) | `domain/admin` | `migrations/00006_expand_config_value.sql` |
| `202606130003` (storage_config seed) | `domain/admin` | `migrations/00007_seed_storage_config.sql` |
| `202608160002` (log database configs) | `domain/admin` | `migrations/00008_seed_log_db_configs.sql` |
| `202606120001` (login_session_ttl) | `domain/auth` | `migrations/00004_seed_login_session_ttl.sql` |
| `202606130001` (w_uploads access_mode) | `domain/upload` | `migrations/00001_add_access_mode.sql` |
| `202606170001` (upload indexes) | `domain/upload` | `migrations/00002_add_composite_indexes.sql` |
| `202606170002` (upload stats table) | `domain/upload` | `migrations/00003_create_upload_stats.sql` |
| `202606170003` (backfill stats) | `domain/upload` | `migrations/00004_backfill_upload_stats.sql` |
| `202606180001` (drop storage_driver) | `domain/upload` | `migrations/00005_drop_storage_driver.sql` |
| `202606140001` (push tables) | `domain/message_gateway` | `migrations/00002_create_push_tables.sql` |
| `202606140004` (push channels) | `domain/message_gateway` | `migrations/00003_create_push_channels.sql` |
| `202606150001` (push task_type) | `domain/message_gateway` | `migrations/00004_add_push_task_type.sql` |
| `202606160001` (remove push config) | `domain/message_gateway` | `migrations/00005_remove_push_config.sql` |
| `202608160003` (message gateway tables) | `domain/message_gateway` | `migrations/00006_create_message_tables.sql` |
| `202606100001` (schedules) | `driver_asynq_cron` | `migrations/00001_create_schedules.sql` |
| `202606110002` (schedules identity) | `driver_asynq_cron` | `migrations/00002_alter_schedules_identity.sql` |
| `202606140005` (update cleanup schedule) | `driver_asynq_cron` | `migrations/00003_update_cleanup_schedule.sql` |
| `202608160001` (user access logs) | `domain/risk_control/logstore` | `migrations/00001_create_access_logs.sql` |
| `202608160002` (log_database configs) | `domain/admin` | (合并到 admin 步骤 7) |
---
### 步骤 4:补充缺失的 `go:embed` 和 `Register()` 调用
**`plugins/domain/admin/plugin.go`**:
```go
//go:embed migrations/*.sql
var adminMigrations embed.FS
// 在 Apply() 中:
ctx.Migrations().Register("admin", adminMigrations)
```
**`plugins/domain/upload/plugin.go`**:
```go
//go:embed migrations/*.sql
var uploadMigrations embed.FS
// 在 Apply() 中:
ctx.Migrations().Register("upload", uploadMigrations)
```
**`plugins/domain/risk_control/plugin.go`**:
```go
// go:embed 由 logstore 子包自行处理(它已有自己的 moved 文件)
// 在 Apply() 中:
ctx.Migrations().Register("risk_control/logstore", logstoreMigrationFS)
```
**`plugins/drivers/driver_asynq_cron/plugin.go`**:
```go
//go:embed migrations/*.sql
var cronMigrations embed.FS
// 在 Apply() 中:
ctx.Migrations().Register("driver_asynq_cron", cronMigrations)
```
---
### 步骤 5:解决 Admin 插件迁移与 Bootstrap 的冲突
当前 `admin/migrations/00001` 执行 `CREATE TABLE IF NOT EXISTS w_system_configs (...)`,但 bootstrap 已在步骤 1 中创建过这张表。需要:
1. **保持 `IF NOT EXISTS`** 保证幂等性
2. **从 admin migration 中移除 `w_schedules` 和 `w_task_executions` 的 CREATE**(它们在 bootstrap 中创建,属于 driver 插件)
3. **仅保留 admin 自己的表**:`w_system_configs`, `w_templates`
4. Seed 数据使用 `ON CONFLICT DO NOTHING` 避免重复:
当前 admin 的 seed 包含 29 个系统配置,其中约 14 个与全局迁移重复。整理后的 admin seed 应:
```sql
INSERT INTO w_system_configs (...) VALUES
('cap_login_enabled', 'false', ...),
('cap_auto_solve', 'true', ...),
-- ... (所有 29 个配置)
ON CONFLICT (key) DO NOTHING;
```
> 全局迁移中 `202606110004` 到 `202608160002` 的 7 个种子 INSERT 将被迁移到 admin,全部使用 `ON CONFLICT DO NOTHING`。
---
### 步骤 6:清理已迁移的全局文件
拆分完成后,从 `pkg/migrator/goose/postgres/` 中删除以下文件:
```
202606100002_access_token_is_admin.sql
202606100001_create_schedules.sql
202606110001_remove_access_token_last_used_at.sql
202606110002_alter_schedules_id_auto_increment.sql
202606110004_add_file_access_whitelist_config.sql
202606110005_add_disk_cache_configs.sql
202606120001_add_login_session_ttl_config.sql
202606120002_add_update_upstream_repository_config.sql
202606130001_add_upload_access_mode.sql
202606130002_expand_system_config_value.sql
202606130003_add_storage_config.sql
202606140001_create_push_tables.sql
202606140003_add_system_user.sql
202606140004_create_push_channels.sql
202606140005_update_system_cleanup_schedule.sql
202606150001_add_task_type_to_push_events.sql
202606160001_remove_push_config.sql
202606170001_add_upload_composite_indexes.sql
202606170002_create_upload_stats_table.sql
202606170003_backfill_upload_stats.sql
202606180001_drop_upload_storage_driver.sql
202608160001_create_user_access_logs.sql
202608160002_log_database_configs.sql
202608160003_create_message_gateway.sql
```
**保留在 `pkg/migrator/goose/postgres/` 的仅限**:
```
00001_bootstrap.sql (合并后的初始化基座)
```
**注意**:`202606110003_rename_tables_to_w_prefix.sql` 也被合并进 bootstrap。`202606090001_initial_schema.sql` 也被合并掉。
---
### 步骤 7:更新 `pkg/migrator` 导出 `gooseDialect()`
在 `pkg/migrator/migrator.go` 中将 `gooseDialect()` 和 `migrationDir()` 改为导出,供 `cmd/app.go` 的 `gooseEngine.Migrate()` 引用。
---
### 步骤 8:验证 + 提交
```bash
cd /Users/ryan/Code/Go/Wavelet
# 1. 编译验证
go build -mod=mod ./...
go vet ./...
# 2. 架构门禁验证
make code-check
# 3. 验证插件迁移注册完整性
grep -rn 'go:embed.*migrations' plugins/domain/*/plugin.go plugins/drivers/*/plugin.go
grep -rn 'Migrations()\.Register' plugins/domain/*/plugin.go plugins/drivers/*/plugin.go
# → 每个有 migrations/ 目录的插件既要有 go:embed 又要有 Register()
# 4. 验证 admin 插件迁移完整性
grep -rn 'w_schedules\|w_task_executions' plugins/domain/admin/migrations/
# → 不应有(这些属于 driver 插件)
# 5. 提交
git add -A && git commit -m "refactor(migration): split global SQL into per-plugin migrations
- Merge 26 global SQLs into bootstrap + per-plugin migrations
- Fix gooseEngine to iterate plugin-registered MigrationEntry
- Add go:embed + Register() to admin, upload, risk_control, driver_asynq_cron
- Remove 23 migrated SQL files from pkg/migrator/goose/
- Keep only bootstrap in pkg/migrator/goose/
- All CREATE TABLE use IF NOT EXISTS, all INSERT use ON CONFLICT DO NOTHING"
```
---
## 依赖关系图
```
Bootstrap (pkg/migrator)
├── 创建 w_users, w_access_tokens, w_auth_sources, w_external_accounts
├── 创建 w_system_configs, w_templates, w_schedules, w_task_executions
├── 创建 w_uploads, w_upload_stats
└── 创建所有 w_ 前缀表
│
├─ auth/00002 (access_tokens is_admin)
├─ auth/00003 (drop last_used_at)
├─ auth/00004 (login_session_ttl seed)
│
├─ user/00002 (system user seed)
│
├─ admin/00001 (w_system_configs, w_templates) [IF NOT EXISTS]
├─ admin/00002 (29 config seeds + 2 template seeds)
├─ admin/00003–00008 (拆分后的种子迁移)
│
├─ upload/00001–00005 (access_mode → indexes → stats → backfill → drop)
│
├─ message_gateway/00001 (w_message_* tables)
├─ message_gateway/00002–00006 (push tables → channels → task_type → cleanup)
│
├─ driver_asynq_cron/00001–00003 (schedules → identity → cleanup)
│
├─ driver_asynq_worker/00001 (task_executions — 如果有追加操作)
│
└─ risk_control/logstore/00001 (w_user_access_logs)
```
所有步骤执行的迁移顺序由 Goose 的文件名前缀控制。Bootstrap 使用 `00001_`,每个插件的迁移从 `00002_` 开始编号(`00001` 留给插件自身表 CREATE,若插件 bootstrap 已创建则从 `00002` 开始)。
-107
View File
@@ -1,107 +0,0 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package migrator
import (
"context"
"database/sql"
"embed"
"io/fs"
"log"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/Rain-kl/Wavelet/pkg/config"
"github.com/pressly/goose/v3"
)
const (
clickhouseMigrationDir = "goose/clickhouse"
clickhouseGooseVersionTable = "goose_clickhouse_version"
clickhouseMaxExecTime = 60
clickhouseReadTimeoutFactor = 2
)
// clickhouseMigrationFS contains SQL migrations under goose/clickhouse.
//
//go:embed goose/clickhouse/*.sql
var clickhouseMigrationFS embed.FS
// MigrateClickHouse runs goose migrations against ClickHouse when enabled.
func MigrateClickHouse() Report {
if !config.Config.ClickHouse.Enabled {
return Report{Backend: "ClickHouse"}
}
cfg := config.Config.ClickHouse
sqlDB := clickhouse.OpenDB(&clickhouse.Options{
Addr: cfg.Hosts,
Auth: clickhouse.Auth{
Database: cfg.Database,
Username: cfg.Username,
Password: cfg.Password,
},
Settings: clickhouse.Settings{
"max_execution_time": clickhouseMaxExecTime,
},
Compression: &clickhouse.Compression{
Method: clickhouse.CompressionLZ4,
},
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
MaxOpenConns: cfg.MaxOpenConn,
MaxIdleConns: cfg.MaxIdleConn,
ConnMaxLifetime: time.Duration(cfg.ConnMaxLifetime) * time.Second,
ReadTimeout: time.Duration(cfg.DialTimeout*clickhouseReadTimeoutFactor) * time.Second,
BlockBufferSize: cfg.BlockBufferSize,
})
subFS, err := fs.Sub(clickhouseMigrationFS, "goose/clickhouse")
if err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] get sub fs failed: %v\n", err)
}
provider, err := goose.NewProvider(
"clickhouse",
sqlDB,
subFS,
goose.WithTableName(clickhouseGooseVersionTable),
goose.WithDisableGlobalRegistry(true),
)
if err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] create goose provider failed: %v\n", err)
}
previousVersion, err := provider.GetDBVersion(context.Background())
if err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] get goose version failed: %v\n", err)
}
if _, err := provider.Up(context.Background()); err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] goose migrate failed: %v\n", err)
}
currentVersion, err := provider.GetDBVersion(context.Background())
if err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] get migrated goose version failed: %v\n", err)
}
closeClickHouseDB(sqlDB)
log.Println("[ClickHouse] goose migrate success")
return Report{
Backend: "ClickHouse",
Enabled: true,
Version: currentVersion,
Applied: currentVersion != previousVersion,
}
}
func closeClickHouseDB(sqlDB *sql.DB) {
if err := sqlDB.Close(); err != nil {
log.Printf("[ClickHouse] close sql db failed: %v\n", err)
}
}
-51
View File
@@ -1,51 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package migrator
import (
"testing"
"github.com/Rain-kl/Wavelet/pkg/config"
"github.com/pressly/goose/v3"
)
func TestClickHouseMigrationFilesEmbedded(t *testing.T) {
entries, err := clickhouseMigrationFS.ReadDir(clickhouseMigrationDir)
if err != nil {
t.Fatalf("ReadDir(%q) error = %v", clickhouseMigrationDir, err)
}
if len(entries) == 0 {
t.Fatal("expected embedded ClickHouse migrations, got none")
}
found := false
for _, entry := range entries {
if entry.IsDir() {
continue
}
if entry.Name() == "202606190001_create_user_access_logs.sql" {
found = true
break
}
}
if !found {
t.Fatal("expected 202606190001_create_user_access_logs.sql in embedded migrations")
}
}
func TestClickHouseGooseDialect(t *testing.T) {
if err := goose.SetDialect("clickhouse"); err != nil {
t.Fatalf("SetDialect(clickhouse) error = %v", err)
}
}
func TestMigrateClickHouseSkipsWhenDisabled(t *testing.T) {
previousEnabled := config.Config.ClickHouse.Enabled
config.Config.ClickHouse.Enabled = false
t.Cleanup(func() {
config.Config.ClickHouse.Enabled = previousEnabled
})
MigrateClickHouse()
}
@@ -1,184 +0,0 @@
-- +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 TEXT 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),
('update_upstream_repository', 'Rain-kl/Wavelet', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('storage_config', '{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}', 'system', 0, '文件存储驱动及连接配置(JSON)', 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 登录验证码', '<h3>Wavelet 登录验证</h3><p>您的登录验证码为:<strong>{{.Code}}</strong>,5分钟内有效,请勿将验证码泄露给他人。</p>', '用户密码登录时发送的验证码邮件模板,支持变量:{{.Code}}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('register_email', '注册验证码邮件', 'email', 'Wavelet 注册验证码', '<h3>Wavelet 注册验证</h3><p>您的注册验证码为:<strong>{{.Code}}</strong>,5分钟内有效,请勿泄露给他人。</p>', '用户注册时发送的验证码邮件模板,支持变量:{{.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;
@@ -1,5 +0,0 @@
-- +goose Up
ALTER TABLE access_tokens ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
-- +goose Down
ALTER TABLE access_tokens DROP COLUMN IF EXISTS is_admin;
@@ -1,9 +0,0 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE access_tokens DROP COLUMN IF EXISTS last_used_at;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE access_tokens ADD COLUMN last_used_at TIMESTAMPTZ;
-- +goose StatementEnd
@@ -1,9 +0,0 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE schedules ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (START WITH 100);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE schedules ALTER COLUMN id DROP IDENTITY IF EXISTS;
-- +goose StatementEnd
@@ -1,66 +0,0 @@
-- +goose Up
ALTER TABLE users RENAME TO w_users;
ALTER TABLE auth_sources RENAME TO w_auth_sources;
ALTER TABLE external_accounts RENAME TO w_external_accounts;
ALTER TABLE system_configs RENAME TO w_system_configs;
ALTER TABLE uploads RENAME TO w_uploads;
ALTER TABLE access_tokens RENAME TO w_access_tokens;
ALTER TABLE task_executions RENAME TO w_task_executions;
ALTER TABLE templates RENAME TO w_templates;
ALTER TABLE schedules RENAME TO w_schedules;
-- Rename indexes for consistency
ALTER INDEX IF EXISTS idx_users_email RENAME TO idx_w_users_email;
ALTER INDEX IF EXISTS idx_users_is_active RENAME TO idx_w_users_is_active;
ALTER INDEX IF EXISTS idx_users_last_login_at RENAME TO idx_w_users_last_login_at;
ALTER INDEX IF EXISTS idx_users_created_at RENAME TO idx_w_users_created_at;
ALTER INDEX IF EXISTS idx_auth_sources_is_active RENAME TO idx_w_auth_sources_is_active;
ALTER INDEX IF EXISTS idx_external_accounts_auth_source_id RENAME TO idx_w_external_accounts_auth_source_id;
ALTER INDEX IF EXISTS idx_external_accounts_user_id RENAME TO idx_w_external_accounts_user_id;
ALTER INDEX IF EXISTS idx_external_accounts_source_external RENAME TO idx_w_external_accounts_source_external;
ALTER INDEX IF EXISTS idx_uploads_user_id RENAME TO idx_w_uploads_user_id;
ALTER INDEX IF EXISTS idx_uploads_file_path RENAME TO idx_w_uploads_file_path;
ALTER INDEX IF EXISTS idx_uploads_hash RENAME TO idx_w_uploads_hash;
ALTER INDEX IF EXISTS idx_uploads_type RENAME TO idx_w_uploads_type;
ALTER INDEX IF EXISTS idx_access_tokens_user_id RENAME TO idx_w_access_tokens_user_id;
ALTER INDEX IF EXISTS idx_task_executions_task_type RENAME TO idx_w_task_executions_task_type;
ALTER INDEX IF EXISTS idx_task_executions_status RENAME TO idx_w_task_executions_status;
ALTER INDEX IF EXISTS idx_task_executions_started_at RENAME TO idx_w_task_executions_started_at;
ALTER INDEX IF EXISTS idx_task_executions_created_at RENAME TO idx_w_task_executions_created_at;
ALTER INDEX IF EXISTS idx_templates_is_system RENAME TO idx_w_templates_is_system;
ALTER INDEX IF EXISTS idx_templates_created_at RENAME TO idx_w_templates_created_at;
ALTER INDEX IF EXISTS idx_templates_updated_at RENAME TO idx_w_templates_updated_at;
ALTER INDEX IF EXISTS idx_schedules_is_active RENAME TO idx_w_schedules_is_active;
-- +goose Down
ALTER INDEX IF EXISTS idx_w_schedules_is_active RENAME TO idx_schedules_is_active;
ALTER INDEX IF EXISTS idx_w_templates_updated_at RENAME TO idx_templates_updated_at;
ALTER INDEX IF EXISTS idx_w_templates_created_at RENAME TO idx_templates_created_at;
ALTER INDEX IF EXISTS idx_w_templates_is_system RENAME TO idx_templates_is_system;
ALTER INDEX IF EXISTS idx_w_task_executions_created_at RENAME TO idx_task_executions_created_at;
ALTER INDEX IF EXISTS idx_w_task_executions_started_at RENAME TO idx_task_executions_started_at;
ALTER INDEX IF EXISTS idx_w_task_executions_status RENAME TO idx_task_executions_status;
ALTER INDEX IF EXISTS idx_w_task_executions_task_type RENAME TO idx_task_executions_task_type;
ALTER INDEX IF EXISTS idx_w_access_tokens_user_id RENAME TO idx_access_tokens_user_id;
ALTER INDEX IF EXISTS idx_w_uploads_type RENAME TO idx_uploads_type;
ALTER INDEX IF EXISTS idx_w_uploads_hash RENAME TO idx_uploads_hash;
ALTER INDEX IF EXISTS idx_w_uploads_file_path RENAME TO idx_uploads_file_path;
ALTER INDEX IF EXISTS idx_w_uploads_user_id RENAME TO idx_uploads_user_id;
ALTER INDEX IF EXISTS idx_w_external_accounts_source_external RENAME TO idx_external_accounts_source_external;
ALTER INDEX IF EXISTS idx_w_external_accounts_user_id RENAME TO idx_external_accounts_user_id;
ALTER INDEX IF EXISTS idx_w_external_accounts_auth_source_id RENAME TO idx_external_accounts_auth_source_id;
ALTER INDEX IF EXISTS idx_w_auth_sources_is_active RENAME TO idx_auth_sources_is_active;
ALTER INDEX IF EXISTS idx_w_users_created_at RENAME TO idx_users_created_at;
ALTER INDEX IF EXISTS idx_w_users_last_login_at RENAME TO idx_users_last_login_at;
ALTER INDEX IF EXISTS idx_w_users_is_active RENAME TO idx_users_is_active;
ALTER INDEX IF EXISTS idx_w_users_email RENAME TO idx_users_email;
ALTER TABLE w_schedules RENAME TO schedules;
ALTER TABLE w_templates RENAME TO templates;
ALTER TABLE w_task_executions RENAME TO task_executions;
ALTER TABLE w_access_tokens RENAME TO access_tokens;
ALTER TABLE w_uploads RENAME TO uploads;
ALTER TABLE w_system_configs RENAME TO system_configs;
ALTER TABLE w_external_accounts RENAME TO external_accounts;
ALTER TABLE w_auth_sources RENAME TO auth_sources;
ALTER TABLE w_users RENAME TO users;
@@ -1,10 +0,0 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('disk_cache_max_size_mb', '100', 'system', 0, '磁盘缓存最大空间大小 (MB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('disk_cache_ttl_minutes', '60', 'system', 0, '磁盘缓存默认有效期 (分钟)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('disk_cache_lru_enabled', 'true', 'system', 0, '是否启用 LRU 淘汰机制', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN ('disk_cache_max_size_mb', 'disk_cache_ttl_minutes', 'disk_cache_lru_enabled');
@@ -1,7 +0,0 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES ('update_upstream_repository', 'Rain-kl/Wavelet', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'update_upstream_repository';
@@ -1,6 +0,0 @@
-- +goose Up
ALTER TABLE w_uploads ADD COLUMN access_mode INTEGER NOT NULL DEFAULT 0;
UPDATE w_uploads SET access_mode = 1 WHERE type = 'avatar';
-- +goose Down
ALTER TABLE w_uploads DROP COLUMN access_mode;
@@ -1,15 +0,0 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES (
'storage_config',
'{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}',
'system',
0,
'文件存储驱动及连接配置(JSON)',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'storage_config';
@@ -1,8 +0,0 @@
-- +goose Up
INSERT INTO w_users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at)
VALUES (999, 'system', '*', '系统', '', TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (username) DO NOTHING;
SELECT setval(pg_get_serial_sequence('w_users', 'id'), COALESCE((SELECT MAX(id) FROM w_users), 1));
-- +goose Down
DELETE FROM w_users WHERE username = 'system';
@@ -1,11 +0,0 @@
-- +goose Up
UPDATE w_schedules
SET name = '系统定期垃圾清理',
task_type = 'system_cleanup'
WHERE id = 1;
-- +goose Down
UPDATE w_schedules
SET name = '清理未使用上传',
task_type = 'cleanup_unused_uploads'
WHERE id = 1;
@@ -1,7 +0,0 @@
-- +goose Up
ALTER TABLE w_push_events ADD COLUMN task_type VARCHAR(100) NOT NULL DEFAULT '';
CREATE INDEX idx_w_push_events_task_type ON w_push_events(task_type);
-- +goose Down
DROP INDEX IF EXISTS idx_w_push_events_task_type;
ALTER TABLE w_push_events DROP COLUMN task_type;
@@ -1,6 +0,0 @@
-- +goose Up
DELETE FROM w_system_configs WHERE key = 'push_config';
DELETE FROM w_push_events WHERE event_key = 'admin_login';
DELETE FROM w_system_configs WHERE key = 'push_global_token';
-- +goose Down
@@ -1,67 +0,0 @@
-- +goose Up
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT 'total', '', COUNT(*), COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = EXCLUDED.file_count,
file_size = EXCLUDED.file_size,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT
'type',
COALESCE(NULLIF(type, ''), 'generic'),
COUNT(*),
COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
GROUP BY COALESCE(NULLIF(type, ''), 'generic')
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = EXCLUDED.file_count,
file_size = EXCLUDED.file_size,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT
'category',
CASE
WHEN LOWER(mime_type) LIKE 'image/%'
OR LOWER(extension) IN ('jpg', 'jpeg', 'png', 'webp', 'gif') THEN '图片'
WHEN LOWER(mime_type) LIKE 'video/%' THEN '视频'
WHEN LOWER(mime_type) LIKE 'audio/%' THEN '音频'
WHEN LOWER(extension) IN ('zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'xz')
OR LOWER(mime_type) LIKE '%zip%'
OR LOWER(mime_type) LIKE '%tar%'
OR LOWER(mime_type) LIKE '%gzip%' THEN '压缩包'
WHEN LOWER(extension) IN ('pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'json', 'yaml', 'yml', 'xml')
OR LOWER(mime_type) LIKE 'text/%'
OR LOWER(mime_type) = 'application/pdf' THEN '文档'
ELSE '其他'
END,
COUNT(*),
COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
GROUP BY 2
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = EXCLUDED.file_count,
file_size = EXCLUDED.file_size,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT
'trend',
TO_CHAR(created_at, 'YYYY-MM-DD'),
COUNT(*),
COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
GROUP BY TO_CHAR(created_at, 'YYYY-MM-DD')
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = EXCLUDED.file_count,
file_size = EXCLUDED.file_size,
updated_at = CURRENT_TIMESTAMP;
-- +goose Down
DELETE FROM w_upload_stats;
@@ -1,7 +0,0 @@
-- +goose Up
DROP INDEX IF EXISTS idx_w_uploads_storage_driver_status;
ALTER TABLE w_uploads DROP COLUMN IF EXISTS storage_driver;
-- +goose Down
ALTER TABLE w_uploads ADD COLUMN IF NOT EXISTS storage_driver VARCHAR(50) NOT NULL DEFAULT 'local';
CREATE INDEX IF NOT EXISTS idx_w_uploads_storage_driver_status ON w_uploads (storage_driver, status);
@@ -1,24 +0,0 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('log_database', '', 'system', 0, '当前日志主库(postgres/sqlite/clickhouse),由切换任务写入', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('log_db_migration', '', 'system', 0, '日志库迁移冻结标记(空或 migrating)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
SELECT k, '30', 'business', 0, descr, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM (VALUES
('log_retention_days_postgres', 'PostgreSQL 用户访问日志保留天数'),
('log_retention_days_sqlite', 'SQLite 用户访问日志保留天数'),
('log_retention_days_clickhouse', 'ClickHouse 用户访问日志保留天数')
) AS v(k, descr)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN (
'log_database',
'log_db_migration',
'log_retention_days_postgres',
'log_retention_days_sqlite',
'log_retention_days_clickhouse'
);
@@ -1,40 +0,0 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS w_message_channels (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
type VARCHAR(32) NOT NULL,
owner_scope VARCHAR(16) NOT NULL DEFAULT 'system',
owner_id BIGINT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
credentials TEXT NOT NULL DEFAULT '',
extra TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_message_channels_type ON w_message_channels (type);
CREATE TABLE IF NOT EXISTS w_message_bindings (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
platform_user_id VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_w_message_bindings_channel_platform
ON w_message_bindings (channel_id, platform_user_id);
CREATE INDEX IF NOT EXISTS idx_w_message_bindings_user ON w_message_bindings (user_id);
CREATE TABLE IF NOT EXISTS w_message_pairing_codes (
code VARCHAR(16) PRIMARY KEY,
channel_id BIGINT NOT NULL,
platform_user_id VARCHAR(128) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_message_pairing_lookup
ON w_message_pairing_codes (channel_id, platform_user_id);
-- +goose Down
DROP TABLE IF EXISTS w_message_pairing_codes;
DROP TABLE IF EXISTS w_message_bindings;
DROP TABLE IF EXISTS w_message_channels;
@@ -1,184 +0,0 @@
-- +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 TEXT 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),
('update_upstream_repository', 'Rain-kl/Wavelet', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('storage_config', '{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}', 'system', 0, '文件存储驱动及连接配置(JSON)', 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 登录验证码', '<h3>Wavelet 登录验证</h3><p>您的登录验证码为:<strong>{{.Code}}</strong>,5分钟内有效,请勿将验证码泄露给他人。</p>', '用户密码登录时发送的验证码邮件模板,支持变量:{{.Code}}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('register_email', '注册验证码邮件', 'email', 'Wavelet 注册验证码', '<h3>Wavelet 注册验证</h3><p>您的注册验证码为:<strong>{{.Code}}</strong>,5分钟内有效,请勿泄露给他人。</p>', '用户注册时发送的验证码邮件模板,支持变量:{{.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;
@@ -1,20 +0,0 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS schedules (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
task_type VARCHAR(64) NOT NULL,
cron VARCHAR(64) NOT NULL,
payload TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_schedules_is_active ON schedules (is_active);
-- Seed initial cleanup task
INSERT INTO schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
VALUES (1, '清理未使用上传', 'cleanup_unused_uploads', '0 */2 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- +goose Down
DROP TABLE IF EXISTS schedules;
@@ -1,5 +0,0 @@
-- +goose Up
ALTER TABLE access_tokens ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE access_tokens DROP COLUMN is_admin;
@@ -1,9 +0,0 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE access_tokens DROP COLUMN last_used_at;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE access_tokens ADD COLUMN last_used_at DATETIME;
-- +goose StatementEnd
@@ -1,32 +0,0 @@
-- +goose Up
-- +goose StatementBegin
-- 1. Rename existing schedules table
ALTER TABLE schedules RENAME TO schedules_old;
-- 2. Create new schedules table with AUTOINCREMENT
CREATE TABLE schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(128) NOT NULL,
task_type VARCHAR(64) NOT NULL,
cron VARCHAR(64) NOT NULL,
payload TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 3. Copy existing data
INSERT INTO schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
SELECT id, name, task_type, cron, payload, is_active, created_at, updated_at FROM schedules_old;
-- 4. Drop the old table
DROP TABLE schedules_old;
-- 5. Recreate index
CREATE INDEX IF NOT EXISTS idx_schedules_is_active ON schedules (is_active);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Re-creating table with AUTOINCREMENT cannot be undone simply without recreating table again.
-- +goose StatementEnd
@@ -1,123 +0,0 @@
-- +goose Up
ALTER TABLE users RENAME TO w_users;
ALTER TABLE auth_sources RENAME TO w_auth_sources;
ALTER TABLE external_accounts RENAME TO w_external_accounts;
ALTER TABLE system_configs RENAME TO w_system_configs;
ALTER TABLE uploads RENAME TO w_uploads;
ALTER TABLE access_tokens RENAME TO w_access_tokens;
ALTER TABLE task_executions RENAME TO w_task_executions;
ALTER TABLE templates RENAME TO w_templates;
ALTER TABLE schedules RENAME TO w_schedules;
-- Drop and Recreate SQLite indexes
DROP INDEX IF EXISTS idx_users_email;
CREATE INDEX IF NOT EXISTS idx_w_users_email ON w_users (email);
DROP INDEX IF EXISTS idx_users_is_active;
CREATE INDEX IF NOT EXISTS idx_w_users_is_active ON w_users (is_active);
DROP INDEX IF EXISTS idx_users_last_login_at;
CREATE INDEX IF NOT EXISTS idx_w_users_last_login_at ON w_users (last_login_at);
DROP INDEX IF EXISTS idx_users_created_at;
CREATE INDEX IF NOT EXISTS idx_w_users_created_at ON w_users (created_at);
DROP INDEX IF EXISTS idx_auth_sources_is_active;
CREATE INDEX IF NOT EXISTS idx_w_auth_sources_is_active ON w_auth_sources (is_active);
DROP INDEX IF EXISTS idx_external_accounts_auth_source_id;
CREATE INDEX IF NOT EXISTS idx_w_external_accounts_auth_source_id ON w_external_accounts (auth_source_id);
DROP INDEX IF EXISTS idx_external_accounts_user_id;
CREATE INDEX IF NOT EXISTS idx_w_external_accounts_user_id ON w_external_accounts (user_id);
DROP INDEX IF EXISTS idx_external_accounts_source_external;
CREATE UNIQUE INDEX IF NOT EXISTS idx_w_external_accounts_source_external ON w_external_accounts (auth_source_id, external_id);
DROP INDEX IF EXISTS idx_uploads_user_id;
CREATE INDEX IF NOT EXISTS idx_w_uploads_user_id ON w_uploads (user_id);
DROP INDEX IF EXISTS idx_uploads_file_path;
CREATE INDEX IF NOT EXISTS idx_w_uploads_file_path ON w_uploads (file_path);
DROP INDEX IF EXISTS idx_uploads_hash;
CREATE INDEX IF NOT EXISTS idx_w_uploads_hash ON w_uploads (hash);
DROP INDEX IF EXISTS idx_uploads_type;
CREATE INDEX IF NOT EXISTS idx_w_uploads_type ON w_uploads (type);
DROP INDEX IF EXISTS idx_access_tokens_user_id;
CREATE INDEX IF NOT EXISTS idx_w_access_tokens_user_id ON w_access_tokens (user_id);
DROP INDEX IF EXISTS idx_task_executions_task_type;
CREATE INDEX IF NOT EXISTS idx_w_task_executions_task_type ON w_task_executions (task_type);
DROP INDEX IF EXISTS idx_task_executions_status;
CREATE INDEX IF NOT EXISTS idx_w_task_executions_status ON w_task_executions (status);
DROP INDEX IF EXISTS idx_task_executions_started_at;
CREATE INDEX IF NOT EXISTS idx_w_task_executions_started_at ON w_task_executions (started_at);
DROP INDEX IF EXISTS idx_task_executions_created_at;
CREATE INDEX IF NOT EXISTS idx_w_task_executions_created_at ON w_task_executions (created_at);
DROP INDEX IF EXISTS idx_templates_is_system;
CREATE INDEX IF NOT EXISTS idx_w_templates_is_system ON w_templates (is_system);
DROP INDEX IF EXISTS idx_templates_created_at;
CREATE INDEX IF NOT EXISTS idx_w_templates_created_at ON w_templates (created_at);
DROP INDEX IF EXISTS idx_templates_updated_at;
CREATE INDEX IF NOT EXISTS idx_w_templates_updated_at ON w_templates (updated_at);
DROP INDEX IF EXISTS idx_schedules_is_active;
CREATE INDEX IF NOT EXISTS idx_w_schedules_is_active ON w_schedules (is_active);
-- +goose Down
ALTER TABLE w_schedules RENAME TO schedules;
ALTER TABLE w_templates RENAME TO templates;
ALTER TABLE w_task_executions RENAME TO task_executions;
ALTER TABLE w_access_tokens RENAME TO access_tokens;
ALTER TABLE w_uploads RENAME TO uploads;
ALTER TABLE w_system_configs RENAME TO system_configs;
ALTER TABLE w_external_accounts RENAME TO external_accounts;
ALTER TABLE w_auth_sources RENAME TO auth_sources;
ALTER TABLE w_users RENAME TO users;
-- Revert indexes
DROP INDEX IF EXISTS idx_w_users_email;
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
DROP INDEX IF EXISTS idx_w_users_is_active;
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users (is_active);
DROP INDEX IF EXISTS idx_w_users_last_login_at;
CREATE INDEX IF NOT EXISTS idx_users_last_login_at ON users (last_login_at);
DROP INDEX IF EXISTS idx_w_users_created_at;
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users (created_at);
DROP INDEX IF EXISTS idx_w_auth_sources_is_active;
CREATE INDEX IF NOT EXISTS idx_auth_sources_is_active ON auth_sources (is_active);
DROP INDEX IF EXISTS idx_w_external_accounts_auth_source_id;
CREATE INDEX IF NOT EXISTS idx_external_accounts_auth_source_id ON external_accounts (auth_source_id);
DROP INDEX IF EXISTS idx_w_external_accounts_user_id;
CREATE INDEX IF NOT EXISTS idx_external_accounts_user_id ON external_accounts (user_id);
DROP INDEX IF EXISTS idx_w_external_accounts_source_external;
CREATE UNIQUE INDEX IF NOT EXISTS idx_external_accounts_source_external ON external_accounts (auth_source_id, external_id);
DROP INDEX IF EXISTS idx_w_uploads_user_id;
CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads (user_id);
DROP INDEX IF EXISTS idx_w_uploads_file_path;
CREATE INDEX IF NOT EXISTS idx_uploads_file_path ON uploads (file_path);
DROP INDEX IF EXISTS idx_w_uploads_hash;
CREATE INDEX IF NOT EXISTS idx_uploads_hash ON uploads (hash);
DROP INDEX IF EXISTS idx_w_uploads_type;
CREATE INDEX IF NOT EXISTS idx_uploads_type ON uploads (type);
DROP INDEX IF EXISTS idx_w_access_tokens_user_id;
CREATE INDEX IF NOT EXISTS idx_access_tokens_user_id ON access_tokens (user_id);
DROP INDEX IF EXISTS idx_w_task_executions_task_type;
CREATE INDEX IF NOT EXISTS idx_task_executions_task_type ON task_executions (task_type);
DROP INDEX IF EXISTS idx_w_task_executions_status;
CREATE INDEX IF NOT EXISTS idx_task_executions_status ON task_executions (status);
DROP INDEX IF EXISTS idx_w_task_executions_started_at;
CREATE INDEX IF NOT EXISTS idx_task_executions_started_at ON task_executions (started_at);
DROP INDEX IF EXISTS idx_w_task_executions_created_at;
CREATE INDEX IF NOT EXISTS idx_task_executions_created_at ON task_executions (created_at);
DROP INDEX IF EXISTS idx_w_templates_is_system;
CREATE INDEX IF NOT EXISTS idx_templates_is_system ON templates (is_system);
DROP INDEX IF EXISTS idx_w_templates_created_at;
CREATE INDEX IF NOT EXISTS idx_templates_created_at ON templates (created_at);
DROP INDEX IF EXISTS idx_w_templates_updated_at;
CREATE INDEX IF NOT EXISTS idx_templates_updated_at ON templates (updated_at);
DROP INDEX IF EXISTS idx_w_schedules_is_active;
CREATE INDEX IF NOT EXISTS idx_schedules_is_active ON schedules (is_active);
@@ -1,7 +0,0 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES ('file_access_whitelist', '["avatar"]', 'system', 1, '免登录访问的文件业务类型白名单 (JSON 数组格式)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'file_access_whitelist';
@@ -1,8 +0,0 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('login_session_ttl_hours', '0', 'system', 0, '登录会话过期时间 (小时,0表示浏览器关闭后自动退出,-1表示永不过期)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'login_session_ttl_hours';
@@ -1,6 +0,0 @@
-- +goose Up
ALTER TABLE w_uploads ADD COLUMN access_mode INTEGER NOT NULL DEFAULT 0;
UPDATE w_uploads SET access_mode = 1 WHERE type = 'avatar';
-- +goose Down
ALTER TABLE w_uploads DROP COLUMN access_mode;
@@ -1,6 +0,0 @@
-- +goose Up
-- SQLite stores VARCHAR and TEXT with the same TEXT affinity.
SELECT 1;
-- +goose Down
SELECT 1;
@@ -1,34 +0,0 @@
-- +goose Up
CREATE TABLE w_push_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
channels TEXT NOT NULL,
targets TEXT NOT NULL,
template TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX idx_w_push_events_enabled ON w_push_events(enabled);
CREATE TABLE w_push_histories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_key TEXT NOT NULL,
channel TEXT NOT NULL,
target TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL,
status TEXT NOT NULL,
error_msg TEXT,
created_at DATETIME NOT NULL
);
CREATE INDEX idx_w_push_histories_event ON w_push_histories(event_key);
CREATE INDEX idx_w_push_histories_created ON w_push_histories(created_at);
-- +goose Down
DROP TABLE IF EXISTS w_push_histories;
DROP TABLE IF EXISTS w_push_events;
@@ -1,19 +0,0 @@
-- +goose Up
CREATE TABLE w_push_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
type TEXT NOT NULL DEFAULT 'custom',
token TEXT,
url TEXT NOT NULL,
other TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX idx_w_push_channels_name ON w_push_channels(name);
CREATE INDEX idx_w_push_channels_enabled ON w_push_channels(enabled);
-- +goose Down
DROP TABLE IF EXISTS w_push_channels;
@@ -1,9 +0,0 @@
-- +goose Up
ALTER TABLE w_push_events ADD COLUMN task_type VARCHAR(100) NOT NULL DEFAULT '';
CREATE INDEX idx_w_push_events_task_type ON w_push_events(task_type);
-- +goose Down
DROP INDEX IF EXISTS idx_w_push_events_task_type;
-- SQLite does not support DROP COLUMN in older versions easily, but standard ALTER TABLE DROP COLUMN works in SQLite 3.35.0+.
-- We can write standard DROP COLUMN.
ALTER TABLE w_push_events DROP COLUMN task_type;
@@ -1,9 +0,0 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS idx_w_uploads_status_created_at ON w_uploads (status, created_at);
CREATE INDEX IF NOT EXISTS idx_w_uploads_storage_driver_status ON w_uploads (storage_driver, status);
CREATE INDEX IF NOT EXISTS idx_w_uploads_hash_file_size_status ON w_uploads (hash, file_size, status);
-- +goose Down
DROP INDEX IF EXISTS idx_w_uploads_hash_file_size_status;
DROP INDEX IF EXISTS idx_w_uploads_storage_driver_status;
DROP INDEX IF EXISTS idx_w_uploads_status_created_at;
@@ -1,12 +0,0 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS w_upload_stats (
dimension VARCHAR(32) NOT NULL,
stat_key VARCHAR(64) NOT NULL DEFAULT '',
file_count BIGINT NOT NULL DEFAULT 0,
file_size BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (dimension, stat_key)
);
-- +goose Down
DROP TABLE IF EXISTS w_upload_stats;
@@ -1,67 +0,0 @@
-- +goose Up
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT 'total', '', COUNT(*), COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = excluded.file_count,
file_size = excluded.file_size,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT
'type',
COALESCE(NULLIF(type, ''), 'generic'),
COUNT(*),
COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
GROUP BY COALESCE(NULLIF(type, ''), 'generic')
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = excluded.file_count,
file_size = excluded.file_size,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT
'category',
CASE
WHEN LOWER(mime_type) LIKE 'image/%'
OR LOWER(extension) IN ('jpg', 'jpeg', 'png', 'webp', 'gif') THEN '图片'
WHEN LOWER(mime_type) LIKE 'video/%' THEN '视频'
WHEN LOWER(mime_type) LIKE 'audio/%' THEN '音频'
WHEN LOWER(extension) IN ('zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'xz')
OR LOWER(mime_type) LIKE '%zip%'
OR LOWER(mime_type) LIKE '%tar%'
OR LOWER(mime_type) LIKE '%gzip%' THEN '压缩包'
WHEN LOWER(extension) IN ('pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'json', 'yaml', 'yml', 'xml')
OR LOWER(mime_type) LIKE 'text/%'
OR LOWER(mime_type) = 'application/pdf' THEN '文档'
ELSE '其他'
END,
COUNT(*),
COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
GROUP BY 2
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = excluded.file_count,
file_size = excluded.file_size,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT
'trend',
STRFTIME('%Y-%m-%d', created_at),
COUNT(*),
COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
GROUP BY STRFTIME('%Y-%m-%d', created_at)
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = excluded.file_count,
file_size = excluded.file_size,
updated_at = CURRENT_TIMESTAMP;
-- +goose Down
DELETE FROM w_upload_stats;
@@ -1,8 +0,0 @@
-- +goose Up
DROP INDEX IF EXISTS idx_w_uploads_storage_driver_status;
-- SQLite lacks DROP COLUMN IF EXISTS; goose runs this only after initial schema created the column.
ALTER TABLE w_uploads DROP COLUMN storage_driver;
-- +goose Down
ALTER TABLE w_uploads ADD COLUMN storage_driver VARCHAR(50) NOT NULL DEFAULT 'local';
CREATE INDEX IF NOT EXISTS idx_w_uploads_storage_driver_status ON w_uploads (storage_driver, status);
@@ -1,22 +0,0 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS w_user_access_logs (
id INTEGER NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
path TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
headers TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 0,
latency INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, created_at)
);
CREATE INDEX IF NOT EXISTS idx_w_user_access_logs_user_id ON w_user_access_logs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_w_user_access_logs_created_at ON w_user_access_logs (created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS w_user_access_logs;
DROP INDEX IF EXISTS idx_w_user_access_logs_user_id;
DROP INDEX IF EXISTS idx_w_user_access_logs_created_at;
@@ -1,17 +0,0 @@
-- +goose Up
INSERT OR IGNORE INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('log_database', '', 'system', 0, '当前日志主库(postgres/sqlite/clickhouse),由切换任务写入', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('log_db_migration', '', 'system', 0, '日志库迁移冻结标记(空或 migrating)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('log_retention_days_postgres', '30', 'business', 0, 'PostgreSQL 用户访问日志保留天数', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('log_retention_days_sqlite', '30', 'business', 0, 'SQLite 用户访问日志保留天数', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('log_retention_days_clickhouse', '30', 'business', 0, 'ClickHouse 用户访问日志保留天数', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
-- +goose Down
DELETE FROM w_system_configs WHERE key IN (
'log_database',
'log_db_migration',
'log_retention_days_postgres',
'log_retention_days_sqlite',
'log_retention_days_clickhouse'
);
@@ -1,40 +0,0 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS w_message_channels (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
owner_scope TEXT NOT NULL DEFAULT 'system',
owner_id INTEGER NULL,
enabled INTEGER NOT NULL DEFAULT 1,
credentials TEXT NOT NULL DEFAULT '',
extra TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_message_channels_type ON w_message_channels (type);
CREATE TABLE IF NOT EXISTS w_message_bindings (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
channel_id INTEGER NOT NULL,
platform_user_id TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_w_message_bindings_channel_platform
ON w_message_bindings (channel_id, platform_user_id);
CREATE INDEX IF NOT EXISTS idx_w_message_bindings_user ON w_message_bindings (user_id);
CREATE TABLE IF NOT EXISTS w_message_pairing_codes (
code TEXT PRIMARY KEY,
channel_id INTEGER NOT NULL,
platform_user_id TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_message_pairing_lookup
ON w_message_pairing_codes (channel_id, platform_user_id);
-- +goose Down
DROP TABLE IF EXISTS w_message_pairing_codes;
DROP TABLE IF EXISTS w_message_bindings;
DROP TABLE IF EXISTS w_message_channels;
-99
View File
@@ -1,99 +0,0 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package migrator 提供数据库迁移功能
package migrator
import (
"context"
"embed"
"log"
"github.com/Rain-kl/Wavelet/pkg/config"
"github.com/Rain-kl/Wavelet/plugins/infra/database"
"github.com/pressly/goose/v3"
)
// migrationFS contains SQL migrations under goose/<dialect>.
//
//go:embed goose/postgres/*.sql goose/sqlite/*.sql
var migrationFS embed.FS
// dbType 返回当前数据库类型名称(用于日志输出)
func dbType() string {
if !config.Config.Database.Enabled {
return "SQLite"
}
return "PostgreSQL"
}
const (
dialectSqlite = "sqlite3"
dialectPostgres = "postgres"
cascadeSuffix = " CASCADE"
)
// Report describes the database migration state observed during startup.
type Report struct {
Backend string
Enabled bool
Version int64
Applied bool
}
func gooseDialect() string {
if !config.Config.Database.Enabled {
return dialectSqlite
}
return dialectPostgres
}
func migrationDir() string {
if !config.Config.Database.Enabled {
return "goose/sqlite"
}
return "goose/postgres"
}
// Migrate 执行数据库迁移
func Migrate() Report {
gormDB := database.DB(context.Background())
if gormDB == nil {
log.Fatalf("[%s] database not initialized\n", dbType())
}
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)
}
previousVersion, err := goose.GetDBVersion(sqlDB)
if err != nil {
log.Fatalf("[%s] get goose version failed: %v\n", dbType(), err)
}
if err := goose.Up(sqlDB, migrationDir()); err != nil {
log.Fatalf("[%s] goose migrate failed: %v\n", dbType(), err)
}
clearSystemConfigCache()
currentVersion, err := goose.GetDBVersion(sqlDB)
if err != nil {
log.Fatalf("[%s] get migrated goose version failed: %v\n", dbType(), err)
}
log.Printf("[%s] goose migrate success\n", dbType())
return Report{
Backend: dbType(),
Enabled: true,
Version: currentVersion,
Applied: currentVersion != previousVersion,
}
}
func clearSystemConfigCache() {
}
-74
View File
@@ -1,74 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package migrator
import (
"testing"
"github.com/Rain-kl/Wavelet/pkg/config"
db "github.com/Rain-kl/Wavelet/plugins/infra/database"
"github.com/alicebob/miniredis/v2"
"github.com/glebarez/sqlite"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
"gorm.io/gorm"
)
const expectedMigratedSystemConfigCount = 35
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)
}
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("miniredis.Run() error = %v", err)
}
redisClient := redis.NewClient(&redis.Options{
Addr: mr.Addr(),
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
})
previousDBEnabled := config.Config.Database.Enabled
config.Config.Database.Enabled = false
db.SetDB(sqliteDB)
t.Cleanup(func() {
config.Config.Database.Enabled = previousDBEnabled
db.SetDB(nil)
_ = redisClient.Close()
mr.Close()
})
Migrate()
var systemConfigCount int64
if err := sqliteDB.Table("w_system_configs").Count(&systemConfigCount).Error; err != nil {
t.Fatalf("Migrate() count w_system_configs error = %v", err)
}
if systemConfigCount != expectedMigratedSystemConfigCount {
t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, expectedMigratedSystemConfigCount)
}
var adminCount int64
if err := sqliteDB.Table("w_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("w_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)
}
}
@@ -1,7 +1,7 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES ('file_access_whitelist', '["avatar"]', 'system', 1, '免登录访问的文件业务类型白名单 (JSON 数组格式)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
VALUES ('file_access_whitelist', '["avatar"]', 'system', 0, '免登录访问的文件业务类型白名单 (JSON 数组格式)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'file_access_whitelist';
DELETE FROM w_system_configs WHERE key = 'file_access_whitelist';
@@ -1,10 +1,10 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
VALUES
('disk_cache_max_size_mb', '100', 'system', 0, '磁盘缓存最大空间大小 (MB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('disk_cache_ttl_minutes', '60', 'system', 0, '磁盘缓存默认有效期 (分钟)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('disk_cache_lru_enabled', 'true', 'system', 0, '是否启用 LRU 淘汰机制', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN ('disk_cache_max_size_mb', 'disk_cache_ttl_minutes', 'disk_cache_lru_enabled');
DELETE FROM w_system_configs WHERE key IN ('disk_cache_max_size_mb', 'disk_cache_ttl_minutes', 'disk_cache_lru_enabled');
@@ -4,4 +4,4 @@ VALUES ('update_upstream_repository', 'Rain-kl/Wavelet', 'system', 0, 'GitHub Ac
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'update_upstream_repository';
DELETE FROM w_system_configs WHERE key = 'update_upstream_repository';
@@ -2,4 +2,4 @@
ALTER TABLE w_system_configs ALTER COLUMN value TYPE TEXT;
-- +goose Down
ALTER TABLE w_system_configs ALTER COLUMN value TYPE VARCHAR(255);
ALTER TABLE w_system_configs ALTER COLUMN value TYPE VARCHAR(255);
@@ -3,13 +3,8 @@ INSERT INTO w_system_configs (key, value, type, visibility, description, created
VALUES (
'storage_config',
'{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}',
'system',
0,
'文件存储驱动及连接配置(JSON)',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT (key) DO NOTHING;
'system', 0, '文件存储驱动及连接配置(JSON)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
) ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'storage_config';
DELETE FROM w_system_configs WHERE key = 'storage_config';
@@ -0,0 +1,9 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('log_database', '', 'system', 0, '当前日志主库(postgres/sqlite/clickhouse),由切换任务写入', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('log_db_migration', '', 'system', 0, '日志库迁移冻结标记(空或 migrating)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN ('log_database', 'log_db_migration');
+7
View File
@@ -6,6 +6,7 @@ package admin
import (
"context"
"embed"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/contracts"
@@ -14,6 +15,9 @@ import (
"github.com/hibiken/asynq"
)
//go:embed migrations/*.sql
var adminMigrations embed.FS
// Option configures the admin plugin.
type Option func(*Plugin)
@@ -56,6 +60,9 @@ func (p *Plugin) Apply(ctx *core.Context) error {
loginMW := authSvc.RequireAuthMiddleware().(gin.HandlerFunc)
adminMW := authSvc.RequireAdminMiddleware().(gin.HandlerFunc)
// 0a. Register migrations
ctx.Migrations().Register("admin", adminMigrations)
// 1. Register Admin HTTP Routes
adminRouter := ctx.Router().Group("/api/v1/admin", loginMW, adminMW)
{
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE w_access_tokens ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
-- +goose Down
ALTER TABLE w_access_tokens DROP COLUMN IF EXISTS is_admin;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE w_access_tokens DROP COLUMN IF EXISTS last_used_at;
-- +goose Down
ALTER TABLE w_access_tokens ADD COLUMN last_used_at TIMESTAMPTZ;
@@ -1,8 +1,8 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
VALUES
('login_session_ttl_hours', '0', 'system', 0, '登录会话过期时间 (小时,0表示浏览器关闭后自动退出,-1表示永不过期)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'login_session_ttl_hours';
DELETE FROM w_system_configs WHERE key = 'login_session_ttl_hours';
@@ -1,5 +1,5 @@
-- +goose Up
CREATE TABLE w_push_events (
CREATE TABLE IF NOT EXISTS w_push_events (
id BIGSERIAL PRIMARY KEY,
event_key VARCHAR(80) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
@@ -11,9 +11,9 @@ CREATE TABLE w_push_events (
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_w_push_events_enabled ON w_push_events(enabled);
CREATE INDEX IF NOT EXISTS idx_w_push_events_enabled ON w_push_events(enabled);
CREATE TABLE w_push_histories (
CREATE TABLE IF NOT EXISTS w_push_histories (
id BIGSERIAL PRIMARY KEY,
event_key VARCHAR(80) NOT NULL,
channel VARCHAR(50) NOT NULL,
@@ -26,9 +26,9 @@ CREATE TABLE w_push_histories (
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_w_push_histories_event ON w_push_histories(event_key);
CREATE INDEX idx_w_push_histories_created ON w_push_histories(created_at);
CREATE INDEX IF NOT EXISTS idx_w_push_histories_event ON w_push_histories(event_key);
CREATE INDEX IF NOT EXISTS idx_w_push_histories_created ON w_push_histories(created_at);
-- +goose Down
DROP TABLE IF EXISTS w_push_histories;
DROP TABLE IF EXISTS w_push_events;
DROP TABLE IF EXISTS w_push_events;
@@ -1,5 +1,5 @@
-- +goose Up
CREATE TABLE w_push_channels (
CREATE TABLE IF NOT EXISTS w_push_channels (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(80) NOT NULL UNIQUE,
description VARCHAR(255),
@@ -12,8 +12,8 @@ CREATE TABLE w_push_channels (
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_w_push_channels_name ON w_push_channels(name);
CREATE INDEX idx_w_push_channels_enabled ON w_push_channels(enabled);
CREATE INDEX IF NOT EXISTS idx_w_push_channels_name ON w_push_channels(name);
CREATE INDEX IF NOT EXISTS idx_w_push_channels_enabled ON w_push_channels(enabled);
-- +goose Down
DROP TABLE IF EXISTS w_push_channels;
DROP TABLE IF EXISTS w_push_channels;
@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE w_push_events ADD COLUMN IF NOT EXISTS task_type VARCHAR(100) NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_w_push_events_task_type ON w_push_events(task_type);
-- +goose Down
DROP INDEX IF EXISTS idx_w_push_events_task_type;
ALTER TABLE w_push_events DROP COLUMN IF EXISTS task_type;
@@ -3,4 +3,4 @@ DELETE FROM w_system_configs WHERE key = 'push_config';
DELETE FROM w_push_events WHERE event_key = 'admin_login';
DELETE FROM w_system_configs WHERE key = 'push_global_token';
-- +goose Down
-- +goose Down
@@ -17,4 +17,4 @@ CREATE INDEX IF NOT EXISTS idx_w_user_access_logs_user_id ON w_user_access_logs
CREATE INDEX IF NOT EXISTS idx_w_user_access_logs_created_at ON w_user_access_logs (created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS w_user_access_logs;
DROP TABLE IF EXISTS w_user_access_logs;
+7
View File
@@ -6,12 +6,16 @@ package risk_control
import (
"context"
"embed"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/extpoints"
"github.com/gin-gonic/gin"
)
//go:embed logstore/migrations/*.sql
var riskControlMigrations embed.FS
// Option configures the risk_control plugin.
type Option func(*Plugin)
@@ -55,6 +59,9 @@ func (p *Plugin) Manifest() core.Manifest {
// Apply registers risk control middlewares, settings, and cleanup hooks into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
// 0. Register user access log table migrations
ctx.Migrations().Register("risk_control/logstore", riskControlMigrations)
// 1. Initialize LogWriter if needed
InitLogWriter(ctx.GoContext())
@@ -0,0 +1,6 @@
-- +goose Up
ALTER TABLE w_uploads ADD COLUMN IF NOT EXISTS access_mode INTEGER NOT NULL DEFAULT 0;
UPDATE w_uploads SET access_mode = 1 WHERE type = 'avatar';
-- +goose Down
ALTER TABLE w_uploads DROP COLUMN IF EXISTS access_mode;
@@ -1,9 +1,7 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS idx_w_uploads_status_created_at ON w_uploads (status, created_at);
CREATE INDEX IF NOT EXISTS idx_w_uploads_storage_driver_status ON w_uploads (storage_driver, status);
CREATE INDEX IF NOT EXISTS idx_w_uploads_hash_file_size_status ON w_uploads (hash, file_size, status);
-- +goose Down
DROP INDEX IF EXISTS idx_w_uploads_hash_file_size_status;
DROP INDEX IF EXISTS idx_w_uploads_storage_driver_status;
DROP INDEX IF EXISTS idx_w_uploads_status_created_at;
@@ -0,0 +1,12 @@
-- +goose Up
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT 'total', '', COUNT(*), COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = EXCLUDED.file_count,
file_size = EXCLUDED.file_size,
updated_at = CURRENT_TIMESTAMP;
-- +goose Down
DELETE FROM w_upload_stats;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE w_uploads DROP COLUMN IF EXISTS storage_driver;
-- +goose Down
ALTER TABLE w_uploads ADD COLUMN IF NOT EXISTS storage_driver VARCHAR(50) NOT NULL DEFAULT 'local';
+7
View File
@@ -6,6 +6,7 @@ package upload
import (
"context"
"embed"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/contracts"
@@ -17,6 +18,9 @@ import (
"github.com/hibiken/asynq"
)
//go:embed migrations/*.sql
var uploadMigrations embed.FS
// Plugin implements core.Plugin to provide file upload and media serving domain services.
type Plugin struct{}
@@ -49,6 +53,9 @@ func (p *Plugin) Apply(ctx *core.Context) error {
}
loginMW := authSvc.RequireAuthMiddleware().(gin.HandlerFunc)
// 0a. Register migrations
ctx.Migrations().Register("upload", uploadMigrations)
// 1. Register File Server Routes
ctx.Router().GET("/f/:id", filesrv.ServeFileByID)
@@ -4,4 +4,4 @@ VALUES (999, 'system', '*', '系统', '', TRUE, FALSE, CURRENT_TIMESTAMP, CURREN
ON CONFLICT (username) DO NOTHING;
-- +goose Down
DELETE FROM w_users WHERE username = 'system';
DELETE FROM w_users WHERE username = 'system';
@@ -1,5 +1,5 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS schedules (
CREATE TABLE IF NOT EXISTS w_schedules (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
task_type VARCHAR(64) NOT NULL,
@@ -9,12 +9,12 @@ CREATE TABLE IF NOT EXISTS schedules (
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_schedules_is_active ON schedules (is_active);
CREATE INDEX IF NOT EXISTS idx_w_schedules_is_active ON w_schedules (is_active);
-- Seed initial cleanup task
INSERT INTO schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
VALUES (1, '清理未使用上传', 'cleanup_unused_uploads', '0 */2 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- +goose Down
DROP TABLE IF EXISTS schedules;
DROP TABLE IF EXISTS w_schedules;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE w_schedules ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (START WITH 100);
-- +goose Down
ALTER TABLE w_schedules ALTER COLUMN id DROP IDENTITY IF EXISTS;
@@ -8,4 +8,4 @@ WHERE id = 1;
UPDATE w_schedules
SET name = '清理未使用上传',
task_type = 'cleanup_unused_uploads'
WHERE id = 1;
WHERE id = 1;
@@ -6,6 +6,7 @@ package driver_asynq_cron
import (
"context"
"embed"
"encoding/json"
"fmt"
"sync"
@@ -17,6 +18,9 @@ import (
"github.com/Rain-kl/Wavelet/plugins/drivers/driver_asynq_worker"
)
//go:embed migrations/*.sql
var cronMigrations embed.FS
// Option configures the Asynq cron scheduler driver plugin.
type Option func(*Plugin)
@@ -86,6 +90,9 @@ func (p *Plugin) Apply(ctx *core.Context) error {
p.coreCtx = ctx
p.mu.Unlock()
// Register migrations for w_schedules table
ctx.Migrations().Register("driver_asynq_cron", cronMigrations)
ctx.OnDispose(func() error {
return p.Stop(context.Background())
})
@@ -0,0 +1,28 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS w_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_w_task_executions_task_type ON w_task_executions (task_type);
CREATE INDEX IF NOT EXISTS idx_w_task_executions_status ON w_task_executions (status);
CREATE INDEX IF NOT EXISTS idx_w_task_executions_started_at ON w_task_executions (started_at);
CREATE INDEX IF NOT EXISTS idx_w_task_executions_created_at ON w_task_executions (created_at);
-- +goose Down
DROP TABLE IF EXISTS w_task_executions;
@@ -6,6 +6,7 @@ package driver_asynq_worker
import (
"context"
"embed"
"errors"
"fmt"
"sync"
@@ -21,6 +22,9 @@ const (
defaultShutdownTimeout = 10 * time.Second
)
//go:embed migrations/*.sql
var workerMigrations embed.FS
// Option configures the Asynq worker driver plugin.
type Option func(*Plugin)
@@ -109,6 +113,9 @@ func (p *Plugin) Apply(ctx *core.Context) error {
p.coreCtx = ctx
p.mu.Unlock()
// Register migrations for w_task_executions table
ctx.Migrations().Register("driver_asynq_worker", workerMigrations)
ctx.OnDispose(func() error {
shutdownCtx, cancel := context.WithTimeout(context.Background(), p.shutdownTimeout)
defer cancel()