🐛 后台改手机号同步登录账号与司机档案电话
- 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 属于其它账号
This commit is contained in:
+16
@@ -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<DriverEnsureProfileVO> ensureProfile(@RequestBody DriverEnsureProfileDTO param);
|
||||
|
||||
/**
|
||||
* 账号改手机号后同步司机档案的 mobile
|
||||
* <p>
|
||||
* 幂等入口:档案 mobile 已是新号时返回成功且不写库。只在档案属于该账号
|
||||
* (user_id + tenant_id 命中)且档案 mobile 等于 oldPhone 时改这一列;
|
||||
* 不满足时不动档案,通过返回值的 message 说明原因,不抛异常给调用方的账号更新。
|
||||
*
|
||||
* @param param 租户、小程序账号、账号旧号与新号
|
||||
* @return 档案ID与本次是否同步
|
||||
*/
|
||||
@PostMapping(SYNC_MOBILE)
|
||||
R<DriverSyncMobileVO> syncMobile(@RequestBody DriverSyncMobileDTO param);
|
||||
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* 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;
|
||||
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* 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;
|
||||
}
|
||||
|
||||
}
|
||||
+7
@@ -103,6 +103,13 @@ public interface IMpUserService extends BaseService<MpUser> {
|
||||
*/
|
||||
boolean updateUser(MpUser user);
|
||||
|
||||
/**
|
||||
* 更新小程序用户,并在手机号变化时同步司机档案的 mobile
|
||||
* <p>
|
||||
* 仅供后台编辑与 App 内改号两个入口使用;档案同步失败不影响本次更新结果。
|
||||
*/
|
||||
boolean updateUserWithPhoneSync(MpUser user);
|
||||
|
||||
/**
|
||||
* 新增或修改(后台管理)
|
||||
*/
|
||||
|
||||
+44
-15
@@ -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<MpUserMapper, MpUser> 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<MpUserMapper, MpUser> 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<MpUserMapper, MpUser> 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<MpUserMapper, MpUser> 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<MpUserMapper, MpUser> 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<Long> ids) {
|
||||
@@ -439,20 +481,7 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> 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.<MpUser>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);
|
||||
|
||||
+2
-1
@@ -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("手机号修改失败");
|
||||
}
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.system.support;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 「改手机号时登录账号该不该跟着改」的判定
|
||||
* <p>
|
||||
* 纯 POJO:不读写库、不依赖 Spring 容器,判定全部以 trim 后比较。
|
||||
* 判据只有一条事实基准:库里的 account 是否等于旧手机号——自定义登录名不参与改号。
|
||||
* <p>
|
||||
* 另外识别前端回传的脱敏值(如 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 本次更新应落库的账号
|
||||
* <p>
|
||||
* 管理员显式提交了另一个账号名时按提交值(前端在账号为空时会把手机号回填进 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;
|
||||
}
|
||||
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 改号后同步司机档案手机号
|
||||
* <p>
|
||||
* 只在后台编辑与 App 内改号两个入口调用:登录链路上 transport 会回调本服务的 updateUser,
|
||||
* 若那里再回调用 transport,会形成 transport(持 driver 行锁) → system → transport 的嵌套更新,
|
||||
* 内层等同一行的锁直到超时。
|
||||
* <p>
|
||||
* 档案同步是账号更新之后的补偿动作:任何失败只记日志、不抛异常,账号更新照常提交。
|
||||
* 调用方据此能看出「账号改了、档案没改」。
|
||||
*
|
||||
* @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<DriverSyncMobileVO> 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+195
-1
@@ -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<MpUser> 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<MpUser> 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<MpUser> 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<MpUser> 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<MpUser> 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<MpUserCache> 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<MpUserCache> 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 掉
|
||||
*/
|
||||
|
||||
+152
@@ -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 的小程序侧入口)。
|
||||
* <p>
|
||||
* 只验证「交给 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<MpUser> 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<MpUser> 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<MpUserCache> ignoredCache = mockStatic(MpUserCache.class);
|
||||
MockedStatic<AuthUtil> ignoredAuth = mockStatic(AuthUtil.class);
|
||||
MockedStatic<MpUserAuth> 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;
|
||||
}
|
||||
|
||||
}
|
||||
+108
@@ -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 改号时账号该不该跟着改」的判据测试。
|
||||
* <p>
|
||||
* 纯函数,不起 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());
|
||||
}
|
||||
|
||||
}
|
||||
+111
@@ -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;
|
||||
|
||||
/**
|
||||
* 改号后同步司机档案手机号的出口测试。
|
||||
* <p>
|
||||
* 这里只测「调用与失败处理」: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<DriverSyncMobileDTO> 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());
|
||||
}
|
||||
|
||||
}
|
||||
+12
@@ -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<DriverSyncMobileVO> syncMobile(@RequestBody DriverSyncMobileDTO param) {
|
||||
if (param == null) {
|
||||
return R.fail("司机档案参数不能为空");
|
||||
}
|
||||
return R.data(driverService.syncMobile(param.getTenantId(), param.getMpUserId(),
|
||||
param.getOldPhone(), param.getNewPhone()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+16
@@ -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<Driver> {
|
||||
*/
|
||||
DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone);
|
||||
|
||||
/**
|
||||
* 账号改号后同步司机档案手机号
|
||||
* <p>
|
||||
* 幂等入口:只在档案属于该账号(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);
|
||||
|
||||
/**
|
||||
* 修改状态
|
||||
*
|
||||
|
||||
+60
@@ -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<DriverMapper, Driver> 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 有全局唯一索引,故不叠加租户条件
|
||||
*/
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* 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
|
||||
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 「账号改号后是否同步司机档案 mobile」的判据
|
||||
* <p>
|
||||
* 纯函数:不读写库、不依赖 Spring 容器。Service 只负责「查档案 → decide → 按结果写库」,
|
||||
* 判据本身可以直接列真值表测试。
|
||||
* <p>
|
||||
* 全部以 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;
|
||||
}
|
||||
|
||||
}
|
||||
+197
@@ -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 的测试。
|
||||
* <p>
|
||||
* 守住四条不变式:只改 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<Driver> 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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user