mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user