feat(distribution): 站点归属兜底——未带站点编码时按访问域名识别分站
- 新增 middleware.SiteDomainFallback:显式头/query 优先,两者都为空时用 Request.Host / X-Forwarded-Host 反查 distribution_sites.domain,并回填 X-Distribution-Site-Code 供后续 sitecode.FromRequest 使用 - 新增 distribution.Repository.ResolveSiteCodeByDomain(仅取 status=active 的站点) - 路由层在所有业务路由之前注册;域名到站点映射走 1 分钟进程内缓存(含负缓存);查库异常不阻断请求、退化为按主站处理;回环/本地地址跳过查询 - 背景:站点归属此前完全依赖前端注入头,首屏探测失败或漏带就会把分站写入记成主站(2026-10-10 前已因此丢失一批分站注册归属) - 新增单测:中间件(显式优先、域名解析、本地地址跳过、转发域兜底、缓存与容错)与仓储域名查询边界(大小写/空白/主站域名/停用站点)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user