优化结构

This commit is contained in:
Yvan 2026-08-27 19:08:51 +08:00
parent 653414a1da
commit 5052835fcb
11 changed files with 197 additions and 19 deletions

View File

@ -10,7 +10,6 @@ configs/ 运行配置
internal/biz/ 领域对象、用例和仓储接口(按 system/payment/integration/task 拆分) internal/biz/ 领域对象、用例和仓储接口(按 system/payment/integration/task 拆分)
internal/config/ Viper 配置模型、加载和热更新 internal/config/ Viper 配置模型、加载和热更新
internal/data/ 数据库、缓存、对象存储及仓储实现 internal/data/ 数据库、缓存、对象存储及仓储实现
internal/global/ 进程级共享资源入口
internal/initialize/ 首次安装、配置保存和重载编排 internal/initialize/ 首次安装、配置保存和重载编排
internal/server/ Gin 服务、路由、中间件和 Handler internal/server/ Gin 服务、路由、中间件和 Handler
internal/service/ 按业务模块组织的 HTTP 输入输出与领域对象转换 internal/service/ 按业务模块组织的 HTTP 输入输出与领域对象转换

View File

@ -3,14 +3,12 @@
系统模块承载当前管理后台的完整业务边界。`internal` 顶层只保留有明确 系统模块承载当前管理后台的完整业务边界。`internal` 顶层只保留有明确
生命周期或分层职责的包: 生命周期或分层职责的包:
- `app`:应用组合根,负责依赖注入后的任务/路由组合
- `modules`:静态模块 catalog按 system/integration/task/payment 维护 Definition - `modules`:静态模块 catalog按 system/integration/task/payment 维护 Definition
- `biz/system`:用户、权限、菜单、审计、媒体和系统配置领域 - `biz/system`:用户、权限、菜单、审计、媒体和系统配置领域
- `biz/payment`:支付订单、支付流程、支付接口和支付日志 - `biz/payment`:支付订单、支付流程、支付接口和支付日志
- `biz/integration`:集成配置定义、校验和连接测试边界 - `biz/integration`:集成配置定义、校验和连接测试边界
- `biz/task`:定时任务模型、用例和任务注册协议 - `biz/task`:定时任务模型、用例和任务注册协议
- `config`Viper 配置模型、加载、快照和热更新 - `config`Viper 配置模型、加载、快照和热更新
- `global`:进程级 DB、Redis、Mongo、Storage、MQ、WebSocket 和 Scheduler 入口
- `data`:共享数据库生命周期;仓储按 `data/system`、`data/integration`、`data/task`、`data/payment` 隔离 - `data`:共享数据库生命周期;仓储按 `data/system`、`data/integration`、`data/task`、`data/payment` 隔离
- `initialize`:数据库首次初始化和系统种子数据编排 - `initialize`:数据库首次初始化和系统种子数据编排
- `integration`Redis、邮件、对象存储、支付、WebSocket、EMQX 和 RabbitMQ 适配器 - `integration`Redis、邮件、对象存储、支付、WebSocket、EMQX 和 RabbitMQ 适配器
@ -25,13 +23,12 @@
目录代表边界模块文件按资源命名。DTO、handler、中间件、路由和 HTTP 目录代表边界模块文件按资源命名。DTO、handler、中间件、路由和 HTTP
响应工具分别放在独立子包中,避免 `service`/`server` 根目录堆积几十个 响应工具分别放在独立子包中,避免 `service`/`server` 根目录堆积几十个
文件同时不把只有一两个文件的业务逻辑再拆成新包。system 的 module 文件同时不把只有一两个文件的业务逻辑再拆成新包。system 的 module
定义位于 `modules/system`,后台 JWT 签发/解析位于 `data/system/token.go`protobuf JSON 统一使用 定义位于 `modules/system`,后台 JWT 签发/解析位于 `data/system/token.go`
`pkg/protoutil`
`internal/modules/catalog.go` 是静态模块 catalog 的唯一注册点,负责按依赖顺序 `internal/modules/catalog.go` 是静态模块 catalog 的唯一注册点,负责按依赖顺序
汇总各模块 Definition。`internal/app/runtime.go` 只负责依赖注入后的任务注册 汇总各模块 Definition。`cmd` 是应用组合根,负责依赖注入后的任务注册和路由
和路由组合。这样新增模块只需在 modules catalog 注册一次app 不再重复 组合;这样新增模块只需在 modules catalog 注册一次,组合根不再重复维护模块
维护模块声明。 声明。
系统表统一使用 `sys_` 前缀;业务表应由新业务模块自行命名和迁移,不要混入 系统表统一使用 `sys_` 前缀;业务表应由新业务模块自行命名和迁移,不要混入
本目录。 本目录。

View File

@ -11,8 +11,19 @@ var ErrTaskRuntimeReload = errors.New("task runtime reload failed")
type TaskRuntimeReloadError struct{ Err error } 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) Is(target error) bool { return target == ErrTaskRuntimeReload }
func (e *TaskRuntimeReloadError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
type DatabaseConfig struct { type DatabaseConfig struct {
Driver, Host, Port, User, Password, Name, Path, Config, Template, AdminPassword string 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) { func (uc *SystemConfigUsecase) IsInitialized(ctx context.Context) (bool, error) {
if uc == nil || uc.repo == nil {
return false, errors.New("系统初始化服务未就绪")
}
return uc.repo.IsInitialized(ctx) return uc.repo.IsInitialized(ctx)
} }
func (uc *SystemConfigUsecase) Initialize(ctx context.Context, config *DatabaseConfig) error { func (uc *SystemConfigUsecase) Initialize(ctx context.Context, config *DatabaseConfig) error {
if uc == nil || uc.repo == nil {
return errors.New("系统初始化服务未就绪")
}
if config == nil { if config == nil {
return errors.New("数据库初始化参数无效") return errors.New("数据库初始化参数无效")
} }
if err := uc.repo.Initialize(ctx, config); err != nil { if err := uc.repo.Initialize(ctx, config); err != nil {
return err return err
} }
if uc.tasks == nil {
return &TaskRuntimeReloadError{Err: errors.New("任务调度服务未就绪")}
}
if err := uc.tasks.Reload(ctx); err != nil { if err := uc.tasks.Reload(ctx); err != nil {
return &TaskRuntimeReloadError{Err: err} return &TaskRuntimeReloadError{Err: err}
} }
return nil return nil
} }
func (uc *SystemConfigUsecase) PersistConfig(ctx context.Context) error { func (uc *SystemConfigUsecase) PersistConfig(ctx context.Context) error {
if uc == nil || uc.repo == nil {
return errors.New("系统配置服务未就绪")
}
return uc.repo.PersistConfig(ctx) return uc.repo.PersistConfig(ctx)
} }
func (uc *SystemConfigUsecase) PersistRuntimeConfig(ctx context.Context, value *config.Config) error { 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) return uc.repo.PersistRuntimeConfig(ctx, value)
} }
func (uc *SystemConfigUsecase) ReloadConfig(ctx context.Context) error { 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 { if err := uc.repo.ReloadConfig(ctx); err != nil {
return err return err
} }
if uc.tasks == nil {
return &TaskRuntimeReloadError{Err: errors.New("任务调度服务未就绪")}
}
if err := uc.tasks.Reload(ctx); err != nil { if err := uc.tasks.Reload(ctx); err != nil {
return &TaskRuntimeReloadError{Err: err} return &TaskRuntimeReloadError{Err: err}
} }
@ -77,11 +109,22 @@ func (uc *SystemConfigUsecase) ReloadConfig(ctx context.Context) error {
} }
func (uc *SystemConfigUsecase) ConfigurationJSON() (json.RawMessage, error) { func (uc *SystemConfigUsecase) ConfigurationJSON() (json.RawMessage, error) {
if uc == nil || uc.repo == nil {
return nil, errors.New("系统配置服务未就绪")
}
return uc.repo.ConfigurationJSON() return uc.repo.ConfigurationJSON()
} }
func (uc *SystemConfigUsecase) SaveConfigurationJSON(ctx context.Context, value json.RawMessage) error { 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) 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()
}

View File

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

View File

