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 }