+ * 按请求来源分流:小程序(User-Type: mp)登录时账号不存在要自动建,Web 短信登录不建号。 + * {@code BladeUserDetailService.loadByPhone} 同时被「发送短信验证码」复用,其中不得建号。 * * @author BladeX */ @@ -56,12 +61,14 @@ public class SmsTokenGranter extends AbstractTokenGranter { private final OAuth2UserService userService; private final ISmsClient smsClient; private final OAuth2Properties properties; + private final MpLoginSupport mpLoginSupport; - public SmsTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, ISmsClient smsClient, OAuth2Properties properties) { + public SmsTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, ISmsClient smsClient, OAuth2Properties properties, MpLoginSupport mpLoginSupport) { super(clientService, userService, passwordHandler); this.userService = userService; this.smsClient = smsClient; this.properties = properties; + this.mpLoginSupport = mpLoginSupport; } @Override @@ -86,8 +93,10 @@ public class SmsTokenGranter extends AbstractTokenGranter { if (!result.isSuccess()) { throw new UserInvalidException(OAuth2TokenConstant.CAPTCHA_NOT_CORRECT); } - // 获取用户信息 - OAuth2User user = userService.loadByPhone(decryptedPhone, request); + // 获取用户信息:小程序登录走自动建号编排,Web 短信登录只做查询 + OAuth2User user = MpLoginRequestSupport.isMp(request) + ? mpLoginSupport.ensureLoginAccount(tenantId, decryptedPhone, null) + : userService.loadByPhone(decryptedPhone, request); // 校验用户信息 if (!userService.validateUser(user)) { throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT); diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java index 33de5291..9d2bb75c 100644 --- a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java +++ b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java @@ -2,7 +2,7 @@ package org.springblade.auth.granter; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; -import org.springblade.auth.utils.TokenUtil; +import org.springblade.auth.support.MpLoginSupport; import org.springblade.core.oauth2.exception.UserInvalidException; import org.springblade.core.oauth2.granter.AbstractTokenGranter; import org.springblade.core.oauth2.handler.PasswordHandler; @@ -11,12 +11,9 @@ import org.springblade.core.oauth2.service.OAuth2Client; import org.springblade.core.oauth2.service.OAuth2ClientService; import org.springblade.core.oauth2.service.OAuth2User; import org.springblade.core.oauth2.service.OAuth2UserService; -import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; import org.springblade.core.tool.utils.WebUtil; -import org.springblade.system.feign.IMpUserClient; -import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.thirdparty.wechat.constant.WechatMiniConstant; import org.springblade.thirdparty.wechat.exception.WechatMiniException; import org.springblade.thirdparty.wechat.pojo.vo.WechatPhoneVO; @@ -28,8 +25,8 @@ import org.springframework.stereotype.Component; * 微信小程序手机号一键登录。 *
* grant_type=wechat_applet,参数:loginCode(wx.login)、phoneCode(getPhoneNumber)。
- * 流程:换 openid + 手机号 → 按手机号查小程序用户(blade_mp_user,不存在则拒绝)
- * → 记录 openid → 按身份发令牌(不依赖系统角色)。
+ * 流程:换 openid + 手机号 → 由 {@link MpLoginSupport} 找到或创建账号并补齐档案
+ * → 按身份发令牌(不依赖系统角色)。
*/
@Slf4j
@Component
@@ -37,17 +34,17 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter {
private final OAuth2UserService userService;
private final IWechatMiniService wechatMiniService;
- private final IMpUserClient mpUserClient;
+ private final MpLoginSupport mpLoginSupport;
public WechatMiniTokenGranter(OAuth2ClientService clientService,
OAuth2UserService userService,
PasswordHandler passwordHandler,
IWechatMiniService wechatMiniService,
- IMpUserClient mpUserClient) {
+ MpLoginSupport mpLoginSupport) {
super(clientService, userService, passwordHandler);
this.userService = userService;
this.wechatMiniService = wechatMiniService;
- this.mpUserClient = mpUserClient;
+ this.mpLoginSupport = mpLoginSupport;
}
@Override
@@ -82,28 +79,12 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter {
throw new UserInvalidException("未获取到微信手机号");
}
- R
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.auth.support;
+
+import org.springblade.core.oauth2.provider.OAuth2Request;
+import org.springblade.core.tool.utils.Func;
+import org.springblade.system.pojo.enums.MpUserIdentity;
+
+/**
+ * 登录请求来源判定
+ *
+ * @author Chill
+ */
+public final class MpLoginRequestSupport {
+
+ private MpLoginRequestSupport() {
+ }
+
+ /**
+ * 是否小程序请求:请求头 User-Type 为 mp
+ *
+ * Web 与小程序共用同一个客户端,来源只认请求头 User-Type。
+ *
+ * @param request 登录请求,可为 null
+ * @return 请求头 User-Type 为 mp 时返回 true
+ */
+ public static boolean isMp(OAuth2Request request) {
+ return request != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(request.getUserType()));
+ }
+
+}
diff --git a/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java
new file mode 100644
index 00000000..1a48b334
--- /dev/null
+++ b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java
@@ -0,0 +1,335 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.auth.support;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.auth.utils.TokenUtil;
+import org.springblade.core.oauth2.exception.UserInvalidException;
+import org.springblade.core.oauth2.service.OAuth2User;
+import org.springblade.core.tool.api.R;
+import org.springblade.core.tool.support.Kv;
+import org.springblade.core.tool.utils.Func;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.system.feign.IMpUserClient;
+import org.springblade.system.pojo.dto.MpUserEnsureDTO;
+import org.springblade.system.pojo.entity.MpUser;
+import org.springblade.system.pojo.entity.MpUserInfo;
+import org.springblade.system.pojo.enums.MpUserIdentity;
+import org.springblade.transport.feign.IDriverClient;
+import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO;
+import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
+import org.springframework.stereotype.Component;
+
+import java.util.Objects;
+
+/**
+ * 小程序登录编排:按 openid / 手机号找到或创建账号,并补齐司机档案与微信绑定。
+ *
+ * 只有「建号失败」会让登录失败;建档与绑 openid 失败只记 WARN,不影响本次登录。
+ * 本类只供登录链路调用。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class MpLoginSupport {
+
+ /**
+ * detail 中承载登录提示的键,前端从令牌的 detail 里读取
+ */
+ public static final String DETAIL_LOGIN_NOTICE = "loginNotice";
+
+ /**
+ * 换号撞键提示:openid 属于旧账号、新手机号已属于另一账号
+ */
+ private static final String NOTICE_PHONE_OCCUPIED = "该微信已绑定其他账号,请联系管理员";
+
+ private final IMpUserClient mpUserClient;
+ private final IDriverClient driverClient;
+
+ /**
+ * 找到或创建本次登录使用的账号,并补齐档案与 openid 绑定。
+ *
+ * 返回 {@link OAuth2User}:两条登录链路都消费 OAuth2User,而 MpUserInfo
+ * (identities / permissions / detail)只在 system 侧组装。
+ *
+ * @param tenantId 请求租户,为空时由 system 侧兜到默认租户
+ * @param phone 本次登录的手机号
+ * @param openid 微信小程序 openid,短信登录传 null
+ * @return 通过用户校验之前的登录用户(身份校验与 setClient 由调用方完成)
+ */
+ public OAuth2User ensureLoginAccount(String tenantId, String phone, String openid) {
+ if (StringUtil.isBlank(phone)) {
+ throw new UserInvalidException("未获取到手机号,无法登录");
+ }
+ MpUser account;
+ String loginNotice = null;
+ // openid 已属于另一个账号:本次不迁移、也不再尝试绑定(与建档冲突提示区分开)
+ boolean openidOccupied = false;
+ // ① openid 命中即复用:openid 是稳定标识,手机号会被运营商二次放号
+ MpUser openidAccount = findByOpenid(tenantId, openid);
+ if (openidAccount == null) {
+ // ② 未按 openid 命中:按手机号查找或创建(登录路径专用,已存在不抛异常)
+ account = ensureUser(tenantId, phone, openid);
+ } else {
+ account = openidAccount;
+ if (!samePhone(openidAccount.getPhone(), phone)) {
+ MpUser phoneAccount = findByPhone(tenantId, phone);
+ if (phoneAccount != null && !Objects.equals(phoneAccount.getId(), openidAccount.getId())) {
+ // ①-撞键:落到手机号账号,不迁移 openid
+ account = phoneAccount;
+ loginNotice = NOTICE_PHONE_OCCUPIED;
+ openidOccupied = true;
+ log.warn("微信换号撞键,登录落到手机号账号:租户={} openid账号={} 手机号账号={}",
+ tenantId, openidAccount.getId(), phoneAccount.getId());
+ } else {
+ account = movePhone(openidAccount, phone);
+ if (account == null) {
+ // 更新手机号失败:并发时可能是手机号刚被别的账号占用,重查一次再决定落点
+ MpUser concurrent = findByPhone(tenantId, phone);
+ if (concurrent != null && !Objects.equals(concurrent.getId(), openidAccount.getId())) {
+ account = concurrent;
+ loginNotice = NOTICE_PHONE_OCCUPIED;
+ openidOccupied = true;
+ log.warn("更新手机号失败后命中手机号账号:租户={} openid账号={} 手机号账号={}",
+ tenantId, openidAccount.getId(), concurrent.getId());
+ } else {
+ // 仍然用 openid 账号登录:手机号未能更新不影响身份识别,下次登录再试
+ account = openidAccount;
+ }
+ }
+ }
+ }
+ }
+ // ③ 身份含 driver 才建档;纯 admin 不建
+ String profileNotice = ensureProfile(account, phone, tenantId);
+ if (StringUtil.isBlank(loginNotice)) {
+ loginNotice = profileNotice;
+ }
+ // ④ 微信登录补绑 openid;失败只 WARN
+ bindOpenid(account, openid, openidOccupied);
+ MpUserInfo userInfo = loadUserInfo(account);
+ attachNotice(userInfo, loginNotice);
+ return TokenUtil.convertMpUser(userInfo);
+ }
+
+ private MpUser findByOpenid(String tenantId, String openid) {
+ if (StringUtil.isBlank(openid)) {
+ return null;
+ }
+ try {
+ R
+ * 只 patch phone:account 是登录名,不随手机号变;status 必须带原值,
+ * system 侧 updateUser 的 prepare 会把空 status 重置成启用。
+ *
+ * @return 更新成功后的账号;失败返回 null,不抛异常
+ */
+ private MpUser movePhone(MpUser account, String phone) {
+ MpUser patch = new MpUser();
+ patch.setId(account.getId());
+ patch.setTenantId(account.getTenantId());
+ patch.setPhone(phone);
+ patch.setAccount(account.getAccount());
+ patch.setIdentities(account.getIdentities());
+ patch.setStatus(account.getStatus());
+ try {
+ R
+ * 保住发码端点的分流口径:小程序端(User-Type: mp)对未注册手机号也发码,
+ * Web 端仍拒绝。
+ * 同时保住「发码不建号」:mp 分支根本不查账号。
+ */
+class Oauth2SmsEndpointTest {
+
+ private static final String TENANT_ID = "000000";
+ private static final String PHONE = "13800000001";
+ private static final String SMS_CODE = "ali_reg";
+
+ private static String publicKey;
+ private static String privateKey;
+
+ private ISmsClient smsClient;
+ private OAuth2UserService userService;
+ private Oauth2SmsEndpoint endpoint;
+
+ @BeforeAll
+ static void initSm2Keys() {
+ AsymmetricCipherKeyPair keyPair = SM2Util.generateKeyPair();
+ publicKey = SM2Util.getPublicKeyString(keyPair);
+ privateKey = SM2Util.getPrivateKeyString(keyPair);
+ }
+
+ @BeforeEach
+ void setUp() {
+ OAuth2Properties properties = new OAuth2Properties();
+ properties.setPublicKey(publicKey);
+ properties.setPrivateKey(privateKey);
+ smsClient = mock(ISmsClient.class);
+ userService = mock(OAuth2UserService.class);
+ endpoint = new Oauth2SmsEndpoint(smsClient, userService, properties);
+ when(smsClient.sendValidate(anyString(), anyString(), anyString())).thenReturn(R.data("sms-id-1"));
+ }
+
+ @AfterEach
+ void clearRequestContext() {
+ RequestContextHolder.resetRequestAttributes();
+ }
+
+ @Test
+ @DisplayName("小程序 + 未注册手机号:发码成功,且完全不查账号(发码不建号)")
+ void mpUnregisteredPhoneSendsCode() {
+ bindRequest("mp");
+ when(userService.loadByPhone(anyString(), any())).thenReturn(null);
+
+ R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE);
+
+ assertTrue(result.isSuccess());
+ verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE);
+ verify(userService, never()).loadByPhone(anyString(), any());
+ }
+
+ @Test
+ @DisplayName("Web + 未注册手机号:仍返回手机号不存在,不发码")
+ void webUnregisteredPhoneIsRejected() {
+ bindRequest(null);
+ when(userService.loadByPhone(anyString(), any())).thenReturn(null);
+
+ R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE);
+
+ assertFalse(result.isSuccess());
+ assertEquals(USER_PHONE_NOT_FOUND, result.getMsg());
+ verify(smsClient, never()).sendValidate(anyString(), anyString(), anyString());
+ }
+
+ @Test
+ @DisplayName("Web + 已注册手机号:行为与改造前一致,正常发码")
+ void webRegisteredPhoneSendsCode() {
+ bindRequest(null);
+ when(userService.loadByPhone(anyString(), any())).thenReturn(mock(OAuth2User.class));
+
+ R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE);
+
+ assertTrue(result.isSuccess());
+ verify(userService).loadByPhone(anyString(), any());
+ verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE);
+ }
+
+ @Test
+ @DisplayName("小程序 + 已注册手机号:跳过注册查询,依旧发码")
+ void mpRegisteredPhoneSendsCode() {
+ bindRequest("mp");
+
+ R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE);
+
+ assertTrue(result.isSuccess());
+ verify(userService, never()).loadByPhone(anyString(), any());
+ verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE);
+ }
+
+ @Test
+ @DisplayName("密文无法解密:无论哪端都不发码")
+ void invalidCipherIsRejected() {
+ bindRequest("mp");
+
+ R result = endpoint.sendValidate(TENANT_ID, "deadbeef", SMS_CODE);
+
+ assertFalse(result.isSuccess());
+ assertEquals(USER_PHONE_NOT_FOUND, result.getMsg());
+ verify(smsClient, never()).sendValidate(anyString(), anyString(), anyString());
+ }
+
+ /**
+ * 请求上下文决定 buildArgs() 读到什么头;Authorization 是 buildClientArgs 的前提
+ */
+ private void bindRequest(String userType) {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ String credential = Base64.getEncoder()
+ .encodeToString("saber:saber_secret".getBytes(StandardCharsets.UTF_8));
+ request.addHeader("Authorization", "Basic " + credential);
+ if (userType != null) {
+ request.addHeader("User-Type", userType);
+ }
+ RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+ }
+
+ private String encrypt(String plainPhone) {
+ return Hex.toHexString(SM2Util.encrypt(plainPhone, publicKey));
+ }
+
+}
diff --git a/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java b/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java
new file mode 100644
index 00000000..4518e344
--- /dev/null
+++ b/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java
@@ -0,0 +1,246 @@
+package org.springblade.auth.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.oauth2.exception.UserInvalidException;
+import org.springblade.core.oauth2.service.OAuth2User;
+import org.springblade.core.tool.api.R;
+import org.springblade.core.tool.support.Kv;
+import org.springblade.system.feign.IMpUserClient;
+import org.springblade.system.pojo.dto.MpUserEnsureDTO;
+import org.springblade.system.pojo.entity.MpUser;
+import org.springblade.system.pojo.entity.MpUserInfo;
+import org.springblade.transport.feign.IDriverClient;
+import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO;
+import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
+
+import java.util.List;
+
+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.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 登录编排测试。
+ *
+ * 保住三条不变式:建号失败才阻断登录;建档失败放行;
+ * openid 换号撞键时落到手机号账号且不迁移 openid。
+ */
+class MpLoginSupportTest {
+
+ private static final String TENANT_ID = "000000";
+ private static final String PHONE = "13800000001";
+ private static final String NEW_PHONE = "13800000002";
+ private static final String OPENID = "wx-openid-1";
+
+ private IMpUserClient mpUserClient;
+ private IDriverClient driverClient;
+ private MpLoginSupport mpLoginSupport;
+
+ @BeforeEach
+ void setUp() {
+ mpUserClient = mock(IMpUserClient.class);
+ driverClient = mock(IDriverClient.class);
+ mpLoginSupport = new MpLoginSupport(mpUserClient, driverClient);
+ }
+
+ @Test
+ @DisplayName("新手机号微信登录:建号 + 建档,登录成功且无提示")
+ void newPhoneCreatesAccountAndProfile() {
+ MpUser created = mpUser(11L, PHONE, OPENID, "driver");
+ when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
+ when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created));
+ when(mpUserClient.userInfoById(11L)).thenReturn(R.data(userInfo(created, "driver")));
+ when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
+
+ OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
+
+ assertNotNull(user);
+ assertEquals("11", user.getUserId());
+ assertNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE));
+ ArgumentCaptor
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.feign;
+
+import org.springblade.core.tool.api.R;
+import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO;
+import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+
+/**
+ * 司机档案 Feign 接口
+ *
+ * @author Chill
+ */
+@FeignClient(value = "blade-transport")
+public interface IDriverClient {
+
+ String API_PREFIX = "/feign/client/driver";
+ String ENSURE_PROFILE = API_PREFIX + "/ensure-profile";
+
+ /**
+ * 查找或创建司机档案,并把档案绑定到指定小程序账号
+ *
+ * 幂等入口:按 user_id 命中时原样返回;冲突(同号多档案 / 已被其他账号占用)
+ * 不新建、不覆盖,通过返回值的 message 说明原因,不抛异常给登录方。
+ *
+ * @param param 租户、小程序账号与手机号
+ * @return 档案ID与本次处置标志
+ */
+ @PostMapping(ENSURE_PROFILE)
+ R
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.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 DriverEnsureProfileDTO 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 = "手机号,仅在按账号未命中时用于查找已有档案")
+ private String phone;
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java
index fc7c3ce8..9c2007c1 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java
@@ -40,6 +40,7 @@ import org.springblade.transport.jackson.FlexibleLocalDateDeserializer;
import java.io.Serial;
import java.time.LocalDate;
+import java.time.LocalDateTime;
/**
* 司机管理实体类
@@ -223,6 +224,17 @@ public class Driver extends TenantEntity {
@Schema(description = "关联小程序用户ID")
@JsonSerialize(using = ToStringSerializer.class)
private Long userId;
+ /**
+ * 档案来源:login_auto(登录自动建)/ login_bound(绑到已有档案)/
+ * admin(后台建)/ cert_backfill(存量订正);NULL 表示来源未知
+ */
+ @Schema(description = "档案来源")
+ private String profileSource;
+ /**
+ * 档案与账号建立关联的时点
+ */
+ @Schema(description = "账号绑定时间")
+ private LocalDateTime accountBoundTime;
/**
* 与联系人关系
*/
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java
new file mode 100644
index 00000000..8859abad
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java
@@ -0,0 +1,58 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.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 DriverEnsureProfileVO implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "命中的司机档案ID,冲突时为null")
+ private Long driverId;
+
+ @Schema(description = "本次新建了档案")
+ private Boolean created;
+
+ @Schema(description = "本次把已有档案绑定到了账号")
+ private Boolean boundExisting;
+
+ @Schema(description = "冲突原因;成功时为null")
+ private String message;
+
+}
diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java
index 0a0a6e4b..b381750b 100644
--- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java
+++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java
@@ -24,6 +24,7 @@ package org.springblade.system.feign;
import org.springblade.core.launch.constant.AppConstant;
import org.springblade.core.tool.api.R;
+import org.springblade.system.pojo.dto.MpUserEnsureDTO;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.entity.MpUserInfo;
import org.springframework.cloud.openfeign.FeignClient;
@@ -46,6 +47,7 @@ public interface IMpUserClient {
String USER_BY_ID = API_PREFIX + "/user-by-id";
String USER_BY_PHONE = API_PREFIX + "/user-by-phone";
String USER_BY_ACCOUNT = API_PREFIX + "/user-by-account";
+ String USER_BY_OPENID = API_PREFIX + "/user-by-openid";
String USER_INFO_BY_ID = API_PREFIX + "/info-by-id";
String USER_INFO_BY_PHONE = API_PREFIX + "/info-by-phone";
String USER_INFO_BY_ACCOUNT = API_PREFIX + "/info-by-account";
@@ -53,6 +55,7 @@ public interface IMpUserClient {
String UPDATE_USER = API_PREFIX + "/update-user";
String BIND_OPENID = API_PREFIX + "/bind-openid";
String SET_PASSWORD = API_PREFIX + "/set-password";
+ String ENSURE_USER = API_PREFIX + "/ensure-user";
/**
* 按主键查询
@@ -72,6 +75,18 @@ public interface IMpUserClient {
@GetMapping(USER_BY_ACCOUNT)
R
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.system.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 MpUserEnsureDTO implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "租户ID,为空时依次取 Tenant-Id 请求头与默认租户")
+ private String tenantId;
+
+ @Schema(description = "手机号")
+ private String phone;
+
+ @Schema(description = "微信小程序openid,非空时随首次建号一并写入")
+ private String openid;
+
+}
diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java
index 4d8eabfe..fae7ebfa 100644
--- a/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java
+++ b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java
@@ -26,6 +26,7 @@ import io.swagger.v3.oas.annotations.Hidden;
import lombok.AllArgsConstructor;
import org.springblade.core.tenant.annotation.NonDS;
import org.springblade.core.tool.api.R;
+import org.springblade.system.pojo.dto.MpUserEnsureDTO;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.entity.MpUserInfo;
import org.springblade.system.service.IMpUserService;
@@ -65,6 +66,18 @@ public class MpUserClient implements IMpUserClient {
return R.data(mpUserService.getByAccount(tenantId, account));
}
+ @Override
+ @GetMapping(USER_BY_OPENID)
+ public R
+ * 与 {@link #submit} 的提交式语义相反:手机号已存在时直接返回已有账号,且不改其身份。
+ */
+ MpUser ensureUser(MpUserEnsureDTO param);
+
/**
* 按主键组装登录信息
*/
diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java
index 93f744ba..125b63ce 100644
--- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java
+++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java
@@ -24,7 +24,9 @@ package org.springblade.system.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springblade.common.utils.PasswordRuleUtil;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
@@ -34,8 +36,10 @@ import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.DigestUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.core.tool.utils.WebUtil;
import org.springblade.system.cache.MpUserCache;
import org.springblade.system.mapper.MpUserMapper;
+import org.springblade.system.pojo.dto.MpUserEnsureDTO;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.entity.MpUserInfo;
import org.springblade.system.pojo.enums.MpUserIdentity;
@@ -53,10 +57,21 @@ import java.util.Objects;
*
* @author Chill
*/
+@Slf4j
@Service
@RequiredArgsConstructor
public class MpUserServiceImpl extends BaseServiceImpl
+ * 请求头与入参不一致时以请求头为准。写库前必须得到非空值:唯一键 (tenant_id, phone)
+ * 对 NULL 不去重,租户为 NULL 会为同一手机号造出重复账号。
+ */
+ private String resolveEnsureTenantId(String tenantId) {
+ String resolved = requestHeaderTenantId();
+ if (resolved == null) {
+ resolved = trimToNull(tenantId);
+ }
+ if (resolved == null) {
+ resolved = DEFAULT_TENANT_ID;
+ }
+ return resolved;
+ }
+
+ /**
+ * 当前请求头里的租户;无请求上下文时返回 null
+ */
+ private String requestHeaderTenantId() {
+ HttpServletRequest request = WebUtil.getRequest();
+ if (request == null) {
+ return null;
+ }
+ return trimToNull(request.getHeader(TENANT_HEADER));
+ }
+
@Override
public MpUserInfo userInfo(Long userId) {
if (Func.isEmpty(userId)) {
@@ -236,6 +337,13 @@ public class MpUserServiceImpl extends BaseServiceImpl
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.feign;
+
+import io.swagger.v3.oas.annotations.Hidden;
+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.vo.DriverEnsureProfileVO;
+import org.springblade.transport.service.IDriverService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 司机档案 Feign 实现类
+ *
+ * @author Chill
+ */
+@NonDS
+@Hidden
+@RestController
+@AllArgsConstructor
+public class DriverClient implements IDriverClient {
+
+ private final IDriverService driverService;
+
+ @Override
+ @PostMapping(ENSURE_PROFILE)
+ public R
- * 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据,
- * 而 {@code blade_transport_driver.id_card_no} 上没有唯一索引:若申报的证件号
- * 已属于另一条档案,直接写会静默产生两条同证件号的档案,后续建档查重、
- * 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。
+ * 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据。
+ * {@code blade_transport_driver.id_card_no} 上有唯一索引
+ * {@code uk_transport_driver_id_card};证件号已属于另一条档案时,
+ * 此处阻断审核并整体回滚,交由管理端先处理档案归属。
*
* 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断;
* 快照没带证件号时无从判断,同样不阻断(历史快照就是这种)。
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java
index 825c3bc0..bd200eb8 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java
@@ -29,6 +29,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
@@ -45,6 +46,7 @@ import org.springblade.transport.excel.DriverExcel;
import org.springblade.transport.mapper.DriverMapper;
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.DriverVO;
import org.springblade.transport.service.IDriverService;
@@ -53,6 +55,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
+import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
@@ -61,6 +64,7 @@ import java.util.Objects;
*
* @author Chill
*/
+@Slf4j
@Service
@RequiredArgsConstructor
public class DriverServiceImpl extends BaseServiceImpl
+ * 不走 {@link #submit}:那条路径要求 15 项必填。这里只满足表约束——
+ * id_card_no 留 NULL(唯一索引对 NULL 不去重,NULL 同时是「未采集」的判据),
+ * 其余 NOT NULL 列写空串或可读占位。
+ */
+ private DriverEnsureProfileVO createPlaceholderProfile(String tenantId, Long mpUserId, String mobile) {
+ Driver driver = new Driver();
+ driver.setTenantId(tenantId);
+ driver.setUserId(mpUserId);
+ driver.setDriverName("司机" + mobileTail(mobile));
+ driver.setMobile(mobile);
+ driver.setDrivingType("");
+ driver.setDriverType("");
+ driver.setOrganizationName("");
+ driver.setEmergencyContactName("");
+ driver.setEmergencyContactMobile("");
+ driver.setStatus(DEFAULT_ENABLED_STATUS);
+ driver.setProfileSource(PROFILE_SOURCE_LOGIN_AUTO);
+ driver.setAccountBoundTime(LocalDateTime.now());
+ try {
+ save(driver);
+ } catch (Exception exception) {
+ // 并发登录撞 uk_transport_driver_user:重查一次,说明另一个请求已经建好档案
+ Driver concurrent = findDriverByUserId(mpUserId);
+ if (concurrent != null) {
+ return ensureResult(concurrent.getId(), false, false, null);
+ }
+ log.error("自动创建司机档案失败,租户={} 账号={}", tenantId, mpUserId, exception);
+ throw new ServiceException("创建司机档案失败");
+ }
+ return ensureResult(driver.getId(), true, false, null);
+ }
+
+ /**
+ * 把已有档案绑定到账号:只 patch 关联字段与留痕,不覆盖档案内容
+ */
+ private DriverEnsureProfileVO bindExistingProfile(Long driverId, Long mpUserId) {
+ Driver patch = new Driver();
+ patch.setId(driverId);
+ patch.setUserId(mpUserId);
+ patch.setProfileSource(PROFILE_SOURCE_LOGIN_BOUND);
+ patch.setAccountBoundTime(LocalDateTime.now());
+ // 只改 user_id 也必须带 driving_vehicle 原值,理由见 carryDrivingVehicle
+ carryDrivingVehicle(driverId, patch);
+ try {
+ updateById(patch);
+ } catch (Exception exception) {
+ // 并发绑定撞 uk_transport_driver_user:另一个请求已经把这个账号绑到档案上
+ Driver concurrent = findDriverByUserId(mpUserId);
+ if (concurrent != null) {
+ return ensureResult(concurrent.getId(), false, false, null);
+ }
+ log.error("绑定司机档案失败,档案={} 账号={}", driverId, mpUserId, exception);
+ throw new ServiceException("绑定司机档案失败");
+ }
+ return ensureResult(driverId, false, true, null);
+ }
+
+ private DriverEnsureProfileVO ensureResult(Long driverId, boolean created, boolean boundExisting, String message) {
+ DriverEnsureProfileVO result = new DriverEnsureProfileVO();
+ result.setDriverId(driverId);
+ result.setCreated(created);
+ result.setBoundExisting(boundExisting);
+ result.setMessage(message);
+ return result;
+ }
+
+ private String mobileTail(String mobile) {
+ return mobile.length() <= 4 ? mobile : mobile.substring(mobile.length() - 4);
+ }
+
@Override
@Transactional(rollbackFor = Exception.class)
public boolean changeStatus(Long id, Integer status) {
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java
index 5a4ed782..3fe5f102 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java
@@ -39,12 +39,12 @@ import org.springframework.stereotype.Component;
* 判据是生效快照指针,不是「最新提交状态」展示缓存——重新提交认证会把展示缓存
* 变成「审核中」,照它判定会让司机一提交就接不了单。生效快照只在审核通过时切换。
*
- * 两个开关默认开启,可关掉以回退到不校验认证的行为:
+ * 两个开关默认关闭,接单不校验认证;显式置 true 恢复拦截:
*
+ * 守住三条不变式:按 user_id 幂等重入不重复建档、同号冲突一律不绑定也不新建、
+ * 绑定那次 patch 必须带上 driving_vehicle 原值(该列 {@code FieldStrategy.ALWAYS}
+ * 会把缺失的列写成 NULL,司机端的车辆绑定会整块消失)。
+ */
+class DriverEnsureProfileTest {
+
+ private static final Long MP_USER_ID = 7001L;
+ private static final Long OTHER_USER_ID = 7002L;
+ private static final Long DRIVER_ID = 9001L;
+ private static final Long NEW_DRIVER_ID = 9002L;
+ private static final String TENANT_ID = "000000";
+ private static final String MOBILE = "15975348524";
+ private static final String PLATE = "桂A32523";
+
+ private DriverMapper driverMapper;
+ private DriverServiceImpl service;
+
+ @BeforeAll
+ static void initTableInfo() {
+ // save 走 MyBatis-Plus 的实体元数据缓存
+ 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);
+ IMpUserClient mpUserClient = mock(IMpUserClient.class);
+ ISysClient sysClient = mock(ISysClient.class);
+ service = new DriverServiceImpl(mpUserClient, sysClient);
+ inject("baseMapper", driverMapper);
+ }
+
+ // ------------------------------------------------------------ 新建占位档案
+
+ @Test
+ @DisplayName("无账号档案、无同号档案:按占位值新建,created=true")
+ void createsPlaceholderProfile() {
+ // 按 user_id 查(getOne → selectOne)与按手机号查(selectList)都没有
+ stubUserLookup();
+ when(driverMapper.selectList(any())).thenReturn(Collections.emptyList());
+ when(driverMapper.insert(any(Driver.class))).thenAnswer(invocation -> {
+ Driver inserted = invocation.getArgument(0);
+ inserted.setId(NEW_DRIVER_ID);
+ return 1;
+ });
+
+ DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE);
+
+ assertTrue(result.getCreated());
+ assertFalse(result.getBoundExisting());
+ assertNull(result.getMessage());
+ assertEquals(NEW_DRIVER_ID, result.getDriverId());
+
+ ArgumentCaptor
+ * 开关默认关闭:未认证的司机与车辆都可接单;只有显式置 true 才按生效快照校验。
+ *
* 关键场景:已认证的车被重新提交认证后仍可接单——判定必须走生效快照,
* 而重新提交会把「最新提交状态」变成「审核中」。
*/
@@ -26,14 +28,33 @@ class CertificationGateTest {
// ------------------------------------------------------------ 开关
@Test
- @DisplayName("门槛默认开启")
- void enabledByDefault() {
- assertTrue(gate.isVehicleGateEnabled());
- assertTrue(gate.isDriverGateEnabled());
+ @DisplayName("门槛默认关闭")
+ void disabledByDefault() {
+ assertFalse(gate.isVehicleGateEnabled());
+ assertFalse(gate.isDriverGateEnabled());
}
@Test
- @DisplayName("关闭后不因未认证而拦截(便于快速回退)")
+ @DisplayName("默认关闭:未认证的司机与车辆都不拦")
+ void disabledByDefaultLetsUncertifiedThrough() {
+ assertTrue(gate.isVehicleAvailable(Boolean.TRUE, null));
+ assertTrue(gate.isDriverAvailable(null));
+ }
+
+ @Test
+ @DisplayName("显式置 true:未认证的司机与车辆重新被拒")
+ void explicitlyEnabledRejectsUncertified() {
+ gate.setVehicleGateEnabled(true);
+ gate.setDriverGateEnabled(true);
+
+ assertFalse(gate.isDriverAvailable(null));
+ assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null));
+ assertTrue(gate.driverUnavailableReason().contains("认证"));
+ assertTrue(gate.vehicleUnavailableReason(Boolean.TRUE, null).contains("认证"));
+ }
+
+ @Test
+ @DisplayName("显式关闭后不因未认证而拦截(配置回退路径)")
void disabledLetsEveryoneThrough() {
gate.setVehicleGateEnabled(false);
gate.setDriverGateEnabled(false);
@@ -45,28 +66,32 @@ class CertificationGateTest {
// ------------------------------------------------------------ 车辆
@Test
- @DisplayName("车辆:有生效快照即可用(核心:重新提交不影响可用性)")
+ @DisplayName("车辆:开启门槛时有生效快照即可用(核心:重新提交不影响可用性)")
void vehicleWithEffectiveSnapshotIsAvailable() {
+ gate.setVehicleGateEnabled(true);
assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L));
}
@Test
@DisplayName("车辆:重新提交后【状态变成审核中】但仍有生效快照 → 仍可用")
void resubmittedVehicleStaysAvailable() {
+ gate.setVehicleGateEnabled(true);
// 展示缓存变成「审核中」,但 effective_cert_record_id 仍指着上一次通过的记录。
assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L),
"已认证的车重新提交后必须仍可接单,否则司机一提交就停工");
}
@Test
- @DisplayName("车辆:从未认证(无生效快照)被拒绝")
+ @DisplayName("车辆:开启门槛时从未认证(无生效快照)被拒绝")
void vehicleWithoutEffectiveSnapshotIsRejected() {
+ gate.setVehicleGateEnabled(true);
assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null));
}
@Test
@DisplayName("车辆:停用状态优先于认证——停用的车即便有生效快照也不可用")
void disabledVehicleIsRejectedEvenIfCertified() {
+ gate.setVehicleGateEnabled(true);
assertFalse(gate.isVehicleAvailable(Boolean.FALSE, 7001L),
"启用/停用是运营状态,认证门槛只叠加不覆盖");
assertFalse(gate.isVehicleAvailable(null, 7001L));
@@ -75,20 +100,23 @@ class CertificationGateTest {
// ------------------------------------------------------------ 司机
@Test
- @DisplayName("司机:有生效快照即可用")
+ @DisplayName("司机:开启门槛时有生效快照即可用")
void driverWithEffectiveSnapshotIsAvailable() {
+ gate.setDriverGateEnabled(true);
assertTrue(gate.isDriverAvailable(9001L));
}
@Test
- @DisplayName("司机:从未认证被拒绝")
+ @DisplayName("司机:开启门槛时从未认证被拒绝")
void driverWithoutEffectiveSnapshotIsRejected() {
+ gate.setDriverGateEnabled(true);
assertFalse(gate.isDriverAvailable(null));
}
@Test
@DisplayName("司机门槛可单独关闭")
void driverGateCanBeDisabledIndependently() {
+ gate.setVehicleGateEnabled(true);
gate.setDriverGateEnabled(false);
assertTrue(gate.isDriverAvailable(null), "司机门槛关闭时不应拦截");
assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null), "车辆门槛不受影响");
@@ -99,6 +127,7 @@ class CertificationGateTest {
@Test
@DisplayName("停用与未认证分开报:前者让人去启用车辆,后者才让人去认证")
void unavailableReasonsDistinguishDisabledFromUncertified() {
+ gate.setVehicleGateEnabled(true);
String disabled = gate.vehicleUnavailableReason(Boolean.FALSE, 7001L);
assertTrue(disabled.contains("停用"), disabled);
assertFalse(disabled.contains("认证审核"), "已认证的车被停用,不该报成认证没通过");
@@ -111,6 +140,7 @@ class CertificationGateTest {
@Test
@DisplayName("可用时没有原因;两种不可用文案都不泄露内部字段名")
void availableHasNoReasonAndTextIsReadable() {
+ gate.setVehicleGateEnabled(true);
assertNull(gate.vehicleUnavailableReason(Boolean.TRUE, 7001L));
assertFalse(gate.vehicleUnavailableReason(Boolean.TRUE, null).contains("effective_cert_record_id"));
assertFalse(gate.vehicleUnavailableReason(Boolean.FALSE, null).contains("effective_cert_record_id"));
diff --git a/doc/sql/transport/blade_transport_driver.sql b/doc/sql/transport/blade_transport_driver.sql
index 19809bf5..e81e8d17 100644
--- a/doc/sql/transport/blade_transport_driver.sql
+++ b/doc/sql/transport/blade_transport_driver.sql
@@ -6,7 +6,7 @@ CREATE TABLE `blade_transport_driver` (
`id` bigint(20) NOT NULL COMMENT '主键',
`tenant_id` varchar(12) DEFAULT '000000' COMMENT '租户ID',
`driver_name` varchar(20) NOT NULL COMMENT '司机姓名',
- `id_card_no` varchar(18) NOT NULL COMMENT '身份证号',
+ `id_card_no` varchar(18) DEFAULT NULL COMMENT '身份证号,NULL 表示尚未采集',
`birthday` date DEFAULT NULL COMMENT '出生年月',
`gender` varchar(10) DEFAULT NULL COMMENT '性别',
`nation` varchar(20) DEFAULT NULL COMMENT '民族',
@@ -37,6 +37,8 @@ CREATE TABLE `blade_transport_driver` (
`driver_type` varchar(20) NOT NULL COMMENT '司机类型:自有/外协',
`mobile` varchar(20) NOT NULL COMMENT '手机号',
`user_id` bigint(20) DEFAULT NULL COMMENT '关联系统用户ID',
+ `profile_source` varchar(20) DEFAULT NULL COMMENT 'login_auto/login_bound/admin/cert_backfill,NULL 为改造前存量',
+ `account_bound_time` datetime DEFAULT NULL COMMENT '档案与账号建立关联的时点',
`contact_relation` varchar(20) DEFAULT NULL COMMENT '与联系人关系',
`organization_name` varchar(50) NOT NULL COMMENT '所属组织',
`emergency_contact_name` varchar(20) NOT NULL COMMENT '紧急联系人姓名',
@@ -54,6 +56,7 @@ CREATE TABLE `blade_transport_driver` (
`is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_transport_driver_id_card` (`id_card_no`) USING BTREE,
+ UNIQUE KEY `uk_transport_driver_user` (`user_id`) USING BTREE,
KEY `idx_transport_driver_mobile` (`mobile`) USING BTREE,
KEY `idx_transport_driver_type` (`driver_type`) USING BTREE,
KEY `idx_transport_driver_org` (`organization_name`) USING BTREE,
diff --git a/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql b/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql
new file mode 100644
index 00000000..30d238d1
--- /dev/null
+++ b/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql
@@ -0,0 +1,148 @@
+-- ----------------------------
+-- 司机档案与登录账号打通(2026-10-23)
+-- ----------------------------
+-- 三处结构变更:
+-- ① user_id 加唯一索引 uk_transport_driver_user —— 一个账号只能对应一条司机档案。
+-- ② id_card_no 放宽为可空 —— NULL 表示「身份证号尚未采集」,是判断档案是否完善的口径。
+-- uk_transport_driver_id_card 保留:MySQL 唯一索引对多行 NULL 不去重,
+-- 并发创建多个占位档案不会被唯一键挡住。
+-- ③ 新增 profile_source / account_bound_time 两列留痕:
+-- profile_source 取值 login_auto / login_bound / admin / cert_backfill,
+-- NULL 表示改造前存量(来源未知,不臆造);account_bound_time 是档案与账号建立关联的时点。
+--
+-- 只做结构变更,不回填数据:存量档案的 profile_source / account_bound_time 一律保持 NULL。
+-- 判定「档案是否采集」用 id_card_no IS NULL,不另加标记列。
+--
+-- 幂等:每段先查 information_schema,条件已满足即跳过,重复执行不报错;
+-- 全部用 PREPARE 动态语句而非存储过程,因此不需要 DELIMITER——按分号逐句切分的客户端
+-- (含数据库驱动)也能直接执行。
+--
+-- 前置检查(user_id 有重复时,第 1 节会以
+-- Duplicate entry '<值>' for key 'blade_transport_driver.uk_transport_driver_user'
+-- 失败并终止;此时本脚本对表零修改,先修数据再重跑):
+-- 唯一索引统计**全表**行,含 is_deleted = 1 的软删除档案:只按 is_deleted = 0
+-- 只能发现活跃重复,软删除行之间的重复同样会让第 1 节失败:
+-- SELECT user_id, COUNT(*) AS dup_count FROM `blade_transport_driver`
+-- WHERE user_id IS NOT NULL
+-- GROUP BY user_id HAVING COUNT(*) > 1;
+-- 修复口径:一个 user_id 只保留一条档案,其余把 user_id 置 NULL(不要改 id / is_deleted),
+-- 确认无误后重跑本脚本。
+--
+-- 注意:uk_transport_driver_user 覆盖软删除行 —— is_deleted = 1 的档案仍占用 user_id,
+-- 后台软删除某账号的档案后,该账号不能再建第二条档案(会撞唯一键);
+-- 需要释放时,软删除前先把该行的 user_id 置 NULL。
+
+SET @transport_driver_schema = DATABASE();
+
+-- ----------------------------
+-- 0. 表存在性守卫
+-- ----------------------------
+-- 表不存在时故意执行一条只读查询,让客户端报 1146「Table ... doesn't exist」;
+-- 否则下面每段都因查不到列/索引而静默跳过,脚本会假绿。
+SET @transport_driver_guard_sql = (
+ SELECT IF(COUNT(*) > 0,
+ 'SELECT 1',
+ 'SELECT 1 FROM `blade_transport_driver` LIMIT 0')
+ FROM information_schema.tables
+ WHERE table_schema = @transport_driver_schema
+ AND table_name = 'blade_transport_driver'
+);
+PREPARE transport_driver_guard_stmt FROM @transport_driver_guard_sql;
+EXECUTE transport_driver_guard_stmt;
+DEALLOCATE PREPARE transport_driver_guard_stmt;
+
+-- ----------------------------
+-- 1. 账号唯一索引(先行执行:有重复时立刻失败,脚本对表零修改)
+-- ----------------------------
+-- 只读诊断:有输出即为重复清单,先按上面口径修数据再往下跑。
+SELECT `user_id`, COUNT(*) AS `dup_count`
+ FROM `blade_transport_driver`
+ WHERE `user_id` IS NOT NULL
+ GROUP BY `user_id`
+HAVING COUNT(*) > 1;
+
+-- 同名索引若已存在但是普通索引(non_unique = 1),本段会以
+-- Duplicate key name 'uk_transport_driver_user' 失败:先 DROP INDEX 再重跑本脚本。
+SET @transport_driver_user_index_sql = (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `blade_transport_driver` ADD UNIQUE KEY `uk_transport_driver_user` (`user_id`) USING BTREE',
+ 'SELECT 1')
+ FROM information_schema.statistics
+ WHERE table_schema = @transport_driver_schema
+ AND table_name = 'blade_transport_driver'
+ AND index_name = 'uk_transport_driver_user'
+ AND non_unique = 0
+);
+PREPARE transport_driver_user_index_stmt FROM @transport_driver_user_index_sql;
+EXECUTE transport_driver_user_index_stmt;
+DEALLOCATE PREPARE transport_driver_user_index_stmt;
+
+-- ----------------------------
+-- 2. 身份证号放宽为可空(唯一索引 uk_transport_driver_id_card 保留)
+-- ----------------------------
+-- 条件写成「非空 或 长度不是 18」而不是只看 is_nullable:
+-- 该列历史上曾被截短/加长过,只判可空性会漏掉类型漂移。
+SET @transport_driver_id_card_sql = (
+ SELECT IF(COUNT(*) > 0,
+ 'ALTER TABLE `blade_transport_driver` MODIFY COLUMN `id_card_no` varchar(18) NULL COMMENT ''身份证号,NULL 表示尚未采集''',
+ 'SELECT 1')
+ FROM information_schema.columns
+ WHERE table_schema = @transport_driver_schema
+ AND table_name = 'blade_transport_driver'
+ AND column_name = 'id_card_no'
+ AND (is_nullable = 'NO' OR character_maximum_length <> 18)
+);
+PREPARE transport_driver_id_card_stmt FROM @transport_driver_id_card_sql;
+EXECUTE transport_driver_id_card_stmt;
+DEALLOCATE PREPARE transport_driver_id_card_stmt;
+
+-- ----------------------------
+-- 3. 来源留痕列:profile_source
+-- ----------------------------
+SET @transport_driver_profile_source_sql = (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `blade_transport_driver` ADD COLUMN `profile_source` varchar(20) DEFAULT NULL COMMENT ''login_auto/login_bound/admin/cert_backfill,NULL 为改造前存量'' AFTER `user_id`',
+ 'SELECT 1')
+ FROM information_schema.columns
+ WHERE table_schema = @transport_driver_schema
+ AND table_name = 'blade_transport_driver'
+ AND column_name = 'profile_source'
+);
+PREPARE transport_driver_profile_source_stmt FROM @transport_driver_profile_source_sql;
+EXECUTE transport_driver_profile_source_stmt;
+DEALLOCATE PREPARE transport_driver_profile_source_stmt;
+
+-- ----------------------------
+-- 4. 绑定留痕列:account_bound_time
+-- ----------------------------
+SET @transport_driver_account_bound_time_sql = (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `blade_transport_driver` ADD COLUMN `account_bound_time` datetime DEFAULT NULL COMMENT ''档案与账号建立关联的时点'' AFTER `profile_source`',
+ 'SELECT 1')
+ FROM information_schema.columns
+ WHERE table_schema = @transport_driver_schema
+ AND table_name = 'blade_transport_driver'
+ AND column_name = 'account_bound_time'
+);
+PREPARE transport_driver_account_bound_time_stmt FROM @transport_driver_account_bound_time_sql;
+EXECUTE transport_driver_account_bound_time_stmt;
+DEALLOCATE PREPARE transport_driver_account_bound_time_stmt;
+
+-- ----------------------------
+-- 5. 回滚
+-- ----------------------------
+-- 顺序与正向相反:先删索引与两列,最后把 id_card_no 收回 NOT NULL。
+-- 收窄之前必须先清掉 NULL,否则 ALTER 会以
+-- Invalid use of NULL value / Data truncated for column 'id_card_no' 失败。
+-- 占位档案的身份证号没有真实值可还原,回滚前须业务先决定「删档案」还是「补占位串」:
+-- -- ① 处理 NULL(示例为删除登录自动建的占位档案;改为 UPDATE 成占位串亦可,注意会占唯一索引)
+-- DELETE FROM `blade_transport_driver`
+-- WHERE `id_card_no` IS NULL AND `profile_source` IN ('login_auto', 'login_bound');
+-- -- ② 收回身份证号非空与旧注释
+-- ALTER TABLE `blade_transport_driver`
+-- MODIFY COLUMN `id_card_no` varchar(18) NOT NULL COMMENT '身份证号';
+-- -- ③ 删唯一索引、删两列
+-- ALTER TABLE `blade_transport_driver` DROP INDEX `uk_transport_driver_user`;
+-- ALTER TABLE `blade_transport_driver` DROP COLUMN `profile_source`, DROP COLUMN `account_bound_time`;
+--
+-- uk_transport_driver_id_card 全程保留,回滚不需要恢复它。
* transport:
* certification:
- * vehicle-gate-enabled: false
- * driver-gate-enabled: false
+ * vehicle-gate-enabled: true
+ * driver-gate-enabled: true
*
*
* @author Chill
@@ -57,14 +57,14 @@ public class CertificationGate {
/**
* 车辆认证门槛开关
*/
- @Value("${transport.certification.vehicle-gate-enabled:true}")
- private boolean vehicleGateEnabled = true;
+ @Value("${transport.certification.vehicle-gate-enabled:false}")
+ private boolean vehicleGateEnabled = false;
/**
* 司机认证门槛开关
*/
- @Value("${transport.certification.driver-gate-enabled:true}")
- private boolean driverGateEnabled = true;
+ @Value("${transport.certification.driver-gate-enabled:false}")
+ private boolean driverGateEnabled = false;
/**
* 判定车辆是否可用
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java
new file mode 100644
index 00000000..4c013e4d
--- /dev/null
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java
@@ -0,0 +1,269 @@
+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.mockito.MockedStatic;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.core.secure.utils.AuthUtil;
+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.DriverEnsureProfileVO;
+import org.springframework.dao.DuplicateKeyException;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.List;
+
+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.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 登录自动建档与绑定已有档案的测试。
+ *