Files
杨豪 5a34e739bb
Build and Publish Docker Image / build-and-push (pull_request) Successful in 15m54s
feat: HH-773 合集重命名接入 egress geo 缓存、geo 失败降级与每小时补探测
- service: EgressCacheKey 从 handler 下沉复用;MergeCachedEgressGeo 在合集
  重命名前合并缓存 geo 字段(纯缓存读、零网络 I/O);StripEgressGeoFields
  防止 geo 字段泄漏进订阅输出
- filter: geo 检测失败的节点保留 '[别名] 原名'(或纯原名)参与编号/排序,
  不再丢弃
- handler: 每小时 StartEgressRefresher 对启用源补探测缺失/过期的 egress
  缓存(含 TTL 内 error 结果一律跳过);RegisterRoutes 返回 *Deps;
  cmd/server.go 启动定时任务并在 shutdown 时取消
- tests: service 缓存 key 稳定性/合并/管道测试、handler 补探测跳过/TTL/
  过期/取消测试、filter 降级测试;修复 2 个过时测试(mihomo YAML 配置、
  缓存命中路径)
2026-08-28 16:40:11 +08:00

173 lines
7.3 KiB
Go

package service
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
"github.com/peterqiu0516/sub-store/internal/database"
"github.com/peterqiu0516/sub-store/internal/model"
)
func newTestCacheRepo(t *testing.T) *database.CacheRepo {
t.Helper()
dir := t.TempDir()
db, err := sqlx.Open("sqlite", filepath.Join(dir, "test.db")+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)")
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := database.RunMigrations(db); err != nil {
t.Fatalf("failed to run migrations: %v", err)
}
return database.NewCacheRepo(db)
}
const usGeoJson = `{"egressIp":"67.215.229.50","country":"United States","countryCode":"US","region":"California","city":"Los Angeles","flag":"🇺🇸"}`
// realityVlessContent mirrors the production self-built source: two nodes
// whose names carry geo hints, one (racknerd-la) that only the egress cache
// can resolve.
var realityVlessContent = strings.Join([]string{
"vless://00000000-0000-0000-0000-000000000001@8.220.220.41:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=dl.google.com&fp=chrome&pbk=public-key-1&sid=short-id-1&spx=%2F&type=tcp&headerType=none#ali-seoul",
"vless://00000000-0000-0000-0000-000000000002@8.216.16.28:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.yahoo.co.jp&fp=chrome&pbk=public-key-2&sid=short-id-2&spx=%2F&type=tcp&headerType=none#Ali-Tokyo",
"vless://00000000-0000-0000-0000-000000000003@67.215.229.50:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.cloudflare.com&fp=chrome&pbk=public-key-3&sid=short-id-3&spx=%2F&type=tcp&headerType=none#racknerd-la",
}, "\n")
func TestEgressCacheKey_IgnoresVolatileFields(t *testing.T) {
base := model.ProxyNode{"type": "vless", "server": "1.2.3.4", "port": 443, "uuid": "u"}
decorated := model.ProxyNode{}
for k, v := range base {
decorated[k] = v
}
for k, v := range map[string]any{
"name": "renamed", "id": "p1", "_sourceAlias": "自建", "_previewId": "x",
"latencyMs": 12.0, "country": "US", "countryCode": "US", "city": "LA",
"region": "CA", "flag": "🇺🇸", "cached": true, "egressError": "e", "egressIp": "1.1.1.1",
} {
decorated[k] = v
}
if EgressCacheKey(base) != EgressCacheKey(decorated) {
t.Error("cache key must ignore volatile/geo decoration fields")
}
// int vs float64 port marshal identically — key is stable across
// pipeline stages and JSON round-trips.
asFloat := model.ProxyNode{"type": "vless", "server": "1.2.3.4", "port": float64(443), "uuid": "u"}
if EgressCacheKey(base) != EgressCacheKey(asFloat) {
t.Error("cache key must be stable across int/float64 port representations")
}
changed := model.ProxyNode{"type": "vless", "server": "1.2.3.4", "port": 8443, "uuid": "u"}
if EgressCacheKey(base) == EgressCacheKey(changed) {
t.Error("cache key must change when connection fields change")
}
}
func TestMergeCachedEgressGeo(t *testing.T) {
repo := newTestCacheRepo(t)
node := model.ProxyNode{"type": "vless", "name": "n1", "server": "1.2.3.4", "port": 443, "uuid": "u"}
repo.SafePut(EgressCacheKey(node), usGeoJson, nil, 300)
merged := MergeCachedEgressGeo(repo, []model.ProxyNode{node})
if merged[0]["country"] != "United States" || merged[0]["countryCode"] != "US" || merged[0]["flag"] != "🇺🇸" {
t.Fatalf("geo fields not merged: %v", merged[0])
}
if _, ok := merged[0]["cached"]; ok {
t.Error("pipeline merge must not set the cached marker")
}
// Cache miss leaves the node untouched.
miss := model.ProxyNode{"type": "vless", "name": "n2", "server": "5.6.7.8", "port": 443, "uuid": "u"}
out := MergeCachedEgressGeo(repo, []model.ProxyNode{miss})
if _, ok := out[0]["country"]; ok {
t.Error("cache miss must not add geo fields")
}
}
func TestBuildSubscriptionResult_CollectionRenameMergesEgressCache(t *testing.T) {
repo := newTestCacheRepo(t)
src := model.SourceRecord{ID: "self", Name: "self", Alias: "自建", Type: "local", Content: realityVlessContent, Enabled: true}
collection := &model.CollectionRecord{
ID: "daily",
Name: "daily",
SourceIds: []string{"self"},
RenameEnabled: true,
// Production "daily" options: flag + alias + country + index, city off.
RenameOptions: &model.RenameOptions{Flag: true, Alias: true, Country: true, City: false, Index: true},
Enabled: true,
}
// Step 1: build without rename to obtain the parsed nodes and seed the
// egress cache for racknerd-la (the node name regex cannot resolve).
plain := &model.CollectionRecord{ID: "daily", Name: "daily", SourceIds: []string{"self"}, Enabled: true}
base, err := BuildSubscriptionResult(context.Background(), BuildOptions{Collection: plain, Sources: []model.SourceRecord{src}, Target: "json"})
if err != nil {
t.Fatalf("base build failed: %v", err)
}
var payload struct {
Proxies []model.ProxyNode `json:"proxies"`
}
if err := json.Unmarshal([]byte(base.Body), &payload); err != nil {
t.Fatalf("parse base body: %v", err)
}
seeded := 0
for _, n := range payload.Proxies {
if n["name"] == "racknerd-la" {
repo.SafePut(EgressCacheKey(n), usGeoJson, nil, 300)
seeded++
}
}
if seeded != 1 {
t.Fatalf("expected to seed exactly one racknerd-la node, seeded %d", seeded)
}
// Step 2: renamed collection build must read the cache (no probing) and
// keep all three nodes.
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{
Collection: collection, Sources: []model.SourceRecord{src}, Target: "json", CacheRepo: repo,
})
if err != nil {
t.Fatalf("rename build failed: %v", err)
}
if result.Nodes != 3 {
t.Fatalf("expected 3 nodes, got %d: %s", result.Nodes, result.Body)
}
if !strings.Contains(result.Body, "🇺🇸 [自建] 美国") {
t.Errorf("racknerd-la should render as %q via cached geo, body: %s", "🇺🇸 [自建] 美国", result.Body)
}
if !strings.Contains(result.Body, "🇰🇷 [自建] 韩国") {
t.Errorf("ali-seoul should keep name-based geo, body: %s", result.Body)
}
// Merged geo fields must not leak into the subscription output.
if strings.Contains(result.Body, `"countryCode"`) || strings.Contains(result.Body, `"egressIp"`) {
t.Errorf("merged cache fields leaked into output: %s", result.Body)
}
}
// Without a cache repo the rename pipeline still works via name fallback and
// geo-unknown nodes are kept under their original (alias-prefixed) names.
func TestBuildSubscriptionResult_RenameKeepsGeoUnknownNodes(t *testing.T) {
src := model.SourceRecord{ID: "self", Name: "self", Alias: "自建", Type: "local", Content: realityVlessContent, Enabled: true}
collection := &model.CollectionRecord{
ID: "daily", Name: "daily", SourceIds: []string{"self"}, RenameEnabled: true,
RenameOptions: &model.RenameOptions{Flag: true, Alias: true, Country: true, City: false, Index: true},
Enabled: true,
}
result, err := BuildSubscriptionResult(context.Background(), BuildOptions{Collection: collection, Sources: []model.SourceRecord{src}, Target: "json"})
if err != nil {
t.Fatalf("build failed: %v", err)
}
if result.Nodes != 3 {
t.Fatalf("expected all 3 nodes kept, got %d: %s", result.Nodes, result.Body)
}
if !strings.Contains(result.Body, "[自建] racknerd-la") {
t.Errorf("geo-unknown node should fall back to alias + original name, body: %s", result.Body)
}
}