refactor(layout): consolidate backend codebase into backend/ package and clean root directory

- Moved cmd/, core/, plugins/, pkg/, downstream/, and main.go into backend/ directory
- Batch updated all Go source files to import github.com/Rain-kl/Wavelet/backend/...
- Updated Makefile, scripts/swagger.sh, architecture guards, and platform skills
- Passed all quality gates (100% tests, 0 lint issues, clean build)
This commit is contained in:
ryan
2026-08-28 12:56:02 +08:00
parent 33b38f8687
commit 43dc97e48c
319 changed files with 912 additions and 1031 deletions
@@ -0,0 +1,9 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package driver_asynq_cron provides Asynq cron schedule driver plugin for Cordis.
package driver_asynq_cron
const (
errLoadLocationFailed = "failed to load location: %w"
)
@@ -0,0 +1,20 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS w_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 TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_schedules_is_active ON w_schedules (is_active);
-- Seed initial cleanup task
INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
VALUES (1, '系统定期垃圾清理', 'system_cleanup', '0 3 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- +goose Down
DROP TABLE IF EXISTS w_schedules;
@@ -0,0 +1,228 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package driver_asynq_cron provides the Asynq cron schedule driver plugin for Cordis.
package driver_asynq_cron
import (
"context"
"embed"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/hibiken/asynq"
"github.com/Rain-kl/Wavelet/backend/core"
"github.com/Rain-kl/Wavelet/backend/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)
// WithRedisOpt sets the Redis connection options for Asynq scheduler.
func WithRedisOpt(opt asynq.RedisConnOpt) Option {
return func(p *Plugin) {
p.redisOpt = opt
}
}
// WithLocation sets the timezone location for the cron scheduler.
func WithLocation(loc *time.Location) Option {
return func(p *Plugin) {
p.location = loc
}
}
// WithSchedulerOpts sets custom Asynq SchedulerOpts.
func WithSchedulerOpts(opts *asynq.SchedulerOpts) Option {
return func(p *Plugin) {
p.schedulerOpts = opts
}
}
// WithScheduler injects a pre-configured Asynq Scheduler instance.
func WithScheduler(sched *asynq.Scheduler) Option {
return func(p *Plugin) {
p.scheduler = sched
}
}
// Plugin implements core.Plugin and core.Driver for Asynq Cron Scheduler.
type Plugin struct {
mu sync.RWMutex
redisOpt asynq.RedisConnOpt
location *time.Location
schedulerOpts *asynq.SchedulerOpts
scheduler *asynq.Scheduler
running bool
coreCtx *core.Context
}
// New creates a new Asynq Cron Scheduler driver plugin.
func New(opts ...Option) *Plugin {
p := &Plugin{
redisOpt: driver_asynq_worker.RedisOpt,
location: time.Local,
}
for _, opt := range opts {
if opt != nil {
opt(p)
}
}
return p
}
// Name returns the unique plugin identifier.
func (p *Plugin) Name() string {
return "driver_asynq_cron"
}
// Apply mounts the Asynq Cron Scheduler driver into the micro-kernel Context.
func (p *Plugin) Apply(ctx *core.Context) error {
p.mu.Lock()
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())
})
return ctx.RegisterDriver(p)
}
// Type returns DriverTypeScheduler.
func (p *Plugin) Type() core.DriverType {
return core.DriverTypeScheduler
}
// Start registers scheduled cron entries from Context and boots the Asynq scheduler.
func (p *Plugin) Start(_ context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.running {
return nil
}
if p.scheduler == nil {
opts := p.schedulerOpts
if opts == nil {
opts = &asynq.SchedulerOpts{
Location: p.location,
}
} else if opts.Location == nil && p.location != nil {
opts.Location = p.location
}
p.scheduler = asynq.NewScheduler(p.redisOpt, opts)
}
if p.coreCtx != nil && p.coreCtx.Schedules() != nil {
for _, sd := range p.coreCtx.Schedules().Schedules() {
payloadBytes, err := encodePayload(sd.Payload)
if err != nil {
return fmt.Errorf("driver_asynq_cron: encode payload for task %q failed: %w", sd.TaskType, err)
}
task := asynq.NewTask(sd.TaskType, payloadBytes)
asynqOpts := buildAsynqOptions(sd.Options)
if _, err := p.scheduler.Register(sd.Spec, task, asynqOpts...); err != nil {
return fmt.Errorf("driver_asynq_cron: register schedule %q for task %q failed: %w", sd.Spec, sd.TaskType, err)
}
}
}
if err := p.scheduler.Start(); err != nil {
return fmt.Errorf("driver_asynq_cron: start scheduler failed: %w", err)
}
p.running = true
return nil
}
// Stop gracefully shuts down the Asynq cron scheduler.
func (p *Plugin) Stop(_ context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
if !p.running {
return nil
}
p.running = false
if p.scheduler != nil {
p.scheduler.Shutdown()
p.scheduler = nil
}
return nil
}
// IsRunning returns whether the scheduler is running.
func (p *Plugin) IsRunning() bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.running
}
// Scheduler returns the underlying Asynq scheduler instance.
func (p *Plugin) Scheduler() *asynq.Scheduler {
p.mu.RLock()
defer p.mu.RUnlock()
return p.scheduler
}
func encodePayload(payload any) ([]byte, error) {
if payload == nil {
return nil, nil
}
switch val := payload.(type) {
case []byte:
return val, nil
case string:
return []byte(val), nil
default:
return json.Marshal(val)
}
}
func buildAsynqOptions(opts map[string]any) []asynq.Option {
if len(opts) == 0 {
return nil
}
var res []asynq.Option
if q, ok := opts["queue"].(string); ok && q != "" {
res = append(res, asynq.Queue(q))
}
if retry, ok := opts["retry"].(int); ok {
res = append(res, asynq.MaxRetry(retry))
} else if maxRetry, ok := opts["max_retry"].(int); ok {
res = append(res, asynq.MaxRetry(maxRetry))
}
if timeout, ok := opts["timeout"].(time.Duration); ok {
res = append(res, asynq.Timeout(timeout))
}
if retention, ok := opts["retention"].(time.Duration); ok {
res = append(res, asynq.Retention(retention))
}
return res
}
@@ -0,0 +1,37 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package driver_asynq_cron
import (
"context"
"time"
db "github.com/Rain-kl/Wavelet/backend/plugins/infra/database"
)
// Schedule 定时任务配置表
type Schedule struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:128;not null"`
TaskType string `json:"task_type" gorm:"size:64;not null"`
Cron string `json:"cron" gorm:"size:64;not null"`
Payload string `json:"payload" gorm:"type:text"`
IsActive bool `json:"is_active" gorm:"not null;default:true"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名
func (Schedule) TableName() string {
return "w_schedules"
}
// ListActiveSchedules 查询所有已启用的定时任务配置
func ListActiveSchedules(ctx context.Context) ([]Schedule, error) {
var schedules []Schedule
if err := db.DB(ctx).Where("is_active = ?", true).Find(&schedules).Error; err != nil {
return nil, err
}
return schedules, nil
}
@@ -0,0 +1,140 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package driver_asynq_cron
import (
"context"
"fmt"
"os/signal"
"sync"
"syscall"
"time"
"github.com/hibiken/asynq"
"github.com/Rain-kl/Wavelet/backend/pkg/logger"
"github.com/Rain-kl/Wavelet/backend/plugins/drivers/driver_asynq_worker"
)
var (
activeScheduler *asynq.Scheduler
schedulerMutex sync.Mutex
quitChan chan struct{}
schedulerOnce sync.Once
)
// GetAsynqClient 获取全局 AsynqClient
func GetAsynqClient() *asynq.Client {
return driver_asynq_worker.AsynqClient
}
// StartScheduler 启动调度器 (该函数阻塞,直到调度器退出)
func StartScheduler() error {
var err error
schedulerOnce.Do(func() {
quitChan = make(chan struct{})
done := quitChan
// 初始化并运行首次调度
if err = ReloadScheduler(); err != nil {
err = fmt.Errorf("initial reload failed: %w", err)
return
}
signalCtx, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stopSignals()
if waitForStop(done, signalCtx.Done()) {
StopScheduler()
}
})
return err
}
// StopScheduler 停止调度服务并解除 StartScheduler 阻塞
func StopScheduler() {
schedulerMutex.Lock()
defer schedulerMutex.Unlock()
if activeScheduler != nil {
activeScheduler.Shutdown()
activeScheduler = nil
}
if quitChan != nil {
close(quitChan)
quitChan = nil
}
}
// ReloadScheduler 重载调度器配置 (线程安全)
func ReloadScheduler() error {
schedulerMutex.Lock()
defer schedulerMutex.Unlock()
// 1. 如果有运行中的调度器,先关闭它
if activeScheduler != nil {
activeScheduler.Shutdown()
activeScheduler = nil
}
// 2. 从数据库载入启用的定时任务配置
schedules, err := ListActiveSchedules(context.Background())
if err != nil {
return fmt.Errorf("load schedules from db failed: %w", err)
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return fmt.Errorf(errLoadLocationFailed, err)
}
// 3. 实例化新的调度器
newScheduler := asynq.NewScheduler(
driver_asynq_worker.RedisOpt,
&asynq.SchedulerOpts{
Location: location,
},
)
// 4. 遍历并注册任务
for _, s := range schedules {
meta := driver_asynq_worker.GetTaskMeta(s.TaskType)
if meta == nil {
continue // 忽略排程配置中无效的任务类型
}
// 构造 Asynq 载荷。定时任务使用对应 Meta 中的 Asynq 标识,同时将数据库中保存的 json 作为参数
t := asynq.NewTask(meta.AsynqTask, []byte(s.Payload))
if _, err := newScheduler.Register(
s.Cron,
t,
asynq.MaxRetry(meta.MaxRetry),
asynq.Queue(meta.Queue),
); err != nil {
// 定时任务配置可能有误(如 Cron 格式不被 Asynq 识别),记录日志并跳过
logger.ErrorF(context.Background(), "[Scheduler] 注册定时任务失败 id=%d name=%s: %v", s.ID, s.Name, err)
continue
}
}
// 5. 启动并替换全局调度器。进程信号由 StartScheduler 统一处理。
if err := newScheduler.Start(); err != nil {
return fmt.Errorf("start scheduler failed: %w", err)
}
activeScheduler = newScheduler
logger.InfoF(context.Background(), "[Scheduler] 成功重新加载定时任务,共注册 %d 个活动任务", len(schedules))
return nil
}
func waitForStop(done <-chan struct{}, signals <-chan struct{}) bool {
select {
case <-done:
return false
case <-signals:
return true
}
}
@@ -0,0 +1,42 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package driver_asynq_cron
import "testing"
func TestWaitForStop(t *testing.T) {
tests := []struct {
name string
closeDone bool
closeSignal bool
wantSignal bool
}{
{
name: "explicit stop",
closeDone: true,
},
{
name: "process signal",
closeSignal: true,
wantSignal: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
done := make(chan struct{})
signals := make(chan struct{})
if tt.closeDone {
close(done)
}
if tt.closeSignal {
close(signals)
}
if got := waitForStop(done, signals); got != tt.wantSignal {
t.Errorf("waitForStop() = %t, want %t", got, tt.wantSignal)
}
})
}
}