chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user