新增 PathMatch 方法 (#122)

This commit is contained in:
jefferyjob
2026-01-15 16:05:43 +08:00
committed by GitHub
parent 27e851c322
commit 70b32637b0
3 changed files with 382 additions and 0 deletions
+3
View File
@@ -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
```
+143
View File
@@ -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)
}
+236
View File
@@ -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)
}