重构项目的实现,优化使用方法与使用逻辑
This commit is contained in:
26
middleware/clientip.go
Normal file
26
middleware/clientip.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// GetClientIP 获取客户端真实 IP
|
||||
func GetClientIP(r *http.Request) string {
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if xff != "" {
|
||||
for i := 0; i < len(xff); i++ {
|
||||
if xff[i] == ',' {
|
||||
return xff[:i]
|
||||
}
|
||||
}
|
||||
return xff
|
||||
}
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
return xri
|
||||
}
|
||||
remoteAddr := r.RemoteAddr
|
||||
for i := len(remoteAddr) - 1; i >= 0; i-- {
|
||||
if remoteAddr[i] == ':' {
|
||||
return remoteAddr[:i]
|
||||
}
|
||||
}
|
||||
return remoteAddr
|
||||
}
|
||||
@@ -4,10 +4,9 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LanguageKey context中存储语言的key
|
||||
type languageKey struct{}
|
||||
"git.toowon.com/jimmy/go-common/requestctx"
|
||||
)
|
||||
|
||||
// LanguageHeaderName 语言请求头名称
|
||||
const LanguageHeaderName = "X-Language"
|
||||
@@ -15,15 +14,9 @@ const LanguageHeaderName = "X-Language"
|
||||
// AcceptLanguageHeaderName Accept-Language 请求头名称
|
||||
const AcceptLanguageHeaderName = "Accept-Language"
|
||||
|
||||
// DefaultLanguage 默认语言
|
||||
const DefaultLanguage = "zh-CN"
|
||||
|
||||
// GetLanguageFromContext 从context中获取语言
|
||||
// GetLanguageFromContext 从 context 中获取语言
|
||||
func GetLanguageFromContext(ctx context.Context) string {
|
||||
if lang, ok := ctx.Value(languageKey{}).(string); ok && lang != "" {
|
||||
return lang
|
||||
}
|
||||
return DefaultLanguage
|
||||
return requestctx.Language(ctx)
|
||||
}
|
||||
|
||||
// Language 语言处理中间件
|
||||
@@ -44,11 +37,10 @@ func Language(next http.Handler) http.Handler {
|
||||
|
||||
// 3. 如果都未设置,使用默认语言
|
||||
if lang == "" {
|
||||
lang = DefaultLanguage
|
||||
lang = requestctx.DefaultLanguage
|
||||
}
|
||||
|
||||
// 将语言存储到context中
|
||||
ctx := context.WithValue(r.Context(), languageKey{}, lang)
|
||||
ctx := requestctx.WithLanguage(r.Context(), lang)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@@ -74,8 +66,7 @@ func LanguageWithDefault(defaultLanguage string) func(http.Handler) http.Handler
|
||||
lang = defaultLanguage
|
||||
}
|
||||
|
||||
// 将语言存储到context中
|
||||
ctx := context.WithValue(r.Context(), languageKey{}, lang)
|
||||
ctx := requestctx.WithLanguage(r.Context(), lang)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,17 +2,15 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.toowon.com/jimmy/go-common/logger"
|
||||
)
|
||||
|
||||
// responseWriter 包装 http.ResponseWriter 以捕获状态码和响应大小
|
||||
// responseWriter 包装 ResponseWriter 以捕获状态码
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
size int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(statusCode int) {
|
||||
@@ -20,202 +18,49 @@ func (rw *responseWriter) WriteHeader(statusCode int) {
|
||||
rw.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
size, err := rw.ResponseWriter.Write(b)
|
||||
rw.size += size
|
||||
return size, err
|
||||
}
|
||||
|
||||
// LoggingConfig 日志中间件配置
|
||||
type LoggingConfig struct {
|
||||
// Logger 日志记录器(可选,如果为nil则使用默认logger)
|
||||
Logger *logger.Logger
|
||||
|
||||
// SkipPaths 跳过记录的路径列表(如健康检查接口)
|
||||
Logger *logger.Logger
|
||||
SkipPaths []string
|
||||
|
||||
// LogRequestBody 是否记录请求体(谨慎使用,可能影响性能)
|
||||
LogRequestBody bool
|
||||
|
||||
// LogResponseBody 是否记录响应体(谨慎使用,可能影响性能和内存)
|
||||
LogResponseBody bool
|
||||
}
|
||||
|
||||
// Logging HTTP请求日志中间件
|
||||
// 记录每个HTTP请求的详细信息,包括:
|
||||
// - 请求方法、路径、IP、User-Agent
|
||||
// - 响应状态码、响应大小
|
||||
// - 请求处理时间
|
||||
//
|
||||
// 使用方式1:使用默认logger
|
||||
//
|
||||
// chain := middleware.NewChain(
|
||||
// middleware.Logging(nil),
|
||||
// )
|
||||
//
|
||||
// 使用方式2:使用自定义logger
|
||||
//
|
||||
// myLogger, _ := logger.NewLogger(loggerConfig)
|
||||
// chain := middleware.NewChain(
|
||||
// middleware.Logging(&middleware.LoggingConfig{
|
||||
// Logger: myLogger,
|
||||
// SkipPaths: []string{"/health", "/metrics"},
|
||||
// }),
|
||||
// )
|
||||
// Logging HTTP 访问日志(固定 Info 级别)
|
||||
func Logging(config *LoggingConfig) func(http.Handler) http.Handler {
|
||||
// 如果没有配置,使用默认配置
|
||||
if config == nil {
|
||||
config = &LoggingConfig{}
|
||||
}
|
||||
|
||||
// 如果没有提供logger,创建一个默认的
|
||||
if config.Logger == nil {
|
||||
// 使用默认配置创建logger(输出到stdout,info级别)
|
||||
defaultLogger, err := logger.NewLogger(nil)
|
||||
if err != nil {
|
||||
// 如果创建失败,使用nil,后面会降级处理
|
||||
config.Logger = nil
|
||||
} else {
|
||||
config.Logger = defaultLogger
|
||||
}
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 检查是否跳过此路径
|
||||
if shouldSkipPath(r.URL.Path, config.SkipPaths) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 记录开始时间
|
||||
startTime := time.Now()
|
||||
|
||||
// 包装 ResponseWriter 以捕获状态码和响应大小
|
||||
rw := &responseWriter{
|
||||
ResponseWriter: w,
|
||||
statusCode: http.StatusOK, // 默认200
|
||||
size: 0,
|
||||
}
|
||||
|
||||
// 处理请求
|
||||
start := time.Now()
|
||||
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
// 计算处理时间
|
||||
duration := time.Since(startTime)
|
||||
fields := map[string]any{
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"duration": time.Since(start).Milliseconds(),
|
||||
}
|
||||
|
||||
// 记录日志
|
||||
logHTTPRequest(config.Logger, r, rw, duration)
|
||||
log := logger.FromContext(r.Context())
|
||||
if config.Logger != nil {
|
||||
log = logger.FromContextWithLogger(r.Context(), config.Logger)
|
||||
}
|
||||
log.Info("HTTP Request", fields)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// shouldSkipPath 检查是否应该跳过该路径
|
||||
func shouldSkipPath(path string, skipPaths []string) bool {
|
||||
for _, skipPath := range skipPaths {
|
||||
if path == skipPath {
|
||||
for _, skip := range skipPaths {
|
||||
if path == skip {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// logHTTPRequest 记录HTTP请求日志
|
||||
func logHTTPRequest(log *logger.Logger, r *http.Request, rw *responseWriter, duration time.Duration) {
|
||||
// 获取客户端IP
|
||||
clientIP := GetClientIP(r)
|
||||
|
||||
// 构建日志字段
|
||||
fields := map[string]interface{}{
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"query": r.URL.RawQuery,
|
||||
"status": rw.statusCode,
|
||||
"size": rw.size,
|
||||
"duration": duration.Milliseconds(), // 毫秒
|
||||
"ip": clientIP,
|
||||
"user_agent": r.UserAgent(),
|
||||
"referer": r.Referer(),
|
||||
}
|
||||
|
||||
// 构建日志消息
|
||||
message := "HTTP Request"
|
||||
|
||||
// 根据状态码选择日志级别
|
||||
if log != nil {
|
||||
// 使用提供的logger
|
||||
if rw.statusCode >= 500 {
|
||||
log.Errorf(fields, message)
|
||||
} else if rw.statusCode >= 400 {
|
||||
log.Warnf(fields, message)
|
||||
} else {
|
||||
log.Infof(fields, message)
|
||||
}
|
||||
} else {
|
||||
// 降级处理:使用标准输出
|
||||
// 注意:这是同步的,不会有性能问题
|
||||
if rw.statusCode >= 500 {
|
||||
logToStdout("ERROR", fields, message)
|
||||
} else if rw.statusCode >= 400 {
|
||||
logToStdout("WARN", fields, message)
|
||||
} else {
|
||||
logToStdout("INFO", fields, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logToStdout 降级处理:输出到标准输出(当logger不可用时)
|
||||
func logToStdout(level string, fields map[string]interface{}, message string) {
|
||||
// 简单的标准输出日志
|
||||
var fieldStr string
|
||||
for k, v := range fields {
|
||||
fieldStr += " " + k + "=" + formatValue(v)
|
||||
}
|
||||
println("[" + level + "] " + message + fieldStr)
|
||||
}
|
||||
|
||||
// formatValue 格式化值(用于日志输出)
|
||||
func formatValue(v interface{}) string {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case int:
|
||||
return strconv.Itoa(val)
|
||||
case int64:
|
||||
return strconv.FormatInt(val, 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// GetClientIP 获取客户端真实IP
|
||||
// 优先级:X-Forwarded-For > X-Real-IP > RemoteAddr
|
||||
func GetClientIP(r *http.Request) string {
|
||||
// 尝试从 X-Forwarded-For 获取
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if xff != "" {
|
||||
// X-Forwarded-For 可能包含多个IP,取第一个
|
||||
for idx := 0; idx < len(xff); idx++ {
|
||||
if xff[idx] == ',' {
|
||||
return xff[:idx]
|
||||
}
|
||||
}
|
||||
return xff
|
||||
}
|
||||
|
||||
// 尝试从 X-Real-IP 获取
|
||||
xri := r.Header.Get("X-Real-IP")
|
||||
if xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
// 使用 RemoteAddr
|
||||
remoteAddr := r.RemoteAddr
|
||||
// 移除端口号
|
||||
for i := len(remoteAddr) - 1; i >= 0; i-- {
|
||||
if remoteAddr[i] == ':' {
|
||||
return remoteAddr[:i]
|
||||
}
|
||||
}
|
||||
return remoteAddr
|
||||
}
|
||||
|
||||
@@ -5,145 +5,43 @@ import (
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
commonhttp "git.toowon.com/jimmy/go-common/http"
|
||||
"git.toowon.com/jimmy/go-common/i18n"
|
||||
"git.toowon.com/jimmy/go-common/logger"
|
||||
)
|
||||
|
||||
// RecoveryConfig Recovery中间件配置
|
||||
// RecoveryConfig Recovery 中间件配置
|
||||
type RecoveryConfig struct {
|
||||
// Logger 日志记录器(可选,如果为nil则使用默认logger)
|
||||
Logger *logger.Logger
|
||||
|
||||
// EnableStackTrace 是否在日志中包含堆栈跟踪
|
||||
EnableStackTrace bool
|
||||
|
||||
// CustomHandler 自定义错误处理函数(可选)
|
||||
// 如果设置了,会在记录日志后调用此函数
|
||||
// 可以用于自定义错误响应格式
|
||||
CustomHandler func(w http.ResponseWriter, r *http.Request, err interface{})
|
||||
I18n *i18n.I18n
|
||||
}
|
||||
|
||||
// Recovery Panic恢复中间件
|
||||
// 捕获HTTP处理过程中的panic,记录错误日志并返回500错误
|
||||
// 防止panic导致整个服务崩溃
|
||||
//
|
||||
// 使用方式1:使用默认配置
|
||||
//
|
||||
// chain := middleware.NewChain(
|
||||
// middleware.Recovery(nil),
|
||||
// )
|
||||
//
|
||||
// 使用方式2:使用自定义配置
|
||||
//
|
||||
// myLogger, _ := logger.NewLogger(loggerConfig)
|
||||
// chain := middleware.NewChain(
|
||||
// middleware.Recovery(&middleware.RecoveryConfig{
|
||||
// Logger: myLogger,
|
||||
// EnableStackTrace: true,
|
||||
// }),
|
||||
// )
|
||||
//
|
||||
// 使用方式3:自定义错误响应
|
||||
//
|
||||
// chain := middleware.NewChain(
|
||||
// middleware.Recovery(&middleware.RecoveryConfig{
|
||||
// Logger: myLogger,
|
||||
// CustomHandler: func(w http.ResponseWriter, r *http.Request, err interface{}) {
|
||||
// // 自定义JSON响应
|
||||
// w.Header().Set("Content-Type", "application/json")
|
||||
// w.WriteHeader(http.StatusInternalServerError)
|
||||
// w.Write([]byte(`{"code":500,"message":"Internal Server Error"}`))
|
||||
// },
|
||||
// }),
|
||||
// )
|
||||
// Recovery Panic 恢复中间件,响应统一 JSON 200 + system.internal_error
|
||||
func Recovery(config *RecoveryConfig) func(http.Handler) http.Handler {
|
||||
// 如果没有配置,使用默认配置
|
||||
if config == nil {
|
||||
config = &RecoveryConfig{
|
||||
EnableStackTrace: true, // 默认启用堆栈跟踪
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有提供logger,创建一个默认的
|
||||
if config.Logger == nil {
|
||||
defaultLogger, err := logger.NewLogger(nil)
|
||||
if err != nil {
|
||||
config.Logger = nil
|
||||
} else {
|
||||
config.Logger = defaultLogger
|
||||
}
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
// 记录panic信息
|
||||
logPanic(config.Logger, r, err, config.EnableStackTrace)
|
||||
|
||||
// 如果提供了自定义处理函数,调用它
|
||||
if config.CustomHandler != nil {
|
||||
config.CustomHandler(w, r, err)
|
||||
return
|
||||
fields := map[string]any{
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"error": fmt.Sprintf("%v", err),
|
||||
"stack": string(debug.Stack()),
|
||||
}
|
||||
log := logger.FromContextWithLogger(r.Context(), nil)
|
||||
if config != nil && config.Logger != nil {
|
||||
log = logger.FromContextWithLogger(r.Context(), config.Logger)
|
||||
}
|
||||
log.Error("panic recovered", fields)
|
||||
|
||||
// 默认错误响应
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
h := commonhttp.NewHandler(w, r)
|
||||
if config != nil && config.I18n != nil {
|
||||
h = commonhttp.NewHandler(w, r, commonhttp.WithI18n(config.I18n))
|
||||
}
|
||||
h.Error("system.internal_error")
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// logPanic 记录panic日志
|
||||
func logPanic(log *logger.Logger, r *http.Request, err interface{}, enableStackTrace bool) {
|
||||
// 获取堆栈跟踪
|
||||
var stack string
|
||||
if enableStackTrace {
|
||||
stack = string(debug.Stack())
|
||||
}
|
||||
|
||||
// 构建日志字段
|
||||
fields := map[string]interface{}{
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"query": r.URL.RawQuery,
|
||||
"ip": GetClientIP(r),
|
||||
"error": fmt.Sprintf("%v", err),
|
||||
}
|
||||
|
||||
// 构建日志消息
|
||||
message := "Panic recovered"
|
||||
if enableStackTrace && stack != "" {
|
||||
message += "\n" + stack
|
||||
}
|
||||
|
||||
// 记录错误日志
|
||||
if log != nil {
|
||||
log.Errorf(fields, message)
|
||||
} else {
|
||||
// 降级处理:输出到标准错误
|
||||
fmt.Printf("[ERROR] %s\n", message)
|
||||
for k, v := range fields {
|
||||
fmt.Printf(" %s: %v\n", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RecoveryWithLogger 使用指定logger的Recovery中间件(便捷函数)
|
||||
func RecoveryWithLogger(log *logger.Logger) func(http.Handler) http.Handler {
|
||||
return Recovery(&RecoveryConfig{
|
||||
Logger: log,
|
||||
EnableStackTrace: true,
|
||||
})
|
||||
}
|
||||
|
||||
// RecoveryWithCustomHandler 使用自定义错误处理的Recovery中间件(便捷函数)
|
||||
func RecoveryWithCustomHandler(customHandler func(w http.ResponseWriter, r *http.Request, err interface{})) func(http.Handler) http.Handler {
|
||||
return Recovery(&RecoveryConfig{
|
||||
EnableStackTrace: true,
|
||||
CustomHandler: customHandler,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
23
middleware/requestid.go
Normal file
23
middleware/requestid.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.toowon.com/jimmy/go-common/logger"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RequestID 为每个请求生成或透传 Request ID,写入 context 与响应头
|
||||
func RequestID() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get("X-Request-ID")
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
ctx := logger.WithRequestID(r.Context(), id)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,77 +4,48 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"git.toowon.com/jimmy/go-common/requestctx"
|
||||
"git.toowon.com/jimmy/go-common/tools"
|
||||
)
|
||||
|
||||
// TimezoneKey context中存储时区的key
|
||||
type timezoneKey struct{}
|
||||
|
||||
// TimezoneHeaderName 时区请求头名称
|
||||
const TimezoneHeaderName = "X-Timezone"
|
||||
|
||||
// DefaultTimezone 默认时区
|
||||
const DefaultTimezone = tools.AsiaShanghai
|
||||
|
||||
// GetTimezoneFromContext 从context中获取时区
|
||||
// GetTimezoneFromContext 从 context 中获取时区
|
||||
func GetTimezoneFromContext(ctx context.Context) string {
|
||||
if tz, ok := ctx.Value(timezoneKey{}).(string); ok && tz != "" {
|
||||
return tz
|
||||
}
|
||||
return DefaultTimezone
|
||||
return requestctx.Timezone(ctx)
|
||||
}
|
||||
|
||||
// Timezone 时区处理中间件
|
||||
// 从请求头 X-Timezone 读取时区信息,如果未传递则使用默认时区 AsiaShanghai
|
||||
// 时区信息会存储到context中,可以通过 GetTimezoneFromContext 获取
|
||||
func Timezone(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 从请求头获取时区
|
||||
timezone := r.Header.Get(TimezoneHeaderName)
|
||||
|
||||
// 如果未传递时区信息,使用默认时区
|
||||
if timezone == "" {
|
||||
timezone = DefaultTimezone
|
||||
timezone = requestctx.DefaultTimezone
|
||||
}
|
||||
|
||||
// 验证时区是否有效
|
||||
if _, err := tools.GetLocation(timezone); err != nil {
|
||||
// 如果时区无效,使用默认时区
|
||||
timezone = DefaultTimezone
|
||||
timezone = requestctx.DefaultTimezone
|
||||
}
|
||||
|
||||
// 将时区存储到context中
|
||||
ctx := context.WithValue(r.Context(), timezoneKey{}, timezone)
|
||||
ctx := requestctx.WithTimezone(r.Context(), timezone)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// TimezoneWithDefault 时区处理中间件(可自定义默认时区)
|
||||
// defaultTimezone: 默认时区,如果未指定则使用 AsiaShanghai
|
||||
func TimezoneWithDefault(defaultTimezone string) func(http.Handler) http.Handler {
|
||||
// 验证默认时区是否有效
|
||||
if _, err := tools.GetLocation(defaultTimezone); err != nil {
|
||||
defaultTimezone = DefaultTimezone
|
||||
defaultTimezone = requestctx.DefaultTimezone
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 从请求头获取时区
|
||||
timezone := r.Header.Get(TimezoneHeaderName)
|
||||
|
||||
// 如果未传递时区信息,使用指定的默认时区
|
||||
if timezone == "" {
|
||||
timezone = defaultTimezone
|
||||
}
|
||||
|
||||
// 验证时区是否有效
|
||||
if _, err := tools.GetLocation(timezone); err != nil {
|
||||
// 如果时区无效,使用默认时区
|
||||
timezone = defaultTimezone
|
||||
}
|
||||
|
||||
// 将时区存储到context中
|
||||
ctx := context.WithValue(r.Context(), timezoneKey{}, timezone)
|
||||
ctx := requestctx.WithTimezone(r.Context(), timezone)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user