diff --git a/blade-auth/pom.xml b/blade-auth/pom.xml index 8bfe4ea1..2aa552e9 100644 --- a/blade-auth/pom.xml +++ b/blade-auth/pom.xml @@ -83,6 +83,17 @@ org.springblade blade-resource-api + + + org.springblade + blade-transport-api + + + + org.springframework.boot + spring-boot-starter-test + test + org.springblade diff --git a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java index dfa3b898..8bbd2755 100644 --- a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java +++ b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java @@ -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); diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java index 540095c0..cd01f6ef 100644 --- a/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java +++ b/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java @@ -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 + *

+ * 按请求来源分流:小程序(User-Type: mp)登录时账号不存在要自动建,Web 短信登录不建号。 + * {@code BladeUserDetailService.loadByPhone} 同时被「发送短信验证码」复用,其中不得建号。 * * @author BladeX */ @@ -56,12 +61,14 @@ public class SmsTokenGranter extends AbstractTokenGranter { private final OAuth2UserService userService; private final ISmsClient smsClient; private final OAuth2Properties properties; + private final MpLoginSupport mpLoginSupport; - public SmsTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, ISmsClient smsClient, OAuth2Properties properties) { + public SmsTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, ISmsClient smsClient, OAuth2Properties properties, MpLoginSupport mpLoginSupport) { super(clientService, userService, passwordHandler); this.userService = userService; this.smsClient = smsClient; this.properties = properties; + this.mpLoginSupport = mpLoginSupport; } @Override @@ -86,8 +93,10 @@ public class SmsTokenGranter extends AbstractTokenGranter { if (!result.isSuccess()) { throw new UserInvalidException(OAuth2TokenConstant.CAPTCHA_NOT_CORRECT); } - // 获取用户信息 - OAuth2User user = userService.loadByPhone(decryptedPhone, request); + // 获取用户信息:小程序登录走自动建号编排,Web 短信登录只做查询 + OAuth2User user = MpLoginRequestSupport.isMp(request) + ? mpLoginSupport.ensureLoginAccount(tenantId, decryptedPhone, null) + : userService.loadByPhone(decryptedPhone, request); // 校验用户信息 if (!userService.validateUser(user)) { throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT); diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java index 33de5291..9d2bb75c 100644 --- a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java +++ b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java @@ -2,7 +2,7 @@ package org.springblade.auth.granter; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; -import org.springblade.auth.utils.TokenUtil; +import org.springblade.auth.support.MpLoginSupport; import org.springblade.core.oauth2.exception.UserInvalidException; import org.springblade.core.oauth2.granter.AbstractTokenGranter; import org.springblade.core.oauth2.handler.PasswordHandler; @@ -11,12 +11,9 @@ import org.springblade.core.oauth2.service.OAuth2Client; import org.springblade.core.oauth2.service.OAuth2ClientService; import org.springblade.core.oauth2.service.OAuth2User; import org.springblade.core.oauth2.service.OAuth2UserService; -import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; import org.springblade.core.tool.utils.WebUtil; -import org.springblade.system.feign.IMpUserClient; -import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.thirdparty.wechat.constant.WechatMiniConstant; import org.springblade.thirdparty.wechat.exception.WechatMiniException; import org.springblade.thirdparty.wechat.pojo.vo.WechatPhoneVO; @@ -28,8 +25,8 @@ import org.springframework.stereotype.Component; * 微信小程序手机号一键登录。 *

* grant_type=wechat_applet,参数:loginCode(wx.login)、phoneCode(getPhoneNumber)。 - * 流程:换 openid + 手机号 → 按手机号查小程序用户(blade_mp_user,不存在则拒绝) - * → 记录 openid → 按身份发令牌(不依赖系统角色)。 + * 流程:换 openid + 手机号 → 由 {@link MpLoginSupport} 找到或创建账号并补齐档案 + * → 按身份发令牌(不依赖系统角色)。 */ @Slf4j @Component @@ -37,17 +34,17 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { private final OAuth2UserService userService; private final IWechatMiniService wechatMiniService; - private final IMpUserClient mpUserClient; + private final MpLoginSupport mpLoginSupport; public WechatMiniTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, IWechatMiniService wechatMiniService, - IMpUserClient mpUserClient) { + MpLoginSupport mpLoginSupport) { super(clientService, userService, passwordHandler); this.userService = userService; this.wechatMiniService = wechatMiniService; - this.mpUserClient = mpUserClient; + this.mpLoginSupport = mpLoginSupport; } @Override @@ -82,28 +79,12 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { throw new UserInvalidException("未获取到微信手机号"); } - R 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 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; } diff --git a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java index 22904efe..10f5d6b7 100644 --- a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java +++ b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java @@ -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 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)) diff --git a/blade-auth/src/main/java/org/springblade/auth/support/MpLoginRequestSupport.java b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginRequestSupport.java new file mode 100644 index 00000000..012ffbbb --- /dev/null +++ b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginRequestSupport.java @@ -0,0 +1,54 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.auth.support; + +import org.springblade.core.oauth2.provider.OAuth2Request; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.pojo.enums.MpUserIdentity; + +/** + * 登录请求来源判定 + * + * @author Chill + */ +public final class MpLoginRequestSupport { + + private MpLoginRequestSupport() { + } + + /** + * 是否小程序请求:请求头 User-Type 为 mp + *

+ * Web 与小程序共用同一个客户端,来源只认请求头 User-Type。 + * + * @param request 登录请求,可为 null + * @return 请求头 User-Type 为 mp 时返回 true + */ + public static boolean isMp(OAuth2Request request) { + return request != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(request.getUserType())); + } + +} diff --git a/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java new file mode 100644 index 00000000..1a48b334 --- /dev/null +++ b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java @@ -0,0 +1,335 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.auth.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.auth.utils.TokenUtil; +import org.springblade.core.oauth2.exception.UserInvalidException; +import org.springblade.core.oauth2.service.OAuth2User; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.support.Kv; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.transport.feign.IDriverClient; +import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +/** + * 小程序登录编排:按 openid / 手机号找到或创建账号,并补齐司机档案与微信绑定。 + *

+ * 只有「建号失败」会让登录失败;建档与绑 openid 失败只记 WARN,不影响本次登录。 + * 本类只供登录链路调用。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MpLoginSupport { + + /** + * detail 中承载登录提示的键,前端从令牌的 detail 里读取 + */ + public static final String DETAIL_LOGIN_NOTICE = "loginNotice"; + + /** + * 换号撞键提示:openid 属于旧账号、新手机号已属于另一账号 + */ + private static final String NOTICE_PHONE_OCCUPIED = "该微信已绑定其他账号,请联系管理员"; + + private final IMpUserClient mpUserClient; + private final IDriverClient driverClient; + + /** + * 找到或创建本次登录使用的账号,并补齐档案与 openid 绑定。 + *

+ * 返回 {@link OAuth2User}:两条登录链路都消费 OAuth2User,而 MpUserInfo + * (identities / permissions / detail)只在 system 侧组装。 + * + * @param tenantId 请求租户,为空时由 system 侧兜到默认租户 + * @param phone 本次登录的手机号 + * @param openid 微信小程序 openid,短信登录传 null + * @return 通过用户校验之前的登录用户(身份校验与 setClient 由调用方完成) + */ + public OAuth2User ensureLoginAccount(String tenantId, String phone, String openid) { + if (StringUtil.isBlank(phone)) { + throw new UserInvalidException("未获取到手机号,无法登录"); + } + MpUser account; + String loginNotice = null; + // openid 已属于另一个账号:本次不迁移、也不再尝试绑定(与建档冲突提示区分开) + boolean openidOccupied = false; + // ① openid 命中即复用:openid 是稳定标识,手机号会被运营商二次放号 + MpUser openidAccount = findByOpenid(tenantId, openid); + if (openidAccount == null) { + // ② 未按 openid 命中:按手机号查找或创建(登录路径专用,已存在不抛异常) + account = ensureUser(tenantId, phone, openid); + } else { + account = openidAccount; + if (!samePhone(openidAccount.getPhone(), phone)) { + MpUser phoneAccount = findByPhone(tenantId, phone); + if (phoneAccount != null && !Objects.equals(phoneAccount.getId(), openidAccount.getId())) { + // ①-撞键:落到手机号账号,不迁移 openid + account = phoneAccount; + loginNotice = NOTICE_PHONE_OCCUPIED; + openidOccupied = true; + log.warn("微信换号撞键,登录落到手机号账号:租户={} openid账号={} 手机号账号={}", + tenantId, openidAccount.getId(), phoneAccount.getId()); + } else { + account = movePhone(openidAccount, phone); + if (account == null) { + // 更新手机号失败:并发时可能是手机号刚被别的账号占用,重查一次再决定落点 + MpUser concurrent = findByPhone(tenantId, phone); + if (concurrent != null && !Objects.equals(concurrent.getId(), openidAccount.getId())) { + account = concurrent; + loginNotice = NOTICE_PHONE_OCCUPIED; + openidOccupied = true; + log.warn("更新手机号失败后命中手机号账号:租户={} openid账号={} 手机号账号={}", + tenantId, openidAccount.getId(), concurrent.getId()); + } else { + // 仍然用 openid 账号登录:手机号未能更新不影响身份识别,下次登录再试 + account = openidAccount; + } + } + } + } + } + // ③ 身份含 driver 才建档;纯 admin 不建 + String profileNotice = ensureProfile(account, phone, tenantId); + if (StringUtil.isBlank(loginNotice)) { + loginNotice = profileNotice; + } + // ④ 微信登录补绑 openid;失败只 WARN + bindOpenid(account, openid, openidOccupied); + MpUserInfo userInfo = loadUserInfo(account); + attachNotice(userInfo, loginNotice); + return TokenUtil.convertMpUser(userInfo); + } + + private MpUser findByOpenid(String tenantId, String openid) { + if (StringUtil.isBlank(openid)) { + return null; + } + try { + R 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 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 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 账号的手机号换成新号 + *

+ * 只 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 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 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 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 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(); + } + +} diff --git a/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java b/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java new file mode 100644 index 00000000..0a363310 --- /dev/null +++ b/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java @@ -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; + +/** + * 发码端点的端分流测试。 + *

+ * 保住发码端点的分流口径:小程序端(User-Type: mp)对未注册手机号也发码, + * Web 端仍拒绝。 + * 同时保住「发码不建号」:mp 分支根本不查账号。 + */ +class Oauth2SmsEndpointTest { + + private static final String TENANT_ID = "000000"; + private static final String PHONE = "13800000001"; + private static final String SMS_CODE = "ali_reg"; + + private static String publicKey; + private static String privateKey; + + private ISmsClient smsClient; + private OAuth2UserService userService; + private Oauth2SmsEndpoint endpoint; + + @BeforeAll + static void initSm2Keys() { + AsymmetricCipherKeyPair keyPair = SM2Util.generateKeyPair(); + publicKey = SM2Util.getPublicKeyString(keyPair); + privateKey = SM2Util.getPrivateKeyString(keyPair); + } + + @BeforeEach + void setUp() { + OAuth2Properties properties = new OAuth2Properties(); + properties.setPublicKey(publicKey); + properties.setPrivateKey(privateKey); + smsClient = mock(ISmsClient.class); + userService = mock(OAuth2UserService.class); + endpoint = new Oauth2SmsEndpoint(smsClient, userService, properties); + when(smsClient.sendValidate(anyString(), anyString(), anyString())).thenReturn(R.data("sms-id-1")); + } + + @AfterEach + void clearRequestContext() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + @DisplayName("小程序 + 未注册手机号:发码成功,且完全不查账号(发码不建号)") + void mpUnregisteredPhoneSendsCode() { + bindRequest("mp"); + when(userService.loadByPhone(anyString(), any())).thenReturn(null); + + R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE); + + assertTrue(result.isSuccess()); + verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE); + verify(userService, never()).loadByPhone(anyString(), any()); + } + + @Test + @DisplayName("Web + 未注册手机号:仍返回手机号不存在,不发码") + void webUnregisteredPhoneIsRejected() { + bindRequest(null); + when(userService.loadByPhone(anyString(), any())).thenReturn(null); + + R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE); + + assertFalse(result.isSuccess()); + assertEquals(USER_PHONE_NOT_FOUND, result.getMsg()); + verify(smsClient, never()).sendValidate(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("Web + 已注册手机号:行为与改造前一致,正常发码") + void webRegisteredPhoneSendsCode() { + bindRequest(null); + when(userService.loadByPhone(anyString(), any())).thenReturn(mock(OAuth2User.class)); + + R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE); + + assertTrue(result.isSuccess()); + verify(userService).loadByPhone(anyString(), any()); + verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE); + } + + @Test + @DisplayName("小程序 + 已注册手机号:跳过注册查询,依旧发码") + void mpRegisteredPhoneSendsCode() { + bindRequest("mp"); + + R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE); + + assertTrue(result.isSuccess()); + verify(userService, never()).loadByPhone(anyString(), any()); + verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE); + } + + @Test + @DisplayName("密文无法解密:无论哪端都不发码") + void invalidCipherIsRejected() { + bindRequest("mp"); + + R result = endpoint.sendValidate(TENANT_ID, "deadbeef", SMS_CODE); + + assertFalse(result.isSuccess()); + assertEquals(USER_PHONE_NOT_FOUND, result.getMsg()); + verify(smsClient, never()).sendValidate(anyString(), anyString(), anyString()); + } + + /** + * 请求上下文决定 buildArgs() 读到什么头;Authorization 是 buildClientArgs 的前提 + */ + private void bindRequest(String userType) { + MockHttpServletRequest request = new MockHttpServletRequest(); + String credential = Base64.getEncoder() + .encodeToString("saber:saber_secret".getBytes(StandardCharsets.UTF_8)); + request.addHeader("Authorization", "Basic " + credential); + if (userType != null) { + request.addHeader("User-Type", userType); + } + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + private String encrypt(String plainPhone) { + return Hex.toHexString(SM2Util.encrypt(plainPhone, publicKey)); + } + +} diff --git a/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java b/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java new file mode 100644 index 00000000..4518e344 --- /dev/null +++ b/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java @@ -0,0 +1,246 @@ +package org.springblade.auth.support; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.core.oauth2.exception.UserInvalidException; +import org.springblade.core.oauth2.service.OAuth2User; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.support.Kv; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springblade.transport.feign.IDriverClient; +import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 登录编排测试。 + *

