优化结构

This commit is contained in:
Yvan
2026-08-22 17:43:14 +08:00
parent 491ed21d48
commit 44ab14448a
14 changed files with 413 additions and 430 deletions
+5
View File
@@ -8,5 +8,10 @@ grouped by role:
- `router/`: resource route registration and the system route registrar
- `httpx/`: system adapter for shared response and cookie helpers
Cross-cutting route policy lives in `internal/routecatalog`: public/private
Swagger security, operation-audit flags, sensitive request-body handling and
API group/description metadata must be declared there. Router contract tests
keep the catalog aligned with the Gin registrations.
Keep new files in the matching role directory instead of adding transport files
to the root package.
+19 -193
View File
@@ -12,6 +12,7 @@ import (
"testing"
"kra/internal/conf"
"kra/internal/routecatalog"
"kra/internal/server/handler"
"github.com/gin-gonic/gin"
@@ -64,6 +65,24 @@ func TestGinRouteContract(t *testing.T) {
}
}
func TestGinRoutesMatchSharedCatalog(t *testing.T) {
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
registered := make(map[string]struct{}, len(engine.Routes()))
for _, route := range engine.Routes() {
key := route.Method + " " + route.Path
registered[key] = struct{}{}
if _, ok := routecatalog.Lookup(route.Method, route.Path); !ok {
t.Errorf("registered route has no descriptor: %s", key)
}
}
for _, descriptor := range routecatalog.Descriptors() {
key := descriptor.Method + " " + descriptor.Path
if _, ok := registered[key]; !ok {
t.Errorf("route descriptor is not registered: %s", key)
}
}
}
func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
var output bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&output, nil))
@@ -229,196 +248,3 @@ func TestLocalStorageRouteIsNotRegisteredForRemoteStorage(t *testing.T) {
}
}
}
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 /integration/configs/:kind
GET /integration/configs/:kind/:provider
GET /payment/orders
GET /payment/orders/:provider/:tradeNo
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 /integration/configs/:kind/:provider/test
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 /payment/callback/:provider
POST /payment/create
POST /payment/fulfill
POST /payment/order
POST /payment/orders/:provider/:tradeNo/fulfill
POST /payment/orders/:provider/:tradeNo/refund
POST /payment/query
POST /payment/refund
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 /integration/configs/:kind/:provider
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`
+10 -25
View File
@@ -16,6 +16,7 @@ import (
"time"
"kra/internal/conf"
"kra/internal/routecatalog"
"github.com/gin-gonic/gin"
)
@@ -75,8 +76,9 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
logLimit = int(config.Zap.AccessLogMaxBytes)
}
paymentCallback := isPaymentCallbackPath(c.Request.URL.Path)
paymentConfigWrite := isPaymentIntegrationConfigWrite(c.Request.Method, c.Request.URL.Path)
bodyPolicy := routecatalog.BodyPolicyFor(c.Request.Method, c.Request.URL.Path)
paymentCallback := bodyPolicy == routecatalog.BodyPolicyPaymentCallback
paymentConfigWrite := bodyPolicy == routecatalog.BodyPolicyPaymentConfig
requestText := ""
if paymentCallback {
requestText = paymentCallbackSummary(requestBody, c.GetHeader("Content-Type"))
@@ -159,16 +161,6 @@ func isMediaUploadRoute(route string) bool {
strings.HasSuffix(route, "/mediaUpload/chunk")
}
func isPaymentCallbackPath(path string) bool {
parts := strings.Split(strings.Trim(path, "/"), "/")
for index := 0; index+1 < len(parts); index++ {
if parts[index] == "payment" && parts[index+1] == "callback" {
return true
}
}
return false
}
func paymentCallbackProvider(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
for index := 0; index+2 < len(parts); index++ {
@@ -179,6 +171,12 @@ func paymentCallbackProvider(path string) string {
return "unknown"
}
// isPaymentIntegrationConfigWrite is kept as a compatibility seam for
// middleware tests; policy ownership lives in routecatalog.
func isPaymentIntegrationConfigWrite(method, path string) bool {
return routecatalog.BodyPolicyFor(method, path) == routecatalog.BodyPolicyPaymentConfig
}
func paymentCallbackSummary(body []byte, contentType string) string {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || mediaType == "" {
@@ -191,19 +189,6 @@ func paymentCallbackSummary(body []byte, contentType string) string {
return "[支付回调正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + " content_type=" + mediaType + "]"
}
func isPaymentIntegrationConfigWrite(method, path string) bool {
if method != http.MethodPut {
return false
}
parts := strings.Split(strings.Trim(path, "/"), "/")
for index := 0; index+3 < len(parts); index++ {
if parts[index] == "integration" && parts[index+1] == "configs" && parts[index+2] == "payment" && parts[index+3] != "" {
return true
}
}
return false
}
func paymentConfigSummary(body []byte) string {
digest := sha256.Sum256(body)
return "[支付配置正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + "]"
+10 -59
View File
@@ -13,6 +13,7 @@ import (
"time"
"kra/internal/conf"
"kra/internal/routecatalog"
"kra/internal/service"
"kra/internal/service/dto"
@@ -27,8 +28,12 @@ func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.
c.Next()
return
}
routePath := c.FullPath()
if routePath == "" {
routePath = c.Request.URL.Path
}
path := c.Request.URL.Path
if !recordsOperation(c.Request.Method, path) {
if !routecatalog.ShouldAudit(c.Request.Method, routePath) {
c.Next()
return
}
@@ -86,7 +91,7 @@ func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.
}
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
operationBody := operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes)
if isPaymentIntegrationConfigWrite(c.Request.Method, path) {
if routecatalog.BodyPolicyFor(c.Request.Method, path) == routecatalog.BodyPolicyPaymentConfig {
operationBody = paymentConfigSummary(requestBody)
}
if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationBody, Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
@@ -161,62 +166,8 @@ func isDownloadResponse(c *gin.Context) bool {
strings.Contains(header.Get("Content-Transfer-Encoding"), "binary")
}
// recordsOperation mirrors the routes on which operation records are enabled.
// Matching by suffix keeps the behavior stable when router-prefix is configured.
// recordsOperation remains as a small compatibility helper for tests and
// custom middleware chains that do not have a Gin context.
func recordsOperation(method, path string) bool {
for route := range operationRoutes {
parts := strings.SplitN(route, " ", 2)
if len(parts) != 2 || parts[0] != method || !operationPathMatches(parts[1], path) {
continue
}
return true
}
return false
return routecatalog.ShouldAudit(method, path)
}
func operationPathMatches(pattern, path string) bool {
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
pathParts := strings.Split(strings.Trim(path, "/"), "/")
if len(pathParts) < len(patternParts) {
return false
}
pathParts = pathParts[len(pathParts)-len(patternParts):]
for index, patternPart := range patternParts {
if strings.HasPrefix(patternPart, "*") {
return index <= len(pathParts)
}
if index >= len(pathParts) || (strings.HasPrefix(patternPart, ":") == false && patternPart != pathParts[index]) {
return false
}
}
return len(patternParts) == len(pathParts)
}
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",
"GET /api/getApiGroups", "GET /api/syncApi", "POST /api/ignoreApi", "POST /api/enterSyncApi", "POST /api/createApi", "POST /api/deleteApi", "POST /api/getApiById", "POST /api/updateApi", "DELETE /api/deleteApisByIds", "POST /api/setApiRoles", "POST /casbin/updateCasbin",
"POST /authority/createAuthority", "POST /authority/deleteAuthority", "PUT /authority/updateAuthority", "POST /authority/copyAuthority", "POST /authority/setDataScope", "POST /authority/setRoleUsers",
"POST /menu/addBaseMenu", "POST /menu/addMenuAuthority", "POST /menu/deleteBaseMenu", "POST /menu/updateBaseMenu", "POST /menu/setMenuRoles",
"POST /department/createDepartment", "PUT /department/updateDepartment", "DELETE /department/deleteDepartment", "POST /department/setDepartmentUsers",
"POST /position/createPosition", "PUT /position/updatePosition", "DELETE /position/deletePosition", "POST /position/setPositionUsers",
"POST /sysDictionary/createSysDictionary", "DELETE /sysDictionary/deleteSysDictionary", "PUT /sysDictionary/updateSysDictionary", "POST /sysDictionary/importSysDictionary", "GET /sysDictionary/exportSysDictionary",
"POST /sysDictionaryDetail/createSysDictionaryDetail", "DELETE /sysDictionaryDetail/deleteSysDictionaryDetail", "PUT /sysDictionaryDetail/updateSysDictionaryDetail",
"POST /sysParams/createSysParams", "DELETE /sysParams/deleteSysParams", "DELETE /sysParams/deleteSysParamsByIds", "PUT /sysParams/updateSysParams",
"POST /securityConfig/setSecurityConfig", "POST /system/setSystemConfig", "POST /system/reloadSystem",
"POST /sysApiToken/createApiToken", "POST /sysApiToken/getApiTokenList", "POST /sysApiToken/deleteApiToken",
"DELETE /sysVersion/deleteSysVersion", "DELETE /sysVersion/deleteSysVersionByIds", "POST /sysVersion/exportVersion", "POST /sysVersion/importVersion",
"POST /sysExportTemplate/createSysExportTemplate", "DELETE /sysExportTemplate/deleteSysExportTemplate", "DELETE /sysExportTemplate/deleteSysExportTemplateByIds", "PUT /sysExportTemplate/updateSysExportTemplate", "POST /sysExportTemplate/importExcel",
"DELETE /sysError/deleteSysError", "DELETE /sysError/deleteSysErrorByIds", "PUT /sysError/updateSysError",
"DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds",
"POST /timedTask/createTimedTask", "PUT /timedTask/updateTimedTask", "DELETE /timedTask/deleteTimedTask", "POST /timedTask/toggleTimedTask", "POST /timedTask/triggerTimedTask",
"POST /info/createInfo", "DELETE /info/deleteInfo", "DELETE /info/deleteInfoByIds", "PUT /info/updateInfo", "POST /email/emailTest", "POST /email/sendEmail",
"PUT /integration/configs/:kind/:provider", "POST /integration/configs/:kind/:provider/test", "DELETE /integration/configs/:kind/:provider",
"POST /payment/create", "POST /payment/query", "POST /payment/refund", "POST /payment/orders/:provider/:tradeNo/refund", "POST /payment/fulfill", "POST /payment/orders/:provider/:tradeNo/fulfill", "POST /payment/providers/:provider/test",
}
out := make(map[string]struct{}, len(values))
for _, value := range values {
out[value] = struct{}{}
}
return out
}()
+4 -16
View File
@@ -8,7 +8,7 @@ import (
"strings"
"sync"
"kra/internal/service"
"kra/internal/routecatalog"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
@@ -87,7 +87,8 @@ func buildSwaggerDocument(routes []gin.RouteInfo, prefix, version string) string
apiPath = "/"
}
documentPath := swaggerPathParameter.ReplaceAllString(apiPath, `{$1}`)
group, description := service.RouteMetadata(route.Method, apiPath)
descriptor := routecatalog.Describe(route.Method, apiPath)
group, description := descriptor.Group, descriptor.Description
if description == "" {
description = route.Method + " " + apiPath
}
@@ -103,7 +104,7 @@ func buildSwaggerDocument(routes []gin.RouteInfo, prefix, version string) string
if parameters := swaggerPathParameters(apiPath); len(parameters) > 0 {
operation["parameters"] = parameters
}
if !swaggerPublicPath(apiPath) {
if !descriptor.Public {
operation["security"] = []map[string][]string{{"ApiKeyAuth": {}}}
}
if paths[documentPath] == nil {
@@ -156,16 +157,3 @@ func swaggerPathParameters(path string) []map[string]any {
}
return parameters
}
func swaggerPublicPath(path string) bool {
for _, marker := range []string{
"/health", "/base/login", "/base/captcha", "/init/checkdb", "/init/initdb",
"/sysExportTemplate/exportExcelByToken", "/sysExportTemplate/exportTemplateByToken",
"/sysError/createSysError", "/info/getInfoPublic",
} {
if path == marker {
return true
}
}
return false
}
+5 -2
View File
@@ -12,6 +12,7 @@ func TestSwaggerMarksOnlyPublicRoutesWithoutAuth(t *testing.T) {
{Method: "GET", Path: "/api/freshCasbin"},
{Method: "GET", Path: "/info/getInfoDataSource"},
{Method: "GET", Path: "/info/getInfoPublic"},
{Method: "POST", Path: "/payment/callback/:provider"},
}, "", "test")
var payload struct {
Paths map[string]map[string]map[string]any `json:"paths"`
@@ -24,7 +25,9 @@ func TestSwaggerMarksOnlyPublicRoutesWithoutAuth(t *testing.T) {
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")
for path, method := range map[string]string{"/info/getInfoPublic": "get", "/payment/callback/{provider}": "post"} {
if _, ok := payload.Paths[path][method]["security"]; ok {
t.Fatalf("public route %s requires authentication", path)
}
}
}