🐛 修复小程序重新授权因空账号判空失败报 500 且 openid 无法绑定

system 侧 R.data(null) 被空值序列化写成 {},Feign 侧反序列化出 id 为空的
MpUser 而非 null,被当作「已查到」:换号撞键分支误置 openidOccupied,导致
openid 永不绑定并提示「该微信已绑定其他账号」;手机号查不到账号时还会以
空主键、空租户调用 bindOpenId,系统侧判缺参 400,再被 Feign 降级包成
AssertionError(Error 而非 Exception)逃出 catch (Exception),登录返回 500。

- 新增 presentAccount():以主键判定是否命中,findByOpenid/findByPhone 统一走它
- bindOpenid 入口加守卫:缺主键或租户只记 WARN 后跳过,不再发空参数请求
- 补 2 条单测:空对象 data 视为未命中;不落空主键账号、不发空主键绑定
This commit is contained in:
2026-10-26 13:48:30 +08:00
parent 1b11d3f369
commit ccf3d7adf7
2 changed files with 58 additions and 2 deletions
@@ -144,7 +144,7 @@ public class MpLoginSupport {
}
try {
R<MpUser> result = mpUserClient.userByOpenid(tenantId, openid);
return isSuccess(result) ? result.getData() : null;
return presentAccount(isSuccess(result) ? result.getData() : null);
} catch (Exception exception) {
// 查询失败按未命中处理,随后由 ensureUser 触达系统服务并兜住建号失败
log.warn("按 openid 查询小程序账号失败:租户={} openid={}", tenantId, openid, exception);
@@ -155,13 +155,25 @@ public class MpLoginSupport {
private MpUser findByPhone(String tenantId, String phone) {
try {
R<MpUser> result = mpUserClient.userByPhone(tenantId, phone);
return isSuccess(result) ? result.getData() : null;
return presentAccount(isSuccess(result) ? result.getData() : null);
} catch (Exception exception) {
log.warn("按手机号查询小程序账号失败:租户={} 手机号={}", tenantId, phone, exception);
return null;
}
}
/**
* 远端「查无此账号」不等于客户端拿到 null
* <p>
* 服务端 R.data(null) 落 HTTP 时被 null 空值序列化写成 {}(BladeBeanSerializerModifier 对
* Object 属性 null 输出空对象),Feign 侧于是反序列化出一个 id 为空的 MpUser 实例而不是 null。
* 调用方只判 null,会把「没查到」当成「查到了空账号」,越过 ensureUser 建号并带着空主键去改库。
* 故一律以主键判定是否命中。
*/
private static MpUser presentAccount(MpUser account) {
return account == null || account.getId() == null ? null : account;
}
/**
* ② 查找或创建账号:登录路径上唯一允许让登录失败的动作
*/
@@ -267,6 +279,12 @@ public class MpLoginSupport {
if (account == null || StringUtil.isBlank(openid)) {
return;
}
// 空主键/空租户来自「查无账号」被反序列化成空对象,绑定请求会被系统侧判为缺参 400
if (account.getId() == null || StringUtil.isBlank(account.getTenantId())) {
log.warn("小程序账号缺少主键或租户,跳过绑定 openid:租户={} 账号={} openid={}",
account.getTenantId(), account.getId(), openid);
return;
}
// 撞键场景 openid 已属于另一个账号,明确不迁移,也不再尝试绑定
if (openidOccupied) {
return;
@@ -229,6 +229,44 @@ class MpLoginSupportTest {
verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString());
}
@Test
@DisplayName("远端查无账号返回空对象(id 为空):按未命中建号,并用真实主键补绑 openid")
void emptyAccountFromRemoteIsTreatedAsMiss() {
// 服务端 R.data(null) 落 HTTP 是 {},Feign 反序列化成 id 为空的 MpUser 而非 null
MpUser created = mpUser(61L, PHONE, null, "driver");
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(new MpUser()));
when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(new MpUser()));
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created));
when(mpUserClient.bindOpenId(TENANT_ID, 61L, OPENID)).thenReturn(R.data(Boolean.TRUE));
when(mpUserClient.userInfoById(61L)).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("61", user.getUserId());
assertNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE), "不该出现换号撞键提示");
verify(mpUserClient).ensureUser(any(MpUserEnsureDTO.class));
verify(mpUserClient, never()).updateUser(any(MpUser.class));
verify(mpUserClient).bindOpenId(TENANT_ID, 61L, OPENID);
}
@Test
@DisplayName("两个查询都返回空对象时:不落空账号、不发起带空主键的绑定")
void emptyAccountDoesNotLeakNullIdentityIntoBind() {
MpUser created = mpUser(62L, PHONE, OPENID, "driver");
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(new MpUser()));
when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(new MpUser()));
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created));
when(mpUserClient.userInfoById(62L)).thenReturn(R.data(userInfo(created, "driver")));
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
assertEquals("62", user.getUserId());
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);