From 13e15c142a5cec215cca1b02417b7786e2236b0a Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Mon, 3 Aug 2026 19:10:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=84=E7=BB=87=E5=87=86=E5=85=A5?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E7=A7=BB=E5=87=BA=E4=BA=8B=E5=8A=A1=20+=20?= =?UTF-8?q?=E7=BB=86=E5=88=86=20getbyunionid=20=E9=94=99=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 组织校验 HTTP 调用从 DB 事务内移到 login() 事务外预校验, 避免首登洪峰长时间占用事务连接(code review M1) - parseOrgMember 只将 60121(未找到对应员工)判为非成员, 其他 errcode 视为调用失败抛异常,不误判拒绝(code review m1) - 企业级 token 失效(40014/42001)时清缓存重取重试一次 --- .../service/client/DingTalkAuthClient.java | 39 +++++++++++++-- .../auth/service/impl/AuthServiceImpl.java | 47 +++++++++++++++--- .../client/DingTalkAuthClientTest.java | 49 ++++++++++++++++++- 3 files changed, 122 insertions(+), 13 deletions(-) diff --git a/crm-auth/src/main/java/com/crm/auth/service/client/DingTalkAuthClient.java b/crm-auth/src/main/java/com/crm/auth/service/client/DingTalkAuthClient.java index fd5c3ca..26922b7 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/client/DingTalkAuthClient.java +++ b/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 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); } + /** + * 判断 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 缓存,未命中则调钉钉接口并回写缓存 */ @@ -111,16 +127,31 @@ public class DingTalkAuthClient implements ThirdPartyAuthClient { } /** - * 解析 getbyunionid 响应:errcode 为 0 且 result.userid 非空即属于本组织 + * 解析 getbyunionid 响应: + *
组织准入校验(ADR-0010)由 login() 在事务外预校验完成,本方法不再调钉钉 API。
*/ - private AuthUser resolveUser(IdentityTypeEnum type, ThirdPartyUserInfo thirdUser, ThirdPartyAuthClient client) { + private AuthUser resolveUser(IdentityTypeEnum type, ThirdPartyUserInfo thirdUser) { // 2.1 该三方身份已绑定过,直接取对应用户 AuthIdentity identity = authIdentityService.getByTypeAndUnionId(type, thirdUser.getUnionId()); if (identity != null) { @@ -127,9 +132,8 @@ public class AuthServiceImpl implements IAuthService { user = authUserService.getByMobile(thirdUser.getMobile()); } - // 2.3 仍无匹配 -> 组织准入校验(ADR-0010)通过后首登自动注册 + // 2.3 仍无匹配 -> 首登自动注册(组织准入已由 login() 的事务外预校验保证) if (user == null) { - verifyOrgMembership(client, thirdUser); user = registerNewUser(thirdUser); } @@ -147,6 +151,33 @@ public class AuthServiceImpl implements IAuthService { return user; } + /** + * 事务外组织准入预校验(ADR-0010):本地已有匹配用户的登录不校验; + * 仅本地查不到时调钉钉组织通讯录确认归属,HTTP 调用不占用 DB 事务连接。 + *事务内注册路径的组织门禁由此处保证(login() 是 doLoginInTx 的唯一入口)。
+ */ + 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 中的用户不受影响。 */ diff --git a/crm-auth/src/test/java/com/crm/auth/service/client/DingTalkAuthClientTest.java b/crm-auth/src/test/java/com/crm/auth/service/client/DingTalkAuthClientTest.java index 77e7a14..6899527 100644 --- a/crm-auth/src/test/java/com/crm/auth/service/client/DingTalkAuthClientTest.java +++ b/crm-auth/src/test/java/com/crm/auth/service/client/DingTalkAuthClientTest.java @@ -162,7 +162,7 @@ class DingTalkAuthClientTest { } @Test - @DisplayName("getbyunionid 未命中(errcode 非 0)-> isOrgMember 返回 false") + @DisplayName("getbyunionid 未命中(errcode=60121 未找到对应员工)-> isOrgMember 返回 false") void isOrgMember_notFound_returnsFalse() { when(valueOps.get(AuthConstants.DINGTALK_CORP_TOKEN_KEY)).thenReturn(CORP_TOKEN); DingTalkAuthClient client = clientWithOrg( @@ -173,6 +173,53 @@ class DingTalkAuthClientTest { 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 @DisplayName("getbyunionid errcode=0 但 result 无 userid -> isOrgMember 返回 false") void isOrgMember_emptyResult_returnsFalse() {