feat: update custom-response plugin to returns different contents for different response status (#2002)

This commit is contained in:
DefNed
2025-04-06 09:04:40 +08:00
committed by GitHub
parent ea85ccb694
commit e5c24a10fb
7 changed files with 627 additions and 87 deletions

View File

@@ -14,34 +14,152 @@ description: 自定义应答插件配置参考
插件执行优先级:`910`
## 配置字段
### 新版本-支持多种返回
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|-------|------------------|------|-----|-------------------------------------|
| rules | array of object | 必填 | - | 规则组 |
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| -------- | -------- | -------- | -------- | -------- |
| status_code | number | 选填 | 200 | 自定义 HTTP 应答状态码 |
| headers | array of string | 选填 | - | 自定义 HTTP 应答头key 和 value 用`=`分隔 |
| body | string | 选填 | - | 自定义 HTTP 应答 Body |
| enable_on_status | array of number | 选填 | - | 匹配原始状态码,生成自定义响应,不填写时,不判断原始状态码 |
`rules`的配置字段说明如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------------------|---------------------------|------|-----|------------------------------------------------------------------------------------------------------------------------------------------------------|
| `status_code` | number | 选填 | 200 | 自定义 HTTP 应答状态码 |
| `headers` | array of string | 选填 | - | 自定义 HTTP 应答头key 和 value 用`=`分隔 |
| `body` | string | 选填 | - | 自定义 HTTP 应答 Body |
| `enable_on_status` | array of string or number | 选填 | - | 匹配原始状态码,生成自定义响应。可填写精确值如:`200`,`404`等,也可以模糊匹配例如:`2xx`来匹配200-299之间的状态码`20x`来匹配200-209之间的状态码x代表任意一位数字。不填写时不判断原始状态码,取第一个`enable_on_status`为空的规则作为默认规则 |
#### 模糊匹配规则:
* 长度为3
* 至少一位数字
* 至少一位x(不区分大小写)
| 规则 | 匹配内容 |
|-----|------------------------------------------------------------------------------------------|
| 40x | 400-409前两位为40的情况 |
| 1x4 | 104,114,124,134,144,154,164,174,184,194第一位和第三位分别为1和4的情况 |
| x23 | 023,123,223,323,423,523,623,723,823,923第二位和第三位为23的情况 |
| 4xx | 400-499第一位为4的情况 |
| x4x | 040-049,140-149,240-249,340-349,440-449,540-549,640-649,740-749,840-849,940-949第二位为4的情况 |
| xx4 | 尾数为4的情况 |
### 老版本-只支持一种返回
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| -------- | -------- |------| -------- |---------------------------------|
| `status_code` | number | 选填 | 200 | 自定义 HTTP 应答状态码 |
| `headers` | array of string | 选填 | - | 自定义 HTTP 应答头key 和 value 用`=`分隔 |
| `body` | string | 选填 | - | 自定义 HTTP 应答 Body |
| `enable_on_status` | array of number | 选填 | - | 匹配原始状态码,生成自定义响应,不填写时,不判断原始状态码 |
匹配优先级:精确匹配 > 模糊匹配 > 默认配置(第一个enable_on_status为空的配置)
## 配置示例
### Mock 应答场景
### 新版本-不同状态码不同应答场景
```yaml
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
rules:
- body: '{"hello":"world 200"}'
enable_on_status:
- 200
- 201
headers:
- key1=value1
- key2=value2
status_code: 200
- body: '{"hello":"world 404"}'
enable_on_status:
- 404
headers:
- key1=value1
- key2=value2
status_code: 200
```
根据该配置,请求将返回自定义应答如下:
根据该配置,200、201请求将返回自定义应答如下:
```text
HTTP/1.1 200 OK
Content-Type: application/json
Hello: World
Content-Length: 17
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 200"}
```
根据该配置404请求将返回自定义应答如下
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 400"}
```
### 新版本-模糊匹配场景
```yaml
rules:
- body: '{"hello":"world 200"}'
enable_on_status:
- 200
headers:
- key1=value1
- key2=value2
status_code: 200
- body: '{"hello":"world 40x"}'
enable_on_status:
- '40x'
headers:
- key1=value1
- key2=value2
status_code: 200
```
根据该配置200状态码将返回自定义应答如下
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 200"}
```
根据该配置401-409之间的状态码将返回自定义应答如下
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 40x"}
```
### 老版本-不同状态码相同应答场景
```yaml
enable_on_status:
- 200
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
```
根据该配置200请求将返回自定义应答如下
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world"}
```

View File

@@ -12,30 +12,165 @@ Plugin Execution Phase: `Authentication Phase`
Plugin Execution Priority: `910`
## Configuration Fields
| Name | Data Type | Requirements | Default Value | Description |
| -------- | -------- | -------- | -------- | -------- |
| status_code | number | Optional | 200 | Custom HTTP response status code |
| headers | array of string | Optional | - | Custom HTTP response headers, keys and values separated by `=` |
| body | string | Optional | - | Custom HTTP response body |
| enable_on_status | array of number | Optional | - | Match original status codes to generate custom responses; if not specified, the original status code is not checked |
### New version - Supports multiple returns
| Name | Data Type | Requirements | Default Value | Description |
|---------------------|-----------------|----------|-----|------------|
| rules | array of object | Required | - | rule array |
The configuration field description of `rules` is as follows
| Name | Data Type | Requirements | Default Value | Description |
|--------------------|---------------------------|--------------|-----|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `status_code` | number | Optional | 200 | Custom HTTP response status code |
| `headers` | array of string | Optional | - | Custom HTTP response headers, keys and values separated by `=` |
| `body` | string | Optional | - | Custom HTTP response body |
| `enable_on_status` | array of string or number | Optional | - | Match the original status code to generate a custom response. You can fill in the exact value such as :`200`,`404`, etc., you can also fuzzy match such as: `2xx` to match the status code between 200-299, `20x` to match the status code between 200-209, x represents any digit. If enable_on_status is not specified, the original status code is not determined and the first rule with ENABLE_ON_status left blank is used as the default rule |
#### Fuzzy matching rule
* Length is 3
* At least one digit
* At least one x(case insensitive)
| rule | Matching content |
|------|------------------------------------------------------------------------------------------|
| 40x | 400-409; If the first two digits are 40 |
| 1x4 | 104,114,124,134,144,154,164,174,184,194The first and third positions are 1 and 4 respectively |
| x23 | 023,123,223,323,423,523,623,723,823,923The second and third positions are 23 |
| 4xx | 400-499The first digit is 4 |
| x4x | 040-049,140-149,240-249,340-349,440-449,540-549,640-649,740-749,840-849,940-949The second digit is 4 |
| xx4 | When the mantissa is 4 |
Matching priority: Exact Match > Fuzzy Match > Default configuration (the first enable_on_status parameter is null)
## Old version - Only one return is supported
| Name | Data Type | Requirements | Default Value | Description |
| -------- | -------- | -------- | -------- |----------------------------------------------------------------------------------------------------|
| `status_code` | number | Optional | 200 | Custom HTTP response status code |
| `headers` | array of string | Optional | - | Custom HTTP response headers, keys and values separated by `=` |
| `body` | string | Optional | - | Custom HTTP response body |
| `enable_on_status` | array of number | Optional | - | Match original status codes to generate custom responses; if not specified, the original status code is not checked |
## Configuration Example
### Mock Response Scenario
### Different status codes for different response scenarios
```yaml
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
rules:
- body: '{"hello":"world 200"}'
enable_on_status:
- '200'
- '201'
headers:
- key1=value1
- key2=value2
status_code: 200
- body: '{"hello":"world 404"}'
enable_on_status:
- '404'
headers:
- key1=value1
- key2=value2
status_code: 200
```
With this configuration, the request will return the following custom response:
According to this configuration 200 201 requests will return a custom response as follows
```text
HTTP/1.1 200 OK
Content-Type: application/json
Hello: World
Content-Length: 17
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 200"}
```
According to this configuration 404 requests will return a custom response as follows
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 400"}
```
With this configuration, 404 response will return the following custom response:
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 404"}
```
### Fuzzy matching scene
```yaml
rules:
- body: '{"hello":"world 200"}'
enable_on_status:
- 200
headers:
- key1=value1
- key2=value2
status_code: 200
- body: '{"hello":"world 40x"}'
enable_on_status:
- '40x'
headers:
- key1=value1
- key2=value2
status_code: 200
```
According to this configuration, the status 200 will return a custom reply as follows
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 200"}
```
According to this configuration, the status code between 401-409 will return a custom reply as follows
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 40x"}
```
### Mock Response Scenario
```yaml
enable_on_status:
- 200
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
```
With this configuration, 200/201 response will return the following custom response:
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world"}
```
### Custom Response on Rate Limiting
```yaml
enable_on_status:

View File

@@ -1 +1 @@
1.0.0
1.1.0

View File

@@ -0,0 +1,25 @@
services:
envoy:
image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v2.0.7
entrypoint: /usr/local/bin/envoy
# 注意这里对 Wasm 开启了 debug 级别日志,在生产环境部署时请使用默认的 info 级别
# 如果需要将 Envoy 的日志级别调整为 debug将 --log-level 参数设置为 debug
command: -c /etc/envoy/envoy.yaml --log-level info --component-log-level wasm:debug
depends_on:
- echo-server
networks:
- wasmtest
ports:
- "10000:10000"
- "9901:9901"
volumes:
- ./envoy.yaml:/etc/envoy/envoy.yaml
- ./plugin.wasm:/etc/envoy/plugin.wasm
echo-server:
image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0
networks:
- wasmtest
ports:
- "3000:3000"
networks:
wasmtest: {}

View File

@@ -1,9 +1,3 @@
admin:
address:
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 9901
static_resources:
listeners:
- name: listener_0
@@ -27,9 +21,9 @@ static_resources:
domains: ["*"]
routes:
- match:
prefix: "/"
prefix: "/echo"
route:
cluster: httpbin
cluster: echo-server
http_filters:
- name: wasmdemo
typed_config:
@@ -42,30 +36,100 @@ static_resources:
runtime: envoy.wasm.runtime.v8
code:
local:
filename: /etc/envoy/main.wasm
filename: /etc/envoy/plugin.wasm
configuration:
"@type": "type.googleapis.com/google.protobuf.StringValue"
value: |
# value: |-
# {
# "rules": [
# {
# "headers": [
# "key1=value1",
# "key2=value2"
# ],
# "status_code": 200,
# "enable_on_status": [
# 200,
# 201
# ],
# "body": "{\"hello\":\"world 200\"}"
# },
# {
# "headers": [
# "key1=value1",
# "key2=value2"
# ],
# "status_code": 200,
# "enable_on_status": [
# 404
# ],
# "body": "{\"hello\":\"world 404\"}"
# }
# ]
# }
value: |-
{
"headers": ["key1=value1", "key2=value2"],
"status_code": 200,
"enable_on_status": [200, 201],
"body": "{\"hello\":\"world\"}"
"rules": [
{
"headers": [
"key1=value1",
"key2=value2"
],
"status_code": 200,
"enable_on_status": [
200
],
"body": "{\"hello\":\"world 200\"}"
},
{
"headers": [
"key1=value1",
"key2=value2"
],
"status_code": 200,
"enable_on_status": [
"40x"
],
"body": "{\"hello\":\"world 40x\"}"
}
]
}
# value: |-
# {
# "headers": [
# "key1=value1",
# "key2=value2"
# ],
# "status_code": 200,
# "body": "{\"hello\":\"world 200\"}",
# "enable_on_status": [
# 200
# ]
# }
# value: |-
# {
# "headers": [
# "key1=value1",
# "key2=value2"
# ],
# "status_code": 200,
# "body": "{\"hello\":\"world 200\"}"
# }
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: httpbin
- name: echo-server
connect_timeout: 30s
type: LOGICAL_DNS
# Comment out the following line to test on v6 networks
dns_lookup_family: V4_ONLY
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: httpbin
cluster_name: echo-server
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: httpbin
port_value: 80
address: echo-server
port_value: 3000

View File

@@ -16,10 +16,12 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
@@ -29,78 +31,160 @@ import (
func main() {
wrapper.SetCtx(
"custom-response",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
wrapper.ParseConfig(parseConfig),
wrapper.ProcessRequestHeaders(onHttpRequestHeaders),
wrapper.ProcessResponseHeaders(onHttpResponseHeaders),
)
}
type CustomResponseConfig struct {
rules []CustomResponseRule
defaultRule *CustomResponseRule
enableOnStatusRuleMap map[string]*CustomResponseRule
}
type CustomResponseRule struct {
statusCode uint32
headers [][2]string
body string
enableOnStatus []uint32
enableOnStatus []string
contentType string
}
func parseConfig(gjson gjson.Result, config *CustomResponseConfig, log wrapper.Log) error {
func parseConfig(gjson gjson.Result, config *CustomResponseConfig) error {
rules := gjson.Get("rules")
rulesVersion := rules.Exists() && rules.IsArray()
if rulesVersion {
for _, cf := range gjson.Get("rules").Array() {
item := new(CustomResponseRule)
if err := parseRuleItem(cf, item); err != nil {
return err
}
// the first rule item which enableOnStatus is empty to be set default
if len(item.enableOnStatus) == 0 && config.defaultRule == nil {
config.defaultRule = item
}
config.rules = append(config.rules, *item)
}
} else {
rule := new(CustomResponseRule)
if err := parseRuleItem(gjson, rule); err != nil {
return err
}
config.rules = append(config.rules, *rule)
config.defaultRule = rule
}
config.enableOnStatusRuleMap = make(map[string]*CustomResponseRule)
for i, configItem := range config.rules {
for _, statusCode := range configItem.enableOnStatus {
if v, ok := config.enableOnStatusRuleMap[statusCode]; ok {
log.Errorf("enable_on_status code used in %v, want to add %v", v, statusCode)
return errors.New("enableOnStatus can only use once")
}
config.enableOnStatusRuleMap[statusCode] = &config.rules[i]
}
}
if rulesVersion && config.defaultRule == nil && len(config.enableOnStatusRuleMap) == 0 {
return errors.New("no valid config is found")
}
return nil
}
func parseRuleItem(gjson gjson.Result, rule *CustomResponseRule) error {
headersArray := gjson.Get("headers").Array()
config.headers = make([][2]string, 0, len(headersArray))
rule.headers = make([][2]string, 0, len(headersArray))
for _, v := range headersArray {
kv := strings.SplitN(v.String(), "=", 2)
if len(kv) == 2 {
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
if strings.EqualFold(key, "content-type") {
config.contentType = value
rule.contentType = value
} else if strings.EqualFold(key, "content-length") {
continue
} else {
config.headers = append(config.headers, [2]string{key, value})
rule.headers = append(rule.headers, [2]string{key, value})
}
} else {
return fmt.Errorf("invalid header pair format: %s", v.String())
}
}
config.body = gjson.Get("body").String()
if config.contentType == "" && config.body != "" {
if json.Valid([]byte(config.body)) {
config.contentType = "application/json; charset=utf-8"
rule.body = gjson.Get("body").String()
if rule.contentType == "" && rule.body != "" {
if json.Valid([]byte(rule.body)) {
rule.contentType = "application/json; charset=utf-8"
} else {
config.contentType = "text/plain; charset=utf-8"
rule.contentType = "text/plain; charset=utf-8"
}
}
config.headers = append(config.headers, [2]string{"content-type", config.contentType})
rule.headers = append(rule.headers, [2]string{"content-type", rule.contentType})
config.statusCode = 200
rule.statusCode = 200
if gjson.Get("status_code").Exists() {
statusCode := gjson.Get("status_code")
parsedStatusCode, err := strconv.Atoi(statusCode.String())
if err != nil {
return fmt.Errorf("invalid status code value: %s", statusCode.String())
}
config.statusCode = uint32(parsedStatusCode)
rule.statusCode = uint32(parsedStatusCode)
}
enableOnStatusArray := gjson.Get("enable_on_status").Array()
config.enableOnStatus = make([]uint32, 0, len(enableOnStatusArray))
rule.enableOnStatus = make([]string, 0, len(enableOnStatusArray))
for _, v := range enableOnStatusArray {
parsedEnableOnStatus, err := strconv.Atoi(v.String())
s := v.String()
_, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("invalid enable_on_status value: %s", v.String())
matchString, err := isValidFuzzyMatchString(s)
if err != nil {
return err
}
rule.enableOnStatus = append(rule.enableOnStatus, matchString)
continue
}
config.enableOnStatus = append(config.enableOnStatus, uint32(parsedEnableOnStatus))
rule.enableOnStatus = append(rule.enableOnStatus, s)
}
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config CustomResponseConfig, log wrapper.Log) types.Action {
if len(config.enableOnStatus) != 0 {
func isValidFuzzyMatchString(s string) (string, error) {
const requiredLength = 3
if len(s) != requiredLength {
return "", fmt.Errorf("invalid enable_on_status %q: length must be %d", s, requiredLength)
}
lower := strings.ToLower(s)
hasX := false
hasDigit := false
for _, c := range lower {
switch {
case c == 'x':
hasX = true
case c >= '0' && c <= '9':
hasDigit = true
default:
return "", fmt.Errorf("invalid enable_on_status %q: must contain only digits and x/X", s)
}
}
if !hasX {
return "", fmt.Errorf("invalid enable_on_status %q: fuzzy match must contain x/X (use enable_on_status for exact statusCode matching)", s)
}
if !hasDigit {
return "", fmt.Errorf("invalid enable_on_status %q: must contain at least one digit", s)
}
return lower, nil
}
func onHttpRequestHeaders(_ wrapper.HttpContext, config CustomResponseConfig) types.Action {
if len(config.enableOnStatusRuleMap) != 0 {
return types.ActionContinue
}
err := proxywasm.SendHttpResponseWithDetail(config.statusCode, "custom-response", config.headers, []byte(config.body), -1)
log.Infof("use default rule %+v", config.defaultRule)
err := proxywasm.SendHttpResponseWithDetail(config.defaultRule.statusCode, "custom-response", config.defaultRule.headers, []byte(config.defaultRule.body), -1)
if err != nil {
log.Errorf("send http response failed: %v", err)
}
@@ -108,28 +192,62 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config CustomResponseConfig,
return types.ActionPause
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, config CustomResponseConfig, log wrapper.Log) types.Action {
// enableOnStatus is not empty, compare the status code.
func onHttpResponseHeaders(_ wrapper.HttpContext, config CustomResponseConfig) types.Action {
// enableOnStatusRuleMap is not empty, compare the status code.
// if match the status code, mock the response.
statusCodeStr, err := proxywasm.GetHttpResponseHeader(":status")
if err != nil {
log.Errorf("get http response status code failed: %v", err)
return types.ActionContinue
}
statusCode, err := strconv.ParseUint(statusCodeStr, 10, 32)
if err != nil {
log.Errorf("parse http response status code failed: %v", err)
if rule, ok := config.enableOnStatusRuleMap[statusCodeStr]; ok {
err = proxywasm.SendHttpResponseWithDetail(rule.statusCode, "custom-response", rule.headers, []byte(rule.body), -1)
if err != nil {
log.Errorf("send http response failed: %v", err)
}
return types.ActionContinue
}
for _, v := range config.enableOnStatus {
if uint32(statusCode) == v {
err = proxywasm.SendHttpResponseWithDetail(config.statusCode, "custom-response", config.headers, []byte(config.body), -1)
if err != nil {
log.Errorf("send http response failed: %v", err)
}
if rule, match := fuzzyMatchCode(config.enableOnStatusRuleMap, statusCodeStr); match {
err = proxywasm.SendHttpResponseWithDetail(rule.statusCode, "custom-response", rule.headers, []byte(rule.body), -1)
if err != nil {
log.Errorf("send http response failed: %v", err)
}
return types.ActionContinue
}
return types.ActionContinue
}
func fuzzyMatchCode(statusRuleMap map[string]*CustomResponseRule, statusCode string) (*CustomResponseRule, bool) {
if len(statusRuleMap) == 0 || statusCode == "" {
return nil, false
}
codeLen := len(statusCode)
for pattern, rule := range statusRuleMap {
// 规则1模式长度必须与状态码一致
if len(pattern) != codeLen {
continue
}
// 纯数字的enableOnStatus已经判断过跳过
if !strings.Contains(pattern, "x") {
continue
}
// 规则2所有数字位必须精确匹配
match := true
for i, c := range pattern {
// 如果是数字位需要校验
if c >= '0' && c <= '9' {
// 边界检查防止panic
if i >= codeLen || statusCode[i] != byte(c) {
match = false
break
}
}
// 非数字位如x自动匹配
}
if match {
return rule, true
}
}
return nil, false
}

View File

@@ -0,0 +1,80 @@
package main
import (
"testing"
)
func Test_prefixMatchCode(t *testing.T) {
rules := map[string]*CustomResponseRule{
"x01": {},
"2x3": {},
"45x": {},
"6xx": {},
"x7x": {},
"xx8": {},
}
tests := []struct {
code string
expectHit bool
}{
{"101", true}, // 匹配x01
{"201", true}, // 匹配x01
{"111", false}, // 不匹配
{"203", true}, // 匹配2x3
{"213", true}, // 匹配2x3
{"450", true}, // 匹配45x
{"451", true}, // 匹配45x
{"600", true}, // 匹配6xx
{"611", true}, // 匹配6xx
{"612", true}, // 匹配6xx
{"171", true}, // 匹配x7x
{"161", false}, // 不匹配
{"228", true}, // 匹配xx8
{"229", false}, // 不匹配
{"123", false}, // 不匹配
}
for _, tt := range tests {
_, found := fuzzyMatchCode(rules, tt.code)
if found != tt.expectHit {
t.Errorf("code:%s expect:%v got:%v", tt.code, tt.expectHit, found)
}
}
}
func TestIsValidPrefixString(t *testing.T) {
tests := []struct {
input string
expected string
hasError bool
}{
{"x1x", "x1x", false},
{"X2X", "x2x", false},
{"xx1", "xx1", false},
{"x12", "x12", false},
{"1x2", "1x2", false},
{"12x", "12x", false},
{"123", "", true}, // 缺少x
{"xxx", "", true}, // 缺少数字
{"xYx", "", true}, // 非法字符
{"x1", "", true}, // 长度不足
{"x123", "", true}, // 长度超限
}
for _, tt := range tests {
result, err := isValidFuzzyMatchString(tt.input)
if tt.hasError {
if err == nil {
t.Errorf("%q: expected error but got none", tt.input)
}
} else {
if err != nil {
t.Errorf("%q: unexpected error: %v", tt.input, err)
}
if result != tt.expected {
t.Errorf("%q: expected %q, got %q", tt.input, tt.expected, result)
}
}
}
}