From 29c9f229ac5a3170cacf2176af95fd1420817efa Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Mon, 26 Oct 2026 14:48:48 +0800 Subject: [PATCH 1/4] =?UTF-8?q?:card=5Ffile=5Fbox:=20=E5=B0=8F=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E7=94=A8=E6=88=B7=20openid=20=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=94=B9=E4=B8=BA=E6=99=AE=E9=80=9A=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/sql/transport/blade_mp_user_20261012.sql | 2 +- doc/sql/transport/blade_mp_user_openid_index_20261026.sql | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 doc/sql/transport/blade_mp_user_openid_index_20261026.sql diff --git a/doc/sql/transport/blade_mp_user_20261012.sql b/doc/sql/transport/blade_mp_user_20261012.sql index 32e2d94a..ff7ca12e 100644 --- a/doc/sql/transport/blade_mp_user_20261012.sql +++ b/doc/sql/transport/blade_mp_user_20261012.sql @@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS `blade_mp_user` ( `is_deleted` int NULL DEFAULT 0 COMMENT '是否已删除', PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `uk_mp_user_tenant_phone` (`tenant_id`, `phone`) USING BTREE, - UNIQUE KEY `uk_mp_user_tenant_openid` (`tenant_id`, `openid`) USING BTREE, + KEY `idx_mp_user_openid` (`tenant_id`, `openid`) USING BTREE, KEY `idx_mp_user_account` (`tenant_id`, `account`) USING BTREE, KEY `idx_mp_user_identities` (`identities`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '小程序用户表'; diff --git a/doc/sql/transport/blade_mp_user_openid_index_20261026.sql b/doc/sql/transport/blade_mp_user_openid_index_20261026.sql new file mode 100644 index 00000000..044ade03 --- /dev/null +++ b/doc/sql/transport/blade_mp_user_openid_index_20261026.sql @@ -0,0 +1,8 @@ +-- 小程序用户表 openid 索引:唯一键 uk_mp_user_tenant_openid 改为普通索引 idx_mp_user_openid(tenant_id, openid) +-- openid 是「该账号最近一次登录用的微信」的留痕,不是账号标识:允许为空,允许多个账号留下同一个 openid +-- 回滚(加回唯一键)前必须先把重复的 openid 置 NULL,否则唯一键 DDL 直接失败 +-- 部署时在业务库执行本脚本 + +ALTER TABLE `blade_mp_user` + DROP INDEX `uk_mp_user_tenant_openid`, + ADD INDEX `idx_mp_user_openid` (`tenant_id`, `openid`) USING BTREE; 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 2/4] =?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) { From 5cc5dbb002c3b4145e319f7c2eb4a8fd656aa4f1 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Mon, 26 Oct 2026 15:19:33 +0800 Subject: [PATCH 3/4] =?UTF-8?q?:bug:=20=E5=90=8E=E5=8F=B0=E6=94=B9?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E5=90=8C=E6=AD=A5=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E4=B8=8E=E5=8F=B8=E6=9C=BA=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=E7=94=B5=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transport 新增 feign /feign/client/driver/sync-mobile:按 user_id+tenant_id 找档案, 档案 mobile 等于账号旧号时只改 mobile 一列(带 driving_vehicle 原值),已是新号幂等成功, 找不到或判据不满足返回 synced=false 与原因 - blade-system updateUserWithPhoneSync:账号等于旧号时跟随新号、补账号唯一性校验, 本地写库后调 feign 同步档案;同步失败只记日志,不回滚账号更新。登录链路仍走 updateUser - App 内改号改调 updateUserWithPhoneSync - bindOpenId 改为覆盖写:不再拦截已绑定其他 openid 或 openid 属于其它账号 --- .../transport/feign/IDriverClient.java | 16 ++ .../pojo/dto/DriverSyncMobileDTO.java | 55 +++++ .../transport/pojo/vo/DriverSyncMobileVO.java | 67 ++++++ .../system/service/IMpUserService.java | 7 + .../service/impl/MpUserServiceImpl.java | 59 ++++-- .../service/impl/UserPhoneServiceImpl.java | 3 +- .../system/support/MpPhoneChangePlan.java | 128 ++++++++++++ .../support/MpUserPhoneChangeSupport.java | 97 +++++++++ .../service/impl/MpUserServiceImplTest.java | 196 ++++++++++++++++- .../impl/UserPhoneServiceImplTest.java | 152 ++++++++++++++ .../system/support/MpPhoneChangePlanTest.java | 108 ++++++++++ .../support/MpUserPhoneChangeSupportTest.java | 111 ++++++++++ .../transport/feign/DriverClient.java | 12 ++ .../transport/service/IDriverService.java | 16 ++ .../service/impl/DriverServiceImpl.java | 60 ++++++ .../support/DriverPhoneSyncDecision.java | 70 +++++++ .../support/DriverPhoneSyncPlanner.java | 96 +++++++++ .../service/impl/DriverSyncMobileTest.java | 197 ++++++++++++++++++ 18 files changed, 1433 insertions(+), 17 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverSyncMobileDTO.java create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverSyncMobileVO.java create mode 100644 blade-service/blade-system/src/main/java/org/springblade/system/support/MpPhoneChangePlan.java create mode 100644 blade-service/blade-system/src/main/java/org/springblade/system/support/MpUserPhoneChangeSupport.java create mode 100644 blade-service/blade-system/src/test/java/org/springblade/system/service/impl/UserPhoneServiceImplTest.java create mode 100644 blade-service/blade-system/src/test/java/org/springblade/system/support/MpPhoneChangePlanTest.java create mode 100644 blade-service/blade-system/src/test/java/org/springblade/system/support/MpUserPhoneChangeSupportTest.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/DriverPhoneSyncDecision.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/DriverPhoneSyncPlanner.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverSyncMobileTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java index cdaf96ce..7a81e0d5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java @@ -27,7 +27,9 @@ package org.springblade.transport.feign; import org.springblade.core.tool.api.R; import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO; +import org.springblade.transport.pojo.dto.DriverSyncMobileDTO; import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; +import org.springblade.transport.pojo.vo.DriverSyncMobileVO; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -42,6 +44,7 @@ public interface IDriverClient { String API_PREFIX = "/feign/client/driver"; String ENSURE_PROFILE = API_PREFIX + "/ensure-profile"; + String SYNC_MOBILE = API_PREFIX + "/sync-mobile"; /** * 查找或创建司机档案,并把档案绑定到指定小程序账号 @@ -55,4 +58,17 @@ public interface IDriverClient { @PostMapping(ENSURE_PROFILE) R ensureProfile(@RequestBody DriverEnsureProfileDTO param); + /** + * 账号改手机号后同步司机档案的 mobile + *

+ * 幂等入口:档案 mobile 已是新号时返回成功且不写库。只在档案属于该账号 + * (user_id + tenant_id 命中)且档案 mobile 等于 oldPhone 时改这一列; + * 不满足时不动档案,通过返回值的 message 说明原因,不抛异常给调用方的账号更新。 + * + * @param param 租户、小程序账号、账号旧号与新号 + * @return 档案ID与本次是否同步 + */ + @PostMapping(SYNC_MOBILE) + R syncMobile(@RequestBody DriverSyncMobileDTO param); + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverSyncMobileDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverSyncMobileDTO.java new file mode 100644 index 00000000..ea2c0c2c --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverSyncMobileDTO.java @@ -0,0 +1,55 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 司机档案「按账号改手机号」参数 + * + * @author Chill + */ +@Data +@Schema(description = "司机档案手机号同步参数") +public class DriverSyncMobileDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "租户ID,为空时取当前请求租户;仍为空则拒绝") + private String tenantId; + + @Schema(description = "关联小程序用户ID(blade_mp_user.id)") + private Long mpUserId; + + @Schema(description = "账号当前手机号:档案 mobile 与之不一致时不改档案") + private String oldPhone; + + @Schema(description = "账号改后的手机号") + private String newPhone; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverSyncMobileVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverSyncMobileVO.java new file mode 100644 index 00000000..662fe710 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverSyncMobileVO.java @@ -0,0 +1,67 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 司机档案「按账号改手机号」结果 + * + * @author Chill + */ +@Data +@Schema(description = "司机档案手机号同步结果") +public class DriverSyncMobileVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "命中的司机档案ID,未找到时为null") + private Long driverId; + + @Schema(description = "档案手机号已同步为新号") + private Boolean synced; + + @Schema(description = "未同步的原因;成功时为null") + private String message; + + public static DriverSyncMobileVO synced(Long driverId) { + DriverSyncMobileVO vo = new DriverSyncMobileVO(); + vo.setDriverId(driverId); + vo.setSynced(true); + return vo; + } + + public static DriverSyncMobileVO notSynced(Long driverId, String message) { + DriverSyncMobileVO vo = new DriverSyncMobileVO(); + vo.setDriverId(driverId); + vo.setSynced(false); + vo.setMessage(message); + return vo; + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java index 718041cb..731dca80 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java @@ -103,6 +103,13 @@ public interface IMpUserService extends BaseService { */ boolean updateUser(MpUser user); + /** + * 更新小程序用户,并在手机号变化时同步司机档案的 mobile + *

+ * 仅供后台编辑与 App 内改号两个入口使用;档案同步失败不影响本次更新结果。 + */ + boolean updateUserWithPhoneSync(MpUser user); + /** * 新增或修改(后台管理) */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java index 26b0832e..19999fd7 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java @@ -48,6 +48,8 @@ import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.system.pojo.enums.MpUserIdentity; import org.springblade.system.pojo.vo.MpUserDataScopeVO; import org.springblade.system.service.IMpUserService; +import org.springblade.system.support.MpPhoneChangePlan; +import org.springblade.system.support.MpUserPhoneChangeSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -89,6 +91,7 @@ public class MpUserServiceImpl extends BaseServiceImpl imp private static final int DATA_LEVEL_CURRENT_ONLY = 2; private final MpUserDataScopeMapper mpUserDataScopeMapper; + private final MpUserPhoneChangeSupport mpUserPhoneChangeSupport; @Override public MpUser getByPhone(String tenantId, String phone) { @@ -309,6 +312,20 @@ public class MpUserServiceImpl extends BaseServiceImpl imp @Override @Transactional(rollbackFor = Exception.class) public boolean updateUser(MpUser user) { + return doUpdateUser(user, false); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean updateUserWithPhoneSync(MpUser user) { + return doUpdateUser(user, true); + } + + /** + * @param syncDriverPhone 是否在手机号变化后同步司机档案的 mobile + * 仅在后台编辑与 App 内改号时开启,登录链路(feign 回调)必须为 false + */ + private boolean doUpdateUser(MpUser user, boolean syncDriverPhone) { if (user == null || Func.isEmpty(user.getId())) { throw new ServiceException("小程序用户主键不能为空"); } @@ -330,6 +347,10 @@ public class MpUserServiceImpl extends BaseServiceImpl imp if (user.getDataScopeDeptIds() == null && Objects.equals(user.getDataScopeRange(), 3)) { user.setDataScopeDeptIds(fillScopes(dbUser).getDataScopeDeptIds()); } + // 改号判据只认库里的值:提交值可能是脱敏值、空串,也可能被 prepare 用手机号兜底 + String submittedAccount = trimToNull(user.getAccount()); + String oldPhone = trimToNull(dbUser.getPhone()); + String oldAccount = trimToNull(dbUser.getAccount()); prepare(user); // 未改手机号(前端脱敏未提交 / 仍为脱敏值)时沿用库中原值,避免把 138****8000 写回 if (Func.isBlank(user.getPhone()) || isMaskedPhone(user.getPhone())) { @@ -338,16 +359,30 @@ public class MpUserServiceImpl extends BaseServiceImpl imp if (Func.isBlank(user.getEmail()) || isMaskedEmail(user.getEmail())) { user.setEmail(dbUser.getEmail()); } + // 改号时账号由 plan 判定是否跟随;提交的账号为空时也用 plan 兜底, + // 避免 prepare 拿回填前的脱敏手机号当账号 + MpPhoneChangePlan phoneChangePlan = MpPhoneChangePlan.of(oldPhone, oldAccount, user.getPhone(), submittedAccount); + if (phoneChangePlan.isPhoneChanged() || Func.isBlank(submittedAccount)) { + user.setAccount(phoneChangePlan.nextAccount()); + } validate(user); MpUser existed = getByPhone(user.getTenantId(), user.getPhone()); if (existed != null && Func.isNotEmpty(existed.getId()) && !existed.getId().equals(user.getId())) { throw new ServiceException("该手机号已存在小程序用户"); } + // account 只有普通索引,库不拦重,账号跟随手机号时在这里补一次唯一性校验 + if (phoneChangePlan.isAccountChanging()) { + assertAccountAvailable(user.getTenantId(), user.getId(), user.getAccount()); + } // 后台编辑不改密码,避免空串覆盖 user.setPassword(null); boolean updated = updateById(user) && submitScopes(user, rebuildScopes); if (updated) { MpUserCache.clear(); + // 同步放在本地写库之后:失败只记日志,不回滚账号更新 + if (syncDriverPhone && phoneChangePlan.isPhoneChanged()) { + mpUserPhoneChangeSupport.syncDriverMobile(user.getTenantId(), user.getId(), oldPhone, user.getPhone()); + } } return updated; } @@ -356,11 +391,18 @@ public class MpUserServiceImpl extends BaseServiceImpl imp @Transactional(rollbackFor = Exception.class) public boolean submitOrUpdate(MpUser user) { if (user != null && Func.isNotEmpty(user.getId())) { - return updateUser(user); + return updateUserWithPhoneSync(user); } return submit(user); } + private void assertAccountAvailable(String tenantId, Long userId, String account) { + MpUser existed = getByAccount(tenantId, account); + if (existed != null && Func.isNotEmpty(existed.getId()) && !existed.getId().equals(userId)) { + throw new ServiceException("该账号已存在小程序用户"); + } + } + @Override @Transactional(rollbackFor = Exception.class) public boolean removeMpUser(List ids) { @@ -439,20 +481,7 @@ public class MpUserServiceImpl extends BaseServiceImpl imp if (!tenantId.equals(user.getTenantId())) { throw new ServiceException("租户不匹配,无法绑定 openid"); } - // 目标账号已有另一个非空 openid 时不覆盖:一个微信换绑由管理员处理, - // 登录路径直接放弃本次绑定 - if (Func.isNotBlank(user.getOpenid()) && !user.getOpenid().equals(openid)) { - log.warn("小程序用户已绑定其他 openid,跳过绑定:租户={} 账号={} 已有openid={} 本次openid={}", - tenantId, userId, user.getOpenid(), openid); - return false; - } - MpUser byOpenId = getOne(Wrappers.lambdaQuery() - .eq(MpUser::getTenantId, tenantId) - .eq(MpUser::getOpenid, openid) - .last("LIMIT 1")); - if (byOpenId != null && !byOpenId.getId().equals(userId)) { - throw new ServiceException("该微信已绑定其他小程序用户"); - } + // openid 是该账号最近一次登录用的微信,不参与识别:同账号换微信、两个账号用同一微信都不拦截,直接覆盖 MpUser patch = new MpUser(); patch.setId(userId); patch.setOpenid(openid); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserPhoneServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserPhoneServiceImpl.java index 605bfc08..52f67bf8 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserPhoneServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserPhoneServiceImpl.java @@ -156,7 +156,8 @@ public class UserPhoneServiceImpl implements IUserPhoneService { } else { updateUser.setAccount(currentUser.getAccount()); } - boolean updated = mpUserService.updateUser(updateUser); + // 走带档案同步的更新:档案归属该账号且档案手机号等于旧号时,档案 mobile 一并跟随 + boolean updated = mpUserService.updateUserWithPhoneSync(updateUser); if (!updated) { throw new ServiceException("手机号修改失败"); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/support/MpPhoneChangePlan.java b/blade-service/blade-system/src/main/java/org/springblade/system/support/MpPhoneChangePlan.java new file mode 100644 index 00000000..f1c9320e --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/support/MpPhoneChangePlan.java @@ -0,0 +1,128 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.support; + +import java.util.Objects; + +/** + * 「改手机号时登录账号该不该跟着改」的判定 + *

+ * 纯 POJO:不读写库、不依赖 Spring 容器,判定全部以 trim 后比较。 + * 判据只有一条事实基准:库里的 account 是否等于旧手机号——自定义登录名不参与改号。 + *

+ * 另外识别前端回传的脱敏值(如 138****8000):含 * 的一律不落库。 + * + * @author Chill + */ +public final class MpPhoneChangePlan { + + private final String oldPhone; + private final String oldAccount; + private final String newPhone; + private final String submittedAccount; + + private MpPhoneChangePlan(String oldPhone, String oldAccount, String newPhone, String submittedAccount) { + this.oldPhone = trimToNull(oldPhone); + this.oldAccount = trimToNull(oldAccount); + this.newPhone = trimToNull(newPhone); + this.submittedAccount = trimToNull(submittedAccount); + } + + /** + * @param oldPhone 库里的手机号(改号前的权威值) + * @param oldAccount 库里的账号 + * @param newPhone 本次要落库的手机号(调用方已用库值回填脱敏/空值) + * @param submittedAccount 调用方提交的账号(prepare 补默认值之前的原值) + */ + public static MpPhoneChangePlan of(String oldPhone, String oldAccount, String newPhone, String submittedAccount) { + return new MpPhoneChangePlan(oldPhone, oldAccount, newPhone, submittedAccount); + } + + /** + * 手机号是否真的变了:脱敏值回填后与库值相同即视为没变。 + * 含脱敏星号的值不可落库,也不当作新手机号(改号判据一律不成立)。 + */ + public boolean isPhoneChanged() { + if (oldPhone == null || newPhone == null || isMasked(oldPhone) || isMasked(newPhone)) { + return false; + } + return !oldPhone.equals(newPhone); + } + + /** + * 入参里是否含前端脱敏值(含 *),含则不可落库 + */ + public boolean isMasked() { + return isMasked(oldPhone) || isMasked(newPhone) || isMasked(oldAccount) || isMasked(submittedAccount); + } + + /** + * 账号是否该跟随手机号:账号当前就是旧号(或历史行为空)时跟随 + */ + public boolean shouldSyncAccount() { + return isPhoneChanged() && !isMasked() && (oldAccount == null || oldAccount.equals(oldPhone)); + } + + /** + * 本次更新应落库的账号 + *

+ * 管理员显式提交了另一个账号名时按提交值(前端在账号为空时会把手机号回填进 account, + * 故「提交值等于旧号」视为未改账号)。否则账号等于旧号就跟随新号,自定义登录名保留。 + */ + public String nextAccount() { + if (!isPhoneChanged()) { + return oldAccount == null ? newPhone : oldAccount; + } + if (submittedAccount != null && !submittedAccount.equals(oldPhone) + && !submittedAccount.equals(oldAccount) && !isMasked(submittedAccount)) { + return submittedAccount; + } + if (oldAccount == null) { + return newPhone; + } + if (oldAccount.equals(oldPhone)) { + return newPhone; + } + return oldAccount; + } + + /** + * 账号是否会变化:变化时要补账号唯一性校验(account 只有普通索引,库不拦重) + */ + public boolean isAccountChanging() { + return isPhoneChanged() && !Objects.equals(oldAccount, nextAccount()); + } + + private static boolean isMasked(String value) { + return value != null && value.contains("*"); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/support/MpUserPhoneChangeSupport.java b/blade-service/blade-system/src/main/java/org/springblade/system/support/MpUserPhoneChangeSupport.java new file mode 100644 index 00000000..2e7ca953 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/support/MpUserPhoneChangeSupport.java @@ -0,0 +1,97 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.feign.IDriverClient; +import org.springblade.transport.pojo.dto.DriverSyncMobileDTO; +import org.springblade.transport.pojo.vo.DriverSyncMobileVO; +import org.springframework.stereotype.Component; + +/** + * 改号后同步司机档案手机号 + *

+ * 只在后台编辑与 App 内改号两个入口调用:登录链路上 transport 会回调本服务的 updateUser, + * 若那里再回调用 transport,会形成 transport(持 driver 行锁) → system → transport 的嵌套更新, + * 内层等同一行的锁直到超时。 + *

+ * 档案同步是账号更新之后的补偿动作:任何失败只记日志、不抛异常,账号更新照常提交。 + * 调用方据此能看出「账号改了、档案没改」。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MpUserPhoneChangeSupport { + + private final IDriverClient driverClient; + + /** + * 按账号 id + 旧手机号同步司机档案的 mobile + * + * @return true 表示档案已同步(含本来就是新号的幂等情形) + */ + public boolean syncDriverMobile(String tenantId, Long userId, String oldPhone, String newPhone) { + String tenant = Func.toStr(tenantId, "").trim(); + String oldMobile = Func.toStr(oldPhone, "").trim(); + String newMobile = Func.toStr(newPhone, "").trim(); + if (Func.isBlank(tenant) || Func.isEmpty(userId) || Func.isBlank(oldMobile) || Func.isBlank(newMobile) + || oldMobile.contains("*") || newMobile.contains("*") || oldMobile.equals(newMobile)) { + return false; + } + DriverSyncMobileDTO param = new DriverSyncMobileDTO(); + param.setTenantId(tenant); + param.setMpUserId(userId); + param.setOldPhone(oldMobile); + param.setNewPhone(newMobile); + try { + R result = driverClient.syncMobile(param); + if (result == null || !result.isSuccess()) { + log.error("小程序用户改号:账号已更新,司机档案同步调用失败。tenantId={} mpUserId={} oldPhone={} newPhone={} reason={}", + tenant, userId, oldMobile, newMobile, result == null ? "无响应" : result.getMsg()); + return false; + } + DriverSyncMobileVO data = result.getData(); + if (data == null) { + log.error("小程序用户改号:账号已更新,司机档案同步无返回数据。tenantId={} mpUserId={} oldPhone={} newPhone={}", + tenant, userId, oldMobile, newMobile); + return false; + } + if (!Boolean.TRUE.equals(data.getSynced())) { + log.warn("小程序用户改号:账号已更新,司机档案未同步。tenantId={} mpUserId={} oldPhone={} newPhone={} driverId={} reason={}", + tenant, userId, oldMobile, newMobile, data.getDriverId(), data.getMessage()); + return false; + } + return true; + } catch (Exception exception) { + log.error("小程序用户改号:账号已更新,司机档案同步异常。tenantId={} mpUserId={} oldPhone={} newPhone={}", + tenant, userId, oldMobile, newMobile, exception); + return false; + } + } + +} diff --git a/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java index 8ff82829..daa5c803 100644 --- a/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java +++ b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java @@ -19,6 +19,7 @@ import org.springblade.system.mapper.MpUserMapper; import org.springblade.system.pojo.entity.MpUser; import org.springblade.system.pojo.entity.MpUserDataScope; import org.springblade.system.pojo.vo.MpUserDataScopeVO; +import org.springblade.system.support.MpUserPhoneChangeSupport; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; @@ -60,10 +61,15 @@ class MpUserServiceImplTest { private static final Long SCOPE_DEPT_B = 1002L; private static final String TENANT_ID = "000000"; private static final String PHONE = "15975348524"; + private static final String NEW_PHONE = "13800138000"; + private static final String CUSTOM_ACCOUNT = "driver-001"; + private static final String LEGACY_ACCOUNT = "15577122728"; + private static final Long OTHER_USER_ID = 7002L; private static final String DEPT_ID = "1123598817739906"; private MpUserMapper mpUserMapper; private MpUserDataScopeMapper mpUserDataScopeMapper; + private MpUserPhoneChangeSupport phoneChangeSupport; private MpUserServiceImpl service; @BeforeAll @@ -80,7 +86,8 @@ class MpUserServiceImplTest { void setUp() throws Exception { mpUserMapper = mock(MpUserMapper.class); mpUserDataScopeMapper = mock(MpUserDataScopeMapper.class); - service = new MpUserServiceImpl(mpUserDataScopeMapper); + phoneChangeSupport = mock(MpUserPhoneChangeSupport.class); + service = new MpUserServiceImpl(mpUserDataScopeMapper, phoneChangeSupport); inject("baseMapper", mpUserMapper); } @@ -317,8 +324,195 @@ class MpUserServiceImplTest { assertTrue(scope.isAllDept()); } + // ------------------------------------------------------------ 改号三处一致(票 05 场景 6) + + @Test + @DisplayName("后台改号(account 就是旧号):手机号、登录账号、司机档案电话三处一致") + void updateUserWithPhoneSyncChangesPhoneAccountAndProfile() { + when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser()); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + + MpUser user = phoneChangeUser(NEW_PHONE); + // 前端编辑页会把库里账号原样回传;回传值仍等于旧号时不当作自定义登录名 + user.setAccount(PHONE); + + assertTrue(updateUserWithPhoneSync(user)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserMapper).updateById(captor.capture()); + assertEquals(NEW_PHONE, captor.getValue().getPhone()); + assertEquals(NEW_PHONE, captor.getValue().getAccount(), "账号等于旧号时跟随新号,否则新号 + 原密码登不进去"); + verify(phoneChangeSupport).syncDriverMobile(TENANT_ID, USER_ID, PHONE, NEW_PHONE); + } + + @Test + @DisplayName("后台改号(自定义登录名):账号原样保留,档案电话照常同步") + void updateUserWithPhoneSyncKeepsCustomAccount() { + MpUser dbUser = dbUser(); + dbUser.setAccount(CUSTOM_ACCOUNT); + when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + + MpUser user = phoneChangeUser(NEW_PHONE); + user.setAccount(CUSTOM_ACCOUNT); + + assertTrue(updateUserWithPhoneSync(user)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserMapper).updateById(captor.capture()); + assertEquals(NEW_PHONE, captor.getValue().getPhone()); + assertEquals(CUSTOM_ACCOUNT, captor.getValue().getAccount(), "自定义登录名不参与改号"); + verify(phoneChangeSupport).syncDriverMobile(TENANT_ID, USER_ID, PHONE, NEW_PHONE); + } + + @Test + @DisplayName("存量反例 account(15577122728) 与 phone 不同:改号只动手机号与档案,账号保持原值") + void updateUserWithPhoneSyncKeepsLegacyAccount() { + MpUser dbUser = dbUser(); + dbUser.setAccount(LEGACY_ACCOUNT); + when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + + MpUser user = phoneChangeUser(NEW_PHONE); + user.setAccount(LEGACY_ACCOUNT); + + assertTrue(updateUserWithPhoneSync(user)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserMapper).updateById(captor.capture()); + assertEquals(LEGACY_ACCOUNT, captor.getValue().getAccount(), "只有 account 等于旧号时才跟随"); + verify(phoneChangeSupport).syncDriverMobile(TENANT_ID, USER_ID, PHONE, NEW_PHONE); + } + + @Test + @DisplayName("登录等非改号入口(updateUser):手机号变了也不调档案同步,避免 transport↔system 回环") + void updateUserDoesNotSyncDriverProfile() { + when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser()); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + + assertTrue(updateUser(phoneChangeUser(NEW_PHONE))); + + verify(phoneChangeSupport, never()).syncDriverMobile(any(), any(), any(), any()); + } + + @Test + @DisplayName("手机号回传脱敏值:视为没改号,账号不动也不调档案同步") + void maskedPhoneIsTreatedAsUnchanged() { + when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser()); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + + assertTrue(updateUserWithPhoneSync(phoneChangeUser("138****8000"))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserMapper).updateById(captor.capture()); + assertEquals(PHONE, captor.getValue().getPhone()); + assertEquals(PHONE, captor.getValue().getAccount()); + verify(phoneChangeSupport, never()).syncDriverMobile(any(), any(), any(), any()); + } + + @Test + @DisplayName("历史行没有 account:改号时账号写成新号,与 prepare 的默认值同口径") + void emptyAccountFollowsNewPhone() { + MpUser dbUser = dbUser(); + dbUser.setAccount(null); + when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + + MpUser user = phoneChangeUser(NEW_PHONE); + user.setAccount(null); + + assertTrue(updateUserWithPhoneSync(user)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserMapper).updateById(captor.capture()); + assertEquals(NEW_PHONE, captor.getValue().getAccount()); + verify(phoneChangeSupport).syncDriverMobile(TENANT_ID, USER_ID, PHONE, NEW_PHONE); + } + + @Test + @DisplayName("新手机号已属于别的账号:拒绝且不调档案同步") + void rejectsPhoneOccupiedByAnotherUser() { + when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser()); + when(mpUserMapper.selectOne(any(), anyBoolean())).thenReturn(otherUser()); + + MpUser user = phoneChangeUser(NEW_PHONE); + + ServiceException exception = assertThrows(ServiceException.class, () -> updateUserWithPhoneSync(user)); + assertEquals("该手机号已存在小程序用户", exception.getMessage()); + verify(mpUserMapper, never()).updateById(any(MpUser.class)); + verify(phoneChangeSupport, never()).syncDriverMobile(any(), any(), any(), any()); + } + + @Test + @DisplayName("账号跟随新号后与别的账号的登录名撞车:拒绝(account 只有普通索引,库不拦重)") + void rejectsAccountOccupiedByAnotherUser() { + when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser()); + // 第一次查手机号命中为空,第二次查账号命中别人 + when(mpUserMapper.selectOne(any(), anyBoolean())).thenReturn(null, otherUser()); + + MpUser user = phoneChangeUser(NEW_PHONE); + + ServiceException exception = assertThrows(ServiceException.class, () -> updateUserWithPhoneSync(user)); + assertEquals("该账号已存在小程序用户", exception.getMessage()); + verify(mpUserMapper, never()).updateById(any(MpUser.class)); + verify(phoneChangeSupport, never()).syncDriverMobile(any(), any(), any(), any()); + } + + @Test + @DisplayName("档案同步失败:账号更新照常成功,不抛异常不回滚") + void profileSyncFailureDoesNotFailUpdate() { + when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser()); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + when(phoneChangeSupport.syncDriverMobile(any(), any(), any(), any())).thenReturn(false); + + MpUser user = phoneChangeUser(NEW_PHONE); + + assertTrue(updateUserWithPhoneSync(user)); + verify(phoneChangeSupport).syncDriverMobile(TENANT_ID, USER_ID, PHONE, NEW_PHONE); + } + + @Test + @DisplayName("后台保存入口(submitOrUpdate):已有账号走带档案同步的更新") + void submitOrUpdateSyncsProfileForExistingUser() { + when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser()); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + + try (MockedStatic ignored = mockStatic(MpUserCache.class)) { + assertTrue(service.submitOrUpdate(phoneChangeUser(NEW_PHONE))); + } + + verify(phoneChangeSupport).syncDriverMobile(TENANT_ID, USER_ID, PHONE, NEW_PHONE); + } + // ------------------------------------------------------------ 夹具 + /** + * 改号入口(带司机档案同步)成功后会清缓存;缓存实现依赖 Spring 容器,无容器时静态 mock 掉 + */ + private boolean updateUserWithPhoneSync(MpUser user) { + try (MockedStatic ignored = mockStatic(MpUserCache.class)) { + return service.updateUserWithPhoneSync(user); + } + } + + private MpUser phoneChangeUser(String phone) { + MpUser user = new MpUser(); + user.setId(USER_ID); + user.setTenantId(TENANT_ID); + user.setPhone(phone); + user.setIdentities("driver"); + return user; + } + + private MpUser otherUser() { + MpUser user = new MpUser(); + user.setId(OTHER_USER_ID); + user.setTenantId(TENANT_ID); + user.setPhone(PHONE); + user.setAccount(PHONE); + return user; + } + /** * updateUser 成功后会清缓存;缓存实现依赖 Spring 容器,无容器时静态 mock 掉 */ diff --git a/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/UserPhoneServiceImplTest.java b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/UserPhoneServiceImplTest.java new file mode 100644 index 00000000..85fdfc14 --- /dev/null +++ b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/UserPhoneServiceImplTest.java @@ -0,0 +1,152 @@ +package org.springblade.system.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.redis.cache.BladeRedis; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.api.R; +import org.springblade.resource.feign.ISmsClient; +import org.springblade.system.cache.MpUserCache; +import org.springblade.system.pojo.dto.PhoneChangeDTO; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.service.IMpUserService; +import org.springblade.system.utils.MpUserAuth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * App 内改号(票 05 场景 6 的小程序侧入口)。 + *

+ * 只验证「交给 updateUser 之前」的三处一致性判据:手机号是新号、账号是否跟随取库里的旧号、 + * 以及必须走带司机档案同步的更新入口(否则档案电话会停在旧号)。 + * 登录态与缓存均为静态工具,无 Spring 容器时需要 mock。 + */ +class UserPhoneServiceImplTest { + + private static final Long USER_ID = 7001L; + private static final String TENANT_ID = "000000"; + private static final String OLD_PHONE = "15975348524"; + private static final String NEW_PHONE = "13800138000"; + private static final String CUSTOM_ACCOUNT = "driver-001"; + private static final String VERIFIED_KEY = "blade:mp-user:phone:change:verified:" + USER_ID; + + private IMpUserService mpUserService; + private ISmsClient smsClient; + private BladeRedis bladeRedis; + private UserPhoneServiceImpl service; + + @BeforeEach + void setUp() { + mpUserService = mock(IMpUserService.class); + smsClient = mock(ISmsClient.class); + bladeRedis = mock(BladeRedis.class); + service = new UserPhoneServiceImpl(mpUserService, smsClient, bladeRedis); + } + + @Test + @DisplayName("App 改号(account 就是旧号):手机号与账号都改,并走带档案同步的更新入口") + void changePhoneFollowsAccountAndSyncsDriverProfile() { + when(bladeRedis.get(VERIFIED_KEY)).thenReturn("1"); + when(mpUserService.getById(USER_ID)).thenReturn(currentUser(OLD_PHONE)); + when(mpUserService.count(any())).thenReturn(0L); + when(smsClient.validateMessage(any(), any(), any(), any(), any())).thenReturn(R.data(true)); + when(mpUserService.updateUserWithPhoneSync(any())).thenReturn(true); + + assertTrue(changePhone()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserService).updateUserWithPhoneSync(captor.capture()); + assertEquals(NEW_PHONE, captor.getValue().getPhone()); + assertEquals(NEW_PHONE, captor.getValue().getAccount(), "账号等于旧号时跟随新号,否则短信登录用不了"); + verify(mpUserService, never()).updateUser(any()); + } + + @Test + @DisplayName("App 改号(自定义登录名):账号不动,仍走带档案同步的更新入口") + void changePhoneKeepsCustomAccount() { + when(bladeRedis.get(VERIFIED_KEY)).thenReturn("1"); + when(mpUserService.getById(USER_ID)).thenReturn(currentUser(CUSTOM_ACCOUNT)); + when(mpUserService.count(any())).thenReturn(0L); + when(smsClient.validateMessage(any(), any(), any(), any(), any())).thenReturn(R.data(true)); + when(mpUserService.updateUserWithPhoneSync(any())).thenReturn(true); + + assertTrue(changePhone()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserService).updateUserWithPhoneSync(captor.capture()); + assertEquals(NEW_PHONE, captor.getValue().getPhone()); + assertEquals(CUSTOM_ACCOUNT, captor.getValue().getAccount()); + verify(mpUserService, times(1)).count(any()); + } + + @Test + @DisplayName("未完成原手机号验证:拒绝,不调更新") + void rejectsWithoutOldPhoneVerification() { + when(bladeRedis.get(VERIFIED_KEY)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, this::changePhone); + assertEquals("请先完成原手机号验证", exception.getMessage()); + verify(mpUserService, never()).updateUserWithPhoneSync(any()); + } + + @Test + @DisplayName("新手机号与当前号相同:拒绝,不调更新") + void rejectsSamePhone() { + PhoneChangeDTO change = change(); + change.setNewPhone(OLD_PHONE); + when(bladeRedis.get(VERIFIED_KEY)).thenReturn("1"); + when(mpUserService.getById(USER_ID)).thenReturn(currentUser(OLD_PHONE)); + + ServiceException exception = assertThrows(ServiceException.class, () -> changePhone(change)); + assertEquals("新手机号不可与当前手机号相同", exception.getMessage()); + verify(mpUserService, never()).updateUserWithPhoneSync(any()); + } + + private boolean changePhone() { + return changePhone(change()); + } + + private boolean changePhone(PhoneChangeDTO change) { + try (MockedStatic ignoredCache = mockStatic(MpUserCache.class); + MockedStatic ignoredAuth = mockStatic(AuthUtil.class); + MockedStatic ignoredMpUserAuth = mockStatic(MpUserAuth.class)) { + ignoredAuth.when(AuthUtil::getUserId).thenReturn(USER_ID); + ignoredAuth.when(AuthUtil::getTenantId).thenReturn(TENANT_ID); + ignoredMpUserAuth.when(MpUserAuth::isMpUser).thenReturn(true); + return service.changePhone(change); + } + } + + private PhoneChangeDTO change() { + PhoneChangeDTO change = new PhoneChangeDTO(); + change.setId("sms-1"); + change.setCode("123456"); + change.setNewPhone(NEW_PHONE); + return change; + } + + private MpUser currentUser(String account) { + MpUser user = new MpUser(); + user.setId(USER_ID); + user.setTenantId(TENANT_ID); + user.setPhone(OLD_PHONE); + user.setAccount(account); + user.setIdentities("driver"); + user.setStatus(1); + return user; + } + +} diff --git a/blade-service/blade-system/src/test/java/org/springblade/system/support/MpPhoneChangePlanTest.java b/blade-service/blade-system/src/test/java/org/springblade/system/support/MpPhoneChangePlanTest.java new file mode 100644 index 00000000..2a200789 --- /dev/null +++ b/blade-service/blade-system/src/test/java/org/springblade/system/support/MpPhoneChangePlanTest.java @@ -0,0 +1,108 @@ +package org.springblade.system.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 「后台 / App 改号时账号该不该跟着改」的判据测试。 + *

+ * 纯函数,不起 Spring 容器、不连库。判据基准只有一条:库里的 account 是否等于旧手机号。 + * 存量数据反例(account=15577122728 / phone=15577122721)在最后两条用例里。 + */ +class MpPhoneChangePlanTest { + + private static final String OLD_PHONE = "15975348524"; + private static final String NEW_PHONE = "13800138000"; + private static final String LEGACY_ACCOUNT = "15577122728"; + private static final String CUSTOM_ACCOUNT = "driver-001"; + + @Test + @DisplayName("账号就是旧号:手机号变化时账号跟随新号") + void accountFollowsPhoneWhenAccountWasOldPhone() { + MpPhoneChangePlan plan = MpPhoneChangePlan.of(OLD_PHONE, OLD_PHONE, NEW_PHONE, OLD_PHONE); + + assertTrue(plan.isPhoneChanged()); + assertTrue(plan.shouldSyncAccount()); + assertEquals(NEW_PHONE, plan.nextAccount()); + assertTrue(plan.isAccountChanging()); + } + + @Test + @DisplayName("自定义登录名:手机号变化时账号不动") + void customAccountStaysWhenPhoneChanges() { + MpPhoneChangePlan plan = MpPhoneChangePlan.of(OLD_PHONE, CUSTOM_ACCOUNT, NEW_PHONE, CUSTOM_ACCOUNT); + + assertTrue(plan.isPhoneChanged()); + assertFalse(plan.shouldSyncAccount()); + assertEquals(CUSTOM_ACCOUNT, plan.nextAccount()); + assertFalse(plan.isAccountChanging()); + } + + @Test + @DisplayName("存量反例 account(15577122728) ≠ phone(15577122721):改号时账号不动") + void legacyAccountDifferentFromPhoneStays() { + MpPhoneChangePlan plan = MpPhoneChangePlan.of(OLD_PHONE, LEGACY_ACCOUNT, NEW_PHONE, LEGACY_ACCOUNT); + + assertFalse(plan.shouldSyncAccount()); + assertEquals(LEGACY_ACCOUNT, plan.nextAccount()); + assertFalse(plan.isAccountChanging()); + } + + @Test + @DisplayName("手机号没变(脱敏值回填后仍是库值):不动账号") + void keepsAccountWhenPhoneUnchanged() { + MpPhoneChangePlan plan = MpPhoneChangePlan.of(OLD_PHONE, OLD_PHONE, OLD_PHONE, OLD_PHONE); + + assertFalse(plan.isPhoneChanged()); + assertFalse(plan.shouldSyncAccount()); + assertFalse(plan.isAccountChanging()); + assertEquals(OLD_PHONE, plan.nextAccount()); + } + + @Test + @DisplayName("手机号或提交的账号含脱敏星号:不落库、不跟随") + void maskedValueNeverTouchesAccount() { + MpPhoneChangePlan maskedPhone = MpPhoneChangePlan.of(OLD_PHONE, OLD_PHONE, "138****8000", OLD_PHONE); + assertFalse(maskedPhone.isPhoneChanged()); + assertFalse(maskedPhone.shouldSyncAccount()); + assertEquals(OLD_PHONE, maskedPhone.nextAccount()); + + MpPhoneChangePlan maskedAccount = MpPhoneChangePlan.of(OLD_PHONE, CUSTOM_ACCOUNT, NEW_PHONE, "dri***001"); + assertTrue(maskedAccount.isMasked()); + assertEquals(CUSTOM_ACCOUNT, maskedAccount.nextAccount()); + } + + @Test + @DisplayName("管理员显式提交了另一个账号名:按提交值落库") + void explicitAccountRenameWins() { + MpPhoneChangePlan plan = MpPhoneChangePlan.of(OLD_PHONE, OLD_PHONE, NEW_PHONE, "admin-002"); + + assertEquals("admin-002", plan.nextAccount()); + assertTrue(plan.isAccountChanging()); + } + + @Test + @DisplayName("没有账号(历史行):改号时账号写成新号,与 prepare 的默认值同口径") + void emptyAccountFollowsPhone() { + MpPhoneChangePlan plan = MpPhoneChangePlan.of(OLD_PHONE, null, NEW_PHONE, null); + + assertTrue(plan.shouldSyncAccount()); + assertEquals(NEW_PHONE, plan.nextAccount()); + assertTrue(plan.isAccountChanging()); + } + + @Test + @DisplayName("比较前 trim:前后空格不影响「账号就是旧号」的判定") + void trimsBeforeComparing() { + MpPhoneChangePlan plan = MpPhoneChangePlan.of(" " + OLD_PHONE + " ", OLD_PHONE + " ", " " + NEW_PHONE, OLD_PHONE); + + assertTrue(plan.isPhoneChanged()); + assertTrue(plan.shouldSyncAccount()); + assertEquals(NEW_PHONE, plan.nextAccount()); + } + +} diff --git a/blade-service/blade-system/src/test/java/org/springblade/system/support/MpUserPhoneChangeSupportTest.java b/blade-service/blade-system/src/test/java/org/springblade/system/support/MpUserPhoneChangeSupportTest.java new file mode 100644 index 00000000..f7b6d953 --- /dev/null +++ b/blade-service/blade-system/src/test/java/org/springblade/system/support/MpUserPhoneChangeSupportTest.java @@ -0,0 +1,111 @@ +package org.springblade.system.support; + +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.tool.api.R; +import org.springblade.transport.feign.IDriverClient; +import org.springblade.transport.pojo.dto.DriverSyncMobileDTO; +import org.springblade.transport.pojo.vo.DriverSyncMobileVO; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 改号后同步司机档案手机号的出口测试。 + *

+ * 这里只测「调用与失败处理」:feign 结果无论成功、判据不满足、RPC 失败还是抛异常, + * 都不能让调用方的事务失败(账号更新已经落库,档案同步是补偿动作)。 + */ +class MpUserPhoneChangeSupportTest { + + private static final String TENANT_ID = "000000"; + private static final Long USER_ID = 7001L; + private static final Long DRIVER_ID = 9001L; + private static final String OLD_PHONE = "15975348524"; + private static final String NEW_PHONE = "13800138000"; + + private IDriverClient driverClient; + private MpUserPhoneChangeSupport support; + + @BeforeEach + void setUp() { + driverClient = mock(IDriverClient.class); + support = new MpUserPhoneChangeSupport(driverClient); + } + + @Test + @DisplayName("档案同步成功:返回 true,入参是租户 + 账号 id + 旧新手机号") + void passesAccountIdentityAndPhonesToDriverClient() { + when(driverClient.syncMobile(any())).thenReturn(R.data(DriverSyncMobileVO.synced(DRIVER_ID))); + + assertTrue(support.syncDriverMobile(TENANT_ID, USER_ID, OLD_PHONE, NEW_PHONE)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DriverSyncMobileDTO.class); + verify(driverClient).syncMobile(captor.capture()); + assertEquals(TENANT_ID, captor.getValue().getTenantId()); + assertEquals(USER_ID, captor.getValue().getMpUserId()); + assertEquals(OLD_PHONE, captor.getValue().getOldPhone()); + assertEquals(NEW_PHONE, captor.getValue().getNewPhone()); + } + + @Test + @DisplayName("档案不属于该账号或手机号对不上:synced=false 只记日志,返回 false 不抛") + void returnsFalseWhenDriverProfileNotSynced() { + when(driverClient.syncMobile(any())) + .thenReturn(R.data(DriverSyncMobileVO.notSynced(null, "未找到该账号的司机档案"))); + + assertFalse(support.syncDriverMobile(TENANT_ID, USER_ID, OLD_PHONE, NEW_PHONE)); + } + + @Test + @DisplayName("RPC 返回失败:返回 false 不抛") + void returnsFalseWhenRpcFails() { + when(driverClient.syncMobile(any())).thenReturn(R.fail("服务不可用")); + + assertFalse(support.syncDriverMobile(TENANT_ID, USER_ID, OLD_PHONE, NEW_PHONE)); + } + + @Test + @DisplayName("RPC 成功但没有数据或 synced 为空:按未同步处理") + void returnsFalseWhenDataOrSyncedMissing() { + when(driverClient.syncMobile(any())).thenReturn(R.data(null)); + assertFalse(support.syncDriverMobile(TENANT_ID, USER_ID, OLD_PHONE, NEW_PHONE)); + + DriverSyncMobileVO empty = new DriverSyncMobileVO(); + empty.setDriverId(DRIVER_ID); + when(driverClient.syncMobile(any())).thenReturn(R.data(empty)); + assertFalse(support.syncDriverMobile(TENANT_ID, USER_ID, OLD_PHONE, NEW_PHONE)); + } + + @Test + @DisplayName("RPC 抛异常(超时 / 连不上):吞掉并返回 false,账号更新不回滚") + void swallowsExceptionAndReturnsFalse() { + when(driverClient.syncMobile(any())).thenThrow(new IllegalStateException("connect timed out")); + + assertFalse(assertDoesNotThrow( + () -> support.syncDriverMobile(TENANT_ID, USER_ID, OLD_PHONE, NEW_PHONE))); + } + + @Test + @DisplayName("入参不全或新旧号相同:不发起 RPC") + void skipsRpcForIncompleteArguments() { + assertFalse(support.syncDriverMobile(TENANT_ID, null, OLD_PHONE, NEW_PHONE)); + assertFalse(support.syncDriverMobile(null, USER_ID, OLD_PHONE, NEW_PHONE)); + assertFalse(support.syncDriverMobile(TENANT_ID, USER_ID, null, NEW_PHONE)); + assertFalse(support.syncDriverMobile(TENANT_ID, USER_ID, OLD_PHONE, " ")); + assertFalse(support.syncDriverMobile(TENANT_ID, USER_ID, OLD_PHONE, OLD_PHONE)); + assertFalse(support.syncDriverMobile(TENANT_ID, USER_ID, "159****8524", NEW_PHONE)); + + verify(driverClient, never()).syncMobile(any()); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java index c5ab2d70..90f6be29 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java @@ -30,7 +30,9 @@ import lombok.AllArgsConstructor; import org.springblade.core.tenant.annotation.NonDS; import org.springblade.core.tool.api.R; import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO; +import org.springblade.transport.pojo.dto.DriverSyncMobileDTO; import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; +import org.springblade.transport.pojo.vo.DriverSyncMobileVO; import org.springblade.transport.service.IDriverService; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -58,4 +60,14 @@ public class DriverClient implements IDriverClient { return R.data(driverService.ensureProfile(param.getTenantId(), param.getMpUserId(), param.getPhone())); } + @Override + @PostMapping(SYNC_MOBILE) + public R syncMobile(@RequestBody DriverSyncMobileDTO param) { + if (param == null) { + return R.fail("司机档案参数不能为空"); + } + return R.data(driverService.syncMobile(param.getTenantId(), param.getMpUserId(), + param.getOldPhone(), param.getNewPhone())); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java index 8864ed8f..73924e3e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java @@ -31,6 +31,7 @@ import org.springblade.core.mp.base.BaseService; import org.springblade.transport.excel.DriverExcel; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; +import org.springblade.transport.pojo.vo.DriverSyncMobileVO; import org.springblade.transport.pojo.vo.DriverExpiryStatVO; import org.springblade.transport.pojo.vo.DriverVO; @@ -70,6 +71,21 @@ public interface IDriverService extends BaseService { */ DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone); + /** + * 账号改号后同步司机档案手机号 + *

+ * 幂等入口:只在档案属于该账号(user_id 命中)且档案 mobile 等于旧号时改这一列, + * 档案已是新号时返回成功且不写库;档案找不到或判据不满足时不动档案,通过 message 说明原因。 + * 参数为空、手机号为脱敏值时抛 ServiceException。 + * + * @param tenantId 租户ID + * @param mpUserId 小程序用户ID + * @param oldPhone 账号改号前的手机号 + * @param newPhone 账号改号后的手机号 + * @return 档案ID与本次是否同步 + */ + DriverSyncMobileVO syncMobile(String tenantId, Long mpUserId, String oldPhone, String newPhone); + /** * 修改状态 * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java index 423bafec..2d4dc598 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java @@ -48,8 +48,11 @@ import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; import org.springblade.transport.pojo.vo.DriverExpiryStatVO; +import org.springblade.transport.pojo.vo.DriverSyncMobileVO; import org.springblade.transport.pojo.vo.DriverVO; import org.springblade.transport.service.IDriverService; +import org.springblade.transport.support.DriverPhoneSyncDecision; +import org.springblade.transport.support.DriverPhoneSyncPlanner; import org.springblade.transport.support.VehiclePlateSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -168,6 +171,63 @@ public class DriverServiceImpl extends BaseServiceImpl imp return bindExistingProfile(existing.getId(), mpUserId); } + @Override + @Transactional(rollbackFor = Exception.class) + public DriverSyncMobileVO syncMobile(String tenantId, Long mpUserId, String oldPhone, String newPhone) { + String resolvedTenantId = trimToNull(tenantId); + if (resolvedTenantId == null) { + resolvedTenantId = trimToNull(AuthUtil.getTenantId()); + } + if (resolvedTenantId == null) { + throw new ServiceException("租户信息为空,无法同步司机档案手机号"); + } + if (Func.isEmpty(mpUserId)) { + throw new ServiceException("小程序用户ID不能为空"); + } + // 旧号是档案当前值的判据基准;含 * 的脱敏串与空串都不是可用手机号 + String oldMobile = trimToNull(oldPhone); + if (oldMobile == null || oldMobile.contains("*")) { + throw new ServiceException("账号原手机号无效,无法同步司机档案"); + } + String newMobile = trimToNull(newPhone); + if (newMobile == null || newMobile.contains("*")) { + throw new ServiceException("手机号无效,不能写入司机档案"); + } + Driver profile = findDriverByUserId(mpUserId); + DriverPhoneSyncDecision decision = DriverPhoneSyncPlanner.decide( + resolvedTenantId, mpUserId, profile, oldMobile, newMobile); + Long driverId = profile == null ? null : profile.getId(); + if (decision == DriverPhoneSyncDecision.INVALID_PHONE) { + throw new ServiceException("手机号无效,不能写入司机档案"); + } + if (decision == DriverPhoneSyncDecision.NO_PROFILE) { + return DriverSyncMobileVO.notSynced(null, "未找到该账号的司机档案"); + } + if (decision == DriverPhoneSyncDecision.TENANT_MISMATCH) { + return DriverSyncMobileVO.notSynced(driverId, "司机档案不属于该账号所在租户,未同步"); + } + if (decision == DriverPhoneSyncDecision.USER_MISMATCH) { + return DriverSyncMobileVO.notSynced(driverId, "司机档案未绑定该账号,未同步"); + } + if (decision == DriverPhoneSyncDecision.ALREADY_SYNCED) { + // 档案已是新号:重复调用(前端重试、日志重放)不应再写 update_time + return DriverSyncMobileVO.synced(driverId); + } + if (decision == DriverPhoneSyncDecision.MOBILE_MISMATCH) { + return DriverSyncMobileVO.notSynced(driverId, "司机档案手机号与账号原手机号不一致,未同步"); + } + Driver patch = new Driver(); + patch.setId(driverId); + patch.setMobile(newMobile); + // 只改 mobile 一列,但 driving_vehicle 是 FieldStrategy.ALWAYS:不带原值会被写成 NULL + carryDrivingVehicle(driverId, patch); + if (!updateById(patch)) { + // 0 行:档案被逻辑删除或租户条件不命中 + return DriverSyncMobileVO.notSynced(driverId, "司机档案更新未命中记录,未同步"); + } + return DriverSyncMobileVO.synced(driverId); + } + /** * 按小程序账号反查档案;user_id 有全局唯一索引,故不叠加租户条件 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/DriverPhoneSyncDecision.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/DriverPhoneSyncDecision.java new file mode 100644 index 00000000..6f567de0 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/DriverPhoneSyncDecision.java @@ -0,0 +1,70 @@ +/** + * 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.transport.support; + +/** + * 司机档案手机号同步的判定结果 + * + * @author Chill + */ +public enum DriverPhoneSyncDecision { + + /** + * 入参不可用:账号ID为空,或旧号/新号为空、为脱敏值 + */ + INVALID_PHONE, + + /** + * 该账号没有司机档案 + */ + NO_PROFILE, + + /** + * 档案的 tenant_id 与账号租户不一致 + */ + TENANT_MISMATCH, + + /** + * 档案的 user_id 不是该账号 + */ + USER_MISMATCH, + + /** + * 档案手机号已是新号:幂等,不写库 + */ + ALREADY_SYNCED, + + /** + * 档案手机号与账号旧号不一致:不动档案 + */ + MOBILE_MISMATCH, + + /** + * 判据全满足:只改 mobile 一列 + */ + NEEDS_UPDATE + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/DriverPhoneSyncPlanner.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/DriverPhoneSyncPlanner.java new file mode 100644 index 00000000..0608653a --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/DriverPhoneSyncPlanner.java @@ -0,0 +1,96 @@ +/** + * 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.transport.support; + +import org.springblade.transport.pojo.entity.Driver; + +import java.util.Objects; + +/** + * 「账号改号后是否同步司机档案 mobile」的判据 + *

+ * 纯函数:不读写库、不依赖 Spring 容器。Service 只负责「查档案 → decide → 按结果写库」, + * 判据本身可以直接列真值表测试。 + *

+ * 全部以 trim 后比较;null 与 null 视为相等只用于「都没有值」的情形,不参与业务判据。 + * + * @author Chill + */ +public final class DriverPhoneSyncPlanner { + + private DriverPhoneSyncPlanner() { + } + + /** + * 判定本次改号是否该改档案的 mobile + * + * @param tenantId 账号所属租户 + * @param mpUserId 小程序账号ID + * @param profile 按 user_id 查到的司机档案,未查到为 null + * @param oldPhone 账号改号前的手机号 + * @param newPhone 账号改号后的手机号 + * @return 判定结果 + */ + public static DriverPhoneSyncDecision decide(String tenantId, Long mpUserId, Driver profile, + String oldPhone, String newPhone) { + String oldMobile = trimToNull(oldPhone); + String newMobile = trimToNull(newPhone); + // 旧号既是「档案是否仍属于这次改号」的基准,也是唯一能安全替换的值; + // 脱敏串与空串写进档案都不可用 + if (mpUserId == null || oldMobile == null || newMobile == null + || oldMobile.contains("*") || newMobile.contains("*")) { + return DriverPhoneSyncDecision.INVALID_PHONE; + } + if (profile == null) { + return DriverPhoneSyncDecision.NO_PROFILE; + } + String resolvedTenantId = trimToNull(tenantId); + String profileTenantId = trimToNull(profile.getTenantId()); + if (resolvedTenantId != null && profileTenantId != null && !resolvedTenantId.equals(profileTenantId)) { + return DriverPhoneSyncDecision.TENANT_MISMATCH; + } + if (!Objects.equals(profile.getUserId(), mpUserId)) { + return DriverPhoneSyncDecision.USER_MISMATCH; + } + String profileMobile = trimToNull(profile.getMobile()); + if (Objects.equals(profileMobile, newMobile)) { + return DriverPhoneSyncDecision.ALREADY_SYNCED; + } + if (!Objects.equals(profileMobile, oldMobile)) { + return DriverPhoneSyncDecision.MOBILE_MISMATCH; + } + return DriverPhoneSyncDecision.NEEDS_UPDATE; + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverSyncMobileTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverSyncMobileTest.java new file mode 100644 index 00000000..35568abb --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverSyncMobileTest.java @@ -0,0 +1,197 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +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.log.exception.ServiceException; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.feign.ISysClient; +import org.springblade.transport.mapper.DriverMapper; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.vo.DriverSyncMobileVO; + +import java.lang.reflect.Field; + +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.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 后台 / App 改号后同步司机档案 phone 的测试。 + *

+ * 守住四条不变式:只改 mobile 一列的 patch 必须带上 driving_vehicle 原值; + * 档案 phone 与账号旧号不一致时不动(那是另一段事实);已是新号时幂等成功; + * 找不到档案只返回 synced=false 与可读原因,不抛异常给调用方的账号更新。 + */ +class DriverSyncMobileTest { + + private static final Long MP_USER_ID = 7001L; + private static final Long DRIVER_ID = 9001L; + private static final String TENANT_ID = "000000"; + private static final String OLD_PHONE = "15975348524"; + private static final String NEW_PHONE = "13800138000"; + private static final String PLATE = "桂A32523"; + + private DriverMapper driverMapper; + private DriverServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + } + + @BeforeEach + void setUp() throws Exception { + driverMapper = mock(DriverMapper.class); + service = new DriverServiceImpl(mock(IMpUserClient.class), mock(ISysClient.class)); + inject("baseMapper", driverMapper); + } + + // ------------------------------------------------------------ 前提满足:只改 mobile 一列 + + @Test + @DisplayName("档案属于该账号且 mobile 等于旧号:只改 mobile,patch 必须带上 driving_vehicle 原值") + void syncsMobileAndKeepsDrivingVehicle() { + Driver profile = driver(DRIVER_ID, MP_USER_ID, OLD_PHONE, PLATE); + stubProfileLookup(profile); + when(driverMapper.selectById(DRIVER_ID)).thenReturn(profile); + when(driverMapper.updateById(any(Driver.class))).thenReturn(1); + + DriverSyncMobileVO result = service.syncMobile(TENANT_ID, MP_USER_ID, OLD_PHONE, NEW_PHONE); + + assertTrue(result.getSynced()); + assertEquals(DRIVER_ID, result.getDriverId()); + assertNull(result.getMessage()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverMapper).updateById(captor.capture()); + Driver patch = captor.getValue(); + assertEquals(DRIVER_ID, patch.getId()); + assertEquals(NEW_PHONE, patch.getMobile()); + assertEquals(PLATE, patch.getDrivingVehicle(), + "patch 不带 driving_vehicle 会把司机的车辆绑定写掉(该列 FieldStrategy.ALWAYS)"); + assertNull(patch.getTenantId(), "只改 mobile,不得写 tenant_id"); + assertNull(patch.getUserId(), "只改 mobile,不得写 user_id"); + verify(driverMapper, never()).insert(any(Driver.class)); + } + + @Test + @DisplayName("档案 mobile 已是新号:幂等成功,不再写库") + void isIdempotentWhenProfileAlreadyHasNewMobile() { + stubProfileLookup(driver(DRIVER_ID, MP_USER_ID, NEW_PHONE, PLATE)); + + DriverSyncMobileVO result = service.syncMobile(TENANT_ID, MP_USER_ID, OLD_PHONE, NEW_PHONE); + + assertTrue(result.getSynced()); + assertEquals(DRIVER_ID, result.getDriverId()); + assertNull(result.getMessage()); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + @Test + @DisplayName("档案 mobile 与账号旧号不一致:不动档案,返回可读原因(不抛异常)") + void skipsWhenProfileMobileDiffersFromOldPhone() { + stubProfileLookup(driver(DRIVER_ID, MP_USER_ID, "13512345678", PLATE)); + + DriverSyncMobileVO result = service.syncMobile(TENANT_ID, MP_USER_ID, OLD_PHONE, NEW_PHONE); + + assertFalse(result.getSynced()); + assertEquals(DRIVER_ID, result.getDriverId()); + assertNotNull(result.getMessage()); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + @Test + @DisplayName("按 user_id + tenant 找不到档案:synced=false 并给出原因,不抛异常") + void returnsNotSyncedWhenProfileMissing() { + stubProfileLookup(null); + + DriverSyncMobileVO result = service.syncMobile(TENANT_ID, MP_USER_ID, OLD_PHONE, NEW_PHONE); + + assertFalse(result.getSynced()); + assertNull(result.getDriverId()); + assertNotNull(result.getMessage()); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + // ------------------------------------------------------------ 入参兜底 + + @Test + @DisplayName("账号 ID 为空:拒绝,不写库") + void rejectsEmptyUserId() { + assertThrows(ServiceException.class, () -> service.syncMobile(TENANT_ID, null, OLD_PHONE, NEW_PHONE)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + @Test + @DisplayName("旧号为空:拒绝,不写库") + void rejectsBlankOldPhone() { + assertThrows(ServiceException.class, () -> service.syncMobile(TENANT_ID, MP_USER_ID, " ", NEW_PHONE)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + @Test + @DisplayName("新号为空:拒绝,不写库") + void rejectsBlankNewPhone() { + assertThrows(ServiceException.class, () -> service.syncMobile(TENANT_ID, MP_USER_ID, OLD_PHONE, null)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + @Test + @DisplayName("脱敏新号:拒绝写库") + void rejectsMaskedNewPhone() { + assertThrows(ServiceException.class, () -> service.syncMobile(TENANT_ID, MP_USER_ID, OLD_PHONE, "138****8000")); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + // ------------------------------------------------------------ 夹具 + + private void stubProfileLookup(Driver profile) { + when(driverMapper.selectOne(any())).thenReturn(profile); + when(driverMapper.selectOne(any(), anyBoolean())).thenReturn(profile); + } + + private Driver driver(Long id, Long userId, String mobile, String drivingVehicle) { + Driver driver = new Driver(); + driver.setId(id); + driver.setIsDeleted(0); + driver.setTenantId(TENANT_ID); + driver.setUserId(userId); + driver.setMobile(mobile); + driver.setDrivingVehicle(drivingVehicle); + return driver; + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} From dca59720bfa206a360b75a2b2f609bf339ab0e31 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Mon, 26 Oct 2026 15:32:11 +0800 Subject: [PATCH 4/4] =?UTF-8?q?:adhesive=5Fbandage:=20code-review=20?= =?UTF-8?q?=E4=BF=AE=E8=AE=A2=EF=BC=9A=E6=94=B9=E5=8F=B7=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=89=8B=E6=9C=BA=E5=8F=B7=E8=84=B1=E6=95=8F?= =?UTF-8?q?=E3=80=81=E7=BB=9F=E4=B8=80=20feign=20=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/support/MpLoginSupport.java | 4 ++-- .../transport/feign/IDriverClient.java | 3 ++- .../system/support/MpPhoneChangePlan.java | 8 +------ .../support/MpUserPhoneChangeSupport.java | 21 +++++++++++-------- .../service/impl/MpUserServiceImplTest.java | 2 +- .../impl/UserPhoneServiceImplTest.java | 2 +- 6 files changed, 19 insertions(+), 21 deletions(-) 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 9e8154bd..030e2502 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 @@ -89,7 +89,7 @@ public class MpLoginSupport { // ③ 身份含 driver 才建档;纯 admin 不建 String loginNotice = ensureProfile(account, phone, tenantId); // ④ 微信登录把 openid 覆盖写为本次值留痕;失败只 WARN - bindOpenid(account, openid); + bindOpenId(account, openid); MpUserInfo userInfo = loadUserInfo(account); attachNotice(userInfo, loginNotice); return TokenUtil.convertMpUser(userInfo); @@ -187,7 +187,7 @@ public class MpLoginSupport { /** * ④ 微信登录把 openid 覆盖写为本次值:「该账号最近一次登录用的微信」,不参与识别;失败只 WARN */ - private void bindOpenid(MpUser account, String openid) { + private void bindOpenId(MpUser account, String openid) { if (account == null || StringUtil.isBlank(openid)) { return; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java index 7a81e0d5..ed954ea2 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java @@ -63,7 +63,8 @@ public interface IDriverClient { *

* 幂等入口:档案 mobile 已是新号时返回成功且不写库。只在档案属于该账号 * (user_id + tenant_id 命中)且档案 mobile 等于 oldPhone 时改这一列; - * 不满足时不动档案,通过返回值的 message 说明原因,不抛异常给调用方的账号更新。 + * 不满足时不动档案,通过返回值的 message 说明原因。入参不合法时服务端抛 ServiceException, + * feign 以调用异常返回,调用方按失败处理并自行决定是否影响账号更新。 * * @param param 租户、小程序账号、账号旧号与新号 * @return 档案ID与本次是否同步 diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/support/MpPhoneChangePlan.java b/blade-service/blade-system/src/main/java/org/springblade/system/support/MpPhoneChangePlan.java index f1c9320e..8db6adb4 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/support/MpPhoneChangePlan.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/support/MpPhoneChangePlan.java @@ -97,13 +97,7 @@ public final class MpPhoneChangePlan { && !submittedAccount.equals(oldAccount) && !isMasked(submittedAccount)) { return submittedAccount; } - if (oldAccount == null) { - return newPhone; - } - if (oldAccount.equals(oldPhone)) { - return newPhone; - } - return oldAccount; + return shouldSyncAccount() ? newPhone : oldAccount; } /** diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/support/MpUserPhoneChangeSupport.java b/blade-service/blade-system/src/main/java/org/springblade/system/support/MpUserPhoneChangeSupport.java index 2e7ca953..31f35456 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/support/MpUserPhoneChangeSupport.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/support/MpUserPhoneChangeSupport.java @@ -22,6 +22,7 @@ */ package org.springblade.system.support; +import cn.hutool.core.util.DesensitizedUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.core.tool.api.R; @@ -34,12 +35,12 @@ import org.springframework.stereotype.Component; /** * 改号后同步司机档案手机号 *

- * 只在后台编辑与 App 内改号两个入口调用:登录链路上 transport 会回调本服务的 updateUser, - * 若那里再回调用 transport,会形成 transport(持 driver 行锁) → system → transport 的嵌套更新, - * 内层等同一行的锁直到超时。 + * 只在后台编辑与 App 内改号两个入口调用。登录链路的账号更新不得回调 transport: + * transport 的司机档案编辑会回调本服务的 updateUser,互相回调会形成 transport(持 driver 行锁) + * → system → transport 的嵌套更新,内层等同一行的锁直到超时。 *

- * 档案同步是账号更新之后的补偿动作:任何失败只记日志、不抛异常,账号更新照常提交。 - * 调用方据此能看出「账号改了、档案没改」。 + * 档案同步在账号更新之后执行:任何失败只记日志、不抛异常,账号更新照常提交。 + * 日志里的手机号一律脱敏;tenantId / mpUserId / driverId 保持原值用于定位。 * * @author Chill */ @@ -63,6 +64,8 @@ public class MpUserPhoneChangeSupport { || oldMobile.contains("*") || newMobile.contains("*") || oldMobile.equals(newMobile)) { return false; } + String maskedOldPhone = DesensitizedUtil.mobilePhone(oldMobile); + String maskedNewPhone = DesensitizedUtil.mobilePhone(newMobile); DriverSyncMobileDTO param = new DriverSyncMobileDTO(); param.setTenantId(tenant); param.setMpUserId(userId); @@ -72,24 +75,24 @@ public class MpUserPhoneChangeSupport { R result = driverClient.syncMobile(param); if (result == null || !result.isSuccess()) { log.error("小程序用户改号:账号已更新,司机档案同步调用失败。tenantId={} mpUserId={} oldPhone={} newPhone={} reason={}", - tenant, userId, oldMobile, newMobile, result == null ? "无响应" : result.getMsg()); + tenant, userId, maskedOldPhone, maskedNewPhone, result == null ? "无响应" : result.getMsg()); return false; } DriverSyncMobileVO data = result.getData(); if (data == null) { log.error("小程序用户改号:账号已更新,司机档案同步无返回数据。tenantId={} mpUserId={} oldPhone={} newPhone={}", - tenant, userId, oldMobile, newMobile); + tenant, userId, maskedOldPhone, maskedNewPhone); return false; } if (!Boolean.TRUE.equals(data.getSynced())) { log.warn("小程序用户改号:账号已更新,司机档案未同步。tenantId={} mpUserId={} oldPhone={} newPhone={} driverId={} reason={}", - tenant, userId, oldMobile, newMobile, data.getDriverId(), data.getMessage()); + tenant, userId, maskedOldPhone, maskedNewPhone, data.getDriverId(), data.getMessage()); return false; } return true; } catch (Exception exception) { log.error("小程序用户改号:账号已更新,司机档案同步异常。tenantId={} mpUserId={} oldPhone={} newPhone={}", - tenant, userId, oldMobile, newMobile, exception); + tenant, userId, maskedOldPhone, maskedNewPhone, exception); return false; } } diff --git a/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java index daa5c803..31298ccf 100644 --- a/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java +++ b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java @@ -324,7 +324,7 @@ class MpUserServiceImplTest { assertTrue(scope.isAllDept()); } - // ------------------------------------------------------------ 改号三处一致(票 05 场景 6) + // ------------------------------------------------------------ 改号三处一致 @Test @DisplayName("后台改号(account 就是旧号):手机号、登录账号、司机档案电话三处一致") diff --git a/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/UserPhoneServiceImplTest.java b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/UserPhoneServiceImplTest.java index 85fdfc14..80b289c1 100644 --- a/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/UserPhoneServiceImplTest.java +++ b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/UserPhoneServiceImplTest.java @@ -28,7 +28,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * App 内改号(票 05 场景 6 的小程序侧入口)。 + * App 内改号(小程序侧改号入口)。 *

* 只验证「交给 updateUser 之前」的三处一致性判据:手机号是新号、账号是否跟随取库里的旧号、 * 以及必须走带司机档案同步的更新入口(否则档案电话会停在旧号)。