优化结构

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

26
cmd/wire_gen.go generated
View File

@ -9,16 +9,18 @@ package main
import ( import (
"github.com/go-kratos/kratos/v3" "github.com/go-kratos/kratos/v3"
"kra/internal/app" "kra/internal/app"
integration2 "kra/internal/biz/integration" integration3 "kra/internal/biz/integration"
payment2 "kra/internal/biz/payment" payment2 "kra/internal/biz/payment"
system2 "kra/internal/biz/system" system2 "kra/internal/biz/system"
"kra/internal/biz/task" task2 "kra/internal/biz/task"
"kra/internal/conf" "kra/internal/conf"
"kra/internal/data" "kra/internal/data"
"kra/internal/data/integration"
"kra/internal/data/payment" "kra/internal/data/payment"
"kra/internal/data/repository" "kra/internal/data/system"
"kra/internal/data/task"
"kra/internal/initialize" "kra/internal/initialize"
"kra/internal/integration" integration2 "kra/internal/integration"
"kra/internal/integration/cache" "kra/internal/integration/cache"
"kra/internal/integration/email" "kra/internal/integration/email"
"kra/internal/integration/mq" "kra/internal/integration/mq"
@ -103,15 +105,15 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
paymentUsecase := payment2.NewPaymentUsecase(paymentRepo, paymentOrderRepo, logger) paymentUsecase := payment2.NewPaymentUsecase(paymentRepo, paymentOrderRepo, logger)
paymentService := service.NewPaymentService(paymentUsecase) paymentService := service.NewPaymentService(paymentUsecase)
handlerPayment := handler.NewPayment(paymentService) handlerPayment := handler.NewPayment(paymentService)
taskRepo := system.NewTaskRepo(dataData) taskRepo := task.NewTaskRepo(dataData)
registry := app.TaskRegistry(catalog) registry := app.TaskRegistry(catalog)
taskUsecase := task.NewTaskUsecaseWithRegistry(taskRepo, registry) taskUsecase := task2.NewTaskUsecaseWithRegistry(taskRepo, registry)
mediaRepo := system.NewMediaRepo(dataData) mediaRepo := system.NewMediaRepo(dataData)
mediaUsecase := system2.NewMediaUsecase(mediaRepo, reloadable, runtimeSettings) mediaUsecase := system2.NewMediaUsecase(mediaRepo, reloadable, runtimeSettings)
taskExecutor := worker.NewTaskExecutorWithRegistry(taskUsecase, mediaUsecase, runtime, registry) taskExecutor := worker.NewTaskExecutorWithRegistry(taskUsecase, mediaUsecase, runtime, registry)
taskScheduler := worker.NewTaskScheduler(taskUsecase, authorityUsecase, taskExecutor, logger) taskScheduler := worker.NewTaskScheduler(taskUsecase, authorityUsecase, taskExecutor, logger)
taskRuntime := worker.NewTaskRuntime(taskScheduler) taskRuntime := worker.NewTaskRuntime(taskScheduler)
taskApplicationUsecase := task.NewTaskApplicationUsecase(taskUsecase, taskRuntime) taskApplicationUsecase := task2.NewTaskApplicationUsecase(taskUsecase, taskRuntime)
taskService := service.NewTaskService(taskApplicationUsecase) taskService := service.NewTaskService(taskApplicationUsecase)
handlerTask := handler.NewTask(taskService) handlerTask := handler.NewTask(taskService)
mediaService := service.NewMediaService(mediaUsecase, runtimeSettings) mediaService := service.NewMediaService(mediaUsecase, runtimeSettings)
@ -151,15 +153,17 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
user := handler.NewUser(userService, authService) user := handler.NewUser(userService, authService)
navigation := handler.NewNavigation(userService) navigation := handler.NewNavigation(userService)
session := handler.NewSession(tokenService) session := handler.NewSession(tokenService)
integrationConfigRepo := system.NewIntegrationConfigRepo(dataData) integrationConfigRepo := integration.NewIntegrationConfigRepo(dataData)
store := data.NewIntegrationRuntime(dataData) store := data.NewIntegrationRuntime(dataData)
connectivityTester := integration.NewConnectivityTester(store) connectivityTester := integration2.NewConnectivityTester(store)
integrationConfigUsecase := integration2.NewIntegrationConfigUsecase(integrationConfigRepo, connectivityTester) integrationConfigUsecase := integration3.NewIntegrationConfigUsecase(integrationConfigRepo, connectivityTester)
integrationConfigService := service.NewIntegrationConfigService(integrationConfigUsecase) integrationConfigService := service.NewIntegrationConfigService(integrationConfigUsecase)
integrationConfig := handler.NewIntegrationConfig(integrationConfigService) integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
v := handler.NewSet(authority, menu, api, permission, organization, announcement, handlerEmail, handlerPayment, handlerTask, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session, integrationConfig) v := handler.NewSet(authority, menu, api, permission, organization, announcement, handlerEmail, handlerPayment, handlerTask, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session, integrationConfig)
routes := router.NewRoutes(v) routes := router.NewRoutes(v)
taskMethods := worker.NewTaskMethods(taskUsecase, mediaUsecase, runtime) maintenanceRepo := system.NewMaintenanceRepo(dataData)
maintenanceUsecase := system2.NewMaintenanceUsecase(maintenanceRepo)
taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, runtime)
appRuntimeContributions := runtimeContributions(routes, taskMethods) appRuntimeContributions := runtimeContributions(routes, taskMethods)
moduleRuntime := app.Runtime(appRuntimeContributions, registry) moduleRuntime := app.Runtime(appRuntimeContributions, registry)
websocketServer, cleanup2, err := websocket.New(store) websocketServer, cleanup2, err := websocket.New(store)

View File

@ -85,7 +85,7 @@ web/src/modules/<module>/
components/ components/
``` ```
修改 KRA 既有系统表和系统行为的内容仍放在现有 `internal/biz`、`internal/data/repository`、`internal/service`、`internal/server` 中,避免创建第二套用户、角色、部门和权限系统。 修改 KRA 既有系统表和系统行为的内容仍放在现有 `internal/biz/system`、`internal/data/system`、`internal/service`、`internal/server` 中,避免创建第二套用户、角色、部门和权限系统。
每个业务模块通过 KRA 现有的 `pkg/module` 机制贡献: 每个业务模块通过 KRA 现有的 `pkg/module` 机制贡献:

View File

@ -4,8 +4,10 @@
package app package app
import ( import (
integrationmodule "kra/internal/modules/integration"
paymentmodule "kra/internal/modules/payment" paymentmodule "kra/internal/modules/payment"
systemmodule "kra/internal/modules/system" systemmodule "kra/internal/modules/system"
taskmodule "kra/internal/modules/task"
"kra/pkg/module" "kra/pkg/module"
platformtask "kra/pkg/task" platformtask "kra/pkg/task"
) )
@ -17,6 +19,8 @@ import (
func Catalog() module.Catalog { func Catalog() module.Catalog {
return module.Catalog{Definitions: []module.Definition{ return module.Catalog{Definitions: []module.Definition{
systemmodule.Definition(), systemmodule.Definition(),
integrationmodule.Definition(),
taskmodule.Definition(),
paymentmodule.Definition(), paymentmodule.Definition(),
}} }}
} }

View File

@ -10,16 +10,28 @@ import (
platformtask "kra/pkg/task" platformtask "kra/pkg/task"
) )
func TestCatalogMigrationIDsAreUnique(t *testing.T) {
seen := map[string]string{}
for _, definition := range Catalog().Definitions {
for _, step := range definition.Migrations {
if previous, exists := seen[step.ID]; exists {
t.Fatalf("migration %q is declared by both %s and %s", step.ID, previous, definition.Name)
}
seen[step.ID] = definition.Name
}
}
}
func TestCatalogIncludesSystemDefinition(t *testing.T) { func TestCatalogIncludesSystemDefinition(t *testing.T) {
catalog := Catalog() catalog := Catalog()
if len(catalog.Definitions) != 2 { if len(catalog.Definitions) != 4 {
t.Fatalf("definitions = %d, want 2", len(catalog.Definitions)) t.Fatalf("definitions = %d, want 4", len(catalog.Definitions))
} }
if catalog.Definitions[0].Name != "system" || catalog.Definitions[1].Name != "payment" { if catalog.Definitions[0].Name != "system" || catalog.Definitions[1].Name != "integration" || catalog.Definitions[2].Name != "task" || catalog.Definitions[3].Name != "payment" {
t.Fatalf("definition order = [%q, %q], want [system, payment]", catalog.Definitions[0].Name, catalog.Definitions[1].Name) t.Fatalf("definition order = [%q, %q, %q, %q], want [system, integration, task, payment]", catalog.Definitions[0].Name, catalog.Definitions[1].Name, catalog.Definitions[2].Name, catalog.Definitions[3].Name)
} }
if got := catalog.MigrationSteps(); len(got) != 5 { if got := catalog.MigrationSteps(); len(got) != 8 {
t.Fatalf("module migrations = %d, want 5", len(got)) t.Fatalf("module migrations = %d, want 8", len(got))
} }
if surface := catalog.Surface(); len(surface.Menus) != 3 || len(surface.APIs) != 15 { if surface := catalog.Surface(); len(surface.Menus) != 3 || len(surface.APIs) != 15 {
t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", len(surface.Menus), len(surface.APIs)) t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", len(surface.Menus), len(surface.APIs))

View File

@ -0,0 +1,19 @@
package system
import "context"
// MaintenanceRepo owns retention work for system-owned tables. Other data
// modules clean their own tables and worker composes the operations.
type MaintenanceRepo interface {
CleanupExpired(context.Context) error
}
type MaintenanceUsecase struct{ repo MaintenanceRepo }
func NewMaintenanceUsecase(repo MaintenanceRepo) *MaintenanceUsecase {
return &MaintenanceUsecase{repo: repo}
}
func (uc *MaintenanceUsecase) CleanupExpired(ctx context.Context) error {
return uc.repo.CleanupExpired(ctx)
}

View File

@ -27,4 +27,5 @@ var ProviderSet = wire.NewSet(
NewMediaUsecase, NewMediaUsecase,
NewAnnouncementUsecase, NewAnnouncementUsecase,
NewEmailUsecase, NewEmailUsecase,
NewMaintenanceUsecase,
) )

View File

@ -3,12 +3,29 @@
`data` owns database clients, persistence models, migrations, configuration `data` owns database clients, persistence models, migrations, configuration
watching, and repository implementations. watching, and repository implementations.
- `repository/`: system repositories and table persistence - `system/`: system repositories and system table persistence
- `payment/`: payment configuration and payment-order persistence - `task/`: timed-task tables and task persistence
- `integration/`: `sys_integration_configs` and integration configuration persistence
- `payment/`: payment-order persistence
- each subpackage owns its Wire `ProviderSet`; the root package only binds the - each subpackage owns its Wire `ProviderSet`; the root package only binds the
shared `Data` infrastructure and aggregates those sets shared `Data` infrastructure and aggregates those sets, mirroring `biz`
- root files: shared database lifecycle, runtime clients, integration-config - root files: shared database lifecycle, runtime clients, configuration
storage, data-scope auditing, and migration orchestration orchestration, data-scope auditing, and migration orchestration
新增数据模块时创建 `internal/data/<module>`,提供 `ProviderSet()`
`Migrations()`,再在 `internal/modules/<module>/definition.go` 注册迁移与
管理面,最后在根 `data.ProviderSet` 中注册该模块。根 `data` 不应直接
拥有业务表 PO也不应让一个模块引用另一个模块的私有 PO。
Root files intentionally stay in one package because they share `Data` state and Root files intentionally stay in one package because they share `Data` state and
reload locks. Do not split them into packages only to reduce file count. reload locks. Do not split them into packages only to reduce file count.
当前内置数据模块为 `system`、`integration`、`task` 和 `payment`。其中:
- `system` 只拥有 `sys_*` 系统表、系统仓储、种子和系统维护清理;
- `integration` 唯一拥有 `sys_integration_configs` 表模型、配置仓储和通信默认值;
- `task` 只拥有定时任务与任务日志表;
- `payment` 只拥有 `pay_orders` 等支付持久化,读取集成配置时复用 integration 的表模型。
配置文件迁移、数据库切换、Redis/Mongo/对象存储重载仍属于根 data 的生命周期编排,
不等同于某个业务模块的表仓储。

View File

@ -12,8 +12,10 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
"kra/internal/conf" "kra/internal/conf"
dataintegration "kra/internal/data/integration"
datapayment "kra/internal/data/payment" datapayment "kra/internal/data/payment"
datasystem "kra/internal/data/repository" datasystem "kra/internal/data/system"
datatask "kra/internal/data/task"
"kra/internal/integration/runtimeconfig" "kra/internal/integration/runtimeconfig"
"kra/internal/integration/storage" "kra/internal/integration/storage"
"kra/pkg/module" "kra/pkg/module"
@ -24,8 +26,12 @@ var ProviderSet = wire.NewSet(
NewIntegrationRuntime, NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)), wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)), wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
wire.Bind(new(dataintegration.Provider), new(*Data)),
wire.Bind(new(datatask.Provider), new(*Data)),
wire.Bind(new(datapayment.Provider), new(*Data)), wire.Bind(new(datapayment.Provider), new(*Data)),
datasystem.ProviderSet, datasystem.ProviderSet,
dataintegration.ProviderSet,
datatask.ProviderSet,
datapayment.ProviderSet, datapayment.ProviderSet,
) )
@ -232,7 +238,7 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor
if storageManager != nil { if storageManager != nil {
storageManager.Replace(activeStorage) storageManager.Replace(activeStorage)
} }
if db.Migrator().HasTable(&integrationConfigPO{}) { if db.Migrator().HasTable("sys_integration_configs") {
if removeErr := d.removeIntegrationConfigFromFile(); removeErr != nil { if removeErr := d.removeIntegrationConfigFromFile(); removeErr != nil {
appLogger.Warn("remove legacy integration configuration from file", "mod", "integration", "error", removeErr) appLogger.Warn("remove legacy integration configuration from file", "mod", "integration", "error", removeErr)
} }

View File

@ -1,22 +1,9 @@
package data package data
import ( import (
"time" datasystem "kra/internal/data/system"
"gorm.io/gorm"
) )
// dataAccessLogPO is the infrastructure-side write model used by GORM // dataAccessLogPO is the infrastructure-side write model used by GORM
// callbacks. The system module owns the query repository for the same table. // callbacks. The system module owns the query repository for the same table.
type dataAccessLogPO struct { type dataAccessLogPO = datasystem.DataAccessLogPO
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
EventType, TargetTable, Operation string
UserID, AuthorityID uint
Scope int
RequestID, Method, Path, Detail string
}
func (dataAccessLogPO) TableName() string { return "sys_data_access_logs" }

View File

@ -1,4 +1,4 @@
package system package integration
import ( import (
"context" "context"
@ -13,7 +13,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type integrationConfigPO struct { type ConfigPO struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
@ -23,7 +23,7 @@ type integrationConfigPO struct {
Config string `gorm:"type:text;not null"` Config string `gorm:"type:text;not null"`
} }
func (integrationConfigPO) TableName() string { return "sys_integration_configs" } func (ConfigPO) TableName() string { return "sys_integration_configs" }
type integrationConfigRepo struct{ data Provider } type integrationConfigRepo struct{ data Provider }
@ -36,7 +36,7 @@ func NewIntegrationConfigRepo(data Provider) integrationbiz.IntegrationConfigRep
} }
func (r *integrationConfigRepo) ListIntegrationConfigs(ctx context.Context, kind string) ([]*integrationbiz.IntegrationConfig, error) { func (r *integrationConfigRepo) ListIntegrationConfigs(ctx context.Context, kind string) ([]*integrationbiz.IntegrationConfig, error) {
var rows []integrationConfigPO var rows []ConfigPO
if err := r.data.DB().WithContext(ctx).Where("kind = ?", kind).Order("provider ASC").Find(&rows).Error; err != nil { if err := r.data.DB().WithContext(ctx).Where("kind = ?", kind).Order("provider ASC").Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
@ -48,7 +48,7 @@ func (r *integrationConfigRepo) ListIntegrationConfigs(ctx context.Context, kind
} }
func (r *integrationConfigRepo) FindIntegrationConfig(ctx context.Context, kind, provider string) (*integrationbiz.IntegrationConfig, error) { func (r *integrationConfigRepo) FindIntegrationConfig(ctx context.Context, kind, provider string) (*integrationbiz.IntegrationConfig, error) {
var row integrationConfigPO var row ConfigPO
if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).First(&row).Error; err != nil { if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("集成配置不存在") return nil, errors.New("集成配置不存在")
@ -60,7 +60,7 @@ func (r *integrationConfigRepo) FindIntegrationConfig(ctx context.Context, kind,
func (r *integrationConfigRepo) SaveIntegrationConfig(ctx context.Context, config *integrationbiz.IntegrationConfig) error { func (r *integrationConfigRepo) SaveIntegrationConfig(ctx context.Context, config *integrationbiz.IntegrationConfig) error {
db := r.data.DB().WithContext(ctx) db := r.data.DB().WithContext(ctx)
var row integrationConfigPO var row ConfigPO
err := db.Where("kind = ? AND provider = ?", config.Kind, config.Provider).First(&row).Error err := db.Where("kind = ? AND provider = ?", config.Kind, config.Provider).First(&row).Error
values := integrationObject(config.Values) values := integrationObject(config.Values)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@ -70,7 +70,7 @@ func (r *integrationConfigRepo) SaveIntegrationConfig(ctx context.Context, confi
} }
} }
encoded, _ := json.Marshal(values) encoded, _ := json.Marshal(values)
if err := db.Create(&integrationConfigPO{Kind: config.Kind, Provider: config.Provider, Enabled: config.Enabled, Config: string(encoded)}).Error; err != nil { if err := db.Create(&ConfigPO{Kind: config.Kind, Provider: config.Provider, Enabled: config.Enabled, Config: string(encoded)}).Error; err != nil {
return err return err
} }
r.publish(config.Kind, config.Provider, config.Enabled, encoded) r.publish(config.Kind, config.Provider, config.Enabled, encoded)
@ -94,7 +94,7 @@ func (r *integrationConfigRepo) SaveIntegrationConfig(ctx context.Context, confi
} }
func (r *integrationConfigRepo) DeleteIntegrationConfig(ctx context.Context, kind, provider string) error { func (r *integrationConfigRepo) DeleteIntegrationConfig(ctx context.Context, kind, provider string) error {
if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).Delete(&integrationConfigPO{}).Error; err != nil { if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).Delete(&ConfigPO{}).Error; err != nil {
return err return err
} }
if runtime := integrationRuntime(r.data); runtime != nil { if runtime := integrationRuntime(r.data); runtime != nil {
@ -116,7 +116,7 @@ func integrationRuntime(provider Provider) *runtimeconfig.Store {
return nil return nil
} }
func integrationConfigFromPO(row integrationConfigPO) *integrationbiz.IntegrationConfig { func integrationConfigFromPO(row ConfigPO) *integrationbiz.IntegrationConfig {
values := integrationObject(json.RawMessage(row.Config)) values := integrationObject(json.RawMessage(row.Config))
maskIntegrationSecrets(row.Kind, row.Provider, values) maskIntegrationSecrets(row.Kind, row.Provider, values)
encoded, _ := json.Marshal(values) encoded, _ := json.Marshal(values)

View File

@ -1,4 +1,4 @@
package system package integration
import ( import (
"context" "context"
@ -21,7 +21,7 @@ func TestIntegrationConfigSavePublishesUnmaskedRuntimeValues(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err = db.AutoMigrate(&integrationConfigPO{}); err != nil { if err = db.AutoMigrate(&ConfigPO{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
provider := &integrationRuntimeTestProvider{Data: &Data{gormDB: newReloadableDB(db, nil)}, store: runtimeconfig.NewStore()} provider := &integrationRuntimeTestProvider{Data: &Data{gormDB: newReloadableDB(db, nil)}, store: runtimeconfig.NewStore()}

View File

@ -0,0 +1,45 @@
package integration
import (
"encoding/json"
integrationbiz "kra/internal/biz/integration"
"kra/pkg/database/migration"
"gorm.io/gorm"
)
func Migrations() []migration.Step {
return []migration.Step{
{ID: "202608200001_data_infrastructure", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &ConfigPO{})
}},
{ID: "202608210001_communication_integration_defaults", Migrate: ensureCommunicationIntegrationConfigs},
}
}
func ensureCommunicationIntegrationConfigs(db *gorm.DB) error {
defaults := []struct{ kind, provider string }{
{integrationbiz.IntegrationKindMQ, "emqx"},
{integrationbiz.IntegrationKindMQ, "rabbitmq"},
{integrationbiz.IntegrationKindWebSocket, "melody"},
}
for _, item := range defaults {
var row ConfigPO
err := db.Where("kind = ? AND provider = ?", item.kind, item.provider).First(&row).Error
if gorm.ErrRecordNotFound == err {
values, marshalErr := json.Marshal(integrationbiz.DefaultIntegrationConfig(item.kind, item.provider))
if marshalErr != nil {
return marshalErr
}
if err = db.Create(&ConfigPO{Kind: item.kind, Provider: item.provider, Enabled: false, Config: string(values)}).Error; err != nil {
return err
}
continue
}
if err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,12 @@
package integration
import (
"gorm.io/gorm"
"kra/internal/integration/runtimeconfig"
)
// Provider is the minimal database/runtime seam for integration configuration.
type Provider interface {
DB() *gorm.DB
IntegrationRuntime() *runtimeconfig.Store
}

View File

@ -0,0 +1,5 @@
package integration
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewIntegrationConfigRepo)

View File

@ -0,0 +1,29 @@
package integration
import (
"errors"
"kra/internal/integration/runtimeconfig"
"gorm.io/gorm"
)
// ReadRuntime returns the communication integration snapshot used by the
// long-lived MQ/WebSocket adapters.
func ReadRuntime(db *gorm.DB) ([]runtimeconfig.Config, error) {
if db == nil || !db.Migrator().HasTable(&ConfigPO{}) {
return nil, nil
}
var rows []ConfigPO
if err := db.Session(&gorm.Session{NewDB: true}).
Where("kind IN ?", []string{"mq", "websocket"}).
Order("kind ASC, provider ASC").
Find(&rows).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
configs := make([]runtimeconfig.Config, 0, len(rows))
for _, row := range rows {
configs = append(configs, runtimeconfig.Config{Kind: row.Kind, Provider: row.Provider, Enabled: row.Enabled, Values: []byte(row.Config)})
}
return configs, nil
}

View File

@ -0,0 +1,42 @@
package integration
import (
"context"
"fmt"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"kra/internal/integration/runtimeconfig"
)
type Data struct {
gormDB *reloadableDB
store *runtimeconfig.Store
}
func (d *Data) DB() *gorm.DB {
if d == nil || d.gormDB == nil {
return nil
}
return d.gormDB.DB()
}
func (d *Data) IntegrationRuntime() *runtimeconfig.Store { return d.store }
type reloadableDB struct{ db *gorm.DB }
func newReloadableDB(db *gorm.DB, _ ...any) *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{})
}

View File

@ -6,9 +6,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"kra/internal/conf" "kra/internal/conf"
dataintegration "kra/internal/data/integration"
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@ -21,21 +21,6 @@ const (
integrationKindPayment = "payment" integrationKindPayment = "payment"
) )
// integrationConfigPO stores credentials and provider-specific options for
// external services. Payment integrations use the same table with kind
// "payment", keeping secrets out of the bootstrap configuration file.
type integrationConfigPO struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
Kind string `gorm:"size:32;not null;uniqueIndex:idx_integration_kind_provider"`
Provider string `gorm:"size:64;not null;uniqueIndex:idx_integration_kind_provider"`
Enabled bool `gorm:"not null;default:false;index"`
Config string `gorm:"type:text;not null"`
}
func (integrationConfigPO) TableName() string { return "sys_integration_configs" }
var storageProviderNames = []string{ var storageProviderNames = []string{
"local", "local",
"qiniu", "qiniu",
@ -140,11 +125,11 @@ func saveStorageIntegrationConfig(db *gorm.DB, storage *conf.AdminBackend_Storag
if err != nil { if err != nil {
return fmt.Errorf("encode %s integration configuration: %w", provider, err) return fmt.Errorf("encode %s integration configuration: %w", provider, err)
} }
var current integrationConfigPO var current dataintegration.ConfigPO
err = tx.Where("kind = ? AND provider = ?", integrationKindStorage, provider).First(&current).Error err = tx.Where("kind = ? AND provider = ?", integrationKindStorage, provider).First(&current).Error
switch { switch {
case errors.Is(err, gorm.ErrRecordNotFound): case errors.Is(err, gorm.ErrRecordNotFound):
current = integrationConfigPO{Kind: integrationKindStorage, Provider: provider} current = dataintegration.ConfigPO{Kind: integrationKindStorage, Provider: provider}
current.Enabled, current.Config = provider == active, value current.Enabled, current.Config = provider == active, value
if err = tx.Create(&current).Error; err != nil { if err = tx.Create(&current).Error; err != nil {
return err return err
@ -162,7 +147,7 @@ func saveStorageIntegrationConfig(db *gorm.DB, storage *conf.AdminBackend_Storag
} }
func loadStorageIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Storage, bool, error) { func loadStorageIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Storage, bool, error) {
var rows []integrationConfigPO var rows []dataintegration.ConfigPO
err := db.Session(&gorm.Session{NewDB: true}). err := db.Session(&gorm.Session{NewDB: true}).
Where("kind = ?", integrationKindStorage). Where("kind = ?", integrationKindStorage).
Order("id ASC"). Order("id ASC").
@ -191,7 +176,7 @@ func loadStorageIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Storage, bool
// sole source of truth. // sole source of truth.
func resolveStorageIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Storage) (*conf.AdminBackend_Storage, error) { func resolveStorageIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Storage) (*conf.AdminBackend_Storage, error) {
clean := db.Session(&gorm.Session{NewDB: true}) clean := db.Session(&gorm.Session{NewDB: true})
if !clean.Migrator().HasTable(&integrationConfigPO{}) { if !clean.Migrator().HasTable(&dataintegration.ConfigPO{}) {
if legacy == nil { if legacy == nil {
return &conf.AdminBackend_Storage{Type: "local"}, nil return &conf.AdminBackend_Storage{Type: "local"}, nil
} }
@ -219,7 +204,7 @@ func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *con
return errors.New("database is not initialized") return errors.New("database is not initialized")
} }
db := d.gormDB.WithContext(ctx) db := d.gormDB.WithContext(ctx)
if !db.Migrator().HasTable(&integrationConfigPO{}) { if !db.Migrator().HasTable(&dataintegration.ConfigPO{}) {
return errors.New("integration configuration table does not exist") return errors.New("integration configuration table does not exist")
} }
return saveStorageIntegrationConfig(db, storage) return saveStorageIntegrationConfig(db, storage)
@ -239,11 +224,11 @@ func saveEmailIntegrationConfig(db *gorm.DB, email *conf.AdminBackend_Email) err
} }
enabled := email.Host != "" && email.From != "" && email.Secret != "" && email.Port > 0 enabled := email.Host != "" && email.From != "" && email.Secret != "" && email.Port > 0
clean := db.Session(&gorm.Session{NewDB: true}) clean := db.Session(&gorm.Session{NewDB: true})
var current integrationConfigPO var current dataintegration.ConfigPO
err = clean.Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").First(&current).Error err = clean.Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").First(&current).Error
switch { switch {
case errors.Is(err, gorm.ErrRecordNotFound): case errors.Is(err, gorm.ErrRecordNotFound):
return clean.Create(&integrationConfigPO{ return clean.Create(&dataintegration.ConfigPO{
Kind: integrationKindEmail, Provider: "smtp", Enabled: enabled, Config: string(raw), Kind: integrationKindEmail, Provider: "smtp", Enabled: enabled, Config: string(raw),
}).Error }).Error
case err != nil: case err != nil:
@ -254,7 +239,7 @@ func saveEmailIntegrationConfig(db *gorm.DB, email *conf.AdminBackend_Email) err
} }
func loadEmailIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Email, bool, error) { func loadEmailIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Email, bool, error) {
var row integrationConfigPO var row dataintegration.ConfigPO
err := db.Session(&gorm.Session{NewDB: true}). err := db.Session(&gorm.Session{NewDB: true}).
Where("kind = ? AND provider = ?", integrationKindEmail, "smtp"). Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").
First(&row).Error First(&row).Error
@ -276,7 +261,7 @@ func loadEmailIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Email, bool, er
func resolveEmailIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Email) (*conf.AdminBackend_Email, error) { func resolveEmailIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Email) (*conf.AdminBackend_Email, error) {
clean := db.Session(&gorm.Session{NewDB: true}) clean := db.Session(&gorm.Session{NewDB: true})
if !clean.Migrator().HasTable(&integrationConfigPO{}) { if !clean.Migrator().HasTable(&dataintegration.ConfigPO{}) {
if legacy == nil { if legacy == nil {
return defaultEmailIntegrationConfig(), nil return defaultEmailIntegrationConfig(), nil
} }
@ -304,7 +289,7 @@ func (d *Data) persistEmailIntegrationConfig(ctx context.Context, email *conf.Ad
return errors.New("database is not initialized") return errors.New("database is not initialized")
} }
db := d.gormDB.WithContext(ctx) db := d.gormDB.WithContext(ctx)
if !db.Migrator().HasTable(&integrationConfigPO{}) { if !db.Migrator().HasTable(&dataintegration.ConfigPO{}) {
return errors.New("integration configuration table does not exist") return errors.New("integration configuration table does not exist")
} }
return saveEmailIntegrationConfig(db, email) return saveEmailIntegrationConfig(db, email)

View File

@ -26,7 +26,7 @@ func openIntegrationConfigTestDB(t *testing.T) *gorm.DB {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { _ = sqlDB.Close() }) t.Cleanup(func() { _ = sqlDB.Close() })
if err = db.AutoMigrate(&integrationConfigPO{}); err != nil { if err = db.AutoMigrate(&dataintegration.ConfigPO{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
return db return db
@ -42,10 +42,10 @@ func TestMigrateAllCreatesIntegrationConfigTable(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { _ = sqlDB.Close() }) t.Cleanup(func() { _ = sqlDB.Close() })
if err = migrateAll(db); err != nil { if err = migrateAll(db, testCatalog()); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !db.Migrator().HasTable(&integrationConfigPO{}) { if !db.Migrator().HasTable(&dataintegration.ConfigPO{}) {
t.Fatal("migrateAll did not create sys_integration_configs") t.Fatal("migrateAll did not create sys_integration_configs")
} }
} }
@ -73,7 +73,7 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
if err := saveEmailIntegrationConfig(db, email); err != nil { if err := saveEmailIntegrationConfig(db, email); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: "wechat-pay", Config: `{"merchant_id":"123"}`}).Error; err != nil { if err := db.Create(&dataintegration.ConfigPO{Kind: integrationKindPayment, Provider: "wechat-pay", Config: `{"merchant_id":"123"}`}).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -103,13 +103,13 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
} }
var storageCount, emailCount, paymentCount int64 var storageCount, emailCount, paymentCount int64
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindStorage).Count(&storageCount).Error; err != nil { if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", integrationKindStorage).Count(&storageCount).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindPayment).Count(&paymentCount).Error; err != nil { if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", integrationKindPayment).Count(&paymentCount).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindEmail).Count(&emailCount).Error; err != nil { if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", integrationKindEmail).Count(&emailCount).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if storageCount != int64(len(storageProviderNames)) { if storageCount != int64(len(storageProviderNames)) {

View File

@ -1,38 +0,0 @@
package data
import (
"encoding/json"
"errors"
integrationbiz "kra/internal/biz/integration"
"gorm.io/gorm"
)
func ensureCommunicationIntegrationConfigs(db *gorm.DB) error {
defaults := []struct {
kind string
provider string
}{
{kind: integrationbiz.IntegrationKindMQ, provider: "emqx"},
{kind: integrationbiz.IntegrationKindMQ, provider: "rabbitmq"},
{kind: integrationbiz.IntegrationKindWebSocket, provider: "melody"},
}
for _, item := range defaults {
var row integrationConfigPO
err := db.Where("kind = ? AND provider = ?", item.kind, item.provider).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
values, marshalErr := json.Marshal(integrationbiz.DefaultIntegrationConfig(item.kind, item.provider))
if marshalErr != nil {
return marshalErr
}
if err = db.Create(&integrationConfigPO{Kind: item.kind, Provider: item.provider, Enabled: false, Config: string(values)}).Error; err != nil {
return err
}
continue
}
if err != nil {
return err
}
}
return nil
}

View File

@ -1,29 +1,13 @@
package data package data
import ( import (
"errors"
"kra/internal/integration/runtimeconfig"
"gorm.io/gorm" "gorm.io/gorm"
dataintegration "kra/internal/data/integration"
"kra/internal/integration/runtimeconfig"
) )
func readIntegrationRuntime(db *gorm.DB) ([]runtimeconfig.Config, error) { func readIntegrationRuntime(db *gorm.DB) ([]runtimeconfig.Config, error) {
if db == nil || !db.Migrator().HasTable(&integrationConfigPO{}) { return dataintegration.ReadRuntime(db)
return nil, nil
}
var rows []integrationConfigPO
if err := db.Session(&gorm.Session{NewDB: true}).
Where("kind IN ?", []string{"mq", "websocket"}).
Order("kind ASC, provider ASC").
Find(&rows).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
configs := make([]runtimeconfig.Config, 0, len(rows))
for _, row := range rows {
configs = append(configs, runtimeconfig.Config{Kind: row.Kind, Provider: row.Provider, Enabled: row.Enabled, Values: []byte(row.Config)})
}
return configs, nil
} }
func (d *Data) loadIntegrationRuntime(db *gorm.DB) error { func (d *Data) loadIntegrationRuntime(db *gorm.DB) error {

View File

@ -1,8 +1,6 @@
package data package data
import ( import (
datapayment "kra/internal/data/payment"
datasystem "kra/internal/data/repository"
"kra/pkg/database/migration" "kra/pkg/database/migration"
"kra/pkg/module" "kra/pkg/module"
@ -10,26 +8,14 @@ import (
) )
func InfrastructureMigrations() []migration.Step { func InfrastructureMigrations() []migration.Step {
return []migration.Step{ return nil
{
ID: "202608200001_data_infrastructure",
Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &integrationConfigPO{})
},
},
{ID: "202608210001_communication_integration_defaults", Migrate: ensureCommunicationIntegrationConfigs},
}
} }
// migrateAll is the single data-layer migration entry point. Module-specific // migrateAll is the single data-layer migration entry point. Every module
// schema work stays with the module that owns its persistent objects. // must register its migrations through the application catalog; there is no
func migrateAll(db *gorm.DB, catalogs ...module.Catalog) error { // hidden system/payment fallback that could silently omit a new module.
steps := InfrastructureMigrations() func migrateAll(db *gorm.DB, catalog module.Catalog) error {
if len(catalogs) > 0 && len(catalogs[0].MigrationSteps()) > 0 { steps := append([]migration.Step{}, InfrastructureMigrations()...)
steps = append(steps, catalogs[0].MigrationSteps()...) steps = append(steps, catalog.MigrationSteps()...)
} else {
steps = append(steps, datasystem.Migrations()...)
steps = append(steps, datapayment.Migrations()...)
}
return migration.Run(db, steps) return migration.Run(db, steps)
} }

View File

@ -3,18 +3,29 @@ package data
import ( import (
"testing" "testing"
integrationmodule "kra/internal/modules/integration"
paymentmodule "kra/internal/modules/payment"
systemmodule "kra/internal/modules/system"
taskmodule "kra/internal/modules/task"
"kra/pkg/database/migration" "kra/pkg/database/migration"
platformmodule "kra/pkg/module"
) )
func testCatalog() platformmodule.Catalog {
return platformmodule.Catalog{Definitions: []platformmodule.Definition{
systemmodule.Definition(), integrationmodule.Definition(), taskmodule.Definition(), paymentmodule.Definition(),
}}
}
func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) { func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err = migrateAll(db); err != nil { if err = migrateAll(db, testCatalog()); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err = migrateAll(db); err != nil { if err = migrateAll(db, testCatalog()); err != nil {
t.Fatalf("second migration run: %v", err) t.Fatalf("second migration run: %v", err)
} }
for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders"} { for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders"} {
@ -26,10 +37,10 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil { if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if versions != 7 { if versions != 8 {
t.Fatalf("migration versions = %d, want 7", versions) t.Fatalf("migration versions = %d, want 8", versions)
} }
var communicationRows []integrationConfigPO var communicationRows []dataintegration.ConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil { if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -1,17 +1,5 @@
package payment package payment
import "time" import dataintegration "kra/internal/data/integration"
const integrationKindPayment = "payment" const integrationKindPayment = "payment"
type integrationConfigPO struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
Kind string `gorm:"size:32;not null;uniqueIndex:idx_integration_kind_provider"`
Provider string `gorm:"size:64;not null;uniqueIndex:idx_integration_kind_provider"`
Enabled bool `gorm:"not null;default:false;index"`
Config string `gorm:"type:text;not null"`
}
func (integrationConfigPO) TableName() string { return "sys_integration_configs" }

View File

@ -26,12 +26,12 @@ func NewPaymentRepo(data Provider) bizpayment.PaymentRepo { return &paymentRepo{
func ensurePaymentIntegrationConfigs(db *gorm.DB) error { func ensurePaymentIntegrationConfigs(db *gorm.DB) error {
for _, provider := range bizpayment.SupportedPaymentProviders { for _, provider := range bizpayment.SupportedPaymentProviders {
var row integrationConfigPO var row dataintegration.ConfigPO
err := db.Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error err := db.Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error
defaults := integrationbiz.DefaultIntegrationConfig(integrationKindPayment, provider) defaults := integrationbiz.DefaultIntegrationConfig(integrationKindPayment, provider)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
encoded, _ := json.Marshal(defaults) encoded, _ := json.Marshal(defaults)
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Enabled: false, Config: string(encoded)}).Error; err != nil { if err := db.Create(&dataintegration.ConfigPO{Kind: integrationKindPayment, Provider: provider, Enabled: false, Config: string(encoded)}).Error; err != nil {
return err return err
} }
continue continue
@ -58,8 +58,8 @@ func ensurePaymentIntegrationConfigs(db *gorm.DB) error {
return nil return nil
} }
func (r *paymentRepo) row(ctx context.Context, provider string) (*integrationConfigPO, map[string]any, error) { func (r *paymentRepo) row(ctx context.Context, provider string) (*dataintegration.ConfigPO, map[string]any, error) {
var row integrationConfigPO var row dataintegration.ConfigPO
if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error; err != nil { if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, bizpayment.ErrPaymentProviderNotFound return nil, nil, bizpayment.ErrPaymentProviderNotFound
@ -298,7 +298,7 @@ func recordPaymentTestError(ctx context.Context, data Provider, provider, tradeN
} }
func (r *paymentRepo) testRow(ctx context.Context, provider string) (map[string]any, error) { func (r *paymentRepo) testRow(ctx context.Context, provider string) (map[string]any, error) {
var row integrationConfigPO var row dataintegration.ConfigPO
if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error; err != nil { if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, bizpayment.ErrPaymentProviderNotFound return nil, bizpayment.ErrPaymentProviderNotFound

View File

@ -36,7 +36,7 @@ func openIntegrationConfigTestDB(t *testing.T) *gorm.DB {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := db.AutoMigrate(&integrationConfigPO{}); err != nil { if err := db.AutoMigrate(&dataintegration.ConfigPO{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { t.Cleanup(func() {
@ -48,7 +48,7 @@ func openIntegrationConfigTestDB(t *testing.T) *gorm.DB {
} }
func migrateAll(db *gorm.DB) error { func migrateAll(db *gorm.DB) error {
if err := db.AutoMigrate(&integrationConfigPO{}); err != nil { if err := db.AutoMigrate(&dataintegration.ConfigPO{}); err != nil {
return err return err
} }
for _, step := range Migrations() { for _, step := range Migrations() {

View File

@ -10,7 +10,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type dataAccessLogPO struct { type DataAccessLogPO struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
@ -21,16 +21,16 @@ type dataAccessLogPO struct {
RequestID, Method, Path, Detail string RequestID, Method, Path, Detail string
} }
func (dataAccessLogPO) TableName() string { return "sys_data_access_logs" } func (DataAccessLogPO) TableName() string { return "sys_data_access_logs" }
func (r *auditRecorderRepo) RecordDataAccess(ctx context.Context, v *system.DataAccessLog) error { func (r *auditRecorderRepo) RecordDataAccess(ctx context.Context, v *system.DataAccessLog) error {
return r.data.DB().WithContext(ctx).Create(&dataAccessLogPO{EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail}).Error return r.data.DB().WithContext(ctx).Create(&DataAccessLogPO{EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail}).Error
} }
func dataAccessFromPO(v dataAccessLogPO) *system.DataAccessLog { func dataAccessFromPO(v DataAccessLogPO) *system.DataAccessLog {
return &system.DataAccessLog{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail} return &system.DataAccessLog{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail}
} }
func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q *system.DataAccessLog) ([]*system.DataAccessLog, int64, error) { func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q *system.DataAccessLog) ([]*system.DataAccessLog, int64, error) {
db := r.data.DB().WithContext(ctx).Model(&dataAccessLogPO{}) db := r.data.DB().WithContext(ctx).Model(&DataAccessLogPO{})
if q != nil { if q != nil {
if q.EventType != "" { if q.EventType != "" {
db = db.Where("event_type = ?", q.EventType) db = db.Where("event_type = ?", q.EventType)
@ -43,7 +43,7 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q *
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
var pos []dataAccessLogPO var pos []DataAccessLogPO
if err := pagination.ApplyRequired(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { if err := pagination.ApplyRequired(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
@ -54,5 +54,5 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q *
return out, total, nil return out, total, nil
} }
func (r *auditQueryRepo) DeleteDataAccess(ctx context.Context, ids []uint) error { func (r *auditQueryRepo) DeleteDataAccess(ctx context.Context, ids []uint) error {
return r.data.DB().WithContext(ctx).Delete(&dataAccessLogPO{}, "id IN ?", ids).Error return r.data.DB().WithContext(ctx).Delete(&DataAccessLogPO{}, "id IN ?", ids).Error
} }

View File

@ -0,0 +1,26 @@
package system
import (
"context"
"time"
bizsystem "kra/internal/biz/system"
"gorm.io/gorm"
)
type maintenanceRepo struct{ data Provider }
func NewMaintenanceRepo(data Provider) bizsystem.MaintenanceRepo {
return &maintenanceRepo{data: data}
}
func (r *maintenanceRepo) CleanupExpired(ctx context.Context) error {
now := time.Now()
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Where("created_at < ?", now.Add(-2160*time.Hour)).Delete(&operationPO{}).Error; err != nil {
return err
}
return tx.Unscoped().Where("created_at < ?", now.Add(-168*time.Hour)).Delete(&jwtBlacklistPO{}).Error
})
}

View File

@ -22,8 +22,8 @@ func Migrations() []migration.Step {
&departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{}, &departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{},
&dictionaryPO{}, &dictionaryDetailPO{}, &parameterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &SecurityConfigPO{}, &dictionaryPO{}, &dictionaryDetailPO{}, &parameterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &SecurityConfigPO{},
&versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{}, &versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{},
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{}, &operationPO{}, &loginLogPO{}, &DataAccessLogPO{}, &errorRecordPO{},
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
&announcementPO{}, &announcementPO{},
) )
}, },

View File

@ -22,8 +22,7 @@ var ProviderSet = wire.NewSet(
NewAuditRepo, NewAuditRepo,
NewAuditRecorderRepo, NewAuditRecorderRepo,
NewLogFileRepo, NewLogFileRepo,
NewTaskRepo,
NewMediaRepo, NewMediaRepo,
NewAnnouncementRepo, NewAnnouncementRepo,
NewIntegrationConfigRepo, NewMaintenanceRepo,
) )

View File

@ -14,21 +14,20 @@ import (
) )
func SeedSystem(ctx context.Context, db *gorm.DB, input *system.DatabaseConfig, surfaces ...platformmodule.Surface) error { func SeedSystem(ctx context.Context, db *gorm.DB, input *system.DatabaseConfig, surfaces ...platformmodule.Surface) error {
return seedSystem(ctx, db, input, nil, surfaces...) return seedSystem(ctx, db, input, surfaces...)
} }
// SeedSystemWithCatalog applies module-contributed administration surfaces and // SeedSystemWithCatalog applies module-contributed administration surfaces in
// default timed tasks in one transaction. The system module remains the owner // one transaction. Each data module seeds its own persistent tables.
// of the system tables, while other modules contribute through the catalog.
func SeedSystemWithCatalog(ctx context.Context, db *gorm.DB, input *system.DatabaseConfig, catalog platformmodule.Catalog) error { func SeedSystemWithCatalog(ctx context.Context, db *gorm.DB, input *system.DatabaseConfig, catalog platformmodule.Catalog) error {
surfaces := make([]platformmodule.Surface, 0, len(catalog.Definitions)) surfaces := make([]platformmodule.Surface, 0, len(catalog.Definitions))
for _, definition := range catalog.Definitions { for _, definition := range catalog.Definitions {
surfaces = append(surfaces, definition.Surface) surfaces = append(surfaces, definition.Surface)
} }
return seedSystem(ctx, db, input, catalog.DefaultTimedTasks(), surfaces...) return seedSystem(ctx, db, input, surfaces...)
} }
func seedSystem(ctx context.Context, db *gorm.DB, input *system.DatabaseConfig, defaults []platformmodule.TimedTask, surfaces ...platformmodule.Surface) error { func seedSystem(ctx context.Context, db *gorm.DB, input *system.DatabaseConfig, surfaces ...platformmodule.Surface) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
rootParentID := uint(0) rootParentID := uint(0)
authority := authorityPO{AuthorityID: 888, AuthorityName: "超级管理员", ParentID: &rootParentID, DataScope: 1, DefaultRouter: "dashboard"} authority := authorityPO{AuthorityID: 888, AuthorityName: "超级管理员", ParentID: &rootParentID, DataScope: 1, DefaultRouter: "dashboard"}
@ -112,18 +111,6 @@ func seedSystem(ctx context.Context, db *gorm.DB, input *system.DatabaseConfig,
if err := tx.Where("template_id = ?", exportTemplate.TemplateID).FirstOrCreate(&exportTemplate).Error; err != nil { if err := tx.Where("template_id = ?", exportTemplate.TemplateID).FirstOrCreate(&exportTemplate).Error; err != nil {
return err return err
} }
var defaultTasks []taskPO
if defaults == nil {
defaultTasks = []taskPO{{Name: "ClearDB", Description: "定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Spec: "@daily", ExecutorType: "method", MethodName: "ClearDB", Enabled: true}, {Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", ExecutorType: "method", MethodName: "CleanStaleUploads", Enabled: true}}
}
for _, item := range defaults {
defaultTasks = append(defaultTasks, taskPO{Name: item.Name, Description: item.Description, Spec: item.Spec, WithSeconds: item.WithSeconds, ExecutorType: "method", MethodName: item.MethodName, Enabled: item.Enabled})
}
for _, task := range defaultTasks {
if err := tx.Where("name = ?", task.Name).FirstOrCreate(&task).Error; err != nil {
return err
}
}
for _, item := range input.APIs { for _, item := range input.APIs {
if item != nil { if item != nil {
po := apiPO{Path: item.Path, Method: strings.ToUpper(item.Method), Description: item.Description, APIGroup: item.APIGroup} po := apiPO{Path: item.Path, Method: strings.ToUpper(item.Method), Description: item.Description, APIGroup: item.APIGroup}

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
})
}

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
}

View File

@ -0,0 +1,5 @@
package task
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewTaskRepo)

View File

@ -1,4 +1,4 @@
package system package task
import ( import (
"context" "context"
@ -10,7 +10,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type taskPO struct { type TaskPO struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
@ -27,9 +27,9 @@ type taskPO struct {
HTTPAllowPrivate, Enabled bool HTTPAllowPrivate, Enabled bool
} }
func (taskPO) TableName() string { return "sys_timed_tasks" } func (TaskPO) TableName() string { return "sys_timed_tasks" }
type taskLogPO struct { type TaskLogPO struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
@ -42,15 +42,15 @@ type taskLogPO struct {
ErrorMsg, Output string `gorm:"type:text"` ErrorMsg, Output string `gorm:"type:text"`
} }
func (taskLogPO) TableName() string { return "sys_timed_task_logs" } func (TaskLogPO) TableName() string { return "sys_timed_task_logs" }
type taskRepo struct{ data Provider } type taskRepo struct{ data Provider }
func NewTaskRepo(data Provider) taskbiz.TaskRepo { return &taskRepo{data: data} } func NewTaskRepo(data Provider) taskbiz.TaskRepo { return &taskRepo{data: data} }
func taskToPO(v *taskbiz.TimedTask) taskPO { 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} 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 { 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} 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 { func (r *taskRepo) CreateTask(ctx context.Context, v *taskbiz.TimedTask) error {
@ -63,7 +63,7 @@ func (r *taskRepo) CreateTask(ctx context.Context, v *taskbiz.TimedTask) error {
} }
func (r *taskRepo) TaskNameExists(ctx context.Context, name string, excludeID uint) (bool, error) { func (r *taskRepo) TaskNameExists(ctx context.Context, name string, excludeID uint) (bool, error) {
var count int64 var count int64
db := r.data.DB().WithContext(ctx).Model(&taskPO{}).Where("name = ?", name) db := r.data.DB().WithContext(ctx).Model(&TaskPO{}).Where("name = ?", name)
if excludeID > 0 { if excludeID > 0 {
db = db.Where("id <> ?", excludeID) db = db.Where("id <> ?", excludeID)
} }
@ -72,13 +72,13 @@ func (r *taskRepo) TaskNameExists(ctx context.Context, name string, excludeID ui
} }
func (r *taskRepo) UpdateTask(ctx context.Context, v *taskbiz.TimedTask) error { func (r *taskRepo) UpdateTask(ctx context.Context, v *taskbiz.TimedTask) error {
po := taskToPO(v) 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 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 { func (r *taskRepo) DeleteTask(ctx context.Context, id uint) error {
return r.data.DB().WithContext(ctx).Delete(&taskPO{}, id).Error return r.data.DB().WithContext(ctx).Delete(&TaskPO{}, id).Error
} }
func (r *taskRepo) FindTask(ctx context.Context, id uint) (*taskbiz.TimedTask, error) { func (r *taskRepo) FindTask(ctx context.Context, id uint) (*taskbiz.TimedTask, error) {
var po taskPO var po TaskPO
if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil {
return nil, err return nil, err
} }
@ -91,7 +91,7 @@ func (r *taskRepo) ListTasks(ctx context.Context, page, size int, q *taskbiz.Tim
if !r.data.DatabaseReady() { if !r.data.DatabaseReady() {
return []*taskbiz.TimedTask{}, 0, nil return []*taskbiz.TimedTask{}, 0, nil
} }
db := r.data.DB().WithContext(ctx).Model(&taskPO{}) db := r.data.DB().WithContext(ctx).Model(&TaskPO{})
if q != nil { if q != nil {
if q.Name != "" { if q.Name != "" {
db = db.Where("name LIKE ?", "%"+q.Name+"%") db = db.Where("name LIKE ?", "%"+q.Name+"%")
@ -107,7 +107,7 @@ func (r *taskRepo) ListTasks(ctx context.Context, page, size int, q *taskbiz.Tim
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
var pos []taskPO var pos []TaskPO
if err := pagination.Apply(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { if err := pagination.Apply(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
@ -118,16 +118,16 @@ func (r *taskRepo) ListTasks(ctx context.Context, page, size int, q *taskbiz.Tim
return out, total, nil return out, total, nil
} }
func (r *taskRepo) ToggleTask(ctx context.Context, id uint, enabled bool) error { 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 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 { 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 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 { 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} 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) { 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{}) db := r.data.DB().WithContext(ctx).Model(&TaskLogPO{})
if taskID != 0 { if taskID != 0 {
db = db.Where("task_id = ?", taskID) db = db.Where("task_id = ?", taskID)
} }
@ -138,7 +138,7 @@ func (r *taskRepo) ListTaskLogs(ctx context.Context, page, size int, taskID uint
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
var pos []taskLogPO var pos []TaskLogPO
if err := pagination.Apply(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { if err := pagination.Apply(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
@ -149,14 +149,7 @@ func (r *taskRepo) ListTaskLogs(ctx context.Context, page, size int, taskID uint
return out, total, nil return out, total, nil
} }
func (r *taskRepo) CleanupLogs(ctx context.Context) error { func (r *taskRepo) CleanupLogs(ctx context.Context) error {
now := time.Now() return r.data.DB().WithContext(ctx).Unscoped().
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { Where("created_at < ?", time.Now().Add(-720*time.Hour)).
if err := tx.Unscoped().Where("created_at < ?", now.Add(-2160*time.Hour)).Delete(&operationPO{}).Error; err != nil { Delete(&TaskLogPO{}).Error
return err
}
if err := tx.Unscoped().Where("created_at < ?", now.Add(-168*time.Hour)).Delete(&jwtBlacklistPO{}).Error; err != nil {
return err
}
return tx.Unscoped().Where("created_at < ?", now.Add(-720*time.Hour)).Delete(&taskLogPO{}).Error
})
} }

View File

@ -1,4 +1,4 @@
package system package task
import ( import (
"context" "context"

View File

@ -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{})
}

View File

@ -4,7 +4,8 @@ import (
"context" "context"
"kra/internal/biz/system" "kra/internal/biz/system"
datasystem "kra/internal/data/repository" datasystem "kra/internal/data/system"
datatask "kra/internal/data/task"
platformmodule "kra/pkg/module" platformmodule "kra/pkg/module"
"gorm.io/gorm" "gorm.io/gorm"
@ -25,7 +26,10 @@ func (r *Repo) IsInitialized(ctx context.Context) (bool, error) {
func (r *Repo) Initialize(ctx context.Context, input *system.DatabaseConfig) error { func (r *Repo) Initialize(ctx context.Context, input *system.DatabaseConfig) error {
return r.backend.InitializeDatabase(ctx, input, func(ctx context.Context, db *gorm.DB) error { return r.backend.InitializeDatabase(ctx, input, func(ctx context.Context, db *gorm.DB) error {
return datasystem.SeedSystemWithCatalog(ctx, db, input, r.catalog) if err := datasystem.SeedSystemWithCatalog(ctx, db, input, r.catalog); err != nil {
return err
}
return datatask.SeedDefaults(ctx, db, r.catalog.DefaultTimedTasks())
}) })
} }

View File

@ -0,0 +1,33 @@
// Package integration contains the built-in integration data and admin
// surface contribution.
package integration
import (
"strings"
dataintegration "kra/internal/data/integration"
"kra/internal/routecatalog"
"kra/pkg/module"
)
func Definition() module.Definition {
return module.Definition{
Name: "integration",
Migrations: dataintegration.Migrations(),
Surface: module.Surface{
Menus: []module.Menu{{Name: "integrationConfig", Path: "integrationConfig", ParentName: "extensions", Component: "view/systemTools/integration/config.vue", Title: "通信集成", Icon: "connection", Sort: 8}},
APIs: integrationAPIs(),
},
}
}
func integrationAPIs() []module.API {
items := make([]module.API, 0, 5)
for _, descriptor := range routecatalog.Descriptors() {
if descriptor.Public || !strings.HasPrefix(descriptor.Path, "/integration/configs") || descriptor.Description == "" {
continue
}
items = append(items, module.API{Path: descriptor.Path, Method: descriptor.Method, Group: descriptor.Group, Description: descriptor.Description})
}
return items
}

View File

@ -0,0 +1,10 @@
package integration
import "testing"
func TestDefinitionOwnsCommunicationSurface(t *testing.T) {
definition := Definition()
if definition.Name != "integration" || len(definition.Surface.Menus) != 1 || len(definition.Surface.APIs) != 5 {
t.Fatalf("integration surface = %#v", definition.Surface)
}
}

View File

@ -3,38 +3,15 @@
package system package system
import ( import (
"strings" datasystem "kra/internal/data/system"
datasystem "kra/internal/data/repository"
"kra/internal/routecatalog"
"kra/pkg/module" "kra/pkg/module"
) )
// Definition describes the built-in system contribution to the application // Definition describes the built-in system contribution to the application
// catalog. Other business modules can expose the same shape independently. // catalog. Other business modules can expose the same shape independently.
func Definition() module.Definition { func Definition() module.Definition {
communication := module.Surface{
Menus: []module.Menu{{Name: "integrationConfig", Path: "integrationConfig", ParentName: "extensions", Component: "view/systemTools/integration/config.vue", Title: "通信集成", Icon: "connection", Sort: 8}},
APIs: integrationAPIs(),
}
return module.Definition{ return module.Definition{
Name: "system", Name: "system",
Migrations: datasystem.Migrations(), Migrations: datasystem.Migrations(),
Surface: communication,
TimedTasks: []module.TimedTask{
{Name: "ClearDB", Description: "定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Spec: "@daily", MethodName: "ClearDB", Enabled: true},
{Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", MethodName: "CleanStaleUploads", Enabled: true},
},
} }
} }
func integrationAPIs() []module.API {
items := make([]module.API, 0, 5)
for _, descriptor := range routecatalog.Descriptors() {
if descriptor.Public || !strings.HasPrefix(descriptor.Path, "/integration/configs") || descriptor.Description == "" {
continue
}
items = append(items, module.API{Path: descriptor.Path, Method: descriptor.Method, Group: descriptor.Group, Description: descriptor.Description})
}
return items
}

View File

@ -2,26 +2,9 @@ package system
import "testing" import "testing"
func TestDefinitionIncludesCommunicationIntegrationSurface(t *testing.T) { func TestDefinitionOwnsOnlySystemMigrations(t *testing.T) {
surface := Definition().Surface definition := Definition()
menuFound := false if definition.Name != "system" || len(definition.Surface.Menus) != 0 || len(definition.Surface.APIs) != 0 {
for _, menu := range surface.Menus { t.Fatalf("system definition = %#v", definition)
if menu.Name == "integrationConfig" {
menuFound = menu.ParentName == "extensions" && menu.Component == "view/systemTools/integration/config.vue"
break
}
}
if !menuFound {
t.Fatal("communication integration menu is missing")
}
apiFound := false
for _, api := range surface.APIs {
if api.Method == "PUT" && api.Path == "/integration/configs/:kind/:provider" {
apiFound = true
break
}
}
if !apiFound {
t.Fatal("communication integration save API is missing")
} }
} }

View File

@ -0,0 +1,18 @@
// Package task contains the built-in timed-task module contribution.
package task
import (
datatask "kra/internal/data/task"
"kra/pkg/module"
)
func Definition() module.Definition {
return module.Definition{
Name: "task",
Migrations: datatask.Migrations(),
TimedTasks: []module.TimedTask{
{Name: "ClearDB", Description: "定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Spec: "@daily", MethodName: "ClearDB", Enabled: true},
{Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", MethodName: "CleanStaleUploads", Enabled: true},
},
}
}

View File

@ -14,12 +14,13 @@ import (
// Other modules provide their own contributor instead of editing this file. // Other modules provide their own contributor instead of editing this file.
type TaskMethods struct { type TaskMethods struct {
tasks *taskbiz.TaskUsecase tasks *taskbiz.TaskUsecase
maintenance *system.MaintenanceUsecase
media *system.MediaUsecase media *system.MediaUsecase
runtime *conf.Runtime runtime *conf.Runtime
} }
func NewTaskMethods(tasks *taskbiz.TaskUsecase, media *system.MediaUsecase, runtime *conf.Runtime) *TaskMethods { func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *conf.Runtime) *TaskMethods {
return &TaskMethods{tasks: tasks, media: media, runtime: runtime} return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime}
} }
var _ platformtask.Contributor = (*TaskMethods)(nil) var _ platformtask.Contributor = (*TaskMethods)(nil)
@ -30,6 +31,9 @@ func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) {
} }
registry.Register(platformtask.Method{ registry.Register(platformtask.Method{
Name: taskbiz.TaskMethodClearDB, Description: "清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Run: func(ctx context.Context, _ json.RawMessage) error { Name: taskbiz.TaskMethodClearDB, Description: "清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Run: func(ctx context.Context, _ json.RawMessage) error {
if err := methods.maintenance.CleanupExpired(ctx); err != nil {
return err
}
return methods.tasks.CleanupLogs(ctx) return methods.tasks.CleanupLogs(ctx)
}, },
}) })