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 之前」的三处一致性判据:手机号是新号、账号是否跟随取库里的旧号、
* 以及必须走带司机档案同步的更新入口(否则档案电话会停在旧号)。