Skip to content

Commit 3b23add

Browse files
committed
feat(nodes): 增强节点质量状态支持并优化展示
- 新增节点质量状态字段及相关枚举定义(untested, success, partial 等) - 修改节点过滤器,支持按质量状态筛选 - 更新节点表格和卡片显示逻辑,整合质量状态标签 - 调整 IP 类型及住宅属性的显示规则,合并异常状态标签 - 新增质量状态详细文本工具函数,提升状态信息的可读性 close: #145
1 parent d9bdf92 commit 3b23add

File tree

30 files changed

+652
-159
lines changed

30 files changed

+652
-159
lines changed

api/clients.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ func GetV2ray(c *gin.Context) {
176176
IsBroadcast: v.IsBroadcast,
177177
IsResidential: v.IsResidential,
178178
FraudScore: v.FraudScore,
179+
QualityStatus: v.QualityStatus,
180+
QualityFamily: v.QualityFamily,
179181
})
180182
nodeLink = utils.RenameNodeLink(v.Link, newName)
181183
}
@@ -202,6 +204,8 @@ func GetV2ray(c *gin.Context) {
202204
IsBroadcast: v.IsBroadcast,
203205
IsResidential: v.IsResidential,
204206
FraudScore: v.FraudScore,
207+
QualityStatus: v.QualityStatus,
208+
QualityFamily: v.QualityFamily,
205209
})
206210
links[i] = utils.RenameNodeLink(link, newName)
207211
}
@@ -294,6 +298,8 @@ func GetClash(c *gin.Context) {
294298
IsBroadcast: v.IsBroadcast,
295299
IsResidential: v.IsResidential,
296300
FraudScore: v.FraudScore,
301+
QualityStatus: v.QualityStatus,
302+
QualityFamily: v.QualityFamily,
297303
})
298304
}
299305
nodeNameMap[v.ID] = finalName
@@ -359,6 +365,8 @@ func GetClash(c *gin.Context) {
359365
IsBroadcast: v.IsBroadcast,
360366
IsResidential: v.IsResidential,
361367
FraudScore: v.FraudScore,
368+
QualityStatus: v.QualityStatus,
369+
QualityFamily: v.QualityFamily,
362370
})
363371
nodeLink = utils.RenameNodeLink(v.Link, newName)
364372
}
@@ -400,6 +408,8 @@ func GetClash(c *gin.Context) {
400408
IsBroadcast: v.IsBroadcast,
401409
IsResidential: v.IsResidential,
402410
FraudScore: v.FraudScore,
411+
QualityStatus: v.QualityStatus,
412+
QualityFamily: v.QualityFamily,
403413
})
404414
renamedLink = utils.RenameNodeLink(link, newName)
405415
}

api/node.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ func normalizeResidentialType(value string) string {
2222
}
2323
}
2424

