ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
+ + "AND flow_code = 'coordination_internal' AND version = 1 LIMIT 1", Long.class, TENANT);
+ if (ids.isEmpty()) {
+ data.master().update("INSERT INTO approval_flow_definition (tenant_id, flow_code, version, name, nodes_json) "
+ + "VALUES (?, 'coordination_internal', 1, '内部协调单', ?)",
+ TENANT, ApprovalConstants.COORDINATION_NODES_JSON);
+ ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
+ + "AND flow_code = 'coordination_internal' AND version = 1 LIMIT 1", Long.class, TENANT);
+ }
+ return ids.isEmpty() ? 0L : ids.get(0);
+ }
+
+ private String format(LocalDateTime time) {
+ return TIME_FORMAT.format(time.withNano(0));
+ }
+
+ private String orgName(Integer organizationId) {
+ return data.server().queryForObject("SELECT organization_name FROM sys_organization WHERE organization_id = ?",
+ String.class, organizationId);
+ }
+
+ private void grantDepartmentLeader() {
+ ApprovalTestData.TestRole role = data.createRole(data.leaderRoleCode(reportOrgId), "部门负责人");
+ ApprovalTestData.TestUser leader = data.createUser("负责人" + data.uniqueSuffix(), reportOrgId);
+ data.bind(leader.userId, role.roleId);
+ }
+
+ private void grantArchivist() {
+ ApprovalTestData.TestRole role = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
+ ApprovalTestData.TestUser archivist = data.createUser("收文员" + data.uniqueSuffix(), reportOrgId);
+ data.bind(archivist.userId, role.roleId);
+ }
+
+ private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
+ ApprovalTestData.TestUser user, String... authorities) {
+ return builder.header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(user.userId, user.username,
+ reportOrgId, TENANT, authorities)));
+ }
+
+ private String quote(String value) {
+ return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
+ }
+}
diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java
new file mode 100644
index 0000000..2d32581
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java
@@ -0,0 +1,406 @@
+package com.gxwebsoft.approval;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.support.ApprovalTestData;
+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.mock.web.MockMultipartFile;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+
+import javax.sql.DataSource;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+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.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 审批中心:待办/已办、通过/驳回、撤回、或签与并发处理的集成测试。
+ *
+ * 本类不开启测试事务:动作接口各自独立提交,便于覆盖并发条件更新;
+ * 数据由 {@link ApprovalTestData#cleanup()} 清理。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles({"gxtyzx-test", "test"})
+class ApprovalTaskIT {
+
+ private static final int TENANT = TestAuthentication.TENANT_ID;
+ private static final String LIST = "approval:instance:list";
+ private static final String SUBMIT = "approval:instance:submit";
+ private static final String APPROVE = "approval:instance:approve";
+
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+ @Autowired
+ private DataSource dataSource;
+
+ private ApprovalTestData data;
+ private ApprovalTestData.TestUser reporter;
+ private ApprovalTestData.TestUser leader;
+ private Integer organizationId;
+
+ @BeforeEach
+ void setUp() {
+ data = new ApprovalTestData(dataSource);
+ organizationId = data.createOrganization("任务部门" + data.uniqueSuffix(), 136);
+ reporter = data.createUser("任务报送人" + data.uniqueSuffix(), organizationId);
+ ApprovalTestData.TestRole leaderRole =
+ data.createRole(data.leaderRoleCode(organizationId), "部门负责人");
+ leader = data.createUser("任务负责人" + data.uniqueSuffix(), organizationId);
+ data.bind(leader.userId, leaderRole.roleId);
+ ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员");
+ ApprovalTestData.TestUser archivist = data.createUser("任务收文员" + data.uniqueSuffix(), organizationId);
+ data.bind(archivist.userId, archivistRole.roleId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ data.cleanup();
+ }
+
+ @Test
+ void approvesTaskAndActivatesNextConfirmNode() throws Exception {
+ long instanceId = createAndSubmit("通过测试");
+ long taskId = pendingTaskId(instanceId, leader.userId);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"opinion\":\"同意办理\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.id").value((int) instanceId))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.currentNodeName").value("存档"))
+ .andExpect(jsonPath("$.data.confirms.length()").value(1))
+ .andExpect(jsonPath("$.data.confirms[0].userId").value(reporter.userId))
+ .andExpect(jsonPath("$.data.confirms[0].status").value("PENDING"));
+
+ assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId))
+ .isEqualTo("DONE");
+ assertThat(data.queryMaster("SELECT opinion FROM approval_task WHERE id = ?", String.class, taskId))
+ .isEqualTo("同意办理");
+ assertThat(data.queryMaster("SELECT handle_time FROM approval_task WHERE id = ?", Object.class, taskId))
+ .isNotNull();
+ assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo("confirm");
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ + "AND node_type = 'CONFIRM' AND assignee_id = ? AND status = 'PENDING'", instanceId, reporter.userId))
+ .isEqualTo(1);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'APPROVE' AND operator_id = ? AND opinion = '同意办理'", instanceId, leader.userId))
+ .isEqualTo(1);
+
+ // 存档人能看到待确认任务与对应权限
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST))
+ .andExpect(jsonPath("$.data.myTask.nodeType").value("CONFIRM"))
+ .andExpect(jsonPath("$.data.permissions.canConfirm").value(true))
+ .andExpect(jsonPath("$.data.permissions.canApprove").value(false));
+ }
+
+ @Test
+ void requiresRejectOpinionAndResubmitsWithSameBizNo() throws Exception {
+ long instanceId = createAndSubmit("驳回重提");
+ long taskId = pendingTaskId(instanceId, leader.userId);
+ String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"opinion\":\" \"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("驳回意见")));
+ assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId))
+ .isEqualTo("PENDING");
+
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"opinion\":\"材料不完整\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("REJECTED"))
+ .andExpect(jsonPath("$.data.statusName").value("已驳回"))
+ .andExpect(jsonPath("$.data.round").value(2));
+
+ // 单据回到报送人:可修改、可重新提交
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST))
+ .andExpect(jsonPath("$.data.permissions.canEdit").value(true))
+ .andExpect(jsonPath("$.data.permissions.canSubmit").value(true));
+
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'PENDING'",
+ instanceId)).isZero();
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'REJECT' AND opinion = '材料不完整'", instanceId)).isEqualTo(1);
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.round").value(3))
+ .andExpect(jsonPath("$.data.bizNo").value(bizNo))
+ .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
+ assertThat(pendingTaskId(instanceId, leader.userId)).isPositive();
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'RESUBMIT' AND round = 3", instanceId)).isEqualTo(1);
+ }
+
+ @Test
+ void approvingOneCountersignTaskCancelsTheRest() throws Exception {
+ ApprovalTestData.TestUser second =
+ data.createUser("第二负责人" + data.uniqueSuffix(), organizationId);
+ Integer leaderRoleId = data.server().queryForObject("SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ?",
+ Integer.class, data.leaderRoleCode(organizationId), TENANT);
+ data.bind(second.userId, leaderRoleId);
+
+ long instanceId = createAndSubmit("或签取消");
+ long firstTask = pendingTaskId(instanceId, leader.userId);
+ long secondTask = pendingTaskId(instanceId, second.userId);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + firstTask + "/approve"), leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(jsonPath("$.code").value(0));
+
+ assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, secondTask))
+ .isEqualTo("CANCELLED");
+
+ mockMvc.perform(authed(get("/api/approval/todo/page"), second, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.count").value(0));
+
+ mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), second, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理")));
+
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'APPROVE'",
+ instanceId)).isEqualTo(1);
+ }
+
+ @Test
+ void concurrentApprovalOnlyHandlesTaskOnce() throws Exception {
+ long instanceId = createAndSubmit("并发处理");
+ long taskId = pendingTaskId(instanceId, leader.userId);
+
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(2);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ AtomicInteger success = new AtomicInteger();
+ AtomicInteger conflict = new AtomicInteger();
+ try {
+ for (int i = 0; i < 2; i++) {
+ executor.submit(() -> {
+ try {
+ start.await();
+ int code = mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"),
+ leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"并发\"}"))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)
+ .contains("\"code\":0") ? 0 : 1;
+ if (code == 0) {
+ success.incrementAndGet();
+ } else {
+ conflict.incrementAndGet();
+ }
+ } catch (Exception e) {
+ conflict.incrementAndGet();
+ } finally {
+ done.countDown();
+ }
+ });
+ }
+ start.countDown();
+ assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
+ } finally {
+ executor.shutdownNow();
+ }
+
+ assertThat(success.get()).isEqualTo(1);
+ assertThat(conflict.get()).isEqualTo(1);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'APPROVE'",
+ instanceId)).isEqualTo(1);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'DONE'",
+ instanceId)).isEqualTo(1);
+ }
+
+ @Test
+ void allowsReporterToApproveOwnTask() throws Exception {
+ Integer leaderRoleId = data.server().queryForObject("SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ?",
+ Integer.class, data.leaderRoleCode(organizationId), TENANT);
+ data.bind(reporter.userId, leaderRoleId);
+
+ long instanceId = createAndSubmit("自审单据");
+ long taskId = pendingTaskId(instanceId, reporter.userId);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"),
+ reporter, organizationId, APPROVE))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.currentNodeName").value("存档"));
+
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'APPROVE' AND operator_id = ?", instanceId, reporter.userId)).isEqualTo(1);
+ }
+
+ @Test
+ void withdrawsOnlyBeforeAnyTaskHandled() throws Exception {
+ long withdrawn = createAndSubmit("撤回单据");
+ String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, withdrawn);
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + withdrawn + "/withdraw"),
+ reporter, organizationId, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("WITHDRAWN"))
+ .andExpect(jsonPath("$.data.round").value(2))
+ .andExpect(jsonPath("$.data.bizNo").value(bizNo))
+ .andExpect(jsonPath("$.data.permissions.canSubmit").value(true));
+
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'PENDING'",
+ withdrawn)).isZero();
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'WITHDRAW'",
+ withdrawn)).isEqualTo(1);
+
+ // 已有处理记录的实例不能撤回
+ long handled = createAndSubmit("已审批撤回");
+ long taskId = pendingTaskId(handled, leader.userId);
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE))
+ .andExpect(jsonPath("$.code").value(0));
+ mockMvc.perform(authed(post("/api/approval/instance/" + handled + "/withdraw"),
+ reporter, organizationId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已有审批人处理,无法撤回")));
+ assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, handled))
+ .isEqualTo("PENDING");
+
+ // 非报送人不能撤回
+ long other = createAndSubmit("他人撤回");
+ mockMvc.perform(authed(post("/api/approval/instance/" + other + "/withdraw"),
+ leader, organizationId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权操作该单据")));
+ }
+
+ @Test
+ void listsTodoDoneAndTodoCountWithFilters() throws Exception {
+ long first = createAndSubmit("场馆维修A");
+ long second = createAndSubmit("场馆维修B");
+
+ mockMvc.perform(authed(get("/api/approval/todo/count"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data").value(2));
+
+ mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.count").value(2))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) second))
+ .andExpect(jsonPath("$.data.list[0].bizNo").isNotEmpty())
+ .andExpect(jsonPath("$.data.list[0].status").value("PENDING"))
+ .andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"))
+ .andExpect(jsonPath("$.data.list[0].submitTime").isNotEmpty());
+
+ mockMvc.perform(authed(get("/api/approval/todo/page").param("keywords", "B"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.data.count").value(1))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) second));
+ mockMvc.perform(authed(get("/api/approval/todo/page").param("status", "REJECTED"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.data.count").value(0));
+
+ mockMvc.perform(authed(get("/api/approval/mine/page").param("keywords", "A"), reporter, organizationId, LIST))
+ .andExpect(jsonPath("$.data.count").value(1))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) first));
+
+ long taskId = pendingTaskId(first, leader.userId);
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE))
+ .andExpect(jsonPath("$.code").value(0));
+
+ mockMvc.perform(authed(get("/api/approval/todo/count"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.data").value(1));
+ mockMvc.perform(authed(get("/api/approval/done/page"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.count").value(1))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) first))
+ .andExpect(jsonPath("$.data.list[0].status").value("PENDING"))
+ .andExpect(jsonPath("$.data.list[0].currentNodeName").value("存档"));
+ mockMvc.perform(authed(get("/api/approval/done/page").param("keywords", "B"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.data.count").value(0));
+
+ ApprovalTestData.TestUser outsider = data.createUser("任务路人" + data.uniqueSuffix(), organizationId);
+ mockMvc.perform(authed(get("/api/approval/todo/page"), outsider, organizationId, LIST))
+ .andExpect(jsonPath("$.data.count").value(0));
+ mockMvc.perform(authed(get("/api/approval/done/page"), outsider, organizationId, LIST))
+ .andExpect(jsonPath("$.data.count").value(0));
+ }
+
+ // ---------- helpers ----------
+
+ private long createAndSubmit(String name) throws Exception {
+ String body = "{\"name\":" + quote(name)
+ + ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45,"
+ + "\"serviceContent\":\"完成场馆设施维修与验收\"}";
+ String response = mockMvc.perform(authed(post("/api/approval/instance/completion"),
+ reporter, organizationId, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ long instanceId = objectMapper.readTree(response).path("data").asLong();
+ data.trackInstance(instanceId);
+
+ uploadAttachment(instanceId);
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, organizationId, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ return instanceId;
+ }
+
+ private void uploadAttachment(long instanceId) throws Exception {
+ MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
+ new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
+ mockMvc.perform(multipart("/api/approval/attachment/upload")
+ .file(file)
+ .param("instanceId", String.valueOf(instanceId))
+ .param("category", "SIGNED")
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
+ organizationId, TENANT, SUBMIT))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ }
+
+ private long pendingTaskId(long instanceId, int userId) {
+ Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
+ + "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
+ assertThat(taskId).as("待办任务应存在").isNotNull();
+ return taskId;
+ }
+
+ private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
+ ApprovalTestData.TestUser user, Integer org,
+ String... authorities) {
+ return builder.header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
+ }
+
+ private String quote(String value) {
+ return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
+ }
+}
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/ApprovalTestData.java b/src/test/java/com/gxwebsoft/support/ApprovalTestData.java
new file mode 100644
index 0000000..884bca9
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/support/ApprovalTestData.java
@@ -0,0 +1,218 @@
+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.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 审批集成测试夹具:负责在共享测试库中创建并清理用户/角色/组织与审批实例。
+ *
+ * sys_* 位于 server 库、审批表位于 master 库,分别使用独立 JdbcTemplate,
+ * 避免测试事务与动态数据源切换互相影响。
+ */
+public class ApprovalTestData {
+
+ private static final AtomicInteger SEQ = new AtomicInteger();
+
+ private final JdbcTemplate master;
+ private final JdbcTemplate server;
+
+ private final List userIds = new ArrayList<>();
+ private final List roleIds = new ArrayList<>();
+ private final List organizationIds = new ArrayList<>();
+ private final List instanceIds = new ArrayList<>();
+
+ public ApprovalTestData(DataSource dataSource) {
+ this.master = new JdbcTemplate(dataSource);
+ DataSource serverDataSource = ((DynamicRoutingDataSource) dataSource).getDataSource("server");
+ this.server = new JdbcTemplate(serverDataSource);
+ }
+
+ /** 唯一后缀,避免共享库中的账号/角色/组织重名。 */
+ public String uniqueSuffix() {
+ return System.nanoTime() + "-" + SEQ.incrementAndGet();
+ }
+
+ public TestUser createUser(String realName, Integer organizationId, int tenantId) {
+ String suffix = uniqueSuffix();
+ String username = "it_" + suffix;
+ Integer id = insert(server,
+ "INSERT INTO sys_user (username, password, nickname, real_name, organization_id, status, deleted, tenant_id) "
+ + "VALUES (?, 'it', ?, ?, ?, 0, 0, ?)",
+ username, realName, realName, organizationId, tenantId);
+ userIds.add(id);
+ return new TestUser(id, username, realName);
+ }
+
+ public TestUser createUser(String realName, Integer organizationId) {
+ return createUser(realName, organizationId, TestAuthentication.TENANT_ID);
+ }
+
+ public TestRole createRole(String roleCode, String roleName, int tenantId) {
+ Integer id = insert(server,
+ "INSERT INTO sys_role (role_name, role_code, sort_number, deleted, tenant_id) VALUES (?, ?, 1, 0, ?)",
+ roleName, roleCode, tenantId);
+ roleIds.add(id);
+ return new TestRole(id, roleCode);
+ }
+
+ public TestRole createRole(String roleCode, String roleName) {
+ return createRole(roleCode, roleName, TestAuthentication.TENANT_ID);
+ }
+
+ public void bind(int userId, int roleId) {
+ insert(server,
+ "INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)",
+ userId, roleId, TestAuthentication.TENANT_ID);
+ }
+
+ public void bindTenant(int userId, int roleId, int tenantId) {
+ insert(server,
+ "INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)",
+ userId, roleId, tenantId);
+ }
+
+ public Integer createOrganization(String name, int parentId, int tenantId) {
+ String code = "T_" + uniqueSuffix();
+ Integer id = insert(server,
+ "INSERT INTO sys_organization (organization_name, organization_code, parent_id, organization_type, deleted, tenant_id) "
+ + "VALUES (?, ?, ?, 'dept', 0, ?)",
+ name, code, parentId, tenantId);
+ organizationIds.add(id);
+ return id;
+ }
+
+ public Integer createOrganization(String name, int parentId) {
+ return createOrganization(name, parentId, TestAuthentication.TENANT_ID);
+ }
+
+ public String organizationCode(Integer organizationId) {
+ return server.queryForObject(
+ "SELECT organization_code FROM sys_organization WHERE organization_id = ?",
+ String.class, organizationId);
+ }
+
+ public String leaderRoleCode(Integer organizationId) {
+ return "dept_leader_" + organizationCode(organizationId);
+ }
+
+ public void clearOrganizationCode(int organizationId) {
+ server.update("UPDATE sys_organization SET organization_code = NULL WHERE organization_id = ?", organizationId);
+ }
+
+ public void disableOrganization(int organizationId) {
+ server.update("UPDATE sys_organization SET deleted = 1 WHERE organization_id = ?", organizationId);
+ }
+
+ public void setUserOrganization(int userId, Integer organizationId) {
+ server.update("UPDATE sys_user SET organization_id = ? WHERE user_id = ?", organizationId, userId);
+ }
+
+ public Integer countServer(String sql, Object... args) {
+ return server.queryForObject(sql, Integer.class, args);
+ }
+
+ public Integer countMaster(String sql, Object... args) {
+ return master.queryForObject(sql, Integer.class, args);
+ }
+
+ public T queryMaster(String sql, Class type, Object... args) {
+ return master.queryForObject(sql, type, args);
+ }
+
+ public JdbcTemplate master() {
+ return master;
+ }
+
+ public JdbcTemplate server() {
+ return server;
+ }
+
+ /** 记录实例 id,便于非事务用例结束后清理。 */
+ public void trackInstance(Long instanceId) {
+ if (instanceId != null) {
+ instanceIds.add(instanceId);
+ }
+ }
+
+ /** 清理本次用例创建的核心库数据与审批数据。 */
+ public void cleanup() {
+ try {
+ for (Long instanceId : instanceIds) {
+ deleteInstance(instanceId);
+ }
+ instanceIds.clear();
+ for (Integer userId : userIds) {
+ server.update("DELETE FROM sys_user_role WHERE user_id = ?", userId);
+ server.update("DELETE FROM sys_file_record WHERE create_user_id = ?", userId);
+ server.update("DELETE FROM sys_user WHERE user_id = ?", userId);
+ }
+ userIds.clear();
+ for (Integer roleId : roleIds) {
+ server.update("DELETE FROM sys_user_role WHERE role_id = ?", roleId);
+ server.update("DELETE FROM sys_role WHERE role_id = ?", roleId);
+ }
+ roleIds.clear();
+ for (Integer organizationId : organizationIds) {
+ server.update("DELETE FROM sys_organization WHERE organization_id = ?", organizationId);
+ }
+ organizationIds.clear();
+ } catch (Exception ignored) {
+ // 清理失败不阻断测试报告;残留行均由唯一后缀标识。
+ }
+ }
+
+ private void deleteInstance(Long instanceId) {
+ master.update("DELETE FROM approval_attachment WHERE instance_id = ?", instanceId);
+ master.update("DELETE FROM approval_record WHERE instance_id = ?", instanceId);
+ master.update("DELETE FROM approval_task WHERE instance_id = ?", instanceId);
+ master.update("DELETE FROM approval_completion_form WHERE instance_id = ?", instanceId);
+ master.update("DELETE FROM approval_coordination_form WHERE instance_id = ?", instanceId);
+ master.update("DELETE FROM approval_instance WHERE id = ?", instanceId);
+ }
+
+ private Integer insert(JdbcTemplate jdbcTemplate, String sql, Object... args) {
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+ jdbcTemplate.update(connection -> {
+ PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
+ for (int i = 0; i < args.length; i++) {
+ statement.setObject(i + 1, args[i]);
+ }
+ return statement;
+ }, keyHolder);
+ Number key = keyHolder.getKey();
+ if (key == null) {
+ throw new IllegalStateException("未取得自增主键: " + sql);
+ }
+ return key.intValue();
+ }
+
+ public static class TestUser {
+ public final int userId;
+ public final String username;
+ public final String realName;
+
+ public TestUser(int userId, String username, String realName) {
+ this.userId = userId;
+ this.username = username;
+ this.realName = realName;
+ }
+ }
+
+ public static class TestRole {
+ public final int roleId;
+ public final String roleCode;
+
+ TestRole(int roleId, String roleCode) {
+ this.roleId = roleId;
+ this.roleCode = roleCode;
+ }
+ }
+}
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