From 5052835fcbfa9272e5a9f351e44f94d466fd3d7a Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Thu, 27 Aug 2026 19:08:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 - internal/README.md | 11 ++-- internal/biz/system/system_init.go | 47 +++++++++++++- internal/biz/system/system_init_test.go | 63 +++++++++++++++++++ internal/data/README.md | 7 ++- internal/data/system/seed.go | 29 ++++++++- internal/data/system/seed_test.go | 21 ++++++- .../data/system/system_init_ignore_test.go | 11 ++++ internal/server/handler/public.go | 9 +++ internal/server/handler/system_config.go | 11 ++++ pkg/README.md | 6 +- 11 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 internal/biz/system/system_init_test.go diff --git a/README.md b/README.md index b45968c..3e2f68c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ configs/ 运行配置 internal/biz/ 领域对象、用例和仓储接口(按 system/payment/integration/task 拆分) internal/config/ Viper 配置模型、加载和热更新 internal/data/ 数据库、缓存、对象存储及仓储实现 -internal/global/ 进程级共享资源入口 internal/initialize/ 首次安装、配置保存和重载编排 internal/server/ Gin 服务、路由、中间件和 Handler internal/service/ 按业务模块组织的 HTTP 输入输出与领域对象转换 diff --git a/internal/README.md b/internal/README.md index 1a804e4..088d8e3 100644 --- a/internal/README.md +++ b/internal/README.md @@ -3,14 +3,12 @@ 系统模块承载当前管理后台的完整业务边界。`internal` 顶层只保留有明确 生命周期或分层职责的包: -- `app`:应用组合根,负责依赖注入后的任务/路由组合 - `modules`:静态模块 catalog,按 system/integration/task/payment 维护 Definition - `biz/system`:用户、权限、菜单、审计、媒体和系统配置领域 - `biz/payment`:支付订单、支付流程、支付接口和支付日志 - `biz/integration`:集成配置定义、校验和连接测试边界 - `biz/task`:定时任务模型、用例和任务注册协议 - `config`:Viper 配置模型、加载、快照和热更新 -- `global`:进程级 DB、Redis、Mongo、Storage、MQ、WebSocket 和 Scheduler 入口 - `data`:共享数据库生命周期;仓储按 `data/system`、`data/integration`、`data/task`、`data/payment` 隔离 - `initialize`:数据库首次初始化和系统种子数据编排 - `integration`:Redis、邮件、对象存储、支付、WebSocket、EMQX 和 RabbitMQ 适配器 @@ -25,13 +23,12 @@ 目录代表边界,模块文件按资源命名。DTO、handler、中间件、路由和 HTTP 响应工具分别放在独立子包中,避免 `service`/`server` 根目录堆积几十个 文件,同时不把只有一两个文件的业务逻辑再拆成新包。system 的 module -定义位于 `modules/system`,后台 JWT 签发/解析位于 `data/system/token.go`,protobuf JSON 统一使用 -`pkg/protoutil`。 +定义位于 `modules/system`,后台 JWT 签发/解析位于 `data/system/token.go`。 `internal/modules/catalog.go` 是静态模块 catalog 的唯一注册点,负责按依赖顺序 -汇总各模块 Definition。`internal/app/runtime.go` 只负责依赖注入后的任务注册 -和路由组合。这样新增模块只需在 modules catalog 注册一次,app 不再重复 -维护模块声明。 +汇总各模块 Definition。`cmd` 是应用组合根,负责依赖注入后的任务注册和路由 +组合;这样新增模块只需在 modules catalog 注册一次,组合根不再重复维护模块 +声明。 系统表统一使用 `sys_` 前缀;业务表应由新业务模块自行命名和迁移,不要混入 本目录。 diff --git a/internal/biz/system/system_init.go b/internal/biz/system/system_init.go index 297aea0..845866b 100644 --- a/internal/biz/system/system_init.go +++ b/internal/biz/system/system_init.go @@ -11,8 +11,19 @@ var ErrTaskRuntimeReload = errors.New("task runtime reload failed") type TaskRuntimeReloadError struct{ Err error } -func (e *TaskRuntimeReloadError) Error() string { return e.Err.Error() } +func (e *TaskRuntimeReloadError) Error() string { + if e == nil || e.Err == nil { + return ErrTaskRuntimeReload.Error() + } + return e.Err.Error() +} func (e *TaskRuntimeReloadError) Is(target error) bool { return target == ErrTaskRuntimeReload } +func (e *TaskRuntimeReloadError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} type DatabaseConfig struct { Driver, Host, Port, User, Password, Name, Path, Config, Template, AdminPassword string @@ -46,30 +57,51 @@ func NewSystemConfigUsecase(repo InitializationRepo, tasks TaskReloader) *System } func (uc *SystemConfigUsecase) IsInitialized(ctx context.Context) (bool, error) { + if uc == nil || uc.repo == nil { + return false, errors.New("系统初始化服务未就绪") + } return uc.repo.IsInitialized(ctx) } func (uc *SystemConfigUsecase) Initialize(ctx context.Context, config *DatabaseConfig) error { + if uc == nil || uc.repo == nil { + return errors.New("系统初始化服务未就绪") + } if config == nil { return errors.New("数据库初始化参数无效") } if err := uc.repo.Initialize(ctx, config); err != nil { return err } + if uc.tasks == nil { + return &TaskRuntimeReloadError{Err: errors.New("任务调度服务未就绪")} + } if err := uc.tasks.Reload(ctx); err != nil { return &TaskRuntimeReloadError{Err: err} } return nil } func (uc *SystemConfigUsecase) PersistConfig(ctx context.Context) error { + if uc == nil || uc.repo == nil { + return errors.New("系统配置服务未就绪") + } return uc.repo.PersistConfig(ctx) } func (uc *SystemConfigUsecase) PersistRuntimeConfig(ctx context.Context, value *config.Config) error { + if uc == nil || uc.repo == nil { + return errors.New("系统配置服务未就绪") + } return uc.repo.PersistRuntimeConfig(ctx, value) } func (uc *SystemConfigUsecase) ReloadConfig(ctx context.Context) error { + if uc == nil || uc.repo == nil { + return errors.New("系统配置服务未就绪") + } if err := uc.repo.ReloadConfig(ctx); err != nil { return err } + if uc.tasks == nil { + return &TaskRuntimeReloadError{Err: errors.New("任务调度服务未就绪")} + } if err := uc.tasks.Reload(ctx); err != nil { return &TaskRuntimeReloadError{Err: err} } @@ -77,11 +109,22 @@ func (uc *SystemConfigUsecase) ReloadConfig(ctx context.Context) error { } func (uc *SystemConfigUsecase) ConfigurationJSON() (json.RawMessage, error) { + if uc == nil || uc.repo == nil { + return nil, errors.New("系统配置服务未就绪") + } return uc.repo.ConfigurationJSON() } func (uc *SystemConfigUsecase) SaveConfigurationJSON(ctx context.Context, value json.RawMessage) error { + if uc == nil || uc.repo == nil { + return errors.New("系统配置服务未就绪") + } return uc.repo.SaveConfigurationJSON(ctx, value) } -func (uc *SystemConfigUsecase) DiskMountPoints() []string { return uc.repo.DiskMountPoints() } +func (uc *SystemConfigUsecase) DiskMountPoints() []string { + if uc == nil || uc.repo == nil { + return nil + } + return uc.repo.DiskMountPoints() +} diff --git a/internal/biz/system/system_init_test.go b/internal/biz/system/system_init_test.go new file mode 100644 index 0000000..41e652a --- /dev/null +++ b/internal/biz/system/system_init_test.go @@ -0,0 +1,63 @@ +package system + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "kra/internal/config" +) + +type initializationRepoStub struct { + initializeErr error + reloadErr error +} + +func (*initializationRepoStub) IsInitialized(context.Context) (bool, error) { return false, nil } +func (r *initializationRepoStub) Initialize(context.Context, *DatabaseConfig) error { + return r.initializeErr +} +func (*initializationRepoStub) PersistConfig(context.Context) error { return nil } +func (*initializationRepoStub) PersistRuntimeConfig(context.Context, *config.Config) error { + return nil +} +func (r *initializationRepoStub) ReloadConfig(context.Context) error { return r.reloadErr } +func (*initializationRepoStub) ConfigurationJSON() (json.RawMessage, error) { + return json.RawMessage(`{}`), nil +} +func (*initializationRepoStub) SaveConfigurationJSON(context.Context, json.RawMessage) error { + return nil +} +func (*initializationRepoStub) DiskMountPoints() []string { return nil } + +type taskReloaderStub struct{ err error } + +func (r taskReloaderStub) Reload(context.Context) error { return r.err } + +func TestInitializeClassifiesPostCommitTaskReloadFailure(t *testing.T) { + reloadErr := errors.New("scheduler unavailable") + uc := NewSystemConfigUsecase(&initializationRepoStub{}, taskReloaderStub{err: reloadErr}) + err := uc.Initialize(context.Background(), &DatabaseConfig{}) + if !errors.Is(err, ErrTaskRuntimeReload) || !errors.Is(err, reloadErr) { + t.Fatalf("Initialize() error = %v", err) + } +} + +func TestReloadConfigClassifiesMissingTaskRuntime(t *testing.T) { + uc := NewSystemConfigUsecase(&initializationRepoStub{}, nil) + err := uc.ReloadConfig(context.Background()) + if !errors.Is(err, ErrTaskRuntimeReload) { + t.Fatalf("ReloadConfig() error = %v", err) + } +} + +func TestSystemConfigUsecaseRejectsMissingRepository(t *testing.T) { + uc := NewSystemConfigUsecase(nil, nil) + if _, err := uc.IsInitialized(context.Background()); err == nil { + t.Fatal("IsInitialized accepted a missing repository") + } + if _, err := uc.ConfigurationJSON(); err == nil { + t.Fatal("ConfigurationJSON accepted a missing repository") + } +} diff --git a/internal/data/README.md b/internal/data/README.md index 61eb45a..d29ec22 100644 --- a/internal/data/README.md +++ b/internal/data/README.md @@ -1,7 +1,8 @@ # Data Layer -`data` owns database clients, persistence models, migrations, configuration -watching, and repository implementations. +`data` owns database clients, persistence models, migrations, explicit runtime +configuration reloads, and repository implementations. File watching and +immutable configuration snapshots belong to `internal/config`. - `system/`: system repositories and system table persistence - `task/`: timed-task tables and task persistence @@ -28,5 +29,5 @@ reload locks. Do not split them into packages only to reduce file count. - `payment` 只拥有 `pay_orders` 等支付持久化,通过 `biz/integration.PaymentConfigReader` 读取支付配置,不感知 integration 的 PO 或表结构。 -配置文件迁移、数据库切换、Redis/Mongo/对象存储重载仍属于根 data 的生命周期编排, +配置文件持久化、数据库切换、Redis/Mongo/对象存储重载仍属于根 data 的生命周期编排, 不等同于某个业务模块的表仓储。 diff --git a/internal/data/system/seed.go b/internal/data/system/seed.go index 63efe11..ec72c81 100644 --- a/internal/data/system/seed.go +++ b/internal/data/system/seed.go @@ -123,7 +123,26 @@ func seedSystem(ctx context.Context, db *gorm.DB, input *system.DatabaseConfig, for _, item := range DefaultIgnoredAPIs("uploads/file") { ignoredAPIs = append(ignoredAPIs, ignoredAPIPO{Method: item.Method, Path: item.Path}) } + // The local storage prefix is configurable. Add the concrete catch-all + // routes supplied by the initialization request so API sync and policy + // generation keep every static file endpoint public, regardless of the + // configured prefix. + for _, item := range input.APIs { + if item == nil { + continue + } + candidate := ignoredAPIPO{Method: strings.ToUpper(item.Method), Path: item.Path} + if isStaticFileAPI(apiPO{Method: candidate.Method, Path: candidate.Path}) { + ignoredAPIs = append(ignoredAPIs, candidate) + } + } + seenIgnored := make(map[string]struct{}, len(ignoredAPIs)) for _, ignored := range ignoredAPIs { + key := ignored.Method + "\x00" + ignored.Path + if _, exists := seenIgnored[key]; exists { + continue + } + seenIgnored[key] = struct{}{} if err := tx.FirstOrCreate(&ignored, ignored).Error; err != nil { return err } @@ -137,7 +156,7 @@ func seedSystem(ctx context.Context, db *gorm.DB, input *system.DatabaseConfig, return err } for _, api := range apiRows { - if _, ignored := ignoreSet[api.Method+"\x00"+api.Path]; ignored { + if _, ignored := ignoreSet[api.Method+"\x00"+api.Path]; ignored || isStaticFileAPI(api) { continue } exists, err := policyExists(tx, 888, api.Path, api.Method) @@ -155,6 +174,14 @@ func seedSystem(ctx context.Context, db *gorm.DB, input *system.DatabaseConfig, }) } +// isStaticFileAPI keeps dynamically configured local-storage routes public. +// The route prefix is runtime configuration, so it cannot be hard-coded in +// DefaultIgnoredAPIs; initialization already receives the concrete Gin route +// list and can identify the catch-all file handlers directly. +func isStaticFileAPI(api apiPO) bool { + return (api.Method == "GET" || api.Method == "HEAD") && strings.HasSuffix(strings.TrimSpace(api.Path), "/*filepath") +} + func seedAdminSurface(tx *gorm.DB, surface platformmodule.Surface) error { for _, item := range surface.Menus { parentID := uint(0) diff --git a/internal/data/system/seed_test.go b/internal/data/system/seed_test.go index 78aab0a..39e3ba4 100644 --- a/internal/data/system/seed_test.go +++ b/internal/data/system/seed_test.go @@ -20,7 +20,11 @@ func TestSeedSystemCreatesInitialDataAndModuleSurface(t *testing.T) { Menus: []platformmodule.Menu{{Name: "orders", Path: "orders", ParentName: "extensions", Component: "view/orders.vue", Title: "订单", Sort: 6}}, APIs: []platformmodule.API{{Path: "/orders", Method: "GET", Group: "订单", Description: "订单列表"}}, } - input := &system.DatabaseConfig{AdminPassword: "admin-password", APIs: []*system.API{{Path: "/healthz", Method: "GET", APIGroup: "系统"}}} + input := &system.DatabaseConfig{AdminPassword: "admin-password", APIs: []*system.API{ + {Path: "/healthz", Method: "GET", APIGroup: "系统"}, + {Path: "/files/*filepath", Method: "GET", APIGroup: "静态文件"}, + {Path: "/files/*filepath", Method: "HEAD", APIGroup: "静态文件"}, + }} if err = SeedSystem(context.Background(), db, input, surface); err != nil { t.Fatal(err) } @@ -53,4 +57,19 @@ func TestSeedSystemCreatesInitialDataAndModuleSurface(t *testing.T) { if policies != 1 { t.Fatalf("root policies = %d, want 1", policies) } + for _, method := range []string{"GET", "HEAD"} { + var ignored int64 + if err = db.Model(&ignoredAPIPO{}).Where("path = ? AND method = ?", "/files/*filepath", method).Count(&ignored).Error; err != nil { + t.Fatal(err) + } + if ignored != 1 { + t.Fatalf("dynamic static route %s ignored rows = %d, want 1", method, ignored) + } + if err = policyScope(db).Where("v0 = ? AND v1 = ? AND v2 = ?", "888", "/files/*filepath", method).Count(&policies).Error; err != nil { + t.Fatal(err) + } + if policies != 0 { + t.Fatalf("dynamic static route %s received root policy", method) + } + } } diff --git a/internal/data/system/system_init_ignore_test.go b/internal/data/system/system_init_ignore_test.go index 6276d11..89f82a5 100644 --- a/internal/data/system/system_init_ignore_test.go +++ b/internal/data/system/system_init_ignore_test.go @@ -28,3 +28,14 @@ func TestDefaultIgnoredAPIsDoNotHideAnnouncementDataSource(t *testing.T) { } } } + +func TestStaticFileAPIsAreIgnoredRegardlessOfConfiguredPrefix(t *testing.T) { + for _, method := range []string{"GET", "HEAD"} { + if !isStaticFileAPI(apiPO{Method: method, Path: "/files/*filepath"}) { + t.Fatalf("%s static route was not recognized", method) + } + } + if isStaticFileAPI(apiPO{Method: "POST", Path: "/files/*filepath"}) { + t.Fatal("non-read static route was incorrectly ignored") + } +} diff --git a/internal/server/handler/public.go b/internal/server/handler/public.go index 8076a03..7a16b47 100644 --- a/internal/server/handler/public.go +++ b/internal/server/handler/public.go @@ -4,6 +4,7 @@ import ( "context" "errors" "kra/internal/biz/system" + "log/slog" "strconv" "strings" "time" @@ -138,6 +139,14 @@ func (h *Public) InitializeDatabase(engine *gin.Engine) gin.HandlerFunc { values = append(values, dto.Route{Path: route.Path, Method: route.Method}) } if err := h.system.InitializeRoutes(c.Request.Context(), &input, values); err != nil { + // Database activation is already committed before the scheduler reload + // runs. GVA reports initialization success in this case as well; do not + // make the UI retry an operation that cannot safely be repeated. + if errors.Is(err, system.ErrTaskRuntimeReload) { + slog.ErrorContext(c.Request.Context(), "数据库初始化完成,但定时任务恢复失败", "mod", "timedTask", "error", err) + Write(c, CodeSuccess, gin.H{}, "自动创建数据库成功") + return + } Fail(c, "自动创建数据库失败,请查看后台日志,检查后在进行初始化") return } diff --git a/internal/server/handler/system_config.go b/internal/server/handler/system_config.go index 1dcac3c..c7c6467 100644 --- a/internal/server/handler/system_config.go +++ b/internal/server/handler/system_config.go @@ -1,8 +1,11 @@ package handler import ( + "errors" + "kra/internal/biz/system" "kra/internal/service" "kra/internal/service/dto" + "log/slog" "github.com/gin-gonic/gin" ) @@ -63,6 +66,14 @@ func (h *SystemConfig) Set(c *gin.Context) { func (h *SystemConfig) Reload(c *gin.Context) { if err := h.system.ReloadConfig(c.Request.Context()); err != nil { + // Reloading the data/configuration is committed before task schedules are + // rebuilt. Match GVA's successful HTTP contract for this partial-success + // case while leaving the typed error available to internal callers. + if errors.Is(err, system.ErrTaskRuntimeReload) { + slog.ErrorContext(c.Request.Context(), "系统配置重载完成,但定时任务恢复失败", "mod", "timedTask", "error", err) + Write(c, CodeSuccess, gin.H{}, "重载系统成功") + return + } Fail(c, "重载系统失败:"+err.Error()) return } diff --git a/pkg/README.md b/pkg/README.md index cfe7472..b0f447d 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -10,7 +10,5 @@ - `logging`:跨 app 复用的结构化日志能力 - `httpx`:跨 HTTP 模块共享的 JSON 响应结构、分页结构和状态码 - `paymentkit`:支付金额、签名、状态、provider 标识和回调应答纯函数 -- `protoutil`:与业务无关的 protobuf JSON 局部合并工具 - -`pkg` 只能提供机制和稳定协议,不能引用任何 `app/*/internal`。数据库配置加载、 -系统集成配置、系统表和 provider 生命周期仍由 `` 负责。 +`pkg` 只能提供机制和稳定协议,不能引用任何 `internal` 业务包。数据库配置加载、 +系统集成配置、系统表和 provider 生命周期仍由 `internal` 负责。