feat: 升级 libtnb/validator v0.2.1,用上新校验能力

- 切片字段加 unique 防重复配置(域名/监听/IP/白名单/网卡/磁盘设备/
  NTP 服务器/应用标识/压缩路径/TLS 版本/索引/定时任务目标)
- 网站统计日期范围 End 加 after_or_equal:Start(结束不早于开始)
- 新增 tags_test.go:CheckRules 体检关键请求 + 全包 validate tag
  编译校验,坏规则名/语法错/未注册自定义规则在测试期即报错

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-07-07 23:10:54 +08:00
parent c5fb80a4fb
commit 6f0593fbf8
14 changed files with 111 additions and 21 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ require (
github.com/libtnb/sessions v1.4.2
github.com/libtnb/sqlite v1.1.2
github.com/libtnb/utils v1.2.2
github.com/libtnb/validator v0.1.1
github.com/libtnb/validator v0.2.1
github.com/medama-io/go-useragent v1.2.3
github.com/mholt/acmez/v3 v3.1.6
github.com/moby/moby/api v1.55.0
+2 -2
View File
@@ -288,8 +288,8 @@ github.com/libtnb/testify v0.0.0-20260406170114-25da2dad39e7 h1:ZzONsNhw69uC/wJQ
github.com/libtnb/testify v0.0.0-20260406170114-25da2dad39e7/go.mod h1:HeQeTfKU6tj2Lx1z79UacwYeDioo6M4ZD7BDDI6+rrg=
github.com/libtnb/utils v1.2.2 h1:HUqgAIb+TIrZPXRytYB5XqzkEXG2U4a4/ZGpprNtL5A=
github.com/libtnb/utils v1.2.2/go.mod h1:KUCx2+Phw3cLXOwuQDy6XwFOliaYuU1rpAuJQuHf+lQ=
github.com/libtnb/validator v0.1.1 h1:8D3YkPGR9N2Ildb2PeApvgzCgIfKznEb6cTHWPskmNs=
github.com/libtnb/validator v0.1.1/go.mod h1:j08Lydxwy7ModkoKeYIHxvZvAtnzzdE8xi2J+SGVrgc=
github.com/libtnb/validator v0.2.1 h1:ZbFx7bEXB9ri0Eb7p81LOSAq0M+VrizDR4aD06TcCvg=
github.com/libtnb/validator v0.2.1/go.mod h1:j08Lydxwy7ModkoKeYIHxvZvAtnzzdE8xi2J+SGVrgc=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+1 -1
View File
@@ -19,5 +19,5 @@ type AppUpdateShow struct {
}
type AppUpdateOrder struct {
Slugs []string `json:"slugs" form:"slugs" validate:"required"`
Slugs []string `json:"slugs" form:"slugs" validate:"required && unique"`
}
+2 -2
View File
@@ -7,7 +7,7 @@ type CertUpload struct {
type CertCreate struct {
Type string `form:"type" json:"type" validate:"required && in:P256,P384,2048,3072,4096"`
Domains []string `form:"domains" json:"domains" validate:"required && dive && required"`
Domains []string `form:"domains" json:"domains" validate:"required && unique && dive && required"`
Alias map[string]string `form:"alias" json:"alias"`
AutoRenewal bool `form:"auto_renewal" json:"auto_renewal"`
AccountID uint `form:"account_id" json:"account_id"`
@@ -18,7 +18,7 @@ type CertCreate struct {
type CertUpdate struct {
ID uint `form:"id" json:"id" validate:"required && exists:certs,id"`
Type string `form:"type" json:"type" validate:"required && in:P256,P384,2048,3072,4096,upload"`
Domains []string `form:"domains" json:"domains" validate:"required && dive && required"`
Domains []string `form:"domains" json:"domains" validate:"required && unique && dive && required"`
Alias map[string]string `form:"alias" json:"alias"`
Cert string `form:"cert" json:"cert"`
Key string `form:"key" json:"key"`
+1 -1
View File
@@ -8,7 +8,7 @@ type CronCreate struct {
SubType string `form:"sub_type" json:"sub_type" validate:"required_if:Type,backup,cutoff"`
Flock bool `form:"flock" json:"flock"`
Storage uint `form:"storage" json:"storage"`
Targets []string `form:"targets" json:"targets" validate:"required_if:Type,backup,cutoff"`
Targets []string `form:"targets" json:"targets" validate:"required_if:Type,backup,cutoff && unique"`
Keep uint `form:"keep" json:"keep" validate:"required"`
URL string `form:"url" json:"url"`
Method string `form:"method" json:"method"`
+1 -1
View File
@@ -66,7 +66,7 @@ type FilePermission struct {
type FileCompress struct {
Dir string `form:"dir" json:"dir" validate:"required && unix_path"`
Paths []string `form:"paths" json:"paths" validate:"required && dive && required"`
Paths []string `form:"paths" json:"paths" validate:"required && unique && dive && required"`
File string `form:"file" json:"file" validate:"required && unix_path"`
}
+2 -2
View File
@@ -4,10 +4,10 @@ package request
type FirewallScanSetting struct {
Enabled bool `json:"enabled"`
Days uint `json:"days" validate:"min:1 && max:365"`
Interfaces []string `json:"interfaces"`
Interfaces []string `json:"interfaces" validate:"unique"`
AutoBlock bool `json:"auto_block"`
BlockThreshold uint `json:"block_threshold" validate:"min:1 && max:100000"`
BlockWindow uint `json:"block_window" validate:"min:1 && max:1440"`
BlockDuration uint `json:"block_duration" validate:"max:87600"`
Whitelist []string `json:"whitelist" validate:"dive && ipcidr"`
Whitelist []string `json:"whitelist" validate:"unique && dive && ipcidr"`
}
+1 -1
View File
@@ -13,7 +13,7 @@ type SettingPanel struct {
Lifetime uint `json:"lifetime" validate:"required && min:10 && max:43200"` // 登录超时,单位:分
IPHeader string `json:"ip_header"`
BindDomain []string `json:"bind_domain"`
BindIP []string `json:"bind_ip" validate:"dive && ipcidr"`
BindIP []string `json:"bind_ip" validate:"unique && dive && ipcidr"`
BindUA []string `json:"bind_ua"`
WebsitePath string `json:"website_path" validate:"required"`
BackupPath string `json:"backup_path" validate:"required"`
+90
View File
@@ -0,0 +1,90 @@
package request
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/libtnb/validator"
"github.com/acepanel/panel/v3/internal/http/rule"
)
// tagValidator mirrors service.NewValidator's rule set. db is nil: CheckRules and
// AddRules only compile expressions, they never invoke a rule's Passes or touch
// the database.
func tagValidator() *validator.Validator {
v := validator.NewValidator(validator.WithStrictRequired())
rule.RegisterRules(v, nil)
return v
}
// TestCheckRulesKeyRequests runs the validator's own tag checker over the
// requests with the richest rules (custom rules, dive, cross-field, and the
// v0.2 additions unique / after_or_equal), catching bad tags at test time.
func TestCheckRulesKeyRequests(t *testing.T) {
v := tagValidator()
for _, req := range []any{
&WebsiteCreate{}, &WebsiteUpdate{}, &WebsiteDefaultConfig{},
&CertCreate{}, &CertUpdate{},
&FileCompress{}, &FilePermission{},
&SettingPanel{}, &UserTokenCreate{}, &FirewallScanSetting{},
&WebsiteStatDateRange{}, &BackupCreate{},
} {
if err := v.CheckRules(req); err != nil {
t.Errorf("%T: %v", req, err)
}
}
}
// TestValidateTagsCompile is the whole-package backstop: every validate tag in
// this package must compile against the real validator, so a typo'd rule name, a
// DSL syntax error, or an unregistered custom rule fails the build. This is the
// engine-backed successor to the ad-hoc checktag used during the gookit migration
// (which, being a plain tag scanner, could not see the Rules() maps that hid the
// Domains.* bug, nor tell whether a custom rule was registered).
func TestValidateTagsCompile(t *testing.T) {
v := tagValidator()
fset := token.NewFileSet()
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
seen := 0
for _, file := range files {
if strings.HasSuffix(file, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, file, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
ast.Inspect(f, func(n ast.Node) bool {
field, ok := n.(*ast.Field)
if !ok || field.Tag == nil {
return true
}
expr := reflect.StructTag(strings.Trim(field.Tag.Value, "`")).Get("validate")
if strings.TrimSpace(expr) == "" {
return true
}
name := "field"
if len(field.Names) > 0 {
name = field.Names[0].Name
}
seen++
if err := v.Map(map[string]any{}, nil).AddRules(name, expr); err != nil {
t.Errorf("%s: %s `validate:%q`: %v", file, name, expr, err)
}
return true
})
}
if seen == 0 {
t.Fatal("no validate tags found — the scanner is broken")
}
t.Logf("compiled %d validate tags", seen)
}
+1 -1
View File
@@ -27,7 +27,7 @@ type ToolboxDiskFormat struct {
// ToolboxDiskVG 卷组请求
type ToolboxDiskVG struct {
Name string `form:"name" json:"name" validate:"required"`
Devices []string `form:"devices" json:"devices" validate:"required"`
Devices []string `form:"devices" json:"devices" validate:"required && unique"`
}
// ToolboxDiskLV 逻辑卷请求
+1 -1
View File
@@ -36,5 +36,5 @@ type ToolboxSystemSyncTime struct {
}
type ToolboxSystemNTPServers struct {
Servers []string `form:"servers" json:"servers" validate:"required"`
Servers []string `form:"servers" json:"servers" validate:"required && unique"`
}
+2 -2
View File
@@ -7,12 +7,12 @@ type UserTokenList struct {
type UserTokenCreate struct {
UserID uint `json:"user_id" validate:"required && exists:users,id"`
IPs []string `json:"ips" validate:"dive && ipcidr"`
IPs []string `json:"ips" validate:"unique && dive && ipcidr"`
ExpiredAt int64 `json:"expired_at" validate:"required"`
}
type UserTokenUpdate struct {
ID uint `uri:"id"`
IPs []string `json:"ips" validate:"dive && ipcidr"`
IPs []string `json:"ips" validate:"unique && dive && ipcidr"`
ExpiredAt int64 `json:"expired_at" validate:"required"`
}
+5 -5
View File
@@ -8,7 +8,7 @@ type WebsiteDefaultConfig struct {
Index string `json:"index" form:"index" validate:"required"`
Stop string `json:"stop" form:"stop" validate:"required"`
NotFound string `json:"not_found" form:"not_found"`
TLSVersions []string `json:"tls_versions" form:"tls_versions" validate:"required && dive && required"`
TLSVersions []string `json:"tls_versions" form:"tls_versions" validate:"required && unique && dive && required"`
}
type WebsiteList struct {
@@ -19,8 +19,8 @@ type WebsiteList struct {
type WebsiteCreate struct {
Type string `json:"type" form:"type" validate:"required && in:proxy,static,php"`
Name string `form:"name" json:"name" validate:"required && not_exists:websites,name && not_in:phpmyadmin,default && regex:\"^[a-zA-Z0-9_-]+$\""`
Listens []string `form:"listens" json:"listens" validate:"required && dive && required"`
Domains []string `form:"domains" json:"domains" validate:"required && dive && required"`
Listens []string `form:"listens" json:"listens" validate:"required && unique && dive && required"`
Domains []string `form:"domains" json:"domains" validate:"required && unique && dive && required"`
Path string `form:"path" json:"path"`
DB bool `form:"db" json:"db"`
DBType string `form:"db_type" json:"db_type" validate:"required_if:DB,true"`
@@ -42,10 +42,10 @@ type WebsiteDelete struct {
type WebsiteUpdate struct {
ID uint `form:"id" json:"id" validate:"required && exists:websites,id"`
Listens []types.Listen `form:"listens" json:"listens" validate:"required && dive && required"`
Domains []string `form:"domains" json:"domains" validate:"required && dive && required"`
Domains []string `form:"domains" json:"domains" validate:"required && unique && dive && required"`
Path string `form:"path" json:"path" validate:"required && unix_path"` // 网站目录
Root string `form:"root" json:"root" validate:"required && unix_path"` // 运行目录
Index []string `form:"index" json:"index" validate:"required && dive && required"`
Index []string `form:"index" json:"index" validate:"required && unique && dive && required"`
// SSL 相关
SSL bool `form:"ssl" json:"ssl"`
+1 -1
View File
@@ -21,7 +21,7 @@ type WebsiteStatSetting struct {
// WebsiteStatDateRange 统计日期范围查询参数
type WebsiteStatDateRange struct {
Start string `json:"start" form:"start" query:"start" validate:"datetime:2006-01-02"`
End string `json:"end" form:"end" query:"end" validate:"datetime:2006-01-02"`
End string `json:"end" form:"end" query:"end" validate:"datetime:2006-01-02 && after_or_equal:Start"`
Sites string `json:"sites" form:"sites" query:"sites"`
}