From cf9a3c79e44d47320d3067966475590e3694fa51 Mon Sep 17 00:00:00 2001 From: LoveLosita <2810873701@qq.com> Date: Wed, 11 Feb 2026 16:16:07 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.2.3.dev.260211=20fix:=20?= =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E5=88=B7=E6=96=B0=20Token=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E9=94=99=E8=AF=AF=E8=BF=94=E5=9B=9E=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 当 token 本身存在问题时,改为返回 400 业务错误 - 不再错误地返回 500 服务端异常状态码 ✅ feat: 🔁 新增基于 X-Idempotency-Key 与 Redis 的通用幂等中间件 - 基于 X-Idempotency-Key 实现请求幂等控制 🧩 - 记录 UUID 及对应返回结果至 Redis - 当相同 UUID 重复请求时,直接返回缓存结果 ⚡ - 应用于所有涉及增删改操作的接口 - 解决部分接口未实现幂等性的问题 🔒 --- backend/auth/jwt_handler.go | 8 ++- backend/dao/cache.go | 40 +++++++++---- backend/middleware/idempotency.go | 96 +++++++++++++++++++++++++++++++ backend/respond/respond.go | 10 ++++ backend/routers/routers.go | 14 ++--- 5 files changed, 149 insertions(+), 19 deletions(-) create mode 100644 backend/middleware/idempotency.go diff --git a/backend/auth/jwt_handler.go b/backend/auth/jwt_handler.go index 0f73b87..778ae2c 100644 --- a/backend/auth/jwt_handler.go +++ b/backend/auth/jwt_handler.go @@ -91,8 +91,12 @@ func ValidateRefreshToken(tokenString string, cache *dao.CacheDAO) (*jwt.Token, return RefreshKey, nil }) - if err != nil || !token.Valid { - return nil, err + if err != nil { + return nil, respond.InvalidRefreshToken + } + + if !token.Valid { + return nil, respond.InvalidRefreshToken } // 2. 断言获取 Claims diff --git a/backend/dao/cache.go b/backend/dao/cache.go index 6632942..1d0397f 100644 --- a/backend/dao/cache.go +++ b/backend/dao/cache.go @@ -20,13 +20,13 @@ func NewCacheDAO(client *redis.Client) *CacheDAO { } // SetBlacklist 把 Token 扔进黑名单 -func (dao *CacheDAO) SetBlacklist(jti string, expiration time.Duration) error { - return dao.client.Set(context.Background(), "blacklist:"+jti, "1", expiration).Err() +func (d *CacheDAO) SetBlacklist(jti string, expiration time.Duration) error { + return d.client.Set(context.Background(), "blacklist:"+jti, "1", expiration).Err() } // IsBlacklisted 检查 Token 是否在黑名单中 -func (dao *CacheDAO) IsBlacklisted(jti string) (bool, error) { - result, err := dao.client.Get(context.Background(), "blacklist:"+jti).Result() +func (d *CacheDAO) IsBlacklisted(jti string) (bool, error) { + result, err := d.client.Get(context.Background(), "blacklist:"+jti).Result() if errors.Is(err, redis.Nil) { return false, nil // 不在黑名单 } else if err != nil { @@ -35,7 +35,7 @@ func (dao *CacheDAO) IsBlacklisted(jti string) (bool, error) { return result == "1", nil // 在黑名单 } -func (dao *CacheDAO) AddTaskClassList(ctx context.Context, userID int, list *model.UserGetTaskClassesResponse) error { +func (d *CacheDAO) AddTaskClassList(ctx context.Context, userID int, list *model.UserGetTaskClassesResponse) error { // 1. 定义 Key,使用 userID 隔离不同用户的数据 key := fmt.Sprintf("smartflow:task_classes:%d", userID) // 2. 序列化:将结构体转为 []byte @@ -44,14 +44,14 @@ func (dao *CacheDAO) AddTaskClassList(ctx context.Context, userID int, list *mod return err } // 3. 存储:设置 30 分钟过期(根据业务灵活调整) - return dao.client.Set(ctx, key, data, 30*time.Minute).Err() + return d.client.Set(ctx, key, data, 30*time.Minute).Err() } -func (dao *CacheDAO) GetTaskClassList(ctx context.Context, userID int) (model.UserGetTaskClassesResponse, error) { +func (d *CacheDAO) GetTaskClassList(ctx context.Context, userID int) (model.UserGetTaskClassesResponse, error) { key := fmt.Sprintf("smartflow:task_classes:%d", userID) var resp model.UserGetTaskClassesResponse // 1. 从 Redis 获取字符串 - val, err := dao.client.Get(ctx, key).Result() + val, err := d.client.Get(ctx, key).Result() if err != nil { // 注意:如果是 redis.Nil,交给 Service 层处理查库逻辑 return resp, err @@ -61,7 +61,27 @@ func (dao *CacheDAO) GetTaskClassList(ctx context.Context, userID int) (model.Us return resp, err } -func (dao *CacheDAO) DeleteTaskClassList(ctx context.Context, userID int) error { +func (d *CacheDAO) DeleteTaskClassList(ctx context.Context, userID int) error { key := fmt.Sprintf("smartflow:task_classes:%d", userID) - return dao.client.Del(ctx, key).Err() + return d.client.Del(ctx, key).Err() +} + +func (d *CacheDAO) GetRecord(ctx context.Context, key string) (string, error) { + val, err := d.client.Get(ctx, key).Result() + if errors.Is(err, redis.Nil) { + return "", nil // 正常没命中的情况 + } + return val, err // 真正的 Redis 报错 +} + +func (d *CacheDAO) SaveRecord(ctx context.Context, key string, val string, ttl time.Duration) error { + return d.client.Set(ctx, key, val, ttl).Err() +} + +func (d *CacheDAO) AcquireLock(ctx context.Context, key string, ttl time.Duration) (bool, error) { + return d.client.SetNX(ctx, key, "processing", ttl).Result() +} + +func (d *CacheDAO) ReleaseLock(ctx context.Context, key string) error { + return d.client.Del(ctx, key).Err() } diff --git a/backend/middleware/idempotency.go b/backend/middleware/idempotency.go new file mode 100644 index 0000000..e8ec515 --- /dev/null +++ b/backend/middleware/idempotency.go @@ -0,0 +1,96 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/LoveLosita/smartflow/backend/dao" + "github.com/LoveLosita/smartflow/backend/respond" + "github.com/gin-gonic/gin" +) + +type IdempotencyValue struct { + Status int `json:"status"` // HTTP 状态码 + Body string `json:"body"` // JSON 响应体 +} + +type responseRecorder struct { + gin.ResponseWriter + body *bytes.Buffer +} + +func (r *responseRecorder) Write(b []byte) (int, error) { + r.body.Write(b) // 录制到缓冲区 + return r.ResponseWriter.Write(b) // 正常发送给前端 +} + +func IdempotencyMiddleware(cache *dao.CacheDAO) gin.HandlerFunc { + return func(c *gin.Context) { + // 1. 获取 IKey + ikey := c.GetHeader("X-Idempotency-Key") + if ikey == "" { + c.JSON(http.StatusBadRequest, respond.MissingIdempotencyKey) // 400 错误,缺少 IKey + c.Abort() + return + } + + userID := c.GetInt("user_id") // 假设 JWT 已存入 + redisKey := fmt.Sprintf("idempotency:%d:%s", userID, ikey) + + // 2. 查 Redis 缓存 + cachedData, err := cache.GetRecord(c, redisKey) + if err != nil { // 💡 Fail-Open:Redis 挂了也别卡住用户,记个日志继续走 + log.Printf("[Idempotency] Redis Get error: %v", err) + } else if cachedData != "" { + // 命中缓存,直接回放录像 + var val IdempotencyValue + json.Unmarshal([]byte(cachedData), &val) + c.Data(val.Status, "application/json", []byte(val.Body)) + c.Abort() + return + } + + // 3. 分布式锁:防止微秒级的并发碰撞 (SetNX) + // 锁 10 秒,防止请求卡死导致 key 永久锁定 + lockKey := redisKey + ":lock" + success, err := cache.AcquireLock(c, lockKey, 10*time.Second) + if err != nil { // 如果加锁报错,为了保险我们依然放行,让底层的数据库唯一索引去兜底 + log.Printf("[Idempotency] Redis Lock error: %v", err) + } else if !success { + c.JSON(http.StatusConflict, respond.RequestIsProcessing) + c.Abort() + return + } + // 💡 只有在加锁成功时才需要 defer 删锁 + if err == nil && success { + defer cache.ReleaseLock(c, lockKey) + } + + // 4. 装饰 ResponseWriter 开始录制 + recorder := &responseRecorder{ + ResponseWriter: c.Writer, + body: bytes.NewBufferString(""), + } + c.Writer = recorder + + // 5. 执行后续 Handler (你的 Service 逻辑) + c.Next() + + // 6. 录制完成,存入 Redis (缓存 24 小时) + // 只有状态码 < 500 时才存入 Redis,这样如果是服务器临时抽风,用户重试依然有机会成功 + if c.Writer.Status() < 500 { + respVal := IdempotencyValue{ + Status: c.Writer.Status(), + Body: recorder.body.String(), + } + data, _ := json.Marshal(respVal) + if err := cache.SaveRecord(c, redisKey, string(data), 24*time.Hour); err != nil { + log.Printf("[Idempotency] Redis Save error: %v", err) + } + } + } +} diff --git a/backend/respond/respond.go b/backend/respond/respond.go index bb42b64..334c3f0 100644 --- a/backend/respond/respond.go +++ b/backend/respond/respond.go @@ -240,4 +240,14 @@ var ( //请求相关的响应 Status: "40036", Info: "task class item not found", } + + MissingIdempotencyKey = Response{ //缺少幂等性键 + Status: "40037", + Info: "missing idempotency key", + } + + RequestIsProcessing = Response{ //请求正在处理中 + Status: "40038", + Info: "request is processing, please do not repeat click", + } ) diff --git a/backend/routers/routers.go b/backend/routers/routers.go index c825820..0eed8c1 100644 --- a/backend/routers/routers.go +++ b/backend/routers/routers.go @@ -52,31 +52,31 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, limiter *pk taskGroup := apiGroup.Group("/task") { taskGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) - taskGroup.POST("/create", handlers.TaskHandler.AddTask) + taskGroup.POST("/create", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.AddTask) taskGroup.GET("/get", handlers.TaskHandler.GetUserTasks) } courseGroup := apiGroup.Group("/course") { courseGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) courseGroup.POST("/validate", handlers.CourseHandler.CheckUserCourse) - courseGroup.POST("/import", handlers.CourseHandler.AddUserCourses) + courseGroup.POST("/import", middleware.IdempotencyMiddleware(cache), handlers.CourseHandler.AddUserCourses) } taskClassGroup := apiGroup.Group("/task-class") { taskClassGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) - taskClassGroup.POST("/add", handlers.TaskClassHandler.UserAddTaskClass) + taskClassGroup.POST("/add", middleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.UserAddTaskClass) taskClassGroup.GET("/list", handlers.TaskClassHandler.UserGetTaskClassInfos) taskClassGroup.GET("/get", handlers.TaskClassHandler.UserGetCompleteTaskClass) - taskClassGroup.PUT("/update", handlers.TaskClassHandler.UserUpdateTaskClass) - taskClassGroup.POST("/insert-into-schedule", handlers.TaskClassHandler.UserAddTaskClassItemIntoSchedule) - taskClassGroup.DELETE("/delete-item", handlers.TaskClassHandler.DeleteTaskClassItem) + taskClassGroup.PUT("/update", middleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.UserUpdateTaskClass) + taskClassGroup.POST("/insert-into-schedule", middleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.UserAddTaskClassItemIntoSchedule) + taskClassGroup.DELETE("/delete-item", middleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.DeleteTaskClassItem) } scheduleGroup := apiGroup.Group("/schedule") { scheduleGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) scheduleGroup.GET("/today", handlers.ScheduleHandler.GetUserTodaySchedule) scheduleGroup.GET("/week", handlers.ScheduleHandler.GetUserWeeklySchedule) - scheduleGroup.DELETE("/delete", handlers.ScheduleHandler.DeleteScheduleEvent) + scheduleGroup.DELETE("/delete", middleware.IdempotencyMiddleware(cache), handlers.ScheduleHandler.DeleteScheduleEvent) } } // 初始化Gin引擎