优化结构

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"
"strings"
"kra/internal/app"
"kra/internal/conf"
"kra/internal/server/router"
"kra/internal/service"
"kra/internal/service/dto"
"kra/internal/worker"
"kra/pkg/logging"
"kra/pkg/module"
"kra/pkg/mq"
platformtask "kra/pkg/task"
"github.com/go-kratos/kratos/v3"
"github.com/go-kratos/kratos/v3/config"
@ -41,6 +45,15 @@ func init() {
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 {
if audit != nil && loggerControl != nil {
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)
apiToken := handler.NewAPIToken(tokenService)
initializationRepo := initialize.NewRepo(dataData, catalog)
systemConfigUsecase := system2.NewSystemConfigUsecase(initializationRepo, taskRuntime)
taskReloader := worker.NewTaskReloader(taskScheduler)
systemConfigUsecase := system2.NewSystemConfigUsecase(initializationRepo, taskReloader)
systemConfigService := service.NewSystemConfigService(systemConfigUsecase, runtimeSettings)
systemConfig := handler.NewSystemConfig(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/tencentyun/cos-go-sdk-v5 v0.7.60
github.com/xuri/excelize/v2 v2.9.0
go.einride.tech/aip v0.86.3
go.mongodb.org/mongo-driver v1.17.2
go.uber.org/automaxprocs v1.6.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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
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/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
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=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
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/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A=
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.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
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/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=

View File

@ -7,4 +7,4 @@ import (
)
// 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) {
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})
}
@ -65,7 +68,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
if err != nil || config == nil {
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)
if lockErr != nil {
return nil, fmt.Errorf("%w: %v", ErrLoginState, lockErr)
@ -77,8 +80,6 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
}
ipTTL := time.Hour
requireCaptcha := config == nil || config.CaptchaOpen == 0
if config != nil {
if config.CaptchaTimeout > 0 {
ipTTL = time.Duration(config.CaptchaTimeout) * time.Second
}
@ -86,8 +87,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
if counterErr != nil {
return nil, fmt.Errorf("%w: %v", ErrLoginState, counterErr)
}
requireCaptcha = config.CaptchaOpen == 0 || failures > config.CaptchaOpen
}
requireCaptcha := config.CaptchaOpen == 0 || failures > config.CaptchaOpen
if requireCaptcha && !uc.security.VerifyCaptcha(ctx, attempt.CaptchaID, attempt.Captcha, true) {
_, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL)
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)
if err != nil {
_, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL)
if config != nil && config.LockEnable {
if config.LockEnable {
lockTTL := time.Duration(config.LockDuration) * time.Minute
failures, stateErr := uc.security.IncrementLoginFailure(ctx, attempt.Username, lockTTL)
if stateErr != nil {
@ -119,7 +119,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
}
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
}
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) {
repo := &switchAuthorityUserRepo{}
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
}
type SystemConfigUsecase struct {
repo InitializationRepo
tasks TaskRuntime
type TaskReloader interface {
Reload(context.Context) error
}
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}
}

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) {
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
}
uc.fallbackDefaultRouter(ctx, u)

View File

