优化结构

This commit is contained in:
Yvan
2026-08-22 20:14:29 +08:00
parent 44ab14448a
commit 699bcceeea
90 changed files with 534 additions and 298 deletions
+46
View File
@@ -0,0 +1,46 @@
package task
import (
"context"
"strings"
"kra/pkg/database/migration"
platformmodule "kra/pkg/module"
"gorm.io/gorm"
)
func Migrations() []migration.Step {
return []migration.Step{{
ID: "202608220001_task_schema",
Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &TaskPO{}, &TaskLogPO{})
},
}}
}
// SeedDefaults inserts module-contributed timed tasks without making the
// system data package own task table shapes.
func SeedDefaults(ctx context.Context, db *gorm.DB, defaults []platformmodule.TimedTask) error {
if db == nil {
return nil
}
if len(defaults) == 0 {
defaults = []platformmodule.TimedTask{
{Name: "ClearDB", Description: "定时清理数据库过期日志", Spec: "@daily", MethodName: "ClearDB", Enabled: true},
{Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", MethodName: "CleanStaleUploads", Enabled: true},
}
}
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, item := range defaults {
if strings.TrimSpace(item.Name) == "" {
continue
}
row := TaskPO{Name: item.Name, Description: item.Description, Spec: item.Spec, WithSeconds: item.WithSeconds, ExecutorType: "method", MethodName: item.MethodName, Enabled: item.Enabled}
if err := tx.Where("name = ?", row.Name).FirstOrCreate(&row).Error; err != nil {
return err
}
}
return nil
})
}
+10
View File
@@ -0,0 +1,10 @@
package task
import "gorm.io/gorm"
// Provider is the minimal database seam required by the task tables.
// Task persistence must not depend on the full data runtime or system repos.
type Provider interface {
DB() *gorm.DB
DatabaseReady() bool
}
+5
View File
@@ -0,0 +1,5 @@
package task
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewTaskRepo)
+155
View File
@@ -0,0 +1,155 @@
package task
import (
"context"
taskbiz "kra/internal/biz/task"
"kra/pkg/database/gormkit"
"kra/pkg/database/pagination"
"time"
"gorm.io/gorm"
)
type TaskPO struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string `gorm:"index"`
Description, Spec string
WithSeconds bool
ExecutorType, MethodName string
Params gormkit.JSON
HTTPURL string
HTTPMethod string
HTTPHeader gormkit.JSON
HTTPBody string `gorm:"type:text"`
HTTPAllowPrivate, Enabled bool
}
func (TaskPO) TableName() string { return "sys_timed_tasks" }
type TaskLogPO struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
TaskID uint `gorm:"index"`
TaskName, TriggerType string
StartedAt, FinishedAt time.Time
DurationMS int64
Status string
ErrorMsg, Output string `gorm:"type:text"`
}
func (TaskLogPO) TableName() string { return "sys_timed_task_logs" }
type taskRepo struct{ data Provider }
func NewTaskRepo(data Provider) taskbiz.TaskRepo { return &taskRepo{data: data} }
func taskToPO(v *taskbiz.TimedTask) TaskPO {
return TaskPO{ID: v.ID, Name: v.Name, Description: v.Description, Spec: v.Spec, WithSeconds: v.WithSeconds, ExecutorType: v.ExecutorType, MethodName: v.MethodName, Params: gormkit.JSON(v.Params), HTTPURL: v.HTTPURL, HTTPMethod: v.HTTPMethod, HTTPHeader: gormkit.JSON(v.HTTPHeader), HTTPBody: v.HTTPBody, HTTPAllowPrivate: v.HTTPAllowPrivate, Enabled: v.Enabled}
}
func taskFromPO(v TaskPO) *taskbiz.TimedTask {
return &taskbiz.TimedTask{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Description: v.Description, Spec: v.Spec, WithSeconds: v.WithSeconds, ExecutorType: v.ExecutorType, MethodName: v.MethodName, Params: []byte(v.Params), HTTPURL: v.HTTPURL, HTTPMethod: v.HTTPMethod, HTTPHeader: []byte(v.HTTPHeader), HTTPBody: v.HTTPBody, HTTPAllowPrivate: v.HTTPAllowPrivate, Enabled: v.Enabled}
}
func (r *taskRepo) CreateTask(ctx context.Context, v *taskbiz.TimedTask) error {
po := taskToPO(v)
if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil {
return err
}
v.ID = po.ID
return nil
}
func (r *taskRepo) TaskNameExists(ctx context.Context, name string, excludeID uint) (bool, error) {
var count int64
db := r.data.DB().WithContext(ctx).Model(&TaskPO{}).Where("name = ?", name)
if excludeID > 0 {
db = db.Where("id <> ?", excludeID)
}
err := db.Count(&count).Error
return count > 0, err
}
func (r *taskRepo) UpdateTask(ctx context.Context, v *taskbiz.TimedTask) error {
po := taskToPO(v)
return r.data.DB().WithContext(ctx).Model(&TaskPO{}).Where("id = ?", v.ID).Select("name", "description", "spec", "with_seconds", "executor_type", "method_name", "params", "http_url", "http_method", "http_header", "http_body", "http_allow_private", "enabled").Updates(&po).Error
}
func (r *taskRepo) DeleteTask(ctx context.Context, id uint) error {
return r.data.DB().WithContext(ctx).Delete(&TaskPO{}, id).Error
}
func (r *taskRepo) FindTask(ctx context.Context, id uint) (*taskbiz.TimedTask, error) {
var po TaskPO
if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil {
return nil, err
}
return taskFromPO(po), nil
}
func (r *taskRepo) ListTasks(ctx context.Context, page, size int, q *taskbiz.TimedTask) ([]*taskbiz.TimedTask, int64, error) {
// During first-install the data layer intentionally serves a bootstrap
// database without system tables. The scheduler starts before /init/initdb
// and should remain idle instead of logging a missing-table SQL error.
if !r.data.DatabaseReady() {
return []*taskbiz.TimedTask{}, 0, nil
}
db := r.data.DB().WithContext(ctx).Model(&TaskPO{})
if q != nil {
if q.Name != "" {
db = db.Where("name LIKE ?", "%"+q.Name+"%")
}
if q.ExecutorType != "" {
db = db.Where("executor_type = ?", q.ExecutorType)
}
if q.EnabledFilter != nil {
db = db.Where("enabled = ?", *q.EnabledFilter)
}
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var pos []TaskPO
if err := pagination.Apply(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
return nil, 0, err
}
out := make([]*taskbiz.TimedTask, 0, len(pos))
for _, po := range pos {
out = append(out, taskFromPO(po))
}
return out, total, nil
}
func (r *taskRepo) ToggleTask(ctx context.Context, id uint, enabled bool) error {
return r.data.DB().WithContext(ctx).Model(&TaskPO{}).Where("id = ?", id).Update("enabled", enabled).Error
}
func (r *taskRepo) RecordTaskLog(ctx context.Context, v *taskbiz.TimedTaskLog) error {
return r.data.DB().WithContext(ctx).Create(&TaskLogPO{TaskID: v.TaskID, TaskName: v.TaskName, TriggerType: v.TriggerType, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, DurationMS: v.DurationMS, Status: v.Status, ErrorMsg: v.ErrorMsg, Output: v.Output}).Error
}
func taskLogFromPO(v TaskLogPO) *taskbiz.TimedTaskLog {
return &taskbiz.TimedTaskLog{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, TaskID: v.TaskID, TaskName: v.TaskName, TriggerType: v.TriggerType, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, DurationMS: v.DurationMS, Status: v.Status, ErrorMsg: v.ErrorMsg, Output: v.Output}
}
func (r *taskRepo) ListTaskLogs(ctx context.Context, page, size int, taskID uint, status string) ([]*taskbiz.TimedTaskLog, int64, error) {
db := r.data.DB().WithContext(ctx).Model(&TaskLogPO{})
if taskID != 0 {
db = db.Where("task_id = ?", taskID)
}
if status != "" {
db = db.Where("status = ?", status)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var pos []TaskLogPO
if err := pagination.Apply(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
return nil, 0, err
}
out := make([]*taskbiz.TimedTaskLog, 0, len(pos))
for _, po := range pos {
out = append(out, taskLogFromPO(po))
}
return out, total, nil
}
func (r *taskRepo) CleanupLogs(ctx context.Context) error {
return r.data.DB().WithContext(ctx).Unscoped().
Where("created_at < ?", time.Now().Add(-720*time.Hour)).
Delete(&TaskLogPO{}).Error
}
+17
View File
@@ -0,0 +1,17 @@
package task
import (
"context"
"testing"
)
func TestTaskRepoListIsEmptyBeforeDatabaseInitialization(t *testing.T) {
repo := NewTaskRepo(&Data{})
items, total, err := repo.ListTasks(context.Background(), 0, 0, nil)
if err != nil {
t.Fatal(err)
}
if total != 0 || len(items) != 0 {
t.Fatalf("bootstrap tasks = (%d, %d), want empty", total, len(items))
}
}
@@ -0,0 +1,42 @@
package task
import (
"context"
"fmt"
"sync/atomic"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type Data struct {
gormDB *reloadableDB
databaseReady atomic.Bool
}
func (d *Data) DB() *gorm.DB {
if d == nil || d.gormDB == nil {
return nil
}
return d.gormDB.DB()
}
func (d *Data) DatabaseReady() bool { return d != nil && d.databaseReady.Load() }
type reloadableDB struct{ db *gorm.DB }
func newReloadableDB(db *gorm.DB) *reloadableDB { return &reloadableDB{db: db} }
func (r *reloadableDB) DB() *gorm.DB {
if r == nil {
return nil
}
return r.db
}
func (r *reloadableDB) WithContext(ctx context.Context) *gorm.DB { return r.db.WithContext(ctx) }
func openWithDriver(driver, dsn string) (*gorm.DB, error) {
if driver != "sqlite" {
return nil, fmt.Errorf("unsupported test database driver %q", driver)
}
return gorm.Open(sqlite.Open(dsn), &gorm.Config{})
}