diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java index 5cbff65..50d0e4d 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java @@ -116,8 +116,10 @@ public class ApprovalAttachmentService { File stored; String path; try { + // 直接落盘到上传根目录:记录路径须与 /api/file/{dir}/{name} 的物理解析一致, + // 任何子目录前缀都要同步出现在记录 path 与文件服务路由里(回归 #44) String base = config.getUploadPath(); - String dir = (base.endsWith("/") ? base : base + "/") + "approval/"; + String dir = base.endsWith("/") ? base : base + "/"; stored = FileServerUtil.upload(file, dir, Boolean.TRUE.equals(config.getUploadUuidName())); path = stored.getAbsolutePath().replace("\\", "/").substring(dir.length() - 1); } catch (Exception e) { diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalAttachmentFileRoundTripIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalAttachmentFileRoundTripIT.java new file mode 100644 index 0000000..25d80a2 --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalAttachmentFileRoundTripIT.java @@ -0,0 +1,135 @@ +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.MvcResult; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; + +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; + +/** + * 附件「上传 → 文件端点取回」往返集成测试(回归 #44)。 + * + *
附件上传成功后,H5 详情页按返回的 url 直接打开原文件;该 url 依赖 + * sys_file_record.path 与落盘物理路径一致。路径错位时预览/下载全部 404, + * 且只有一次真实往返能发现。
+ * + *不加 {@code @Transactional}:下载端点经 {@code @DS("server")} 的 + * FileRecordService 查文件记录,测试事务会把连接钉死在 master(该表在 core 库), + * 与生产无事务的调用形态不符;数据隔离由 {@link ApprovalTestData#cleanup()} 负责。
+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalAttachmentFileRoundTripIT { + + private static final int TENANT = TestAuthentication.TENANT_ID; + 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 uploadedAttachmentIsFetchableThroughPreviewAndDownloadEndpoints() throws Exception { + long instanceId = createCompletionDraft("附件往返"); + JsonNode attachment = uploadAttachment(instanceId); + String url = attachment.path("url").asText(); + assertThat(url).contains("/file/"); + + // 前端拿到的 url 去掉 scheme/host 后必须能直接 GET 到原文件(浏览器 window.open 无登录态) + String fetchPath = "/api" + url.substring(url.indexOf("/file/")); + byte[] payload = pngBytes(); + + MvcResult preview = mockMvc.perform(get(fetchPath)) + .andExpect(status().isOk()) + .andReturn(); + assertThat(preview.getResponse().getContentAsByteArray()).isEqualTo(payload); + + // 下载端点走同一条物理路径解析,一并回归 + String downloadPath = fetchPath.replace("/api/file/", "/api/file/download/"); + MvcResult download = mockMvc.perform(get(downloadPath)) + .andExpect(status().isOk()) + .andReturn(); + assertThat(download.getResponse().getContentAsByteArray()).isEqualTo(payload); + } + + // ---------- helpers ---------- + + private long createCompletionDraft(String name) throws Exception { + String body = "{\"name\":\"" + name + "\",\"implementOrg\":\"实施单位\",\"fee\":\"1.00\"," + + "\"serviceContent\":\"服务内容\"}"; + String response = mockMvc.perform(authed(post("/api/approval/instance/completion")) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + long id = objectMapper.readTree(response).path("data").asLong(); + data.trackInstance(id); + return id; + } + + private JsonNode uploadAttachment(long instanceId) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png", 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(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + return objectMapper.readTree(response).path("data"); + } + + private byte[] pngBytes() { + return new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; + } + + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder authed( + org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder builder) { + return builder.header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, organizationId, TENANT, SUBMIT))); + } +}