优化结构

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/config/ Viper 配置模型、加载和热更新
internal/data/ 数据库、缓存、对象存储及仓储实现
internal/global/ 进程级共享资源入口
internal/initialize/ 首次安装、配置保存和重载编排
internal/server/ Gin 服务、路由、中间件和 Handler
internal/service/ 按业务模块组织的 HTTP 输入输出与领域对象转换

View File

@ -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_` 前缀;业务表应由新业务模块自行命名和迁移,不要混入
本目录。

View File

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

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` 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 的生命周期编排,
不等同于某个业务模块的表仓储。

View File

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

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

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

View File

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

View File

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