+ * 保住三条不变式:建号失败才阻断登录;建档失败放行; + * openid 换号撞键时落到手机号账号且不迁移 openid。 + */ +class MpLoginSupportTest { + + private static final String TENANT_ID = "000000"; + private static final String PHONE = "13800000001"; + private static final String NEW_PHONE = "13800000002"; + private static final String OPENID = "wx-openid-1"; + + private IMpUserClient mpUserClient; + private IDriverClient driverClient; + private MpLoginSupport mpLoginSupport; + + @BeforeEach + void setUp() { + mpUserClient = mock(IMpUserClient.class); + driverClient = mock(IDriverClient.class); + mpLoginSupport = new MpLoginSupport(mpUserClient, driverClient); + } + + @Test + @DisplayName("新手机号微信登录:建号 + 建档,登录成功且无提示") + void newPhoneCreatesAccountAndProfile() { + MpUser created = mpUser(11L, PHONE, OPENID, "driver"); + when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null)); + when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created)); + when(mpUserClient.userInfoById(11L)).thenReturn(R.data(userInfo(created, "driver"))); + when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult())); + + OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID); + + assertNotNull(user); + assertEquals("11", user.getUserId()); + assertNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE)); + ArgumentCaptor 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 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 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; + } + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java new file mode 100644 index 00000000..cdaf96ce --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java @@ -0,0 +1,58 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.feign; + +import org.springblade.core.tool.api.R; +import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +/** + * 司机档案 Feign 接口 + * + * @author Chill + */ +@FeignClient(value = "blade-transport") +public interface IDriverClient { + + String API_PREFIX = "/feign/client/driver"; + String ENSURE_PROFILE = API_PREFIX + "/ensure-profile"; + + /** + * 查找或创建司机档案,并把档案绑定到指定小程序账号 + *

+ * 幂等入口:按 user_id 命中时原样返回;冲突(同号多档案 / 已被其他账号占用) + * 不新建、不覆盖,通过返回值的 message 说明原因,不抛异常给登录方。 + * + * @param param 租户、小程序账号与手机号 + * @return 档案ID与本次处置标志 + */ + @PostMapping(ENSURE_PROFILE) + R ensureProfile(@RequestBody DriverEnsureProfileDTO param); + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java new file mode 100644 index 00000000..cab277e8 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java @@ -0,0 +1,55 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 司机档案「查找或创建并绑定账号」参数 + * + * @author Chill + */ +@Data +@Schema(description = "司机档案查找或创建参数") +public class DriverEnsureProfileDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "租户ID,为空时取当前请求租户;仍为空则拒绝写库") + private String tenantId; + + @Schema(description = "关联小程序用户ID(blade_mp_user.id)") + private Long mpUserId; + + @Schema(description = "手机号,仅在按账号未命中时用于查找已有档案") + private String phone; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java index fc7c3ce8..9c2007c1 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java @@ -40,6 +40,7 @@ import org.springblade.transport.jackson.FlexibleLocalDateDeserializer; import java.io.Serial; import java.time.LocalDate; +import java.time.LocalDateTime; /** * 司机管理实体类 @@ -223,6 +224,17 @@ public class Driver extends TenantEntity { @Schema(description = "关联小程序用户ID") @JsonSerialize(using = ToStringSerializer.class) private Long userId; + /** + * 档案来源:login_auto(登录自动建)/ login_bound(绑到已有档案)/ + * admin(后台建)/ cert_backfill(存量订正);NULL 表示来源未知 + */ + @Schema(description = "档案来源") + private String profileSource; + /** + * 档案与账号建立关联的时点 + */ + @Schema(description = "账号绑定时间") + private LocalDateTime accountBoundTime; /** * 与联系人关系 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java new file mode 100644 index 00000000..8859abad --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java @@ -0,0 +1,58 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 司机档案「查找或创建并绑定账号」结果 + * + * @author Chill + */ +@Data +@Schema(description = "司机档案查找或创建结果") +public class DriverEnsureProfileVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "命中的司机档案ID,冲突时为null") + private Long driverId; + + @Schema(description = "本次新建了档案") + private Boolean created; + + @Schema(description = "本次把已有档案绑定到了账号") + private Boolean boundExisting; + + @Schema(description = "冲突原因;成功时为null") + private String message; + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java index 0a0a6e4b..b381750b 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java @@ -24,6 +24,7 @@ package org.springblade.system.feign; import org.springblade.core.launch.constant.AppConstant; import org.springblade.core.tool.api.R; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; import org.springblade.system.pojo.entity.MpUser; import org.springblade.system.pojo.entity.MpUserInfo; import org.springframework.cloud.openfeign.FeignClient; @@ -46,6 +47,7 @@ public interface IMpUserClient { String USER_BY_ID = API_PREFIX + "/user-by-id"; String USER_BY_PHONE = API_PREFIX + "/user-by-phone"; String USER_BY_ACCOUNT = API_PREFIX + "/user-by-account"; + String USER_BY_OPENID = API_PREFIX + "/user-by-openid"; String USER_INFO_BY_ID = API_PREFIX + "/info-by-id"; String USER_INFO_BY_PHONE = API_PREFIX + "/info-by-phone"; String USER_INFO_BY_ACCOUNT = API_PREFIX + "/info-by-account"; @@ -53,6 +55,7 @@ public interface IMpUserClient { String UPDATE_USER = API_PREFIX + "/update-user"; String BIND_OPENID = API_PREFIX + "/bind-openid"; String SET_PASSWORD = API_PREFIX + "/set-password"; + String ENSURE_USER = API_PREFIX + "/ensure-user"; /** * 按主键查询 @@ -72,6 +75,18 @@ public interface IMpUserClient { @GetMapping(USER_BY_ACCOUNT) R userByAccount(@RequestParam("tenantId") String tenantId, @RequestParam("account") String account); + /** + * 按租户 + openid 查询 + */ + @GetMapping(USER_BY_OPENID) + R userByOpenid(@RequestParam("tenantId") String tenantId, @RequestParam("openid") String openid); + + /** + * 查找或创建小程序用户(登录路径专用,已存在时不抛异常、不改身份) + */ + @PostMapping(ENSURE_USER) + R ensureUser(@RequestBody MpUserEnsureDTO param); + /** * 按主键查询登录信息 */ diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java new file mode 100644 index 00000000..ea38a27c --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java @@ -0,0 +1,55 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 小程序用户「查找或创建」参数 + * + * @author Chill + */ +@Data +@Schema(description = "小程序用户查找或创建参数") +public class MpUserEnsureDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "租户ID,为空时依次取 Tenant-Id 请求头与默认租户") + private String tenantId; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "微信小程序openid,非空时随首次建号一并写入") + private String openid; + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java index 4d8eabfe..fae7ebfa 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java @@ -26,6 +26,7 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.AllArgsConstructor; import org.springblade.core.tenant.annotation.NonDS; import org.springblade.core.tool.api.R; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; import org.springblade.system.pojo.entity.MpUser; import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.system.service.IMpUserService; @@ -65,6 +66,18 @@ public class MpUserClient implements IMpUserClient { return R.data(mpUserService.getByAccount(tenantId, account)); } + @Override + @GetMapping(USER_BY_OPENID) + public R userByOpenid(String tenantId, String openid) { + return R.data(mpUserService.getByOpenid(tenantId, openid)); + } + + @Override + @PostMapping(ENSURE_USER) + public R ensureUser(@RequestBody MpUserEnsureDTO param) { + return R.data(mpUserService.ensureUser(param)); + } + @Override @GetMapping(USER_INFO_BY_ID) public R userInfoById(Long userId) { diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java index c0294ced..6b20f61d 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java @@ -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 getByAccount(String tenantId, String account); + /** + * 按租户 + 微信小程序 openid 查询 + */ + MpUser getByOpenid(String tenantId, String openid); + + /** + * 查找或创建小程序用户(登录路径专用) + *

+ * 与 {@link #submit} 的提交式语义相反:手机号已存在时直接返回已有账号,且不改其身份。 + */ + MpUser ensureUser(MpUserEnsureDTO param); + /** * 按主键组装登录信息 */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java index 93f744ba..125b63ce 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java @@ -24,7 +24,9 @@ package org.springblade.system.service.impl; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springblade.common.utils.PasswordRuleUtil; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; @@ -34,8 +36,10 @@ import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.DigestUtil; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; +import org.springblade.core.tool.utils.WebUtil; import org.springblade.system.cache.MpUserCache; import org.springblade.system.mapper.MpUserMapper; +import org.springblade.system.pojo.dto.MpUserEnsureDTO; import org.springblade.system.pojo.entity.MpUser; import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.system.pojo.enums.MpUserIdentity; @@ -53,10 +57,21 @@ import java.util.Objects; * * @author Chill */ +@Slf4j @Service @RequiredArgsConstructor public class MpUserServiceImpl extends BaseServiceImpl 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 imp .last("LIMIT 1")); } + @Override + public MpUser getByOpenid(String tenantId, String openid) { + if (Func.isBlank(tenantId) || Func.isBlank(openid)) { + return null; + } + return getOne(Wrappers.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 → 默认租户 + *

+ * 请求头与入参不一致时以请求头为准。写库前必须得到非空值:唯一键 (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 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.lambdaQuery() .eq(MpUser::getTenantId, tenantId) .eq(MpUser::getOpenid, openid) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java new file mode 100644 index 00000000..c5ab2d70 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java @@ -0,0 +1,61 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.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 ensureProfile(@RequestBody DriverEnsureProfileDTO param) { + if (param == null) { + return R.fail("司机档案参数不能为空"); + } + return R.data(driverService.ensureProfile(param.getTenantId(), param.getMpUserId(), param.getPhone())); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java index 45200175..8864ed8f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java @@ -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 { */ boolean submit(Driver driver); + /** + * 查找或创建司机档案并绑定小程序账号(登录路径专用) + * + * @param tenantId 租户ID,必须非空 + * @param mpUserId 小程序用户ID + * @param phone 手机号 + * @return 档案ID与本次处置标志;冲突时 message 携带可读原因 + */ + DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone); + /** * 修改状态 * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index 247239d0..14fa3cbf 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -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 { /** * 身份证号不得落在另一名司机名下 *

- * 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据, - * 而 {@code blade_transport_driver.id_card_no} 上没有唯一索引:若申报的证件号 - * 已属于另一条档案,直接写会静默产生两条同证件号的档案,后续建档查重、 - * 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。 + * 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据。 + * {@code blade_transport_driver.id_card_no} 上有唯一索引 + * {@code uk_transport_driver_id_card};证件号已属于另一条档案时, + * 此处阻断审核并整体回滚,交由管理端先处理档案归属。 *

* 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断; * 快照没带证件号时无从判断,同样不阻断(历史快照就是这种)。 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java index 825c3bc0..bd200eb8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java @@ -29,6 +29,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; @@ -45,6 +46,7 @@ import org.springblade.transport.excel.DriverExcel; import org.springblade.transport.mapper.DriverMapper; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; import org.springblade.transport.pojo.vo.DriverExpiryStatVO; import org.springblade.transport.pojo.vo.DriverVO; import org.springblade.transport.service.IDriverService; @@ -53,6 +55,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Objects; @@ -61,6 +64,7 @@ import java.util.Objects; * * @author Chill */ +@Slf4j @Service @RequiredArgsConstructor public class DriverServiceImpl extends BaseServiceImpl implements IDriverService { @@ -74,6 +78,16 @@ public class DriverServiceImpl extends BaseServiceImpl 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 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 sameMobile = list(Wrappers.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.lambdaQuery() + .eq(Driver::getUserId, mpUserId) + .last("LIMIT 1")); + } + + /** + * 新建占位档案 + *

+ * 不走 {@link #submit}:那条路径要求 15 项必填。这里只满足表约束—— + * id_card_no 留 NULL(唯一索引对 NULL 不去重,NULL 同时是「未采集」的判据), + * 其余 NOT NULL 列写空串或可读占位。 + */ + private DriverEnsureProfileVO createPlaceholderProfile(String tenantId, Long mpUserId, String mobile) { + Driver driver = new Driver(); + driver.setTenantId(tenantId); + driver.setUserId(mpUserId); + driver.setDriverName("司机" + mobileTail(mobile)); + driver.setMobile(mobile); + driver.setDrivingType(""); + driver.setDriverType(""); + driver.setOrganizationName(""); + driver.setEmergencyContactName(""); + driver.setEmergencyContactMobile(""); + driver.setStatus(DEFAULT_ENABLED_STATUS); + driver.setProfileSource(PROFILE_SOURCE_LOGIN_AUTO); + driver.setAccountBoundTime(LocalDateTime.now()); + try { + save(driver); + } catch (Exception exception) { + // 并发登录撞 uk_transport_driver_user:重查一次,说明另一个请求已经建好档案 + Driver concurrent = findDriverByUserId(mpUserId); + if (concurrent != null) { + return ensureResult(concurrent.getId(), false, false, null); + } + log.error("自动创建司机档案失败,租户={} 账号={}", tenantId, mpUserId, exception); + throw new ServiceException("创建司机档案失败"); + } + return ensureResult(driver.getId(), true, false, null); + } + + /** + * 把已有档案绑定到账号:只 patch 关联字段与留痕,不覆盖档案内容 + */ + private DriverEnsureProfileVO bindExistingProfile(Long driverId, Long mpUserId) { + Driver patch = new Driver(); + patch.setId(driverId); + patch.setUserId(mpUserId); + patch.setProfileSource(PROFILE_SOURCE_LOGIN_BOUND); + patch.setAccountBoundTime(LocalDateTime.now()); + // 只改 user_id 也必须带 driving_vehicle 原值,理由见 carryDrivingVehicle + carryDrivingVehicle(driverId, patch); + try { + updateById(patch); + } catch (Exception exception) { + // 并发绑定撞 uk_transport_driver_user:另一个请求已经把这个账号绑到档案上 + Driver concurrent = findDriverByUserId(mpUserId); + if (concurrent != null) { + return ensureResult(concurrent.getId(), false, false, null); + } + log.error("绑定司机档案失败,档案={} 账号={}", driverId, mpUserId, exception); + throw new ServiceException("绑定司机档案失败"); + } + return ensureResult(driverId, false, true, null); + } + + private DriverEnsureProfileVO ensureResult(Long driverId, boolean created, boolean boundExisting, String message) { + DriverEnsureProfileVO result = new DriverEnsureProfileVO(); + result.setDriverId(driverId); + result.setCreated(created); + result.setBoundExisting(boundExisting); + result.setMessage(message); + return result; + } + + private String mobileTail(String mobile) { + return mobile.length() <= 4 ? mobile : mobile.substring(mobile.length() - 4); + } + @Override @Transactional(rollbackFor = Exception.class) public boolean changeStatus(Long id, Integer status) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java index 5a4ed782..3fe5f102 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java @@ -39,12 +39,12 @@ import org.springframework.stereotype.Component; * 判据是生效快照指针,不是「最新提交状态」展示缓存——重新提交认证会把展示缓存 * 变成「审核中」,照它判定会让司机一提交就接不了单。生效快照只在审核通过时切换。 *

- * 两个开关默认开启,可关掉以回退到不校验认证的行为: + * 两个开关默认关闭,接单不校验认证;显式置 true 恢复拦截: *

  * transport:
  *   certification:
- *     vehicle-gate-enabled: false
- *     driver-gate-enabled: false
+ *     vehicle-gate-enabled: true
+ *     driver-gate-enabled: true
  * 
* * @author Chill @@ -57,14 +57,14 @@ public class CertificationGate { /** * 车辆认证门槛开关 */ - @Value("${transport.certification.vehicle-gate-enabled:true}") - private boolean vehicleGateEnabled = true; + @Value("${transport.certification.vehicle-gate-enabled:false}") + private boolean vehicleGateEnabled = false; /** * 司机认证门槛开关 */ - @Value("${transport.certification.driver-gate-enabled:true}") - private boolean driverGateEnabled = true; + @Value("${transport.certification.driver-gate-enabled:false}") + private boolean driverGateEnabled = false; /** * 判定车辆是否可用 diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java new file mode 100644 index 00000000..4c013e4d --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java @@ -0,0 +1,269 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.feign.ISysClient; +import org.springblade.transport.mapper.DriverMapper; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; +import org.springframework.dao.DuplicateKeyException; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 登录自动建档与绑定已有档案的测试。 + *

+ * 守住三条不变式:按 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 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 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 = 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); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java index 8a9bbb0f..3f21819a 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java @@ -11,6 +11,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** * 认证可用性门槛测试。 *

+ * 开关默认关闭:未认证的司机与车辆都可接单;只有显式置 true 才按生效快照校验。 + *

* 关键场景:已认证的车被重新提交认证后仍可接单——判定必须走生效快照, * 而重新提交会把「最新提交状态」变成「审核中」。 */ @@ -26,14 +28,33 @@ class CertificationGateTest { // ------------------------------------------------------------ 开关 @Test - @DisplayName("门槛默认开启") - void enabledByDefault() { - assertTrue(gate.isVehicleGateEnabled()); - assertTrue(gate.isDriverGateEnabled()); + @DisplayName("门槛默认关闭") + void disabledByDefault() { + assertFalse(gate.isVehicleGateEnabled()); + assertFalse(gate.isDriverGateEnabled()); } @Test - @DisplayName("关闭后不因未认证而拦截(便于快速回退)") + @DisplayName("默认关闭:未认证的司机与车辆都不拦") + void disabledByDefaultLetsUncertifiedThrough() { + assertTrue(gate.isVehicleAvailable(Boolean.TRUE, null)); + assertTrue(gate.isDriverAvailable(null)); + } + + @Test + @DisplayName("显式置 true:未认证的司机与车辆重新被拒") + void explicitlyEnabledRejectsUncertified() { + gate.setVehicleGateEnabled(true); + gate.setDriverGateEnabled(true); + + assertFalse(gate.isDriverAvailable(null)); + assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null)); + assertTrue(gate.driverUnavailableReason().contains("认证")); + assertTrue(gate.vehicleUnavailableReason(Boolean.TRUE, null).contains("认证")); + } + + @Test + @DisplayName("显式关闭后不因未认证而拦截(配置回退路径)") void disabledLetsEveryoneThrough() { gate.setVehicleGateEnabled(false); gate.setDriverGateEnabled(false); @@ -45,28 +66,32 @@ class CertificationGateTest { // ------------------------------------------------------------ 车辆 @Test - @DisplayName("车辆:有生效快照即可用(核心:重新提交不影响可用性)") + @DisplayName("车辆:开启门槛时有生效快照即可用(核心:重新提交不影响可用性)") void vehicleWithEffectiveSnapshotIsAvailable() { + gate.setVehicleGateEnabled(true); assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L)); } @Test @DisplayName("车辆:重新提交后【状态变成审核中】但仍有生效快照 → 仍可用") void resubmittedVehicleStaysAvailable() { + gate.setVehicleGateEnabled(true); // 展示缓存变成「审核中」,但 effective_cert_record_id 仍指着上一次通过的记录。 assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L), "已认证的车重新提交后必须仍可接单,否则司机一提交就停工"); } @Test - @DisplayName("车辆:从未认证(无生效快照)被拒绝") + @DisplayName("车辆:开启门槛时从未认证(无生效快照)被拒绝") void vehicleWithoutEffectiveSnapshotIsRejected() { + gate.setVehicleGateEnabled(true); assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null)); } @Test @DisplayName("车辆:停用状态优先于认证——停用的车即便有生效快照也不可用") void disabledVehicleIsRejectedEvenIfCertified() { + gate.setVehicleGateEnabled(true); assertFalse(gate.isVehicleAvailable(Boolean.FALSE, 7001L), "启用/停用是运营状态,认证门槛只叠加不覆盖"); assertFalse(gate.isVehicleAvailable(null, 7001L)); @@ -75,20 +100,23 @@ class CertificationGateTest { // ------------------------------------------------------------ 司机 @Test - @DisplayName("司机:有生效快照即可用") + @DisplayName("司机:开启门槛时有生效快照即可用") void driverWithEffectiveSnapshotIsAvailable() { + gate.setDriverGateEnabled(true); assertTrue(gate.isDriverAvailable(9001L)); } @Test - @DisplayName("司机:从未认证被拒绝") + @DisplayName("司机:开启门槛时从未认证被拒绝") void driverWithoutEffectiveSnapshotIsRejected() { + gate.setDriverGateEnabled(true); assertFalse(gate.isDriverAvailable(null)); } @Test @DisplayName("司机门槛可单独关闭") void driverGateCanBeDisabledIndependently() { + gate.setVehicleGateEnabled(true); gate.setDriverGateEnabled(false); assertTrue(gate.isDriverAvailable(null), "司机门槛关闭时不应拦截"); assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null), "车辆门槛不受影响"); @@ -99,6 +127,7 @@ class CertificationGateTest { @Test @DisplayName("停用与未认证分开报:前者让人去启用车辆,后者才让人去认证") void unavailableReasonsDistinguishDisabledFromUncertified() { + gate.setVehicleGateEnabled(true); String disabled = gate.vehicleUnavailableReason(Boolean.FALSE, 7001L); assertTrue(disabled.contains("停用"), disabled); assertFalse(disabled.contains("认证审核"), "已认证的车被停用,不该报成认证没通过"); @@ -111,6 +140,7 @@ class CertificationGateTest { @Test @DisplayName("可用时没有原因;两种不可用文案都不泄露内部字段名") void availableHasNoReasonAndTextIsReadable() { + gate.setVehicleGateEnabled(true); assertNull(gate.vehicleUnavailableReason(Boolean.TRUE, 7001L)); assertFalse(gate.vehicleUnavailableReason(Boolean.TRUE, null).contains("effective_cert_record_id")); assertFalse(gate.vehicleUnavailableReason(Boolean.FALSE, null).contains("effective_cert_record_id")); diff --git a/doc/sql/transport/blade_transport_driver.sql b/doc/sql/transport/blade_transport_driver.sql index 19809bf5..e81e8d17 100644 --- a/doc/sql/transport/blade_transport_driver.sql +++ b/doc/sql/transport/blade_transport_driver.sql @@ -6,7 +6,7 @@ CREATE TABLE `blade_transport_driver` ( `id` bigint(20) NOT NULL COMMENT '主键', `tenant_id` varchar(12) DEFAULT '000000' COMMENT '租户ID', `driver_name` varchar(20) NOT NULL COMMENT '司机姓名', - `id_card_no` varchar(18) NOT NULL COMMENT '身份证号', + `id_card_no` varchar(18) DEFAULT NULL COMMENT '身份证号,NULL 表示尚未采集', `birthday` date DEFAULT NULL COMMENT '出生年月', `gender` varchar(10) DEFAULT NULL COMMENT '性别', `nation` varchar(20) DEFAULT NULL COMMENT '民族', @@ -37,6 +37,8 @@ CREATE TABLE `blade_transport_driver` ( `driver_type` varchar(20) NOT NULL COMMENT '司机类型:自有/外协', `mobile` varchar(20) NOT NULL COMMENT '手机号', `user_id` bigint(20) DEFAULT NULL COMMENT '关联系统用户ID', + `profile_source` varchar(20) DEFAULT NULL COMMENT 'login_auto/login_bound/admin/cert_backfill,NULL 为改造前存量', + `account_bound_time` datetime DEFAULT NULL COMMENT '档案与账号建立关联的时点', `contact_relation` varchar(20) DEFAULT NULL COMMENT '与联系人关系', `organization_name` varchar(50) NOT NULL COMMENT '所属组织', `emergency_contact_name` varchar(20) NOT NULL COMMENT '紧急联系人姓名', @@ -54,6 +56,7 @@ CREATE TABLE `blade_transport_driver` ( `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除', PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `uk_transport_driver_id_card` (`id_card_no`) USING BTREE, + UNIQUE KEY `uk_transport_driver_user` (`user_id`) USING BTREE, KEY `idx_transport_driver_mobile` (`mobile`) USING BTREE, KEY `idx_transport_driver_type` (`driver_type`) USING BTREE, KEY `idx_transport_driver_org` (`organization_name`) USING BTREE, diff --git a/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql b/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql new file mode 100644 index 00000000..30d238d1 --- /dev/null +++ b/doc/sql/transport/blade_transport_driver_profile_account_20261023.sql @@ -0,0 +1,148 @@ +-- ---------------------------- +-- 司机档案与登录账号打通(2026-10-23) +-- ---------------------------- +-- 三处结构变更: +-- ① user_id 加唯一索引 uk_transport_driver_user —— 一个账号只能对应一条司机档案。 +-- ② id_card_no 放宽为可空 —— NULL 表示「身份证号尚未采集」,是判断档案是否完善的口径。 +-- uk_transport_driver_id_card 保留:MySQL 唯一索引对多行 NULL 不去重, +-- 并发创建多个占位档案不会被唯一键挡住。 +-- ③ 新增 profile_source / account_bound_time 两列留痕: +-- profile_source 取值 login_auto / login_bound / admin / cert_backfill, +-- NULL 表示改造前存量(来源未知,不臆造);account_bound_time 是档案与账号建立关联的时点。 +-- +-- 只做结构变更,不回填数据:存量档案的 profile_source / account_bound_time 一律保持 NULL。 +-- 判定「档案是否采集」用 id_card_no IS NULL,不另加标记列。 +-- +-- 幂等:每段先查 information_schema,条件已满足即跳过,重复执行不报错; +-- 全部用 PREPARE 动态语句而非存储过程,因此不需要 DELIMITER——按分号逐句切分的客户端 +-- (含数据库驱动)也能直接执行。 +-- +-- 前置检查(user_id 有重复时,第 1 节会以 +-- Duplicate entry '<值>' for key 'blade_transport_driver.uk_transport_driver_user' +-- 失败并终止;此时本脚本对表零修改,先修数据再重跑): +-- 唯一索引统计**全表**行,含 is_deleted = 1 的软删除档案:只按 is_deleted = 0 +-- 只能发现活跃重复,软删除行之间的重复同样会让第 1 节失败: +-- SELECT user_id, COUNT(*) AS dup_count FROM `blade_transport_driver` +-- WHERE user_id IS NOT NULL +-- GROUP BY user_id HAVING COUNT(*) > 1; +-- 修复口径:一个 user_id 只保留一条档案,其余把 user_id 置 NULL(不要改 id / is_deleted), +-- 确认无误后重跑本脚本。 +-- +-- 注意:uk_transport_driver_user 覆盖软删除行 —— is_deleted = 1 的档案仍占用 user_id, +-- 后台软删除某账号的档案后,该账号不能再建第二条档案(会撞唯一键); +-- 需要释放时,软删除前先把该行的 user_id 置 NULL。 + +SET @transport_driver_schema = DATABASE(); + +-- ---------------------------- +-- 0. 表存在性守卫 +-- ---------------------------- +-- 表不存在时故意执行一条只读查询,让客户端报 1146「Table ... doesn't exist」; +-- 否则下面每段都因查不到列/索引而静默跳过,脚本会假绿。 +SET @transport_driver_guard_sql = ( + SELECT IF(COUNT(*) > 0, + 'SELECT 1', + 'SELECT 1 FROM `blade_transport_driver` LIMIT 0') + FROM information_schema.tables + WHERE table_schema = @transport_driver_schema + AND table_name = 'blade_transport_driver' +); +PREPARE transport_driver_guard_stmt FROM @transport_driver_guard_sql; +EXECUTE transport_driver_guard_stmt; +DEALLOCATE PREPARE transport_driver_guard_stmt; + +-- ---------------------------- +-- 1. 账号唯一索引(先行执行:有重复时立刻失败,脚本对表零修改) +-- ---------------------------- +-- 只读诊断:有输出即为重复清单,先按上面口径修数据再往下跑。 +SELECT `user_id`, COUNT(*) AS `dup_count` + FROM `blade_transport_driver` + WHERE `user_id` IS NOT NULL + GROUP BY `user_id` +HAVING COUNT(*) > 1; + +-- 同名索引若已存在但是普通索引(non_unique = 1),本段会以 +-- Duplicate key name 'uk_transport_driver_user' 失败:先 DROP INDEX 再重跑本脚本。 +SET @transport_driver_user_index_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_transport_driver` ADD UNIQUE KEY `uk_transport_driver_user` (`user_id`) USING BTREE', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = @transport_driver_schema + AND table_name = 'blade_transport_driver' + AND index_name = 'uk_transport_driver_user' + AND non_unique = 0 +); +PREPARE transport_driver_user_index_stmt FROM @transport_driver_user_index_sql; +EXECUTE transport_driver_user_index_stmt; +DEALLOCATE PREPARE transport_driver_user_index_stmt; + +-- ---------------------------- +-- 2. 身份证号放宽为可空(唯一索引 uk_transport_driver_id_card 保留) +-- ---------------------------- +-- 条件写成「非空 或 长度不是 18」而不是只看 is_nullable: +-- 该列历史上曾被截短/加长过,只判可空性会漏掉类型漂移。 +SET @transport_driver_id_card_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_transport_driver` MODIFY COLUMN `id_card_no` varchar(18) NULL COMMENT ''身份证号,NULL 表示尚未采集''', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = @transport_driver_schema + AND table_name = 'blade_transport_driver' + AND column_name = 'id_card_no' + AND (is_nullable = 'NO' OR character_maximum_length <> 18) +); +PREPARE transport_driver_id_card_stmt FROM @transport_driver_id_card_sql; +EXECUTE transport_driver_id_card_stmt; +DEALLOCATE PREPARE transport_driver_id_card_stmt; + +-- ---------------------------- +-- 3. 来源留痕列:profile_source +-- ---------------------------- +SET @transport_driver_profile_source_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_transport_driver` ADD COLUMN `profile_source` varchar(20) DEFAULT NULL COMMENT ''login_auto/login_bound/admin/cert_backfill,NULL 为改造前存量'' AFTER `user_id`', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = @transport_driver_schema + AND table_name = 'blade_transport_driver' + AND column_name = 'profile_source' +); +PREPARE transport_driver_profile_source_stmt FROM @transport_driver_profile_source_sql; +EXECUTE transport_driver_profile_source_stmt; +DEALLOCATE PREPARE transport_driver_profile_source_stmt; + +-- ---------------------------- +-- 4. 绑定留痕列:account_bound_time +-- ---------------------------- +SET @transport_driver_account_bound_time_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_transport_driver` ADD COLUMN `account_bound_time` datetime DEFAULT NULL COMMENT ''档案与账号建立关联的时点'' AFTER `profile_source`', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = @transport_driver_schema + AND table_name = 'blade_transport_driver' + AND column_name = 'account_bound_time' +); +PREPARE transport_driver_account_bound_time_stmt FROM @transport_driver_account_bound_time_sql; +EXECUTE transport_driver_account_bound_time_stmt; +DEALLOCATE PREPARE transport_driver_account_bound_time_stmt; + +-- ---------------------------- +-- 5. 回滚 +-- ---------------------------- +-- 顺序与正向相反:先删索引与两列,最后把 id_card_no 收回 NOT NULL。 +-- 收窄之前必须先清掉 NULL,否则 ALTER 会以 +-- Invalid use of NULL value / Data truncated for column 'id_card_no' 失败。 +-- 占位档案的身份证号没有真实值可还原,回滚前须业务先决定「删档案」还是「补占位串」: +-- -- ① 处理 NULL(示例为删除登录自动建的占位档案;改为 UPDATE 成占位串亦可,注意会占唯一索引) +-- DELETE FROM `blade_transport_driver` +-- WHERE `id_card_no` IS NULL AND `profile_source` IN ('login_auto', 'login_bound'); +-- -- ② 收回身份证号非空与旧注释 +-- ALTER TABLE `blade_transport_driver` +-- MODIFY COLUMN `id_card_no` varchar(18) NOT NULL COMMENT '身份证号'; +-- -- ③ 删唯一索引、删两列 +-- ALTER TABLE `blade_transport_driver` DROP INDEX `uk_transport_driver_user`; +-- ALTER TABLE `blade_transport_driver` DROP COLUMN `profile_source`, DROP COLUMN `account_bound_time`; +-- +-- uk_transport_driver_id_card 全程保留,回滚不需要恢复它。