diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py index c5ad37b..09e7937 100644 --- a/inventory-backend/app/api/v1/outbound.py +++ b/inventory-backend/app/api/v1/outbound.py @@ -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='新增', diff --git a/inventory-backend/app/api/v1/purchase.py b/inventory-backend/app/api/v1/purchase.py index 6ef46c7..462d247 100644 --- a/inventory-backend/app/api/v1/purchase.py +++ b/inventory-backend/app/api/v1/purchase.py @@ -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('//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)""" diff --git a/inventory-backend/app/api/v1/transactions.py b/inventory-backend/app/api/v1/transactions.py index 5e442d1..8665a02 100644 --- a/inventory-backend/app/api/v1/transactions.py +++ b/inventory-backend/app/api/v1/transactions.py @@ -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(): """ diff --git a/inventory-backend/app/utils/decorators.py b/inventory-backend/app/utils/decorators.py index 733c3c6..73cc2bc 100644 --- a/inventory-backend/app/utils/decorators.py +++ b/inventory-backend/app/utils/decorators.py @@ -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 \ No newline at end of file