feat: Redis幂等锁 + 关键端点防重复提交
## decorators.py
- 新增 @prevent_double_submit(lock_timeout=5) 装饰器
Redis key: idem:{user_id}:{path}:{MD5(body)}
锁存在→409, 不存在→setex→执行业务→delete
Redis不可用时fail-open降级放行
## purchase.py
- POST /purchase (创建): +@prevent_double_submit
- PATCH /purchase/<id>/approve (审批): +@prevent_double_submit
## outbound.py
- POST /outbound (出库): +@prevent_double_submit
## transactions.py
- POST /borrow/dispatch (借库扣减): +@prevent_double_submit
This commit is contained in:
@ -1,7 +1,7 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.outbound_service import OutboundService
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
from app.utils.decorators import permission_required, audit_log
|
||||
from app.utils.decorators import permission_required, audit_log, prevent_double_submit
|
||||
from app.services.auth_service import AuthService
|
||||
import traceback
|
||||
|
||||
@ -110,6 +110,7 @@ def scan_barcode():
|
||||
# --------------------------------------------------------
|
||||
@outbound_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
@audit_log(
|
||||
module='出库管理',
|
||||
action='新增',
|
||||
|
||||
@ -2,7 +2,7 @@ import traceback
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity
|
||||
from app.services.purchase_service import PurchaseService
|
||||
from app.utils.decorators import permission_required
|
||||
from app.utils.decorators import permission_required, prevent_double_submit
|
||||
|
||||
purchase_bp = Blueprint('purchase', __name__, url_prefix='/api/v1/purchase')
|
||||
|
||||
@ -70,6 +70,7 @@ def get_purchase_list():
|
||||
# --------------------------------------------------------
|
||||
@purchase_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
def create_purchase_request():
|
||||
"""创建采购申请(任何登录用户可提交)"""
|
||||
try:
|
||||
@ -138,6 +139,7 @@ def get_purchase_detail(purchase_id):
|
||||
# --------------------------------------------------------
|
||||
@purchase_bp.route('/<int:purchase_id>/approve', methods=['PATCH'])
|
||||
@jwt_required()
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
@permission_required('inbound_purchase:operation')
|
||||
def approve_purchase_request(purchase_id):
|
||||
"""审批采购申请:必须有操作权限(inbound_purchase:operation)"""
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from flask import Blueprint, jsonify, request # .material -> .base refactor checked
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
from app.utils.decorators import permission_required, audit_log
|
||||
from app.utils.decorators import permission_required, audit_log, prevent_double_submit
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.trans_service import TransService
|
||||
from app.services.borrow_service import BorrowApprovalService
|
||||
@ -308,6 +308,7 @@ def get_borrow_stock_list():
|
||||
# --- 执行借库扣减(审批通过后调用)---
|
||||
@trans_bp.route('/borrow/dispatch', methods=['POST'])
|
||||
@jwt_required()
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
@permission_required('op_borrow:operation')
|
||||
def dispatch_borrow():
|
||||
"""
|
||||
|
||||
@ -288,4 +288,65 @@ def audit_log(module: str = None, action: str = None, get_target_id_fn=None, get
|
||||
def decorator(*inner_args, **inner_kwargs):
|
||||
return fn(*inner_args, **inner_kwargs)
|
||||
return decorator
|
||||
return wrapper
|
||||
|
||||
|
||||
def prevent_double_submit(lock_timeout=5):
|
||||
"""
|
||||
Redis 幂等锁装饰器 — 防止重复提交。
|
||||
|
||||
原理:
|
||||
- 基于 (user_id, request.path, hash(body)) 生成唯一 Redis key
|
||||
- 若 key 存在 → 返回 409 Conflict
|
||||
- 若不存在 → setex(key, timeout, "1") → 执行业务 → delete(key)
|
||||
|
||||
用法:
|
||||
@app.route('/submit', methods=['POST'])
|
||||
@prevent_double_submit(lock_timeout=10)
|
||||
def submit(): ...
|
||||
"""
|
||||
import hashlib
|
||||
from app.extensions import redis_client
|
||||
|
||||
def wrapper(fn):
|
||||
@wraps(fn)
|
||||
def decorator(*args, **kwargs):
|
||||
if redis_client is None:
|
||||
return fn(*args, **kwargs) # Redis 不可用时 fail-open
|
||||
|
||||
try:
|
||||
from flask_jwt_extended import get_jwt_identity
|
||||
user_id = str(get_jwt_identity() or 'anonymous')
|
||||
path = request.path
|
||||
|
||||
# 对请求体做确定性哈希(空 body = 空串 hash)
|
||||
body_str = ''
|
||||
if request.is_json:
|
||||
body_str = json.dumps(request.get_json() or {}, sort_keys=True)
|
||||
body_hash = hashlib.md5(body_str.encode()).hexdigest()[:12]
|
||||
|
||||
lock_key = f"idem:{user_id}:{path}:{body_hash}"
|
||||
|
||||
if redis_client.exists(lock_key):
|
||||
logging.warning(f"[幂等锁] 重复请求被拦截: {lock_key}")
|
||||
return jsonify({
|
||||
'code': 409,
|
||||
'msg': '请求正在处理中,请勿重复提交'
|
||||
}), 409
|
||||
|
||||
redis_client.setex(lock_key, lock_timeout, "1")
|
||||
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
finally:
|
||||
try:
|
||||
redis_client.delete(lock_key)
|
||||
except Exception:
|
||||
pass # 清理失败不影响业务
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f"[幂等锁] Redis 异常,降级放行: {e}")
|
||||
return fn(*args, **kwargs) # fail-open
|
||||
|
||||
return decorator
|
||||
return wrapper
|
||||
Reference in New Issue
Block a user