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

图 1:异常发生在”存 token”之后、”存用户信息”之前,应用被劈成一半已登录、一半未初始化的状态。
故障现象与第一个误判
故障的三个表现放在一起看互相矛盾:
- 钉钉内打开微应用,免登后停在登录页;
- 刷新页面后有时可以继续进入,甚至开始加载资源树;
- 主动退出后再次打开,仍可能停在登录页。
第一反应是查后端。结果在 admin 日志里只看到资源树相关请求,看不到任何钉钉接口的日志——这很容易让人误判为”钉钉授权码根本没拿到”,然后去查钉钉 SDK、查应用配置,方向全错。
实际上日志给的是另一个提示:资源树请求能发出来,说明请求是带着有效 token 的。免登链路大概率已经走完了,问题出在走完之后的某个地方。光靠服务端日志看不到那一段,只能往 H5 里埋点。
沿免登链路埋七个诊断点
H5 工程在本地,分支 dev。先同步同事的代码,核对免登、路由守卫、退出登录和用户信息初始化四块逻辑,然后沿着免登链路在七个位置加诊断日志:
- 路由守卫是否发现 token;
- 钉钉 SDK 是否加载成功;
dd.ready是否触发;requestAuthCode是否取得授权码;/user/login是否返回 token 和loginUser;/user/app/info是否返回用户菜单;- 登录后的路由跳转是否执行。
诊断版本发到测试环境跑了一次,关键日志立刻把案情锁定:
1 | [17:29:26.512] dd.ready |
五行日志,四个事实:钉钉 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'),当场抛异常。

图 2:报错里的 u 就是 undefined 的首字母——一个可选字段被当成必填字段解析的典型现场。
为什么生产环境没有这个字段?核对本地 Maven 仓库里的公共认证组件 common-auth-2.6.0-SNAPSHOT.jar 后确认:该组件始终返回 token,但只在 dev/test profile 下附带 loginUser,prod 环境不返回。也就是说,生产缺 loginUser 是公共组件的既有行为,不是这次钉钉授权出的问题。前后端对这个字段的约定不一致——后端眼里它是可选的,H5 眼里它是必填的——这才是本质原因。
为什么”刷新一下就能进”
异常抛出的位置,恰好卡在登录流程三步走的中间:
1 | 1. 保存 token ← 已执行 |
于是形成 token 已存在 + login_u 不存在 的半登录态。刷新后路由守卫发现 token,尝试直接进首页并重新请求 /user/app/info——这就是”刷新后能进”的真相。但旧代码随后仍然从 login_u 里读 userId 等字段,刷新后的日志证实了第二次异常:
1 | guard.token→home |
/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 | if (!res.loginUser) { |
用户信息初始化兼容:login_u 存在时按旧格式解析(兼容老版本写入的缓存),不存在时用空对象;以 /user/app/info 的返回为基础用户信息,login_u 仅作为补充:
1 | const loginUserRaw = localStorage.getItem('login_u') |
修复后的完整流程变成:钉钉取授权码 → /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 问题里,”改了但没生效”有一半是缓存的锅。
留下三条经验
- “Unexpected token u” 里的 u 就是 undefined。 下次再看到
JSON at position 0之类报错,先想想哪个字段可能是undefined/null被直接喂给了JSON.parse,这是前后端契约问题最典型的报错形态。 - 环境的差异要当成契约的一部分。 同一个接口在 dev/test 返回
loginUser、prod 不返回,意味着”本地测不出来”是注定的。判断一个字段可不可靠,依据应该是生产环境的真实响应,而不是本地联调的响应。 - “刷新后能进”不是玄学,是状态机泄露。 登录态被拆成 token 和用户信息两份存储时,任何一步中途失败都会制造半登录态。修复的方向不是”让每步都成功”,而是让任何一份数据缺失时都有明确的兜底路径。