Files
team-api/internal/middleware/idempotency.go
T
Qianandchengqian be9e707bfd 修复异步图片导致的多个问题 (#12)
* fix:修复响应污染问题

* fix:修改存储相关文件为中文注释

* fix:修复二次退款问题

* fix:修复多个考虑不全面问题
1.误删用户数据
2.R2存储默认区域
3.删除失败导致孤儿记录
4.网络抖动事变时重试
5.前端无限次请求获取图片
6.文件管理权限

* fix:修复字符串截断异常问题和文件下载超时问题

* fix:修复多图问题

* fix:异步生成图片时未配置oss提示错误信息

* fix:修复关闭异步后同步生成出错问题

* feat:新增文件存储测试功能,防止设置后不生效

* fix:修复同步图片转异步后计费错误

* fix:修复无下限扣款问题

* fix:修复结算重复扣款问题

* fix:修复计费出错导致的0费用结算

* fix:修复回调双重履约

* fix:修复预扣事务

* fix:修复费用字段类型问题

* fix:修复缓存问题

* fix:修复安全问题

* fix:修复数据库操作事务问题

* refactor:重构事务使用方式,改为官方推荐的dao形式

* fix:修复余额更新隐患

* fix:修复N+1查询问题

* fix:修复密钥安全问题

* fix:结算失败时打印错误日志

* improve:优化扣款

* fix:修复用户接口越权

---------

Co-authored-by: chengqian <wuchengqian@kkidc.com>
2026-07-22 17:53:18 +08:00

139 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"fmt"
"net/http"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/qianfree/team-api/internal/response"
)
const (
IdempotencyHeader = "Idempotency-Key"
// maxCachedBodyLen limits the response body size stored for idempotent replay.
// Responses larger than this will still be deduplicated but won't cache the body.
maxCachedBodyLen = 64 * 1024 // 64 KB
// idempotencyTTL is how long an idempotency record is retained before it can be cleaned up.
idempotencyTTL = 24 * time.Hour
)
// Idempotency ensures that requests with the same Idempotency-Key
// are processed only once. Subsequent requests with the same key
// return the cached response.
//
// 幂等键按认证主体(userType + tenantID + userID)隔离:不同租户/用户即便复用同一
// 客户端 Idempotency-Key,也各自独立,避免命中彼此缓存的响应体(跨租户数据泄漏)
// 或彼此误触 409。因此本中间件必须挂载在认证中间件(如 TenantAuth)之后。
func Idempotency(r *ghttp.Request) {
clientKey := r.GetHeader(IdempotencyHeader)
if clientKey == "" {
r.Middleware.Next()
return
}
ctx := r.Context()
// 组合出按主体隔离的存储键(仍是单一字符串,不改表结构)
tenantID := r.GetCtxVar(CtxKeyTenantID).Int64()
userID := r.GetCtxVar(CtxKeyUserID).Int64()
userType := r.GetCtxVar(CtxKeyUserType).String()
idempotencyKey := buildIdempotencyKey(userType, tenantID, userID, clientKey)
// Check if this key has been processed before
var record struct {
Id int64 `json:"id"`
Status string `json:"status"`
ResponseBody string `json:"response_body"`
}
err := dao.SysIdempotencyRecords.Ctx(ctx).
Where("idempotency_key", idempotencyKey).
Where("expires_at > NOW()").
Scan(&record)
if err != nil {
// DB error: fail-closed to prevent duplicate processing
response.ErrorMsg(r, 500, "系统繁忙,请稍后重试")
return
}
if record.Id > 0 {
if record.Status == "completed" && record.ResponseBody != "" {
// 重放缓存响应。本中间件只挂载在 /api/* 统一响应端点上:成功响应恒为
// HTTP 200 + JSON(response.Success 使用 WriteJson),业务错误为 HTTP 422
// 且会被标记为 failed 而不进入此重放分支。因此固定回写 200 + JSON Content-Type,
// 避免复用未初始化的 r.Response.Status(默认 0)以及丢失 Content-Type。
r.Response.Header().Set("Content-Type", "application/json; charset=utf-8")
r.Response.WriteStatus(http.StatusOK)
r.Response.Write([]byte(record.ResponseBody))
r.Exit()
return
}
if record.Status == "processing" {
writeConflictResponse(r)
return
}
}
// Insert processing record
_, err = dao.SysIdempotencyRecords.Ctx(ctx).Data(do.SysIdempotencyRecords{
IdempotencyKey: idempotencyKey,
Status: "processing",
ExpiresAt: gtime.Now().Add(idempotencyTTL),
}).Insert()
if err != nil {
// If insert fails (duplicate key), another request is already processing
writeConflictResponse(r)
return
}
// Capture the response
r.Middleware.Next()
// After processing, update the record with cached body
status := "completed"
statusCode := r.Response.Status
if statusCode >= 400 {
status = "failed"
}
var responseBody string
if buf := r.Response.Buffer(); len(buf) > 0 && len(buf) <= maxCachedBodyLen {
responseBody = string(buf)
}
// Update the record
dao.SysIdempotencyRecords.Ctx(ctx).
Where("idempotency_key", idempotencyKey).
Data(do.SysIdempotencyRecords{
Status: status,
ResponseBody: responseBody,
}).Update()
}
// buildIdempotencyKey 组合出按认证主体隔离的幂等存储键。
// 加入 userType/tenantID/userID 前缀,使不同主体即便复用同一客户端 key 也互不干扰,
// 杜绝跨租户命中彼此缓存响应体(数据泄漏)或误触 409。
func buildIdempotencyKey(userType string, tenantID, userID int64, clientKey string) string {
return fmt.Sprintf("%s:%d:%d:%s", userType, tenantID, userID, clientKey)
}
// writeConflictResponse writes a 409 Conflict response for duplicate idempotency keys.
func writeConflictResponse(r *ghttp.Request) {
r.Response.WriteStatus(409)
r.Response.WriteJson(g.Map{
"error": g.Map{
"type": "conflict",
"message": "请求正在处理中,请勿重复提交",
"code": "409",
},
})
r.Exit()
}