mirror of
https://github.com/jefferyjob/go-easy-utils.git
synced 2026-04-22 15:57:06 +08:00
新增 PathMatch 方法 (#122)
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user