diff --git a/backend/internal/modules/auth/handler.go b/backend/internal/modules/auth/handler.go index 068e12e..e324b7f 100644 --- a/backend/internal/modules/auth/handler.go +++ b/backend/internal/modules/auth/handler.go @@ -2,10 +2,10 @@ package auth import ( "errors" - "net" "net/http" "strings" + "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" @@ -86,20 +86,18 @@ func (h *Handler) SendSMS(c *gin.Context) { return } - siteCode := strings.TrimSpace(firstNonEmpty( - c.GetHeader("X-Distribution-Site-Code"), - c.Query("distribution_site_code"), - c.Query("site_code"), - req.SiteCode, - )) + siteCode := sitecode.FromRequest(c) + if siteCode == "" { + siteCode = strings.TrimSpace(req.SiteCode) + } domain := strings.TrimSpace(firstNonEmpty( c.GetHeader("X-Distribution-Domain"), c.Query("distribution_domain"), c.Query("domain"), )) if siteCode == "" && domain == "" { - host := extractHost(c.Request.Host) - if host != "" && host != "localhost" && host != "127.0.0.1" && host != "::1" { + host := sitecode.ExtractHost(c.Request.Host) + if host != "" && !sitecode.IsLocalHost(host) { domain = host } } @@ -120,17 +118,6 @@ func firstNonEmpty(items ...string) string { return "" } -func extractHost(rawHost string) string { - rawHost = strings.TrimSpace(rawHost) - if rawHost == "" { - return "" - } - if host, _, err := net.SplitHostPort(rawHost); err == nil { - return strings.Trim(host, "[]") - } - return strings.Trim(rawHost, "[]") -} - // Login 短信验证码登录 // @Summary 短信验证码登录 // @Description 使用手机号和验证码登录 diff --git a/backend/internal/modules/chat/handler_common.go b/backend/internal/modules/chat/handler_common.go index a5a79e6..7131084 100644 --- a/backend/internal/modules/chat/handler_common.go +++ b/backend/internal/modules/chat/handler_common.go @@ -1,25 +1,14 @@ package chat import ( - "strings" - + "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" ) func parseSiteCode(c *gin.Context) string { - for _, val := range []string{ - c.Query("distribution_site_code"), - c.Query("site_code"), - c.GetHeader("X-Distribution-Site-Code"), - c.GetHeader("X-Site-Code"), - } { - if trimmed := strings.TrimSpace(val); trimmed != "" { - return trimmed - } - } - return "" + return sitecode.FromRequest(c) } func (h *Handler) list(c *gin.Context, principal Principal) { diff --git a/backend/internal/modules/chat/qrcode_ocr_test.go b/backend/internal/modules/chat/qrcode_ocr_test.go index 2f6f36e..8a46a04 100644 --- a/backend/internal/modules/chat/qrcode_ocr_test.go +++ b/backend/internal/modules/chat/qrcode_ocr_test.go @@ -238,4 +238,3 @@ func TestCollectPaddleOCRJSONLTextSupportsPPOCRv6(t *testing.T) { t.Fatalf("candidates = %v, want 火爆出租-778群 as first candidate", candidates) } } - diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index d07a14d..1ebb495 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -7,6 +7,7 @@ import ( "strings" "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" @@ -355,20 +356,13 @@ func (h *Handler) ListPublicSiteListings(c *gin.Context) { } func (h *Handler) GetPublicSiteInfo(c *gin.Context) { - code := strings.TrimSpace(c.Query("code")) - if code == "" { - code = strings.TrimSpace(c.Query("site_code")) - } - if code == "" { - code = strings.TrimSpace(c.GetHeader("X-Distribution-Site-Code")) - } + code := sitecode.FromQueryThenHeader(c) domain := strings.TrimSpace(c.Query("domain")) if code == "" && domain == "" { - host := c.Request.Host - if colon := strings.Index(host, ":"); colon != -1 { - host = host[:colon] + host := sitecode.ExtractHost(c.Request.Host) + if host != "" && !sitecode.IsLocalHost(host) { + domain = host } - domain = host } info, err := h.service.GetPublicSiteInfo(c.Request.Context(), code, domain) if err != nil { @@ -382,14 +376,11 @@ func (h *Handler) GetPublicSiteInfo(c *gin.Context) { // 仅当域名属于系统中正常运行(active)的分销站点时返回 200,其余情况返回 404, // 防止任意外部未授权域名解析到该服务器刷爆 Let's Encrypt 证书申请配额。 func (h *Handler) CheckDomain(c *gin.Context) { - domain := strings.ToLower(strings.TrimSpace(c.Query("domain"))) + domain := sitecode.ExtractHost(c.Query("domain")) if domain == "" { c.String(http.StatusBadRequest, "domain required") return } - if colon := strings.Index(domain, ":"); colon != -1 { - domain = domain[:colon] - } info, err := h.service.GetPublicSiteInfo(c.Request.Context(), "", domain) if err != nil || info == nil || info.Status != "active" { diff --git a/backend/internal/modules/file/storage_test.go b/backend/internal/modules/file/storage_test.go index c78455e..30fe416 100644 --- a/backend/internal/modules/file/storage_test.go +++ b/backend/internal/modules/file/storage_test.go @@ -91,4 +91,3 @@ func TestNormalizeSceneListingScreenshotAndOperatingExpense(t *testing.T) { t.Fatalf("normalize operating_expense scene = %q, want operating-expense", got) } } - diff --git a/backend/internal/modules/listing/handler_parse.go b/backend/internal/modules/listing/handler_parse.go index 10bb386..4b55f47 100644 --- a/backend/internal/modules/listing/handler_parse.go +++ b/backend/internal/modules/listing/handler_parse.go @@ -5,6 +5,7 @@ import ( "strings" "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" @@ -119,7 +120,7 @@ func parsePublicListQuery(c *gin.Context) PublicListQuery { MinSecretKD: parseOptionalFloat(c.Query("min_secret_kd")), MaxSecretKD: parseOptionalFloat(c.Query("max_secret_kd")), ResourceRanges: parseResourceRanges(c), - DistributionSiteCode: strings.TrimSpace(firstNonEmpty(c.GetHeader("X-Distribution-Site-Code"), c.Query("distribution_site_code"), c.Query("site_code"))), + DistributionSiteCode: sitecode.FromRequest(c), } if query.PageSize > 50 { query.PageSize = 50 diff --git a/backend/internal/modules/listing/handler_public.go b/backend/internal/modules/listing/handler_public.go index 66f5ef6..763f1a9 100644 --- a/backend/internal/modules/listing/handler_public.go +++ b/backend/internal/modules/listing/handler_public.go @@ -3,9 +3,9 @@ package listing import ( "net/http" "strconv" - "strings" filemodule "hfb_sys/backend/internal/modules/file" + "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" @@ -25,7 +25,7 @@ func (h *Handler) FindPublic(c *gin.Context) { if !ok { return } - siteCode := strings.TrimSpace(firstNonEmpty(c.GetHeader("X-Distribution-Site-Code"), c.Query("distribution_site_code"), c.Query("site_code"))) + siteCode := sitecode.FromRequest(c) item, err := h.service.FindPublic(c.Request.Context(), id, siteCode) if err != nil { writeListingError(c, err) diff --git a/backend/internal/modules/listing/handler_user.go b/backend/internal/modules/listing/handler_user.go index 1130ea0..684cdf6 100644 --- a/backend/internal/modules/listing/handler_user.go +++ b/backend/internal/modules/listing/handler_user.go @@ -1,6 +1,7 @@ package listing import ( + "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" "strings" @@ -18,7 +19,7 @@ func (h *Handler) Create(c *gin.Context) { response.BadRequest(c, listingBindErrorMessage(err)) return } - siteCode := strings.TrimSpace(c.GetHeader("X-Distribution-Site-Code")) + siteCode := sitecode.FromRequest(c) item, err := h.service.Create(c.Request.Context(), ownerID, req, siteCode) if err != nil { writeListingError(c, err) @@ -42,7 +43,7 @@ func (h *Handler) Update(c *gin.Context) { response.BadRequest(c, listingBindErrorMessage(err)) return } - item, err := h.service.Update(c.Request.Context(), ownerID, id, req, strings.TrimSpace(c.GetHeader("X-Distribution-Site-Code"))) + item, err := h.service.Update(c.Request.Context(), ownerID, id, req, sitecode.FromRequest(c)) if err != nil { writeListingError(c, err) return @@ -103,7 +104,7 @@ func (h *Handler) ListMine(c *gin.Context) { response.Unauthorized(c, "缺少用户上下文") return } - siteCode := strings.TrimSpace(c.GetHeader("X-Distribution-Site-Code")) + siteCode := sitecode.FromRequest(c) items, err := h.service.ListMine(c.Request.Context(), ownerID, siteCode) if err != nil { writeListingError(c, err) diff --git a/backend/internal/modules/notification/handler.go b/backend/internal/modules/notification/handler.go index 9b66cab..140db89 100644 --- a/backend/internal/modules/notification/handler.go +++ b/backend/internal/modules/notification/handler.go @@ -5,6 +5,7 @@ import ( "strconv" "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" @@ -99,17 +100,7 @@ func (h *Handler) MarkRead(c *gin.Context) { } func parseSiteCode(c *gin.Context) string { - siteCode := c.Query("distribution_site_code") - if siteCode == "" { - siteCode = c.Query("site_code") - } - if siteCode == "" { - siteCode = c.GetHeader("X-Distribution-Site-Code") - } - if siteCode == "" { - siteCode = c.GetHeader("X-Site-Code") - } - return siteCode + return sitecode.FromRequest(c) } func currentUserID(c *gin.Context) (uint64, bool) { diff --git a/backend/internal/modules/order/handler_user.go b/backend/internal/modules/order/handler_user.go index a2fd473..2d5738b 100644 --- a/backend/internal/modules/order/handler_user.go +++ b/backend/internal/modules/order/handler_user.go @@ -3,6 +3,7 @@ package order import ( "strings" + "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" @@ -20,7 +21,7 @@ func (h *Handler) Create(c *gin.Context) { return } if strings.TrimSpace(req.DistributionSiteCode) == "" { - req.DistributionSiteCode = strings.TrimSpace(c.GetHeader("X-Distribution-Site-Code")) + req.DistributionSiteCode = sitecode.FromRequest(c) } item, err := h.service.Create(c.Request.Context(), userID, req) if err != nil { @@ -37,7 +38,7 @@ func (h *Handler) List(c *gin.Context) { return } page, pageSize := parsePagination(c) - siteCode := strings.TrimSpace(firstNonEmpty(c.GetHeader("X-Distribution-Site-Code"), c.Query("distribution_site_code"), c.Query("site_code"))) + siteCode := sitecode.FromRequest(c) items, err := h.service.ListForUser(c.Request.Context(), userID, page, pageSize, siteCode) if err != nil { writeOrderError(c, err) @@ -53,7 +54,7 @@ func (h *Handler) ListSellerHandoffs(c *gin.Context) { return } page, pageSize := parsePagination(c) - siteCode := strings.TrimSpace(firstNonEmpty(c.GetHeader("X-Distribution-Site-Code"), c.Query("distribution_site_code"), c.Query("site_code"))) + siteCode := sitecode.FromRequest(c) items, err := h.service.ListSellerHandoffs(c.Request.Context(), userID, SellerHandoffQuery{ Page: page, PageSize: pageSize, @@ -77,7 +78,7 @@ func (h *Handler) Detail(c *gin.Context) { if !ok { return } - siteCode := strings.TrimSpace(firstNonEmpty(c.GetHeader("X-Distribution-Site-Code"), c.Query("distribution_site_code"), c.Query("site_code"))) + siteCode := sitecode.FromRequest(c) item, err := h.service.FindForUser(c.Request.Context(), userID, id, siteCode) if err != nil { writeOrderError(c, err) diff --git a/backend/internal/modules/payment/repository_integration_test.go b/backend/internal/modules/payment/repository_integration_test.go index 3d1dce2..52618b2 100644 --- a/backend/internal/modules/payment/repository_integration_test.go +++ b/backend/internal/modules/payment/repository_integration_test.go @@ -927,4 +927,3 @@ func TestStartPaymentRoutesToDistributionSitePaymentChannel(t *testing.T) { t.Fatalf("Order B expected MerchantID=%s, got %s", defaultConfig.MerchantID, savedB.MerchantID) } } - diff --git a/backend/internal/modules/wallet/handler.go b/backend/internal/modules/wallet/handler.go index 9f9b491..f1ecbc7 100644 --- a/backend/internal/modules/wallet/handler.go +++ b/backend/internal/modules/wallet/handler.go @@ -3,9 +3,9 @@ package wallet import ( "errors" "strconv" - "strings" "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" @@ -20,17 +20,7 @@ func NewHandler(service *Service) *Handler { } func parseSiteCode(c *gin.Context) string { - for _, val := range []string{ - c.Query("distribution_site_code"), - c.Query("site_code"), - c.GetHeader("X-Distribution-Site-Code"), - c.GetHeader("X-Site-Code"), - } { - if trimmed := strings.TrimSpace(val); trimmed != "" { - return trimmed - } - } - return "" + return sitecode.FromRequest(c) } func (h *Handler) Balance(c *gin.Context) { diff --git a/backend/internal/modules/withdrawal/handler.go b/backend/internal/modules/withdrawal/handler.go index c3c5c6a..95f5a57 100644 --- a/backend/internal/modules/withdrawal/handler.go +++ b/backend/internal/modules/withdrawal/handler.go @@ -2,8 +2,8 @@ package withdrawal import ( "strconv" - "strings" + "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" @@ -18,17 +18,7 @@ func NewHandler(service *Service) *Handler { } func parseSiteCode(c *gin.Context) string { - for _, val := range []string{ - c.Query("distribution_site_code"), - c.Query("site_code"), - c.GetHeader("X-Distribution-Site-Code"), - c.GetHeader("X-Site-Code"), - } { - if trimmed := strings.TrimSpace(val); trimmed != "" { - return trimmed - } - } - return "" + return sitecode.FromRequest(c) } // ========== 用户端接口 ========== diff --git a/backend/internal/modules/withdrawal/presenter.go b/backend/internal/modules/withdrawal/presenter.go index 979194d..34561f8 100644 --- a/backend/internal/modules/withdrawal/presenter.go +++ b/backend/internal/modules/withdrawal/presenter.go @@ -95,29 +95,29 @@ func (r *Repository) toDetailDTO(ctx context.Context, w model.WithdrawalRequest) DistributionSiteName: siteName, DistributionSiteCode: siteCode, UserNickname: user.Nickname, - UserPhone: user.Phone, - AmountCent: w.AmountCent, - FeeCent: w.FeeCent, - ActualAmountCent: w.ActualAmountCent, - PaymentAccountID: w.PaymentAccountID, - AccountType: w.AccountType, - AccountName: w.AccountName, - AccountNo: fullAccountNo, - BankName: w.BankName, - BankBranch: w.BankBranch, - CertificateURLs: certificateURLs, - Status: w.Status, - ReviewedBy: w.ReviewedBy, - ReviewedByName: reviewedByName, - ReviewedAt: w.ReviewedAt, - ReviewRemark: w.ReviewRemark, - PaidBy: w.PaidBy, - PaidByName: paidByName, - PaidAt: w.PaidAt, - PaymentProofURL: w.PaymentProofURL, - PaymentRemark: w.PaymentRemark, - CreatedAt: w.CreatedAt, - UpdatedAt: w.UpdatedAt, + UserPhone: user.Phone, + AmountCent: w.AmountCent, + FeeCent: w.FeeCent, + ActualAmountCent: w.ActualAmountCent, + PaymentAccountID: w.PaymentAccountID, + AccountType: w.AccountType, + AccountName: w.AccountName, + AccountNo: fullAccountNo, + BankName: w.BankName, + BankBranch: w.BankBranch, + CertificateURLs: certificateURLs, + Status: w.Status, + ReviewedBy: w.ReviewedBy, + ReviewedByName: reviewedByName, + ReviewedAt: w.ReviewedAt, + ReviewRemark: w.ReviewRemark, + PaidBy: w.PaidBy, + PaidByName: paidByName, + PaidAt: w.PaidAt, + PaymentProofURL: w.PaymentProofURL, + PaymentRemark: w.PaymentRemark, + CreatedAt: w.CreatedAt, + UpdatedAt: w.UpdatedAt, }, nil } diff --git a/backend/internal/sitecode/sitecode.go b/backend/internal/sitecode/sitecode.go new file mode 100644 index 0000000..8690441 --- /dev/null +++ b/backend/internal/sitecode/sitecode.go @@ -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 "" +} diff --git a/backend/internal/sitecode/sitecode_test.go b/backend/internal/sitecode/sitecode_test.go new file mode 100644 index 0000000..7012eb5 --- /dev/null +++ b/backend/internal/sitecode/sitecode_test.go @@ -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) + } + } +}