From 8ea8d48fff5425b564ee924ca3f18ab1f8c1c6b8 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Mon, 26 Oct 2026 14:52:44 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=94=B9=E4=B8=BA=E5=8F=AA=E6=8C=89=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E5=8F=B7=E8=AF=86=E5=88=AB=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 微信 openid 改为每次登录覆盖写留痕,不再参与账号查找,删除 openid 查账号、改手机号与手机号占用提示。 --- .../auth/granter/WechatMiniTokenGranter.java | 6 +- .../auth/support/MpLoginSupport.java | 129 +------ .../auth/support/MpLoginSupportTest.java | 342 +++++++++--------- 3 files changed, 199 insertions(+), 278 deletions(-) diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java index 9d2bb75c..91eec71e 100644 --- a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java +++ b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java @@ -25,8 +25,8 @@ import org.springframework.stereotype.Component; * 微信小程序手机号一键登录。 *

* grant_type=wechat_applet,参数:loginCode(wx.login)、phoneCode(getPhoneNumber)。 - * 流程:换 openid + 手机号 → 由 {@link MpLoginSupport} 找到或创建账号并补齐档案 - * → 按身份发令牌(不依赖系统角色)。 + * 流程:换 openid + 手机号 → 由 {@link MpLoginSupport} 按手机号找到或创建账号、补齐司机档案、 + * 把 openid 覆盖写为本次值留痕 → 按身份发令牌(不依赖系统角色)。 */ @Slf4j @Component @@ -79,7 +79,7 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { throw new UserInvalidException("未获取到微信手机号"); } - // 账号不存在时自动建号;建档与绑 openid 失败不阻断登录 + // 账号不存在时自动建号;建档与写 openid 失败不阻断登录 OAuth2User user = mpLoginSupport.ensureLoginAccount(request.getTenantId(), phone, session.getOpenid()); if (!userService.validateUser(user)) { throw new UserInvalidException("用户身份无效,无法登录"); diff --git a/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java index da5442a4..9e8154bd 100644 --- a/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java +++ b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java @@ -44,12 +44,10 @@ import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO; import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; import org.springframework.stereotype.Component; -import java.util.Objects; - /** - * 小程序登录编排:按 openid / 手机号找到或创建账号,并补齐司机档案与微信绑定。 + * 小程序登录编排:按手机号找到或创建账号,并补齐司机档案与微信 openid 留痕。 *

