快速结论:这个报错通常出现在 Dify 自托管(源码部署)并开启验证码相关邮件流程时:并发请求会把不同用户的验证码写进同一个共享字典,导致邮件收到的验证码和 token payload 里存的验证码不一致,用户无法完成重置/注册/转让。优先排查 api/services/account_service.py 中三个方法是否仍使用可变默认参数 additional_data: dict[str, Any] = {}。
适用环境:Issue 已确认 Dify 1.16.1(源码检出 3aa937de),Self Hosted (Source);涉及 SERVER_WORKER_CLASS 默认为 gevent 的部署方式。Issue 未提供具体的 Python、CUDA、显卡或依赖版本,请勿据此推断。
最快修复方案:暂无确认的一步修复方案。Issue 评论给出的最小改法(将默认值改为 None,并在方法内部新建字典,可优先尝试)尚未标注为已合并/已验证的修复。
注意事项:该改法只是 Issue 评论中提出的建议,不能替代官方补丁;若采用类型化 token payload 对象(参考 PR #36412 的做法)属于更大范围的改动,需自行评估兼容性。修改前建议先备份并确认调用方行为。
问题场景
在 Dify 自托管源码部署中,运行找回密码、邮箱注册、所有者转让这三条会“生成验证码并发送邮件”的流程。高并发或 worker 以 gevent 方式运行多个 greenlet 时,调用 AccountService.generate_reset_password_token、generate_email_register_token、generate_owner_transfer_token 会触发该问题。
复现方式是用两个线程对真实方法做交叉控制:在第一个调用方即将让 generate_token 访问 Redis(挂起点)时把控制权交给第二个调用方,然后比较各自 token payload 中实际存下的 code 与返回给调用方(用于发邮件)的 code。
报错原文
AssertionError: assert stored == returned
Differing items:
{'first@example.com': '906060'} != {'first@example.com': '517404'}
以及无需并发即可出现的确定性副作用:
>>> data = {"invite_id": "abc"}
>>> AccountService.generate_reset_password_token("u@x.com", additional_data=data)[0]
'164794'
>>> data
{'invite_id': 'abc', 'code': '164794'}
>>> AccountService.generate_reset_password_token("v@x.com")
>>> AccountService.generate_reset_password_token.__func__.__defaults__
(None, None, {'code': '798569'})
原因分析
最可能的原因是可变默认参数被共享并被就地修改。三个方法都使用 additional_data: dict[str, Any] = {} 作为默认参数,默认字典在导入时只创建一次,所有未显式传入 additional_data 的调用共享同一个对象,随后方法内部执行 additional_data["code"] = code 写入验证码。
由于 SERVER_WORKER_CLASS 默认为 gevent,一个 worker 会用大量 greenlet 共用这个对象;而 TokenManager.generate_token 会在一次 Redis 往返(挂起点)之后才读取 additional_data,于是两个并发请求会互相覆盖,导致 Redis 中 token 携带的验证码与调用方实际发出的验证码不一致。
此外,还有不依赖并发的确定性影响:调用方自己传入的字典也会被就地修改,且默认字典会保留最后一次写入的验证码。依赖默认值的调用点正是三个“生成新验证码并发送邮件”的位置:send_reset_password_email、send_email_register_email、send_owner_transfer_email;控制器侧传入的是新的字典字面量({"phase": "reset"} / {}),因此绕过了“共享默认字典”这一部分,但其字面量仍会被修改。
环境排查
- 确认 Dify 版本与提交:Issue 报告为 1.16.1,源码检出
3aa937de;评论指出当前 main 分支仍存在该问题。 - 确认部署方式:Self Hosted (Source)。
- 确认
SERVER_WORKER_CLASS配置:默认值为gevent,会以多 greenlet 方式使用共享对象。 - 检查
api/services/account_service.py中generate_reset_password_token、generate_email_register_token、generate_owner_transfer_token三个方法是否仍存在additional_data: dict[str, Any] = {}的写法及additional_data["code"] = code的就地写入。 - 检查上述三个“发送邮件”调用点是否依赖默认字典(未传
additional_data)。 - Issue 未提供 Python、CUDA、PyTorch、显卡、节点或依赖版本信息,这些项无法据此排查。
解决步骤
- 定位
api/services/account_service.py中三个受影响方法:generate_reset_password_token、generate_email_register_token、generate_owner_transfer_token。 - 确认它们是否使用可变默认参数
additional_data: dict[str, Any] = {},并在方法内就地写入additional_data["code"] = code。 - 可优先尝试 Issue 评论给出的最小改法:把默认值改为
None,并在方法内部新建字典,例如additional_data: dict[str, Any] | None = None,然后if additional_data is None: additional_data = {},避免共享默认对象。 - 如需与已有修复保持一致,可参考 PR #36412 中
generate_change_email_token的做法,改用类型化 token payload 对象,避免对调用方传入字典的就地修改(属于更大改动,需自行评估)。 - 修改后重新部署受影响的服务/worker,确保新代码生效。
验证方法
按 Issue 的复现思路验证:用两个线程对真实方法做交叉控制,在第一个调用方即将让 generate_token 访问 Redis 时切换到第二个调用方,然后断言 stored == returned,即每个 token payload 中存下的 code 与返回给调用方(并用于发送邮件)的 code 一致。同时确认传入自定义 additional_data 后该字典不再被就地修改,且默认字典中不再残留上一次的 code。
参考来源
相关参考:langgenius/dify PR #36412(generate_change_email_token 的同类修复)
AI 工具推荐
想把多个 AI 模型放在一个入口?
GamsGo AI 集成 ChatGPT、DeepSeek、Gemini、Claude、Midjourney、Veo 等常用模型,适合写作、绘图、视频和日常 AI 工作流。
推广链接:通过此链接购买,我可能获得佣金,不影响你的价格。
这个方案解决了吗?
可以继续搜索完整报错,或查看同一工具的其他排查指南。


