refactor(sitecode): 统一各模块分销站点编码解析为公共包

- 新增 internal/sitecode 包:FromRequest 头优先解析、FromQueryThenHeader
  探测接口专用、ExtractHost/IsLocalHost 提取 Host 域名
- 替换 auth/order/listing/chat/notification/withdrawal/wallet/distribution
  八模块 17 处各自内联的解析逻辑,统一优先级与 TrimSpace 行为
- 修复 notification 漏 TrimSpace、探测接口会话头压过显式 query、
  GetPublicSiteInfo 把 localhost Host 当站点域名查库等不一致
- 顺手清理 auth 中无引用的 extractHost 死代码
- gofmt 修复 withdrawal/presenter、chat/file/payment 测试四个历史遗留格式问题
This commit is contained in:
yml2213
2026-09-17 14:54:38 +08:00
parent 69774458b0
commit 8dc21d2757
16 changed files with 236 additions and 119 deletions
+79
View File
@@ -0,0 +1,79 @@
// Package sitecode 统一解析请求中的分销站点编码。
//
// 此前各业务模块(auth/order/wallet/chat/notification/withdrawal/listing/distribution)
// 各自内联解析,优先级和 TrimSpace 处理不一致,且对旧头 X-Site-Code 的支持参差不齐。
// 本包收敛为两个入口:
//
// - FromRequest:常规业务接口使用。SPA 前端(frontend/src/shared/api/client.ts)
// 会为每个请求统一注入 X-Distribution-Site-Code 头,因此头是权威来源;
// query 参数与旧头仅作为非 SPA 调用(脚本、排障)的兜底。
// - FromQueryThenHeader:仅用于 GET /distribution/site-info 站点探测。
// SPA 初始化探测新站点时会话头尚未建立,此时显式 query 参数必须压过会话头。
package sitecode
import (
"net"
"strings"
"github.com/gin-gonic/gin"
)
// HeaderName 是前端统一注入的权威站点编码请求头。
const HeaderName = "X-Distribution-Site-Code"
// LegacyHeaderName 是历史版本使用的站点编码请求头,仅作兜底兼容。
const LegacyHeaderName = "X-Site-Code"
// FromRequest 按权威优先级解析站点编码:
// X-Distribution-Site-Code 头 → X-Site-Code 旧头 → distribution_site_code / site_code query。
// 所有来源均做 TrimSpace,返回空串表示主站。
func FromRequest(c *gin.Context) string {
return firstNonEmpty(
c.GetHeader(HeaderName),
c.GetHeader(LegacyHeaderName),
c.Query("distribution_site_code"),
c.Query("site_code"),
)
}
// FromQueryThenHeader 仅用于站点探测接口(GET /distribution/site-info):
// 显式 query 参数优先于会话头。SPA 探测新站点时会话头里还是上一个站点的编码,
// 必须让 query 压过它,否则从主站跳转到分站时会探测到错误站点。
func FromQueryThenHeader(c *gin.Context) string {
return firstNonEmpty(
c.Query("code"),
c.Query("site_code"),
c.Query("distribution_site_code"),
c.GetHeader(HeaderName),
c.GetHeader(LegacyHeaderName),
)
}
// ExtractHost 从 HTTP Host(可能带端口、IPv6 方括号)中提取纯主机名,小写化。
func ExtractHost(rawHost string) string {
rawHost = strings.TrimSpace(rawHost)
if rawHost == "" {
return ""
}
var host string
if h, _, err := net.SplitHostPort(rawHost); err == nil {
host = h
} else {
host = rawHost
}
return strings.ToLower(strings.Trim(host, "[]"))
}
// IsLocalHost 判断主机名是否属于本地开发地址(不作为站点域名解析)。
func IsLocalHost(host string) bool {
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if trimmed := strings.TrimSpace(v); trimmed != "" {
return trimmed
}
}
return ""
}
+100
View File
@@ -0,0 +1,100 @@
package sitecode
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func newTestContext(method, target string, headers map[string]string) *gin.Context {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest(method, target, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
c.Request = req
return c
}
func TestFromRequest(t *testing.T) {
cases := []struct {
name string
target string
header map[string]string
want string
}{
{name: "仅头", target: "/api/listings", header: map[string]string{HeaderName: "site_a"}, want: "site_a"},
{name: "旧头兜底", target: "/api/wallet/balance", header: map[string]string{LegacyHeaderName: "site_b"}, want: "site_b"},
{name: "query兜底", target: "/api/wallet/balance?distribution_site_code=site_c", header: nil, want: "site_c"},
{name: "短query兜底", target: "/api/orders?site_code=site_d", header: nil, want: "site_d"},
{name: "头优先于query", target: "/api/orders?site_code=query_site", header: map[string]string{HeaderName: "header_site"}, want: "header_site"},
{name: "权威头优先于旧头", target: "/api/chats", header: map[string]string{HeaderName: "new_site", LegacyHeaderName: "old_site"}, want: "new_site"},
{name: "空白字符修剪", target: "/api/orders?site_code=%20%20site_e%20%20", header: nil, want: "site_e"},
{name: "主站为空", target: "/api/orders", header: nil, want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := FromRequest(newTestContext("GET", tc.target, tc.header))
if got != tc.want {
t.Fatalf("FromRequest() = %q, want %q", got, tc.want)
}
})
}
}
func TestFromQueryThenHeader(t *testing.T) {
cases := []struct {
name string
target string
header map[string]string
want string
}{
{name: "code参数优先", target: "/distribution/site-info?code=probe_site", header: map[string]string{HeaderName: "session_site"}, want: "probe_site"},
{name: "site_code参数优先", target: "/distribution/site-info?site_code=probe2", header: map[string]string{HeaderName: "session"}, want: "probe2"},
{name: "无query时回落头", target: "/distribution/site-info", header: map[string]string{HeaderName: "session"}, want: "session"},
{name: "全空", target: "/distribution/site-info", header: nil, want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := FromQueryThenHeader(newTestContext("GET", tc.target, tc.header))
if got != tc.want {
t.Fatalf("FromQueryThenHeader() = %q, want %q", got, tc.want)
}
})
}
}
func TestExtractHost(t *testing.T) {
cases := []struct {
raw string
want string
}{
{raw: "shop.example.com:8080", want: "shop.example.com"},
{raw: "shop.example.com", want: "shop.example.com"},
{raw: "Shop.Example.COM", want: "shop.example.com"},
{raw: "[::1]:5189", want: "::1"},
{raw: " shop.example.com ", want: "shop.example.com"},
{raw: "", want: ""},
}
for _, tc := range cases {
if got := ExtractHost(tc.raw); got != tc.want {
t.Errorf("ExtractHost(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
func TestIsLocalHost(t *testing.T) {
for _, local := range []string{"localhost", "127.0.0.1", "::1"} {
if !IsLocalHost(local) {
t.Errorf("IsLocalHost(%q) 应为 true", local)
}
}
for _, remote := range []string{"shop.example.com", "0.0.0.0", ""} {
if IsLocalHost(remote) {
t.Errorf("IsLocalHost(%q) 应为 false", remote)
}
}
}