@ -12,6 +12,16 @@ type defaultRouterUserRepo struct {
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) {
return r.hasMenu, r.err
}
@ -35,3 +45,10 @@ func TestFallbackDefaultRouterMatchesMenuLookupOutcome(t *testing.T) {
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
- `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
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.DatabaseProvider), new(*Data)),
wire.Bind(new(datapayment.Provider), new(*Data)),
datasystem.NewRuntimeSettings,
datasystem.NewTokenIssuer,
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,
datasystem.ProviderSet,
datapayment.ProviderSet,
)
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 {
if runtime == nil {
runtime = conf.NewRuntime(nil, nil)
}
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
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) {
id, _ := strconv.ParseUint(c.Query("ID"), 10, 64)
if err := h.service.DeleteErrors(c.Request.Context(), []uint{uint(id)}); err != nil {
id, err := positiveUintQuery(c, "ID")
if err != nil {
Fail(c, "错误记录ID非法")
return
}
if err := h.service.DeleteErrors(c.Request.Context(), []uint{id}); err != nil {
Fail(c, "删除失败:"+err.Error())
return
}
Write(c, CodeSuccess, gin.H{}, "删除成功")
}
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())
return
}
@ -259,8 +268,12 @@ func (h *Audit) UpdateError(c *gin.Context) {
Write(c, CodeSuccess, gin.H{}, "更新成功")
}
func (h *Audit) Error(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("ID"), 10, 64)
item, err := h.service.Error(c.Request.Context(), uint(id))
id, err := positiveUintQuery(c, "ID")
if err != nil {
Fail(c, "错误记录ID非法")
return
}
item, err := h.service.Error(c.Request.Context(), id)
if err != nil {
Fail(c, "查询失败:"+err.Error())
return

View File

@ -142,7 +142,11 @@ func (h *Export) Preview(c *gin.Context) {
Fail(c, "模板ID不能为空")
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)
if err != nil {
Fail(c, "获取失败")

View File

@ -56,7 +56,11 @@ func (h *Media) Upload(c *gin.Context) {
return
}
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"
item, err := h.service.Upload(c.Request.Context(), claims.ID, header.Filename, header.Header.Get("Content-Type"), category, opened, save)
if err != nil {
@ -276,12 +280,16 @@ func (h *Media) CompleteUpload(c *gin.Context) {
}
func (h *Media) CancelUpload(c *gin.Context) {
claims := Claims(c)
id, _ := strconv.ParseUint(c.Param("uploadId"), 10, 64)
if claims == nil {
Fail(c, "未登录")
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())
return
}

View File

@ -1,11 +1,31 @@
package handler
import (
"errors"
"strconv"
"strings"
"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) {
var value, size int
if raw, exists := c.GetQuery("page"); exists && raw != "" {
@ -25,14 +45,18 @@ func page(c *gin.Context) (int, int, error) {
return value, size, nil
}
func IDsFromQuery(c *gin.Context) []uint {
func IDsFromQuery(c *gin.Context) ([]uint, error) {
values := c.QueryArray("IDs[]")
if len(values) == 0 {
return nil, errInvalidPositiveUint
}
ids := make([]uint, 0, len(values))
for _, value := range values {
id, _ := strconv.ParseUint(value, 10, 64)
if id > 0 {
ids = append(ids, uint(id))
id, err := positiveUint(value)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids
return ids, nil
}

View File

@ -1,6 +1,7 @@
package handler
import (
"kra/internal/server/middleware"
"kra/internal/service"
"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 (h *Session) Logout(c *gin.Context) {
token := c.GetHeader("x-token")
token := middleware.RequestToken(c, false)
if token == "" {
token, _ = c.Cookie("x-token")
NoAuth(c, "未登录或非法访问")
return
}
if err := h.tokens.BlacklistToken(c.Request.Context(), token); err != nil {
Fail(c, "jwt作废失败")

View File

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

View File

@ -22,6 +22,11 @@ func AccessControl(runtime *conf.Runtime, access accessController) gin.HandlerFu
NoAuth(c, "未登录或非法访问")
return
}
if access == nil {
Write(c, CodeError, gin.H{}, "权限服务不可用")
c.Abort()
return
}
path := c.Request.URL.Path
policyPath := path
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
multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data")
mediaUpload := multipart && isMediaUploadRoute(c.FullPath())
config := runtime.Admin()
var config *conf.AdminBackend
if runtime != nil {
config = runtime.Admin()
}
bodyLimit := defaultRequestBodyLimit
if mediaUpload {
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 {
return func(c *gin.Context) {
if runtime == nil || recorder == nil {
c.Next()
return
}
path := c.Request.URL.Path
if !recordsOperation(c.Request.Method, path) {
c.Next()
@ -95,12 +99,11 @@ func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.
}
func operationQueryBody(raw string) []byte {
query, _ := url.QueryUnescape(raw)
values := make(map[string]string)
for _, item := range strings.Split(query, "&") {
parts := strings.Split(item, "=")
if len(parts) == 2 {
values[parts[0]] = parts[1]
parsed, _ := url.ParseQuery(raw)
values := make(map[string]string, len(parsed))
for key, items := range parsed {
if len(items) > 0 {
values[key] = items[len(items)-1]
}
}
body, _ := json.Marshal(&values)
@ -189,23 +192,6 @@ func operationPathMatches(pattern, path string) bool {
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{} {
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",

View File

@ -68,7 +68,9 @@ func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool)
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"))
if token == "" {
authorization := strings.TrimSpace(c.GetHeader("Authorization"))
@ -91,6 +93,10 @@ func requestToken(c *gin.Context, allowQueryToken bool) string {
return token
}
func requestToken(c *gin.Context, allowQueryToken bool) string {
return RequestToken(c, allowQueryToken)
}
func tokenErrorMessage(err error) string {
message := "无法处理此token"
switch {

View File

@ -19,6 +19,10 @@ const (
// configuration reload takes effect without rebuilding the Gin engine.
func CORS(runtime *conf.Runtime) gin.HandlerFunc {
return func(c *gin.Context) {
if runtime == nil {
c.Next()
return
}
config := runtime.Admin()
if config == nil || config.Cors == nil {
c.Next()
@ -28,12 +32,14 @@ func CORS(runtime *conf.Runtime) gin.HandlerFunc {
origin := c.GetHeader("Origin")
corsHandled := false
if mode == "allow-all" {
if origin != "" {
setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true)
corsHandled = true
}
} else if rule := matchingCORSRule(config.Cors.Whitelist, origin); rule != nil {
setCORSHeaders(c, rule.AllowOrigin, rule.AllowHeaders, rule.AllowMethods, rule.ExposeHeaders, rule.AllowCredentials)
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)
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 {
for _, rule := range rules {
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) {
c.Header("Vary", "Origin")
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Headers", headers)
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)
}
}
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 (
"bytes"
"encoding/json"
"errors"
"log/slog"
"net/http"
@ -12,6 +13,16 @@ import (
"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) {
t.Helper()
gin.SetMode(gin.TestMode)

View File

@ -16,6 +16,10 @@ func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc {
c.Next()
return
}
if settings == nil {
c.Next()
return
}
config, err := settings.CurrentSecurity(c.Request.Context())
if err != nil || config == nil {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": CodeError, "msg": "安全服务暂不可用"})
@ -29,7 +33,11 @@ func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc {
if window < 1 {
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)
if cacheErr != nil {
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 {
routes = append([]gin.RouteInfo(nil), routes...)
basePath := strings.TrimSuffix(prefix, "/")
if basePath == "" {
basePath = "/"
@ -159,8 +160,8 @@ func swaggerPathParameters(path string) []map[string]any {
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",
"/sysExportTemplate/exportExcelByToken", "/sysExportTemplate/exportTemplateByToken",
"/sysError/createSysError", "/info/getInfoPublic",
} {
if path == marker {
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 NewTaskReloader(scheduler *TaskScheduler) system.TaskReloader { return scheduler }
func (s *TaskScheduler) Start(ctx context.Context) error {
runContext, cancel := context.WithCancel(ctx)
s.ctxMu.Lock()

View File

@ -3,4 +3,4 @@ package worker
import "github.com/google/wire"
// 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
}
for _, registrar := range r.routes {
if registrar == nil {
continue
}
registrar.RegisterRoutes(public, private, engine)
}
}

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"testing"
"github.com/gin-gonic/gin"
"kra/pkg/database/migration"
"kra/pkg/task"
)
@ -41,3 +42,18 @@ func TestCatalogCollectsContributions(t *testing.T) {
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")
}
}