优化结构

This commit is contained in:
Yvan 2026-08-22 14:12:00 +08:00
parent b27a66e9d3
commit 9dd62c9fb6
37 changed files with 352 additions and 130 deletions

View File

@ -9,12 +9,16 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"kra/internal/app"
"kra/internal/conf" "kra/internal/conf"
"kra/internal/server/router"
"kra/internal/service" "kra/internal/service"
"kra/internal/service/dto" "kra/internal/service/dto"
"kra/internal/worker" "kra/internal/worker"
"kra/pkg/logging" "kra/pkg/logging"
"kra/pkg/module"
"kra/pkg/mq" "kra/pkg/mq"
platformtask "kra/pkg/task"
"github.com/go-kratos/kratos/v3" "github.com/go-kratos/kratos/v3"
"github.com/go-kratos/kratos/v3/config" "github.com/go-kratos/kratos/v3/config"
@ -41,6 +45,15 @@ func init() {
flag.StringVar(&flagconf, "conf", "./configs", "config path, eg: -conf config.yaml") flag.StringVar(&flagconf, "conf", "./configs", "config path, eg: -conf config.yaml")
} }
// runtimeContributions is the binary-level list of modules with constructed
// route or task dependencies. Adding another runtime module is explicit here.
func runtimeContributions(systemRoutes *router.Routes, systemTasks *worker.TaskMethods) app.RuntimeContributions {
return app.RuntimeContributions{
Routes: []module.RouteRegistrar{systemRoutes},
Tasks: []platformtask.Contributor{systemTasks},
}
}
func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskScheduler, audit *service.AuditRecorder, loggerControl *logging.ReloadableLogger, _ mq.Client) *kratos.App { func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskScheduler, audit *service.AuditRecorder, loggerControl *logging.ReloadableLogger, _ mq.Client) *kratos.App {
if audit != nil && loggerControl != nil { if audit != nil && loggerControl != nil {
loggerControl.SetErrorSink(logging.ErrorSinkFunc(func(ctx context.Context, entry logging.ErrorEntry) error { loggerControl.SetErrorSink(logging.ErrorSinkFunc(func(ctx context.Context, entry logging.ErrorEntry) error {

3
cmd/wire_gen.go generated
View File

@ -139,7 +139,8 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
tokenService := service.NewTokenService(tokenUsecase, tokenIssuer) tokenService := service.NewTokenService(tokenUsecase, tokenIssuer)
apiToken := handler.NewAPIToken(tokenService) apiToken := handler.NewAPIToken(tokenService)
initializationRepo := initialize.NewRepo(dataData, catalog) initializationRepo := initialize.NewRepo(dataData, catalog)
systemConfigUsecase := system2.NewSystemConfigUsecase(initializationRepo, taskRuntime) taskReloader := worker.NewTaskReloader(taskScheduler)
systemConfigUsecase := system2.NewSystemConfigUsecase(initializationRepo, taskReloader)
systemConfigService := service.NewSystemConfigService(systemConfigUsecase, runtimeSettings) systemConfigService := service.NewSystemConfigService(systemConfigUsecase, runtimeSettings)
systemConfig := handler.NewSystemConfig(systemConfigService, securityService) systemConfig := handler.NewSystemConfig(systemConfigService, securityService)
public := handler.NewPublic(authService, systemConfigService, securityService) public := handler.NewPublic(authService, systemConfigService, securityService)

1
go.mod
View File

@ -37,7 +37,6 @@ require (
github.com/swaggo/swag v1.16.4 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.mongodb.org/mongo-driver v1.17.2 go.mongodb.org/mongo-driver v1.17.2
go.uber.org/automaxprocs v1.6.0 go.uber.org/automaxprocs v1.6.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0

6
go.sum
View File

@ -411,8 +411,6 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.einride.tech/aip v0.86.3 h1:jg80Ec4XBPYg1i7avzrl3MJol/dUwmMMLHtcmEMyxgM=
go.einride.tech/aip v0.86.3/go.mod h1:dZuN/0sXeoscfWqsW8QLcLrGZdvsCC1B2R2CZ4kHmao=
go.mongodb.org/mongo-driver v1.17.2 h1:gvZyk8352qSfzyZ2UMWcpDpMSGEr1eqE4T793SqyhzM= go.mongodb.org/mongo-driver v1.17.2 h1:gvZyk8352qSfzyZ2UMWcpDpMSGEr1eqE4T793SqyhzM=
go.mongodb.org/mongo-driver v1.17.2/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.mongodb.org/mongo-driver v1.17.2/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@ -569,8 +567,6 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d h1:/hmn0Ku5kWij/kjGsrcJeC1T/MrJi2iNWwgAqrihFwc=
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM= google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM=
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A= google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
@ -605,8 +601,6 @@ gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w= modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w=
modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8= modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=

View File

@ -7,4 +7,4 @@ import (
) )
// ProviderSet is biz providers. // ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.NewUserUsecase, system.NewAuthenticationUsecase, system.NewSystemConfigUsecase, system.NewAuthorityUsecase, system.NewAPIUsecase, system.NewPermissionUsecase, system.NewAccessControlUsecase, system.NewMenuUsecase, system.NewDepartmentUsecase, system.NewPositionUsecase, system.NewDictionaryUsecase, system.NewParameterUsecase, system.NewTokenUsecase, system.NewSecurityUsecase, system.NewVersionUsecase, system.NewExportUsecase, system.NewAuditUsecase, system.NewAuditRecorderUsecase, system.NewLogViewerUsecase, system.NewTaskUsecaseWithRegistry, system.NewTaskApplicationUsecase, system.NewMediaUsecase, system.NewAnnouncementUsecase, system.NewEmailUsecase, system.NewPaymentUsecase, system.NewIntegrationConfigUsecase) var ProviderSet = wire.NewSet(system.ProviderSet)

View File

@ -57,6 +57,9 @@ func NewAuthenticationUsecase(users *UserUsecase, security *SecurityUsecase, iss
} }
func (uc *AuthenticationUsecase) recordLogin(ctx context.Context, attempt *LoginAttempt, status bool, message string, userID uint) { func (uc *AuthenticationUsecase) recordLogin(ctx context.Context, attempt *LoginAttempt, status bool, message string, userID uint) {
if uc.audit == nil || attempt == nil {
return
}
_ = uc.audit.RecordLogin(ctx, &LoginLog{Username: attempt.Username, IP: attempt.IP, Status: status, ErrorMessage: message, Agent: attempt.Agent, UserID: userID}) _ = uc.audit.RecordLogin(ctx, &LoginLog{Username: attempt.Username, IP: attempt.IP, Status: status, ErrorMessage: message, Agent: attempt.Agent, UserID: userID})
} }
@ -65,7 +68,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
if err != nil || config == nil { if err != nil || config == nil {
return nil, fmt.Errorf("%w: security config unavailable", ErrLoginState) return nil, fmt.Errorf("%w: security config unavailable", ErrLoginState)
} }
if config != nil && config.LockEnable { if config.LockEnable {
locked, lockErr := uc.security.LoginLocked(ctx, attempt.Username) locked, lockErr := uc.security.LoginLocked(ctx, attempt.Username)
if lockErr != nil { if lockErr != nil {
return nil, fmt.Errorf("%w: %v", ErrLoginState, lockErr) return nil, fmt.Errorf("%w: %v", ErrLoginState, lockErr)
@ -77,17 +80,14 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
} }
ipTTL := time.Hour ipTTL := time.Hour
requireCaptcha := config == nil || config.CaptchaOpen == 0 if config.CaptchaTimeout > 0 {
if config != nil { ipTTL = time.Duration(config.CaptchaTimeout) * time.Second
if config.CaptchaTimeout > 0 {
ipTTL = time.Duration(config.CaptchaTimeout) * time.Second
}
failures, counterErr := uc.security.EnsureLoginIPCounter(ctx, attempt.IP, ipTTL)
if counterErr != nil {
return nil, fmt.Errorf("%w: %v", ErrLoginState, counterErr)
}
requireCaptcha = config.CaptchaOpen == 0 || failures > config.CaptchaOpen
} }
failures, counterErr := uc.security.EnsureLoginIPCounter(ctx, attempt.IP, ipTTL)
if counterErr != nil {
return nil, fmt.Errorf("%w: %v", ErrLoginState, counterErr)
}
requireCaptcha := config.CaptchaOpen == 0 || failures > config.CaptchaOpen
if requireCaptcha && !uc.security.VerifyCaptcha(ctx, attempt.CaptchaID, attempt.Captcha, true) { if requireCaptcha && !uc.security.VerifyCaptcha(ctx, attempt.CaptchaID, attempt.Captcha, true) {
_, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL) _, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL)
uc.recordLogin(ctx, attempt, false, "验证码错误", 0) uc.recordLogin(ctx, attempt, false, "验证码错误", 0)
@ -97,7 +97,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
user, err := uc.users.Login(ctx, attempt.Username, attempt.Password) user, err := uc.users.Login(ctx, attempt.Username, attempt.Password)
if err != nil { if err != nil {
_, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL) _, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL)
if config != nil && config.LockEnable { if config.LockEnable {
lockTTL := time.Duration(config.LockDuration) * time.Minute lockTTL := time.Duration(config.LockDuration) * time.Minute
failures, stateErr := uc.security.IncrementLoginFailure(ctx, attempt.Username, lockTTL) failures, stateErr := uc.security.IncrementLoginFailure(ctx, attempt.Username, lockTTL)
if stateErr != nil { if stateErr != nil {
@ -119,7 +119,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
} }
uc.security.ClearLoginState(ctx, attempt.Username) uc.security.ClearLoginState(ctx, attempt.Username)
if config != nil && config.PwdExpireEnable && config.PwdExpireDays > 0 && user.PasswordUpdatedAt != nil && time.Now().After((*user.PasswordUpdatedAt).AddDate(0, 0, config.PwdExpireDays)) { if config.PwdExpireEnable && config.PwdExpireDays > 0 && user.PasswordUpdatedAt != nil && time.Now().After((*user.PasswordUpdatedAt).AddDate(0, 0, config.PwdExpireDays)) {
user.MustChangePassword = true user.MustChangePassword = true
} }
issued, err := uc.issuer.IssueToken(user, user.AuthorityID, user.MustChangePassword, 0) issued, err := uc.issuer.IssueToken(user, user.AuthorityID, user.MustChangePassword, 0)

View File

@ -140,6 +140,11 @@ func TestLoginRecordsSuccessBeforeMultipointCacheFailure(t *testing.T) {
} }
} }
func TestRecordLoginAllowsMissingAuditRecorder(t *testing.T) {
uc := &AuthenticationUsecase{}
uc.recordLogin(context.Background(), &LoginAttempt{Username: "admin"}, false, "failed", 0)
}
func TestSwitchAuthorityReissuesCurrentClaimsWithoutReloadingUser(t *testing.T) { func TestSwitchAuthorityReissuesCurrentClaimsWithoutReloadingUser(t *testing.T) {
repo := &switchAuthorityUserRepo{} repo := &switchAuthorityUserRepo{}
issuer := &switchAuthorityIssuer{} issuer := &switchAuthorityIssuer{}

View File

@ -1,39 +0,0 @@
package system
import (
"go.einride.tech/aip/filtering"
"go.einride.tech/aip/ordering"
)
type ListOption func(*ListOptions)
type ListOptions struct {
Filter filtering.Filter
OrderBy ordering.OrderBy
Offset int
Limit int
}
func ListFilter(filter filtering.Filter) ListOption {
return func(o *ListOptions) {
o.Filter = filter
}
}
func ListOrderBy(orderBy ordering.OrderBy) ListOption {
return func(o *ListOptions) {
o.OrderBy = orderBy
}
}
func ListOffset(offset int) ListOption {
return func(o *ListOptions) {
o.Offset = offset
}
}
func ListLimit(limit int) ListOption {
return func(o *ListOptions) {
o.Limit = limit
}
}

View File

@ -0,0 +1,34 @@
package system
import "github.com/google/wire"
// ProviderSet wires the system domain usecases. The parent biz package keeps
// the aggregate entry point used by Wire while each domain owns its providers.
var ProviderSet = wire.NewSet(
NewUserUsecase,
NewAuthenticationUsecase,
NewSystemConfigUsecase,
NewAuthorityUsecase,
NewAPIUsecase,
NewPermissionUsecase,
NewAccessControlUsecase,
NewMenuUsecase,
NewDepartmentUsecase,
NewPositionUsecase,
NewDictionaryUsecase,
NewParameterUsecase,
NewTokenUsecase,
NewSecurityUsecase,
NewVersionUsecase,
NewExportUsecase,
NewAuditUsecase,
NewAuditRecorderUsecase,
NewLogViewerUsecase,
NewTaskUsecaseWithRegistry,
NewTaskApplicationUsecase,
NewMediaUsecase,
NewAnnouncementUsecase,
NewEmailUsecase,
NewPaymentUsecase,
NewIntegrationConfigUsecase,
)

View File

@ -30,12 +30,16 @@ type InitializationRepo interface {
DiskMountPoints() []string DiskMountPoints() []string
} }
type SystemConfigUsecase struct { type TaskReloader interface {
repo InitializationRepo Reload(context.Context) error
tasks TaskRuntime
} }
func NewSystemConfigUsecase(repo InitializationRepo, tasks TaskRuntime) *SystemConfigUsecase { type SystemConfigUsecase struct {
repo InitializationRepo
tasks TaskReloader
}
func NewSystemConfigUsecase(repo InitializationRepo, tasks TaskReloader) *SystemConfigUsecase {
return &SystemConfigUsecase{repo: repo, tasks: tasks} return &SystemConfigUsecase{repo: repo, tasks: tasks}
} }

View File

@ -62,7 +62,7 @@ func NewUserUsecase(repo UserRepo) *UserUsecase { return &UserUsecase{repo: repo
func (uc *UserUsecase) Login(ctx context.Context, username, password string) (*User, error) { func (uc *UserUsecase) Login(ctx context.Context, username, password string) (*User, error) {
u, err := uc.repo.FindUserByUsername(ctx, username) u, err := uc.repo.FindUserByUsername(ctx, username)
if err != nil || bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) != nil { if err != nil || u == nil || bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) != nil {
return nil, ErrInvalidCredentials return nil, ErrInvalidCredentials
} }
uc.fallbackDefaultRouter(ctx, u) uc.fallbackDefaultRouter(ctx, u)

View File

@ -12,6 +12,16 @@ type defaultRouterUserRepo struct {
err error err error
} }
type loginUserRepo struct {
UserRepo
user *User
err error
}
func (r *loginUserRepo) FindUserByUsername(context.Context, string) (*User, error) {
return r.user, r.err
}
func (r *defaultRouterUserRepo) HasAuthorityMenu(context.Context, uint, string) (bool, error) { func (r *defaultRouterUserRepo) HasAuthorityMenu(context.Context, uint, string) (bool, error) {
return r.hasMenu, r.err return r.hasMenu, r.err
} }
@ -35,3 +45,10 @@ func TestFallbackDefaultRouterMatchesMenuLookupOutcome(t *testing.T) {
t.Fatalf("empty missing default route = %q", user.Authority.DefaultRouter) t.Fatalf("empty missing default route = %q", user.Authority.DefaultRouter)
} }
} }
func TestLoginRejectsMissingUserWithoutPanic(t *testing.T) {
user, err := NewUserUsecase(&loginUserRepo{}).Login(context.Background(), "missing", "password")
if !errors.Is(err, ErrInvalidCredentials) || user != nil {
t.Fatalf("Login() = %#v, %v; want nil, ErrInvalidCredentials", user, err)
}
}

View File

@ -5,6 +5,8 @@ watching, and repository implementations.
- `repository/`: system repositories and table persistence - `repository/`: system repositories and table persistence
- `payment/`: payment configuration and payment-order persistence - `payment/`: payment configuration and payment-order persistence
- each subpackage owns its Wire `ProviderSet`; the root package only binds the
shared `Data` infrastructure and aggregates those sets
- root files: shared database lifecycle, runtime clients, integration-config - root files: shared database lifecycle, runtime clients, integration-config
storage, data-scope auditing, and migration orchestration storage, data-scope auditing, and migration orchestration

View File

@ -25,14 +25,8 @@ var ProviderSet = wire.NewSet(
wire.Bind(new(datasystem.Provider), new(*Data)), wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)), wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
wire.Bind(new(datapayment.Provider), new(*Data)), wire.Bind(new(datapayment.Provider), new(*Data)),
datasystem.NewRuntimeSettings, datasystem.ProviderSet,
datasystem.NewTokenIssuer, datapayment.ProviderSet,
datasystem.NewUserRepo, datasystem.NewAuthorityAccessRepo, datasystem.NewAPIRepo, datasystem.NewPermissionRepo,
datasystem.NewMenuRepo, datasystem.NewDepartmentRepo, datasystem.NewPositionRepo, datasystem.NewDictionaryRepo, datasystem.NewParameterRepo, datasystem.NewAPITokenRepo,
datasystem.NewSecurityRepo,
datasystem.NewVersionRepo, datasystem.NewExportRepo, datasystem.NewAuditRepo, datasystem.NewAuditRecorderRepo, datasystem.NewLogFileRepo, datasystem.NewTaskRepo,
datasystem.NewMediaRepo, datasystem.NewAnnouncementRepo, datapayment.NewPaymentRepo, datapayment.NewPaymentOrderRepo,
datasystem.NewIntegrationConfigRepo,
) )
func NewIntegrationRuntime(data *Data) *runtimeconfig.Store { func NewIntegrationRuntime(data *Data) *runtimeconfig.Store {

View File

@ -0,0 +1,6 @@
package payment
import "github.com/google/wire"
// ProviderSet wires payment persistence repositories.
var ProviderSet = wire.NewSet(NewPaymentRepo, NewPaymentOrderRepo)

View File

@ -0,0 +1,29 @@
package system
import "github.com/google/wire"
// ProviderSet wires system repositories and their runtime-backed adapters.
var ProviderSet = wire.NewSet(
NewRuntimeSettings,
NewTokenIssuer,
NewUserRepo,
NewAuthorityAccessRepo,
NewAPIRepo,
NewPermissionRepo,
NewMenuRepo,
NewDepartmentRepo,
NewPositionRepo,
NewDictionaryRepo,
NewParameterRepo,
NewAPITokenRepo,
NewSecurityRepo,
NewVersionRepo,
NewExportRepo,
NewAuditRepo,
NewAuditRecorderRepo,
NewLogFileRepo,
NewTaskRepo,
NewMediaRepo,
NewAnnouncementRepo,
NewIntegrationConfigRepo,
)

View File

@ -27,6 +27,9 @@ func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, h
} }
func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes *platformmodule.Runtime, ws *websocket.Server) *gin.Engine { func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes *platformmodule.Runtime, ws *websocket.Server) *gin.Engine {
if runtime == nil {
runtime = conf.NewRuntime(nil, nil)
}
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
engine := gin.New() engine := gin.New()
if err := engine.SetTrustedProxies(nil); err != nil && logger != nil { if err := engine.SetTrustedProxies(nil); err != nil && logger != nil {

View File

@ -232,15 +232,24 @@ func failLogViewer(c *gin.Context, err error, logger *slog.Logger) {
} }
func (h *Audit) DeleteError(c *gin.Context) { func (h *Audit) DeleteError(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) id, err := positiveUintQuery(c, "ID")
if err := h.service.DeleteErrors(c.Request.Context(), []uint{uint(id)}); err != nil { if err != nil {
Fail(c, "错误记录ID非法")
return
}
if err := h.service.DeleteErrors(c.Request.Context(), []uint{id}); err != nil {
Fail(c, "删除失败:"+err.Error()) Fail(c, "删除失败:"+err.Error())
return return
} }
Write(c, CodeSuccess, gin.H{}, "删除成功") Write(c, CodeSuccess, gin.H{}, "删除成功")
} }
func (h *Audit) DeleteErrors(c *gin.Context) { func (h *Audit) DeleteErrors(c *gin.Context) {
if err := h.service.DeleteErrors(c.Request.Context(), IDsFromQuery(c)); err != nil { ids, err := IDsFromQuery(c)
if err != nil {
Fail(c, "错误记录ID非法")
return
}
if err := h.service.DeleteErrors(c.Request.Context(), ids); err != nil {
Fail(c, "批量删除失败:"+err.Error()) Fail(c, "批量删除失败:"+err.Error())
return return
} }
@ -259,8 +268,12 @@ func (h *Audit) UpdateError(c *gin.Context) {
Write(c, CodeSuccess, gin.H{}, "更新成功") Write(c, CodeSuccess, gin.H{}, "更新成功")
} }
func (h *Audit) Error(c *gin.Context) { func (h *Audit) Error(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) id, err := positiveUintQuery(c, "ID")
item, err := h.service.Error(c.Request.Context(), uint(id)) if err != nil {
Fail(c, "错误记录ID非法")
return
}
item, err := h.service.Error(c.Request.Context(), id)
if err != nil { if err != nil {
Fail(c, "查询失败:"+err.Error()) Fail(c, "查询失败:"+err.Error())
return return

View File

@ -142,7 +142,11 @@ func (h *Export) Preview(c *gin.Context) {
Fail(c, "模板ID不能为空") Fail(c, "模板ID不能为空")
return return
} }
params, _ := exportParams(c.Request.URL.Query()) params, err := exportParams(c.Request.URL.Query())
if err != nil {
Fail(c, "解析 params 参数失败")
return
}
sql, err := h.service.Preview(c.Request.Context(), templateID, params) sql, err := h.service.Preview(c.Request.Context(), templateID, params)
if err != nil { if err != nil {
Fail(c, "获取失败") Fail(c, "获取失败")

View File

@ -56,7 +56,11 @@ func (h *Media) Upload(c *gin.Context) {
return return
} }
defer opened.Close() defer opened.Close()
category, _ := strconv.Atoi(c.DefaultPostForm("classId", "0")) category, parseErr := strconv.Atoi(c.DefaultPostForm("classId", "0"))
if parseErr != nil || category < 0 {
Fail(c, "文件分类 ID 非法")
return
}
save := c.DefaultQuery("noSave", "0") == "0" save := c.DefaultQuery("noSave", "0") == "0"
item, err := h.service.Upload(c.Request.Context(), claims.ID, header.Filename, header.Header.Get("Content-Type"), category, opened, save) item, err := h.service.Upload(c.Request.Context(), claims.ID, header.Filename, header.Header.Get("Content-Type"), category, opened, save)
if err != nil { if err != nil {
@ -276,12 +280,16 @@ func (h *Media) CompleteUpload(c *gin.Context) {
} }
func (h *Media) CancelUpload(c *gin.Context) { func (h *Media) CancelUpload(c *gin.Context) {
claims := Claims(c) claims := Claims(c)
id, _ := strconv.ParseUint(c.Param("uploadId"), 10, 64)
if claims == nil { if claims == nil {
Fail(c, "未登录") Fail(c, "未登录")
return return
} }
if err := h.service.CancelUpload(c.Request.Context(), claims.ID, uint(id)); err != nil { id, err := positiveUintParam(c, "uploadId")
if err != nil {
Fail(c, "上传会话 ID 非法")
return
}
if err := h.service.CancelUpload(c.Request.Context(), claims.ID, id); err != nil {
Fail(c, err.Error()) Fail(c, err.Error())
return return
} }

View File

@ -1,11 +1,31 @@
package handler package handler
import ( import (
"errors"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
var errInvalidPositiveUint = errors.New("参数必须是正整数")
func positiveUint(raw string) (uint, error) {
parsed, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 64)
if err != nil || parsed == 0 || uint64(uint(parsed)) != parsed {
return 0, errInvalidPositiveUint
}
return uint(parsed), nil
}
func positiveUintQuery(c *gin.Context, key string) (uint, error) {
return positiveUint(c.Query(key))
}
func positiveUintParam(c *gin.Context, key string) (uint, error) {
return positiveUint(c.Param(key))
}
func page(c *gin.Context) (int, int, error) { func page(c *gin.Context) (int, int, error) {
var value, size int var value, size int
if raw, exists := c.GetQuery("page"); exists && raw != "" { if raw, exists := c.GetQuery("page"); exists && raw != "" {
@ -25,14 +45,18 @@ func page(c *gin.Context) (int, int, error) {
return value, size, nil return value, size, nil
} }
func IDsFromQuery(c *gin.Context) []uint { func IDsFromQuery(c *gin.Context) ([]uint, error) {
values := c.QueryArray("IDs[]") values := c.QueryArray("IDs[]")
if len(values) == 0 {
return nil, errInvalidPositiveUint
}
ids := make([]uint, 0, len(values)) ids := make([]uint, 0, len(values))
for _, value := range values { for _, value := range values {
id, _ := strconv.ParseUint(value, 10, 64) id, err := positiveUint(value)
if id > 0 { if err != nil {
ids = append(ids, uint(id)) return nil, err
} }
ids = append(ids, id)
} }
return ids return ids, nil
} }

View File

@ -1,6 +1,7 @@
package handler package handler
import ( import (
"kra/internal/server/middleware"
"kra/internal/service" "kra/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -10,9 +11,10 @@ type Session struct{ tokens *service.TokenService }
func NewSession(tokens *service.TokenService) *Session { return &Session{tokens: tokens} } func NewSession(tokens *service.TokenService) *Session { return &Session{tokens: tokens} }
func (h *Session) Logout(c *gin.Context) { func (h *Session) Logout(c *gin.Context) {
token := c.GetHeader("x-token") token := middleware.RequestToken(c, false)
if token == "" { if token == "" {
token, _ = c.Cookie("x-token") NoAuth(c, "未登录或非法访问")
return
} }
if err := h.tokens.BlacklistToken(c.Request.Context(), token); err != nil { if err := h.tokens.BlacklistToken(c.Request.Context(), token); err != nil {
Fail(c, "jwt作废失败") Fail(c, "jwt作废失败")

View File

@ -27,8 +27,12 @@ func versionStage(err error) (system.VersionStage, bool) {
} }
func (h *Version) Delete(c *gin.Context) { func (h *Version) Delete(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) id, err := positiveUintQuery(c, "ID")
if err := h.service.DeleteVersions(c.Request.Context(), []uint{uint(id)}); err != nil { if err != nil {
Fail(c, "版本ID非法")
return
}
if err := h.service.DeleteVersions(c.Request.Context(), []uint{id}); err != nil {
Fail(c, "删除失败:"+err.Error()) Fail(c, "删除失败:"+err.Error())
return return
} }
@ -38,8 +42,16 @@ func (h *Version) DeleteMany(c *gin.Context) {
raw := c.QueryArray("IDs[]") raw := c.QueryArray("IDs[]")
ids := make([]uint, 0, len(raw)) ids := make([]uint, 0, len(raw))
for _, value := range raw { for _, value := range raw {
id, _ := strconv.ParseUint(value, 10, 64) id, err := positiveUint(value)
ids = append(ids, uint(id)) if err != nil {
Fail(c, "版本ID非法")
return
}
ids = append(ids, id)
}
if len(ids) == 0 {
Fail(c, "版本ID不能为空")
return
} }
if err := h.service.DeleteVersions(c.Request.Context(), ids); err != nil { if err := h.service.DeleteVersions(c.Request.Context(), ids); err != nil {
Fail(c, "批量删除失败:"+err.Error()) Fail(c, "批量删除失败:"+err.Error())
@ -48,8 +60,12 @@ func (h *Version) DeleteMany(c *gin.Context) {
Write(c, CodeSuccess, gin.H{}, "批量删除成功") Write(c, CodeSuccess, gin.H{}, "批量删除成功")
} }
func (h *Version) Find(c *gin.Context) { func (h *Version) Find(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) id, err := positiveUintQuery(c, "ID")
item, err := h.service.Version(c.Request.Context(), uint(id)) if err != nil {
Fail(c, "版本ID非法")
return
}
item, err := h.service.Version(c.Request.Context(), id)
if err != nil { if err != nil {
Fail(c, "查询失败:"+err.Error()) Fail(c, "查询失败:"+err.Error())
return return
@ -112,8 +128,12 @@ func (h *Version) Download(c *gin.Context) {
Fail(c, "版本ID不能为空") Fail(c, "版本ID不能为空")
return return
} }
id, _ := strconv.ParseUint(rawID, 10, 64) id, err := positiveUint(rawID)
raw, code, err := h.service.VersionData(c.Request.Context(), uint(id)) if err != nil {
Fail(c, "版本ID非法")
return
}
raw, code, err := h.service.VersionData(c.Request.Context(), id)
if err != nil { if err != nil {
Fail(c, "获取版本记录失败:"+err.Error()) Fail(c, "获取版本记录失败:"+err.Error())
return return

View File

@ -22,6 +22,11 @@ func AccessControl(runtime *conf.Runtime, access accessController) gin.HandlerFu
NoAuth(c, "未登录或非法访问") NoAuth(c, "未登录或非法访问")
return return
} }
if access == nil {
Write(c, CodeError, gin.H{}, "权限服务不可用")
c.Abort()
return
}
path := c.Request.URL.Path path := c.Request.URL.Path
policyPath := path policyPath := path
if config := runtime.Admin(); config != nil { if config := runtime.Admin(); config != nil {

View File

@ -30,7 +30,10 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
var requestBody []byte var requestBody []byte
multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data")
mediaUpload := multipart && isMediaUploadRoute(c.FullPath()) mediaUpload := multipart && isMediaUploadRoute(c.FullPath())
config := runtime.Admin() var config *conf.AdminBackend
if runtime != nil {
config = runtime.Admin()
}
bodyLimit := defaultRequestBodyLimit bodyLimit := defaultRequestBodyLimit
if mediaUpload { if mediaUpload {
bodyLimit = system.DefaultMaxMediaFileSize + (1 << 20) bodyLimit = system.DefaultMaxMediaFileSize + (1 << 20)

View File

@ -23,6 +23,10 @@ const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed"
func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.HandlerFunc { func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if runtime == nil || recorder == nil {
c.Next()
return
}
path := c.Request.URL.Path path := c.Request.URL.Path
if !recordsOperation(c.Request.Method, path) { if !recordsOperation(c.Request.Method, path) {
c.Next() c.Next()
@ -95,12 +99,11 @@ func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.
} }
func operationQueryBody(raw string) []byte { func operationQueryBody(raw string) []byte {
query, _ := url.QueryUnescape(raw) parsed, _ := url.ParseQuery(raw)
values := make(map[string]string) values := make(map[string]string, len(parsed))
for _, item := range strings.Split(query, "&") { for key, items := range parsed {
parts := strings.Split(item, "=") if len(items) > 0 {
if len(parts) == 2 { values[key] = items[len(items)-1]
values[parts[0]] = parts[1]
} }
} }
body, _ := json.Marshal(&values) body, _ := json.Marshal(&values)
@ -189,23 +192,6 @@ func operationPathMatches(pattern, path string) bool {
return len(patternParts) == len(pathParts) return len(patternParts) == len(pathParts)
} }
func routeSuffix(path string) string {
bestIndex := -1
bestPath := path
for _, marker := range []string{"/user/", "/api/", "/casbin/", "/authority/", "/menu/", "/department/", "/position/", "/sysDictionary/", "/sysDictionaryDetail/", "/sysParams/", "/securityConfig/", "/system/", "/sysApiToken/", "/sysVersion/", "/sysExportTemplate/", "/sysError/", "/sysLoginLog/", "/sysOperationRecord/", "/dataAccessLog/", "/timedTask/", "/info/", "/email/", "/integration/", "/payment/"} {
if index := strings.Index(path, marker); index >= 0 {
// Router prefixes may themselves contain a registered route marker
// (for example /api/integration/...). Keep the deepest match so the
// policy and audit route remain the actual application endpoint.
if index > bestIndex {
bestIndex = index
bestPath = path[index:]
}
}
}
return bestPath
}
var operationRoutes = func() map[string]struct{} { var operationRoutes = func() map[string]struct{} {
values := []string{ values := []string{
"POST /user/admin_register", "POST /user/changePassword", "POST /user/setUserAuthority", "DELETE /user/deleteUser", "PUT /user/setUserInfo", "PUT /user/setSelfInfo", "POST /user/setUserAuthorities", "POST /user/setUserDepartments", "POST /user/setUserPositions", "POST /user/resetPassword", "PUT /user/setSelfSetting", "POST /user/admin_register", "POST /user/changePassword", "POST /user/setUserAuthority", "DELETE /user/deleteUser", "PUT /user/setUserInfo", "PUT /user/setSelfInfo", "POST /user/setUserAuthorities", "POST /user/setUserDepartments", "POST /user/setUserPositions", "POST /user/resetPassword", "PUT /user/setSelfSetting",

View File

@ -68,7 +68,9 @@ func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool)
return true return true
} }
func requestToken(c *gin.Context, allowQueryToken bool) string { // RequestToken extracts the authentication token accepted by HTTP handlers.
// Query-string tokens are opt-in for WebSocket handshakes only.
func RequestToken(c *gin.Context, allowQueryToken bool) string {
token := strings.TrimSpace(c.GetHeader("x-token")) token := strings.TrimSpace(c.GetHeader("x-token"))
if token == "" { if token == "" {
authorization := strings.TrimSpace(c.GetHeader("Authorization")) authorization := strings.TrimSpace(c.GetHeader("Authorization"))
@ -91,6 +93,10 @@ func requestToken(c *gin.Context, allowQueryToken bool) string {
return token return token
} }
func requestToken(c *gin.Context, allowQueryToken bool) string {
return RequestToken(c, allowQueryToken)
}
func tokenErrorMessage(err error) string { func tokenErrorMessage(err error) string {
message := "无法处理此token" message := "无法处理此token"
switch { switch {

View File

@ -19,6 +19,10 @@ const (
// configuration reload takes effect without rebuilding the Gin engine. // configuration reload takes effect without rebuilding the Gin engine.
func CORS(runtime *conf.Runtime) gin.HandlerFunc { func CORS(runtime *conf.Runtime) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if runtime == nil {
c.Next()
return
}
config := runtime.Admin() config := runtime.Admin()
if config == nil || config.Cors == nil { if config == nil || config.Cors == nil {
c.Next() c.Next()
@ -28,12 +32,14 @@ func CORS(runtime *conf.Runtime) gin.HandlerFunc {
origin := c.GetHeader("Origin") origin := c.GetHeader("Origin")
corsHandled := false corsHandled := false
if mode == "allow-all" { if mode == "allow-all" {
setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true) if origin != "" {
corsHandled = true setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true)
corsHandled = true
}
} else if rule := matchingCORSRule(config.Cors.Whitelist, origin); rule != nil { } else if rule := matchingCORSRule(config.Cors.Whitelist, origin); rule != nil {
setCORSHeaders(c, rule.AllowOrigin, rule.AllowHeaders, rule.AllowMethods, rule.ExposeHeaders, rule.AllowCredentials) setCORSHeaders(c, rule.AllowOrigin, rule.AllowHeaders, rule.AllowMethods, rule.ExposeHeaders, rule.AllowCredentials)
corsHandled = true corsHandled = true
} else if mode == "strict-whitelist" && !(c.Request.Method == http.MethodGet && c.Request.URL.Path == "/health") { } else if mode == "strict-whitelist" && !(c.Request.Method == http.MethodGet && isHealthPath(c.Request.URL.Path)) {
c.AbortWithStatus(http.StatusForbidden) c.AbortWithStatus(http.StatusForbidden)
return return
} }
@ -45,6 +51,11 @@ func CORS(runtime *conf.Runtime) gin.HandlerFunc {
} }
} }
func isHealthPath(path string) bool {
path = strings.TrimSuffix(path, "/")
return path == "/health" || strings.HasSuffix(path, "/health")
}
func matchingCORSRule(rules []*conf.AdminBackend_CORSRule, origin string) *conf.AdminBackend_CORSRule { func matchingCORSRule(rules []*conf.AdminBackend_CORSRule, origin string) *conf.AdminBackend_CORSRule {
for _, rule := range rules { for _, rule := range rules {
if rule != nil && origin == rule.AllowOrigin { if rule != nil && origin == rule.AllowOrigin {
@ -55,6 +66,7 @@ func matchingCORSRule(rules []*conf.AdminBackend_CORSRule, origin string) *conf.
} }
func setCORSHeaders(c *gin.Context, origin, headers, methods, expose string, credentials bool) { func setCORSHeaders(c *gin.Context, origin, headers, methods, expose string, credentials bool) {
c.Header("Vary", "Origin")
c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Headers", headers) c.Header("Access-Control-Allow-Headers", headers)
c.Header("Access-Control-Allow-Methods", methods) c.Header("Access-Control-Allow-Methods", methods)

View File

@ -37,3 +37,9 @@ func TestCORSConsumesMatchedWhitelistPreflight(t *testing.T) {
t.Fatalf("matched whitelist preflight status = %d, want %d", response.Code, http.StatusNoContent) t.Fatalf("matched whitelist preflight status = %d, want %d", response.Code, http.StatusNoContent)
} }
} }
func TestCORSStrictWhitelistAllowsPrefixedHealth(t *testing.T) {
if !isHealthPath("/admin/health") {
t.Fatal("prefixed health endpoint was not recognized by strict whitelist")
}
}

View File

@ -2,6 +2,7 @@ package middleware
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
@ -12,6 +13,16 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func TestOperationQueryBodyPreservesEncodedSeparators(t *testing.T) {
var values map[string]string
if err := json.Unmarshal(operationQueryBody("filter=one%3Dtwo%26three&repeat=first&repeat=last"), &values); err != nil {
t.Fatal(err)
}
if values["filter"] != "one=two&three" || values["repeat"] != "last" {
t.Fatalf("unexpected query body: %#v", values)
}
}
func runErrorAudit(t *testing.T, path, response string, logger *slog.Logger) { func runErrorAudit(t *testing.T, path, response string, logger *slog.Logger) {
t.Helper() t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)

View File

@ -16,6 +16,10 @@ func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc {
c.Next() c.Next()
return return
} }
if settings == nil {
c.Next()
return
}
config, err := settings.CurrentSecurity(c.Request.Context()) config, err := settings.CurrentSecurity(c.Request.Context())
if err != nil || config == nil { if err != nil || config == nil {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": CodeError, "msg": "安全服务暂不可用"}) c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": CodeError, "msg": "安全服务暂不可用"})
@ -29,7 +33,11 @@ func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc {
if window < 1 { if window < 1 {
window = 60 window = 60
} }
key := "KRA_SecLimit" + c.ClientIP() + c.FullPath() route := c.FullPath()
if route == "" {
route = path
}
key := "KRA_SecLimit:" + c.ClientIP() + ":" + route
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 { if cacheErr != nil {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": CodeError, "msg": "安全服务暂不可用"}) c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": CodeError, "msg": "安全服务暂不可用"})

View File

@ -57,6 +57,7 @@ func registerSwagger(engine *gin.Engine, prefix, version string, logger *slog.Lo
} }
func buildSwaggerDocument(routes []gin.RouteInfo, prefix, version string) string { func buildSwaggerDocument(routes []gin.RouteInfo, prefix, version string) string {
routes = append([]gin.RouteInfo(nil), routes...)
basePath := strings.TrimSuffix(prefix, "/") basePath := strings.TrimSuffix(prefix, "/")
if basePath == "" { if basePath == "" {
basePath = "/" basePath = "/"
@ -159,8 +160,8 @@ func swaggerPathParameters(path string) []map[string]any {
func swaggerPublicPath(path string) bool { func swaggerPublicPath(path string) bool {
for _, marker := range []string{ for _, marker := range []string{
"/health", "/base/login", "/base/captcha", "/init/checkdb", "/init/initdb", "/health", "/base/login", "/base/captcha", "/init/checkdb", "/init/initdb",
"/api/freshCasbin", "/sysExportTemplate/exportExcelByToken", "/sysExportTemplate/exportTemplateByToken", "/sysExportTemplate/exportExcelByToken", "/sysExportTemplate/exportTemplateByToken",
"/sysError/createSysError", "/info/getInfoDataSource", "/info/getInfoPublic", "/sysError/createSysError", "/info/getInfoPublic",
} { } {
if path == marker { if path == marker {
return true return true

View File

@ -0,0 +1,30 @@
package server
import (
"encoding/json"
"testing"
"github.com/gin-gonic/gin"
)
func TestSwaggerMarksOnlyPublicRoutesWithoutAuth(t *testing.T) {
document := buildSwaggerDocument([]gin.RouteInfo{
{Method: "GET", Path: "/api/freshCasbin"},
{Method: "GET", Path: "/info/getInfoDataSource"},
{Method: "GET", Path: "/info/getInfoPublic"},
}, "", "test")
var payload struct {
Paths map[string]map[string]map[string]any `json:"paths"`
}
if err := json.Unmarshal([]byte(document), &payload); err != nil {
t.Fatal(err)
}
for _, path := range []string{"/api/freshCasbin", "/info/getInfoDataSource"} {
if _, ok := payload.Paths[path]["get"]["security"]; !ok {
t.Fatalf("private route %s was marked public", path)
}
}
if _, ok := payload.Paths["/info/getInfoPublic"]["get"]["security"]; ok {
t.Fatal("public route /info/getInfoPublic requires authentication")
}
}

View File

@ -46,6 +46,8 @@ func NewTaskScheduler(tasks *system.TaskUsecase, authorities *system.AuthorityUs
func NewTaskRuntime(scheduler *TaskScheduler) system.TaskRuntime { return scheduler } func NewTaskRuntime(scheduler *TaskScheduler) system.TaskRuntime { return scheduler }
func NewTaskReloader(scheduler *TaskScheduler) system.TaskReloader { return scheduler }
func (s *TaskScheduler) Start(ctx context.Context) error { func (s *TaskScheduler) Start(ctx context.Context) error {
runContext, cancel := context.WithCancel(ctx) runContext, cancel := context.WithCancel(ctx)
s.ctxMu.Lock() s.ctxMu.Lock()

View File

@ -3,4 +3,4 @@ package worker
import "github.com/google/wire" import "github.com/google/wire"
// ProviderSet contains background task runtime providers. // ProviderSet contains background task runtime providers.
var ProviderSet = wire.NewSet(NewTaskMethods, NewTaskExecutorWithRegistry, NewTaskScheduler, NewTaskRuntime) var ProviderSet = wire.NewSet(NewTaskMethods, NewTaskExecutorWithRegistry, NewTaskScheduler, NewTaskRuntime, NewTaskReloader)

View File

@ -97,6 +97,9 @@ func (r *Runtime) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.E
return return
} }
for _, registrar := range r.routes { for _, registrar := range r.routes {
if registrar == nil {
continue
}
registrar.RegisterRoutes(public, private, engine) registrar.RegisterRoutes(public, private, engine)
} }
} }

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"testing" "testing"
"github.com/gin-gonic/gin"
"kra/pkg/database/migration" "kra/pkg/database/migration"
"kra/pkg/task" "kra/pkg/task"
) )
@ -41,3 +42,18 @@ func TestCatalogCollectsContributions(t *testing.T) {
t.Fatalf("unexpected task methods: %#v", got) t.Fatalf("unexpected task methods: %#v", got)
} }
} }
type routeRegistrarStub struct{ called bool }
func (stub *routeRegistrarStub) RegisterRoutes(*gin.RouterGroup, *gin.RouterGroup, *gin.Engine) {
stub.called = true
}
func TestRuntimeSkipsNilRouteRegistrars(t *testing.T) {
stub := &routeRegistrarStub{}
runtime := NewRuntime(nil, stub)
runtime.RegisterRoutes(nil, nil, nil)
if !stub.called {
t.Fatal("non-nil route registrar was not called")
}
}