From 70b32637b0b2d3d2933a0b5300c3f38568a7721c Mon Sep 17 00:00:00 2001 From: jefferyjob <58850169@qq.com> Date: Thu, 15 Jan 2026 16:05:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20PathMatch=20=E6=96=B9?= =?UTF-8?q?=E6=B3=95=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- strx/README.md | 3 + strx/path_match.go | 143 ++++++++++++++++++++++++ strx/path_match_test.go | 236 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 382 insertions(+) create mode 100644 strx/path_match.go create mode 100644 strx/path_match_test.go diff --git a/strx/README.md b/strx/README.md index 9379805..0332334 100644 --- a/strx/README.md +++ b/strx/README.md @@ -52,4 +52,7 @@ func ToBytes(v string) []byte // Cut 删除 s 中出现的 sub 字符串 func Cut(s, sub string, n ...int) string + +// 判断权限路径模式是否可以匹配实际请求路径 +func PathMatch(pattern, path string) bool ``` \ No newline at end of file diff --git a/strx/path_match.go b/strx/path_match.go new file mode 100644 index 0000000..e2094c0 --- /dev/null +++ b/strx/path_match.go @@ -0,0 +1,143 @@ +package strx + +import "strings" + +// PathMatch 判断权限路径模式是否可以匹配实际请求路径 +// +// 支持的通配规则: +// - * :单层路径匹配(仅匹配一个 segment) +// - ** :多层路径匹配(必须至少匹配一层) +// - users* / *Test :同一层级内的段内通配,不可跨越 '/' +// +// 该方法会统一对路径进行标准化处理,然后按路径段(segment)逐层匹配。 +// 适用于权限校验场景,匹配规则严格、行为可预测。 +func PathMatch(pattern, path string) bool { + pattern = normalizePath(pattern) + path = normalizePath(path) + + pSegs := strings.Split(pattern, "/") + rSegs := strings.Split(path, "/") + + return matchSegments(pSegs, rSegs) +} + +// normalizePath 对路径进行标准化: +// - 确保以 '/' 开头 +// - 移除末尾多余的 '/' +// +// 目的是避免因路径书写格式差异(如是否带尾部 /)导致匹配结果不一致。 +func normalizePath(p string) string { + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return strings.TrimSuffix(p, "/") +} + +// matchSegments 按路径段(以 '/' 分隔)逐段进行匹配。 +// +// 匹配规则说明: +// - 普通段:必须与请求段匹配(支持段内 * 通配) +// - * :匹配任意单个路径段 +// - ** :匹配多个路径段,但至少匹配一层 +// +// 该函数是核心匹配逻辑,严格保证权限路径的匹配边界, +// 不允许 ** 匹配 0 层,避免权限过宽。 +func matchSegments(pSegs, rSegs []string) bool { + i, j := 0, 0 + + for i < len(pSegs) && j < len(rSegs) { + switch pSegs[i] { + + case "**": + // ** 必须至少匹配一层 + if i+1 == len(pSegs) { + return j < len(rSegs) + } + + // 吃掉至少一层,然后尝试匹配后续 + for k := j + 1; k <= len(rSegs); k++ { + if matchSegments(pSegs[i+1:], rSegs[k:]) { + return true + } + } + return false + + case "*": + // 单层匹配 + i++ + j++ + + default: + // 普通 segment(支持 users* 这种) + if !matchSegment(pSegs[i], rSegs[j]) { + return false + } + i++ + j++ + } + } + + // pattern 和 path 必须同时结束 + if i == len(pSegs) && j == len(rSegs) { + return true + } + + // pattern 剩下的只能是 **,但 ** 不能匹配 0 层 + if i == len(pSegs)-1 && pSegs[i] == "**" && j < len(rSegs) { + return true + } + + return false +} + +// matchSegment 判断单个路径段是否匹配。 +// +// 支持两种情况: +// - 完全相等 +// - 段内通配(如 users*、*Test) +// +// 注意: +// - 该匹配仅作用于单个 segment +// - 不允许跨越 '/',以保证路径层级的严格性 +func matchSegment(pattern, segment string) bool { + // 完全相等 + if pattern == segment { + return true + } + + // 段内通配:users* + if strings.Contains(pattern, "*") { + return matchWithStar(pattern, segment) + } + + return false +} + +// matchWithStar 实现单个路径段内的 '*' 通配匹配。 +// +// 规则说明: +// - 仅支持一个 '*' +// - '*' 可匹配任意长度字符,但不能跨越路径分隔符 +// - 适用于 users*、*Service、user*Test 等规则 +func matchWithStar(pattern, str string) bool { + // 仅支持一个 * + idx := strings.Index(pattern, "*") + if idx == -1 { + return pattern == str + } + + prefix := pattern[:idx] + suffix := pattern[idx+1:] + + if !strings.HasPrefix(str, prefix) { + return false + } + if suffix != "" && !strings.HasSuffix(str, suffix) { + return false + } + + return len(str) >= len(prefix)+len(suffix) +} diff --git a/strx/path_match_test.go b/strx/path_match_test.go new file mode 100644 index 0000000..ef296d7 --- /dev/null +++ b/strx/path_match_test.go @@ -0,0 +1,236 @@ +package strx + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func ExamplePathMatch() { + fmt.Println(PathMatch("/api/users", "/api/users")) + fmt.Println(PathMatch("/api/users*", "/api/users123")) + fmt.Println(PathMatch("/api/users*", "/api/users/add")) + fmt.Println(PathMatch("/api/**/detail", "/api/user/info/detail")) + fmt.Println(PathMatch("/api/**", "/api")) + + // Output: + // true + // true + // false + // true + // false +} + +func Benchmark_MatchPath(b *testing.B) { + pattern := "/api/**/users*" + path := "/api/admin/system/v1/usersDetail" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = PathMatch(pattern, path) + } +} + +func Test_MatchPath(t *testing.T) { + tests := []struct { + name string + pattern string + path string + want bool + }{ + // ========= 精确匹配 ========= + { + name: "精确路径完全匹配 - 未配置", + pattern: "", + path: "", + want: true, + }, + { + name: "精确路径完全匹配", + pattern: "/api/users", + path: "/api/users", + want: true, + }, + { + name: "精确路径不匹配", + pattern: "/api/users", + path: "/api/user", + want: false, + }, + + // ========= 段内 * 通配 ========= + { + name: "段内前缀通配 users* 可以匹配同层路径", + pattern: "/api/users*", + path: "/api/users666", + want: true, + }, + { + name: "段内前缀通配 users* 不能跨层级匹配", + pattern: "/api/users*", + path: "/api/users/add", + want: false, + }, + { + name: "段内后缀通配 *Test 可以匹配同层路径", + pattern: "/api/*Test", + path: "/api/UserTest", + want: true, + }, + { + name: "段内前后缀组合通配 user*Test 可以匹配", + pattern: "/api/user*Test", + path: "/api/userABC123Test", + want: true, + }, + { + name: "段内通配存在后缀但请求段不以该后缀结尾", + pattern: "/api/user*Test", + path: "/api/user123Demo", + want: false, + }, + + // ========= 单层 * ========= + { + name: "单层通配符 * 匹配一个路径段", + pattern: "/api/*/detail", + path: "/api/user/detail", + want: true, + }, + { + name: "单层通配符 * 不能匹配多个路径段", + pattern: "/api/*/detail", + path: "/api/user/info/detail", + want: false, + }, + + // ========= 多层 ** ========= + { + name: "多层通配符 ** 匹配多个路径段", + pattern: "/api/**", + path: "/api/user/info/detail", + want: true, + }, + { + name: "多层通配符 ** 必须至少匹配一层路径", + pattern: "/api/**", + path: "/api", + want: false, + }, + { + name: "多层通配符 ** 位于中间可以匹配多层路径", + pattern: "/api/**/detail", + path: "/api/user/info/detail", + want: true, + }, + { + name: "多层通配符 ** 位于中间但不能匹配零层", + pattern: "/api/**/detail", + path: "/api/detail", + want: false, + }, + { + name: "pattern 末尾仅剩 ** 且匹配至少一层路径", + pattern: "/api/**", + path: "/api/users", + want: true, + }, + + // ========= 组合规则 ========= + { + name: "单层通配与段内通配组合匹配成功", + pattern: "/api/*/users*", + path: "/api/admin/usersAdd", + want: true, + }, + { + name: "单层通配与段内通配组合匹配失败", + pattern: "/api/*/users*", + path: "/api/admin/users/add", + want: false, + }, + { + name: "多层通配与段内通配组合匹配成功", + pattern: "/api/**/users*", + path: "/api/a/b/c/usersTest", + want: true, + }, + + // ========= 路径规范化 ========= + { + name: "权限路径缺少前导斜杠仍可匹配", + pattern: "api/users", + path: "/api/users", + want: true, + }, + { + name: "权限路径包含尾部斜杠仍可匹配", + pattern: "/api/users/", + path: "/api/users", + want: true, + }, + + // ========= 边界与反例 ========= + { + name: "权限路径层级多于请求路径时不匹配", + pattern: "/api/users/add", + path: "/api/users", + want: false, + }, + { + name: "请求路径层级多于权限路径且无通配符时不匹配", + pattern: "/api/users", + path: "/api/users/add", + want: false, + }, + { + name: "根路径不能匹配非根路径", + pattern: "/", + path: "/api", + want: false, + }, + { + name: "根路径可以精确匹配根路径", + pattern: "/", + path: "/", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok := PathMatch(tt.pattern, tt.path) + require.Equal(t, tt.want, ok) + }) + } +} + +// 本用例用于覆盖 matchWithStar 中「pattern 不包含 *」的分支 +// 即 idx == -1 时,函数应退化为普通字符串全等匹配 +func Test_matchWithStar_noStar(t *testing.T) { + // pattern 与 segment 完全一致,应返回 true + ok := matchWithStar("users", "users") + require.True(t, ok) + + // pattern 与 segment 不一致,应返回 false + ok = matchWithStar("users", "user") + require.False(t, ok) +} + +// 本用例用于覆盖 matchSegments 中一个“API 层不可达,但内部逻辑可达”的分支: +// 当 pattern 只剩最后一个 **,且 path 还有剩余 segment 时,应允许匹配成功 +// +// 该场景无法通过 MatchPath 黑盒测试触达, +// 必须直接对白盒方法 matchSegments 进行测试 +func Test_matchSegments_lastDoubleStarMatchesRemaining(t *testing.T) { + pSegs := []string{"api", "**"} + rSegs := []string{"api", "user", "info"} + + // 匹配过程: + // api == api → 正常推进 + // 此时 pattern 只剩最后一个 **,path 还有多层 → 应返回 true + ok := matchSegments(pSegs, rSegs) + + require.True(t, ok) +}