From 170b3154594f02f568fe4c437b84e9320f599969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Fri, 7 Aug 2026 12:14:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=BB=9F=E4=B8=80=E6=90=9C=E7=B4=A2=E8=8C=83?= =?UTF-8?q?=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持按账号、昵称、电话、邮件和角色名搜索 - 补充搜索提示、特殊字符处理及前后端测试 --- .../system/SysAccountController.java | 55 ++++++++ .../system/SysAccountControllerTest.java | 70 ++++++++++ .../src/locales/langs/en-US/sysAccount.json | 1 + .../src/locales/langs/zh-CN/sysAccount.json | 1 + .../system/sysAccount/SysAccountList.test.ts | 123 ++++++++++++++++++ .../system/sysAccount/SysAccountList.vue | 3 +- 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.test.ts diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java index 4e15a543..81cc2438 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java @@ -5,7 +5,10 @@ import cn.dev33.satoken.stp.StpUtil; import cn.hutool.crypto.digest.BCrypt; import com.alibaba.fastjson2.JSONObject; import com.mybatisflex.core.paginate.Page; +import com.mybatisflex.core.query.QueryCondition; +import com.mybatisflex.core.query.QueryMethods; import com.mybatisflex.core.query.QueryWrapper; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.dao.DuplicateKeyException; import org.springframework.transaction.annotation.Transactional; @@ -49,6 +52,10 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import static tech.easyflow.system.entity.table.SysAccountRoleTableDef.SYS_ACCOUNT_ROLE; +import static tech.easyflow.system.entity.table.SysAccountTableDef.SYS_ACCOUNT; +import static tech.easyflow.system.entity.table.SysRoleTableDef.SYS_ROLE; + /** * 用户表 控制层。 * @@ -58,6 +65,7 @@ import java.util.stream.Collectors; @RestController("sysAccountController") @RequestMapping("/api/v1/sysAccount") public class SysAccountController extends BaseCurdController { + private static final String ACCOUNT_SEARCH_KEYWORD_PARAM = "keyword"; private static final String SUPER_ADMIN_HOME_PATH = "/dashboard/workspace"; private static final String USER_HOME_PATH = "/ai/agent-chat"; @@ -81,6 +89,53 @@ public class SysAccountController extends BaseCurdController统一关键字同时匹配账号、昵称、电话、邮件和已关联角色名称。角色条件使用相关 + * {@code EXISTS} 子查询,避免多角色关联导致分页记录重复。

+ * + * @param request 当前 HTTP 请求 + * @return 账号列表查询条件 + */ + @Override + protected QueryWrapper buildQueryWrapper(HttpServletRequest request) { + String keyword = request.getParameter(ACCOUNT_SEARCH_KEYWORD_PARAM); + if (!StringUtil.hasText(keyword)) { + return super.buildQueryWrapper(request); + } + + String likePattern = "%" + escapeLikeKeyword(keyword.trim()) + "%"; + QueryWrapper roleExistsQuery = QueryMethods.selectOne() + .from(SYS_ACCOUNT_ROLE) + .innerJoin(SYS_ROLE) + .on(SYS_ACCOUNT_ROLE.ROLE_ID.eq(SYS_ROLE.ID)) + .where(SYS_ACCOUNT_ROLE.ACCOUNT_ID.eq(SYS_ACCOUNT.ID)) + // 关联角色必须与账号属于同一租户,避免异常关系数据跨租户命中。 + .and(SYS_ROLE.TENANT_ID.eq(SYS_ACCOUNT.TENANT_ID)) + .and(SYS_ROLE.ROLE_NAME.likeRaw(likePattern)); + QueryCondition keywordCondition = SYS_ACCOUNT.LOGIN_NAME.likeRaw(likePattern) + .or(SYS_ACCOUNT.NICKNAME.likeRaw(likePattern)) + .or(SYS_ACCOUNT.MOBILE.likeRaw(likePattern)) + .or(SYS_ACCOUNT.EMAIL.likeRaw(likePattern)) + .or(QueryMethods.exists(roleExistsQuery)); + + return QueryWrapper.create().and(keywordCondition); + } + + /** + * 转义 MySQL LIKE 模式中的特殊字符,使用户输入按普通文本匹配。 + * + * @param keyword 已去除首尾空格的关键字 + * @return 可安全放入 LIKE 模式的文本 + */ + private String escapeLikeKeyword(String keyword) { + return keyword + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); + } + @Override @LogRecord("分页查询") protected Page queryPage(Page page, QueryWrapper queryWrapper) { diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysAccountControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysAccountControllerTest.java index 470c4dfe..6d61a201 100644 --- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysAccountControllerTest.java +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysAccountControllerTest.java @@ -2,6 +2,7 @@ package tech.easyflow.admin.controller.system; import com.alibaba.fastjson2.JSONObject; import com.mybatisflex.core.query.QueryWrapper; +import jakarta.servlet.http.HttpServletRequest; import org.mockito.MockedStatic; import org.testng.annotations.Test; import tech.easyflow.auth.service.AuthCredentialKeyService; @@ -15,7 +16,9 @@ import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysRoleService; import java.math.BigInteger; +import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @@ -27,12 +30,79 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; /** * {@link SysAccountController} 创建账号测试。 */ public class SysAccountControllerTest { + /** + * 验证统一关键字覆盖账号字段和关联角色名称,并保留租户关联条件。 + */ + @Test + public void buildQueryWrapperShouldSearchAccountFieldsAndRoleName() { + SysAccountController controller = new SysAccountController( + mock(SysAccountService.class), + mock(AuthCredentialKeyService.class), + mock(SysRoleService.class) + ); + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getParameter("keyword")).thenReturn(" search-user "); + + QueryWrapper queryWrapper = controller.buildQueryWrapper(request); + + String sql = queryWrapper.toSQL().toLowerCase(Locale.ROOT); + assertTrue(sql.contains("login_name"), sql); + assertTrue(sql.contains("nickname"), sql); + assertTrue(sql.contains("mobile"), sql); + assertTrue(sql.contains("email"), sql); + assertTrue(sql.contains("exists"), sql); + assertTrue(sql.contains("tb_sys_account_role"), sql); + assertTrue(sql.contains("tb_sys_role"), sql); + assertTrue(sql.contains("role_name"), sql); + assertTrue(sql.contains("tenant_id"), sql); + assertTrue(sql.contains("search-user"), sql); + } + + /** + * 验证空关键字不会产生额外筛选条件。 + */ + @Test + public void buildQueryWrapperShouldIgnoreBlankKeyword() { + SysAccountController controller = new SysAccountController( + mock(SysAccountService.class), + mock(AuthCredentialKeyService.class), + mock(SysRoleService.class) + ); + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getParameter("keyword")).thenReturn(" "); + when(request.getParameterMap()).thenReturn(Collections.emptyMap()); + + QueryWrapper queryWrapper = controller.buildQueryWrapper(request); + + assertFalse(queryWrapper.hasCondition()); + } + + /** + * 验证 LIKE 通配符和反斜杠按普通字符参与搜索。 + */ + @Test + public void buildQueryWrapperShouldEscapeLikePatternCharacters() { + SysAccountController controller = new SysAccountController( + mock(SysAccountService.class), + mock(AuthCredentialKeyService.class), + mock(SysRoleService.class) + ); + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getParameter("keyword")).thenReturn("a_b%c\\d"); + + String sql = controller.buildQueryWrapper(request).toSQL(); + + assertTrue(sql.contains("a\\\\_b\\\\%c\\\\\\\\d"), sql); + } + /** * 验证创建用户时保留表单选择的部门,不被操作者部门覆盖。 */ diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json b/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json index ba03a89f..d99031ab 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json @@ -3,6 +3,7 @@ "deptId": "Dept", "tenantId": "TenantId", "loginName": "LoginName", + "searchPlaceholder": "Search account, nickname, phone, email, or role", "password": "Password", "accountType": "AccountType", "nickname": "Nickname", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json index d45652aa..47c2649c 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json @@ -3,6 +3,7 @@ "deptId": "部门", "tenantId": "租户ID", "loginName": "登录账号", + "searchPlaceholder": "搜索账号、昵称、电话、邮件或角色", "password": "密码", "accountType": "账户类型", "nickname": "昵称", diff --git a/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.test.ts b/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.test.ts new file mode 100644 index 00000000..9b05db50 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.test.ts @@ -0,0 +1,123 @@ +/* eslint-disable vue/one-component-per-file -- Inline stubs keep this search test isolated. */ +import { mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import SysAccountList from './SysAccountList.vue'; + +const pageDataMocks = vi.hoisted(() => ({ + setQuery: vi.fn(), +})); +const dictStoreMocks = vi.hoisted(() => ({ + fetchDictionary: vi.fn(), +})); + +vi.mock('#/api/request', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + }, +})); +vi.mock('#/locales', () => ({ + $t: (key: string) => { + if (key === 'sysAccount.searchPlaceholder') { + return '搜索账号、昵称、电话、邮件或角色'; + } + return key; + }, +})); +vi.mock('#/store', () => ({ + useDictStore: () => dictStoreMocks, +})); +vi.mock('#/components/headerSearch/HeaderSearch.vue', () => ({ + default: defineComponent({ + name: 'HeaderSearch', + props: { + buttons: { + type: Array, + default: () => [], + }, + searchPlaceholder: { + type: String, + default: '', + }, + }, + emits: ['buttonClick', 'search'], + setup(props, { emit }) { + return () => + h( + 'button', + { + 'data-placeholder': props.searchPlaceholder, + 'data-testid': 'account-search', + onClick: () => emit('search', ' alice@example.com '), + }, + props.searchPlaceholder, + ); + }, + }), +})); +vi.mock('#/components/page/ListPageShell.vue', () => ({ + default: defineComponent({ + name: 'ListPageShell', + setup(_, { slots }) { + return () => h('div', [slots.filters?.(), slots.default?.()]); + }, + }), +})); +vi.mock('#/components/page/PageData.vue', () => ({ + default: defineComponent({ + name: 'PageData', + setup(_, { expose }) { + expose(pageDataMocks); + return () => h('div'); + }, + }), +})); +vi.mock('./SysAccountImportModal.vue', () => ({ + default: defineComponent({ + name: 'SysAccountImportModal', + setup: () => () => h('div'), + }), +})); +vi.mock('./SysAccountModal.vue', () => ({ + default: defineComponent({ + name: 'SysAccountModal', + setup: () => () => h('div'), + }), +})); +vi.mock('./SysAccountPasswordResultModal.vue', () => ({ + default: defineComponent({ + name: 'SysAccountPasswordResultModal', + setup: () => () => h('div'), + }), +})); + +describe('sysAccountList search', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('submits a trimmed unified keyword and describes searchable fields', async () => { + const wrapper = mount(SysAccountList, { + global: { + directives: { + access: {}, + loading: {}, + }, + }, + }); + const search = wrapper.get('[data-testid="account-search"]'); + + expect(search.attributes('data-placeholder')).toBe( + '搜索账号、昵称、电话、邮件或角色', + ); + await search.trigger('click'); + + expect(pageDataMocks.setQuery).toHaveBeenCalledWith({ + keyword: 'alice@example.com', + }); + wrapper.unmount(); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.vue b/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.vue index b2e333d4..0fe53be1 100644 --- a/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.vue +++ b/easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountList.vue @@ -69,7 +69,7 @@ function initDict() { dictStore.fetchDictionary('dataStatus'); } const handleSearch = (params: string) => { - pageDataRef.value.setQuery({ loginName: params, isQueryOr: true }); + pageDataRef.value.setQuery({ keyword: params.trim() }); }; function clearSelection() { selectedRows.value = []; @@ -276,6 +276,7 @@ function isAdmin(data: any) {