feat(approval): 业务完成确认单草稿、附件、提交与引擎起步 (#19)

This commit is contained in:
2026-10-30 20:18:47 +08:00
parent 029164c810
commit 4a1d02f040
49 changed files with 3732 additions and 3 deletions
@@ -0,0 +1,519 @@
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 org.springframework.transaction.annotation.Transactional;
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.delete;
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;
/**
* 业务完成确认单:草稿、附件、提交预检与引擎起步的集成测试。
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles({"gxtyzx-test", "test"})
@Transactional
class ApprovalCompletionDraftIT {
private static final int TENANT = TestAuthentication.TENANT_ID;
private static final String LIST = "approval:instance:list";
private static final String SUBMIT = "approval:instance:submit";
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private DataSource dataSource;
private ApprovalTestData data;
private ApprovalTestData.TestUser reporter;
private Integer organizationId;
@BeforeEach
void setUp() {
data = new ApprovalTestData(dataSource);
organizationId = data.createOrganization("测试部门" + data.uniqueSuffix(), 136);
reporter = data.createUser("报送人" + data.uniqueSuffix(), organizationId);
}
@AfterEach
void tearDown() {
data.cleanup();
}
@Test
void createsDraftWithoutBizNoAndListsAsUnsubmitted() throws Exception {
long instanceId = createCompletion(reporter, organizationId, TENANT, "场馆维修");
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
assertThat(bizNo(instanceId)).isNull();
assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
.isEqualTo(reporter.userId);
assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
.isEqualTo(organizationId);
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, TENANT, LIST))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.statusName").value("未提交"))
.andExpect(jsonPath("$.data.name").value("场馆维修"))
.andExpect(jsonPath("$.data.docTypeName").value("业务完成确认单"))
.andExpect(jsonPath("$.data.permissions.canEdit").value(true))
.andExpect(jsonPath("$.data.bizNo").doesNotExist());
mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, TENANT, LIST))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
.andExpect(jsonPath("$.data.list[0].statusName").value("未提交"))
.andExpect(jsonPath("$.data.list[0].title").value("场馆维修"))
.andExpect(jsonPath("$.data.list[0].bizNo").doesNotExist());
}
@Test
void validatesCompletionFields() throws Exception {
expectSaveFailure(body("", "实施单位", "10.00", "服务内容"), "名称不能为空");
expectSaveFailure(body(repeat("名", 101), "实施单位", "10.00", "服务内容"), "名称不能超过100字");
expectSaveFailure(body("名称", "", "10.00", "服务内容"), "实施单位不能为空");
expectSaveFailure(body("名称", "实施单位", "-1", "服务内容"), "费用必须为0到99999999.99之间的两位小数");
expectSaveFailure(body("名称", "实施单位", "100000000", "服务内容"), "费用必须为0到99999999.99之间的两位小数");
expectSaveFailure(body("名称", "实施单位", "1.234", "服务内容"), "费用必须为0到99999999.99之间的两位小数");
expectSaveFailure(body("名称", "实施单位", "10.00", ""), "服务内容不能为空");
expectSaveFailure(body("名称", "实施单位", "10.00", repeat("内", 2001)), "服务内容不能超过2000字");
}
@Test
void rejectsSubmitWhenReporterHasNoOrganization() throws Exception {
ApprovalTestData.TestUser noOrgReporter = data.createUser("无部门" + data.uniqueSuffix(), null);
long instanceId = createCompletion(noOrgReporter, null, TENANT, "无部门单据");
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
noOrgReporter, null, TENANT, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("未归属部门")));
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
assertThat(bizNo(instanceId)).isNull();
}
@Test
void rejectsSubmitWhenDepartmentLeaderRoleMissing() throws Exception {
long instanceId = createCompletion(reporter, organizationId, TENANT, "缺负责人");
uploadAttachment(instanceId, reporter, organizationId, "image/png");
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
reporter, organizationId, TENANT, SUBMIT))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("报送部门负责人角色缺配")));
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
}
@Test
void rejectsSubmitWhenArchivistRoleMissing() throws Exception {
grantDepartmentLeader(organizationId, TENANT);
long instanceId = createCompletion(reporter, organizationId, TENANT, "缺收文员");
uploadAttachment(instanceId, reporter, organizationId, "image/png");
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
reporter, organizationId, TENANT, SUBMIT))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("收文员角色缺配")));
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
}
@Test
void rejectsSubmitWhenAttachmentMissing() throws Exception {
grantDepartmentLeader(organizationId, TENANT);
grantArchivist(TENANT);
long instanceId = createCompletion(reporter, organizationId, TENANT, "无附件");
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
reporter, organizationId, TENANT, SUBMIT))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请至少上传一张附件")));
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
}
@Test
void submitsAndStartsDepartmentLeaderCountersign() throws Exception {
List<ApprovalTestData.TestUser> leaders = grantDepartmentLeader(organizationId, TENANT, 2);
grantArchivist(TENANT);
long instanceId = createCompletion(reporter, organizationId, TENANT, "完工确认");
long attachmentId = uploadAttachment(instanceId, reporter, organizationId, "image/png");
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
reporter, organizationId, TENANT, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("PENDING"))
.andExpect(jsonPath("$.data.statusName").value("审批中"))
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"))
.andExpect(jsonPath("$.data.permissions.canEdit").value(false))
.andExpect(jsonPath("$.data.permissions.canSubmit").value(false))
.andExpect(jsonPath("$.data.attachments.length()").value(1))
.andExpect(jsonPath("$.data.records[0].action").value("SUBMIT"))
.andExpect(jsonPath("$.data.records[0].actionName").value("提交"));
assertThat(instanceStatus(instanceId)).isEqualTo("PENDING");
assertThat(data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId)).isEqualTo(1);
assertThat(data.queryMaster("SELECT submit_time FROM approval_instance WHERE id = ?", Object.class, instanceId)).isNotNull();
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", String.class, instanceId))
.isEqualTo("deptLeader");
assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?", String.class, instanceId))
.isEqualTo("APPROVE");
String bizNo = bizNo(instanceId);
assertThat(bizNo).matches("WG\\d{8}-\\d{4,}");
assertThat(bizNo.substring(2, 10)).isEqualTo(todayFromDatabase());
List<Integer> assignees = data.master().queryForList(
"SELECT assignee_id FROM approval_task WHERE instance_id = ? AND status = 'PENDING' ORDER BY id",
Integer.class, instanceId);
assertThat(assignees).containsExactlyInAnyOrder(leaders.get(0).userId, leaders.get(1).userId);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'SUBMIT'",
instanceId)).isEqualTo(1);
// 提交后字段与附件均只读
mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, TENANT, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body("改名", "实施单位", "10.00", "服务内容", instanceId)))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改")));
MockMultipartFile file = new MockMultipartFile("file", "locked.png", "image/png", pngBytes());
mockMvc.perform(multipart("/api/approval/attachment/upload")
.file(file)
.param("instanceId", String.valueOf(instanceId))
.param("category", "OTHER")
.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改")));
// 重复提交保持审批中
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
reporter, organizationId, TENANT, SUBMIT))
.andExpect(jsonPath("$.code").value(1));
assertThat(instanceStatus(instanceId)).isEqualTo("PENDING");
assertThat(attachmentId).isPositive();
}
@Test
void allocatesUniqueDailySequenceByTenant() throws Exception {
grantDepartmentLeader(organizationId, TENANT);
grantArchivist(TENANT);
long first = submitCompletion(createCompletion(reporter, organizationId, TENANT, "单号一"));
long second = submitCompletion(createCompletion(reporter, organizationId, TENANT, "单号二"));
String firstNo = bizNo(first);
String secondNo = bizNo(second);
String date = todayFromDatabase();
assertThat(firstNo).matches("WG" + date + "-\\d{4,}");
assertThat(secondNo).matches("WG" + date + "-\\d{4,}");
int firstSeq = Integer.parseInt(firstNo.substring(firstNo.indexOf('-') + 1));
int secondSeq = Integer.parseInt(secondNo.substring(secondNo.indexOf('-') + 1));
assertThat(secondSeq).isEqualTo(firstSeq + 1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
TENANT, firstNo)).isEqualTo(1);
// 另一租户使用同一天同一前缀独立取号,互不冲突
int otherTenant = 10023;
Integer otherOrg = data.createOrganization("外租户部门" + data.uniqueSuffix(), 136, otherTenant);
ApprovalTestData.TestUser otherReporter = data.createUser("外租户报送人" + data.uniqueSuffix(), otherOrg, otherTenant);
ApprovalTestData.TestRole otherLeaderRole = data.createRole("dept_leader_" + otherOrg, "外租户负责人", otherTenant);
ApprovalTestData.TestUser otherLeader = data.createUser("外租户负责人" + data.uniqueSuffix(), otherOrg, otherTenant);
data.bindTenant(otherLeader.userId, otherLeaderRole.roleId, otherTenant);
ApprovalTestData.TestRole otherArchivistRole = data.createRole("approval_archivist", "外租户收文员", otherTenant);
ApprovalTestData.TestUser otherArchivist = data.createUser("外租户收文员" + data.uniqueSuffix(), otherOrg, otherTenant);
data.bindTenant(otherArchivist.userId, otherArchivistRole.roleId, otherTenant);
long otherInstance = submitCompletionFor(createCompletion(otherReporter, otherOrg, otherTenant, "外租户单据"),
otherReporter, otherOrg, otherTenant);
String otherNo = bizNo(otherInstance);
assertThat(otherNo).matches("WG" + date + "-\\d{4,}");
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
otherTenant, otherNo)).isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
TENANT, firstNo)).isEqualTo(1);
}
@Test
void enforcesAttachmentRules() throws Exception {
long instanceId = createCompletion(reporter, organizationId, TENANT, "附件规则");
MockMultipartFile text = new MockMultipartFile("file", "evil.txt", "text/plain",
"x".getBytes(StandardCharsets.UTF_8));
mockMvc.perform(multipart("/api/approval/attachment/upload").file(text)
.param("instanceId", String.valueOf(instanceId))
.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持")));
MockMultipartFile mismatch = new MockMultipartFile("file", "fake.jpg", "application/pdf", pngBytes());
mockMvc.perform(multipart("/api/approval/attachment/upload").file(mismatch)
.param("instanceId", String.valueOf(instanceId))
.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持")));
MockMultipartFile tooLarge = new MockMultipartFile("file", "big.png", "image/png",
new byte[20 * 1024 * 1024 + 1]);
mockMvc.perform(multipart("/api/approval/attachment/upload").file(tooLarge)
.param("instanceId", String.valueOf(instanceId))
.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("20MB")));
for (int i = 0; i < 9; i++) {
uploadAttachment(instanceId, reporter, organizationId, "image/png");
}
MockMultipartFile tenth = new MockMultipartFile("file", "tenth.png", "image/png", pngBytes());
mockMvc.perform(multipart("/api/approval/attachment/upload").file(tenth)
.param("instanceId", String.valueOf(instanceId))
.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("9张")));
}
@Test
void deletesAttachmentLogicallyAndKeepsFileRecord() throws Exception {
long instanceId = createCompletion(reporter, organizationId, TENANT, "删除附件");
long attachmentId = uploadAttachment(instanceId, reporter, organizationId, "image/png");
Integer fileId = data.queryMaster("SELECT file_id FROM approval_attachment WHERE id = ?", Integer.class, attachmentId);
mockMvc.perform(authed(delete("/api/approval/attachment/" + attachmentId), reporter, organizationId, TENANT, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
assertThat(data.queryMaster("SELECT deleted FROM approval_attachment WHERE id = ?", Integer.class, attachmentId))
.isEqualTo(1);
assertThat(data.countServer("SELECT COUNT(*) FROM sys_file_record WHERE id = ? AND deleted = 0", fileId))
.isEqualTo(1);
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, TENANT, LIST))
.andExpect(jsonPath("$.data.attachments.length()").value(0));
}
@Test
void mineListIsOrderedAndScoped() throws Exception {
grantDepartmentLeader(organizationId, TENANT);
grantArchivist(TENANT);
long first = submitCompletion(createCompletion(reporter, organizationId, TENANT, "先提交"));
long second = submitCompletion(createCompletion(reporter, organizationId, TENANT, "后提交"));
mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, organizationId, TENANT, 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[1].id").value((int) first))
.andExpect(jsonPath("$.data.list[0].statusName").value("审批中"))
.andExpect(jsonPath("$.data.list[0].bizNo").isNotEmpty())
.andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"));
ApprovalTestData.TestUser outsider = data.createUser("路人" + data.uniqueSuffix(), organizationId);
mockMvc.perform(authed(get("/api/approval/mine/page"), outsider, organizationId, TENANT, LIST))
.andExpect(jsonPath("$.data.count").value(0));
mockMvc.perform(authed(get("/api/approval/instance/" + first), outsider, organizationId, TENANT, LIST))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
ApprovalTestData.TestUser archivist = grantArchivist(TENANT);
mockMvc.perform(authed(get("/api/approval/instance/" + first), archivist, organizationId, TENANT, LIST))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value((int) first));
}
@Test
void draftStaysInvisibleEvenToAllVisibleArchivist() throws Exception {
ApprovalTestData.TestUser archivist = grantArchivist(TENANT);
long draft = createCompletion(reporter, organizationId, TENANT, "草稿保密");
mockMvc.perform(authed(get("/api/approval/instance/" + draft), archivist, organizationId, TENANT, LIST))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
}
// ---------- helpers ----------
private void expectSaveFailure(String body, String expectedMessage) throws Exception {
mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, TENANT, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage)));
}
private long createCompletion(ApprovalTestData.TestUser user, Integer org, int tenant, String name) throws Exception {
String body = body(name, "广西体育中心实施部", "123.45", "完成场馆设施维修与验收");
String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), user, org, tenant, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
JsonNode node = objectMapper.readTree(response);
long id = node.path("data").asLong();
data.trackInstance(id);
return id;
}
private long submitCompletion(long instanceId) throws Exception {
return submitCompletionFor(instanceId, reporterFor(instanceId), organizationIdFor(instanceId), TENANT);
}
private long submitCompletionFor(long instanceId, ApprovalTestData.TestUser user, Integer org, int tenant)
throws Exception {
uploadAttachment(instanceId, user, org, tenant, "image/png");
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), user, org, tenant, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
return instanceId;
}
private long uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org,
String contentType) throws Exception {
return uploadAttachment(instanceId, user, org, TENANT, contentType);
}
private long uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org, int tenant,
String contentType) throws Exception {
String filename = "sign." + extensionOf(contentType);
MockMultipartFile file = new MockMultipartFile("file", filename, contentType, pngBytes());
String response = mockMvc.perform(multipart("/api/approval/attachment/upload")
.file(file)
.param("instanceId", String.valueOf(instanceId))
.param("category", "SIGNED")
.header("tenantId", tenant)
.with(authentication(TestAuthentication.user(user.userId, user.username, org, tenant, SUBMIT))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
return objectMapper.readTree(response).path("data").path("id").asLong();
}
private String extensionOf(String contentType) {
switch (contentType) {
case "image/png":
return "png";
case "image/jpeg":
return "jpg";
default:
return "bin";
}
}
private byte[] pngBytes() {
return new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
}
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, ApprovalTestData.TestUser user,
Integer org, int tenant, String... authorities) {
return builder.header("tenantId", tenant)
.with(authentication(TestAuthentication.user(user.userId, user.username, org, tenant, authorities)));
}
private ApprovalTestData.TestUser reporterFor(long instanceId) {
Integer reporterId = data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?",
Integer.class, instanceId);
return new ApprovalTestData.TestUser(reporterId, "reporter-" + reporterId, "报送人");
}
private Integer organizationIdFor(long instanceId) {
return data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId);
}
private List<ApprovalTestData.TestUser> grantDepartmentLeader(int orgId, int tenant) {
return grantDepartmentLeader(orgId, tenant, 1);
}
private List<ApprovalTestData.TestUser> grantDepartmentLeader(int orgId, int tenant, int memberCount) {
ApprovalTestData.TestRole role = data.createRole("dept_leader_" + orgId, "部门负责人", tenant);
List<ApprovalTestData.TestUser> members = new ArrayList<>();
for (int i = 0; i < memberCount; i++) {
ApprovalTestData.TestUser member = data.createUser("负责人" + data.uniqueSuffix(), orgId, tenant);
data.bindTenant(member.userId, role.roleId, tenant);
members.add(member);
}
return members;
}
private ApprovalTestData.TestUser grantArchivist(int tenant) {
ApprovalTestData.TestRole role = data.createRole("approval_archivist", "收文员", tenant);
ApprovalTestData.TestUser member = data.createUser("收文员" + data.uniqueSuffix(), organizationId, tenant);
data.bindTenant(member.userId, role.roleId, tenant);
return member;
}
private String body(String name, String implementOrg, String fee, String serviceContent) {
return body(name, implementOrg, fee, serviceContent, null);
}
private String body(String name, String implementOrg, String fee, String serviceContent, Long id) {
StringBuilder sb = new StringBuilder("{");
if (id != null) {
sb.append("\"id\":").append(id).append(',');
}
sb.append("\"name\":").append(quote(name)).append(',')
.append("\"implementOrg\":").append(quote(implementOrg)).append(',')
.append("\"fee\":").append(fee).append(',')
.append("\"serviceContent\":").append(quote(serviceContent))
.append('}');
return sb.toString();
}
private String quote(String value) {
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
private String repeat(String value, int count) {
return value.repeat(count);
}
private String instanceStatus(long instanceId) {
return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
}
private String bizNo(long instanceId) {
return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
}
private String todayFromDatabase() {
return data.queryMaster("SELECT DATE_FORMAT(CURDATE(), '%Y%m%d')", String.class);
}
}
@@ -0,0 +1,203 @@
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;
/**
* 审批集成测试夹具:负责在共享测试库中创建并清理用户/角色/组织与审批实例。
*
* <p>sys_* 位于 server 库、审批表位于 master 库,分别使用独立 JdbcTemplate,
* 避免测试事务与动态数据源切换互相影响。</p>
*/
public class ApprovalTestData {
private static final AtomicInteger SEQ = new AtomicInteger();
private final JdbcTemplate master;
private final JdbcTemplate server;
private final List<Integer> userIds = new ArrayList<>();
private final List<Integer> roleIds = new ArrayList<>();
private final List<Integer> organizationIds = new ArrayList<>();
private final List<Long> 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) {
Integer id = insert(server,
"INSERT INTO sys_organization (organization_name, parent_id, organization_type, deleted, tenant_id) "
+ "VALUES (?, ?, 'dept', 0, ?)",
name, parentId, tenantId);
organizationIds.add(id);
return id;
}
public Integer createOrganization(String name, int parentId) {
return createOrganization(name, parentId, TestAuthentication.TENANT_ID);
}
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> T queryMaster(String sql, Class<T> 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;
}
}
}
@@ -23,6 +23,20 @@ public final class TestAuthentication {
}
public static Authentication admin(String... authorities) {
return user(USER_ID, "admin", null, TENANT_ID, authorities);
}
/**
* 构造指定用户/组织/租户的登录态,供审批流集成测试使用。
*
* @param userId 用户id
* @param username 账号(同时作为姓名)
* @param organizationId 所属部门id,可为 null
* @param tenantId 租户id
* @param authorities 权限串
*/
public static Authentication user(int userId, String username, Integer organizationId,
int tenantId, String... authorities) {
List<Menu> menus = Arrays.stream(authorities)
.map(authority -> {
Menu menu = new Menu();
@@ -32,9 +46,12 @@ public final class TestAuthentication {
.collect(Collectors.toList());
User user = new User();
user.setUserId(USER_ID);
user.setUsername("admin");
user.setTenantId(TENANT_ID);
user.setUserId(userId);
user.setUsername(username);
user.setNickname(username);
user.setRealName(username);
user.setOrganizationId(organizationId);
user.setTenantId(tenantId);
user.setStatus(0);
user.setAuthorities(menus);