diff --git a/docs/test-backdoor-login.md b/docs/test-backdoor-login.md index f196ae0..327ef1d 100644 --- a/docs/test-backdoor-login.md +++ b/docs/test-backdoor-login.md @@ -6,7 +6,9 @@ http://47.111.103.66/shihezi/backDoor ``` -页面只显示 `sys_user` 中未删除、未停用的用户,支持按用户 ID、账号、姓名或手机号搜索。手机号会脱敏,身份证号不会展示。选择用户后,后端生成与 PC 单点登录相同类型的两小时 token,页面将 token 写入同源的 `access_token` 和 `refresh_token`,再根据 `/getInfo` 返回的用户类型进入求职门户或管理端。 +页面只显示 `sys_user` 中未删除、未停用的用户,支持按用户 ID、账号、姓名或手机号搜索。同一账号存在多条历史记录时,只显示 `create_time` 最新的有效记录;旧用户 ID 即使通过 `?id=` 直接访问也会被拒绝。手机号会脱敏,身份证号不会展示。选择用户后,后端生成与 PC 单点登录相同类型的两小时 token,页面将 token 写入同源的 `access_token` 和 `refresh_token`,然后无论用户类型都统一进入 `/shihezi/job-portal`。 + +用户选择页采用全屏布局,每页显示 8 个账号并提供分页,不产生页面滚动条或横向表格滚动条。 已知用户 ID 时,也可以直接访问: @@ -53,5 +55,4 @@ http://127.0.0.1:9091/backDoor backdoor: login: portal-base-path: /shihezi - api-base-path: /api/shihezi ``` diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/config/BackDoorLoginProperties.java b/ruoyi-admin/src/main/java/com/ruoyi/web/config/BackDoorLoginProperties.java index fb1723a..20914a3 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/config/BackDoorLoginProperties.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/config/BackDoorLoginProperties.java @@ -16,7 +16,6 @@ public class BackDoorLoginProperties private boolean enabled; private int maxUsers = DEFAULT_MAX_USERS; private String portalBasePath = "/shihezi"; - private String apiBasePath = "/api/shihezi"; public boolean isEnabled() { @@ -48,16 +47,6 @@ public class BackDoorLoginProperties this.portalBasePath = portalBasePath; } - public String getApiBasePath() - { - return normalizeBasePath(apiBasePath, "/api/shihezi"); - } - - public void setApiBasePath(String apiBasePath) - { - this.apiBasePath = apiBasePath; - } - private String normalizeBasePath(String value, String defaultValue) { if (value == null || value.trim().isEmpty()) diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorLoginController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorLoginController.java index 7d776ce..587f0bf 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorLoginController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorLoginController.java @@ -32,6 +32,7 @@ public class BackDoorLoginController { private static final Logger log = LoggerFactory.getLogger(BackDoorLoginController.class); private static final MediaType HTML_UTF8 = new MediaType("text", "html", StandardCharsets.UTF_8); + private static final int PAGE_SIZE = 8; private final BackDoorLoginService backDoorLoginService; private final BackDoorPageRenderer pageRenderer; @@ -49,11 +50,12 @@ public class BackDoorLoginController @GetMapping(value = "/backDoor", produces = "text/html;charset=UTF-8") public ResponseEntity backDoor( @RequestParam(value = "id", required = false) String id, - @RequestParam(value = "keyword", required = false) String keyword) + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "page", required = false) String page) { if (StringUtils.isBlank(id)) { - return renderUserList(keyword); + return renderUserList(keyword, parsePage(page)); } Long userId; @@ -86,13 +88,14 @@ public class BackDoorLoginController } } - private ResponseEntity renderUserList(String keyword) + private ResponseEntity renderUserList(String keyword, int page) { try { List users = backDoorLoginService.listAvailableUsers(keyword, properties.getMaxUsers()); return html(HttpStatus.OK, - pageRenderer.renderUserList(users, StringUtils.trimToEmpty(keyword), properties.getMaxUsers())); + pageRenderer.renderUserList(users, StringUtils.trimToEmpty(keyword), properties.getMaxUsers(), + page, PAGE_SIZE)); } catch (Exception e) { @@ -101,6 +104,22 @@ public class BackDoorLoginController } } + private int parsePage(String page) + { + if (StringUtils.isBlank(page)) + { + return 1; + } + try + { + return Math.max(1, Integer.parseInt(page.trim())); + } + catch (NumberFormatException e) + { + return 1; + } + } + private ResponseEntity html(HttpStatus status, String body) { return ResponseEntity.status(status) @@ -109,7 +128,7 @@ public class BackDoorLoginController .header(HttpHeaders.PRAGMA, "no-cache") .header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; " - + "connect-src 'self'; img-src data:; form-action 'self'; base-uri 'none'; " + + "img-src data:; form-action 'self'; base-uri 'none'; " + "frame-ancestors 'none'") .header("X-Content-Type-Options", "nosniff") .header("X-Frame-Options", "DENY") diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorPageRenderer.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorPageRenderer.java index b7de731..7873dcf 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorPageRenderer.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/BackDoorPageRenderer.java @@ -17,6 +17,7 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; import org.springframework.util.StreamUtils; import org.springframework.web.util.HtmlUtils; +import org.springframework.web.util.UriUtils; /** * 渲染后门用户选择页和登录跳转页。 @@ -38,14 +39,24 @@ public class BackDoorPageRenderer this.errorTemplate = loadTemplate("backdoor/error.html"); } - public String renderUserList(List users, String keyword, int maxUsers) + public String renderUserList(List users, String keyword, int maxUsers, int requestedPage, int pageSize) { List safeUsers = users == null ? Collections.emptyList() : users; + int safePageSize = Math.max(1, pageSize); + int totalPages = Math.max(1, (safeUsers.size() + safePageSize - 1) / safePageSize); + int currentPage = Math.max(1, Math.min(requestedPage, totalPages)); + int fromIndex = Math.min((currentPage - 1) * safePageSize, safeUsers.size()); + int toIndex = Math.min(fromIndex + safePageSize, safeUsers.size()); + List pageUsers = safeUsers.subList(fromIndex, toIndex); + return userListTemplate - .replace("@@ROWS@@", renderRows(safeUsers)) + .replace("@@ROWS@@", renderRows(pageUsers)) .replace("@@KEYWORD@@", html(keyword)) .replace("@@COUNT@@", String.valueOf(safeUsers.size())) - .replace("@@MAX_USERS@@", String.valueOf(maxUsers)); + .replace("@@MAX_USERS@@", String.valueOf(maxUsers)) + .replace("@@CURRENT_PAGE@@", String.valueOf(currentPage)) + .replace("@@TOTAL_PAGES@@", String.valueOf(totalPages)) + .replace("@@PAGINATION@@", renderPagination(keyword, currentPage, totalPages)); } public String renderLogin(LoginSession session, BackDoorLoginProperties properties) @@ -58,7 +69,6 @@ public class BackDoorPageRenderer .replace("@@USER_NAME@@", html(displayName)) .replace("@@TOKEN_JSON@@", javascriptString(session.getToken())) .replace("@@EXPIRE_AT@@", String.valueOf(expireAt)) - .replace("@@API_BASE_PATH_JSON@@", javascriptString(properties.getApiBasePath())) .replace("@@PORTAL_BASE_PATH_JSON@@", javascriptString(properties.getPortalBasePath())); } @@ -94,6 +104,44 @@ public class BackDoorPageRenderer return rows.toString(); } + private String renderPagination(String keyword, int currentPage, int totalPages) + { + StringBuilder pagination = new StringBuilder(512); + appendPageLink(pagination, keyword, currentPage - 1, "上一页", currentPage == 1, false); + for (int page = 1; page <= totalPages; page++) + { + appendPageLink(pagination, keyword, page, String.valueOf(page), false, page == currentPage); + } + appendPageLink(pagination, keyword, currentPage + 1, "下一页", currentPage == totalPages, false); + return pagination.toString(); + } + + private void appendPageLink(StringBuilder target, String keyword, int page, String label, + boolean disabled, boolean current) + { + if (disabled) + { + target.append("").append(label).append(""); + return; + } + if (current) + { + target.append("").append(label).append(""); + return; + } + + String href = "?page=" + page; + if (StringUtils.isNotBlank(keyword)) + { + href += "&keyword=" + UriUtils.encodeQueryParam(keyword, StandardCharsets.UTF_8); + } + target.append("") + .append(label) + .append(""); + } + private String maskPhone(String phone) { String value = StringUtils.trimToEmpty(phone); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/service/BackDoorLoginService.java b/ruoyi-admin/src/main/java/com/ruoyi/web/service/BackDoorLoginService.java index 7ecc71b..b388244 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/service/BackDoorLoginService.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/service/BackDoorLoginService.java @@ -63,6 +63,7 @@ public class BackDoorLoginService SysUser user = userMapper.selectUserById(userId); validateUser(user); + validateEffectiveAccount(user); LoginUser loginUser = (LoginUser) userDetailsService.createLoginUser(user); loginService.recordLoginInfo(user.getUserId()); @@ -92,6 +93,15 @@ public class BackDoorLoginService } } + private void validateEffectiveAccount(SysUser user) + { + Long effectiveUserId = userMapper.selectBackDoorEffectiveUserId(user.getUserName()); + if (!user.getUserId().equals(effectiveUserId)) + { + throw new ServiceException("该账号存在更新记录,请从用户列表选择有效账号"); + } + } + public static class LoginSession { private final SysUser user; diff --git a/ruoyi-admin/src/main/resources/application-test.yml b/ruoyi-admin/src/main/resources/application-test.yml index c85dcdf..bf9ca44 100644 --- a/ruoyi-admin/src/main/resources/application-test.yml +++ b/ruoyi-admin/src/main/resources/application-test.yml @@ -151,4 +151,3 @@ backdoor: enabled: true max-users: 100 portal-base-path: /shihezi - api-base-path: /api/shihezi diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 92aa158..9d56761 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -66,7 +66,6 @@ backdoor: enabled: false max-users: 100 portal-base-path: /shihezi - api-base-path: /api/shihezi # PageHelper分页插件 pagehelper: diff --git a/ruoyi-admin/src/main/resources/backdoor/login.html b/ruoyi-admin/src/main/resources/backdoor/login.html index b2b0733..98acc59 100644 --- a/ruoyi-admin/src/main/resources/backdoor/login.html +++ b/ruoyi-admin/src/main/resources/backdoor/login.html @@ -51,10 +51,9 @@

