合并 main 分支并融合订单列表最小化与 SQL 治理

- Auth/权限中间件取 main 演进版(错误细分、token 来源),保留 dev 的用户 cookie 与登出撤销语义
- 后台订单列表:融合 main 的首页免 COUNT 优化与关键词索引路由,保留 dev 的最小化 AdminOrderListItemDTO(结账明细仍由详情接口返回,同步调整对应集成测试)
- 保留 dev 的 token_version 冻结/解冻递增与 main 的状态变化判断及通知
- 前端:保留 dev 登出接口与 main 的 fetchMe silent 参数、SSE 登录跳转;后台订单表按 dev 最小化 DTO 移除号主到手列
This commit is contained in:
yml2213
2026-09-05 11:43:13 +08:00
294 changed files with 30171 additions and 13439 deletions
+21 -16
View File
@@ -35,6 +35,8 @@ type AdminTokenContext struct {
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 "))
@@ -44,13 +46,19 @@ func extractBearerToken(c *gin.Context) string {
return ""
}
type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error
func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin.HandlerFunc {
var validate UserTokenValidatorFunc
if len(validators) > 0 {
validate = validators[0]
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 == "" {
@@ -72,7 +80,11 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin
}
if validate != nil {
if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
response.Unauthorized(c, "访问令牌无效或已过期")
if errors.Is(err, auth.ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
} else {
response.Unauthorized(c, "登录状态已失效,请重新登录")
}
c.Abort()
return
}
@@ -86,16 +98,9 @@ func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) {
tokenText := extractBearerToken(c)
tokenSource := "bearer"
tokenText, tokenSource := extractAdminToken(c)
if tokenText == "" {
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
tokenText = strings.TrimSpace(cookieToken)
tokenSource = "cookie"
}
}
if tokenText == "" {
RecordAdminAuthFailure(c, "missing", "none", 0, 0)
RecordAdminAuthFailure(c, "missing", tokenSource, 0, 0)
response.Unauthorized(c, "缺少后台访问令牌")
c.Abort()
return
+26 -1
View File
@@ -1,6 +1,7 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
@@ -19,7 +20,7 @@ func TestUserAuthRejectsQueryToken(t *testing.T) {
}
engine := gin.New()
engine.GET("/protected", Auth(manager), func(c *gin.Context) {
engine.GET("/protected", Auth(manager, nil), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
@@ -38,3 +39,27 @@ func TestUserAuthRejectsQueryToken(t *testing.T) {
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)
}
}
+63 -21
View File
@@ -7,10 +7,14 @@ import (
"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
@@ -32,6 +36,16 @@ type redisRateLimiter struct {
}
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()
@@ -48,34 +62,62 @@ func RateLimitPerMinute(limit int, rdb *redis.Client) gin.HandlerFunc {
fallback: limiter,
limit: limit,
window: time.Minute,
}).handle
}).handle(keyFunc)
}
return limiter.handle
return limiter.handle(keyFunc)
}
func (l *redisRateLimiter) handle(c *gin.Context) {
now := time.Now()
key := c.ClientIP()
allowed, resetAt, err := l.allow(c.Request.Context(), key, now)
if err != nil {
allowed, resetAt = l.fallback.allow(key, now)
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()
}
if !allowed {
writeRateLimited(c, now, resetAt)
return
}
c.Next()
}
func (l *rateLimiter) handle(c *gin.Context) {
now := time.Now()
key := c.ClientIP()
allowed, resetAt := l.allow(key, now)
if !allowed {
writeRateLimited(c, now, resetAt)
return
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)
}
c.Next()
}
func (l *redisRateLimiter) allow(ctx context.Context, key string, now time.Time) (bool, time.Time, error) {
@@ -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)
}
}
+10 -1
View File
@@ -108,16 +108,25 @@ func isPaymentNotifyPath(path string) bool {
}
// shouldSkipHTTPLog 判断是否为无需记录的普通请求。
func shouldSkipHTTPLog(_, _ string, status int, latencyMs float64) bool {
func shouldSkipHTTPLog(path, route string, status int, latencyMs float64) bool {
if status >= 500 || status == http.StatusTooManyRequests {
return false
}
// SSE 的请求耗时就是连接存活时间,由 chathub 单独记录连接生命周期。
if isChatSSEPath(path, route) {
return true
}
if latencyMs >= slowRequestThresholdMs {
return false
}
return true
}
func isChatSSEPath(path, route string) bool {
return path == "/api/chats/events" || path == "/api/admin/chats/events" ||
route == "/api/chats/events" || route == "/api/admin/chats/events"
}
func meaningfulAuthFailure(c *gin.Context) bool {
value, ok := c.Get(ContextAuthFailureReason)
if !ok {
@@ -19,6 +19,10 @@ func TestShouldSkipHTTPLog(t *testing.T) {
{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/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) {