Browse Source

fix: 组织准入校验移出事务 + 细分 getbyunionid 错误码

- 组织校验 HTTP 调用从 DB 事务内移到 login() 事务外预校验,
  避免首登洪峰长时间占用事务连接(code review M1)
- parseOrgMember 只将 60121(未找到对应员工)判为非成员,
  其他 errcode 视为调用失败抛异常,不误判拒绝(code review m1)
- 企业级 token 失效(40014/42001)时清缓存重取重试一次
master
luoweijian 1 month ago
parent
commit
13e15c142a
  1. 39
      crm-auth/src/main/java/com/crm/auth/service/client/DingTalkAuthClient.java
  2. 47
      crm-auth/src/main/java/com/crm/auth/service/impl/AuthServiceImpl.java
  3. 49
      crm-auth/src/test/java/com/crm/auth/service/client/DingTalkAuthClientTest.java

39
crm-auth/src/main/java/com/crm/auth/service/client/DingTalkAuthClient.java

@ -84,9 +84,25 @@ public class DingTalkAuthClient implements ThirdPartyAuthClient {
} }
String corpToken = getCorpAccessToken(); String corpToken = getCorpAccessToken();
String rawBody = requestGetByUnionIdApi(corpToken, unionId); String rawBody = requestGetByUnionIdApi(corpToken, unionId);
// 企业级 token 失效(40014 非法 / 42001 超时):清缓存重取一次,避免缓存期内组织校验整体不可用
if (isTokenInvalid(rawBody)) {
log.warn("组织准入校验:企业级 token 已失效,清缓存重取重试,unionId={}", unionId);
stringRedisTemplate.delete(AuthConstants.DINGTALK_CORP_TOKEN_KEY);
corpToken = getCorpAccessToken();
rawBody = requestGetByUnionIdApi(corpToken, unionId);
}
return parseOrgMember(rawBody, unionId); return parseOrgMember(rawBody, unionId);
} }
/**
* 判断 getbyunionid 响应是否为 token 失效类错误40014 非法 token / 42001 token 超时
*/
private boolean isTokenInvalid(String rawBody) {
JSONObject result = JSONUtil.parseObj(rawBody);
Integer errcode = result.getInt("errcode");
return errcode != null && (errcode == 40014 || errcode == 42001);
}
/** /**
* 获取企业级 access token优先读 Redis 缓存未命中则调钉钉接口并回写缓存 * 获取企业级 access token优先读 Redis 缓存未命中则调钉钉接口并回写缓存
*/ */
@ -111,16 +127,31 @@ public class DingTalkAuthClient implements ThirdPartyAuthClient {
} }
/** /**
* 解析 getbyunionid 响应errcode 0 result.userid 非空即属于本组织 * 解析 getbyunionid 响应
* <ul>
* <li>errcode=0 result.userid 非空 -> 属于本组织</li>
* <li>errcode=60121未找到对应员工-> 不属于本组织</li>
* <li>其他 errcodetoken 失效权限不足等-> 调用失败抛异常而非误判为非成员</li>
* </ul>
*/ */
private boolean parseOrgMember(String rawBody, String unionId) { private boolean parseOrgMember(String rawBody, String unionId) {
JSONObject result = JSONUtil.parseObj(rawBody); JSONObject result = JSONUtil.parseObj(rawBody);
Integer errcode = result.getInt("errcode"); Integer errcode = result.getInt("errcode");
if (errcode == null || errcode != 0) { if (errcode == null) {
log.info("组织准入校验未通过:unionId={}, errcode={}, errmsg={}", log.error("组织准入校验响应缺少 errcode:unionId={}, body={}", unionId, rawBody);
unionId, errcode, result.getStr("errmsg")); throw new BusinessErrorException(AuthConstants.CODE_THIRD_PARTY_AUTH_FAIL,
"钉钉组织归属查询失败,请稍后重试");
}
if (errcode == 60121) {
log.info("组织准入校验未通过:unionId={}, errcode=60121 未找到对应员工", unionId);
return false; return false;
} }
if (errcode != 0) {
log.error("组织准入校验调用失败:unionId={}, errcode={}, errmsg={}",
unionId, errcode, result.getStr("errmsg"));
throw new BusinessErrorException(AuthConstants.CODE_THIRD_PARTY_AUTH_FAIL,
"钉钉组织归属查询失败,请稍后重试");
}
JSONObject userResult = result.getJSONObject("result"); JSONObject userResult = result.getJSONObject("result");
boolean member = userResult != null && StrUtil.isNotBlank(userResult.getStr("userid")); boolean member = userResult != null && StrUtil.isNotBlank(userResult.getStr("userid"));
if (!member) { if (!member) {

47
crm-auth/src/main/java/com/crm/auth/service/impl/AuthServiceImpl.java

@ -71,8 +71,12 @@ public class AuthServiceImpl implements IAuthService {
ThirdPartyAuthClient client = clientFactory.getClient(type); ThirdPartyAuthClient client = clientFactory.getClient(type);
ThirdPartyUserInfo thirdUser = client.getUserInfo(authCode); ThirdPartyUserInfo thirdUser = client.getUserInfo(authCode);
// 2~4. 数据库操作(事务保护,通过 self 调用走 AOP 代理) // 2. 事务外组织准入预校验(ADR-0010):仅本地无匹配用户时触发。
AuthUser user = self.doLoginInTx(type, thirdUser, client); // 钉钉组织通讯录 HTTP 调用放在 DB 事务之外,避免首登洪峰长时间占用事务连接
preCheckOrgMembership(type, thirdUser, client);
// 3. 数据库操作(事务保护,通过 self 调用走 AOP 代理)
AuthUser user = self.doLoginInTx(type, thirdUser);
// 5. 签发 token(Redis 写入在 DB 事务之外,失败不影响 DB 数据) // 5. 签发 token(Redis 写入在 DB 事务之外,失败不影响 DB 数据)
AuthLoginUser loginUser = new AuthLoginUser(); AuthLoginUser loginUser = new AuthLoginUser();
@ -92,8 +96,8 @@ public class AuthServiceImpl implements IAuthService {
* DB 事务内的登录核心逻辑定位用户 + 校验状态 + 刷新登录时间 * DB 事务内的登录核心逻辑定位用户 + 校验状态 + 刷新登录时间
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
protected AuthUser doLoginInTx(IdentityTypeEnum type, ThirdPartyUserInfo thirdUser, ThirdPartyAuthClient client) { protected AuthUser doLoginInTx(IdentityTypeEnum type, ThirdPartyUserInfo thirdUser) {
AuthUser user = resolveUser(type, thirdUser, client); AuthUser user = resolveUser(type, thirdUser);
if (Boolean.FALSE.equals(user.getEnabled())) { if (Boolean.FALSE.equals(user.getEnabled())) {
throw new BusinessErrorException(AuthConstants.CODE_USER_DISABLED, "账号已被禁用,请联系管理员"); throw new BusinessErrorException(AuthConstants.CODE_USER_DISABLED, "账号已被禁用,请联系管理员");
@ -107,9 +111,10 @@ public class AuthServiceImpl implements IAuthService {
} }
/** /**
* 定位本地用户已绑定身份 -> 手机号匹配 -> 组织准入校验 -> 首登注册 * 定位本地用户已绑定身份 -> 手机号匹配 -> 首登注册
* <p>组织准入校验ADR-0010 login() 在事务外预校验完成本方法不再调钉钉 API</p>
*/ */
private AuthUser resolveUser(IdentityTypeEnum type, ThirdPartyUserInfo thirdUser, ThirdPartyAuthClient client) { private AuthUser resolveUser(IdentityTypeEnum type, ThirdPartyUserInfo thirdUser) {
// 2.1 该三方身份已绑定过,直接取对应用户 // 2.1 该三方身份已绑定过,直接取对应用户
AuthIdentity identity = authIdentityService.getByTypeAndUnionId(type, thirdUser.getUnionId()); AuthIdentity identity = authIdentityService.getByTypeAndUnionId(type, thirdUser.getUnionId());
if (identity != null) { if (identity != null) {
@ -127,9 +132,8 @@ public class AuthServiceImpl implements IAuthService {
user = authUserService.getByMobile(thirdUser.getMobile()); user = authUserService.getByMobile(thirdUser.getMobile());
} }
// 2.3 仍无匹配 -> 组织准入校验(ADR-0010)通过后首登自动注册 // 2.3 仍无匹配 -> 首登自动注册(组织准入已由 login() 的事务外预校验保证)
if (user == null) { if (user == null) {
verifyOrgMembership(client, thirdUser);
user = registerNewUser(thirdUser); user = registerNewUser(thirdUser);
} }
@ -147,6 +151,33 @@ public class AuthServiceImpl implements IAuthService {
return user; return user;
} }
/**
* 事务外组织准入预校验ADR-0010本地已有匹配用户的登录不校验
* 仅本地查不到时调钉钉组织通讯录确认归属HTTP 调用不占用 DB 事务连接
* <p>事务内注册路径的组织门禁由此处保证login() doLoginInTx 的唯一入口</p>
*/
private void preCheckOrgMembership(IdentityTypeEnum type, ThirdPartyUserInfo thirdUser, ThirdPartyAuthClient client) {
if (hasLocalUser(type, thirdUser)) {
return;
}
verifyOrgMembership(client, thirdUser);
}
/**
* 只读探测本地是否已有匹配用户 resolveUser 的定位规则一致仅用于决定是否触发组织准入校验
*/
private boolean hasLocalUser(IdentityTypeEnum type, ThirdPartyUserInfo thirdUser) {
AuthIdentity identity = authIdentityService.getByTypeAndUnionId(type, thirdUser.getUnionId());
if (identity != null) {
AuthUser existUser = authUserService.getById(identity.getUserId());
if (existUser != null) {
return true;
}
}
return StrUtil.isNotBlank(thirdUser.getMobile())
&& authUserService.getByMobile(thirdUser.getMobile()) != null;
}
/** /**
* 组织准入校验ADR-0010非本组织成员拒绝登录仅在首登注册前触发已在 DB 中的用户不受影响 * 组织准入校验ADR-0010非本组织成员拒绝登录仅在首登注册前触发已在 DB 中的用户不受影响
*/ */

49
crm-auth/src/test/java/com/crm/auth/service/client/DingTalkAuthClientTest.java

@ -162,7 +162,7 @@ class DingTalkAuthClientTest {
} }
@Test @Test
@DisplayName("getbyunionid 未命中(errcode 非 0)-> isOrgMember 返回 false") @DisplayName("getbyunionid 未命中(errcode=60121 未找到对应员工)-> isOrgMember 返回 false")
void isOrgMember_notFound_returnsFalse() { void isOrgMember_notFound_returnsFalse() {
when(valueOps.get(AuthConstants.DINGTALK_CORP_TOKEN_KEY)).thenReturn(CORP_TOKEN); when(valueOps.get(AuthConstants.DINGTALK_CORP_TOKEN_KEY)).thenReturn(CORP_TOKEN);
DingTalkAuthClient client = clientWithOrg( DingTalkAuthClient client = clientWithOrg(
@ -173,6 +173,53 @@ class DingTalkAuthClientTest {
assertThat(member).isFalse(); assertThat(member).isFalse();
} }
@Test
@DisplayName("getbyunionid 返回非 60121 的业务错误(如 40001 无权限)-> 抛 61002,不误判为「非成员」")
void isOrgMember_apiBusinessError_throws61002() {
when(valueOps.get(AuthConstants.DINGTALK_CORP_TOKEN_KEY)).thenReturn(CORP_TOKEN);
DingTalkAuthClient client = clientWithOrg(
() -> CORP_TOKEN,
(corpToken, unionId) -> "{\"errcode\":40001,\"errmsg\":\"access forbidden\"}");
assertThatThrownBy(() -> client.isOrgMember(thirdUser("u-1")))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", AuthConstants.CODE_THIRD_PARTY_AUTH_FAIL);
}
@Test
@DisplayName("企业级 token 失效(40014)-> 清缓存重取重试,重试成功返回 true")
void isOrgMember_tokenInvalid_retriesWithFreshToken() {
when(valueOps.get(AuthConstants.DINGTALK_CORP_TOKEN_KEY)).thenReturn(CORP_TOKEN, CORP_TOKEN, null);
AtomicInteger getByUnionIdCalls = new AtomicInteger();
DingTalkAuthClient client = clientWithOrg(
() -> "{\"accessToken\":\"" + CORP_TOKEN + "\"}",
(corpToken, unionId) -> {
getByUnionIdCalls.incrementAndGet();
if (getByUnionIdCalls.get() == 1) {
return "{\"errcode\":40014,\"errmsg\":\"invalid access_token\"}";
}
return "{\"errcode\":0,\"result\":{\"userid\":\"manager123\"}}";
});
assertThat(client.isOrgMember(thirdUser("u-1"))).isTrue();
assertThat(getByUnionIdCalls.get()).isEqualTo(2);
// 失效的旧 token 被删除,重新回写新 token
verify(redis).delete(AuthConstants.DINGTALK_CORP_TOKEN_KEY);
}
@Test
@DisplayName("企业级 token 失效重试后仍失效 -> 抛 61002(调用失败,不是「非成员」)")
void isOrgMember_tokenInvalidRetryStillFails_throws61002() {
when(valueOps.get(AuthConstants.DINGTALK_CORP_TOKEN_KEY)).thenReturn(CORP_TOKEN, CORP_TOKEN, null);
DingTalkAuthClient client = clientWithOrg(
() -> "{\"accessToken\":\"" + CORP_TOKEN + "\"}",
(corpToken, unionId) -> "{\"errcode\":42001,\"errmsg\":\"access_token expired\"}");
assertThatThrownBy(() -> client.isOrgMember(thirdUser("u-404")))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", AuthConstants.CODE_THIRD_PARTY_AUTH_FAIL);
}
@Test @Test
@DisplayName("getbyunionid errcode=0 但 result 无 userid -> isOrgMember 返回 false") @DisplayName("getbyunionid errcode=0 但 result 无 userid -> isOrgMember 返回 false")
void isOrgMember_emptyResult_returnsFalse() { void isOrgMember_emptyResult_returnsFalse() {

Loading…
Cancel
Save