正在进入系统

正在以 @@USER_NAME@@ 的身份建立登录状态…

-

登录状态已建立,但自动跳转失败,请手动选择入口。

+

无法建立登录状态,请返回用户列表后重试。

@@ -64,43 +63,27 @@ const token = @@TOKEN_JSON@@; const expireAt = @@EXPIRE_AT@@; - const apiBase = @@API_BASE_PATH_JSON@@; const portalBase = @@PORTAL_BASE_PATH_JSON@@; const portalUrl = portalBase + '/job-portal'; - const managementUrl = portalBase + '/management/management/list/index?type=1'; - - ['userInfo', 'getInfoCache', 'appUserId', 'resume_userId'].forEach(function (key) { - localStorage.removeItem(key); - sessionStorage.removeItem(key); - }); - sessionStorage.removeItem('user'); - localStorage.setItem('access_token', token); - localStorage.setItem('refresh_token', token); - localStorage.setItem('expireTime', String(expireAt)); - localStorage.removeItem('lcToken'); document.getElementById('portalLink').href = portalUrl; - document.getElementById('managementLink').href = managementUrl; - fetch(apiBase + '/getInfo', { - method: 'GET', - cache: 'no-store', - credentials: 'same-origin', - headers: { 'Authorization': 'Bearer ' + token } - }) - .then(function (response) { - if (!response.ok) throw new Error('HTTP ' + response.status); - return response.json(); - }) - .then(function (result) { - if (result.code !== 200) throw new Error('getInfo failed'); - window.location.replace(result.userType === 'common' ? portalUrl : managementUrl); - }) - .catch(function () { - document.getElementById('spinner').style.display = 'none'; - document.getElementById('error').style.display = 'block'; - document.getElementById('actions').style.display = 'flex'; + try { + ['userInfo', 'getInfoCache', 'appUserId', 'resume_userId'].forEach(function (key) { + localStorage.removeItem(key); + sessionStorage.removeItem(key); }); + sessionStorage.removeItem('user'); + localStorage.setItem('access_token', token); + localStorage.setItem('refresh_token', token); + localStorage.setItem('expireTime', String(expireAt)); + localStorage.removeItem('lcToken'); + window.location.replace(portalUrl); + } catch (error) { + document.getElementById('spinner').style.display = 'none'; + document.getElementById('error').style.display = 'block'; + document.getElementById('actions').style.display = 'flex'; + } }()); diff --git a/ruoyi-admin/src/main/resources/backdoor/user-list.html b/ruoyi-admin/src/main/resources/backdoor/user-list.html index 94216c4..a280bb2 100644 --- a/ruoyi-admin/src/main/resources/backdoor/user-list.html +++ b/ruoyi-admin/src/main/resources/backdoor/user-list.html @@ -8,31 +8,31 @@ 测试用户登录 @@ -113,10 +158,18 @@ placeholder="输入用户 ID、账号、姓名或手机号搜索"> -
当前 @@COUNT@@ 条,最多显示 @@MAX_USERS@@ 条
+
共 @@COUNT@@ 个有效账号 · 第 @@CURRENT_PAGE@@ / @@TOTAL_PAGES@@ 页
+ + + + + + + + @@ -124,13 +177,16 @@ - + @@ROWS@@
用户 ID姓名 手机号 用户来源 / 部门操作
-
如列表中没有目标账号,请使用上方搜索框;停用和已删除账号不会显示。
+
+ 同一账号只显示最新有效记录;停用、删除和历史重复记录不会显示。最多载入 @@MAX_USERS@@ 个账号。 + +
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorLoginControllerTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorLoginControllerTest.java index 27f41c5..d4cbf5b 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorLoginControllerTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorLoginControllerTest.java @@ -41,7 +41,7 @@ class BackDoorLoginControllerTest { when(loginService.listAvailableUsers("tester", 100)).thenReturn(Collections.emptyList()); - ResponseEntity response = controller.backDoor(null, "tester"); + ResponseEntity response = controller.backDoor(null, "tester", "1"); assertEquals(HttpStatus.OK, response.getStatusCode()); assertTrue(response.getBody().contains("选择一个 PC 用户登录")); @@ -54,7 +54,7 @@ class BackDoorLoginControllerTest @Test void malformedIdReturnsHtmlErrorWithoutAttemptingLogin() { - ResponseEntity response = controller.backDoor("not-a-number", null); + ResponseEntity response = controller.backDoor("not-a-number", null, null); assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); assertTrue(response.getBody().contains("用户 ID 必须是正整数")); @@ -69,11 +69,13 @@ class BackDoorLoginControllerTest user.setUserName("tester"); when(loginService.login(8L)).thenReturn(new LoginSession(user, "signed-token")); - ResponseEntity response = controller.backDoor("8", null); + ResponseEntity response = controller.backDoor("8", null, null); assertEquals(HttpStatus.OK, response.getStatusCode()); assertTrue(response.getBody().contains("const token = \"signed-token\"")); assertTrue(response.getBody().contains("localStorage.setItem('access_token', token)")); + assertTrue(response.getBody().contains("window.location.replace(portalUrl)")); + assertFalse(response.getBody().contains("/management/")); assertFalse(response.getBody().contains("?token=signed-token")); } } diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorPageRendererTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorPageRendererTest.java index aebc887..0fbc273 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorPageRendererTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/BackDoorPageRendererTest.java @@ -1,6 +1,8 @@ package com.ruoyi.web.controller.system; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import com.ruoyi.common.core.domain.entity.SysDept; import com.ruoyi.common.core.domain.entity.SysUser; @@ -28,7 +30,7 @@ class BackDoorPageRendererTest dept.setDeptName("研发 <一部>"); user.setDept(dept); - String html = renderer.renderUserList(Collections.singletonList(user), "")); assertTrue(html.contains("tester</td><script>alert(1)</script>")); @@ -36,6 +38,31 @@ class BackDoorPageRendererTest assertTrue(html.contains("138****5678")); assertTrue(html.contains("研发 <一部>")); assertTrue(html.contains("?id=7")); + assertTrue(html.contains("html, body { width: 100%; height: 100%; overflow: hidden; }")); + } + + @Test + void userListPaginatesWithoutRenderingOffPageUsers() + { + List users = new ArrayList<>(); + for (long userId = 1; userId <= 9; userId++) + { + SysUser user = new SysUser(); + user.setUserId(userId); + user.setUserName("tester" + userId); + users.add(user); + } + + String firstPage = renderer.renderUserList(users, "", 100, 1, 8); + String secondPage = renderer.renderUserList(users, "", 100, 2, 8); + + assertTrue(firstPage.contains("?id=8")); + assertFalse(firstPage.contains("?id=9")); + assertTrue(firstPage.contains("第 1 / 2 页")); + assertTrue(firstPage.contains("?page=2")); + assertTrue(secondPage.contains("?id=9")); + assertFalse(secondPage.contains("?id=8")); + assertTrue(secondPage.contains("第 2 / 2 页")); } @Test @@ -52,7 +79,10 @@ class BackDoorPageRendererTest assertFalse(html.contains("token")); assertTrue(html.contains("token\\u003c/script\\u003e")); assertTrue(html.contains("localStorage.setItem('access_token', token)")); - assertTrue(html.contains("fetch(apiBase + '/getInfo'")); + assertTrue(html.contains("window.location.replace(portalUrl)")); + assertFalse(html.contains("/management/")); + assertFalse(html.contains("userType")); + assertFalse(html.contains("/getInfo")); assertFalse(html.contains("?token=")); } } diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/service/BackDoorLoginServiceTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/service/BackDoorLoginServiceTest.java index 1e709a0..27b84dc 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/web/service/BackDoorLoginServiceTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/web/service/BackDoorLoginServiceTest.java @@ -62,6 +62,7 @@ class BackDoorLoginServiceTest SysUser user = activeUser(12L); LoginUser loginUser = org.mockito.Mockito.mock(LoginUser.class); when(userMapper.selectUserById(12L)).thenReturn(user); + when(userMapper.selectBackDoorEffectiveUserId("tester")).thenReturn(12L); when(userDetailsService.createLoginUser(user)).thenReturn(loginUser); when(tokenService.createTokenHourTwo(loginUser)).thenReturn("test-token"); @@ -99,6 +100,19 @@ class BackDoorLoginServiceTest verify(tokenService, never()).createTokenHourTwo(org.mockito.ArgumentMatchers.any()); } + @Test + void loginRejectsObsoleteDuplicateAccount() + { + SysUser obsoleteUser = activeUser(11L); + when(userMapper.selectUserById(11L)).thenReturn(obsoleteUser); + when(userMapper.selectBackDoorEffectiveUserId("tester")).thenReturn(12L); + + ServiceException exception = assertThrows(ServiceException.class, () -> service.login(11L)); + + assertEquals("该账号存在更新记录,请从用户列表选择有效账号", exception.getMessage()); + verify(tokenService, never()).createTokenHourTwo(org.mockito.ArgumentMatchers.any()); + } + private SysUser activeUser(Long userId) { SysUser user = new SysUser(); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java index f9cce70..229cb98 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java @@ -23,6 +23,14 @@ public interface SysUserMapper */ List selectBackDoorUserList(@Param("keyword") String keyword, @Param("limit") int limit); + /** + * 查询指定账号在后门登录规则下的最新有效用户 ID。 + * + * @param userName 用户账号 + * @return 最新未删除记录的用户 ID + */ + Long selectBackDoorEffectiveUserId(@Param("userName") String userName); + /** * 根据条件分页查询用户列表 * diff --git a/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml index 8604601..bed93cd 100644 --- a/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml @@ -75,12 +75,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" u.app_user_id, u.lc_userid, d.dept_name - from sys_user u + from ( + select source_user.*, + row_number() over ( + partition by source_user.user_name + order by source_user.create_time desc nulls last, source_user.user_id desc + ) as account_rank + from sys_user source_user + where source_user.del_flag = '0' + and source_user.user_name is not null + and source_user.user_name != '' + ) u left join sys_dept d on d.dept_id = u.dept_id - where u.del_flag = '0' + where u.account_rank = 1 and u.status = '0' - and u.user_name is not null - and u.user_name != '' and ( cast(u.user_id as varchar) like concat('%', cast(#{keyword} as varchar), '%') @@ -93,6 +101,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" u.user_id desc limit #{limit} + + select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark,