feat: Implement app backdoor login and user listing for mini-programs with corresponding tests

This commit is contained in:
2026-07-21 12:05:38 +08:00
parent e53dc7da4e
commit 9039effee4
4 changed files with 252 additions and 9 deletions

View File

@@ -3,11 +3,15 @@ package com.ruoyi.web.controller.system;
import java.nio.charset.StandardCharsets;
import java.util.List;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.web.config.BackDoorLoginProperties;
import com.ruoyi.web.service.BackDoorLoginService;
import com.ruoyi.web.service.BackDoorLoginService.AppLoginSession;
import com.ruoyi.web.service.BackDoorLoginService.LoginSession;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
@@ -19,6 +23,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -88,6 +94,54 @@ public class BackDoorLoginController
}
}
/**
* 小程序测试授权用户列表。
*
* 与 /backDoor 一样仅在 backdoor.login.enabled=true 时存在,且只返回已关联
* AppUser 的最小化展示字段。
*/
@GetMapping("/app/backDoor/users")
public AjaxResult appBackDoorUsers(
@RequestParam(value = "keyword", required = false) String keyword)
{
try
{
return AjaxResult.success(backDoorLoginService.listAvailableAppUsers(keyword, properties.getMaxUsers()));
}
catch (Exception e)
{
log.error("加载小程序测试授权用户列表失败", e);
return AjaxResult.error("加载用户列表失败,请稍后重试");
}
}
/**
* 小程序测试授权登录。返回 AppUser 对应的 LoginSiteUser Token而非 PC SysUser Token。
*/
@PostMapping("/app/backDoor/login")
public AjaxResult appBackDoorLogin(@RequestBody(required = false) JSONObject body)
{
Long userId = body == null ? null : body.getLong("userId");
try
{
AppLoginSession session = backDoorLoginService.loginApp(userId);
AjaxResult ajax = AjaxResult.success();
ajax.put(Constants.TOKEN, session.getToken());
ajax.put("appUserId", session.getAppUser().getUserId());
ajax.put("isCompanyUser", session.getAppUser().getIsCompanyUser());
return ajax;
}
catch (ServiceException e)
{
return AjaxResult.error(e.getMessage());
}
catch (Exception e)
{
log.error("小程序测试授权登录失败userId={}", userId, e);
return AjaxResult.error("授权登录失败,请稍后重试");
}
}
private ResponseEntity<String> renderUserList(String keyword, int page)
{
try

View File

@@ -1,7 +1,9 @@
package com.ruoyi.web.service;
import java.util.List;
import java.util.stream.Collectors;
import com.ruoyi.common.core.domain.entity.AppUser;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.enums.UserStatus;
@@ -10,6 +12,7 @@ import com.ruoyi.framework.web.service.SysLoginService;
import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.framework.web.service.UserDetailsServiceImpl;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.ISysUserService;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -31,16 +34,19 @@ public class BackDoorLoginService
private final UserDetailsServiceImpl userDetailsService;
private final TokenService tokenService;
private final SysLoginService loginService;
private final ISysUserService sysUserService;
public BackDoorLoginService(SysUserMapper userMapper,
UserDetailsServiceImpl userDetailsService,
TokenService tokenService,
SysLoginService loginService)
SysLoginService loginService,
ISysUserService sysUserService)
{
this.userMapper = userMapper;
this.userDetailsService = userDetailsService;
this.tokenService = tokenService;
this.loginService = loginService;
this.sysUserService = sysUserService;
}
public List<SysUser> listAvailableUsers(String keyword, int limit)
@@ -54,7 +60,59 @@ public class BackDoorLoginService
return userMapper.selectBackDoorUserList(normalizedKeyword, safeLimit);
}
/**
* 返回可用于小程序测试授权的用户。
*
* 小程序会话的主体是 AppUser不能复用 PC 后门签发的 SysUser Token
* 因此这里只暴露已经关联 app_user 的有效测试账号。
*/
public List<AppLoginUser> listAvailableAppUsers(String keyword, int limit)
{
return listAvailableUsers(keyword, limit).stream()
.filter(user -> user.getAppUserId() != null && user.getAppUserId() > 0)
.map(AppLoginUser::new)
.collect(Collectors.toList());
}
public LoginSession login(Long userId)
{
SysUser user = getEffectiveUser(userId);
LoginUser loginUser = (LoginUser) userDetailsService.createLoginUser(user);
loginService.recordLoginInfo(user.getUserId());
String token = tokenService.createTokenHourTwo(loginUser);
log.warn("测试环境后门登录userId={}, userName={}", user.getUserId(), user.getUserName());
return new LoginSession(user, token);
}
/**
* 仅供测试环境小程序使用的后门授权。
*
* 先校验与 PC 后门相同的 SysUser 有效性,再通过 app_user_id 找到业务用户,
* 由既有小程序登录逻辑签发 LoginSiteUser Token。
*/
public AppLoginSession loginApp(Long userId)
{
SysUser user = getEffectiveUser(userId);
Long appUserId = user.getAppUserId();
if (appUserId == null || appUserId <= 0)
{
throw new ServiceException("该用户未关联小程序业务账号,不能授权登录");
}
AppUser appUser = sysUserService.selectAppUserById(appUserId);
if (appUser == null)
{
throw new ServiceException("关联的小程序业务账号不存在,不能授权登录");
}
String token = loginService.loginUserIdApp(appUser);
log.warn("测试环境小程序后门授权sysUserId={}, appUserId={}", user.getUserId(), appUser.getUserId());
return new AppLoginSession(user, appUser, token);
}
private SysUser getEffectiveUser(Long userId)
{
if (userId == null || userId <= 0)
{
@@ -64,13 +122,7 @@ public class BackDoorLoginService
SysUser user = userMapper.selectUserById(userId);
validateUser(user);
validateEffectiveAccount(user);
LoginUser loginUser = (LoginUser) userDetailsService.createLoginUser(user);
loginService.recordLoginInfo(user.getUserId());
String token = tokenService.createTokenHourTwo(loginUser);
log.warn("测试环境后门登录userId={}, userName={}", user.getUserId(), user.getUserName());
return new LoginSession(user, token);
return user;
}
private void validateUser(SysUser user)
@@ -123,4 +175,77 @@ public class BackDoorLoginService
return token;
}
}
/** 用于小程序后门列表的最小化用户信息,避免返回手机号、证件号等字段。 */
public static class AppLoginUser
{
private final Long userId;
private final Long appUserId;
private final String userName;
private final String nickName;
private final String displayName;
public AppLoginUser(SysUser user)
{
this.userId = user.getUserId();
this.appUserId = user.getAppUserId();
this.userName = user.getUserName();
this.nickName = user.getNickName();
this.displayName = StringUtils.defaultIfBlank(user.getNickName(), user.getUserName());
}
public Long getUserId()
{
return userId;
}
public Long getAppUserId()
{
return appUserId;
}
public String getUserName()
{
return userName;
}
public String getNickName()
{
return nickName;
}
public String getDisplayName()
{
return displayName;
}
}
public static class AppLoginSession
{
private final SysUser sysUser;
private final AppUser appUser;
private final String token;
public AppLoginSession(SysUser sysUser, AppUser appUser, String token)
{
this.sysUser = sysUser;
this.appUser = appUser;
this.token = token;
}
public SysUser getSysUser()
{
return sysUser;
}
public AppUser getAppUser()
{
return appUser;
}
public String getToken()
{
return token;
}
}
}

