org.springblade
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..b79f8497 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,7 @@ package org.springblade.auth.granter;
import jakarta.servlet.http.HttpServletRequest;
import org.springblade.auth.constant.BladeAuthConstant;
+import org.springblade.auth.service.MpLoginSupport;
import org.springblade.core.oauth2.constant.OAuth2TokenConstant;
import org.springblade.core.oauth2.exception.UserInvalidException;
import org.springblade.core.oauth2.granter.AbstractTokenGranter;
@@ -43,10 +44,15 @@ import org.springblade.core.tool.utils.SM2Util;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.core.tool.utils.WebUtil;
import org.springblade.resource.feign.ISmsClient;
+import org.springblade.system.pojo.enums.MpUserIdentity;
import org.springframework.stereotype.Component;
/**
* SmsTokenGranter
+ *
+ * 除验证码校验外,还要按请求来源分流:小程序(User-Type: mp)登录时账号不存在要自动建,
+ * Web 短信登录保持原样不建号。建号逻辑不能下沉到 {@code BladeUserDetailService.loadByPhone},
+ * 那条方法还被「发送短信验证码」复用,会在发码时就建出账号(刷号)。
*
* @author BladeX
*/
@@ -56,12 +62,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 +94,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 = isMpLoginRequest(request)
+ ? mpLoginSupport.ensureLoginAccount(tenantId, decryptedPhone, null)
+ : userService.loadByPhone(decryptedPhone, request);
// 校验用户信息
if (!userService.validateUser(user)) {
throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT);
@@ -97,6 +107,13 @@ public class SmsTokenGranter extends AbstractTokenGranter {
return user;
}
+ /**
+ * 是否小程序登录:请求头 User-Type=mp(Web 与小程序共用客户端,不能按 clientId 分流)
+ */
+ private boolean isMpLoginRequest(OAuth2Request request) {
+ return request != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(request.getUserType()));
+ }
+
private SmsCode buildSmsCode() {
HttpServletRequest request = WebUtil.getRequest();
return new SmsCode().setId(request.getParameter("id"))
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..101387c8 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.service.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/MpLoginSupport.java b/blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java
new file mode 100644
index 00000000..6032ebeb
--- /dev/null
+++ b/blade-auth/src/main/java/org/springblade/auth/service/MpLoginSupport.java
@@ -0,0 +1,338 @@
+/**
+ * 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.service;
+
+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 属于旧账号、新手机号已属于另一账号(票 05 Q2-A)
+ */
+ private static final String NOTICE_PHONE_OCCUPIED = "该微信已绑定其他账号,请联系管理员";
+
+ private final IMpUserClient mpUserClient;
+ private final IDriverClient driverClient;
+
+ /**
+ * 找到或创建本次登录使用的账号,并尽力补齐档案与 openid 绑定。
+ *
+ * 返回 {@link OAuth2User} 而不是 {@link MpUserInfo}:两条登录链路都需要 OAuth2User,
+ * 且 MpUserInfo 的唯一组装点(identities / permissions / detail)在 system 侧,
+ * 由本类统一转换可避免 auth 侧复制一份 detail 口径。
+ *
+ * @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。以 openid 账号为准会把用户
+ // 正在用的手机号账号顶掉,自动合并又要动档案归属(票 05)
+ 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 不建,避免污染司机列表与派单池(票 03)
+ String profileNotice = ensureProfile(account, phone, tenantId);
+ if (StringUtil.isBlank(loginNotice)) {
+ loginNotice = profileNotice;
+ }
+ // ④ 微信登录补绑 openid;失败只 WARN(票 05)
+ 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 账号的手机号换成新号(票 03: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/service/MpLoginSupportTest.java b/blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java
new file mode 100644
index 00000000..98617200
--- /dev/null
+++ b/blade-auth/src/test/java/org/springblade/auth/service/MpLoginSupportTest.java
@@ -0,0 +1,246 @@
+package org.springblade.auth.service;
+
+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;
+
+/**
+ * 登录编排测试。
+ *
+ * 守住票 02 / 03 / 05 的三条口径:建号失败才阻断登录;建档失败放行;
+ * 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/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 a0a12bab..c1e77ca9 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
@@ -319,6 +319,13 @@ public class MpUserServiceImpl extends BaseServiceImpl imp
if (!tenantId.equals(user.getTenantId())) {
throw new ServiceException("租户不匹配,无法绑定 openid");
}
+ // 目标账号已有另一个非空 openid 时不得覆盖:旧逻辑会静默顶掉上一个微信的绑定,
+ // 而一个微信换绑需要管理员确认,登录路径只放弃本次绑定(票 05)
+ 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)