From a8e059e92eb1e9759de9bbd2a68cef21c9dbb14d Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 23 Oct 2026 15:58:42 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E7=99=BB=E5=BD=95=E7=BC=96=E6=8E=92MpLoginSu?= =?UTF-8?q?pport=E4=B8=8E=E7=99=BB=E5=BD=95=E5=A4=B1=E8=B4=A5=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blade-auth/pom.xml | 11 + .../auth/service/MpLoginSupport.java | 338 ++++++++++++++++++ .../auth/service/MpLoginSupportTest.java | 246 +++++++++++++ 3 files changed, 595 insertions(+) create mode 100644 blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java create mode 100644 blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java diff --git a/blade-auth/pom.xml b/blade-auth/pom.xml index 8bfe4ea1..a7bad3cf 100644 --- a/blade-auth/pom.xml +++ b/blade-auth/pom.xml @@ -83,6 +83,17 @@ org.springblade blade-resource-api + + + org.springblade + blade-transport-api + + + + org.springframework.boot + spring-boot-starter-test + test + org.springblade diff --git a/blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java b/blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java new file mode 100644 index 00000000..6032ebeb --- /dev/null +++ b/blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java @@ -0,0 +1,338 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.auth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.auth.utils.TokenUtil; +import org.springblade.core.oauth2.exception.UserInvalidException; +import org.springblade.core.oauth2.service.OAuth2User; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.support.Kv; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.transport.feign.IDriverClient; +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 都是尽力而为,失败只记 WARN, + * 下次登录自愈。复制这段判断到别处前先确认调用方不是登录路径。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MpLoginSupport { + + /** + * detail 中承载登录提示的键,前端从令牌的 detail 里读取 + */ + public static final String DETAIL_LOGIN_NOTICE = "loginNotice"; + + /** + * 换号撞键:openid 属于旧账号、新手机号已属于另一账号(票 05 Q2-A) + */ + private static final String NOTICE_PHONE_OCCUPIED = "该微信已绑定其他账号,请联系管理员"; + + private final IMpUserClient mpUserClient; + private final IDriverClient driverClient; + + /** + * 找到或创建本次登录使用的账号,并尽力补齐档案与 openid 绑定。 + *

+ * 返回 {@link OAuth2User} 而不是 {@link MpUserInfo}:两条登录链路都需要 OAuth2User, + * 且 MpUserInfo 的唯一组装点(identities / permissions / detail)在 system 侧, + * 由本类统一转换可避免 auth 侧复制一份 detail 口径。 + * + * @param tenantId 请求租户,为空时由 system 侧兜到默认租户 + * @param phone 本次登录的手机号 + * @param openid 微信小程序 openid,短信登录传 null + * @return 通过用户校验之前的登录用户(身份校验与 setClient 由调用方完成) + */ + public OAuth2User ensureLoginAccount(String tenantId, String phone, String openid) { + 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 命中:按手机号查找或创建(登录路径专用,已存在不抛异常) + account = ensureUser(tenantId, phone, openid); + } else { + account = openidAccount; + if (!samePhone(openidAccount.getPhone(), phone)) { + MpUser phoneAccount = findByPhone(tenantId, phone); + if (phoneAccount != null && !Objects.equals(phoneAccount.getId(), openidAccount.getId())) { + // ①-撞键:落到手机号账号,不迁移 openid。以 openid 账号为准会把用户 + // 正在用的手机号账号顶掉,自动合并又要动档案归属(票 05) + account = phoneAccount; + loginNotice = NOTICE_PHONE_OCCUPIED; + openidOccupied = true; + log.warn("微信换号撞键,登录落到手机号账号:租户={} openid账号={} 手机号账号={}", + tenantId, openidAccount.getId(), phoneAccount.getId()); + } else { + 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 不建,避免污染司机列表与派单池(票 03) + String profileNotice = ensureProfile(account, phone, tenantId); + if (StringUtil.isBlank(loginNotice)) { + loginNotice = profileNotice; + } + // ④ 微信登录补绑 openid;失败只 WARN(票 05) + bindOpenid(account, openid, openidOccupied); + 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 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); + return isSuccess(result) ? result.getData() : null; + } catch (Exception exception) { + log.warn("按手机号查询小程序账号失败:租户={} 手机号={}", tenantId, phone, exception); + return null; + } + } + + /** + * ② 查找或创建账号:这是登录路径上唯一允许让登录失败的动作 + */ + private MpUser ensureUser(String tenantId, String phone, String openid) { + MpUserEnsureDTO param = new MpUserEnsureDTO(); + param.setTenantId(tenantId); + param.setPhone(phone); + param.setOpenid(openid); + try { + R result = mpUserClient.ensureUser(param); + if (isSuccess(result) && result.getData() != null && Func.isNotEmpty(result.getData().getId())) { + return result.getData(); + } + // R.data(null) 也是 success,此时 msg 是「暂无数据」之类的占位文案,不能当业务错误下发 + String message = result != null && !result.isSuccess() ? result.getMsg() : null; + log.error("小程序账号查找或创建失败:租户={} 手机号={} 返回={}", tenantId, phone, message); + throw new UserInvalidException(StringUtil.isBlank(message) ? "账号创建失败,请稍后重试" : message); + } catch (UserInvalidException exception) { + throw exception; + } catch (Exception exception) { + log.error("小程序账号查找或创建失败:租户={} 手机号={}", tenantId, phone, exception); + throw new UserInvalidException("账号创建失败,请稍后重试"); + } + } + + /** + * 把 openid 账号的手机号换成新号(票 03: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,不阻断登录 + * + * @return 冲突时的可读原因,供 detail 提示使用;成功或无需建档返回 null + */ + private String ensureProfile(MpUser account, String requestedPhone, String requestedTenantId) { + if (account == null || !MpUserIdentity.has(account.getIdentities(), MpUserIdentity.DRIVER)) { + return null; + } + // 租户与手机号优先取账号上的值:档案必须与账号落同一租户(唯一键对 NULL 不去重), + // 手机号更新失败时也不能拿新号去建,否则档案和账号对不上 + String tenantId = Func.toStr(account.getTenantId(), requestedTenantId); + String phone = Func.toStr(account.getPhone(), requestedPhone); + DriverEnsureProfileDTO param = new DriverEnsureProfileDTO(); + param.setTenantId(tenantId); + param.setMpUserId(account.getId()); + param.setPhone(phone); + try { + R result = driverClient.ensureProfile(param); + if (!isSuccess(result) || result.getData() == null) { + log.warn("登录自动建档未完成(不阻断登录):租户={} 账号={} 手机号={} 返回={}", + tenantId, account.getId(), phone, result == null ? null : result.getMsg()); + return null; + } + DriverEnsureProfileVO profile = result.getData(); + if (StringUtil.isNotBlank(profile.getMessage())) { + log.warn("登录自动建档冲突(不阻断登录):租户={} 账号={} 手机号={} 原因={}", + tenantId, account.getId(), phone, profile.getMessage()); + return profile.getMessage(); + } + return null; + } catch (Exception exception) { + log.warn("登录自动建档失败(不阻断登录):租户={} 账号={} 手机号={}", + tenantId, account.getId(), phone, exception); + return null; + } + } + + /** + * ④ 微信登录补绑 openid;失败只 WARN,不阻断登录 + */ + private void bindOpenid(MpUser account, String openid, boolean openidOccupied) { + if (account == null || StringUtil.isBlank(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={} 返回={}", + account.getTenantId(), account.getId(), openid, result == null ? null : result.getMsg()); + } + } catch (Exception exception) { + log.warn("绑定微信 openid 失败(不阻断登录):租户={} 账号={} openid={}", + account.getTenantId(), account.getId(), openid, exception); + } + } + + /** + * MpUserInfo 由 system 侧 buildUserInfo 统一组装,auth 不复制一份 detail 口径 + */ + private MpUserInfo loadUserInfo(MpUser account) { + try { + R result = mpUserClient.userInfoById(account.getId()); + if (isSuccess(result) && result.getData() != null && result.getData().getUser() != null) { + return result.getData(); + } + String message = result != null && !result.isSuccess() ? result.getMsg() : null; + log.error("小程序账号登录信息查询失败:租户={} 账号={} 返回={}", account.getTenantId(), account.getId(), message); + throw new UserInvalidException(StringUtil.isBlank(message) ? "账号信息查询失败,请稍后重试" : message); + } catch (UserInvalidException exception) { + throw exception; + } catch (Exception exception) { + log.error("小程序账号登录信息查询失败:租户={} 账号={}", account.getTenantId(), account.getId(), exception); + throw new UserInvalidException("账号信息查询失败,请稍后重试"); + } + } + + private void attachNotice(MpUserInfo userInfo, String loginNotice) { + if (userInfo == null || StringUtil.isBlank(loginNotice)) { + return; + } + // detail 是否会下发到前端取决于令牌序列化,故冲突点同时都记了 WARN + Kv detail = userInfo.getDetail() == null ? Kv.create() : userInfo.getDetail(); + detail.set(DETAIL_LOGIN_NOTICE, loginNotice); + userInfo.setDetail(detail); + } + + private boolean samePhone(String left, String right) { + return Objects.equals(trimToNull(left), trimToNull(right)); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimValue = value.trim(); + return trimValue.isEmpty() ? null : trimValue; + } + + private boolean isSuccess(R result) { + return result != null && result.isSuccess(); + } + +} diff --git a/blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java b/blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java new file mode 100644 index 00000000..98617200 --- /dev/null +++ b/blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java @@ -0,0 +1,246 @@ +package org.springblade.auth.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.core.oauth2.exception.UserInvalidException; +import org.springblade.core.oauth2.service.OAuth2User; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.support.Kv; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springblade.transport.feign.IDriverClient; +import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO; +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.verify; +import static org.mockito.Mockito.when; + +/** + * 登录编排测试。 + *

+ * 守住票 02 / 03 / 05 的三条口径:建号失败才阻断登录;建档失败放行; + * openid 换号撞键时落到手机号账号且不迁移 openid。 + */ +class MpLoginSupportTest { + + private static final String TENANT_ID = "000000"; + private static final String PHONE = "13800000001"; + private static final String NEW_PHONE = "13800000002"; + private static final String OPENID = "wx-openid-1"; + + private IMpUserClient mpUserClient; + private IDriverClient driverClient; + private MpLoginSupport mpLoginSupport; + + @BeforeEach + void setUp() { + mpUserClient = mock(IMpUserClient.class); + driverClient = mock(IDriverClient.class); + mpLoginSupport = new MpLoginSupport(mpUserClient, driverClient); + } + + @Test + @DisplayName("新手机号微信登录:建号 + 建档,登录成功且无提示") + void newPhoneCreatesAccountAndProfile() { + MpUser created = mpUser(11L, 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(11L)).thenReturn(R.data(userInfo(created, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + + assertNotNull(user); + assertEquals("11", 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()); + ArgumentCaptor profileCaptor = ArgumentCaptor.forClass(DriverEnsureProfileDTO.class); + verify(driverClient).ensureProfile(profileCaptor.capture()); + assertEquals(TENANT_ID, profileCaptor.getValue().getTenantId()); + assertEquals(11L, 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, 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, "13900000000", OPENID, "driver"); + when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount)); + 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(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + + 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("纯 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)); + } + + @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"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, null); + + assertEquals("51", user.getUserId()); + verify(mpUserClient, never()).userByOpenid(anyString(), anyString()); + verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString()); + } + + private MpUser mpUser(Long id, String phone, String openid, String identities) { + MpUser user = new MpUser(); + user.setId(id); + user.setTenantId(TENANT_ID); + user.setPhone(phone); + user.setAccount(phone); + user.setOpenid(openid); + user.setIdentities(identities); + user.setStatus(1); + return user; + } + + private MpUserInfo userInfo(MpUser user, String... identities) { + MpUserInfo userInfo = new MpUserInfo(); + userInfo.setUser(user); + userInfo.setIdentities(List.of(identities)); + userInfo.setDetail(Kv.create() + .set("type", "mp") + .set("identities", String.join(",", identities)) + .set("openid", user.getOpenid() == null ? "" : user.getOpenid())); + return userInfo; + } + + private DriverEnsureProfileVO profileResult() { + DriverEnsureProfileVO result = new DriverEnsureProfileVO(); + result.setDriverId(9001L); + result.setCreated(true); + result.setBoundExisting(false); + return result; + } + +}