优化结构
This commit is contained in:
parent
653414a1da
commit
5052835fcb
|
|
@ -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 输入输出与领域对象转换
|
||||
|
|
|
|||
|
|
@ -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_` 前缀;业务表应由新业务模块自行命名和迁移,不要混入
|
||||
本目录。
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 的生命周期编排,
|
||||
不等同于某个业务模块的表仓储。
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,5 @@
|
|||
- `logging`:跨 app 复用的结构化日志能力
|
||||
- `httpx`:跨 HTTP 模块共享的 JSON 响应结构、分页结构和状态码
|
||||
- `paymentkit`:支付金额、签名、状态、provider 标识和回调应答纯函数
|
||||
- `protoutil`:与业务无关的 protobuf JSON 局部合并工具
|
||||
|
||||
`pkg` 只能提供机制和稳定协议,不能引用任何 `app/*/internal`。数据库配置加载、
|
||||
系统集成配置、系统表和 provider 生命周期仍由 `` 负责。
|
||||
`pkg` 只能提供机制和稳定协议,不能引用任何 `internal` 业务包。数据库配置加载、
|
||||
系统集成配置、系统表和 provider 生命周期仍由 `internal` 负责。
|
||||
|
|
|
|||
Loading…
Reference in New Issue