roleIds = user.getRoles().stream().map(Role::getRoleId).collect(Collectors.toList());
- if (userRoleService.saveBatch(user.getUserId(), roleIds) < roleIds.size()) {
+ if (!roleIds.isEmpty() && userRoleService.saveBatch(user.getUserId(), roleIds) < roleIds.size()) {
throw new BusinessException("用户角色添加失败");
}
}
diff --git a/src/main/java/com/gxwebsoft/common/system/vo/RoleMemberVO.java b/src/main/java/com/gxwebsoft/common/system/vo/RoleMemberVO.java
new file mode 100644
index 0000000..68edf9f
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/common/system/vo/RoleMemberVO.java
@@ -0,0 +1,40 @@
+package com.gxwebsoft.common.system.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 角色成员
+ *
+ * @author WebSoft
+ * @since 2026-10-30
+ */
+@Data
+@ApiModel(description = "角色成员")
+public class RoleMemberVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("用户id")
+ private Integer userId;
+
+ @ApiModelProperty("账号")
+ private String username;
+
+ @ApiModelProperty("真实姓名")
+ private String realName;
+
+ @ApiModelProperty("昵称")
+ private String nickname;
+
+ @ApiModelProperty("所属组织id")
+ private Integer organizationId;
+
+ @ApiModelProperty("所属组织名称")
+ private String organizationName;
+
+ @ApiModelProperty("状态, 0正常, 1停用")
+ private Integer status;
+}
diff --git a/src/test/java/com/gxwebsoft/approval/RoleMemberIT.java b/src/test/java/com/gxwebsoft/approval/RoleMemberIT.java
new file mode 100644
index 0000000..c62a63b
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/approval/RoleMemberIT.java
@@ -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;
+ }
+}
diff --git a/src/test/java/com/gxwebsoft/support/CoreTestData.java b/src/test/java/com/gxwebsoft/support/CoreTestData.java
new file mode 100644
index 0000000..4f19c0f
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/support/CoreTestData.java
@@ -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。
+ *
+ * 共享测试库不会被测试事务回滚,调用方必须在用例结束后执行 {@link #cleanup()}。
+ */
+public final class CoreTestData {
+
+ private final JdbcTemplate core;
+ private final List userIds = new ArrayList<>();
+ private final List roleIds = new ArrayList<>();
+ private final List organizationIds = new ArrayList<>();
+ private final List 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 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