🔀 合并放开小程序登录与接单认证门槛
This commit is contained in:
@@ -83,6 +83,17 @@
|
||||
<groupId>org.springblade</groupId>
|
||||
<artifactId>blade-resource-api</artifactId>
|
||||
</dependency>
|
||||
<!-- 登录编排调用司机档案的 ensureProfile -->
|
||||
<dependency>
|
||||
<groupId>org.springblade</groupId>
|
||||
<artifactId>blade-transport-api</artifactId>
|
||||
</dependency>
|
||||
<!-- 登录编排测试:mock feign 返回值 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!--安全模块-->
|
||||
<dependency>
|
||||
<groupId>org.springblade</groupId>
|
||||
|
||||
@@ -29,6 +29,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import org.springblade.auth.constant.BladeAuthConstant;
|
||||
import org.springblade.auth.support.MpLoginRequestSupport;
|
||||
import org.springblade.core.oauth2.props.OAuth2Properties;
|
||||
import org.springblade.core.oauth2.provider.OAuth2Request;
|
||||
import org.springblade.core.oauth2.service.OAuth2User;
|
||||
@@ -87,13 +88,16 @@ public class Oauth2SmsEndpoint {
|
||||
if (StringUtil.isBlank(decryptedPhone)) {
|
||||
return R.fail(USER_PHONE_NOT_FOUND);
|
||||
}
|
||||
// 校验手机是否已注册,防止恶意发送验证码
|
||||
// buildArgs:带上 User-Type 等请求头,小程序传 User-Type=mp 时查 blade_mp_user
|
||||
OAuth2Request request = OAuth2Request.create().buildArgs();
|
||||
request.setTenantId(tenantId);
|
||||
OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request);
|
||||
if (oAuth2User == null) {
|
||||
return R.fail(USER_PHONE_NOT_FOUND);
|
||||
// 「手机号是否已注册」只对 Web 校验;小程序端未注册手机号也发码,账号在登录时创建。
|
||||
// 发码路径只做查询,不建号。
|
||||
if (!MpLoginRequestSupport.isMp(request)) {
|
||||
OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request);
|
||||
if (oAuth2User == null) {
|
||||
return R.fail(USER_PHONE_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
// 使用指定短信资源(默认 ali_reg)
|
||||
String smsResourceCode = Func.toStr(code, BladeAuthConstant.LOGIN_SMS_CODE);
|
||||
|
||||
@@ -27,6 +27,8 @@ package org.springblade.auth.granter;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springblade.auth.constant.BladeAuthConstant;
|
||||
import org.springblade.auth.support.MpLoginRequestSupport;
|
||||
import org.springblade.auth.support.MpLoginSupport;
|
||||
import org.springblade.core.oauth2.constant.OAuth2TokenConstant;
|
||||
import org.springblade.core.oauth2.exception.UserInvalidException;
|
||||
import org.springblade.core.oauth2.granter.AbstractTokenGranter;
|
||||
@@ -47,6 +49,9 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* SmsTokenGranter
|
||||
* <p>
|
||||
* 按请求来源分流:小程序(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);
|
||||
|
||||
@@ -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;
|
||||
* 微信小程序手机号一键登录。
|
||||
* <p>
|
||||
* 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<MpUserInfo> mpUserResult = mpUserClient.userInfoByPhone(request.getTenantId(), phone);
|
||||
if (mpUserResult == null || !mpUserResult.isSuccess() || mpUserResult.getData() == null
|
||||
|| mpUserResult.getData().getUser() == null) {
|
||||
throw new UserInvalidException("用户不存在,无法登录");
|
||||
}
|
||||
|
||||
OAuth2User user = TokenUtil.convertMpUser(mpUserResult.getData());
|
||||
// 账号不存在时自动建号;建档与绑 openid 失败不阻断登录
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(request.getTenantId(), phone, session.getOpenid());
|
||||
if (!userService.validateUser(user)) {
|
||||
throw new UserInvalidException("用户身份无效,无法登录");
|
||||
}
|
||||
|
||||
R<Boolean> bindResult = mpUserClient.bindOpenId(
|
||||
request.getTenantId(),
|
||||
Func.toLong(user.getUserId()),
|
||||
session.getOpenid()
|
||||
);
|
||||
if (bindResult == null || !bindResult.isSuccess()) {
|
||||
String msg = bindResult != null ? bindResult.getMsg() : "绑定 openid 失败";
|
||||
log.warn("绑定微信 openid 失败 userId={} openid={} msg={}", user.getUserId(), session.getOpenid(), msg);
|
||||
throw new UserInvalidException(StringUtil.isBlank(msg) ? "绑定 openid 失败" : msg);
|
||||
}
|
||||
|
||||
user.setClient(oauthClient);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
package org.springblade.auth.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.auth.support.MpLoginRequestSupport;
|
||||
import org.springblade.auth.utils.TokenUtil;
|
||||
import org.springblade.core.oauth2.exception.OAuth2ErrorCode;
|
||||
import org.springblade.core.oauth2.provider.OAuth2Request;
|
||||
@@ -36,7 +37,6 @@ import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.system.feign.IUserClient;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
import org.springblade.system.pojo.entity.UserInfo;
|
||||
import org.springblade.system.pojo.enums.MpUserIdentity;
|
||||
import org.springblade.system.pojo.enums.UserType;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -54,7 +54,7 @@ public class BladeUserDetailService implements OAuth2UserService {
|
||||
@Override
|
||||
public OAuth2User loadByUserId(String userId, OAuth2Request request) {
|
||||
// 小程序刷新/鉴权:User-Type=mp 时只查 blade_mp_user
|
||||
if (isMpLoginRequest(request)) {
|
||||
if (MpLoginRequestSupport.isMp(request)) {
|
||||
OAuth2User mpUser = loadMpUserById(userId);
|
||||
if (mpUser == null) {
|
||||
OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER);
|
||||
@@ -83,7 +83,7 @@ public class BladeUserDetailService implements OAuth2UserService {
|
||||
String tenantId = request.getTenantId();
|
||||
|
||||
// 小程序账号密码 / 图形验证码登录:仅查 blade_mp_user
|
||||
if (isMpLoginRequest(request)) {
|
||||
if (MpLoginRequestSupport.isMp(request)) {
|
||||
R<MpUserInfo> mpResult = mpUserClient.userInfoByAccount(tenantId, username);
|
||||
if (mpResult == null || !mpResult.isSuccess() || mpResult.getData() == null) {
|
||||
return null;
|
||||
@@ -104,7 +104,7 @@ public class BladeUserDetailService implements OAuth2UserService {
|
||||
String tenantId = request.getTenantId();
|
||||
|
||||
// 小程序短信登录:仅查 blade_mp_user
|
||||
if (isMpLoginRequest(request)) {
|
||||
if (MpLoginRequestSupport.isMp(request)) {
|
||||
return loadMpUserByPhone(tenantId, phone);
|
||||
}
|
||||
|
||||
@@ -125,17 +125,6 @@ public class BladeUserDetailService implements OAuth2UserService {
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否小程序登录:请求头 User-Type=mp(Web 与小程序共用 saber3 客户端,不能按 clientId 分流)
|
||||
*/
|
||||
private boolean isMpLoginRequest(OAuth2Request request) {
|
||||
return request != null && isMpUserType(request.getUserType());
|
||||
}
|
||||
|
||||
private boolean isMpUserType(String userType) {
|
||||
return MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(userType));
|
||||
}
|
||||
|
||||
private String resolveUserType(OAuth2Request request) {
|
||||
return Optional.ofNullable(request.getUserType())
|
||||
.filter(s -> !StringUtil.isBlank(s))
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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.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
|
||||
* <p>
|
||||
* 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()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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.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 / 手机号找到或创建账号,并补齐司机档案与微信绑定。
|
||||
* <p>
|
||||
* 只有「建号失败」会让登录失败;建档与绑 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 绑定。
|
||||
* <p>
|
||||
* 返回 {@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<MpUser> result = mpUserClient.userByOpenid(tenantId, openid);
|
||||
return isSuccess(result) ? result.getData() : null;
|
||||
} catch (Exception exception) {
|
||||
// 查询失败按未命中处理,随后由 ensureUser 触达系统服务并兜住建号失败
|
||||
log.warn("按 openid 查询小程序账号失败:租户={} openid={}", tenantId, openid, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private MpUser findByPhone(String tenantId, String phone) {
|
||||
try {
|
||||
R<MpUser> result = mpUserClient.userByPhone(tenantId, phone);
|
||||
return isSuccess(result) ? result.getData() : null;
|
||||
} catch (Exception exception) {
|
||||
log.warn("按手机号查询小程序账号失败:租户={} 手机号={}", tenantId, phone, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ② 查找或创建账号:登录路径上唯一允许让登录失败的动作
|
||||
*/
|
||||
private MpUser ensureUser(String tenantId, String phone, String openid) {
|
||||
MpUserEnsureDTO param = new MpUserEnsureDTO();
|
||||
param.setTenantId(tenantId);
|
||||
param.setPhone(phone);
|
||||
param.setOpenid(openid);
|
||||
try {
|
||||
R<MpUser> result = mpUserClient.ensureUser(param);
|
||||
if (isSuccess(result) && result.getData() != null && Func.isNotEmpty(result.getData().getId())) {
|
||||
return result.getData();
|
||||
}
|
||||
// R.data(null) 也是 success,此时 msg 是「暂无数据」之类的占位文案,不能当业务错误下发
|
||||
String message = result != null && !result.isSuccess() ? result.getMsg() : null;
|
||||
log.error("小程序账号查找或创建失败:租户={} 手机号={} 返回={}", tenantId, phone, message);
|
||||
throw new UserInvalidException(StringUtil.isBlank(message) ? "账号创建失败,请稍后重试" : message);
|
||||
} catch (UserInvalidException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
log.error("小程序账号查找或创建失败:租户={} 手机号={}", tenantId, phone, exception);
|
||||
throw new UserInvalidException("账号创建失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 openid 账号的手机号换成新号
|
||||
* <p>
|
||||
* 只 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<Boolean> result = mpUserClient.updateUser(patch);
|
||||
if (isSuccess(result) && Boolean.TRUE.equals(result.getData())) {
|
||||
account.setPhone(phone);
|
||||
return account;
|
||||
}
|
||||
log.warn("更新小程序账号手机号失败:租户={} 账号={} 新手机号={} 返回={}",
|
||||
account.getTenantId(), account.getId(), phone, result == null ? null : result.getMsg());
|
||||
} catch (Exception exception) {
|
||||
log.warn("更新小程序账号手机号失败:租户={} 账号={} 新手机号={}",
|
||||
account.getTenantId(), account.getId(), phone, exception);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ③ 为司机身份账号补齐档案;失败或冲突只记 WARN,不阻断登录
|
||||
*
|
||||
* @return 冲突时的可读原因,供 detail 提示使用;成功或无需建档返回 null
|
||||
*/
|
||||
private String ensureProfile(MpUser account, String requestedPhone, String requestedTenantId) {
|
||||
if (account == null || !MpUserIdentity.has(account.getIdentities(), MpUserIdentity.DRIVER)) {
|
||||
return null;
|
||||
}
|
||||
// 租户与手机号取账号上的值:档案必须与账号落同一租户(唯一键对 NULL 不去重);
|
||||
// 账号手机号未能更新时,不使用入参手机号建档
|
||||
String tenantId = Func.toStr(account.getTenantId(), requestedTenantId);
|
||||
String phone = Func.toStr(account.getPhone(), requestedPhone);
|
||||
DriverEnsureProfileDTO param = new DriverEnsureProfileDTO();
|
||||
param.setTenantId(tenantId);
|
||||
param.setMpUserId(account.getId());
|
||||
param.setPhone(phone);
|
||||
try {
|
||||
R<DriverEnsureProfileVO> result = driverClient.ensureProfile(param);
|
||||
if (!isSuccess(result) || result.getData() == null) {
|
||||
log.warn("登录自动建档未完成(不阻断登录):租户={} 账号={} 手机号={} 返回={}",
|
||||
tenantId, account.getId(), phone, result == null ? null : result.getMsg());
|
||||
return null;
|
||||
}
|
||||
DriverEnsureProfileVO profile = result.getData();
|
||||
if (StringUtil.isNotBlank(profile.getMessage())) {
|
||||
log.warn("登录自动建档冲突(不阻断登录):租户={} 账号={} 手机号={} 原因={}",
|
||||
tenantId, account.getId(), phone, profile.getMessage());
|
||||
return profile.getMessage();
|
||||
}
|
||||
return null;
|
||||
} catch (Exception exception) {
|
||||
log.warn("登录自动建档失败(不阻断登录):租户={} 账号={} 手机号={}",
|
||||
tenantId, account.getId(), phone, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ④ 微信登录补绑 openid;失败只 WARN,不阻断登录
|
||||
*/
|
||||
private void bindOpenid(MpUser account, String openid, boolean openidOccupied) {
|
||||
if (account == null || StringUtil.isBlank(openid)) {
|
||||
return;
|
||||
}
|
||||
// 撞键场景 openid 已属于另一个账号,明确不迁移,也不再尝试绑定
|
||||
if (openidOccupied) {
|
||||
return;
|
||||
}
|
||||
if (StringUtil.isNotBlank(account.getOpenid())) {
|
||||
if (!openid.equals(account.getOpenid())) {
|
||||
log.warn("小程序账号已有其他 openid,本次不覆盖:租户={} 账号={} 已有={} 本次={}",
|
||||
account.getTenantId(), account.getId(), account.getOpenid(), openid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
R<Boolean> result = mpUserClient.bindOpenId(account.getTenantId(), account.getId(), openid);
|
||||
if (!isSuccess(result) || !Boolean.TRUE.equals(result.getData())) {
|
||||
log.warn("绑定微信 openid 失败(不阻断登录):租户={} 账号={} openid={} 返回={}",
|
||||
account.getTenantId(), account.getId(), openid, result == null ? null : result.getMsg());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.warn("绑定微信 openid 失败(不阻断登录):租户={} 账号={} openid={}",
|
||||
account.getTenantId(), account.getId(), openid, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MpUserInfo 由 system 侧 buildUserInfo 统一组装,auth 不复制一份 detail 口径
|
||||
*/
|
||||
private MpUserInfo loadUserInfo(MpUser account) {
|
||||
try {
|
||||
R<MpUserInfo> result = mpUserClient.userInfoById(account.getId());
|
||||
if (isSuccess(result) && result.getData() != null && result.getData().getUser() != null) {
|
||||
return result.getData();
|
||||
}
|
||||
String message = result != null && !result.isSuccess() ? result.getMsg() : null;
|
||||
log.error("小程序账号登录信息查询失败:租户={} 账号={} 返回={}", account.getTenantId(), account.getId(), message);
|
||||
throw new UserInvalidException(StringUtil.isBlank(message) ? "账号信息查询失败,请稍后重试" : message);
|
||||
} catch (UserInvalidException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
log.error("小程序账号登录信息查询失败:租户={} 账号={}", account.getTenantId(), account.getId(), exception);
|
||||
throw new UserInvalidException("账号信息查询失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private void attachNotice(MpUserInfo userInfo, String loginNotice) {
|
||||
if (userInfo == null || StringUtil.isBlank(loginNotice)) {
|
||||
return;
|
||||
}
|
||||
// detail 是否下发到前端取决于令牌序列化,冲突点同时另记 WARN
|
||||
Kv detail = userInfo.getDetail() == null ? Kv.create() : userInfo.getDetail();
|
||||
detail.set(DETAIL_LOGIN_NOTICE, loginNotice);
|
||||
userInfo.setDetail(detail);
|
||||
}
|
||||
|
||||
private boolean samePhone(String left, String right) {
|
||||
return Objects.equals(trimToNull(left), trimToNull(right));
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimValue = value.trim();
|
||||
return trimValue.isEmpty() ? null : trimValue;
|
||||
}
|
||||
|
||||
private boolean isSuccess(R<?> result) {
|
||||
return result != null && result.isSuccess();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package org.springblade.auth.endpoint;
|
||||
|
||||
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
|
||||
import org.bouncycastle.util.encoders.Hex;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.springblade.core.oauth2.props.OAuth2Properties;
|
||||
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.SM2Util;
|
||||
import org.springblade.resource.feign.ISmsClient;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.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;
|
||||
import static org.springblade.core.oauth2.constant.OAuth2TokenConstant.USER_PHONE_NOT_FOUND;
|
||||
|
||||
/**
|
||||
* 发码端点的端分流测试。
|
||||
* <p>
|
||||
* 保住发码端点的分流口径:小程序端(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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 登录编排测试。
|
||||
* <p>
|
||||
* 保住三条不变式:建号失败才阻断登录;建档失败放行;
|
||||
* 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<MpUserEnsureDTO> ensureCaptor = ArgumentCaptor.forClass(MpUserEnsureDTO.class);
|
||||
verify(mpUserClient).ensureUser(ensureCaptor.capture());
|
||||
assertEquals(TENANT_ID, ensureCaptor.getValue().getTenantId());
|
||||
assertEquals(PHONE, ensureCaptor.getValue().getPhone());
|
||||
assertEquals(OPENID, ensureCaptor.getValue().getOpenid());
|
||||
ArgumentCaptor<DriverEnsureProfileDTO> profileCaptor = ArgumentCaptor.forClass(DriverEnsureProfileDTO.class);
|
||||
verify(driverClient).ensureProfile(profileCaptor.capture());
|
||||
assertEquals(TENANT_ID, profileCaptor.getValue().getTenantId());
|
||||
assertEquals(11L, profileCaptor.getValue().getMpUserId());
|
||||
assertEquals(PHONE, profileCaptor.getValue().getPhone());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("建档抛异常:登录仍然成功")
|
||||
void profileFailureDoesNotBlockLogin() {
|
||||
MpUser created = mpUser(12L, 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(12L)).thenReturn(R.data(userInfo(created, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class)))
|
||||
.thenThrow(new RuntimeException("transport 不可用"));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
|
||||
|
||||
assertNotNull(user);
|
||||
assertEquals("12", user.getUserId());
|
||||
verify(driverClient).ensureProfile(any(DriverEnsureProfileDTO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("openid 绑定失败:登录仍然成功,不抛异常")
|
||||
void bindOpenidFailureDoesNotBlockLogin() {
|
||||
MpUser existing = mpUser(13L, PHONE, null, "driver");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
|
||||
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(existing));
|
||||
when(mpUserClient.userInfoById(13L)).thenReturn(R.data(userInfo(existing, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
|
||||
when(mpUserClient.bindOpenId(TENANT_ID, 13L, OPENID)).thenReturn(R.fail("该微信已绑定其他小程序用户"));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
|
||||
|
||||
assertNotNull(user);
|
||||
assertEquals("13", user.getUserId());
|
||||
verify(mpUserClient).bindOpenId(TENANT_ID, 13L, OPENID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("打开号码来源:微信登录时给空 openid 账号补绑 openid")
|
||||
void bindsOpenidForPhoneAccount() {
|
||||
MpUser existing = mpUser(14L, PHONE, null, "driver");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
|
||||
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(existing));
|
||||
when(mpUserClient.userInfoById(14L)).thenReturn(R.data(userInfo(existing, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
|
||||
when(mpUserClient.bindOpenId(TENANT_ID, 14L, OPENID)).thenReturn(R.data(Boolean.TRUE));
|
||||
|
||||
mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
|
||||
|
||||
verify(mpUserClient).bindOpenId(TENANT_ID, 14L, OPENID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("openid 命中旧账号但新手机号已被别的账号占用:落到手机号账号、不迁移 openid、有提示")
|
||||
void phoneOccupiedKeepsPhoneAccount() {
|
||||
MpUser openidAccount = mpUser(21L, "13900000000", OPENID, "driver");
|
||||
MpUser phoneAccount = mpUser(22L, NEW_PHONE, null, "driver");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount));
|
||||
when(mpUserClient.userByPhone(TENANT_ID, NEW_PHONE)).thenReturn(R.data(phoneAccount));
|
||||
when(mpUserClient.userInfoById(22L)).thenReturn(R.data(userInfo(phoneAccount, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, NEW_PHONE, OPENID);
|
||||
|
||||
assertNotNull(user);
|
||||
assertEquals("22", user.getUserId(), "应落在手机号账号上");
|
||||
verify(mpUserClient, never()).updateUser(any(MpUser.class));
|
||||
verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString());
|
||||
assertNotNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE), "应带上可读提示");
|
||||
assertTrue(String.valueOf(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE)).contains("该微信已绑定其他账号"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("openid 命中且手机号不同但新号空闲:更新账号手机号")
|
||||
void openidHitWithFreePhoneUpdatesPhone() {
|
||||
MpUser openidAccount = mpUser(31L, "13900000000", OPENID, "driver");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount));
|
||||
when(mpUserClient.userByPhone(TENANT_ID, NEW_PHONE)).thenReturn(R.data(null));
|
||||
when(mpUserClient.updateUser(any(MpUser.class))).thenReturn(R.data(Boolean.TRUE));
|
||||
when(mpUserClient.userInfoById(31L)).thenReturn(R.data(userInfo(openidAccount, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, NEW_PHONE, OPENID);
|
||||
|
||||
assertEquals("31", user.getUserId());
|
||||
ArgumentCaptor<MpUser> updateCaptor = ArgumentCaptor.forClass(MpUser.class);
|
||||
verify(mpUserClient).updateUser(updateCaptor.capture());
|
||||
assertEquals(31L, updateCaptor.getValue().getId());
|
||||
assertEquals(NEW_PHONE, updateCaptor.getValue().getPhone());
|
||||
verify(mpUserClient, never()).ensureUser(any(MpUserEnsureDTO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("纯 admin 身份:不建司机档案")
|
||||
void adminOnlyDoesNotCreateProfile() {
|
||||
MpUser admin = mpUser(41L, PHONE, OPENID, "admin");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
|
||||
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(admin));
|
||||
when(mpUserClient.userInfoById(41L)).thenReturn(R.data(userInfo(admin, "admin")));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
|
||||
|
||||
assertEquals("41", user.getUserId());
|
||||
verify(driverClient, never()).ensureProfile(any(DriverEnsureProfileDTO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("建号失败是唯一阻断登录的条件")
|
||||
void accountCreationFailureBlocksLogin() {
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
|
||||
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.fail("创建小程序用户失败"));
|
||||
|
||||
UserInvalidException exception = assertThrows(UserInvalidException.class,
|
||||
() -> mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID));
|
||||
assertEquals("创建小程序用户失败", exception.getMessage());
|
||||
verify(driverClient, never()).ensureProfile(any(DriverEnsureProfileDTO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("短信登录(openid 为空)不做 openid 相关调用")
|
||||
void smsLoginSkipsOpenid() {
|
||||
MpUser created = mpUser(51L, PHONE, null, "driver");
|
||||
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created));
|
||||
when(mpUserClient.userInfoById(51L)).thenReturn(R.data(userInfo(created, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, null);
|
||||
|
||||
assertEquals("51", user.getUserId());
|
||||
verify(mpUserClient, never()).userByOpenid(anyString(), anyString());
|
||||
verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private MpUser mpUser(Long id, String phone, String openid, String identities) {
|
||||
MpUser user = new MpUser();
|
||||
user.setId(id);
|
||||
user.setTenantId(TENANT_ID);
|
||||
user.setPhone(phone);
|
||||
user.setAccount(phone);
|
||||
user.setOpenid(openid);
|
||||
user.setIdentities(identities);
|
||||
user.setStatus(1);
|
||||
return user;
|
||||
}
|
||||
|
||||
private MpUserInfo userInfo(MpUser user, String... identities) {
|
||||
MpUserInfo userInfo = new MpUserInfo();
|
||||
userInfo.setUser(user);
|
||||
userInfo.setIdentities(List.of(identities));
|
||||
userInfo.setDetail(Kv.create()
|
||||
.set("type", "mp")
|
||||
.set("identities", String.join(",", identities))
|
||||
.set("openid", user.getOpenid() == null ? "" : user.getOpenid()));
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
private DriverEnsureProfileVO profileResult() {
|
||||
DriverEnsureProfileVO result = new DriverEnsureProfileVO();
|
||||
result.setDriverId(9001L);
|
||||
result.setCreated(true);
|
||||
result.setBoundExisting(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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.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";
|
||||
|
||||
/**
|
||||
* 查找或创建司机档案,并把档案绑定到指定小程序账号
|
||||
* <p>
|
||||
* 幂等入口:按 user_id 命中时原样返回;冲突(同号多档案 / 已被其他账号占用)
|
||||
* 不新建、不覆盖,通过返回值的 message 说明原因,不抛异常给登录方。
|
||||
*
|
||||
* @param param 租户、小程序账号与手机号
|
||||
* @return 档案ID与本次处置标志
|
||||
*/
|
||||
@PostMapping(ENSURE_PROFILE)
|
||||
R<DriverEnsureProfileVO> ensureProfile(@RequestBody DriverEnsureProfileDTO param);
|
||||
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* 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.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;
|
||||
|
||||
}
|
||||
+12
@@ -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;
|
||||
/**
|
||||
* 与联系人关系
|
||||
*/
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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.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;
|
||||
|
||||
}
|
||||
+15
@@ -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<MpUser> userByAccount(@RequestParam("tenantId") String tenantId, @RequestParam("account") String account);
|
||||
|
||||
/**
|
||||
* 按租户 + openid 查询
|
||||
*/
|
||||
@GetMapping(USER_BY_OPENID)
|
||||
R<MpUser> userByOpenid(@RequestParam("tenantId") String tenantId, @RequestParam("openid") String openid);
|
||||
|
||||
/**
|
||||
* 查找或创建小程序用户(登录路径专用,已存在时不抛异常、不改身份)
|
||||
*/
|
||||
@PostMapping(ENSURE_USER)
|
||||
R<MpUser> ensureUser(@RequestBody MpUserEnsureDTO param);
|
||||
|
||||
/**
|
||||
* 按主键查询登录信息
|
||||
*/
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* 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.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;
|
||||
|
||||
}
|
||||
+13
@@ -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<MpUser> userByOpenid(String tenantId, String openid) {
|
||||
return R.data(mpUserService.getByOpenid(tenantId, openid));
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping(ENSURE_USER)
|
||||
public R<MpUser> ensureUser(@RequestBody MpUserEnsureDTO param) {
|
||||
return R.data(mpUserService.ensureUser(param));
|
||||
}
|
||||
|
||||
@Override
|
||||
@GetMapping(USER_INFO_BY_ID)
|
||||
public R<MpUserInfo> userInfoById(Long userId) {
|
||||
|
||||
+13
@@ -24,6 +24,7 @@ package org.springblade.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.system.pojo.dto.MpUserEnsureDTO;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
|
||||
@@ -47,6 +48,18 @@ public interface IMpUserService extends BaseService<MpUser> {
|
||||
*/
|
||||
MpUser getByAccount(String tenantId, String account);
|
||||
|
||||
/**
|
||||
* 按租户 + 微信小程序 openid 查询
|
||||
*/
|
||||
MpUser getByOpenid(String tenantId, String openid);
|
||||
|
||||
/**
|
||||
* 查找或创建小程序用户(登录路径专用)
|
||||
* <p>
|
||||
* 与 {@link #submit} 的提交式语义相反:手机号已存在时直接返回已有账号,且不改其身份。
|
||||
*/
|
||||
MpUser ensureUser(MpUserEnsureDTO param);
|
||||
|
||||
/**
|
||||
* 按主键组装登录信息
|
||||
*/
|
||||
|
||||
+108
@@ -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<MpUserMapper, MpUser> implements IMpUserService {
|
||||
|
||||
/**
|
||||
* 兜底租户:与 blade_mp_user.tenant_id 的建表默认值一致
|
||||
*/
|
||||
private static final String DEFAULT_TENANT_ID = "000000";
|
||||
|
||||
/**
|
||||
* 登录请求携带租户的请求头名
|
||||
*/
|
||||
private static final String TENANT_HEADER = "Tenant-Id";
|
||||
|
||||
@Override
|
||||
public MpUser getByPhone(String tenantId, String phone) {
|
||||
if (Func.isBlank(tenantId) || Func.isBlank(phone)) {
|
||||
@@ -81,6 +96,92 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public MpUser getByOpenid(String tenantId, String openid) {
|
||||
if (Func.isBlank(tenantId) || Func.isBlank(openid)) {
|
||||
return null;
|
||||
}
|
||||
return getOne(Wrappers.<MpUser>lambdaQuery()
|
||||
.eq(MpUser::getTenantId, tenantId)
|
||||
.eq(MpUser::getOpenid, openid)
|
||||
.orderByDesc(MpUser::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public MpUser ensureUser(MpUserEnsureDTO param) {
|
||||
if (param == null) {
|
||||
throw new ServiceException("小程序用户参数不能为空");
|
||||
}
|
||||
String tenantId = resolveEnsureTenantId(param.getTenantId());
|
||||
String phone = trimToNull(param.getPhone());
|
||||
if (Func.isBlank(phone)) {
|
||||
throw new ServiceException("手机号不能为空");
|
||||
}
|
||||
// 先查后插:命中即返回,不新建也不改身份。租户为空时唯一键对 NULL 不去重,
|
||||
// 故 resolveEnsureTenantId 已在写库前把租户兜到非空。
|
||||
MpUser existed = getByPhone(tenantId, phone);
|
||||
if (existed != null && Func.isNotEmpty(existed.getId())) {
|
||||
return existed;
|
||||
}
|
||||
MpUser user = new MpUser();
|
||||
user.setTenantId(tenantId);
|
||||
user.setPhone(phone);
|
||||
user.setAccount(phone);
|
||||
user.setIdentities(MpUserIdentity.DRIVER.getCode());
|
||||
user.setOpenid(trimToNull(param.getOpenid()));
|
||||
user.setStatus(1);
|
||||
prepare(user);
|
||||
validate(user);
|
||||
// 登录路径不设密码,password 保持为空
|
||||
user.setPassword(null);
|
||||
try {
|
||||
save(user);
|
||||
} catch (Exception exception) {
|
||||
// 并发建号撞 (tenant_id, phone) 唯一键:重读一次并返回已有账号
|
||||
MpUser concurrent = getByPhone(tenantId, phone);
|
||||
if (concurrent != null && Func.isNotEmpty(concurrent.getId())) {
|
||||
return concurrent;
|
||||
}
|
||||
log.warn("小程序用户建号失败,租户={} 手机号={}", tenantId, phone, exception);
|
||||
throw new ServiceException("创建小程序用户失败");
|
||||
}
|
||||
if (Func.isEmpty(user.getId())) {
|
||||
throw new ServiceException("创建小程序用户失败");
|
||||
}
|
||||
MpUserCache.clear();
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录路径的租户解析:Tenant-Id 头 → 入参 tenantId → 默认租户
|
||||
* <p>
|
||||
* 请求头与入参不一致时以请求头为准。写库前必须得到非空值:唯一键 (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<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)
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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.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<DriverEnsureProfileVO> ensureProfile(@RequestBody DriverEnsureProfileDTO param) {
|
||||
if (param == null) {
|
||||
return R.fail("司机档案参数不能为空");
|
||||
}
|
||||
return R.data(driverService.ensureProfile(param.getTenantId(), param.getMpUserId(), param.getPhone()));
|
||||
}
|
||||
|
||||
}
|
||||
+11
@@ -30,6 +30,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
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.DriverExpiryStatVO;
|
||||
import org.springblade.transport.pojo.vo.DriverVO;
|
||||
|
||||
@@ -59,6 +60,16 @@ public interface IDriverService extends BaseService<Driver> {
|
||||
*/
|
||||
boolean submit(Driver driver);
|
||||
|
||||
/**
|
||||
* 查找或创建司机档案并绑定小程序账号(登录路径专用)
|
||||
*
|
||||
* @param tenantId 租户ID,必须非空
|
||||
* @param mpUserId 小程序用户ID
|
||||
* @param phone 手机号
|
||||
* @return 档案ID与本次处置标志;冲突时 message 携带可读原因
|
||||
*/
|
||||
DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone);
|
||||
|
||||
/**
|
||||
* 修改状态
|
||||
*
|
||||
|
||||
+6
-6
@@ -349,8 +349,8 @@ public class CertAuditServiceImpl implements ICertAuditService {
|
||||
if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) {
|
||||
return;
|
||||
}
|
||||
// 身份证号要落到档案上,先确认它没有落在别人名下:该列没有唯一索引,
|
||||
// 直接写会静默产生两条同证件号的档案,管理端的证件号查重从此失效
|
||||
// 身份证号要落到档案上,先确认它没有落在别人名下:重复证件号一旦落库,
|
||||
// 建档查重与按证件号识别同一人都会失效
|
||||
requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId());
|
||||
driver.setDriverName(snapshot.getRealName());
|
||||
driver.setIdCardNo(snapshot.getIdCardNo());
|
||||
@@ -382,10 +382,10 @@ public class CertAuditServiceImpl implements ICertAuditService {
|
||||
/**
|
||||
* 身份证号不得落在另一名司机名下
|
||||
* <p>
|
||||
* 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据,
|
||||
* 而 {@code blade_transport_driver.id_card_no} 上没有唯一索引:若申报的证件号
|
||||
* 已属于另一条档案,直接写会静默产生两条同证件号的档案,后续建档查重、
|
||||
* 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。
|
||||
* 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据。
|
||||
* {@code blade_transport_driver.id_card_no} 上有唯一索引
|
||||
* {@code uk_transport_driver_id_card};证件号已属于另一条档案时,
|
||||
* 此处阻断审核并整体回滚,交由管理端先处理档案归属。
|
||||
* <p>
|
||||
* 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断;
|
||||
* 快照没带证件号时无从判断,同样不阻断(历史快照就是这种)。
|
||||
|
||||
+140
@@ -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<DriverMapper, Driver> implements IDriverService {
|
||||
@@ -74,6 +78,16 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
private static final int REMARK_MAX_LENGTH = 200;
|
||||
private static final int DEFAULT_ENABLED_STATUS = 1;
|
||||
private static final int DEFAULT_FALSE = 0;
|
||||
/**
|
||||
* 档案来源:登录时自动新建
|
||||
*/
|
||||
private static final String PROFILE_SOURCE_LOGIN_AUTO = "login_auto";
|
||||
/**
|
||||
* 档案来源:绑定到已有档案
|
||||
*/
|
||||
private static final String PROFILE_SOURCE_LOGIN_BOUND = "login_bound";
|
||||
private static final String CONFLICT_MOBILE_OCCUPIED = "该手机号已被其他账号占用,请联系管理员";
|
||||
private static final String CONFLICT_MOBILE_MULTIPLE = "该手机号存在多条司机档案,请联系管理员核对";
|
||||
|
||||
private final IMpUserClient mpUserClient;
|
||||
private final ISysClient sysClient;
|
||||
@@ -101,6 +115,132 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone) {
|
||||
String resolvedTenantId = trimToNull(tenantId);
|
||||
if (resolvedTenantId == null) {
|
||||
resolvedTenantId = trimToNull(AuthUtil.getTenantId());
|
||||
}
|
||||
// 租户是档案归属与手机号查重的范围,tenant_id 为空的档案不能被后续同号查询命中,
|
||||
// 故此处拒绝写库(登录层只记 WARN)
|
||||
if (resolvedTenantId == null) {
|
||||
throw new ServiceException("租户信息为空,无法创建司机档案");
|
||||
}
|
||||
if (Func.isEmpty(mpUserId)) {
|
||||
throw new ServiceException("小程序用户ID不能为空");
|
||||
}
|
||||
String mobile = trimToNull(phone);
|
||||
if (mobile == null) {
|
||||
throw new ServiceException("手机号不能为空");
|
||||
}
|
||||
// 查找顺序固定为 user_id → phone:user_id 是账号的稳定标识,
|
||||
// phone 会被运营商二次放号
|
||||
Driver bound = findDriverByUserId(mpUserId);
|
||||
if (bound != null) {
|
||||
return ensureResult(bound.getId(), false, false, null);
|
||||
}
|
||||
List<Driver> sameMobile = list(Wrappers.<Driver>lambdaQuery()
|
||||
.eq(Driver::getTenantId, resolvedTenantId)
|
||||
.eq(Driver::getMobile, mobile));
|
||||
if (sameMobile.isEmpty()) {
|
||||
return createPlaceholderProfile(resolvedTenantId, mpUserId, mobile);
|
||||
}
|
||||
if (sameMobile.size() > 1) {
|
||||
return ensureResult(null, false, false, CONFLICT_MOBILE_MULTIPLE);
|
||||
}
|
||||
Driver existing = sameMobile.get(0);
|
||||
if (Func.isNotEmpty(existing.getUserId())) {
|
||||
if (Objects.equals(existing.getUserId(), mpUserId)) {
|
||||
return ensureResult(existing.getId(), false, false, null);
|
||||
}
|
||||
return ensureResult(null, false, false, CONFLICT_MOBILE_OCCUPIED);
|
||||
}
|
||||
return bindExistingProfile(existing.getId(), mpUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按小程序账号反查档案;user_id 有全局唯一索引,故不叠加租户条件
|
||||
*/
|
||||
private Driver findDriverByUserId(Long mpUserId) {
|
||||
return getOne(Wrappers.<Driver>lambdaQuery()
|
||||
.eq(Driver::getUserId, mpUserId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建占位档案
|
||||
* <p>
|
||||
* 不走 {@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) {
|
||||
|
||||
+7
-7
@@ -39,12 +39,12 @@ import org.springframework.stereotype.Component;
|
||||
* 判据是生效快照指针,不是「最新提交状态」展示缓存——重新提交认证会把展示缓存
|
||||
* 变成「审核中」,照它判定会让司机一提交就接不了单。生效快照只在审核通过时切换。
|
||||
* <p>
|
||||
* 两个开关默认开启,可关掉以回退到不校验认证的行为:
|
||||
* 两个开关默认关闭,接单不校验认证;显式置 true 恢复拦截:
|
||||
* <pre>
|
||||
* transport:
|
||||
* certification:
|
||||
* vehicle-gate-enabled: false
|
||||
* driver-gate-enabled: false
|
||||
* vehicle-gate-enabled: true
|
||||
* driver-gate-enabled: true
|
||||
* </pre>
|
||||
*
|
||||
* @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;
|
||||
|
||||
/**
|
||||
* 判定车辆是否可用
|
||||
|
||||
+269
@@ -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;
|
||||
|
||||
/**
|
||||
* 登录自动建档与绑定已有档案的测试。
|
||||
* <p>
|
||||
* 守住三条不变式:按 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<Driver> captor = ArgumentCaptor.forClass(Driver.class);
|
||||
verify(driverMapper).insert(captor.capture());
|
||||
Driver saved = captor.getValue();
|
||||
assertEquals(TENANT_ID, saved.getTenantId(), "tenant_id 必须显式写入且非空");
|
||||
assertEquals(MP_USER_ID, saved.getUserId());
|
||||
assertEquals(MOBILE, saved.getMobile());
|
||||
assertEquals("司机8524", saved.getDriverName());
|
||||
assertNull(saved.getIdCardNo(), "身份证号留 NULL 才是「未采集」,也是补全判据");
|
||||
assertEquals("", saved.getDrivingType());
|
||||
assertEquals("", saved.getDriverType());
|
||||
assertEquals("", saved.getOrganizationName());
|
||||
assertEquals("", saved.getEmergencyContactName());
|
||||
assertEquals("", saved.getEmergencyContactMobile());
|
||||
assertEquals(1, saved.getStatus());
|
||||
assertEquals("login_auto", saved.getProfileSource());
|
||||
assertNotNull(saved.getAccountBoundTime());
|
||||
verify(driverMapper, never()).updateById(any(Driver.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 绑定已有档案
|
||||
|
||||
@Test
|
||||
@DisplayName("命中同号未绑定的档案:绑定并留痕,patch 必须带上 driving_vehicle 原值")
|
||||
void bindsExistingProfileAndKeepsDrivingVehicle() {
|
||||
Driver existing = driver(DRIVER_ID, null, PLATE);
|
||||
stubUserLookup();
|
||||
when(driverMapper.selectList(any())).thenReturn(List.of(existing));
|
||||
when(driverMapper.selectById(DRIVER_ID)).thenReturn(existing);
|
||||
when(driverMapper.updateById(any(Driver.class))).thenReturn(1);
|
||||
|
||||
DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE);
|
||||
|
||||
assertFalse(result.getCreated());
|
||||
assertTrue(result.getBoundExisting());
|
||||
assertNull(result.getMessage());
|
||||
assertEquals(DRIVER_ID, result.getDriverId());
|
||||
|
||||
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
|
||||
verify(driverMapper).updateById(captor.capture());
|
||||
Driver patch = captor.getValue();
|
||||
assertEquals(MP_USER_ID, patch.getUserId());
|
||||
assertEquals("login_bound", patch.getProfileSource());
|
||||
assertNotNull(patch.getAccountBoundTime());
|
||||
assertEquals(PLATE, patch.getDrivingVehicle(),
|
||||
"绑定 patch 不带 driving_vehicle 会把司机的车辆绑定写掉");
|
||||
assertNull(patch.getMobile(), "绑定不得覆盖档案上的手机号");
|
||||
verify(driverMapper, never()).insert(any(Driver.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 幂等重入
|
||||
|
||||
@Test
|
||||
@DisplayName("按 user_id 命中:直接返回该档案,不新建、不改手机号")
|
||||
void returnsExistingProfileForRepeatLogin() {
|
||||
stubUserLookup(driver(DRIVER_ID, MP_USER_ID, PLATE));
|
||||
|
||||
DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE);
|
||||
|
||||
assertFalse(result.getCreated());
|
||||
assertFalse(result.getBoundExisting());
|
||||
assertNull(result.getMessage());
|
||||
assertEquals(DRIVER_ID, result.getDriverId());
|
||||
verify(driverMapper, never()).insert(any(Driver.class));
|
||||
verify(driverMapper, never()).updateById(any(Driver.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("并发重复建档撞唯一键:重查后按已存在的档案返回,不把异常抛给登录方")
|
||||
void toleratesConcurrentInsert() {
|
||||
Driver concurrent = driver(DRIVER_ID, MP_USER_ID, PLATE);
|
||||
stubUserLookup(null, concurrent);
|
||||
when(driverMapper.selectList(any())).thenReturn(Collections.emptyList());
|
||||
when(driverMapper.insert(any(Driver.class))).thenThrow(new DuplicateKeyException("uk_transport_driver_user"));
|
||||
|
||||
DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE);
|
||||
|
||||
assertFalse(result.getCreated());
|
||||
assertFalse(result.getBoundExisting());
|
||||
assertEquals(DRIVER_ID, result.getDriverId());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 冲突
|
||||
|
||||
@Test
|
||||
@DisplayName("同手机号多条档案:拒绝绑定也不新建,返回可读原因")
|
||||
void rejectsMultipleProfilesWithSameMobile() {
|
||||
stubUserLookup();
|
||||
when(driverMapper.selectList(any())).thenReturn(
|
||||
List.of(driver(DRIVER_ID, null, PLATE), driver(9003L, null, "")));
|
||||
|
||||
DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE);
|
||||
|
||||
assertFalse(result.getCreated());
|
||||
assertFalse(result.getBoundExisting());
|
||||
assertNull(result.getDriverId());
|
||||
assertEquals("该手机号存在多条司机档案,请联系管理员核对", result.getMessage());
|
||||
verify(driverMapper, never()).insert(any(Driver.class));
|
||||
verify(driverMapper, never()).updateById(any(Driver.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同手机号档案已属于其他账号:不覆盖,返回可读原因")
|
||||
void rejectsMobileOwnedByAnotherAccount() {
|
||||
stubUserLookup();
|
||||
when(driverMapper.selectList(any())).thenReturn(
|
||||
List.of(driver(DRIVER_ID, OTHER_USER_ID, PLATE)));
|
||||
|
||||
DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE);
|
||||
|
||||
assertFalse(result.getCreated());
|
||||
assertFalse(result.getBoundExisting());
|
||||
assertNull(result.getDriverId());
|
||||
assertEquals("该手机号已被其他账号占用,请联系管理员", result.getMessage());
|
||||
verify(driverMapper, never()).insert(any(Driver.class));
|
||||
verify(driverMapper, never()).updateById(any(Driver.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 入参兜底
|
||||
|
||||
@Test
|
||||
@DisplayName("租户为空且当前上下文也没有租户:拒绝写库")
|
||||
void rejectsBlankTenant() {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getTenantId).thenReturn(null);
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.ensureProfile(null, MP_USER_ID, MOBILE));
|
||||
}
|
||||
verify(driverMapper, never()).insert(any(Driver.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
/**
|
||||
* 按 user_id 反查走 {@code getOne} → {@code baseMapper.selectOne};MyBatis-Plus 同时提供
|
||||
* 单参与带 throwEx 两个重载,桩必须两个都打,否则 mock 返回默认值、反查永远落空。
|
||||
*/
|
||||
private void stubUserLookup() {
|
||||
stubUserLookup((Driver) null);
|
||||
}
|
||||
|
||||
private void stubUserLookup(Driver first, Driver... rest) {
|
||||
when(driverMapper.selectOne(any())).thenReturn(first, rest);
|
||||
when(driverMapper.selectOne(any(), anyBoolean())).thenReturn(first, rest);
|
||||
}
|
||||
|
||||
private Driver driver(Long id, Long userId, 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);
|
||||
}
|
||||
|
||||
}
|
||||
+39
-9
@@ -11,6 +11,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
/**
|
||||
* 认证可用性门槛测试。
|
||||
* <p>
|
||||
* 开关默认关闭:未认证的司机与车辆都可接单;只有显式置 true 才按生效快照校验。
|
||||
* <p>
|
||||
* 关键场景:已认证的车被重新提交认证后仍可接单——判定必须走生效快照,
|
||||
* 而重新提交会把「最新提交状态」变成「审核中」。
|
||||
*/
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 全程保留,回滚不需要恢复它。
|
||||
Reference in New Issue
Block a user