Files
hfb_sys/backend/internal/middleware/site_domain_fallback.go
T
yml2213 2e13443805 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 前已因此丢失一批分站注册归属)
- 新增单测:中间件(显式优先、域名解析、本地地址跳过、转发域兜底、缓存与容错)与仓储域名查询边界(大小写/空白/主站域名/停用站点)
2026-10-13 17:33:16 +08:00

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
}