钉钉免登明明成功,为什么还停在登录页——一次"半登录态"排查

测试同事反馈:在钉钉里打开 H5 微应用,免登之后没有进入应用,而是停在账号密码登录页。更诡异的是,刷新一下有时又能进去;主动退出再打开,又可能卡住。最后查出来的真相是:钉钉授权和后端登录其实都成功了,token 已经拿到手,是 H5 自己在登录成功之后抛出异常,把用户卡在了门外——一种”半登录态”。

半登录态:token 已落地,用户信息缺失
图 1:异常发生在”存 token”之后、”存用户信息”之前,应用被劈成一半已登录、一半未初始化的状态。

故障现象与第一个误判

故障的三个表现放在一起看互相矛盾:

  • 钉钉内打开微应用,免登后停在登录页
  • 刷新页面后有时可以继续进入,甚至开始加载资源树;
  • 主动退出后再次打开,仍可能停在登录页。

第一反应是查后端。结果在 admin 日志里只看到资源树相关请求,看不到任何钉钉接口的日志——这很容易让人误判为”钉钉授权码根本没拿到”,然后去查钉钉 SDK、查应用配置,方向全错。

实际上日志给的是另一个提示:资源树请求能发出来,说明请求是带着有效 token 的。免登链路大概率已经走完了,问题出在走完之后的某个地方。光靠服务端日志看不到那一段,只能往 H5 里埋点。

沿免登链路埋七个诊断点

H5 工程在本地,分支 dev。先同步同事的代码,核对免登、路由守卫、退出登录和用户信息初始化四块逻辑,然后沿着免登链路在七个位置加诊断日志:

  1. 路由守卫是否发现 token;
  2. 钉钉 SDK 是否加载成功;
  3. dd.ready 是否触发;
  4. requestAuthCode 是否取得授权码;
  5. /user/login 是否返回 token 和 loginUser
  6. /user/app/info 是否返回用户菜单;
  7. 登录后的路由跳转是否执行。

诊断版本发到测试环境跑了一次,关键日志立刻把案情锁定:

1
2
3
4
5
[17:29:26.512] dd.ready
[17:29:26.682] authCode.ok {"hasCode":true,"codeLen":32}
[17:29:27.135] handleLogin.res {"hasToken":true,"loginUserType":"undefined","loginUserLen":0}
[17:29:27.137] handleLogin.fail {"msg":"Unexpected token u in JSON at position 0"}
[17:29:27.139] state.dump {"hasToken":true,"hasLoginU":false}

五行日志,四个事实:钉钉 SDK 正常、授权码正常、后端登录成功且返回了 token、登录响应里没有 loginUser 字段,而 H5 在解析它的时候抛了异常。

根因:把可选字段当成必填字段

Unexpected token u in JSON at position 0 这个报错里的 u,就是 undefined 的首字母。旧 H5 的登录处理里有一行无条件执行的代码:

1
JSON.parse(decodeURIComponent(res.loginUser))

生产环境的 /user/login 响应中 res.loginUser === undefined,这行代码实际执行的是 JSON.parse('undefined'),当场抛异常。

undefined 被喂给 JSON.parse
图 2:报错里的 u 就是 undefined 的首字母——一个可选字段被当成必填字段解析的典型现场。

为什么生产环境没有这个字段?核对本地 Maven 仓库里的公共认证组件 common-auth-2.6.0-SNAPSHOT.jar 后确认:该组件始终返回 token,但只在 dev/test profile 下附带 loginUser,prod 环境不返回。也就是说,生产缺 loginUser 是公共组件的既有行为,不是这次钉钉授权出的问题。前后端对这个字段的约定不一致——后端眼里它是可选的,H5 眼里它是必填的——这才是本质原因。

为什么”刷新一下就能进”

异常抛出的位置,恰好卡在登录流程三步走的中间:

