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 响应: + * */ private boolean parseOrgMember(String rawBody, String unionId) { JSONObject result = JSONUtil.parseObj(rawBody); Integer errcode = result.getInt("errcode"); - if (errcode == null || errcode != 0) { - log.info("组织准入校验未通过:unionId={}, errcode={}, errmsg={}", - unionId, errcode, result.getStr("errmsg")); + if (errcode == null) { + log.error("组织准入校验响应缺少 errcode:unionId={}, body={}", unionId, rawBody); + throw new BusinessErrorException(AuthConstants.CODE_THIRD_PARTY_AUTH_FAIL, + "钉钉组织归属查询失败,请稍后重试"); + } + if (errcode == 60121) { + log.info("组织准入校验未通过:unionId={}, errcode=60121 未找到对应员工", unionId); 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"); boolean member = userResult != null && StrUtil.isNotBlank(userResult.getStr("userid")); if (!member) { diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/AuthServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/AuthServiceImpl.java index 4037cf0..4346d4a 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/impl/AuthServiceImpl.java +++ b/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); ThirdPartyUserInfo thirdUser = client.getUserInfo(authCode); - // 2~4. 数据库操作(事务保护,通过 self 调用走 AOP 代理) - AuthUser user = self.doLoginInTx(type, thirdUser, client); + // 2. 事务外组织准入预校验(ADR-0010):仅本地无匹配用户时触发。 + // 钉钉组织通讯录 HTTP 调用放在 DB 事务之外,避免首登洪峰长时间占用事务连接 + preCheckOrgMembership(type, thirdUser, client); + + // 3. 数据库操作(事务保护,通过 self 调用走 AOP 代理) + AuthUser user = self.doLoginInTx(type, thirdUser); // 5. 签发 token(Redis 写入在 DB 事务之外,失败不影响 DB 数据) AuthLoginUser loginUser = new AuthLoginUser(); @@ -92,8 +96,8 @@ public class AuthServiceImpl implements IAuthService { * DB 事务内的登录核心逻辑:定位用户 + 校验状态 + 刷新登录时间 */ @Transactional(rollbackFor = Exception.class) - protected AuthUser doLoginInTx(IdentityTypeEnum type, ThirdPartyUserInfo thirdUser, ThirdPartyAuthClient client) { - AuthUser user = resolveUser(type, thirdUser, client); + protected AuthUser doLoginInTx(IdentityTypeEnum type, ThirdPartyUserInfo thirdUser) { + AuthUser user = resolveUser(type, thirdUser); if (Boolean.FALSE.equals(user.getEnabled())) { throw new BusinessErrorException(AuthConstants.CODE_USER_DISABLED, "账号已被禁用,请联系管理员"); @@ -107,9 +111,10 @@ public class AuthServiceImpl implements IAuthService { } /** - * 定位本地用户:已绑定身份 -> 手机号匹配 -> 组织准入校验 -> 首登注册 + * 定位本地用户:已绑定身份 -> 手机号匹配 -> 首登注册。 + *

组织准入校验(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() {