View File

@@ -2,9 +2,13 @@ package com.ruoyi.web.controller.system;
import java.util.Collections;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.AppUser;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.web.config.BackDoorLoginProperties;
import com.ruoyi.web.service.BackDoorLoginService;
import com.ruoyi.web.service.BackDoorLoginService.AppLoginSession;
import com.ruoyi.web.service.BackDoorLoginService.LoginSession;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -78,4 +82,24 @@ class BackDoorLoginControllerTest
assertFalse(response.getBody().contains("/management/"));
assertFalse(response.getBody().contains("?token=signed-token"));
}
@Test
void appBackDoorLoginReturnsSiteToken()
{
SysUser sysUser = new SysUser();
sysUser.setUserId(8L);
AppUser appUser = new AppUser();
appUser.setUserId(66L);
appUser.setIsCompanyUser("1");
when(loginService.loginApp(8L)).thenReturn(new AppLoginSession(sysUser, appUser, "site-token"));
JSONObject body = new JSONObject();
body.put("userId", 8L);
AjaxResult response = controller.appBackDoorLogin(body);
assertEquals(200, response.get("code"));
assertEquals("site-token", response.get("token"));
assertEquals(66L, response.get("appUserId"));
assertEquals("1", response.get("isCompanyUser"));
}
}

