fix: 审批附件落盘改到上传根目录,修复预览/下载 404(#44)

This commit is contained in:
2026-10-31 04:38:09 +08:00
parent fe96bb0457
commit 61d4f69716
2 changed files with 138 additions and 1 deletions
@@ -116,8 +116,10 @@ public class ApprovalAttachmentService {
File stored; File stored;
String path; String path;
try { try {
// 直接落盘到上传根目录:记录路径须与 /api/file/{dir}/{name} 的物理解析一致,
// 任何子目录前缀都要同步出现在记录 path 与文件服务路由里(回归 #44)
String base = config.getUploadPath(); 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())); stored = FileServerUtil.upload(file, dir, Boolean.TRUE.equals(config.getUploadUuidName()));
path = stored.getAbsolutePath().replace("\\", "/").substring(dir.length() - 1); path = stored.getAbsolutePath().replace("\\", "/").substring(dir.length() - 1);
} catch (Exception e) { } catch (Exception e) {
@@ -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)。
*
* <p>附件上传成功后,H5 详情页按返回的 url 直接打开原文件;该 url 依赖
* sys_file_record.path 与落盘物理路径一致。路径错位时预览/下载全部 404,
* 且只有一次真实往返能发现。</p>
*
* <p>不加 {@code @Transactional}:下载端点经 {@code @DS("server")} 的
* FileRecordService 查文件记录,测试事务会把连接钉死在 master(该表在 core 库),
* 与生产无事务的调用形态不符;数据隔离由 {@link ApprovalTestData#cleanup()} 负责。</p>
*/
@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)));
}
}