diff --git a/backend/internal/middleware/site_domain_fallback.go b/backend/internal/middleware/site_domain_fallback.go new file mode 100644 index 0000000..e9110f6 --- /dev/null +++ b/backend/internal/middleware/site_domain_fallback.go @@ -0,0 +1,132 @@ +package middleware + +import ( + "context" + "strings" + "sync" + "time" + + "hfb_sys/backend/internal/sitecode" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// SiteDomainResolver 按访问域名解析启用中的分销站点编码;无匹配返回空串。 +type SiteDomainResolver interface { + ResolveSiteCodeByDomain(ctx context.Context, domain string) (string, error) +} + +// siteDomainCacheTTL 域名→站点编码的进程内缓存时长。 +// 站点域名极少变更,1 分钟足以吸收首屏探测与脚本产生的无站点头请求, +// 又不会让改绑后的域名长时间解析到旧站点。 +const siteDomainCacheTTL = time.Minute + +type siteDomainCacheEntry struct { + code string + expiresAt time.Time +} + +type siteDomainCache struct { + mu sync.RWMutex + items map[string]siteDomainCacheEntry +} + +func newSiteDomainCache() *siteDomainCache { + return &siteDomainCache{items: make(map[string]siteDomainCacheEntry)} +} + +func (c *siteDomainCache) get(host string) (string, bool) { + c.mu.RLock() + entry, ok := c.items[host] + c.mu.RUnlock() + if !ok || time.Now().After(entry.expiresAt) { + return "", false + } + return entry.code, true +} + +func (c *siteDomainCache) set(host, code string) { + c.mu.Lock() + c.items[host] = siteDomainCacheEntry{code: code, expiresAt: time.Now().Add(siteDomainCacheTTL)} + c.mu.Unlock() +} + +// SiteDomainFallback 在请求未显式携带站点编码时,按访问域名兜底识别分销站点, +// 并把结果回填到权威请求头,使后续所有 sitecode.FromRequest 调用自动生效。 +// +// 背景:站点归属此前完全依赖前端注入的 X-Distribution-Site-Code 头, +// 首屏探测失败或漏带就会把分站写入当成主站(2026-10-10 之前已因此丢过一批注册归属)。 +// 显式头/query 始终优先,本中间件只在两者都为空时兜底;数据库异常时不阻断请求, +// 退化为原有行为(视为主站)。 +func SiteDomainFallback(resolver SiteDomainResolver, logger *zap.Logger) gin.HandlerFunc { + if resolver == nil { + return func(c *gin.Context) { c.Next() } + } + cache := newSiteDomainCache() + return func(c *gin.Context) { + if sitecode.FromRequest(c) != "" { + c.Next() + return + } + if code := resolveSiteCodeByHost(c, resolver, cache, logger); code != "" { + c.Request.Header.Set(sitecode.HeaderName, code) + } + c.Next() + } +} + +func resolveSiteCodeByHost(c *gin.Context, resolver SiteDomainResolver, cache *siteDomainCache, logger *zap.Logger) string { + for _, host := range candidateHosts(c) { + if code, ok := cache.get(host); ok { + if code != "" { + return code + } + continue + } + code, err := resolver.ResolveSiteCodeByDomain(c.Request.Context(), host) + if err != nil { + // 记入缓存,避免数据库故障时每个请求都打一次日志与查询。 + cache.set(host, "") + if logger != nil { + logger.Warn("按域名兜底解析分销站点失败,已按未指定站点处理", + zap.String("host", host), + zap.Error(err), + ) + } + continue + } + cache.set(host, code) + if code != "" { + return code + } + } + return "" +} + +// candidateHosts 返回可能承载站点域名的候选主机名(去空、去本地地址、去重)。 +// 依次尝试请求 Host 与反代透传的 X-Forwarded-Host。 +func candidateHosts(c *gin.Context) []string { + raw := []string{c.Request.Host} + for _, value := range strings.Split(c.GetHeader("X-Forwarded-Host"), ",") { + raw = append(raw, value) + } + hosts := make([]string, 0, len(raw)) + for _, item := range raw { + host := sitecode.ExtractHost(item) + if host == "" || sitecode.IsLocalHost(host) { + continue + } + duplicated := false + for _, existing := range hosts { + if existing == host { + duplicated = true + break + } + } + if !duplicated { + hosts = append(hosts, host) + } + } + return hosts +} diff --git a/backend/internal/middleware/site_domain_fallback_test.go b/backend/internal/middleware/site_domain_fallback_test.go new file mode 100644 index 0000000..3a6e863 --- /dev/null +++ b/backend/internal/middleware/site_domain_fallback_test.go @@ -0,0 +1,153 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "hfb_sys/backend/internal/sitecode" + + "github.com/gin-gonic/gin" +) + +type fakeSiteDomainResolver struct { + byDomain map[string]string + err error + calls int + hosts []string +} + +func (f *fakeSiteDomainResolver) ResolveSiteCodeByDomain(_ context.Context, domain string) (string, error) { + f.calls++ + f.hosts = append(f.hosts, domain) + if f.err != nil { + return "", f.err + } + return f.byDomain[domain], nil +} + +// newSiteFallbackRouter 构建一个把 sitecode.FromRequest 结果回显的路由,便于断言兜底结果。 +func newSiteFallbackRouter(resolver SiteDomainResolver) *gin.Engine { + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.Use(SiteDomainFallback(resolver, nil)) + engine.GET("/probe", func(c *gin.Context) { + c.String(http.StatusOK, sitecode.FromRequest(c)) + }) + return engine +} + +func doSiteFallbackRequest(t *testing.T, engine *gin.Engine, host, target string, headers map[string]string) string { + t.Helper() + req := httptest.NewRequest(http.MethodGet, target, nil) + req.Host = host + for key, value := range headers { + req.Header.Set(key, value) + } + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", recorder.Code) + } + return recorder.Body.String() +} + +func TestSiteDomainFallbackResolvesByHost(t *testing.T) { + resolver := &fakeSiteDomainResolver{byDomain: map[string]string{"tysh.hao858.com": "gz_dist"}} + engine := newSiteFallbackRouter(resolver) + + if got := doSiteFallbackRequest(t, engine, "tysh.hao858.com", "/probe", nil); got != "gz_dist" { + t.Fatalf("站点域名应解析为 gz_dist, got %q", got) + } + // 带端口的 Host 也要能解析 + if got := doSiteFallbackRequest(t, engine, "tysh.hao858.com:443", "/probe", nil); got != "gz_dist" { + t.Fatalf("带端口站点域名应解析为 gz_dist, got %q", got) + } + // 主站域名无匹配:保持为空 + if got := doSiteFallbackRequest(t, engine, "hao858.com", "/probe", nil); got != "" { + t.Fatalf("主站域名不应被识别为分站, got %q", got) + } +} + +func TestSiteDomainFallbackPrefersExplicitSiteCode(t *testing.T) { + resolver := &fakeSiteDomainResolver{byDomain: map[string]string{"tysh.hao858.com": "gz_dist"}} + engine := newSiteFallbackRouter(resolver) + + // 显式头优先:即使域名能解析出 gz_dist,也必须尊重前端给的站点 + cases := []struct { + name string + target string + headers map[string]string + }{ + {name: "权威头", target: "/probe", headers: map[string]string{sitecode.HeaderName: "other_site"}}, + {name: "旧头", target: "/probe", headers: map[string]string{sitecode.LegacyHeaderName: "legacy_site"}}, + {name: "query", target: "/probe?site_code=query_site", headers: nil}, + {name: "旧query", target: "/probe?distribution_site_code=legacy_query", headers: nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := doSiteFallbackRequest(t, engine, "tysh.hao858.com", tc.target, tc.headers) + if got == "gz_dist" || got == "" { + t.Fatalf("显式站点应优先于域名兜底, got %q", got) + } + }) + } + if resolver.calls != 0 { + t.Fatalf("显式站点不应触发域名解析, calls = %d", resolver.calls) + } +} + +func TestSiteDomainFallbackSkipsLocalAndIPHosts(t *testing.T) { + resolver := &fakeSiteDomainResolver{byDomain: map[string]string{}} + engine := newSiteFallbackRouter(resolver) + + for _, host := range []string{"localhost:8080", "127.0.0.1:18088", "10.0.0.5:8080"} { + if got := doSiteFallbackRequest(t, engine, host, "/probe", nil); got != "" { + t.Fatalf("本地/内网地址 %s 不应解析站点, got %q", host, got) + } + } + // 本地地址直接跳过;10.0.0.5 不是 IsLocalHost 白名单,会查一次但无匹配 + if resolver.calls > 1 { + t.Fatalf("本地地址不应反复查询, calls = %d", resolver.calls) + } +} + +func TestSiteDomainFallbackFallsBackToForwardedHost(t *testing.T) { + resolver := &fakeSiteDomainResolver{byDomain: map[string]string{"tysh.hao858.com": "gz_dist"}} + engine := newSiteFallbackRouter(resolver) + + got := doSiteFallbackRequest(t, engine, "hao858.com", "/probe", map[string]string{ + "X-Forwarded-Host": "tysh.hao858.com", + }) + if got != "gz_dist" { + t.Fatalf("应回退到 X-Forwarded-Host 解析站点, got %q", got) + } +} + +func TestSiteDomainFallbackCachesAndFailsOpen(t *testing.T) { + resolver := &fakeSiteDomainResolver{byDomain: map[string]string{"tysh.hao858.com": "gz_dist"}} + engine := newSiteFallbackRouter(resolver) + + for i := 0; i < 3; i++ { + if got := doSiteFallbackRequest(t, engine, "tysh.hao858.com", "/probe", nil); got != "gz_dist" { + t.Fatalf("第 %d 次解析 = %q, want gz_dist", i+1, got) + } + } + if resolver.calls != 1 { + t.Fatalf("同域名应命中缓存只查一次, calls = %d", resolver.calls) + } + + // 解析失败时不阻断请求,退化为未指定站点 + failing := &fakeSiteDomainResolver{err: errors.New("db down")} + failEngine := newSiteFallbackRouter(failing) + for i := 0; i < 3; i++ { + if got := doSiteFallbackRequest(t, failEngine, "tysh.hao858.com", "/probe", nil); got != "" { + t.Fatalf("解析失败应退化为空站点, got %q", got) + } + } + if failing.calls != 1 { + t.Fatalf("解析失败也应缓存避免刷日志, calls = %d", failing.calls) + } +} diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index d08b78a..91400cb 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -2679,6 +2679,34 @@ func (r *Repository) evictSiteInfoCache(ctx context.Context, site *model.Distrib } } +// ResolveSiteCodeByDomain 按访问域名解析启用中的分销站点编码,找不到返回空串。 +// +// 用途:站点归属兜底。前端(SPA)会显式带 X-Distribution-Site-Code 头, +// 但首屏探测、脚本调用、分享链接直达等场景可能没带;此时按请求域名判定站点, +// 避免注册等写入把分站用户记成主站(历史上就丢过一批)。 +func (r *Repository) ResolveSiteCodeByDomain(ctx context.Context, domain string) (string, error) { + if r == nil || r.db == nil { + return "", ErrDependencyUnavailable + } + domain = strings.ToLower(strings.TrimSpace(domain)) + if domain == "" { + return "", nil + } + var site model.DistributionSite + err := r.db.WithContext(ctx). + Select("site_code"). + Where("status = 'active' AND domain = ?", domain). + Order("id ASC"). + First(&site).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + return "", err + } + return site.SiteCode, nil +} + func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string) (*PublicSiteInfoDTO, error) { if r.db == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index a9d7630..0a0f444 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -42,6 +42,45 @@ func distributionTestDB(t *testing.T) *gorm.DB { return db } +// 站点归属兜底依赖「按访问域名反查站点编码」,这里覆盖大小写/空白、主站域名、停用站点等边界。 +func TestRepositoryResolveSiteCodeByDomain(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + if err := db.Create(&model.DistributionSite{ + SiteCode: "gz_dist", SiteName: "天域商行", Domain: "tysh.hao858.com", Status: "active", TaxRateBPS: 100, + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.DistributionSite{ + SiteCode: "down_site", SiteName: "停用站", Domain: "down.hao858.com", Status: "disabled", TaxRateBPS: 100, + }).Error; err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + domain string + want string + }{ + {name: "命中并统一小写", domain: "TYSH.HAO858.COM", want: "gz_dist"}, + {name: "去除首尾空白", domain: " tysh.hao858.com ", want: "gz_dist"}, + {name: "主站域名无匹配", domain: "hao858.com", want: ""}, + {name: "停用站点不解析", domain: "down.hao858.com", want: ""}, + {name: "空域名", domain: " ", want: ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := repo.ResolveSiteCodeByDomain(t.Context(), tc.domain) + if err != nil { + t.Fatalf("ResolveSiteCodeByDomain(%q) error = %v", tc.domain, err) + } + if got != tc.want { + t.Fatalf("ResolveSiteCodeByDomain(%q) = %q, want %q", tc.domain, got, tc.want) + } + }) + } +} + func TestDistributionOrderSnapshotAndQueries(t *testing.T) { db := distributionTestDB(t) repo := NewRepository(db) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 72fe339..1512809 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -358,6 +358,11 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { authService.SetSiteSMSResolver(distributionService) } } + // 站点归属兜底:请求未携带站点编码时按访问域名判定站点(前端首屏探测/漏带场景), + // 避免注册等写入把分站用户记成主站。必须注册在任何业务路由之前。 + if distributionRepo != nil { + engine.Use(middleware.SiteDomainFallback(distributionRepo, logger)) + } distributionHandler := distribution.NewHandler(distributionService) if invitationHandler != nil && distributionService != nil { invitationHandler.SetSiteScoper(distributionService)