feat: 实现 RBAC 权限系统和数据权限控制 (004-rbac-data-permission)
主要功能: - 实现完整的 RBAC 权限系统(账号、角色、权限的多对多关联) - 基于 owner_id + shop_id 的自动数据权限过滤 - 使用 PostgreSQL WITH RECURSIVE 查询下级账号 - Redis 缓存优化下级账号查询性能(30分钟过期) - 支持多租户数据隔离和层级权限管理 技术实现: - 新增 Account、Role、Permission 模型及关联关系表 - 实现 GORM Scopes 自动应用数据权限过滤 - 添加数据库迁移脚本(000002_rbac_data_permission、000003_add_owner_id_shop_id) - 完善错误码定义(1010-1027 为 RBAC 相关错误) - 重构 main.go 采用函数拆分提高可读性 测试覆盖: - 添加 Account、Role、Permission 的集成测试 - 添加数据权限过滤的单元测试和集成测试 - 添加下级账号查询和缓存的单元测试 - 添加 API 回归测试确保向后兼容 文档更新: - 更新 README.md 添加 RBAC 功能说明 - 更新 CLAUDE.md 添加技术栈和开发原则 - 添加 docs/004-rbac-data-permission/ 功能总结和使用指南 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// globalConfig 保存当前配置,支持原子访问
|
||||
@@ -178,13 +177,3 @@ func Set(cfg *Config) error {
|
||||
globalConfig.Store(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// unsafeSet 无验证设置配置(仅供热重载内部使用)
|
||||
func unsafeSet(cfg *Config) {
|
||||
globalConfig.Store(cfg)
|
||||
}
|
||||
|
||||
// atomicSwap 原子地交换配置
|
||||
func atomicSwap(new *Config) *Config {
|
||||
return (*Config)(atomic.SwapPointer((*unsafe.Pointer)(unsafe.Pointer(&globalConfig)), unsafe.Pointer(new)))
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ middleware:
|
||||
{
|
||||
name: "environment-specific config (dev)",
|
||||
setupEnv: func() {
|
||||
os.Setenv(constants.EnvConfigEnv, "dev")
|
||||
_ = os.Setenv(constants.EnvConfigEnv, "dev")
|
||||
},
|
||||
cleanupEnv: func() {
|
||||
_ = os.Unsetenv(constants.EnvConfigEnv)
|
||||
@@ -202,8 +202,8 @@ middleware:
|
||||
{
|
||||
name: "invalid YAML syntax",
|
||||
setupEnv: func() {
|
||||
os.Setenv(constants.EnvConfigPath, "")
|
||||
os.Setenv(constants.EnvConfigEnv, "")
|
||||
_ = os.Setenv(constants.EnvConfigPath, "")
|
||||
_ = os.Setenv(constants.EnvConfigEnv, "")
|
||||
},
|
||||
cleanupEnv: func() {
|
||||
_ = os.Unsetenv(constants.EnvConfigPath)
|
||||
@@ -230,7 +230,7 @@ server:
|
||||
{
|
||||
name: "validation error - invalid server address",
|
||||
setupEnv: func() {
|
||||
os.Setenv(constants.EnvConfigPath, "")
|
||||
_ = os.Setenv(constants.EnvConfigPath, "")
|
||||
},
|
||||
cleanupEnv: func() {
|
||||
_ = os.Unsetenv(constants.EnvConfigPath)
|
||||
@@ -287,7 +287,7 @@ middleware:
|
||||
{
|
||||
name: "validation error - timeout out of range",
|
||||
setupEnv: func() {
|
||||
os.Setenv(constants.EnvConfigPath, "")
|
||||
_ = os.Setenv(constants.EnvConfigPath, "")
|
||||
},
|
||||
cleanupEnv: func() {
|
||||
_ = os.Unsetenv(constants.EnvConfigPath)
|
||||
@@ -344,7 +344,7 @@ middleware:
|
||||
{
|
||||
name: "validation error - invalid redis port",
|
||||
setupEnv: func() {
|
||||
os.Setenv(constants.EnvConfigPath, "")
|
||||
_ = os.Setenv(constants.EnvConfigPath, "")
|
||||
},
|
||||
cleanupEnv: func() {
|
||||
_ = os.Unsetenv(constants.EnvConfigPath)
|
||||
|
||||
@@ -71,8 +71,8 @@ middleware:
|
||||
}
|
||||
|
||||
// Set config path
|
||||
os.Setenv(constants.EnvConfigPath, configFile)
|
||||
defer os.Unsetenv(constants.EnvConfigPath)
|
||||
_ = os.Setenv(constants.EnvConfigPath, configFile)
|
||||
defer func() { _ = os.Unsetenv(constants.EnvConfigPath) }()
|
||||
|
||||
// Load initial config
|
||||
cfg, err := Load()
|
||||
@@ -226,8 +226,8 @@ middleware:
|
||||
}
|
||||
|
||||
// Set config path
|
||||
os.Setenv(constants.EnvConfigPath, configFile)
|
||||
defer os.Unsetenv(constants.EnvConfigPath)
|
||||
_ = os.Setenv(constants.EnvConfigPath, configFile)
|
||||
defer func() { _ = os.Unsetenv(constants.EnvConfigPath) }()
|
||||
|
||||
// Load initial config
|
||||
cfg, err := Load()
|
||||
@@ -385,8 +385,8 @@ middleware:
|
||||
t.Fatalf("failed to create config file: %v", err)
|
||||
}
|
||||
|
||||
os.Setenv(constants.EnvConfigPath, configFile)
|
||||
defer os.Unsetenv(constants.EnvConfigPath)
|
||||
_ = os.Setenv(constants.EnvConfigPath, configFile)
|
||||
defer func() { _ = os.Unsetenv(constants.EnvConfigPath) }()
|
||||
|
||||
// Load config
|
||||
_, err := Load()
|
||||
|
||||
@@ -4,9 +4,12 @@ import "time"
|
||||
|
||||
// Fiber Locals 的上下文键
|
||||
const (
|
||||
ContextKeyRequestID = "requestid"
|
||||
ContextKeyUserID = "user_id"
|
||||
ContextKeyStartTime = "start_time"
|
||||
ContextKeyRequestID = "requestid" // 请求记录ID
|
||||
ContextKeyStartTime = "start_time" //请求开始时间
|
||||
ContextKeyUserID = "user_id" // 用户ID
|
||||
ContextKeyUserType = "user_type" //用户类型
|
||||
ContextKeyShopID = "shop_id" //店铺ID
|
||||
ContextKeyUserInfo = "user_info" //完整的用户信息
|
||||
)
|
||||
|
||||
// 配置环境变量
|
||||
@@ -47,6 +50,33 @@ const (
|
||||
UserStatusSuspended = "suspended" // 暂停
|
||||
)
|
||||
|
||||
// RBAC 用户类型常量
|
||||
const (
|
||||
UserTypeRoot = 1 // root 用户(跳过数据权限过滤)
|
||||
UserTypePlatform = 2 // 平台用户
|
||||
UserTypeAgent = 3 // 代理用户
|
||||
UserTypeEnterprise = 4 // 企业用户
|
||||
)
|
||||
|
||||
// RBAC 角色类型常量
|
||||
const (
|
||||
RoleTypeSuper = 1 // 超级角色
|
||||
RoleTypeAgent = 2 // 代理角色
|
||||
RoleTypeEnterprise = 3 // 企业角色
|
||||
)
|
||||
|
||||
// RBAC 权限类型常量
|
||||
const (
|
||||
PermissionTypeMenu = 1 // 菜单权限
|
||||
PermissionTypeButton = 2 // 按钮权限
|
||||
)
|
||||
|
||||
// RBAC 状态常量
|
||||
const (
|
||||
StatusDisabled = 0 // 禁用
|
||||
StatusEnabled = 1 // 启用
|
||||
)
|
||||
|
||||
// 订单状态常量
|
||||
const (
|
||||
OrderStatusPending = "pending" // 待支付
|
||||
|
||||
@@ -25,3 +25,10 @@ func RedisTaskLockKey(requestID string) string {
|
||||
func RedisTaskStatusKey(taskID string) string {
|
||||
return fmt.Sprintf("task:status:%s", taskID)
|
||||
}
|
||||
|
||||
// RedisAccountSubordinatesKey 生成账号下级 ID 列表的 Redis 键
|
||||
// 用途:缓存递归查询的下级账号 ID 列表
|
||||
// 过期时间:30 分钟
|
||||
func RedisAccountSubordinatesKey(accountID uint) string {
|
||||
return fmt.Sprintf("account:subordinates:%d", accountID)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,26 @@ const (
|
||||
CodeTooManyRequests = 1008 // 请求过多
|
||||
CodeRequestTooLarge = 1009 // 请求体过大
|
||||
|
||||
// RBAC 相关错误 (1010-1099)
|
||||
CodeAccountNotFound = 1010 // 账号不存在
|
||||
CodeAccountDisabled = 1011 // 账号已禁用
|
||||
CodeAccountDeleted = 1012 // 账号已删除
|
||||
CodeUsernameExists = 1013 // 用户名已存在
|
||||
CodePhoneExists = 1014 // 手机号已存在
|
||||
CodeInvalidPassword = 1015 // 密码格式不正确
|
||||
CodePasswordTooWeak = 1016 // 密码强度不足
|
||||
CodeParentIDRequired = 1017 // 非 root 用户必须提供上级账号
|
||||
CodeInvalidParentID = 1018 // 上级账号不存在或无效
|
||||
CodeCannotModifyParent = 1019 // 禁止修改上级账号
|
||||
CodeCannotModifyUserType = 1020 // 禁止修改用户类型
|
||||
CodeRoleNotFound = 1021 // 角色不存在
|
||||
CodeRoleNameExists = 1022 // 角色名称已存在
|
||||
CodePermissionNotFound = 1023 // 权限不存在
|
||||
CodePermCodeExists = 1024 // 权限编码已存在
|
||||
CodeInvalidPermCode = 1025 // 权限编码格式不正确
|
||||
CodeRoleAlreadyAssigned = 1026 // 角色已分配
|
||||
CodePermAlreadyAssigned = 1027 // 权限已分配
|
||||
|
||||
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码
|
||||
CodeInternalError = 2001 // 内部服务器错误
|
||||
CodeDatabaseError = 2002 // 数据库错误
|
||||
@@ -31,22 +51,40 @@ const (
|
||||
|
||||
// errorMessages 错误消息映射表(中文)
|
||||
var errorMessages = map[int]string{
|
||||
CodeSuccess: "成功",
|
||||
CodeInvalidParam: "参数验证失败",
|
||||
CodeMissingToken: "缺失认证令牌",
|
||||
CodeInvalidToken: "无效或过期的令牌",
|
||||
CodeUnauthorized: "未授权访问",
|
||||
CodeForbidden: "禁止访问",
|
||||
CodeNotFound: "资源未找到",
|
||||
CodeConflict: "资源冲突",
|
||||
CodeTooManyRequests: "请求过多,请稍后重试",
|
||||
CodeRequestTooLarge: "请求体过大",
|
||||
CodeInternalError: "内部服务器错误",
|
||||
CodeDatabaseError: "数据库错误",
|
||||
CodeRedisError: "缓存服务错误",
|
||||
CodeServiceUnavailable: "服务暂时不可用",
|
||||
CodeTimeout: "请求超时",
|
||||
CodeTaskQueueError: "任务队列错误",
|
||||
CodeSuccess: "成功",
|
||||
CodeInvalidParam: "参数验证失败",
|
||||
CodeMissingToken: "缺失认证令牌",
|
||||
CodeInvalidToken: "无效或过期的令牌",
|
||||
CodeUnauthorized: "未授权访问",
|
||||
CodeForbidden: "禁止访问",
|
||||
CodeNotFound: "资源未找到",
|
||||
CodeConflict: "资源冲突",
|
||||
CodeTooManyRequests: "请求过多,请稍后重试",
|
||||
CodeRequestTooLarge: "请求体过大",
|
||||
CodeAccountNotFound: "账号不存在",
|
||||
CodeAccountDisabled: "账号已禁用",
|
||||
CodeAccountDeleted: "账号已删除",
|
||||
CodeUsernameExists: "用户名已存在",
|
||||
CodePhoneExists: "手机号已存在",
|
||||
CodeInvalidPassword: "密码格式不正确",
|
||||
CodePasswordTooWeak: "密码强度不足",
|
||||
CodeParentIDRequired: "非 root 用户必须提供上级账号",
|
||||
CodeInvalidParentID: "上级账号不存在或无效",
|
||||
CodeCannotModifyParent: "禁止修改上级账号",
|
||||
CodeCannotModifyUserType: "禁止修改用户类型",
|
||||
CodeRoleNotFound: "角色不存在",
|
||||
CodeRoleNameExists: "角色名称已存在",
|
||||
CodePermissionNotFound: "权限不存在",
|
||||
CodePermCodeExists: "权限编码已存在",
|
||||
CodeInvalidPermCode: "权限编码格式不正确(应为 module:action 格式)",
|
||||
CodeRoleAlreadyAssigned: "角色已分配",
|
||||
CodePermAlreadyAssigned: "权限已分配",
|
||||
CodeInternalError: "内部服务器错误",
|
||||
CodeDatabaseError: "数据库错误",
|
||||
CodeRedisError: "缓存服务错误",
|
||||
CodeServiceUnavailable: "服务暂时不可用",
|
||||
CodeTimeout: "请求超时",
|
||||
CodeTaskQueueError: "任务队列错误",
|
||||
}
|
||||
|
||||
// GetMessage 获取错误码对应的消息
|
||||
|
||||
@@ -43,7 +43,7 @@ func FromFiberContext(c *fiber.Ctx) *ErrorContext {
|
||||
}
|
||||
|
||||
// 提取 User ID(如果已认证)
|
||||
if uid := c.Locals("user_id"); uid != nil {
|
||||
if uid := c.Locals(constants.ContextKeyUserID); uid != nil {
|
||||
if userID, ok := uid.(string); ok {
|
||||
ctx.UserID = userID
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// TestSafeErrorHandler 测试 SafeErrorHandler 基本功能
|
||||
func TestSafeErrorHandler(t *testing.T) {
|
||||
logger, _ := zap.NewProduction()
|
||||
defer logger.Sync()
|
||||
defer func() { _ = logger.Sync() }()
|
||||
handler := SafeErrorHandler(logger)
|
||||
|
||||
tests := []struct {
|
||||
@@ -156,7 +156,7 @@ func TestAppErrorUnwrap(t *testing.T) {
|
||||
// BenchmarkSafeErrorHandler 基准测试错误处理性能
|
||||
func BenchmarkSafeErrorHandler(b *testing.B) {
|
||||
logger, _ := zap.NewProduction()
|
||||
defer logger.Sync()
|
||||
defer func() { _ = logger.Sync() }()
|
||||
_ = SafeErrorHandler(logger) // 避免未使用变量警告
|
||||
|
||||
testErrors := []error{
|
||||
@@ -330,7 +330,7 @@ func TestErrorMessageSanitization(t *testing.T) {
|
||||
// TestConcurrentErrorHandling 测试并发场景下的错误处理
|
||||
func TestConcurrentErrorHandling(t *testing.T) {
|
||||
logger, _ := zap.NewProduction()
|
||||
defer logger.Sync()
|
||||
defer func() { _ = logger.Sync() }()
|
||||
handler := SafeErrorHandler(logger)
|
||||
if handler == nil {
|
||||
t.Fatal("SafeErrorHandler returned nil")
|
||||
|
||||
@@ -124,7 +124,7 @@ func TestMaxBackups(t *testing.T) {
|
||||
zap.Int("iteration", i),
|
||||
)
|
||||
}
|
||||
Sync()
|
||||
_ = Sync()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ func TestCompressionConfig(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
Sync()
|
||||
_ = Sync()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// 验证日志文件存在
|
||||
@@ -239,7 +239,7 @@ func TestMaxAge(t *testing.T) {
|
||||
|
||||
// 写入日志
|
||||
logger.Info("test max age", zap.String("config", "maxage=1"))
|
||||
Sync()
|
||||
_ = Sync()
|
||||
|
||||
// 验证配置已应用(无法在单元测试中验证实际的清理行为,因为需要等待1天)
|
||||
// 这里只验证初始化没有错误
|
||||
|
||||
149
pkg/middleware/auth.go
Normal file
149
pkg/middleware/auth.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// SetUserContext 将用户信息设置到 context 中
|
||||
// 在 Auth 中间件认证成功后调用
|
||||
func SetUserContext(ctx context.Context, userID uint, userType int, shopID uint) context.Context {
|
||||
ctx = context.WithValue(ctx, constants.ContextKeyUserID, userID)
|
||||
ctx = context.WithValue(ctx, constants.ContextKeyUserType, userType)
|
||||
ctx = context.WithValue(ctx, constants.ContextKeyShopID, shopID)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// GetUserIDFromContext 从 context 中提取用户 ID
|
||||
// 如果未设置,返回 0
|
||||
func GetUserIDFromContext(ctx context.Context) uint {
|
||||
if ctx == nil {
|
||||
return 0
|
||||
}
|
||||
if userID, ok := ctx.Value(constants.ContextKeyUserID).(uint); ok {
|
||||
return userID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetUserTypeFromContext 从 context 中提取用户类型
|
||||
// 如果未设置,返回 0
|
||||
func GetUserTypeFromContext(ctx context.Context) int {
|
||||
if ctx == nil {
|
||||
return 0
|
||||
}
|
||||
if userType, ok := ctx.Value(constants.ContextKeyUserID).(int); ok {
|
||||
return userType
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetShopIDFromContext 从 context 中提取店铺 ID
|
||||
// 如果未设置,返回 0
|
||||
func GetShopIDFromContext(ctx context.Context) uint {
|
||||
if ctx == nil {
|
||||
return 0
|
||||
}
|
||||
if shopID, ok := ctx.Value(constants.ContextKeyShopID).(uint); ok {
|
||||
return shopID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// IsRootUser 检查当前用户是否为 root 用户
|
||||
// root 用户跳过数据权限过滤
|
||||
func IsRootUser(ctx context.Context) bool {
|
||||
userType := GetUserTypeFromContext(ctx)
|
||||
return userType == constants.UserTypeRoot
|
||||
}
|
||||
|
||||
// SetUserToFiberContext 将用户信息设置到 Fiber context 的 Locals 中
|
||||
// 同时也设置到标准 context 中,便于 GORM 查询使用
|
||||
func SetUserToFiberContext(c *fiber.Ctx, userID uint, userType int, shopID uint) {
|
||||
// 设置到 Fiber Locals
|
||||
c.Locals(constants.ContextKeyUserID, userID)
|
||||
c.Locals(constants.ContextKeyUserType, userType)
|
||||
c.Locals(constants.ContextKeyShopID, shopID)
|
||||
|
||||
// 设置到标准 context(用于 GORM 数据权限过滤)
|
||||
ctx := SetUserContext(c.UserContext(), userID, userType, shopID)
|
||||
c.SetUserContext(ctx)
|
||||
}
|
||||
|
||||
// AuthConfig Auth 中间件配置
|
||||
type AuthConfig struct {
|
||||
// TokenExtractor 自定义 token 提取函数
|
||||
// 如果为空,默认从 Authorization header 提取 Bearer token
|
||||
TokenExtractor func(c *fiber.Ctx) string
|
||||
|
||||
// TokenValidator token 验证函数
|
||||
// 验证成功返回用户 ID、用户类型、店铺 ID
|
||||
// 验证失败返回 error
|
||||
TokenValidator func(token string) (userID uint, userType int, shopID uint, err error)
|
||||
|
||||
// Skip 跳过认证的路径
|
||||
Skip []string
|
||||
}
|
||||
|
||||
// Auth 认证中间件
|
||||
// 从请求中提取 token,验证后将用户信息设置到 context
|
||||
func Auth(config AuthConfig) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// 检查是否跳过认证
|
||||
path := c.Path()
|
||||
for _, skipPath := range config.Skip {
|
||||
if path == skipPath {
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 token
|
||||
var token string
|
||||
if config.TokenExtractor != nil {
|
||||
token = config.TokenExtractor(c)
|
||||
} else {
|
||||
// 默认从 Authorization header 提取 Bearer token
|
||||
token = extractBearerToken(c)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"code": errors.CodeUnauthorized,
|
||||
"message": "未提供认证令牌",
|
||||
})
|
||||
}
|
||||
|
||||
// 验证 token
|
||||
if config.TokenValidator == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"code": errors.CodeInternalError,
|
||||
"message": "认证验证器未配置",
|
||||
})
|
||||
}
|
||||
|
||||
userID, userType, shopID, err := config.TokenValidator(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"code": errors.CodeUnauthorized,
|
||||
"message": "认证令牌无效",
|
||||
})
|
||||
}
|
||||
|
||||
// 将用户信息设置到 context
|
||||
SetUserToFiberContext(c, userID, userType, shopID)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// extractBearerToken 从 Authorization header 提取 Bearer token
|
||||
func extractBearerToken(c *fiber.Ctx) string {
|
||||
auth := c.Get("Authorization")
|
||||
if len(auth) > 7 && auth[:7] == "Bearer " {
|
||||
return auth[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -44,3 +44,26 @@ func SuccessWithMessage(c *fiber.Ctx, data any, message string) error {
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// PaginationData 分页数据结构
|
||||
type PaginationData struct {
|
||||
Items any `json:"items"` // 数据列表
|
||||
Total int64 `json:"total"` // 总数
|
||||
Page int `json:"page"` // 当前页码
|
||||
Size int `json:"size"` // 每页大小
|
||||
}
|
||||
|
||||
// SuccessWithPagination 返回分页响应
|
||||
func SuccessWithPagination(c *fiber.Ctx, items any, total int64, page, size int) error {
|
||||
return c.JSON(Response{
|
||||
Code: errors.CodeSuccess,
|
||||
Data: PaginationData{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
Size: size,
|
||||
},
|
||||
Message: "success",
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -428,7 +428,7 @@ func TestMultipleResponses(t *testing.T) {
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
|
||||
var response Response
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
@@ -454,7 +454,7 @@ func TestTimestampFormat(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var response Response
|
||||
|
||||
Reference in New Issue
Block a user