You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Django连续请求导致Session属性被覆盖的问题排查与解决方案咨询

订单导出功能的Session冲突问题分析与解决

问题根源:并行请求导致的Session写入竞态

从你的日志和代码来看,问题出在浏览器同时打开两个导出窗口时的Session并行写入冲突:
当订单已支付时,前端JS会同时调用window.open()发起发票和收据的导出请求,这两个请求几乎同时到达服务器:

  1. 第一个请求(比如发票)读取当前Session的初始状态(还没有任何_*_token字段),把签名token存入_invoice_token,然后重定向
  2. 第二个请求(收据)也读取到完全一样的初始Session状态,把同一个token存入_receipt_token,然后重定向
  3. 因为Django的Session默认是覆盖式写入的,第二个请求的Session修改会覆盖第一个请求刚写入的_invoice_token字段
  4. 当处理发票的重定向请求时,去Session里找_invoice_token,发现它已经被覆盖没了,自然就抛出"invalid signature"错误

简单说就是两个请求抢着写Session,最后只有后写的那个字段存活,先写的被覆盖了。

不暴露token的解决方案

下面几个方案都能解决问题,而且不需要把签名token直接暴露在最终的URL里:

方案1:给请求加个延迟,让它们串行执行

这是最简单的临时修复,修改模板里的JS,让收据的请求晚一点发起,避免两个请求同时打给服务器:

<script>
...
{% if messages %}
{% for msg in messages %}
{% if msg.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %}
// 先打开发票窗口
window.open("{% url 'sale:export' 'invoice' msg %}", "_blank");
{% if msg.level == DEFAULT_MESSAGE_LEVELS.INFO %}
// 延迟500ms再打开收据,错开请求时间
setTimeout(() => {
    window.open("{% url 'sale:export' 'receipt' msg %}", "_blank");
}, 500);
{% endif %}
{% elif msg.level == DEFAULT_MESSAGE_LEVELS.INFO %}
window.open("{% url 'sale:export' 'receipt' msg %}", "_blank");
{% endif %}
{% endfor %}
{% endif %}
</script>

这个方法成本最低,不需要改后端代码,适合快速解决问题。

方案2:用原子化的Session更新避免覆盖

Django默认的Session写入是读取快照-修改-覆盖保存,并行请求会冲突。我们可以直接操作SessionStore,确保每次写入都是基于最新的Session状态:

# 在ExportView的get方法中,替换原来的request.session[action['session_prop']] = kwargs['token']
from django.contrib.sessions.backends.db import SessionStore

def get(self, request, *args, **kwargs):
    # ... 其他原有逻辑 ...
    else:
        # 显式加载最新的Session数据,再写入字段,避免覆盖
        session_store = SessionStore(session_key=request.session.session_key)
        session_store.load()
        session_store[action['session_prop']] = kwargs['token']
        session_store.save(must_create=False)
        # ... 原有重定向逻辑 ...

这个方法能从根源上解决Session的竞态问题,适合需要保持原有重定向逻辑的场景。

方案3:用缓存存临时映射,彻底脱离Session

我们可以生成一个唯一的临时token,把它和原始签名token、操作类型绑定存在缓存里,这样完全不用依赖Session,也就不会有冲突:

  1. 修改ExportView的重定向逻辑:
# 在ExportView的get方法中
from django.core.cache import cache
import uuid

def get(self, request, *args, **kwargs):
    # ... 原有检查action的逻辑 ...
    action = self.actions[kwargs['action']]
    if kwargs['token'] == action['url_token']:
        # 从缓存取原始token
        temp_token = kwargs['token']
        data = cache.get(f"export_{temp_token}")
        if not data or data[1] != kwargs['action']:
            return render(request, '400.html', {'msg': 'invalid signature'}, status=400)
        sign = signing.load(data[0])
        cache.delete(f"export_{temp_token}")  # 用完就删,避免重复使用
        return getattr(self, kwargs['action'])(sign)
    else:
        # 生成唯一临时token
        temp_token = str(uuid.uuid4())
        # 把原始token和操作类型存入缓存,设置5分钟过期
        cache.set(f"export_{temp_token}", (kwargs['token'], kwargs['action']), timeout=300)
        # 重定向到带临时token的URL
        redirect_url = request.path.replace(kwargs['token'], temp_token)
        return redirect(redirect_url)

这个方法安全性更高,临时token过期自动失效,而且完全不会有Session冲突的问题。

方案4:改用POST请求,彻底跳过Session和重定向

把GET请求改成POST请求,把签名token放在表单的隐藏字段里,这样URL里完全不会出现token,同时也避免了并行请求的Session问题:

  1. 修改模板,用隐藏表单代替window.open:
{% if messages %}
{% for msg in messages %}
{% if msg.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %}
<form method="POST" action="{% url 'sale:export' 'invoice' %}" target="_blank">
    {% csrf_token %}
    <input type="hidden" name="token" value="{{msg}}">
    <button type="submit" style="display:none;" id="submit-invoice-{{msg}}"></button>
</form>
{% endif %}
{% if msg.level == DEFAULT_MESSAGE_LEVELS.INFO %}
<form method="POST" action="{% url 'sale:export' 'receipt' %}" target="_blank">
    {% csrf_token %}
    <input type="hidden" name="token" value="{{msg}}">
    <button type="submit" style="display:none;" id="submit-receipt-{{msg}}"></button>
</form>
{% endif %}
{% endfor %}
{% endif %}

<script>
...
{% if messages %}
{% for msg in messages %}
{% if msg.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %}
document.getElementById('submit-invoice-{{msg}}').click();
{% if msg.level == DEFAULT_MESSAGE_LEVELS.INFO %}
setTimeout(() => {
    document.getElementById('submit-receipt-{{msg}}').click();
}, 500);
{% endif %}
{% elif msg.level == DEFAULT_MESSAGE_LEVELS.INFO %}
document.getElementById('submit-receipt-{{msg}}').click();
{% endif %}
{% endfor %}
{% endif %}
</script>
  1. 修改urls.py,去掉URL里的token参数:
path('orders/export/<action>/', views.ExportView.as_view(), name='export'),
  1. 修改ExportView支持POST请求:
class ExportView(View):
    http_method_names = ['get', 'post']
    # ... 原有actions定义 ...

    def post(self, request, *args, **kwargs):
        if kwargs['action'] not in self.actions:
            return render(request, '400.html', {'msg': 'undefined action'}, status=400)
        token = request.POST.get('token')
        if not token:
            return render(request, '400.html', {'msg': 'missing token'}, status=400)
        try:
            sign = signing.load(token)
        except:
            return render(request, '400.html', {'msg': 'invalid signature'}, status=400)
        return getattr(self, kwargs['action'])(sign)
    
    # ... 原有get方法可以保留,也可以删掉,看需求 ...

这个方法是最安全的,token完全不会出现在URL里,也彻底解决了Session的竞态问题。


内容的提问来源于stack exchange,提问作者Ezon Zhao

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.27 20:22:32