🔀 合并票 08:司机端档案解析收口到 user_id

This commit is contained in:
2026-10-27 22:37:08 +08:00
4 changed files with 91 additions and 136 deletions
@@ -54,9 +54,10 @@ public class DriverAppController extends BladeController {
@GetMapping("/mine")
@ApiOperationSupport(order = 1)
@Operation(summary = "当前登录司机档案", description = "按手机号匹配 blade_transport_driver.mobile;可传 mobile,未传则从登录态解析")
@Operation(summary = "当前登录司机档案", description = "按登录用户的 user_id 匹配 blade_transport_driver.user_id")
// mobile:旧版小程序仍会带这个参数,已不参与解析,保留只为不让老客户端报错
public R<DriverVO> mine(@RequestParam(required = false) String mobile) {
return R.data(driverAppService.currentByPhone(mobile));
return R.data(driverAppService.current());
}
@GetMapping("/vehicles")
@@ -34,11 +34,11 @@ import java.util.List;
public interface IDriverAppService {
/**
* 当前登录用户对应的司机档案(按手机号匹配 blade_transport_driver.mobile)
* 当前登录用户对应的司机档案(按 user_id 匹配 blade_transport_driver.user_id)
*
* @param mobile 小程序可显式传入当前用户手机号;为空时从登录态解析
* @return 司机档案;未登录抛异常,查不到返回 null
*/
DriverVO currentByPhone(String mobile);
DriverVO current();
/**
* 当前司机绑定的车辆列表(driver.driving_vehicle ↔ vehicle.plate_no)
@@ -49,10 +49,9 @@ public interface IDriverAppService {
* 当前登录用户对应的司机**实体**
* <p>
* 认证提交要写回司机表,且越权校验要读 {@code driving_vehicle},
* 故这里返回实体而非视图对象。解析顺序:先按登录手机号匹配,
* 匹配不到再按 {@code user_id} 兜底。
* 故这里返回实体而非视图对象。解析口径与 {@link #current()} 一致。
*
* @return 司机实体;未登录或查不到时返回 null
* @return 司机实体;未登录抛异常,查不到返回 null
*/
Driver currentDriverEntity();
@@ -27,10 +27,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.DriverVehicleCardVO;
@@ -57,17 +54,16 @@ public class DriverAppServiceImpl implements IDriverAppService {
private final IDriverService driverService;
private final ITransportVehicleService transportVehicleService;
private final IMpUserClient mpUserClient;
@Override
public DriverVO currentByPhone(String mobile) {
Driver driver = currentDriver(mobile);
public DriverVO current() {
Driver driver = currentDriver();
return driver == null ? null : DriverWrapper.build().entityVO(driver);
}
@Override
public List<DriverVehicleCardVO> myVehicles() {
Driver driver = currentDriver(null);
Driver driver = currentDriver();
if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) {
return List.of();
}
@@ -92,28 +88,23 @@ public class DriverAppServiceImpl implements IDriverAppService {
@Override
public Driver currentDriverEntity() {
return currentDriver(null);
return currentDriver();
}
private Driver currentDriver(String mobileHint) {
/**
* 当前司机的唯一解析入口:只按登录用户的 {@code user_id} 查档案。
* <p>
* 手机号不能用来认人:同一号码可能存在多条档案,按号码查「最近更新的一条」
* 取到的可能是别人的档案;号码本身也会被改。
*/
private Driver currentDriver() {
Long userId = AuthUtil.getUserId();
if (userId == null || userId <= 0) {
throw new ServiceException("未登录");
}
String phone = resolvePhone(userId, mobileHint);
Driver driver = null;
if (Func.isNotEmpty(phone)) {
driver = driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getMobile, phone)
.orderByDesc(Driver::getUpdateTime)
.last("LIMIT 1"));
}
if (driver == null) {
driver = driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getUserId, userId)
.last("LIMIT 1"));
}
return driver;
return driverService.getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getUserId, userId)
.last("LIMIT 1"));
}
private DriverVehicleCardVO toCard(TransportVehicle vehicle) {
@@ -178,53 +169,4 @@ public class DriverAppServiceImpl implements IDriverAppService {
return date == null ? "" : date.toString();
}
/**
* 解析用于匹配司机档案的手机号。
* 优先级:前端传入且与本人一致的 mobile → JWT account(司机账号多为手机号)→ 用户中心 phone/account
*/
private String resolvePhone(Long userId, String mobileHint) {
String selfPhone = resolveSelfPhone(userId);
String hint = Func.isEmpty(mobileHint) ? null : mobileHint.trim();
if (Func.isNotEmpty(hint)) {
if (Func.isEmpty(selfPhone)) {
// 解析不出本人号码时不能拿前端传的 mobile 去查:
// currentDriver 会直接按 mobile 等值查询,传入他人号码即可读到他人档案。
// 这里返回空让它退回按 user_id 查,宁可查不到也不越权。
return null;
}
if (!selfPhone.equals(hint)) {
throw new ServiceException("只能查询本人司机档案");
}
return hint;
}
return selfPhone;
}
private String resolveSelfPhone(Long userId) {
String account = AuthUtil.getUserAccount();
if (isMobileLike(account)) {
return account.trim();
}
// 小程序用户 phone 带 @Sensitive(MOBILE),经 Feign 回来是脱敏串,
// 因此这里只取未脱敏的 account;脱敏的 phone 一律不用。
R<MpUser> result = mpUserClient.userById(userId);
if (result != null && R.isSuccess(result) && result.getData() != null) {
String userAccount = result.getData().getAccount();
if (isMobileLike(userAccount)) {
return userAccount.trim();
}
}
return Func.isEmpty(account) ? null : account.trim();
}
/**
* 是否是「可作为司机档案手机号使用」的号码串。
* <p>
* 只判 1 开头的 11~15 位纯数字:库里既有 11 位真实手机号,也有 12 位账号
* (如 175874212082),写死 11 位会让这类账号的司机解析不出本人号码。
*/
private boolean isMobileLike(String value) {
return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$");
}
}
@@ -1,15 +1,16 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.api.R;
import org.springblade.system.feign.IMpUserClient;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.DriverVehicleCardVO;
@@ -17,19 +18,23 @@ import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.ITransportVehicleService;
import java.time.LocalDate;
import java.util.Date;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
/**
* 司机端车辆卡片(重新认证表单的数据来源)测试。
* 司机端「当前司机」解析与车辆卡片(重新认证表单的数据来源)测试。
* <p>
* 卡片字段会**直接进表单**,所以这里盯的不是「显示得好不好看」,而是两条会让回显出错的契约:
* 解析侧盯住 ADR-0028 的唯一入口:登录用户 → {@code user_id} → 档案,手机号只是档案上的一个字段,
* 不参与查找;卡片字段会**直接进表单**,所以这里盯的不是「显示得好不好看」,而是两条会让回显出错的契约:
* 日期字段只能是 {@code yyyy-MM-dd} 或空串(不能是「长期」这类文字);
* 标了长期有效的字段不能再下发一个作废的止期。
*/
@@ -42,11 +47,17 @@ class DriverAppServiceImplTest {
private ITransportVehicleService transportVehicleService;
private DriverAppServiceImpl service;
@BeforeAll
static void initTableInfo() {
// 实体元数据就绪后,LambdaQueryWrapper 才能把 Driver::getUserId 解析成列名
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Driver.class);
}
@BeforeEach
void setUp() {
driverService = mock(IDriverService.class);
transportVehicleService = mock(ITransportVehicleService.class);
service = new DriverAppServiceImpl(driverService, transportVehicleService, mock(IMpUserClient.class));
service = new DriverAppServiceImpl(driverService, transportVehicleService);
}
@Test
@@ -85,33 +96,69 @@ class DriverAppServiceImplTest {
assertEquals("2028-12-04", card.getRoadTransportCertStartDate());
}
// ---------------------------------------------------------------- 手机号解析
// ---------------------------------------------------------------- 当前司机解析
@Test
@DisplayName("12 位手机号也能解析出本人号码——库里存在 12 位司机账号,写死 11 位会让他们查不到自己的档案")
void twelveDigitAccountIsUsableAsPhone() {
assertEquals("黄大富", currentByPhone("175874212082", "175874212082", null).getDriverName());
@DisplayName("同号两条档案时取登录人自己那条——按手机号取「最近更新的一条」会把冒名者当成当前司机")
void currentDriverComesFromUserIdNotMobile() {
Driver owner = driver(9001L, USER_ID, "黄大富", MOBILE);
owner.setUpdateTime(new Date(1735689600000L));
Driver impostor = driver(9002L, 7002L, "冒名者", MOBILE);
impostor.setUpdateTime(new Date(1767225600000L));
when(driverService.getOne(any(LambdaQueryWrapper.class))).thenAnswer(invocation -> {
LambdaQueryWrapper<Driver> wrapper = invocation.getArgument(0);
String sqlSegment = wrapper.getSqlSegment();
if (sqlSegment.contains("mobile")) {
return impostor;
}
return sqlSegment.contains("user_id") ? owner : null;
});
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
// 司机账号本身就是手机号:手机号优先的实现正是靠它命中那条同号档案
authUtil.when(AuthUtil::getUserAccount).thenReturn(MOBILE);
assertEquals("黄大富", service.current().getDriverName());
}
}
@Test
@DisplayName("小程序用户 phone 是脱敏串时改用其 account——脱敏 phone 既查不到档案,也会把本人真实号码误判成越权")
void maskedUserPhoneFallsBackToAccount() {
MpUser user = new MpUser();
user.setPhone("175****12082");
user.setAccount("175874212082");
@DisplayName("没有档案时返回空数据而不是异常——没档案的登录用户看到的是空页面、也不能操作")
void missingProfileReturnsEmptyInsteadOfFailing() {
when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(null);
assertEquals("黄大富", currentByPhone(null, null, user).getDriverName());
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
assertNull(service.current(), "查不到档案时档案为空");
assertNull(service.currentDriverEntity(), "查不到档案时实体为空");
assertTrue(service.myVehicles().isEmpty(), "查不到档案时车辆列表为空");
}
}
@Test
@DisplayName("传他人手机号一律拒绝——不能因为解析不到本人号码就放行,那等于允许按手机号查任意档案")
void otherPeoplesMobileIsRejected() {
assertEquals("只能查询本人司机档案", assertThrows(ServiceException.class,
() -> currentByPhone("13900000001", "175874212082", null)).getMessage());
@DisplayName("未登录直接抛异常,不能当成「没有档案」静默返回空")
void anonymousIsRejected() {
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(null);
assertEquals("未登录", assertThrows(ServiceException.class,
() -> service.current()).getMessage());
assertEquals("未登录", assertThrows(ServiceException.class, service::myVehicles).getMessage());
assertEquals("未登录", assertThrows(ServiceException.class, service::currentDriverEntity).getMessage());
}
}
// ---------------------------------------------------------------- 辅助
private Driver driver(Long id, Long userId, String driverName, String mobile) {
Driver driver = new Driver();
driver.setId(id);
driver.setUserId(userId);
driver.setDriverName(driverName);
driver.setMobile(mobile);
return driver;
}
/** 造一辆只关心有效期三个字段的车 */
private TransportVehicle vehicle(LocalDate licenseStart, LocalDate licenseEnd, Integer licenseLongTerm) {
TransportVehicle vehicle = new TransportVehicle();
@@ -129,13 +176,9 @@ class DriverAppServiceImplTest {
return vehicle;
}
/** 按当前登录司机的手机号跑一次「我的车辆」,取第一张卡片 */
/** 按当前登录司机跑一次「我的车辆」,取第一张卡片 */
private DriverVehicleCardVO firstCard(TransportVehicle vehicle) {
Driver driver = new Driver();
driver.setId(9001L);
driver.setUserId(USER_ID);
driver.setDriverName("韦司机");
driver.setMobile(MOBILE);
Driver driver = driver(9001L, USER_ID, "韦司机", MOBILE);
driver.setDrivingVehicle(vehicle.getPlateNo());
when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver);
@@ -143,39 +186,9 @@ class DriverAppServiceImplTest {
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
// 账号本身就是手机号,resolvePhone 走这条短路分支,不再回调用户中心
authUtil.when(AuthUtil::getUserAccount).thenReturn(MOBILE);
List<DriverVehicleCardVO> cards = service.myVehicles();
assertEquals(1, cards.size(), "应当只返回绑定到本人的这一辆车");
return cards.get(0);
}
}
/**
* 按给定的登录态跑一次 currentByPhone。
*
* @param mobileHint 前端传入的 mobile,可为 null
* @param account JWT 里的账号,可为 null
* @param user 小程序用户,可为 null(表示走不到小程序用户中心)
*/
private Driver currentByPhone(String mobileHint, String account, MpUser user) {
IMpUserClient mpUserClient = mock(IMpUserClient.class);
if (user != null) {
when(mpUserClient.userById(USER_ID)).thenReturn(R.data(user));
}
DriverAppServiceImpl scoped = new DriverAppServiceImpl(driverService, transportVehicleService, mpUserClient);
Driver driver = new Driver();
driver.setId(9002L);
driver.setUserId(USER_ID);
driver.setDriverName("黄大富");
driver.setMobile("175874212082");
when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver);
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
authUtil.when(AuthUtil::getUserAccount).thenReturn(account);
return scoped.currentByPhone(mobileHint);
}
}
}