feat: 角色成员维护接口与用户清空角色修复

- 新增 /api/system/role-member 分页/添加/移除,@DS("server"),权限 sys:role:update,写操作记操作日志
- UserServiceImpl.updateUser 在 roles 非空判断下先删后插,空列表清空全部角色;updateUser/pageRel 切 server 库
- OperationRecordServiceImpl 加类级 @DS("server"),操作日志真正写入 gxtyzx_core
- OperationLogAspect 兼容无 User-Agent 的客户端,避免写日志 NPE 导致写操作失败
- RoleMemberIT 覆盖添加成员、移除最后一名成员、按角色分页、用户清空角色、403 与操作日志
This commit is contained in:
2026-10-30 19:59:41 +08:00
parent 029164c810
commit d44e91d6e6
13 changed files with 738 additions and 5 deletions
@@ -0,0 +1,225 @@
package com.gxwebsoft.approval;
import com.gxwebsoft.support.CoreTestData;
import com.gxwebsoft.support.TestAuthentication;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import javax.sql.DataSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 角色成员维护与用户清空角色的端到端行为。
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles({"gxtyzx-test", "test"})
class RoleMemberIT {
@Autowired
private MockMvc mockMvc;
@Autowired
private DataSource dataSource;
private CoreTestData data;
private String suffix;
@BeforeEach
void setUp() {
data = new CoreTestData(dataSource);
suffix = String.valueOf(System.nanoTime());
}
@AfterEach
void tearDown() {
data.cleanup();
}
@Test
void addsMemberAndListsRoleMembers() throws Exception {
int roleId = data.createRole("dept_leader_" + suffix, "部门负责人" + suffix);
int organizationId = data.createOrganization("测试部门-" + suffix);
int userId = data.createUser("member_" + suffix, "张三", organizationId, 0);
addMember(roleId, userId).andExpect(jsonPath("$.code").value(0));
assertThat(awaitOperationRecord("/api/system/role-member", String.valueOf(roleId))).isTrue();
mockMvc.perform(get("/api/system/role-member/page")
.with(auth("sys:role:update"))
.header("tenantId", TestAuthentication.TENANT_ID)
.param("roleId", String.valueOf(roleId))
.param("page", "1")
.param("limit", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].userId").value(userId))
.andExpect(jsonPath("$.data.list[0].username").value("member_" + suffix))
.andExpect(jsonPath("$.data.list[0].realName").value("张三"))
.andExpect(jsonPath("$.data.list[0].organizationId").value(organizationId))
.andExpect(jsonPath("$.data.list[0].organizationName").value("测试部门-" + suffix))
.andExpect(jsonPath("$.data.list[0].status").value(0));
}
@Test
void removingLastMemberLeavesRoleEmpty() throws Exception {
int roleId = data.createRole("approval_archivist_" + suffix, "收文员" + suffix);
int userId = data.createUser("archivist_" + suffix, "李四", null, 0);
addMember(roleId, userId).andExpect(jsonPath("$.code").value(0));
assertThat(data.roleIdsOf(userId)).containsExactly(roleId);
mockMvc.perform(delete("/api/system/role-member")
.with(auth("sys:role:update"))
.header("tenantId", TestAuthentication.TENANT_ID)
.param("roleId", String.valueOf(roleId))
.param("userId", String.valueOf(userId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
assertThat(data.roleIdsOf(userId)).isEmpty();
mockMvc.perform(get("/api/system/role-member/page")
.with(auth("sys:role:update"))
.header("tenantId", TestAuthentication.TENANT_ID)
.param("roleId", String.valueOf(roleId))
.param("page", "1")
.param("limit", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.count").value(0))
.andExpect(jsonPath("$.data.list.length()").value(0));
}
@Test
void clearsAllRolesWhenUserUpdateHasEmptyRoleList() throws Exception {
int roleId = data.createRole("dept_deputy_" + suffix, "部门副职" + suffix);
int userId = data.createUser("deputy_" + suffix, "王五", null, 0);
data.bind(userId, roleId);
assertThat(data.roleIdsOf(userId)).containsExactly(roleId);
mockMvc.perform(put("/api/system/user")
.with(auth("sys:user:update"))
.header("tenantId", TestAuthentication.TENANT_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"userId\":" + userId + ",\"nickname\":\"改名" + suffix + "\",\"roles\":[]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
assertThat(data.roleIdsOf(userId)).isEmpty();
mockMvc.perform(get("/api/system/role-member/page")
.with(auth("sys:role:update"))
.header("tenantId", TestAuthentication.TENANT_ID)
.param("roleId", String.valueOf(roleId))
.param("page", "1")
.param("limit", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.count").value(0));
}
@Test
void pagesRoleMembersAndHidesDisabledUsers() throws Exception {
int roleId = data.createRole("dept_leader_page_" + suffix, "分页部门负责人" + suffix);
int otherRoleId = data.createRole("other_role_" + suffix, "其他角色" + suffix);
int first = data.createUser("page_a_" + suffix, "甲", null, 0);
int second = data.createUser("page_b_" + suffix, "乙", null, 0);
int third = data.createUser("page_c_" + suffix, "丙", null, 0);
int disabled = data.createUser("page_disabled_" + suffix, "停用", null, 1);
int outsider = data.createUser("page_other_" + suffix, "外角色", null, 0);
for (int userId : new int[]{first, second, third, disabled}) {
data.bind(userId, roleId);
}
data.bind(outsider, otherRoleId);
mockMvc.perform(get("/api/system/role-member/page")
.with(auth("sys:role:update"))
.header("tenantId", TestAuthentication.TENANT_ID)
.param("roleId", String.valueOf(roleId))
.param("page", "1")
.param("limit", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.count").value(3))
.andExpect(jsonPath("$.data.list.length()").value(2));
mockMvc.perform(get("/api/system/role-member/page")
.with(auth("sys:role:update"))
.header("tenantId", TestAuthentication.TENANT_ID)
.param("roleId", String.valueOf(roleId))
.param("page", "2")
.param("limit", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.count").value(3))
.andExpect(jsonPath("$.data.list.length()").value(1));
mockMvc.perform(get("/api/system/role-member/page")
.with(auth("sys:role:update"))
.header("tenantId", TestAuthentication.TENANT_ID)
.param("roleId", String.valueOf(roleId))
.param("keywords", "page_a_"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].userId").value(first));
}
@Test
void rejectsDuplicateMemberWithChineseMessage() throws Exception {
int roleId = data.createRole("dept_leader_dup_" + suffix, "重复部门负责人" + suffix);
int userId = data.createUser("dup_" + suffix, "赵六", null, 0);
addMember(roleId, userId).andExpect(jsonPath("$.code").value(0));
addMember(roleId, userId)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已是角色成员")));
}
@Test
void rejectsWithoutRoleUpdateAuthority() throws Exception {
mockMvc.perform(get("/api/system/role-member/page")
.with(auth("sys:role:list"))
.header("tenantId", TestAuthentication.TENANT_ID)
.param("roleId", "586"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(403));
}
private org.springframework.test.web.servlet.ResultActions addMember(int roleId, int userId) throws Exception {
return mockMvc.perform(post("/api/system/role-member")
.with(auth("sys:role:update"))
.header("tenantId", TestAuthentication.TENANT_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"roleId\":" + roleId + ",\"userId\":" + userId + "}"))
.andExpect(status().isOk());
}
private RequestPostProcessor auth(String... authorities) {
return authentication(TestAuthentication.admin(authorities));
}
/* 操作日志异步写入,最多等待 2 秒 */
private boolean awaitOperationRecord(String url, String paramsMark) throws InterruptedException {
long deadline = System.currentTimeMillis() + 2000;
while (System.currentTimeMillis() < deadline) {
if (data.countOperationRecords(url, paramsMark) > 0) {
return true;
}
Thread.sleep(100);
}
return data.countOperationRecords(url, paramsMark) > 0;
}
}
@@ -0,0 +1,167 @@
package com.gxwebsoft.support;
import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import javax.sql.DataSource;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* gxtyzx_core 测试夹具:直接写 sys_user / sys_role / sys_user_role / sys_organization。
*
* <p>共享测试库不会被测试事务回滚,调用方必须在用例结束后执行 {@link #cleanup()}。</p>
*/
public final class CoreTestData {
private final JdbcTemplate core;
private final List<Integer> userIds = new ArrayList<>();
private final List<Integer> roleIds = new ArrayList<>();
private final List<Integer> organizationIds = new ArrayList<>();
private final List<String> operationParamMarks = new ArrayList<>();
public CoreTestData(DataSource dataSource) {
DataSource server = ((DynamicRoutingDataSource) dataSource).getDataSource("server");
this.core = new JdbcTemplate(server);
}
/**
* 创建启用/停用用户,返回用户id。
*/
public int createUser(String username, String realName, Integer organizationId, int status) {
KeyHolder keyHolder = new GeneratedKeyHolder();
core.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO sys_user (username, password, nickname, real_name, organization_id, status, deleted, tenant_id) "
+ "VALUES (?, ?, ?, ?, ?, ?, 0, ?)", Statement.RETURN_GENERATED_KEYS);
ps.setString(1, username);
ps.setString(2, "test-password");
ps.setString(3, realName);
ps.setString(4, realName);
if (organizationId == null) {
ps.setNull(5, java.sql.Types.INTEGER);
} else {
ps.setInt(5, organizationId);
}
ps.setInt(6, status);
ps.setInt(7, TestAuthentication.TENANT_ID);
return ps;
}, keyHolder);
int userId = keyHolder.getKey().intValue();
userIds.add(userId);
return userId;
}
/**
* 创建角色,返回角色id。
*/
public int createRole(String roleCode, String roleName) {
KeyHolder keyHolder = new GeneratedKeyHolder();
core.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO sys_role (role_name, role_code, deleted, tenant_id) VALUES (?, ?, 0, ?)",
Statement.RETURN_GENERATED_KEYS);
ps.setString(1, roleName);
ps.setString(2, roleCode);
ps.setInt(3, TestAuthentication.TENANT_ID);
return ps;
}, keyHolder);
int roleId = keyHolder.getKey().intValue();
roleIds.add(roleId);
// 角色成员写操作的操作日志参数里含 roleId,结束时可据此清理
operationParamMarks.add(String.valueOf(roleId));
return roleId;
}
/**
* 创建组织,返回组织id。
*/
public int createOrganization(String organizationName) {
KeyHolder keyHolder = new GeneratedKeyHolder();
core.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO sys_organization (parent_id, organization_name, organization_type, deleted, tenant_id) "
+ "VALUES (0, ?, '', 0, ?)", Statement.RETURN_GENERATED_KEYS);
ps.setString(1, organizationName);
ps.setInt(2, TestAuthentication.TENANT_ID);
return ps;
}, keyHolder);
int organizationId = keyHolder.getKey().intValue();
organizationIds.add(organizationId);
return organizationId;
}
/**
* 绑定用户与角色。
*/
public void bind(int userId, int roleId) {
core.update("INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)",
userId, roleId, TestAuthentication.TENANT_ID);
}
/**
* 查询用户当前的角色id。
*/
public List<Integer> roleIdsOf(int userId) {
return core.queryForList("SELECT role_id FROM sys_user_role WHERE user_id = ?", Integer.class, userId);
}
/**
* 统计包含指定参数标记的操作日志条数。
*/
public int countOperationRecords(String url, String paramsMark) {
Integer count = core.queryForObject(
"SELECT COUNT(*) FROM sys_operation_record WHERE url = ? AND params LIKE ?",
Integer.class, url, "%" + paramsMark + "%");
return count == null ? 0 : count;
}
/**
* 清理本夹具创建的全部行。
*/
public void cleanup() {
if (!userIds.isEmpty() || !roleIds.isEmpty()) {
List<Object> args = new ArrayList<>();
String userIn = placeholders(userIds.size());
String roleIn = placeholders(roleIds.size());
StringBuilder sql = new StringBuilder("DELETE FROM sys_user_role WHERE 1 = 0");
if (!userIds.isEmpty()) {
sql.append(" OR user_id IN (").append(userIn).append(')');
args.addAll(userIds);
}
if (!roleIds.isEmpty()) {
sql.append(" OR role_id IN (").append(roleIn).append(')');
args.addAll(roleIds);
}
core.update(sql.toString(), args.toArray());
}
if (!userIds.isEmpty()) {
core.update("DELETE FROM sys_user WHERE user_id IN (" + placeholders(userIds.size()) + ")", userIds.toArray());
}
if (!roleIds.isEmpty()) {
core.update("DELETE FROM sys_role WHERE role_id IN (" + placeholders(roleIds.size()) + ")", roleIds.toArray());
}
if (!organizationIds.isEmpty()) {
core.update("DELETE FROM sys_organization WHERE organization_id IN (" + placeholders(organizationIds.size()) + ")",
organizationIds.toArray());
}
if (!operationParamMarks.isEmpty()) {
for (String mark : operationParamMarks) {
core.update("DELETE FROM sys_operation_record WHERE params LIKE ?", "%" + mark + "%");
}
operationParamMarks.clear();
}
userIds.clear();
roleIds.clear();
organizationIds.clear();
}
private static String placeholders(int size) {
return String.join(",", Collections.nCopies(size, "?"));
}
}