简介:本资源面向使用Java开发钉钉企业内部应用的开发者,聚焦“钉钉微应用免登进入H5系统首页”这一典型场景,帮助读者打通前端获取免登授权码与后端校验用户身份的完整链路。资源包内含1个PDF文档,大小约129KB,以图文形式梳理了从钉钉开放平台创建H5微应用、配置agentId、appKey、appSecret与corpId,到开通企业通讯录接口权限、发布应用的全过程。文档重点讲解了ddNoLogin.html页面调用requestAuthCode获取code、后端通过gettoken与getuserinfo接口换取用户信息、校验通过后重定向至H5首页,以及免登成功后向用户推送消息通知的实现思路,并涉及access_token定时刷新与Redis缓存等细节。目前已有2113人学习下载,适合需要快速落地钉钉免登功能的Java后端与前端协作开发者参考,可据此理解授权流程、接口调用顺序与异常处理要点。
1. 钉钉微应用免登进 H5:为什么你的 Java 后端总在换 code 那一步翻车
很多团队第一次做钉钉微应用免登,前端页面在钉钉容器里能打开,dd.runtime.permission.requestAuthCode也能拿到临时授权码,结果 Java 后端拿着这个 code 去换用户信息时,要么返回40078,要么invalid code,要么用户对不上。问题往往不在前端,而在后端对「免登」这条链路的理解有偏差。钉钉微应用免登进入某 H5 系统首页,本质是:钉钉容器内 H5 通过 JSAPI 拿到临时授权码,Java 服务端用这个 code 加上企业级 access_token,去钉钉开放接口换取当前用户的 userId,再映射到你 H5 系统自己的账号体系,最后签发自家会话,让用户无感知进入首页。它解决的是「员工在钉钉工作台点开应用,不用再输账号密码」这个诉求,适合企业内部系统、OA、工单、报表这类已经跑在钉钉组织架构里的 H5 项目。下面按我实际落地的顺序,把 Java 侧每一步拆开讲。
2. 免登链路拆解:从钉钉容器到 Java 会话的四个角色
2.1 四个角色和两次换取
免登链路里同时存在四个角色:钉钉客户端容器、H5 前端页面、Java 后端服务、钉钉开放平台。它们之间发生两次关键换取。
第一次换取发生在 H5 前端和钉钉容器之间。H5 页面引入钉钉 JSAPI 后,调用dd.runtime.permission.requestAuthCode,钉钉容器校验当前页面所属微应用的 appKey 与当前登录员工身份,返回一个临时授权码 code。这个 code 有效期很短,常见做法是拿到后立刻发给后端,不要在前端缓存。
第二次换取发生在 Java 后端和钉钉开放平台之间。后端先用企业 corpId 和 corpSecret 换企业级 access_token,再用 access_token 加前端传来的 code 调topapi/v2/user/getuserinfo,拿到 userId。这个 userId 是钉钉组织内的员工唯一标识,不是你 H5 系统的账号。你需要在自己的用户表里维护一条钉钉 userId 到本地账号的映射,才能签发本地会话。
注意:access_token 是应用级凭证,不是用户级。它代表「这个微应用有权读取本企业通讯录」,不要把它下发给前端,也不要每个请求都重新获取。
2.2 为什么不能前端直接换 userId
有些方案图省事,让前端直接调钉钉接口换 userId,再把 userId 传给后端。这样做有两个硬伤。第一,corpSecret 必须出现在前端,等于把企业通讯录读取权限暴露给任何打开控制台的人。第二,前端传来的 userId 不可信,攻击者可以伪造任意 userId 直接登录他人账号。正确做法是前端只传 code,后端完成换取和会话签发,前端拿到的只有自家 sessionId 或 token。
2.3 本地账号映射的三种策略
拿到钉钉 userId 后,怎么对应到 H5 系统账号,常见三种策略。第一种是手机号匹配,钉钉返回的用户信息里带手机号,用手机号查本地用户表。第二种是工号匹配,如果企业把工号写进了钉钉用户扩展字段。第三种是首次登录绑定,第一次免登时让用户确认绑定关系,之后走映射表。我一般推荐第三种,因为前两种依赖钉钉侧数据质量,一旦手机号或工号对不上,免登直接失败,而且排查起来是黑匣子。绑定表结构至少要有ding_user_id、local_user_id、bind_time三个字段,ding_user_id建唯一索引。
3. Java 后端落地:access_token 缓存与 code 换 userId 的完整实现
3.1 依赖与配置项
Java 侧我一般用 Spring Boot 加 Hutool 的 HttpUtil,不额外引钉钉 SDK,因为 SDK 版本更新频繁,直接调 HTTP 接口反而可控。pom 里加:
<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.25</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>配置项放在application.yml,不要硬编码:
dingtalk: corp-id: dingxxxxxxxxxxxx app-key: dingxxxxxxxxxxxx app-secret: your_app_secret agent-id: 123456789corp-id是企业标识,app-key和app-secret是微应用凭证,agent-id是微应用在企业的唯一编号。这三个值在钉钉开发者后台微应用详情页都能找到。注意app-secret不要提交到代码仓库,用环境变量或配置中心注入。
3.2 access_token 的缓存实现
access_token 有效期通常两小时,钉钉对获取频率有限制,不能每次请求都去换。我用 Redis 缓存,key 设ding:access_token:{appKey},过期时间设 7000 秒,比官方有效期略短,留出刷新余量。
@Service public class DingTokenService { @Autowired private StringRedisTemplate redisTemplate; @Value("${dingtalk.app-key}") private String appKey; @Value("${dingtalk.app-secret}") private String appSecret; private static final String TOKEN_KEY_PREFIX = "ding:access_token:"; public String getAccessToken() { String cacheKey = TOKEN_KEY_PREFIX + appKey; String cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null && !cached.isEmpty()) { return cached; } // 缓存失效,重新获取 String url = "https://oapi.dingtalk.com/gettoken"; Map<String, Object> params = new HashMap<>(); params.put("appkey", appKey); params.put("appsecret", appSecret); String resp = HttpUtil.get(url, params); JSONObject json = JSONUtil.parseObj(resp); Integer errCode = json.getInt("errcode"); if (errCode == null || errCode != 0) { throw new RuntimeException("获取access_token失败: " + json.getStr("errmsg")); } String token = json.getStr("access_token"); // 过期时间设7000秒,留刷新余量 redisTemplate.opsForValue().set(cacheKey, token, 7000, TimeUnit.SECONDS); return token; } }逻辑说明:先查 Redis,命中直接返回;未命中调钉钉gettoken接口,校验errcode为 0 后写入缓存。参数说明:appkey和appsecret对应微应用凭证,errcode非 0 时errmsg会给出具体原因,常见的是invalid appkey或invalid appsecret。这里没有做并发锁,高并发下可能同时触发多次刷新,如果 QPS 高,建议加 Redis 分布式锁或本地synchronized按 appKey 加锁。
3.3 code 换 userId 的接口实现
前端传来的 code 只能用一次,换完即失效。后端接口收到 code 后,先换 userId,再查映射表,最后签发本地会话。
@RestController @RequestMapping("/api/ding") public class DingLoginController { @Autowired private DingTokenService tokenService; @Autowired private UserBindService userBindService; @PostMapping("/login") public Result<LoginVO> login(@RequestBody DingLoginDTO dto) { // 1. 参数校验 if (dto.getCode() == null || dto.getCode().isEmpty()) { return Result.fail("授权码不能为空"); } // 2. 换取userId String accessToken = tokenService.getAccessToken(); String url = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"; Map<String, Object> params = new HashMap<>(); params.put("access_token", accessToken); params.put("code", dto.getCode()); String resp = HttpUtil.post(url, JSONUtil.toJsonStr(params)); JSONObject json = JSONUtil.parseObj(resp); Integer errCode = json.getInt("errcode"); if (errCode == null || errCode != 0) { // 40078 通常是code已使用或过期 return Result.fail("免登失败: " + json.getStr("errmsg")); } String dingUserId = json.getJSONObject("result").getStr("userid"); // 3. 查本地绑定关系 Long localUserId = userBindService.findLocalUserId(dingUserId); if (localUserId == null) { // 未绑定,返回需要绑定的标识 return Result.fail("NEED_BIND:" + dingUserId); } // 4. 签发本地会话 String sessionId = userBindService.createSession(localUserId); LoginVO vo = new LoginVO(); vo.setSessionId(sessionId); vo.setRedirectUrl("/index"); return Result.ok(vo); } }逻辑说明:第一步校验 code 非空;第二步用 access_token 和 code 调getuserinfo,从result.userid取钉钉用户标识;第三步查本地绑定表,未绑定返回NEED_BIND让前端跳绑定页;第四步签发 sessionId 并返回首页跳转地址。参数说明:code来自前端 JSAPI,access_token来自缓存服务,userid是钉钉组织内员工标识。errcode为 40078 时说明 code 已被使用或过期,前端需要重新调 JSAPI 获取新 code。
3.4 前端 JSAPI 调用与 code 传递
H5 页面在钉钉容器内需要先鉴权再调免登接口。常见做法是在页面加载时调dd.ready,然后在回调里请求授权码。
dd.ready(function() { dd.runtime.permission.requestAuthCode({ corpId: 'dingxxxxxxxxxxxx', onSuccess: function(info) { // info.code 是临时授权码 fetch('/api/ding/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: info.code }) }).then(res => res.json()).then(data => { if (data.code === 0) { window.location.href = data.data.redirectUrl; } else if (data.msg.startsWith('NEED_BIND')) { // 跳转绑定页 window.location.href = '/bind?dingUserId=' + data.msg.split(':')[1]; } }); }, onFail: function(err) { console.error('获取授权码失败', err); } }); });逻辑说明:dd.ready确保容器环境就绪,requestAuthCode传入 corpId 获取 code,成功后立即 POST 给后端。参数说明:corpId必须与后端配置一致,info.code有效期很短,不要做任何异步延迟。onFail里常见错误是当前页面不在微应用可信域名内,需要在钉钉后台配置应用首页地址和可信域名。
4. 避坑与排查:免登链路上最容易翻车的五个点
4.1 code 被重复使用导致 40078
现象:第一次免登成功,刷新页面后失败,日志里errcode为 40078。原因:前端在页面加载和路由跳转时多次调用requestAuthCode,或者后端接口被重复请求,同一个 code 被用了两次。解决:前端确保一次页面加载只调一次免登接口,后端对 code 做一次性校验,用 Redis 记录已使用的 code,key 设ding:used_code:{code},过期时间 300 秒,重复请求直接拒绝。
4.2 access_token 缓存击穿导致限流
现象:服务重启后瞬间大量请求同时去换 access_token,钉钉返回request over limit。原因:缓存为空时没有加锁,所有并发请求都打到钉钉接口。解决:在getAccessToken方法里按 appKey 加本地锁或 Redis 分布式锁,保证同一时刻只有一个请求去刷新,其他请求等待后读缓存。
4.3 可信域名未配置导致 JSAPI 调不通
现象:dd.ready不触发,或者requestAuthCode直接onFail,错误信息提示权限不足。原因:H5 页面所在域名没有加入微应用的可信域名列表。解决:在钉钉开发者后台微应用详情页,把 H5 首页域名和所有涉及跳转的域名都加入可信域名,注意不要带路径和端口,只填域名。
4.4 userId 对不上本地账号
现象:免登成功拿到 userId,但查本地用户表为空,用户被卡在绑定页。原因:钉钉组织内员工没有同步到本地,或者手机号、工号匹配规则不一致。解决:首次免登时走绑定流程,让用户输入本地账号密码确认绑定,绑定关系写入映射表。后续免登直接查映射表,不再依赖手机号或工号。
4.5 会话签发后首页仍跳登录页
现象:后端返回 sessionId,前端跳转首页,但首页拦截器又重定向到登录页。原因:sessionId 没有正确写入 Cookie 或 Header,或者拦截器没有识别钉钉免登签发的会话类型。解决:统一会话载体,免登签发的 sessionId 和普通登录签发的 sessionId 用同一套校验逻辑,拦截器只认 sessionId,不区分来源。如果前端是 SPA,确保 sessionId 存在 localStorage 并在每次请求 Header 里带上。
5. 进阶技巧:用绑定表加会话续期把免登做成可运维的闭环
免登跑通只是第一步,真正上线后要面对的是绑定关系维护和会话过期。我一般会在绑定表上加last_login_time和status两个字段,status标记绑定是否有效,员工离职后钉钉侧删除用户,本地绑定关系也要能批量失效。会话续期方面,免登签发的 sessionId 有效期设短一点,比如 30 分钟,前端在会话快过期时静默调一次续期接口,续期接口重新走一遍 code 换 userId 太重,我一般用 refreshToken 机制,首次免登返回 sessionId 和 refreshToken,续期时只校验 refreshToken。
验证免登是否真正可用,不能只点一次。我习惯用三个场景压:第一,新员工首次免登,验证绑定流程;第二,已绑定员工二次免登,验证映射表查询和会话签发;第三,员工在钉钉侧被停用后免登,验证是否被正确拒绝。第三个场景最容易漏,很多系统只测正常流程,结果离职员工还能通过缓存会话进入首页。
// 会话续期接口示例 @PostMapping("/refresh") public Result<LoginVO> refresh(@RequestBody RefreshDTO dto) { // 校验refreshToken有效性 Long localUserId = userBindService.validateRefreshToken(dto.getRefreshToken()); if (localUserId == null) { return Result.fail("refreshToken已失效,请重新免登"); } // 重新签发sessionId,refreshToken不变 String newSessionId = userBindService.createSession(localUserId); LoginVO vo = new LoginVO(); vo.setSessionId(newSessionId); return Result.ok(vo); }逻辑说明:续期接口只校验 refreshToken,不重新走钉钉换取,减少对钉钉接口的依赖。参数说明:refreshToken首次免登时下发,有效期可以设 7 天,sessionId有效期 30 分钟。这样即使用户在钉钉容器里停留很久,也不会因为 sessionId 过期被踢回登录页。
最后说个血泪经验:免登链路的日志一定要打全,code、access_token 获取结果、userId、绑定查询结果、会话签发结果,每一步都记 traceId。出问题时,没有日志的免登就是黑匣子,你只能靠猜。我现在的习惯是,任何涉及第三方换取的操作,入口和出口都打日志,宁可多打,不要少打。希望帮到你。
本文还有配套的精品资源,点击获取