简介:本资源面向使用Java开发钉钉企业内部应用的开发者,聚焦“钉钉微应用免登进入H5系统首页”这一典型场景,帮助读者打通前端获取免登授权码与后端校验用户身份的完整链路。资源包内含1个PDF文档,大小约129KB,以图文形式梳理了从钉钉开放平台创建H5微应用、配置公网IP白名单、记录agentId、appKey、appSecret与corpId,到开通企业通讯录接口权限、发布应用的准备流程。正文重点讲解ddNoLogin.html中调用requestAuthCode获取code、通过AJAX将code传给后端、后端借助gettoken与getuserinfo接口换取用户信息并重定向至首页的实现思路,同时涉及access_token定时刷新与Redis缓存、免登成功后发送消息通知等扩展点。目前已有2113人学习下载,适合需要快速落地钉钉免登功能、理清前后端协作与接口调用顺序的Java开发者参考。
1. 钉钉微应用免登进 H5 首页:一个被低估的 Java 后端活儿
很多团队做钉钉微应用时,第一反应是“前端拿个 code 丢给后端就完事了”,真到联调才发现:code 换 userid 报 400、token 缓存过期、公网 IP 没加白名单、发消息一天只能发一次。这个项目的核心就是用 Java 把“钉钉微应用免登进入某 H5 系统首页”跑通——用户在钉钉里点微应用,前端通过 JS-API 拿到免登授权码,后端用 appKey/appSecret 换 access_token,再换 userid、拉手机号,跟 H5 系统用户表比对,存在就放行到首页,不存在返回“您无权限”。适合正在做企业内部 H5 系统对接钉钉的 Java 后端,也适合想搞清楚免登链路到底经过几次 HTTP 请求的开发者。下面按“资源是什么 → 怎么用 → 坑在哪”拆开讲。
2. 免登链路拆解:从 corpId 到手机号的四次握手
2.1 为什么必须走“前端拿 code + 后端换身份”这条路
钉钉免登的本质是:前端不接触任何敏感凭证,只负责拿一个一次性的免登授权码 code;后端拿着 code 和 access_token 去钉钉开放平台换用户身份。这样 appSecret 永远不出现在浏览器里,是这套方案能上生产的前提。
整条链路一共四次关键请求,缺一不可:
| 步骤 | 请求方 | 目标 | 关键参数 | 返回 |
|---|---|---|---|---|
| 1 | 前端 JS-API | 钉钉客户端 | corpId | code |
| 2 | 后端 | gettoken 接口 | appkey、appsecret | access_token |
| 3 | 后端 | getuserinfo 接口 | access_token、code | userid |
| 4 | 后端 | user/get 接口 | access_token、userid | mobile、name 等 |
第 2 步的 access_token 有效期是 2 小时,有效期内重复获取会返回相同结果并自动续期。所以正确做法不是每次请求都去换 token,而是缓存起来定时刷新。项目里用的是“每隔 1 小时 50 分钟刷新一次,缓存进 Redis”,这个时间点卡在 2 小时过期之前,留了 10 分钟缓冲,是常见做法。
第 3 步的 code 是一次性的,用过即废,且有效期很短。这意味着前端拿到 code 后必须立刻发给后端,不能存起来慢慢用。第 4 步拿到的 mobile 才是跟 H5 系统用户表比对的关键字段——因为钉钉的 userid 是钉钉体系内的,你的 H5 系统大概率是用手机号或自己的用户 ID 建的账号。
2.2 前端 ddNoLogin.html:只做一件事,拿 code 就发走
前端页面不需要 dd.config 鉴权,因为 requestAuthCode 这个方法本身不需要鉴权。这一点很多人会搞混,以为所有 JS-API 都要先 config,结果白白多写一堆签名逻辑。
<!DOCTYPE html> <html> <head> <title>微应用登陆</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=0" /> <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script> <script type="text/javascript" src="http://g.alicdn.com/dingding/open-develop/1.9.0/dingtalk.js"></script> </head> <body> <div id="ddNoLogin"></div> <script type="text/javascript"> dd.ready(function() { // 1.获取免登授权码code,此方法不需要dd.config鉴权 dd.runtime.permission.requestAuthCode({ corpId: corpId, // 企业id,由后端渲染或配置注入 onSuccess: function(result) { var code = result.code; getUserInfo(code); // 拿到code立刻发给后端 }, onFail: function(err) { alert('出错了, ' + err); } }); }); function getUserInfo(code) { $.ajax({ type: "GET", url: "/xxx/noLogin?code=" + code, async: false, dataType: 'json', contentType: "application/json;charset=utf-8", success: (function(res) { if (res.code == "0000") { window.location.href = '/#/xxxxx'; // 免登成功,跳首页 } else { $('#ddNoLogin').html(res.msg); // 无权限,展示提示 } }), }); } </script> </body> </html>逻辑说明:dd.ready 保证钉钉 JS 环境就绪后再调 requestAuthCode。corpId 是四个固定参数之一,从钉钉开发者后台首页获取。onSuccess 里拿到的 code 通过 AJAX 同步发给后端/xxx/noLogin,后端返回 code 为 "0000" 表示免登成功,前端跳转 H5 首页;否则把后端返回的 msg 渲染到页面上,用户看到“您无权限访问”。
参数说明:corpId 必须和微应用所属企业一致,填错会直接 onFail。async 设为 false 是为了确保跳转前拿到结果,但生产环境更推荐用回调或 Promise 处理,避免阻塞。url 里的/xxx/noLogin要和后端 Controller 的@RequestMapping对齐。
2.3 后端定时任务:token 缓存进 Redis 的正确姿势
access_token 不能每次请求都去换,否则一是浪费调用次数,二是并发场景下容易拿到不同 token 导致互相覆盖。项目里用 Spring 的@Scheduled定时刷新,缓存进 Redis。
/** * 定时获取钉钉的token */ @Component @EnableScheduling public class DdTokenTask { @Autowired private JedisClient jedisClient; public static final long cacheTime = 1000 * 60 * 55 * 2; // 1小时50分钟 @Value("${dtalk.tokenUrl}") private String tokenUrl; @Value("${dtalk.app.key}") private String appKey; @Value("${dtalk.app.secret}") private String appSecret; @Value("${dtalk.redisTokenKey}") private String tokenKey; @Value("${dtalk.taskRun}") private String taskRun; /** * 每隔1小时50分钟获取钉钉的access_token */ @Scheduled(fixedRate = cacheTime) @Async public void getDdTokenTask() { if ("true".equals(taskRun)) { System.out.println("--->>>>>-------------获取钉钉token的定时任务开始了:" + DateUtil.formatDateToString(new Date(), "HH:mm:ss")); String accessTokenUrl = tokenUrl + "?appkey=" + appKey + "&appsecret=" + appSecret; // 访问获取access_token 有效期是2小时 String accessToken = JsonUtil.getJsonNode(HttpUtil.doGet(accessTokenUrl)).get("access_token").asText(); // 放入到redis中 jedisClient.set(tokenKey, accessToken); System.out.println("--->>>>>-------------获取钉钉token的定时任务结束了,token:" + accessToken); } } }逻辑说明:@Scheduled(fixedRate = cacheTime)表示以固定频率执行,cacheTime 设为 1 小时 50 分钟,比 token 的 2 小时有效期提前 10 分钟刷新。@Async让任务异步执行,不阻塞主线程。taskRun 是个开关,方便本地开发时关掉定时任务,避免多个环境抢 token。
参数说明:tokenUrl 对应https://oapi.dingtalk.com/gettoken,appKey 和 appSecret 从微应用后台获取,redisTokenKey 是缓存 key 名。项目里没用钉钉官方 SDK,而是直接用 HTTP 请求,原因是公司私服没有该 SDK 依赖——这是很现实的取舍,HTTP 方式少一个依赖,但需要自己处理 JSON 解析和异常。
注意:如果部署了多个实例,每个实例都会跑定时任务,导致 token 被反复刷新。常见做法是加分布式锁,或者只让一个实例执行刷新,其他实例只读 Redis。
3. 免登接口落地:noLogin 方法里的四次 HTTP 调用
3.1 Controller 完整实现与参数传递
后端 noLogin 接口是整个免登的核心,它串起了“换 userid → 拉手机号 → 比对用户 → 发消息”四件事。
@RestController @RequestMapping("/ddUser") @Api(value = "/ddUser", description = "钉钉H5微应用登录", tags = {"DdLoginController"}) public class DdLoginController { @Autowired private JedisClient jedisClient; @Value("${dtalk.userUrl}") private String userUrl; @Value("${dtalk.userDetailUrl}") private String userDetailUrl; @Value("${dtalk.redisTokenKey}") private String tokenKey; @Value("${dtalk.agentId}") private Integer agentId; @GetMapping("/noLogin") @ApiOperation("钉钉免登") @ApiImplicitParam(paramType = "query", name = "code", value = "免登授权码", dataType = "String") public WebResponse noLogin(@RequestParam("code") String code, HttpServletResponse response) { // 2.获取access_token(从Redis缓存读取) String accessToken = jedisClient.get(tokenKey); // 3.获取用户userid String userIdUrl = userUrl + "?access_token=" + accessToken + "&code=" + code; JsonNode user = JsonUtil.getJsonNode(HttpUtil.doGet(userIdUrl)); if (user.get("errcode").asInt() != 0) { // 有些公司的公网ip不固定,导致微应用中设置的不对,这里就会报错 return WebResponse.resFail(user.get("errmsg").asText()); } String userId = user.get("userid").asText(); // 4.获取用户详情 手机号 String userInfoUrl = userDetailUrl + "?access_token=" + accessToken + "&userid=" + userId; JsonNode userInfo = JsonUtil.getJsonNode(HttpUtil.doGet(userInfoUrl)); String mobile = userInfo.get("mobile").asText(); System.out.println("钉钉用户的手机号:" + mobile); // 通过手机号获取该用户 SysUser sysUser = sysUserService.getByMobile(mobile); if (sysUser == null) { return WebResponse.resFail("您无权限访问", null); } // 钉钉发送免登成功消息给用户 sendMessage(accessToken, userId, userInfo.get("name").asText()); return WebResponse.resSuccess("免登成功", loginUserInfo); } }逻辑说明:先从 Redis 拿 access_token,避免每次请求都去换。然后用 code 换 userid,这里判断 errcode 是否为 0,不为 0 说明 code 无效或 token 过期,直接把钉钉返回的 errmsg 透传给前端。拿到 userid 后再拉用户详情,取 mobile 字段。用 mobile 去 H5 系统用户表查,查不到就返回“您无权限访问”。查到就发消息并返回成功。
参数说明:userUrl 对应https://oapi.dingtalk.com/user/getuserinfo,userDetailUrl 对应https://oapi.dingtalk.com/user/get。agentId 是微应用的标识,发消息时必须传。code 是前端传来的免登授权码,一次性使用。
3.2 工作通知消息:为什么消息里要加时间戳
免登成功后给用户发一条工作通知,是项目里加的小需求。钉钉的工作通知接口有个限制:给同一个用户发送相同内容,一天只能发一次;发送不同内容,一天可以 500 次。所以项目里在消息内容中拼了当前时间,保证每次内容都不同。
// 钉钉发送消息给用户 private void sendMessage(String token, String userId, String userName) { String messageUrl = "https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token=" + token; Map<String, Object> map = new HashMap<>(); map.put("agent_id", agentId.longValue()); map.put("userid_list", userId); map.put("to_all_user", false); String content = "用户" + userName + "在" + DateUtil.formatDateToString(new Date(), "yyyy-MM-dd HH:mm:ss") + "时成功登录xxH5端,并进入到xxx页面"; String msg = "{\"msgtype\":\"text\",\"text\":{\"content\":" + "\"" + content + "\"" + "}}"; JSONObject jsonObj = JSONObject.parseObject(msg); map.put("msg", jsonObj); HttpUtil.doPost(messageUrl, map, "UTF-8", 20000, null); }逻辑说明:agent_id 是微应用 ID,userid_list 是接收人列表,to_all_user 设为 false 表示不发给全员。msg 是一个 JSON 对象,msgtype 为 text,content 里拼了用户名、时间和页面信息。加时间戳是为了绕过“相同内容一天一次”的限制。
参数说明:messageUrl 里的 access_token 就是前面缓存的 token。HttpUtil.doPost 的超时设为 20000 毫秒,因为发消息接口偶尔会慢。注意 userid_list 传的是钉钉的 userid,不是手机号。
注意:工作通知消息的接口权限需要在钉钉后台开通,否则会返回权限不足。另外,消息发送失败不会影响免登主流程,建议用 try-catch 包起来,别让发消息的异常把登录搞挂了。
4. 避坑排查:免登联调时最容易翻车的五个点
4.1 现象:code 换 userid 返回 400,提示 invalid code
原因:code 是一次性的,且有效期极短。常见触发场景是前端拿到 code 后没有立刻发请求,或者用户刷新了页面导致 code 被重复使用。另一个原因是 corpId 填错,导致 code 根本不属于这个企业。
解决:前端在 onSuccess 回调里第一时间发 AJAX,不要做任何异步等待。后端收到 code 后立即调用 getuserinfo,不要先做其他耗时操作。corpId 从钉钉后台首页复制,别手敲。
4.2 现象:gettoken 返回 400,提示 invalid appkey or appsecret
原因:appKey 或 appSecret 填错,或者微应用类型选错了。项目里特别强调是在“企业内部开发”中创建 H5 微应用,不是“第三方企业应用”。两者拿到的凭证体系不一样,用错了就换不到 token。
解决:登录 open-dev.dingtalk.com,确认应用在“企业内部开发”分类下。重新复制 appKey 和 appSecret,注意不要带空格。如果还是不行,检查应用是否已发布,未发布的应用部分接口不可用。
4.3 现象:getuserinfo 返回 400,提示 ip not in whitelist
原因:钉钉要求调用服务端接口的服务器公网 IP 在微应用的白名单里。很多公司出口 IP 不固定,或者用了多台机器负载均衡,只填了一个 IP。
解决:用curl ifconfig.me查看当前公网 IP,填到微应用的服务器出口 IP 配置里。如果 IP 会变,常见做法是联系运维固定出口 IP,或者把所有可能的出口 IP 都加上。项目代码里也做了处理:errcode 不为 0 时直接把 errmsg 返回给前端,方便定位。
4.4 现象:token 突然失效,所有免登请求报错
原因:定时任务没跑起来,或者 Redis 里的 token 被清掉了。另一个常见原因是多个实例同时刷新 token,后刷新的覆盖了先刷新的,导致部分请求拿到旧 token。
解决:检查@EnableScheduling是否生效,taskRun 配置是否为 true。多实例部署时,用分布式锁保证只有一个实例执行刷新,或者把刷新逻辑抽到单独的定时任务服务里。Redis 的 key 设置合理的过期时间,但不要短于刷新周期。
4.5 现象:发消息返回 400,提示 send too fast 或超过频率限制
原因:给同一个用户发送了相同内容,触发了“一天一次”的限制。或者短时间内给大量用户发消息,触发了接口频率限制。
解决:在消息内容里拼时间戳或随机数,保证每次内容不同。批量发送时加间隔,不要瞬间打满。项目里的做法是在 content 里拼yyyy-MM-dd HH:mm:ss,简单有效。
5. 进阶技巧:把免登做成可复用的认证切面
免登逻辑写在一个 Controller 里能跑,但如果有多个 H5 页面都要免登,复制粘贴就会失控。我一般会把“code 换用户”这段抽成一个独立的服务方法,返回一个统一的 LoginUser 对象,Controller 只负责调它和跳转。
@Service public class DingTalkAuthService { @Autowired private JedisClient jedisClient; @Value("${dtalk.userUrl}") private String userUrl; @Value("${dtalk.userDetailUrl}") private String userDetailUrl; @Value("${dtalk.redisTokenKey}") private String tokenKey; /** * 用免登code换取系统用户,失败返回null */ public SysUser authByCode(String code) { String accessToken = jedisClient.get(tokenKey); if (accessToken == null) { throw new RuntimeException("token未就绪,请检查定时任务"); } // code换userid String userIdUrl = userUrl + "?access_token=" + accessToken + "&code=" + code; JsonNode user = JsonUtil.getJsonNode(HttpUtil.doGet(userIdUrl)); if (user.get("errcode").asInt() != 0) { return null; } String userId = user.get("userid").asText(); // userid换手机号 String userInfoUrl = userDetailUrl + "?access_token=" + accessToken + "&userid=" + userId; JsonNode userInfo = JsonUtil.getJsonNode(HttpUtil.doGet(userInfoUrl)); String mobile = userInfo.get("mobile").asText(); return sysUserService.getByMobile(mobile); } }这样 Controller 里就只剩三行:调 authByCode、判空、返回结果。多个页面共用同一套逻辑,改一处全生效。
验证免登是否真的通了,我习惯按这个顺序走一遍:先在钉钉开发者后台确认应用已发布、IP 白名单已配、接口权限已开;然后在手机钉钉里点微应用,看前端是否拿到 code;再看后端日志里 getuserinfo 返回的 errcode 是否为 0;最后看 Redis 里 token 是否存在且未过期。这四步走完,基本能定位到是哪一环断了。
还有一个容易忽略的点:H5 系统的用户表里,手机号字段必须和钉钉返回的 mobile 格式一致。钉钉返回的是不带国家码的 11 位手机号,如果你的系统存的是带 +86 的格式,比对就会失败。我一般会在比对前做一次归一化,去掉 +86 和空格。
从那以后我每次接钉钉免登,都强制先把 token 定时任务和 IP 白名单这两件事确认一遍,再开始写业务代码。这两处不出问题,后面的链路基本就是顺的。希望帮到你。
本文还有配套的精品资源,点击获取