From 083cc3a9eabeb91994c887332b1b3d2ce3148e67 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Mon, 17 Aug 2026 15:23:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/config.yaml | 464 +++++++++--------- internal/biz/api.go | 5 + internal/data/api_policy.go | 6 + internal/data/api_policy_test.go | 14 + internal/data/migrations.go | 1 + internal/data/system_init.go | 10 +- internal/data/system_init_ignore_test.go | 18 +- internal/server/gin.go | 49 +- internal/server/gin_test.go | 69 +++ internal/server/handler/api.go | 7 +- internal/server/handler/export.go | 27 +- internal/server/handler/export_test.go | 40 ++ internal/server/middleware/access.go | 9 +- internal/server/middleware/audit.go | 10 +- internal/server/middleware/cors.go | 5 +- internal/server/middleware/cors_test.go | 39 ++ internal/server/middleware/error_audit.go | 18 +- .../server/middleware/error_audit_test.go | 36 ++ internal/server/swagger.go | 3 + internal/service/api.go | 33 +- internal/service/route_path.go | 19 + internal/service/route_path_test.go | 26 + internal/service/system_init.go | 9 +- web/vite.config.js | 9 +- 24 files changed, 639 insertions(+), 287 deletions(-) create mode 100644 internal/server/handler/export_test.go create mode 100644 internal/server/middleware/cors_test.go create mode 100644 internal/service/route_path.go create mode 100644 internal/service/route_path_test.go diff --git a/configs/config.yaml b/configs/config.yaml index 95e0cee..f4ca916 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -1,235 +1,235 @@ server: - http: - network: tcp - addr: 0.0.0.0:8000 - timeout: 600s + http: + network: tcp + addr: 0.0.0.0:8000 + timeout: 600s data: - database: - driver: mysql - source: root:root@tcp(127.0.0.1:3306)/kra?timeout=5s&parseTime=True&loc=Local&charset=utf8mb4 - host: 127.0.0.1 - port: "3306" - user: root - password: "12345678" - name: kra - config: timeout=5s&parseTime=True&loc=Local&charset=utf8mb4 - path: "" - alias_name: "" - disable: false - prefix: "" - engine: InnoDB - log_mode: info - max_idle_conns: 10 - max_open_conns: 100 - conn_max_lifetime: 3600 - singular: false - redis: - network: tcp - name: default - addr: 127.0.0.1:6379 - password: "" - db: 0 - use_cluster: false - cluster_addrs: [] - read_timeout: 0.2s - write_timeout: 0.2s - # Additional databases use alias_name as the lookup key. Disabled entries - # remain available as configuration examples without opening connections. - database_list: [] - # Example: - # database_list: - # - driver: mysql - # source: "" - # host: 127.0.0.1 - # port: "3306" - # user: root - # password: "" - # name: business - # config: timeout=5s&parseTime=True&loc=Local&charset=utf8mb4 - # path: "" - # alias_name: business - # disable: true - # prefix: "" - # engine: InnoDB - # log_mode: info - # max_idle_conns: 10 - # max_open_conns: 100 - # conn_max_lifetime: 3600 - # singular: false - redis_list: [] - # Example: - # redis_list: - # - network: tcp - # name: cache - # addr: 127.0.0.1:6379 - # password: "" - # db: 0 - # use_cluster: false - # cluster_addrs: [] - # read_timeout: 0.2s - # write_timeout: 0.2s - mongo: - coll: "" - options: "" - database: "" - username: "" - password: "" - auth_source: "" - min_pool_size: 0 - max_pool_size: 100 - socket_timeout_ms: 0 - connect_timeout_ms: 0 - is_zap: false - hosts: - - host: "" - port: "" -admin: - # The administration system router prefix maps to this transport prefix. - router_prefix: "" - jwt: - # Production deployments must override this value with a private secret. - signing_key: change-me-before-production - expires_time: 604800s - buffer_time: 86400s - issuer: kra - captcha: - key_long: 6 - img_width: 240 - img_height: 80 - store_expiration: 180s - local: - store_path: uploads/file - path_prefix: uploads/file - media: - # Upload chunks are stored below .chunks in the selected storage backend. - session_ttl: 24 - max_file_size: 0 - chunk_dir: uploads/chunks - system: - use_redis: false - use_multipoint: false - use_strict_auth: false - disable_auto_migrate: false - use_mongo: false - addr: 8000 - iplimit_count: 0 - iplimit_time: 0 - zap: - level: info - prefix: "[kra] " - format: json - director: logs - encode_level: LowercaseLevelEncoder - stacktrace_key: stacktrace - show_line: true - log_in_console: true - retention_day: 7 - access_req_body: true - access_resp_data: true - access_req_headers: true - access_log_max_bytes: 1024 - file_only_modules: [] - cors: - mode: whitelist - whitelist: [] + database: + driver: mysql + source: root:Xu950329.@tcp(127.0.0.1:3306)/kra?charset=utf8mb4&parseTime=True&loc=Local + host: 127.0.0.1 + port: "3306" + user: root + password: Xu950329. + name: kra + config: charset=utf8mb4&parseTime=True&loc=Local + path: "" + alias_name: "" + disable: false + prefix: "" + engine: InnoDB + log_mode: info + max_idle_conns: 10 + max_open_conns: 100 + conn_max_lifetime: 3600 + singular: false + redis: + network: tcp + name: default + addr: 127.0.0.1:6379 + password: "" + db: 0 + use_cluster: false + cluster_addrs: [] + read_timeout: 0.2s + write_timeout: 0.2s + # Additional databases use alias_name as the lookup key. Disabled entries + # remain available as configuration examples without opening connections. + database_list: [] # Example: - # whitelist: - # - allow_origin: https://admin.example.com - # allow_headers: Content-Type,Authorization,X-Token,X-User-Id - # allow_methods: POST,GET,PUT,DELETE,OPTIONS - # expose_headers: Content-Length,Content-Type - # allow_credentials: true - app: - node: "" - app_id: kra - env: development - disk_list: - - mount_point: / - storage: - # local, qiniu, aliyun-oss, huawei-obs, tencent-cos, aws-s3, - # cloudflare-r2 or minio - type: local - qiniu: - zone: ZoneHuadong - bucket: "" - base_url: "" - access_key: "" - secret_key: "" - use_https: false - use_cdn_domains: false - aliyun_oss: - endpoint: "" - region: "" - bucket: "" - access_key: "" - secret_key: "" - base_url: "" - path_prefix: "" - use_ssl: true - force_path_style: false - account_id: "" - huawei_obs: - endpoint: "" - region: "" - bucket: "" - access_key: "" - secret_key: "" - base_url: "" - path_prefix: "" - use_ssl: true - force_path_style: false - account_id: "" - tencent_cos: - endpoint: "" - region: "" - bucket: "" - access_key: "" - secret_key: "" - base_url: "" - path_prefix: "" - use_ssl: true - force_path_style: false - account_id: "" - aws_s3: - endpoint: "" - region: "" - bucket: "" - access_key: "" - secret_key: "" - base_url: "" - path_prefix: "" - use_ssl: true - force_path_style: false - account_id: "" - cloudflare_r2: - endpoint: "" - region: auto - bucket: "" - access_key: "" - secret_key: "" - base_url: "" - path_prefix: uploads - use_ssl: true - force_path_style: false - account_id: "" - minio: - endpoint: "" - region: "" - bucket: "" - access_key: "" - secret_key: "" - base_url: "" - path_prefix: "" - use_ssl: false - force_path_style: true - account_id: "" - email: - # Leave host/from/secret empty to disable SMTP error notifications. - to: "" - from: "" - host: "" - secret: "" - nickname: "" - port: 465 - is_ssl: true - is_login_auth: false + # database_list: + # - driver: mysql + # source: "" + # host: 127.0.0.1 + # port: "3306" + # user: root + # password: "" + # name: business + # config: timeout=5s&parseTime=True&loc=Local&charset=utf8mb4 + # path: "" + # alias_name: business + # disable: true + # prefix: "" + # engine: InnoDB + # log_mode: info + # max_idle_conns: 10 + # max_open_conns: 100 + # conn_max_lifetime: 3600 + # singular: false + redis_list: [] + # Example: + # redis_list: + # - network: tcp + # name: cache + # addr: 127.0.0.1:6379 + # password: "" + # db: 0 + # use_cluster: false + # cluster_addrs: [] + # read_timeout: 0.2s + # write_timeout: 0.2s + mongo: + coll: "" + options: "" + database: "" + username: "" + password: "" + auth_source: "" + min_pool_size: 0 + max_pool_size: 100 + socket_timeout_ms: 0 + connect_timeout_ms: 0 + is_zap: false + hosts: + - host: "" + port: "" +admin: + # The administration system router prefix maps to this transport prefix. + router_prefix: "" + jwt: + # Production deployments must override this value with a private secret. + signing_key: 86a6eb31-46b3-4da9-ae79-85aeff3e699d + expires_time: 604800s + buffer_time: 86400s + issuer: kra + captcha: + key_long: 6 + img_width: 240 + img_height: 80 + store_expiration: 180s + local: + store_path: uploads/file + path_prefix: uploads/file + media: + # Upload chunks are stored below .chunks in the selected storage backend. + session_ttl: 24 + max_file_size: 0 + chunk_dir: uploads/chunks + system: + use_redis: false + use_multipoint: false + use_strict_auth: false + disable_auto_migrate: false + use_mongo: false + addr: 8000 + iplimit_count: 0 + iplimit_time: 0 + zap: + level: info + prefix: "[kra] " + format: json + director: logs + encode_level: LowercaseLevelEncoder + stacktrace_key: stacktrace + show_line: true + log_in_console: true + retention_day: 7 + access_req_body: true + access_resp_data: true + access_req_headers: true + access_log_max_bytes: 1024 + file_only_modules: [] + cors: + mode: whitelist + whitelist: [] + # Example: + # whitelist: + # - allow_origin: https://admin.example.com + # allow_headers: Content-Type,Authorization,X-Token,X-User-Id + # allow_methods: POST,GET,PUT,DELETE,OPTIONS + # expose_headers: Content-Length,Content-Type + # allow_credentials: true + app: + node: "" + app_id: kra + env: development + disk_list: + - mount_point: / + storage: + # local, qiniu, aliyun-oss, huawei-obs, tencent-cos, aws-s3, + # cloudflare-r2 or minio + type: local + qiniu: + zone: ZoneHuadong + bucket: "" + base_url: "" + access_key: "" + secret_key: "" + use_https: false + use_cdn_domains: false + aliyun_oss: + endpoint: "" + region: "" + bucket: "" + access_key: "" + secret_key: "" + base_url: "" + path_prefix: "" + use_ssl: true + force_path_style: false + account_id: "" + huawei_obs: + endpoint: "" + region: "" + bucket: "" + access_key: "" + secret_key: "" + base_url: "" + path_prefix: "" + use_ssl: true + force_path_style: false + account_id: "" + tencent_cos: + endpoint: "" + region: "" + bucket: "" + access_key: "" + secret_key: "" + base_url: "" + path_prefix: "" + use_ssl: true + force_path_style: false + account_id: "" + aws_s3: + endpoint: "" + region: "" + bucket: "" + access_key: "" + secret_key: "" + base_url: "" + path_prefix: "" + use_ssl: true + force_path_style: false + account_id: "" + cloudflare_r2: + endpoint: "" + region: auto + bucket: "" + access_key: "" + secret_key: "" + base_url: "" + path_prefix: uploads + use_ssl: true + force_path_style: false + account_id: "" + minio: + endpoint: "" + region: "" + bucket: "" + access_key: "" + secret_key: "" + base_url: "" + path_prefix: "" + use_ssl: false + force_path_style: true + account_id: "" + email: + # Leave host/from/secret empty to disable SMTP error notifications. + to: "" + from: "" + host: "" + secret: "" + nickname: "" + port: 465 + is_ssl: true + is_login_auth: false diff --git a/internal/biz/api.go b/internal/biz/api.go index ae0aafc..310a944 100644 --- a/internal/biz/api.go +++ b/internal/biz/api.go @@ -26,6 +26,7 @@ type APIRepo interface { ListAPIs(context.Context, int, int, *API) ([]*API, int64, error) APIRoleIDs(context.Context, string, string) ([]uint, error) SetAPIRoles(context.Context, string, string, []uint) error + CheckPolicyStore(context.Context) error Authorize(context.Context, uint, string, string) (bool, error) PolicyPaths(context.Context, uint) ([]*API, error) SetPolicyPaths(context.Context, uint, []*API) error @@ -38,6 +39,10 @@ type APIUsecase struct{ APIRepo } func NewAPIUsecase(repo APIRepo) *APIUsecase { return &APIUsecase{APIRepo: repo} } +func (uc *APIUsecase) FreshCasbin(ctx context.Context) error { + return uc.CheckPolicyStore(ctx) +} + // DeleteAPI preserves the single-delete contract used by the legacy admin: // the target is looked up first, so deleting a missing API returns the // repository's not-found error instead of silently succeeding on an empty diff --git a/internal/data/api_policy.go b/internal/data/api_policy.go index 22790e3..c374bfa 100644 --- a/internal/data/api_policy.go +++ b/internal/data/api_policy.go @@ -48,6 +48,12 @@ func (r *apiRepo) SetAPIRoles(ctx context.Context, path, method string, ids []ui return tx.Create(&rules).Error }) } + +func (r *apiRepo) CheckPolicyStore(ctx context.Context) error { + var count int64 + return r.data.gormDB.WithContext(ctx).Model(&casbinRulePO{}).Count(&count).Error +} + func (r *apiRepo) Authorize(ctx context.Context, aid uint, path, method string) (bool, error) { rows, err := policyRowsForAuthority(r.data.gormDB.WithContext(ctx), aid) if err != nil { diff --git a/internal/data/api_policy_test.go b/internal/data/api_policy_test.go index dc43a17..cd0e8d9 100644 --- a/internal/data/api_policy_test.go +++ b/internal/data/api_policy_test.go @@ -112,3 +112,17 @@ func TestSetPolicyPathsUsesCompatibleDedupeKey(t *testing.T) { t.Fatalf("deduplicated policies = %#v, want only the first concatenated-key match", rows) } } + +func TestCheckPolicyStore(t *testing.T) { + data := newPolicyTestData(t) + repo := &apiRepo{data: data} + if err := repo.CheckPolicyStore(context.Background()); err != nil { + t.Fatalf("CheckPolicyStore returned error for migrated table: %v", err) + } + if err := data.gormDB.WithContext(context.Background()).Migrator().DropTable(&casbinRulePO{}); err != nil { + t.Fatal(err) + } + if err := repo.CheckPolicyStore(context.Background()); err == nil { + t.Fatal("CheckPolicyStore succeeded after casbin_rule was dropped") + } +} diff --git a/internal/data/migrations.go b/internal/data/migrations.go index 3395d39..8626b98 100644 --- a/internal/data/migrations.go +++ b/internal/data/migrations.go @@ -20,6 +20,7 @@ func migrateAll(db *gorm.DB) error { &apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{}, &departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{}, &dictionaryPO{}, &dictionaryDetailPO{}, ¶meterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &securityConfigPO{}, + &integrationConfigPO{}, &versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{}, &operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{}, &taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{}, diff --git a/internal/data/system_init.go b/internal/data/system_init.go index 48e527a..0e657a9 100644 --- a/internal/data/system_init.go +++ b/internal/data/system_init.go @@ -198,7 +198,11 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database return err } } - ignoredAPIs := defaultIgnoredAPIs() + staticPath := "uploads/file" + if admin := r.data.runtime.Admin(); admin != nil && admin.Local != nil && strings.Trim(admin.Local.PathPrefix, "/") != "" { + staticPath = strings.Trim(admin.Local.PathPrefix, "/") + } + ignoredAPIs := defaultIgnoredAPIs(staticPath) for _, ignored := range ignoredAPIs { if err := tx.FirstOrCreate(&ignored, ignored).Error; err != nil { return err @@ -250,10 +254,12 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database return nil } -func defaultIgnoredAPIs() []ignoredAPIPO { +func defaultIgnoredAPIs(staticPath string) []ignoredAPIPO { + staticRoute := "/" + strings.Trim(staticPath, "/") + "/*filepath" return []ignoredAPIPO{ {Method: "GET", Path: "/api/freshCasbin"}, {Method: "GET", Path: "/health"}, {Method: "GET", Path: "/swagger/*any"}, + {Method: "GET", Path: staticRoute}, {Method: "HEAD", Path: staticRoute}, {Method: "POST", Path: "/system/reloadSystem"}, {Method: "POST", Path: "/base/login"}, {Method: "POST", Path: "/base/captcha"}, {Method: "POST", Path: "/init/initdb"}, {Method: "POST", Path: "/init/checkdb"}, {Method: "GET", Path: "/info/getInfoDataSource"}, diff --git a/internal/data/system_init_ignore_test.go b/internal/data/system_init_ignore_test.go index 44fc192..57d144a 100644 --- a/internal/data/system_init_ignore_test.go +++ b/internal/data/system_init_ignore_test.go @@ -3,10 +3,20 @@ package data import "testing" func TestDefaultIgnoredAPIsIncludeSwagger(t *testing.T) { - for _, api := range defaultIgnoredAPIs() { - if api.Method == "GET" && api.Path == "/swagger/*any" { - return + wants := map[string]bool{ + "GET /swagger/*any": false, + "GET /uploads/file/*filepath": false, + "HEAD /uploads/file/*filepath": false, + } + for _, api := range defaultIgnoredAPIs("uploads/file") { + key := api.Method + " " + api.Path + if _, ok := wants[key]; ok { + wants[key] = true + } + } + for key, found := range wants { + if !found { + t.Fatalf("default ignored APIs do not include %s", key) } } - t.Fatal("default ignored APIs do not include the Swagger handler") } diff --git a/internal/server/gin.go b/internal/server/gin.go index 8bd9026..9705ce8 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -61,6 +61,7 @@ func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, h serverrouter.RegisterAnnouncement(private, public, handlers.Announcement) serverrouter.RegisterEmail(private, handlers.Email) registerSwagger(engine, prefix, version, logger) + registerLocalStorage(engine, runtime) engine.NoRoute(func(c *gin.Context) { if serveLocalStorage(c, runtime) { @@ -117,6 +118,50 @@ func NewGinServer(c *conf.Server, engine *gin.Engine) *kratoshttp.Server { // serveLocalStorage resolves the local path for every request so a config // reload takes effect without rebuilding the Gin engine. func serveLocalStorage(c *gin.Context, runtime *conf.Runtime) bool { + config := runtime.Admin() + if config == nil || config.Local == nil || config.Local.StorePath == "" { + return false + } + prefix := "/" + strings.Trim(config.Local.PathPrefix, "/") + return serveLocalStorageAt(c, runtime, prefix) +} + +func registerLocalStorage(engine *gin.Engine, runtime *conf.Runtime) { + config := runtime.Admin() + if config == nil || config.Local == nil || config.Local.StorePath == "" || strings.Trim(config.Local.PathPrefix, "/") == "" { + return + } + if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" { + return + } + prefix := "/" + strings.Trim(config.Local.PathPrefix, "/") + if localStorageRouteConflicts(engine.Routes(), prefix) { + return + } + handler := func(c *gin.Context) { + if !serveLocalStorageAt(c, runtime, prefix) { + c.Status(http.StatusNotFound) + } + } + engine.GET(prefix+"/*filepath", handler) + engine.HEAD(prefix+"/*filepath", handler) +} + +func localStorageRouteConflicts(routes []gin.RouteInfo, prefix string) bool { + staticRoot := strings.Split(strings.TrimPrefix(prefix, "/"), "/")[0] + for _, route := range routes { + if route.Method != http.MethodGet && route.Method != http.MethodHead { + continue + } + routeRoot := strings.Split(strings.TrimPrefix(route.Path, "/"), "/")[0] + if routeRoot == staticRoot { + return true + } + } + return false +} + +func serveLocalStorageAt(c *gin.Context, runtime *conf.Runtime, prefix string) bool { config := runtime.Admin() if config == nil || config.Local == nil || config.Local.StorePath == "" { return false @@ -124,7 +169,9 @@ func serveLocalStorage(c *gin.Context, runtime *conf.Runtime) bool { if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" { return false } - prefix := "/" + strings.Trim(config.Local.PathPrefix, "/") + if currentPrefix := "/" + strings.Trim(config.Local.PathPrefix, "/"); currentPrefix != prefix { + return false + } if prefix == "/" || (c.Request.URL.Path != prefix && !strings.HasPrefix(c.Request.URL.Path, prefix+"/")) { return false } diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index 6502946..b2e19a4 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -90,6 +90,15 @@ func TestSwaggerSupportsRouterPrefix(t *testing.T) { } } +func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) { + engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") + response := httptest.NewRecorder() + engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil)) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"basePath":"/"`) { + t.Fatalf("swagger basePath mismatch: status=%d body=%s", response.Code, response.Body.String()) + } +} + func TestLocalStorageResponseHeaders(t *testing.T) { root := t.TempDir() for name, body := range map[string]string{"script.html": "", "image.png": "png"} { @@ -120,6 +129,66 @@ func TestLocalStorageResponseHeaders(t *testing.T) { } } +func TestLocalStorageRoutesAreRegistered(t *testing.T) { + runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}}) + engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") + seen := map[string]bool{} + for _, route := range engine.Routes() { + seen[route.Method+" "+route.Path] = true + } + for _, expected := range []string{"GET /uploads/file/*filepath", "HEAD /uploads/file/*filepath"} { + if !seen[expected] { + t.Fatalf("missing registered static route %s", expected) + } + } + response := httptest.NewRecorder() + engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil)) + if strings.Contains(response.Body.String(), "/uploads/file/") { + t.Fatal("swagger document should not expose static file wildcard") + } +} + +func TestLocalStorageOldPrefixStopsServingAfterReload(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "image.png"), []byte("png"), 0o600); err != nil { + t.Fatal(err) + } + runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "local"}}) + engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") + runtime.Replace(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "files"}, Storage: &conf.AdminBackend_Storage{Type: "local"}}) + + oldResponse := httptest.NewRecorder() + engine.ServeHTTP(oldResponse, httptest.NewRequest(http.MethodGet, "/uploads/file/image.png", nil)) + if oldResponse.Code != http.StatusNotFound { + t.Fatalf("old static prefix status = %d, want %d", oldResponse.Code, http.StatusNotFound) + } + newResponse := httptest.NewRecorder() + engine.ServeHTTP(newResponse, httptest.NewRequest(http.MethodGet, "/files/image.png", nil)) + if newResponse.Code != http.StatusOK { + t.Fatalf("new static prefix status = %d, want %d", newResponse.Code, http.StatusOK) + } +} + +func TestLocalStorageConflictingPrefixFallsBackWithoutStartupPanic(t *testing.T) { + runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "api"}, Storage: &conf.AdminBackend_Storage{Type: "local"}}) + engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") + for _, route := range engine.Routes() { + if route.Method == http.MethodGet && route.Path == "/api/*filepath" { + t.Fatal("conflicting static wildcard must not be registered") + } + } +} + +func TestLocalStorageRouteIsNotRegisteredForRemoteStorage(t *testing.T) { + runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "s3"}}) + engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") + for _, route := range engine.Routes() { + if strings.HasSuffix(route.Path, "/*filepath") && strings.HasPrefix(route.Path, "/uploads/file/") { + t.Fatalf("remote storage registered local route %s %s", route.Method, route.Path) + } + } +} + const expectedGinRouteContract = `DELETE /api/deleteApisByIds DELETE /dataAccessLog/deleteDataAccessLogByIds DELETE /department/deleteDepartment diff --git a/internal/server/handler/api.go b/internal/server/handler/api.go index c03c2bd..3fd7391 100644 --- a/internal/server/handler/api.go +++ b/internal/server/handler/api.go @@ -199,9 +199,10 @@ func (h *API) ApplySync(c *gin.Context) { httpx.OK(c) } func (h *API) FreshCasbin(c *gin.Context) { - // Policies are read from casbin_rule on every authorization decision, so - // there is no in-memory enforcer cache to reload. Keep the compatible - // endpoint and success response. + if err := h.service.FreshCasbin(c.Request.Context()); err != nil { + httpx.Fail(c, "刷新失败") + return + } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "刷新成功") } diff --git a/internal/server/handler/export.go b/internal/server/handler/export.go index caa9ac7..8ad8549 100644 --- a/internal/server/handler/export.go +++ b/internal/server/handler/export.go @@ -22,15 +22,26 @@ func NewExport(service *service.ExportService) *Export { return &Export{service: service} } -func exportParams(values url.Values) map[string]string { +func exportParams(values url.Values) (map[string]string, error) { out := map[string]string{} - nested, _ := url.ParseQuery(values.Get("params")) + nested, err := url.ParseQuery(values.Get("params")) + if err != nil { + return nil, err + } for key, items := range nested { if len(items) > 0 { out[key] = items[0] } } - return out + return out, nil +} + +func exportIssueParams(values url.Values, blank bool) (map[string]string, error) { + params, err := exportParams(values) + if err != nil && blank { + return map[string]string{}, nil + } + return params, err } func (h *Export) Create(c *gin.Context) { @@ -133,7 +144,8 @@ func (h *Export) Preview(c *gin.Context) { httpx.Fail(c, "模板ID不能为空") return } - sql, err := h.service.Preview(c.Request.Context(), templateID, exportParams(c.Request.URL.Query())) + params, _ := exportParams(c.Request.URL.Query()) + sql, err := h.service.Preview(c.Request.Context(), templateID, params) if err != nil { httpx.Fail(c, "获取失败") return @@ -148,7 +160,12 @@ func (h *Export) Issue(blank bool) gin.HandlerFunc { httpx.Fail(c, "模板ID不能为空") return } - token, err := h.service.IssueToken(c.Request.Context(), templateID, exportParams(c.Request.URL.Query()), blank) + params, err := exportIssueParams(c.Request.URL.Query(), blank) + if err != nil { + httpx.Fail(c, "解析 params 参数失败") + return + } + token, err := h.service.IssueToken(c.Request.Context(), templateID, params, blank) if err != nil { httpx.Fail(c, "导出令牌创建失败") return diff --git a/internal/server/handler/export_test.go b/internal/server/handler/export_test.go new file mode 100644 index 0000000..d0f65fd --- /dev/null +++ b/internal/server/handler/export_test.go @@ -0,0 +1,40 @@ +package handler + +import ( + "net/url" + "testing" +) + +func TestExportParamsRejectsMalformedNestedQuery(t *testing.T) { + _, err := exportParams(url.Values{"params": {"name=%zz"}}) + if err == nil { + t.Fatal("exportParams accepted malformed nested query") + } +} + +func TestExportParamsUsesFirstValue(t *testing.T) { + got, err := exportParams(url.Values{"params": {"name=first&name=second"}}) + if err != nil { + t.Fatalf("exportParams returned error: %v", err) + } + if got["name"] != "first" { + t.Fatalf("exportParams name = %q, want first", got["name"]) + } +} + +func TestExportIssueParamsKeepsBlankTemplateCompatible(t *testing.T) { + got, err := exportIssueParams(url.Values{"params": {"name=%zz"}}, true) + if err != nil { + t.Fatalf("blank template params returned error: %v", err) + } + if len(got) != 0 { + t.Fatalf("blank template params = %#v, want empty", got) + } +} + +func TestExportIssueParamsRejectsMalformedExcelParams(t *testing.T) { + _, err := exportIssueParams(url.Values{"params": {"name=%zz"}}, false) + if err == nil { + t.Fatal("Excel export accepted malformed nested query") + } +} diff --git a/internal/server/middleware/access.go b/internal/server/middleware/access.go index 59b579e..a75f8ed 100644 --- a/internal/server/middleware/access.go +++ b/internal/server/middleware/access.go @@ -1,8 +1,6 @@ package middleware import ( - "strings" - "kra/internal/biz" "kra/internal/conf" "kra/internal/server/httpx" @@ -20,11 +18,8 @@ func AccessControl(runtime *conf.Runtime, access *service.AccessControlService) } path := c.Request.URL.Path policyPath := path - if config := runtime.Admin(); config != nil && config.RouterPrefix != "" { - policyPath = strings.TrimPrefix(policyPath, strings.TrimSuffix(config.RouterPrefix, "/")) - if policyPath == "" { - policyPath = "/" - } + if config := runtime.Admin(); config != nil { + policyPath = service.NormalizeRoutePath(policyPath, config.RouterPrefix) } allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, policyPath, c.Request.Method) if err != nil || !allowed { diff --git a/internal/server/middleware/audit.go b/internal/server/middleware/audit.go index bd5d24a..ad26217 100644 --- a/internal/server/middleware/audit.go +++ b/internal/server/middleware/audit.go @@ -3,6 +3,7 @@ package middleware import ( "bytes" "encoding/json" + "fmt" "io" "net/http" "net/url" @@ -17,6 +18,8 @@ import ( "github.com/gin-gonic/gin" ) +const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed" + func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.HandlerFunc { return func(c *gin.Context) { path := c.Request.URL.Path @@ -66,7 +69,12 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H responseBody = "[超出记录长度]" } errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String() - _ = service.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: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}) + if err := service.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: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil { + // Preserve the business response, but expose audit persistence failures + // to the global access/error logging pipeline. + c.Set(ctxOperationAuditPersistFailedKey, true) + _ = c.Error(fmt.Errorf("operation audit persist: %w", err)) + } } } diff --git a/internal/server/middleware/cors.go b/internal/server/middleware/cors.go index 52c6e50..a5000a9 100644 --- a/internal/server/middleware/cors.go +++ b/internal/server/middleware/cors.go @@ -26,15 +26,18 @@ func CORS(runtime *conf.Runtime) gin.HandlerFunc { } mode := strings.TrimSpace(config.Cors.Mode) origin := c.GetHeader("Origin") + corsHandled := false if mode == "allow-all" { 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") { c.AbortWithStatus(http.StatusForbidden) return } - if c.Request.Method == http.MethodOptions { + if corsHandled && c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return } diff --git a/internal/server/middleware/cors_test.go b/internal/server/middleware/cors_test.go new file mode 100644 index 0000000..8cd5d58 --- /dev/null +++ b/internal/server/middleware/cors_test.go @@ -0,0 +1,39 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "kra/internal/conf" + + "github.com/gin-gonic/gin" +) + +func runCORSTest(t *testing.T, admin *conf.AdminBackend, method, origin string) *httptest.ResponseRecorder { + t.Helper() + engine := gin.New() + engine.Use(CORS(conf.NewRuntime(nil, admin))) + engine.Any("/test", func(c *gin.Context) { c.Status(http.StatusOK) }) + request := httptest.NewRequest(method, "/test", nil) + if origin != "" { + request.Header.Set("Origin", origin) + } + response := httptest.NewRecorder() + engine.ServeHTTP(response, request) + return response +} + +func TestCORSDoesNotConsumeUnmatchedWhitelistPreflight(t *testing.T) { + response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_CORS{Mode: "whitelist"}}, http.MethodOptions, "https://unknown.example") + if response.Code != http.StatusOK { + t.Fatalf("unmatched whitelist preflight status = %d, want %d", response.Code, http.StatusOK) + } +} + +func TestCORSConsumesMatchedWhitelistPreflight(t *testing.T) { + response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_CORS{Mode: "whitelist", Whitelist: []*conf.AdminBackend_CORSRule{{AllowOrigin: "https://admin.example"}}}}, http.MethodOptions, "https://admin.example") + if response.Code != http.StatusNoContent { + t.Fatalf("matched whitelist preflight status = %d, want %d", response.Code, http.StatusNoContent) + } +} diff --git a/internal/server/middleware/error_audit.go b/internal/server/middleware/error_audit.go index 62ef39f..f4a81fc 100644 --- a/internal/server/middleware/error_audit.go +++ b/internal/server/middleware/error_audit.go @@ -17,11 +17,13 @@ import ( func ErrorAudit(logger *slog.Logger) gin.HandlerFunc { return func(c *gin.Context) { c.Next() + privateErrors := strings.TrimSpace(c.Errors.ByType(gin.ErrorTypePrivate).String()) + auditPersistFailed, _ := c.Get(ctxOperationAuditPersistFailedKey) // sysError writes must never audit themselves. Log-viewer failures are // already recorded by the handler with the underlying filesystem error; // emitting again from the response envelope would duplicate both the // classified error file and the sys_error row. - if strings.Contains(c.Request.URL.Path, "/sysError/") || strings.Contains(c.Request.URL.Path, "/logViewer/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500 { + if auditPersistFailed != true && (strings.Contains(c.Request.URL.Path, "/sysError/") || strings.Contains(c.Request.URL.Path, "/logViewer/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500) { return } var response httpx.Response @@ -31,12 +33,22 @@ func ErrorAudit(logger *slog.Logger) gin.HandlerFunc { body = buffer.Bytes() } } - if json.Unmarshal(body, &response) != nil || response.Code == httpx.CodeSuccess || expectedClientFailure(response.Msg) { + if json.Unmarshal(body, &response) != nil && privateErrors == "" { return } + if response.Code == httpx.CodeSuccess && privateErrors == "" { + return + } + if privateErrors == "" && expectedClientFailure(response.Msg) { + return + } + errorMessage := response.Msg + if privateErrors != "" { + errorMessage = privateErrors + } requestID, _ := c.Get("request_id") if logger != nil { - logger.ErrorContext(c.Request.Context(), "请求处理失败", "mod", failureLogModule(c.Request.URL.Path), "path", c.Request.URL.Path, "method", c.Request.Method, "status", c.Writer.Status(), "error", response.Msg, "request_id", stringValue(requestID), "trace_id", stringValueFromContext(c, "trace_id")) + logger.ErrorContext(c.Request.Context(), "请求处理失败", "mod", failureLogModule(c.Request.URL.Path), "path", c.Request.URL.Path, "method", c.Request.Method, "status", c.Writer.Status(), "error", errorMessage, "request_id", stringValue(requestID), "trace_id", stringValueFromContext(c, "trace_id")) } } } diff --git a/internal/server/middleware/error_audit_test.go b/internal/server/middleware/error_audit_test.go index c884501..686da5d 100644 --- a/internal/server/middleware/error_audit_test.go +++ b/internal/server/middleware/error_audit_test.go @@ -2,6 +2,7 @@ package middleware import ( "bytes" + "errors" "log/slog" "net/http" "net/http/httptest" @@ -73,3 +74,38 @@ func TestErrorAuditSkipsLogViewerFailureAlreadyLoggedByHandler(t *testing.T) { t.Fatalf("log viewer failure must not be emitted twice, got %s", output.String()) } } + +func TestErrorAuditEmitsPrivateMiddlewareErrorOnSuccessResponse(t *testing.T) { + var output bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&output, nil)) + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.Use(ErrorAudit(logger)) + engine.GET("/test", func(c *gin.Context) { + c.Error(errors.New("operation audit persist: database unavailable")) + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{}, "msg": "成功"}) + }) + response := httptest.NewRecorder() + engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/test", nil)) + if !strings.Contains(output.String(), "operation audit persist: database unavailable") { + t.Fatalf("private middleware error was not logged: %s", output.String()) + } +} + +func TestErrorAuditEmitsOperationAuditFailureForSysErrorRoute(t *testing.T) { + var output bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&output, nil)) + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.Use(ErrorAudit(logger)) + engine.PUT("/sysError/updateSysError", func(c *gin.Context) { + c.Set(ctxOperationAuditPersistFailedKey, true) + c.Error(errors.New("operation audit persist: database unavailable")) + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{}, "msg": "成功"}) + }) + response := httptest.NewRecorder() + engine.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/sysError/updateSysError", nil)) + if !strings.Contains(output.String(), "operation audit persist: database unavailable") { + t.Fatalf("sysError operation audit failure was not logged: %s", output.String()) + } +} diff --git a/internal/server/swagger.go b/internal/server/swagger.go index 556e55b..0d4bc39 100644 --- a/internal/server/swagger.go +++ b/internal/server/swagger.go @@ -69,6 +69,9 @@ func buildSwaggerDocument(routes []gin.RouteInfo, prefix, version string) string }) paths := make(map[string]map[string]any, len(routes)) for _, route := range routes { + if strings.HasSuffix(route.Path, "/*filepath") { + continue + } method := strings.ToLower(route.Method) switch method { case "get", "post", "put", "delete", "patch": diff --git a/internal/service/api.go b/internal/service/api.go index 22a6fed..4184e91 100644 --- a/internal/service/api.go +++ b/internal/service/api.go @@ -18,20 +18,14 @@ func NewAPIService(uc *biz.APIUsecase, settings biz.RuntimeSettings) *APIService } func (s *APIService) NormalizeRoutePath(path string) string { - routerPrefix := s.settings.RouterPrefix() - if routerPrefix == "" { + if s.settings == nil { return path } - prefix := strings.TrimSuffix(routerPrefix, "/") - normalized := strings.TrimPrefix(path, prefix) - if normalized == "" { - return "/" - } - return normalized + return NormalizeRoutePath(path, s.settings.RouterPrefix()) } -func apiDomain(value *dto.APIRequest) *biz.API { - return &biz.API{ID: value.ID, Path: value.Path, Description: value.Description, APIGroup: value.APIGroup, Method: value.Method} +func (s *APIService) apiDomain(value *dto.APIRequest) *biz.API { + return &biz.API{ID: value.ID, Path: s.NormalizeRoutePath(value.Path), Description: value.Description, APIGroup: value.APIGroup, Method: value.Method} } func apiResponse(value *biz.API) *dto.APIResponse { @@ -87,14 +81,14 @@ func (s *APIService) Groups(ctx context.Context) ([]string, map[string]string, e } func (s *APIService) CreateAPIRequest(ctx context.Context, req *dto.APIRequest) (*dto.APIResponse, error) { - value := apiDomain(req) + value := s.apiDomain(req) if err := s.uc.CreateAPI(ctx, value); err != nil { return nil, err } return apiResponse(value), nil } func (s *APIService) UpdateAPIRequest(ctx context.Context, req *dto.APIRequest) error { - return s.uc.UpdateAPI(ctx, apiDomain(req)) + return s.uc.UpdateAPI(ctx, s.apiDomain(req)) } func (s *APIService) FindAPIResponse(ctx context.Context, id uint) (*dto.APIResponse, error) { value, err := s.uc.FindAPI(ctx, id) @@ -107,10 +101,10 @@ func (s *APIService) ApplyAPISyncRequest(ctx context.Context, req *dto.ApplyAPIS added := make([]*biz.API, 0, len(req.NewAPIs)) deleted := make([]*biz.API, 0, len(req.DeleteAPIs)) for i := range req.NewAPIs { - added = append(added, apiDomain(&req.NewAPIs[i])) + added = append(added, s.apiDomain(&req.NewAPIs[i])) } for i := range req.DeleteAPIs { - deleted = append(deleted, apiDomain(&req.DeleteAPIs[i])) + deleted = append(deleted, s.apiDomain(&req.DeleteAPIs[i])) } return s.uc.ApplyAPISync(ctx, added, deleted) } @@ -142,7 +136,7 @@ func (s *APIService) SyncAPIResponses(ctx context.Context, routes []dto.APIReque // The compatible sync endpoint compares Gin's route table directly, so newly // discovered routes carry only path and method. Group/description are // intentionally left empty for the operator to fill in the sync dialog. - items = append(items, apiDomain(&routes[i])) + items = append(items, s.apiDomain(&routes[i])) } return s.SyncAPIs(ctx, items) } @@ -154,10 +148,13 @@ func (s *APIService) DeleteAPI(ctx context.Context, id uint) error { return s.uc.DeleteAPI(ctx, id) } func (s *APIService) APIRoleIDs(ctx context.Context, path, method string) ([]uint, error) { - return s.uc.APIRoleIDs(ctx, path, method) + return s.uc.APIRoleIDs(ctx, s.NormalizeRoutePath(path), method) } func (s *APIService) SetAPIRoles(ctx context.Context, path, method string, ids []uint) error { - return s.uc.SetAPIRoles(ctx, path, method, ids) + return s.uc.SetAPIRoles(ctx, s.NormalizeRoutePath(path), method, ids) +} +func (s *APIService) FreshCasbin(ctx context.Context) error { + return s.uc.FreshCasbin(ctx) } func (s *APIService) SyncAPIs(ctx context.Context, routes []*biz.API) (*dto.APISyncResponse, error) { diff, err := s.uc.SyncAPIs(ctx, routes) @@ -167,5 +164,5 @@ func (s *APIService) SyncAPIs(ctx context.Context, routes []*biz.API) (*dto.APIS return &dto.APISyncResponse{NewAPIs: apiResponses(diff.Added), DeleteAPIs: apiResponses(diff.Deleted), IgnoreAPIs: apiResponses(diff.Ignored)}, nil } func (s *APIService) SetAPIIgnored(ctx context.Context, path, method string, ignored bool) error { - return s.uc.SetAPIIgnored(ctx, path, method, ignored) + return s.uc.SetAPIIgnored(ctx, s.NormalizeRoutePath(path), method, ignored) } diff --git a/internal/service/route_path.go b/internal/service/route_path.go new file mode 100644 index 0000000..600dd1c --- /dev/null +++ b/internal/service/route_path.go @@ -0,0 +1,19 @@ +package service + +import "strings" + +// NormalizeRoutePath removes only a complete configured router prefix. Paths +// such as /administrator must not be shortened when the prefix is /admin. +func NormalizeRoutePath(path, routerPrefix string) string { + prefix := strings.TrimSuffix(strings.TrimSpace(routerPrefix), "/") + if prefix == "" || prefix == "/" { + return path + } + if path == prefix { + return "/" + } + if strings.HasPrefix(path, prefix+"/") { + return strings.TrimPrefix(path, prefix) + } + return path +} diff --git a/internal/service/route_path_test.go b/internal/service/route_path_test.go new file mode 100644 index 0000000..464183c --- /dev/null +++ b/internal/service/route_path_test.go @@ -0,0 +1,26 @@ +package service + +import "testing" + +func TestNormalizeRoutePath(t *testing.T) { + tests := []struct { + name string + path string + prefix string + want string + }{ + {name: "empty prefix", path: "/api/login", prefix: "", want: "/api/login"}, + {name: "configured prefix", path: "/admin/api/login", prefix: "/admin", want: "/api/login"}, + {name: "trailing slash prefix", path: "/admin/api/login", prefix: "/admin/", want: "/api/login"}, + {name: "prefix root", path: "/admin", prefix: "/admin", want: "/"}, + {name: "prefix boundary", path: "/administrator/api/login", prefix: "/admin", want: "/administrator/api/login"}, + {name: "already normalized", path: "/api/login", prefix: "/admin", want: "/api/login"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeRoutePath(tt.path, tt.prefix); got != tt.want { + t.Fatalf("NormalizeRoutePath(%q, %q) = %q, want %q", tt.path, tt.prefix, got, tt.want) + } + }) + } +} diff --git a/internal/service/system_init.go b/internal/service/system_init.go index 8918f35..1246162 100644 --- a/internal/service/system_init.go +++ b/internal/service/system_init.go @@ -2,7 +2,6 @@ package service import ( "context" - "strings" "kra/internal/biz" "kra/internal/routeinfo" @@ -29,13 +28,7 @@ func (s *SystemConfigService) Initialize(ctx context.Context, input *dto.Databas func (s *SystemConfigService) InitializeRoutes(ctx context.Context, input *dto.DatabaseInitRequest, routes []dto.Route) error { apis := make([]*biz.API, 0, len(routes)) for _, route := range routes { - path := route.Path - if routerPrefix := s.settings.RouterPrefix(); routerPrefix != "" { - path = strings.TrimPrefix(path, strings.TrimSuffix(routerPrefix, "/")) - if path == "" { - path = "/" - } - } + path := NormalizeRoutePath(route.Path, s.settings.RouterPrefix()) group, description := routeinfo.Metadata(route.Method, path) apis = append(apis, &biz.API{Path: path, Method: route.Method, APIGroup: group, Description: description}) } diff --git a/web/vite.config.js b/web/vite.config.js index 5ce345c..fe4b8b1 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -15,14 +15,19 @@ const pathMapPlugin = () => ({ const result = {} const walk = (directory) => { if (!fs.existsSync(directory)) return - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entries = fs.readdirSync(directory, { withFileTypes: true }) + .sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) + for (const entry of entries) { const filename = path.join(directory, entry.name) if (entry.isDirectory()) { walk(filename) } else if (filename.endsWith('.vue')) { const source = fs.readFileSync(filename, 'utf8') const match = source.match(/defineOptions\s*\(\s*{[\s\S]*?name:\s*['"]([^'"]+)['"]/) - if (match) result[`/src/${filename.replace(/^src\//, '')}`] = match[1] + if (match) { + const relativePath = path.relative('src', filename).split(path.sep).join('/') + result[`/src/${relativePath}`] = match[1] + } } } }