chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
+290
View File
@@ -0,0 +1,290 @@
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"hfb_sys/backend/internal/logging"
"hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
const (
ContextUserID = "user_id"
ContextPhone = "phone"
ContextAdminID = "admin_id"
ContextUsername = "username"
ContextPasswordMustChange = "password_must_change"
ContextAuthFailureReason = "auth_failure_reason"
ContextAuthTokenSource = "auth_token_source"
ContextAuthTokenVersion = "auth_token_version"
ContextAuthCurrentVersion = "auth_current_token_version"
ContextAuthFailureDetail = "auth_failure_detail"
AdminAccessCookieName = "hfb_admin_access"
UserAccessCookieName = "hfb_user_access"
)
type AdminTokenContext struct {
Username string
PasswordMustChange bool
}
type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error)
type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error
func extractBearerToken(c *gin.Context) string {
header := c.GetHeader("Authorization")
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if tokenText != "" && tokenText != header {
return tokenText
}
return ""
}
func extractAdminToken(c *gin.Context) (string, string) {
if tokenText := extractBearerToken(c); tokenText != "" {
return tokenText, "bearer"
}
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
if tokenText := strings.TrimSpace(cookieToken); tokenText != "" {
return tokenText, "cookie"
}
}
return "", "none"
}
func Auth(jwtManager *auth.JWTManager, validate UserTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) {
tokenText := extractBearerToken(c)
if tokenText == "" {
if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil {
tokenText = strings.TrimSpace(cookieToken)
}
}
if tokenText == "" {
response.Unauthorized(c, "缺少访问令牌")
c.Abort()
return
}
claims, err := jwtManager.ParseSubject(tokenText, "access", "user")
if err != nil {
response.Unauthorized(c, "访问令牌无效或已过期")
c.Abort()
return
}
if validate != nil {
if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
if errors.Is(err, auth.ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
} else {
response.Unauthorized(c, "登录状态已失效,请重新登录")
}
c.Abort()
return
}
}
c.Set(ContextUserID, claims.UserID)
c.Set(ContextPhone, claims.Phone)
c.Next()
}
}
// AuthUserOrAdmin 允许普通用户或管理员访问共享资源(如私有文件流)。
func AuthUserOrAdmin(jwtManager *auth.JWTManager, userValidate UserTokenValidatorFunc, adminValidate AdminTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 优先尝试管理员鉴权(Header Bearer 或 Admin Cookie)
if tokenText, tokenSource := extractAdminToken(c); tokenText != "" {
if claims, err := jwtManager.ParseSubject(tokenText, "access", "admin"); err == nil {
username := claims.Phone
passwordMustChange := false
validAdmin := true
if adminValidate != nil {
tokenContext, err := adminValidate(c.Request.Context(), claims.UserID, claims.TokenVersion)
if err != nil {
RecordAdminAuthFailure(c, "invalid", tokenSource, 0, 0)
validAdmin = false
} else {
username = tokenContext.Username
passwordMustChange = tokenContext.PasswordMustChange
}
}
if validAdmin {
c.Set(ContextAdminID, claims.UserID)
c.Request = c.Request.WithContext(logging.WithAdminID(c.Request.Context(), claims.UserID))
c.Set(ContextUsername, username)
c.Set(ContextPasswordMustChange, passwordMustChange)
c.Next()
return
}
}
}
// 2. 尝试普通用户鉴权(Header Bearer 或 User Cookie)
tokenText := extractBearerToken(c)
if tokenText == "" {
if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil {
tokenText = strings.TrimSpace(cookieToken)
}
}
if tokenText == "" {
response.Unauthorized(c, "缺少访问令牌")
c.Abort()
return
}
claims, err := jwtManager.ParseSubject(tokenText, "access", "user")
if err != nil {
response.Unauthorized(c, "访问令牌无效或已过期")
c.Abort()
return
}
if userValidate != nil {
if err := userValidate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
if errors.Is(err, auth.ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
} else {
response.Unauthorized(c, "登录状态已失效,请重新登录")
}
c.Abort()
return
}
}
c.Set(ContextUserID, claims.UserID)
c.Set(ContextPhone, claims.Phone)
c.Next()
}
}
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) {
tokenText, tokenSource := extractAdminToken(c)
if tokenText == "" {
RecordAdminAuthFailure(c, "missing", tokenSource, 0, 0)
response.Unauthorized(c, "缺少后台访问令牌")
c.Abort()
return
}
claims, err := jwtManager.ParseSubject(tokenText, "access", "admin")
if err != nil {
RecordAdminAuthFailure(c, auth.TokenFailureReason(err), tokenSource, 0, 0)
response.Unauthorized(c, "后台访问令牌无效或已过期")
c.Abort()
return
}
c.Set(ContextAdminID, claims.UserID)
c.Request = c.Request.WithContext(logging.WithAdminID(c.Request.Context(), claims.UserID))
username := claims.Phone
passwordMustChange := false
if validate != nil {
tokenContext, err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion)
if err != nil {
reason, tokenVersion, currentVersion := AdminValidationFailure(err)
RecordAdminAuthFailure(c, reason, tokenSource, tokenVersion, currentVersion, AdminValidationFailureDetail(err))
if AdminValidationUnavailable(err) {
response.ServiceUnavailable(c, "后台认证服务暂时不可用")
c.Abort()
return
}
response.Unauthorized(c, "后台访问令牌无效或已过期")
c.Abort()
return
}
username = tokenContext.Username
passwordMustChange = tokenContext.PasswordMustChange
}
c.Set(ContextUsername, username)
c.Set(ContextPasswordMustChange, passwordMustChange)
c.Next()
}
}
type authFailureReasonCarrier interface {
AuthFailureReason() string
}
type authFailureVersionCarrier interface {
AuthFailureVersions() (int64, int64)
}
type authFailureDetailCarrier interface {
AuthFailureDetail() string
}
type authFailureUnavailableCarrier interface {
AuthFailureUnavailable() bool
}
// AdminValidationFailure 将认证包内部错误转换为安全、可观测的失败类别。
func AdminValidationFailure(err error) (string, int64, int64) {
reason := "admin_validation_failed"
var reasonCarrier authFailureReasonCarrier
if errors.As(err, &reasonCarrier) && reasonCarrier.AuthFailureReason() != "" {
reason = reasonCarrier.AuthFailureReason()
}
var versionCarrier authFailureVersionCarrier
if errors.As(err, &versionCarrier) {
tokenVersion, currentVersion := versionCarrier.AuthFailureVersions()
return reason, tokenVersion, currentVersion
}
return reason, 0, 0
}
// AdminValidationFailureDetail 返回仅供服务端日志记录的安全错误摘要。
func AdminValidationFailureDetail(err error) string {
var detailCarrier authFailureDetailCarrier
if errors.As(err, &detailCarrier) {
return detailCarrier.AuthFailureDetail()
}
return ""
}
// AdminValidationUnavailable 判断失败是否由临时依赖故障引起。
func AdminValidationUnavailable(err error) bool {
var unavailableCarrier authFailureUnavailableCarrier
return errors.As(err, &unavailableCarrier) && unavailableCarrier.AuthFailureUnavailable()
}
// RecordAdminAuthFailure 把认证失败诊断字段写入请求上下文,供访问日志统一输出。
func RecordAdminAuthFailure(c *gin.Context, reason, source string, tokenVersion, currentVersion int64, detail ...string) {
c.Set(ContextAuthFailureReason, reason)
c.Set(ContextAuthTokenSource, source)
if tokenVersion != 0 || currentVersion != 0 {
c.Set(ContextAuthTokenVersion, tokenVersion)
c.Set(ContextAuthCurrentVersion, currentVersion)
}
if len(detail) > 0 && detail[0] != "" {
c.Set(ContextAuthFailureDetail, detail[0])
}
}
func RequireAdminPasswordChanged() gin.HandlerFunc {
allowed := map[string]bool{
"/api/admin/me": true,
"/api/admin/auth/logout": true,
"/api/admin/admin-users/me/password": true,
}
return func(c *gin.Context) {
value, ok := c.Get(ContextPasswordMustChange)
if !ok {
c.Next()
return
}
mustChange, ok := value.(bool)
if !ok || !mustChange || allowed[c.FullPath()] {
c.Next()
return
}
response.Error(c, http.StatusForbidden, "password_must_change", "请先修改初始密码")
c.Abort()
}
}
+65
View File
@@ -0,0 +1,65 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"hfb_sys/backend/internal/modules/auth"
"github.com/gin-gonic/gin"
)
func TestUserAuthRejectsQueryToken(t *testing.T) {
gin.SetMode(gin.TestMode)
manager := auth.NewJWTManager("test-jwt-secret-for-query-token-rejection")
pair, err := manager.GenerateSubjectPairWithVersion(1, "13800000000", "user", 1)
if err != nil {
t.Fatalf("GenerateSubjectPairWithVersion() error = %v", err)
}
engine := gin.New()
engine.GET("/protected", Auth(manager, nil), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
queryRequest := httptest.NewRequest(http.MethodGet, "/protected?token="+pair.AccessToken, nil)
queryResponse := httptest.NewRecorder()
engine.ServeHTTP(queryResponse, queryRequest)
if queryResponse.Code != http.StatusUnauthorized {
t.Fatalf("query token status = %d, want %d", queryResponse.Code, http.StatusUnauthorized)
}
bearerRequest := httptest.NewRequest(http.MethodGet, "/protected", nil)
bearerRequest.Header.Set("Authorization", "Bearer "+pair.AccessToken)
bearerResponse := httptest.NewRecorder()
engine.ServeHTTP(bearerResponse, bearerRequest)
if bearerResponse.Code != http.StatusNoContent {
t.Fatalf("bearer token status = %d, want %d", bearerResponse.Code, http.StatusNoContent)
}
}
func TestAuthRejectsRevokedUserToken(t *testing.T) {
gin.SetMode(gin.TestMode)
manager := auth.NewJWTManager("test-secret")
pair, err := manager.GenerateSubjectPairWithVersion(7, "13900000007", "user", 0)
if err != nil {
t.Fatalf("生成令牌失败:%v", err)
}
engine := gin.New()
engine.GET("/protected", Auth(manager, func(_ context.Context, _ uint64, _ int64) error {
return auth.ErrTokenVersionMismatch
}), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer "+pair.AccessToken)
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("响应状态 = %d, want %d", response.Code, http.StatusUnauthorized)
}
}
+105
View File
@@ -0,0 +1,105 @@
package middleware
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// RequirePermission 检查当前管理员是否拥有指定权限。
// 超级管理员(拥有 super_admin 角色的管理员)自动放行。
func RequirePermission(permCode string, rdb *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
if checkPermission(c, permCode, rdb) {
c.Next()
}
}
}
// RequireAnyPermission 检查当前管理员是否拥有任意一个指定的权限。
// 超级管理员(拥有 super_admin 角色或 * 权限)自动放行。
func RequireAnyPermission(rdb *redis.Client, permCodes ...string) gin.HandlerFunc {
return func(c *gin.Context) {
if checkAnyPermission(c, rdb, permCodes...) {
c.Next()
}
}
}
func RequirePermissionIf(permCode string, rdb *redis.Client, predicate func(*gin.Context) bool) gin.HandlerFunc {
return func(c *gin.Context) {
if predicate == nil || !predicate(c) {
c.Next()
return
}
if checkPermission(c, permCode, rdb) {
c.Next()
}
}
}
func checkAnyPermission(c *gin.Context, rdb *redis.Client, permCodes ...string) bool {
value, ok := c.Get(ContextAdminID)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
c.Abort()
return false
}
adminID, ok := value.(uint64)
if !ok {
response.Unauthorized(c, "管理员上下文无效")
c.Abort()
return false
}
codes, err := getPermCodes(c, rdb, adminID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "perm_check_failed", "权限校验服务暂时不可用")
c.Abort()
return false
}
for _, code := range codes {
if code == "*" {
return true
}
for _, target := range permCodes {
if code == target {
return true
}
}
}
response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限")
c.Abort()
return false
}
func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
return checkAnyPermission(c, rdb, permCode)
}
func getPermCodes(c *gin.Context, rdb *redis.Client, adminID uint64) ([]string, error) {
if rdb == nil {
return nil, errors.New("redis unavailable")
}
key := fmt.Sprintf("admin:perms:%d", adminID)
raw, err := rdb.Get(c.Request.Context(), key).Result()
if errors.Is(err, redis.Nil) {
return nil, nil // 缓存未命中,视为无权限
}
if err != nil {
return nil, err
}
var codes []string
if err := json.Unmarshal([]byte(raw), &codes); err != nil {
return nil, err
}
return codes, nil
}
@@ -0,0 +1,94 @@
package middleware
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
func TestRequireAnyPermission(t *testing.T) {
gin.SetMode(gin.TestMode)
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:16379",
DB: 14,
})
ctx := t.Context()
if err := rdb.Ping(ctx).Err(); err != nil {
t.Skipf("跳过测试:未检测到测试 Redis: %v", err)
}
t.Cleanup(func() {
_ = rdb.FlushDB(context.Background()).Err()
_ = rdb.Close()
})
_ = rdb.FlushDB(ctx)
adminID := uint64(101)
permKey := fmt.Sprintf("admin:perms:%d", adminID)
setPerms := func(perms []string) {
raw, err := json.Marshal(perms)
if err != nil {
t.Fatal(err)
}
if err := rdb.Set(ctx, permKey, string(raw), time.Minute).Err(); err != nil {
t.Fatal(err)
}
}
setupRouter := func() *gin.Engine {
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(ContextAdminID, adminID)
c.Next()
})
r.GET("/test-any", RequireAnyPermission(rdb, "distribution:site_view", "qrcode:view"), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
return r
}
router := setupRouter()
// 1. 无任何权限 -> 403
setPerms([]string{"dashboard:view"})
req := httptest.NewRequest(http.MethodGet, "/test-any", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
// 2. 具备 qrcode:view -> 200
setPerms([]string{"qrcode:view"})
req = httptest.NewRequest(http.MethodGet, "/test-any", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// 3. 具备 distribution:site_view -> 200
setPerms([]string{"distribution:site_view"})
req = httptest.NewRequest(http.MethodGet, "/test-any", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// 4. 超管通配符 * -> 200
setPerms([]string{"*"})
req = httptest.NewRequest(http.MethodGet, "/test-any", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
+175
View File
@@ -0,0 +1,175 @@
package middleware
import (
"context"
"net/http"
"strconv"
"sync"
"time"
"hfb_sys/backend/internal/modules/auth"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
type rateLimitKeyFunc func(c *gin.Context) string
type rateLimitBucket struct {
count int
resetAt time.Time
lastSeen time.Time
}
type rateLimiter struct {
mu sync.Mutex
limit int
window time.Duration
buckets map[string]rateLimitBucket
}
type redisRateLimiter struct {
redis *redis.Client
fallback *rateLimiter
limit int
window time.Duration
}
func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc {
return rateLimitPerMinute(limit, rdb, ipRateLimitKey)
}
// AdminAwareRateLimitPerMinute isolates authenticated admin traffic by admin ID.
// Login and other requests without a valid admin access token fall back to IP.
func AdminAwareRateLimitPerMinute(limit int, rdb *redis.Client, jwtManager *auth.JWTManager) gin.HandlerFunc {
return rateLimitPerMinute(limit, rdb, adminOrIPRateLimitKey(jwtManager))
}
func rateLimitPerMinute(limit int, rdb *redis.Client, keyFunc rateLimitKeyFunc) gin.HandlerFunc {
if limit <= 0 {
return func(c *gin.Context) {
c.Next()
}
}
limiter := &rateLimiter{
limit: limit,
window: time.Minute,
buckets: make(map[string]rateLimitBucket),
}
if rdb != nil {
return (&redisRateLimiter{
redis: rdb,
fallback: limiter,
limit: limit,
window: time.Minute,
}).handle(keyFunc)
}
return limiter.handle(keyFunc)
}
func (l *redisRateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc {
return func(c *gin.Context) {
now := time.Now()
key := keyFunc(c)
allowed, resetAt, err := l.allow(c.Request.Context(), key, now)
if err != nil {
allowed, resetAt = l.fallback.allow(key, now)
}
if !allowed {
writeRateLimited(c, now, resetAt)
return
}
c.Next()
}
}
func (l *rateLimiter) handle(keyFunc rateLimitKeyFunc) gin.HandlerFunc {
return func(c *gin.Context) {
now := time.Now()
key := keyFunc(c)
allowed, resetAt := l.allow(key, now)
if !allowed {
writeRateLimited(c, now, resetAt)
return
}
c.Next()
}
}
func ipRateLimitKey(c *gin.Context) string {
return "ip:" + c.ClientIP()
}
func adminOrIPRateLimitKey(jwtManager *auth.JWTManager) rateLimitKeyFunc {
return func(c *gin.Context) string {
if value, ok := c.Get(ContextAdminID); ok {
if adminID, ok := value.(uint64); ok && adminID != 0 {
return "admin:" + strconv.FormatUint(adminID, 10)
}
}
if jwtManager != nil {
tokenText, _ := extractAdminToken(c)
if tokenText != "" {
claims, err := jwtManager.ParseSubject(tokenText, "access", "admin")
if err == nil && claims.UserID != 0 {
return "admin:" + strconv.FormatUint(claims.UserID, 10)
}
}
}
return ipRateLimitKey(c)
}
}
func (l *redisRateLimiter) allow(ctx context.Context, key string, now time.Time) (bool, time.Time, error) {
windowSeconds := int64(l.window / time.Second)
windowID := now.Unix() / windowSeconds
redisKey := "rate_limit:" + key + ":" + strconv.FormatInt(windowID, 10)
count, err := l.redis.Incr(ctx, redisKey).Result()
if err != nil {
return false, time.Time{}, err
}
if count == 1 {
_ = l.redis.Expire(ctx, redisKey, 2*l.window).Err()
}
resetAt := time.Unix((windowID+1)*windowSeconds, 0)
return count <= int64(l.limit), resetAt, nil
}
func (l *rateLimiter) allow(key string, now time.Time) (bool, time.Time) {
l.mu.Lock()
defer l.mu.Unlock()
l.cleanup(now)
bucket := l.buckets[key]
if bucket.resetAt.IsZero() || !now.Before(bucket.resetAt) {
bucket = rateLimitBucket{resetAt: now.Add(l.window)}
}
bucket.count++
bucket.lastSeen = now
l.buckets[key] = bucket
return bucket.count <= l.limit, bucket.resetAt
}
func (l *rateLimiter) cleanup(now time.Time) {
for key, bucket := range l.buckets {
if now.Sub(bucket.lastSeen) > 2*l.window {
delete(l.buckets, key)
}
}
}
func retryAfterSeconds(now time.Time, resetAt time.Time) string {
seconds := int(resetAt.Sub(now).Seconds())
if seconds < 1 {
seconds = 1
}
return strconv.Itoa(seconds)
}
func writeRateLimited(c *gin.Context, now time.Time, resetAt time.Time) {
c.Header("Retry-After", retryAfterSeconds(now, resetAt))
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"code": "rate_limited",
"message": "请求过于频繁,请稍后再试",
})
}
@@ -0,0 +1,91 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"hfb_sys/backend/internal/modules/auth"
"github.com/gin-gonic/gin"
)
func runRateLimitedRequest(router *gin.Engine, bearerToken string) int {
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
if bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+bearerToken)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
return recorder.Code
}
func newRateLimitRouter(limit int, rdb interface{}, handler gin.HandlerFunc) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(handler)
router.GET("/ping", func(c *gin.Context) {
c.Status(http.StatusOK)
})
return router
}
func adminAccessToken(t *testing.T, jwtManager *auth.JWTManager, adminID uint64, phone string) string {
t.Helper()
pair, err := jwtManager.GenerateSubjectPairWithVersion(adminID, phone, "admin", 0)
if err != nil {
t.Fatalf("生成后台令牌失败: %v", err)
}
return pair.AccessToken
}
func TestAdminAwareRateLimitIsolatesAdminsBehindSharedIP(t *testing.T) {
jwtManager := auth.NewJWTManager("test-secret")
adminA := adminAccessToken(t, jwtManager, 101, "13800000001")
adminB := adminAccessToken(t, jwtManager, 102, "13800000002")
router := newRateLimitRouter(2, nil, AdminAwareRateLimitPerMinute(2, nil, jwtManager))
for i := 0; i < 2; i++ {
if code := runRateLimitedRequest(router, adminA); code != http.StatusOK {
t.Fatalf("客服A 第 %d 次请求 = %d, want 200", i+1, code)
}
}
if code := runRateLimitedRequest(router, adminA); code != http.StatusTooManyRequests {
t.Fatalf("客服A 超额请求 = %d, want 429", code)
}
if code := runRateLimitedRequest(router, adminB); code != http.StatusOK {
t.Fatalf("同出口IP的客服B 应不受客服A影响, got %d, want 200", code)
}
}
func TestAdminAwareRateLimitFallsBackToIP(t *testing.T) {
jwtManager := auth.NewJWTManager("test-secret")
userPair, err := jwtManager.GenerateSubjectPairWithVersion(201, "13900000001", "user", 0)
if err != nil {
t.Fatalf("生成用户令牌失败: %v", err)
}
router := newRateLimitRouter(2, nil, AdminAwareRateLimitPerMinute(2, nil, jwtManager))
for i := 0; i < 2; i++ {
if code := runRateLimitedRequest(router, ""); code != http.StatusOK {
t.Fatalf("匿名请求 第 %d 次 = %d, want 200", i+1, code)
}
}
if code := runRateLimitedRequest(router, ""); code != http.StatusTooManyRequests {
t.Fatalf("匿名请求超额 = %d, want 429", code)
}
// 用户令牌无法按 admin 身份解析,应与匿名共享同一个 IP 桶。
if code := runRateLimitedRequest(router, userPair.AccessToken); code != http.StatusTooManyRequests {
t.Fatalf("用户令牌应回退到IP维度, got %d, want 429", code)
}
}
func TestLegacyRateLimitPerMinuteKeepsIPKey(t *testing.T) {
router := newRateLimitRouter(1, nil, RateLimitPerMinute(1, nil))
if code := runRateLimitedRequest(router, ""); code != http.StatusOK {
t.Fatalf("首次匿名请求 = %d, want 200", code)
}
if code := runRateLimitedRequest(router, ""); code != http.StatusTooManyRequests {
t.Fatalf("第二次匿名请求 = %d, want 429", code)
}
}
+44
View File
@@ -0,0 +1,44 @@
package middleware
import (
"errors"
"hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RequireRealname(users *auth.UserRepository) gin.HandlerFunc {
return func(c *gin.Context) {
if users == nil {
response.ServiceUnavailable(c, "数据库未连接")
c.Abort()
return
}
value, ok := c.Get(ContextUserID)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
c.Abort()
return
}
user, err := users.FindByID(c.Request.Context(), value.(uint64))
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Unauthorized(c, "用户不存在")
c.Abort()
return
}
if err != nil {
response.ServiceUnavailable(c, "用户查询失败")
c.Abort()
return
}
if user.RealnameStatus != "verified" {
response.Error(c, 403, "realname_required", "请先完成实名认证")
c.Abort()
return
}
c.Next()
}
}
+31
View File
@@ -0,0 +1,31 @@
package middleware
import (
"fmt"
"net/http"
"runtime/debug"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
const contextPanicStack = "panic_stack"
func Recovery(_ *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if recovered := recover(); recovered != nil {
err := fmt.Errorf("panic: %v", recovered)
_ = c.Error(err)
c.Set(contextPanicStack, string(debug.Stack()))
if !c.Writer.Written() {
response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用")
}
c.Abort()
}
}()
c.Next()
}
}
+66
View File
@@ -0,0 +1,66 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"sync/atomic"
"time"
"hfb_sys/backend/internal/logging"
"github.com/gin-gonic/gin"
)
var requestIDFallbackCounter atomic.Uint64
const (
RequestIDHeader = "X-Request-ID"
ContextRequestID = "request_id"
)
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader(RequestIDHeader)
if !validRequestID(requestID) {
requestID = newRequestID()
}
c.Set(ContextRequestID, requestID)
c.Writer.Header().Set(RequestIDHeader, requestID)
c.Request = c.Request.WithContext(logging.WithRequestID(c.Request.Context(), requestID))
c.Next()
}
}
func GetRequestID(c *gin.Context) string {
value, ok := c.Get(ContextRequestID)
if !ok {
return ""
}
requestID, ok := value.(string)
if !ok {
return ""
}
return requestID
}
func newRequestID() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("%x-%x", time.Now().UnixNano(), requestIDFallbackCounter.Add(1))
}
return hex.EncodeToString(buf)
}
func validRequestID(value string) bool {
if value == "" || len(value) > 64 {
return false
}
return strings.IndexFunc(value, func(char rune) bool {
return !((char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
char == '-' || char == '_' || char == '.')
}) == -1
}
@@ -0,0 +1,24 @@
package middleware
import "testing"
func TestValidRequestID(t *testing.T) {
tests := []struct {
name string
value string
want bool
}{
{name: "标准 ID", value: "req-20260729_ab.cd", want: true},
{name: "空值", value: "", want: false},
{name: "包含空格", value: "bad id", want: false},
{name: "包含换行", value: "bad\nid", want: false},
{name: "超长", value: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validRequestID(tt.value); got != tt.want {
t.Fatalf("validRequestID() = %v, want %v", got, tt.want)
}
})
}
}
@@ -0,0 +1,144 @@
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"time"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// 查询请求默认静默;成功写操作、慢请求、服务端错误、限流和有诊断价值的认证失败会输出。
const slowRequestThresholdMs = 500
func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
latencyMs := float64(time.Since(start).Microseconds()) / 1000
status := c.Writer.Status()
path := c.Request.URL.Path
route := c.FullPath()
authFailure := meaningfulAuthFailure(c)
operation := meaningfulOperation(c.Request.Method, path, status)
if shouldSkipHTTPLog(path, route, status, latencyMs) && !authFailure && !operation {
return
}
fields := []zap.Field{
zap.String("request_id", GetRequestID(c)),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.String("route", route),
zap.Int("status", status),
zap.String("code", response.CodeFromContext(c)),
zap.Float64("duration_ms", latencyMs),
zap.String("client_ip", c.ClientIP()),
}
if userID, ok := c.Get(ContextUserID); ok {
fields = append(fields, zap.Any("user_id", userID))
}
if adminID, ok := c.Get(ContextAdminID); ok {
fields = append(fields, zap.Any("admin_id", adminID))
}
if reason, ok := c.Get(ContextAuthFailureReason); ok {
fields = append(fields, zap.Any("auth_failure_reason", reason))
}
if source, ok := c.Get(ContextAuthTokenSource); ok {
fields = append(fields, zap.Any("auth_token_source", source))
}
if detail, ok := c.Get(ContextAuthFailureDetail); ok {
fields = append(fields, zap.Any("auth_failure_detail", detail))
}
if tokenVersion, ok := c.Get(ContextAuthTokenVersion); ok {
fields = append(fields, zap.Any("auth_token_version", tokenVersion))
}
if currentVersion, ok := c.Get(ContextAuthCurrentVersion); ok {
fields = append(fields, zap.Any("auth_current_token_version", currentVersion))
}
if len(c.Errors) > 0 {
fields = append(fields,
zap.String("error", c.Errors.Last().Err.Error()),
zap.Int("error_count", len(c.Errors)),
)
}
if stack, ok := c.Get(contextPanicStack); ok {
fields = append(fields, zap.Any("stack", stack))
}
switch {
case errors.Is(c.Request.Context().Err(), context.Canceled):
if logger.Core().Enabled(zap.DebugLevel) {
logger.Debug("HTTP 客户端中断连接", fields...)
}
case status >= 500:
logger.Error("HTTP 请求失败", fields...)
case status == http.StatusTooManyRequests:
logger.Warn("HTTP 请求被限流", fields...)
case authFailure:
logger.Warn("后台认证失败", fields...)
case latencyMs >= slowRequestThresholdMs:
logger.Warn("HTTP 慢请求", fields...)
case operation:
logger.Info("HTTP 操作完成", fields...)
}
}
}
func meaningfulOperation(method, path string, status int) bool {
if status < 200 || status >= 400 {
return false
}
// 支付回调已有更完整的业务结果日志,不再重复记录一条访问日志。
if isPaymentNotifyPath(path) {
return false
}
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
func isPaymentNotifyPath(path string) bool {
return (strings.Contains(path, "/payment/") || strings.Contains(path, "/payments/")) &&
strings.HasSuffix(strings.TrimSuffix(path, "/"), "/notify")
}
// shouldSkipHTTPLog 判断是否为无需记录的普通请求。
func shouldSkipHTTPLog(path, route string, status int, latencyMs float64) bool {
if status >= 500 || status == http.StatusTooManyRequests {
return false
}
// SSE 的请求耗时就是连接存活时间,由 chathub 单独记录连接生命周期。
if isSSEPath(path, route) {
return true
}
if latencyMs >= slowRequestThresholdMs {
return false
}
return true
}
func isSSEPath(path, route string) bool {
return path == "/api/auth/events" || route == "/api/auth/events" ||
path == "/api/chats/events" || route == "/api/chats/events" ||
path == "/api/admin/chats/events" || route == "/api/admin/chats/events"
}
func meaningfulAuthFailure(c *gin.Context) bool {
value, ok := c.Get(ContextAuthFailureReason)
if !ok {
return false
}
reason, _ := value.(string)
return reason != "" && reason != "missing"
}
@@ -0,0 +1,52 @@
package middleware
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestRequestLoggerRecordsServerErrorCause(t *testing.T) {
gin.SetMode(gin.TestMode)
core, observed := observer.New(zap.DebugLevel)
engine := gin.New()
engine.Use(RequestID(), RequestLogger(zap.New(core)))
engine.GET("/failed", func(c *gin.Context) {
response.RecordError(c, errors.New("database unavailable"))
response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用")
})
request := httptest.NewRequest(http.MethodGet, "/failed", nil)
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
entries := observed.FilterMessage("HTTP 请求失败").All()
if len(entries) != 1 {
t.Fatalf("server error logs = %d, want 1", len(entries))
}
fields := entries[0].ContextMap()
if fields["error"] != "database unavailable" || fields["code"] != "internal_error" {
t.Fatalf("unexpected fields: %v", fields)
}
}
func TestRequestLoggerSkipsOrdinaryNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
core, observed := observer.New(zap.DebugLevel)
engine := gin.New()
engine.Use(RequestID(), RequestLogger(zap.New(core)))
request := httptest.NewRequest(http.MethodGet, "/missing", nil)
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if observed.Len() != 0 {
t.Fatalf("ordinary 404 should not produce logs: %v", observed.All())
}
}
@@ -0,0 +1,61 @@
package middleware
import "testing"
func TestShouldSkipHTTPLog(t *testing.T) {
tests := []struct {
name string
path string
route string
status int
latencyMs float64
wantSkip bool
}{
{name: "普通成功请求跳过", path: "/api/orders", status: 200, latencyMs: 20, wantSkip: true},
{name: "创建成功请求跳过", path: "/api/orders", status: 201, latencyMs: 30, wantSkip: true},
{name: "普通 400 跳过", path: "/api/orders", status: 400, latencyMs: 2, wantSkip: true},
{name: "普通 401 跳过", path: "/api/orders", status: 401, latencyMs: 2, wantSkip: true},
{name: "普通 404 跳过", path: "/unknown", status: 404, latencyMs: 2, wantSkip: true},
{name: "轮询 500 不跳过", path: "/api/wallet/balance", status: 500, latencyMs: 1, wantSkip: false},
{name: "限流请求不跳过", path: "/api/auth/sms", status: 429, latencyMs: 1, wantSkip: false},
{name: "慢请求不跳过", path: "/api/orders", status: 200, latencyMs: 500, wantSkip: false},
{name: "用户全局通知 SSE 长连接跳过", path: "/api/auth/events", route: "/api/auth/events", status: 200, latencyMs: 60_000, wantSkip: true},
{name: "用户 SSE 长连接跳过", path: "/api/chats/events", route: "/api/chats/events", status: 200, latencyMs: 60_000, wantSkip: true},
{name: "后台 SSE 长连接跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 200, latencyMs: 600_000, wantSkip: true},
{name: "SSE 服务端错误不跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 500, latencyMs: 600_000, wantSkip: false},
{name: "SSE 限流不跳过", path: "/api/admin/chats/events", route: "/api/admin/chats/events", status: 429, latencyMs: 1, wantSkip: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldSkipHTTPLog(tt.path, tt.route, tt.status, tt.latencyMs)
if got != tt.wantSkip {
t.Fatalf("shouldSkipHTTPLog() = %v, want %v", got, tt.wantSkip)
}
})
}
}
func TestMeaningfulOperation(t *testing.T) {
tests := []struct {
name string
method string
path string
status int
want bool
}{
{name: "创建成功", method: "POST", path: "/api/orders", status: 201, want: true},
{name: "更新成功", method: "PUT", path: "/api/orders/1", status: 200, want: true},
{name: "删除成功", method: "DELETE", path: "/api/orders/1", status: 204, want: true},
{name: "普通查询", method: "GET", path: "/api/orders", status: 200, want: false},
{name: "失败写操作", method: "POST", path: "/api/orders", status: 400, want: false},
{name: "支付回调已有业务日志", method: "POST", path: "/api/payments/lakala/notify", status: 200, want: false},
{name: "支付回调尾部斜杠", method: "POST", path: "/api/payments/leshua/notify/", status: 200, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := meaningfulOperation(tt.method, tt.path, tt.status); got != tt.want {
t.Fatalf("meaningfulOperation() = %v, want %v", got, tt.want)
}
})
}
}
@@ -0,0 +1,132 @@
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
}
@@ -0,0 +1,153 @@
package middleware
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"hfb_sys/backend/internal/sitecode"
"github.com/gin-gonic/gin"
)
type fakeSiteDomainResolver struct {
byDomain map[string]string
err error
calls int
hosts []string
}
func (f *fakeSiteDomainResolver) ResolveSiteCodeByDomain(_ context.Context, domain string) (string, error) {
f.calls++
f.hosts = append(f.hosts, domain)
if f.err != nil {
return "", f.err
}
return f.byDomain[domain], nil
}
// newSiteFallbackRouter 构建一个把 sitecode.FromRequest 结果回显的路由,便于断言兜底结果。
func newSiteFallbackRouter(resolver SiteDomainResolver) *gin.Engine {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(SiteDomainFallback(resolver, nil))
engine.GET("/probe", func(c *gin.Context) {
c.String(http.StatusOK, sitecode.FromRequest(c))
})
return engine
}
func doSiteFallbackRequest(t *testing.T, engine *gin.Engine, host, target string, headers map[string]string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, target, nil)
req.Host = host
for key, value := range headers {
req.Header.Set(key, value)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", recorder.Code)
}
return recorder.Body.String()
}
func TestSiteDomainFallbackResolvesByHost(t *testing.T) {
resolver := &fakeSiteDomainResolver{byDomain: map[string]string{"tysh.hao858.com": "gz_dist"}}
engine := newSiteFallbackRouter(resolver)
if got := doSiteFallbackRequest(t, engine, "tysh.hao858.com", "/probe", nil); got != "gz_dist" {
t.Fatalf("站点域名应解析为 gz_dist, got %q", got)
}
// 带端口的 Host 也要能解析
if got := doSiteFallbackRequest(t, engine, "tysh.hao858.com:443", "/probe", nil); got != "gz_dist" {
t.Fatalf("带端口站点域名应解析为 gz_dist, got %q", got)
}
// 主站域名无匹配:保持为空
if got := doSiteFallbackRequest(t, engine, "hao858.com", "/probe", nil); got != "" {
t.Fatalf("主站域名不应被识别为分站, got %q", got)
}
}
func TestSiteDomainFallbackPrefersExplicitSiteCode(t *testing.T) {
resolver := &fakeSiteDomainResolver{byDomain: map[string]string{"tysh.hao858.com": "gz_dist"}}
engine := newSiteFallbackRouter(resolver)
// 显式头优先:即使域名能解析出 gz_dist,也必须尊重前端给的站点
cases := []struct {
name string
target string
headers map[string]string
}{
{name: "权威头", target: "/probe", headers: map[string]string{sitecode.HeaderName: "other_site"}},
{name: "旧头", target: "/probe", headers: map[string]string{sitecode.LegacyHeaderName: "legacy_site"}},
{name: "query", target: "/probe?site_code=query_site", headers: nil},
{name: "旧query", target: "/probe?distribution_site_code=legacy_query", headers: nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := doSiteFallbackRequest(t, engine, "tysh.hao858.com", tc.target, tc.headers)
if got == "gz_dist" || got == "" {
t.Fatalf("显式站点应优先于域名兜底, got %q", got)
}
})
}
if resolver.calls != 0 {
t.Fatalf("显式站点不应触发域名解析, calls = %d", resolver.calls)
}
}
func TestSiteDomainFallbackSkipsLocalAndIPHosts(t *testing.T) {
resolver := &fakeSiteDomainResolver{byDomain: map[string]string{}}
engine := newSiteFallbackRouter(resolver)
for _, host := range []string{"localhost:8080", "127.0.0.1:18088", "10.0.0.5:8080"} {
if got := doSiteFallbackRequest(t, engine, host, "/probe", nil); got != "" {
t.Fatalf("本地/内网地址 %s 不应解析站点, got %q", host, got)
}
}
// 本地地址直接跳过;10.0.0.5 不是 IsLocalHost 白名单,会查一次但无匹配
if resolver.calls > 1 {
t.Fatalf("本地地址不应反复查询, calls = %d", resolver.calls)
}
}
func TestSiteDomainFallbackFallsBackToForwardedHost(t *testing.T) {
resolver := &fakeSiteDomainResolver{byDomain: map[string]string{"tysh.hao858.com": "gz_dist"}}
engine := newSiteFallbackRouter(resolver)
got := doSiteFallbackRequest(t, engine, "hao858.com", "/probe", map[string]string{
"X-Forwarded-Host": "tysh.hao858.com",
})
if got != "gz_dist" {
t.Fatalf("应回退到 X-Forwarded-Host 解析站点, got %q", got)
}
}
func TestSiteDomainFallbackCachesAndFailsOpen(t *testing.T) {
resolver := &fakeSiteDomainResolver{byDomain: map[string]string{"tysh.hao858.com": "gz_dist"}}
engine := newSiteFallbackRouter(resolver)
for i := 0; i < 3; i++ {
if got := doSiteFallbackRequest(t, engine, "tysh.hao858.com", "/probe", nil); got != "gz_dist" {
t.Fatalf("第 %d 次解析 = %q, want gz_dist", i+1, got)
}
}
if resolver.calls != 1 {
t.Fatalf("同域名应命中缓存只查一次, calls = %d", resolver.calls)
}
// 解析失败时不阻断请求,退化为未指定站点
failing := &fakeSiteDomainResolver{err: errors.New("db down")}
failEngine := newSiteFallbackRouter(failing)
for i := 0; i < 3; i++ {
if got := doSiteFallbackRequest(t, failEngine, "tysh.hao858.com", "/probe", nil); got != "" {
t.Fatalf("解析失败应退化为空站点, got %q", got)
}
}
if failing.calls != 1 {
t.Fatalf("解析失败也应缓存避免刷日志, calls = %d", failing.calls)
}
}