🐛 后台改手机号同步登录账号与司机档案电话

- 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:
2026-10-26 15:19:33 +08:00
parent ddf0d7b2cf
commit 5cc5dbb002
18 changed files with 1433 additions and 17 deletions
@@ -103,6 +103,13 @@ public interface IMpUserService extends BaseService<MpUser> {
*/
boolean updateUser(MpUser user);
/**
* 更新小程序用户,并在手机号变化时同步司机档案的 mobile
* <p>
* 仅供后台编辑与 App 内改号两个入口使用;档案同步失败不影响本次更新结果。
*/
boolean updateUserWithPhoneSync(MpUser user);
/**
* 新增或修改(后台管理)
*/
@@ -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);
@@ -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("手机号修改失败");
}
@@ -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;
}
}
@@ -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;
}
}
}