- 新增 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 前已因此丢失一批分站注册归属) - 新增单测:中间件(显式优先、域名解析、本地地址跳过、转发域兜底、缓存与容错)与仓储域名查询边界(大小写/空白/主站域名/停用站点)
133 lines
3.7 KiB
Go
133 lines
3.7 KiB
Go
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
|
|
}
|