@ -1,7 +1,8 @@
# Data Layer # Data Layer
`data` owns database clients, persistence models, migrations, configuration `data` owns database clients, persistence models, migrations, explicit runtime
watching, and repository implementations. configuration reloads, and repository implementations. File watching and
immutable configuration snapshots belong to `internal/config`.
- `system/`: system repositories and system table persistence - `system/`: system repositories and system table persistence
- `task/`: timed-task tables and task 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` - `payment` 只拥有 `pay_orders` 等支付持久化,通过 `biz/integration.PaymentConfigReader`
读取支付配置,不感知 integration 的 PO 或表结构。 读取支付配置,不感知 integration 的 PO 或表结构。
配置文件迁移、数据库切换、Redis/Mongo/对象存储重载仍属于根 data 的生命周期编排, 配置文件持久化、数据库切换、Redis/Mongo/对象存储重载仍属于根 data 的生命周期编排,
不等同于某个业务模块的表仓储。 不等同于某个业务模块的表仓储。

View File

@ -123,7 +123,26 @@ func seedSystem(ctx context.Context, db *gorm.DB, input *system.DatabaseConfig,
for _, item := range DefaultIgnoredAPIs("uploads/file") { for _, item := range DefaultIgnoredAPIs("uploads/file") {
ignoredAPIs = append(ignoredAPIs, ignoredAPIPO{Method: item.Method, Path: item.Path}) 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 { 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 { if err := tx.FirstOrCreate(&ignored, ignored).Error; err != nil {
return err return err
} }
@ -137,7 +156,7 @@ func seedSystem(ctx context.Context, db *gorm.DB, input *system.DatabaseConfig,
return err return err
} }
for _, api := range apiRows { 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 continue
} }
exists, err := policyExists(tx, 888, api.Path, api.Method) 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 { func seedAdminSurface(tx *gorm.DB, surface platformmodule.Surface) error {
for _, item := range surface.Menus { for _, item := range surface.Menus {
parentID := uint(0) parentID := uint(0)

View File

@ -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}}, 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: "订单列表"}}, 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 { if err = SeedSystem(context.Background(), db, input, surface); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -53,4 +57,19 @@ func TestSeedSystemCreatesInitialDataAndModuleSurface(t *testing.T) {
if policies != 1 { if policies != 1 {
t.Fatalf("root policies = %d, want 1", policies) 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)
}
}
} }

View File

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

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"kra/internal/biz/system" "kra/internal/biz/system"
"log/slog"
"strconv" "strconv"
"strings" "strings"
"time" "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}) values = append(values, dto.Route{Path: route.Path, Method: route.Method})
} }
if err := h.system.InitializeRoutes(c.Request.Context(), &input, values); err != nil { 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, "自动创建数据库失败,请查看后台日志,检查后在进行初始化") Fail(c, "自动创建数据库失败,请查看后台日志,检查后在进行初始化")
return return
} }

View File

@ -1,8 +1,11 @@
package handler package handler
import ( import (
"errors"
"kra/internal/biz/system"
"kra/internal/service" "kra/internal/service"
"kra/internal/service/dto" "kra/internal/service/dto"
"log/slog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -63,6 +66,14 @@ func (h *SystemConfig) Set(c *gin.Context) {
func (h *SystemConfig) Reload(c *gin.Context) { func (h *SystemConfig) Reload(c *gin.Context) {
if err := h.system.ReloadConfig(c.Request.Context()); err != nil { 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()) Fail(c, "重载系统失败:"+err.Error())
return return
} }

View File

@ -10,7 +10,5 @@
- `logging`:跨 app 复用的结构化日志能力 - `logging`:跨 app 复用的结构化日志能力
- `httpx`:跨 HTTP 模块共享的 JSON 响应结构、分页结构和状态码 - `httpx`:跨 HTTP 模块共享的 JSON 响应结构、分页结构和状态码
- `paymentkit`支付金额、签名、状态、provider 标识和回调应答纯函数 - `paymentkit`支付金额、签名、状态、provider 标识和回调应答纯函数
- `protoutil`:与业务无关的 protobuf JSON 局部合并工具 `pkg` 只能提供机制和稳定协议,不能引用任何 `internal` 业务包。数据库配置加载、
系统集成配置、系统表和 provider 生命周期仍由 `internal` 负责。
`pkg` 只能提供机制和稳定协议,不能引用任何 `app/*/internal`。数据库配置加载、
系统集成配置、系统表和 provider 生命周期仍由 `` 负责。