From 7db8097c8445179127296f212730cb7c0e947852 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 21:03:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(approval):=20PC=20=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E6=94=B9=E6=B4=BE=E6=8E=A5=E5=8F=A3=E4=B8=8E=E9=9B=86=E6=88=90?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /api/approval/task/{id}/assign:权限 approval:instance:assign, 任务须 PENDING、目标同租户启用用户、原因必填;事务内锁实例后条件更新 处理人并写 ASSIGN 记录(opinion=原因),@OperationLog 记录原/新处理人与原因 - 新增 ApprovalTaskAssignParam / ApprovalAssignVO - 新增 ApprovalPcTodoIT:改派成功与归属变化、无效目标、非待办任务、原因必填、 无权限 403、ASSIGN 记录、操作日志、改派候选用户 --- .../controller/ApprovalTaskController.java | 12 + .../param/ApprovalTaskAssignParam.java | 20 + .../approval/service/ApprovalTaskService.java | 84 +++++ .../approval/vo/ApprovalAssignVO.java | 29 ++ .../gxwebsoft/approval/ApprovalPcTodoIT.java | 349 ++++++++++++++++++ 5 files changed, 494 insertions(+) create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java index 58af23e..6d75442 100644 --- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java @@ -1,11 +1,14 @@ package com.gxwebsoft.approval.controller; import com.gxwebsoft.approval.param.ApprovalTaskActionParam; +import com.gxwebsoft.approval.param.ApprovalTaskAssignParam; import com.gxwebsoft.approval.param.ApprovalTaskListParam; import com.gxwebsoft.approval.service.ApprovalInstanceService; import com.gxwebsoft.approval.service.ApprovalTaskService; +import com.gxwebsoft.approval.vo.ApprovalAssignVO; import com.gxwebsoft.approval.vo.ApprovalInstanceVO; import com.gxwebsoft.approval.vo.ApprovalItemVO; +import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.PageResult; @@ -85,4 +88,13 @@ public class ApprovalTaskController extends BaseController { ApprovalTaskService.Result result = taskService.withdraw(id, user); return success("撤回成功", instanceService.detail(result.getInstanceId(), user)); } + + @PreAuthorize("hasAuthority('approval:instance:assign')") + @OperationLog(value = "改派审批待办", module = "审批管理") + @ApiOperation("改派") + @PostMapping("/task/{id}/assign") + public ApiResult assign(@PathVariable("id") Long id, + @RequestBody(required = false) ApprovalTaskAssignParam param) { + return success("改派成功", taskService.assign(id, param, getLoginUser())); + } } diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java new file mode 100644 index 0000000..a826e08 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java @@ -0,0 +1,20 @@ +package com.gxwebsoft.approval.param; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * 待办改派参数。 + */ +@Data +public class ApprovalTaskAssignParam implements Serializable { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("改派对象用户id") + private Integer targetUserId; + + @ApiModelProperty("改派原因;必填") + private String reason; +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java index 0cf213d..a6a1a2f 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java @@ -8,8 +8,10 @@ import com.gxwebsoft.approval.entity.ApprovalInstance; import com.gxwebsoft.approval.entity.ApprovalTask; import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; import com.gxwebsoft.approval.mapper.ApprovalTaskMapper; +import com.gxwebsoft.approval.param.ApprovalTaskAssignParam; import com.gxwebsoft.approval.support.ApprovalApprover; import com.gxwebsoft.approval.support.ApprovalNode; +import com.gxwebsoft.approval.vo.ApprovalAssignVO; import com.gxwebsoft.approval.vo.ApprovalUserVO; import com.gxwebsoft.common.core.exception.BusinessException; import com.gxwebsoft.common.system.entity.User; @@ -109,6 +111,28 @@ public class ApprovalTaskService { return new Result(instanceId, null); } + /** + * 改派:管理员把待处理任务换给同租户其他启用用户,原因必填并写 ASSIGN 记录。 + * + *

改派不改变任务状态,只替换处理人;操作人不必是任务当前处理人。

+ */ + public ApprovalAssignVO assign(Long taskId, ApprovalTaskAssignParam param, User user) { + if (param == null || param.getTargetUserId() == null) { + throw new BusinessException("请选择改派对象"); + } + String reason = requireAssignReason(param.getReason()); + ApprovalTask task = requireAssignableTask(taskId); + // 事务外解析目标用户:启用、未删除且属于当前租户(@DS("server") 查询显式带租户) + List targets = + directoryService.listActiveUsersByIds(Collections.singletonList(param.getTargetUserId())); + if (targets.isEmpty()) { + throw new BusinessException("改派对象不存在或已停用"); + } + ApprovalUserVO target = targets.get(0); + String targetName = displayName(target); + return inTransaction(() -> doAssign(taskId, task, target.getUserId(), targetName, reason, user)); + } + public Integer todoCount(User user) { return taskMapper.countPendingInstances(user.getUserId()); } @@ -171,6 +195,58 @@ public class ApprovalTaskService { } } + /** + * 事务内改派:锁实例后条件更新处理人,并追加 ASSIGN 记录。 + */ + private ApprovalAssignVO doAssign(Long taskId, ApprovalTask task, Integer targetUserId, + String targetName, String reason, User user) { + ApprovalInstance locked = engineService.lockInstance(task.getInstanceId()); + ensureTaskHandleable(task, locked); + int rows = taskMapper.update(null, new UpdateWrapper() + .eq("id", taskId) + .eq("status", ApprovalConstants.TASK_PENDING) + .set("assignee_id", targetUserId) + .set("assignee_name", targetName)); + if (rows == 0) { + throw new BusinessException("任务已处理"); + } + engineService.addRecord(locked, taskId, task.getNodeKey(), user, + ApprovalConstants.ACTION_ASSIGN, reason); + ApprovalAssignVO vo = new ApprovalAssignVO(); + vo.setInstanceId(locked.getId()); + vo.setTaskId(taskId); + vo.setOriginalAssigneeId(task.getAssigneeId()); + vo.setOriginalAssigneeName(task.getAssigneeName()); + vo.setAssigneeId(targetUserId); + vo.setAssigneeName(targetName); + vo.setReason(reason); + return vo; + } + + /** + * 改派前的基本校验:任务存在、待处理且单据仍在审批中。 + */ + private ApprovalTask requireAssignableTask(Long taskId) { + if (taskId == null) { + throw new BusinessException("任务不存在"); + } + ApprovalTask task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + if (!ApprovalConstants.TASK_PENDING.equals(task.getStatus())) { + throw new BusinessException("任务已处理"); + } + ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) { + throw new BusinessException("单据不在审批中,无法改派"); + } + return task; + } + /** * 任务必须属于实例当前轮次、当前节点,否则视为已被处理或已失效。 */ @@ -310,6 +386,14 @@ public class ApprovalTaskService { return normalized; } + private String requireAssignReason(String reason) { + String normalized = normalizeOpinion(reason); + if (normalized == null) { + throw new BusinessException("请填写改派原因"); + } + return normalized; + } + private T inTransaction(Supplier action) { return new TransactionTemplate(transactionManager).execute(status -> action.get()); } diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java new file mode 100644 index 0000000..718c9d3 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java @@ -0,0 +1,29 @@ +package com.gxwebsoft.approval.vo; + +import io.swagger.annotations.ApiModel; +import lombok.Data; + +import java.io.Serializable; + +/** + * 改派结果:原处理人、新处理人与原因,同时用于操作日志留痕。 + */ +@Data +@ApiModel(description = "改派结果") +public class ApprovalAssignVO implements Serializable { + private static final long serialVersionUID = 1L; + + private Long instanceId; + + private Long taskId; + + private Integer originalAssigneeId; + + private String originalAssigneeName; + + private Integer assigneeId; + + private String assigneeName; + + private String reason; +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java new file mode 100644 index 0000000..26e43fd --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java @@ -0,0 +1,349 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.JsonNode; +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.ArrayList; +import java.util.List; + +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; + +/** + * PC 待办审批与改派的端到端行为。 + * + *

覆盖:改派成功与待办归属、目标用户校验、非待办任务、原因必填、改派权限 403、 + * ASSIGN 处理记录、操作日志以及改派候选用户查询。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalPcTodoIT { + + 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"; + private static final String ASSIGN = "approval:instance:assign"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private ApprovalTestData.TestUser leader; + private ApprovalTestData.TestUser admin; + private Integer organizationId; + private final List assignUrls = new ArrayList<>(); + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + organizationId = data.createOrganization("PC待办部门" + data.uniqueSuffix(), 136); + reporter = data.createUser("PC报送人" + data.uniqueSuffix(), organizationId); + ApprovalTestData.TestRole leaderRole = + data.createRole("dept_leader_" + organizationId, "部门负责人"); + leader = data.createUser("PC负责人" + data.uniqueSuffix(), organizationId); + data.bind(leader.userId, leaderRole.roleId); + ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员"); + ApprovalTestData.TestUser archivist = data.createUser("PC收文员" + data.uniqueSuffix(), organizationId); + data.bind(archivist.userId, archivistRole.roleId); + admin = data.createUser("PC管理员" + data.uniqueSuffix(), organizationId); + } + + @AfterEach + void tearDown() { + for (String url : assignUrls) { + data.server().update("DELETE FROM sys_operation_record WHERE user_id = ? AND url = ?", + admin.userId, url); + } + assignUrls.clear(); + data.cleanup(); + } + + @Test + void assignsPendingTaskAndMovesTodoToTargetUser() throws Exception { + long instanceId = createAndSubmit("改派单据"); + long taskId = pendingTaskId(instanceId, leader.userId); + ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId); + String reason = "原处理人休假" + data.uniqueSuffix(); + + mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, reason)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.instanceId").value((int) instanceId)) + .andExpect(jsonPath("$.data.taskId").value((int) taskId)) + .andExpect(jsonPath("$.data.originalAssigneeId").value(leader.userId)) + .andExpect(jsonPath("$.data.assigneeId").value(target.userId)) + .andExpect(jsonPath("$.data.assigneeName").value(target.realName)) + .andExpect(jsonPath("$.data.reason").value(reason)); + + // 任务仍为待处理,归属已换人 + assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId)) + .isEqualTo("PENDING"); + assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId)) + .isEqualTo(target.userId); + assertThat(data.queryMaster("SELECT assignee_name FROM approval_task WHERE id = ?", String.class, taskId)) + .isEqualTo(target.realName); + + // 处理记录留痕:改派人与原因 + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'ASSIGN' AND operator_id = ? AND opinion = ?", instanceId, admin.userId, reason)) + .isEqualTo(1); + + // 原处理人待办消失、新处理人待办出现 + mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(0)); + mockMvc.perform(authed(get("/api/approval/todo/page"), target, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId)) + .andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人")); + + // 新处理人可正常处理改派后的任务 + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), target, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"接办\"}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")); + + // 原处理人已无权处理该任务 + long second = createAndSubmit("改派后原处理人"); + long secondTask = pendingTaskId(second, leader.userId); + mockMvc.perform(assign(secondTask, admin, ASSIGN, target.userId, "转办" + data.uniqueSuffix())) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), leader, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务"))); + } + + @Test + void rejectsAssignToUnknownOrInactiveUser() throws Exception { + long instanceId = createAndSubmit("改派无效目标"); + long taskId = pendingTaskId(instanceId, leader.userId); + + ApprovalTestData.TestUser inactive = data.createUser("停用目标" + data.uniqueSuffix(), organizationId); + data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", inactive.userId); + + mockMvc.perform(assign(taskId, admin, ASSIGN, inactive.userId, "停用目标改派")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("改派对象不存在或已停用"))); + + mockMvc.perform(assign(taskId, admin, ASSIGN, 99999999, "不存在用户改派")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("改派对象不存在或已停用"))); + + assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId)) + .isEqualTo(leader.userId); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'ASSIGN'", + instanceId)).isZero(); + } + + @Test + void requiresAssignReason() throws Exception { + long instanceId = createAndSubmit("改派无原因"); + long taskId = pendingTaskId(instanceId, leader.userId); + ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId); + + mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, " ")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请填写改派原因"))); + + assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId)) + .isEqualTo(leader.userId); + } + + @Test + void rejectsAssignForHandledTask() throws Exception { + long instanceId = createAndSubmit("改派已处理任务"); + long taskId = pendingTaskId(instanceId, leader.userId); + ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId); + + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, "已处理后改派")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理"))); + + assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId)) + .isEqualTo(leader.userId); + } + + @Test + void rejectsAssignWithoutAuthority() throws Exception { + long instanceId = createAndSubmit("无权限改派"); + long taskId = pendingTaskId(instanceId, leader.userId); + ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId); + + mockMvc.perform(assign(taskId, admin, APPROVE, target.userId, "越权改派")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(403)); + + assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId)) + .isEqualTo(leader.userId); + + mockMvc.perform(authed(get("/api/approval/directory/users").param("keywords", "PC"), + admin, organizationId, APPROVE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void writesOperationLogWithOriginalAndNewAssignee() throws Exception { + long instanceId = createAndSubmit("改派操作日志"); + long taskId = pendingTaskId(instanceId, leader.userId); + ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId); + String reason = "操作日志原因" + data.uniqueSuffix(); + String url = "/api/approval/task/" + taskId + "/assign"; + assignUrls.add(url); + + mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, reason)) + .andExpect(jsonPath("$.code").value(0)); + + JsonNode record = awaitOperationRecord(url, reason); + assertThat(record).as("改派操作日志应写入 sys_operation_record").isNotNull(); + assertThat(record.path("userId").asInt()).isEqualTo(admin.userId); + assertThat(record.path("description").asText()).contains("改派"); + assertThat(record.path("params").asText()).contains(reason); + assertThat(record.path("result").asText()) + .contains(leader.realName) + .contains(target.realName) + .contains(reason); + } + + @Test + void listsOnlyActiveUsersForAssignCandidates() throws Exception { + ApprovalTestData.TestUser active = data.createUser("候选人" + data.uniqueSuffix(), organizationId); + ApprovalTestData.TestUser inactive = data.createUser("停用候选人" + data.uniqueSuffix(), organizationId); + data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", inactive.userId); + + String body = mockMvc.perform(authed(get("/api/approval/directory/users") + .param("keywords", active.realName), admin, organizationId, ASSIGN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode users = objectMapper.readTree(body).path("data"); + assertThat(users.isArray()).isTrue(); + assertThat(users.findValuesAsText("userId")).contains(String.valueOf(active.userId)); + assertThat(users.findValuesAsText("userId")).doesNotContain(String.valueOf(inactive.userId)); + JsonNode matched = null; + for (JsonNode user : users) { + if (user.path("userId").asInt() == active.userId) { + matched = user; + } + } + assertThat(matched).isNotNull(); + assertThat(matched.path("realName").asText()).isEqualTo(active.realName); + } + + // ---------- helpers ---------- + + private MockHttpServletRequestBuilder assign(long taskId, ApprovalTestData.TestUser operator, + String authority, int targetUserId, String reason) + throws Exception { + return authed(post("/api/approval/task/" + taskId + "/assign"), operator, organizationId, authority) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetUserId\":" + targetUserId + ",\"reason\":" + quote(reason) + "}"); + } + + 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; + } + + /** 操作日志异步写入,最多等待 2 秒。 */ + private JsonNode awaitOperationRecord(String url, String paramsMark) throws Exception { + long deadline = System.currentTimeMillis() + 2000; + while (System.currentTimeMillis() < deadline) { + List> rows = data.server().queryForList( + "SELECT user_id AS userId, description, params, result FROM sys_operation_record " + + "WHERE url = ? AND params LIKE ? ORDER BY id DESC LIMIT 1", + url, "%" + paramsMark + "%"); + if (!rows.isEmpty()) { + return objectMapper.valueToTree(rows.get(0)); + } + Thread.sleep(100); + } + return null; + } + + 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("\"", "\\\"") + "\""; + } +}