diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..92f55f8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.github +.idea +.vscode +.DS_Store +.codegraph +gva +web/node_modules +web/dist +node_modules +dist +bin +coverage +*.log +*.tmp +configs/*.local.yaml +uploads diff --git a/Dockerfile b/Dockerfile index b2ba6a6..aec9e35 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,13 +10,19 @@ FROM debian:stable-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ netbase \ - && rm -rf /var/lib/apt/lists/ \ - && apt-get autoremove -y && apt-get autoclean -y + && rm -rf /var/lib/apt/lists/ \ + && apt-get autoremove -y && apt-get autoclean -y -COPY --from=builder /src/bin /app +RUN groupadd --system kra && useradd --system --gid kra --home-dir /app kra + +COPY --from=builder --chown=kra:kra /src/bin /app + +RUN mkdir -p /data/conf /app/uploads/file && chown -R kra:kra /data/conf /app/uploads WORKDIR /app +USER kra + EXPOSE 8000 EXPOSE 9000 VOLUME /data/conf diff --git a/README.md b/README.md index 45bd701..cbd1908 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ cmd/kratos-admin/ 服务入口与 Wire configs/ 运行配置 internal/biz/ 领域对象、用例和仓储接口 internal/data/ 数据库、缓存、对象存储及仓储实现 -internal/server/ Gin 服务、路由和定时任务调度 +internal/server/ Gin 服务、路由、中间件和 Handler internal/service/ HTTP 输入输出与领域对象转换 +internal/worker/ 定时任务调度与后台工作进程 web/ Vue 管理端 ``` @@ -24,6 +25,8 @@ go run ./cmd/kratos-admin -conf ./configs 默认 HTTP 地址为 `http://127.0.0.1:8000`。生产环境必须替换 JWT 签名密钥和数据库凭据。 +配置文件发生变化时会自动刷新内存配置;管理端“重载服务”会进一步重建数据库、Redis、对象存储连接并重新载入定时任务。路由前缀和 HTTP 监听地址需要重启进程后生效。 + ## 启动前端 ```shell diff --git a/cmd/kratos-admin/main.go b/cmd/kratos-admin/main.go index 5bd8d16..7403078 100644 --- a/cmd/kratos-admin/main.go +++ b/cmd/kratos-admin/main.go @@ -7,14 +7,14 @@ import ( "path/filepath" "kra/internal/conf" - "kra/internal/server" "kra/internal/worker" + "kra/pkg/logging" - "github.com/go-kratos/kratos/contrib/otel/v3/tracing" "github.com/go-kratos/kratos/v3" "github.com/go-kratos/kratos/v3/config" "github.com/go-kratos/kratos/v3/config/file" "github.com/go-kratos/kratos/v3/log" + kratoshttp "github.com/go-kratos/kratos/v3/transport/http" _ "go.uber.org/automaxprocs" ) @@ -35,7 +35,7 @@ func init() { flag.StringVar(&flagconf, "conf", "../../configs", "config path, eg: -conf config.yaml") } -func newApp(logger *slog.Logger, hs *server.GinServer, scheduler *worker.TaskScheduler) *kratos.App { +func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskScheduler) *kratos.App { return kratos.New( kratos.ID(id), kratos.Name(Name), @@ -51,17 +51,12 @@ func newApp(logger *slog.Logger, hs *server.GinServer, scheduler *worker.TaskSch func main() { flag.Parse() - logger := log.NewLogger( - slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - AddSource: true, - Level: slog.LevelInfo, - }), - log.WithExtractor(tracing.TraceAttrs), - ).With( + logger, closeLogger := logging.NewZapLogger("logs", "application.log", slog.String("service.id", id), slog.String("service.name", Name), slog.String("service.version", Version), ) + defer closeLogger() log.SetDefault(logger) c := config.New( config.WithSource( diff --git a/cmd/kratos-admin/wire.go b/cmd/kratos-admin/wire.go index f113d21..d5d7e47 100644 --- a/cmd/kratos-admin/wire.go +++ b/cmd/kratos-admin/wire.go @@ -20,5 +20,5 @@ import ( // wireApp init kratos application. func wireApp(*conf.Server, *conf.Data, *conf.AdminBackend, *slog.Logger) (*kratos.App, func(), error) { - panic(wire.Build(server.ProviderSet, data.ProviderSet, biz.ProviderSet, service.ProviderSet, newApp)) + panic(wire.Build(conf.NewRuntime, server.ProviderSet, data.ProviderSet, biz.ProviderSet, service.ProviderSet, newApp)) } diff --git a/cmd/kratos-admin/wire_gen.go b/cmd/kratos-admin/wire_gen.go index d9552f5..9c8a256 100644 --- a/cmd/kratos-admin/wire_gen.go +++ b/cmd/kratos-admin/wire_gen.go @@ -26,7 +26,8 @@ import ( // wireApp init kratos application. func wireApp(confServer *conf.Server, confData *conf.Data, adminBackend *conf.AdminBackend, logger *slog.Logger) (*kratos.App, func(), error) { - dataData, cleanup, err := data.NewData(confData, adminBackend) + runtime := conf.NewRuntime(confData, adminBackend) + dataData, cleanup, err := data.NewData(runtime) if err != nil { return nil, nil, err } @@ -40,8 +41,8 @@ func wireApp(confServer *conf.Server, confData *conf.Data, adminBackend *conf.Ad systemUsecase := biz.NewSystemUsecase(systemRepo, cache, fileStorage) settingsRepo := data.NewSettingsRepo(dataData) settingsUsecase := biz.NewSettingsUsecase(settingsRepo) - settingsService := service.NewSettingsService(settingsUsecase, adminBackend) - systemService := service.NewSystemService(systemUsecase, adminBackend, settingsService) + settingsService := service.NewSettingsService(settingsUsecase, runtime, cache) + systemService := service.NewSystemService(systemUsecase, runtime, settingsService) accessRepo := data.NewAccessRepo(dataData) accessUsecase := biz.NewAccessUsecase(accessRepo) accessService := service.NewAccessService(accessUsecase) @@ -60,7 +61,7 @@ func wireApp(confServer *conf.Server, confData *conf.Data, adminBackend *conf.Ad announcementUsecase := biz.NewAnnouncementUsecase(announcementRepo) announcementService := service.NewAnnouncementService(announcementUsecase) announcement := handler.NewAnnouncement(announcementService) - emailRepo := data.NewEmailRepo(adminBackend) + emailRepo := data.NewEmailRepo(runtime) emailUsecase := biz.NewEmailUsecase(emailRepo) emailService := service.NewEmailService(emailUsecase) email := handler.NewEmail(emailService) @@ -68,7 +69,7 @@ func wireApp(confServer *conf.Server, confData *conf.Data, adminBackend *conf.Ad taskUsecase := biz.NewTaskUsecase(taskRepo) mediaRepo := data.NewMediaRepo(dataData) mediaUsecase := biz.NewMediaUsecase(mediaRepo, fileStorage) - taskService := service.NewTaskService(taskUsecase, mediaUsecase, adminBackend) + taskService := service.NewTaskService(taskUsecase, mediaUsecase, runtime) taskScheduler := worker.NewTaskScheduler(taskService, logger) task := handler.NewTask(taskService, taskScheduler) mediaService := service.NewMediaService(mediaUsecase) @@ -89,12 +90,12 @@ func wireApp(confServer *conf.Server, confData *conf.Data, adminBackend *conf.Ad parameter := handler.NewParameter(settingsService) apiToken := handler.NewAPIToken(settingsService) systemConfig := handler.NewSystemConfig(systemService, settingsService, taskScheduler) - public := handler.NewPublic(adminBackend, systemService, settingsService, auditService, taskScheduler) + public := handler.NewPublic(runtime, systemService, settingsService, auditService, taskScheduler) user := handler.NewUser(systemService) navigation := handler.NewNavigation(systemService) session := handler.NewSession(settingsService) - ginServer := server.NewGinServer(confServer, adminBackend, systemService, accessService, authority, menu, api, permission, organization, announcement, email, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session, settingsService, auditService, emailService, logger) - app := newApp(logger, ginServer, taskScheduler) + httpServer := server.NewGinServer(confServer, runtime, systemService, accessService, authority, menu, api, permission, organization, announcement, email, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session, settingsService, auditService, emailService, logger) + app := newApp(logger, httpServer, taskScheduler) return app, func() { cleanup() }, nil diff --git a/configs/config.yaml b/configs/config.yaml index d483277..75c0806 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -1,7 +1,7 @@ server: http: addr: 0.0.0.0:8000 - timeout: 1s + timeout: 600s data: database: driver: mysql @@ -16,6 +16,7 @@ data: addr: 127.0.0.1:6379 read_timeout: 0.2s write_timeout: 0.2s + database_list: [] admin: router_prefix: "" jwt: @@ -34,6 +35,13 @@ admin: path_prefix: uploads/file media: session_ttl: 24 + system: + use_redis: false + use_multipoint: false + use_strict_auth: false + disable_auto_migrate: false + disk_list: + - mount_point: / storage: # local, qiniu, aliyun-oss, huawei-obs, tencent-cos, aws-s3, # cloudflare-r2 or minio diff --git a/go.mod b/go.mod index b71bd53..a282256 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 github.com/casbin/casbin/v3 v3.10.0 github.com/dzwvip/gorm-oracle v0.1.2 + github.com/fsnotify/fsnotify v1.10.1 github.com/gin-gonic/gin v1.10.0 github.com/glebarez/sqlite v1.11.0 github.com/go-kratos/kratos/contrib/otel/v3 v3.0.0-20260617100506-4e232a3eff59 @@ -29,6 +30,8 @@ require ( github.com/xuri/excelize/v2 v2.9.0 go.einride.tech/aip v0.86.3 go.uber.org/automaxprocs v1.6.0 + go.uber.org/zap v1.27.0 + go.uber.org/zap/exp v0.3.0 golang.org/x/crypto v0.53.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 @@ -71,7 +74,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.8.4 // indirect github.com/emirpasic/gods v1.12.0 // indirect - github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gammazero/toposort v0.1.1 // indirect github.com/gin-contrib/sse v0.1.0 // indirect @@ -134,6 +136,7 @@ require ( go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/image v0.23.0 // indirect golang.org/x/net v0.56.0 // indirect diff --git a/go.sum b/go.sum index 6b7e0a6..9c1cb74 100644 --- a/go.sum +++ b/go.sum @@ -175,8 +175,6 @@ github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -364,6 +362,14 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= @@ -390,8 +396,6 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -487,8 +491,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= diff --git a/internal/biz/README.md b/internal/biz/README.md index 26a66b6..97a06f5 100644 --- a/internal/biz/README.md +++ b/internal/biz/README.md @@ -1 +1,3 @@ # Biz + +本目录只包含领域对象、用例和仓储接口,不依赖 HTTP Handler、数据库驱动或对象存储 SDK。跨多表、必须原子完成的业务动作通过具有业务含义的仓储方法表达。 diff --git a/internal/biz/access.go b/internal/biz/access.go index d586021..87a9659 100644 --- a/internal/biz/access.go +++ b/internal/biz/access.go @@ -2,6 +2,7 @@ package biz import ( "context" + "strings" "time" ) @@ -13,9 +14,28 @@ type API struct { Description string APIGroup string Method string + OrderKey string + Desc bool + StrictAll bool } -type AccessRepo interface { +type APIRepo interface { + CreateAPI(context.Context, *API) error + UpdateAPI(context.Context, *API) error + DeleteAPIs(context.Context, []uint) error + FindAPI(context.Context, uint) (*API, error) + ListAPIs(context.Context, int, int, *API) ([]*API, int64, error) + APIRoleIDs(context.Context, string, string) ([]uint, error) + SetAPIRoles(context.Context, string, string, []uint) error + Authorize(context.Context, uint, string, string) (bool, error) + PolicyPaths(context.Context, uint) ([]*API, error) + SetPolicyPaths(context.Context, uint, []*API) error + IgnoredAPIs(context.Context) ([]*API, error) + SetAPIIgnored(context.Context, string, string, bool) error + ApplyAPISync(context.Context, []*API, []*API) error +} + +type AuthorityAccessRepo interface { CreateAuthority(context.Context, *Authority) error CopyAuthority(context.Context, uint, *Authority) error UpdateAuthority(context.Context, *Authority) error @@ -26,21 +46,9 @@ type AccessRepo interface { SetDataScope(context.Context, uint, int, []uint) error DataScopeDepartmentIDs(context.Context, uint) ([]uint, error) ResolveDataScope(context.Context, uint, uint) (DataScope, error) +} - CreateAPI(context.Context, *API) error - UpdateAPI(context.Context, *API) error - DeleteAPIs(context.Context, []uint) error - FindAPI(context.Context, uint) (*API, error) - ListAPIs(context.Context, int, int, *API) ([]*API, int64, error) - APIRoleIDs(context.Context, uint) ([]uint, error) - SetAPIRoles(context.Context, uint, []uint) error - Authorize(context.Context, uint, string, string) (bool, error) - PolicyPaths(context.Context, uint) ([]*API, error) - SetPolicyPaths(context.Context, uint, []*API) error - IgnoredAPIs(context.Context) ([]*API, error) - SetAPIIgnored(context.Context, string, string, bool) error - ApplyAPISync(context.Context, []*API, []*API) error - +type PermissionRepo interface { Buttons(context.Context, uint) ([]*MenuButton, error) SetAuthorityButtons(context.Context, uint, map[uint][]uint) error AuthorityButtonIDs(context.Context, uint) ([]uint, error) @@ -49,14 +57,85 @@ type AccessRepo interface { CanRemoveButton(context.Context, uint) (bool, error) } -type AccessUsecase struct{ repo AccessRepo } +type AccessRepo interface { + AuthorityAccessRepo + APIRepo + PermissionRepo +} -func NewAccessUsecase(repo AccessRepo) *AccessUsecase { return &AccessUsecase{repo: repo} } +type AccessUsecase struct{ AccessRepo } + +func NewAccessUsecase(repo AccessRepo) *AccessUsecase { return &AccessUsecase{AccessRepo: repo} } -func (uc *AccessUsecase) Repo() AccessRepo { return uc.repo } func (uc *AccessUsecase) Authorize(ctx context.Context, authorityID uint, path, method string) (bool, error) { if authorityID == 888 { return true, nil } - return uc.repo.Authorize(ctx, authorityID, path, method) + return uc.AccessRepo.Authorize(ctx, authorityID, path, method) +} + +type APISyncDiff struct { + Added []*API + Deleted []*API + Ignored []*API +} + +func (uc *AccessUsecase) SyncAPIs(ctx context.Context, routes []*API) (*APISyncDiff, error) { + stored, _, err := uc.ListAPIs(ctx, 0, 0, nil) + if err != nil { + return nil, err + } + ignored, err := uc.IgnoredAPIs(ctx) + if err != nil { + return nil, err + } + key := func(value *API) string { return strings.ToUpper(value.Method) + " " + value.Path } + ignoreSet := make(map[string]bool, len(ignored)) + for _, item := range ignored { + ignoreSet[key(item)] = true + } + routeSet := make(map[string]*API, len(routes)) + for _, item := range routes { + if !ignoreSet[key(item)] { + routeSet[key(item)] = item + } + } + storedSet := make(map[string]*API, len(stored)) + for _, item := range stored { + storedSet[key(item)] = item + } + diff := &APISyncDiff{Ignored: ignored} + for routeKey, item := range routeSet { + if storedSet[routeKey] == nil { + diff.Added = append(diff.Added, item) + } + } + for storedKey, item := range storedSet { + if routeSet[storedKey] == nil && !ignoreSet[storedKey] { + diff.Deleted = append(diff.Deleted, item) + } + } + return diff, nil +} + +func (uc *AccessUsecase) AuthorityTree(ctx context.Context) ([]*Authority, error) { + items, err := uc.ListAuthorities(ctx) + if err != nil { + return nil, err + } + byID := make(map[uint]*Authority, len(items)) + for _, item := range items { + item.Children = nil + byID[item.AuthorityID] = item + } + roots := make([]*Authority, 0) + for _, item := range items { + if item.ParentID != nil && *item.ParentID != 0 && byID[*item.ParentID] != nil { + parent := byID[*item.ParentID] + parent.Children = append(parent.Children, item) + } else { + roots = append(roots, item) + } + } + return roots, nil } diff --git a/internal/biz/actor.go b/internal/biz/actor.go new file mode 100644 index 0000000..118a6c5 --- /dev/null +++ b/internal/biz/actor.go @@ -0,0 +1,19 @@ +package biz + +import "context" + +type actorContextKey struct{} + +type Actor struct { + UserID uint + AuthorityID uint +} + +func NewActorContext(ctx context.Context, actor Actor) context.Context { + return context.WithValue(ctx, actorContextKey{}, actor) +} + +func ActorFromContext(ctx context.Context) (Actor, bool) { + actor, ok := ctx.Value(actorContextKey{}).(Actor) + return actor, ok +} diff --git a/internal/biz/api_token.go b/internal/biz/api_token.go index 504574c..0925377 100644 --- a/internal/biz/api_token.go +++ b/internal/biz/api_token.go @@ -2,6 +2,7 @@ package biz import ( "context" + "errors" "time" ) @@ -23,6 +24,29 @@ type APITokenRepo interface { CreateAPIToken(context.Context, *APIToken) error ListAPITokens(context.Context, int, int, uint, *bool) ([]*APIToken, int64, error) DisableAPIToken(context.Context, uint) (string, error) + DisableAndBlacklistAPIToken(context.Context, uint) error BlacklistToken(context.Context, string) error IsTokenDisabled(context.Context, string) (bool, error) } + +func (uc *SettingsUsecase) PrepareAPIToken(ctx context.Context, userID, authorityID uint, days int) (*User, time.Duration, error) { + user, allowed, err := uc.UserHasAuthority(ctx, userID, authorityID) + if err != nil { + return nil, 0, err + } + if !allowed { + return nil, 0, errors.New("用户不具备该角色权限") + } + duration := time.Duration(days) * 24 * time.Hour + if days == -1 { + duration = 100 * 365 * 24 * time.Hour + } + if duration <= 0 { + return nil, 0, errors.New("有效天数必须大于0或为-1") + } + return user, duration, nil +} + +func (uc *SettingsUsecase) DisableToken(ctx context.Context, id uint) error { + return uc.SettingsRepo.DisableAndBlacklistAPIToken(ctx, id) +} diff --git a/internal/biz/audit.go b/internal/biz/audit.go index 3821248..6b45473 100644 --- a/internal/biz/audit.go +++ b/internal/biz/audit.go @@ -14,7 +14,16 @@ type OperationRecord struct { Agent, ErrorMessage, Body, Response string UserID uint RequestID, TraceID, DeviceID string + User *User } + +type OperationLogRepo interface { + RecordOperation(context.Context, *OperationRecord) error + ListOperations(context.Context, int, int, *OperationRecord) ([]*OperationRecord, int64, error) + FindOperation(context.Context, uint) (*OperationRecord, error) + DeleteOperations(context.Context, []uint) error +} + type LoginLog struct { ID uint CreatedAt time.Time @@ -22,7 +31,17 @@ type LoginLog struct { Status bool ErrorMessage, Agent string UserID uint + User *User + FilterByStatus bool } + +type LoginLogRepo interface { + RecordLogin(context.Context, *LoginLog) error + ListLogins(context.Context, int, int, *LoginLog) ([]*LoginLog, int64, error) + FindLogin(context.Context, uint) (*LoginLog, error) + DeleteLogins(context.Context, []uint) error +} + type DataAccessLog struct { ID uint CreatedAt time.Time @@ -31,6 +50,13 @@ type DataAccessLog struct { Scope int RequestID, Method, Path, Detail string } + +type DataAccessLogRepo interface { + RecordDataAccess(context.Context, *DataAccessLog) error + ListDataAccess(context.Context, int, int, *DataAccessLog) ([]*DataAccessLog, int64, error) + DeleteDataAccess(context.Context, []uint) error +} + type LogDate struct { Date string FileCount int @@ -48,33 +74,36 @@ type LogContent struct { Size int64 ModifiedAt time.Time } + +type LogFileRepo interface { + LogDates(context.Context, string) ([]LogDate, error) + LogFiles(context.Context, string) ([]LogFile, error) + LogContent(context.Context, string, string, *int64) (*LogContent, error) +} + type ErrorRecord struct { ID uint CreatedAt time.Time Form, Info, Level, RequestID, TraceID, Solution, Status string + CreatedAtRange []time.Time } -type AuditRepo interface { - RecordOperation(context.Context, *OperationRecord) error - ListOperations(context.Context, int, int, *OperationRecord) ([]*OperationRecord, int64, error) - FindOperation(context.Context, uint) (*OperationRecord, error) - DeleteOperations(context.Context, []uint) error - RecordLogin(context.Context, *LoginLog) error - ListLogins(context.Context, int, int, *LoginLog) ([]*LoginLog, int64, error) - FindLogin(context.Context, uint) (*LoginLog, error) - DeleteLogins(context.Context, []uint) error - RecordDataAccess(context.Context, *DataAccessLog) error - ListDataAccess(context.Context, int, int, *DataAccessLog) ([]*DataAccessLog, int64, error) - DeleteDataAccess(context.Context, []uint) error - LogDates(context.Context, string) ([]LogDate, error) - LogFiles(context.Context, string) ([]LogFile, error) - LogContent(context.Context, string, string, *int64) (*LogContent, error) + +type ErrorRecordRepo interface { CreateError(context.Context, *ErrorRecord) error UpdateError(context.Context, *ErrorRecord) error DeleteErrors(context.Context, []uint) error FindError(context.Context, uint) (*ErrorRecord, error) ListErrors(context.Context, int, int, *ErrorRecord) ([]*ErrorRecord, int64, error) } -type AuditUsecase struct{ repo AuditRepo } -func NewAuditUsecase(repo AuditRepo) *AuditUsecase { return &AuditUsecase{repo: repo} } -func (uc *AuditUsecase) Repo() AuditRepo { return uc.repo } +type AuditRepo interface { + OperationLogRepo + LoginLogRepo + DataAccessLogRepo + LogFileRepo + ErrorRecordRepo +} + +type AuditUsecase struct{ AuditRepo } + +func NewAuditUsecase(repo AuditRepo) *AuditUsecase { return &AuditUsecase{AuditRepo: repo} } diff --git a/internal/biz/dictionary.go b/internal/biz/dictionary.go index fcc32ae..a860d41 100644 --- a/internal/biz/dictionary.go +++ b/internal/biz/dictionary.go @@ -5,19 +5,6 @@ import ( "time" ) -type Dictionary struct { - ID uint - CreatedAt time.Time - UpdatedAt time.Time - Name string - Type string - Status bool - Desc string - ParentID *uint - Children []*Dictionary - Details []*DictionaryDetail -} - type DictionaryDetail struct { ID uint CreatedAt time.Time @@ -34,16 +21,66 @@ type DictionaryDetail struct { Children []*DictionaryDetail } -type DictionaryRepo interface { - CreateDictionary(context.Context, *Dictionary) error - UpdateDictionary(context.Context, *Dictionary) error - DeleteDictionary(context.Context, uint) error - FindDictionary(context.Context, uint, string, bool) (*Dictionary, error) - ListDictionaries(context.Context, int, int, string, string, bool) ([]*Dictionary, int64, error) +type DictionaryDetailFilter struct { + DictionaryID uint + Label string + Value string + Status *bool + ParentID *uint + Level *int +} + +type DictionaryDetailRepo interface { CreateDictionaryDetail(context.Context, *DictionaryDetail) error UpdateDictionaryDetail(context.Context, *DictionaryDetail) error DeleteDictionaryDetail(context.Context, uint) error FindDictionaryDetail(context.Context, uint) (*DictionaryDetail, error) - ListDictionaryDetails(context.Context, int, int, uint, string, string) ([]*DictionaryDetail, int64, error) + ListDictionaryDetails(context.Context, int, int, DictionaryDetailFilter) ([]*DictionaryDetail, int64, error) DictionaryDetailTree(context.Context, uint, string) ([]*DictionaryDetail, error) } + +type Dictionary struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Name string + Type string + Status bool + Desc string + ParentID *uint + Children []*Dictionary + Details []*DictionaryDetail +} + +type DictionaryRepo interface { + DictionaryMetadataRepo + DictionaryDetailRepo +} + +type DictionaryMetadataRepo interface { + CreateDictionary(context.Context, *Dictionary) error + ImportDictionary(context.Context, *Dictionary, []*DictionaryDetail) error + UpdateDictionary(context.Context, *Dictionary) error + DeleteDictionary(context.Context, uint) error + FindDictionary(context.Context, uint, string, *bool, bool) (*Dictionary, error) + ExportDictionary(context.Context, uint) (*Dictionary, error) + ListDictionaries(context.Context, int, int, string, string, bool) ([]*Dictionary, int64, error) +} + +func (uc *SettingsUsecase) ImportDictionary(ctx context.Context, dictionary *Dictionary, details []*DictionaryDetail) error { + return uc.SettingsRepo.ImportDictionary(ctx, dictionary, details) +} + +func (uc *SettingsUsecase) DictionaryDetailsByParent(ctx context.Context, dictionaryID, parentID uint) ([]*DictionaryDetail, error) { + items, _, err := uc.ListDictionaryDetails(ctx, 0, 0, DictionaryDetailFilter{DictionaryID: dictionaryID}) + if err != nil { + return nil, err + } + out := make([]*DictionaryDetail, 0) + for _, item := range items { + if (parentID == 0 && item.ParentID == nil) || (item.ParentID != nil && *item.ParentID == parentID) { + out = append(out, item) + } + } + return out, nil +} diff --git a/internal/biz/errors.go b/internal/biz/errors.go index 6922727..1eb7be4 100644 --- a/internal/biz/errors.go +++ b/internal/biz/errors.go @@ -9,4 +9,5 @@ var ( // ErrInvalidCredentials is returned for any failed login, regardless of // whether the user exists, to avoid leaking which accounts are registered. ErrInvalidCredentials = errors.Unauthorized("INVALID_CREDENTIALS", "invalid credentials") + ErrUserDisabled = errors.Forbidden("USER_DISABLED", "user disabled") ) diff --git a/internal/biz/export.go b/internal/biz/export.go index cc0030d..9457b47 100644 --- a/internal/biz/export.go +++ b/internal/biz/export.go @@ -16,6 +16,8 @@ type ExportTemplate struct { Order string Conditions []ExportCondition Joins []ExportJoin + StartCreatedAt *time.Time + EndCreatedAt *time.Time } type ExportRepo interface { CreateExportTemplate(context.Context, *ExportTemplate) error @@ -24,9 +26,9 @@ type ExportRepo interface { FindExportTemplate(context.Context, uint, string) (*ExportTemplate, error) ListExportTemplates(context.Context, int, int, *ExportTemplate) ([]*ExportTemplate, int64, error) QueryExport(context.Context, *ExportTemplate, map[string]string) ([]map[string]any, string, error) + PreviewExport(context.Context, *ExportTemplate, map[string]string) (string, error) ImportExportRows(context.Context, *ExportTemplate, []map[string]any) error } -type ExportUsecase struct{ repo ExportRepo } +type ExportUsecase struct{ ExportRepo } -func NewExportUsecase(repo ExportRepo) *ExportUsecase { return &ExportUsecase{repo: repo} } -func (uc *ExportUsecase) Repo() ExportRepo { return uc.repo } +func NewExportUsecase(repo ExportRepo) *ExportUsecase { return &ExportUsecase{ExportRepo: repo} } diff --git a/internal/biz/media.go b/internal/biz/media.go index 74e1e58..64430fe 100644 --- a/internal/biz/media.go +++ b/internal/biz/media.go @@ -13,62 +13,35 @@ import ( "github.com/google/uuid" ) -type MediaFile struct { - ID uint - CreatedAt time.Time - UpdatedAt time.Time - Name string - CategoryID int - URL, Tag, Key string - Size int64 - Mime, MD5 string - UserID uint -} -type AttachmentCategory struct { - ID uint - CreatedAt time.Time - UpdatedAt time.Time - Name string - ParentID uint - Children []*AttachmentCategory -} -type MediaMetadataRepo interface { - CreateMedia(context.Context, *MediaFile) error - FindMedia(context.Context, uint) (*MediaFile, error) - FindMediaByHash(context.Context, uint, string) (*MediaFile, error) - ListMedia(context.Context, int, int, string, int, string, uint) ([]*MediaFile, int64, error) - UpdateMediaName(context.Context, uint, string) error - DeleteMedia(context.Context, uint) error - MediaKeyReferences(context.Context, string) (int64, error) - CreateMediaBatch(context.Context, []*MediaFile) error - SaveCategory(context.Context, *AttachmentCategory) error - DeleteCategory(context.Context, uint) error - ListCategories(context.Context) ([]*AttachmentCategory, error) -} - type MediaRepo interface { MediaMetadataRepo UploadRepo } type MediaUsecase struct { - repo MediaRepo + MediaRepo files FileStorage } func NewMediaUsecase(repo MediaRepo, files FileStorage) *MediaUsecase { - return &MediaUsecase{repo: repo, files: files} + return &MediaUsecase{MediaRepo: repo, files: files} } -func (uc *MediaUsecase) Repo() MediaRepo { return uc.repo } -var blockedExtensions = map[string]bool{".exe": true, ".dll": true, ".bat": true, ".cmd": true, ".sh": true, ".php": true, ".jsp": true, ".asp": true, ".aspx": true, ".html": true, ".htm": true, ".svg": true} +var allowedMediaExtensions = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".bmp": true, ".ico": true, ".avif": true, + ".mp3": true, ".wav": true, ".ogg": true, ".m4a": true, ".flac": true, ".aac": true, + ".mp4": true, ".webm": true, ".mov": true, ".avi": true, ".mkv": true, + ".txt": true, ".md": true, ".csv": true, ".json": true, ".log": true, ".pdf": true, + ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, + ".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true, ".tgz": true, ".bin": true, +} func validateMediaName(name string) error { - if name == "" || filepath.Base(name) != name { - return errors.New("文件名不合法") + if name == "" || strings.TrimSpace(name) != name || strings.ContainsAny(name, `/\`) { + return errors.New("file extension is not allowed") } - if blockedExtensions[strings.ToLower(filepath.Ext(name))] { - return errors.New("文件类型不允许上传") + if !allowedMediaExtensions[strings.ToLower(filepath.Ext(name))] { + return errors.New("file extension is not allowed") } return nil } @@ -85,7 +58,7 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, mime stri } media := &MediaFile{Name: name, CategoryID: categoryID, URL: stored.URL, Tag: strings.TrimPrefix(ext, "."), Key: key, Size: stored.Size, Mime: mime, MD5: hex.EncodeToString(hash.Sum(nil)), UserID: userID} if save { - if err = uc.repo.CreateMedia(ctx, media); err != nil { + if err = uc.CreateMedia(ctx, media); err != nil { _ = uc.files.Delete(ctx, key) return nil, err } @@ -93,11 +66,11 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, mime stri return media, nil } func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error { - media, err := uc.repo.FindMedia(ctx, id) + media, err := uc.FindMedia(ctx, id) if err != nil { return err } - count, err := uc.repo.MediaKeyReferences(ctx, media.Key) + count, err := uc.MediaKeyReferences(ctx, media.Key) if err != nil { return err } @@ -106,7 +79,7 @@ func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error { return err } } - return uc.repo.DeleteMedia(ctx, id) + return uc.DeleteMedia(ctx, id) } func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string, limit int) ([]*StoredFile, string, bool, error) { return uc.files.List(ctx, prefix, cursor, limit) diff --git a/internal/biz/media_metadata.go b/internal/biz/media_metadata.go new file mode 100644 index 0000000..48ad5d4 --- /dev/null +++ b/internal/biz/media_metadata.go @@ -0,0 +1,53 @@ +package biz + +import ( + "context" + "time" +) + +type MediaFile struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Name string + CategoryID int + URL, Tag, Key string + Size int64 + Mime, MD5 string + UserID uint +} + +type MediaFilter struct { + Page, PageSize int + Keyword string + CategoryID int + Tag string + UserID uint + StartCreatedAt time.Time + EndCreatedAt time.Time + OrderKey string + Desc bool +} + +type AttachmentCategory struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Name string + ParentID uint + Children []*AttachmentCategory +} + +type MediaMetadataRepo interface { + CreateMedia(context.Context, *MediaFile) error + FindMedia(context.Context, uint) (*MediaFile, error) + FindMediaByHash(context.Context, uint, string) (*MediaFile, error) + ListMedia(context.Context, MediaFilter) ([]*MediaFile, int64, error) + UpdateMediaName(context.Context, uint, string) error + DeleteMedia(context.Context, uint) error + MediaKeyReferences(context.Context, string) (int64, error) + CreateMediaBatch(context.Context, []*MediaFile) error + SaveCategory(context.Context, *AttachmentCategory) error + DeleteCategory(context.Context, uint) error + ListCategories(context.Context) ([]*AttachmentCategory, error) +} diff --git a/internal/biz/media_upload.go b/internal/biz/media_upload.go index 9dec91f..958e2f4 100644 --- a/internal/biz/media_upload.go +++ b/internal/biz/media_upload.go @@ -15,33 +15,6 @@ import ( "github.com/google/uuid" ) -type UploadSession struct { - ID uint - UserID uint - FileName, FileHash string - FileSize, ChunkSize int64 - ChunkTotal int - Status, StorageKey string - MediaID uint -} -type UploadChunk struct { - Index int - Hash string - Size int64 -} -type UploadRepo interface { - FindUploadingSession(context.Context, uint, string) (*UploadSession, error) - CreateUploadSession(context.Context, *UploadSession) error - FindUploadSession(context.Context, uint) (*UploadSession, error) - CompleteUploadSession(context.Context, uint, string, uint) error - DeleteUploadSession(context.Context, uint) error - UpsertChunk(context.Context, uint, *UploadChunk) error - ListChunks(context.Context, uint) ([]*UploadChunk, error) - DeleteChunks(context.Context, uint) error - StaleUploadSessionIDs(context.Context, time.Time) ([]uint, error) - DeleteUploadData(context.Context, uint) error -} - func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash string, size, chunkSize int64, total int) (*UploadSession, *MediaFile, []int, error) { if err := validateMediaName(name); err != nil { return nil, nil, nil, err @@ -49,22 +22,22 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash if size <= 0 || chunkSize <= 0 || total <= 0 { return nil, nil, nil, errors.New("上传参数不合法") } - if media, err := uc.repo.FindMediaByHash(ctx, userID, hash); err == nil { + if media, err := uc.FindMediaByHash(ctx, userID, hash); err == nil { copy := *media copy.ID = 0 copy.Name = name - if err = uc.repo.CreateMedia(ctx, ©); err == nil { + if err = uc.CreateMedia(ctx, ©); err == nil { return nil, ©, nil, nil } } - session, err := uc.repo.FindUploadingSession(ctx, userID, hash) + session, err := uc.FindUploadingSession(ctx, userID, hash) if err != nil { session = &UploadSession{UserID: userID, FileName: name, FileHash: hash, FileSize: size, ChunkSize: chunkSize, ChunkTotal: total, Status: "uploading"} - if err = uc.repo.CreateUploadSession(ctx, session); err != nil { + if err = uc.CreateUploadSession(ctx, session); err != nil { return nil, nil, nil, err } } - chunks, err := uc.repo.ListChunks(ctx, session.ID) + chunks, err := uc.ListChunks(ctx, session.ID) if err != nil { return nil, nil, nil, err } @@ -76,7 +49,7 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash return session, nil, indices, nil } func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, index int, expected string, reader io.Reader) error { - session, err := uc.repo.FindUploadSession(ctx, uploadID) + session, err := uc.FindUploadSession(ctx, uploadID) if err != nil || session.UserID != userID { return errors.New("上传会话不存在或无权操作") } @@ -94,14 +67,14 @@ func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, in _ = uc.files.Delete(ctx, key) return errors.New("分片校验失败") } - return uc.repo.UpsertChunk(ctx, uploadID, &UploadChunk{Index: index, Hash: actual, Size: stored.Size}) + return uc.UpsertChunk(ctx, uploadID, &UploadChunk{Index: index, Hash: actual, Size: stored.Size}) } func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uint, mime string) (*MediaFile, error) { - session, err := uc.repo.FindUploadSession(ctx, uploadID) + session, err := uc.FindUploadSession(ctx, uploadID) if err != nil || session.UserID != userID { return nil, errors.New("上传会话不存在或无权操作") } - chunks, err := uc.repo.ListChunks(ctx, uploadID) + chunks, err := uc.ListChunks(ctx, uploadID) if err != nil || len(chunks) != session.ChunkTotal { return nil, errors.New("分片不完整") } @@ -124,34 +97,34 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin return nil, errors.New("整文件校验失败") } media := &MediaFile{Name: session.FileName, URL: stored.URL, Tag: strings.TrimPrefix(ext, "."), Key: key, Size: stored.Size, Mime: mime, MD5: hash, UserID: userID} - if err = uc.repo.CreateMedia(ctx, media); err != nil { + if err = uc.CreateMedia(ctx, media); err != nil { _ = uc.files.Delete(ctx, key) return nil, err } - _ = uc.repo.CompleteUploadSession(ctx, uploadID, key, media.ID) - _ = uc.repo.DeleteChunks(ctx, uploadID) + _ = uc.CompleteUploadSession(ctx, uploadID, key, media.ID) + _ = uc.DeleteChunks(ctx, uploadID) _ = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", uploadID)) return media, nil } func (uc *MediaUsecase) CancelUpload(ctx context.Context, userID, uploadID uint) error { - session, err := uc.repo.FindUploadSession(ctx, uploadID) + session, err := uc.FindUploadSession(ctx, uploadID) if err != nil || session.UserID != userID { return errors.New("上传会话不存在或无权操作") } - _ = uc.repo.DeleteChunks(ctx, uploadID) + _ = uc.DeleteChunks(ctx, uploadID) _ = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", uploadID)) - return uc.repo.DeleteUploadSession(ctx, uploadID) + return uc.DeleteUploadSession(ctx, uploadID) } func (uc *MediaUsecase) CleanupStale(ctx context.Context, ttlHours int) error { if ttlHours <= 0 { ttlHours = 24 } - ids, err := uc.repo.StaleUploadSessionIDs(ctx, time.Now().Add(-time.Duration(ttlHours)*time.Hour)) + ids, err := uc.StaleUploadSessionIDs(ctx, time.Now().Add(-time.Duration(ttlHours)*time.Hour)) if err != nil { return err } for _, id := range ids { - if err = uc.repo.DeleteUploadData(ctx, id); err != nil { + if err = uc.DeleteUploadData(ctx, id); err != nil { return err } if err = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", id)); err != nil { diff --git a/internal/biz/menu.go b/internal/biz/menu.go index 90430ae..bdc3d00 100644 --- a/internal/biz/menu.go +++ b/internal/biz/menu.go @@ -47,6 +47,7 @@ type MenuRepo interface { SetAuthorityMenus(context.Context, uint, []uint) error AuthorityMenuIDs(context.Context, uint) ([]uint, error) MenuRoleIDs(context.Context, uint) ([]uint, error) + DefaultRouterRoleIDs(context.Context, uint) ([]uint, error) SetMenuRoles(context.Context, uint, []uint) error } @@ -128,6 +129,9 @@ func (uc *MenuUsecase) AuthorityMenus(ctx context.Context, authorityID uint) ([] func (uc *MenuUsecase) RoleIDs(ctx context.Context, menuID uint) ([]uint, error) { return uc.repo.MenuRoleIDs(ctx, menuID) } +func (uc *MenuUsecase) DefaultRouterRoleIDs(ctx context.Context, menuID uint) ([]uint, error) { + return uc.repo.DefaultRouterRoleIDs(ctx, menuID) +} func (uc *MenuUsecase) SetRoles(ctx context.Context, menuID uint, ids []uint) error { return uc.repo.SetMenuRoles(ctx, menuID, ids) diff --git a/internal/biz/organization.go b/internal/biz/organization.go index 7e1abfe..f097c74 100644 --- a/internal/biz/organization.go +++ b/internal/biz/organization.go @@ -14,8 +14,28 @@ type Department struct { Ancestors string Sort int LeaderID uint + Leader *User Status bool Children []*Department + NamePath string +} + +type DepartmentRepo interface { + CreateDepartment(context.Context, *Department) error + UpdateDepartment(context.Context, *Department) error + DeleteDepartment(context.Context, uint) error + FindDepartment(context.Context, uint) (*Department, error) + ListDepartments(context.Context, string) ([]*Department, error) + DepartmentUserIDs(context.Context, uint) ([]uint, error) + SetDepartmentUsers(context.Context, uint, []uint) error + SetUserDepartments(context.Context, uint, []uint, uint) error +} + +func (uc *OrganizationUsecase) Departments(ctx context.Context, name string) ([]*Department, error) { + return uc.ListDepartments(ctx, name) +} +func (uc *OrganizationUsecase) Department(ctx context.Context, id uint) (*Department, error) { + return uc.FindDepartment(ctx, id) } type Position struct { @@ -29,76 +49,37 @@ type Position struct { Remark string } -type OrganizationRepo interface { - CreateDepartment(context.Context, *Department) error - UpdateDepartment(context.Context, *Department) error - DeleteDepartment(context.Context, uint) error - FindDepartment(context.Context, uint) (*Department, error) - ListDepartments(context.Context, string) ([]*Department, error) - DepartmentUserIDs(context.Context, uint) ([]uint, error) - SetDepartmentUsers(context.Context, uint, []uint) error - SetUserDepartments(context.Context, uint, []uint, uint) error +type PositionListFilter struct { + Name string + Code string + Status *bool +} + +type PositionRepo interface { CreatePosition(context.Context, *Position) error UpdatePosition(context.Context, *Position) error DeletePosition(context.Context, uint) error FindPosition(context.Context, uint) (*Position, error) - ListPositions(context.Context, int, int, *Position) ([]*Position, int64, error) + ListPositions(context.Context, int, int, *PositionListFilter) ([]*Position, int64, error) PositionUserIDs(context.Context, uint) ([]uint, error) SetPositionUsers(context.Context, uint, []uint) error SetUserPositions(context.Context, uint, []uint) error } -type OrganizationUsecase struct{ repo OrganizationRepo } - -func NewOrganizationUsecase(repo OrganizationRepo) *OrganizationUsecase { - return &OrganizationUsecase{repo: repo} -} - -func (uc *OrganizationUsecase) Departments(ctx context.Context, name string) ([]*Department, error) { - return uc.repo.ListDepartments(ctx, name) -} -func (uc *OrganizationUsecase) CreateDepartment(ctx context.Context, value *Department) error { - return uc.repo.CreateDepartment(ctx, value) -} -func (uc *OrganizationUsecase) UpdateDepartment(ctx context.Context, value *Department) error { - return uc.repo.UpdateDepartment(ctx, value) -} -func (uc *OrganizationUsecase) DeleteDepartment(ctx context.Context, id uint) error { - return uc.repo.DeleteDepartment(ctx, id) -} -func (uc *OrganizationUsecase) Department(ctx context.Context, id uint) (*Department, error) { - return uc.repo.FindDepartment(ctx, id) -} -func (uc *OrganizationUsecase) DepartmentUserIDs(ctx context.Context, id uint) ([]uint, error) { - return uc.repo.DepartmentUserIDs(ctx, id) -} -func (uc *OrganizationUsecase) SetDepartmentUsers(ctx context.Context, id uint, ids []uint) error { - return uc.repo.SetDepartmentUsers(ctx, id, ids) -} -func (uc *OrganizationUsecase) SetUserDepartments(ctx context.Context, id uint, ids []uint, primary uint) error { - return uc.repo.SetUserDepartments(ctx, id, ids, primary) -} -func (uc *OrganizationUsecase) Positions(ctx context.Context, page, size int, filter *Position) ([]*Position, int64, error) { - return uc.repo.ListPositions(ctx, page, size, filter) -} -func (uc *OrganizationUsecase) CreatePosition(ctx context.Context, value *Position) error { - return uc.repo.CreatePosition(ctx, value) -} -func (uc *OrganizationUsecase) UpdatePosition(ctx context.Context, value *Position) error { - return uc.repo.UpdatePosition(ctx, value) -} -func (uc *OrganizationUsecase) DeletePosition(ctx context.Context, id uint) error { - return uc.repo.DeletePosition(ctx, id) +func (uc *OrganizationUsecase) Positions(ctx context.Context, page, size int, filter *PositionListFilter) ([]*Position, int64, error) { + return uc.ListPositions(ctx, page, size, filter) } func (uc *OrganizationUsecase) Position(ctx context.Context, id uint) (*Position, error) { - return uc.repo.FindPosition(ctx, id) + return uc.FindPosition(ctx, id) } -func (uc *OrganizationUsecase) PositionUserIDs(ctx context.Context, id uint) ([]uint, error) { - return uc.repo.PositionUserIDs(ctx, id) + +type OrganizationRepo interface { + DepartmentRepo + PositionRepo } -func (uc *OrganizationUsecase) SetPositionUsers(ctx context.Context, id uint, ids []uint) error { - return uc.repo.SetPositionUsers(ctx, id, ids) -} -func (uc *OrganizationUsecase) SetUserPositions(ctx context.Context, id uint, ids []uint) error { - return uc.repo.SetUserPositions(ctx, id, ids) + +type OrganizationUsecase struct{ OrganizationRepo } + +func NewOrganizationUsecase(repo OrganizationRepo) *OrganizationUsecase { + return &OrganizationUsecase{OrganizationRepo: repo} } diff --git a/internal/biz/parameter.go b/internal/biz/parameter.go index 7bfa0e9..a89d850 100644 --- a/internal/biz/parameter.go +++ b/internal/biz/parameter.go @@ -6,13 +6,15 @@ import ( ) type SystemParameter struct { - ID uint - CreatedAt time.Time - UpdatedAt time.Time - Name string - Key string - Value string - Desc string + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Name string + Key string + Value string + Desc string + StartCreatedAt *time.Time + EndCreatedAt *time.Time } type ParameterRepo interface { diff --git a/internal/biz/security.go b/internal/biz/security.go index 611ffeb..5fe8b17 100644 --- a/internal/biz/security.go +++ b/internal/biz/security.go @@ -1,6 +1,9 @@ package biz -import "context" +import ( + "context" + "errors" +) type SecurityConfig struct { ID uint @@ -29,3 +32,42 @@ type SecurityRepo interface { SecurityConfig(context.Context) (*SecurityConfig, error) SaveSecurityConfig(context.Context, *SecurityConfig) error } + +func (uc *SettingsUsecase) UpdateSecurity(ctx context.Context, value *SecurityConfig) error { + if value.KeyLong < 1 || value.ImgWidth < 1 || value.ImgHeight < 1 || value.PwdMinLength < 1 || value.LimitWindow < 1 || value.LimitCount < 1 || value.LockThreshold < 1 || value.LockDuration < 1 || value.PwdExpireDays < 1 { + return errors.New("安全配置数值必须大于0") + } + return uc.SaveSecurityConfig(ctx, value) +} + +func (uc *SettingsUsecase) ValidatePassword(value *SecurityConfig, password string) error { + if len([]rune(password)) < value.PwdMinLength { + return errors.New("密码长度不足") + } + hasUpper, hasLower, hasDigit, hasSpecial := false, false, false, false + for _, ch := range password { + switch { + case ch >= 'A' && ch <= 'Z': + hasUpper = true + case ch >= 'a' && ch <= 'z': + hasLower = true + case ch >= '0' && ch <= '9': + hasDigit = true + default: + hasSpecial = true + } + } + if value.PwdRequireUpper && !hasUpper { + return errors.New("密码必须包含大写字母") + } + if value.PwdRequireLower && !hasLower { + return errors.New("密码必须包含小写字母") + } + if value.PwdRequireDigit && !hasDigit { + return errors.New("密码必须包含数字") + } + if value.PwdRequireSpecial && !hasSpecial { + return errors.New("密码必须包含特殊字符") + } + return nil +} diff --git a/internal/biz/settings.go b/internal/biz/settings.go index a387d7d..ac7901b 100644 --- a/internal/biz/settings.go +++ b/internal/biz/settings.go @@ -7,7 +7,8 @@ type SettingsRepo interface { SecurityRepo } -type SettingsUsecase struct{ repo SettingsRepo } +type SettingsUsecase struct{ SettingsRepo } -func NewSettingsUsecase(repo SettingsRepo) *SettingsUsecase { return &SettingsUsecase{repo: repo} } -func (uc *SettingsUsecase) Repo() SettingsRepo { return uc.repo } +func NewSettingsUsecase(repo SettingsRepo) *SettingsUsecase { + return &SettingsUsecase{SettingsRepo: repo} +} diff --git a/internal/biz/system_init.go b/internal/biz/system_init.go index ad85687..ac2a8a4 100644 --- a/internal/biz/system_init.go +++ b/internal/biz/system_init.go @@ -12,6 +12,7 @@ type InitializationRepo interface { Initialize(context.Context, *DatabaseConfig) error PersistConfig(context.Context) error PersistAdminConfig(context.Context, []byte) error + PersistRuntimeConfig(context.Context, []byte, []byte) error ReloadConfig(context.Context) error } @@ -28,4 +29,7 @@ func (uc *SystemUsecase) PersistConfig(ctx context.Context) error { return uc.re func (uc *SystemUsecase) PersistAdminConfig(ctx context.Context, value []byte) error { return uc.repo.PersistAdminConfig(ctx, value) } +func (uc *SystemUsecase) PersistRuntimeConfig(ctx context.Context, data, admin []byte) error { + return uc.repo.PersistRuntimeConfig(ctx, data, admin) +} func (uc *SystemUsecase) ReloadConfig(ctx context.Context) error { return uc.repo.ReloadConfig(ctx) } diff --git a/internal/biz/task.go b/internal/biz/task.go index b4a4fe8..3eb628c 100644 --- a/internal/biz/task.go +++ b/internal/biz/task.go @@ -2,7 +2,13 @@ package biz import ( "context" + "encoding/json" + "errors" + "fmt" + "net/url" "time" + + "github.com/robfig/cron/v3" ) type TimedTask struct { @@ -28,6 +34,37 @@ type TimedTaskLog struct { DurationMS int64 Status, ErrorMsg, Output string } + +const ( + TaskExecutorMethod = "method" + TaskExecutorHTTP = "http" + TaskMethodClearDB = "ClearDB" + TaskMethodUploads = "CleanStaleUploads" +) + +type TaskMethod struct { + Name string + Description string +} + +var registeredTaskMethods = []TaskMethod{ + {Name: TaskMethodClearDB, Description: "清理数据库过期日志(操作记录、JWT黑名单、定时任务日志)"}, + {Name: TaskMethodUploads, Description: "清理过期大文件上传会话"}, +} + +func RegisteredTaskMethods() []TaskMethod { + return append([]TaskMethod(nil), registeredTaskMethods...) +} + +func registeredTaskMethod(name string) bool { + for _, method := range registeredTaskMethods { + if method.Name == name { + return true + } + } + return false +} + type TaskRepo interface { CreateTask(context.Context, *TimedTask) error UpdateTask(context.Context, *TimedTask) error @@ -40,7 +77,75 @@ type TaskRepo interface { CleanupLogs(context.Context) error TaskNameExists(context.Context, string, uint) (bool, error) } -type TaskUsecase struct{ repo TaskRepo } +type TaskUsecase struct{ TaskRepo } -func NewTaskUsecase(repo TaskRepo) *TaskUsecase { return &TaskUsecase{repo: repo} } -func (uc *TaskUsecase) Repo() TaskRepo { return uc.repo } +func NewTaskUsecase(repo TaskRepo) *TaskUsecase { return &TaskUsecase{TaskRepo: repo} } + +func (uc *TaskUsecase) Validate(value *TimedTask) error { + if value.Name == "" { + return errors.New("任务名不能为空") + } + var err error + if value.WithSeconds { + _, err = cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor).Parse(value.Spec) + } else { + _, err = cron.ParseStandard(value.Spec) + } + if err != nil { + return fmt.Errorf("cron 表达式非法: %w", err) + } + switch value.ExecutorType { + case TaskExecutorMethod: + if !registeredTaskMethod(value.MethodName) { + return errors.New("方法未注册") + } + if len(value.Params) > 0 && !json.Valid(value.Params) { + return errors.New("params 必须是合法 JSON") + } + case TaskExecutorHTTP: + parsed, parseErr := url.Parse(value.HTTPURL) + if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" { + return errors.New("httpUrl 必须是合法的 http/https 地址") + } + if len(value.HTTPHeader) > 0 { + headers := map[string]string{} + if json.Unmarshal(value.HTTPHeader, &headers) != nil { + return errors.New("httpHeader 必须是 JSON 对象") + } + } + default: + return errors.New("executorType 必须为 method 或 http") + } + return nil +} + +func (uc *TaskUsecase) Create(ctx context.Context, value *TimedTask) error { + if err := uc.Validate(value); err != nil { + return err + } + exists, err := uc.TaskNameExists(ctx, value.Name, 0) + if err != nil { + return err + } + if exists { + return fmt.Errorf("任务名 %s 已存在", value.Name) + } + return uc.CreateTask(ctx, value) +} + +func (uc *TaskUsecase) Update(ctx context.Context, value *TimedTask) error { + if value.ID == 0 { + return errors.New("缺少任务 ID") + } + if err := uc.Validate(value); err != nil { + return err + } + exists, err := uc.TaskNameExists(ctx, value.Name, value.ID) + if err != nil { + return err + } + if exists { + return fmt.Errorf("任务名 %s 已存在", value.Name) + } + return uc.UpdateTask(ctx, value) +} diff --git a/internal/biz/upload_session.go b/internal/biz/upload_session.go new file mode 100644 index 0000000..bae39aa --- /dev/null +++ b/internal/biz/upload_session.go @@ -0,0 +1,35 @@ +package biz + +import ( + "context" + "time" +) + +type UploadSession struct { + ID uint + UserID uint + FileName, FileHash string + FileSize, ChunkSize int64 + ChunkTotal int + Status, StorageKey string + MediaID uint +} + +type UploadChunk struct { + Index int + Hash string + Size int64 +} + +type UploadRepo interface { + FindUploadingSession(context.Context, uint, string) (*UploadSession, error) + CreateUploadSession(context.Context, *UploadSession) error + FindUploadSession(context.Context, uint) (*UploadSession, error) + CompleteUploadSession(context.Context, uint, string, uint) error + DeleteUploadSession(context.Context, uint) error + UpsertChunk(context.Context, uint, *UploadChunk) error + ListChunks(context.Context, uint) ([]*UploadChunk, error) + DeleteChunks(context.Context, uint) error + StaleUploadSessionIDs(context.Context, time.Time) ([]uint, error) + DeleteUploadData(context.Context, uint) error +} diff --git a/internal/biz/user.go b/internal/biz/user.go index d2f15d5..85f0849 100644 --- a/internal/biz/user.go +++ b/internal/biz/user.go @@ -41,6 +41,7 @@ type Authority struct { ParentID *uint DataScope int DefaultRouter string + Children []*Authority } type UserRepo interface { @@ -49,13 +50,17 @@ type UserRepo interface { MenusByAuthority(context.Context, uint) ([]*Menu, error) ListUsers(context.Context, int, int, *UserListFilter) ([]*User, int64, error) CreateUser(context.Context, *User) (*User, error) + CreateUserWithAuthorities(context.Context, *User, []uint) (*User, error) UpdateUser(context.Context, *User) error + UpdateSelfUser(context.Context, *User) error + UpdateUserWithAuthorities(context.Context, *User, []uint) error DeleteUser(context.Context, uint) error UpdatePassword(context.Context, uint, string, bool) error SetUserAuthorities(context.Context, uint, []uint) error SetUserAuthority(context.Context, uint, uint) error SetUserSetting(context.Context, uint, map[string]any) error ListAuthorities(context.Context) ([]*Authority, error) + FillDepartmentNamePaths(context.Context, *User) error } func (uc *SystemUsecase) Login(ctx context.Context, username, password string) (*User, error) { @@ -63,11 +68,37 @@ func (uc *SystemUsecase) Login(ctx context.Context, username, password string) ( if err != nil || bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) != nil { return nil, ErrInvalidCredentials } + uc.fallbackDefaultRouter(ctx, u) return u, nil } func (uc *SystemUsecase) User(ctx context.Context, id uint) (*User, error) { - return uc.repo.FindUserByID(ctx, id) + user, err := uc.repo.FindUserByID(ctx, id) + if err != nil { + return nil, err + } + _ = uc.repo.FillDepartmentNamePaths(ctx, user) + uc.fallbackDefaultRouter(ctx, user) + return user, nil +} + +func (uc *SystemUsecase) fallbackDefaultRouter(ctx context.Context, user *User) { + if user == nil || user.Authority.DefaultRouter == "" { + return + } + menus, err := uc.repo.MenusByAuthority(ctx, user.AuthorityID) + if err != nil || !menuNameExists(menus, user.Authority.DefaultRouter) { + user.Authority.DefaultRouter = "404" + } +} + +func menuNameExists(menus []*Menu, name string) bool { + for _, menu := range menus { + if menu.Name == name || menuNameExists(menu.Children, name) { + return true + } + } + return false } func (uc *SystemUsecase) Menus(ctx context.Context, authorityID uint) ([]*Menu, error) { @@ -77,16 +108,37 @@ func (uc *SystemUsecase) Menus(ctx context.Context, authorityID uint) ([]*Menu, func (uc *SystemUsecase) ListUsers(ctx context.Context, page, pageSize int, filter *UserListFilter) ([]*User, int64, error) { return uc.repo.ListUsers(ctx, page, pageSize, filter) } -func (uc *SystemUsecase) CreateUser(ctx context.Context, user *User) (*User, error) { +func (uc *SystemUsecase) CreateUser(ctx context.Context, user *User, authorityIDs []uint) (*User, error) { + if user.AuthorityID == 0 && len(authorityIDs) > 0 { + user.AuthorityID = authorityIDs[0] + } + authorityIDs = includeAuthority(authorityIDs, user.AuthorityID) hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) if err != nil { return nil, err } user.Password = string(hash) - return uc.repo.CreateUser(ctx, user) + return uc.repo.CreateUserWithAuthorities(ctx, user, authorityIDs) } -func (uc *SystemUsecase) UpdateUser(ctx context.Context, user *User) error { - return uc.repo.UpdateUser(ctx, user) +func (uc *SystemUsecase) UpdateUser(ctx context.Context, user *User, authorityIDs []uint) error { + authorityIDs = includeAuthority(authorityIDs, user.AuthorityID) + return uc.repo.UpdateUserWithAuthorities(ctx, user, authorityIDs) +} + +func (uc *SystemUsecase) UpdateSelfUser(ctx context.Context, user *User) error { + return uc.repo.UpdateSelfUser(ctx, user) +} + +func includeAuthority(ids []uint, primary uint) []uint { + if primary == 0 { + return ids + } + for _, id := range ids { + if id == primary { + return ids + } + } + return append(ids, primary) } func (uc *SystemUsecase) DeleteUser(ctx context.Context, id uint) error { return uc.repo.DeleteUser(ctx, id) diff --git a/internal/biz/version.go b/internal/biz/version.go index ad404eb..ed23d24 100644 --- a/internal/biz/version.go +++ b/internal/biz/version.go @@ -2,9 +2,12 @@ package biz import ( "context" + "errors" "time" ) +var ErrInvalidVersion = errors.New("版本信息格式错误") + type Version struct { ID uint CreatedAt time.Time @@ -27,11 +30,10 @@ type VersionRepo interface { CreateVersion(context.Context, *Version) error DeleteVersions(context.Context, []uint) error FindVersion(context.Context, uint) (*Version, error) - ListVersions(context.Context, int, int, string, string) ([]*Version, int64, error) + ListVersions(context.Context, int, int, string, string, []*time.Time) ([]*Version, int64, error) BuildVersionBundle(context.Context, []uint, []uint, []uint) (*VersionBundle, error) ImportVersionBundle(context.Context, *VersionBundle) error } -type VersionUsecase struct{ repo VersionRepo } +type VersionUsecase struct{ VersionRepo } -func NewVersionUsecase(repo VersionRepo) *VersionUsecase { return &VersionUsecase{repo: repo} } -func (uc *VersionUsecase) Repo() VersionRepo { return uc.repo } +func NewVersionUsecase(repo VersionRepo) *VersionUsecase { return &VersionUsecase{VersionRepo: repo} } diff --git a/internal/conf/conf.pb.go b/internal/conf/conf.pb.go index 24633dd..1e50a77 100644 --- a/internal/conf/conf.pb.go +++ b/internal/conf/conf.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.11 +// protoc-gen-go v1.36.12 // protoc v7.35.1 // source: conf/conf.proto @@ -130,6 +130,7 @@ type Data struct { state protoimpl.MessageState `protogen:"open.v1"` Database *Data_Database `protobuf:"bytes,1,opt,name=database,proto3" json:"database,omitempty"` Redis *Data_Redis `protobuf:"bytes,2,opt,name=redis,proto3" json:"redis,omitempty"` + DatabaseList []*Data_Database `protobuf:"bytes,3,rep,name=database_list,json=databaseList,proto3" json:"database_list,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -178,6 +179,13 @@ func (x *Data) GetRedis() *Data_Redis { return nil } +func (x *Data) GetDatabaseList() []*Data_Database { + if x != nil { + return x.DatabaseList + } + return nil +} + // AdminBackend contains settings for the administration HTTP transport. type AdminBackend struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -188,8 +196,10 @@ type AdminBackend struct { Email *AdminBackend_Email `protobuf:"bytes,5,opt,name=email,proto3" json:"email,omitempty"` Storage *AdminBackend_Storage `protobuf:"bytes,6,opt,name=storage,proto3" json:"storage,omitempty"` // ConfigPath is populated by the entrypoint and is not required in YAML. - ConfigPath string `protobuf:"bytes,7,opt,name=config_path,json=configPath,proto3" json:"config_path,omitempty"` - Media *AdminBackend_Media `protobuf:"bytes,8,opt,name=media,proto3" json:"media,omitempty"` + ConfigPath string `protobuf:"bytes,7,opt,name=config_path,json=configPath,proto3" json:"config_path,omitempty"` + Media *AdminBackend_Media `protobuf:"bytes,8,opt,name=media,proto3" json:"media,omitempty"` + DiskList []*AdminBackend_Disk `protobuf:"bytes,9,rep,name=disk_list,json=diskList,proto3" json:"disk_list,omitempty"` + System *AdminBackend_System `protobuf:"bytes,10,opt,name=system,proto3" json:"system,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -280,6 +290,20 @@ func (x *AdminBackend) GetMedia() *AdminBackend_Media { return nil } +func (x *AdminBackend) GetDiskList() []*AdminBackend_Disk { + if x != nil { + return x.DiskList + } + return nil +} + +func (x *AdminBackend) GetSystem() *AdminBackend_System { + if x != nil { + return x.System + } + return nil +} + type Server_HTTP struct { state protoimpl.MessageState `protogen:"open.v1"` Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"` @@ -351,6 +375,8 @@ type Data_Database struct { Name string `protobuf:"bytes,7,opt,name=name,proto3" json:"name,omitempty"` Config string `protobuf:"bytes,8,opt,name=config,proto3" json:"config,omitempty"` Path string `protobuf:"bytes,9,opt,name=path,proto3" json:"path,omitempty"` + AliasName string `protobuf:"bytes,10,opt,name=alias_name,json=aliasName,proto3" json:"alias_name,omitempty"` + Disable bool `protobuf:"varint,11,opt,name=disable,proto3" json:"disable,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -448,6 +474,20 @@ func (x *Data_Database) GetPath() string { return "" } +func (x *Data_Database) GetAliasName() string { + if x != nil { + return x.AliasName + } + return "" +} + +func (x *Data_Database) GetDisable() bool { + if x != nil { + return x.Disable + } + return false +} + type Data_Redis struct { state protoimpl.MessageState `protogen:"open.v1"` Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"` @@ -848,6 +888,118 @@ func (x *AdminBackend_Media) GetSessionTtl() int32 { return 0 } +type AdminBackend_Disk struct { + state protoimpl.MessageState `protogen:"open.v1"` + MountPoint string `protobuf:"bytes,1,opt,name=mount_point,json=mountPoint,proto3" json:"mount_point,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminBackend_Disk) Reset() { + *x = AdminBackend_Disk{} + mi := &file_conf_conf_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminBackend_Disk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminBackend_Disk) ProtoMessage() {} + +func (x *AdminBackend_Disk) ProtoReflect() protoreflect.Message { + mi := &file_conf_conf_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminBackend_Disk.ProtoReflect.Descriptor instead. +func (*AdminBackend_Disk) Descriptor() ([]byte, []int) { + return file_conf_conf_proto_rawDescGZIP(), []int{3, 5} +} + +func (x *AdminBackend_Disk) GetMountPoint() string { + if x != nil { + return x.MountPoint + } + return "" +} + +type AdminBackend_System struct { + state protoimpl.MessageState `protogen:"open.v1"` + UseRedis bool `protobuf:"varint,1,opt,name=use_redis,json=useRedis,proto3" json:"use_redis,omitempty"` + UseMultipoint bool `protobuf:"varint,2,opt,name=use_multipoint,json=useMultipoint,proto3" json:"use_multipoint,omitempty"` + UseStrictAuth bool `protobuf:"varint,3,opt,name=use_strict_auth,json=useStrictAuth,proto3" json:"use_strict_auth,omitempty"` + DisableAutoMigrate bool `protobuf:"varint,4,opt,name=disable_auto_migrate,json=disableAutoMigrate,proto3" json:"disable_auto_migrate,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminBackend_System) Reset() { + *x = AdminBackend_System{} + mi := &file_conf_conf_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminBackend_System) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminBackend_System) ProtoMessage() {} + +func (x *AdminBackend_System) ProtoReflect() protoreflect.Message { + mi := &file_conf_conf_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminBackend_System.ProtoReflect.Descriptor instead. +func (*AdminBackend_System) Descriptor() ([]byte, []int) { + return file_conf_conf_proto_rawDescGZIP(), []int{3, 6} +} + +func (x *AdminBackend_System) GetUseRedis() bool { + if x != nil { + return x.UseRedis + } + return false +} + +func (x *AdminBackend_System) GetUseMultipoint() bool { + if x != nil { + return x.UseMultipoint + } + return false +} + +func (x *AdminBackend_System) GetUseStrictAuth() bool { + if x != nil { + return x.UseStrictAuth + } + return false +} + +func (x *AdminBackend_System) GetDisableAutoMigrate() bool { + if x != nil { + return x.DisableAutoMigrate + } + return false +} + type AdminBackend_Storage struct { state protoimpl.MessageState `protogen:"open.v1"` Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` @@ -864,7 +1016,7 @@ type AdminBackend_Storage struct { func (x *AdminBackend_Storage) Reset() { *x = AdminBackend_Storage{} - mi := &file_conf_conf_proto_msgTypes[12] + mi := &file_conf_conf_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -876,7 +1028,7 @@ func (x *AdminBackend_Storage) String() string { func (*AdminBackend_Storage) ProtoMessage() {} func (x *AdminBackend_Storage) ProtoReflect() protoreflect.Message { - mi := &file_conf_conf_proto_msgTypes[12] + mi := &file_conf_conf_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -889,7 +1041,7 @@ func (x *AdminBackend_Storage) ProtoReflect() protoreflect.Message { // Deprecated: Use AdminBackend_Storage.ProtoReflect.Descriptor instead. func (*AdminBackend_Storage) Descriptor() ([]byte, []int) { - return file_conf_conf_proto_rawDescGZIP(), []int{3, 5} + return file_conf_conf_proto_rawDescGZIP(), []int{3, 7} } func (x *AdminBackend_Storage) GetType() string { @@ -963,7 +1115,7 @@ type AdminBackend_Qiniu struct { func (x *AdminBackend_Qiniu) Reset() { *x = AdminBackend_Qiniu{} - mi := &file_conf_conf_proto_msgTypes[13] + mi := &file_conf_conf_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -975,7 +1127,7 @@ func (x *AdminBackend_Qiniu) String() string { func (*AdminBackend_Qiniu) ProtoMessage() {} func (x *AdminBackend_Qiniu) ProtoReflect() protoreflect.Message { - mi := &file_conf_conf_proto_msgTypes[13] + mi := &file_conf_conf_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -988,7 +1140,7 @@ func (x *AdminBackend_Qiniu) ProtoReflect() protoreflect.Message { // Deprecated: Use AdminBackend_Qiniu.ProtoReflect.Descriptor instead. func (*AdminBackend_Qiniu) Descriptor() ([]byte, []int) { - return file_conf_conf_proto_rawDescGZIP(), []int{3, 6} + return file_conf_conf_proto_rawDescGZIP(), []int{3, 8} } func (x *AdminBackend_Qiniu) GetZone() string { @@ -1060,7 +1212,7 @@ type AdminBackend_ObjectStore struct { func (x *AdminBackend_ObjectStore) Reset() { *x = AdminBackend_ObjectStore{} - mi := &file_conf_conf_proto_msgTypes[14] + mi := &file_conf_conf_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1072,7 +1224,7 @@ func (x *AdminBackend_ObjectStore) String() string { func (*AdminBackend_ObjectStore) ProtoMessage() {} func (x *AdminBackend_ObjectStore) ProtoReflect() protoreflect.Message { - mi := &file_conf_conf_proto_msgTypes[14] + mi := &file_conf_conf_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1085,7 +1237,7 @@ func (x *AdminBackend_ObjectStore) ProtoReflect() protoreflect.Message { // Deprecated: Use AdminBackend_ObjectStore.ProtoReflect.Descriptor instead. func (*AdminBackend_ObjectStore) Descriptor() ([]byte, []int) { - return file_conf_conf_proto_rawDescGZIP(), []int{3, 7} + return file_conf_conf_proto_rawDescGZIP(), []int{3, 9} } func (x *AdminBackend_ObjectStore) GetEndpoint() string { @@ -1173,10 +1325,11 @@ const file_conf_conf_proto_rawDesc = "" + "\x04HTTP\x12\x18\n" + "\anetwork\x18\x01 \x01(\tR\anetwork\x12\x12\n" + "\x04addr\x18\x02 \x01(\tR\x04addr\x123\n" + - "\atimeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\xf6\x03\n" + + "\atimeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\xef\x04\n" + "\x04Data\x125\n" + "\bdatabase\x18\x01 \x01(\v2\x19.kratos.api.Data.DatabaseR\bdatabase\x12,\n" + - "\x05redis\x18\x02 \x01(\v2\x16.kratos.api.Data.RedisR\x05redis\x1a\xd2\x01\n" + + "\x05redis\x18\x02 \x01(\v2\x16.kratos.api.Data.RedisR\x05redis\x12>\n" + + "\rdatabase_list\x18\x03 \x03(\v2\x19.kratos.api.Data.DatabaseR\fdatabaseList\x1a\x8b\x02\n" + "\bDatabase\x12\x16\n" + "\x06driver\x18\x01 \x01(\tR\x06driver\x12\x16\n" + "\x06source\x18\x02 \x01(\tR\x06source\x12\x12\n" + @@ -1186,12 +1339,16 @@ const file_conf_conf_proto_rawDesc = "" + "\bpassword\x18\x06 \x01(\tR\bpassword\x12\x12\n" + "\x04name\x18\a \x01(\tR\x04name\x12\x16\n" + "\x06config\x18\b \x01(\tR\x06config\x12\x12\n" + - "\x04path\x18\t \x01(\tR\x04path\x1a\xb3\x01\n" + + "\x04path\x18\t \x01(\tR\x04path\x12\x1d\n" + + "\n" + + "alias_name\x18\n" + + " \x01(\tR\taliasName\x12\x18\n" + + "\adisable\x18\v \x01(\bR\adisable\x1a\xb3\x01\n" + "\x05Redis\x12\x18\n" + "\anetwork\x18\x01 \x01(\tR\anetwork\x12\x12\n" + "\x04addr\x18\x02 \x01(\tR\x04addr\x12<\n" + "\fread_timeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\vreadTimeout\x12>\n" + - "\rwrite_timeout\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\fwriteTimeout\"\xb1\x10\n" + + "\rwrite_timeout\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\fwriteTimeout\"\xf8\x12\n" + "\fAdminBackend\x12#\n" + "\rrouter_prefix\x18\x01 \x01(\tR\frouterPrefix\x12.\n" + "\x03jwt\x18\x02 \x01(\v2\x1c.kratos.api.AdminBackend.JWTR\x03jwt\x12:\n" + @@ -1201,7 +1358,10 @@ const file_conf_conf_proto_rawDesc = "" + "\astorage\x18\x06 \x01(\v2 .kratos.api.AdminBackend.StorageR\astorage\x12\x1f\n" + "\vconfig_path\x18\a \x01(\tR\n" + "configPath\x124\n" + - "\x05media\x18\b \x01(\v2\x1e.kratos.api.AdminBackend.MediaR\x05media\x1a\xb8\x01\n" + + "\x05media\x18\b \x01(\v2\x1e.kratos.api.AdminBackend.MediaR\x05media\x12:\n" + + "\tdisk_list\x18\t \x03(\v2\x1d.kratos.api.AdminBackend.DiskR\bdiskList\x127\n" + + "\x06system\x18\n" + + " \x01(\v2\x1f.kratos.api.AdminBackend.SystemR\x06system\x1a\xb8\x01\n" + "\x03JWT\x12\x1f\n" + "\vsigning_key\x18\x01 \x01(\tR\n" + "signingKey\x12<\n" + @@ -1231,7 +1391,15 @@ const file_conf_conf_proto_rawDesc = "" + "\ris_login_auth\x18\b \x01(\bR\visLoginAuth\x1a(\n" + "\x05Media\x12\x1f\n" + "\vsession_ttl\x18\x01 \x01(\x05R\n" + - "sessionTtl\x1a\xe8\x03\n" + + "sessionTtl\x1a'\n" + + "\x04Disk\x12\x1f\n" + + "\vmount_point\x18\x01 \x01(\tR\n" + + "mountPoint\x1a\xa6\x01\n" + + "\x06System\x12\x1b\n" + + "\tuse_redis\x18\x01 \x01(\bR\buseRedis\x12%\n" + + "\x0euse_multipoint\x18\x02 \x01(\bR\ruseMultipoint\x12&\n" + + "\x0fuse_strict_auth\x18\x03 \x01(\bR\ruseStrictAuth\x120\n" + + "\x14disable_auto_migrate\x18\x04 \x01(\bR\x12disableAutoMigrate\x1a\xe8\x03\n" + "\aStorage\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x124\n" + "\x05qiniu\x18\x02 \x01(\v2\x1e.kratos.api.AdminBackend.QiniuR\x05qiniu\x12C\n" + @@ -1283,7 +1451,7 @@ func file_conf_conf_proto_rawDescGZIP() []byte { return file_conf_conf_proto_rawDescData } -var file_conf_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 15) +var file_conf_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 17) var file_conf_conf_proto_goTypes = []any{ (*Bootstrap)(nil), // 0: kratos.api.Bootstrap (*Server)(nil), // 1: kratos.api.Server @@ -1297,10 +1465,12 @@ var file_conf_conf_proto_goTypes = []any{ (*AdminBackend_Local)(nil), // 9: kratos.api.AdminBackend.Local (*AdminBackend_Email)(nil), // 10: kratos.api.AdminBackend.Email (*AdminBackend_Media)(nil), // 11: kratos.api.AdminBackend.Media - (*AdminBackend_Storage)(nil), // 12: kratos.api.AdminBackend.Storage - (*AdminBackend_Qiniu)(nil), // 13: kratos.api.AdminBackend.Qiniu - (*AdminBackend_ObjectStore)(nil), // 14: kratos.api.AdminBackend.ObjectStore - (*durationpb.Duration)(nil), // 15: google.protobuf.Duration + (*AdminBackend_Disk)(nil), // 12: kratos.api.AdminBackend.Disk + (*AdminBackend_System)(nil), // 13: kratos.api.AdminBackend.System + (*AdminBackend_Storage)(nil), // 14: kratos.api.AdminBackend.Storage + (*AdminBackend_Qiniu)(nil), // 15: kratos.api.AdminBackend.Qiniu + (*AdminBackend_ObjectStore)(nil), // 16: kratos.api.AdminBackend.ObjectStore + (*durationpb.Duration)(nil), // 17: google.protobuf.Duration } var file_conf_conf_proto_depIdxs = []int32{ 1, // 0: kratos.api.Bootstrap.server:type_name -> kratos.api.Server @@ -1309,30 +1479,33 @@ var file_conf_conf_proto_depIdxs = []int32{ 4, // 3: kratos.api.Server.http:type_name -> kratos.api.Server.HTTP 5, // 4: kratos.api.Data.database:type_name -> kratos.api.Data.Database 6, // 5: kratos.api.Data.redis:type_name -> kratos.api.Data.Redis - 7, // 6: kratos.api.AdminBackend.jwt:type_name -> kratos.api.AdminBackend.JWT - 8, // 7: kratos.api.AdminBackend.captcha:type_name -> kratos.api.AdminBackend.Captcha - 9, // 8: kratos.api.AdminBackend.local:type_name -> kratos.api.AdminBackend.Local - 10, // 9: kratos.api.AdminBackend.email:type_name -> kratos.api.AdminBackend.Email - 12, // 10: kratos.api.AdminBackend.storage:type_name -> kratos.api.AdminBackend.Storage - 11, // 11: kratos.api.AdminBackend.media:type_name -> kratos.api.AdminBackend.Media - 15, // 12: kratos.api.Server.HTTP.timeout:type_name -> google.protobuf.Duration - 15, // 13: kratos.api.Data.Redis.read_timeout:type_name -> google.protobuf.Duration - 15, // 14: kratos.api.Data.Redis.write_timeout:type_name -> google.protobuf.Duration - 15, // 15: kratos.api.AdminBackend.JWT.expires_time:type_name -> google.protobuf.Duration - 15, // 16: kratos.api.AdminBackend.JWT.buffer_time:type_name -> google.protobuf.Duration - 15, // 17: kratos.api.AdminBackend.Captcha.store_expiration:type_name -> google.protobuf.Duration - 13, // 18: kratos.api.AdminBackend.Storage.qiniu:type_name -> kratos.api.AdminBackend.Qiniu - 14, // 19: kratos.api.AdminBackend.Storage.aliyun_oss:type_name -> kratos.api.AdminBackend.ObjectStore - 14, // 20: kratos.api.AdminBackend.Storage.huawei_obs:type_name -> kratos.api.AdminBackend.ObjectStore - 14, // 21: kratos.api.AdminBackend.Storage.tencent_cos:type_name -> kratos.api.AdminBackend.ObjectStore - 14, // 22: kratos.api.AdminBackend.Storage.aws_s3:type_name -> kratos.api.AdminBackend.ObjectStore - 14, // 23: kratos.api.AdminBackend.Storage.cloudflare_r2:type_name -> kratos.api.AdminBackend.ObjectStore - 14, // 24: kratos.api.AdminBackend.Storage.minio:type_name -> kratos.api.AdminBackend.ObjectStore - 25, // [25:25] is the sub-list for method output_type - 25, // [25:25] is the sub-list for method input_type - 25, // [25:25] is the sub-list for extension type_name - 25, // [25:25] is the sub-list for extension extendee - 0, // [0:25] is the sub-list for field type_name + 5, // 6: kratos.api.Data.database_list:type_name -> kratos.api.Data.Database + 7, // 7: kratos.api.AdminBackend.jwt:type_name -> kratos.api.AdminBackend.JWT + 8, // 8: kratos.api.AdminBackend.captcha:type_name -> kratos.api.AdminBackend.Captcha + 9, // 9: kratos.api.AdminBackend.local:type_name -> kratos.api.AdminBackend.Local + 10, // 10: kratos.api.AdminBackend.email:type_name -> kratos.api.AdminBackend.Email + 14, // 11: kratos.api.AdminBackend.storage:type_name -> kratos.api.AdminBackend.Storage + 11, // 12: kratos.api.AdminBackend.media:type_name -> kratos.api.AdminBackend.Media + 12, // 13: kratos.api.AdminBackend.disk_list:type_name -> kratos.api.AdminBackend.Disk + 13, // 14: kratos.api.AdminBackend.system:type_name -> kratos.api.AdminBackend.System + 17, // 15: kratos.api.Server.HTTP.timeout:type_name -> google.protobuf.Duration + 17, // 16: kratos.api.Data.Redis.read_timeout:type_name -> google.protobuf.Duration + 17, // 17: kratos.api.Data.Redis.write_timeout:type_name -> google.protobuf.Duration + 17, // 18: kratos.api.AdminBackend.JWT.expires_time:type_name -> google.protobuf.Duration + 17, // 19: kratos.api.AdminBackend.JWT.buffer_time:type_name -> google.protobuf.Duration + 17, // 20: kratos.api.AdminBackend.Captcha.store_expiration:type_name -> google.protobuf.Duration + 15, // 21: kratos.api.AdminBackend.Storage.qiniu:type_name -> kratos.api.AdminBackend.Qiniu + 16, // 22: kratos.api.AdminBackend.Storage.aliyun_oss:type_name -> kratos.api.AdminBackend.ObjectStore + 16, // 23: kratos.api.AdminBackend.Storage.huawei_obs:type_name -> kratos.api.AdminBackend.ObjectStore + 16, // 24: kratos.api.AdminBackend.Storage.tencent_cos:type_name -> kratos.api.AdminBackend.ObjectStore + 16, // 25: kratos.api.AdminBackend.Storage.aws_s3:type_name -> kratos.api.AdminBackend.ObjectStore + 16, // 26: kratos.api.AdminBackend.Storage.cloudflare_r2:type_name -> kratos.api.AdminBackend.ObjectStore + 16, // 27: kratos.api.AdminBackend.Storage.minio:type_name -> kratos.api.AdminBackend.ObjectStore + 28, // [28:28] is the sub-list for method output_type + 28, // [28:28] is the sub-list for method input_type + 28, // [28:28] is the sub-list for extension type_name + 28, // [28:28] is the sub-list for extension extendee + 0, // [0:28] is the sub-list for field type_name } func init() { file_conf_conf_proto_init() } @@ -1346,7 +1519,7 @@ func file_conf_conf_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_conf_conf_proto_rawDesc), len(file_conf_conf_proto_rawDesc)), NumEnums: 0, - NumMessages: 15, + NumMessages: 17, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/conf/conf.proto b/internal/conf/conf.proto index 89e5419..c3e9d77 100644 --- a/internal/conf/conf.proto +++ b/internal/conf/conf.proto @@ -31,6 +31,8 @@ message Data { string name = 7; string config = 8; string path = 9; + string alias_name = 10; + bool disable = 11; } message Redis { string network = 1; @@ -40,6 +42,7 @@ message Data { } Database database = 1; Redis redis = 2; + repeated Database database_list = 3; } // AdminBackend contains settings for the administration HTTP transport. @@ -53,6 +56,8 @@ message AdminBackend { // ConfigPath is populated by the entrypoint and is not required in YAML. string config_path = 7; Media media = 8; + repeated Disk disk_list = 9; + System system = 10; message JWT { string signing_key = 1; @@ -88,6 +93,17 @@ message AdminBackend { int32 session_ttl = 1; } + message Disk { + string mount_point = 1; + } + + message System { + bool use_redis = 1; + bool use_multipoint = 2; + bool use_strict_auth = 3; + bool disable_auto_migrate = 4; + } + message Storage { string type = 1; Qiniu qiniu = 2; diff --git a/internal/conf/runtime.go b/internal/conf/runtime.go new file mode 100644 index 0000000..5c26a76 --- /dev/null +++ b/internal/conf/runtime.go @@ -0,0 +1,94 @@ +package conf + +import ( + "sync/atomic" + + "google.golang.org/protobuf/proto" +) + +// Runtime owns the active immutable configuration snapshot. Callers always +// receive clones so a config file reload cannot race with request handling. +type Runtime struct { + snapshot atomic.Pointer[runtimeSnapshot] +} + +type runtimeSnapshot struct { + data *Data + admin *AdminBackend +} + +func NewRuntime(data *Data, admin *AdminBackend) *Runtime { + runtime := &Runtime{} + runtime.Replace(data, admin) + return runtime +} + +func cloneData(value *Data) *Data { + if value == nil { + return nil + } + return proto.Clone(value).(*Data) +} + +func cloneAdmin(value *AdminBackend) *AdminBackend { + if value == nil { + return nil + } + return proto.Clone(value).(*AdminBackend) +} + +func (r *Runtime) Replace(data *Data, admin *AdminBackend) { + r.snapshot.Store(&runtimeSnapshot{data: cloneData(data), admin: cloneAdmin(admin)}) +} + +func (r *Runtime) Data() *Data { + snapshot := r.snapshot.Load() + if snapshot == nil { + return nil + } + return cloneData(snapshot.data) +} + +func (r *Runtime) Admin() *AdminBackend { + snapshot := r.snapshot.Load() + if snapshot == nil { + return nil + } + return cloneAdmin(snapshot.admin) +} + +func (r *Runtime) Values() (*Data, *AdminBackend) { + snapshot := r.snapshot.Load() + if snapshot == nil { + return nil, nil + } + return cloneData(snapshot.data), cloneAdmin(snapshot.admin) +} + +func (r *Runtime) ConfigPath() string { + snapshot := r.snapshot.Load() + if snapshot == nil || snapshot.admin == nil { + return "" + } + return snapshot.admin.ConfigPath +} + +func (r *Runtime) UpdateDatabase(database *Data_Database) { + for { + current := r.snapshot.Load() + var data *Data + var admin *AdminBackend + if current != nil { + data = cloneData(current.data) + admin = cloneAdmin(current.admin) + } + if data == nil { + data = &Data{} + } + data.Database = proto.Clone(database).(*Data_Database) + next := &runtimeSnapshot{data: data, admin: admin} + if r.snapshot.CompareAndSwap(current, next) { + return + } + } +} diff --git a/internal/conf/runtime_test.go b/internal/conf/runtime_test.go new file mode 100644 index 0000000..b886961 --- /dev/null +++ b/internal/conf/runtime_test.go @@ -0,0 +1,41 @@ +package conf + +import ( + "sync" + "testing" +) + +func TestRuntimeSnapshotsAreImmutable(t *testing.T) { + runtime := NewRuntime(&Data{Database: &Data_Database{Name: "first"}}, &AdminBackend{RouterPrefix: "/api", Jwt: &AdminBackend_JWT{SigningKey: "secret"}}) + admin := runtime.Admin() + admin.RouterPrefix = "/changed" + admin.Jwt.SigningKey = "changed" + data := runtime.Data() + data.Database.Name = "changed" + + if got := runtime.Admin(); got.RouterPrefix != "/api" || got.Jwt.SigningKey != "secret" { + t.Fatalf("admin snapshot mutated: %+v", got) + } + if got := runtime.Data().Database.Name; got != "first" { + t.Fatalf("data snapshot mutated: %s", got) + } +} + +func TestRuntimeConcurrentReplaceAndRead(t *testing.T) { + runtime := NewRuntime(&Data{Database: &Data_Database{Name: "initial"}}, &AdminBackend{Jwt: &AdminBackend_JWT{SigningKey: "initial"}}) + var wait sync.WaitGroup + for worker := 0; worker < 8; worker++ { + wait.Add(1) + go func() { + defer wait.Done() + for index := 0; index < 500; index++ { + _ = runtime.Admin().Jwt.SigningKey + _ = runtime.Data().Database.Name + } + }() + } + for index := 0; index < 500; index++ { + runtime.Replace(&Data{Database: &Data_Database{Name: "next"}}, &AdminBackend{Jwt: &AdminBackend_JWT{SigningKey: "next"}}) + } + wait.Wait() +} diff --git a/internal/data/announcement.go b/internal/data/announcement.go index 2b54944..452d5b4 100644 --- a/internal/data/announcement.go +++ b/internal/data/announcement.go @@ -93,11 +93,7 @@ func (r *announcementRepo) List(ctx context.Context, filter biz.AnnouncementFilt return nil, 0, err } if filter.PageSize > 0 { - page := filter.Page - if page < 1 { - page = 1 - } - db = db.Offset((page - 1) * filter.PageSize).Limit(filter.PageSize) + db = applyPagination(db, filter.Page, filter.PageSize, 100) } var pos []announcementPO if err := db.Find(&pos).Error; err != nil { diff --git a/internal/data/api.go b/internal/data/api.go index 1e7ee1b..f3c9f27 100644 --- a/internal/data/api.go +++ b/internal/data/api.go @@ -45,7 +45,12 @@ func apiFromPO(po apiPO) *biz.API { return &biz.API{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Path: po.Path, Description: po.Description, APIGroup: po.APIGroup, Method: po.Method} } func (r *accessRepo) CreateAPI(ctx context.Context, v *biz.API) error { - return r.data.gormDB.WithContext(ctx).Create(&apiPO{Path: v.Path, Description: v.Description, APIGroup: v.APIGroup, Method: strings.ToUpper(v.Method)}).Error + po := apiPO{Path: v.Path, Description: v.Description, APIGroup: v.APIGroup, Method: strings.ToUpper(v.Method)} + if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + return err + } + v.ID, v.CreatedAt, v.UpdatedAt, v.Method = po.ID, po.CreatedAt, po.UpdatedAt, po.Method + return nil } func (r *accessRepo) UpdateAPI(ctx context.Context, v *biz.API) error { return r.data.gormDB.WithContext(ctx).Model(&apiPO{}).Where("id = ?", v.ID).Updates(map[string]any{"path": v.Path, "description": v.Description, "api_group": v.APIGroup, "method": strings.ToUpper(v.Method)}).Error @@ -66,17 +71,26 @@ func (r *accessRepo) FindAPI(ctx context.Context, id uint) (*biz.API, error) { return apiFromPO(po), nil } func (r *accessRepo) ListAPIs(ctx context.Context, page, size int, q *biz.API) ([]*biz.API, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } db := r.data.gormDB.WithContext(ctx).Model(&apiPO{}) + if q != nil && q.StrictAll { + config := r.data.runtime.Admin() + if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { + var authority authorityPO + if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { + return nil, 0, err + } + if authority.ParentID != nil && *authority.ParentID != 0 { + db = db.Where("id IN (?)", r.data.gormDB.WithContext(ctx).Model(&authorityAPIPO{}).Select("api_id").Where("authority_id = ?", actor.AuthorityID)) + } + } + } if q != nil { if q.Path != "" { db = db.Where("path LIKE ?", "%"+q.Path+"%") } + if q.Description != "" { + db = db.Where("description LIKE ?", "%"+q.Description+"%") + } if q.APIGroup != "" { db = db.Where("api_group = ?", q.APIGroup) } @@ -88,8 +102,23 @@ func (r *accessRepo) ListAPIs(ctx context.Context, page, size int, q *biz.API) ( if err := db.Count(&total).Error; err != nil { return nil, 0, err } + order := "id desc" + if q != nil && q.OrderKey != "" { + allowed := map[string]bool{"id": true, "path": true, "api_group": true, "description": true, "method": true} + if !allowed[q.OrderKey] { + return nil, 0, errors.New("非法的排序字段: " + q.OrderKey) + } + order = q.OrderKey + if q.Desc { + order += " desc" + } + } var pos []apiPO - if err := db.Order("api_group,path,method").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + maxSize := 100 + if q != nil && q.StrictAll { + maxSize = 0 + } + if err := applyPagination(db.Order(order), page, size, maxSize).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.API, 0, len(pos)) @@ -98,19 +127,26 @@ func (r *accessRepo) ListAPIs(ctx context.Context, page, size int, q *biz.API) ( } return out, total, nil } -func (r *accessRepo) APIRoleIDs(ctx context.Context, id uint) ([]uint, error) { +func (r *accessRepo) APIRoleIDs(ctx context.Context, path, method string) ([]uint, error) { var ids []uint - err := r.data.gormDB.WithContext(ctx).Model(&authorityAPIPO{}).Where("api_id = ?", id).Pluck("authority_id", &ids).Error + err := r.data.gormDB.WithContext(ctx).Table("sys_authority_apis"). + Joins("JOIN sys_apis ON sys_apis.id = sys_authority_apis.api_id"). + Where("sys_apis.path = ? AND sys_apis.method = ? AND sys_apis.deleted_at IS NULL", path, strings.ToUpper(method)). + Pluck("sys_authority_apis.authority_id", &ids).Error return ids, err } -func (r *accessRepo) SetAPIRoles(ctx context.Context, id uint, ids []uint) error { +func (r *accessRepo) SetAPIRoles(ctx context.Context, path, method string, ids []uint) error { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("api_id = ?", id).Delete(&authorityAPIPO{}).Error; err != nil { + var api apiPO + if err := tx.Where("path = ? AND method = ?", path, strings.ToUpper(method)).First(&api).Error; err != nil { + return err + } + if err := tx.Where("api_id = ?", api.ID).Delete(&authorityAPIPO{}).Error; err != nil { return err } links := make([]authorityAPIPO, 0, len(ids)) for _, aid := range ids { - links = append(links, authorityAPIPO{AuthorityID: aid, APIID: id}) + links = append(links, authorityAPIPO{AuthorityID: aid, APIID: api.ID}) } if len(links) > 0 { return tx.Create(&links).Error @@ -159,6 +195,31 @@ func (r *accessRepo) PolicyPaths(ctx context.Context, aid uint) ([]*biz.API, err return out, nil } func (r *accessRepo) SetPolicyPaths(ctx context.Context, aid uint, paths []*biz.API) error { + if err := r.checkAuthorityIDAuth(ctx, aid); err != nil { + return err + } + config := r.data.runtime.Admin() + if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { + var authority authorityPO + if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { + return err + } + if authority.ParentID != nil && *authority.ParentID != 0 { + allowed, err := r.PolicyPaths(ctx, actor.AuthorityID) + if err != nil { + return err + } + allowedSet := make(map[string]bool, len(allowed)) + for _, item := range allowed { + allowedSet[item.Path+"\x00"+strings.ToUpper(item.Method)] = true + } + for _, item := range paths { + if !allowedSet[item.Path+"\x00"+strings.ToUpper(item.Method)] { + return errors.New("存在api不在权限列表中") + } + } + } + } return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("authority_id = ?", aid).Delete(&authorityAPIPO{}).Error; err != nil { return err diff --git a/internal/data/api_token.go b/internal/data/api_token.go index fe08dab..0aa78cd 100644 --- a/internal/data/api_token.go +++ b/internal/data/api_token.go @@ -58,12 +58,6 @@ func (r *settingsRepo) CreateAPIToken(ctx context.Context, v *biz.APIToken) erro return nil } func (r *settingsRepo) ListAPITokens(ctx context.Context, page, size int, userID uint, status *bool) ([]*biz.APIToken, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } db := r.data.gormDB.WithContext(ctx).Model(&apiTokenPO{}) if userID != 0 { db = db.Where("user_id = ?", userID) @@ -76,16 +70,26 @@ func (r *settingsRepo) ListAPITokens(ctx context.Context, page, size int, userID return nil, 0, err } var pos []apiTokenPO - if err := db.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } + userIDs := make([]uint, 0, len(pos)) + for _, po := range pos { + userIDs = append(userIDs, po.UserID) + } + var userPOs []userPO + if len(userIDs) > 0 { + if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", userIDs).Find(&userPOs).Error; err != nil { + return nil, 0, err + } + } + users := make(map[uint]*biz.User, len(userPOs)) + for i := range userPOs { + users[userPOs[i].ID] = baseBizUser(&userPOs[i]) + } out := make([]*biz.APIToken, 0, len(pos)) for _, po := range pos { - v := &biz.APIToken{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UserID: po.UserID, AuthorityID: po.AuthorityID, Token: po.Token, Status: po.Status, ExpiresAt: po.ExpiresAt, Remark: po.Remark} - var userPO userPO - if r.data.gormDB.WithContext(ctx).First(&userPO, po.UserID).Error == nil { - v.User, _ = (&systemRepo{data: r.data}).toBizUser(ctx, &userPO) - } + v := &biz.APIToken{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UserID: po.UserID, AuthorityID: po.AuthorityID, Token: po.Token, Status: po.Status, ExpiresAt: po.ExpiresAt, Remark: po.Remark, User: users[po.UserID]} out = append(out, v) } return out, total, nil @@ -97,6 +101,19 @@ func (r *settingsRepo) DisableAPIToken(ctx context.Context, id uint) (string, er } return po.Token, r.data.gormDB.WithContext(ctx).Model(&po).Update("status", false).Error } +func (r *settingsRepo) DisableAndBlacklistAPIToken(ctx context.Context, id uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var po apiTokenPO + if err := tx.First(&po, id).Error; err != nil { + return err + } + // Blacklist the token before disabling it while making both writes atomic. + if err := tx.Create(&jwtBlacklistPO{JWT: po.Token}).Error; err != nil { + return err + } + return tx.Model(&po).Update("status", false).Error + }) +} func (r *settingsRepo) BlacklistToken(ctx context.Context, token string) error { if token == "" { return errors.New("token is empty") diff --git a/internal/data/authority.go b/internal/data/authority.go index c7445f1..737393e 100644 --- a/internal/data/authority.go +++ b/internal/data/authority.go @@ -11,7 +11,67 @@ import ( "gorm.io/gorm" ) +func (r *accessRepo) strictAuthorityIDs(ctx context.Context, actorID uint) (map[uint]bool, error) { + allowed := make(map[uint]bool) + if actorID == 0 { + return allowed, nil + } + var actor authorityPO + if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actorID).First(&actor).Error; err != nil { + return nil, err + } + var authorities []authorityPO + if err := r.data.gormDB.WithContext(ctx).Find(&authorities).Error; err != nil { + return nil, err + } + children := make(map[uint][]uint) + for _, authority := range authorities { + if authority.ParentID != nil { + children[*authority.ParentID] = append(children[*authority.ParentID], authority.AuthorityID) + } + } + var walk func(uint) + walk = func(id uint) { + for _, child := range children[id] { + if !allowed[child] { + allowed[child] = true + walk(child) + } + } + } + if actor.ParentID == nil || *actor.ParentID == 0 { + allowed[actorID] = true + } + walk(actorID) + return allowed, nil +} + +func (r *accessRepo) checkAuthorityIDAuth(ctx context.Context, targetID uint) error { + config := r.data.runtime.Admin() + if config == nil || config.System == nil || !config.System.UseStrictAuth { + return nil + } + actor, ok := biz.ActorFromContext(ctx) + if !ok { + return errors.New("您提交的角色ID不合法") + } + allowed, err := r.strictAuthorityIDs(ctx, actor.AuthorityID) + if err != nil { + return err + } + if !allowed[targetID] { + return errors.New("您提交的角色ID不合法") + } + return nil +} + func (r *accessRepo) CreateAuthority(ctx context.Context, value *biz.Authority) error { + config := r.data.runtime.Admin() + if config != nil && config.System != nil && config.System.UseStrictAuth && (value.ParentID == nil || *value.ParentID == 0) { + if actor, ok := biz.ActorFromContext(ctx); ok { + value.ParentID = &actor.AuthorityID + } + } return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var count int64 if err := tx.Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Count(&count).Error; err != nil { @@ -46,6 +106,18 @@ func (r *accessRepo) CreateAuthority(ctx context.Context, value *biz.Authority) } func (r *accessRepo) CopyAuthority(ctx context.Context, sourceID uint, value *biz.Authority) error { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + config := r.data.runtime.Admin() + actor, hasActor := biz.ActorFromContext(ctx) + strict := hasActor && actor.AuthorityID != 888 && config != nil && config.System != nil && config.System.UseStrictAuth + if strict { + allowed, err := r.strictAuthorityIDs(ctx, actor.AuthorityID) + if err != nil { + return err + } + if value.ParentID == nil || !allowed[*value.ParentID] { + return errors.New("严格角色模式下只能在自己的角色树中复制角色") + } + } var count int64 if err := tx.Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Count(&count).Error; err != nil { return err @@ -76,6 +148,15 @@ func (r *accessRepo) CopyAuthority(ctx context.Context, sourceID uint, value *bi return err } for i := range apis { + if strict { + var allowedCount int64 + if err := tx.Model(&authorityAPIPO{}).Where("authority_id = ? AND api_id = ?", actor.AuthorityID, apis[i].APIID).Count(&allowedCount).Error; err != nil { + return err + } + if allowedCount == 0 { + return errors.New("严格角色模式下不能复制当前角色未拥有的 API") + } + } apis[i].AuthorityID = value.AuthorityID } if len(apis) > 0 { @@ -95,16 +176,6 @@ func (r *accessRepo) CopyAuthority(ctx context.Context, sourceID uint, value *bi return err } } - var departments []authorityDepartmentPO - if err := copyLinks("sys_authority_departments", &departments, map[string]any{"authority_id": sourceID}); err != nil { - return err - } - for i := range departments { - departments[i].AuthorityID = value.AuthorityID - } - if len(departments) > 0 { - return tx.Create(&departments).Error - } return nil }) } @@ -155,8 +226,20 @@ func (r *accessRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, err if err := r.data.gormDB.WithContext(ctx).Order("authority_id").Find(&pos).Error; err != nil { return nil, err } + var allowed map[uint]bool + config := r.data.runtime.Admin() + if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { + var err error + allowed, err = r.strictAuthorityIDs(ctx, actor.AuthorityID) + if err != nil { + return nil, err + } + } out := make([]*biz.Authority, 0, len(pos)) for _, po := range pos { + if allowed != nil && !allowed[po.AuthorityID] { + continue + } v := toBizAuthority(po) out = append(out, &v) } @@ -190,11 +273,13 @@ func (r *accessRepo) SetAuthorityUsers(ctx context.Context, id uint, ids []uint) } var user userPO if err := tx.First(&user, uid).Error; err == nil && user.AuthorityID == id { - if err = tx.Model(&user).Update("authority_id", 888).Error; err != nil { + var another userAuthorityPO + if err = tx.Where("sys_user_id = ?", uid).First(&another).Error; errors.Is(err, gorm.ErrRecordNotFound) { + continue + } else if err != nil { return err } - fallback := userAuthorityPO{SysUserID: uid, SysAuthorityAuthorityID: 888} - if err = tx.FirstOrCreate(&fallback, fallback).Error; err != nil { + if err = tx.Model(&user).Update("authority_id", another.SysAuthorityAuthorityID).Error; err != nil { return err } } @@ -215,12 +300,14 @@ func (r *accessRepo) SetDataScope(ctx context.Context, id uint, scope int, deptI if err := tx.Where("authority_id = ?", id).Delete(&authorityDepartmentPO{}).Error; err != nil { return err } - links := make([]authorityDepartmentPO, 0, len(deptIDs)) - for _, did := range deptIDs { - links = append(links, authorityDepartmentPO{AuthorityID: id, DepartmentID: did}) - } - if len(links) > 0 { - return tx.Create(&links).Error + if scope == 5 { + links := make([]authorityDepartmentPO, 0, len(deptIDs)) + for _, did := range deptIDs { + links = append(links, authorityDepartmentPO{AuthorityID: id, DepartmentID: did}) + } + if len(links) > 0 { + return tx.Create(&links).Error + } } return nil }) diff --git a/internal/data/cache.go b/internal/data/cache.go index 2f69c8c..dd7c9bf 100644 --- a/internal/data/cache.go +++ b/internal/data/cache.go @@ -22,14 +22,14 @@ type cacheStore struct { memory map[string]memoryCacheEntry } +const maxMemoryCacheEntries = 10000 + func NewCache(data *Data) biz.Cache { return &cacheStore{data: data, memory: make(map[string]memoryCacheEntry)} } func (s *cacheStore) client() *redis.Client { - s.data.mu.RLock() - defer s.data.mu.RUnlock() - return s.data.redis + return s.data.redis.load() } func (s *cacheStore) Get(ctx context.Context, key string) (string, bool, error) { @@ -62,6 +62,7 @@ func (s *cacheStore) Set(ctx context.Context, key, value string, expiration time } s.mu.Lock() defer s.mu.Unlock() + s.makeMemoryRoom(key) entry := memoryCacheEntry{value: value} if expiration > 0 { entry.expiresAt = time.Now().Add(expiration) @@ -95,6 +96,7 @@ func (s *cacheStore) Increment(ctx context.Context, key string, expiration time. } s.mu.Lock() defer s.mu.Unlock() + s.makeMemoryRoom(key) entry, ok := s.memory[key] if ok && !entry.expiresAt.IsZero() && time.Now().After(entry.expiresAt) { ok = false @@ -111,3 +113,27 @@ func (s *cacheStore) Increment(ctx context.Context, key string, expiration time. s.memory[key] = entry return value, nil } + +func (s *cacheStore) makeMemoryRoom(incoming string) { + if len(s.memory) < maxMemoryCacheEntries { + return + } + now := time.Now() + for key, entry := range s.memory { + if !entry.expiresAt.IsZero() && now.After(entry.expiresAt) { + delete(s.memory, key) + } + } + if len(s.memory) < maxMemoryCacheEntries { + return + } + if _, exists := s.memory[incoming]; exists { + return + } + // The in-memory store is only a Redis fallback. A bounded arbitrary eviction + // is preferable to unbounded growth under IP churn. + for key := range s.memory { + delete(s.memory, key) + break + } +} diff --git a/internal/data/config_store.go b/internal/data/config_store.go index 5d6b041..a868ddd 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -53,14 +53,22 @@ func setYAMLMapping(node *yaml.Node, key string, value any) error { } func (d *Data) persistConfig() error { - return d.persistConfigValues(d.config, d.admin) + dataConfig, adminConfig := d.runtime.Values() + return d.persistConfigValues(dataConfig, adminConfig) } func (d *Data) persistConfigValues(dataConfig *conf.Data, adminConfig *conf.AdminBackend) error { - if d.admin == nil || d.admin.ConfigPath == "" { + d.configMu.Lock() + defer d.configMu.Unlock() + return d.persistConfigValuesLocked(dataConfig, adminConfig) +} + +func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *conf.AdminBackend) error { + if adminConfig == nil || adminConfig.ConfigPath == "" { return nil } - raw, err := os.ReadFile(d.admin.ConfigPath) + configPath := adminConfig.ConfigPath + raw, err := os.ReadFile(configPath) if err != nil { return err } @@ -86,10 +94,10 @@ func (d *Data) persistConfigValues(dataConfig *conf.Data, adminConfig *conf.Admi if err != nil { return err } - if err = os.MkdirAll(filepath.Dir(d.admin.ConfigPath), 0o755); err != nil { + if err = os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { return err } - temporary, err := os.CreateTemp(filepath.Dir(d.admin.ConfigPath), ".kra-config-*.yaml") + temporary, err := os.CreateTemp(filepath.Dir(configPath), ".kra-config-*.yaml") if err != nil { return err } @@ -106,33 +114,24 @@ func (d *Data) persistConfigValues(dataConfig *conf.Data, adminConfig *conf.Admi if err = temporary.Close(); err != nil { return err } - return os.Rename(tempName, d.admin.ConfigPath) + return os.Rename(tempName, configPath) } func (d *Data) reloadConfig(ctx context.Context) error { - if d.admin == nil || d.admin.ConfigPath == "" { + d.configMu.Lock() + defer d.configMu.Unlock() + configPath := d.runtime.ConfigPath() + if configPath == "" { return fmt.Errorf("configuration path is not set") } - raw, err := os.ReadFile(d.admin.ConfigPath) + next, err := readBootstrap(configPath) if err != nil { return err } - var plain map[string]any - if err = yaml.Unmarshal(raw, &plain); err != nil { - return err - } - encoded, err := json.Marshal(plain) - if err != nil { - return err - } - var next conf.Bootstrap - if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(encoded, &next); err != nil { - return err - } if next.Data == nil || next.Data.Database == nil || next.Admin == nil { return fmt.Errorf("data.database and admin configuration are required") } - next.Admin.ConfigPath = d.admin.ConfigPath + next.Admin.ConfigPath = configPath candidateDB, err := openDatabase(next.Data.Database, false, "") if err != nil { return fmt.Errorf("reload database: %w", err) @@ -150,35 +149,49 @@ func (d *Data) reloadConfig(ctx context.Context) error { } else if err = sqlDB.PingContext(ctx); err != nil { return fmt.Errorf("reload database: %w", err) } - if err = migrateAll(candidateDB.WithContext(ctx)); err != nil { - return fmt.Errorf("reload database migrations: %w", err) + if next.Admin.System == nil || !next.Admin.System.DisableAutoMigrate { + if err = migrateAll(candidateDB.WithContext(ctx)); err != nil { + return fmt.Errorf("reload database migrations: %w", err) + } } candidateStorage, err := buildFileStorage(next.Admin) if err != nil { return fmt.Errorf("reload storage: %w", err) } - candidateRedis := openRedis(next.Data.Redis) + useRedis := next.Admin.System != nil && next.Admin.System.UseRedis + candidateRedis := openRedis(next.Data.Redis, useRedis) + candidateDBList, err := openDatabaseList(next.Data.DatabaseList) + if err != nil { + return err + } - d.mu.Lock() - oldDB, oldRedis := d.gormDB, d.redis - d.gormDB, d.redis = candidateDB, candidateRedis - proto.Reset(d.config) - proto.Merge(d.config, next.Data) - proto.Reset(d.admin) - proto.Merge(d.admin, next.Admin) - d.database = d.config.Database - d.mu.Unlock() + d.gormDB.replace(candidateDB) + d.replaceDatabaseList(candidateDBList) + d.redis.replace(candidateRedis) + d.runtime.Replace(next.Data, next.Admin) if d.storage != nil { d.storage.replace(candidateStorage) } closeCandidate = false - if oldDB != nil { - if sqlDB, closeErr := oldDB.DB(); closeErr == nil { - _ = sqlDB.Close() - } - } - if oldRedis != nil { - _ = oldRedis.Close() - } return nil } + +func readBootstrap(configPath string) (*conf.Bootstrap, error) { + raw, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + var plain map[string]any + if err = yaml.Unmarshal(raw, &plain); err != nil { + return nil, err + } + encoded, err := json.Marshal(plain) + if err != nil { + return nil, err + } + var next conf.Bootstrap + if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(encoded, &next); err != nil { + return nil, err + } + return &next, nil +} diff --git a/internal/data/config_watch.go b/internal/data/config_watch.go new file mode 100644 index 0000000..0230401 --- /dev/null +++ b/internal/data/config_watch.go @@ -0,0 +1,80 @@ +package data + +import ( + "log" + "path/filepath" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +// watchConfig refreshes the in-memory config snapshot on file changes, while +// /system/reloadSystem remains responsible +// for rebuilding database, Redis, storage, and scheduled tasks. +func (d *Data) watchConfig() func() { + configPath := d.runtime.ConfigPath() + if configPath == "" { + return func() {} + } + absolute, err := filepath.Abs(configPath) + if err != nil { + log.Printf("resolve config watch path: %v", err) + return func() {} + } + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("create config watcher: %v", err) + return func() {} + } + if err = watcher.Add(filepath.Dir(absolute)); err != nil { + log.Printf("watch config directory: %v", err) + _ = watcher.Close() + return func() {} + } + done := make(chan struct{}) + var once sync.Once + go func() { + var timer *time.Timer + for { + select { + case event, ok := <-watcher.Events: + if !ok || filepath.Clean(event.Name) != absolute || event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { + continue + } + if timer != nil { + timer.Stop() + } + timer = time.AfterFunc(100*time.Millisecond, func() { + next, loadErr := readBootstrap(absolute) + if loadErr != nil { + log.Printf("reload changed config: %v", loadErr) + return + } + if next.Data == nil || next.Admin == nil { + log.Printf("reload changed config: data and admin configuration are required") + return + } + next.Admin.ConfigPath = absolute + d.runtime.Replace(next.Data, next.Admin) + log.Printf("config file changed: %s", absolute) + }) + case watchErr, ok := <-watcher.Errors: + if ok { + log.Printf("config watcher error: %v", watchErr) + } + case <-done: + if timer != nil { + timer.Stop() + } + return + } + } + }() + return func() { + once.Do(func() { + close(done) + _ = watcher.Close() + }) + } +} diff --git a/internal/data/data.go b/internal/data/data.go index e1048cd..4031b5f 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -16,21 +16,71 @@ import ( var ProviderSet = wire.NewSet(NewData, NewSystemRepo, NewAccessRepo, NewMenuRepo, NewOrganizationRepo, NewSettingsRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewTaskRepo, NewMediaRepo, NewAnnouncementRepo, NewEmailRepo, NewCache, NewFileStorage) type Data struct { - mu sync.RWMutex initMu sync.Mutex - gormDB *gorm.DB - redis *redis.Client - database *conf.Data_Database - config *conf.Data - admin *conf.AdminBackend + configMu sync.Mutex + gormDB *reloadableDB + redis *reloadableRedis + runtime *conf.Runtime storage *reloadableStorage + dbListMu sync.RWMutex + dbList map[string]*gorm.DB } -func NewData(c *conf.Data, admin *conf.AdminBackend) (*Data, func(), error) { +func openDatabaseList(configs []*conf.Data_Database) (map[string]*gorm.DB, error) { + items := make(map[string]*gorm.DB) + for _, config := range configs { + if config == nil || config.Disable || config.AliasName == "" { + continue + } + db, err := openDatabase(config, false, "") + if err != nil { + for _, opened := range items { + if sqlDB, dbErr := opened.DB(); dbErr == nil { + _ = sqlDB.Close() + } + } + return nil, fmt.Errorf("open database %q: %w", config.AliasName, err) + } + items[config.AliasName] = db + } + return items, nil +} + +func closeDatabaseList(items map[string]*gorm.DB) { + for _, db := range items { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + } +} + +func (d *Data) replaceDatabaseList(items map[string]*gorm.DB) { + d.dbListMu.Lock() + old := d.dbList + d.dbList = items + d.dbListMu.Unlock() + closeDatabaseList(old) +} + +func (d *Data) database(name string) (*gorm.DB, error) { + if name == "" { + return d.gormDB.DB(), nil + } + d.dbListMu.RLock() + db := d.dbList[name] + d.dbListMu.RUnlock() + if db == nil { + return nil, fmt.Errorf("database %q not found", name) + } + return db, nil +} + +func NewData(runtime *conf.Runtime) (*Data, func(), error) { + c := runtime.Data() if c == nil || c.Database == nil { return nil, nil, fmt.Errorf("database configuration is required") } - d := &Data{database: c.Database, config: c, admin: admin} + d := &Data{runtime: runtime} db, err := openDatabase(c.Database, false, "") usingFallback := false if err != nil { @@ -43,31 +93,33 @@ func NewData(c *conf.Data, admin *conf.AdminBackend) (*Data, func(), error) { } usingFallback = true } - d.gormDB = db - if !usingFallback { + d.gormDB = newReloadableDB(db) + d.dbList, err = openDatabaseList(c.DatabaseList) + if err != nil { + d.gormDB.close() + return nil, nil, err + } + admin := runtime.Admin() + disableAutoMigrate := admin != nil && admin.System != nil && admin.System.DisableAutoMigrate + if !usingFallback && !disableAutoMigrate { if err = migrateAll(db); err != nil { return nil, nil, fmt.Errorf("migrate tables: %w", err) } } - if candidate := openRedis(c.Redis); candidate != nil { - d.redis = candidate - } + useRedis := admin != nil && admin.System != nil && admin.System.UseRedis + d.redis = newReloadableRedis(openRedis(c.Redis, useRedis)) + stopConfigWatcher := d.watchConfig() cleanup := func() { - d.mu.RLock() - db := d.gormDB - d.mu.RUnlock() - if sqlDB, closeErr := db.DB(); closeErr == nil { - _ = sqlDB.Close() - } - if d.redis != nil { - _ = d.redis.Close() - } + stopConfigWatcher() + d.gormDB.close() + closeDatabaseList(d.dbList) + d.redis.close() } return d, cleanup, nil } -func openRedis(config *conf.Data_Redis) *redis.Client { - if config == nil || config.Addr == "" { +func openRedis(config *conf.Data_Redis, enabled bool) *redis.Client { + if !enabled || config == nil || config.Addr == "" { return nil } options := &redis.Options{Addr: config.Addr, Network: config.Network} @@ -89,15 +141,6 @@ func openRedis(config *conf.Data_Redis) *redis.Client { } func (d *Data) activateDatabase(db *gorm.DB, config *conf.Data_Database) { - d.mu.Lock() - old := d.gormDB - d.gormDB = db - d.database = config - d.config.Database = config - d.mu.Unlock() - if old != nil { - if sqlDB, e := old.DB(); e == nil { - _ = sqlDB.Close() - } - } + d.gormDB.replace(db) + d.runtime.UpdateDatabase(config) } diff --git a/internal/data/data_access_log.go b/internal/data/data_access_log.go index a7c56db..ea756ae 100644 --- a/internal/data/data_access_log.go +++ b/internal/data/data_access_log.go @@ -29,12 +29,6 @@ func dataAccessFromPO(v dataAccessLogPO) *biz.DataAccessLog { return &biz.DataAccessLog{ID: v.ID, CreatedAt: v.CreatedAt, 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 *auditRepo) ListDataAccess(ctx context.Context, page, size int, q *biz.DataAccessLog) ([]*biz.DataAccessLog, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } db := r.data.gormDB.WithContext(ctx).Model(&dataAccessLogPO{}) if q != nil { if q.EventType != "" { @@ -52,7 +46,7 @@ func (r *auditRepo) ListDataAccess(ctx context.Context, page, size int, q *biz.D return nil, 0, err } var pos []dataAccessLogPO - if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.DataAccessLog, 0, len(pos)) diff --git a/internal/data/department.go b/internal/data/department.go index 1abea14..be3104d 100644 --- a/internal/data/department.go +++ b/internal/data/department.go @@ -44,6 +44,30 @@ func (authorityDepartmentPO) TableName() string { return "sys_authority_departme func deptFromPO(po departmentPO) *biz.Department { return &biz.Department{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Name: po.Name, ParentID: po.ParentID, Ancestors: po.Ancestors, Sort: po.Sort, LeaderID: po.LeaderID, Status: po.Status} } + +func (r *organizationRepo) attachDepartmentLeaders(ctx context.Context, departments []*biz.Department) error { + leaderIDs := make([]uint, 0, len(departments)) + for _, department := range departments { + if department.LeaderID != 0 { + leaderIDs = append(leaderIDs, department.LeaderID) + } + } + if len(leaderIDs) == 0 { + return nil + } + var leaders []userPO + if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", leaderIDs).Find(&leaders).Error; err != nil { + return err + } + leaderByID := make(map[uint]*biz.User, len(leaders)) + for i := range leaders { + leaderByID[leaders[i].ID] = baseBizUser(&leaders[i]) + } + for _, department := range departments { + department.Leader = leaderByID[department.LeaderID] + } + return nil +} func (r *organizationRepo) CreateDepartment(ctx context.Context, v *biz.Department) error { v.Ancestors = "0" if v.ParentID != 0 { @@ -99,7 +123,11 @@ func (r *organizationRepo) FindDepartment(ctx context.Context, id uint) (*biz.De if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } - return deptFromPO(po), nil + department := deptFromPO(po) + if err := r.attachDepartmentLeaders(ctx, []*biz.Department{department}); err != nil { + return nil, err + } + return department, nil } func (r *organizationRepo) ListDepartments(ctx context.Context, name string) ([]*biz.Department, error) { var pos []departmentPO @@ -111,8 +139,17 @@ func (r *organizationRepo) ListDepartments(ctx context.Context, name string) ([] return nil, err } nodes := map[uint]*biz.Department{} + items := make([]*biz.Department, 0, len(pos)) for _, po := range pos { - nodes[po.ID] = deptFromPO(po) + item := deptFromPO(po) + nodes[po.ID] = item + items = append(items, item) + } + if err := r.attachDepartmentLeaders(ctx, items); err != nil { + return nil, err + } + if name != "" { + return items, nil } var roots []*biz.Department for _, po := range pos { diff --git a/internal/data/dictionary.go b/internal/data/dictionary.go index 41b8b1f..6360d90 100644 --- a/internal/data/dictionary.go +++ b/internal/data/dictionary.go @@ -62,6 +62,47 @@ func (r *settingsRepo) CreateDictionary(ctx context.Context, v *biz.Dictionary) v.ID = po.ID return nil } + +func (r *settingsRepo) ImportDictionary(ctx context.Context, dictionary *biz.Dictionary, details []*biz.DictionaryDetail) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + po := dictionaryPO{Name: dictionary.Name, Type: dictionary.Type, Status: dictionary.Status, Desc: dictionary.Desc, ParentID: dictionary.ParentID} + if err := tx.Create(&po).Error; err != nil { + return err + } + dictionary.ID = po.ID + idMap := make(map[uint]uint, len(details)) + created := make(map[uint]uint, len(details)) + for index, item := range details { + // Incomplete detail rows are ignored to preserve import behavior. + if item.Label == "" || item.Value == "" { + continue + } + oldID := item.ID + detail := dictionaryDetailPO{Label: item.Label, Value: item.Value, Extend: item.Extend, Status: item.Status, Sort: item.Sort, DictionaryID: po.ID, Level: item.Level, Path: item.Path} + if err := tx.Create(&detail).Error; err != nil { + return err + } + created[uint(index)] = detail.ID + if oldID > 0 { + idMap[oldID] = detail.ID + } + } + for index, item := range details { + if item.ID == 0 || item.ParentID == nil || *item.ParentID == 0 { + continue + } + newID, exists := created[uint(index)] + newParentID, parentExists := idMap[*item.ParentID] + if !exists || !parentExists { + continue + } + if err := tx.Model(&dictionaryDetailPO{}).Where("id = ?", newID).Update("parent_id", newParentID).Error; err != nil { + return err + } + } + return nil + }) +} func (r *settingsRepo) UpdateDictionary(ctx context.Context, v *biz.Dictionary) error { return r.data.gormDB.WithContext(ctx).Model(&dictionaryPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "type": v.Type, "status": v.Status, "desc": v.Desc, "parent_id": v.ParentID}).Error } @@ -80,51 +121,98 @@ func (r *settingsRepo) DeleteDictionary(ctx context.Context, id uint) error { return tx.Delete(&dictionaryPO{}, id).Error }) } -func (r *settingsRepo) FindDictionary(ctx context.Context, id uint, typ string, details bool) (*biz.Dictionary, error) { +func (r *settingsRepo) FindDictionary(ctx context.Context, id uint, typ string, status *bool, details bool) (*biz.Dictionary, error) { var po dictionaryPO db := r.data.gormDB.WithContext(ctx) - var err error - if id != 0 { - err = db.First(&po, id).Error - } else { - err = db.Where("type = ?", typ).First(&po).Error + active := true + if status != nil { + active = *status } + err := db.Where("(type = ? OR id = ?) AND status = ?", typ, id, active).First(&po).Error if err != nil { return nil, err } out := dictionaryFromPO(po) if details { - out.Details, _, err = r.ListDictionaryDetails(ctx, 1, 10000, out.ID, "", "") + out.Details, _, err = r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: out.ID}) + if err == nil { + filtered := out.Details[:0] + for _, item := range out.Details { + if item.Status { + filtered = append(filtered, item) + } + } + out.Details = filtered + } } return out, err } + +func (r *settingsRepo) ExportDictionary(ctx context.Context, id uint) (*biz.Dictionary, error) { + var po dictionaryPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + value := dictionaryFromPO(po) + details, _, err := r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: id}) + if err != nil { + return nil, err + } + value.Details = details + return value, nil +} func (r *settingsRepo) ListDictionaries(ctx context.Context, page, size int, name, typ string, details bool) ([]*biz.Dictionary, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } db := r.data.gormDB.WithContext(ctx).Model(&dictionaryPO{}) if name != "" { - db = db.Where("name LIKE ?", "%"+name+"%") - } - if typ != "" { - db = db.Where("type LIKE ?", "%"+typ+"%") + like := "%" + name + "%" + db = db.Where("name LIKE ? OR type LIKE ?", like, like) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var pos []dictionaryPO - if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db.Order("id"), page, size, 0).Find(&pos).Error; err != nil { return nil, 0, err } + parentIDs := make([]uint, 0, len(pos)) + dictionaryIDs := make([]uint, 0, len(pos)) + for _, po := range pos { + parentIDs = append(parentIDs, po.ID) + dictionaryIDs = append(dictionaryIDs, po.ID) + } + childrenByParent := make(map[uint][]dictionaryPO) + if len(parentIDs) > 0 { + var children []dictionaryPO + if err := r.data.gormDB.WithContext(ctx).Where("parent_id IN ?", parentIDs).Order("id").Find(&children).Error; err != nil { + return nil, 0, err + } + for _, child := range children { + if child.ParentID != nil { + childrenByParent[*child.ParentID] = append(childrenByParent[*child.ParentID], child) + } + } + } + detailsByDictionary := make(map[uint][]dictionaryDetailPO) + if details && len(dictionaryIDs) > 0 { + var values []dictionaryDetailPO + if err := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id IN ?", dictionaryIDs).Order("sort").Find(&values).Error; err != nil { + return nil, 0, err + } + for _, value := range values { + detailsByDictionary[value.DictionaryID] = append(detailsByDictionary[value.DictionaryID], value) + } + } out := make([]*biz.Dictionary, 0, len(pos)) for _, po := range pos { v := dictionaryFromPO(po) + for _, child := range childrenByParent[po.ID] { + v.Children = append(v.Children, dictionaryFromPO(child)) + } if details { - v.Details, _, _ = r.ListDictionaryDetails(ctx, 1, 10000, v.ID, "", "") + for _, detail := range detailsByDictionary[v.ID] { + v.Details = append(v.Details, detailFromPO(detail)) + } } out = append(out, v) } @@ -197,29 +285,32 @@ func (r *settingsRepo) FindDictionaryDetail(ctx context.Context, id uint) (*biz. } return detailFromPO(po), nil } -func (r *settingsRepo) ListDictionaryDetails(ctx context.Context, page, size int, dictionaryID uint, label, value string) ([]*biz.DictionaryDetail, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } +func (r *settingsRepo) ListDictionaryDetails(ctx context.Context, page, size int, filter biz.DictionaryDetailFilter) ([]*biz.DictionaryDetail, int64, error) { db := r.data.gormDB.WithContext(ctx).Model(&dictionaryDetailPO{}) - if dictionaryID != 0 { - db = db.Where("sys_dictionary_id = ?", dictionaryID) + if filter.DictionaryID != 0 { + db = db.Where("sys_dictionary_id = ?", filter.DictionaryID) } - if label != "" { - db = db.Where("label LIKE ?", "%"+label+"%") + if filter.Label != "" { + db = db.Where("label LIKE ?", "%"+filter.Label+"%") } - if value != "" { - db = db.Where("value LIKE ?", "%"+value+"%") + if filter.Value != "" { + db = db.Where("value = ?", filter.Value) + } + if filter.Status != nil { + db = db.Where("status = ?", *filter.Status) + } + if filter.ParentID != nil { + db = db.Where("parent_id = ?", *filter.ParentID) + } + if filter.Level != nil { + db = db.Where("level = ?", *filter.Level) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var pos []dictionaryDetailPO - if err := db.Order("sort,id").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db.Order("sort,id"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.DictionaryDetail, 0, len(pos)) @@ -230,13 +321,14 @@ func (r *settingsRepo) ListDictionaryDetails(ctx context.Context, page, size int } func (r *settingsRepo) DictionaryDetailTree(ctx context.Context, dictionaryID uint, typ string) ([]*biz.DictionaryDetail, error) { if dictionaryID == 0 { - dictionary, err := r.FindDictionary(ctx, 0, typ, false) + active := true + dictionary, err := r.FindDictionary(ctx, 0, typ, &active, false) if err != nil { return nil, err } dictionaryID = dictionary.ID } - items, _, err := r.ListDictionaryDetails(ctx, 1, 100000, dictionaryID, "", "") + items, _, err := r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: dictionaryID}) if err != nil { return nil, err } diff --git a/internal/data/email.go b/internal/data/email.go index 712946b..8665729 100644 --- a/internal/data/email.go +++ b/internal/data/email.go @@ -17,21 +17,22 @@ import ( ) type emailRepo struct { - config *conf.AdminBackend + runtime *conf.Runtime } -func NewEmailRepo(config *conf.AdminBackend) biz.EmailRepo { - if config == nil { - return &emailRepo{} - } - return &emailRepo{config: config} +func NewEmailRepo(runtime *conf.Runtime) biz.EmailRepo { + return &emailRepo{runtime: runtime} } func (r *emailRepo) email() *conf.AdminBackend_Email { - if r.config == nil { + if r.runtime == nil { return nil } - return r.config.Email + config := r.runtime.Admin() + if config == nil { + return nil + } + return config.Email } func (r *emailRepo) Enabled() bool { diff --git a/internal/data/error_record.go b/internal/data/error_record.go index 293405b..f474ca9 100644 --- a/internal/data/error_record.go +++ b/internal/data/error_record.go @@ -48,22 +48,16 @@ func (r *auditRepo) FindError(ctx context.Context, id uint) (*biz.ErrorRecord, e return errorFromPO(po), nil } func (r *auditRepo) ListErrors(ctx context.Context, page, size int, q *biz.ErrorRecord) ([]*biz.ErrorRecord, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } db := r.data.gormDB.WithContext(ctx).Model(&errorRecordPO{}) if q != nil { + if len(q.CreatedAtRange) == 2 { + db = db.Where("created_at BETWEEN ? AND ?", q.CreatedAtRange[0], q.CreatedAtRange[1]) + } if q.Form != "" { - db = db.Where("form LIKE ?", "%"+q.Form+"%") + db = db.Where("form = ?", q.Form) } - if q.Level != "" { - db = db.Where("level = ?", q.Level) - } - if q.Status != "" { - db = db.Where("status = ?", q.Status) + if q.Info != "" { + db = db.Where("info LIKE ?", "%"+q.Info+"%") } } var total int64 @@ -71,7 +65,7 @@ func (r *auditRepo) ListErrors(ctx context.Context, page, size int, q *biz.Error return nil, 0, err } var pos []errorRecordPO - if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.ErrorRecord, 0, len(pos)) diff --git a/internal/data/export.go b/internal/data/export.go index b2b5b6a..0c8155c 100644 --- a/internal/data/export.go +++ b/internal/data/export.go @@ -2,12 +2,11 @@ package data import ( "context" + "encoding/json" "errors" "fmt" "gorm.io/gorm" "kra/internal/biz" - "regexp" - "sort" "strconv" "strings" "time" @@ -151,14 +150,11 @@ func (r *exportRepo) FindExportTemplate(ctx context.Context, id uint, tid string return exportFromPO(po, conditions, joins), nil } func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q *biz.ExportTemplate) ([]*biz.ExportTemplate, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } db := r.data.gormDB.WithContext(ctx).Model(&exportTemplatePO{}) if q != nil { + if q.StartCreatedAt != nil && q.EndCreatedAt != nil { + db = db.Where("created_at BETWEEN ? AND ?", q.StartCreatedAt, q.EndCreatedAt) + } if q.Name != "" { db = db.Where("name LIKE ?", "%"+q.Name+"%") } @@ -174,37 +170,46 @@ func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q return nil, 0, err } var pos []exportTemplatePO - if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db, page, size, 0).Find(&pos).Error; err != nil { return nil, 0, err } + templateIDs := make([]string, 0, len(pos)) + for _, po := range pos { + templateIDs = append(templateIDs, po.TemplateID) + } + var conditions []exportConditionPO + var joins []exportJoinPO + if len(templateIDs) > 0 { + if err := r.data.gormDB.WithContext(ctx).Where("template_id IN ?", templateIDs).Find(&conditions).Error; err != nil { + return nil, 0, err + } + if err := r.data.gormDB.WithContext(ctx).Where("template_id IN ?", templateIDs).Find(&joins).Error; err != nil { + return nil, 0, err + } + } + conditionsByTemplate := make(map[string][]exportConditionPO, len(pos)) + for _, condition := range conditions { + conditionsByTemplate[condition.TemplateID] = append(conditionsByTemplate[condition.TemplateID], condition) + } + joinsByTemplate := make(map[string][]exportJoinPO, len(pos)) + for _, join := range joins { + joinsByTemplate[join.TemplateID] = append(joinsByTemplate[join.TemplateID], join) + } out := make([]*biz.ExportTemplate, 0, len(pos)) for _, po := range pos { - v, err := r.FindExportTemplate(ctx, po.ID, "") - if err != nil { - return nil, 0, err - } - out = append(out, v) + out = append(out, exportFromPO(po, conditionsByTemplate[po.TemplateID], joinsByTemplate[po.TemplateID])) } return out, total, nil } -var safeIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.]*$`) -var allowedOperator = map[string]bool{"=": true, "!=": true, ">": true, ">=": true, "<": true, "<=": true, "LIKE": true, "IN": true, "NOT IN": true, "BETWEEN": true} - -func validateSelectSQL(sql string) error { - normalized := strings.ToLower(strings.TrimSpace(sql)) - if !strings.HasPrefix(normalized, "select ") || strings.Contains(normalized, ";") { - return errors.New("仅允许单条 SELECT 查询") - } - return nil -} func (r *exportRepo) QueryExport(ctx context.Context, t *biz.ExportTemplate, params map[string]string) ([]map[string]any, string, error) { - db := r.data.gormDB.WithContext(ctx) + selected, err := r.data.database(t.DBName) + if err != nil { + return nil, "", err + } + db := selected.WithContext(ctx) var rows []map[string]any if t.SQL != "" { - if err := validateSelectSQL(t.SQL); err != nil { - return nil, "", err - } args := map[string]any{} for k, v := range params { args[k] = v @@ -212,52 +217,54 @@ func (r *exportRepo) QueryExport(ctx context.Context, t *biz.ExportTemplate, par err := db.Raw(t.SQL, args).Scan(&rows).Error return rows, t.SQL, err } - if !safeIdentifier.MatchString(t.TableName) { - return nil, "", errors.New("表名不合法") - } columns := make([]string, 0) - for key := range parseTemplateColumns(t.TemplateInfo) { - if !safeIdentifier.MatchString(key) { - return nil, "", errors.New("列名不合法") - } - columns = append(columns, key) + if err := json.Unmarshal([]byte(t.TemplateInfo), &map[string]string{}); err != nil { + return nil, "", err + } + decoder := json.NewDecoder(strings.NewReader(t.TemplateInfo)) + _, _ = decoder.Token() + for decoder.More() { + key, _ := decoder.Token() + columns = append(columns, key.(string)) + var ignored any + _ = decoder.Decode(&ignored) } - sort.Strings(columns) if len(columns) == 0 { return nil, "", errors.New("模板列为空") } query := db.Table(t.TableName).Select(strings.Join(columns, ",")) for _, j := range t.Joins { - joinType := strings.ToUpper(strings.TrimSpace(j.Join)) - if joinType != "LEFT JOIN" && joinType != "RIGHT JOIN" && joinType != "INNER JOIN" && joinType != "JOIN" { - return nil, "", errors.New("关联类型不合法") + query = query.Joins(j.Join + " " + j.Table + " ON " + j.On) + } + if params["filterDeleted"] == "true" { + query = query.Where(fmt.Sprintf("%s.deleted_at IS NULL", t.TableName)) + for _, join := range t.Joins { + if db.Migrator().HasColumn(join.Table, "deleted_at") { + query = query.Where(fmt.Sprintf("%s.deleted_at IS NULL", join.Table)) + } } - if !safeIdentifier.MatchString(j.Table) { - return nil, "", errors.New("关联表不合法") - } - query = query.Joins(joinType + " " + j.Table + " ON " + j.On) } for _, condition := range t.Conditions { - operator := strings.ToUpper(strings.TrimSpace(condition.Operator)) - if !allowedOperator[operator] || !safeIdentifier.MatchString(condition.Column) { - return nil, "", errors.New("查询条件不合法") - } + operator := condition.Operator value := params[condition.From] - if value == "" { - continue - } switch operator { case "LIKE": - query = query.Where(condition.Column+" LIKE ?", "%"+value+"%") + if value != "" { + query = query.Where(condition.Column+" LIKE ?", "%"+value+"%") + } case "IN", "NOT IN": - query = query.Where(condition.Column+" "+operator+" ?", strings.Split(value, ",")) + if value != "" { + query = query.Where(condition.Column+" "+operator+" (?)", value) + } case "BETWEEN": start, end := params["start"+condition.From], params["end"+condition.From] if start != "" && end != "" { query = query.Where(condition.Column+" BETWEEN ? AND ?", start, end) } default: - query = query.Where(condition.Column+" "+operator+" ?", value) + if value != "" { + query = query.Where(condition.Column+" "+operator+" ?", value) + } } } limit := 0 @@ -269,17 +276,141 @@ func (r *exportRepo) QueryExport(ctx context.Context, t *biz.ExportTemplate, par if limit > 0 { query = query.Limit(limit) } - if t.Order != "" { - parts := strings.Fields(t.Order) - if len(parts) < 1 || len(parts) > 2 || !safeIdentifier.MatchString(parts[0]) || (len(parts) == 2 && strings.ToUpper(parts[1]) != "ASC" && strings.ToUpper(parts[1]) != "DESC") { - return nil, "", errors.New("排序不合法") + if raw := params["offset"]; raw != "" { + if offset, parseErr := strconv.Atoi(raw); parseErr == nil { + query = query.Offset(offset) } - query = query.Order(t.Order) + } + order := params["order"] + if order == "" { + order = t.Order + } + if order != "" { + parts := strings.Split(order, " ") + fields := map[string]bool{} + columnTypes, columnErr := db.Migrator().ColumnTypes(t.TableName) + if columnErr != nil { + return nil, "", columnErr + } + for _, column := range columnTypes { + fields[column.Name()] = true + } + if !fields[parts[0]] { + return nil, "", fmt.Errorf("order by %s is not in the fields", order) + } + orderSQL := parts[0] + if len(parts) > 1 { + if parts[1] != "asc" && parts[1] != "desc" { + return nil, "", fmt.Errorf("order by %s is not secure", order) + } + orderSQL += " " + parts[1] + } + query = query.Order(orderSQL) } preview := query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) }) - err := query.Find(&rows).Error + err = query.Find(&rows).Error return rows, preview, err } + +func (r *exportRepo) PreviewExport(ctx context.Context, t *biz.ExportTemplate, params map[string]string) (string, error) { + columns := make([]string, 0) + decoder := json.NewDecoder(strings.NewReader(t.TemplateInfo)) + if token, err := decoder.Token(); err != nil || token != json.Delim('{') { + return "", err + } + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return "", err + } + columns = append(columns, key.(string)) + var ignored any + if err = decoder.Decode(&ignored); err != nil { + return "", err + } + } + var builder strings.Builder + builder.WriteString("SELECT ") + builder.WriteString(strings.Join(columns, ", ")) + builder.WriteString(" FROM ") + builder.WriteString(t.TableName) + for _, join := range t.Joins { + builder.WriteString(" " + join.Join + " " + join.Table + " ON " + join.On) + } + wheres := make([]string, 0) + if params["filterDeleted"] == "true" { + wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", t.TableName)) + selected, err := r.data.database(t.DBName) + if err != nil { + return "", err + } + db := selected.WithContext(ctx) + for _, join := range t.Joins { + if db.Migrator().HasColumn(join.Table, "deleted_at") { + wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", join.Table)) + } + } + } + for _, condition := range t.Conditions { + op, column, value := strings.ToUpper(strings.TrimSpace(condition.Operator)), strings.TrimSpace(condition.Column), params[condition.From] + switch op { + case "BETWEEN": + start, end := params["start"+condition.From], params["end"+condition.From] + if start != "" && end != "" { + wheres = append(wheres, fmt.Sprintf("%s BETWEEN '%s' AND '%s'", column, start, end)) + } else { + wheres = append(wheres, fmt.Sprintf("%s BETWEEN {start%s} AND {end%s}", column, condition.From, condition.From)) + } + case "IN", "NOT IN": + if value != "" { + parts := strings.Split(value, ",") + for index := range parts { + parts[index] = strings.TrimSpace(parts[index]) + } + wheres = append(wheres, fmt.Sprintf("%s %s ('%s')", column, op, strings.Join(parts, "','"))) + } else { + wheres = append(wheres, fmt.Sprintf("%s %s ({%s})", column, op, condition.From)) + } + case "LIKE": + if value != "" { + wheres = append(wheres, fmt.Sprintf("%s LIKE '%%%s%%'", column, value)) + } else { + wheres = append(wheres, fmt.Sprintf("%s LIKE {%%%s%%}", column, condition.From)) + } + default: + if value != "" { + wheres = append(wheres, fmt.Sprintf("%s %s '%s'", column, op, value)) + } else { + wheres = append(wheres, fmt.Sprintf("%s %s {%s}", column, op, condition.From)) + } + } + } + if len(wheres) > 0 { + builder.WriteString(" WHERE " + strings.Join(wheres, " AND ")) + } + order := params["order"] + if order == "" { + order = t.Order + } + if order != "" { + builder.WriteString(" ORDER BY " + order) + } + limitRaw, offsetRaw := params["limit"], params["offset"] + if limitRaw == "" && t.Limit != nil && *t.Limit != 0 { + limitRaw = strconv.Itoa(*t.Limit) + } + limit, _ := strconv.Atoi(limitRaw) + offset, _ := strconv.Atoi(offsetRaw) + if limit > 0 { + builder.WriteString(" LIMIT " + strconv.Itoa(limit)) + if offset > 0 { + builder.WriteString(" OFFSET " + strconv.Itoa(offset)) + } + } else if offset > 0 { + builder.WriteString(" OFFSET " + strconv.Itoa(offset)) + } + return builder.String(), nil +} func parseTemplateColumns(raw string) map[string]string { out := map[string]string{} parts := strings.Split(strings.Trim(strings.TrimSpace(raw), "{}"), ",") @@ -293,16 +424,29 @@ func parseTemplateColumns(raw string) map[string]string { } func (r *exportRepo) ImportExportRows(ctx context.Context, t *biz.ExportTemplate, rows []map[string]any) error { sql := strings.TrimSpace(t.ImportSQL) - lower := strings.ToLower(sql) - if sql == "" || strings.Contains(sql, ";") || (!strings.HasPrefix(lower, "insert ") && !strings.HasPrefix(lower, "update ")) { - return errors.New("导入 SQL 仅允许单条 INSERT 或 UPDATE") + selected, err := r.data.database(t.DBName) + if err != nil { + return err } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - for index, row := range rows { - if err := tx.Exec(sql, row).Error; err != nil { - return fmt.Errorf("第%d行导入失败: %w", index+2, err) + return selected.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if sql != "" { + for _, row := range rows { + if err := tx.Exec(sql, row).Error; err != nil { + return err + } + } + return nil + } + needCreated := tx.Migrator().HasColumn(t.TableName, "created_at") + needUpdated := tx.Migrator().HasColumn(t.TableName, "updated_at") + for _, row := range rows { + if row["created_at"] == nil && needCreated { + row["created_at"] = time.Now() + } + if row["updated_at"] == nil && needUpdated { + row["updated_at"] = time.Now() } } - return nil + return tx.Table(t.TableName).CreateInBatches(&rows, 1000).Error }) } diff --git a/internal/data/file_storage.go b/internal/data/file_storage.go index 1e40f55..a1b5268 100644 --- a/internal/data/file_storage.go +++ b/internal/data/file_storage.go @@ -16,7 +16,7 @@ type reloadableStorage struct { } func NewFileStorage(data *Data) (biz.FileStorage, error) { - storage, err := buildFileStorage(data.admin) + storage, err := buildFileStorage(data.runtime.Admin()) if err != nil { return nil, err } diff --git a/internal/data/login_log.go b/internal/data/login_log.go index e2a567a..01a464d 100644 --- a/internal/data/login_log.go +++ b/internal/data/login_log.go @@ -29,19 +29,13 @@ func loginFromPO(v loginLogPO) *biz.LoginLog { return &biz.LoginLog{ID: v.ID, CreatedAt: v.CreatedAt, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID} } func (r *auditRepo) ListLogins(ctx context.Context, page, size int, q *biz.LoginLog) ([]*biz.LoginLog, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } db := r.data.gormDB.WithContext(ctx).Model(&loginLogPO{}) if q != nil { if q.Username != "" { db = db.Where("username LIKE ?", "%"+q.Username+"%") } - if q.IP != "" { - db = db.Where("ip LIKE ?", "%"+q.IP+"%") + if q.FilterByStatus { + db = db.Where("status = ?", q.Status) } } var total int64 @@ -49,12 +43,21 @@ func (r *auditRepo) ListLogins(ctx context.Context, page, size int, q *biz.Login return nil, 0, err } var pos []loginLogPO - if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } + ids := make([]uint, 0, len(pos)) + for _, value := range pos { + if value.UserID != 0 { + ids = append(ids, value.UserID) + } + } + users := auditUsers(ctx, r.data.gormDB.WithContext(ctx), ids) out := make([]*biz.LoginLog, 0, len(pos)) for _, po := range pos { - out = append(out, loginFromPO(po)) + value := loginFromPO(po) + value.User = users[po.UserID] + out = append(out, value) } return out, total, nil } diff --git a/internal/data/media.go b/internal/data/media.go index 73b92bc..772764c 100644 --- a/internal/data/media.go +++ b/internal/data/media.go @@ -71,32 +71,45 @@ func (r *mediaRepo) FindMediaByHash(ctx context.Context, userID uint, hash strin } return mediaFromPO(po), nil } -func (r *mediaRepo) ListMedia(ctx context.Context, page, size int, keyword string, categoryID int, tag string, userID uint) ([]*biz.MediaFile, int64, error) { - if page < 1 { - page = 1 +func (r *mediaRepo) ListMedia(ctx context.Context, filter biz.MediaFilter) ([]*biz.MediaFile, int64, error) { + if filter.Page < 1 { + filter.Page = 1 } - if size < 1 { - size = 10 + if filter.PageSize < 1 { + filter.PageSize = 10 } db := r.data.gormDB.WithContext(ctx).Model(&mediaPO{}) - if keyword != "" { - db = db.Where("name LIKE ?", "%"+keyword+"%") + if filter.Keyword != "" { + db = db.Where("name LIKE ?", "%"+filter.Keyword+"%") } - if categoryID > 0 { - db = db.Where("class_id = ?", categoryID) + if filter.CategoryID > 0 { + db = db.Where("class_id = ?", filter.CategoryID) } - if tag != "" { - db = db.Where("tag = ?", tag) + if filter.Tag != "" { + db = db.Where("tag = ?", filter.Tag) } - if userID > 0 { - db = db.Where("user_id = ?", userID) + if filter.UserID > 0 { + db = db.Where("user_id = ?", filter.UserID) + } + if !filter.StartCreatedAt.IsZero() { + db = db.Where("created_at >= ?", filter.StartCreatedAt) + } + if !filter.EndCreatedAt.IsZero() { + db = db.Where("created_at < ?", filter.EndCreatedAt) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var pos []mediaPO - if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + orderKey := filter.OrderKey + if orderKey != "id" && orderKey != "size" && orderKey != "created_at" && orderKey != "name" { + orderKey = "id" + } + if filter.Desc { + orderKey += " desc" + } + if err := applyPagination(db.Order(orderKey), filter.Page, filter.PageSize, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.MediaFile, 0, len(pos)) @@ -130,9 +143,6 @@ func (r *mediaRepo) SaveCategory(ctx context.Context, v *biz.AttachmentCategory) db := r.data.gormDB.WithContext(ctx) var count int64 query := db.Model(&categoryPO{}).Where("name = ? AND pid = ?", v.Name, v.ParentID) - if v.ID != 0 { - query = query.Where("id <> ?", v.ID) - } if err := query.Count(&count).Error; err != nil { return err } diff --git a/internal/data/menu.go b/internal/data/menu.go index 09386a2..c75147d 100644 --- a/internal/data/menu.go +++ b/internal/data/menu.go @@ -193,7 +193,18 @@ func (r *menuRepo) Find(ctx context.Context, id uint) (*biz.Menu, error) { func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) { var pos []menuPO - if err := r.data.gormDB.WithContext(ctx).Order("sort,id").Find(&pos).Error; err != nil { + db := r.data.gormDB.WithContext(ctx).Model(&menuPO{}).Order("sort,id") + config := r.data.runtime.Admin() + if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { + var authority authorityPO + if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { + return nil, err + } + if authority.ParentID != nil && *authority.ParentID != 0 { + db = db.Where("id IN (?)", r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).Select("sys_base_menu_id").Where("sys_authority_authority_id = ?", actor.AuthorityID)) + } + } + if err := db.Find(&pos).Error; err != nil { return nil, err } out := make([]*biz.Menu, 0, len(pos)) @@ -231,6 +242,31 @@ func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) { } func (r *menuRepo) SetAuthorityMenus(ctx context.Context, id uint, ids []uint) error { + if err := (&accessRepo{data: r.data}).checkAuthorityIDAuth(ctx, id); err != nil { + return err + } + config := r.data.runtime.Admin() + if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { + var authority authorityPO + if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { + return err + } + if authority.ParentID != nil && *authority.ParentID != 0 { + var allowedIDs []uint + if err := r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ?", actor.AuthorityID).Pluck("sys_base_menu_id", &allowedIDs).Error; err != nil { + return err + } + allowed := make(map[uint]bool, len(allowedIDs)) + for _, menuID := range allowedIDs { + allowed[menuID] = true + } + for _, menuID := range ids { + if !allowed[menuID] { + return errors.New("添加失败,请勿跨级操作") + } + } + } + } return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil { return err @@ -258,6 +294,16 @@ func (r *menuRepo) MenuRoleIDs(ctx context.Context, id uint) ([]uint, error) { return ids, err } +func (r *menuRepo) DefaultRouterRoleIDs(ctx context.Context, id uint) ([]uint, error) { + var menu menuPO + if err := r.data.gormDB.WithContext(ctx).First(&menu, id).Error; err != nil { + return nil, err + } + var ids []uint + err := r.data.gormDB.WithContext(ctx).Model(&authorityPO{}).Where("default_router = ?", menu.Name).Pluck("authority_id", &ids).Error + return ids, err +} + func (r *menuRepo) SetMenuRoles(ctx context.Context, id uint, ids []uint) error { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("sys_base_menu_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil { diff --git a/internal/data/operation_log.go b/internal/data/operation_log.go index 173f4ce..8642225 100644 --- a/internal/data/operation_log.go +++ b/internal/data/operation_log.go @@ -36,12 +36,6 @@ func opFromPO(v operationPO) *biz.OperationRecord { return &biz.OperationRecord{ID: v.ID, CreatedAt: v.CreatedAt, IP: v.IP, Method: v.Method, Path: v.Path, Status: v.Status, LatencyMS: v.LatencyMS, Agent: v.Agent, ErrorMessage: v.ErrorMessage, Body: v.Body, Response: v.Response, UserID: v.UserID, RequestID: v.RequestID, TraceID: v.TraceID, DeviceID: v.DeviceID} } func (r *auditRepo) ListOperations(ctx context.Context, page, size int, q *biz.OperationRecord) ([]*biz.OperationRecord, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } db := r.data.gormDB.WithContext(ctx).Model(&operationPO{}) if q != nil { if q.Path != "" { @@ -53,27 +47,49 @@ func (r *auditRepo) ListOperations(ctx context.Context, page, size int, q *biz.O if q.Status != 0 { db = db.Where("status = ?", q.Status) } - if q.IP != "" { - db = db.Where("ip LIKE ?", "%"+q.IP+"%") - } - if q.UserID != 0 { - db = db.Where("user_id = ?", q.UserID) - } } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var pos []operationPO - if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } + users := auditUsers(ctx, r.data.gormDB.WithContext(ctx), operationUserIDs(pos)) out := make([]*biz.OperationRecord, 0, len(pos)) for _, po := range pos { - out = append(out, opFromPO(po)) + value := opFromPO(po) + value.User = users[po.UserID] + out = append(out, value) } return out, total, nil } + +func operationUserIDs(values []operationPO) []uint { + ids := make([]uint, 0, len(values)) + for _, value := range values { + if value.UserID != 0 { + ids = append(ids, value.UserID) + } + } + return ids +} + +func auditUsers(ctx context.Context, db *gorm.DB, ids []uint) map[uint]*biz.User { + result := make(map[uint]*biz.User) + if len(ids) == 0 { + return result + } + var users []userPO + if db.WithContext(ctx).Where("id IN ?", ids).Find(&users).Error != nil { + return result + } + for _, value := range users { + result[value.ID] = &biz.User{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, UUID: value.UUID, Username: value.Username, NickName: value.NickName, HeaderImg: value.HeaderImg, AuthorityID: value.AuthorityID, DeptID: value.DeptID, Phone: value.Phone, Email: value.Email, Enable: value.Enable} + } + return result +} func (r *auditRepo) FindOperation(ctx context.Context, id uint) (*biz.OperationRecord, error) { var po operationPO if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { diff --git a/internal/data/pagination.go b/internal/data/pagination.go new file mode 100644 index 0000000..40c8c71 --- /dev/null +++ b/internal/data/pagination.go @@ -0,0 +1,16 @@ +package data + +import "gorm.io/gorm" + +func applyPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB { + if size <= 0 { + return db + } + if maxSize > 0 && size > maxSize { + size = maxSize + } + if page <= 0 { + page = 1 + } + return db.Offset((page - 1) * size).Limit(size) +} diff --git a/internal/data/parameter.go b/internal/data/parameter.go index ed972af..03722e7 100644 --- a/internal/data/parameter.go +++ b/internal/data/parameter.go @@ -52,14 +52,11 @@ func (r *settingsRepo) FindParameter(ctx context.Context, id uint, key string) ( return parameterFromPO(po), nil } func (r *settingsRepo) ListParameters(ctx context.Context, page, size int, q *biz.SystemParameter) ([]*biz.SystemParameter, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } db := r.data.gormDB.WithContext(ctx).Model(¶meterPO{}) if q != nil { + if q.StartCreatedAt != nil && q.EndCreatedAt != nil { + db = db.Where("created_at BETWEEN ? AND ?", q.StartCreatedAt, q.EndCreatedAt) + } if q.Name != "" { db = db.Where("name LIKE ?", "%"+q.Name+"%") } @@ -72,7 +69,7 @@ func (r *settingsRepo) ListParameters(ctx context.Context, page, size int, q *bi return nil, 0, err } var pos []parameterPO - if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db, page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.SystemParameter, 0, len(pos)) diff --git a/internal/data/position.go b/internal/data/position.go index 607e46b..e4d68c1 100644 --- a/internal/data/position.go +++ b/internal/data/position.go @@ -60,13 +60,7 @@ func (r *organizationRepo) FindPosition(ctx context.Context, id uint) (*biz.Posi } return posFromPO(po), nil } -func (r *organizationRepo) ListPositions(ctx context.Context, page, size int, q *biz.Position) ([]*biz.Position, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } +func (r *organizationRepo) ListPositions(ctx context.Context, page, size int, q *biz.PositionListFilter) ([]*biz.Position, int64, error) { db := r.data.gormDB.WithContext(ctx).Model(&positionPO{}) if q != nil { if q.Name != "" { @@ -75,13 +69,16 @@ func (r *organizationRepo) ListPositions(ctx context.Context, page, size int, q if q.Code != "" { db = db.Where("code LIKE ?", "%"+q.Code+"%") } + if q.Status != nil { + db = db.Where("status = ?", *q.Status) + } } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var pos []positionPO - if err := db.Order("sort,id").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db.Order("sort"), page, size, 0).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.Position, 0, len(pos)) @@ -96,6 +93,9 @@ func (r *organizationRepo) PositionUserIDs(ctx context.Context, id uint) ([]uint return ids, err } func (r *organizationRepo) SetPositionUsers(ctx context.Context, id uint, ids []uint) error { + if id == 0 { + return errors.New("岗位ID不能为空") + } return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("sys_position_id = ?", id).Delete(&userPositionPO{}).Error; err != nil { return err diff --git a/internal/data/runtime_clients.go b/internal/data/runtime_clients.go new file mode 100644 index 0000000..2995d81 --- /dev/null +++ b/internal/data/runtime_clients.go @@ -0,0 +1,104 @@ +package data + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// reloadableDB makes the pointer swap atomic. Replaced pools are retained +// until application shutdown so in-flight GORM operations remain valid. +type reloadableDB struct { + current atomic.Pointer[gorm.DB] + mu sync.Mutex + retired []*gorm.DB +} + +func newReloadableDB(db *gorm.DB) *reloadableDB { + r := &reloadableDB{} + r.current.Store(db) + return r +} + +func (r *reloadableDB) WithContext(ctx context.Context) *gorm.DB { + return r.current.Load().WithContext(ctx) +} + +func (r *reloadableDB) DB() *gorm.DB { return r.current.Load() } + +func (r *reloadableDB) replace(db *gorm.DB) { + old := r.current.Swap(db) + if old != nil && old != db { + r.mu.Lock() + r.retired = append(r.retired, old) + r.mu.Unlock() + } +} + +func (r *reloadableDB) close() { + current := r.current.Load() + r.mu.Lock() + all := append([]*gorm.DB{current}, r.retired...) + r.retired = nil + r.mu.Unlock() + seen := map[*gorm.DB]struct{}{} + for _, db := range all { + if db == nil { + continue + } + if _, ok := seen[db]; ok { + continue + } + seen[db] = struct{}{} + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + } +} + +type reloadableRedis struct { + current atomic.Pointer[redis.Client] + mu sync.Mutex + retired []*redis.Client +} + +func newReloadableRedis(client *redis.Client) *reloadableRedis { + r := &reloadableRedis{} + if client != nil { + r.current.Store(client) + } + return r +} + +func (r *reloadableRedis) load() *redis.Client { return r.current.Load() } + +func (r *reloadableRedis) replace(client *redis.Client) { + old := r.current.Swap(client) + if old != nil && old != client { + r.mu.Lock() + r.retired = append(r.retired, old) + r.mu.Unlock() + } +} + +func (r *reloadableRedis) close() { + current := r.current.Load() + r.mu.Lock() + all := append([]*redis.Client{current}, r.retired...) + r.retired = nil + r.mu.Unlock() + seen := map[*redis.Client]struct{}{} + for _, client := range all { + if client == nil { + continue + } + if _, ok := seen[client]; ok { + continue + } + seen[client] = struct{}{} + _ = client.Close() + } +} diff --git a/internal/data/system_init.go b/internal/data/system_init.go index d7c67e7..2967abe 100644 --- a/internal/data/system_init.go +++ b/internal/data/system_init.go @@ -19,12 +19,37 @@ import ( func (r *systemRepo) PersistConfig(context.Context) error { return r.data.persistConfig() } func (r *systemRepo) PersistAdminConfig(_ context.Context, raw []byte) error { - next := proto.Clone(r.data.admin).(*conf.AdminBackend) + currentData, currentAdmin := r.data.runtime.Values() + next := proto.Clone(currentAdmin).(*conf.AdminBackend) if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); err != nil { return err } - next.ConfigPath = r.data.admin.ConfigPath - return r.data.persistConfigValues(r.data.config, next) + next.ConfigPath = currentAdmin.ConfigPath + if err := r.data.persistConfigValues(currentData, next); err != nil { + return err + } + // Writing through the management API updates the same in-memory values + // immediately; the file watcher remains the fallback for external edits. + r.data.runtime.Replace(currentData, next) + return nil +} +func (r *systemRepo) PersistRuntimeConfig(_ context.Context, dataRaw, adminRaw []byte) error { + currentData, currentAdmin := r.data.runtime.Values() + nextData := proto.Clone(currentData).(*conf.Data) + nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend) + options := protojson.UnmarshalOptions{DiscardUnknown: true} + if err := options.Unmarshal(dataRaw, nextData); err != nil { + return err + } + if err := options.Unmarshal(adminRaw, nextAdmin); err != nil { + return err + } + nextAdmin.ConfigPath = currentAdmin.ConfigPath + if err := r.data.persistConfigValues(nextData, nextAdmin); err != nil { + return err + } + r.data.runtime.Replace(nextData, nextAdmin) + return nil } func (r *systemRepo) ReloadConfig(ctx context.Context) error { return r.data.reloadConfig(ctx) @@ -161,9 +186,10 @@ func (r *systemRepo) Initialize(ctx context.Context, input *biz.DatabaseConfig) }); err != nil { return err } - nextData := proto.Clone(r.data.config).(*conf.Data) + currentData, currentAdmin := r.data.runtime.Values() + nextData := proto.Clone(currentData).(*conf.Data) nextData.Database = config - if err := r.data.persistConfigValues(nextData, r.data.admin); err != nil { + if err := r.data.persistConfigValues(nextData, currentAdmin); err != nil { return fmt.Errorf("persist database configuration: %w", err) } r.data.activateDatabase(candidate, config) diff --git a/internal/data/task.go b/internal/data/task.go index 2a68e39..395a563 100644 --- a/internal/data/task.go +++ b/internal/data/task.go @@ -83,12 +83,6 @@ func (r *taskRepo) FindTask(ctx context.Context, id uint) (*biz.TimedTask, error return taskFromPO(po), nil } func (r *taskRepo) ListTasks(ctx context.Context, page, size int, q *biz.TimedTask) ([]*biz.TimedTask, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } db := r.data.gormDB.WithContext(ctx).Model(&taskPO{}) if q != nil { if q.Name != "" { @@ -106,7 +100,7 @@ func (r *taskRepo) ListTasks(ctx context.Context, page, size int, q *biz.TimedTa return nil, 0, err } var pos []taskPO - if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.TimedTask, 0, len(pos)) @@ -125,12 +119,6 @@ func taskLogFromPO(v taskLogPO) *biz.TimedTaskLog { return &biz.TimedTaskLog{ID: v.ID, CreatedAt: v.CreatedAt, 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) ([]*biz.TimedTaskLog, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } db := r.data.gormDB.WithContext(ctx).Model(&taskLogPO{}) if taskID != 0 { db = db.Where("task_id = ?", taskID) @@ -143,7 +131,7 @@ func (r *taskRepo) ListTaskLogs(ctx context.Context, page, size int, taskID uint return nil, 0, err } var pos []taskLogPO - if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.TimedTaskLog, 0, len(pos)) diff --git a/internal/data/transactions_test.go b/internal/data/transactions_test.go new file mode 100644 index 0000000..e2d4f86 --- /dev/null +++ b/internal/data/transactions_test.go @@ -0,0 +1,83 @@ +package data + +import ( + "context" + "strings" + "testing" + + "kra/internal/biz" + "kra/internal/conf" +) + +func newTransactionTestData(t *testing.T) *Data { + t.Helper() + name := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := openWithDriver("sqlite", "file:"+name+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + if err = migrateAll(db); err != nil { + t.Fatal(err) + } + data := &Data{gormDB: newReloadableDB(db), redis: newReloadableRedis(nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})} + t.Cleanup(func() { data.gormDB.close() }) + return data +} + +func TestUserAuthorityWritesAreAtomic(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + if err := data.gormDB.WithContext(ctx).Create(&[]authorityPO{{AuthorityID: 888, AuthorityName: "admin"}, {AuthorityID: 999, AuthorityName: "operator"}}).Error; err != nil { + t.Fatal(err) + } + repo := &systemRepo{data: data} + user := &biz.User{Username: "atomic", Password: "hash", NickName: "before", AuthorityID: 888, Enable: 1} + created, err := repo.CreateUserWithAuthorities(ctx, user, []uint{888, 999}) + if err != nil { + t.Fatal(err) + } + var links int64 + if err = data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).Where("sys_user_id = ?", created.ID).Count(&links).Error; err != nil || links != 2 { + t.Fatalf("authority links = %d, err = %v", links, err) + } + + created.NickName = "must rollback" + if err = repo.UpdateUserWithAuthorities(ctx, created, []uint{888, 123456}); err == nil { + t.Fatal("expected invalid authority update to fail") + } + var persisted userPO + if err = data.gormDB.WithContext(ctx).First(&persisted, created.ID).Error; err != nil { + t.Fatal(err) + } + if persisted.NickName != "before" { + t.Fatalf("user update was not rolled back: %q", persisted.NickName) + } + + invalid := &biz.User{Username: "invalid", Password: "hash", AuthorityID: 888, Enable: 1} + if _, err = repo.CreateUserWithAuthorities(ctx, invalid, []uint{888, 123456}); err == nil { + t.Fatal("expected invalid authority create to fail") + } + var users int64 + if err = data.gormDB.WithContext(ctx).Model(&userPO{}).Where("username = ?", "invalid").Count(&users).Error; err != nil || users != 0 { + t.Fatalf("partial user persisted: count=%d err=%v", users, err) + } +} + +func TestDictionaryImportKeepsHierarchyInOneTransaction(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + repo := &settingsRepo{data: data} + parentID := uint(10) + dictionary := &biz.Dictionary{Name: "status", Type: "status", Status: true} + details := []*biz.DictionaryDetail{{ID: 10, Label: "parent", Value: "1", Status: true}, {ID: 11, Label: "child", Value: "2", ParentID: &parentID, Status: true}} + if err := repo.ImportDictionary(ctx, dictionary, details); err != nil { + t.Fatal(err) + } + var persisted []dictionaryDetailPO + if err := data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ?", dictionary.ID).Order("id").Find(&persisted).Error; err != nil { + t.Fatal(err) + } + if len(persisted) != 2 || persisted[1].ParentID == nil || *persisted[1].ParentID != persisted[0].ID { + t.Fatalf("unexpected imported hierarchy: %+v", persisted) + } +} diff --git a/internal/data/user.go b/internal/data/user.go index d21a893..bcdf018 100644 --- a/internal/data/user.go +++ b/internal/data/user.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "strconv" + "strings" "time" "kra/internal/biz" @@ -80,6 +82,119 @@ func toBizAuthority(po authorityPO) biz.Authority { return biz.Authority{AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter} } +func baseBizUser(po *userPO) *biz.User { + setting := map[string]any(nil) + if po.OriginSetting != "" { + _ = json.Unmarshal([]byte(po.OriginSetting), &setting) + } + return &biz.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, DeptID: po.DeptID, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt} +} + +func (r *systemRepo) toBizUsers(ctx context.Context, pos []userPO) ([]*biz.User, error) { + if len(pos) == 0 { + return []*biz.User{}, nil + } + db := r.data.gormDB.WithContext(ctx) + userIDs := make([]uint, 0, len(pos)) + authorityIDs := make([]uint, 0, len(pos)) + departmentIDs := make([]uint, 0, len(pos)) + for i := range pos { + userIDs = append(userIDs, pos[i].ID) + authorityIDs = append(authorityIDs, pos[i].AuthorityID) + if pos[i].DeptID != 0 { + departmentIDs = append(departmentIDs, pos[i].DeptID) + } + } + var authorityLinks []userAuthorityPO + if err := db.Where("sys_user_id IN ?", userIDs).Find(&authorityLinks).Error; err != nil { + return nil, err + } + authorityByUser := make(map[uint][]uint, len(pos)) + for _, link := range authorityLinks { + authorityByUser[link.SysUserID] = append(authorityByUser[link.SysUserID], link.SysAuthorityAuthorityID) + authorityIDs = append(authorityIDs, link.SysAuthorityAuthorityID) + } + var authorityPOs []authorityPO + if err := db.Where("authority_id IN ?", authorityIDs).Find(&authorityPOs).Error; err != nil { + return nil, err + } + authorityByID := make(map[uint]authorityPO, len(authorityPOs)) + for _, item := range authorityPOs { + authorityByID[item.AuthorityID] = item + } + + var departmentLinks []userDepartmentPO + if err := db.Where("sys_user_id IN ?", userIDs).Find(&departmentLinks).Error; err != nil { + return nil, err + } + departmentByUser := make(map[uint][]uint, len(pos)) + for _, link := range departmentLinks { + departmentByUser[link.UserID] = append(departmentByUser[link.UserID], link.DepartmentID) + departmentIDs = append(departmentIDs, link.DepartmentID) + } + var departmentPOs []departmentPO + if len(departmentIDs) > 0 { + if err := db.Where("id IN ?", departmentIDs).Find(&departmentPOs).Error; err != nil { + return nil, err + } + } + departmentByID := make(map[uint]departmentPO, len(departmentPOs)) + for _, item := range departmentPOs { + departmentByID[item.ID] = item + } + + var positionLinks []userPositionPO + if err := db.Where("sys_user_id IN ?", userIDs).Find(&positionLinks).Error; err != nil { + return nil, err + } + positionByUser := make(map[uint][]uint, len(pos)) + positionIDs := make([]uint, 0, len(positionLinks)) + for _, link := range positionLinks { + positionByUser[link.UserID] = append(positionByUser[link.UserID], link.PositionID) + positionIDs = append(positionIDs, link.PositionID) + } + var positionPOs []positionPO + if len(positionIDs) > 0 { + if err := db.Where("id IN ?", positionIDs).Find(&positionPOs).Error; err != nil { + return nil, err + } + } + positionByID := make(map[uint]positionPO, len(positionPOs)) + for _, item := range positionPOs { + positionByID[item.ID] = item + } + + users := make([]*biz.User, 0, len(pos)) + for i := range pos { + po := &pos[i] + user := baseBizUser(po) + user.Authority = toBizAuthority(authorityByID[po.AuthorityID]) + user.Authorities = make([]biz.Authority, 0, len(authorityByUser[po.ID])) + for _, id := range authorityByUser[po.ID] { + if item, ok := authorityByID[id]; ok { + user.Authorities = append(user.Authorities, toBizAuthority(item)) + } + } + user.Departments = make([]biz.Department, 0, len(departmentByUser[po.ID])) + for _, id := range departmentByUser[po.ID] { + if item, ok := departmentByID[id]; ok { + user.Departments = append(user.Departments, *deptFromPO(item)) + } + } + if primary, ok := departmentByID[po.DeptID]; ok { + user.Department = deptFromPO(primary) + } + user.Positions = make([]biz.Position, 0, len(positionByUser[po.ID])) + for _, id := range positionByUser[po.ID] { + if item, ok := positionByID[id]; ok { + user.Positions = append(user.Positions, *posFromPO(item)) + } + } + users = append(users, user) + } + return users, nil +} + func (r *systemRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*biz.Menu, error) { var pos []menuPO err := r.data.gormDB.WithContext(ctx).Table("sys_base_menus"). @@ -90,23 +205,31 @@ func (r *systemRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([] return nil, err } byID := make(map[uint]*biz.Menu, len(pos)) + menuIDs := make([]uint, 0, len(pos)) for _, po := range pos { menu := &biz.Menu{ID: po.ID, ParentID: po.ParentID, Path: po.Path, Name: po.Name, Hidden: po.Hidden, Component: po.Component, Sort: po.Sort, ActiveName: po.ActiveName, KeepAlive: po.KeepAlive, DefaultMenu: po.DefaultMenu, Title: po.Title, Icon: po.Icon, CloseTab: po.CloseTab, TransitionType: po.TransitionType, Children: []*biz.Menu{}} + menu.Parameters = []*biz.MenuParameter{} + menu.Buttons = []*biz.MenuButton{} + byID[po.ID] = menu + menuIDs = append(menuIDs, po.ID) + } + if len(menuIDs) > 0 { var parameters []menuParameterPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", po.ID).Order("id").Find(¶meters).Error; err != nil { + if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Order("id").Find(¶meters).Error; err != nil { return nil, err } for _, parameter := range parameters { + menu := byID[parameter.MenuID] menu.Parameters = append(menu.Parameters, &biz.MenuParameter{ID: parameter.ID, MenuID: parameter.MenuID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) } var buttons []menuButtonPO - if err := r.data.gormDB.WithContext(ctx).Table("sys_base_menu_btns").Joins("JOIN sys_authority_btns ON sys_authority_btns.sys_base_menu_btn_id = sys_base_menu_btns.id").Where("sys_authority_btns.authority_id = ? AND sys_authority_btns.sys_menu_id = ?", authorityID, po.ID).Find(&buttons).Error; err != nil { + if err := r.data.gormDB.WithContext(ctx).Table("sys_base_menu_btns").Joins("JOIN sys_authority_btns ON sys_authority_btns.sys_base_menu_btn_id = sys_base_menu_btns.id").Where("sys_authority_btns.authority_id = ? AND sys_authority_btns.sys_menu_id IN ?", authorityID, menuIDs).Find(&buttons).Error; err != nil { return nil, err } for _, button := range buttons { + menu := byID[button.MenuID] menu.Buttons = append(menu.Buttons, &biz.MenuButton{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: button.MenuID, AuthorityID: authorityID}) } - byID[po.ID] = menu } roots := make([]*biz.Menu, 0) for _, po := range pos { @@ -121,22 +244,7 @@ func (r *systemRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([] } func (r *systemRepo) ListUsers(ctx context.Context, page, pageSize int, filter *biz.UserListFilter) ([]*biz.User, int64, error) { - if page < 1 { - page = 1 - } - if pageSize < 1 { - pageSize = 10 - } db := r.data.gormDB.WithContext(ctx).Model(&userPO{}) - if scope, ok := biz.DataScopeFromContext(ctx); ok && !scope.All { - if scope.OwnerUserID != 0 { - db = db.Where("sys_users.id = ?", scope.OwnerUserID) - } else if len(scope.DepartmentIDs) > 0 { - db = db.Joins("JOIN sys_user_departments ON sys_user_departments.sys_user_id = sys_users.id").Where("sys_user_departments.sys_department_id IN ?", scope.DepartmentIDs).Distinct("sys_users.id") - } else { - db = db.Where("1 = 0") - } - } order := "id desc" if filter != nil { if filter.Username != "" { @@ -151,7 +259,7 @@ func (r *systemRepo) ListUsers(ctx context.Context, page, pageSize int, filter * if filter.Email != "" { db = db.Where("email LIKE ?", "%"+filter.Email+"%") } - allowed := map[string]bool{"id": true, "username": true, "nick_name": true, "phone": true, "email": true, "created_at": true} + allowed := map[string]bool{"id": true, "username": true, "nick_name": true, "phone": true, "email": true} if allowed[filter.OrderKey] { order = filter.OrderKey if filter.Desc { @@ -164,21 +272,18 @@ func (r *systemRepo) ListUsers(ctx context.Context, page, pageSize int, filter * return nil, 0, err } var pos []userPO - if err := db.Order(order).Offset((page - 1) * pageSize).Limit(pageSize).Find(&pos).Error; err != nil { + if err := applyPagination(db.Order(order), page, pageSize, 100).Find(&pos).Error; err != nil { return nil, 0, err } - users := make([]*biz.User, 0, len(pos)) - for i := range pos { - user, err := r.toBizUser(ctx, &pos[i]) - if err != nil { - return nil, 0, err - } - users = append(users, user) - } - return users, total, nil + users, err := r.toBizUsers(ctx, pos) + return users, total, err } func (r *systemRepo) CreateUser(ctx context.Context, user *biz.User) (*biz.User, error) { + return r.CreateUserWithAuthorities(ctx, user, nil) +} + +func (r *systemRepo) CreateUserWithAuthorities(ctx context.Context, user *biz.User, authorityIDs []uint) (*biz.User, error) { if user.UUID == "" { user.UUID = uuid.NewString() } @@ -193,23 +298,134 @@ func (r *systemRepo) CreateUser(ctx context.Context, user *biz.User) (*biz.User, } now := time.Now() po := userPO{UUID: user.UUID, Username: user.Username, Password: user.Password, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Phone: user.Phone, Email: user.Email, Enable: user.Enable, PasswordUpdatedAt: &now, MustChangePassword: user.MustChangePassword} + if len(authorityIDs) == 0 { + authorityIDs = []uint{user.AuthorityID} + } if err := r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := validateAuthorities(tx, authorityIDs); err != nil { + return err + } if err := tx.Create(&po).Error; err != nil { return err } - return tx.Create(&userAuthorityPO{SysUserID: po.ID, SysAuthorityAuthorityID: po.AuthorityID}).Error + links := make([]userAuthorityPO, 0, len(authorityIDs)) + for _, authorityID := range authorityIDs { + links = append(links, userAuthorityPO{SysUserID: po.ID, SysAuthorityAuthorityID: authorityID}) + } + return tx.Create(&links).Error }); err != nil { return nil, err } return r.FindUserByID(ctx, po.ID) } +func validateAuthorities(tx *gorm.DB, authorityIDs []uint) error { + for _, authorityID := range authorityIDs { + var count int64 + if err := tx.Model(&authorityPO{}).Where("authority_id = ?", authorityID).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return errors.New("角色不存在") + } + } + return nil +} + func (r *systemRepo) UpdateUser(ctx context.Context, user *biz.User) error { + return r.updateUser(r.data.gormDB.WithContext(ctx), user) +} + +func (r *systemRepo) UpdateSelfUser(ctx context.Context, user *biz.User) error { + updates := make(map[string]any) + if user.NickName != "" { + updates["nick_name"] = user.NickName + } + if user.HeaderImg != "" { + updates["header_img"] = user.HeaderImg + } + if user.Phone != "" { + updates["phone"] = user.Phone + } + if user.Email != "" { + updates["email"] = user.Email + } + if user.Enable != 0 { + updates["enable"] = user.Enable + } + if len(updates) == 0 { + return nil + } + return r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("id = ?", user.ID).Updates(updates).Error +} + +func (r *systemRepo) FillDepartmentNamePaths(ctx context.Context, user *biz.User) error { + if user == nil || len(user.Departments) == 0 { + return nil + } + idSet := make(map[uint]bool) + for i := range user.Departments { + for _, part := range strings.Split(user.Departments[i].Ancestors, ",") { + id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64) + if err == nil && id != 0 { + idSet[uint(id)] = true + } + } + } + ids := make([]uint, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + nameByID := make(map[uint]string, len(ids)) + if len(ids) > 0 { + var rows []departmentPO + if err := r.data.gormDB.WithContext(ctx).Select("id", "name").Where("id IN ?", ids).Find(&rows).Error; err != nil { + return err + } + for _, row := range rows { + nameByID[row.ID] = row.Name + } + } + for i := range user.Departments { + parts := make([]string, 0) + for _, part := range strings.Split(user.Departments[i].Ancestors, ",") { + id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64) + if err == nil && id != 0 && nameByID[uint(id)] != "" { + parts = append(parts, nameByID[uint(id)]) + } + } + parts = append(parts, user.Departments[i].Name) + user.Departments[i].NamePath = strings.Join(parts, "/") + } + if user.Department != nil { + for i := range user.Departments { + if user.Departments[i].ID == user.Department.ID { + user.Department.NamePath = user.Departments[i].NamePath + break + } + } + } + return nil +} + +func (r *systemRepo) updateUser(tx *gorm.DB, user *biz.User) error { updates := map[string]any{"nick_name": user.NickName, "header_img": user.HeaderImg, "phone": user.Phone, "email": user.Email, "enable": user.Enable} if user.AuthorityID != 0 { updates["authority_id"] = user.AuthorityID } - return r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("id = ?", user.ID).Updates(updates).Error + return tx.Model(&userPO{}).Where("id = ?", user.ID).Updates(updates).Error +} + +func (r *systemRepo) UpdateUserWithAuthorities(ctx context.Context, user *biz.User, authorityIDs []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := r.updateUser(tx, user); err != nil { + return err + } + if len(authorityIDs) == 0 { + return nil + } + return setUserAuthorities(tx, user.ID, authorityIDs) + }) } func (r *systemRepo) DeleteUser(ctx context.Context, id uint) error { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -248,36 +464,60 @@ func (r *systemRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, err func (r *systemRepo) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("sys_user_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil { - return err - } - links := make([]userAuthorityPO, 0, len(authorityIDs)) + access := &accessRepo{data: r.data} for _, authorityID := range authorityIDs { - var count int64 - if err := tx.Model(&authorityPO{}).Where("authority_id = ?", authorityID).Count(&count).Error; err != nil || count == 0 { - if err != nil { - return err - } - return errors.New("角色不存在") + if err := access.checkAuthorityIDAuth(ctx, authorityID); err != nil { + return err } - links = append(links, userAuthorityPO{SysUserID: id, SysAuthorityAuthorityID: authorityID}) } - if err := tx.Create(&links).Error; err != nil { - return err - } - return tx.Model(&userPO{}).Where("id = ?", id).Update("authority_id", authorityIDs[0]).Error + return setUserAuthorities(tx, id, authorityIDs) }) } +func setUserAuthorities(tx *gorm.DB, id uint, authorityIDs []uint) error { + if len(authorityIDs) == 0 { + return errors.New("角色不能为空") + } + if err := validateAuthorities(tx, authorityIDs); err != nil { + return err + } + if err := tx.Where("sys_user_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil { + return err + } + links := make([]userAuthorityPO, 0, len(authorityIDs)) + for _, authorityID := range authorityIDs { + links = append(links, userAuthorityPO{SysUserID: id, SysAuthorityAuthorityID: authorityID}) + } + if err := tx.Create(&links).Error; err != nil { + return err + } + return tx.Model(&userPO{}).Where("id = ?", id).Update("authority_id", authorityIDs[0]).Error +} + func (r *systemRepo) SetUserAuthority(ctx context.Context, id, authorityID uint) error { + db := r.data.gormDB.WithContext(ctx) var count int64 - if err := r.data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).Where("sys_user_id = ? AND sys_authority_authority_id = ?", id, authorityID).Count(&count).Error; err != nil { + if err := db.Model(&userAuthorityPO{}).Where("sys_user_id = ? AND sys_authority_authority_id = ?", id, authorityID).Count(&count).Error; err != nil { return err } if count == 0 { return errors.New("该用户无此角色") } - return r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Update("authority_id", authorityID).Error + var authority authorityPO + if err := db.Where("authority_id = ?", authorityID).First(&authority).Error; err != nil { + return err + } + var menuCount int64 + if err := db.Model(&menuPO{}). + Joins("JOIN sys_authority_menus ON sys_authority_menus.sys_base_menu_id = sys_base_menus.id"). + Where("sys_authority_menus.sys_authority_authority_id = ? AND sys_base_menus.name = ?", authorityID, authority.DefaultRouter). + Count(&menuCount).Error; err != nil { + return err + } + if menuCount == 0 { + return errors.New("找不到默认路由,无法切换本角色") + } + return db.Model(&userPO{}).Where("id = ?", id).Update("authority_id", authorityID).Error } func (r *systemRepo) SetUserSetting(ctx context.Context, id uint, setting map[string]any) error { diff --git a/internal/data/version.go b/internal/data/version.go index d5a3ec5..8f45dfd 100644 --- a/internal/data/version.go +++ b/internal/data/version.go @@ -47,14 +47,11 @@ func (r *versionRepo) FindVersion(ctx context.Context, id uint) (*biz.Version, e } return versionFromPO(po), nil } -func (r *versionRepo) ListVersions(ctx context.Context, page, size int, name, code string) ([]*biz.Version, int64, error) { - if page < 1 { - page = 1 - } - if size < 1 { - size = 10 - } +func (r *versionRepo) ListVersions(ctx context.Context, page, size int, name, code string, createdAtRange []*time.Time) ([]*biz.Version, int64, error) { db := r.data.gormDB.WithContext(ctx).Model(&versionPO{}) + if len(createdAtRange) == 2 && createdAtRange[0] != nil && createdAtRange[1] != nil { + db = db.Where("created_at BETWEEN ? AND ?", createdAtRange[0], createdAtRange[1]) + } if name != "" { db = db.Where("version_name LIKE ?", "%"+name+"%") } @@ -66,7 +63,7 @@ func (r *versionRepo) ListVersions(ctx context.Context, page, size int, name, co return nil, 0, err } var pos []versionPO - if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + if err := applyPagination(db, page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.Version, 0, len(pos)) @@ -94,14 +91,45 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d } byID[po.ID] = v } + if len(menuIDs) > 0 { + var parameters []menuParameterPO + if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Find(¶meters).Error; err != nil { + return nil, err + } + for _, parameter := range parameters { + if menu := byID[parameter.MenuID]; menu != nil { + menu.Parameters = append(menu.Parameters, &biz.MenuParameter{Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + } + } + } for _, po := range menus { v := byID[po.ID] if parent := byID[po.ParentID]; parent != nil { parent.Children = append(parent.Children, v) - } else { + } else if po.ParentID == 0 { bundle.Menus = append(bundle.Menus, v) } } + var cleanMenu func(*biz.Menu) + cleanMenu = func(menu *biz.Menu) { + menu.ID = 0 + menu.ParentID = 0 + for _, button := range menu.Buttons { + button.ID = 0 + button.MenuID = 0 + button.AuthorityID = 0 + } + for _, parameter := range menu.Parameters { + parameter.ID = 0 + parameter.MenuID = 0 + } + for _, child := range menu.Children { + cleanMenu(child) + } + } + for _, menu := range bundle.Menus { + cleanMenu(menu) + } var apis []apiPO if len(apiIDs) > 0 { if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", apiIDs).Find(&apis).Error; err != nil { @@ -123,7 +151,7 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d for _, po := range dictionaries { v := dictionaryFromPO(po) v.ID = 0 - details, _, err := settings.ListDictionaryDetails(ctx, 1, 100000, po.ID, "", "") + details, _, err := settings.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: po.ID}) if err != nil { return nil, err } @@ -159,6 +187,11 @@ func (r *versionRepo) ImportVersionBundle(ctx context.Context, b *biz.VersionBun return err } } + for _, parameter := range item.Parameters { + if err = tx.Create(&menuParameterPO{MenuID: po.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}).Error; err != nil { + return err + } + } } else if err != nil { return err } diff --git a/internal/server/gin.go b/internal/server/gin.go index 01c85dd..d88886b 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -1,13 +1,13 @@ package server import ( - "context" - "errors" "log/slog" - "net" + "mime" "net/http" "os" + "path" "strings" + "time" "kra/internal/conf" "kra/internal/server/handler" @@ -17,23 +17,16 @@ import ( "kra/internal/service" "github.com/gin-gonic/gin" + kratoshttp "github.com/go-kratos/kratos/v3/transport/http" ) -// GinServer adapts Gin to the Kratos transport.Server lifecycle. -type GinServer struct { - network string - address string - server *http.Server - listener net.Listener - logger *slog.Logger -} - -func NewGinServer(c *conf.Server, config *conf.AdminBackend, system *service.SystemService, access *service.AccessService, authority *handler.Authority, menu *handler.Menu, api *handler.API, permission *handler.Permission, organization *handler.Organization, announcement *handler.Announcement, email *handler.Email, task *handler.Task, media *handler.Media, auditHandler *handler.Audit, export *handler.Export, version *handler.Version, dictionary *handler.Dictionary, parameter *handler.Parameter, apiToken *handler.APIToken, systemConfig *handler.SystemConfig, publicHandler *handler.Public, user *handler.User, navigation *handler.Navigation, session *handler.Session, settings *service.SettingsService, audit *service.AuditService, emails *service.EmailService, logger *slog.Logger) *GinServer { +func NewGinServer(c *conf.Server, runtime *conf.Runtime, system *service.SystemService, access *service.AccessService, authority *handler.Authority, menu *handler.Menu, api *handler.API, permission *handler.Permission, organization *handler.Organization, announcement *handler.Announcement, email *handler.Email, task *handler.Task, media *handler.Media, auditHandler *handler.Audit, export *handler.Export, version *handler.Version, dictionary *handler.Dictionary, parameter *handler.Parameter, apiToken *handler.APIToken, systemConfig *handler.SystemConfig, publicHandler *handler.Public, user *handler.User, navigation *handler.Navigation, session *handler.Session, settings *service.SettingsService, audit *service.AuditService, emails *service.EmailService, logger *slog.Logger) *kratoshttp.Server { gin.SetMode(gin.ReleaseMode) engine := gin.New() engine.Use(servermiddleware.RequestMeta(), servermiddleware.EmailErrorAlert(emails, logger), gin.Recovery(), servermiddleware.SecurityRateLimit(system, settings), servermiddleware.OperationAudit(audit)) prefix := "" + config := runtime.Admin() if config != nil { prefix = strings.TrimSuffix(config.RouterPrefix, "/") } @@ -42,13 +35,13 @@ func NewGinServer(c *conf.Server, config *conf.AdminBackend, system *service.Sys serverrouter.RegisterPublic(public, engine, publicHandler) private := engine.Group(prefix) - private.Use(servermiddleware.Auth(config, settings), servermiddleware.MustChangePassword(), servermiddleware.AccessControl(access, audit)) + private.Use(servermiddleware.Auth(runtime, settings), servermiddleware.MustChangePassword(), servermiddleware.AccessControl(runtime, access, audit)) serverrouter.RegisterUser(private, user) serverrouter.RegisterNavigation(private, navigation) serverrouter.RegisterSession(private, session) serverrouter.RegisterAuthority(private, authority) serverrouter.RegisterMenu(private, menu) - serverrouter.RegisterAPI(private, engine, api) + serverrouter.RegisterAPI(private, public, engine, api) serverrouter.RegisterPermission(private, permission) serverrouter.RegisterOrganization(private, organization) serverrouter.RegisterDictionary(private, dictionary) @@ -64,7 +57,7 @@ func NewGinServer(c *conf.Server, config *conf.AdminBackend, system *service.Sys serverrouter.RegisterEmail(private, email) engine.NoRoute(func(c *gin.Context) { - if serveLocalStorage(c, config) { + if serveLocalStorage(c, runtime) { return } httpx.Fail(c, "请求的接口不存在") @@ -79,12 +72,24 @@ func NewGinServer(c *conf.Server, config *conf.AdminBackend, system *service.Sys address = c.Http.Addr } } - return &GinServer{network: network, address: address, server: &http.Server{Addr: address, Handler: engine}, logger: logger} + timeout := 10 * time.Minute + if c != nil && c.Http != nil && c.Http.Timeout != nil && c.Http.Timeout.AsDuration() > 0 { + timeout = c.Http.Timeout.AsDuration() + } + server := kratoshttp.NewServer(kratoshttp.Network(network), kratoshttp.Address(address), kratoshttp.Timeout(timeout)) + server.HandlePrefix("/", engine) + server.Server.ReadHeaderTimeout = 10 * time.Second + server.Server.ReadTimeout = timeout + server.Server.WriteTimeout = timeout + server.Server.IdleTimeout = 2 * time.Minute + server.Server.MaxHeaderBytes = 1 << 20 + return server } // serveLocalStorage resolves the local path for every request so a config // reload takes effect without rebuilding the Gin engine. -func serveLocalStorage(c *gin.Context, config *conf.AdminBackend) bool { +func serveLocalStorage(c *gin.Context, runtime *conf.Runtime) bool { + config := runtime.Admin() if config == nil || config.Local == nil || config.Local.StorePath == "" { return false } @@ -95,10 +100,26 @@ func serveLocalStorage(c *gin.Context, config *conf.AdminBackend) bool { if prefix == "/" || (c.Request.URL.Path != prefix && !strings.HasPrefix(c.Request.URL.Path, prefix+"/")) { return false } + c.Header("X-Content-Type-Options", "nosniff") + filename := path.Base(c.Request.URL.Path) + if !canServeUploadInline(filename) { + c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) + } http.StripPrefix(prefix, http.FileServer(filesOnly{FileSystem: http.Dir(config.Local.StorePath)})).ServeHTTP(c.Writer, c.Request) return true } +func canServeUploadInline(filename string) bool { + switch strings.ToLower(path.Ext(filename)) { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".avif", + ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", + ".mp4", ".webm", ".mov", ".avi", ".mkv": + return true + default: + return false + } +} + type filesOnly struct{ http.FileSystem } func (f filesOnly) Open(name string) (http.File, error) { @@ -117,22 +138,3 @@ func (f filesOnly) Open(name string) (http.File, error) { } return file, nil } - -func (s *GinServer) Start(ctx context.Context) error { - listener, err := net.Listen(s.network, s.address) - if err != nil { - return err - } - s.listener = listener - s.server.BaseContext = func(net.Listener) context.Context { return ctx } - s.logger.InfoContext(ctx, "Gin HTTP server listening", "addr", listener.Addr().String()) - if err := s.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { - return err - } - return nil -} - -func (s *GinServer) Stop(ctx context.Context) error { - s.logger.InfoContext(ctx, "Gin HTTP server stopping") - return s.server.Shutdown(ctx) -} diff --git a/internal/server/handler/announcement.go b/internal/server/handler/announcement.go index 16b7556..50f9271 100644 --- a/internal/server/handler/announcement.go +++ b/internal/server/handler/announcement.go @@ -88,8 +88,8 @@ func (h *Announcement) Find(c *gin.Context) { } func (h *Announcement) List(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + page, _ := strconv.Atoi(c.Query("page")) + pageSize, _ := strconv.Atoi(c.Query("pageSize")) start, startErr := parseTime(c.Query("startCreatedAt")) end, endErr := parseTime(c.Query("endCreatedAt")) if startErr != nil || endErr != nil || (start == nil) != (end == nil) { diff --git a/internal/server/handler/api.go b/internal/server/handler/api.go index 0aa9710..5629c4f 100644 --- a/internal/server/handler/api.go +++ b/internal/server/handler/api.go @@ -1,7 +1,7 @@ package handler import ( - "strconv" + "strings" "kra/internal/server/httpx" "kra/internal/service" @@ -28,7 +28,7 @@ func (h *API) List(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") } func (h *API) All(c *gin.Context) { - items, err := h.service.AllAPI(c.Request.Context()) + items, err := h.service.AllAPI(c.Request.Context(), true) if err != nil { httpx.Fail(c, "获取失败") return @@ -41,11 +41,12 @@ func (h *API) Create(c *gin.Context) { httpx.Fail(c, "参数错误") return } - if err := h.service.CreateAPIRequest(c.Request.Context(), &req); err != nil { + created, err := h.service.CreateAPIRequest(c.Request.Context(), &req) + if err != nil { httpx.Fail(c, "创建失败") return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, created, "创建成功") } func (h *API) Update(c *gin.Context) { var req dto.APIRequest @@ -97,28 +98,40 @@ func (h *API) Find(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, gin.H{"api": item}, "获取成功") } func (h *API) Groups(c *gin.Context) { - items, err := h.service.AllAPI(c.Request.Context()) + items, err := h.service.AllAPI(c.Request.Context(), false) if err != nil { httpx.Fail(c, "获取失败") return } seen := map[string]bool{} groups := []string{} + groupAPIMap := map[string]string{} for _, item := range items { - if item.APIGroup != "" && !seen[item.APIGroup] { + if !seen[item.APIGroup] { seen[item.APIGroup] = true groups = append(groups, item.APIGroup) } + parts := strings.Split(item.Path, "/") + if len(parts) > 1 { + groupAPIMap[parts[1]] = item.APIGroup + } } - httpx.Write(c, httpx.CodeSuccess, gin.H{"groups": groups}, "获取成功") + httpx.Write(c, httpx.CodeSuccess, gin.H{"groups": groups, "apiGroupMap": groupAPIMap}, "获取成功") } func (h *API) Roles(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("apiId"), 10, 64) - ids, err := h.service.APIRoleIDs(c.Request.Context(), uint(id)) + path, method := c.Query("path"), c.Query("method") + if path == "" || method == "" { + httpx.Fail(c, "API路径和请求方法不能为空") + return + } + ids, err := h.service.APIRoleIDs(c.Request.Context(), path, method) if err != nil { httpx.Fail(c, "获取失败") return } + if ids == nil { + ids = []uint{} + } httpx.Write(c, httpx.CodeSuccess, ids, "获取成功") } func (h *API) SetRoles(c *gin.Context) { @@ -127,7 +140,11 @@ func (h *API) SetRoles(c *gin.Context) { httpx.Fail(c, "参数错误") return } - if err := h.service.SetAPIRoles(c.Request.Context(), req.APIID, req.AuthorityIDs); err != nil { + if req.Path == "" || req.Method == "" { + httpx.Fail(c, "API路径和请求方法不能为空") + return + } + if err := h.service.SetAPIRoles(c.Request.Context(), req.Path, req.Method, req.AuthorityIDs); err != nil { httpx.Fail(c, "设置失败") return } diff --git a/internal/server/handler/audit.go b/internal/server/handler/audit.go index 74b9e64..6b70d48 100644 --- a/internal/server/handler/audit.go +++ b/internal/server/handler/audit.go @@ -2,6 +2,7 @@ package handler import ( "strconv" + "time" "kra/internal/server/httpx" "kra/internal/service" @@ -15,8 +16,8 @@ type Audit struct{ service *service.AuditService } func NewAudit(service *service.AuditService) *Audit { return &Audit{service: service} } func page(c *gin.Context) (int, int) { - value, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + value, _ := strconv.Atoi(c.Query("page")) + size, _ := strconv.Atoi(c.Query("pageSize")) return value, size } func IDsFromQuery(c *gin.Context) []uint { @@ -37,8 +38,7 @@ func IDsFromQuery(c *gin.Context) []uint { func (h *Audit) Operations(c *gin.Context) { p, size := page(c) status, _ := strconv.Atoi(c.Query("status")) - userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64) - items, total, err := h.service.OperationsFilter(c.Request.Context(), p, size, c.Query("path"), c.Query("method"), status, c.Query("ip"), uint(userID)) + items, total, err := h.service.OperationsFilter(c.Request.Context(), p, size, c.Query("path"), c.Query("method"), status) if err != nil { httpx.Fail(c, "获取失败") return @@ -52,7 +52,7 @@ func (h *Audit) Operation(c *gin.Context) { httpx.Fail(c, "查询失败") return } - httpx.Write(c, httpx.CodeSuccess, gin.H{"resysOperationRecord": item}, "查询成功") + httpx.Write(c, httpx.CodeSuccess, gin.H{"reSysOperationRecord": item}, "查询成功") } func (h *Audit) DeleteOperation(c *gin.Context) { var req dto.IDRequest @@ -81,7 +81,8 @@ func (h *Audit) DeleteOperations(c *gin.Context) { func (h *Audit) Logins(c *gin.Context) { p, size := page(c) - items, total, err := h.service.LoginsFilter(c.Request.Context(), p, size, c.Query("username"), c.Query("ip")) + status, _ := strconv.ParseBool(c.Query("status")) + items, total, err := h.service.LoginsFilter(c.Request.Context(), p, size, c.Query("username"), status) if err != nil { httpx.Fail(c, "获取失败") return @@ -216,11 +217,20 @@ func (h *Audit) Error(c *gin.Context) { httpx.Fail(c, "查询失败") return } - httpx.Write(c, httpx.CodeSuccess, gin.H{"resysError": item}, "查询成功") + httpx.Write(c, httpx.CodeSuccess, item, "查询成功") } func (h *Audit) Errors(c *gin.Context) { p, size := page(c) - items, total, err := h.service.ErrorsFilter(c.Request.Context(), p, size, c.Query("form"), c.Query("level"), c.Query("status")) + createdAtRange := make([]time.Time, 0, 2) + for _, raw := range c.QueryArray("createdAtRange[]") { + value, parseErr := time.Parse(time.RFC3339, raw) + if parseErr != nil { + httpx.Fail(c, parseErr.Error()) + return + } + createdAtRange = append(createdAtRange, value) + } + items, total, err := h.service.ErrorsFilter(c.Request.Context(), p, size, c.Query("form"), c.Query("info"), createdAtRange) if err != nil { httpx.Fail(c, "获取失败") return diff --git a/internal/server/handler/authority.go b/internal/server/handler/authority.go index d4900f4..9104d4d 100644 --- a/internal/server/handler/authority.go +++ b/internal/server/handler/authority.go @@ -92,6 +92,9 @@ func (h *Authority) Users(c *gin.Context) { httpx.Fail(c, "获取失败") return } + if ids == nil { + ids = []uint{} + } httpx.Write(c, httpx.CodeSuccess, ids, "获取成功") } func (h *Authority) SetDataScope(c *gin.Context) { @@ -113,5 +116,8 @@ func (h *Authority) DataScopeDepartments(c *gin.Context) { httpx.Fail(c, "获取失败") return } + if ids == nil { + ids = []uint{} + } httpx.Write(c, httpx.CodeSuccess, ids, "获取成功") } diff --git a/internal/server/handler/dictionary.go b/internal/server/handler/dictionary.go index c07bc5b..b072cda 100644 --- a/internal/server/handler/dictionary.go +++ b/internal/server/handler/dictionary.go @@ -3,6 +3,7 @@ package handler import ( "strconv" + "kra/internal/biz" "kra/internal/server/httpx" "kra/internal/service" "kra/internal/service/dto" @@ -54,27 +55,35 @@ func (h *Dictionary) Delete(c *gin.Context) { } func (h *Dictionary) Find(c *gin.Context) { id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - item, err := h.service.Dictionary(c.Request.Context(), uint(id), c.Query("type"), true) + var status *bool + if raw, exists := c.GetQuery("status"); exists { + value, parseErr := strconv.ParseBool(raw) + if parseErr != nil { + httpx.Fail(c, parseErr.Error()) + return + } + status = &value + } + item, err := h.service.Dictionary(c.Request.Context(), uint(id), c.Query("type"), status, true) if err != nil { httpx.Fail(c, "查询失败") return } - httpx.Write(c, httpx.CodeSuccess, gin.H{"reSysDictionary": item}, "查询成功") + httpx.Write(c, httpx.CodeSuccess, gin.H{"resysDictionary": item}, "查询成功") } func (h *Dictionary) List(details bool) gin.HandlerFunc { return func(c *gin.Context) { - p, size := page(c) - items, total, err := h.service.Dictionaries(c.Request.Context(), p, size, c.Query("name"), c.Query("type"), details) + items, _, err := h.service.Dictionaries(c.Request.Context(), 0, 0, c.Query("name"), "", details) if err != nil { httpx.Fail(c, "获取失败") return } - httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: p, PageSize: size}, "获取成功") + httpx.Write(c, httpx.CodeSuccess, items, "获取成功") } } func (h *Dictionary) Export(c *gin.Context) { id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - item, err := h.service.Dictionary(c.Request.Context(), uint(id), "", true) + item, err := h.service.ExportDictionary(c.Request.Context(), uint(id)) if err != nil { httpx.Fail(c, "导出失败") return @@ -83,6 +92,13 @@ func (h *Dictionary) Export(c *gin.Context) { delete(item, "CreatedAt") delete(item, "UpdatedAt") delete(item, "DeletedAt") + if details, ok := item["sysDictionaryDetails"].([]map[string]any); ok { + for _, detail := range details { + for _, key := range []string{"ID", "CreatedAt", "UpdatedAt", "DeletedAt", "sysDictionaryID", "parentID", "disabled", "children"} { + delete(detail, key) + } + } + } httpx.Write(c, httpx.CodeSuccess, item, "导出成功") } func (h *Dictionary) Import(c *gin.Context) { @@ -146,7 +162,33 @@ func (h *Dictionary) FindDetail(c *gin.Context) { func (h *Dictionary) Details(c *gin.Context) { p, size := page(c) dictionaryID, _ := strconv.ParseUint(c.Query("sysDictionaryID"), 10, 64) - items, total, err := h.service.DictionaryDetails(c.Request.Context(), p, size, uint(dictionaryID), c.Query("label"), c.Query("value")) + filter := biz.DictionaryDetailFilter{DictionaryID: uint(dictionaryID), Label: c.Query("label"), Value: c.Query("value")} + if raw, exists := c.GetQuery("status"); exists { + value, parseErr := strconv.ParseBool(raw) + if parseErr != nil { + httpx.Fail(c, parseErr.Error()) + return + } + filter.Status = &value + } + if raw, exists := c.GetQuery("parentID"); exists { + value, parseErr := strconv.ParseUint(raw, 10, 64) + if parseErr != nil { + httpx.Fail(c, parseErr.Error()) + return + } + parentID := uint(value) + filter.ParentID = &parentID + } + if raw, exists := c.GetQuery("level"); exists { + value, parseErr := strconv.Atoi(raw) + if parseErr != nil { + httpx.Fail(c, parseErr.Error()) + return + } + filter.Level = &value + } + items, total, err := h.service.DictionaryDetails(c.Request.Context(), p, size, filter) if err != nil { httpx.Fail(c, "获取失败") return diff --git a/internal/server/handler/email.go b/internal/server/handler/email.go index 5f2ac4f..a6d7bb6 100644 --- a/internal/server/handler/email.go +++ b/internal/server/handler/email.go @@ -14,7 +14,7 @@ func NewEmail(service *service.EmailService) *Email { return &Email{service: ser func (h *Email) Test(c *gin.Context) { if err := h.service.Test(c.Request.Context()); err != nil { - httpx.Fail(c, "发送失败:"+err.Error()) + httpx.Fail(c, "发送失败") return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "发送成功") @@ -22,12 +22,12 @@ func (h *Email) Test(c *gin.Context) { func (h *Email) Send(c *gin.Context) { var req dto.SendEmailRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.Send(c.Request.Context(), req.To, req.Subject, req.Body); err != nil { - httpx.Fail(c, "发送失败:"+err.Error()) + httpx.Fail(c, "发送失败") return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "发送成功") diff --git a/internal/server/handler/export.go b/internal/server/handler/export.go index 78752ec..e746368 100644 --- a/internal/server/handler/export.go +++ b/internal/server/handler/export.go @@ -106,7 +106,13 @@ func (h *Export) Find(c *gin.Context) { } func (h *Export) List(c *gin.Context) { p, size := page(c) - items, total, err := h.service.TemplatesFilter(c.Request.Context(), p, size, c.Query("name"), c.Query("tableName"), c.Query("templateID")) + start, startErr := parseTime(c.Query("startCreatedAt")) + end, endErr := parseTime(c.Query("endCreatedAt")) + if startErr != nil || endErr != nil { + httpx.Fail(c, "参数错误") + return + } + items, total, err := h.service.TemplatesFilter(c.Request.Context(), p, size, c.Query("name"), c.Query("tableName"), c.Query("templateID"), start, end) if err != nil { httpx.Fail(c, "获取失败") return diff --git a/internal/server/handler/media.go b/internal/server/handler/media.go index ac914d7..6af9068 100644 --- a/internal/server/handler/media.go +++ b/internal/server/handler/media.go @@ -3,6 +3,7 @@ package handler import ( "strconv" + "kra/internal/biz" "kra/internal/server/httpx" servermiddleware "kra/internal/server/middleware" "kra/internal/service" @@ -32,7 +33,7 @@ func (h *Media) Upload(c *gin.Context) { save := c.DefaultQuery("noSave", "0") == "0" item, err := h.service.Upload(c.Request.Context(), claims.ID, header.Filename, header.Header.Get("Content-Type"), category, opened, save) if err != nil { - httpx.Fail(c, "上传失败:"+err.Error()) + httpx.Fail(c, "上传文件失败") return } httpx.Write(c, httpx.CodeSuccess, gin.H{"file": item}, "上传成功") @@ -44,7 +45,7 @@ func (h *Media) List(c *gin.Context) { httpx.Fail(c, "参数错误") return } - items, total, err := h.service.MediaList(c.Request.Context(), req.Page, req.PageSize, req.Keyword, req.ClassID, req.Tag, req.UserID) + items, total, err := h.service.MediaList(c.Request.Context(), biz.MediaFilter{Page: req.Page, PageSize: req.PageSize, Keyword: req.Keyword, CategoryID: req.ClassID, Tag: req.Tag, UserID: req.UserID, StartCreatedAt: req.StartCreatedAt, EndCreatedAt: req.EndCreatedAt, OrderKey: req.OrderKey, Desc: req.Desc}) if err != nil { httpx.Fail(c, "获取失败") return @@ -58,10 +59,10 @@ func (h *Media) Delete(c *gin.Context) { return } if err := h.service.Delete(c.Request.Context(), req.ID); err != nil { - httpx.Fail(c, "删除失败:"+err.Error()) + httpx.Fail(c, "删除失败") return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") } func (h *Media) DeleteMany(c *gin.Context) { var req dto.IDsRequest @@ -79,7 +80,7 @@ func (h *Media) DeleteMany(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, gin.H{"failedIds": failed}, "部分文件删除失败") return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "批量删除成功") } func (h *Media) Find(c *gin.Context) { id, _ := strconv.ParseUint(c.Query("id"), 10, 64) @@ -88,7 +89,7 @@ func (h *Media) Find(c *gin.Context) { httpx.Fail(c, "查询失败") return } - httpx.Write(c, httpx.CodeSuccess, item, "获取成功") + httpx.OKWithData(c, item) } func (h *Media) Rename(c *gin.Context) { var req dto.RenameMediaRequest @@ -100,7 +101,7 @@ func (h *Media) Rename(c *gin.Context) { httpx.Fail(c, "编辑失败") return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "编辑成功") } func (h *Media) ImportURLs(c *gin.Context) { var req []dto.ImportMediaRequest @@ -108,16 +109,11 @@ func (h *Media) ImportURLs(c *gin.Context) { httpx.Fail(c, "参数错误") return } - claims := servermiddleware.Claims(c) - if claims == nil { - httpx.Fail(c, "未登录") + if err := h.service.ImportURLRequests(c.Request.Context(), req); err != nil { + httpx.Fail(c, "导入URL失败") return } - if err := h.service.ImportURLRequests(c.Request.Context(), claims.ID, req); err != nil { - httpx.Fail(c, "导入失败") - return - } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "导入URL成功") } func (h *Media) Storage(c *gin.Context) { var req dto.StorageListRequest @@ -148,10 +144,10 @@ func (h *Media) SaveCategory(c *gin.Context) { return } if err := h.service.SaveCategoryRequest(c.Request.Context(), &req); err != nil { - httpx.Fail(c, err.Error()) + httpx.Fail(c, "创建/更新失败:"+err.Error()) return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "创建/更新成功") } func (h *Media) DeleteCategory(c *gin.Context) { var req dto.IDRequest @@ -160,10 +156,10 @@ func (h *Media) DeleteCategory(c *gin.Context) { return } if err := h.service.DeleteCategory(c.Request.Context(), req.ID); err != nil { - httpx.Fail(c, err.Error()) + httpx.Fail(c, "删除失败") return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") } func (h *Media) InitUpload(c *gin.Context) { diff --git a/internal/server/handler/menu.go b/internal/server/handler/menu.go index 5bf23c8..e41db48 100644 --- a/internal/server/handler/menu.go +++ b/internal/server/handler/menu.go @@ -15,12 +15,12 @@ type Menu struct{ service *service.MenuService } func NewMenu(service *service.MenuService) *Menu { return &Menu{service: service} } func (h *Menu) List(c *gin.Context) { - items, err := h.service.List(c.Request.Context()) + items, err := h.service.Tree(c.Request.Context()) if err != nil { httpx.Fail(c, "获取失败") return } - httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: int64(len(items)), Page: 1, PageSize: 999}, "获取成功") + httpx.Write(c, httpx.CodeSuccess, items, "获取成功") } func (h *Menu) Tree(c *gin.Context) { @@ -119,7 +119,18 @@ func (h *Menu) RoleIDs(c *gin.Context) { httpx.Fail(c, "获取失败") return } - httpx.Write(c, httpx.CodeSuccess, ids, "获取成功") + if ids == nil { + ids = []uint{} + } + defaultIDs, err := h.service.DefaultRouterRoleIDs(c.Request.Context(), uint(id)) + if err != nil { + httpx.Fail(c, "获取失败") + return + } + if defaultIDs == nil { + defaultIDs = []uint{} + } + httpx.Write(c, httpx.CodeSuccess, gin.H{"authorityIds": ids, "defaultRouterAuthorityIds": defaultIDs}, "获取成功") } func (h *Menu) SetRoles(c *gin.Context) { diff --git a/internal/server/handler/organization.go b/internal/server/handler/organization.go index d302429..4052b4f 100644 --- a/internal/server/handler/organization.go +++ b/internal/server/handler/organization.go @@ -28,27 +28,35 @@ func (h *Organization) ListDepartments(c *gin.Context) { } func (h *Organization) CreateDepartment(c *gin.Context) { var req dto.DepartmentRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.Name == "" { + httpx.Fail(c, "部门名称不能为空") return } if err := h.service.CreateDepartment(c.Request.Context(), &req); err != nil { - httpx.Fail(c, "创建失败") + httpx.Fail(c, "创建失败:"+err.Error()) return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, nil, "创建成功") } func (h *Organization) UpdateDepartment(c *gin.Context) { var req dto.DepartmentRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.ID == 0 { + httpx.Fail(c, "部门ID不能为空") return } if err := h.service.UpdateDepartment(c.Request.Context(), &req); err != nil { - httpx.Fail(c, "更新失败") + httpx.Fail(c, "更新失败:"+err.Error()) return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, nil, "更新成功") } func (h *Organization) DeleteDepartment(c *gin.Context) { var req dto.DeleteDepartmentRequest @@ -60,7 +68,7 @@ func (h *Organization) DeleteDepartment(c *gin.Context) { httpx.Fail(c, err.Error()) return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, nil, "删除成功") } func (h *Organization) FindDepartment(c *gin.Context) { id, _ := strconv.ParseUint(c.Query("id"), 10, 64) @@ -78,6 +86,9 @@ func (h *Organization) DepartmentUsers(c *gin.Context) { httpx.Fail(c, "获取失败") return } + if ids == nil { + ids = []uint{} + } httpx.Write(c, httpx.CodeSuccess, ids, "获取成功") } func (h *Organization) SetDepartmentUsers(c *gin.Context) { @@ -120,27 +131,35 @@ func (h *Organization) ListPositions(c *gin.Context) { } func (h *Organization) CreatePosition(c *gin.Context) { var req dto.PositionRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.Name == "" { + httpx.Fail(c, "岗位名称不能为空") return } if err := h.service.CreatePosition(c.Request.Context(), &req); err != nil { - httpx.Fail(c, "创建失败") + httpx.Fail(c, "创建失败:"+err.Error()) return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, nil, "创建成功") } func (h *Organization) UpdatePosition(c *gin.Context) { var req dto.PositionRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.ID == 0 { + httpx.Fail(c, "岗位ID不能为空") return } if err := h.service.UpdatePosition(c.Request.Context(), &req); err != nil { - httpx.Fail(c, "更新失败") + httpx.Fail(c, "更新失败:"+err.Error()) return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, nil, "更新成功") } func (h *Organization) DeletePosition(c *gin.Context) { var req dto.DeletePositionRequest @@ -149,10 +168,10 @@ func (h *Organization) DeletePosition(c *gin.Context) { return } if err := h.service.DeletePosition(c.Request.Context(), req.ID); err != nil { - httpx.Fail(c, "删除失败") + httpx.Fail(c, "删除失败:"+err.Error()) return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, nil, "删除成功") } func (h *Organization) FindPosition(c *gin.Context) { id, _ := strconv.ParseUint(c.Query("id"), 10, 64) @@ -170,6 +189,9 @@ func (h *Organization) PositionUsers(c *gin.Context) { httpx.Fail(c, "获取失败") return } + if ids == nil { + ids = []uint{} + } httpx.Write(c, httpx.CodeSuccess, ids, "获取成功") } func (h *Organization) SetPositionUsers(c *gin.Context) { diff --git a/internal/server/handler/parameter.go b/internal/server/handler/parameter.go index bbfdb56..c08f52a 100644 --- a/internal/server/handler/parameter.go +++ b/internal/server/handler/parameter.go @@ -77,7 +77,7 @@ func (h *Parameter) Find(c *gin.Context) { httpx.Fail(c, "查询失败") return } - httpx.Write(c, httpx.CodeSuccess, gin.H{"resysParams": item}, "查询成功") + httpx.Write(c, httpx.CodeSuccess, item, "查询成功") } func (h *Parameter) Get(c *gin.Context) { @@ -91,7 +91,13 @@ func (h *Parameter) Get(c *gin.Context) { func (h *Parameter) List(c *gin.Context) { p, size := page(c) - items, total, err := h.service.ParametersFilter(c.Request.Context(), p, size, c.Query("name"), c.Query("key")) + start, startErr := parseTime(c.Query("startCreatedAt")) + end, endErr := parseTime(c.Query("endCreatedAt")) + if startErr != nil || endErr != nil { + httpx.Fail(c, "参数错误") + return + } + items, total, err := h.service.ParametersFilter(c.Request.Context(), p, size, c.Query("name"), c.Query("key"), start, end) if err != nil { httpx.Fail(c, "获取失败") return diff --git a/internal/server/handler/public.go b/internal/server/handler/public.go index b01755f..c172c00 100644 --- a/internal/server/handler/public.go +++ b/internal/server/handler/public.go @@ -2,11 +2,12 @@ package handler import ( "context" - "net/http" + "errors" "strconv" "strings" "time" + "kra/internal/biz" "kra/internal/conf" "kra/internal/server/httpx" "kra/internal/service" @@ -18,23 +19,22 @@ import ( ) type Public struct { - system *service.SystemService - settings *service.SettingsService - audit *service.AuditService - scheduler *worker.TaskScheduler - store *captchaStore - keyLong int - imageWidth int - imageHeight int + system *service.SystemService + settings *service.SettingsService + audit *service.AuditService + scheduler *worker.TaskScheduler + runtime *conf.Runtime + store *captchaStore } -func NewPublic(config *conf.AdminBackend, system *service.SystemService, settings *service.SettingsService, audit *service.AuditService, scheduler *worker.TaskScheduler) *Public { - expiration := 3 * time.Minute +func NewPublic(runtime *conf.Runtime, system *service.SystemService, settings *service.SettingsService, audit *service.AuditService, scheduler *worker.TaskScheduler) *Public { + return &Public{system: system, settings: settings, audit: audit, scheduler: scheduler, runtime: runtime, store: &captchaStore{service: system, runtime: runtime}} +} + +func (h *Public) captchaConfig() (int, int, int) { keyLong, width, height := 6, 240, 80 + config := h.runtime.Admin() if config != nil && config.Captcha != nil { - if config.Captcha.StoreExpiration != nil { - expiration = config.Captcha.StoreExpiration.AsDuration() - } if config.Captcha.KeyLong > 0 { keyLong = int(config.Captcha.KeyLong) } @@ -45,23 +45,18 @@ func NewPublic(config *conf.AdminBackend, system *service.SystemService, setting height = int(config.Captcha.ImgHeight) } } - return &Public{system: system, settings: settings, audit: audit, scheduler: scheduler, store: &captchaStore{service: system, expiration: expiration}, keyLong: keyLong, imageWidth: width, imageHeight: height} + return keyLong, width, height } func (h *Public) Captcha(c *gin.Context) { - keyLong, width, height := h.keyLong, h.imageWidth, h.imageHeight + keyLong, width, height := h.captchaConfig() security, _ := h.settings.CurrentSecurity(c.Request.Context()) openCaptcha := true if security != nil { keyLong, width, height = security.KeyLong, security.ImgWidth, security.ImgHeight - if security.CaptchaOpen > 0 { - failures, _ := cachedInt(c.Request.Context(), h.system, "login:fail:"+c.ClientIP()) - openCaptcha = failures >= security.CaptchaOpen - } - } - if !openCaptcha { - httpx.Write(c, httpx.CodeSuccess, gin.H{"captchaId": "", "picPath": "", "captchaLength": keyLong, "openCaptcha": false}, "验证码获取成功") - return + ttl := time.Duration(security.CaptchaTimeout) * time.Second + failures, _ := ensureLoginIPCounter(c.Request.Context(), h.system, c.ClientIP(), ttl) + openCaptcha = security.CaptchaOpen == 0 || failures > security.CaptchaOpen } driver := base64Captcha.NewDriverDigit(height, width, keyLong, 0.7, 80) id, picture, _, err := base64Captcha.NewCaptcha(driver, h.store).Generate() @@ -81,42 +76,71 @@ func (h *Public) Login(c *gin.Context) { security, _ := h.settings.CurrentSecurity(c.Request.Context()) if security != nil && security.LockEnable { if _, locked, _ := h.system.CacheGet(c.Request.Context(), "login:lock:"+req.Username); locked { - httpx.Fail(c, "账户已锁定,请稍后重试") + httpx.Fail(c, "账号已锁定,请 "+strconv.Itoa(security.LockDuration)+" 分钟后再试") + _ = h.audit.RecordLoginRequest(c.Request.Context(), &dto.LoginLogRequest{Username: req.Username, IP: c.ClientIP(), Status: false, ErrorMessage: "账号已锁定", Agent: c.Request.UserAgent()}) return } } requireCaptcha := security == nil || security.CaptchaOpen == 0 - if security != nil && security.CaptchaOpen > 0 { - failures, _ := cachedInt(c.Request.Context(), h.system, "login:fail:"+c.ClientIP()) - requireCaptcha = failures >= security.CaptchaOpen + ipTTL := time.Hour + if security != nil { + if security.CaptchaTimeout > 0 { + ipTTL = time.Duration(security.CaptchaTimeout) * time.Second + } + failures, _ := ensureLoginIPCounter(c.Request.Context(), h.system, c.ClientIP(), ipTTL) + requireCaptcha = security.CaptchaOpen == 0 || failures > security.CaptchaOpen } if requireCaptcha && (req.CaptchaID == "" || req.Captcha == "" || !h.store.Verify(req.CaptchaID, req.Captcha, true)) { + _, _ = h.system.CacheIncrement(c.Request.Context(), c.ClientIP(), ipTTL) + _ = h.audit.RecordLoginRequest(c.Request.Context(), &dto.LoginLogRequest{Username: req.Username, IP: c.ClientIP(), Status: false, ErrorMessage: "验证码错误", Agent: c.Request.UserAgent()}) httpx.Fail(c, "验证码错误") return } result, err := h.system.Login(c.Request.Context(), req.Username, req.Password) if err != nil { - _ = h.audit.RecordLoginRequest(c.Request.Context(), &dto.LoginLogRequest{Username: req.Username, IP: c.ClientIP(), Status: false, ErrorMessage: "用户名不存在或者密码错误", Agent: c.Request.UserAgent()}) - ttl := time.Hour - if security != nil && security.CaptchaTimeout > 0 { - ttl = time.Duration(security.CaptchaTimeout) * time.Second + _, _ = h.system.CacheIncrement(c.Request.Context(), c.ClientIP(), ipTTL) + if errors.Is(err, biz.ErrUserDisabled) { + _ = h.audit.RecordLoginRequest(c.Request.Context(), &dto.LoginLogRequest{Username: req.Username, IP: c.ClientIP(), Status: false, ErrorMessage: "用户被禁止登录", Agent: c.Request.UserAgent()}) + httpx.Fail(c, "用户被禁止登录") + return } - failures, _ := incrementCached(c.Request.Context(), h.system, "login:fail:"+c.ClientIP(), ttl) - if security != nil && security.LockEnable && failures >= security.LockThreshold { - _ = h.system.CacheSet(c.Request.Context(), "login:lock:"+req.Username, "1", time.Duration(security.LockDuration)*time.Minute) + _ = h.audit.RecordLoginRequest(c.Request.Context(), &dto.LoginLogRequest{Username: req.Username, IP: c.ClientIP(), Status: false, ErrorMessage: "用户名不存在或者密码错误", Agent: c.Request.UserAgent()}) + if security != nil && security.LockEnable { + lockTTL := time.Duration(security.LockDuration) * time.Minute + failures, _ := h.system.CacheIncrement(c.Request.Context(), "login:fail:"+req.Username, lockTTL) + if int(failures) >= security.LockThreshold { + _ = h.system.CacheSet(c.Request.Context(), "login:lock:"+req.Username, "1", lockTTL) + } } httpx.Fail(c, "用户名不存在或者密码错误") return } userID, _ := result.User["ID"].(uint) _ = h.audit.RecordLoginRequest(c.Request.Context(), &dto.LoginLogRequest{Username: req.Username, IP: c.ClientIP(), Status: true, Agent: c.Request.UserAgent(), UserID: userID}) - _ = h.system.CacheDelete(c.Request.Context(), "login:fail:"+c.ClientIP()) + _ = h.system.CacheDelete(c.Request.Context(), "login:fail:"+req.Username) _ = h.system.CacheDelete(c.Request.Context(), "login:lock:"+req.Username) maxAge := int(time.Until(time.UnixMilli(result.ExpiresAt)).Seconds()) - http.SetCookie(c.Writer, &http.Cookie{Name: "x-token", Value: result.Token, Path: "/", MaxAge: maxAge, HttpOnly: true, SameSite: http.SameSiteStrictMode}) + httpx.SetTokenCookie(c, result.Token, maxAge) httpx.Write(c, httpx.CodeSuccess, result, "登录成功") } +func ensureLoginIPCounter(ctx context.Context, system *service.SystemService, ip string, expiration time.Duration) (int, error) { + value, exists, err := system.CacheGet(ctx, ip) + if err != nil { + return 0, err + } + if exists { + return strconv.Atoi(value) + } + if expiration <= 0 { + expiration = time.Hour + } + if err = system.CacheSet(ctx, ip, "1", expiration); err != nil { + return 0, err + } + return 1, nil +} + func (h *Public) CheckDatabase(c *gin.Context) { initialized, err := h.system.IsInitialized(c.Request.Context()) if err != nil { @@ -163,27 +187,19 @@ func (h *Public) InitializeDatabase(engine *gin.Engine) gin.HandlerFunc { } } -func cachedInt(ctx context.Context, system *service.SystemService, key string) (int, error) { - value, ok, err := system.CacheGet(ctx, key) - if err != nil || !ok { - return 0, err - } - return strconv.Atoi(value) -} - -func incrementCached(ctx context.Context, system *service.SystemService, key string, ttl time.Duration) (int, error) { - value, err := system.CacheIncrement(ctx, key, ttl) - return int(value), err -} - type captchaStore struct { - service *service.SystemService - expiration time.Duration + service *service.SystemService + runtime *conf.Runtime } func (s *captchaStore) key(id string) string { return "captcha:" + id } func (s *captchaStore) Set(id, value string) error { - return s.service.CacheSet(context.Background(), s.key(id), value, s.expiration) + expiration := 3 * time.Minute + config := s.runtime.Admin() + if config != nil && config.Captcha != nil && config.Captcha.StoreExpiration != nil { + expiration = config.Captcha.StoreExpiration.AsDuration() + } + return s.service.CacheSet(context.Background(), s.key(id), value, expiration) } func (s *captchaStore) Get(id string, clear bool) string { value, ok, err := s.service.CacheGet(context.Background(), s.key(id)) diff --git a/internal/server/handler/session.go b/internal/server/handler/session.go index 2badae4..a89ca48 100644 --- a/internal/server/handler/session.go +++ b/internal/server/handler/session.go @@ -1,8 +1,6 @@ package handler import ( - "net/http" - "kra/internal/server/httpx" "kra/internal/service" @@ -21,6 +19,6 @@ func (h *Session) Logout(c *gin.Context) { httpx.Fail(c, "退出失败") return } - http.SetCookie(c.Writer, &http.Cookie{Name: "x-token", Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) + httpx.SetTokenCookie(c, "", -1) httpx.OK(c) } diff --git a/internal/server/handler/system_config.go b/internal/server/handler/system_config.go index 849691f..1b45015 100644 --- a/internal/server/handler/system_config.go +++ b/internal/server/handler/system_config.go @@ -2,7 +2,6 @@ package handler import ( "runtime" - "syscall" "time" "kra/internal/server/httpx" @@ -12,6 +11,7 @@ import ( "github.com/gin-gonic/gin" "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/mem" ) @@ -78,24 +78,31 @@ func (h *SystemConfig) Reload(c *gin.Context) { } func (h *SystemConfig) ServerInfo(c *gin.Context) { - cpuPercent, _ := cpu.Percent(200*time.Millisecond, false) - memory, _ := mem.VirtualMemory() - disk := []gin.H{} - var stat syscall.Statfs_t - if syscall.Statfs(".", &stat) == nil { - total := uint64(stat.Blocks) * uint64(stat.Bsize) - free := uint64(stat.Bavail) * uint64(stat.Bsize) - used := total - free - percent := 0 - if total > 0 { - percent = int(used * 100 / total) + physicalCores, err := cpu.Counts(false) + if err != nil { + httpx.Fail(c, "获取失败") + return + } + cpuPercent, err := cpu.Percent(200*time.Millisecond, true) + if err != nil { + httpx.Fail(c, "获取失败") + return + } + memory, err := mem.VirtualMemory() + if err != nil { + httpx.Fail(c, "获取失败") + return + } + diskInfo := make([]gin.H, 0) + for _, mountPoint := range h.system.DiskMountPoints() { + usage, usageErr := disk.Usage(mountPoint) + if usageErr != nil { + httpx.Fail(c, "获取失败") + return } - disk = append(disk, gin.H{"mountPoint": ".", "usedMb": used / 1024 / 1024, "usedGb": used / 1024 / 1024 / 1024, "totalMb": total / 1024 / 1024, "totalGb": total / 1024 / 1024 / 1024, "usedPercent": percent}) + diskInfo = append(diskInfo, gin.H{"mountPoint": mountPoint, "usedMb": usage.Used / 1024 / 1024, "usedGb": usage.Used / 1024 / 1024 / 1024, "totalMb": usage.Total / 1024 / 1024, "totalGb": usage.Total / 1024 / 1024 / 1024, "usedPercent": int(usage.UsedPercent)}) } - usedMB, totalMB, memoryPercent := uint64(0), uint64(0), float64(0) - if memory != nil { - usedMB, totalMB, memoryPercent = memory.Used/1024/1024, memory.Total/1024/1024, memory.UsedPercent - } - server := gin.H{"os": gin.H{"goos": runtime.GOOS, "numCpu": runtime.NumCPU(), "compiler": runtime.Compiler, "goVersion": runtime.Version(), "numGoroutine": runtime.NumGoroutine()}, "cpu": gin.H{"cpus": cpuPercent, "cores": runtime.NumCPU()}, "ram": gin.H{"usedMb": usedMB, "totalMb": totalMB, "usedPercent": memoryPercent}, "disk": disk} + usedMB, totalMB, memoryPercent := memory.Used/1024/1024, memory.Total/1024/1024, int(memory.UsedPercent) + server := gin.H{"os": gin.H{"goos": runtime.GOOS, "numCpu": runtime.NumCPU(), "compiler": runtime.Compiler, "goVersion": runtime.Version(), "numGoroutine": runtime.NumGoroutine()}, "cpu": gin.H{"cpus": cpuPercent, "cores": physicalCores}, "ram": gin.H{"usedMb": usedMB, "totalMb": totalMB, "usedPercent": memoryPercent}, "disk": diskInfo} httpx.Write(c, httpx.CodeSuccess, gin.H{"server": server}, "获取成功") } diff --git a/internal/server/handler/task.go b/internal/server/handler/task.go index fc4b1c0..a34fc76 100644 --- a/internal/server/handler/task.go +++ b/internal/server/handler/task.go @@ -103,8 +103,8 @@ func (h *Task) Trigger(c *gin.Context) { } func (h *Task) List(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + page, _ := strconv.Atoi(c.Query("page")) + size, _ := strconv.Atoi(c.Query("pageSize")) var enabled *bool if raw := c.Query("enabled"); raw != "" { value, _ := strconv.ParseBool(raw) @@ -119,8 +119,8 @@ func (h *Task) List(c *gin.Context) { } func (h *Task) Logs(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + page, _ := strconv.Atoi(c.Query("page")) + size, _ := strconv.Atoi(c.Query("pageSize")) taskID, _ := strconv.ParseUint(c.Query("taskId"), 10, 64) items, total, err := h.service.Logs(c.Request.Context(), page, size, uint(taskID), c.Query("status")) if err != nil { @@ -144,6 +144,8 @@ func (h *Task) AlertStream(c *gin.Context) { httpx.Fail(c, "SSE 不受支持") return } + // Remove the normal server deadline for long-lived SSE connections. + _ = http.NewResponseController(c.Writer).SetWriteDeadline(time.Time{}) events := h.scheduler.Subscribe() defer h.scheduler.Unsubscribe(events) ticker := time.NewTicker(20 * time.Second) diff --git a/internal/server/handler/user.go b/internal/server/handler/user.go index 4fd722a..420f5f2 100644 --- a/internal/server/handler/user.go +++ b/internal/server/handler/user.go @@ -1,7 +1,6 @@ package handler import ( - "net/http" "strconv" "time" @@ -69,8 +68,8 @@ func (h *User) UpdateSelf(c *gin.Context) { httpx.Fail(c, "参数错误") return } - value := &dto.UserRequest{ID: claims.ID, NickName: req.NickName, HeaderImg: req.HeaderImg, Phone: req.Phone, Email: req.Email, Enable: 1} - if err := h.service.UpdateUserRequest(c.Request.Context(), value); err != nil { + value := service.UserInput{ID: claims.ID, NickName: req.NickName, HeaderImg: req.HeaderImg, Phone: req.Phone, Email: req.Email, Enable: req.Enable} + if err := h.service.UpdateSelfUser(c.Request.Context(), value); err != nil { httpx.Fail(c, "修改失败") return } @@ -83,6 +82,11 @@ func (h *User) Delete(c *gin.Context) { httpx.Fail(c, "参数错误") return } + claims := middleware.Claims(c) + if claims != nil && claims.ID == req.ID { + httpx.Fail(c, "删除失败, 无法删除自己。") + return + } if err := h.service.DeleteUser(c.Request.Context(), req.ID); err != nil { httpx.Fail(c, "删除失败") return @@ -159,7 +163,7 @@ func (h *User) SwitchAuthority(c *gin.Context) { c.Header("new-token", login.Token) c.Header("new-expires-at", strconv.FormatInt(login.ExpiresAt/1000, 10)) maxAge := int(time.Until(time.UnixMilli(login.ExpiresAt)).Seconds()) - http.SetCookie(c.Writer, &http.Cookie{Name: "x-token", Value: login.Token, Path: "/", MaxAge: maxAge, HttpOnly: true, SameSite: http.SameSiteStrictMode}) + httpx.SetTokenCookie(c, login.Token, maxAge) httpx.OK(c) } diff --git a/internal/server/handler/version.go b/internal/server/handler/version.go index 825da53..e9ca1d8 100644 --- a/internal/server/handler/version.go +++ b/internal/server/handler/version.go @@ -23,7 +23,7 @@ func (h *Version) Delete(c *gin.Context) { httpx.Fail(c, "删除失败") return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") } func (h *Version) DeleteMany(c *gin.Context) { raw := c.QueryArray("IDs[]") @@ -36,7 +36,7 @@ func (h *Version) DeleteMany(c *gin.Context) { httpx.Fail(c, "删除失败") return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "批量删除成功") } func (h *Version) Find(c *gin.Context) { id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) @@ -49,7 +49,16 @@ func (h *Version) Find(c *gin.Context) { } func (h *Version) List(c *gin.Context) { p, size := page(c) - items, total, err := h.service.Versions(c.Request.Context(), p, size, c.Query("versionName"), c.Query("versionCode")) + createdAtRange := make([]*time.Time, 0, 2) + for _, raw := range c.QueryArray("createdAtRange[]") { + value, parseErr := parseTime(raw) + if parseErr != nil { + httpx.Fail(c, parseErr.Error()) + return + } + createdAtRange = append(createdAtRange, value) + } + items, total, err := h.service.Versions(c.Request.Context(), p, size, c.Query("versionName"), c.Query("versionCode"), createdAtRange) if err != nil { httpx.Fail(c, "获取失败") return @@ -66,7 +75,7 @@ func (h *Version) Export(c *gin.Context) { httpx.Fail(c, "创建发版失败:"+err.Error()) return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "创建发版成功") } func (h *Version) Download(c *gin.Context) { id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) @@ -89,5 +98,5 @@ func (h *Version) Import(c *gin.Context) { httpx.Fail(c, "导入失败:"+err.Error()) return } - httpx.OK(c) + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "导入成功") } diff --git a/internal/server/httpx/cookie.go b/internal/server/httpx/cookie.go new file mode 100644 index 0000000..be82833 --- /dev/null +++ b/internal/server/httpx/cookie.go @@ -0,0 +1,27 @@ +package httpx + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +func requestUsesHTTPS(request *http.Request) bool { + if request.TLS != nil { + return true + } + forwarded := strings.SplitN(request.Header.Get("X-Forwarded-Proto"), ",", 2)[0] + return strings.EqualFold(strings.TrimSpace(forwarded), "https") +} + +// SetTokenCookie also marks cookies Secure behind TLS or a TLS-terminating +// reverse proxy. +func SetTokenCookie(c *gin.Context, value string, maxAge int) { + cookie := &http.Cookie{Name: "x-token", Value: value, Path: "/", MaxAge: maxAge, Secure: requestUsesHTTPS(c.Request), HttpOnly: true, SameSite: http.SameSiteStrictMode} + if maxAge < 0 { + cookie.Expires = time.Unix(1, 0) + } + http.SetCookie(c.Writer, cookie) +} diff --git a/internal/server/middleware/access.go b/internal/server/middleware/access.go index cda5e74..2c6cf26 100644 --- a/internal/server/middleware/access.go +++ b/internal/server/middleware/access.go @@ -2,7 +2,10 @@ package middleware import ( "net/http" + "strings" + "kra/internal/biz" + "kra/internal/conf" "kra/internal/server/httpx" "kra/internal/service" "kra/internal/service/dto" @@ -10,7 +13,7 @@ import ( "github.com/gin-gonic/gin" ) -func AccessControl(access *service.AccessService, audit *service.AuditService) gin.HandlerFunc { +func AccessControl(runtime *conf.Runtime, access *service.AccessService, audit *service.AuditService) gin.HandlerFunc { return func(c *gin.Context) { claims := Claims(c) if claims == nil { @@ -18,7 +21,14 @@ func AccessControl(access *service.AccessService, audit *service.AuditService) g return } path := c.Request.URL.Path - allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, path, c.Request.Method) + policyPath := path + if config := runtime.Admin(); config != nil && config.RouterPrefix != "" { + policyPath = strings.TrimPrefix(policyPath, strings.TrimSuffix(config.RouterPrefix, "/")) + if policyPath == "" { + policyPath = "/" + } + } + allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, policyPath, c.Request.Method) if err != nil || !allowed { requestID, _ := c.Get("request_id") requestIDText, _ := requestID.(string) @@ -31,6 +41,7 @@ func AccessControl(access *service.AccessService, audit *service.AuditService) g httpx.Fail(c, "数据权限加载失败") return } + requestContext = biz.NewActorContext(requestContext, biz.Actor{UserID: claims.ID, AuthorityID: claims.AuthorityID}) c.Request = c.Request.WithContext(requestContext) c.Next() } diff --git a/internal/server/middleware/auth.go b/internal/server/middleware/auth.go index 22ae6e9..1029032 100644 --- a/internal/server/middleware/auth.go +++ b/internal/server/middleware/auth.go @@ -16,13 +16,14 @@ import ( const claimsKey = "admin_claims" -func Auth(config *conf.AdminBackend, settings *service.SettingsService) gin.HandlerFunc { +func Auth(runtime *conf.Runtime, settings *service.SettingsService) gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader("x-token") if token == "" { token, _ = c.Cookie("x-token") } secret := "" + config := runtime.Admin() if config != nil && config.Jwt != nil { secret = config.Jwt.SigningKey } @@ -35,9 +36,14 @@ func Auth(config *conf.AdminBackend, settings *service.SettingsService) gin.Hand httpx.NoAuth(c, "登录状态已失效") return } + if active, checkErr := settings.ActiveTokenMatches(c.Request.Context(), claims.Username, token); checkErr != nil || !active { + httpx.NoAuth(c, "登录状态已失效") + return + } if claims.ExpiresAt != nil && claims.BufferTime > 0 && time.Until(claims.ExpiresAt.Time) < time.Duration(claims.BufferTime)*time.Second { expires, buffer := 7*24*time.Hour, time.Duration(claims.BufferTime)*time.Second issuer := claims.Issuer + config = runtime.Admin() if config != nil && config.Jwt != nil { if config.Jwt.ExpiresTime != nil { expires = config.Jwt.ExpiresTime.AsDuration() @@ -50,10 +56,10 @@ func Auth(config *conf.AdminBackend, settings *service.SettingsService) gin.Hand } } newToken, newClaims, refreshErr := adminauth.Generate(secret, issuer, expires, buffer, claims.ID, claims.AuthorityID, claims.UUID, claims.Username, claims.NickName, claims.MustChangePwd) - if refreshErr == nil { + if refreshErr == nil && settings.RotateActiveToken(c.Request.Context(), claims.Username, token, newToken, expires) == nil { c.Header("new-token", newToken) c.Header("new-expires-at", strconv.FormatInt(newClaims.ExpiresAt.Unix(), 10)) - http.SetCookie(c.Writer, &http.Cookie{Name: "x-token", Value: newToken, Path: "/", MaxAge: int(expires.Seconds()), HttpOnly: true, SameSite: http.SameSiteStrictMode}) + httpx.SetTokenCookie(c, newToken, int(expires.Seconds())) } } c.Set(claimsKey, claims) diff --git a/internal/server/middleware/rate_limit.go b/internal/server/middleware/rate_limit.go index b45c69e..6ed4aed 100644 --- a/internal/server/middleware/rate_limit.go +++ b/internal/server/middleware/rate_limit.go @@ -1,8 +1,6 @@ package middleware import ( - "net/http" - "strconv" "time" "kra/internal/server/httpx" @@ -26,11 +24,11 @@ func SecurityRateLimit(system *service.SystemService, settings *service.Settings if window < 1 { window = 60 } - bucket := time.Now().Unix() / int64(window) - key := "rate:" + c.ClientIP() + ":" + strconv.FormatInt(bucket, 10) - count, cacheErr := system.CacheIncrement(c.Request.Context(), key, time.Duration(window+1)*time.Second) + key := "KRA_SecLimit" + c.ClientIP() + c.FullPath() + count, cacheErr := system.CacheIncrement(c.Request.Context(), key, time.Duration(window)*time.Second) if cacheErr == nil && int(count) > config.LimitCount { - c.AbortWithStatusJSON(http.StatusTooManyRequests, httpx.Response{Code: httpx.CodeError, Data: nil, Msg: "请求过于频繁"}) + httpx.Fail(c, "请求太过频繁,请稍后再试") + c.Abort() return } c.Next() diff --git a/internal/server/router/api.go b/internal/server/router/api.go index 58681fe..13b3e6f 100644 --- a/internal/server/router/api.go +++ b/internal/server/router/api.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" ) -func RegisterAPI(group *gin.RouterGroup, engine *gin.Engine, h *handler.API) { +func RegisterAPI(group, public *gin.RouterGroup, engine *gin.Engine, h *handler.API) { api := group.Group("/api") api.POST("/getApiList", h.List) api.POST("/getAllApis", h.All) @@ -21,7 +21,7 @@ func RegisterAPI(group *gin.RouterGroup, engine *gin.Engine, h *handler.API) { api.GET("/syncApi", h.Sync(engine)) api.POST("/ignoreApi", h.Ignore) api.POST("/enterSyncApi", h.ApplySync) - api.GET("/freshCasbin", h.FreshCasbin) + public.Group("/api").GET("/freshCasbin", h.FreshCasbin) casbin := group.Group("/casbin") casbin.POST("/updateCasbin", h.SetPolicyPaths) casbin.POST("/getPolicyPathByAuthorityId", h.PolicyPaths) diff --git a/internal/service/README.md b/internal/service/README.md index 42321b7..e36f059 100644 --- a/internal/service/README.md +++ b/internal/service/README.md @@ -1 +1,3 @@ # Service + +本目录负责管理端 DTO 与领域对象之间的转换和输入校验,不直接访问数据库。固定 JSON 契约放在 `dto/`,只有用户设置、导出行等真正动态的数据使用 `map[string]any`。 diff --git a/internal/service/access.go b/internal/service/access.go index d0d3e01..f460aef 100644 --- a/internal/service/access.go +++ b/internal/service/access.go @@ -2,7 +2,6 @@ package service import ( "context" - "strings" "kra/internal/biz" "kra/internal/service/dto" @@ -15,54 +14,37 @@ func (s *AccessService) Authorize(ctx context.Context, aid uint, path, method st return s.uc.Authorize(ctx, aid, path, method) } -func authorityDTO(v *biz.Authority) map[string]any { return convertAuthority(*v) } -func apiDTO(v *biz.API) map[string]any { - return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "path": v.Path, "description": v.Description, "apiGroup": v.APIGroup, "method": v.Method} -} -func departmentDTO(v *biz.Department) map[string]any { +func authorityDTO(v *biz.Authority) map[string]any { + out := convertAuthority(*v) children := make([]map[string]any, 0, len(v.Children)) - for _, c := range v.Children { - children = append(children, departmentDTO(c)) + for _, child := range v.Children { + children = append(children, authorityDTO(child)) } - return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "parentId": v.ParentID, "ancestors": v.Ancestors, "sort": v.Sort, "leaderId": v.LeaderID, "leader": nil, "status": v.Status, "children": children, "namePath": ""} + out["children"] = children + return out } -func positionDTO(v *biz.Position) map[string]any { - return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "code": v.Code, "sort": v.Sort, "status": v.Status, "remark": v.Remark} -} - func (s *AccessService) Authorities(ctx context.Context) ([]map[string]any, error) { - items, err := s.uc.Repo().ListAuthorities(ctx) + items, err := s.uc.AuthorityTree(ctx) if err != nil { return nil, err } - byID := make(map[uint]map[string]any, len(items)) + roots := make([]map[string]any, 0, len(items)) for _, v := range items { - byID[v.AuthorityID] = authorityDTO(v) - } - roots := make([]map[string]any, 0) - for _, v := range items { - item := byID[v.AuthorityID] - if v.ParentID != nil && *v.ParentID != 0 && byID[*v.ParentID] != nil { - parent := byID[*v.ParentID] - children, _ := parent["children"].([]map[string]any) - parent["children"] = append(children, item) - } else { - roots = append(roots, item) - } + roots = append(roots, authorityDTO(v)) } return roots, nil } func (s *AccessService) CreateAuthority(ctx context.Context, v *biz.Authority) error { - return s.uc.Repo().CreateAuthority(ctx, v) + return s.uc.CreateAuthority(ctx, v) } func (s *AccessService) CopyAuthority(ctx context.Context, sourceID uint, v *biz.Authority) error { - return s.uc.Repo().CopyAuthority(ctx, sourceID, v) + return s.uc.CopyAuthority(ctx, sourceID, v) } func (s *AccessService) UpdateAuthority(ctx context.Context, v *biz.Authority) error { - return s.uc.Repo().UpdateAuthority(ctx, v) + return s.uc.UpdateAuthority(ctx, v) } func (s *AccessService) DeleteAuthority(ctx context.Context, id uint) error { - return s.uc.Repo().DeleteAuthority(ctx, id) + return s.uc.DeleteAuthority(ctx, id) } func authorityDomain(req *dto.AuthorityRequest) *biz.Authority { return &biz.Authority{AuthorityID: req.AuthorityID, AuthorityName: req.AuthorityName, ParentID: req.ParentID, DataScope: req.DataScope, DefaultRouter: req.DefaultRouter} @@ -92,19 +74,19 @@ func (s *AccessService) UpdateAuthorityRequest(ctx context.Context, req *dto.Aut return authorityResponse(value), nil } func (s *AccessService) SetAuthorityUsers(ctx context.Context, id uint, ids []uint) error { - return s.uc.Repo().SetAuthorityUsers(ctx, id, ids) + return s.uc.SetAuthorityUsers(ctx, id, ids) } func (s *AccessService) AuthorityUserIDs(ctx context.Context, id uint) ([]uint, error) { - return s.uc.Repo().AuthorityUserIDs(ctx, id) + return s.uc.AuthorityUserIDs(ctx, id) } func (s *AccessService) SetDataScope(ctx context.Context, id uint, scope int, ids []uint) error { - return s.uc.Repo().SetDataScope(ctx, id, scope, ids) + return s.uc.SetDataScope(ctx, id, scope, ids) } func (s *AccessService) DataScopeDepartmentIDs(ctx context.Context, id uint) ([]uint, error) { - return s.uc.Repo().DataScopeDepartmentIDs(ctx, id) + return s.uc.DataScopeDepartmentIDs(ctx, id) } func (s *AccessService) ResolveDataScope(ctx context.Context, authorityID, userID uint) (biz.DataScope, error) { - return s.uc.Repo().ResolveDataScope(ctx, authorityID, userID) + return s.uc.ResolveDataScope(ctx, authorityID, userID) } func (s *AccessService) ContextWithDataScope(ctx context.Context, authorityID, userID uint) (context.Context, error) { scope, err := s.ResolveDataScope(ctx, authorityID, userID) @@ -114,62 +96,8 @@ func (s *AccessService) ContextWithDataScope(ctx context.Context, authorityID, u return biz.NewDataScopeContext(ctx, scope), nil } -func (s *AccessService) DeleteAPIs(ctx context.Context, ids []uint) error { - return s.uc.Repo().DeleteAPIs(ctx, ids) -} -func (s *AccessService) APIRoleIDs(ctx context.Context, id uint) ([]uint, error) { - return s.uc.Repo().APIRoleIDs(ctx, id) -} -func (s *AccessService) SetAPIRoles(ctx context.Context, id uint, ids []uint) error { - return s.uc.Repo().SetAPIRoles(ctx, id, ids) -} -func (s *AccessService) SyncAPIs(ctx context.Context, routes []*biz.API) (*dto.APISyncResponse, error) { - stored, _, err := s.uc.Repo().ListAPIs(ctx, 1, 100000, nil) - if err != nil { - return nil, err - } - ignored, err := s.uc.Repo().IgnoredAPIs(ctx) - if err != nil { - return nil, err - } - key := func(v *biz.API) string { return strings.ToUpper(v.Method) + " " + v.Path } - ignoreSet := make(map[string]bool, len(ignored)) - for _, item := range ignored { - ignoreSet[key(item)] = true - } - routeSet := make(map[string]*biz.API, len(routes)) - for _, item := range routes { - if !ignoreSet[key(item)] { - routeSet[key(item)] = item - } - } - storedSet := make(map[string]*biz.API, len(stored)) - for _, item := range stored { - storedSet[key(item)] = item - } - added := make([]*dto.APIResponse, 0) - deleted := make([]*dto.APIResponse, 0) - ignoredDTO := make([]*dto.APIResponse, 0, len(ignored)) - for k, item := range routeSet { - if storedSet[k] == nil { - added = append(added, apiResponse(item)) - } - } - for k, item := range storedSet { - if routeSet[k] == nil && !ignoreSet[k] { - deleted = append(deleted, apiResponse(item)) - } - } - for _, item := range ignored { - ignoredDTO = append(ignoredDTO, apiResponse(item)) - } - return &dto.APISyncResponse{NewAPIs: added, DeleteAPIs: deleted, IgnoreAPIs: ignoredDTO}, nil -} -func (s *AccessService) SetAPIIgnored(ctx context.Context, path, method string, ignored bool) error { - return s.uc.Repo().SetAPIIgnored(ctx, path, method, ignored) -} func (s *AccessService) Buttons(ctx context.Context, id uint) ([]map[string]any, error) { - items, err := s.uc.Repo().Buttons(ctx, id) + items, err := s.uc.Buttons(ctx, id) if err != nil { return nil, err } @@ -180,17 +108,17 @@ func (s *AccessService) Buttons(ctx context.Context, id uint) ([]map[string]any, return out, nil } func (s *AccessService) SetAuthorityButtons(ctx context.Context, id uint, v map[uint][]uint) error { - return s.uc.Repo().SetAuthorityButtons(ctx, id, v) + return s.uc.SetAuthorityButtons(ctx, id, v) } func (s *AccessService) AuthorityButtonIDs(ctx context.Context, id uint) ([]uint, error) { - return s.uc.Repo().AuthorityButtonIDs(ctx, id) + return s.uc.AuthorityButtonIDs(ctx, id) } func (s *AccessService) SelectedButtons(ctx context.Context, aid, menuID uint) ([]uint, error) { - return s.uc.Repo().SelectedButtons(ctx, aid, menuID) + return s.uc.SelectedButtons(ctx, aid, menuID) } func (s *AccessService) SetSelectedButtons(ctx context.Context, aid, menuID uint, ids []uint) error { - return s.uc.Repo().SetSelectedButtons(ctx, aid, menuID, ids) + return s.uc.SetSelectedButtons(ctx, aid, menuID, ids) } func (s *AccessService) CanRemoveButton(ctx context.Context, id uint) (bool, error) { - return s.uc.Repo().CanRemoveButton(ctx, id) + return s.uc.CanRemoveButton(ctx, id) } diff --git a/internal/service/api.go b/internal/service/api.go index 1ab08a7..26ff9a2 100644 --- a/internal/service/api.go +++ b/internal/service/api.go @@ -24,29 +24,33 @@ func apiResponses(items []*biz.API) []*dto.APIResponse { } func (s *AccessService) ListAPI(ctx context.Context, req *dto.APIListRequest) ([]*dto.APIResponse, int64, error) { - items, total, err := s.uc.Repo().ListAPIs(ctx, req.Page, req.PageSize, &biz.API{Path: req.Path, Description: req.Description, APIGroup: req.APIGroup, Method: req.Method}) + items, total, err := s.uc.ListAPIs(ctx, req.Page, req.PageSize, &biz.API{Path: req.Path, Description: req.Description, APIGroup: req.APIGroup, Method: req.Method, OrderKey: req.OrderKey, Desc: req.Desc}) if err != nil { return nil, 0, err } return apiResponses(items), total, nil } -func (s *AccessService) AllAPI(ctx context.Context) ([]*dto.APIResponse, error) { - items, _, err := s.uc.Repo().ListAPIs(ctx, 1, 10000, nil) +func (s *AccessService) AllAPI(ctx context.Context, strict bool) ([]*dto.APIResponse, error) { + items, _, err := s.uc.ListAPIs(ctx, 0, 0, &biz.API{StrictAll: strict}) if err != nil { return nil, err } return apiResponses(items), nil } -func (s *AccessService) CreateAPIRequest(ctx context.Context, req *dto.APIRequest) error { - return s.uc.Repo().CreateAPI(ctx, apiDomain(req)) +func (s *AccessService) CreateAPIRequest(ctx context.Context, req *dto.APIRequest) (*dto.APIResponse, error) { + value := apiDomain(req) + if err := s.uc.CreateAPI(ctx, value); err != nil { + return nil, err + } + return apiResponse(value), nil } func (s *AccessService) UpdateAPIRequest(ctx context.Context, req *dto.APIRequest) error { - return s.uc.Repo().UpdateAPI(ctx, apiDomain(req)) + return s.uc.UpdateAPI(ctx, apiDomain(req)) } func (s *AccessService) FindAPIResponse(ctx context.Context, id uint) (*dto.APIResponse, error) { - value, err := s.uc.Repo().FindAPI(ctx, id) + value, err := s.uc.FindAPI(ctx, id) if err != nil { return nil, err } @@ -61,17 +65,17 @@ func (s *AccessService) ApplyAPISyncRequest(ctx context.Context, req *dto.ApplyA for i := range req.DeleteAPIs { deleted = append(deleted, apiDomain(&req.DeleteAPIs[i])) } - return s.uc.Repo().ApplyAPISync(ctx, added, deleted) + return s.uc.ApplyAPISync(ctx, added, deleted) } func (s *AccessService) SetPolicyPathsRequest(ctx context.Context, req *dto.SetPolicyPathsRequest) error { paths := make([]*biz.API, 0, len(req.Infos)) for _, value := range req.Infos { paths = append(paths, &biz.API{Path: value.Path, Method: value.Method}) } - return s.uc.Repo().SetPolicyPaths(ctx, req.AuthorityID, paths) + return s.uc.SetPolicyPaths(ctx, req.AuthorityID, paths) } func (s *AccessService) PolicyPathResponses(ctx context.Context, authorityID uint) ([]dto.PolicyPathRequest, error) { - items, err := s.uc.Repo().PolicyPaths(ctx, authorityID) + items, err := s.uc.PolicyPaths(ctx, authorityID) if err != nil { return nil, err } @@ -88,3 +92,26 @@ func (s *AccessService) SyncAPIResponses(ctx context.Context, routes []dto.APIRe } return s.SyncAPIs(ctx, items) } + +func apiDTO(v *biz.API) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "path": v.Path, "description": v.Description, "apiGroup": v.APIGroup, "method": v.Method} +} +func (s *AccessService) DeleteAPIs(ctx context.Context, ids []uint) error { + return s.uc.DeleteAPIs(ctx, ids) +} +func (s *AccessService) APIRoleIDs(ctx context.Context, path, method string) ([]uint, error) { + return s.uc.APIRoleIDs(ctx, path, method) +} +func (s *AccessService) SetAPIRoles(ctx context.Context, path, method string, ids []uint) error { + return s.uc.SetAPIRoles(ctx, path, method, ids) +} +func (s *AccessService) SyncAPIs(ctx context.Context, routes []*biz.API) (*dto.APISyncResponse, error) { + diff, err := s.uc.SyncAPIs(ctx, routes) + if err != nil { + return nil, err + } + return &dto.APISyncResponse{NewAPIs: apiResponses(diff.Added), DeleteAPIs: apiResponses(diff.Deleted), IgnoreAPIs: apiResponses(diff.Ignored)}, nil +} +func (s *AccessService) SetAPIIgnored(ctx context.Context, path, method string, ignored bool) error { + return s.uc.SetAPIIgnored(ctx, path, method, ignored) +} diff --git a/internal/service/api_token.go b/internal/service/api_token.go index a97286e..ea00ed7 100644 --- a/internal/service/api_token.go +++ b/internal/service/api_token.go @@ -2,7 +2,6 @@ package service import ( "context" - "errors" "time" "kra/internal/biz" @@ -18,29 +17,20 @@ func tokenDTO(v *biz.APIToken) map[string]any { } func (s *SettingsService) CreateAPIToken(ctx context.Context, userID, authorityID uint, days int, remark string) (string, error) { - user, allowed, err := s.Repo().UserHasAuthority(ctx, userID, authorityID) + user, duration, err := s.uc.PrepareAPIToken(ctx, userID, authorityID, days) if err != nil { return "", err } - if !allowed { - return "", errors.New("用户不具备该角色权限") - } - duration := time.Duration(days) * 24 * time.Hour - if days == -1 { - duration = 100 * 365 * 24 * time.Hour - } - if duration <= 0 { - return "", errors.New("有效天数必须大于0或为-1") - } secret, issuer := "", "kra" buffer := 24 * time.Hour - if s.config != nil && s.config.Jwt != nil { - secret = s.config.Jwt.SigningKey - if s.config.Jwt.Issuer != "" { - issuer = s.config.Jwt.Issuer + config := s.runtime.Admin() + if config != nil && config.Jwt != nil { + secret = config.Jwt.SigningKey + if config.Jwt.Issuer != "" { + issuer = config.Jwt.Issuer } - if s.config.Jwt.BufferTime != nil { - buffer = s.config.Jwt.BufferTime.AsDuration() + if config.Jwt.BufferTime != nil { + buffer = config.Jwt.BufferTime.AsDuration() } } token, _, err := adminauth.Generate(secret, issuer, duration, buffer, user.ID, authorityID, user.UUID, user.Username, user.NickName, false) @@ -48,13 +38,13 @@ func (s *SettingsService) CreateAPIToken(ctx context.Context, userID, authorityI return "", err } record := &biz.APIToken{UserID: userID, AuthorityID: authorityID, Token: token, Status: true, ExpiresAt: time.Now().Add(duration), Remark: remark} - if err = s.Repo().CreateAPIToken(ctx, record); err != nil { + if err = s.uc.CreateAPIToken(ctx, record); err != nil { return "", err } return token, nil } func (s *SettingsService) APITokens(ctx context.Context, page, size int, userID uint, status *bool) ([]map[string]any, int64, error) { - items, total, err := s.Repo().ListAPITokens(ctx, page, size, userID, status) + items, total, err := s.uc.ListAPITokens(ctx, page, size, userID, status) if err != nil { return nil, 0, err } @@ -65,15 +55,11 @@ func (s *SettingsService) APITokens(ctx context.Context, page, size int, userID return out, total, nil } func (s *SettingsService) DisableAPIToken(ctx context.Context, id uint) error { - token, err := s.Repo().DisableAPIToken(ctx, id) - if err != nil { - return err - } - return s.Repo().BlacklistToken(ctx, token) + return s.uc.DisableToken(ctx, id) } func (s *SettingsService) BlacklistToken(ctx context.Context, token string) error { - return s.Repo().BlacklistToken(ctx, token) + return s.uc.BlacklistToken(ctx, token) } func (s *SettingsService) IsTokenDisabled(ctx context.Context, token string) (bool, error) { - return s.Repo().IsTokenDisabled(ctx, token) + return s.uc.IsTokenDisabled(ctx, token) } diff --git a/internal/service/audit.go b/internal/service/audit.go index 20b06f4..5931ffa 100644 --- a/internal/service/audit.go +++ b/internal/service/audit.go @@ -2,6 +2,7 @@ package service import ( "context" + "kra/internal/biz" "kra/internal/service/dto" ) @@ -9,44 +10,25 @@ import ( type AuditService struct{ uc *biz.AuditUsecase } func NewAuditService(uc *biz.AuditUsecase) *AuditService { return &AuditService{uc: uc} } + func (s *AuditService) RecordOperation(ctx context.Context, v *biz.OperationRecord) error { - return s.uc.Repo().RecordOperation(ctx, v) + return s.uc.RecordOperation(ctx, v) } func (s *AuditService) RecordOperationRequest(ctx context.Context, value *dto.OperationRecordRequest) error { return s.RecordOperation(ctx, &biz.OperationRecord{IP: value.IP, Method: value.Method, Path: value.Path, Status: value.Status, LatencyMS: value.LatencyMS, Agent: value.Agent, ErrorMessage: value.ErrorMessage, Body: value.Body, Response: value.Response, UserID: value.UserID, RequestID: value.RequestID, TraceID: value.TraceID, DeviceID: value.DeviceID}) } -func (s *AuditService) OperationsFilter(ctx context.Context, page, size int, path, method string, status int, ip string, userID uint) ([]map[string]any, int64, error) { - return s.Operations(ctx, page, size, &biz.OperationRecord{Path: path, Method: method, Status: status, IP: ip, UserID: userID}) -} -func (s *AuditService) RecordLogin(ctx context.Context, v *biz.LoginLog) error { - return s.uc.Repo().RecordLogin(ctx, v) -} -func (s *AuditService) RecordLoginRequest(ctx context.Context, value *dto.LoginLogRequest) error { - return s.RecordLogin(ctx, &biz.LoginLog{Username: value.Username, IP: value.IP, Status: value.Status, ErrorMessage: value.ErrorMessage, Agent: value.Agent, UserID: value.UserID}) -} -func (s *AuditService) LoginsFilter(ctx context.Context, page, size int, username, ip string) ([]map[string]any, int64, error) { - return s.Logins(ctx, page, size, &biz.LoginLog{Username: username, IP: ip}) -} -func (s *AuditService) RecordDataAccess(ctx context.Context, v *biz.DataAccessLog) error { - return s.uc.Repo().RecordDataAccess(ctx, v) -} -func (s *AuditService) RecordDataAccessRequest(ctx context.Context, value *dto.DataAccessRecordRequest) error { - return s.RecordDataAccess(ctx, &biz.DataAccessLog{EventType: value.EventType, Operation: value.Operation, UserID: value.UserID, AuthorityID: value.AuthorityID, RequestID: value.RequestID, Method: value.Method, Path: value.Path, Detail: value.Detail}) -} -func (s *AuditService) DataAccessRequest(ctx context.Context, req *dto.DataAccessListRequest) ([]map[string]any, int64, error) { - return s.DataAccess(ctx, req.Page, req.PageSize, &biz.DataAccessLog{EventType: req.EventType, TargetTable: req.TargetTable, UserID: req.UserID}) +func (s *AuditService) OperationsFilter(ctx context.Context, page, size int, path, method string, status int) ([]map[string]any, int64, error) { + return s.Operations(ctx, page, size, &biz.OperationRecord{Path: path, Method: method, Status: status}) } func opDTO(v *biz.OperationRecord) map[string]any { - return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "ip": v.IP, "method": v.Method, "path": v.Path, "status": v.Status, "latency_ms": v.LatencyMS, "agent": v.Agent, "error_message": v.ErrorMessage, "body": v.Body, "resp": v.Response, "user_id": v.UserID, "request_id": v.RequestID, "trace_id": v.TraceID, "device_id": v.DeviceID, "user": nil} -} -func loginDTO(v *biz.LoginLog) map[string]any { - return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "username": v.Username, "ip": v.IP, "status": v.Status, "errorMessage": v.ErrorMessage, "agent": v.Agent, "userId": v.UserID, "user": nil} -} -func dataAccessDTO(v *biz.DataAccessLog) map[string]any { - return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "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} + user := map[string]any{} + if v.User != nil { + user = convertUser(v.User) + } + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "ip": v.IP, "method": v.Method, "path": v.Path, "status": v.Status, "latency_ms": v.LatencyMS, "agent": v.Agent, "error_message": v.ErrorMessage, "body": v.Body, "resp": v.Response, "user_id": v.UserID, "request_id": v.RequestID, "trace_id": v.TraceID, "device_id": v.DeviceID, "user": user} } func (s *AuditService) Operations(ctx context.Context, page, size int, q *biz.OperationRecord) ([]map[string]any, int64, error) { - items, total, err := s.uc.Repo().ListOperations(ctx, page, size, q) + items, total, err := s.uc.ListOperations(ctx, page, size, q) if err != nil { return nil, 0, err } @@ -57,17 +39,34 @@ func (s *AuditService) Operations(ctx context.Context, page, size int, q *biz.Op return out, total, nil } func (s *AuditService) Operation(ctx context.Context, id uint) (map[string]any, error) { - v, err := s.uc.Repo().FindOperation(ctx, id) + v, err := s.uc.FindOperation(ctx, id) if err != nil { return nil, err } return opDTO(v), nil } func (s *AuditService) DeleteOperations(ctx context.Context, ids []uint) error { - return s.uc.Repo().DeleteOperations(ctx, ids) + return s.uc.DeleteOperations(ctx, ids) +} + +func (s *AuditService) RecordLogin(ctx context.Context, v *biz.LoginLog) error { + return s.uc.RecordLogin(ctx, v) +} +func (s *AuditService) RecordLoginRequest(ctx context.Context, value *dto.LoginLogRequest) error { + return s.RecordLogin(ctx, &biz.LoginLog{Username: value.Username, IP: value.IP, Status: value.Status, ErrorMessage: value.ErrorMessage, Agent: value.Agent, UserID: value.UserID}) +} +func (s *AuditService) LoginsFilter(ctx context.Context, page, size int, username string, status bool) ([]map[string]any, int64, error) { + return s.Logins(ctx, page, size, &biz.LoginLog{Username: username, Status: status, FilterByStatus: status}) +} +func loginDTO(v *biz.LoginLog) map[string]any { + user := map[string]any{} + if v.User != nil { + user = convertUser(v.User) + } + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "username": v.Username, "ip": v.IP, "status": v.Status, "errorMessage": v.ErrorMessage, "agent": v.Agent, "userId": v.UserID, "user": user} } func (s *AuditService) Logins(ctx context.Context, page, size int, q *biz.LoginLog) ([]map[string]any, int64, error) { - items, total, err := s.uc.Repo().ListLogins(ctx, page, size, q) + items, total, err := s.uc.ListLogins(ctx, page, size, q) if err != nil { return nil, 0, err } @@ -78,17 +77,30 @@ func (s *AuditService) Logins(ctx context.Context, page, size int, q *biz.LoginL return out, total, nil } func (s *AuditService) Login(ctx context.Context, id uint) (map[string]any, error) { - v, err := s.uc.Repo().FindLogin(ctx, id) + v, err := s.uc.FindLogin(ctx, id) if err != nil { return nil, err } return loginDTO(v), nil } func (s *AuditService) DeleteLogins(ctx context.Context, ids []uint) error { - return s.uc.Repo().DeleteLogins(ctx, ids) + return s.uc.DeleteLogins(ctx, ids) +} + +func (s *AuditService) RecordDataAccess(ctx context.Context, v *biz.DataAccessLog) error { + return s.uc.RecordDataAccess(ctx, v) +} +func (s *AuditService) RecordDataAccessRequest(ctx context.Context, value *dto.DataAccessRecordRequest) error { + return s.RecordDataAccess(ctx, &biz.DataAccessLog{EventType: value.EventType, Operation: value.Operation, UserID: value.UserID, AuthorityID: value.AuthorityID, RequestID: value.RequestID, Method: value.Method, Path: value.Path, Detail: value.Detail}) +} +func (s *AuditService) DataAccessRequest(ctx context.Context, req *dto.DataAccessListRequest) ([]map[string]any, int64, error) { + return s.DataAccess(ctx, req.Page, req.PageSize, &biz.DataAccessLog{EventType: req.EventType, TargetTable: req.TargetTable, UserID: req.UserID}) +} +func dataAccessDTO(v *biz.DataAccessLog) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "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 (s *AuditService) DataAccess(ctx context.Context, page, size int, q *biz.DataAccessLog) ([]map[string]any, int64, error) { - items, total, err := s.uc.Repo().ListDataAccess(ctx, page, size, q) + items, total, err := s.uc.ListDataAccess(ctx, page, size, q) if err != nil { return nil, 0, err } @@ -99,76 +111,5 @@ func (s *AuditService) DataAccess(ctx context.Context, page, size int, q *biz.Da return out, total, nil } func (s *AuditService) DeleteDataAccess(ctx context.Context, ids []uint) error { - return s.uc.Repo().DeleteDataAccess(ctx, ids) -} -func (s *AuditService) LogDates(ctx context.Context, month string) (map[string]any, error) { - items, err := s.uc.Repo().LogDates(ctx, month) - if err != nil { - return nil, err - } - out := make([]map[string]any, 0, len(items)) - for _, v := range items { - out = append(out, map[string]any{"date": v.Date, "fileCount": v.FileCount}) - } - return map[string]any{"month": month, "dates": out}, nil -} -func (s *AuditService) LogFiles(ctx context.Context, date string) (map[string]any, error) { - items, err := s.uc.Repo().LogFiles(ctx, date) - if err != nil { - return nil, err - } - out := make([]map[string]any, 0, len(items)) - for _, v := range items { - out = append(out, map[string]any{"path": v.Path, "name": v.Name, "size": v.Size, "modifiedAt": v.ModifiedAt}) - } - return map[string]any{"date": date, "files": out}, nil -} -func (s *AuditService) LogContent(ctx context.Context, date, path string, cursor *int64) (map[string]any, error) { - v, err := s.uc.Repo().LogContent(ctx, date, path, cursor) - if err != nil { - return nil, err - } - return map[string]any{"date": v.Date, "path": v.Path, "content": v.Content, "lineCount": v.LineCount, "nextCursor": v.NextCursor, "hasMore": v.HasMore, "limitedByBytes": v.LimitedByBytes, "size": v.Size, "modifiedAt": v.ModifiedAt}, nil -} -func errorDTO(v *biz.ErrorRecord) map[string]any { - return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "form": v.Form, "info": v.Info, "level": v.Level, "request_id": v.RequestID, "trace_id": v.TraceID, "solution": v.Solution, "status": v.Status} -} -func (s *AuditService) CreateError(ctx context.Context, v *biz.ErrorRecord) error { - return s.uc.Repo().CreateError(ctx, v) -} -func errorDomain(value *dto.ErrorRecordRequest) *biz.ErrorRecord { - return &biz.ErrorRecord{ID: value.ID, Form: value.Form, Info: value.Info, Level: value.Level, RequestID: value.RequestID, TraceID: value.TraceID, Solution: value.Solution, Status: value.Status} -} -func (s *AuditService) CreateErrorRequest(ctx context.Context, req *dto.ErrorRecordRequest) error { - return s.CreateError(ctx, errorDomain(req)) -} -func (s *AuditService) UpdateErrorRequest(ctx context.Context, req *dto.ErrorRecordRequest) error { - return s.UpdateError(ctx, errorDomain(req)) -} -func (s *AuditService) ErrorsFilter(ctx context.Context, page, size int, form, level, status string) ([]map[string]any, int64, error) { - return s.Errors(ctx, page, size, &biz.ErrorRecord{Form: form, Level: level, Status: status}) -} -func (s *AuditService) UpdateError(ctx context.Context, v *biz.ErrorRecord) error { - return s.uc.Repo().UpdateError(ctx, v) -} -func (s *AuditService) DeleteErrors(ctx context.Context, ids []uint) error { - return s.uc.Repo().DeleteErrors(ctx, ids) -} -func (s *AuditService) Error(ctx context.Context, id uint) (map[string]any, error) { - v, err := s.uc.Repo().FindError(ctx, id) - if err != nil { - return nil, err - } - return errorDTO(v), nil -} -func (s *AuditService) Errors(ctx context.Context, page, size int, q *biz.ErrorRecord) ([]map[string]any, int64, error) { - items, total, err := s.uc.Repo().ListErrors(ctx, page, size, q) - if err != nil { - return nil, 0, err - } - out := make([]map[string]any, 0, len(items)) - for _, v := range items { - out = append(out, errorDTO(v)) - } - return out, total, nil + return s.uc.DeleteDataAccess(ctx, ids) } diff --git a/internal/service/audit_error.go b/internal/service/audit_error.go new file mode 100644 index 0000000..644703f --- /dev/null +++ b/internal/service/audit_error.go @@ -0,0 +1,52 @@ +package service + +import ( + "context" + "time" + + "kra/internal/biz" + "kra/internal/service/dto" +) + +func errorDTO(v *biz.ErrorRecord) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "form": v.Form, "info": v.Info, "level": v.Level, "request_id": v.RequestID, "trace_id": v.TraceID, "solution": v.Solution, "status": v.Status} +} +func (s *AuditService) CreateError(ctx context.Context, v *biz.ErrorRecord) error { + return s.uc.CreateError(ctx, v) +} +func errorDomain(value *dto.ErrorRecordRequest) *biz.ErrorRecord { + return &biz.ErrorRecord{ID: value.ID, Form: value.Form, Info: value.Info, Level: value.Level, RequestID: value.RequestID, TraceID: value.TraceID, Solution: value.Solution, Status: value.Status} +} +func (s *AuditService) CreateErrorRequest(ctx context.Context, req *dto.ErrorRecordRequest) error { + return s.CreateError(ctx, errorDomain(req)) +} +func (s *AuditService) UpdateErrorRequest(ctx context.Context, req *dto.ErrorRecordRequest) error { + return s.UpdateError(ctx, errorDomain(req)) +} +func (s *AuditService) ErrorsFilter(ctx context.Context, page, size int, form, info string, createdAtRange []time.Time) ([]map[string]any, int64, error) { + return s.Errors(ctx, page, size, &biz.ErrorRecord{Form: form, Info: info, CreatedAtRange: createdAtRange}) +} +func (s *AuditService) UpdateError(ctx context.Context, v *biz.ErrorRecord) error { + return s.uc.UpdateError(ctx, v) +} +func (s *AuditService) DeleteErrors(ctx context.Context, ids []uint) error { + return s.uc.DeleteErrors(ctx, ids) +} +func (s *AuditService) Error(ctx context.Context, id uint) (map[string]any, error) { + v, err := s.uc.FindError(ctx, id) + if err != nil { + return nil, err + } + return errorDTO(v), nil +} +func (s *AuditService) Errors(ctx context.Context, page, size int, q *biz.ErrorRecord) ([]map[string]any, int64, error) { + items, total, err := s.uc.ListErrors(ctx, page, size, q) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, errorDTO(v)) + } + return out, total, nil +} diff --git a/internal/service/audit_log_file.go b/internal/service/audit_log_file.go new file mode 100644 index 0000000..63d433d --- /dev/null +++ b/internal/service/audit_log_file.go @@ -0,0 +1,35 @@ +package service + +import ( + "context" +) + +func (s *AuditService) LogDates(ctx context.Context, month string) (map[string]any, error) { + items, err := s.uc.LogDates(ctx, month) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, map[string]any{"date": v.Date, "fileCount": v.FileCount}) + } + return map[string]any{"month": month, "dates": out}, nil +} +func (s *AuditService) LogFiles(ctx context.Context, date string) (map[string]any, error) { + items, err := s.uc.LogFiles(ctx, date) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, map[string]any{"path": v.Path, "name": v.Name, "size": v.Size, "modifiedAt": v.ModifiedAt}) + } + return map[string]any{"date": date, "files": out}, nil +} +func (s *AuditService) LogContent(ctx context.Context, date, path string, cursor *int64) (map[string]any, error) { + v, err := s.uc.LogContent(ctx, date, path, cursor) + if err != nil { + return nil, err + } + return map[string]any{"date": v.Date, "path": v.Path, "content": v.Content, "lineCount": v.LineCount, "nextCursor": v.NextCursor, "hasMore": v.HasMore, "limitedByBytes": v.LimitedByBytes, "size": v.Size, "modifiedAt": v.ModifiedAt}, nil +} diff --git a/internal/service/authentication.go b/internal/service/authentication.go index ff3a033..f2eeee4 100644 --- a/internal/service/authentication.go +++ b/internal/service/authentication.go @@ -34,29 +34,39 @@ func (s *SystemService) Login(ctx context.Context, username, password string) (* return nil, err } if u.Enable != 1 { - return nil, biz.ErrInvalidCredentials + return nil, biz.ErrUserDisabled } if security, securityErr := s.settings.CurrentSecurity(ctx); securityErr == nil && security.PwdExpireEnable && u.PasswordUpdatedAt != nil && time.Since(*u.PasswordUpdatedAt) > time.Duration(security.PwdExpireDays)*24*time.Hour { u.MustChangePassword = true } - return s.issueLogin(u, u.AuthorityID) + return s.issueLogin(ctx, u, u.AuthorityID) } -func (s *SystemService) issueLogin(user *biz.User, authorityID uint) (*LoginResult, error) { +func (s *SystemService) issueLogin(ctx context.Context, user *biz.User, authorityID uint) (*LoginResult, error) { expires, buffer := 7*24*time.Hour, 24*time.Hour secret, issuer := "", "kra" - if s.config != nil && s.config.Jwt != nil { - secret, issuer = s.config.Jwt.SigningKey, s.config.Jwt.Issuer - if s.config.Jwt.ExpiresTime != nil { - expires = s.config.Jwt.ExpiresTime.AsDuration() + config := s.runtime.Admin() + if config != nil && config.Jwt != nil { + secret, issuer = config.Jwt.SigningKey, config.Jwt.Issuer + if config.Jwt.ExpiresTime != nil { + expires = config.Jwt.ExpiresTime.AsDuration() } - if s.config.Jwt.BufferTime != nil { - buffer = s.config.Jwt.BufferTime.AsDuration() + if config.Jwt.BufferTime != nil { + buffer = config.Jwt.BufferTime.AsDuration() } } token, claims, err := adminauth.Generate(secret, issuer, expires, buffer, user.ID, authorityID, user.UUID, user.Username, user.NickName, user.MustChangePassword) if err != nil { return nil, err } + if s.settings.UseMultipoint() { + oldToken, _, cacheErr := s.settings.cache.Get(ctx, activeTokenKey(user.Username)) + if cacheErr != nil { + return nil, cacheErr + } + if cacheErr = s.settings.RotateActiveToken(ctx, user.Username, oldToken, token, expires); cacheErr != nil { + return nil, cacheErr + } + } return &LoginResult{User: convertUser(user), Token: token, ExpiresAt: claims.ExpiresAt.UnixMilli(), NeedChangePassword: user.MustChangePassword}, nil } diff --git a/internal/service/dictionary.go b/internal/service/dictionary.go index f644f9f..6fb77d6 100644 --- a/internal/service/dictionary.go +++ b/internal/service/dictionary.go @@ -2,8 +2,6 @@ package service import ( "context" - "encoding/json" - "errors" "kra/internal/biz" "kra/internal/service/dto" @@ -16,25 +14,12 @@ func dictionaryDomain(value *dto.DictionaryRequest) *biz.Dictionary { } return &biz.Dictionary{ID: value.ID, Name: value.Name, Type: value.Type, Status: status, Desc: value.Description, ParentID: value.ParentID} } -func detailDomain(value *dto.DictionaryDetailRequest) *biz.DictionaryDetail { - status := true - if value.Status != nil { - status = *value.Status - } - return &biz.DictionaryDetail{ID: value.ID, Label: value.Label, Value: value.Value, Extend: value.Extend, Status: status, Sort: value.Sort, DictionaryID: value.DictionaryID, ParentID: value.ParentID, Level: value.Level, Path: value.Path} -} func (s *SettingsService) CreateDictionaryRequest(ctx context.Context, req *dto.DictionaryRequest) error { return s.CreateDictionary(ctx, dictionaryDomain(req)) } func (s *SettingsService) UpdateDictionaryRequest(ctx context.Context, req *dto.DictionaryRequest) error { return s.UpdateDictionary(ctx, dictionaryDomain(req)) } -func (s *SettingsService) CreateDictionaryDetailRequest(ctx context.Context, req *dto.DictionaryDetailRequest) error { - return s.CreateDictionaryDetail(ctx, detailDomain(req)) -} -func (s *SettingsService) UpdateDictionaryDetailRequest(ctx context.Context, req *dto.DictionaryDetailRequest) error { - return s.UpdateDictionaryDetail(ctx, detailDomain(req)) -} func dictionaryDTO(v *biz.Dictionary) map[string]any { children := make([]map[string]any, 0, len(v.Children)) for _, x := range v.Children { @@ -46,15 +31,8 @@ func dictionaryDTO(v *biz.Dictionary) map[string]any { } return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "type": v.Type, "status": v.Status, "desc": v.Desc, "parentID": v.ParentID, "children": children, "sysDictionaryDetails": details} } -func detailDTO(v *biz.DictionaryDetail) map[string]any { - children := make([]map[string]any, 0, len(v.Children)) - for _, x := range v.Children { - children = append(children, detailDTO(x)) - } - return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "label": v.Label, "value": v.Value, "extend": v.Extend, "status": v.Status, "sort": v.Sort, "sysDictionaryID": v.DictionaryID, "parentID": v.ParentID, "level": v.Level, "path": v.Path, "disabled": !v.Status, "children": children} -} func (s *SettingsService) Dictionaries(ctx context.Context, page, size int, name, typ string, details bool) ([]map[string]any, int64, error) { - items, total, err := s.Repo().ListDictionaries(ctx, page, size, name, typ, details) + items, total, err := s.uc.ListDictionaries(ctx, page, size, name, typ, details) if err != nil { return nil, 0, err } @@ -64,24 +42,52 @@ func (s *SettingsService) Dictionaries(ctx context.Context, page, size int, name } return out, total, nil } -func (s *SettingsService) Dictionary(ctx context.Context, id uint, typ string, details bool) (map[string]any, error) { - v, err := s.Repo().FindDictionary(ctx, id, typ, details) +func (s *SettingsService) Dictionary(ctx context.Context, id uint, typ string, status *bool, details bool) (map[string]any, error) { + v, err := s.uc.FindDictionary(ctx, id, typ, status, details) + if err != nil { + return nil, err + } + return dictionaryDTO(v), nil +} +func (s *SettingsService) ExportDictionary(ctx context.Context, id uint) (map[string]any, error) { + v, err := s.uc.ExportDictionary(ctx, id) if err != nil { return nil, err } return dictionaryDTO(v), nil } func (s *SettingsService) CreateDictionary(ctx context.Context, v *biz.Dictionary) error { - return s.Repo().CreateDictionary(ctx, v) + return s.uc.CreateDictionary(ctx, v) } func (s *SettingsService) UpdateDictionary(ctx context.Context, v *biz.Dictionary) error { - return s.Repo().UpdateDictionary(ctx, v) + return s.uc.UpdateDictionary(ctx, v) } func (s *SettingsService) DeleteDictionary(ctx context.Context, id uint) error { - return s.Repo().DeleteDictionary(ctx, id) + return s.uc.DeleteDictionary(ctx, id) } -func (s *SettingsService) DictionaryDetails(ctx context.Context, page, size int, dictionaryID uint, label, value string) ([]map[string]any, int64, error) { - items, total, err := s.Repo().ListDictionaryDetails(ctx, page, size, dictionaryID, label, value) + +func detailDomain(value *dto.DictionaryDetailRequest) *biz.DictionaryDetail { + status := true + if value.Status != nil { + status = *value.Status + } + return &biz.DictionaryDetail{ID: value.ID, Label: value.Label, Value: value.Value, Extend: value.Extend, Status: status, Sort: value.Sort, DictionaryID: value.DictionaryID, ParentID: value.ParentID, Level: value.Level, Path: value.Path} +} +func (s *SettingsService) CreateDictionaryDetailRequest(ctx context.Context, req *dto.DictionaryDetailRequest) error { + return s.CreateDictionaryDetail(ctx, detailDomain(req)) +} +func (s *SettingsService) UpdateDictionaryDetailRequest(ctx context.Context, req *dto.DictionaryDetailRequest) error { + return s.UpdateDictionaryDetail(ctx, detailDomain(req)) +} +func detailDTO(v *biz.DictionaryDetail) map[string]any { + children := make([]map[string]any, 0, len(v.Children)) + for _, x := range v.Children { + children = append(children, detailDTO(x)) + } + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "label": v.Label, "value": v.Value, "extend": v.Extend, "status": v.Status, "sort": v.Sort, "sysDictionaryID": v.DictionaryID, "parentID": v.ParentID, "level": v.Level, "path": v.Path, "disabled": !v.Status, "children": children} +} +func (s *SettingsService) DictionaryDetails(ctx context.Context, page, size int, filter biz.DictionaryDetailFilter) ([]map[string]any, int64, error) { + items, total, err := s.uc.ListDictionaryDetails(ctx, page, size, filter) if err != nil { return nil, 0, err } @@ -92,14 +98,14 @@ func (s *SettingsService) DictionaryDetails(ctx context.Context, page, size int, return out, total, nil } func (s *SettingsService) DictionaryDetail(ctx context.Context, id uint) (map[string]any, error) { - v, err := s.Repo().FindDictionaryDetail(ctx, id) + v, err := s.uc.FindDictionaryDetail(ctx, id) if err != nil { return nil, err } return detailDTO(v), nil } func (s *SettingsService) DictionaryTree(ctx context.Context, id uint, typ string) ([]map[string]any, error) { - items, err := s.Repo().DictionaryDetailTree(ctx, id, typ) + items, err := s.uc.DictionaryDetailTree(ctx, id, typ) if err != nil { return nil, err } @@ -110,73 +116,11 @@ func (s *SettingsService) DictionaryTree(ctx context.Context, id uint, typ strin return out, nil } func (s *SettingsService) CreateDictionaryDetail(ctx context.Context, v *biz.DictionaryDetail) error { - return s.Repo().CreateDictionaryDetail(ctx, v) + return s.uc.CreateDictionaryDetail(ctx, v) } func (s *SettingsService) UpdateDictionaryDetail(ctx context.Context, v *biz.DictionaryDetail) error { - return s.Repo().UpdateDictionaryDetail(ctx, v) + return s.uc.UpdateDictionaryDetail(ctx, v) } func (s *SettingsService) DeleteDictionaryDetail(ctx context.Context, id uint) error { - return s.Repo().DeleteDictionaryDetail(ctx, id) -} -func (s *SettingsService) ImportDictionaryJSON(ctx context.Context, raw string) error { - var payload struct { - Name string `json:"name"` - Type string `json:"type"` - Status *bool `json:"status"` - Description string `json:"desc"` - Details []dto.DictionaryDetailRequest `json:"sysDictionaryDetails"` - } - if json.Unmarshal([]byte(raw), &payload) != nil || payload.Name == "" || payload.Type == "" { - return errors.New("JSON 格式错误") - } - dictionary := dictionaryDomain(&dto.DictionaryRequest{Name: payload.Name, Type: payload.Type, Status: payload.Status, Description: payload.Description}) - if err := s.CreateDictionary(ctx, dictionary); err != nil { - return err - } - mapping := map[uint]uint{} - pending := append([]dto.DictionaryDetailRequest(nil), payload.Details...) - for len(pending) > 0 { - progress := false - next := pending[:0] - for _, item := range pending { - oldID := item.ID - if item.ParentID != nil && *item.ParentID != 0 { - parent, exists := mapping[*item.ParentID] - if !exists { - next = append(next, item) - continue - } - item.ParentID = &parent - } - value := detailDomain(&item) - value.ID = 0 - value.DictionaryID = dictionary.ID - if err := s.CreateDictionaryDetail(ctx, value); err != nil { - return errors.New("导入详情失败") - } - if oldID != 0 { - mapping[oldID] = value.ID - } - progress = true - } - if !progress { - return errors.New("字典层级关系无效") - } - pending = next - } - return nil -} -func (s *SettingsService) DictionaryDetailsByParent(ctx context.Context, dictionaryID, parentID uint) ([]map[string]any, error) { - items, _, err := s.DictionaryDetails(ctx, 1, 10000, dictionaryID, "", "") - if err != nil { - return nil, err - } - out := []map[string]any{} - for _, item := range items { - parent, _ := item["parentID"].(*uint) - if (parentID == 0 && parent == nil) || (parent != nil && *parent == parentID) { - out = append(out, item) - } - } - return out, nil + return s.uc.DeleteDictionaryDetail(ctx, id) } diff --git a/internal/service/dictionary_import.go b/internal/service/dictionary_import.go new file mode 100644 index 0000000..bac157d --- /dev/null +++ b/internal/service/dictionary_import.go @@ -0,0 +1,40 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + + "kra/internal/biz" + "kra/internal/service/dto" +) + +func (s *SettingsService) ImportDictionaryJSON(ctx context.Context, raw string) error { + var payload struct { + Name string `json:"name"` + Type string `json:"type"` + Status *bool `json:"status"` + Description string `json:"desc"` + Details []dto.DictionaryDetailRequest `json:"sysDictionaryDetails"` + } + if json.Unmarshal([]byte(raw), &payload) != nil || payload.Name == "" || payload.Type == "" { + return errors.New("JSON 格式错误") + } + dictionary := dictionaryDomain(&dto.DictionaryRequest{Name: payload.Name, Type: payload.Type, Status: payload.Status, Description: payload.Description}) + details := make([]*biz.DictionaryDetail, 0, len(payload.Details)) + for i := range payload.Details { + details = append(details, detailDomain(&payload.Details[i])) + } + return s.uc.ImportDictionary(ctx, dictionary, details) +} +func (s *SettingsService) DictionaryDetailsByParent(ctx context.Context, dictionaryID, parentID uint) ([]map[string]any, error) { + items, err := s.uc.DictionaryDetailsByParent(ctx, dictionaryID, parentID) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + out = append(out, detailDTO(item)) + } + return out, nil +} diff --git a/internal/service/dto/api.go b/internal/service/dto/api.go index 5d90f6e..29ae7ae 100644 --- a/internal/service/dto/api.go +++ b/internal/service/dto/api.go @@ -17,6 +17,8 @@ type APIListRequest struct { Description string `json:"description"` APIGroup string `json:"apiGroup"` Method string `json:"method"` + OrderKey string `json:"orderKey"` + Desc bool `json:"desc"` } type APIResponse struct { @@ -40,7 +42,8 @@ type GetAPIRequest struct { ID uint `json:"id"` } type SetAPIRolesRequest struct { - APIID uint `json:"apiId"` + Path string `json:"path"` + Method string `json:"method"` AuthorityIDs []uint `json:"authorityIds"` } type IgnoreAPIRequest struct { diff --git a/internal/service/dto/media.go b/internal/service/dto/media.go index 6ac3d91..7dc7f6f 100644 --- a/internal/service/dto/media.go +++ b/internal/service/dto/media.go @@ -1,12 +1,18 @@ package dto +import "time" + type MediaListRequest struct { - Page int `json:"page"` - PageSize int `json:"pageSize"` - Keyword string `json:"keyword"` - ClassID int `json:"classId"` - Tag string `json:"tag"` - UserID uint `json:"userId"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Keyword string `json:"keyword"` + ClassID int `json:"classId"` + Tag string `json:"tag"` + UserID uint `json:"userId"` + StartCreatedAt time.Time `json:"startCreatedAt"` + EndCreatedAt time.Time `json:"endCreatedAt"` + OrderKey string `json:"orderKey"` + Desc bool `json:"desc"` } type IDRequest struct { ID uint `json:"ID" form:"id"` @@ -27,6 +33,7 @@ type ImportMediaRequest struct { Size int64 `json:"size"` Mime string `json:"mime"` MD5 string `json:"md5"` + UserID uint `json:"userId"` } type StorageListRequest struct { Prefix string `json:"prefix"` diff --git a/internal/service/dto/organization.go b/internal/service/dto/organization.go index 742d6ec..0f1c009 100644 --- a/internal/service/dto/organization.go +++ b/internal/service/dto/organization.go @@ -57,6 +57,7 @@ type PositionListRequest struct { PageSize int `json:"pageSize"` Name string `json:"name"` Code string `json:"code"` + Status *bool `json:"status"` } type DeletePositionRequest struct { ID uint `json:"ID"` diff --git a/internal/service/dto/system.go b/internal/service/dto/system.go index a059903..a7bd8ab 100644 --- a/internal/service/dto/system.go +++ b/internal/service/dto/system.go @@ -41,6 +41,7 @@ type SelfUserRequest struct { HeaderImg string `json:"headerImg"` Phone string `json:"phone"` Email string `json:"email"` + Enable int `json:"enable"` } type ResetPasswordRequest struct { diff --git a/internal/service/dto/system_config.go b/internal/service/dto/system_config.go index 5cdffec..40c50c5 100644 --- a/internal/service/dto/system_config.go +++ b/internal/service/dto/system_config.go @@ -27,12 +27,45 @@ type SecurityConfigRequest struct { type SetSystemConfigRequest struct { Config struct { + Data *struct { + Database *struct { + Driver string `json:"driver"` + Source string `json:"source"` + Host string `json:"host"` + Port string `json:"port"` + User string `json:"user"` + Password string `json:"password"` + Name string `json:"name"` + Config string `json:"config"` + Path string `json:"path"` + } `json:"database"` + Redis *struct { + Network string `json:"network"` + Addr string `json:"addr"` + ReadTimeout string `json:"read_timeout"` + WriteTimeout string `json:"write_timeout"` + } `json:"redis"` + } `json:"data"` Admin struct { RouterPrefix string `json:"routerPrefix"` - JWT struct { - SigningKey string `json:"signingKey"` - Issuer string `json:"issuer"` + System struct { + UseRedis bool `json:"useRedis"` + UseMultipoint bool `json:"useMultipoint"` + UseStrictAuth bool `json:"useStrictAuth"` + DisableAutoMigrate bool `json:"disableAutoMigrate"` + } `json:"system"` + JWT struct { + SigningKey string `json:"signingKey"` + ExpiresTime string `json:"expiresTime"` + BufferTime string `json:"bufferTime"` + Issuer string `json:"issuer"` } `json:"jwt"` + Captcha struct { + KeyLong int32 `json:"keyLong"` + ImgWidth int32 `json:"imgWidth"` + ImgHeight int32 `json:"imgHeight"` + StoreExpiration string `json:"storeExpiration"` + } `json:"captcha"` Local struct { StorePath string `json:"storePath"` PathPrefix string `json:"pathPrefix"` diff --git a/internal/service/dto/version.go b/internal/service/dto/version.go index d5b6ffd..e5e6fb2 100644 --- a/internal/service/dto/version.go +++ b/internal/service/dto/version.go @@ -13,15 +13,21 @@ type VersionMenuButtonRequest struct { Name string `json:"name"` Description string `json:"desc"` } +type VersionMenuParameterRequest struct { + Type string `json:"type"` + Key string `json:"key"` + Value string `json:"value"` +} type VersionMenuRequest struct { - Path string `json:"path"` - Name string `json:"name"` - Hidden bool `json:"hidden"` - Component string `json:"component"` - Sort int `json:"sort"` - Meta VersionMenuMetaRequest `json:"meta"` - MenuButtons []VersionMenuButtonRequest `json:"menuBtn"` - Children []VersionMenuRequest `json:"children"` + Path string `json:"path"` + Name string `json:"name"` + Hidden bool `json:"hidden"` + Component string `json:"component"` + Sort int `json:"sort"` + Meta VersionMenuMetaRequest `json:"meta"` + MenuButtons []VersionMenuButtonRequest `json:"menuBtn"` + Parameters []VersionMenuParameterRequest `json:"parameters"` + Children []VersionMenuRequest `json:"children"` } type VersionAPIRequest struct { Path string `json:"path"` diff --git a/internal/service/export.go b/internal/service/export.go index 11ec55f..c0cdd04 100644 --- a/internal/service/export.go +++ b/internal/service/export.go @@ -2,14 +2,10 @@ package service import ( "context" - "encoding/json" - "errors" - "fmt" - "github.com/xuri/excelize/v2" - "io" + "time" + "kra/internal/biz" "kra/internal/service/dto" - "sort" ) type ExportService struct{ uc *biz.ExportUsecase } @@ -31,8 +27,8 @@ func (s *ExportService) CreateRequest(ctx context.Context, req *dto.ExportTempla func (s *ExportService) UpdateRequest(ctx context.Context, req *dto.ExportTemplateRequest) error { return s.Update(ctx, exportTemplateDomain(req)) } -func (s *ExportService) TemplatesFilter(ctx context.Context, page, size int, name, tableName, templateID string) ([]map[string]any, int64, error) { - return s.Templates(ctx, page, size, &biz.ExportTemplate{Name: name, TableName: tableName, TemplateID: templateID}) +func (s *ExportService) TemplatesFilter(ctx context.Context, page, size int, name, tableName, templateID string, start, end *time.Time) ([]map[string]any, int64, error) { + return s.Templates(ctx, page, size, &biz.ExportTemplate{Name: name, TableName: tableName, TemplateID: templateID, StartCreatedAt: start, EndCreatedAt: end}) } func exportDTO(v *biz.ExportTemplate) map[string]any { conditions := make([]map[string]any, 0, len(v.Conditions)) @@ -46,23 +42,23 @@ func exportDTO(v *biz.ExportTemplate) map[string]any { return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "dbName": v.DBName, "name": v.Name, "tableName": v.TableName, "templateID": v.TemplateID, "templateInfo": v.TemplateInfo, "sql": v.SQL, "importSql": v.ImportSQL, "limit": v.Limit, "order": v.Order, "conditions": conditions, "joinTemplate": joins} } func (s *ExportService) Create(ctx context.Context, v *biz.ExportTemplate) error { - return s.uc.Repo().CreateExportTemplate(ctx, v) + return s.uc.CreateExportTemplate(ctx, v) } func (s *ExportService) Update(ctx context.Context, v *biz.ExportTemplate) error { - return s.uc.Repo().UpdateExportTemplate(ctx, v) + return s.uc.UpdateExportTemplate(ctx, v) } func (s *ExportService) Delete(ctx context.Context, ids []uint) error { - return s.uc.Repo().DeleteExportTemplates(ctx, ids) + return s.uc.DeleteExportTemplates(ctx, ids) } func (s *ExportService) Template(ctx context.Context, id uint, tid string) (map[string]any, error) { - v, err := s.uc.Repo().FindExportTemplate(ctx, id, tid) + v, err := s.uc.FindExportTemplate(ctx, id, tid) if err != nil { return nil, err } return exportDTO(v), nil } func (s *ExportService) Templates(ctx context.Context, page, size int, q *biz.ExportTemplate) ([]map[string]any, int64, error) { - items, total, err := s.uc.Repo().ListExportTemplates(ctx, page, size, q) + items, total, err := s.uc.ListExportTemplates(ctx, page, size, q) if err != nil { return nil, 0, err } @@ -72,133 +68,3 @@ func (s *ExportService) Templates(ctx context.Context, page, size int, q *biz.Ex } return out, total, nil } -func templateColumns(raw string) ([]string, map[string]string, error) { - labels := map[string]string{} - if err := json.Unmarshal([]byte(raw), &labels); err != nil { - return nil, nil, err - } - keys := make([]string, 0, len(labels)) - for key := range labels { - keys = append(keys, key) - } - sort.Strings(keys) - return keys, labels, nil -} -func (s *ExportService) Export(ctx context.Context, tid string, params map[string]string) ([]byte, string, error) { - template, err := s.uc.Repo().FindExportTemplate(ctx, 0, tid) - if err != nil { - return nil, "", err - } - rows, _, err := s.uc.Repo().QueryExport(ctx, template, params) - if err != nil { - return nil, "", err - } - keys, labels, err := templateColumns(template.TemplateInfo) - if err != nil { - return nil, "", err - } - file := excelize.NewFile() - sheet := file.GetSheetName(0) - for column, key := range keys { - cell, _ := excelize.CoordinatesToCellName(column+1, 1) - _ = file.SetCellValue(sheet, cell, labels[key]) - } - for rowIndex, row := range rows { - for column, key := range keys { - cell, _ := excelize.CoordinatesToCellName(column+1, rowIndex+2) - _ = file.SetCellValue(sheet, cell, row[key]) - } - } - buffer, err := file.WriteToBuffer() - _ = file.Close() - if err != nil { - return nil, "", err - } - return buffer.Bytes(), template.Name + ".xlsx", nil -} -func (s *ExportService) ExportBlankTemplate(ctx context.Context, tid string) ([]byte, string, error) { - template, err := s.uc.Repo().FindExportTemplate(ctx, 0, tid) - if err != nil { - return nil, "", err - } - keys, labels, err := templateColumns(template.TemplateInfo) - if err != nil { - return nil, "", err - } - file := excelize.NewFile() - sheet := file.GetSheetName(0) - for column, key := range keys { - cell, _ := excelize.CoordinatesToCellName(column+1, 1) - _ = file.SetCellValue(sheet, cell, labels[key]) - } - buffer, err := file.WriteToBuffer() - _ = file.Close() - if err != nil { - return nil, "", err - } - return buffer.Bytes(), template.Name + "_template.xlsx", nil -} -func (s *ExportService) Preview(ctx context.Context, tid string, params map[string]string) (string, error) { - template, err := s.uc.Repo().FindExportTemplate(ctx, 0, tid) - if err != nil { - return "", err - } - _, sql, err := s.uc.Repo().QueryExport(ctx, template, params) - return sql, err -} -func (s *ExportService) Import(ctx context.Context, tid string, reader io.Reader) error { - template, err := s.uc.Repo().FindExportTemplate(ctx, 0, tid) - if err != nil { - return err - } - file, err := excelize.OpenReader(reader) - if err != nil { - return err - } - defer file.Close() - sheets := file.GetSheetList() - if len(sheets) == 0 { - return errors.New("工作簿没有工作表") - } - rows, err := file.GetRows(sheets[0]) - if err != nil { - return err - } - if len(rows) < 2 { - return nil - } - keys, labels, err := templateColumns(template.TemplateInfo) - if err != nil { - return err - } - reverse := map[string]string{} - for _, key := range keys { - reverse[labels[key]] = key - } - headers := rows[0] - items := make([]map[string]any, 0, len(rows)-1) - for rowIndex, row := range rows[1:] { - item := map[string]any{} - empty := true - for column, value := range row { - if column >= len(headers) { - continue - } - key := reverse[headers[column]] - if key != "" { - item[key] = value - if value != "" { - empty = false - } - } - } - if !empty { - items = append(items, item) - } - _ = rowIndex - } - if err = s.uc.Repo().ImportExportRows(ctx, template, items); err != nil { - return fmt.Errorf("导入失败: %w", err) - } - return nil -} diff --git a/internal/service/export_excel.go b/internal/service/export_excel.go new file mode 100644 index 0000000..e7bbc31 --- /dev/null +++ b/internal/service/export_excel.go @@ -0,0 +1,149 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/xuri/excelize/v2" +) + +func templateColumns(raw string) ([]string, map[string]string, error) { + labels := map[string]string{} + decoder := json.NewDecoder(strings.NewReader(raw)) + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return nil, nil, err + } + keys := make([]string, 0) + for decoder.More() { + keyToken, tokenErr := decoder.Token() + if tokenErr != nil { + return nil, nil, tokenErr + } + key := keyToken.(string) + var label string + if tokenErr = decoder.Decode(&label); tokenErr != nil { + return nil, nil, tokenErr + } + keys = append(keys, key) + labels[key] = label + } + if _, err = decoder.Token(); err != nil { + return nil, nil, err + } + return keys, labels, nil +} +func (s *ExportService) Export(ctx context.Context, tid string, params map[string]string) ([]byte, string, error) { + template, err := s.uc.FindExportTemplate(ctx, 0, tid) + if err != nil { + return nil, "", err + } + rows, _, err := s.uc.QueryExport(ctx, template, params) + if err != nil { + return nil, "", err + } + keys, labels, err := templateColumns(template.TemplateInfo) + if err != nil { + return nil, "", err + } + file := excelize.NewFile() + sheet := file.GetSheetName(0) + for column, key := range keys { + cell, _ := excelize.CoordinatesToCellName(column+1, 1) + _ = file.SetCellValue(sheet, cell, labels[key]) + } + for rowIndex, row := range rows { + for column, key := range keys { + cell, _ := excelize.CoordinatesToCellName(column+1, rowIndex+2) + _ = file.SetCellValue(sheet, cell, row[key]) + } + } + buffer, err := file.WriteToBuffer() + _ = file.Close() + if err != nil { + return nil, "", err + } + return buffer.Bytes(), template.Name + ".xlsx", nil +} +func (s *ExportService) ExportBlankTemplate(ctx context.Context, tid string) ([]byte, string, error) { + template, err := s.uc.FindExportTemplate(ctx, 0, tid) + if err != nil { + return nil, "", err + } + keys, labels, err := templateColumns(template.TemplateInfo) + if err != nil { + return nil, "", err + } + file := excelize.NewFile() + sheet := file.GetSheetName(0) + for column, key := range keys { + cell, _ := excelize.CoordinatesToCellName(column+1, 1) + _ = file.SetCellValue(sheet, cell, labels[key]) + } + buffer, err := file.WriteToBuffer() + _ = file.Close() + if err != nil { + return nil, "", err + } + return buffer.Bytes(), template.Name + "_template.xlsx", nil +} +func (s *ExportService) Preview(ctx context.Context, tid string, params map[string]string) (string, error) { + template, err := s.uc.FindExportTemplate(ctx, 0, tid) + if err != nil { + return "", err + } + return s.uc.PreviewExport(ctx, template, params) +} +func (s *ExportService) Import(ctx context.Context, tid string, reader io.Reader) error { + template, err := s.uc.FindExportTemplate(ctx, 0, tid) + if err != nil { + return err + } + file, err := excelize.OpenReader(reader) + if err != nil { + return err + } + defer file.Close() + sheets := file.GetSheetList() + if len(sheets) == 0 { + return errors.New("工作簿没有工作表") + } + rows, err := file.GetRows(sheets[0]) + if err != nil { + return err + } + if len(rows) < 2 { + return errors.New("Excel data is not enough.\nIt should contain title row and data") + } + keys, labels, err := templateColumns(template.TemplateInfo) + if err != nil { + return err + } + reverse := map[string]string{} + for _, key := range keys { + reverse[labels[key]] = key + } + headers := rows[0] + items := make([]map[string]any, 0, len(rows)-1) + for _, row := range rows[1:] { + item := map[string]any{} + for column, value := range row { + if column >= len(headers) { + continue + } + key := reverse[headers[column]] + if key != "" { + item[key] = value + } + } + items = append(items, item) + } + if err = s.uc.ImportExportRows(ctx, template, items); err != nil { + return fmt.Errorf("导入失败: %w", err) + } + return nil +} diff --git a/internal/service/media.go b/internal/service/media.go index 8bc8186..0947441 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -29,14 +29,14 @@ func (s *MediaService) Upload(ctx context.Context, userID uint, name, mime strin return mediaDTO(v), nil } func (s *MediaService) Media(ctx context.Context, id uint) (map[string]any, error) { - v, err := s.uc.Repo().FindMedia(ctx, id) + v, err := s.uc.FindMedia(ctx, id) if err != nil { return nil, err } return mediaDTO(v), nil } -func (s *MediaService) MediaList(ctx context.Context, page, size int, keyword string, categoryID int, tag string, userID uint) ([]map[string]any, int64, error) { - items, total, err := s.uc.Repo().ListMedia(ctx, page, size, keyword, categoryID, tag, userID) +func (s *MediaService) MediaList(ctx context.Context, filter biz.MediaFilter) ([]map[string]any, int64, error) { + items, total, err := s.uc.ListMedia(ctx, filter) if err != nil { return nil, 0, err } @@ -48,15 +48,15 @@ func (s *MediaService) MediaList(ctx context.Context, page, size int, keyword st } func (s *MediaService) Delete(ctx context.Context, id uint) error { return s.uc.Delete(ctx, id) } func (s *MediaService) Rename(ctx context.Context, id uint, name string) error { - return s.uc.Repo().UpdateMediaName(ctx, id, name) + return s.uc.UpdateMediaName(ctx, id, name) } func (s *MediaService) ImportURLs(ctx context.Context, items []*biz.MediaFile) error { - return s.uc.Repo().CreateMediaBatch(ctx, items) + return s.uc.CreateMediaBatch(ctx, items) } -func (s *MediaService) ImportURLRequests(ctx context.Context, userID uint, values []dto.ImportMediaRequest) error { +func (s *MediaService) ImportURLRequests(ctx context.Context, values []dto.ImportMediaRequest) error { items := make([]*biz.MediaFile, 0, len(values)) for _, value := range values { - items = append(items, &biz.MediaFile{Name: value.Name, CategoryID: value.ClassID, URL: value.URL, Tag: value.Tag, Key: value.Key, Size: value.Size, Mime: value.Mime, MD5: value.MD5, UserID: userID}) + items = append(items, &biz.MediaFile{Name: value.Name, CategoryID: value.ClassID, URL: value.URL, Tag: value.Tag, Key: value.Key, Size: value.Size, Mime: value.Mime, MD5: value.MD5, UserID: value.UserID}) } return s.ImportURLs(ctx, items) } @@ -72,7 +72,7 @@ func (s *MediaService) Storage(ctx context.Context, prefix, cursor string, limit return out, next, more, nil } func (s *MediaService) Categories(ctx context.Context) ([]map[string]any, error) { - items, err := s.uc.Repo().ListCategories(ctx) + items, err := s.uc.ListCategories(ctx) if err != nil { return nil, err } @@ -83,11 +83,11 @@ func (s *MediaService) Categories(ctx context.Context) ([]map[string]any, error) return out, nil } func (s *MediaService) SaveCategory(ctx context.Context, v *biz.AttachmentCategory) error { - return s.uc.Repo().SaveCategory(ctx, v) + return s.uc.SaveCategory(ctx, v) } func (s *MediaService) SaveCategoryRequest(ctx context.Context, value *dto.CategoryRequest) error { return s.SaveCategory(ctx, &biz.AttachmentCategory{ID: value.ID, Name: value.Name, ParentID: value.PID}) } func (s *MediaService) DeleteCategory(ctx context.Context, id uint) error { - return s.uc.Repo().DeleteCategory(ctx, id) + return s.uc.DeleteCategory(ctx, id) } diff --git a/internal/service/menu.go b/internal/service/menu.go index 6a9d922..1fdfa6b 100644 --- a/internal/service/menu.go +++ b/internal/service/menu.go @@ -11,47 +11,6 @@ type MenuService struct{ uc *biz.MenuUsecase } func NewMenuService(uc *biz.MenuUsecase) *MenuService { return &MenuService{uc: uc} } -func menuDomain(v *dto.MenuRequest) *biz.Menu { - menu := &biz.Menu{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType} - menu.Buttons = make([]*biz.MenuButton, 0, len(v.MenuButtons)) - for _, button := range v.MenuButtons { - menu.Buttons = append(menu.Buttons, &biz.MenuButton{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: v.ID}) - } - menu.Parameters = make([]*biz.MenuParameter, 0, len(v.Parameters)) - for _, parameter := range v.Parameters { - menu.Parameters = append(menu.Parameters, &biz.MenuParameter{ID: parameter.ID, MenuID: v.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) - } - return menu -} - -func menuResponse(menu *biz.Menu) *dto.MenuResponse { - children := make([]*dto.MenuResponse, 0, len(menu.Children)) - for _, child := range menu.Children { - children = append(children, menuResponse(child)) - } - buttons := make([]dto.MenuButtonResponse, 0, len(menu.Buttons)) - buttonAuthorities := make(map[string]uint, len(menu.Buttons)) - for _, button := range menu.Buttons { - buttons = append(buttons, dto.MenuButtonResponse{ID: button.ID, Name: button.Name, Description: button.Description, SysBaseMenuID: button.MenuID}) - if button.AuthorityID != 0 { - buttonAuthorities[button.Name] = button.AuthorityID - } - } - parameters := make([]dto.MenuParameterResponse, 0, len(menu.Parameters)) - for _, parameter := range menu.Parameters { - parameters = append(parameters, dto.MenuParameterResponse{ID: parameter.ID, SysBaseMenuID: parameter.MenuID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) - } - return &dto.MenuResponse{ID: menu.ID, ParentID: menu.ParentID, Path: menu.Path, Name: menu.Name, Hidden: menu.Hidden, Component: menu.Component, Sort: menu.Sort, Meta: dto.MenuMetaResponse{ActiveName: menu.ActiveName, KeepAlive: menu.KeepAlive, DefaultMenu: menu.DefaultMenu, Title: menu.Title, Icon: menu.Icon, CloseTab: menu.CloseTab, TransitionType: menu.TransitionType}, Children: children, Parameters: parameters, MenuButtons: buttons, Buttons: buttonAuthorities} -} - -func menuResponses(items []*biz.Menu) []*dto.MenuResponse { - out := make([]*dto.MenuResponse, 0, len(items)) - for _, item := range items { - out = append(out, menuResponse(item)) - } - return out -} - func (s *MenuService) List(ctx context.Context) ([]*dto.MenuResponse, error) { items, err := s.uc.List(ctx) if err != nil { @@ -107,6 +66,50 @@ func (s *MenuService) AuthorityMenus(ctx context.Context, authorityID uint) ([]* func (s *MenuService) RoleIDs(ctx context.Context, menuID uint) ([]uint, error) { return s.uc.RoleIDs(ctx, menuID) } +func (s *MenuService) DefaultRouterRoleIDs(ctx context.Context, menuID uint) ([]uint, error) { + return s.uc.DefaultRouterRoleIDs(ctx, menuID) +} func (s *MenuService) SetRoles(ctx context.Context, req *dto.SetMenuRolesRequest) error { return s.uc.SetRoles(ctx, req.MenuID, req.AuthorityIDs) } + +func menuDomain(v *dto.MenuRequest) *biz.Menu { + menu := &biz.Menu{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType} + menu.Buttons = make([]*biz.MenuButton, 0, len(v.MenuButtons)) + for _, button := range v.MenuButtons { + menu.Buttons = append(menu.Buttons, &biz.MenuButton{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: v.ID}) + } + menu.Parameters = make([]*biz.MenuParameter, 0, len(v.Parameters)) + for _, parameter := range v.Parameters { + menu.Parameters = append(menu.Parameters, &biz.MenuParameter{ID: parameter.ID, MenuID: v.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + } + return menu +} + +func menuResponse(menu *biz.Menu) *dto.MenuResponse { + children := make([]*dto.MenuResponse, 0, len(menu.Children)) + for _, child := range menu.Children { + children = append(children, menuResponse(child)) + } + buttons := make([]dto.MenuButtonResponse, 0, len(menu.Buttons)) + buttonAuthorities := make(map[string]uint, len(menu.Buttons)) + for _, button := range menu.Buttons { + buttons = append(buttons, dto.MenuButtonResponse{ID: button.ID, Name: button.Name, Description: button.Description, SysBaseMenuID: button.MenuID}) + if button.AuthorityID != 0 { + buttonAuthorities[button.Name] = button.AuthorityID + } + } + parameters := make([]dto.MenuParameterResponse, 0, len(menu.Parameters)) + for _, parameter := range menu.Parameters { + parameters = append(parameters, dto.MenuParameterResponse{ID: parameter.ID, SysBaseMenuID: parameter.MenuID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + } + return &dto.MenuResponse{ID: menu.ID, ParentID: menu.ParentID, Path: menu.Path, Name: menu.Name, Hidden: menu.Hidden, Component: menu.Component, Sort: menu.Sort, Meta: dto.MenuMetaResponse{ActiveName: menu.ActiveName, KeepAlive: menu.KeepAlive, DefaultMenu: menu.DefaultMenu, Title: menu.Title, Icon: menu.Icon, CloseTab: menu.CloseTab, TransitionType: menu.TransitionType}, Children: children, Parameters: parameters, MenuButtons: buttons, Buttons: buttonAuthorities} +} + +func menuResponses(items []*biz.Menu) []*dto.MenuResponse { + out := make([]*dto.MenuResponse, 0, len(items)) + for _, item := range items { + out = append(out, menuResponse(item)) + } + return out +} diff --git a/internal/service/organization.go b/internal/service/organization.go index f8bd1c0..7792d2a 100644 --- a/internal/service/organization.go +++ b/internal/service/organization.go @@ -18,7 +18,11 @@ func departmentResponse(value *biz.Department) *dto.DepartmentResponse { for _, child := range value.Children { children = append(children, departmentResponse(child)) } - return &dto.DepartmentResponse{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, DeletedAt: nil, Name: value.Name, ParentID: value.ParentID, Ancestors: value.Ancestors, Sort: value.Sort, LeaderID: value.LeaderID, Leader: nil, Status: value.Status, Children: children, NamePath: ""} + var leader any + if value.Leader != nil { + leader = convertUser(value.Leader) + } + return &dto.DepartmentResponse{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, DeletedAt: nil, Name: value.Name, ParentID: value.ParentID, Ancestors: value.Ancestors, Sort: value.Sort, LeaderID: value.LeaderID, Leader: leader, Status: value.Status, Children: children, NamePath: value.NamePath} } func departmentDomain(value *dto.DepartmentRequest) *biz.Department { @@ -69,7 +73,7 @@ func positionResponse(value *biz.Position) *dto.PositionResponse { return &dto.PositionResponse{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, DeletedAt: nil, Name: value.Name, Code: value.Code, Sort: value.Sort, Status: value.Status, Remark: value.Remark} } func (s *OrganizationService) Positions(ctx context.Context, req *dto.PositionListRequest) ([]*dto.PositionResponse, int64, error) { - items, total, err := s.uc.Positions(ctx, req.Page, req.PageSize, &biz.Position{Name: req.Name, Code: req.Code}) + items, total, err := s.uc.Positions(ctx, req.Page, req.PageSize, &biz.PositionListFilter{Name: req.Name, Code: req.Code, Status: req.Status}) if err != nil { return nil, 0, err } diff --git a/internal/service/parameter.go b/internal/service/parameter.go index b25f094..b184067 100644 --- a/internal/service/parameter.go +++ b/internal/service/parameter.go @@ -2,6 +2,7 @@ package service import ( "context" + "time" "kra/internal/biz" "kra/internal/service/dto" @@ -16,14 +17,14 @@ func (s *SettingsService) CreateParameterRequest(ctx context.Context, req *dto.S func (s *SettingsService) UpdateParameterRequest(ctx context.Context, req *dto.SystemParameterRequest) error { return s.UpdateParameter(ctx, parameterDomain(req)) } -func (s *SettingsService) ParametersFilter(ctx context.Context, page, size int, name, key string) ([]map[string]any, int64, error) { - return s.Parameters(ctx, page, size, &biz.SystemParameter{Name: name, Key: key}) +func (s *SettingsService) ParametersFilter(ctx context.Context, page, size int, name, key string, start, end *time.Time) ([]map[string]any, int64, error) { + return s.Parameters(ctx, page, size, &biz.SystemParameter{Name: name, Key: key, StartCreatedAt: start, EndCreatedAt: end}) } func parameterDTO(v *biz.SystemParameter) map[string]any { return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "key": v.Key, "value": v.Value, "desc": v.Desc} } func (s *SettingsService) Parameters(ctx context.Context, page, size int, q *biz.SystemParameter) ([]map[string]any, int64, error) { - items, total, err := s.Repo().ListParameters(ctx, page, size, q) + items, total, err := s.uc.ListParameters(ctx, page, size, q) if err != nil { return nil, 0, err } @@ -34,18 +35,18 @@ func (s *SettingsService) Parameters(ctx context.Context, page, size int, q *biz return out, total, nil } func (s *SettingsService) Parameter(ctx context.Context, id uint, key string) (map[string]any, error) { - v, err := s.Repo().FindParameter(ctx, id, key) + v, err := s.uc.FindParameter(ctx, id, key) if err != nil { return nil, err } return parameterDTO(v), nil } func (s *SettingsService) CreateParameter(ctx context.Context, v *biz.SystemParameter) error { - return s.Repo().CreateParameter(ctx, v) + return s.uc.CreateParameter(ctx, v) } func (s *SettingsService) UpdateParameter(ctx context.Context, v *biz.SystemParameter) error { - return s.Repo().UpdateParameter(ctx, v) + return s.uc.UpdateParameter(ctx, v) } func (s *SettingsService) DeleteParameters(ctx context.Context, ids []uint) error { - return s.Repo().DeleteParameters(ctx, ids) + return s.uc.DeleteParameters(ctx, ids) } diff --git a/internal/service/security.go b/internal/service/security.go index 7986124..058e05d 100644 --- a/internal/service/security.go +++ b/internal/service/security.go @@ -2,7 +2,6 @@ package service import ( "context" - "errors" "kra/internal/biz" "kra/internal/service/dto" @@ -19,7 +18,7 @@ func (s *SettingsService) CurrentSecurity(ctx context.Context) (*biz.SecurityCon return ©, nil } s.securityMu.RUnlock() - value, err := s.Repo().SecurityConfig(ctx) + value, err := s.uc.SecurityConfig(ctx) if err != nil { return nil, err } @@ -37,10 +36,7 @@ func (s *SettingsService) Security(ctx context.Context) (map[string]any, error) return securityDTO(value), nil } func (s *SettingsService) SaveSecurity(ctx context.Context, value *biz.SecurityConfig) (map[string]any, error) { - if value.KeyLong < 1 || value.ImgWidth < 1 || value.ImgHeight < 1 || value.PwdMinLength < 1 || value.LimitWindow < 1 || value.LimitCount < 1 || value.LockThreshold < 1 || value.LockDuration < 1 || value.PwdExpireDays < 1 { - return nil, errors.New("安全配置数值必须大于0") - } - if err := s.Repo().SaveSecurityConfig(ctx, value); err != nil { + if err := s.uc.UpdateSecurity(ctx, value); err != nil { return nil, err } s.securityMu.Lock() @@ -66,33 +62,5 @@ func (s *SettingsService) ValidatePassword(ctx context.Context, password string) if err != nil { return err } - if len([]rune(password)) < cfg.PwdMinLength { - return errors.New("密码长度不足") - } - hasUpper, hasLower, hasDigit, hasSpecial := false, false, false, false - for _, ch := range password { - switch { - case ch >= 'A' && ch <= 'Z': - hasUpper = true - case ch >= 'a' && ch <= 'z': - hasLower = true - case ch >= '0' && ch <= '9': - hasDigit = true - default: - hasSpecial = true - } - } - if cfg.PwdRequireUpper && !hasUpper { - return errors.New("密码必须包含大写字母") - } - if cfg.PwdRequireLower && !hasLower { - return errors.New("密码必须包含小写字母") - } - if cfg.PwdRequireDigit && !hasDigit { - return errors.New("密码必须包含数字") - } - if cfg.PwdRequireSpecial && !hasSpecial { - return errors.New("密码必须包含特殊字符") - } - return nil + return s.uc.ValidatePassword(cfg, password) } diff --git a/internal/service/settings.go b/internal/service/settings.go index baefc3e..708c1e6 100644 --- a/internal/service/settings.go +++ b/internal/service/settings.go @@ -1,7 +1,9 @@ package service import ( + "context" "sync" + "time" "kra/internal/biz" "kra/internal/conf" @@ -9,12 +11,39 @@ import ( type SettingsService struct { uc *biz.SettingsUsecase - config *conf.AdminBackend + runtime *conf.Runtime securityMu sync.RWMutex securityCache *biz.SecurityConfig + cache biz.Cache } -func NewSettingsService(uc *biz.SettingsUsecase, config *conf.AdminBackend) *SettingsService { - return &SettingsService{uc: uc, config: config} +func NewSettingsService(uc *biz.SettingsUsecase, runtime *conf.Runtime, cache biz.Cache) *SettingsService { + return &SettingsService{uc: uc, runtime: runtime, cache: cache} +} + +func (s *SettingsService) UseMultipoint() bool { + config := s.runtime.Admin() + return config != nil && config.System != nil && config.System.UseMultipoint +} + +func activeTokenKey(username string) string { return "jwt:active:" + username } + +func (s *SettingsService) ActiveTokenMatches(ctx context.Context, username, token string) (bool, error) { + if !s.UseMultipoint() { + return true, nil + } + active, ok, err := s.cache.Get(ctx, activeTokenKey(username)) + return ok && active == token, err +} + +func (s *SettingsService) RotateActiveToken(ctx context.Context, username, oldToken, newToken string, expiration time.Duration) error { + if !s.UseMultipoint() { + return nil + } + if oldToken != "" && oldToken != newToken { + if err := s.BlacklistToken(ctx, oldToken); err != nil { + return err + } + } + return s.cache.Set(ctx, activeTokenKey(username), newToken, expiration) } -func (s *SettingsService) Repo() biz.SettingsRepo { return s.uc.Repo() } diff --git a/internal/service/system.go b/internal/service/system.go index 5094931..ba0f352 100644 --- a/internal/service/system.go +++ b/internal/service/system.go @@ -9,12 +9,12 @@ import ( type SystemService struct { uc *biz.SystemUsecase - config *conf.AdminBackend + runtime *conf.Runtime settings *SettingsService } -func NewSystemService(uc *biz.SystemUsecase, config *conf.AdminBackend, settings *SettingsService) *SystemService { - return &SystemService{uc: uc, config: config, settings: settings} +func NewSystemService(uc *biz.SystemUsecase, runtime *conf.Runtime, settings *SettingsService) *SystemService { + return &SystemService{uc: uc, runtime: runtime, settings: settings} } func (s *SystemService) IsInitialized(ctx context.Context) (bool, error) { diff --git a/internal/service/system_config.go b/internal/service/system_config.go index eae0cb9..e288e43 100644 --- a/internal/service/system_config.go +++ b/internal/service/system_config.go @@ -2,12 +2,15 @@ package service import ( "context" + "encoding/json" + "time" "kra/internal/conf" "kra/internal/service/dto" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" ) func (s *SystemService) PersistConfig(ctx context.Context) error { return s.uc.PersistConfig(ctx) } @@ -22,42 +25,84 @@ func (s *SystemService) PersistAdminConfig(ctx context.Context, value *conf.Admi func (s *SystemService) ReloadConfig(ctx context.Context) error { return s.uc.ReloadConfig(ctx) } +func (s *SystemService) DiskMountPoints() []string { + config := s.runtime.Admin() + if config == nil { + return nil + } + points := make([]string, 0, len(config.DiskList)) + for _, item := range config.DiskList { + if item != nil && item.MountPoint != "" { + points = append(points, item.MountPoint) + } + } + return points +} + func (s *SystemService) SystemConfig() map[string]any { admin := map[string]any{"routerPrefix": ""} email := map[string]any{} - if s.config == nil { - return map[string]any{"config": map[string]any{"admin": admin, "email": email}} + config := s.runtime.Admin() + if config == nil { + return map[string]any{"config": map[string]any{"admin": admin, "email": email, "data": map[string]any{}}} } - admin["routerPrefix"] = s.config.RouterPrefix - if s.config.Jwt != nil { - admin["jwt"] = map[string]any{"signingKey": "******", "expiresTime": s.config.Jwt.ExpiresTime.AsDuration().String(), "bufferTime": s.config.Jwt.BufferTime.AsDuration().String(), "issuer": s.config.Jwt.Issuer} + admin["routerPrefix"] = config.RouterPrefix + if config.System != nil { + admin["system"] = map[string]any{"useRedis": config.System.UseRedis, "useMultipoint": config.System.UseMultipoint, "useStrictAuth": config.System.UseStrictAuth, "disableAutoMigrate": config.System.DisableAutoMigrate} } - if s.config.Captcha != nil { - admin["captcha"] = map[string]any{"keyLong": s.config.Captcha.KeyLong, "imgWidth": s.config.Captcha.ImgWidth, "imgHeight": s.config.Captcha.ImgHeight, "storeExpiration": s.config.Captcha.StoreExpiration.AsDuration().String()} + if config.Jwt != nil { + admin["jwt"] = map[string]any{"signingKey": "******", "expiresTime": config.Jwt.ExpiresTime.AsDuration().String(), "bufferTime": config.Jwt.BufferTime.AsDuration().String(), "issuer": config.Jwt.Issuer} } - if s.config.Local != nil { - admin["local"] = map[string]any{"storePath": s.config.Local.StorePath, "pathPrefix": s.config.Local.PathPrefix} + if config.Captcha != nil { + admin["captcha"] = map[string]any{"keyLong": config.Captcha.KeyLong, "imgWidth": config.Captcha.ImgWidth, "imgHeight": config.Captcha.ImgHeight, "storeExpiration": config.Captcha.StoreExpiration.AsDuration().String()} } - if s.config.Media != nil { - admin["media"] = map[string]any{"sessionTtl": s.config.Media.SessionTtl} + if config.Local != nil { + admin["local"] = map[string]any{"storePath": config.Local.StorePath, "pathPrefix": config.Local.PathPrefix} } - if s.config.Email != nil { - email = map[string]any{"to": s.config.Email.To, "from": s.config.Email.From, "host": s.config.Email.Host, "secret": "******", "nickname": s.config.Email.Nickname, "port": s.config.Email.Port, "is-ssl": s.config.Email.IsSsl, "is-loginauth": s.config.Email.IsLoginAuth} + if config.Media != nil { + admin["media"] = map[string]any{"sessionTtl": config.Media.SessionTtl} } - if s.config.Storage != nil { - storage := proto.Clone(s.config.Storage).(*conf.AdminBackend_Storage) + if config.Email != nil { + email = map[string]any{"to": config.Email.To, "from": config.Email.From, "host": config.Email.Host, "secret": "******", "nickname": config.Email.Nickname, "port": config.Email.Port, "is-ssl": config.Email.IsSsl, "is-loginauth": config.Email.IsLoginAuth} + } + if config.Storage != nil { + storage := proto.Clone(config.Storage).(*conf.AdminBackend_Storage) maskStorageSecrets(storage) admin["storage"] = storage } - return map[string]any{"config": map[string]any{"admin": admin, "email": email}} + data := s.runtime.Data() + if data != nil { + if data.Database != nil { + data.Database.Password = "******" + } + for _, database := range data.DatabaseList { + if database != nil { + database.Password = "******" + } + } + } + dataMap := map[string]any{} + if raw, err := (protojson.MarshalOptions{UseProtoNames: true}).Marshal(data); err == nil { + _ = json.Unmarshal(raw, &dataMap) + } + return map[string]any{"config": map[string]any{"admin": admin, "email": email, "data": dataMap}} } func (s *SystemService) SaveSystemConfig(ctx context.Context, req *dto.SetSystemConfigRequest) error { - if s.config == nil { + config := s.runtime.Admin() + if config == nil { return nil } - next := proto.Clone(s.config).(*conf.AdminBackend) + next := proto.Clone(config).(*conf.AdminBackend) + data := s.runtime.Data() next.RouterPrefix = req.Config.Admin.RouterPrefix + if next.System == nil { + next.System = &conf.AdminBackend_System{} + } + next.System.UseRedis = req.Config.Admin.System.UseRedis + next.System.UseMultipoint = req.Config.Admin.System.UseMultipoint + next.System.UseStrictAuth = req.Config.Admin.System.UseStrictAuth + next.System.DisableAutoMigrate = req.Config.Admin.System.DisableAutoMigrate if next.Jwt != nil { if req.Config.Admin.JWT.SigningKey != "" && req.Config.Admin.JWT.SigningKey != "******" { next.Jwt.SigningKey = req.Config.Admin.JWT.SigningKey @@ -65,6 +110,26 @@ func (s *SystemService) SaveSystemConfig(ctx context.Context, req *dto.SetSystem if req.Config.Admin.JWT.Issuer != "" { next.Jwt.Issuer = req.Config.Admin.JWT.Issuer } + if value, err := time.ParseDuration(req.Config.Admin.JWT.ExpiresTime); err == nil && value > 0 { + next.Jwt.ExpiresTime = durationpb.New(value) + } + if value, err := time.ParseDuration(req.Config.Admin.JWT.BufferTime); err == nil && value >= 0 { + next.Jwt.BufferTime = durationpb.New(value) + } + } + if next.Captcha != nil { + if req.Config.Admin.Captcha.KeyLong > 0 { + next.Captcha.KeyLong = req.Config.Admin.Captcha.KeyLong + } + if req.Config.Admin.Captcha.ImgWidth > 0 { + next.Captcha.ImgWidth = req.Config.Admin.Captcha.ImgWidth + } + if req.Config.Admin.Captcha.ImgHeight > 0 { + next.Captcha.ImgHeight = req.Config.Admin.Captcha.ImgHeight + } + if value, err := time.ParseDuration(req.Config.Admin.Captcha.StoreExpiration); err == nil && value > 0 { + next.Captcha.StoreExpiration = durationpb.New(value) + } } if next.Local != nil { if req.Config.Admin.Local.StorePath != "" { @@ -89,7 +154,37 @@ func (s *SystemService) SaveSystemConfig(ctx context.Context, req *dto.SetSystem next.Email.Secret = req.Config.Email.Secret } } - return s.PersistAdminConfig(ctx, next) + if req.Config.Data != nil { + if data == nil { + data = &conf.Data{} + } + if value := req.Config.Data.Database; value != nil { + password := value.Password + if data.Database != nil && (password == "" || password == "******") { + password = data.Database.Password + } + data.Database = &conf.Data_Database{Driver: value.Driver, Source: value.Source, Host: value.Host, Port: value.Port, User: value.User, Password: password, Name: value.Name, Config: value.Config, Path: value.Path} + } + if value := req.Config.Data.Redis; value != nil { + redis := &conf.Data_Redis{Network: value.Network, Addr: value.Addr} + if duration, parseErr := time.ParseDuration(value.ReadTimeout); parseErr == nil && duration >= 0 { + redis.ReadTimeout = durationpb.New(duration) + } + if duration, parseErr := time.ParseDuration(value.WriteTimeout); parseErr == nil && duration >= 0 { + redis.WriteTimeout = durationpb.New(duration) + } + data.Redis = redis + } + } + dataRaw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(data) + if err != nil { + return err + } + adminRaw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(next) + if err != nil { + return err + } + return s.uc.PersistRuntimeConfig(ctx, dataRaw, adminRaw) } func objectStores(storage *conf.AdminBackend_Storage) []*conf.AdminBackend_ObjectStore { diff --git a/internal/service/task.go b/internal/service/task.go index 70e6824..a231ca5 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -1,31 +1,23 @@ package service import ( - "bytes" "context" "encoding/json" - "errors" - "fmt" - "github.com/robfig/cron/v3" - "io" + "time" + "kra/internal/biz" "kra/internal/conf" "kra/internal/service/dto" - "net" - "net/http" - "net/url" - "strings" - "time" ) type TaskService struct { - uc *biz.TaskUsecase - media *biz.MediaUsecase - config *conf.AdminBackend + uc *biz.TaskUsecase + media *biz.MediaUsecase + runtime *conf.Runtime } -func NewTaskService(uc *biz.TaskUsecase, media *biz.MediaUsecase, config *conf.AdminBackend) *TaskService { - return &TaskService{uc: uc, media: media, config: config} +func NewTaskService(uc *biz.TaskUsecase, media *biz.MediaUsecase, runtime *conf.Runtime) *TaskService { + return &TaskService{uc: uc, media: media, runtime: runtime} } func taskDomain(v *dto.TaskRequest) *biz.TimedTask { return &biz.TimedTask{ID: v.ID, Name: v.Name, Description: v.Description, Spec: v.Spec, WithSeconds: v.WithSeconds, ExecutorType: v.ExecutorType, MethodName: v.MethodName, Params: v.Params, HTTPURL: v.HTTPURL, HTTPMethod: v.HTTPMethod, HTTPHeader: v.HTTPHeader, HTTPBody: v.HTTPBody, HTTPAllowPrivate: v.HTTPAllowPrivate, Enabled: v.Enabled} @@ -43,86 +35,26 @@ func (s *TaskService) UpdateRequest(ctx context.Context, req *dto.TaskRequest) e func (s *TaskService) ListRequest(ctx context.Context, page, size int, name, executorType string, enabled *bool, next map[uint]time.Time) ([]map[string]any, int64, error) { return s.Tasks(ctx, page, size, &biz.TimedTask{Name: name, ExecutorType: executorType, EnabledFilter: enabled}, next) } -func (s *TaskService) Validate(v *biz.TimedTask) error { - if v.Name == "" { - return errors.New("任务名不能为空") - } - var err error - if v.WithSeconds { - _, err = cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor).Parse(v.Spec) - } else { - _, err = cron.ParseStandard(v.Spec) - } - if err != nil { - return fmt.Errorf("cron 表达式非法: %w", err) - } - switch v.ExecutorType { - case "method": - if v.MethodName != "ClearDB" && v.MethodName != "CleanStaleUploads" { - return errors.New("方法未注册") - } - if len(v.Params) > 0 && !json.Valid(v.Params) { - return errors.New("params 必须是合法 JSON") - } - case "http": - parsed, parseErr := url.Parse(v.HTTPURL) - if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil { - return errors.New("httpUrl 必须是合法的 http/https 地址") - } - if len(v.HTTPHeader) > 0 { - headers := map[string]string{} - if json.Unmarshal(v.HTTPHeader, &headers) != nil { - return errors.New("httpHeader 必须是 JSON 对象") - } - } - default: - return errors.New("executorType 必须为 method 或 http") - } - return nil -} func (s *TaskService) Create(ctx context.Context, v *biz.TimedTask) error { - if err := s.Validate(v); err != nil { - return err - } - exists, err := s.uc.Repo().TaskNameExists(ctx, v.Name, 0) - if err != nil { - return err - } - if exists { - return fmt.Errorf("任务名 %s 已存在", v.Name) - } - return s.uc.Repo().CreateTask(ctx, v) + return s.uc.Create(ctx, v) } func (s *TaskService) Update(ctx context.Context, v *biz.TimedTask) error { - if v.ID == 0 { - return errors.New("缺少任务 ID") - } - if err := s.Validate(v); err != nil { - return err - } - exists, err := s.uc.Repo().TaskNameExists(ctx, v.Name, v.ID) - if err != nil { - return err - } - if exists { - return fmt.Errorf("任务名 %s 已存在", v.Name) - } - return s.uc.Repo().UpdateTask(ctx, v) + return s.uc.Update(ctx, v) } func (s *TaskService) Delete(ctx context.Context, id uint) error { - return s.uc.Repo().DeleteTask(ctx, id) + return s.uc.DeleteTask(ctx, id) } func (s *TaskService) Toggle(ctx context.Context, id uint, enabled bool) error { - return s.uc.Repo().ToggleTask(ctx, id, enabled) + return s.uc.ToggleTask(ctx, id, enabled) } func (s *TaskService) Task(ctx context.Context, id uint) (*biz.TimedTask, error) { - return s.uc.Repo().FindTask(ctx, id) + return s.uc.FindTask(ctx, id) } func taskDTO(v *biz.TimedTask, next *time.Time) map[string]any { return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "description": v.Description, "spec": v.Spec, "withSeconds": v.WithSeconds, "executorType": v.ExecutorType, "methodName": v.MethodName, "params": json.RawMessage(v.Params), "httpUrl": v.HTTPURL, "httpMethod": v.HTTPMethod, "httpHeader": json.RawMessage(v.HTTPHeader), "httpBody": v.HTTPBody, "httpAllowPrivate": v.HTTPAllowPrivate, "enabled": v.Enabled, "nextRunAt": next} } func (s *TaskService) Tasks(ctx context.Context, page, size int, q *biz.TimedTask, next map[uint]time.Time) ([]map[string]any, int64, error) { - items, total, err := s.uc.Repo().ListTasks(ctx, page, size, q) + items, total, err := s.uc.ListTasks(ctx, page, size, q) if err != nil { return nil, 0, err } @@ -137,8 +69,15 @@ func (s *TaskService) Tasks(ctx context.Context, page, size int, q *biz.TimedTas } return out, total, nil } + +// ScheduledTasks is the typed internal contract used by the scheduler and +// deliberately bypasses transport response maps. +func (s *TaskService) ScheduledTasks(ctx context.Context) ([]*biz.TimedTask, error) { + items, _, err := s.uc.ListTasks(ctx, 0, 0, nil) + return items, err +} func (s *TaskService) Logs(ctx context.Context, page, size int, taskID uint, status string) ([]map[string]any, int64, error) { - items, total, err := s.uc.Repo().ListTaskLogs(ctx, page, size, taskID, status) + items, total, err := s.uc.ListTaskLogs(ctx, page, size, taskID, status) if err != nil { return nil, 0, err } @@ -148,85 +87,3 @@ func (s *TaskService) Logs(ctx context.Context, page, size int, taskID uint, sta } return out, total, nil } -func privateIP(ip net.IP) bool { - return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() -} -func (s *TaskService) runHTTP(ctx context.Context, v *biz.TimedTask) (string, error) { - parsed, _ := url.Parse(v.HTTPURL) - if !v.HTTPAllowPrivate { - ips, err := net.DefaultResolver.LookupIP(ctx, "ip", parsed.Hostname()) - if err != nil { - return "", err - } - for _, ip := range ips { - if privateIP(ip) { - return "", errors.New("禁止访问内网或环回地址") - } - } - } - method := strings.ToUpper(v.HTTPMethod) - if method == "" { - method = http.MethodGet - } - request, err := http.NewRequestWithContext(ctx, method, v.HTTPURL, bytes.NewBufferString(v.HTTPBody)) - if err != nil { - return "", err - } - headers := map[string]string{} - _ = json.Unmarshal(v.HTTPHeader, &headers) - for key, value := range headers { - request.Header.Set(key, value) - } - response, err := (&http.Client{Timeout: 30 * time.Second}).Do(request) - if err != nil { - return "", err - } - defer response.Body.Close() - body, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - output := fmt.Sprintf("HTTP %d: %s", response.StatusCode, string(body)) - if response.StatusCode < 200 || response.StatusCode >= 300 { - return output, fmt.Errorf("HTTP 状态码 %d", response.StatusCode) - } - return output, nil -} -func (s *TaskService) Run(ctx context.Context, v *biz.TimedTask, trigger string) (log *biz.TimedTaskLog) { - started := time.Now() - log = &biz.TimedTaskLog{TaskID: v.ID, TaskName: v.Name, TriggerType: trigger, StartedAt: started, Status: "success"} - defer func() { - if recovered := recover(); recovered != nil { - log.Status = "fail" - log.ErrorMsg = fmt.Sprint(recovered) - } - log.FinishedAt = time.Now() - log.DurationMS = log.FinishedAt.Sub(started).Milliseconds() - _ = s.uc.Repo().RecordTaskLog(context.Background(), log) - }() - runCtx, cancel := context.WithTimeout(ctx, 35*time.Second) - defer cancel() - var err error - switch v.ExecutorType { - case "method": - switch v.MethodName { - case "ClearDB": - err = s.uc.Repo().CleanupLogs(runCtx) - log.Output = "过期日志清理完成" - case "CleanStaleUploads": - ttl := 24 - if s.config != nil && s.config.Media != nil && s.config.Media.SessionTtl > 0 { - ttl = int(s.config.Media.SessionTtl) - } - err = s.media.CleanupStale(runCtx, ttl) - log.Output = "过期大文件上传会话清理完成" - } - case "http": - log.Output, err = s.runHTTP(runCtx, v) - } - if err != nil { - log.Status = "fail" - log.ErrorMsg = err.Error() - } - return log -} -func (s *TaskService) RegisteredMethods() []map[string]any { - return []map[string]any{{"name": "ClearDB", "description": "清理数据库过期日志(操作记录、JWT黑名单、定时任务日志)"}, {"name": "CleanStaleUploads", "description": "清理过期大文件上传会话"}} -} diff --git a/internal/service/task_execution.go b/internal/service/task_execution.go new file mode 100644 index 0000000..8ea28e4 --- /dev/null +++ b/internal/service/task_execution.go @@ -0,0 +1,167 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "syscall" + "time" + + "kra/internal/biz" +) + +func privateIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() +} + +func taskHTTPClient(allowPrivate bool) *http.Client { + dialer := &net.Dialer{Timeout: 10 * time.Second, Control: func(_ string, address string, _ syscall.RawConn) error { + if allowPrivate { + return nil + } + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("解析拨号地址失败: %w", err) + } + ip := net.ParseIP(host) + if ip == nil { + return fmt.Errorf("非法拨号 IP: %s", host) + } + if privateIP(ip) { + return fmt.Errorf("目标解析为内网/环回/链路本地地址, 已被 SSRF 防护拒绝(可在任务上开启\"允许内网\"豁免): %s", ip) + } + return nil + }} + return &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{Proxy: nil, DialContext: dialer.DialContext}} +} + +func (s *TaskService) runHTTP(ctx context.Context, v *biz.TimedTask) (string, error) { + parsed, err := url.Parse(v.HTTPURL) + if err != nil { + return "", err + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("仅允许 http/https, 实际为 %q", parsed.Scheme) + } + method := strings.ToUpper(v.HTTPMethod) + if method == "" { + method = http.MethodGet + } + request, err := http.NewRequestWithContext(ctx, method, v.HTTPURL, bytes.NewBufferString(v.HTTPBody)) + if err != nil { + return "", err + } + headers := map[string]string{} + _ = json.Unmarshal(v.HTTPHeader, &headers) + for key, value := range headers { + request.Header.Set(key, value) + } + response, err := taskHTTPClient(v.HTTPAllowPrivate).Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + output := fmt.Sprintf("HTTP %d: %s", response.StatusCode, string(body)) + if response.StatusCode < 200 || response.StatusCode >= 300 { + return output, fmt.Errorf("非 2xx 响应: %d", response.StatusCode) + } + return output, nil +} + +var errTaskTimeout = errors.New("任务执行超时") + +func truncateTaskText(value string) string { + const limit = 4000 + if len(value) <= limit { + return value + } + return value[:limit] + "...(截断)" +} + +func (s *TaskService) runMethod(v *biz.TimedTask) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + done := make(chan error, 1) + go func() { + defer func() { + if recovered := recover(); recovered != nil { + done <- fmt.Errorf("panic: %v", recovered) + } + }() + var err error + switch v.MethodName { + case biz.TaskMethodClearDB: + err = s.uc.CleanupLogs(ctx) + case biz.TaskMethodUploads: + ttl := 24 + config := s.runtime.Admin() + if config != nil && config.Media != nil && config.Media.SessionTtl > 0 { + ttl = int(config.Media.SessionTtl) + } + err = s.media.CleanupStale(ctx, ttl) + } + done <- err + }() + select { + case err := <-done: + if errors.Is(err, context.DeadlineExceeded) { + return errTaskTimeout + } + return err + case <-ctx.Done(): + return errTaskTimeout + } +} + +func (s *TaskService) Run(ctx context.Context, v *biz.TimedTask, trigger string) (log *biz.TimedTaskLog) { + started := time.Now() + log = &biz.TimedTaskLog{TaskID: v.ID, TaskName: v.Name, TriggerType: trigger, StartedAt: started, Status: "success"} + defer func() { + if recovered := recover(); recovered != nil { + log.Status = "fail" + log.ErrorMsg = truncateTaskText(fmt.Sprint(recovered)) + } + log.Output = truncateTaskText(log.Output) + log.FinishedAt = time.Now() + log.DurationMS = log.FinishedAt.Sub(started).Milliseconds() + logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) + defer cancel() + _ = s.uc.RecordTaskLog(logCtx, log) + }() + var err error + switch v.ExecutorType { + case biz.TaskExecutorMethod: + err = s.runMethod(v) + case biz.TaskExecutorHTTP: + runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + log.Output, err = s.runHTTP(runCtx, v) + cancel() + default: + err = fmt.Errorf("未知执行器类型: %s", v.ExecutorType) + } + if err != nil { + if errors.Is(err, errTaskTimeout) || errors.Is(err, context.DeadlineExceeded) { + log.Status = "timeout" + } else { + log.Status = "fail" + } + log.ErrorMsg = truncateTaskText(err.Error()) + } + return log +} +func (s *TaskService) RegisteredMethods() []map[string]any { + methods := biz.RegisteredTaskMethods() + out := make([]map[string]any, 0, len(methods)) + for _, method := range methods { + out = append(out, map[string]any{"name": method.Name, "description": method.Description}) + } + return out +} diff --git a/internal/service/task_execution_test.go b/internal/service/task_execution_test.go new file mode 100644 index 0000000..a8c323f --- /dev/null +++ b/internal/service/task_execution_test.go @@ -0,0 +1,17 @@ +package service + +import ( + "net" + "testing" +) + +func TestPrivateIP(t *testing.T) { + for _, value := range []string{"127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.1.1", "::1"} { + if !privateIP(net.ParseIP(value)) { + t.Fatalf("expected %s to be private", value) + } + } + if privateIP(net.ParseIP("8.8.8.8")) { + t.Fatal("public IP was classified as private") + } +} diff --git a/internal/service/user.go b/internal/service/user.go index 32632ff..9c2ab99 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -7,26 +7,6 @@ import ( "kra/internal/service/dto" ) -func (s *SystemService) User(ctx context.Context, id uint) (map[string]any, error) { - value, err := s.uc.User(ctx, id) - if err != nil { - return nil, err - } - return convertUser(value), nil -} - -func (s *SystemService) Menus(ctx context.Context, authorityID uint) ([]map[string]any, error) { - menus, err := s.uc.Menus(ctx, authorityID) - if err != nil { - return nil, err - } - result := make([]map[string]any, 0, len(menus)) - for _, menu := range menus { - result = append(result, convertMenu(menu)) - } - return result, nil -} - type UserInput struct { ID uint Username, Password, NickName, HeaderImg, Phone, Email string @@ -48,6 +28,26 @@ func (s *SystemService) UpdateUserRequest(ctx context.Context, value *dto.UserRe return s.UpdateUser(ctx, userInput(value)) } +func (s *SystemService) User(ctx context.Context, id uint) (map[string]any, error) { + value, err := s.uc.User(ctx, id) + if err != nil { + return nil, err + } + return convertUser(value), nil +} + +func (s *SystemService) Menus(ctx context.Context, authorityID uint) ([]map[string]any, error) { + menus, err := s.uc.Menus(ctx, authorityID) + if err != nil { + return nil, err + } + result := make([]map[string]any, 0, len(menus)) + for _, menu := range menus { + result = append(result, convertMenu(menu)) + } + return result, nil +} + func (s *SystemService) ListUsers(ctx context.Context, page, pageSize int, filter *biz.UserListFilter) ([]map[string]any, int64, error) { users, total, err := s.uc.ListUsers(ctx, page, pageSize, filter) if err != nil { @@ -59,51 +59,36 @@ func (s *SystemService) ListUsers(ctx context.Context, page, pageSize int, filte } return result, total, nil } + +func (s *SystemService) Authorities(ctx context.Context) ([]map[string]any, error) { + values, err := s.uc.Authorities(ctx) + if err != nil { + return nil, err + } + result := make([]map[string]any, 0, len(values)) + for _, value := range values { + result = append(result, convertAuthority(*value)) + } + return result, nil +} + func (s *SystemService) CreateUser(ctx context.Context, input UserInput) (map[string]any, error) { if err := s.settings.ValidatePassword(ctx, input.Password); err != nil { return nil, err } security, _ := s.settings.CurrentSecurity(ctx) mustChange := security != nil && security.ForceNewUserChangePassword - if input.AuthorityID == 0 && len(input.AuthorityIDs) > 0 { - input.AuthorityID = input.AuthorityIDs[0] - } - input.AuthorityIDs = includeAuthority(input.AuthorityIDs, input.AuthorityID) - user, err := s.uc.CreateUser(ctx, &biz.User{Username: input.Username, Password: input.Password, NickName: input.NickName, HeaderImg: input.HeaderImg, AuthorityID: input.AuthorityID, Phone: input.Phone, Email: input.Email, Enable: input.Enable, MustChangePassword: mustChange}) + user, err := s.uc.CreateUser(ctx, &biz.User{Username: input.Username, Password: input.Password, NickName: input.NickName, HeaderImg: input.HeaderImg, AuthorityID: input.AuthorityID, Phone: input.Phone, Email: input.Email, Enable: input.Enable, MustChangePassword: mustChange}, input.AuthorityIDs) if err != nil { return nil, err } - if len(input.AuthorityIDs) > 0 { - if err = s.uc.SetUserAuthorities(ctx, user.ID, input.AuthorityIDs); err != nil { - return nil, err - } - user, err = s.uc.User(ctx, user.ID) - if err != nil { - return nil, err - } - } return convertUser(user), nil } func (s *SystemService) UpdateUser(ctx context.Context, input UserInput) error { - input.AuthorityIDs = includeAuthority(input.AuthorityIDs, input.AuthorityID) - if err := s.uc.UpdateUser(ctx, &biz.User{ID: input.ID, NickName: input.NickName, HeaderImg: input.HeaderImg, AuthorityID: input.AuthorityID, Phone: input.Phone, Email: input.Email, Enable: input.Enable}); err != nil { - return err - } - if len(input.AuthorityIDs) > 0 { - return s.uc.SetUserAuthorities(ctx, input.ID, input.AuthorityIDs) - } - return nil + return s.uc.UpdateUser(ctx, &biz.User{ID: input.ID, NickName: input.NickName, HeaderImg: input.HeaderImg, AuthorityID: input.AuthorityID, Phone: input.Phone, Email: input.Email, Enable: input.Enable}, input.AuthorityIDs) } -func includeAuthority(ids []uint, primary uint) []uint { - if primary == 0 { - return ids - } - for _, id := range ids { - if id == primary { - return ids - } - } - return append(ids, primary) +func (s *SystemService) UpdateSelfUser(ctx context.Context, input UserInput) error { + return s.uc.UpdateSelfUser(ctx, &biz.User{ID: input.ID, NickName: input.NickName, HeaderImg: input.HeaderImg, Phone: input.Phone, Email: input.Email, Enable: input.Enable}) } func (s *SystemService) DeleteUser(ctx context.Context, id uint) error { return s.uc.DeleteUser(ctx, id) @@ -120,17 +105,7 @@ func (s *SystemService) ChangePassword(ctx context.Context, id uint, oldPassword } return s.uc.ChangePassword(ctx, id, oldPassword, newPassword) } -func (s *SystemService) Authorities(ctx context.Context) ([]map[string]any, error) { - values, err := s.uc.Authorities(ctx) - if err != nil { - return nil, err - } - result := make([]map[string]any, 0, len(values)) - for _, value := range values { - result = append(result, convertAuthority(*value)) - } - return result, nil -} + func (s *SystemService) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error { return s.uc.SetUserAuthorities(ctx, id, authorityIDs) } @@ -142,50 +117,8 @@ func (s *SystemService) SwitchAuthority(ctx context.Context, id, authorityID uin if err != nil { return nil, err } - return s.issueLogin(user, authorityID) + return s.issueLogin(ctx, user, authorityID) } func (s *SystemService) SetUserSetting(ctx context.Context, id uint, setting map[string]any) error { return s.uc.SetUserSetting(ctx, id, setting) } - -func convertAuthority(value biz.Authority) map[string]any { - return map[string]any{"authorityId": value.AuthorityID, "authorityName": value.AuthorityName, "parentId": value.ParentID, "children": []map[string]any{}, "menus": []any{}, "dataScope": value.DataScope, "defaultRouter": value.DefaultRouter} -} -func convertUser(user *biz.User) map[string]any { - authorities := make([]map[string]any, 0, len(user.Authorities)) - for _, value := range user.Authorities { - authorities = append(authorities, convertAuthority(value)) - } - departments := make([]map[string]any, 0, len(user.Departments)) - for i := range user.Departments { - departments = append(departments, departmentDTO(&user.Departments[i])) - } - positions := make([]map[string]any, 0, len(user.Positions)) - for i := range user.Positions { - positions = append(positions, positionDTO(&user.Positions[i])) - } - department := map[string]any{} - if user.Department != nil { - department = departmentDTO(user.Department) - } - return map[string]any{"ID": user.ID, "CreatedAt": user.CreatedAt, "UpdatedAt": user.UpdatedAt, "DeletedAt": nil, "uuid": user.UUID, "userName": user.Username, "nickName": user.NickName, "headerImg": user.HeaderImg, "authorityId": user.AuthorityID, "authority": convertAuthority(user.Authority), "authorities": authorities, "deptId": user.DeptID, "dept": department, "departments": departments, "positions": positions, "phone": user.Phone, "email": user.Email, "enable": user.Enable, "originSetting": user.OriginSetting} -} -func convertMenu(menu *biz.Menu) map[string]any { - children := make([]map[string]any, 0, len(menu.Children)) - for _, child := range menu.Children { - children = append(children, convertMenu(child)) - } - buttons := make([]map[string]any, 0, len(menu.Buttons)) - buttonAuthorities := make(map[string]uint, len(menu.Buttons)) - for _, button := range menu.Buttons { - buttons = append(buttons, map[string]any{"ID": button.ID, "name": button.Name, "desc": button.Description, "sysBaseMenuID": button.MenuID}) - if button.AuthorityID != 0 { - buttonAuthorities[button.Name] = button.AuthorityID - } - } - parameters := make([]map[string]any, 0, len(menu.Parameters)) - for _, parameter := range menu.Parameters { - parameters = append(parameters, map[string]any{"ID": parameter.ID, "SysBaseMenuID": parameter.MenuID, "type": parameter.Type, "key": parameter.Key, "value": parameter.Value}) - } - return map[string]any{"ID": menu.ID, "parentId": menu.ParentID, "path": menu.Path, "name": menu.Name, "hidden": menu.Hidden, "component": menu.Component, "sort": menu.Sort, "meta": map[string]any{"activeName": menu.ActiveName, "keepAlive": menu.KeepAlive, "defaultMenu": menu.DefaultMenu, "title": menu.Title, "icon": menu.Icon, "closeTab": menu.CloseTab, "transitionType": menu.TransitionType}, "children": children, "parameters": parameters, "menuBtn": buttons, "btns": buttonAuthorities} -} diff --git a/internal/service/user_conversion.go b/internal/service/user_conversion.go new file mode 100644 index 0000000..2e07b97 --- /dev/null +++ b/internal/service/user_conversion.go @@ -0,0 +1,56 @@ +package service + +import "kra/internal/biz" + +func departmentDTO(v *biz.Department) map[string]any { + children := make([]map[string]any, 0, len(v.Children)) + for _, c := range v.Children { + children = append(children, departmentDTO(c)) + } + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "parentId": v.ParentID, "ancestors": v.Ancestors, "sort": v.Sort, "leaderId": v.LeaderID, "leader": nil, "status": v.Status, "children": children, "namePath": v.NamePath} +} +func positionDTO(v *biz.Position) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "code": v.Code, "sort": v.Sort, "status": v.Status, "remark": v.Remark} +} + +func convertAuthority(value biz.Authority) map[string]any { + return map[string]any{"authorityId": value.AuthorityID, "authorityName": value.AuthorityName, "parentId": value.ParentID, "children": []map[string]any{}, "menus": []any{}, "dataScope": value.DataScope, "defaultRouter": value.DefaultRouter} +} +func convertUser(user *biz.User) map[string]any { + authorities := make([]map[string]any, 0, len(user.Authorities)) + for _, value := range user.Authorities { + authorities = append(authorities, convertAuthority(value)) + } + departments := make([]map[string]any, 0, len(user.Departments)) + for i := range user.Departments { + departments = append(departments, departmentDTO(&user.Departments[i])) + } + positions := make([]map[string]any, 0, len(user.Positions)) + for i := range user.Positions { + positions = append(positions, positionDTO(&user.Positions[i])) + } + department := map[string]any{} + if user.Department != nil { + department = departmentDTO(user.Department) + } + return map[string]any{"ID": user.ID, "CreatedAt": user.CreatedAt, "UpdatedAt": user.UpdatedAt, "DeletedAt": nil, "uuid": user.UUID, "userName": user.Username, "nickName": user.NickName, "headerImg": user.HeaderImg, "authorityId": user.AuthorityID, "authority": convertAuthority(user.Authority), "authorities": authorities, "deptId": user.DeptID, "dept": department, "departments": departments, "positions": positions, "phone": user.Phone, "email": user.Email, "enable": user.Enable, "originSetting": user.OriginSetting} +} +func convertMenu(menu *biz.Menu) map[string]any { + children := make([]map[string]any, 0, len(menu.Children)) + for _, child := range menu.Children { + children = append(children, convertMenu(child)) + } + buttons := make([]map[string]any, 0, len(menu.Buttons)) + buttonAuthorities := make(map[string]uint, len(menu.Buttons)) + for _, button := range menu.Buttons { + buttons = append(buttons, map[string]any{"ID": button.ID, "name": button.Name, "desc": button.Description, "sysBaseMenuID": button.MenuID}) + if button.AuthorityID != 0 { + buttonAuthorities[button.Name] = button.AuthorityID + } + } + parameters := make([]map[string]any, 0, len(menu.Parameters)) + for _, parameter := range menu.Parameters { + parameters = append(parameters, map[string]any{"ID": parameter.ID, "SysBaseMenuID": parameter.MenuID, "type": parameter.Type, "key": parameter.Key, "value": parameter.Value}) + } + return map[string]any{"ID": menu.ID, "parentId": menu.ParentID, "path": menu.Path, "name": menu.Name, "hidden": menu.Hidden, "component": menu.Component, "sort": menu.Sort, "meta": map[string]any{"activeName": menu.ActiveName, "keepAlive": menu.KeepAlive, "defaultMenu": menu.DefaultMenu, "title": menu.Title, "icon": menu.Icon, "closeTab": menu.CloseTab, "transitionType": menu.TransitionType}, "children": children, "parameters": parameters, "menuBtn": buttons, "btns": buttonAuthorities} +} diff --git a/internal/service/version.go b/internal/service/version.go index 2af80fc..37d00ca 100644 --- a/internal/service/version.go +++ b/internal/service/version.go @@ -15,8 +15,8 @@ func NewVersionService(uc *biz.VersionUsecase) *VersionService { return &Version func versionDTO(v *biz.Version) map[string]any { return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "versionName": v.Name, "versionCode": v.Code, "description": v.Description, "versionData": v.Data} } -func (s *VersionService) Versions(ctx context.Context, page, size int, name, code string) ([]map[string]any, int64, error) { - items, total, err := s.uc.Repo().ListVersions(ctx, page, size, name, code) +func (s *VersionService) Versions(ctx context.Context, page, size int, name, code string, createdAtRange []*time.Time) ([]map[string]any, int64, error) { + items, total, err := s.uc.ListVersions(ctx, page, size, name, code, createdAtRange) if err != nil { return nil, 0, err } @@ -27,24 +27,25 @@ func (s *VersionService) Versions(ctx context.Context, page, size int, name, cod return out, total, nil } func (s *VersionService) Version(ctx context.Context, id uint) (map[string]any, error) { - v, err := s.uc.Repo().FindVersion(ctx, id) + v, err := s.uc.FindVersion(ctx, id) if err != nil { return nil, err } return versionDTO(v), nil } func (s *VersionService) VersionData(ctx context.Context, id uint) (string, string, error) { - v, err := s.uc.Repo().FindVersion(ctx, id) + v, err := s.uc.FindVersion(ctx, id) if err != nil { return "", "", err } return v.Data, v.Code, nil } func (s *VersionService) DeleteVersions(ctx context.Context, ids []uint) error { - return s.uc.Repo().DeleteVersions(ctx, ids) + return s.uc.DeleteVersions(ctx, ids) } + func (s *VersionService) Export(ctx context.Context, name, code, description string, menuIDs, apiIDs, dictIDs []uint) error { - bundle, err := s.uc.Repo().BuildVersionBundle(ctx, menuIDs, apiIDs, dictIDs) + bundle, err := s.uc.BuildVersionBundle(ctx, menuIDs, apiIDs, dictIDs) if err != nil { return err } @@ -69,22 +70,28 @@ func (s *VersionService) Export(ctx context.Context, name, code, description str if err != nil { return err } - return s.uc.Repo().CreateVersion(ctx, &biz.Version{Name: name, Code: code, Description: description, Data: string(raw)}) + return s.uc.CreateVersion(ctx, &biz.Version{Name: name, Code: code, Description: description, Data: string(raw)}) } func (s *VersionService) Import(ctx context.Context, bundle *biz.VersionBundle) error { - return s.uc.Repo().ImportVersionBundle(ctx, bundle) + return s.uc.ImportVersionBundle(ctx, bundle) } func versionMenuDomain(value dto.VersionMenuRequest) *biz.Menu { menu := &biz.Menu{Path: value.Path, Name: value.Name, Hidden: value.Hidden, Component: value.Component, Sort: value.Sort, ActiveName: value.Meta.ActiveName, KeepAlive: value.Meta.KeepAlive, DefaultMenu: value.Meta.DefaultMenu, Title: value.Meta.Title, Icon: value.Meta.Icon, CloseTab: value.Meta.CloseTab, TransitionType: value.Meta.TransitionType} for _, button := range value.MenuButtons { menu.Buttons = append(menu.Buttons, &biz.MenuButton{Name: button.Name, Description: button.Description}) } + for _, parameter := range value.Parameters { + menu.Parameters = append(menu.Parameters, &biz.MenuParameter{Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + } for _, child := range value.Children { menu.Children = append(menu.Children, versionMenuDomain(child)) } return menu } func (s *VersionService) ImportRequest(ctx context.Context, req *dto.ImportVersionRequest) error { + if req.Version.Name == "" || req.Version.Code == "" { + return biz.ErrInvalidVersion + } bundle := &biz.VersionBundle{Name: req.Version.Name, Code: req.Version.Code, Description: req.Version.Description, ExportTime: req.Version.ExportTime} for _, menu := range req.Menus { bundle.Menus = append(bundle.Menus, versionMenuDomain(menu)) @@ -103,5 +110,15 @@ func (s *VersionService) ImportRequest(ctx context.Context, req *dto.ImportVersi } bundle.Dictionaries = append(bundle.Dictionaries, item) } - return s.Import(ctx, bundle) + if err := s.Import(ctx, bundle); err != nil { + return err + } + raw, _ := json.Marshal(req) + _ = s.uc.CreateVersion(ctx, &biz.Version{ + Name: req.Version.Name, + Code: req.Version.Code + "_imported_" + time.Now().Format("20060102150405"), + Description: "导入版本: " + req.Version.Description, + Data: string(raw), + }) + return nil } diff --git a/internal/worker/task_scheduler.go b/internal/worker/task_scheduler.go index 3c5fb52..a9ca4f9 100644 --- a/internal/worker/task_scheduler.go +++ b/internal/worker/task_scheduler.go @@ -21,6 +21,9 @@ type TaskScheduler struct { seconds *cron.Cron mu sync.Mutex entries map[uint]scheduledEntry + ctxMu sync.RWMutex + runContext context.Context + cancel context.CancelFunc subMu sync.RWMutex subscribers map[chan []byte]struct{} } @@ -35,15 +38,18 @@ func NewTaskScheduler(service *service.TaskService, logger *slog.Logger) *TaskSc } func (s *TaskScheduler) Start(ctx context.Context) error { + runContext, cancel := context.WithCancel(ctx) + s.ctxMu.Lock() + s.runContext, s.cancel = runContext, cancel + s.ctxMu.Unlock() s.standard.Start() s.seconds.Start() - items, _, err := s.service.Tasks(ctx, 1, 100000, nil, nil) + items, err := s.service.ScheduledTasks(ctx) if err == nil { - for _, raw := range items { - id, _ := raw["ID"].(uint) - if task, findErr := s.service.Task(ctx, id); findErr == nil && task.Enabled { + for _, task := range items { + if task.Enabled { if scheduleErr := s.Schedule(task); scheduleErr != nil { - s.logger.ErrorContext(ctx, "restore timed task failed", "id", id, "error", scheduleErr) + s.logger.ErrorContext(ctx, "restore timed task failed", "id", task.ID, "error", scheduleErr) } } } @@ -55,6 +61,11 @@ func (s *TaskScheduler) Start(ctx context.Context) error { } func (s *TaskScheduler) Stop(ctx context.Context) error { + s.ctxMu.Lock() + if s.cancel != nil { + s.cancel() + } + s.ctxMu.Unlock() standardDone, secondsDone := s.standard.Stop().Done(), s.seconds.Stop().Done() select { case <-standardDone: @@ -93,16 +104,11 @@ func (s *TaskScheduler) Reload(ctx context.Context) error { delete(s.entries, id) } s.mu.Unlock() - items, _, err := s.service.Tasks(ctx, 1, 100000, nil, nil) + items, err := s.service.ScheduledTasks(ctx) if err != nil { return err } - for _, raw := range items { - id, _ := raw["ID"].(uint) - task, findErr := s.service.Task(ctx, id) - if findErr != nil { - return findErr - } + for _, task := range items { if task.Enabled { if err = s.Schedule(task); err != nil { return err @@ -112,6 +118,22 @@ func (s *TaskScheduler) Reload(ctx context.Context) error { return nil } +func (s *TaskScheduler) executionContext() context.Context { + s.ctxMu.RLock() + defer s.ctxMu.RUnlock() + if s.runContext != nil { + return s.runContext + } + return context.Background() +} + +func (s *TaskScheduler) run(task *biz.TimedTask, trigger string) { + log := s.service.Run(s.executionContext(), task, trigger) + if log.Status != "success" { + s.Broadcast(map[string]any{"taskId": task.ID, "taskName": task.Name, "status": log.Status, "errorMsg": log.ErrorMsg, "time": time.Now()}) + } +} + func (s *TaskScheduler) Schedule(task *biz.TimedTask) error { s.Remove(task.ID) if !task.Enabled { @@ -119,10 +141,7 @@ func (s *TaskScheduler) Schedule(task *biz.TimedTask) error { } copy := *task run := func() { - log := s.service.Run(context.Background(), ©, "auto") - if log.Status != "success" { - s.Broadcast(map[string]any{"taskId": copy.ID, "taskName": copy.Name, "status": log.Status, "errorMsg": log.ErrorMsg, "time": time.Now()}) - } + s.run(©, "auto") } var id cron.EntryID var err error @@ -172,12 +191,8 @@ func (s *TaskScheduler) NextRuns() map[uint]time.Time { } func (s *TaskScheduler) Trigger(task *biz.TimedTask) { - go func() { - log := s.service.Run(context.Background(), task, "manual") - if log.Status != "success" { - s.Broadcast(map[string]any{"taskId": task.ID, "taskName": task.Name, "status": log.Status, "errorMsg": log.ErrorMsg, "time": time.Now()}) - } - }() + copy := *task + go s.run(©, "manual") } func (s *TaskScheduler) Subscribe() chan []byte { diff --git a/pkg/logging/daily.go b/pkg/logging/daily.go new file mode 100644 index 0000000..58d0b6d --- /dev/null +++ b/pkg/logging/daily.go @@ -0,0 +1,55 @@ +package logging + +import ( + "os" + "path/filepath" + "sync" + "time" +) + +// DailyWriter writes log entries to //. It keeps the +// file handle open for the active day and rotates on the first write after +// midnight, matching the directory layout consumed by the log viewer. +type DailyWriter struct { + mu sync.Mutex + root string + name string + date string + file *os.File +} + +func NewDailyWriter(root, name string) *DailyWriter { + return &DailyWriter{root: root, name: name} +} + +func (w *DailyWriter) Write(value []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + date := time.Now().Format("2006-01-02") + if w.file == nil || w.date != date { + if w.file != nil { + _ = w.file.Close() + } + directory := filepath.Join(w.root, date) + if err := os.MkdirAll(directory, 0o755); err != nil { + return 0, err + } + file, err := os.OpenFile(filepath.Join(directory, w.name), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return 0, err + } + w.file, w.date = file, date + } + return w.file.Write(value) +} + +func (w *DailyWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return nil + } + err := w.file.Close() + w.file = nil + return err +} diff --git a/pkg/logging/zap.go b/pkg/logging/zap.go new file mode 100644 index 0000000..6fbff3b --- /dev/null +++ b/pkg/logging/zap.go @@ -0,0 +1,34 @@ +package logging + +import ( + "log/slog" + "os" + + "github.com/go-kratos/kratos/contrib/otel/v3/tracing" + kratoslog "github.com/go-kratos/kratos/v3/log" + "go.uber.org/zap" + "go.uber.org/zap/exp/zapslog" + "go.uber.org/zap/zapcore" +) + +// NewZapLogger adapts a Zap core to the slog logger used by Kratos v3. +// The file layout remains compatible with the administration log viewer. +func NewZapLogger(root, filename string, attrs ...any) (*slog.Logger, func()) { + file := NewDailyWriter(root, filename) + encoder := zap.NewProductionEncoderConfig() + encoder.EncodeTime = zapcore.ISO8601TimeEncoder + encoder.EncodeLevel = zapcore.LowercaseLevelEncoder + core := zapcore.NewCore( + zapcore.NewJSONEncoder(encoder), + zapcore.NewMultiWriteSyncer(zapcore.AddSync(os.Stdout), zapcore.AddSync(file)), + zap.NewAtomicLevelAt(zap.InfoLevel), + ) + zapLogger := zap.New(core) + handler := zapslog.NewHandler(zapLogger.Core(), zapslog.WithCaller(true), zapslog.AddStacktraceAt(slog.LevelError)) + logger := kratoslog.NewLogger(handler, kratoslog.WithExtractor(tracing.TraceAttrs)).With(attrs...) + cleanup := func() { + _ = zapLogger.Sync() + _ = file.Close() + } + return logger, cleanup +} diff --git a/web/src/view/superAdmin/params/sysParams.vue b/web/src/view/superAdmin/params/sysParams.vue index df5371c..26c98f7 100644 --- a/web/src/view/superAdmin/params/sysParams.vue +++ b/web/src/view/superAdmin/params/sysParams.vue @@ -241,20 +241,11 @@ 来获取对应的参数。

- 后端需要提前 + 后端通过 import - "github.com/kra/kra/server/service/system"SettingsUsecase.ParameterByKey(ctx, key) -

-

- 然后调用 - new(system.SysParamsService).GetSysParam("{{ - formData.key - }}") - 来获取对应的 value 值。 + 获取对应参数值,业务代码应通过 biz 层接口调用。

diff --git a/web/src/view/systemTools/system/system.vue b/web/src/view/systemTools/system/system.vue index 881484b..3b7a06e 100644 --- a/web/src/view/systemTools/system/system.vue +++ b/web/src/view/systemTools/system/system.vue @@ -23,6 +23,24 @@ + + + + + + + + + + + + + + + + + + @@ -32,6 +50,43 @@ + +
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + +

保存后点击“重载服务”,数据库连接会按新配置重建。

+
+
+ + + + + + + @@ -114,13 +169,19 @@ const config = ref({ admin: { routerPrefix: '', - jwt: { signingKey: '******', issuer: 'kra' }, + jwt: { signingKey: '******', expiresTime: '168h', bufferTime: '24h', issuer: 'kra' }, + captcha: { keyLong: 6, imgWidth: 240, imgHeight: 80, storeExpiration: '3m' }, local: { storePath: 'uploads/file', pathPrefix: 'uploads/file' }, media: { sessionTtl: 24 }, + system: { useRedis: false, useMultipoint: false, useStrictAuth: false, disableAutoMigrate: false }, storage: { type: 'local', qiniu: {}, aliyun_oss: {}, huawei_obs: {}, tencent_cos: {}, aws_s3: {}, cloudflare_r2: {}, minio: {} } }, email: { to: '', from: '', host: '', secret: '******', nickname: '', port: 465, 'is-ssl': true, 'is-loginauth': false + }, + data: { + database: { driver: 'mysql', source: '', host: '127.0.0.1', port: '3306', user: '', password: '******', name: '', config: '', path: '' }, + redis: { network: 'tcp', addr: '', read_timeout: '200ms', write_timeout: '200ms' } } }) const storageTypes = [ @@ -138,8 +199,10 @@ config.value = { admin: { ...config.value.admin, ...res.data.config.admin, jwt: { ...config.value.admin.jwt, ...res.data.config.admin?.jwt }, + captcha: { ...config.value.admin.captcha, ...res.data.config.admin?.captcha }, local: { ...config.value.admin.local, ...res.data.config.admin?.local } , media: { ...config.value.admin.media, ...res.data.config.admin?.media } + , system: { ...config.value.admin.system, ...res.data.config.admin?.system } , storage: { ...config.value.admin.storage, ...res.data.config.admin?.storage, qiniu: { ...config.value.admin.storage.qiniu, ...res.data.config.admin?.storage?.qiniu }, aliyun_oss: { ...config.value.admin.storage.aliyun_oss, ...res.data.config.admin?.storage?.aliyun_oss }, @@ -150,7 +213,11 @@ minio: { ...config.value.admin.storage.minio, ...res.data.config.admin?.storage?.minio } } }, - email: { ...config.value.email, ...res.data.config.email } + email: { ...config.value.email, ...res.data.config.email }, + data: { + database: { ...config.value.data.database, ...res.data.config.data?.database }, + redis: { ...config.value.data.redis, ...res.data.config.data?.redis } + } } } } diff --git a/web/src/view/systemTools/timedTask/index.vue b/web/src/view/systemTools/timedTask/index.vue index 504c74e..999aae7 100644 --- a/web/src/view/systemTools/timedTask/index.vue +++ b/web/src/view/systemTools/timedTask/index.vue @@ -299,21 +299,16 @@ const headerText = ref('') const methodOptions = ref([]) const formTitle = computed(() => (form.value.ID ? '编辑定时任务' : '新增定时任务')) -// 注册方法代码模板: 引导二开在 server/initialize/timer.go 的 Timer() 中追加 task.Register -const methodRegisterTemplate = `// 位置: server/initialize/timer.go -> Timer() -task.Register("MyTask", "任务说明(展示在方法下拉中)", func(ctx context.Context, params json.RawMessage) error { - // params 为面板「参数(JSON)」原文, 按需解析; 无参数可忽略 - // var p struct { - // Days int \`json:"days"\` - // } - // if len(params) > 0 { - // if err := json.Unmarshal(params, &p); err != nil { - // return err - // } - // } - // TODO: 业务逻辑; 数据库操作请使用 global.KRA_DB.WithContext(ctx) - return nil -})` +// 扩展内置任务时需要同时登记名称并实现执行分支。 +const methodRegisterTemplate = `// 1. internal/biz/task.go: 在 RegisteredTaskMethods 中登记任务 +{Name: "MyTask", Description: "任务说明(展示在方法下拉中)"}, + +// 2. internal/service/task_execution.go: 在 TaskExecutorMethod 分支中实现 +case "MyTask": + // params := v.Params // 面板「参数(JSON)」原文,可按需解析 + // 通过 biz usecase 执行业务逻辑,不要在 service 中直接访问数据库 + err = s.uc.RunMyTask(runCtx, v.Params) + log.Output = "任务执行完成"` const copyRegisterTemplate = () => { const input = document.createElement('textarea') @@ -322,7 +317,7 @@ const copyRegisterTemplate = () => { input.select() document.execCommand('copy') document.body.removeChild(input) - ElMessage.success('已复制, 请粘贴到 server/initialize/timer.go 的 Timer() 中, 重启后生效') + ElMessage.success('扩展说明已复制,请按注释修改后端并重启服务') } const openForm = async (row) => {