Version: 0.2.3.dev.260211

fix: 🐛 修复刷新 Token 接口错误返回问题

- 当 token 本身存在问题时,改为返回 400 业务错误
- 不再错误地返回 500 服务端异常状态码 

feat: 🔁 新增基于 X-Idempotency-Key 与 Redis 的通用幂等中间件

- 基于 X-Idempotency-Key 实现请求幂等控制 🧩
- 记录 UUID 及对应返回结果至 Redis
- 当相同 UUID 重复请求时,直接返回缓存结果 
- 应用于所有涉及增删改操作的接口
- 解决部分接口未实现幂等性的问题 🔒
This commit is contained in:
LoveLosita
2026-02-11 16:16:07 +08:00
parent 0bc06963ee
commit cf9a3c79e4
5 changed files with 149 additions and 19 deletions

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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-OpenRedis 挂了也别卡住用户,记个日志继续走
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)
}
}
}
}

View File

@@ -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",
}
)

View File

@@ -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引擎