This commit is contained in:
parent
a85477be80
commit
a9e1f9e6f8
|
|
@ -1,12 +1,17 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"kra/internal/conf"
|
"kra/internal/conf"
|
||||||
|
"kra/internal/service"
|
||||||
|
"kra/internal/service/dto"
|
||||||
"kra/internal/worker"
|
"kra/internal/worker"
|
||||||
"kra/pkg/logging"
|
"kra/pkg/logging"
|
||||||
|
|
||||||
|
|
@ -35,7 +40,12 @@ func init() {
|
||||||
flag.StringVar(&flagconf, "conf", "../../configs", "config path, eg: -conf config.yaml")
|
flag.StringVar(&flagconf, "conf", "../../configs", "config path, eg: -conf config.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskScheduler) *kratos.App {
|
func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskScheduler, audit *service.AuditRecorder, loggerControl *logging.ReloadableLogger) *kratos.App {
|
||||||
|
if audit != nil && loggerControl != nil {
|
||||||
|
loggerControl.SetErrorSink(logging.ErrorSinkFunc(func(ctx context.Context, entry logging.ErrorEntry) error {
|
||||||
|
return audit.CreateErrorRequest(ctx, &dto.ErrorRecordRequest{Form: entry.Form, Info: entry.Info, Level: entry.Level, RequestID: entry.RequestID, TraceID: entry.TraceID})
|
||||||
|
}))
|
||||||
|
}
|
||||||
return kratos.New(
|
return kratos.New(
|
||||||
kratos.ID(id),
|
kratos.ID(id),
|
||||||
kratos.Name(Name),
|
kratos.Name(Name),
|
||||||
|
|
@ -63,6 +73,30 @@ func zapSettings(admin *conf.AdminBackend) (string, logging.Options) {
|
||||||
return root, options
|
return root, options
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func httpAddress(server *conf.Server) string {
|
||||||
|
if server != nil && server.Http != nil && server.Http.Addr != "" {
|
||||||
|
return server.Http.Addr
|
||||||
|
}
|
||||||
|
return ":8000"
|
||||||
|
}
|
||||||
|
|
||||||
|
func swaggerAddress(server *conf.Server, admin *conf.AdminBackend) string {
|
||||||
|
address := httpAddress(server)
|
||||||
|
if strings.HasPrefix(address, ":") {
|
||||||
|
address = "127.0.0.1" + address
|
||||||
|
} else if host, port, err := net.SplitHostPort(address); err == nil && (host == "" || host == "0.0.0.0" || host == "::") {
|
||||||
|
address = net.JoinHostPort("127.0.0.1", port)
|
||||||
|
}
|
||||||
|
prefix := ""
|
||||||
|
if admin != nil {
|
||||||
|
prefix = strings.Trim(admin.RouterPrefix, "/")
|
||||||
|
}
|
||||||
|
if prefix != "" {
|
||||||
|
prefix = "/" + prefix
|
||||||
|
}
|
||||||
|
return "http://" + address + prefix + "/swagger/index.html"
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
c := config.New(
|
c := config.New(
|
||||||
|
|
@ -87,6 +121,7 @@ func main() {
|
||||||
}
|
}
|
||||||
logger, loggerControl := logging.NewReloadableZapLogger(logRoot, "application.log", logOptions, loggerAttrs...)
|
logger, loggerControl := logging.NewReloadableZapLogger(logRoot, "application.log", logOptions, loggerAttrs...)
|
||||||
defer loggerControl.Close()
|
defer loggerControl.Close()
|
||||||
|
slog.SetDefault(logger)
|
||||||
log.SetDefault(logger)
|
log.SetDefault(logger)
|
||||||
if bc.Admin != nil {
|
if bc.Admin != nil {
|
||||||
bc.Admin.ConfigPath = flagconf
|
bc.Admin.ConfigPath = flagconf
|
||||||
|
|
@ -102,11 +137,12 @@ func main() {
|
||||||
})
|
})
|
||||||
defer unsubscribeLogger()
|
defer unsubscribeLogger()
|
||||||
|
|
||||||
app, cleanup, err := wireApp(bc.Server, runtime, logger, Version)
|
app, cleanup, err := wireApp(bc.Server, runtime, logger, loggerControl, Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
logger.Info("Kra administration service initialized", "mod", "system", "version", Version, "http_address", httpAddress(bc.Server), "swagger", swaggerAddress(bc.Server, bc.Admin), "admin_frontend", "http://127.0.0.1:8080")
|
||||||
|
|
||||||
// start and wait for stop signal
|
// start and wait for stop signal
|
||||||
if err := app.Run(); err != nil {
|
if err := app.Run(); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"kra/internal/conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSwaggerAddress(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
server *conf.Server
|
||||||
|
admin *conf.AdminBackend
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "default", want: "http://127.0.0.1:8000/swagger/index.html"},
|
||||||
|
{name: "wildcard with prefix", server: &conf.Server{Http: &conf.Server_HTTP{Addr: "0.0.0.0:9000"}}, admin: &conf.AdminBackend{RouterPrefix: "/admin/"}, want: "http://127.0.0.1:9000/admin/swagger/index.html"},
|
||||||
|
{name: "host", server: &conf.Server{Http: &conf.Server_HTTP{Addr: "example.test:8080"}}, want: "http://example.test:8080/swagger/index.html"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := swaggerAddress(test.server, test.admin); got != test.want {
|
||||||
|
t.Fatalf("swaggerAddress() = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,12 +14,13 @@ import (
|
||||||
"kra/internal/server"
|
"kra/internal/server"
|
||||||
"kra/internal/service"
|
"kra/internal/service"
|
||||||
"kra/internal/worker"
|
"kra/internal/worker"
|
||||||
|
"kra/pkg/logging"
|
||||||
|
|
||||||
"github.com/go-kratos/kratos/v3"
|
"github.com/go-kratos/kratos/v3"
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wireApp init kratos application.
|
// wireApp init kratos application.
|
||||||
func wireApp(*conf.Server, *conf.Runtime, *slog.Logger, string) (*kratos.App, func(), error) {
|
func wireApp(*conf.Server, *conf.Runtime, *slog.Logger, *logging.ReloadableLogger, string) (*kratos.App, func(), error) {
|
||||||
panic(wire.Build(server.ProviderSet, worker.ProviderSet, data.ProviderSet, biz.ProviderSet, service.ProviderSet, newApp))
|
panic(wire.Build(server.ProviderSet, worker.ProviderSet, data.ProviderSet, biz.ProviderSet, service.ProviderSet, newApp))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"kra/internal/server/handler"
|
"kra/internal/server/handler"
|
||||||
"kra/internal/service"
|
"kra/internal/service"
|
||||||
"kra/internal/worker"
|
"kra/internal/worker"
|
||||||
|
"kra/pkg/logging"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -25,8 +26,8 @@ import (
|
||||||
// Injectors from wire.go:
|
// Injectors from wire.go:
|
||||||
|
|
||||||
// wireApp init kratos application.
|
// wireApp init kratos application.
|
||||||
func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger, string2 string) (*kratos.App, func(), error) {
|
func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger, reloadableLogger *logging.ReloadableLogger, string2 string) (*kratos.App, func(), error) {
|
||||||
dataData, cleanup, err := data.NewData(runtime)
|
dataData, cleanup, err := data.NewData(runtime, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +133,7 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
|
||||||
set := handler.NewSet(authority, menu, api, permission, organization, announcement, email, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session)
|
set := handler.NewSet(authority, menu, api, permission, organization, announcement, email, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session)
|
||||||
engine := server.NewGinEngine(runtime, accessControlService, set, authService, securityService, auditRecorder, logger, string2)
|
engine := server.NewGinEngine(runtime, accessControlService, set, authService, securityService, auditRecorder, logger, string2)
|
||||||
httpServer := server.NewGinServer(confServer, engine)
|
httpServer := server.NewGinServer(confServer, engine)
|
||||||
app := newApp(logger, httpServer, taskScheduler)
|
app := newApp(logger, httpServer, taskScheduler, auditRecorder, reloadableLogger)
|
||||||
return app, func() {
|
return app, func() {
|
||||||
cleanup()
|
cleanup()
|
||||||
}, nil
|
}, nil
|
||||||
|
|
|
||||||
16
go.mod
16
go.mod
|
|
@ -26,6 +26,9 @@ require (
|
||||||
github.com/redis/go-redis/v9 v9.7.0
|
github.com/redis/go-redis/v9 v9.7.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/shirou/gopsutil/v4 v4.25.7
|
github.com/shirou/gopsutil/v4 v4.25.7
|
||||||
|
github.com/swaggo/files v1.0.1
|
||||||
|
github.com/swaggo/gin-swagger v1.6.0
|
||||||
|
github.com/swaggo/swag v1.16.4
|
||||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.60
|
github.com/tencentyun/cos-go-sdk-v5 v0.7.60
|
||||||
github.com/xuri/excelize/v2 v2.9.0
|
github.com/xuri/excelize/v2 v2.9.0
|
||||||
go.einride.tech/aip v0.86.3
|
go.einride.tech/aip v0.86.3
|
||||||
|
|
@ -34,6 +37,7 @@ require (
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
go.uber.org/zap/exp v0.3.0
|
go.uber.org/zap/exp v0.3.0
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.53.0
|
||||||
|
golang.org/x/sync v0.21.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/mysql v1.6.0
|
gorm.io/driver/mysql v1.6.0
|
||||||
|
|
@ -47,6 +51,9 @@ replace github.com/go-kratos/kratos/v3 v3.0.0 => github.com/go-kratos/kratos/v3
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/BurntSushi/toml v1.3.2 // indirect
|
github.com/BurntSushi/toml v1.3.2 // indirect
|
||||||
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
|
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||||
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||||
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect
|
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
|
||||||
|
|
@ -83,6 +90,10 @@ require (
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||||
|
github.com/go-openapi/spec v0.20.4 // indirect
|
||||||
|
github.com/go-openapi/swag v0.19.15 // indirect
|
||||||
github.com/go-playground/form/v4 v4.3.0 // indirect
|
github.com/go-playground/form/v4 v4.3.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
|
@ -103,11 +114,13 @@ require (
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.6 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/microsoft/go-mssqldb v1.8.2 // indirect
|
github.com/microsoft/go-mssqldb v1.8.2 // indirect
|
||||||
github.com/minio/crc64nvme v1.0.1 // indirect
|
github.com/minio/crc64nvme v1.0.1 // indirect
|
||||||
|
|
@ -147,13 +160,14 @@ require (
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/image v0.23.0 // indirect
|
golang.org/x/image v0.23.0 // indirect
|
||||||
golang.org/x/net v0.56.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
|
||||||
google.golang.org/grpc v1.81.1 // indirect
|
google.golang.org/grpc v1.81.1 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
modernc.org/fileutil v1.0.0 // indirect
|
modernc.org/fileutil v1.0.0 // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
|
|
||||||
38
go.sum
38
go.sum
|
|
@ -20,6 +20,12 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mx
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||||
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
||||||
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||||
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
|
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||||
|
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||||
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||||
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||||
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
|
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
|
||||||
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4=
|
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4=
|
||||||
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI=
|
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI=
|
||||||
|
|
@ -110,6 +116,8 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
github.com/gammazero/toposort v0.1.1 h1:OivGxsWxF3U3+U80VoLJ+f50HcPU1MIqE1JlKzoJ2Eg=
|
github.com/gammazero/toposort v0.1.1 h1:OivGxsWxF3U3+U80VoLJ+f50HcPU1MIqE1JlKzoJ2Eg=
|
||||||
github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNangj2NTBQDvw=
|
github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNangj2NTBQDvw=
|
||||||
|
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||||
|
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
|
|
@ -131,6 +139,16 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||||
|
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||||
|
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||||
|
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||||
|
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||||
|
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||||
|
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||||
|
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
|
@ -177,8 +195,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/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 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
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.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.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
|
@ -216,6 +232,8 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
|
||||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
|
@ -242,6 +260,10 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
|
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
|
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
|
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||||
|
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I=
|
github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I=
|
||||||
|
|
@ -270,6 +292,7 @@ github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8
|
||||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ=
|
github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ=
|
||||||
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
|
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
||||||
|
|
@ -327,6 +350,12 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||||
|
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||||
|
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
|
||||||
|
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
|
||||||
|
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||||
|
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
|
||||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
|
||||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.60 h1:/e/tmvRmfKexr/QQIBzWhOkZWsmY3EK72NrI6G/Tv0o=
|
github.com/tencentyun/cos-go-sdk-v5 v0.7.60 h1:/e/tmvRmfKexr/QQIBzWhOkZWsmY3EK72NrI6G/Tv0o=
|
||||||
|
|
@ -418,6 +447,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
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=
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
|
@ -449,6 +479,7 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|
@ -528,14 +559,17 @@ google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBN
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,10 @@ func (uc *APIUsecase) SyncAPIs(ctx context.Context, routes []*API) (*APISyncDiff
|
||||||
}
|
}
|
||||||
for _, item := range stored {
|
for _, item := range stored {
|
||||||
storedKey := key(item)
|
storedKey := key(item)
|
||||||
if routeSet[storedKey] == nil && !ignoreSet[storedKey] {
|
// Ignoring an API removes it from the in-memory route comparison. If it
|
||||||
|
// already exists in sys_apis, the reference implementation therefore
|
||||||
|
// returns it in deleteApis while also returning it in ignoreApis.
|
||||||
|
if routeSet[storedKey] == nil {
|
||||||
diff.Deleted = append(diff.Deleted, item)
|
diff.Deleted = append(diff.Deleted, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@ package biz
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SecurityConfig struct {
|
type SecurityConfig struct {
|
||||||
|
|
@ -90,33 +93,37 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *SecurityUsecase) ValidatePassword(value *SecurityConfig, password string) error {
|
func (uc *SecurityUsecase) ValidatePassword(value *SecurityConfig, password string) error {
|
||||||
if len([]rune(password)) < value.PwdMinLength {
|
if value.PwdMinLength > 0 && utf8.RuneCountInString(password) < value.PwdMinLength {
|
||||||
return errors.New("密码长度不足")
|
return fmt.Errorf("密码长度不能少于 %d 位", value.PwdMinLength)
|
||||||
}
|
}
|
||||||
hasUpper, hasLower, hasDigit, hasSpecial := false, false, false, false
|
hasUpper, hasLower, hasDigit, hasSpecial := false, false, false, false
|
||||||
for _, ch := range password {
|
for _, ch := range password {
|
||||||
switch {
|
switch {
|
||||||
case ch >= 'A' && ch <= 'Z':
|
case unicode.IsUpper(ch):
|
||||||
hasUpper = true
|
hasUpper = true
|
||||||
case ch >= 'a' && ch <= 'z':
|
case unicode.IsLower(ch):
|
||||||
hasLower = true
|
hasLower = true
|
||||||
case ch >= '0' && ch <= '9':
|
case unicode.IsDigit(ch):
|
||||||
hasDigit = true
|
hasDigit = true
|
||||||
default:
|
case unicode.IsPunct(ch) || unicode.IsSymbol(ch):
|
||||||
hasSpecial = true
|
hasSpecial = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
missing := make([]string, 0, 4)
|
||||||
if value.PwdRequireUpper && !hasUpper {
|
if value.PwdRequireUpper && !hasUpper {
|
||||||
return errors.New("密码必须包含大写字母")
|
missing = append(missing, "大写字母")
|
||||||
}
|
}
|
||||||
if value.PwdRequireLower && !hasLower {
|
if value.PwdRequireLower && !hasLower {
|
||||||
return errors.New("密码必须包含小写字母")
|
missing = append(missing, "小写字母")
|
||||||
}
|
}
|
||||||
if value.PwdRequireDigit && !hasDigit {
|
if value.PwdRequireDigit && !hasDigit {
|
||||||
return errors.New("密码必须包含数字")
|
missing = append(missing, "数字")
|
||||||
}
|
}
|
||||||
if value.PwdRequireSpecial && !hasSpecial {
|
if value.PwdRequireSpecial && !hasSpecial {
|
||||||
return errors.New("密码必须包含特殊字符")
|
missing = append(missing, "特殊字符")
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return fmt.Errorf("密码必须包含%s", strings.Join(missing, "、"))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
|
||||||
}
|
}
|
||||||
parentAllowed := value.ParentID != nil && (allowed[*value.ParentID] || *value.ParentID == actor.AuthorityID)
|
parentAllowed := value.ParentID != nil && (allowed[*value.ParentID] || *value.ParentID == actor.AuthorityID)
|
||||||
if !parentAllowed {
|
if !parentAllowed {
|
||||||
return errors.New("严格角色模式下只能在自己的角色树中复制角色")
|
return errors.New("您提交的角色ID不合法")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var count int64
|
var count int64
|
||||||
|
|
@ -168,7 +168,7 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !allowed {
|
if !allowed {
|
||||||
return errors.New("严格角色模式下不能复制当前角色未拥有的 API")
|
return errors.New("存在api不在权限列表中")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
key := api.V1 + "\x00" + api.V2
|
key := api.V1 + "\x00" + api.V2
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ func newPolicyRule(authorityID uint, path, method string) casbinRulePO {
|
||||||
}
|
}
|
||||||
|
|
||||||
func policyScope(db *gorm.DB) *gorm.DB {
|
func policyScope(db *gorm.DB) *gorm.DB {
|
||||||
return db.Where("ptype = ?", "p")
|
return db.Session(&gorm.Session{NewDB: true}).Model(&casbinRulePO{}).Where("ptype = ?", "p")
|
||||||
}
|
}
|
||||||
|
|
||||||
func deletePoliciesForAuthority(db *gorm.DB, authorityID uint) error {
|
func deletePoliciesForAuthority(db *gorm.DB, authorityID uint) error {
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,7 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||||
return fmt.Errorf("data.database and admin configuration are required")
|
return fmt.Errorf("data.database and admin configuration are required")
|
||||||
}
|
}
|
||||||
next.Admin.ConfigPath = configPath
|
next.Admin.ConfigPath = configPath
|
||||||
candidateDB, err := openDatabase(next.Data.Database, false, "")
|
candidateDB, err := openDatabase(next.Data.Database, false, "", d.logger())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reload database: %w", err)
|
return fmt.Errorf("reload database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -287,21 +287,27 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||||
return fmt.Errorf("reload storage: %w", err)
|
return fmt.Errorf("reload storage: %w", err)
|
||||||
}
|
}
|
||||||
useRedis := next.Admin.System != nil && next.Admin.System.UseRedis
|
useRedis := next.Admin.System != nil && next.Admin.System.UseRedis
|
||||||
candidateRedis := openRedis(next.Data.Redis, useRedis)
|
candidateRedis := openRedis(next.Data.Redis, useRedis, d.logger())
|
||||||
useMongo := next.Admin.System != nil && next.Admin.System.UseMongo
|
useMongo := next.Admin.System != nil && next.Admin.System.UseMongo
|
||||||
candidateMongo, mongoErr := openMongo(next.Data.Mongo, useMongo)
|
candidateMongo, mongoErr := openMongo(next.Data.Mongo, useMongo)
|
||||||
|
if mongoErr != nil {
|
||||||
|
d.logger().Error("mongo unavailable during configuration reload", "mod", "mongo", "error", mongoErr)
|
||||||
|
}
|
||||||
mongoAccepted := false
|
mongoAccepted := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if !mongoAccepted && candidateMongo != nil {
|
if !mongoAccepted && candidateMongo != nil {
|
||||||
_ = candidateMongo.Disconnect(context.Background())
|
_ = candidateMongo.Disconnect(context.Background())
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
candidateDBList, err := openDatabaseList(next.Data.DatabaseList)
|
candidateDBList, err := openDatabaseList(next.Data.DatabaseList, d.logger())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
d.gormDB.replace(candidateDB)
|
d.gormDB.replace(candidateDB, d.enqueueDataScopeAudit)
|
||||||
|
for _, item := range candidateDBList {
|
||||||
|
registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
|
||||||
|
}
|
||||||
d.replaceDatabaseList(candidateDBList)
|
d.replaceDatabaseList(candidateDBList)
|
||||||
d.redis.replace(candidateRedis)
|
d.redis.replace(candidateRedis)
|
||||||
if mongoErr == nil {
|
if mongoErr == nil {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -13,22 +12,23 @@ import (
|
||||||
// /system/reloadSystem remains responsible
|
// /system/reloadSystem remains responsible
|
||||||
// for rebuilding database, Redis, storage, and scheduled tasks.
|
// for rebuilding database, Redis, storage, and scheduled tasks.
|
||||||
func (d *Data) watchConfig() func() {
|
func (d *Data) watchConfig() func() {
|
||||||
|
logger := d.logger()
|
||||||
configPath := d.runtime.ConfigPath()
|
configPath := d.runtime.ConfigPath()
|
||||||
if configPath == "" {
|
if configPath == "" {
|
||||||
return func() {}
|
return func() {}
|
||||||
}
|
}
|
||||||
absolute, err := filepath.Abs(configPath)
|
absolute, err := filepath.Abs(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("resolve config watch path: %v", err)
|
logger.Error("resolve config watch path", "mod", "system", "error", err)
|
||||||
return func() {}
|
return func() {}
|
||||||
}
|
}
|
||||||
watcher, err := fsnotify.NewWatcher()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("create config watcher: %v", err)
|
logger.Error("create config watcher", "mod", "system", "error", err)
|
||||||
return func() {}
|
return func() {}
|
||||||
}
|
}
|
||||||
if err = watcher.Add(filepath.Dir(absolute)); err != nil {
|
if err = watcher.Add(filepath.Dir(absolute)); err != nil {
|
||||||
log.Printf("watch config directory: %v", err)
|
logger.Error("watch config directory", "mod", "system", "error", err)
|
||||||
_ = watcher.Close()
|
_ = watcher.Close()
|
||||||
return func() {}
|
return func() {}
|
||||||
}
|
}
|
||||||
|
|
@ -48,20 +48,20 @@ func (d *Data) watchConfig() func() {
|
||||||
timer = time.AfterFunc(100*time.Millisecond, func() {
|
timer = time.AfterFunc(100*time.Millisecond, func() {
|
||||||
next, loadErr := readBootstrap(absolute)
|
next, loadErr := readBootstrap(absolute)
|
||||||
if loadErr != nil {
|
if loadErr != nil {
|
||||||
log.Printf("reload changed config: %v", loadErr)
|
logger.Error("reload changed config", "mod", "system", "error", loadErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if next.Data == nil || next.Admin == nil {
|
if next.Data == nil || next.Admin == nil {
|
||||||
log.Printf("reload changed config: data and admin configuration are required")
|
logger.Error("reload changed config: data and admin configuration are required", "mod", "system")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.Admin.ConfigPath = absolute
|
next.Admin.ConfigPath = absolute
|
||||||
d.runtime.Replace(next.Data, next.Admin)
|
d.runtime.Replace(next.Data, next.Admin)
|
||||||
log.Printf("config file changed: %s", absolute)
|
logger.Info("config file changed", "mod", "system", "path", absolute)
|
||||||
})
|
})
|
||||||
case watchErr, ok := <-watcher.Errors:
|
case watchErr, ok := <-watcher.Errors:
|
||||||
if ok {
|
if ok {
|
||||||
log.Printf("config watcher error: %v", watchErr)
|
logger.Error("config watcher error", "mod", "system", "error", watchErr)
|
||||||
}
|
}
|
||||||
case <-done:
|
case <-done:
|
||||||
if timer != nil {
|
if timer != nil {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package data
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -25,15 +25,24 @@ type Data struct {
|
||||||
storage *reloadableStorage
|
storage *reloadableStorage
|
||||||
dbListMu sync.RWMutex
|
dbListMu sync.RWMutex
|
||||||
dbList map[string]*gorm.DB
|
dbList map[string]*gorm.DB
|
||||||
|
appLogger *slog.Logger
|
||||||
|
auditLog *dataScopeAuditWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
func openDatabaseList(configs []*conf.Data_Database) (map[string]*gorm.DB, error) {
|
func (d *Data) logger() *slog.Logger {
|
||||||
|
if d != nil && d.appLogger != nil {
|
||||||
|
return d.appLogger
|
||||||
|
}
|
||||||
|
return slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDatabaseList(configs []*conf.Data_Database, appLogger ...*slog.Logger) (map[string]*gorm.DB, error) {
|
||||||
items := make(map[string]*gorm.DB)
|
items := make(map[string]*gorm.DB)
|
||||||
for _, config := range configs {
|
for _, config := range configs {
|
||||||
if config == nil || config.Disable || config.AliasName == "" {
|
if config == nil || config.Disable || config.AliasName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
db, err := openDatabase(config, false, "")
|
db, err := openDatabase(config, false, "", appLogger...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
for _, opened := range items {
|
for _, opened := range items {
|
||||||
if sqlDB, dbErr := opened.DB(); dbErr == nil {
|
if sqlDB, dbErr := opened.DB(); dbErr == nil {
|
||||||
|
|
@ -42,7 +51,6 @@ func openDatabaseList(configs []*conf.Data_Database) (map[string]*gorm.DB, error
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("open database %q: %w", config.AliasName, err)
|
return nil, fmt.Errorf("open database %q: %w", config.AliasName, err)
|
||||||
}
|
}
|
||||||
registerDataScopeCallbacks(db)
|
|
||||||
items[config.AliasName] = db
|
items[config.AliasName] = db
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
|
|
@ -77,30 +85,38 @@ func (d *Data) database(name string) (*gorm.DB, error) {
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewData(runtime *conf.Runtime) (*Data, func(), error) {
|
func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), error) {
|
||||||
|
if appLogger == nil {
|
||||||
|
appLogger = slog.Default()
|
||||||
|
}
|
||||||
c := runtime.Data()
|
c := runtime.Data()
|
||||||
if c == nil || c.Database == nil {
|
if c == nil || c.Database == nil {
|
||||||
return nil, nil, fmt.Errorf("database configuration is required")
|
return nil, nil, fmt.Errorf("database configuration is required")
|
||||||
}
|
}
|
||||||
d := &Data{runtime: runtime}
|
d := &Data{runtime: runtime, appLogger: appLogger}
|
||||||
db, err := openDatabase(c.Database, false, "")
|
db, err := openDatabase(c.Database, false, "", appLogger)
|
||||||
usingFallback := false
|
usingFallback := false
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The initialization endpoint must remain available when the configured
|
// The initialization endpoint must remain available when the configured
|
||||||
// target database has not been created yet.
|
// target database has not been created yet.
|
||||||
log.Printf("configured database unavailable before initialization: %v", err)
|
appLogger.Warn("configured database unavailable before initialization", "mod", "system", "error", err)
|
||||||
db, err = openFallbackDatabase()
|
db, err = openFallbackDatabase(appLogger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("open bootstrap database: %w", err)
|
return nil, nil, fmt.Errorf("open bootstrap database: %w", err)
|
||||||
}
|
}
|
||||||
usingFallback = true
|
usingFallback = true
|
||||||
}
|
}
|
||||||
d.gormDB = newReloadableDB(db)
|
d.gormDB = newReloadableDB(db, d.enqueueDataScopeAudit)
|
||||||
d.dbList, err = openDatabaseList(c.DatabaseList)
|
d.auditLog = newDataScopeAuditWriter(d, appLogger)
|
||||||
|
d.dbList, err = openDatabaseList(c.DatabaseList, appLogger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
d.auditLog.Close()
|
||||||
d.gormDB.close()
|
d.gormDB.close()
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
for _, item := range d.dbList {
|
||||||
|
registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
|
||||||
|
}
|
||||||
admin := runtime.Admin()
|
admin := runtime.Admin()
|
||||||
disableAutoMigrate := admin != nil && admin.System != nil && admin.System.DisableAutoMigrate
|
disableAutoMigrate := admin != nil && admin.System != nil && admin.System.DisableAutoMigrate
|
||||||
if !usingFallback && !disableAutoMigrate {
|
if !usingFallback && !disableAutoMigrate {
|
||||||
|
|
@ -109,17 +125,18 @@ func NewData(runtime *conf.Runtime) (*Data, func(), error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
useRedis := admin != nil && admin.System != nil && admin.System.UseRedis
|
useRedis := admin != nil && admin.System != nil && admin.System.UseRedis
|
||||||
d.redis = newReloadableRedis(openRedis(c.Redis, useRedis))
|
d.redis = newReloadableRedis(openRedis(c.Redis, useRedis, appLogger))
|
||||||
useMongo := admin != nil && admin.System != nil && admin.System.UseMongo
|
useMongo := admin != nil && admin.System != nil && admin.System.UseMongo
|
||||||
mongoClient, err := openMongo(c.Mongo, useMongo)
|
mongoClient, err := openMongo(c.Mongo, useMongo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("mongo unavailable: %v", err)
|
appLogger.Error("mongo unavailable", "mod", "mongo", "error", err)
|
||||||
mongoClient = nil
|
mongoClient = nil
|
||||||
}
|
}
|
||||||
d.mongo = newReloadableMongo(mongoClient)
|
d.mongo = newReloadableMongo(mongoClient)
|
||||||
stopConfigWatcher := d.watchConfig()
|
stopConfigWatcher := d.watchConfig()
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
stopConfigWatcher()
|
stopConfigWatcher()
|
||||||
|
d.auditLog.Close()
|
||||||
d.gormDB.close()
|
d.gormDB.close()
|
||||||
closeDatabaseList(d.dbList)
|
closeDatabaseList(d.dbList)
|
||||||
d.redis.close()
|
d.redis.close()
|
||||||
|
|
@ -128,7 +145,7 @@ func NewData(runtime *conf.Runtime) (*Data, func(), error) {
|
||||||
return d, cleanup, nil
|
return d, cleanup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func openRedis(config *conf.Data_Redis, enabled bool) redis.UniversalClient {
|
func openRedis(config *conf.Data_Redis, enabled bool, appLogger ...*slog.Logger) redis.UniversalClient {
|
||||||
if !enabled || config == nil || (config.Addr == "" && len(config.ClusterAddrs) == 0) {
|
if !enabled || config == nil || (config.Addr == "" && len(config.ClusterAddrs) == 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +169,11 @@ func openRedis(config *conf.Data_Redis, enabled bool) redis.UniversalClient {
|
||||||
pingCtx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
|
pingCtx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := candidate.Ping(pingCtx).Err(); err != nil {
|
if err := candidate.Ping(pingCtx).Err(); err != nil {
|
||||||
log.Printf("redis unavailable, using in-memory cache: %v", err)
|
log := slog.Default()
|
||||||
|
if len(appLogger) > 0 && appLogger[0] != nil {
|
||||||
|
log = appLogger[0]
|
||||||
|
}
|
||||||
|
log.Warn("redis unavailable, using in-memory cache", "mod", "redis", "error", err)
|
||||||
_ = candidate.Close()
|
_ = candidate.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -160,6 +181,6 @@ func openRedis(config *conf.Data_Redis, enabled bool) redis.UniversalClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Data) activateDatabase(db *gorm.DB, config *conf.Data_Database) {
|
func (d *Data) activateDatabase(db *gorm.DB, config *conf.Data_Database) {
|
||||||
d.gormDB.replace(db)
|
d.gormDB.replace(db, d.enqueueDataScopeAudit)
|
||||||
d.runtime.UpdateDatabase(config)
|
d.runtime.UpdateDatabase(config)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log"
|
"log/slog"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -16,25 +16,27 @@ import (
|
||||||
// registerDataScopeCallbacks installs the global GORM data-scope engine.
|
// registerDataScopeCallbacks installs the global GORM data-scope engine.
|
||||||
// System tables are deliberately excluded: their access is controlled by
|
// System tables are deliberately excluded: their access is controlled by
|
||||||
// Casbin, while ownership columns on business tables are row-level scope.
|
// Casbin, while ownership columns on business tables are row-level scope.
|
||||||
func registerDataScopeCallbacks(db *gorm.DB) {
|
type dataScopeAuditEnqueue func(dataAccessLogPO)
|
||||||
|
|
||||||
|
func registerDataScopeCallbacks(db *gorm.DB, enqueue dataScopeAuditEnqueue) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := db.Callback().Query()
|
q := db.Callback().Query()
|
||||||
if q.Get("data_scope:query") == nil {
|
if q.Get("data_scope:query") == nil {
|
||||||
_ = q.Before("gorm:query").Register("data_scope:query", applyDataScope("query"))
|
_ = q.Before("gorm:query").Register("data_scope:query", applyDataScope("query", enqueue))
|
||||||
}
|
}
|
||||||
u := db.Callback().Update()
|
u := db.Callback().Update()
|
||||||
if u.Get("data_scope:update") == nil {
|
if u.Get("data_scope:update") == nil {
|
||||||
_ = u.Before("gorm:update").Register("data_scope:update", applyDataScope("update"))
|
_ = u.Before("gorm:update").Register("data_scope:update", applyDataScope("update", enqueue))
|
||||||
_ = u.Before("gorm:update").Register("data_scope:stamp_update", stampUpdatedBy)
|
_ = u.Before("gorm:update").Register("data_scope:stamp_update", stampUpdatedBy)
|
||||||
_ = u.After("gorm:update").Register("data_scope:audit_update", auditBlockedWrite("update"))
|
_ = u.After("gorm:update").Register("data_scope:audit_update", auditBlockedWrite("update", enqueue))
|
||||||
}
|
}
|
||||||
d := db.Callback().Delete()
|
d := db.Callback().Delete()
|
||||||
if d.Get("data_scope:delete") == nil {
|
if d.Get("data_scope:delete") == nil {
|
||||||
_ = d.Before("gorm:delete").Register("data_scope:delete", applyDataScope("delete"))
|
_ = d.Before("gorm:delete").Register("data_scope:delete", applyDataScope("delete", enqueue))
|
||||||
_ = d.Before("gorm:delete").After("data_scope:delete").Register("data_scope:stamp_delete", stampDeletedBy)
|
_ = d.Before("gorm:delete").After("data_scope:delete").Register("data_scope:stamp_delete", stampDeletedBy)
|
||||||
_ = d.After("gorm:delete").Register("data_scope:audit_delete", auditBlockedWrite("delete"))
|
_ = d.After("gorm:delete").Register("data_scope:audit_delete", auditBlockedWrite("delete", enqueue))
|
||||||
}
|
}
|
||||||
c := db.Callback().Create()
|
c := db.Callback().Create()
|
||||||
if c.Get("data_scope:stamp") == nil {
|
if c.Get("data_scope:stamp") == nil {
|
||||||
|
|
@ -50,7 +52,7 @@ func isControlledTable(db *gorm.DB) bool {
|
||||||
return db.Statement.Schema != nil && !strings.HasPrefix(db.Statement.Table, "sys_") && (hasScopeField(db, "dept_id") || hasScopeField(db, "created_by"))
|
return db.Statement.Schema != nil && !strings.HasPrefix(db.Statement.Table, "sys_") && (hasScopeField(db, "dept_id") || hasScopeField(db, "created_by"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyDataScope(operation string) func(*gorm.DB) {
|
func applyDataScope(operation string, enqueue dataScopeAuditEnqueue) func(*gorm.DB) {
|
||||||
return func(db *gorm.DB) {
|
return func(db *gorm.DB) {
|
||||||
if !isControlledTable(db) {
|
if !isControlledTable(db) {
|
||||||
return
|
return
|
||||||
|
|
@ -65,8 +67,8 @@ func applyDataScope(operation string) func(*gorm.DB) {
|
||||||
}
|
}
|
||||||
scope, ok := biz.DataScopeFromContext(db.Statement.Context)
|
scope, ok := biz.DataScopeFromContext(db.Statement.Context)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("数据权限: 业务表[%s]访问无身份上下文, 已放行(待补 ctx / 或使用系统上下文)", db.Statement.Table)
|
slog.WarnContext(db.Statement.Context, "数据权限: 业务表访问无身份上下文, 已放行(待补 ctx / 或使用系统上下文)", "mod", "data-scope", "table", db.Statement.Table)
|
||||||
recordDataScopeEvent(db, "no_identity", operation, "无身份上下文访问受控表, 已放行", biz.DataScope{})
|
recordDataScopeEvent(db, enqueue, "no_identity", operation, "无身份上下文访问受控表, 已放行", biz.DataScope{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (operation == "update" || operation == "delete") && !db.AllowGlobalUpdate && !hasWriteConditions(db) {
|
if (operation == "update" || operation == "delete") && !db.AllowGlobalUpdate && !hasWriteConditions(db) {
|
||||||
|
|
@ -91,18 +93,20 @@ func applyDataScope(operation string) func(*gorm.DB) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordDataScopeEvent(db *gorm.DB, eventType, operation, detail string, scope biz.DataScope) {
|
func recordDataScopeEvent(db *gorm.DB, enqueue dataScopeAuditEnqueue, eventType, operation, detail string, scope biz.DataScope) {
|
||||||
record := &dataAccessLogPO{EventType: eventType, TargetTable: db.Statement.Table, Operation: operation, UserID: scope.UserID, AuthorityID: scope.AuthorityID, Scope: scope.Scope, RequestID: scope.RequestID, Method: scope.Method, Path: scope.Path, Detail: detail}
|
if enqueue == nil {
|
||||||
_ = db.Session(&gorm.Session{NewDB: true, SkipHooks: true}).Create(record).Error
|
return
|
||||||
|
}
|
||||||
|
enqueue(dataAccessLogPO{EventType: eventType, TargetTable: db.Statement.Table, Operation: operation, UserID: scope.UserID, AuthorityID: scope.AuthorityID, Scope: scope.Scope, RequestID: scope.RequestID, Method: scope.Method, Path: scope.Path, Detail: detail})
|
||||||
}
|
}
|
||||||
|
|
||||||
func auditBlockedWrite(operation string) func(*gorm.DB) {
|
func auditBlockedWrite(operation string, enqueue dataScopeAuditEnqueue) func(*gorm.DB) {
|
||||||
return func(db *gorm.DB) {
|
return func(db *gorm.DB) {
|
||||||
if _, applied := db.Statement.Clauses["data_scope:applied"]; !applied || db.Error != nil || db.RowsAffected != 0 {
|
if _, applied := db.Statement.Clauses["data_scope:applied"]; !applied || db.Error != nil || db.RowsAffected != 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if scope, ok := biz.DataScopeFromContext(db.Statement.Context); ok && !scope.All {
|
if scope, ok := biz.DataScopeFromContext(db.Statement.Context); ok && !scope.All {
|
||||||
recordDataScopeEvent(db, "blocked_write", operation, "数据范围过滤后写操作影响 0 行(疑似越权尝试)", scope)
|
recordDataScopeEvent(db, enqueue, "blocked_write", operation, "数据范围过滤后写操作影响 0 行(疑似越权尝试)", scope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dataScopeAuditQueueSize = 1024
|
||||||
|
dataScopeAuditBatchSize = 100
|
||||||
|
dataScopeAuditInterval = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// dataScopeAuditWriter keeps row-scope auditing best-effort: callbacks only
|
||||||
|
// enqueue, while a background worker writes batches to the active database.
|
||||||
|
type dataScopeAuditWriter struct {
|
||||||
|
data *Data
|
||||||
|
logger *slog.Logger
|
||||||
|
queue chan dataAccessLogPO
|
||||||
|
batchSize int
|
||||||
|
interval time.Duration
|
||||||
|
stop chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDataScopeAuditWriter(data *Data, logger *slog.Logger) *dataScopeAuditWriter {
|
||||||
|
return newDataScopeAuditWriterWithOptions(data, logger, dataScopeAuditQueueSize, dataScopeAuditBatchSize, dataScopeAuditInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDataScopeAuditWriterWithOptions(data *Data, logger *slog.Logger, queueSize, batchSize int, interval time.Duration) *dataScopeAuditWriter {
|
||||||
|
w := &dataScopeAuditWriter{
|
||||||
|
data: data,
|
||||||
|
logger: logger,
|
||||||
|
queue: make(chan dataAccessLogPO, queueSize),
|
||||||
|
batchSize: batchSize,
|
||||||
|
interval: interval,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go w.run()
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Data) enqueueDataScopeAudit(record dataAccessLogPO) {
|
||||||
|
if d == nil || d.auditLog == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.auditLog.Enqueue(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dataScopeAuditWriter) Enqueue(record dataAccessLogPO) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case w.queue <- record:
|
||||||
|
default:
|
||||||
|
w.log().Warn("数据权限审计缓冲已满, 事件被丢弃", "mod", "data-scope", "event_type", record.EventType, "table", record.TargetTable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dataScopeAuditWriter) Close() {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.closeOnce.Do(func() { close(w.stop) })
|
||||||
|
<-w.done
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dataScopeAuditWriter) run() {
|
||||||
|
defer close(w.done)
|
||||||
|
ticker := time.NewTicker(w.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
batch := make([]dataAccessLogPO, 0, w.batchSize)
|
||||||
|
flush := func() {
|
||||||
|
if len(batch) == 0 || w.data == nil || w.data.gormDB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := w.data.gormDB.DB()
|
||||||
|
if db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.WithContext(context.Background()).Session(&gorm.Session{NewDB: true, SkipHooks: true}).Create(&batch).Error; err != nil {
|
||||||
|
w.log().Warn("数据权限审计批量写入失败", "mod", "data-scope", "error", err)
|
||||||
|
}
|
||||||
|
batch = batch[:0]
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case record := <-w.queue:
|
||||||
|
batch = append(batch, record)
|
||||||
|
if len(batch) >= w.batchSize {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
flush()
|
||||||
|
case <-w.stop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case record := <-w.queue:
|
||||||
|
batch = append(batch, record)
|
||||||
|
default:
|
||||||
|
flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dataScopeAuditWriter) log() *slog.Logger {
|
||||||
|
if w != nil && w.logger != nil {
|
||||||
|
return w.logger
|
||||||
|
}
|
||||||
|
return slog.Default()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openDataScopeAuditTestDB(t *testing.T, name string) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&dataAccessLogPO{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForDataScopeAuditCount(t *testing.T, db *gorm.DB, want int64) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&dataAccessLogPO{}).Count(&count).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("data access log count did not reach %d", want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataScopeAuditWriterFlushesBatch(t *testing.T) {
|
||||||
|
db := openDataScopeAuditTestDB(t, "data-scope-audit-batch")
|
||||||
|
d := &Data{gormDB: newReloadableDB(db, nil)}
|
||||||
|
w := newDataScopeAuditWriterWithOptions(d, slog.New(slog.NewTextHandler(io.Discard, nil)), 8, 2, time.Hour)
|
||||||
|
d.auditLog = w
|
||||||
|
t.Cleanup(w.Close)
|
||||||
|
|
||||||
|
d.enqueueDataScopeAudit(dataAccessLogPO{EventType: "no_identity", TargetTable: "example"})
|
||||||
|
d.enqueueDataScopeAudit(dataAccessLogPO{EventType: "blocked_write", TargetTable: "example"})
|
||||||
|
waitForDataScopeAuditCount(t, db, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataScopeAuditWriterUsesReloadedDatabase(t *testing.T) {
|
||||||
|
first := openDataScopeAuditTestDB(t, "data-scope-audit-first")
|
||||||
|
second := openDataScopeAuditTestDB(t, "data-scope-audit-second")
|
||||||
|
d := &Data{gormDB: newReloadableDB(first, nil)}
|
||||||
|
w := newDataScopeAuditWriterWithOptions(d, slog.New(slog.NewTextHandler(io.Discard, nil)), 8, 100, 10*time.Millisecond)
|
||||||
|
d.auditLog = w
|
||||||
|
t.Cleanup(w.Close)
|
||||||
|
|
||||||
|
d.enqueueDataScopeAudit(dataAccessLogPO{EventType: "no_identity", TargetTable: "example"})
|
||||||
|
d.gormDB.replace(second, nil)
|
||||||
|
waitForDataScopeAuditCount(t, second, 1)
|
||||||
|
|
||||||
|
var firstCount int64
|
||||||
|
if err := first.Model(&dataAccessLogPO{}).Count(&firstCount).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if firstCount != 0 {
|
||||||
|
t.Fatalf("audit log was written to retired database: %d", firstCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -102,7 +103,7 @@ func databaseDSN(c *conf.Data_Database, name string) (string, error) {
|
||||||
return "", fmt.Errorf("unsupported database driver %q", c.Driver)
|
return "", fmt.Errorf("unsupported database driver %q", c.Driver)
|
||||||
}
|
}
|
||||||
|
|
||||||
func gormConfig(config *conf.Data_Database) *gorm.Config {
|
func gormConfig(config *conf.Data_Database, appLogger ...*slog.Logger) *gorm.Config {
|
||||||
level := logger.Info
|
level := logger.Info
|
||||||
switch strings.ToLower(config.LogMode) {
|
switch strings.ToLower(config.LogMode) {
|
||||||
case "silent":
|
case "silent":
|
||||||
|
|
@ -112,15 +113,19 @@ func gormConfig(config *conf.Data_Database) *gorm.Config {
|
||||||
case "warn":
|
case "warn":
|
||||||
level = logger.Warn
|
level = logger.Warn
|
||||||
}
|
}
|
||||||
return &gorm.Config{Logger: logger.Default.LogMode(level), NamingStrategy: schema.NamingStrategy{TablePrefix: config.Prefix, SingularTable: config.Singular}}
|
var log *slog.Logger
|
||||||
|
if len(appLogger) > 0 {
|
||||||
|
log = appLogger[0]
|
||||||
|
}
|
||||||
|
return &gorm.Config{Logger: newGormLogger(log, level), NamingStrategy: schema.NamingStrategy{TablePrefix: config.Prefix, SingularTable: config.Singular}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func openWithDriver(driver, dsn string) (*gorm.DB, error) {
|
func openWithDriver(driver, dsn string, appLogger ...*slog.Logger) (*gorm.DB, error) {
|
||||||
return openWithDriverConfig(driver, dsn, &conf.Data_Database{Driver: driver})
|
return openWithDriverConfig(driver, dsn, &conf.Data_Database{Driver: driver}, appLogger...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func openWithDriverConfig(driver, dsn string, config *conf.Data_Database) (*gorm.DB, error) {
|
func openWithDriverConfig(driver, dsn string, config *conf.Data_Database, appLogger ...*slog.Logger) (*gorm.DB, error) {
|
||||||
gormConfig := gormConfig(config)
|
gormConfig := gormConfig(config, appLogger...)
|
||||||
var db *gorm.DB
|
var db *gorm.DB
|
||||||
var err error
|
var err error
|
||||||
switch normalizedDriver(driver) {
|
switch normalizedDriver(driver) {
|
||||||
|
|
@ -159,7 +164,7 @@ func openWithDriverConfig(driver, dsn string, config *conf.Data_Database) (*gorm
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func openDatabase(c *conf.Data_Database, create bool, template string) (*gorm.DB, error) {
|
func openDatabase(c *conf.Data_Database, create bool, template string, appLogger ...*slog.Logger) (*gorm.DB, error) {
|
||||||
driver := normalizedDriver(c.Driver)
|
driver := normalizedDriver(c.Driver)
|
||||||
if driver == "" {
|
if driver == "" {
|
||||||
return nil, fmt.Errorf("unsupported database driver %q", c.Driver)
|
return nil, fmt.Errorf("unsupported database driver %q", c.Driver)
|
||||||
|
|
@ -172,7 +177,7 @@ func openDatabase(c *conf.Data_Database, create bool, template string) (*gorm.DB
|
||||||
if err = os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil {
|
if err = os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return openWithDriverConfig(driver, dsn, c)
|
return openWithDriverConfig(driver, dsn, c, appLogger...)
|
||||||
}
|
}
|
||||||
if create && driver != "oracle" {
|
if create && driver != "oracle" {
|
||||||
if !databaseNamePattern.MatchString(c.Name) {
|
if !databaseNamePattern.MatchString(c.Name) {
|
||||||
|
|
@ -189,7 +194,7 @@ func openDatabase(c *conf.Data_Database, create bool, template string) (*gorm.DB
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
adminDB, err := openWithDriverConfig(driver, dsn, c)
|
adminDB, err := openWithDriverConfig(driver, dsn, c, appLogger...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("connect database server: %w", err)
|
return nil, fmt.Errorf("connect database server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -225,9 +230,9 @@ func openDatabase(c *conf.Data_Database, create bool, template string) (*gorm.DB
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return openWithDriverConfig(driver, dsn, c)
|
return openWithDriverConfig(driver, dsn, c, appLogger...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func openFallbackDatabase() (*gorm.DB, error) {
|
func openFallbackDatabase(appLogger ...*slog.Logger) (*gorm.DB, error) {
|
||||||
return openWithDriver("sqlite", "file:kra-bootstrap?mode=memory&cache=shared")
|
return openWithDriver("sqlite", "file:kra-bootstrap?mode=memory&cache=shared", appLogger...)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -389,9 +389,11 @@ func (r *dictionaryRepo) ListDictionaryDetails(ctx context.Context, page, size i
|
||||||
}
|
}
|
||||||
func (r *dictionaryRepo) DictionaryDetailTree(ctx context.Context, dictionaryID uint, typ string) ([]*biz.DictionaryDetail, error) {
|
func (r *dictionaryRepo) DictionaryDetailTree(ctx context.Context, dictionaryID uint, typ string) ([]*biz.DictionaryDetail, error) {
|
||||||
if dictionaryID == 0 {
|
if dictionaryID == 0 {
|
||||||
active := true
|
// The tree-by-type endpoint resolves only by dictionary type. Unlike the
|
||||||
dictionary, err := r.FindDictionary(ctx, 0, typ, &active, false)
|
// public dictionary lookup, it does not require the dictionary itself to
|
||||||
if err != nil {
|
// be enabled.
|
||||||
|
var dictionary dictionaryPO
|
||||||
|
if err := r.data.gormDB.WithContext(ctx).Where("type = ?", typ).First(&dictionary).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dictionaryID = dictionary.ID
|
dictionaryID = dictionary.ID
|
||||||
|
|
@ -409,7 +411,7 @@ func (r *dictionaryRepo) DictionaryDetailTree(ctx context.Context, dictionaryID
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item.ParentID != nil && byID[*item.ParentID] != nil {
|
if item.ParentID != nil && byID[*item.ParentID] != nil {
|
||||||
byID[*item.ParentID].Children = append(byID[*item.ParentID].Children, item)
|
byID[*item.ParentID].Children = append(byID[*item.ParentID].Children, item)
|
||||||
} else {
|
} else if item.ParentID == nil {
|
||||||
roots = append(roots, item)
|
roots = append(roots, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gormLogger forwards GORM diagnostics through the application logger so SQL
|
||||||
|
// entries participate in the same daily/category files and error sink as the
|
||||||
|
// rest of the service. Slow queries use the administration contract's 200ms
|
||||||
|
// threshold.
|
||||||
|
type gormLogger struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
slowThreshold time.Duration
|
||||||
|
level logger.LogLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGormLogger(log *slog.Logger, level logger.LogLevel) *gormLogger {
|
||||||
|
if log == nil {
|
||||||
|
log = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
}
|
||||||
|
return &gormLogger{logger: log, slowThreshold: 200 * time.Millisecond, level: level}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gormLogger) LogMode(level logger.LogLevel) logger.Interface {
|
||||||
|
next := *g
|
||||||
|
next.level = level
|
||||||
|
return &next
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gormLogger) Info(ctx context.Context, message string, args ...any) {
|
||||||
|
g.logger.InfoContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gormLogger) Warn(ctx context.Context, message string, args ...any) {
|
||||||
|
g.logger.WarnContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gormLogger) Error(ctx context.Context, message string, args ...any) {
|
||||||
|
g.logger.ErrorContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gormLogger) Trace(ctx context.Context, begin time.Time, query func() (string, int64), queryErr error) {
|
||||||
|
if g.level <= logger.Silent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elapsed := time.Since(begin)
|
||||||
|
sql, rows := query()
|
||||||
|
fields := []any{"mod", "sql", "gorm_logger", true, "sql", sql, "rows", rows, "elapsed_ms", elapsed.Milliseconds()}
|
||||||
|
switch {
|
||||||
|
case queryErr != nil && g.level >= logger.Error && !errors.Is(queryErr, logger.ErrRecordNotFound):
|
||||||
|
fields = append(fields, "error", queryErr)
|
||||||
|
g.logger.ErrorContext(ctx, "SQL 执行错误", fields...)
|
||||||
|
case elapsed > g.slowThreshold && g.level >= logger.Warn:
|
||||||
|
g.logger.WarnContext(ctx, "SQL 慢查询", fields...)
|
||||||
|
case g.level >= logger.Info:
|
||||||
|
g.logger.InfoContext(ctx, "SQL", fields...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"kra/pkg/logging"
|
||||||
|
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGORMLoggerUsesApplicationCategories(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
appLogger, cleanup := logging.NewZapLogger(root, "application.log", logging.Options{Level: "info", Format: "json"})
|
||||||
|
databaseLogger := newGormLogger(appLogger, logger.Info)
|
||||||
|
databaseLogger.Trace(context.Background(), time.Now(), func() (string, int64) { return "SELECT 1", 1 }, nil)
|
||||||
|
databaseLogger.Trace(context.Background(), time.Now(), func() (string, int64) { return "SELECT missing", 0 }, errors.New("database failure"))
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
date := time.Now().Format("2006-01-02")
|
||||||
|
for _, name := range []string{"application.log", filepath.Join("sql", "application.log"), filepath.Join("error", "error.log")} {
|
||||||
|
info, err := os.Stat(filepath.Join(root, date, name))
|
||||||
|
if err != nil || info.Size() == 0 {
|
||||||
|
t.Fatalf("expected non-empty GORM log %s: info=%v err=%v", name, info, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"kra/internal/biz"
|
||||||
|
"kra/internal/conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogViewerReadsNestedCategoryFiles(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
date := "2026-08-16"
|
||||||
|
path := filepath.Join(root, date, "http", "access.log")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("first\nsecond\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{Director: root}})
|
||||||
|
repo := &logFileRepo{data: &Data{runtime: runtime}}
|
||||||
|
files, err := repo.LogFiles(context.Background(), date)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 || files[0].Path != filepath.Join("http", "access.log") {
|
||||||
|
t.Fatalf("unexpected nested log files: %+v", files)
|
||||||
|
}
|
||||||
|
content, err := repo.LogContent(context.Background(), date, "http/access.log", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if content.Content != "first\nsecond\n" || content.LineCount != 2 || content.HasMore {
|
||||||
|
t.Fatalf("unexpected log content: %+v", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogViewerRejectsPathTraversal(t *testing.T) {
|
||||||
|
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{Director: t.TempDir()}})
|
||||||
|
repo := &logFileRepo{data: &Data{runtime: runtime}}
|
||||||
|
_, err := repo.LogContent(context.Background(), "2026-08-16", "../application.log", nil)
|
||||||
|
if !errors.Is(err, biz.ErrInvalidLogPath) {
|
||||||
|
t.Fatalf("expected invalid path error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,7 @@ func migrateAll(db *gorm.DB) error {
|
||||||
// Older builds used a status label outside the administration page's supported
|
// Older builds used a status label outside the administration page's supported
|
||||||
// state set, so normalize existing rows during migration.
|
// state set, so normalize existing rows during migration.
|
||||||
func normalizeErrorRecordStatuses(db *gorm.DB) error {
|
func normalizeErrorRecordStatuses(db *gorm.DB) error {
|
||||||
return db.Model(&errorRecordPO{}).Where("status = ?", "未解决").Update("status", "未处理").Error
|
return db.Session(&gorm.Session{NewDB: true}).Model(&errorRecordPO{}).Where("status = ?", "未解决").Update("status", "未处理").Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateLegacyAuthorityAPIsToCasbinRules upgrades the early Kra join-table
|
// migrateLegacyAuthorityAPIsToCasbinRules upgrades the early Kra join-table
|
||||||
|
|
@ -46,7 +46,8 @@ func normalizeErrorRecordStatuses(db *gorm.DB) error {
|
||||||
// table in place for backwards compatibility, but make casbin_rule the sole
|
// table in place for backwards compatibility, but make casbin_rule the sole
|
||||||
// live policy source. Existing policy rows are not duplicated.
|
// live policy source. Existing policy rows are not duplicated.
|
||||||
func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
|
func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
|
||||||
if !db.Migrator().HasTable(&authorityAPIPO{}) || !db.Migrator().HasTable(&casbinRulePO{}) {
|
clean := db.Session(&gorm.Session{NewDB: true})
|
||||||
|
if !clean.Migrator().HasTable(&authorityAPIPO{}) || !clean.Migrator().HasTable(&casbinRulePO{}) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
type legacyPolicy struct {
|
type legacyPolicy struct {
|
||||||
|
|
@ -54,22 +55,28 @@ func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
|
||||||
Path string
|
Path string
|
||||||
Method string
|
Method string
|
||||||
}
|
}
|
||||||
var rows []legacyPolicy
|
query := clean.Table("sys_authority_apis sa").
|
||||||
if err := db.Table("sys_authority_apis sa").
|
|
||||||
Select("sa.authority_id, a.path, a.method").
|
Select("sa.authority_id, a.path, a.method").
|
||||||
Joins("JOIN sys_apis a ON a.id = sa.api_id").
|
Joins("JOIN sys_apis a ON a.id = sa.api_id")
|
||||||
Where("a.deleted_at IS NULL").Find(&rows).Error; err != nil {
|
// Early Kra schemas stored sys_apis without soft-delete timestamps. The
|
||||||
|
// legacy-policy migration must run before assuming that column exists;
|
||||||
|
// otherwise an upgrade from those schemas cannot start on MySQL.
|
||||||
|
if clean.Migrator().HasColumn(&apiPO{}, "deleted_at") {
|
||||||
|
query = query.Where("a.deleted_at IS NULL")
|
||||||
|
}
|
||||||
|
var rows []legacyPolicy
|
||||||
|
if err := query.Find(&rows).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
exists, err := policyExists(db, row.AuthorityID, row.Path, row.Method)
|
exists, err := policyExists(clean, row.AuthorityID, row.Path, row.Method)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := db.Create(&casbinRulePO{Ptype: "p", V0: fmt.Sprint(row.AuthorityID), V1: row.Path, V2: row.Method}).Error; err != nil {
|
if err := clean.Create(&casbinRulePO{Ptype: "p", V0: fmt.Sprint(row.AuthorityID), V1: row.Path, V2: row.Method}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +89,8 @@ func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
|
||||||
// composite primary key portably, so rebuild the small table once while
|
// composite primary key portably, so rebuild the small table once while
|
||||||
// preserving every existing ignore rule.
|
// preserving every existing ignore rule.
|
||||||
func migrateLegacyIgnoreAPITable(db *gorm.DB) error {
|
func migrateLegacyIgnoreAPITable(db *gorm.DB) error {
|
||||||
if !db.Migrator().HasTable(&ignoredAPIPO{}) || db.Migrator().HasColumn(&ignoredAPIPO{}, "id") {
|
clean := db.Session(&gorm.Session{NewDB: true})
|
||||||
|
if !clean.Migrator().HasTable(&ignoredAPIPO{}) || clean.Migrator().HasColumn(&ignoredAPIPO{}, "id") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
legacyTable := fmt.Sprintf("sys_ignore_apis_legacy_%d", time.Now().UnixNano())
|
legacyTable := fmt.Sprintf("sys_ignore_apis_legacy_%d", time.Now().UnixNano())
|
||||||
|
|
@ -90,7 +98,7 @@ func migrateLegacyIgnoreAPITable(db *gorm.DB) error {
|
||||||
Path string
|
Path string
|
||||||
Method string
|
Method string
|
||||||
}
|
}
|
||||||
return db.Transaction(func(tx *gorm.DB) error {
|
return clean.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Migrator().RenameTable(ignoredAPIPO{}.TableName(), legacyTable); err != nil {
|
if err := tx.Migrator().RenameTable(ignoredAPIPO{}.TableName(), legacyTable); err != nil {
|
||||||
return fmt.Errorf("rename legacy ignore API table: %w", err)
|
return fmt.Errorf("rename legacy ignore API table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -123,16 +131,17 @@ func migrateLegacyIgnoreAPITable(db *gorm.DB) error {
|
||||||
// database has the root role but no stored API links, materialize the same
|
// database has the root role but no stored API links, materialize the same
|
||||||
// policy set and let normal authorization read it thereafter.
|
// policy set and let normal authorization read it thereafter.
|
||||||
func reconcileRootAuthorityAPIs(db *gorm.DB) error {
|
func reconcileRootAuthorityAPIs(db *gorm.DB) error {
|
||||||
|
clean := db.Session(&gorm.Session{NewDB: true})
|
||||||
var authorityCount int64
|
var authorityCount int64
|
||||||
if err := db.Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil || authorityCount == 0 {
|
if err := clean.Session(&gorm.Session{NewDB: true}).Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil || authorityCount == 0 {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var policyCount int64
|
var policyCount int64
|
||||||
if err := policyScope(db).Where("v0 = ?", "888").Count(&policyCount).Error; err != nil || policyCount != 0 {
|
if err := policyScope(clean).Where("v0 = ?", "888").Count(&policyCount).Error; err != nil || policyCount != 0 {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var ignored []ignoredAPIPO
|
var ignored []ignoredAPIPO
|
||||||
if err := db.Find(&ignored).Error; err != nil {
|
if err := clean.Session(&gorm.Session{NewDB: true}).Find(&ignored).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ignoreSet := make(map[string]struct{}, len(ignored))
|
ignoreSet := make(map[string]struct{}, len(ignored))
|
||||||
|
|
@ -140,7 +149,7 @@ func reconcileRootAuthorityAPIs(db *gorm.DB) error {
|
||||||
ignoreSet[item.Method+"\x00"+item.Path] = struct{}{}
|
ignoreSet[item.Method+"\x00"+item.Path] = struct{}{}
|
||||||
}
|
}
|
||||||
var apis []apiPO
|
var apis []apiPO
|
||||||
if err := db.Find(&apis).Error; err != nil {
|
if err := clean.Session(&gorm.Session{NewDB: true}).Find(&apis).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rules := make([]casbinRulePO, 0, len(apis))
|
rules := make([]casbinRulePO, 0, len(apis))
|
||||||
|
|
@ -153,13 +162,14 @@ func reconcileRootAuthorityAPIs(db *gorm.DB) error {
|
||||||
if len(rules) == 0 {
|
if len(rules) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return db.Create(&rules).Error
|
return clean.Session(&gorm.Session{NewDB: true}).Create(&rules).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileReferenceIndexes removes constraints created by older Kra builds
|
// reconcileReferenceIndexes removes constraints created by older Kra builds
|
||||||
// that are not part of the administration data model. Business services own
|
// that are not part of the administration data model. Business services own
|
||||||
// duplicate checks and their user-facing error messages.
|
// duplicate checks and their user-facing error messages.
|
||||||
func reconcileReferenceIndexes(db *gorm.DB) error {
|
func reconcileReferenceIndexes(db *gorm.DB) error {
|
||||||
|
clean := db.Session(&gorm.Session{NewDB: true})
|
||||||
obsolete := []struct {
|
obsolete := []struct {
|
||||||
model any
|
model any
|
||||||
name string
|
name string
|
||||||
|
|
@ -171,8 +181,9 @@ func reconcileReferenceIndexes(db *gorm.DB) error {
|
||||||
{&exportTemplatePO{}, "idx_sys_export_templates_template_id"},
|
{&exportTemplatePO{}, "idx_sys_export_templates_template_id"},
|
||||||
}
|
}
|
||||||
for _, item := range obsolete {
|
for _, item := range obsolete {
|
||||||
if db.Migrator().HasIndex(item.model, item.name) {
|
migrator := clean.Session(&gorm.Session{NewDB: true}).Migrator()
|
||||||
if err := db.Migrator().DropIndex(item.model, item.name); err != nil {
|
if migrator.HasIndex(item.model, item.name) {
|
||||||
|
if err := migrator.DropIndex(item.model, item.name); err != nil {
|
||||||
return fmt.Errorf("drop obsolete index %s: %w", item.name, err)
|
return fmt.Errorf("drop obsolete index %s: %w", item.name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -181,17 +192,18 @@ func reconcileReferenceIndexes(db *gorm.DB) error {
|
||||||
name string
|
name string
|
||||||
field string
|
field string
|
||||||
}{{"idx_sys_users_uuid", "UUID"}, {"idx_sys_users_username", "Username"}} {
|
}{{"idx_sys_users_uuid", "UUID"}, {"idx_sys_users_username", "Username"}} {
|
||||||
unique, err := indexIsUnique(db, &userPO{}, item.name)
|
unique, err := indexIsUnique(clean.Session(&gorm.Session{NewDB: true}), &userPO{}, item.name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !unique {
|
if !unique {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err = db.Migrator().DropIndex(&userPO{}, item.name); err != nil {
|
migrator := clean.Session(&gorm.Session{NewDB: true}).Migrator()
|
||||||
|
if err = migrator.DropIndex(&userPO{}, item.name); err != nil {
|
||||||
return fmt.Errorf("drop legacy unique index %s: %w", item.name, err)
|
return fmt.Errorf("drop legacy unique index %s: %w", item.name, err)
|
||||||
}
|
}
|
||||||
if err = db.Migrator().CreateIndex(&userPO{}, item.field); err != nil {
|
if err = migrator.CreateIndex(&userPO{}, item.field); err != nil {
|
||||||
return fmt.Errorf("create reference index %s: %w", item.name, err)
|
return fmt.Errorf("create reference index %s: %w", item.name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,9 @@ func (r *reloadableMongo) close() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newReloadableDB(db *gorm.DB) *reloadableDB {
|
func newReloadableDB(db *gorm.DB, enqueue dataScopeAuditEnqueue) *reloadableDB {
|
||||||
r := &reloadableDB{}
|
r := &reloadableDB{}
|
||||||
registerDataScopeCallbacks(db)
|
registerDataScopeCallbacks(db, enqueue)
|
||||||
r.current.Store(db)
|
r.current.Store(db)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
@ -62,8 +62,8 @@ func (r *reloadableDB) WithContext(ctx context.Context) *gorm.DB {
|
||||||
|
|
||||||
func (r *reloadableDB) DB() *gorm.DB { return r.current.Load() }
|
func (r *reloadableDB) DB() *gorm.DB { return r.current.Load() }
|
||||||
|
|
||||||
func (r *reloadableDB) replace(db *gorm.DB) {
|
func (r *reloadableDB) replace(db *gorm.DB, enqueue dataScopeAuditEnqueue) {
|
||||||
registerDataScopeCallbacks(db)
|
registerDataScopeCallbacks(db, enqueue)
|
||||||
old := r.current.Swap(db)
|
old := r.current.Swap(db)
|
||||||
if old != nil && old != db {
|
if old != nil && old != db {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
||||||
if initialized {
|
if initialized {
|
||||||
return errors.New("数据库已初始化,无需重复初始化")
|
return errors.New("数据库已初始化,无需重复初始化")
|
||||||
}
|
}
|
||||||
candidate, err := openDatabase(config, true, input.Template)
|
candidate, err := openDatabase(config, true, input.Template, r.data.logger())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -251,6 +251,7 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
||||||
func defaultIgnoredAPIs() []ignoredAPIPO {
|
func defaultIgnoredAPIs() []ignoredAPIPO {
|
||||||
return []ignoredAPIPO{
|
return []ignoredAPIPO{
|
||||||
{Method: "GET", Path: "/api/freshCasbin"}, {Method: "GET", Path: "/health"},
|
{Method: "GET", Path: "/api/freshCasbin"}, {Method: "GET", Path: "/health"},
|
||||||
|
{Method: "GET", Path: "/swagger/*any"},
|
||||||
{Method: "POST", Path: "/system/reloadSystem"}, {Method: "POST", Path: "/base/login"},
|
{Method: "POST", Path: "/system/reloadSystem"}, {Method: "POST", Path: "/base/login"},
|
||||||
{Method: "POST", Path: "/base/captcha"}, {Method: "POST", Path: "/init/initdb"},
|
{Method: "POST", Path: "/base/captcha"}, {Method: "POST", Path: "/init/initdb"},
|
||||||
{Method: "POST", Path: "/init/checkdb"}, {Method: "GET", Path: "/info/getInfoDataSource"},
|
{Method: "POST", Path: "/init/checkdb"}, {Method: "GET", Path: "/info/getInfoDataSource"},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package data
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDefaultIgnoredAPIsIncludeSwagger(t *testing.T) {
|
||||||
|
for _, api := range defaultIgnoredAPIs() {
|
||||||
|
if api.Method == "GET" && api.Path == "/swagger/*any" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("default ignored APIs do not include the Swagger handler")
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,7 @@ func newTransactionTestData(t *testing.T) *Data {
|
||||||
if err = migrateAll(db); err != nil {
|
if err = migrateAll(db); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
data := &Data{gormDB: newReloadableDB(db), redis: newReloadableRedis(nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})}
|
data := &Data{gormDB: newReloadableDB(db, nil), redis: newReloadableRedis(nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})}
|
||||||
t.Cleanup(func() { data.gormDB.close() })
|
t.Cleanup(func() { data.gormDB.close() })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import (
|
||||||
func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine {
|
func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
engine.Use(servermiddleware.RequestMeta(), servermiddleware.Recovery(audit, logger), servermiddleware.AccessLog(runtime, logger, version), servermiddleware.CORS(runtime), servermiddleware.ErrorAudit(audit), servermiddleware.SecurityRateLimit(security))
|
engine.Use(servermiddleware.RequestMeta(), servermiddleware.Recovery(logger), servermiddleware.AccessLog(runtime, logger, version), servermiddleware.CORS(runtime), servermiddleware.ErrorAudit(logger), servermiddleware.SecurityRateLimit(security))
|
||||||
|
|
||||||
prefix := ""
|
prefix := ""
|
||||||
config := runtime.Admin()
|
config := runtime.Admin()
|
||||||
|
|
@ -60,6 +60,7 @@ func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, h
|
||||||
serverrouter.RegisterMedia(private, handlers.Media)
|
serverrouter.RegisterMedia(private, handlers.Media)
|
||||||
serverrouter.RegisterAnnouncement(private, public, handlers.Announcement)
|
serverrouter.RegisterAnnouncement(private, public, handlers.Announcement)
|
||||||
serverrouter.RegisterEmail(private, handlers.Email)
|
serverrouter.RegisterEmail(private, handlers.Email)
|
||||||
|
registerSwagger(engine, prefix, version, logger)
|
||||||
|
|
||||||
engine.NoRoute(func(c *gin.Context) {
|
engine.NoRoute(func(c *gin.Context) {
|
||||||
if serveLocalStorage(c, runtime) {
|
if serveLocalStorage(c, runtime) {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"kra/internal/conf"
|
"kra/internal/conf"
|
||||||
"kra/internal/server/handler"
|
"kra/internal/server/handler"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGinRouteContract(t *testing.T) {
|
func emptyHandlers() *handler.Set {
|
||||||
handlers := &handler.Set{
|
return &handler.Set{
|
||||||
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
|
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
|
||||||
Permission: &handler.Permission{}, Organization: &handler.Organization{},
|
Permission: &handler.Permission{}, Organization: &handler.Organization{},
|
||||||
Announcement: &handler.Announcement{}, Email: &handler.Email{}, Task: &handler.Task{},
|
Announcement: &handler.Announcement{}, Email: &handler.Email{}, Task: &handler.Task{},
|
||||||
|
|
@ -17,11 +25,12 @@ func TestGinRouteContract(t *testing.T) {
|
||||||
APIToken: &handler.APIToken{}, SystemConfig: &handler.SystemConfig{}, Public: &handler.Public{},
|
APIToken: &handler.APIToken{}, SystemConfig: &handler.SystemConfig{}, Public: &handler.Public{},
|
||||||
User: &handler.User{}, Navigation: &handler.Navigation{}, Session: &handler.Session{},
|
User: &handler.User{}, Navigation: &handler.Navigation{}, Session: &handler.Session{},
|
||||||
}
|
}
|
||||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, handlers, nil, nil, nil, nil, "test")
|
|
||||||
routes := engine.Routes()
|
|
||||||
if len(routes) != 177 {
|
|
||||||
t.Fatalf("route contract changed: got %d routes, want 177", len(routes))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGinRouteContract(t *testing.T) {
|
||||||
|
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||||
|
routes := engine.Routes()
|
||||||
|
actual := make([]string, 0, len(routes))
|
||||||
seen := make(map[string]struct{}, len(routes))
|
seen := make(map[string]struct{}, len(routes))
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
key := route.Method + " " + route.Path
|
key := route.Method + " " + route.Path
|
||||||
|
|
@ -29,5 +38,263 @@ func TestGinRouteContract(t *testing.T) {
|
||||||
t.Fatalf("duplicate route %s", key)
|
t.Fatalf("duplicate route %s", key)
|
||||||
}
|
}
|
||||||
seen[key] = struct{}{}
|
seen[key] = struct{}{}
|
||||||
|
actual = append(actual, key)
|
||||||
|
}
|
||||||
|
sort.Strings(actual)
|
||||||
|
if value := strings.Join(actual, "\n"); value != expectedGinRouteContract {
|
||||||
|
t.Fatalf("route contract changed:\n%s", value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||||
|
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, logger, "test")
|
||||||
|
text := output.String()
|
||||||
|
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||||
|
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":178`) {
|
||||||
|
t.Fatalf("startup route summary is missing: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwaggerUsesRegisteredGinRoutes(t *testing.T) {
|
||||||
|
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil)
|
||||||
|
engine.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("swagger document status = %d, body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, expected := range []string{`"swagger":"2.0"`, `"version":"v1.0.0"`, `"/base/login"`, `"/timedTask/triggerTimedTask"`, `"/mediaUpload/{uploadId}"`} {
|
||||||
|
if !strings.Contains(body, expected) {
|
||||||
|
t.Fatalf("swagger document missing %s", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwaggerSupportsRouterPrefix(t *testing.T) {
|
||||||
|
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{RouterPrefix: "/admin"}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||||
|
for _, path := range []string{"/admin/swagger/index.html", "/admin/swagger/doc.json"} {
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
engine.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("swagger path %s status = %d, body=%s", path, response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, "/doc.json") && !strings.Contains(response.Body.String(), `"basePath":"/admin"`) {
|
||||||
|
t.Fatalf("swagger document does not use router prefix: %s", response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalStorageResponseHeaders(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, body := range map[string]string{"script.html": "<script>alert(1)</script>", "image.png": "png"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "local"}})
|
||||||
|
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
path string
|
||||||
|
attachment bool
|
||||||
|
}{
|
||||||
|
{path: "/uploads/file/script.html", attachment: true},
|
||||||
|
{path: "/uploads/file/image.png", attachment: false},
|
||||||
|
} {
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, test.path, nil)
|
||||||
|
engine.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusOK || response.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||||
|
t.Fatalf("unexpected static response for %s: status=%d headers=%v", test.path, response.Code, response.Header())
|
||||||
|
}
|
||||||
|
hasAttachment := strings.Contains(response.Header().Get("Content-Disposition"), "attachment")
|
||||||
|
if hasAttachment != test.attachment {
|
||||||
|
t.Fatalf("attachment header for %s = %v, want %v", test.path, hasAttachment, test.attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedGinRouteContract = `DELETE /api/deleteApisByIds
|
||||||
|
DELETE /dataAccessLog/deleteDataAccessLogByIds
|
||||||
|
DELETE /department/deleteDepartment
|
||||||
|
DELETE /info/deleteInfo
|
||||||
|
DELETE /info/deleteInfoByIds
|
||||||
|
DELETE /mediaUpload/:uploadId
|
||||||
|
DELETE /position/deletePosition
|
||||||
|
DELETE /sysDictionary/deleteSysDictionary
|
||||||
|
DELETE /sysDictionaryDetail/deleteSysDictionaryDetail
|
||||||
|
DELETE /sysError/deleteSysError
|
||||||
|
DELETE /sysError/deleteSysErrorByIds
|
||||||
|
DELETE /sysExportTemplate/deleteSysExportTemplate
|
||||||
|
DELETE /sysExportTemplate/deleteSysExportTemplateByIds
|
||||||
|
DELETE /sysLoginLog/deleteLoginLog
|
||||||
|
DELETE /sysLoginLog/deleteLoginLogByIds
|
||||||
|
DELETE /sysOperationRecord/deleteSysOperationRecord
|
||||||
|
DELETE /sysOperationRecord/deleteSysOperationRecordByIds
|
||||||
|
DELETE /sysParams/deleteSysParams
|
||||||
|
DELETE /sysParams/deleteSysParamsByIds
|
||||||
|
DELETE /sysVersion/deleteSysVersion
|
||||||
|
DELETE /sysVersion/deleteSysVersionByIds
|
||||||
|
DELETE /timedTask/deleteTimedTask
|
||||||
|
DELETE /user/deleteUser
|
||||||
|
GET /api/freshCasbin
|
||||||
|
GET /api/getApiGroups
|
||||||
|
GET /api/getApiRoles
|
||||||
|
GET /api/syncApi
|
||||||
|
GET /attachmentCategory/getCategoryList
|
||||||
|
GET /authority/getDataScopeDepts
|
||||||
|
GET /authority/getUsersByAuthority
|
||||||
|
GET /department/findDepartment
|
||||||
|
GET /department/getDepartmentUsers
|
||||||
|
GET /fileUploadAndDownload/findFile
|
||||||
|
GET /health
|
||||||
|
GET /info/findInfo
|
||||||
|
GET /info/getInfoDataSource
|
||||||
|
GET /info/getInfoList
|
||||||
|
GET /info/getInfoPublic
|
||||||
|
GET /logViewer/content
|
||||||
|
GET /logViewer/dates
|
||||||
|
GET /logViewer/files
|
||||||
|
GET /menu/getMenuRoles
|
||||||
|
GET /position/findPosition
|
||||||
|
GET /position/getPositionUsers
|
||||||
|
GET /securityConfig/getSecurityConfig
|
||||||
|
GET /swagger/*any
|
||||||
|
GET /sysDictionary/exportSysDictionary
|
||||||
|
GET /sysDictionary/findSysDictionary
|
||||||
|
GET /sysDictionary/getSysDictionaryList
|
||||||
|
GET /sysDictionary/getSysDictionaryListWithDetails
|
||||||
|
GET /sysDictionaryDetail/findSysDictionaryDetail
|
||||||
|
GET /sysDictionaryDetail/getDictionaryDetailsByParent
|
||||||
|
GET /sysDictionaryDetail/getDictionaryPath
|
||||||
|
GET /sysDictionaryDetail/getDictionaryTreeList
|
||||||
|
GET /sysDictionaryDetail/getDictionaryTreeListByType
|
||||||
|
GET /sysDictionaryDetail/getSysDictionaryDetailList
|
||||||
|
GET /sysError/findSysError
|
||||||
|
GET /sysError/getSysErrorList
|
||||||
|
GET /sysExportTemplate/exportExcel
|
||||||
|
GET /sysExportTemplate/exportExcelByToken
|
||||||
|
GET /sysExportTemplate/exportTemplate
|
||||||
|
GET /sysExportTemplate/exportTemplateByToken
|
||||||
|
GET /sysExportTemplate/findSysExportTemplate
|
||||||
|
GET /sysExportTemplate/getSysExportTemplateList
|
||||||
|
GET /sysExportTemplate/previewSQL
|
||||||
|
GET /sysLoginLog/findLoginLog
|
||||||
|
GET /sysLoginLog/getLoginLogList
|
||||||
|
GET /sysOperationRecord/findSysOperationRecord
|
||||||
|
GET /sysOperationRecord/getSysOperationRecordList
|
||||||
|
GET /sysParams/findSysParams
|
||||||
|
GET /sysParams/getSysParam
|
||||||
|
GET /sysParams/getSysParamsList
|
||||||
|
GET /sysVersion/downloadVersionJson
|
||||||
|
GET /sysVersion/findSysVersion
|
||||||
|
GET /sysVersion/getSysVersionList
|
||||||
|
GET /timedTask/alertStream
|
||||||
|
GET /timedTask/getRegisteredMethods
|
||||||
|
GET /timedTask/getTimedTaskList
|
||||||
|
GET /timedTask/getTimedTaskLogList
|
||||||
|
GET /user/getUserInfo
|
||||||
|
POST /api/createApi
|
||||||
|
POST /api/deleteApi
|
||||||
|
POST /api/enterSyncApi
|
||||||
|
POST /api/getAllApis
|
||||||
|
POST /api/getApiById
|
||||||
|
POST /api/getApiList
|
||||||
|
POST /api/ignoreApi
|
||||||
|
POST /api/setApiRoles
|
||||||
|
POST /api/updateApi
|
||||||
|
POST /attachmentCategory/addCategory
|
||||||
|
POST /attachmentCategory/deleteCategory
|
||||||
|
POST /authority/copyAuthority
|
||||||
|
POST /authority/createAuthority
|
||||||
|
POST /authority/deleteAuthority
|
||||||
|
POST /authority/getAuthorityList
|
||||||
|
POST /authority/setDataScope
|
||||||
|
POST /authority/setRoleUsers
|
||||||
|
POST /authorityBtn/canRemoveAuthorityBtn
|
||||||
|
POST /authorityBtn/getAuthorityBtn
|
||||||
|
POST /authorityBtn/setAuthorityBtn
|
||||||
|
POST /base/captcha
|
||||||
|
POST /base/login
|
||||||
|
POST /casbin/getPolicyPathByAuthorityId
|
||||||
|
POST /casbin/updateCasbin
|
||||||
|
POST /dataAccessLog/getDataAccessLogList
|
||||||
|
POST /department/createDepartment
|
||||||
|
POST /department/getDepartmentList
|
||||||
|
POST /department/setDepartmentUsers
|
||||||
|
POST /email/emailTest
|
||||||
|
POST /email/sendEmail
|
||||||
|
POST /fileUploadAndDownload/deleteFile
|
||||||
|
POST /fileUploadAndDownload/deleteFiles
|
||||||
|
POST /fileUploadAndDownload/editFileName
|
||||||
|
POST /fileUploadAndDownload/getFileList
|
||||||
|
POST /fileUploadAndDownload/importURL
|
||||||
|
POST /fileUploadAndDownload/listOssFiles
|
||||||
|
POST /fileUploadAndDownload/upload
|
||||||
|
POST /info/createInfo
|
||||||
|
POST /init/checkdb
|
||||||
|
POST /init/initdb
|
||||||
|
POST /jwt/jsonInBlacklist
|
||||||
|
POST /mediaUpload/chunk
|
||||||
|
POST /mediaUpload/complete
|
||||||
|
POST /mediaUpload/init
|
||||||
|
POST /menu/addBaseMenu
|
||||||
|
POST /menu/addMenuAuthority
|
||||||
|
POST /menu/deleteBaseMenu
|
||||||
|
POST /menu/getBaseMenuById
|
||||||
|
POST /menu/getBaseMenuTree
|
||||||
|
POST /menu/getMenu
|
||||||
|
POST /menu/getMenuAuthority
|
||||||
|
POST /menu/getMenuList
|
||||||
|
POST /menu/setMenuRoles
|
||||||
|
POST /menu/updateBaseMenu
|
||||||
|
POST /position/createPosition
|
||||||
|
POST /position/getPositionList
|
||||||
|
POST /position/setPositionUsers
|
||||||
|
POST /securityConfig/setSecurityConfig
|
||||||
|
POST /sysApiToken/createApiToken
|
||||||
|
POST /sysApiToken/deleteApiToken
|
||||||
|
POST /sysApiToken/getApiTokenList
|
||||||
|
POST /sysDictionary/createSysDictionary
|
||||||
|
POST /sysDictionary/importSysDictionary
|
||||||
|
POST /sysDictionaryDetail/createSysDictionaryDetail
|
||||||
|
POST /sysError/createSysError
|
||||||
|
POST /sysExportTemplate/createSysExportTemplate
|
||||||
|
POST /sysExportTemplate/importExcel
|
||||||
|
POST /sysParams/createSysParams
|
||||||
|
POST /sysVersion/exportVersion
|
||||||
|
POST /sysVersion/importVersion
|
||||||
|
POST /system/getServerInfo
|
||||||
|
POST /system/getSystemConfig
|
||||||
|
POST /system/reloadSystem
|
||||||
|
POST /system/setSystemConfig
|
||||||
|
POST /timedTask/createTimedTask
|
||||||
|
POST /timedTask/toggleTimedTask
|
||||||
|
POST /timedTask/triggerTimedTask
|
||||||
|
POST /user/admin_register
|
||||||
|
POST /user/changePassword
|
||||||
|
POST /user/getUserList
|
||||||
|
POST /user/resetPassword
|
||||||
|
POST /user/setUserAuthorities
|
||||||
|
POST /user/setUserAuthority
|
||||||
|
POST /user/setUserDepartments
|
||||||
|
POST /user/setUserPositions
|
||||||
|
PUT /authority/updateAuthority
|
||||||
|
PUT /department/updateDepartment
|
||||||
|
PUT /info/updateInfo
|
||||||
|
PUT /position/updatePosition
|
||||||
|
PUT /sysDictionary/updateSysDictionary
|
||||||
|
PUT /sysDictionaryDetail/updateSysDictionaryDetail
|
||||||
|
PUT /sysError/updateSysError
|
||||||
|
PUT /sysExportTemplate/updateSysExportTemplate
|
||||||
|
PUT /sysParams/updateSysParams
|
||||||
|
PUT /timedTask/updateTimedTask
|
||||||
|
PUT /user/setSelfInfo
|
||||||
|
PUT /user/setSelfSetting
|
||||||
|
PUT /user/setUserInfo`
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ func (h *Menu) AuthorityMenus(c *gin.Context) {
|
||||||
}
|
}
|
||||||
menus, err := h.service.AuthorityMenus(c.Request.Context(), req.AuthorityID)
|
menus, err := h.service.AuthorityMenus(c.Request.Context(), req.AuthorityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpx.Fail(c, "获取失败")
|
httpx.Write(c, httpx.CodeError, gin.H{"menus": menus}, "获取失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpx.Write(c, httpx.CodeSuccess, gin.H{"menus": menus}, "获取成功")
|
httpx.Write(c, httpx.CodeSuccess, gin.H{"menus": menus}, "获取成功")
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,11 @@ func (h *User) Create(c *gin.Context) {
|
||||||
}
|
}
|
||||||
result, err := h.service.CreateUserRequest(c.Request.Context(), &req)
|
result, err := h.service.CreateUserRequest(c.Request.Context(), &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpx.Fail(c, "注册失败")
|
if service.IsPasswordPolicyError(err) {
|
||||||
|
httpx.Fail(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.Write(c, httpx.CodeError, gin.H{"user": dto.UserResponse{}}, "注册失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpx.Write(c, httpx.CodeSuccess, gin.H{"user": result}, "注册成功")
|
httpx.Write(c, httpx.CodeSuccess, gin.H{"user": result}, "注册成功")
|
||||||
|
|
@ -138,6 +142,10 @@ func (h *User) ResetPassword(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.service.ResetPassword(c.Request.Context(), req.ID, req.Password); err != nil {
|
if err := h.service.ResetPassword(c.Request.Context(), req.ID, req.Password); err != nil {
|
||||||
|
if service.IsPasswordPolicyError(err) {
|
||||||
|
httpx.Fail(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
httpx.Fail(c, "重置失败"+err.Error())
|
httpx.Fail(c, "重置失败"+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -164,6 +172,10 @@ func (h *User) ChangePassword(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.service.ChangePassword(c.Request.Context(), claims.ID, req.Password, req.NewPassword); err != nil {
|
if err := h.service.ChangePassword(c.Request.Context(), claims.ID, req.Password, req.NewPassword); err != nil {
|
||||||
|
if service.IsPasswordPolicyError(err) {
|
||||||
|
httpx.Fail(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
httpx.Fail(c, "修改失败,原密码与当前账户不符")
|
httpx.Fail(c, "修改失败,原密码与当前账户不符")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,25 @@ package middleware
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"kra/internal/server/httpx"
|
"kra/internal/server/httpx"
|
||||||
"kra/internal/service"
|
|
||||||
"kra/internal/service/dto"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrorAudit supplies the database sink that the Error-level logging core
|
// ErrorAudit emits a structured Error-level log for unexpected HTTP failures.
|
||||||
// provides. Expected authentication, permission and input failures are not
|
// The logging core is the single persistence path for sys_error, matching the
|
||||||
// system errors and therefore are not inserted into sys_error.
|
// reference behavior and avoiding duplicate rows for HTTP failures.
|
||||||
func ErrorAudit(audit *service.AuditRecorder) gin.HandlerFunc {
|
func ErrorAudit(logger *slog.Logger) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Next()
|
c.Next()
|
||||||
if strings.Contains(c.Request.URL.Path, "/sysError/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500 {
|
// sysError writes must never audit themselves. Log-viewer failures are
|
||||||
|
// already recorded by the handler with the underlying filesystem error;
|
||||||
|
// emitting again from the response envelope would duplicate both the
|
||||||
|
// classified error file and the sys_error row.
|
||||||
|
if strings.Contains(c.Request.URL.Path, "/sysError/") || strings.Contains(c.Request.URL.Path, "/logViewer/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var response httpx.Response
|
var response httpx.Response
|
||||||
|
|
@ -32,9 +35,26 @@ func ErrorAudit(audit *service.AuditRecorder) gin.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requestID, _ := c.Get("request_id")
|
requestID, _ := c.Get("request_id")
|
||||||
_ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: response.Msg, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id")})
|
if logger != nil {
|
||||||
|
logger.ErrorContext(c.Request.Context(), "请求处理失败", "mod", failureLogModule(c.Request.URL.Path), "path", c.Request.URL.Path, "method", c.Request.Method, "status", c.Writer.Status(), "error", response.Msg, "request_id", stringValue(requestID), "trace_id", stringValueFromContext(c, "trace_id"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func failureLogModule(path string) string {
|
||||||
|
for _, marker := range []string{"/fileUploadAndDownload/", "/mediaUpload/", "/attachmentCategory/"} {
|
||||||
|
if strings.Contains(path, marker) {
|
||||||
|
return "upload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(path, "/timedTask/") {
|
||||||
|
return "timedTask"
|
||||||
|
}
|
||||||
|
if strings.Contains(path, "/logViewer/") {
|
||||||
|
return "log-viewer"
|
||||||
|
}
|
||||||
|
return "biz"
|
||||||
|
}
|
||||||
|
|
||||||
func expectedClientFailure(message string) bool {
|
func expectedClientFailure(message string) bool {
|
||||||
for _, value := range []string{"参数错误", "请输入用户名和密码", "验证码错误", "用户名不存在或者密码错误", "用户被禁止登录", "账号已锁定", "权限不足", "密码已过期", "未登录", "token", "令牌失效"} {
|
for _, value := range []string{"参数错误", "请输入用户名和密码", "验证码错误", "用户名不存在或者密码错误", "用户被禁止登录", "账号已锁定", "权限不足", "密码已过期", "未登录", "token", "令牌失效"} {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runErrorAudit(t *testing.T, path, response string, logger *slog.Logger) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(ErrorAudit(logger))
|
||||||
|
engine.GET(path, func(c *gin.Context) {
|
||||||
|
c.Set(ctxRespBufferKey, bytes.NewBufferString(response))
|
||||||
|
c.Set("request_id", "request-1")
|
||||||
|
c.Set("trace_id", "trace-1")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
})
|
||||||
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(responseRecorder, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorAuditEmitsUnexpectedBusinessFailure(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||||
|
|
||||||
|
runErrorAudit(t, "/test", `{"code":7,"data":{},"msg":"数据库写入失败"}`, logger)
|
||||||
|
|
||||||
|
if text := output.String(); !strings.Contains(text, `"mod":"biz"`) || !strings.Contains(text, `"error":"数据库写入失败"`) {
|
||||||
|
t.Fatalf("unexpected error log: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorAuditUsesFeatureModule(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||||
|
|
||||||
|
runErrorAudit(t, "/timedTask/triggerTimedTask", `{"code":7,"data":{},"msg":"任务执行失败"}`, logger)
|
||||||
|
if text := output.String(); !strings.Contains(text, `"mod":"timedTask"`) {
|
||||||
|
t.Fatalf("unexpected feature log module: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorAuditSkipsExpectedClientFailure(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||||
|
|
||||||
|
runErrorAudit(t, "/test", `{"code":7,"data":{},"msg":"参数错误"}`, logger)
|
||||||
|
|
||||||
|
if output.Len() != 0 {
|
||||||
|
t.Fatalf("expected no error log, got %s", output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorAuditAllowsNilLogger(t *testing.T) {
|
||||||
|
runErrorAudit(t, "/test", `{"code":7,"data":{},"msg":"数据库写入失败"}`, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorAuditSkipsLogViewerFailureAlreadyLoggedByHandler(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||||
|
|
||||||
|
runErrorAudit(t, "/logViewer/content", `{"code":7,"data":{},"msg":"日志文件不可读取"}`, logger)
|
||||||
|
|
||||||
|
if output.Len() != 0 {
|
||||||
|
t.Fatalf("log viewer failure must not be emitted twice, got %s", output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,7 @@ func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc {
|
||||||
key := "KRA_SecLimit" + c.ClientIP() + c.FullPath()
|
key := "KRA_SecLimit" + c.ClientIP() + c.FullPath()
|
||||||
count, cacheErr := settings.IncrementRateLimit(c.Request.Context(), key, time.Duration(window)*time.Second)
|
count, cacheErr := settings.IncrementRateLimit(c.Request.Context(), key, time.Duration(window)*time.Second)
|
||||||
if cacheErr == nil && int(count) > config.LimitCount {
|
if cacheErr == nil && int(count) > config.LimitCount {
|
||||||
httpx.Fail(c, "请求太过频繁,请稍后再试")
|
c.JSON(200, gin.H{"code": httpx.CodeError, "msg": "请求太过频繁,请稍后再试"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"kra/internal/biz"
|
||||||
|
"kra/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rateLimitSecurityRepo struct{}
|
||||||
|
|
||||||
|
func (rateLimitSecurityRepo) SecurityConfig(context.Context) (*biz.SecurityConfig, error) {
|
||||||
|
return &biz.SecurityConfig{LimitEnable: true, LimitWindow: 60, LimitCount: 1}, nil
|
||||||
|
}
|
||||||
|
func (rateLimitSecurityRepo) SaveSecurityConfig(context.Context, *biz.SecurityConfig) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type rateLimitCache struct{}
|
||||||
|
|
||||||
|
func (rateLimitCache) Get(context.Context, string) (string, bool, error) { return "", false, nil }
|
||||||
|
func (rateLimitCache) Set(context.Context, string, string, time.Duration) error { return nil }
|
||||||
|
func (rateLimitCache) Delete(context.Context, string) error { return nil }
|
||||||
|
func (rateLimitCache) Increment(context.Context, string, time.Duration) (int64, error) { return 2, nil }
|
||||||
|
|
||||||
|
func TestSecurityRateLimitMatchesResponseContract(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
settings := service.NewSecurityService(biz.NewSecurityUsecase(rateLimitSecurityRepo{}, rateLimitCache{}, nil, nil))
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(SecurityRateLimit(settings))
|
||||||
|
engine.POST("/base/login", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||||
|
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/base/login", nil))
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, `"code":7`) || !strings.Contains(body, `"msg":"请求太过频繁,请稍后再试"`) || strings.Contains(body, `"data"`) {
|
||||||
|
t.Fatalf("unexpected rate-limit response: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -10,13 +9,10 @@ import (
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"kra/internal/service"
|
|
||||||
"kra/internal/service/dto"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Recovery(audit *service.AuditRecorder, logger *slog.Logger) gin.HandlerFunc {
|
func Recovery(logger *slog.Logger) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
defer func() {
|
defer func() {
|
||||||
panicValue := recover()
|
panicValue := recover()
|
||||||
|
|
@ -31,12 +27,9 @@ func Recovery(audit *service.AuditRecorder, logger *slog.Logger) gin.HandlerFunc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
request, _ := httputil.DumpRequest(c.Request, false)
|
request, _ := httputil.DumpRequest(c.Request, false)
|
||||||
info := fmt.Sprintf("error=%v request=%s stack=%s", panicValue, request, debug.Stack())
|
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger.ErrorContext(c.Request.Context(), "recovery from panic", "mod", "error", "error", panicValue, "request", string(request), "stack", string(debug.Stack()))
|
logger.ErrorContext(c.Request.Context(), "recovery from panic", "mod", "http", "error", panicValue, "request", string(request), "stack", string(debug.Stack()))
|
||||||
}
|
}
|
||||||
requestID, _ := c.Get("request_id")
|
|
||||||
_ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: info, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id")})
|
|
||||||
if brokenPipe {
|
if brokenPipe {
|
||||||
if err, ok := panicValue.(error); ok {
|
if err, ok := panicValue.(error); ok {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"kra/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
swaggerFiles "github.com/swaggo/files"
|
||||||
|
ginSwagger "github.com/swaggo/gin-swagger"
|
||||||
|
"github.com/swaggo/swag"
|
||||||
|
)
|
||||||
|
|
||||||
|
const swaggerInstanceName = "kra-admin"
|
||||||
|
|
||||||
|
var (
|
||||||
|
swaggerPathParameter = regexp.MustCompile(`:([A-Za-z0-9_]+)`)
|
||||||
|
swaggerRegistration sync.Once
|
||||||
|
swaggerDocument runtimeSwaggerDocument
|
||||||
|
)
|
||||||
|
|
||||||
|
type runtimeSwaggerDocument struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
doc string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *runtimeSwaggerDocument) ReadDoc() string {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
return d.doc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *runtimeSwaggerDocument) replace(doc string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.doc = doc
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerSwagger(engine *gin.Engine, prefix, version string, logger *slog.Logger) {
|
||||||
|
swaggerRegistration.Do(func() { swag.Register(swaggerInstanceName, &swaggerDocument) })
|
||||||
|
swaggerDocument.replace(buildSwaggerDocument(engine.Routes(), prefix, version))
|
||||||
|
path := strings.TrimSuffix(prefix, "/") + "/swagger/*any"
|
||||||
|
engine.GET(path, ginSwagger.WrapHandler(
|
||||||
|
swaggerFiles.Handler,
|
||||||
|
ginSwagger.InstanceName(swaggerInstanceName),
|
||||||
|
ginSwagger.URL("doc.json"),
|
||||||
|
ginSwagger.PersistAuthorization(true),
|
||||||
|
))
|
||||||
|
if logger != nil {
|
||||||
|
logger.Info("register swagger handler", "mod", "system", "path", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSwaggerDocument(routes []gin.RouteInfo, prefix, version string) string {
|
||||||
|
basePath := strings.TrimSuffix(prefix, "/")
|
||||||
|
if basePath == "" {
|
||||||
|
basePath = "/"
|
||||||
|
}
|
||||||
|
sort.Slice(routes, func(i, j int) bool {
|
||||||
|
if routes[i].Path == routes[j].Path {
|
||||||
|
return routes[i].Method < routes[j].Method
|
||||||
|
}
|
||||||
|
return routes[i].Path < routes[j].Path
|
||||||
|
})
|
||||||
|
paths := make(map[string]map[string]any, len(routes))
|
||||||
|
for _, route := range routes {
|
||||||
|
method := strings.ToLower(route.Method)
|
||||||
|
switch method {
|
||||||
|
case "get", "post", "put", "delete", "patch":
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
apiPath := route.Path
|
||||||
|
if prefix != "" {
|
||||||
|
apiPath = strings.TrimPrefix(apiPath, strings.TrimSuffix(prefix, "/"))
|
||||||
|
}
|
||||||
|
if apiPath == "" {
|
||||||
|
apiPath = "/"
|
||||||
|
}
|
||||||
|
documentPath := swaggerPathParameter.ReplaceAllString(apiPath, `{$1}`)
|
||||||
|
group, description := service.RouteMetadata(route.Method, apiPath)
|
||||||
|
if description == "" {
|
||||||
|
description = route.Method + " " + apiPath
|
||||||
|
}
|
||||||
|
operation := map[string]any{
|
||||||
|
"tags": []string{group},
|
||||||
|
"summary": description,
|
||||||
|
"operationId": swaggerOperationID(route.Method, apiPath),
|
||||||
|
"produces": []string{"application/json"},
|
||||||
|
"responses": map[string]any{
|
||||||
|
"200": map[string]any{"description": "OK", "schema": map[string]any{"$ref": "#/definitions/Response"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if parameters := swaggerPathParameters(apiPath); len(parameters) > 0 {
|
||||||
|
operation["parameters"] = parameters
|
||||||
|
}
|
||||||
|
if !swaggerPublicPath(apiPath) {
|
||||||
|
operation["security"] = []map[string][]string{{"ApiKeyAuth": {}}}
|
||||||
|
}
|
||||||
|
if paths[documentPath] == nil {
|
||||||
|
paths[documentPath] = map[string]any{}
|
||||||
|
}
|
||||||
|
paths[documentPath][method] = operation
|
||||||
|
}
|
||||||
|
document := map[string]any{
|
||||||
|
"swagger": "2.0",
|
||||||
|
"info": map[string]any{"title": "Kra Administration API", "version": version},
|
||||||
|
"basePath": basePath,
|
||||||
|
"schemes": []string{"http", "https"},
|
||||||
|
"consumes": []string{"application/json"},
|
||||||
|
"produces": []string{"application/json"},
|
||||||
|
"securityDefinitions": map[string]any{
|
||||||
|
"ApiKeyAuth": map[string]any{"type": "apiKey", "name": "x-token", "in": "header"},
|
||||||
|
},
|
||||||
|
"paths": paths,
|
||||||
|
"definitions": map[string]any{
|
||||||
|
"Response": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"code": map[string]any{"type": "integer"},
|
||||||
|
"data": map[string]any{"type": "object"},
|
||||||
|
"msg": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(document)
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func swaggerOperationID(method, path string) string {
|
||||||
|
value := strings.ToLower(method) + "_" + strings.Trim(path, "/")
|
||||||
|
value = swaggerPathParameter.ReplaceAllString(value, "$1")
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return '_'
|
||||||
|
}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func swaggerPathParameters(path string) []map[string]any {
|
||||||
|
matches := swaggerPathParameter.FindAllStringSubmatch(path, -1)
|
||||||
|
parameters := make([]map[string]any, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
parameters = append(parameters, map[string]any{"name": match[1], "in": "path", "required": true, "type": "string"})
|
||||||
|
}
|
||||||
|
return parameters
|
||||||
|
}
|
||||||
|
|
||||||
|
func swaggerPublicPath(path string) bool {
|
||||||
|
for _, marker := range []string{
|
||||||
|
"/health", "/base/login", "/base/captcha", "/init/checkdb", "/init/initdb",
|
||||||
|
"/api/freshCasbin", "/sysExportTemplate/exportExcelByToken", "/sysExportTemplate/exportTemplateByToken",
|
||||||
|
"/sysError/createSysError", "/info/getInfoDataSource", "/info/getInfoPublic",
|
||||||
|
} {
|
||||||
|
if path == marker {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
@ -177,3 +177,9 @@ func routeMetadata(method, path string) (string, string) {
|
||||||
}
|
}
|
||||||
return routeGroup(path), ""
|
return routeGroup(path), ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RouteMetadata exposes the administration API grouping and description to
|
||||||
|
// transport-level documentation without leaking the metadata table itself.
|
||||||
|
func RouteMetadata(method, path string) (string, string) {
|
||||||
|
return routeMetadata(method, path)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,22 @@ package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"kra/internal/biz"
|
"kra/internal/biz"
|
||||||
"kra/internal/service/dto"
|
"kra/internal/service/dto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type PasswordPolicyError struct{ Err error }
|
||||||
|
|
||||||
|
func (e *PasswordPolicyError) Error() string { return e.Err.Error() }
|
||||||
|
func (e *PasswordPolicyError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
func IsPasswordPolicyError(err error) bool {
|
||||||
|
var policyErr *PasswordPolicyError
|
||||||
|
return errors.As(err, &policyErr)
|
||||||
|
}
|
||||||
|
|
||||||
func securityDTO(v *biz.SecurityConfig) *dto.SecurityConfigResponse {
|
func securityDTO(v *biz.SecurityConfig) *dto.SecurityConfigResponse {
|
||||||
return &dto.SecurityConfigResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword}
|
return &dto.SecurityConfigResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword}
|
||||||
}
|
}
|
||||||
|
|
@ -44,5 +55,8 @@ func (s *SecurityService) ValidatePassword(ctx context.Context, password string)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.uc.ValidatePassword(cfg, password)
|
if err := s.uc.ValidatePassword(cfg, password); err != nil {
|
||||||
|
return &PasswordPolicyError{Err: err}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ func (s *TaskScheduler) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TaskScheduler) Stop(ctx context.Context) error {
|
func (s *TaskScheduler) Stop(ctx context.Context) error {
|
||||||
|
s.closeSubscribers()
|
||||||
s.ctxMu.Lock()
|
s.ctxMu.Lock()
|
||||||
if s.cancel != nil {
|
if s.cancel != nil {
|
||||||
s.cancel()
|
s.cancel()
|
||||||
|
|
@ -241,6 +242,18 @@ func (s *TaskScheduler) Unsubscribe(userID uint, ch chan []byte) {
|
||||||
}
|
}
|
||||||
s.subMu.Unlock()
|
s.subMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *TaskScheduler) closeSubscribers() {
|
||||||
|
s.subMu.Lock()
|
||||||
|
defer s.subMu.Unlock()
|
||||||
|
for userID, subscribers := range s.subscribers {
|
||||||
|
for ch := range subscribers {
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
delete(s.subscribers, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *TaskScheduler) PublishToUsers(userIDs []uint, value any) {
|
func (s *TaskScheduler) PublishToUsers(userIDs []uint, value any) {
|
||||||
raw, err := json.Marshal(value)
|
raw, err := json.Marshal(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stackFrame struct {
|
||||||
|
File string
|
||||||
|
Line int
|
||||||
|
Func string
|
||||||
|
}
|
||||||
|
|
||||||
|
var stackFileLinePattern = regexp.MustCompile(`\s*(.+\.go):(\d+)\s*$`)
|
||||||
|
|
||||||
|
func finalApplicationCaller(stack string) (stackFrame, bool) {
|
||||||
|
if stack == "" {
|
||||||
|
return stackFrame{}, false
|
||||||
|
}
|
||||||
|
functionName := ""
|
||||||
|
for _, raw := range strings.Split(stack, "\n") {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches := stackFileLinePattern.FindStringSubmatch(line)
|
||||||
|
if matches == nil {
|
||||||
|
functionName = line
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lineNumber, _ := strconv.Atoi(matches[2])
|
||||||
|
if skipStackFile(matches[1]) {
|
||||||
|
functionName = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return stackFrame{File: matches[1], Line: lineNumber, Func: functionName}, true
|
||||||
|
}
|
||||||
|
return stackFrame{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipStackFile(filename string) bool {
|
||||||
|
normalized := strings.ReplaceAll(filename, "\\", "/")
|
||||||
|
for _, marker := range []string{
|
||||||
|
"/go/pkg/mod/",
|
||||||
|
"/go.uber.org/",
|
||||||
|
"/gorm.io/",
|
||||||
|
"/pkg/logging/",
|
||||||
|
"/internal/server/middleware/",
|
||||||
|
"/internal/server/router/",
|
||||||
|
} {
|
||||||
|
if strings.Contains(normalized, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Contains(normalized, "/src/") &&
|
||||||
|
(strings.Contains(normalized, "/go/go") || strings.Contains(normalized, "/go/src/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func functionSourceAt(filename string, line int) (name, source string, startLine, endLine int, err error) {
|
||||||
|
content, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", 0, 0, fmt.Errorf("read file failed: %w", err)
|
||||||
|
}
|
||||||
|
files := token.NewFileSet()
|
||||||
|
parsed, err := parser.ParseFile(files, filename, content, parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", 0, 0, fmt.Errorf("parse file failed: %w", err)
|
||||||
|
}
|
||||||
|
var target *ast.FuncDecl
|
||||||
|
ast.Inspect(parsed, func(node ast.Node) bool {
|
||||||
|
declaration, ok := node.(*ast.FuncDecl)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
start := files.Position(declaration.Pos()).Line
|
||||||
|
end := files.Position(declaration.End()).Line
|
||||||
|
if line >= start && line <= end {
|
||||||
|
target, startLine, endLine = declaration, start, end
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if target == nil {
|
||||||
|
return "", "", 0, 0, fmt.Errorf("no function encloses line %d in %s", line, filename)
|
||||||
|
}
|
||||||
|
start := files.Position(target.Pos()).Offset
|
||||||
|
end := files.Position(target.End()).Offset
|
||||||
|
if start < 0 || end > len(content) || start >= end {
|
||||||
|
return "", "", 0, 0, fmt.Errorf("invalid offsets for function: start=%d end=%d len=%d", start, end, len(content))
|
||||||
|
}
|
||||||
|
return target.Name.Name, string(content[start:end]), startLine, endLine, nil
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,42 @@ type Options struct {
|
||||||
FileOnlyModules []string
|
FileOnlyModules []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrorEntry is the storage-neutral representation of an Error-level log.
|
||||||
|
// Keeping it in pkg/logging lets the log core report failures without taking a
|
||||||
|
// dependency on the application service or persistence layers.
|
||||||
|
type ErrorEntry struct {
|
||||||
|
Form, Info, Level, RequestID, TraceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorSink receives Error-level log entries. The sink must not log failures
|
||||||
|
// through the same logger, otherwise a storage failure could recurse forever.
|
||||||
|
type ErrorSink interface {
|
||||||
|
RecordLogError(context.Context, ErrorEntry) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorSinkFunc func(context.Context, ErrorEntry) error
|
||||||
|
|
||||||
|
func (f ErrorSinkFunc) RecordLogError(ctx context.Context, entry ErrorEntry) error {
|
||||||
|
return f(ctx, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorSinkState struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sink ErrorSink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *errorSinkState) record(entry ErrorEntry) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
sink := s.sink
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if sink != nil {
|
||||||
|
_ = sink.RecordLogError(context.Background(), entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type handlerOperation struct {
|
type handlerOperation struct {
|
||||||
attrs []slog.Attr
|
attrs []slog.Attr
|
||||||
group string
|
group string
|
||||||
|
|
@ -79,10 +115,11 @@ func (h *reloadableHandler) WithGroup(name string) slog.Handler {
|
||||||
type ReloadableLogger struct {
|
type ReloadableLogger struct {
|
||||||
state *reloadableHandlerState
|
state *reloadableHandlerState
|
||||||
filename string
|
filename string
|
||||||
|
errorSink *errorSinkState
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ReloadableLogger) Reload(root string, options Options) {
|
func (l *ReloadableLogger) Reload(root string, options Options) {
|
||||||
handler, cleanup := newZapHandler(root, l.filename, options)
|
handler, cleanup := newZapHandler(root, l.filename, options, l.errorSink)
|
||||||
l.state.mu.Lock()
|
l.state.mu.Lock()
|
||||||
previous := l.state.cleanup
|
previous := l.state.cleanup
|
||||||
l.state.handler = handler
|
l.state.handler = handler
|
||||||
|
|
@ -93,6 +130,17 @@ func (l *ReloadableLogger) Reload(root string, options Options) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetErrorSink changes the database/audit target while keeping the current
|
||||||
|
// logger and its hot-reload state intact.
|
||||||
|
func (l *ReloadableLogger) SetErrorSink(sink ErrorSink) {
|
||||||
|
if l == nil || l.errorSink == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.errorSink.mu.Lock()
|
||||||
|
l.errorSink.sink = sink
|
||||||
|
l.errorSink.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (l *ReloadableLogger) Close() {
|
func (l *ReloadableLogger) Close() {
|
||||||
l.state.mu.Lock()
|
l.state.mu.Lock()
|
||||||
cleanup := l.state.cleanup
|
cleanup := l.state.cleanup
|
||||||
|
|
@ -162,6 +210,7 @@ type routedFileCore struct {
|
||||||
retentionDay int
|
retentionDay int
|
||||||
state *routedFileState
|
state *routedFileState
|
||||||
fields []zapcore.Field
|
fields []zapcore.Field
|
||||||
|
errorSink *errorSinkState
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *routedFileCore) Enabled(level zapcore.Level) bool { return c.level.Enabled(level) }
|
func (c *routedFileCore) Enabled(level zapcore.Level) bool { return c.level.Enabled(level) }
|
||||||
|
|
@ -169,7 +218,7 @@ func (c *routedFileCore) Enabled(level zapcore.Level) bool { return c.level.Enab
|
||||||
func (c *routedFileCore) With(fields []zapcore.Field) zapcore.Core {
|
func (c *routedFileCore) With(fields []zapcore.Field) zapcore.Core {
|
||||||
inherited := append([]zapcore.Field(nil), c.fields...)
|
inherited := append([]zapcore.Field(nil), c.fields...)
|
||||||
inherited = append(inherited, fields...)
|
inherited = append(inherited, fields...)
|
||||||
return &routedFileCore{base: c.base.With(fields), encoder: c.encoder, level: c.level, root: c.root, retentionDay: c.retentionDay, state: c.state, fields: inherited}
|
return &routedFileCore{base: c.base.With(fields), encoder: c.encoder, level: c.level, root: c.root, retentionDay: c.retentionDay, state: c.state, fields: inherited, errorSink: c.errorSink}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *routedFileCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
|
func (c *routedFileCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
|
||||||
|
|
@ -183,6 +232,9 @@ func (c *routedFileCore) Write(entry zapcore.Entry, fields []zapcore.Field) erro
|
||||||
baseErr := c.base.Write(entry, fields)
|
baseErr := c.base.Write(entry, fields)
|
||||||
allFields := append([]zapcore.Field(nil), c.fields...)
|
allFields := append([]zapcore.Field(nil), c.fields...)
|
||||||
allFields = append(allFields, fields...)
|
allFields = append(allFields, fields...)
|
||||||
|
if entry.Level >= zapcore.ErrorLevel && !isGORMLoggerEntry(entry.Caller.File, allFields) {
|
||||||
|
c.errorSink.record(errorEntryFromZap(entry, allFields))
|
||||||
|
}
|
||||||
paths := routedLogPaths(moduleField(allFields), entry.Level)
|
paths := routedLogPaths(moduleField(allFields), entry.Level)
|
||||||
if len(paths) == 0 {
|
if len(paths) == 0 {
|
||||||
return baseErr
|
return baseErr
|
||||||
|
|
@ -204,6 +256,66 @@ func (c *routedFileCore) Write(entry zapcore.Entry, fields []zapcore.Field) erro
|
||||||
return baseErr
|
return baseErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isGORMLoggerEntry(filename string, fields []zapcore.Field) bool {
|
||||||
|
for _, field := range fields {
|
||||||
|
if field.Key == "gorm_logger" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized := strings.ReplaceAll(filename, "\\", "/")
|
||||||
|
return strings.HasSuffix(normalized, "/gorm_logger_writer.go") ||
|
||||||
|
strings.HasSuffix(normalized, "/internal/data/gorm_logger.go")
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorEntryFromZap(entry zapcore.Entry, fields []zapcore.Field) ErrorEntry {
|
||||||
|
requestID, traceID, errorText := "", "", ""
|
||||||
|
for _, field := range fields {
|
||||||
|
switch field.Key {
|
||||||
|
case "request_id":
|
||||||
|
if requestID == "" {
|
||||||
|
requestID = zapFieldString(field)
|
||||||
|
}
|
||||||
|
case "trace_id":
|
||||||
|
if traceID == "" {
|
||||||
|
traceID = zapFieldString(field)
|
||||||
|
}
|
||||||
|
case "error", "err":
|
||||||
|
if errorText == "" {
|
||||||
|
errorText = zapFieldString(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info := entry.Message
|
||||||
|
if errorText != "" {
|
||||||
|
info += " | 错误: " + errorText
|
||||||
|
}
|
||||||
|
if entry.Caller.File != "" {
|
||||||
|
info += fmt.Sprintf(" \n 源文件:%s:%d", entry.Caller.File, entry.Caller.Line)
|
||||||
|
}
|
||||||
|
if entry.Stack != "" {
|
||||||
|
info += " \n 调用栈:" + entry.Stack
|
||||||
|
if frame, ok := finalApplicationCaller(entry.Stack); ok {
|
||||||
|
functionName, source, startLine, endLine, err := functionSourceAt(frame.File, frame.Line)
|
||||||
|
if err == nil {
|
||||||
|
info += fmt.Sprintf(" \n 最终调用方法:%s:%d (%s lines %d-%d)\n----- 产生日志的方法代码如下 -----\n%s", frame.File, frame.Line, functionName, startLine, endLine, source)
|
||||||
|
} else {
|
||||||
|
info += fmt.Sprintf(" \n 最终调用方法:%s:%d (%s) | extract_err=%v", frame.File, frame.Line, functionName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrorEntry{Form: "后端", Info: info, Level: entry.Level.String(), RequestID: requestID, TraceID: traceID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func zapFieldString(field zapcore.Field) string {
|
||||||
|
if field.String != "" {
|
||||||
|
return field.String
|
||||||
|
}
|
||||||
|
if field.Interface != nil {
|
||||||
|
return fmt.Sprint(field.Interface)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (c *routedFileCore) Sync() error {
|
func (c *routedFileCore) Sync() error {
|
||||||
result := c.base.Sync()
|
result := c.base.Sync()
|
||||||
c.state.mu.Lock()
|
c.state.mu.Lock()
|
||||||
|
|
@ -274,7 +386,7 @@ func safeModuleName(value string) string {
|
||||||
|
|
||||||
// NewZapLogger adapts a Zap core to the slog logger used by Kratos v3.
|
// NewZapLogger adapts a Zap core to the slog logger used by Kratos v3.
|
||||||
// The file layout remains compatible with the administration log viewer.
|
// The file layout remains compatible with the administration log viewer.
|
||||||
func newZapHandler(root, filename string, options Options) (slog.Handler, func()) {
|
func newZapHandler(root, filename string, options Options, errorSink *errorSinkState) (slog.Handler, func()) {
|
||||||
file := NewDailyWriter(root, filename, options.RetentionDay)
|
file := NewDailyWriter(root, filename, options.RetentionDay)
|
||||||
encoder := zap.NewProductionEncoderConfig()
|
encoder := zap.NewProductionEncoderConfig()
|
||||||
encoder.EncodeTime = zapcore.RFC3339NanoTimeEncoder
|
encoder.EncodeTime = zapcore.RFC3339NanoTimeEncoder
|
||||||
|
|
@ -324,7 +436,7 @@ func newZapHandler(root, filename string, options Options) (slog.Handler, func()
|
||||||
consoleCore := zapcore.NewCore(outputEncoder.Clone(), zapcore.AddSync(os.Stdout), levelEnabler)
|
consoleCore := zapcore.NewCore(outputEncoder.Clone(), zapcore.AddSync(os.Stdout), levelEnabler)
|
||||||
core = zapcore.NewTee(fileCore, &moduleFilterCore{Core: consoleCore, fileOnly: fileOnly})
|
core = zapcore.NewTee(fileCore, &moduleFilterCore{Core: consoleCore, fileOnly: fileOnly})
|
||||||
}
|
}
|
||||||
routed := &routedFileCore{base: core, encoder: outputEncoder.Clone(), level: levelEnabler, root: root, retentionDay: options.RetentionDay, state: &routedFileState{writers: map[string]*DailyWriter{}}}
|
routed := &routedFileCore{base: core, encoder: outputEncoder.Clone(), level: levelEnabler, root: root, retentionDay: options.RetentionDay, state: &routedFileState{writers: map[string]*DailyWriter{}}, errorSink: errorSink}
|
||||||
zapLogger := zap.New(routed)
|
zapLogger := zap.New(routed)
|
||||||
handlerOptions := []zapslog.HandlerOption{zapslog.AddStacktraceAt(slog.LevelError)}
|
handlerOptions := []zapslog.HandlerOption{zapslog.AddStacktraceAt(slog.LevelError)}
|
||||||
if options.ShowLine {
|
if options.ShowLine {
|
||||||
|
|
@ -342,9 +454,10 @@ func newZapHandler(root, filename string, options Options) (slog.Handler, func()
|
||||||
// NewReloadableZapLogger keeps the slog/Kratos adapter stable while replacing
|
// NewReloadableZapLogger keeps the slog/Kratos adapter stable while replacing
|
||||||
// the underlying Zap core when the runtime configuration changes.
|
// the underlying Zap core when the runtime configuration changes.
|
||||||
func NewReloadableZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, *ReloadableLogger) {
|
func NewReloadableZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, *ReloadableLogger) {
|
||||||
baseHandler, cleanup := newZapHandler(root, filename, options)
|
errorSink := &errorSinkState{}
|
||||||
|
baseHandler, cleanup := newZapHandler(root, filename, options, errorSink)
|
||||||
state := &reloadableHandlerState{handler: baseHandler, cleanup: cleanup}
|
state := &reloadableHandlerState{handler: baseHandler, cleanup: cleanup}
|
||||||
control := &ReloadableLogger{state: state, filename: filename}
|
control := &ReloadableLogger{state: state, filename: filename, errorSink: errorSink}
|
||||||
handler := &contextHandler{handler: &reloadableHandler{state: state}}
|
handler := &contextHandler{handler: &reloadableHandler{state: state}}
|
||||||
logger := kratoslog.NewLogger(handler, kratoslog.WithExtractor(tracing.TraceAttrs)).With(attrs...)
|
logger := kratoslog.NewLogger(handler, kratoslog.WithExtractor(tracing.TraceAttrs)).With(attrs...)
|
||||||
return logger, control
|
return logger, control
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,16 @@ import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestZapHandlerHonorsConfiguredLevel(t *testing.T) {
|
func TestZapHandlerHonorsConfiguredLevel(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "error", Format: "json"})
|
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "error", Format: "json"}, nil)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
if handler.Enabled(context.Background(), slog.LevelInfo) {
|
if handler.Enabled(context.Background(), slog.LevelInfo) {
|
||||||
|
|
@ -23,7 +27,7 @@ func TestZapHandlerHonorsConfiguredLevel(t *testing.T) {
|
||||||
|
|
||||||
func TestZapHandlerUsesDebugFallbackForInvalidLevel(t *testing.T) {
|
func TestZapHandlerUsesDebugFallbackForInvalidLevel(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "not-a-level", Format: "json"})
|
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "not-a-level", Format: "json"}, nil)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
if !handler.Enabled(context.Background(), slog.LevelDebug) {
|
if !handler.Enabled(context.Background(), slog.LevelDebug) {
|
||||||
|
|
@ -33,7 +37,7 @@ func TestZapHandlerUsesDebugFallbackForInvalidLevel(t *testing.T) {
|
||||||
|
|
||||||
func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
|
func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "info", Format: "json"})
|
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "info", Format: "json"}, nil)
|
||||||
logger := slog.New(handler)
|
logger := slog.New(handler)
|
||||||
logger.Info("request", "mod", "http", "request_id", "req-1")
|
logger.Info("request", "mod", "http", "request_id", "req-1")
|
||||||
logger.Error("failed", "mod", "users")
|
logger.Error("failed", "mod", "users")
|
||||||
|
|
@ -56,3 +60,59 @@ func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestZapHandlerRecordsEveryErrorThroughSink(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
var entries []ErrorEntry
|
||||||
|
logger, control := NewReloadableZapLogger(root, "application.log", Options{Level: "info", Format: "json"})
|
||||||
|
defer control.Close()
|
||||||
|
control.SetErrorSink(ErrorSinkFunc(func(_ context.Context, entry ErrorEntry) error {
|
||||||
|
entries = append(entries, entry)
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
logger.Error("task execution failed", "mod", "timedTask", "request_id", "request-1", "trace_id", "trace-1", "error", os.ErrPermission)
|
||||||
|
control.Reload(t.TempDir(), Options{Level: "info", Format: "json"})
|
||||||
|
logger.Error("reloaded logger failure", "mod", "system")
|
||||||
|
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected two error entries across reload, got %d", len(entries))
|
||||||
|
}
|
||||||
|
entry := entries[0]
|
||||||
|
if entry.Form != "后端" || entry.Level != "error" || entry.RequestID != "request-1" || entry.TraceID != "trace-1" || !strings.Contains(entry.Info, "task execution failed") || !strings.Contains(entry.Info, os.ErrPermission.Error()) {
|
||||||
|
t.Fatalf("unexpected error entry: %+v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorEntryIncludesFinalApplicationSource(t *testing.T) {
|
||||||
|
filename := filepath.Join(t.TempDir(), "worker.go")
|
||||||
|
content := "package sample\n\nfunc execute() {\n\tprintln(\"failed\")\n}\n"
|
||||||
|
if err := os.WriteFile(filename, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry := errorEntryFromZap(zapcore.Entry{Message: "task failed", Level: zapcore.ErrorLevel, Stack: "kra/internal/worker.execute\n" + filename + ":4"}, nil)
|
||||||
|
if !strings.Contains(entry.Info, "最终调用方法:"+filename+":4 (execute lines 3-5)") || !strings.Contains(entry.Info, "func execute()") {
|
||||||
|
t.Fatalf("expected final caller source in error entry: %s", entry.Info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorSinkSkipsGORMBridge(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
var entries []ErrorEntry
|
||||||
|
state := &errorSinkState{sink: ErrorSinkFunc(func(_ context.Context, entry ErrorEntry) error {
|
||||||
|
entries = append(entries, entry)
|
||||||
|
return nil
|
||||||
|
})}
|
||||||
|
base := zapcore.NewNopCore()
|
||||||
|
core := &routedFileCore{base: base, encoder: zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), level: zapcore.ErrorLevel, root: root, state: &routedFileState{writers: map[string]*DailyWriter{}}, errorSink: state}
|
||||||
|
for _, filename := range []string{"/tmp/gorm_logger_writer.go", "/workspace/internal/data/gorm_logger.go"} {
|
||||||
|
if err := core.Write(zapcore.Entry{Level: zapcore.ErrorLevel, Message: "database failed", Caller: zapcore.EntryCaller{Defined: true, File: filename, Line: 10}}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := core.Write(zapcore.Entry{Level: zapcore.ErrorLevel, Message: "database failed"}, []zapcore.Field{zap.Bool("gorm_logger", true)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("gorm bridge error must not recurse into sys_error: %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue