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

- 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
@@ -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()));
}
}
@@ -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);
/**
* 修改状态
*
@@ -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 有全局唯一索引,故不叠加租户条件
*/
@@ -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
}
@@ -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;
}
}
@@ -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);
}
}