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

实现刷新令牌轮换时为何仅第61行.save()抛VersionError

问题描述

近期调整项目Controller层代码实现刷新令牌轮换(refresh token rotation)逻辑,修改涉及refreshTokenController文件:

  • 原有逻辑:服务端收到携带有效刷新令牌Cookie的API请求时,直接为客户端返回新的访问令牌
  • 修改后异常:接口收到请求时抛出VersionError错误
    经初步定位:
  • 仅注释掉代码第61行的第三个.save()调用时,报错会消失
  • 单独或同时注释第32行、第49行的前两个.save()调用,都无法消除报错

对应代码如下:

const jwt = require('jsonwebtoken');
const { defineModel } = require('../services/dbHandler');
const { userSchema } = require('../schemas/userSchema');
const { authTokenErr, cookieOptions, serverErr } = require('../utils');

const refreshAccessToken = async (req, res) => {
  const prevRefreshToken = req.cookies?.refToken;

  if (!prevRefreshToken) return res.status(401).json(authTokenErr);

  res.clearCookie('refToken', prevRefreshToken, cookieOptions);

  try {
    const User = await defineModel('global', 'User', userSchema);

    const dbUserWithToken = await User.findOne({
      refreshToken: prevRefreshToken,
    }).exec();

    if (!dbUserWithToken) {
      jwt.verify(
        prevRefreshToken,
        process.env.REFRESH_TOKEN_SECRET,
        async (err, payload) => {
          if (err) return res.status(401).json(authTokenErr);

          const compromisedDbUser = await User.findOne({
            _id: payload._uid,
          }).exec();

          compromisedDbUser.refreshToken = [];
          await compromisedDbUser.save();
        }
      );

      return res.status(401).json(authTokenErr);
    }

    const updatedTokenArr = dbUserWithToken.refreshToken.filter(
      (token) => token !== prevRefreshToken
    );

    jwt.verify(
      prevRefreshToken,
      process.env.REFRESH_TOKEN_SECRET,
      async (err, payload) => {
        if (err || payload._uid !== dbUserWithToken._id.toString()) {
          dbUserWithToken.refreshToken = [...updatedTokenArr];
          const result = await dbUserWithToken.save();

          return res.status(401).json(authTokenErr);
        }

        const renewedRefreshToken = dbUserWithToken.generateRefreshToken();
        const renewedAccessToken = dbUserWithToken.generateAccessToken();

        dbUserWithToken.refreshToken = [
          ...updatedTokenArr,
          renewedRefreshToken,
        ];
        await dbUserWithToken.save();    // <---- 触发VersionError的位置

        res.cookie('refToken', renewedRefreshToken, cookieOptions);

        return res.status(200).json({ accessToken: renewedAccessToken });
      }
    );
  } catch (err) {
    res.status(500).json(serverErr);
    console.error(err);
  }
};

module.exports = { refreshAccessToken };
排查与修复方案

首先明确:Mongoose抛出VersionError的核心原因是当前持有的文档实例内部版本号(默认字段为__v)和数据库中该记录的最新版本号不匹配,被Mongoose的乐观并发控制机制拦截,避免旧数据覆盖新的修改。
结合代码逻辑,按以下优先级排查修复:

  • 第一优先级:修复异步回调带来的时序混乱与悬空Promise问题
    现有代码使用jwt.verify的回调模式,回调内的所有数据库逻辑不受外层try/catch捕获,且第20-37行的分支中,代码不会等待jwt回调内的compromisedDbUser.save()执行完成就直接返回响应,这部分悬空执行的数据库操作很容易和同请求/并发请求的操作产生竞态,触发版本冲突。
    直接将jwt.verify改为同步调用(jsonwebtoken原生支持不传回调时同步返回结果、同步抛出验签错误),让所有逻辑严格串行执行,彻底消除异步回调的时序问题,改法参考:
    // 移除所有jwt.verify的回调写法,统一用同步方式验签
    let payload
    try {
      payload = jwt.verify(prevRefreshToken, process.env.REFRESH_TOKEN_SECRET)
    } catch (verifyErr) {
      // 验签失败逻辑直接写在这里,无需嵌套回调
      if (dbUserWithToken) {
        dbUserWithToken.refreshToken = [...updatedTokenArr]
        await dbUserWithToken.save()
      }
      return res.status(401).json(authTokenErr)
    }
    
    // 验签通过后再做uid匹配、token生成、保存的逻辑,全部在同一异步栈内串行执行
    if (payload._uid !== dbUserWithToken._id.toString()) {
      dbUserWithToken.refreshToken = [...updatedTokenArr]
      await dbUserWithToken.save()
      return res.status(401).json(authTokenErr)
    }
    
    const renewedRefreshToken = dbUserWithToken.generateRefreshToken();
    const renewedAccessToken = dbUserWithToken.generateAccessToken();
    dbUserWithToken.refreshToken = [...updatedTokenArr, renewedRefreshToken]
    await dbUserWithToken.save()
    
    改完后所有数据库操作都可以被外层try/catch捕获,不会出现悬空的异步save操作,绝大多数场景下版本错误会直接消失。
  • 第二优先级:排查Mongoose Model重复编译问题
    代码中每次请求都会执行defineModel('global', 'User', userSchema),如果该方法内部没有做全局缓存,每次调用都重新编译生成新的Mongoose Model,不同Model实例持有的版本键配置、内部状态不互通,也会触发版本校验失败。
    可以加日志验证:每次调用defineModel后打印Model实例,确认User模型是全局单例,没有重复编译。
  • 第三优先级:验证旧实例版本不匹配问题
    如果上述修改完成后仍报错,可以在保存前重新查询最新版本的用户文档,再做修改保存,绕开旧实例版本不一致的问题:
    // 替换原有第61行的save逻辑
    const latestUser = await User.findById(dbUserWithToken._id).exec()
    latestUser.refreshToken = [...updatedTokenArr, renewedRefreshToken]
    await latestUser.save()
    
    如果改完后报错消失,可以100%确认问题根源是之前持有的文档实例版本和数据库最新版本不匹配。

额外注意:第11行的res.clearCookie参数传法有误,该方法的签名是res.clearCookie( cookieName, [options] ),当前代码把prevRefreshToken作为第二个参数传入,会导致Cookie无法正常清除,建议一并修正。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.27 16:15:12