简介:一份面向 Java 开发者的钉钉微应用免登实现方案,典型场景是用户在钉钉 App 内点击企业微应用后,无需重复登录即可直接进入某个 H5 系统首页,并可根据企业内部账号体系判断是否有权访问。资源梳理了免登全流程:先创建一个前端页面,通过钉钉 JS-API 的 requestAuthCode 获取免登授权码 code;随后后端接收 code,用 appKey、appSecret 换取 access_token 并缓存到 Redis 中,再调用钉钉用户信息接口校验用户身份;校验成功则重定向到 H5 首页,失败则显示友好错误提示,免登成功后还可主动给用户发送一条提醒消息。同时归纳了实际接入中的关键注意点,包括微应用在钉钉开放平台创建时需区分内部开发与第三方企业应用、必须记录 agentId/appKey/appSecret/corpId 四个参数、配置服务器公网 IP 白名单、开通企业通讯录相关接口权限,以及定期刷新 token、处理网络异常等。整份资源压缩包仅含 1 个 PDF 文档,体积约 129KB,适合已有 Java Web 基础、想在钉钉生态中快速落地 H5 免登功能的工程师查阅复用;这份 PDF 已有 2113 人浏览学习。文档条理清晰,覆盖从建应用、取 code、换 token、调接口到前端跳转的完整闭环,对无权限场景也给出了处理思路,可直接参照排坑落地。
1. 从“钉钉里点开页面还要登录”到免登:这个需求在解决什么
企业内部把审批、报表、工单这类H5系统挂上钉钉工作台之后,最常见的尴尬是:用户在钉钉里点开应用,页面却弹出一个跟自己系统独立的登录框,手机小键盘输账号密码又慢又容易错,换密码后更是全员不会登。用java做钉钉微应用免登,就是让H5首页在钉钉容器内直接打开,用户全程不需要看到登录框。这套方案面向的是内部信息化负责人、给企业做集成的Java开发,以及所有想把第三方系统塞进钉钉工作台的运维人员。下面把免登从“玄学”拆成一条可复现的链路,落到Spring Boot代码和上线前要验证的清单。
2. 免登链路为什么绕不开Java后端:选型与参数拆解
2.1 免登的本质:用钉钉身份换一个可信会话
钉钉微应用免登并不是一个开关,也不是后端配置一个corpSecret就能让所有页面自动免密。它的本质是一次三方换票:钉钉客户端给页面提供一个可信的执行环境,前端在这个环境里通过JS-SDK申请到一次性授权码authCode;Java后端拿着authCode,配合应用的appKey/appSecret去钉钉开放平台换取“当前登录钉钉的人是谁”,得到userid;拿到userid之后,后端再把它映射成H5系统自己的登录态。整个过程里,前端只碰一次性票据,不接触手机号、部门等敏感字段。把身份兑换放在服务端做,是为了避免用户信息在网页里裸奔,也便于后端记录审计日志。
很多人第一次接免登时会误以为“既然脚本里有corpSecret,用户点开就应该自动登录”。实际不是这样:如果H5页面不是从钉钉容器里打开,前端根本申请不到authCode,免登链路第一步就断了。所以在做技术方案时,要先接受一个前提:免登只对钉钉微应用内打开的页面生效,浏览器直接访问系统地址时,仍然要回到传统的账号密码登录。这也是为什么很多内部H5系统会同时保留两套入口。
2.2 五个关键参数与各自的“寿命”
上手之前先把涉及的参数列清楚。corpId是企业ID,在钉钉开放平台首页能看到,它标识当前H5挂在哪个组织下;appKey和appSecret(新版后台里对应Client ID与Client Secret)是自建应用的凭证,在应用详情页生成,负责让后端以“该应用”的身份调用钉钉接口;authCode是前端在运行时申请到的一次性票据,传给后端换userid后立刻作废;access_token则是后端用appKey/appSecret换来的企业级凭证,所有后续开放接口调用都要带上它。
| 参数 | 从哪里看 | 有效期 | 使用位置 |
|---|---|---|---|
| corpId | 钉钉开放平台首页 | 长期不变 | 前端申请authCode、后端绑定表维度 |
| appKey / appSecret | 应用详情页 | 长期,但可重置 | 后端换取access_token |
| authCode | 前端JS-SDK运行时获取 | 极短且只能用一次 | 后端换userid |
| access_token | 后端用appKey/appSecret换 | 7200秒左右 | 后续所有钉钉开放接口凭证 |
access_token有一个特殊机制容易被忽视:它在整个企业内唯一,后一次拉取会让之前拉取的access_token失效。也就是说不能每个实例各自拉一个,否则所有并发请求会互相把对方的凭证踢下线,导致大面积401。至于为什么authCode有效期那么短,是因为它是为“正在钉钉里操作的那个人”证明身份的临时凭证,生命周期越短,被截获后重放的风险越小;而后端拿到userid之后,真正维持长期登录的是H5系统自己签发的token,跟authCode已经没有关系。
2.3 为什么选Java而不是让H5前端直连钉钉
选java实现这套逻辑,并不是因为钉钉只有Java SDK,而是因为大多数内部H5系统的服务端本来就是一个基于Spring Boot的Java应用,用户表、权限、菜单都在这个应用里。免登要做的事情是把“钉钉身份”翻译成“H5系统身份”,这个翻译动作放在业务侧最顺,因为拿到userid之后立刻就要查用户表、签token,不需要跨服务。退一步说,就算未来把免登独立成认证服务,用Java写也便于复用现有的用户体系代码。
另一个原因是排查成本。前端的JavaScript代码跑在钉钉内置浏览器里,出了问题难抓包,日志也不好捞。把身份换取的HTTP调用全部放到Java后端,请求参数、响应体、耗时都可以打进应用日志,和H5业务日志串成一条线。这个优势在多人协作的团队里非常实用,不需要前端同学一遍遍在钉钉开发者工具里反复试。我见过一些项目让前端直接拿appKey去调接口,等于把应用凭证暴露在公网环境里,换userid之后的日志也没法统一审计,怎么看都不是好做法。
2.4 免登时序:从点开应用到进入首页的6步
把整条链路按时间顺序讲清楚,后面写代码就不会迷路。第一步,用户在钉钉里点开工作台中的H5应用,钉钉客户端加载配置好的首页地址。第二步,前端页面完成dd.ready初始化后,调用钉钉JS-SDK申请authCode。第三步,前端把authCode通过POST请求交给Java后端的免登接口。第四步,后端先取缓存里的access_token,没有就用appKey/appSecret重新拉取。第五步,后端带access_token和authCode调用钉钉的getuserinfo接口,得到当前用户的userid。第六步,后端把这个userid映射成本系统用户,签发自己的token,返回给前端,前端携带token跳转到H5首页。
需要特别说明的是,第二步到第五步之间的耗时非常敏感:authCode只能用一次,且有效期短,前端一旦在取码之后停留过久,后端换userid就会报“无效code”。所以生产环境我一般建议前端拿到code后立即提交,不要做二次确认、不要停留在当前页等用户操作。access_token的获取时间完全可以用缓存吸收,不必每次请求都重新拉取,这样免登接口的响应时间基本就取决于一次钉钉getuserinfo调用的网络耗时。
3. 用Java实现免登核心接口:从authCode换到userid
3.1 先准备依赖与配置文件
在Spring Boot工程里,免登接口涉及的依赖并不多:spring-boot-starter-web提供HTTP接口能力,spring-boot-starter-data-redis用来缓存access_token和登录态,然后就是Java自带的RestTemplate。pom.xml里的关键部分如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>starter-validation不是必需,但authCode参数校验用得上。配置文件方面,建议把这些钉钉参数集中放在application.yml里,并加上环境前缀,避免测试环境和生产环境混用。常见配置如下:
ding: corp-id: "钉钉开放平台首页的企业ID" app-key: "自建应用的appKey" app-secret: "自建应用的appSecret" access-token-cache-key: "ding:access_token" login-token-expire-hours: 12appSecret属于敏感信息,如果公司有配置中心或密钥管理服务,不要把它写死在代码仓库里,而是用占位符配合启动环境变量注入。很多团队把这套参数直接提交到Git仓库,后来appSecret重置一次就要发一次版,得不偿失。我一般会在配置中心放一套、本地放一套,用profile区分环境。
3.2 核心客户端:用来换access_token和userid
下面这个类是免登链路的后端核心,负责两件事:第一,从缓存或钉钉接口获取access_token;第二,用authCode换取当前登录用户的userid。代码里刻意没有引入钉钉官方SDK,而是直接走HTTP接口,目的是让读者看清每个参数的流向,也方便排查。如果团队希望少写代码,引入官方SDK后逻辑是等价的。
// DingTalkClient.java package com.example.dingauth.service; import java.time.Duration; import java.util.HashMap; import java.util.Map; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; @Service public class DingTalkClient { private static final String GET_TOKEN_URL = "https://oapi.dingtalk.com/gettoken?appkey={appKey}&appsecret={appSecret}"; private static final String GET_USER_INFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo?access_token={accessToken}"; private final StringRedisTemplate redisTemplate; private final RestTemplate restTemplate; @Value("${ding.app-key}") private String appKey; @Value("${ding.app-secret}") private String appSecret; public DingTalkClient(StringRedisTemplate redisTemplate, RestTemplate restTemplate) { this.redisTemplate = redisTemplate; this.restTemplate = restTemplate; } public String getAccessToken() { String cacheKey = "ding:access_token"; String cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null && !cached.isEmpty()) { return cached; } Map<String, String> params = new HashMap<>(); params.put("appKey", appKey); params.put("appSecret", appSecret); GetTokenResponse resp = restTemplate.getForObject( GET_TOKEN_URL, GetTokenResponse.class, params); if (resp == null || resp.getErrcode() != 0) { throw new IllegalStateException("获取钉钉access_token失败: " + resp); } // 钉钉默认有效期7200秒,这里提前300秒刷新,避免边界过期 redisTemplate.opsForValue().set( cacheKey, resp.getAccessToken(), Duration.ofSeconds(7200 - 300)); return resp.getAccessToken(); } public String getUserIdByAuthCode(String authCode) { String accessToken = getAccessToken(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); Map<String, String> body = new HashMap<>(); body.put("code", authCode); HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers); ResponseEntity<GetUserInfoResponse> response = restTemplate.exchange( GET_USER_INFO_URL, HttpMethod.POST, entity, GetUserInfoResponse.class, accessToken); GetUserInfoResponse resp = response.getBody(); if (resp == null || resp.getErrcode() != 0 || resp.getResult() == null) { throw new IllegalStateException("authCode换userid失败: " + resp); } return resp.getResult().getUserid(); } // 省略GetTokenResponse和GetUserInfoResponse的静态内部类,字段与钉钉接口返回一一对应 }这段代码有几个参数需要说明。GET_TOKEN_URL采用模板占位符写法,appKey和appSecret会被RestTemplate自动替换,避免拼字符串时出现特殊字符问题。access_token的缓存采用“先查缓存,没有再拉取”的写法,这比每次请求都拉取要稳得多,也为后面多实例防止覆盖打下了基础。getuserinfo接口必须使用POST,body里传code,鉴权方式是把access_token挂到URL的参数上,这个细节很容易被当成GET请求导致签名错误。
如果应用部署了多个实例,上面的“先查缓存”仍存在同一时刻并发拉取的窗口。更稳的做法是加一把Redis分布式锁,在锁内再查一次缓存,查不到才调钉钉接口。这个锁不复杂,但属于高并发场景下的必要补丁,后面避坑章节会给出触发原因。
3.3 前端配合:在钉钉容器里取authCode
后端接口准备好后,前端要做的是在钉钉容器里安全取到authCode。这里说的“安全”主要指两点:一是要判断当前环境确实是钉钉内置浏览器,二是要在dd.ready回调成功后再取码,不要在页面加载早期就抢跑。常见的前端写法如下:
// auth.js —— 钉钉微应用免登取码模块 import * as dd from 'dingtalk-jsapi'; function getAuthCode(corpId) { return new Promise((resolve, reject) => { // 先判断容器:非钉钉环境直接走账号密码登录 const ua = navigator.userAgent; if (ua.indexOf('DingTalk') === -1) { reject(new Error('NOT_IN_DINGTALK')); return; } dd.ready(() => { dd.runtime.permission.requestAuthCode({ corpId: corpId, onSuccess: function (info) { // info.code 就是一次性authCode,拿到后立刻提交后端 resolve(info.code); }, onFail: function (err) { reject(err); } }); }); }); }这段代码里的corpId可以从后端一个公开配置接口读取,也可以在前端构建时注入。我不建议把它硬编码在静态JS里,因为如果企业有多个组织,同一个H5系统可能要被多个corpId复用。取到authCode后,前端应该马上交给免登接口:用fetch或axios POST到/api/ding/login,然后把后端返回的token存起来跳首页。这里有个容易忽略的细节——dd.ready在部分钉钉客户端版本中会等待很久,如果超过5秒还没回调,不要死等,直接降级到登录页,避免用户感觉页面卡死。这里保留的是存量项目里最常见的requestAuthCode写法,如果你的钉钉JS-SDK版本提示该方法不可用,按官方文档换成新的取码方法即可,语义完全一样。
钉钉内置浏览器对H5页面会做缓存。改了前端代码后,第一次在钉钉里打开可能还是旧页面,需要清除客户端缓存或退出重进。这个现象经常被误认为是免登失败,排查时先排除这一项,能省半小时。
3.4 后端超时与重试:两个接口要区别对待
钉钉开放接口属于外部依赖,网络抖动是常态。但免登链路对“重试”要特别谨慎:gettoken接口可以重试,因为它是幂等的;getuserinfo接口不能盲目重试,因为authCode用一次就作废,重试只会拿到同一个“invalid code”。所以RestTemplate的超时参数要单独配置,我一般把连接超时设为3秒、读取超时设为5秒,用下面的方式创建:
// RestTemplateConfig.java @Configuration public class RestTemplateConfig { @Bean public RestTemplate dingRestTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(3000); factory.setReadTimeout(5000); return new RestTemplate(factory); } }如果读取超时设得太短,比如1秒,钉钉接口在高峰期会偶发超时;但设得太长,用户会感觉页面长时间转圈,而且authCode在等待期间早就过期了。3秒连接、5秒读取是一个在大多数网络环境下都够用的起点,具体可以根据公司到钉钉机房的RTT微调。操作上我的习惯是:把gettoken和getuserinfo的耗时单独打到日志里,连续观察两天再决定要不要调整超时。
提示:getuserinfo接口的鉴权是在URL上带access_token,body里只放code,不要把appSecret放进请求,也不要让前端直接调钉钉接口换userid。
4. 免登之后进入H5首页:用户绑定、Token签发与跳转
4.1 用户怎么对上号:本地绑定表优先,手机号兜底
拿到钉钉的userid只是完成了“知道这个人在钉钉里是谁”,还差一步“知道他在H5系统里是谁”。常见做法有两种:一是维护一张绑定表,让用户第一次访问时手动绑定钉钉账号和H5账号,以后就靠这张表;二是通过手机号自动匹配,因为钉钉通讯录里有手机号,H5用户表里往往也预留了手机号,后端调用钉钉通讯录详情接口拿到手机号后直接查H5用户。如果H5系统本身就是Spring Boot加MyBatis这类常见Java后端,绑定表的实现成本非常低:
-- ding_user_bind.sql create table ding_user_bind ( id bigint auto_increment primary key, corp_id varchar(64) not null comment '钉钉企业ID', ding_userid varchar(128) not null comment '钉钉用户userid', h5_user_id bigint not null comment 'H5系统用户ID', create_time datetime not null default current_timestamp, unique key uk_corp_ding_user (corp_id, ding_userid) ) comment '钉钉用户与H5系统用户绑定关系表';绑定表一定要把corpId和dingUserId一起作为唯一键。原因前面说过:同一个钉钉账号在不同企业下的userid可能是相同字符串,如果只按dingUserId查,两个企业的用户会串号。手机号自动匹配适合老系统改造的过渡期,但手机号可能会变更,绑定时效也不可控,而且通讯录详情接口涉及企业数据权限,不是每个企业都会开放。所以我的建议是:能建绑定表就建绑定表,手机号只作为首次匹配的兜底手段。
4.2 签发H5自己的登录态:UUID加Redis
免登成功之后,千万不要把钉钉的access_token拿来做H5系统的登录态。钉钉的access_token是应用级凭证,不是用户凭证,用它做权限判断既不符合安全模型,也没办法单独踢人下线。正确的做法是让H5系统用自己的方式签发token,常见的有JWT和Redis两种。内部系统我一般选UUID加Redis,理由很简单:可以随时主动失效,管理员踢人只要删一个key;而JWT天然难撤销,一旦泄漏要到过期时间才能失效。
// TokenService.java import java.time.Duration; import java.util.UUID; @Service public class TokenService { private static final Duration EXPIRE = Duration.ofHours(12); private final StringRedisTemplate redisTemplate; public TokenService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public String issueToken(Long h5UserId) { // 用UUID去掉横线,长度适中且不包含可猜测的业务信息 String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set( "h5:login:" + token, String.valueOf(h5UserId), EXPIRE); return token; } public Long parseUserId(String token) { String value = redisTemplate.opsForValue().get("h5:login:" + token); return value == null ? null : Long.valueOf(value); } }过期时间12小时是内部系统的常见值,一个工作日足够。如果公司安全要求高,可以缩短到8小时或更短,代价是用户下午又要重新走一次钉钉免登——反正免登本身不花用户操作,缩短影响很小。这里的token通过HTTP响应返回给前端,建议在前端内存或localStorage中保存,不要放进URL里到处传播,否则会出现在访问日志中。
4.3 免登登录接口完整实现
把上面的DingTalkClient和TokenService串起来,就是免登登录接口。接口接收前端传来的corpId和authCode,正常情况下返回H5系统自己的token和首页地址;用户未绑定时,返回一个特殊状态码,由前端跳转到绑定页。
// DingAuthController.java @RestController @RequestMapping("/api/ding") public class DingAuthController { private final DingTalkClient dingTalkClient; private final UserMapper userMapper; private final TokenService tokenService; public DingAuthController(DingTalkClient dingTalkClient, UserMapper userMapper, TokenService tokenService) { this.dingTalkClient = dingTalkClient; this.userMapper = userMapper; this.tokenService = tokenService; } @PostMapping("/login") public LoginResult login(@RequestBody @Valid LoginRequest request) { String dingUserId = dingTalkClient.getUserIdByAuthCode(request.getAuthCode()); // 优先按绑定表查找,命中直接签发token H5User h5User = userMapper.findByDingBind(request.getCorpId(), dingUserId); if (h5User != null) { String token = tokenService.issueToken(h5User.getId()); return LoginResult.success(token, "/index"); } // 未绑定:返回特殊状态,由前端引导到绑定页完成首次绑定 return LoginResult.needBind(dingUserId); } }这段代码做了适当精简,但主流程是完整的。LoginResult.success里带着首页地址,前端不需要自己写死跳转路径,这样首页URL调整时不用改前端。异常情况由Spring的@RestControllerAdvice统一捕获,返回业务错误码,避免堆栈直接暴露给前端。如果企业允许通过手机号自动绑定,可以在未命中绑定表之后增加一步“调钉钉通讯录接口取手机号,再查H5用户表”,命中后自动写入绑定表,这个分支不影响上面主流程。
4.4 携带Token进首页:前端跳转与后端拦截器
前端拿到token之后,最稳的做法是先把token写入localStorage,再执行location.href跳转。如果边跳边传,首页首屏的接口请求可能比token写入更早发出,导致第一次请求就401。下面的代码演示了跳转前的保存逻辑:
// login-result.js async function handleDingAuthCode(authCode, corpId) { const resp = await fetch('/api/ding/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ corpId: corpId, authCode: authCode }) }); const data = await resp.json(); if (data.status === 'NEED_BIND') { location.href = '/bind?dingUserId=' + encodeURIComponent(data.dingUserId); return; } localStorage.setItem('h5_token', data.token); location.href = data.redirectUrl; }首页的接口请求则统一从一个工具函数里取token,塞进请求头。后端拦截器对需要登录的请求校验token,校验通过就把userId放入ThreadLocal或请求上下文。拦截器里要注意一点:不要对免登接口本身做token校验,否则会形成“登录需要token、token需要登录”的死循环;最直接的办法是给免登接口的路径加白名单,放在拦截器的excludePathPatterns里。
4.5 用户未绑定时降级:补绑定后自动登入
新员工第一天使用系统,钉钉通讯录里可能还没有对应H5账号,这时不能把用户挡在系统外面。降级方案是跳到一个很轻的绑定页:页面展示钉钉侧的用户名,用户输入H5系统的工号或手机号,后端验证通过后写入ding_user_bind表,同时直接签发token进入首页。这个绑定操作不需要用户输入密码,因为钉钉侧的身份已经被authCode认证过了,绑定页要验证的是“这个钉钉用户属于哪个H5账号”。如果企业安全要求高,可以再加一条短信验证码。绑定完成后再回到免登首页,用户后续访问就全自动了。
5. 避坑与排查:钉钉免登最常见的5个坑
5.1 现象:接口大量报invalid access_token
上线第一天,业务高峰期突然出现大面积“invalid access_token”,而且集中在某一批接口。排查发现,后端部署了3个实例,每个实例都各自拉取access_token并缓存在本地内存,后拉取的凭证覆盖了前面的,导致钉钉侧只认最新那个实例的token,其他实例的请求全部401。
原因就是前面讲过的:钉钉的access_token在企业内是唯一的,不是每个应用实例一个。解决方法是把access_token统一放到Redis,所有实例共用一份;再给“拉取凭证”这个动作加锁,保证同一时刻只有一个实例在调gettoken接口,其他实例等待后直接读缓存。我习惯用Redis的SETNX加一把10秒锁,拿到锁的实例拉取,没拿到锁的实例休眠后读缓存,确保不会并发覆盖。这个锁的成本很低,但能避免上线第二天的投诉。
5.2 现象:页面一刷新免登就失败
用户反馈,首次进入首页正常,一旦在H5页面里刷新,就跳到登录页或者报“无效code”。抓前后端日志发现,前端在页面刷新时又执行了一次requestAuthCode,拿到了新code,但提交给后端的却是localStorage里存的旧code,旧code早就因为被消费过而作废。
原因是authCode是一次性的,而很多前端实现把authCode存在了模块级变量或本地存储里,刷新后还拿出来用。解决方法是:页面上只保留一次取码动作,进入首页后不再重复取码;如果刷新后确实需要重新获取身份,应该重新调用requestAuthCode,拿到的是新code。另外,如果在钉钉客户端里改了代码还在跑旧逻辑,先清内置浏览器的缓存再排查其他,别在错误方向上浪费时间。
5.3 现象:联调时authCode换userid返回URL不合法
本地联调时,前端在钉钉开发者工具里能正常取到authCode,但后端拿着它去换userid,返回“url 不是合法地址”或“应用未授权”。翻配置发现,钉钉后台“开发管理”里配置的服务器URL还是生产域名,而本地联调用的是临时映射域名,钉钉校验来源URL时直接拒绝。
原因不是代码问题,而是钉钉对回调来源有严格的域名白名单校验。解决方法是把本地联调用的临时域名也加到后台的可信域名列表里,注意H5应用首页URL和服务器出口IP两处都可能需要配置。这里没有捷径,能用的域名就那几个,配置完等一两分钟再试。如果公司有固定的联调网关,尽量用它,避免每台开发机都要改后台配置。
5.4 现象:不同企业用户串号
两个下属企业用了同一套H5系统,A企业的某位员工登录后,B企业一个userid相同的人也能看到A的数据。原因是userid只在单个企业内唯一,不同企业之间可能重复;如果绑定表只存dingUserId不存corpId,查出来的就是错人。
解决方法是把corpId作为绑定表的维度之一,查询时必须同时带corpId和dingUserId。签发的H5 token里也建议把corpId一并带上,数据权限里再按corpId过滤。这个问题在老系统改造时尤其多发,因为老代码经常默认“钉钉里只有一个企业”。如果已经出现了串号数据,需要清理ding_user_bind表里的重复记录,再强制相关用户重新走一次免登绑定。
5.5 现象:偶发超时且前端无法重试
用户偶尔点开应用时页面转圈很久,最终失败;后端日志里能看到访问钉钉接口的connect timed out异常,但重试后又能成功。很多时候是后端默认HTTP客户端的超时时间太短,或者没有区分“可重试的gettoken”和“不可重试的getuserinfo”。
原因是authCode有效期窄,第一次调用getuserinfo超时后,authCode已经失效,再重试只会得到同样的错误。解决方法是把getuserinfo的超时设长一点,比如读取5秒,并且后端不做自动重试;前端如果遇到这类失败,可以让用户重新走一次应用入口,重新获取新的authCode,而不是原地重发同一个code。判断这类问题有个比较实用的信号:后端日志里如果同时出现两行请求同一个authCode的getuserinfo记录,多半就是重试逻辑写错了。
6. 免登链路的自测与灰度:如何验证没有翻车
6.1 冒烟验证:gettoken链路可以用脚本打,authCode链路只能真机
免登链路里只有access_token获取是纯后端动作,可以先写一段冒烟脚本验证参数和网络没问题。下面用curl做一次最小验证,把appKey和appSecret替换成你后台的真实值即可:
#!/usr/bin/env bash # ding-smoke.sh —— 只验证gettoken链路,authCode必须真机 APP_KEY="your-app-key" APP_SECRET="your-app-secret" TOKEN_JSON=$(curl -s "https://oapi.dingtalk.com/gettoken?appkey=${APP_KEY}&appsecret=${APP_SECRET}") echo "$TOKEN_JSON"如果返回errcode为0,说明凭证和网络没问题;下一步才需要在钉钉客户端里打开H5应用验证完整的免登流程。authCode无法脱离钉钉容器生成,所以后端接口不能完全靠脚本做自动化回归,至少要有一次真实手机上的冒烟。验证时重点看后端日志里的三个时间点:authCode接收、getuserinfo返回、token签发。
6.2 加一个免登开关,随时降级到账号密码登录
把免登做成默认开启但可随时关闭的开关,是上线前最便宜的保险。在application.yml里配置ding.login.enabled,然后在免登接口最前面判断一下:
ding: login: enabled: true fallback-url: "/login"当开关为false时,免登接口直接返回一个降级标识,由前端跳转到普通的账号密码登录页。这样万一免登在某个版本的钉钉客户端上出现兼容问题,不需要停机就能让全员回到老登录方式。灰度发布时,先用工作台可见范围把一个部门设为试点,观察两天再扩大到全公司;同时把免登登录成功率和平均耗时输出成指标,连续稳定再放心。
我自己在这些年的集成里吃过不少亏,最典型的就是多实例access_token互相覆盖,上线第二天就被用户投诉。后来凡是接外部身份源,我都习惯先锁凭证、再查缓存,日志里把身份源返回的原始报文完整打印出来,黑匣子就变成了透明盒子。这套流程不算复杂,但能让你在钉钉微应用免登这个功能上少踩许多坑。希望帮到你。
本文还有配套的精品资源,点击获取