1
2
3
1. 保存 token          ← 已执行
2. 解析 loginUser ← 在这里炸了
3. 保存 login_u ← 永远没执行

于是形成 token 已存在 + login_u 不存在 的半登录态。刷新后路由守卫发现 token,尝试直接进首页并重新请求 /user/app/info——这就是”刷新后能进”的真相。但旧代码随后仍然从 login_u 里读 userId 等字段,刷新后的日志证实了第二次异常:

1
2
3
guard.token→home
getUserInfo.res {"hasRes":true,"menusLen":1,"hasLoginU":false}
getUserInfo.fail {"msg":"Cannot read properties of null (reading 'userId')"}

/user/app/info 明明返回了完整的用户、组织和菜单数据,H5 却执着于一个根本不存在的本地缓存。现象上时好时坏,机制上完全确定。

修复后的免登流程
图 3:修复后的流程不再强依赖 loginUser,用户信息以 /user/app/info 的返回为准。

修复:只改 H5,不动后端

修复方案确定为 H5 侧兼容,理由很直接:公共认证组件是多个系统共用的,为一个前端改它,爆炸半径太大;而且 /user/app/info 本来就返回完整用户信息,loginUser 本质上是一份冗余。改动集中在 src/store/module/user.js 一处。

登录响应兼容/user/login 未返回 loginUser 时,保留已取得的 token,清掉可能残留的旧 login_u(避免串用上一个用户的数据),不再解析、也不再判登录失败:

1
2
3
4
5
if (!res.loginUser) {
localStorage.removeItem('login_u')
resolve(null)
return
}

用户信息初始化兼容login_u 存在时按旧格式解析(兼容老版本写入的缓存),不存在时用空对象;以 /user/app/info 的返回为基础用户信息,login_u 仅作为补充:

1
2
3
4
5
const loginUserRaw = localStorage.getItem('login_u')
const loginUserInfo = loginUserRaw
? JSON.parse(decodeURIComponent(loginUserRaw))
: {}
const userInfo = Object.assign({}, res, loginUserInfo)

修复后的完整流程变成:钉钉取授权码 → /user/login 返回 token → loginUser 缺失则跳过解析 → /user/app/info 返回用户、组织和菜单 → 初始化 Vuex → 直接进入应用。

验证与纪律

  • 已执行 production build,构建成功;45 个 warning 全是项目原有的,没有新增编译错误;git diff --check 无空白符问题。
  • 正式提交只包含 user.js 的功能修复(fix: 兼容钉钉免登未返回loginUser),已推送 origin/dev排查期间加的诊断面板和链路日志一行都没有混入功能提交,单独留在本地备份分支——调试代码和功能修复必须分开走,这是纪律。
  • 尚未完成的最后一步:本地无法模拟真实钉钉客户端身份,端到端结论需要在钉钉微应用内用测试账号复测(首次打开直进应用、退出重开自动免登、主动退出后不再自动进入)。部署清单里特意加了一条:ZIP 解压到独立新目录再切换,确认线上加载的是新的 app.*.js,排除浏览器和钉钉 WebView 缓存——H5 问题里,”改了但没生效”有一半是缓存的锅。

留下三条经验

  1. “Unexpected token u” 里的 u 就是 undefined。 下次再看到 JSON at position 0 之类报错,先想想哪个字段可能是 undefined/null 被直接喂给了 JSON.parse,这是前后端契约问题最典型的报错形态。
  2. 环境的差异要当成契约的一部分。 同一个接口在 dev/test 返回 loginUser、prod 不返回,意味着”本地测不出来”是注定的。判断一个字段可不可靠,依据应该是生产环境的真实响应,而不是本地联调的响应。
  3. “刷新后能进”不是玄学,是状态机泄露。 登录态被拆成 token 和用户信息两份存储时,任何一步中途失败都会制造半登录态。修复的方向不是”让每步都成功”,而是让任何一份数据缺失时都有明确的兜底路径