25+
func normalizeQualityStatus(value string) string {
26+
switch strings.ToLower(strings.TrimSpace(value)) {
27+
case models.QualityStatusUntested, models.QualityStatusSuccess, models.QualityStatusPartial, models.QualityStatusFailed, models.QualityStatusDisabled:
28+
return strings.ToLower(strings.TrimSpace(value))
29+
default:
30+
return ""
31+
}
32+
}
33+
2534
func normalizeIPType(value string) string {
2635
switch strings.ToLower(strings.TrimSpace(value)) {
2736
case "native", "broadcast", "untested":
@@ -329,6 +338,7 @@ func NodeGet(c *gin.Context) {
329338
filter.Tags = c.QueryArray("tags[]")
330339
filter.ResidentialType = normalizeResidentialType(c.Query("residentialType"))
331340
filter.IPType = normalizeIPType(c.Query("ipType"))
341+
filter.QualityStatus = normalizeQualityStatus(c.Query("qualityStatus"))
332342
if filter.ResidentialType == "" && c.Query("onlyResidential") == "true" {
333343
filter.ResidentialType = "residential"
334344
}
@@ -432,6 +442,7 @@ func NodeGetIDs(c *gin.Context) {
432442
filter.Tags = c.QueryArray("tags[]")
433443
filter.ResidentialType = normalizeResidentialType(c.Query("residentialType"))
434444
filter.IPType = normalizeIPType(c.Query("ipType"))
445+
filter.QualityStatus = normalizeQualityStatus(c.Query("qualityStatus"))
435446
if filter.ResidentialType == "" && c.Query("onlyResidential") == "true" {
436447
filter.ResidentialType = "residential"
437448
}

api/preview.go

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,27 @@ type PreviewRequest struct {
1717
SubscriptionID int `json:"SubscriptionID"`
1818

1919
// 以下字段用于表单预览(未保存的订阅)
20-
NodeIDs []int `json:"NodeIDs"` // 选中的节点ID列表(带排序)
21-
NodeSorts []int `json:"NodeSorts"` // 节点对应的排序值
22-
Groups []string `json:"Groups"` // 选中的分组列表
23-
GroupSorts []int `json:"GroupSorts"` // 分组对应的排序值
24-
Scripts []int `json:"Scripts"` // 选中的脚本ID列表
25-
DelayTime int `json:"DelayTime"` // 最大延迟过滤
26-
MinSpeed float64 `json:"MinSpeed"` // 最小速度过滤
27-
CountryWhitelist string `json:"CountryWhitelist"` // 国家白名单
28-
CountryBlacklist string `json:"CountryBlacklist"` // 国家黑名单
29-
TagWhitelist string `json:"TagWhitelist"` // 标签白名单
30-
TagBlacklist string `json:"TagBlacklist"` // 标签黑名单
31-
ProtocolWhitelist string `json:"ProtocolWhitelist"` // 协议白名单
32-
ProtocolBlacklist string `json:"ProtocolBlacklist"` // 协议黑名单
33-
NodeNameWhitelist string `json:"NodeNameWhitelist"` // 节点名称白名单
34-
NodeNameBlacklist string `json:"NodeNameBlacklist"` // 节点名称黑名单
35-
MaxFraudScore int `json:"MaxFraudScore"` // 最大欺诈评分
36-
OnlyResidential bool `json:"OnlyResidential"` // 仅住宅IP
37-
OnlyNative bool `json:"OnlyNative"` // 仅原生IP
38-
ResidentialType string `json:"ResidentialType"` // 住宅属性过滤
39-
IPType string `json:"IPType"` // IP类型过滤
20+
NodeIDs []int `json:"NodeIDs"` // 选中的节点ID列表(带排序)
21+
NodeSorts []int `json:"NodeSorts"` // 节点对应的排序值
22+
Groups []string `json:"Groups"` // 选中的分组列表
23+
GroupSorts []int `json:"GroupSorts"` // 分组对应的排序值
24+
Scripts []int `json:"Scripts"` // 选中的脚本ID列表
25+
DelayTime int `json:"DelayTime"` // 最大延迟过滤
26+
MinSpeed float64 `json:"MinSpeed"` // 最小速度过滤
27+
CountryWhitelist string `json:"CountryWhitelist"` // 国家白名单
28+
CountryBlacklist string `json:"CountryBlacklist"` // 国家黑名单
29+
TagWhitelist string `json:"TagWhitelist"` // 标签白名单
30+
TagBlacklist string `json:"TagBlacklist"` // 标签黑名单
31+
ProtocolWhitelist string `json:"ProtocolWhitelist"` // 协议白名单
32+
ProtocolBlacklist string `json:"ProtocolBlacklist"` // 协议黑名单
33+
NodeNameWhitelist string `json:"NodeNameWhitelist"` // 节点名称白名单
34+
NodeNameBlacklist string `json:"NodeNameBlacklist"` // 节点名称黑名单
35+
MaxFraudScore int `json:"MaxFraudScore"` // 最大欺诈评分
36+
OnlyResidential bool `json:"OnlyResidential"` // 仅住宅IP
37+
OnlyNative bool `json:"OnlyNative"` // 仅原生IP
38+
ResidentialType string `json:"ResidentialType"` // 住宅属性过滤
39+
IPType string `json:"IPType"` // IP类型过滤
40+
QualityStatus string `json:"QualityStatus"`
4041
NodeNamePreprocess string `json:"NodeNamePreprocess"` // 原名预处理规则
4142
NodeNameRule string `json:"NodeNameRule"` // 节点命名规则模板
4243
DeduplicationRule string `json:"DeduplicationRule"` // 去重规则配置
@@ -132,6 +133,8 @@ func previewSavedSubscription(subID int) (*models.PreviewResult, error) {
132133
IsBroadcast: node.IsBroadcast,
133134
IsResidential: node.IsResidential,
134135
FraudScore: node.FraudScore,
136+
QualityStatus: node.QualityStatus,
137+
QualityFamily: node.QualityFamily,
135138
})
136139
previewLink = utils.RenameNodeLink(node.Link, previewName)
137140
}
@@ -176,6 +179,7 @@ func previewFormSubscription(req PreviewRequest) (*models.PreviewResult, error)
176179
OnlyNative: req.OnlyNative,
177180
ResidentialType: req.ResidentialType,
178181
IPType: req.IPType,
182+
QualityStatus: req.QualityStatus,
179183
NodeNamePreprocess: req.NodeNamePreprocess,
180184
NodeNameRule: req.NodeNameRule,
181185
DeduplicationRule: req.DeduplicationRule,

api/sub.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ func normalizeSubscriptionIPType(value string) string {
3030
}
3131
}
3232

33+
func normalizeSubscriptionQualityStatus(value string) string {
34+
switch strings.ToLower(strings.TrimSpace(value)) {
35+
case models.QualityStatusUntested, models.QualityStatusSuccess, models.QualityStatusPartial, models.QualityStatusFailed, models.QualityStatusDisabled:
36+
return strings.ToLower(strings.TrimSpace(value))
37+
default:
38+
return ""
39+
}
40+
}
41+
3342
func SubTotal(c *gin.Context) {
3443
var Sub models.Subcription
3544
subs, err := Sub.List()
@@ -122,6 +131,7 @@ func SubAdd(c *gin.Context) {
122131
onlyNative := c.PostForm("OnlyNative") == "true"
123132
residentialType := normalizeSubscriptionResidentialType(c.PostForm("ResidentialType"))
124133
ipType := normalizeSubscriptionIPType(c.PostForm("IPType"))
134+
qualityStatus := normalizeSubscriptionQualityStatus(c.PostForm("QualityStatus"))
125135
if residentialType == "" && onlyResidential {
126136
residentialType = "residential"
127137
}
@@ -205,6 +215,7 @@ func SubAdd(c *gin.Context) {
205215
sub.OnlyNative = ipType == "native"
206216
sub.ResidentialType = residentialType
207217
sub.IPType = ipType
218+
sub.QualityStatus = qualityStatus
208219
sub.CreateDate = time.Now().Format("2006-01-02 15:04:05")
209220

210221
err := sub.Add()
@@ -291,6 +302,7 @@ func SubUpdate(c *gin.Context) {
291302
onlyNative := c.PostForm("OnlyNative") == "true"
292303
residentialType := normalizeSubscriptionResidentialType(c.PostForm("ResidentialType"))
293304
ipType := normalizeSubscriptionIPType(c.PostForm("IPType"))
305+
qualityStatus := normalizeSubscriptionQualityStatus(c.PostForm("QualityStatus"))
294306
if residentialType == "" && onlyResidential {
295307
residentialType = "residential"
296308
}
@@ -384,6 +396,7 @@ func SubUpdate(c *gin.Context) {
384396
sub.OnlyNative = ipType == "native"
385397
sub.ResidentialType = residentialType
386398
sub.IPType = ipType
399+
sub.QualityStatus = qualityStatus
387400
err = sub.Update()
388401
if err != nil {
389402
utils.FailWithMsg(c, "更新失败")

api/subscription_chain.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ func GetChainOptions(c *gin.Context) {
244244
{"value": "speed", "label": "速度 (MB/s)"},
245245
{"value": "delay_time", "label": "延迟 (ms)"},
246246
{"value": "fraud_score", "label": "欺诈评分"},
247+
{"value": "quality_status", "label": "质量状态"},
247248
{"value": "ip_type", "label": "IP类型"},
248249
{"value": "residential_type", "label": "住宅属性"},
249250
{"value": "speed_status", "label": "测速状态"},

models/db_migrate.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,29 @@ func RunMigrations() error {
8686
utils.Error("执行迁移 0027_add_user_pending_mfa_columns 失败: %v", err)
8787
}
8888

89+
if err := database.RunCustomMigration("0028_backfill_node_quality_status", func() error {
90+
if !db.Migrator().HasColumn(&Node{}, "QualityStatus") {
91+
if err := db.Migrator().AddColumn(&Node{}, "QualityStatus"); err != nil {
92+
return err
93+
}
94+
}
95+
if !db.Migrator().HasColumn(&Node{}, "QualityFamily") {
96+
if err := db.Migrator().AddColumn(&Node{}, "QualityFamily"); err != nil {
97+
return err
98+
}
99+
}
100+
101+
if err := db.Model(&Node{}).Where("quality_status IS NULL OR quality_status = ''").Updates(map[string]interface{}{
102+
"quality_status": gorm.Expr("CASE WHEN fraud_score >= 0 THEN 'success' ELSE 'untested' END"),
103+
"quality_family": gorm.Expr("CASE WHEN landing_ip LIKE '%:%' THEN 'ipv6' WHEN landing_ip IS NOT NULL AND landing_ip != '' THEN 'ipv4' ELSE '' END"),
104+
}).Error; err != nil {
105+
return err
106+
}
107+
return nil
108+
}); err != nil {
109+
utils.Error("执行迁移 0028_backfill_node_quality_status 失败: %v", err)
110+
}
111+
89112
if err := database.RunCustomMigration("0024_migrate_legacy_webhook_settings", func() error {
90113
legacyURL, _ := GetSetting("webhook_url")
91114
legacyMethod, _ := GetSetting("webhook_method")

models/node.go

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,21 @@ type Node struct {
4848
IsBroadcast bool `gorm:"default:false"` // IP来源:true=广播IP false=原生IP
4949
IsResidential bool `gorm:"default:false"` // 是否住宅IP
5050
FraudScore int `gorm:"default:-1"` // 欺诈评分(0-100,-1表示未检测)
51+
QualityStatus string `gorm:"size:32;default:'untested'"`
52+
QualityFamily string `gorm:"size:16;default:''"`
5153
}
5254

55+
const (
56+
QualityStatusUntested = "untested"
57+
QualityStatusSuccess = "success"
58+
QualityStatusPartial = "partial"
59+
QualityStatusFailed = "failed"
60+
QualityStatusDisabled = "disabled"
61+
62+
QualityFamilyIPv4 = "ipv4"
63+
QualityFamilyIPv6 = "ipv6"
64+
)
65+
5366
// nodeCache 使用新的泛型缓存,支持二级索引
5467
var nodeCache *cache.MapCache[int, Node]
5568

@@ -121,6 +134,23 @@ func NormalizeNodeForImport(node *Node) {
121134
if node.UpdatedAt.IsZero() {
122135
node.UpdatedAt = node.CreatedAt
123136
}
137+
138+
if node.QualityStatus == "" {
139+
switch {
140+
case node.FraudScore >= 0:
141+
node.QualityStatus = QualityStatusSuccess
142+
case node.FraudScore < 0:
143+
node.QualityStatus = QualityStatusUntested
144+
}
145+
}
146+
147+
if node.QualityFamily == "" && node.LandingIP != "" {
148+
if strings.Contains(node.LandingIP, ":") {
149+
node.QualityFamily = QualityFamilyIPv6
150+
} else {
151+
node.QualityFamily = QualityFamilyIPv4
152+
}
153+
}
124154
}
125155

126156
// InitNodeCache 初始化节点缓存
@@ -198,7 +228,7 @@ func (node *Node) Update() error {
198228

199229
// UpdateSpeed 更新节点测速结果
200230
func (node *Node) UpdateSpeed() error {
201-
err := database.DB.Model(node).Select("Speed", "SpeedStatus", "LinkCountry", "LandingIP", "DelayTime", "DelayStatus", "LatencyCheckAt", "SpeedCheckAt", "IsBroadcast", "IsResidential", "FraudScore").Updates(node).Error
231+
err := database.DB.Model(node).Select("Speed", "SpeedStatus", "LinkCountry", "LandingIP", "DelayTime", "DelayStatus", "LatencyCheckAt", "SpeedCheckAt", "IsBroadcast", "IsResidential", "FraudScore", "QualityStatus", "QualityFamily").Updates(node).Error
202232
if err != nil {
203233
return err
204234
}
@@ -215,6 +245,8 @@ func (node *Node) UpdateSpeed() error {
215245
cachedNode.IsBroadcast = node.IsBroadcast
216246
cachedNode.IsResidential = node.IsResidential
217247
cachedNode.FraudScore = node.FraudScore
248+
cachedNode.QualityStatus = node.QualityStatus
249+
cachedNode.QualityFamily = node.QualityFamily
218250
nodeCache.Set(node.ID, cachedNode)
219251
}
220252
return nil
@@ -235,6 +267,8 @@ type SpeedTestResult struct {
235267
IsBroadcast bool // IP来源:true=广播IP
236268
IsResidential bool // 是否住宅IP
237269
FraudScore int // 欺诈评分(0-100,-1=未检测)
270+
QualityStatus string
271+
QualityFamily string
238272
}
239273

240274
// BatchAddNodes 批量添加节点(高效 + 容错)
@@ -400,6 +434,8 @@ var speedResultFields = []speedResultField{
400434
return "0"
401435
}},
402436
{"fraud_score", func(r SpeedTestResult) string { return fmt.Sprintf("%d", r.FraudScore) }},
437+
{"quality_status", func(r SpeedTestResult) string { return fmt.Sprintf("'%s'", escapeSQL(r.QualityStatus)) }},
438+
{"quality_family", func(r SpeedTestResult) string { return fmt.Sprintf("'%s'", escapeSQL(r.QualityFamily)) }},
403439
}
404440

405441
// tryBatchUpdateWithCaseWhen 使用 CASE WHEN 批量更新(高效)
@@ -475,6 +511,8 @@ func batchUpdateNodeCache(chunk []SpeedTestResult, skipSpeed bool) {
475511
cachedNode.IsBroadcast = r.IsBroadcast
476512
cachedNode.IsResidential = r.IsResidential
477513
cachedNode.FraudScore = r.FraudScore
514+
cachedNode.QualityStatus = r.QualityStatus
515+
cachedNode.QualityFamily = r.QualityFamily
478516
nodeCache.Set(r.NodeID, cachedNode)
479517
}
480518
}
@@ -494,6 +532,8 @@ func fallbackToIndividualSpeedUpdate(chunk []SpeedTestResult, skipSpeed bool) in
494532
"is_broadcast": r.IsBroadcast,
495533
"is_residential": r.IsResidential,
496534
"fraud_score": r.FraudScore,
535+
"quality_status": r.QualityStatus,
536+
"quality_family": r.QualityFamily,
497537
}
498538
if !skipSpeed {
499539
updates["speed"] = r.Speed
@@ -524,6 +564,8 @@ func fallbackToIndividualSpeedUpdate(chunk []SpeedTestResult, skipSpeed bool) in
524564
cachedNode.IsBroadcast = r.IsBroadcast
525565
cachedNode.IsResidential = r.IsResidential
526566
cachedNode.FraudScore = r.FraudScore
567+
cachedNode.QualityStatus = r.QualityStatus
568+
cachedNode.QualityFamily = r.QualityFamily
527569
nodeCache.Set(r.NodeID, cachedNode)
528570
}
529571
}
@@ -705,10 +747,21 @@ type NodeFilter struct {
705747
MaxFraudScore int // 最大欺诈评分(0=不限制)
706748
ResidentialType string // 住宅属性过滤: residential/datacenter/untested
707749
IPType string // IP类型过滤: native/broadcast/untested
750+
QualityStatus string
708751
}
709752

710753
func hasNodeQualityData(n Node) bool {
711-
return n.FraudScore >= 0
754+
return n.QualityStatus == QualityStatusSuccess
755+
}
756+
757+
func getNodeQualityStatusValue(n Node) string {
758+
if n.QualityStatus != "" {
759+
return n.QualityStatus
760+
}
761+
if n.FraudScore >= 0 {
762+
return QualityStatusSuccess
763+
}
764+
return QualityStatusUntested
712765
}
713766

714767
func getNodeResidentialTypeValue(n Node) string {
@@ -761,6 +814,17 @@ func matchNodeIPType(n Node, ipType string) bool {
761814
}
762815
}
763816

817+
func matchNodeQualityStatus(n Node, qualityStatus string) bool {
818+
switch qualityStatus {
819+
case "", "all":
820+
return true
821+
case QualityStatusUntested, QualityStatusSuccess, QualityStatusPartial, QualityStatusFailed, QualityStatusDisabled:
822+
return getNodeQualityStatusValue(n) == qualityStatus
823+
default:
824+
return true
825+
}
826+
}
827+
764828
// ListWithFilters 根据过滤条件获取节点列表
765829
func (node *Node) ListWithFilters(filter NodeFilter) ([]Node, error) {
766830
// 预处理搜索关键词
@@ -877,11 +941,15 @@ func (node *Node) ListWithFilters(filter NodeFilter) ([]Node, error) {
877941

878942
// 最大欺诈评分过滤
879943
if filter.MaxFraudScore > 0 {
880-
if n.FraudScore < 0 || n.FraudScore > filter.MaxFraudScore {
944+
if getNodeQualityStatusValue(n) != QualityStatusSuccess || n.FraudScore < 0 || n.FraudScore > filter.MaxFraudScore {
881945
return false
882946
}
883947
}
884948

949+
if !matchNodeQualityStatus(n, filter.QualityStatus) {
950+
return false
951+
}
952+
885953
// 住宅属性过滤
886954
if !matchNodeResidentialType(n, filter.ResidentialType) {
887955
return false

0 commit comments

Comments
 (0)