View File

@@ -3,6 +3,7 @@ package com.ruoyi.web.service;
import java.util.Collections;
import java.util.List;
import com.ruoyi.common.core.domain.entity.AppUser;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.exception.ServiceException;
@@ -10,6 +11,8 @@ import com.ruoyi.framework.web.service.SysLoginService;
import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.framework.web.service.UserDetailsServiceImpl;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.ISysUserService;
import com.ruoyi.web.service.BackDoorLoginService.AppLoginSession;
import com.ruoyi.web.service.BackDoorLoginService.LoginSession;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -35,13 +38,15 @@ class BackDoorLoginServiceTest
private TokenService tokenService;
@Mock
private SysLoginService sysLoginService;
@Mock
private ISysUserService sysUserService;
private BackDoorLoginService service;
@BeforeEach
void setUp()
{
service = new BackDoorLoginService(userMapper, userDetailsService, tokenService, sysLoginService);
service = new BackDoorLoginService(userMapper, userDetailsService, tokenService, sysLoginService, sysUserService);
}
@Test
@@ -113,6 +118,41 @@ class BackDoorLoginServiceTest
verify(tokenService, never()).createTokenHourTwo(org.mockito.ArgumentMatchers.any());
}
@Test
void loginAppCreatesSiteTokenForLinkedAppUser()
{
SysUser user = activeUser(12L);
user.setAppUserId(66L);
AppUser appUser = new AppUser();
appUser.setUserId(66L);
appUser.setName("测试求职者");
when(userMapper.selectUserById(12L)).thenReturn(user);
when(userMapper.selectBackDoorEffectiveUserId("tester")).thenReturn(12L);
when(sysUserService.selectAppUserById(66L)).thenReturn(appUser);
when(sysLoginService.loginUserIdApp(appUser)).thenReturn("site-test-token");
AppLoginSession session = service.loginApp(12L);
assertSame(user, session.getSysUser());
assertSame(appUser, session.getAppUser());
assertEquals("site-test-token", session.getToken());
verify(sysLoginService).loginUserIdApp(appUser);
verify(tokenService, never()).createTokenHourTwo(org.mockito.ArgumentMatchers.any());
}
@Test
void loginAppRejectsUserWithoutAppUserLink()
{
SysUser user = activeUser(12L);
when(userMapper.selectUserById(12L)).thenReturn(user);
when(userMapper.selectBackDoorEffectiveUserId("tester")).thenReturn(12L);
ServiceException exception = assertThrows(ServiceException.class, () -> service.loginApp(12L));
assertEquals("该用户未关联小程序业务账号,不能授权登录", exception.getMessage());
verify(sysLoginService, never()).loginUserIdApp(org.mockito.ArgumentMatchers.any());
}
private SysUser activeUser(Long userId)
{
SysUser user = new SysUser();