发布 v1.10 #5
@@ -5,7 +5,10 @@ import cn.dev33.satoken.stp.StpUtil;
|
|||||||
import cn.hutool.crypto.digest.BCrypt;
|
import cn.hutool.crypto.digest.BCrypt;
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
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 com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -49,6 +52,10 @@ import java.util.Map;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
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")
|
@RestController("sysAccountController")
|
||||||
@RequestMapping("/api/v1/sysAccount")
|
@RequestMapping("/api/v1/sysAccount")
|
||||||
public class SysAccountController extends BaseCurdController<SysAccountService, SysAccount> {
|
public class SysAccountController extends BaseCurdController<SysAccountService, SysAccount> {
|
||||||
|
private static final String ACCOUNT_SEARCH_KEYWORD_PARAM = "keyword";
|
||||||
private static final String SUPER_ADMIN_HOME_PATH = "/dashboard/workspace";
|
private static final String SUPER_ADMIN_HOME_PATH = "/dashboard/workspace";
|
||||||
private static final String USER_HOME_PATH = "/ai/agent-chat";
|
private static final String USER_HOME_PATH = "/ai/agent-chat";
|
||||||
|
|
||||||
@@ -81,6 +89,53 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
|||||||
this.sysRoleService = sysRoleService;
|
this.sysRoleService = sysRoleService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造账号列表查询条件。
|
||||||
|
*
|
||||||
|
* <p>统一关键字同时匹配账号、昵称、电话、邮件和已关联角色名称。角色条件使用相关
|
||||||
|
* {@code EXISTS} 子查询,避免多角色关联导致分页记录重复。</p>
|
||||||
|
*
|
||||||
|
* @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
|
@Override
|
||||||
@LogRecord("分页查询")
|
@LogRecord("分页查询")
|
||||||
protected Page<SysAccount> queryPage(Page<SysAccount> page, QueryWrapper queryWrapper) {
|
protected Page<SysAccount> queryPage(Page<SysAccount> page, QueryWrapper queryWrapper) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package tech.easyflow.admin.controller.system;
|
|||||||
|
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.mockito.MockedStatic;
|
import org.mockito.MockedStatic;
|
||||||
import org.testng.annotations.Test;
|
import org.testng.annotations.Test;
|
||||||
import tech.easyflow.auth.service.AuthCredentialKeyService;
|
import tech.easyflow.auth.service.AuthCredentialKeyService;
|
||||||
@@ -15,7 +16,9 @@ import tech.easyflow.system.service.SysAccountService;
|
|||||||
import tech.easyflow.system.service.SysRoleService;
|
import tech.easyflow.system.service.SysRoleService;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
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.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.testng.Assert.assertEquals;
|
import static org.testng.Assert.assertEquals;
|
||||||
|
import static org.testng.Assert.assertFalse;
|
||||||
|
import static org.testng.Assert.assertTrue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link SysAccountController} 创建账号测试。
|
* {@link SysAccountController} 创建账号测试。
|
||||||
*/
|
*/
|
||||||
public class SysAccountControllerTest {
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证创建用户时保留表单选择的部门,不被操作者部门覆盖。
|
* 验证创建用户时保留表单选择的部门,不被操作者部门覆盖。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"deptId": "Dept",
|
"deptId": "Dept",
|
||||||
"tenantId": "TenantId",
|
"tenantId": "TenantId",
|
||||||
"loginName": "LoginName",
|
"loginName": "LoginName",
|
||||||
|
"searchPlaceholder": "Search account, nickname, phone, email, or role",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"accountType": "AccountType",
|
"accountType": "AccountType",
|
||||||
"nickname": "Nickname",
|
"nickname": "Nickname",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"deptId": "部门",
|
"deptId": "部门",
|
||||||
"tenantId": "租户ID",
|
"tenantId": "租户ID",
|
||||||
"loginName": "登录账号",
|
"loginName": "登录账号",
|
||||||
|
"searchPlaceholder": "搜索账号、昵称、电话、邮件或角色",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
"accountType": "账户类型",
|
"accountType": "账户类型",
|
||||||
"nickname": "昵称",
|
"nickname": "昵称",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -69,7 +69,7 @@ function initDict() {
|
|||||||
dictStore.fetchDictionary('dataStatus');
|
dictStore.fetchDictionary('dataStatus');
|
||||||
}
|
}
|
||||||
const handleSearch = (params: string) => {
|
const handleSearch = (params: string) => {
|
||||||
pageDataRef.value.setQuery({ loginName: params, isQueryOr: true });
|
pageDataRef.value.setQuery({ keyword: params.trim() });
|
||||||
};
|
};
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selectedRows.value = [];
|
selectedRows.value = [];
|
||||||
@@ -276,6 +276,7 @@ function isAdmin(data: any) {
|
|||||||
<template #filters>
|
<template #filters>
|
||||||
<HeaderSearch
|
<HeaderSearch
|
||||||
:buttons="headerButtons"
|
:buttons="headerButtons"
|
||||||
|
:search-placeholder="$t('sysAccount.searchPlaceholder')"
|
||||||
@search="handleSearch"
|
@search="handleSearch"
|
||||||
@button-click="handleHeaderButtonClick"
|
@button-click="handleHeaderButtonClick"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user