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] =?UTF-8?q?:bug:=20=E5=90=8E=E5=8F=B0=E6=94=B9=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E5=8F=B7=E5=90=8C=E6=AD=A5=E7=99=BB=E5=BD=95=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E4=B8=8E=E5=8F=B8=E6=9C=BA=E6=A1=A3=E6=A1=88=E7=94=B5?= =?UTF-8?q?=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); + } + +}