mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-13 03:44:23 +08:00
130 lines
3.8 KiB
Go
130 lines
3.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/truewhile/MeBox/internal/middleware"
|
|
)
|
|
|
|
// 移动端 v1 错误码。新增错误码必须同步 OpenAPI 与 App 的 ApiErrorMapper。
|
|
const (
|
|
mobileCodeInvalidArgument = "INVALID_ARGUMENT"
|
|
mobileCodeInvalidCredentials = "INVALID_CREDENTIALS"
|
|
mobileCodeTokenExpired = "TOKEN_EXPIRED"
|
|
mobileCodeSessionRevoked = "SESSION_REVOKED"
|
|
mobileCodeAccountDisabled = "ACCOUNT_DISABLED"
|
|
mobileCodeAccountExpired = "ACCOUNT_EXPIRED"
|
|
mobileCodePermissionDenied = "PERMISSION_DENIED"
|
|
mobileCodeProfilePINRequired = "PROFILE_PIN_REQUIRED"
|
|
mobileCodeRateLimited = "RATE_LIMITED"
|
|
mobileCodeUpstreamUnavailable = "UPSTREAM_UNAVAILABLE"
|
|
mobileCodeTranscodeBusy = "TRANSCODE_BUSY"
|
|
mobileCodePlaybackSessionAbsent = "PLAYBACK_SESSION_NOT_FOUND"
|
|
mobileCodeResourceExpired = "RESOURCE_EXPIRED"
|
|
mobileCodeProgressConflict = "PROGRESS_CONFLICT"
|
|
mobileCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
|
|
)
|
|
|
|
const (
|
|
mobileCtxRequestID = "mobile_request_id"
|
|
mobileCtxAuthSession = "mobile_auth_session"
|
|
mobileRequestIDHeader = "X-Request-ID"
|
|
)
|
|
|
|
// mobileErrorBody 是 v1 统一错误响应。字段直接位于顶层,便于客户端按
|
|
// code 分流,不需要再判断后端历史包装层。
|
|
type mobileErrorBody struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
RequestID string `json:"request_id"`
|
|
Retryable bool `json:"retryable"`
|
|
RetryAfterSec int `json:"retry_after_sec,omitempty"`
|
|
Details any `json:"details,omitempty"`
|
|
}
|
|
|
|
// mobileRequestIDMiddleware 为每个 v1 请求绑定可追踪的请求 ID。
|
|
// 合法客户端值会沿用(便于 App 与服务端日志对齐),非法或过长则由服务器
|
|
// 重新生成;该值仅用于诊断,绝不参与鉴权。
|
|
func mobileRequestIDMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
id := strings.TrimSpace(c.GetHeader(mobileRequestIDHeader))
|
|
if !validRequestID(id) {
|
|
id = uuid.NewString()
|
|
}
|
|
c.Set(mobileCtxRequestID, id)
|
|
c.Header(mobileRequestIDHeader, id)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func validRequestID(id string) bool {
|
|
if len(id) < 8 || len(id) > 128 {
|
|
return false
|
|
}
|
|
for _, r := range id {
|
|
if r > unicode.MaxASCII {
|
|
return false
|
|
}
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
continue
|
|
}
|
|
switch r {
|
|
case '-', '_', '.', ':':
|
|
continue
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func currentMobileRequestID(c *gin.Context) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
if v, ok := c.Get(mobileCtxRequestID); ok {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// writeMobileError 输出统一错误体。request_id 缺失时现场生成,保证所有
|
|
// v1 错误都可用于对账,而不是只在注册了中间件的路由上才有。
|
|
func writeMobileError(c *gin.Context, status int, code, message string, retryable bool, retryAfterSec int) {
|
|
requestID := currentMobileRequestID(c)
|
|
if requestID == "" {
|
|
requestID = uuid.NewString()
|
|
c.Header(mobileRequestIDHeader, requestID)
|
|
}
|
|
if retryAfterSec > 0 {
|
|
c.Header("Retry-After", strconv.Itoa(retryAfterSec))
|
|
}
|
|
c.AbortWithStatusJSON(status, mobileErrorBody{
|
|
Code: code,
|
|
Message: message,
|
|
RequestID: requestID,
|
|
Retryable: retryable,
|
|
RetryAfterSec: retryAfterSec,
|
|
})
|
|
}
|
|
|
|
// mobileRateLimit 与旧端点共用 RateLimiter,但返回 v1 错误契约和 Retry-After。
|
|
func mobileRateLimit(limiter *middleware.RateLimiter) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if !limiter.Allow(c.ClientIP()) {
|
|
writeMobileError(c, http.StatusTooManyRequests, mobileCodeRateLimited,
|
|
"too many requests, please try again later", true, 60)
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|