- * 只有「建号失败」会让登录失败;建档与绑 openid 失败只记 WARN,不影响本次登录。 + * 只有「建号失败」会让登录失败;建档与写 openid 失败只记 WARN,不影响本次登录。 * 本类只供登录链路调用。 * * @author Chill @@ -64,16 +62,11 @@ public class MpLoginSupport { */ public static final String DETAIL_LOGIN_NOTICE = "loginNotice"; - /** - * 换号撞键提示:openid 属于旧账号、新手机号已属于另一账号 - */ - private static final String NOTICE_PHONE_OCCUPIED = "该微信已绑定其他账号,请联系管理员"; - private final IMpUserClient mpUserClient; private final IDriverClient driverClient; /** - * 找到或创建本次登录使用的账号,并补齐档案与 openid 绑定。 + * 找到或创建本次登录使用的账号,并补齐档案与 openid 留痕。 *

* 返回 {@link OAuth2User}:两条登录链路都消费 OAuth2User,而 MpUserInfo * (identities / permissions / detail)只在 system 侧组装。 @@ -87,71 +80,21 @@ public class MpLoginSupport { if (StringUtil.isBlank(phone)) { throw new UserInvalidException("未获取到手机号,无法登录"); } - MpUser account; - String loginNotice = null; - // openid 已属于另一个账号:本次不迁移、也不再尝试绑定(与建档冲突提示区分开) - boolean openidOccupied = false; - // ① openid 命中即复用:openid 是稳定标识,手机号会被运营商二次放号 - MpUser openidAccount = findByOpenid(tenantId, openid); - if (openidAccount == null) { - // ② 未按 openid 命中:按手机号查找或创建(登录路径专用,已存在不抛异常) + // ① 手机号是账号唯一识别凭据:按手机号命中即复用 + MpUser account = findByPhone(tenantId, phone); + if (account == null) { + // ② 未命中:查找或创建(登录路径专用,已存在不抛异常) account = ensureUser(tenantId, phone, openid); - } else { - account = openidAccount; - // 账号 phone 经 feign 下发时是脱敏值(MpUser.phone 标了 @Sensitive),不能拿来比较。 - // 改按「该手机号是否已有账号」判断:命中自己是同一个人、命中别人是撞键、没命中就换号。 - MpUser phoneAccount = findByPhone(tenantId, phone); - if (phoneAccount != null && !Objects.equals(phoneAccount.getId(), openidAccount.getId())) { - // ①-撞键:落到手机号账号,不迁移 openid - account = phoneAccount; - loginNotice = NOTICE_PHONE_OCCUPIED; - openidOccupied = true; - log.warn("微信换号撞键,登录落到手机号账号:租户={} openid账号={} 手机号账号={}", - tenantId, openidAccount.getId(), phoneAccount.getId()); - } else if (phoneAccount == null) { - account = movePhone(openidAccount, phone); - if (account == null) { - // 更新手机号失败:并发时可能是手机号刚被别的账号占用,重查一次再决定落点 - MpUser concurrent = findByPhone(tenantId, phone); - if (concurrent != null && !Objects.equals(concurrent.getId(), openidAccount.getId())) { - account = concurrent; - loginNotice = NOTICE_PHONE_OCCUPIED; - openidOccupied = true; - log.warn("更新手机号失败后命中手机号账号:租户={} openid账号={} 手机号账号={}", - tenantId, openidAccount.getId(), concurrent.getId()); - } else { - // 仍然用 openid 账号登录:手机号未能更新不影响身份识别,下次登录再试 - account = openidAccount; - } - } - } } // ③ 身份含 driver 才建档;纯 admin 不建 - String profileNotice = ensureProfile(account, phone, tenantId); - if (StringUtil.isBlank(loginNotice)) { - loginNotice = profileNotice; - } - // ④ 微信登录补绑 openid;失败只 WARN - bindOpenid(account, openid, openidOccupied); + String loginNotice = ensureProfile(account, phone, tenantId); + // ④ 微信登录把 openid 覆盖写为本次值留痕;失败只 WARN + bindOpenid(account, openid); MpUserInfo userInfo = loadUserInfo(account); attachNotice(userInfo, loginNotice); return TokenUtil.convertMpUser(userInfo); } - private MpUser findByOpenid(String tenantId, String openid) { - if (StringUtil.isBlank(openid)) { - return null; - } - try { - R result = mpUserClient.userByOpenid(tenantId, openid); - return presentAccount(isSuccess(result) ? result.getData() : null); - } catch (Exception exception) { - // 查询失败按未命中处理,随后由 ensureUser 触达系统服务并兜住建号失败 - log.warn("按 openid 查询小程序账号失败:租户={} openid={}", tenantId, openid, exception); - return null; - } - } - private MpUser findByPhone(String tenantId, String phone) { try { R result = mpUserClient.userByPhone(tenantId, phone); @@ -199,37 +142,6 @@ public class MpLoginSupport { } } - /** - * 把 openid 账号的手机号换成新号 - *

- * 只 patch phone:account 是登录名,不随手机号变;status 必须带原值, - * system 侧 updateUser 的 prepare 会把空 status 重置成启用。 - * - * @return 更新成功后的账号;失败返回 null,不抛异常 - */ - private MpUser movePhone(MpUser account, String phone) { - MpUser patch = new MpUser(); - patch.setId(account.getId()); - patch.setTenantId(account.getTenantId()); - patch.setPhone(phone); - patch.setAccount(account.getAccount()); - patch.setIdentities(account.getIdentities()); - patch.setStatus(account.getStatus()); - try { - R result = mpUserClient.updateUser(patch); - if (isSuccess(result) && Boolean.TRUE.equals(result.getData())) { - account.setPhone(phone); - return account; - } - log.warn("更新小程序账号手机号失败:租户={} 账号={} 新手机号={} 返回={}", - account.getTenantId(), account.getId(), phone, result == null ? null : result.getMsg()); - } catch (Exception exception) { - log.warn("更新小程序账号手机号失败:租户={} 账号={} 新手机号={}", - account.getTenantId(), account.getId(), phone, exception); - } - return null; - } - /** * ③ 为司机身份账号补齐档案;失败或冲突只记 WARN,不阻断登录 * @@ -273,37 +185,26 @@ public class MpLoginSupport { } /** - * ④ 微信登录补绑 openid;失败只 WARN,不阻断登录 + * ④ 微信登录把 openid 覆盖写为本次值:「该账号最近一次登录用的微信」,不参与识别;失败只 WARN */ - private void bindOpenid(MpUser account, String openid, boolean openidOccupied) { + private void bindOpenid(MpUser account, String openid) { if (account == null || StringUtil.isBlank(openid)) { return; } // 空主键/空租户来自「查无账号」被反序列化成空对象,绑定请求会被系统侧判为缺参 400 if (account.getId() == null || StringUtil.isBlank(account.getTenantId())) { - log.warn("小程序账号缺少主键或租户,跳过绑定 openid:租户={} 账号={} openid={}", + log.warn("小程序账号缺少主键或租户,跳过写入 openid:租户={} 账号={} openid={}", account.getTenantId(), account.getId(), openid); return; } - // 撞键场景 openid 已属于另一个账号,明确不迁移,也不再尝试绑定 - if (openidOccupied) { - return; - } - if (StringUtil.isNotBlank(account.getOpenid())) { - if (!openid.equals(account.getOpenid())) { - log.warn("小程序账号已有其他 openid,本次不覆盖:租户={} 账号={} 已有={} 本次={}", - account.getTenantId(), account.getId(), account.getOpenid(), openid); - } - return; - } try { R result = mpUserClient.bindOpenId(account.getTenantId(), account.getId(), openid); if (!isSuccess(result) || !Boolean.TRUE.equals(result.getData())) { - log.warn("绑定微信 openid 失败(不阻断登录):租户={} 账号={} openid={} 返回={}", + log.warn("写入小程序账号 openid 失败(不阻断登录):租户={} 账号={} openid={} 返回={}", account.getTenantId(), account.getId(), openid, result == null ? null : result.getMsg()); } } catch (Exception exception) { - log.warn("绑定微信 openid 失败(不阻断登录):租户={} 账号={} openid={}", + log.warn("写入小程序账号 openid 失败(不阻断登录):租户={} 账号={} openid={}", account.getTenantId(), account.getId(), openid, exception); } } diff --git a/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java b/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java index 79284906..2deb88ca 100644 --- a/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java +++ b/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java @@ -19,24 +19,23 @@ import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 登录编排测试。 *

- * 保住三条不变式:建号失败才阻断登录;建档失败放行; - * openid 换号撞键时落到手机号账号且不迁移 openid。 + * 手机号是账号唯一识别凭据:登录只按手机号找账号,openid 只做「最近一次登录用的微信」留痕; + * 登录路径不改手机号、不按 openid 找账号。只有建号失败才阻断登录。 */ class MpLoginSupportTest { @@ -44,6 +43,7 @@ class MpLoginSupportTest { private static final String PHONE = "13800000001"; private static final String NEW_PHONE = "13800000002"; private static final String OPENID = "wx-openid-1"; + private static final String OTHER_OPENID = "wx-openid-2"; private IMpUserClient mpUserClient; private IDriverClient driverClient; @@ -57,214 +57,234 @@ class MpLoginSupportTest { } @Test - @DisplayName("新手机号微信登录:建号 + 建档,登录成功且无提示") - void newPhoneCreatesAccountAndProfile() { - // feign 下发时 phone 是脱敏值(MpUser.phone 标了 @Sensitive),建档必须仍用登录入参的真实号 - MpUser created = mpUser(11L, "138****0001", OPENID, "driver"); - when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); - when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); - when(mpUserClient.userInfoById(11L)).thenReturn(R.data(userInfo(created, "driver"))); + @DisplayName("手机号命中账号:登进该账号,openid 覆盖为本次值,手机号与登录名不动") + void phoneHitReusesAccountAndOverwritesOpenid() { + // feign 下发时 phone 是脱敏值(MpUser.phone 标了 @Sensitive),登录不改它 + MpUser existing = mpUser(11L, "138****0001", OTHER_OPENID, "driver"); + existing.setAccount("wangwu"); + when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(existing)); + when(mpUserClient.userInfoById(11L)).thenReturn(R.data(userInfo(existing, "driver"))); when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + when(mpUserClient.bindOpenId(TENANT_ID, 11L, OPENID)).thenReturn(R.data(Boolean.TRUE)); OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); assertNotNull(user); assertEquals("11", user.getUserId()); + assertEquals("wangwu", user.getAccount()); + assertEquals("138****0001", user.getPhone()); + assertNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE)); + verify(mpUserClient).bindOpenId(TENANT_ID, 11L, OPENID); + // 不再按 openid 找账号,也不再改手机号 + verify(mpUserClient, never()).userByOpenid(anyString(), anyString()); + verify(mpUserClient, never()).updateUser(any(MpUser.class)); + verify(mpUserClient, never()).ensureUser(any(MpUserEnsureDTO.class)); + } + + @Test + @DisplayName("同一手机号两次登录、openid 不同:落在同一账号,第二次覆盖第一次") + void repeatedLoginOverwritesOpenid() { + MpUser existing = mpUser(12L, PHONE, OPENID, "driver"); + when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(existing)); + when(mpUserClient.userInfoById(12L)).thenReturn(R.data(userInfo(existing, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + when(mpUserClient.bindOpenId(TENANT_ID, 12L, OPENID)).thenReturn(R.data(Boolean.TRUE)); + when(mpUserClient.bindOpenId(TENANT_ID, 12L, OTHER_OPENID)).thenReturn(R.data(Boolean.TRUE)); + + OAuth2User first = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + OAuth2User second = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OTHER_OPENID); + + assertEquals("12", first.getUserId()); + assertEquals("12", second.getUserId(), "同一手机号始终落在同一账号"); + verify(mpUserClient).bindOpenId(TENANT_ID, 12L, OPENID); + verify(mpUserClient).bindOpenId(TENANT_ID, 12L, OTHER_OPENID); + // 账号已存在,两次登录都不该重建 + verify(mpUserClient, never()).ensureUser(any(MpUserEnsureDTO.class)); + } + + @Test + @DisplayName("openid 曾属于别的账号:仍按手机号登进本账号,无换号提示、不改手机号") + void openidHistoryOnOtherAccountDoesNotAffectLogin() { + MpUser phoneAccount = mpUser(21L, PHONE, null, "driver"); + when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(phoneAccount)); + when(mpUserClient.userInfoById(21L)).thenReturn(R.data(userInfo(phoneAccount, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + when(mpUserClient.bindOpenId(TENANT_ID, 21L, OPENID)).thenReturn(R.data(Boolean.TRUE)); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + + assertEquals("21", user.getUserId()); + assertNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE), "换号撞键提示已不存在"); + verify(mpUserClient, never()).userByOpenid(anyString(), anyString()); + verify(mpUserClient).bindOpenId(TENANT_ID, 21L, OPENID); + verify(mpUserClient, never()).updateUser(any(MpUser.class)); + } + + @Test + @DisplayName("新手机号微信登录:建号 + 建档 + openid 落库,登录成功且无提示") + void newPhoneCreatesAccountAndProfile() { + MpUser created = mpUser(31L, "138****0001", OPENID, "driver"); + when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(null)); + when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); + when(mpUserClient.userInfoById(31L)).thenReturn(R.data(userInfo(created, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + when(mpUserClient.bindOpenId(TENANT_ID, 31L, OPENID)).thenReturn(R.data(Boolean.TRUE)); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + + assertNotNull(user); + assertEquals("31", user.getUserId()); assertNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE)); ArgumentCaptor ensureCaptor = ArgumentCaptor.forClass(MpUserEnsureDTO.class); verify(mpUserClient).ensureUser(ensureCaptor.capture()); assertEquals(TENANT_ID, ensureCaptor.getValue().getTenantId()); assertEquals(PHONE, ensureCaptor.getValue().getPhone()); assertEquals(OPENID, ensureCaptor.getValue().getOpenid()); + // 建档必须用登录入参的真实手机号,不能用被脱敏的账号 phone ArgumentCaptor profileCaptor = ArgumentCaptor.forClass(DriverEnsureProfileDTO.class); verify(driverClient).ensureProfile(profileCaptor.capture()); assertEquals(TENANT_ID, profileCaptor.getValue().getTenantId()); - assertEquals(11L, profileCaptor.getValue().getMpUserId()); + assertEquals(31L, profileCaptor.getValue().getMpUserId()); assertEquals(PHONE, profileCaptor.getValue().getPhone()); - } - - @Test - @DisplayName("建档抛异常:登录仍然成功") - void profileFailureDoesNotBlockLogin() { - MpUser created = mpUser(12L, PHONE, OPENID, "driver"); - when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); - when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); - when(mpUserClient.userInfoById(12L)).thenReturn(R.data(userInfo(created, "driver"))); - when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))) - .thenThrow(new RuntimeException("transport 不可用")); - - OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); - - assertNotNull(user); - assertEquals("12", user.getUserId()); - verify(driverClient).ensureProfile(any(DriverEnsureProfileDTO.class)); - } - - @Test - @DisplayName("openid 绑定失败:登录仍然成功,不抛异常") - void bindOpenidFailureDoesNotBlockLogin() { - MpUser existing = mpUser(13L, PHONE, null, "driver"); - when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); - when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(existing)); - when(mpUserClient.userInfoById(13L)).thenReturn(R.data(userInfo(existing, "driver"))); - when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); - when(mpUserClient.bindOpenId(TENANT_ID, 13L, OPENID)).thenReturn(R.fail("该微信已绑定其他小程序用户")); - - OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); - - assertNotNull(user); - assertEquals("13", user.getUserId()); - verify(mpUserClient).bindOpenId(TENANT_ID, 13L, OPENID); - } - - @Test - @DisplayName("打开号码来源:微信登录时给空 openid 账号补绑 openid") - void bindsOpenidForPhoneAccount() { - MpUser existing = mpUser(14L, PHONE, null, "driver"); - when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); - when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(existing)); - when(mpUserClient.userInfoById(14L)).thenReturn(R.data(userInfo(existing, "driver"))); - when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); - when(mpUserClient.bindOpenId(TENANT_ID, 14L, OPENID)).thenReturn(R.data(Boolean.TRUE)); - - mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); - - verify(mpUserClient).bindOpenId(TENANT_ID, 14L, OPENID); - } - - @Test - @DisplayName("openid 命中旧账号但新手机号已被别的账号占用:落到手机号账号、不迁移 openid、有提示") - void phoneOccupiedKeepsPhoneAccount() { - MpUser openidAccount = mpUser(21L, "13900000000", OPENID, "driver"); - MpUser phoneAccount = mpUser(22L, NEW_PHONE, null, "driver"); - when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount)); - when(mpUserClient.userByPhone(TENANT_ID, NEW_PHONE)).thenReturn(R.data(phoneAccount)); - when(mpUserClient.userInfoById(22L)).thenReturn(R.data(userInfo(phoneAccount, "driver"))); - when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); - - OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, NEW_PHONE, OPENID); - - assertNotNull(user); - assertEquals("22", user.getUserId(), "应落在手机号账号上"); + verify(mpUserClient).bindOpenId(TENANT_ID, 31L, OPENID); verify(mpUserClient, never()).updateUser(any(MpUser.class)); - verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString()); - assertNotNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE), "应带上可读提示"); - assertTrue(String.valueOf(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE)).contains("该微信已绑定其他账号")); } @Test - @DisplayName("openid 命中且手机号不同但新号空闲:更新账号手机号") - void openidHitWithFreePhoneUpdatesPhone() { - MpUser openidAccount = mpUser(31L, "139****0000", OPENID, "driver"); - when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount)); + @DisplayName("同一微信换手机号登录:登进新号对应的账号,旧账号不被改动") + void sameOpenidNewPhoneLandsOnNewPhoneAccount() { + MpUser created = mpUser(41L, NEW_PHONE, OPENID, "driver"); when(mpUserClient.userByPhone(TENANT_ID, NEW_PHONE)).thenReturn(R.data(null)); - when(mpUserClient.updateUser(any(MpUser.class))).thenReturn(R.data(Boolean.TRUE)); - when(mpUserClient.userInfoById(31L)).thenReturn(R.data(userInfo(openidAccount, "driver"))); + when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); + when(mpUserClient.userInfoById(41L)).thenReturn(R.data(userInfo(created, "driver"))); when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + when(mpUserClient.bindOpenId(TENANT_ID, 41L, OPENID)).thenReturn(R.data(Boolean.TRUE)); OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, NEW_PHONE, OPENID); - assertEquals("31", user.getUserId()); - ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(MpUser.class); - verify(mpUserClient).updateUser(updateCaptor.capture()); - assertEquals(31L, updateCaptor.getValue().getId()); - assertEquals(NEW_PHONE, updateCaptor.getValue().getPhone()); - verify(mpUserClient, never()).ensureUser(any(MpUserEnsureDTO.class)); - } - - @Test - @DisplayName("openid 命中且该手机号就是本账号:不重复更新手机号") - void openidHitSameAccountDoesNotUpdatePhone() { - MpUser openidAccount = mpUser(32L, "138****0001", OPENID, "driver"); - when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount)); - when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(openidAccount)); - when(mpUserClient.userInfoById(32L)).thenReturn(R.data(userInfo(openidAccount, "driver"))); - when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); - - OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); - - assertEquals("32", user.getUserId()); - verify(mpUserClient, never()).updateUser(any(MpUser.class)); - verify(mpUserClient, never()).ensureUser(any(MpUserEnsureDTO.class)); - } - - @Test - @DisplayName("纯 admin 身份:不建司机档案") - void adminOnlyDoesNotCreateProfile() { - MpUser admin = mpUser(41L, PHONE, OPENID, "admin"); - when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); - when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(admin)); - when(mpUserClient.userInfoById(41L)).thenReturn(R.data(userInfo(admin, "admin"))); - - OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); - assertEquals("41", user.getUserId()); - verify(driverClient, never()).ensureProfile(any(DriverEnsureProfileDTO.class)); + ArgumentCaptor ensureCaptor = ArgumentCaptor.forClass(MpUserEnsureDTO.class); + verify(mpUserClient).ensureUser(ensureCaptor.capture()); + assertEquals(NEW_PHONE, ensureCaptor.getValue().getPhone()); + // 旧账号既不改手机号也不改 openid + verify(mpUserClient, never()).userByOpenid(anyString(), anyString()); + verify(mpUserClient, never()).updateUser(any(MpUser.class)); + verify(mpUserClient).bindOpenId(TENANT_ID, 41L, OPENID); } @Test - @DisplayName("建号失败是唯一阻断登录的条件") - void accountCreationFailureBlocksLogin() { - when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); - when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.fail("创建小程序用户失败")); - - UserInvalidException exception = assertThrows(UserInvalidException.class, - () -> mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID)); - assertEquals("创建小程序用户失败", exception.getMessage()); - verify(driverClient, never()).ensureProfile(any(DriverEnsureProfileDTO.class)); - } - - @Test - @DisplayName("短信登录(openid 为空)不做 openid 相关调用") - void smsLoginSkipsOpenid() { - MpUser created = mpUser(51L, PHONE, null, "driver"); - when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); - when(mpUserClient.userInfoById(51L)).thenReturn(R.data(userInfo(created, "driver"))); + @DisplayName("短信登录:不读不写 openid,也不改手机号") + void smsLoginDoesNotTouchOpenid() { + MpUser existing = mpUser(51L, PHONE, OTHER_OPENID, "driver"); + when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(existing)); + when(mpUserClient.userInfoById(51L)).thenReturn(R.data(userInfo(existing, "driver"))); when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, null); assertEquals("51", user.getUserId()); + assertNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE)); verify(mpUserClient, never()).userByOpenid(anyString(), anyString()); verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString()); + verify(mpUserClient, never()).updateUser(any(MpUser.class)); + } + + @Test + @DisplayName("短信登录:司机档案冲突仍下发 loginNotice") + void smsLoginReportsProfileConflict() { + MpUser existing = mpUser(52L, PHONE, null, "driver"); + when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(existing)); + when(mpUserClient.userInfoById(52L)).thenReturn(R.data(userInfo(existing, "driver"))); + DriverEnsureProfileVO conflict = new DriverEnsureProfileVO(); + conflict.setMessage("手机号已被其他司机档案占用"); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(conflict)); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, null); + + assertEquals("手机号已被其他司机档案占用", user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE)); + verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString()); } @Test - @DisplayName("远端查无账号返回空对象(id 为空):按未命中建号,并用真实主键补绑 openid") - void emptyAccountFromRemoteIsTreatedAsMiss() { - // 服务端 R.data(null) 落 HTTP 是 {},Feign 反序列化成 id 为空的 MpUser 而非 null - MpUser created = mpUser(61L, PHONE, null, "driver"); - when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(new MpUser())); - when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(new MpUser())); - when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); - when(mpUserClient.bindOpenId(TENANT_ID, 61L, OPENID)).thenReturn(R.data(Boolean.TRUE)); - when(mpUserClient.userInfoById(61L)).thenReturn(R.data(userInfo(created, "driver"))); + @DisplayName("写 openid 失败:登录仍然成功") + void openidWriteFailureDoesNotBlockLogin() { + MpUser existing = mpUser(53L, PHONE, null, "driver"); + when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(existing)); + when(mpUserClient.userInfoById(53L)).thenReturn(R.data(userInfo(existing, "driver"))); when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + when(mpUserClient.bindOpenId(TENANT_ID, 53L, OPENID)).thenThrow(new RuntimeException("system 不可用")); OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); assertNotNull(user); - assertEquals("61", user.getUserId()); - assertNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE), "不该出现换号撞键提示"); - verify(mpUserClient).ensureUser(any(MpUserEnsureDTO.class)); - verify(mpUserClient, never()).updateUser(any(MpUser.class)); - verify(mpUserClient).bindOpenId(TENANT_ID, 61L, OPENID); + assertEquals("53", user.getUserId()); + verify(mpUserClient).bindOpenId(TENANT_ID, 53L, OPENID); } @Test - @DisplayName("两个查询都返回空对象时:不落空账号、不发起带空主键的绑定") - void emptyAccountDoesNotLeakNullIdentityIntoBind() { - MpUser created = mpUser(62L, PHONE, OPENID, "driver"); - when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(new MpUser())); - when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(new MpUser())); - when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); - when(mpUserClient.userInfoById(62L)).thenReturn(R.data(userInfo(created, "driver"))); - when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + @DisplayName("建档抛异常:登录仍然成功") + void profileFailureDoesNotBlockLogin() { + MpUser existing = mpUser(54L, PHONE, OPENID, "driver"); + when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(existing)); + when(mpUserClient.userInfoById(54L)).thenReturn(R.data(userInfo(existing, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))) + .thenThrow(new RuntimeException("transport 不可用")); + when(mpUserClient.bindOpenId(TENANT_ID, 54L, OPENID)).thenReturn(R.data(Boolean.TRUE)); OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); - assertEquals("62", user.getUserId()); + assertNotNull(user); + assertEquals("54", user.getUserId()); + verify(driverClient).ensureProfile(any(DriverEnsureProfileDTO.class)); + } + + @Test + @DisplayName("建号失败是唯一阻断登录的条件") + void accountCreationFailureBlocksLogin() { + when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(null)); + when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.fail("创建小程序用户失败")); + + UserInvalidException exception = assertThrows(UserInvalidException.class, + () -> mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID)); + + assertEquals("创建小程序用户失败", exception.getMessage()); verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString()); + verify(driverClient, never()).ensureProfile(any(DriverEnsureProfileDTO.class)); + } + + @Test + @DisplayName("纯 admin 身份:不建司机档案") + void adminOnlyDoesNotCreateProfile() { + MpUser admin = mpUser(61L, PHONE, OPENID, "admin"); + when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(admin)); + when(mpUserClient.userInfoById(61L)).thenReturn(R.data(userInfo(admin, "admin"))); + when(mpUserClient.bindOpenId(TENANT_ID, 61L, OPENID)).thenReturn(R.data(Boolean.TRUE)); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + + assertEquals("61", user.getUserId()); + verify(driverClient, never()).ensureProfile(any(DriverEnsureProfileDTO.class)); + } + + @Test + @DisplayName("远端查无账号返回空对象(id 为空):按未命中建号,用真实主键写 openid") + void emptyAccountFromRemoteIsTreatedAsMiss() { + // 服务端 R.data(null) 落 HTTP 是 {},Feign 反序列化成 id 为空的 MpUser 而非 null + MpUser created = mpUser(71L, PHONE, OPENID, "driver"); + when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(new MpUser())); + when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); + when(mpUserClient.userInfoById(71L)).thenReturn(R.data(userInfo(created, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + when(mpUserClient.bindOpenId(TENANT_ID, 71L, OPENID)).thenReturn(R.data(Boolean.TRUE)); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + + assertNotNull(user); + assertEquals("71", user.getUserId()); + assertNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE)); + verify(mpUserClient).ensureUser(any(MpUserEnsureDTO.class)); + verify(mpUserClient, never()).updateUser(any(MpUser.class)); + verify(mpUserClient).bindOpenId(TENANT_ID, 71L, OPENID); } private MpUser mpUser(Long id, String phone, String openid, String identities) {