Browse Source

fix(backend): B-3 报告上传文件类型/内容安全校验(MIME→扩展名→魔数)

R6/N11:上传非 PDF(错误/恶意类型)内容已被拒(此前未校验,恶意文件上传风险)。

- 新增 FileValidationUtil.validatePdf 公共静态校验:
  ① Content-Type 白名单(application/pdf、application/x-pdf、application/octet-stream,
     实况缺失容忍);② 扩展名仅 .pdf(大小写不敏感);③ 内容魔数 %PDF-(25 45 44 46 2D)兜底。
  空文件/非 PDF 一律 Result.error(400)(项目惯例 HTTP 200 信封),不返回 500、不静默接受。
- DanReportController.upload、ReportParserController.uploadAndParse 复用该校验,
  非 PDF 不进入存储/解析/采集链路(下游 service 不被触达)。
- HealthReportController 同域上传入口一并覆盖:/report/upload、/report/upload-only(PDF分支)、
  /report/parse-preview(PDF分支) 接入同一校验;图片/舌诊分支行为不受影响,
  /report/bodyfat-upload 为图片域自有校验不纳入 PDF 白名单。
- 契约单测 15 例(MockMvc standalone,全绿):
  ReportUploadValidationContractTest(dan-report/report-parser,覆盖 3 条验收:
  非 PDF 4xx、空文件 4xx、合法 PDF 走原成功路径)+ HealthReportUploadValidationTest(health 同域)。
- 基线复跑:全量套件对比 origin/cfclub 无新增回归(HealthDimensionControllerTest、
  DanReportParseServiceTest 等失败为基线既有,与本次改动无关)。
Sisyphus 1 week ago
parent
commit
a8514edd22

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/DanReportController.java

@@ -5,6 +5,7 @@ import com.etotem.cfc.dto.DanReportEditDTO;
 import com.etotem.cfc.dto.DanReportUploadVO;
 import com.etotem.cfc.service.DanReportUploadService;
 import com.etotem.cfc.service.ReportMemberPermissionService;
+import com.etotem.cfc.util.FileValidationUtil;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
@@ -32,6 +33,11 @@ public class DanReportController {
             @RequestParam("dimension") String dimension,
             @RequestParam("memberId") Long memberId,
             @RequestAttribute("userId") Long userId) {
+        // B-3/R6: 文件类型/内容安全校验(MIME → 扩展名 → 魔数),非 PDF/空文件一律 400 拒绝,不进入存储/解析链路
+        Result<Map<String, Object>> invalid = FileValidationUtil.validatePdf(file);
+        if (invalid != null) {
+            return invalid;
+        }
         return uploadService.uploadAndParse(userId, memberId, dimension, file);
     }
 

+ 16 - 20
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -58,6 +58,7 @@ import com.etotem.cfc.service.api.WechatServiceInterface;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.etotem.cfc.util.FileValidationUtil;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import lombok.extern.slf4j.Slf4j;
@@ -821,15 +822,12 @@ public class HealthReportController {
             @RequestAttribute("userId") Long userId,
             @RequestAttribute(value = "familyId", required = false) Long familyId) {
 
-        if (file.isEmpty()) {
-            return Result.error("文件不能为空");
-        }
-
-        String contentType = file.getContentType();
         String originalFilename = file.getOriginalFilename();
-        if (originalFilename == null || (!originalFilename.toLowerCase().endsWith(".pdf")
-                && (contentType == null || !contentType.equalsIgnoreCase("application/pdf")))) {
-            return Result.error("仅支持PDF文件");
+
+        // B-3/R6: 文件类型/内容安全校验(MIME → 扩展名 → 魔数),空文件/非 PDF 一律 400 拒绝
+        Result<Map<String, Object>> invalid = FileValidationUtil.validatePdf(file);
+        if (invalid != null) {
+            return invalid;
         }
 
         try {
@@ -930,7 +928,7 @@ public class HealthReportController {
         }
 
         if (file.isEmpty()) {
-            return Result.error("文件不能为空");
+            return Result.error(400, "文件不能为空");
         }
 
         String contentType = file.getContentType();
@@ -961,11 +959,10 @@ public class HealthReportController {
                     }
                 }
             } else {
-                // PDF 类型校验
-                String lcName = originalFilename != null ? originalFilename.toLowerCase() : "";
-                if (!lcName.endsWith(".pdf")
-                        && (contentType == null || !contentType.equalsIgnoreCase("application/pdf"))) {
-                    return Result.error("仅支持PDF文件");
+                // B-3/R6: PDF 类型三层校验(MIME → 扩展名 → 内容魔数),非 PDF/空文件一律 400 拒绝
+                Result<Map<String, Object>> invalid = FileValidationUtil.validatePdf(file);
+                if (invalid != null) {
+                    return invalid;
                 }
             }
 
@@ -1321,7 +1318,7 @@ public class HealthReportController {
         }
 
         if (file == null || file.isEmpty()) {
-            return Result.error("文件不能为空");
+            return Result.error(400, "文件不能为空");
         }
 
         String contentType = file.getContentType();
@@ -1358,11 +1355,10 @@ public class HealthReportController {
                 return Result.success(result);
             }
 
-            // PDF 解析(默认路径)
-            String lcName = originalFilename != null ? originalFilename.toLowerCase() : "";
-            if (!lcName.endsWith(".pdf")
-                    && (contentType == null || !contentType.equalsIgnoreCase("application/pdf"))) {
-                return Result.error("仅支持PDF文件");
+            // PDF 解析(默认路径)— B-3/R6: 三层校验(MIME → 扩展名 → 内容魔数),非 PDF/空文件一律 400 拒绝
+            Result<Map<String, Object>> pdfInvalid = FileValidationUtil.validatePdf(file);
+            if (pdfInvalid != null) {
+                return pdfInvalid;
             }
 
             // 保存文件(通过 StorageService 支持本地磁盘或京东云 OSS)

+ 5 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/ReportParserController.java

@@ -10,6 +10,7 @@ import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
 import com.etotem.cfc.service.HealthReportDraftService;
 import com.etotem.cfc.service.ReportCollectService;
 import com.etotem.cfc.service.ReportFingerprintService;
+import com.etotem.cfc.util.FileValidationUtil;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.web.bind.annotation.*;
@@ -96,8 +97,10 @@ public class ReportParserController {
             @RequestParam("file") MultipartFile file,
             @RequestParam(value = "fileName", required = false) String fileName,
             @RequestAttribute("userId") Long userId) {
-        if (file == null || file.isEmpty()) {
-            return Result.error("请选择文件");
+        // B-3/R6: 文件类型/内容安全校验(MIME → 扩展名 → 魔数),空文件/非 PDF 一律 400 拒绝,不进入存储/采集链路
+        Result<Map<String, Object>> invalid = FileValidationUtil.validatePdf(file);
+        if (invalid != null) {
+            return invalid;
         }
 
         try {

+ 88 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/FileValidationUtil.java

@@ -0,0 +1,88 @@
+package com.etotem.cfc.util;
+
+import com.etotem.cfc.common.Result;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+
+/**
+ * 上传文件安全校验工具(B-3 / R6)。
+ *
+ * <p>报告上传统一走三层校验:Content-Type 白名单 → 扩展名(.pdf)→ 内容魔数(%PDF-)。
+ * 任一校验不满足即返回 400 语义错误(Result.code=400,HTTP 200 信封),
+ * 绝不把非 PDF 文件放入存储/解析链路,也绝不返回 500/静默接受。
+ *
+ * <p>校验顺序说明:
+ * <ul>
+ *   <li>Content-Type 白名单:{@code application/pdf}、{@code application/x-pdf}、
+ *       {@code application/octet-stream};Content-Type 缺失时容忍(pdf 实况常见),由扩展名与魔数兜底。</li>
+ *   <li>扩展名:仅允许 {@code .pdf}(大小写不敏感)。</li>
+ *   <li>魔数:文件头必须为 {@code %PDF-}(十六进制 25 45 44 46 2D),防伪造 MIME/扩展名。</li>
+ * </ul>
+ */
+public final class FileValidationUtil {
+
+    /** PDF 头魔数(%PDF-,ASCII 编码,十六进制 25 45 44 46 2D) */
+    private static final byte[] PDF_HEADER = new byte[]{0x25, 0x50, 0x44, 0x46, 0x2D};
+
+    /** 仅允许的扩展名(大小写不敏感) */
+    private static final String PDF_EXTENSION = ".pdf";
+
+    /** 拒绝提示(统一语义,沿用项目 Result 业务码惯例) */
+    private static final String REJECT_MESSAGE = "仅支持上传 PDF 格式报告文件";
+
+    private FileValidationUtil() {
+    }
+
+    /**
+     * 校验上传文件是否为合法 PDF(MIME → 扩展名 → 魔数 三层校验)。
+     *
+     * @param file 上传文件(可为 null)
+     * @param <T>  返回类型参数(由调用方返回值类型推断)
+     * @return null 表示校验通过;否则返回 code=400 的错误结果(空文件/非 PDF/内容无法读取)
+     */
+    public static <T> Result<T> validatePdf(MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            return Result.error(400, "文件不能为空");
+        }
+
+        // 第一层:Content-Type 白名单(缺失时容忍,由扩展名 + 魔数兜底)
+        String contentType = file.getContentType();
+        if (contentType != null && !contentType.trim().isEmpty()) {
+            String ct = contentType.trim().toLowerCase(Locale.ROOT);
+            int semi = ct.indexOf(';');
+            if (semi > 0) {
+                ct = ct.substring(0, semi).trim();
+            }
+            boolean mimeAllowed = "application/pdf".equals(ct)
+                    || "application/x-pdf".equals(ct)
+                    || "application/octet-stream".equals(ct);
+            if (!mimeAllowed) {
+                return Result.error(400, REJECT_MESSAGE);
+            }
+        }
+
+        // 第二层:扩展名(大小写不敏感)
+        String originalFilename = file.getOriginalFilename();
+        if (originalFilename == null
+                || !originalFilename.toLowerCase(Locale.ROOT).endsWith(PDF_EXTENSION)) {
+            return Result.error(400, REJECT_MESSAGE);
+        }
+
+        // 第三层:内容魔数检测 PDF 签名(%PDF-),兜住伪造 MIME/扩展名
+        try {
+            byte[] header = new byte[PDF_HEADER.length];
+            int read = file.getInputStream().read(header);
+            if (read != PDF_HEADER.length
+                    || !new String(header, StandardCharsets.US_ASCII).startsWith("%PDF-")) {
+                return Result.error(400, REJECT_MESSAGE);
+            }
+        } catch (IOException e) {
+            return Result.error(400, "文件内容读取失败,无法校验文件类型");
+        }
+
+        return null;
+    }
+}

+ 219 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/HealthReportUploadValidationTest.java

@@ -0,0 +1,219 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.dto.ParsedReportResult;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.service.HealthReportDraftService;
+import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.PdfParseService;
+import com.etotem.cfc.service.ReportCollectService;
+import com.etotem.cfc.service.StorageService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * TDD 契约单测(B-3 / R6 · 健康报告上传域同类风险覆盖)。
+ *
+ * <p>覆盖端点(同域上传入口,PDF 分支统一接入三层校验 MIME → 扩展名 → 魔数):
+ * <ul>
+ *   <li>POST /api/health/report/upload        (HealthReportController.uploadReport)</li>
+ *   <li>POST /api/health/report/upload-only   (HealthReportController.uploadOnly 的 PDF 分支)</li>
+ *   <li>POST /api/health/report/parse-preview (HealthReportController.parsePreview 的 PDF 分支,已废弃但同风险)</li>
+ * </ul>
+ *
+ * <p>契约:非 PDF/空文件 → Result.code=400(非 500、非成功),正常 PDF 走原成功路径 code=200。
+ * 校验语义沿用项目惯例:Result 业务码(400)+ HTTP 200 信封。
+ * 测试不依赖数据库/外部服务:下游 service 全 mock,不启 Spring 上下文。
+ */
+@DisplayName("B-3 健康报告上传文件类型校验契约(health)")
+public class HealthReportUploadValidationTest {
+
+    private static final byte[] PDF_BYTES = ("%PDF-1.7\n%\u00e2\u00e3\u00cf\u00d3\n"
+            + "1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF\n").getBytes(StandardCharsets.UTF_8);
+
+    private MockMvc mockMvc;
+
+    @Mock private PdfParseService pdfParseService;
+    @Mock private HealthReportService healthReportService;
+    @Mock private HealthReportDraftService healthReportDraftService;
+    @Mock private StorageService storageService;
+    @Mock private ReportCollectService reportCollectService;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        HealthReportController controller = new HealthReportController();
+        ReflectionTestUtils.setField(controller, "pdfParseService", pdfParseService);
+        ReflectionTestUtils.setField(controller, "healthReportService", healthReportService);
+        ReflectionTestUtils.setField(controller, "healthReportDraftService", healthReportDraftService);
+        ReflectionTestUtils.setField(controller, "storageService", storageService);
+        ReflectionTestUtils.setField(controller, "reportCollectService", reportCollectService);
+        ReflectionTestUtils.setField(controller, "objectMapper", objectMapper);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    // ────────────────────────── /report/upload ──────────────────────────
+
+    @Test
+    @DisplayName("report/upload:上传非 PDF → 400 拒绝且不进入 PDF 解析链路")
+    public void uploadReport_rejectsNonPdf_returns400() throws Exception {
+        MockMultipartFile exe = new MockMultipartFile(
+                "file", "malware.exe", "application/octet-stream",
+                "MZ\u0090\u0000evil_executable".getBytes(StandardCharsets.UTF_8));
+
+        mockMvc.perform(multipart("/api/health/report/upload")
+                        .file(exe)
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("仅支持上传 PDF 格式报告文件"));
+
+        verify(pdfParseService, never()).parse(any(InputStream.class));
+    }
+
+    @Test
+    @DisplayName("report/upload:空文件 → 400 拒绝")
+    public void uploadReport_rejectsEmptyFile_returns400() throws Exception {
+        MockMultipartFile empty = new MockMultipartFile(
+                "file", "empty.pdf", "application/pdf", new byte[0]);
+
+        mockMvc.perform(multipart("/api/health/report/upload")
+                        .file(empty)
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("文件不能为空"));
+
+        verify(pdfParseService, never()).parse(any(InputStream.class));
+    }
+
+    @Test
+    @DisplayName("report/upload:合法 PDF(%PDF- 魔数)→ 走原成功路径 code=200")
+    public void uploadReport_validPdf_returnsSuccess() throws Exception {
+        MockMultipartFile pdf = new MockMultipartFile(
+                "file", "legit.pdf", "application/pdf", PDF_BYTES);
+
+        ParsedReportResult parsed = new ParsedReportResult();
+        parsed.setOverallScore(80);
+        when(pdfParseService.parse(any(InputStream.class))).thenReturn(parsed);
+        when(storageService.storeFile(any(MultipartFile.class), eq("health-reports")))
+                .thenReturn("/uploads/health-reports/legit.pdf");
+        HealthReportDraft draft = new HealthReportDraft();
+        draft.setId(666L);
+        when(healthReportDraftService.createDraft(eq(1L), isNull(), eq("gut_flora"),
+                anyString(), anyString(), anyString())).thenReturn(draft);
+        HealthReport created = new HealthReport();
+        created.setId(666L);
+        created.setReportType("gut_flora");
+        when(healthReportService.createReport(any(), anyList(), anyList(), anyList()))
+                .thenReturn(created);
+        Map<String, Object> detail = new HashMap<>();
+        detail.put("reportId", 666L);
+        when(healthReportService.getReportDetail(666L)).thenReturn(detail);
+
+        mockMvc.perform(multipart("/api/health/report/upload")
+                        .file(pdf)
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.reportId").value(666));
+
+        verify(pdfParseService).parse(any(InputStream.class));
+        verify(healthReportService).createReport(any(), anyList(), anyList(), anyList());
+        verify(healthReportDraftService).markConfirmed(666L, 666L);
+    }
+
+    // ────────────────────────── /report/upload-only ──────────────────────────
+
+    @Test
+    @DisplayName("report/upload-only(type=pdf):上传非 PDF → 400 拒绝且不存储/不去重")
+    public void uploadOnly_rejectsNonPdf_returns400() throws Exception {
+        MockMultipartFile txt = new MockMultipartFile(
+                "file", "notes.txt", "text/plain", "hello world".getBytes(StandardCharsets.UTF_8));
+
+        mockMvc.perform(multipart("/api/health/report/upload-only")
+                        .file(txt)
+                        .param("type", "pdf")
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400));
+
+        verify(storageService, never()).storeFile(any(MultipartFile.class), anyString());
+    }
+
+    @Test
+    @DisplayName("report/upload-only(type=pdf):合法 PDF(%PDF- 魔数)→ 走原成功路径(建草稿+异步采集)")
+    public void uploadOnly_validPdf_returnsSuccess() throws Exception {
+        MockMultipartFile pdf = new MockMultipartFile(
+                "file", "legit.pdf", "application/pdf", PDF_BYTES);
+
+        // Mockito 对未打桩的 Long 返回 0(非 null),显式置 null 跳过重复上传检测分支
+        when(healthReportService.findReportIdByFileHash(anyString(), anyString())).thenReturn(null);
+        when(healthReportService.findCollectingDraftIdByFileHash(anyString())).thenReturn(null);
+        when(storageService.storeFile(any(MultipartFile.class), eq("health-reports")))
+                .thenReturn("/uploads/health-reports/legit.pdf");
+        HealthReportDraft draft = new HealthReportDraft();
+        draft.setId(777L);
+        when(healthReportDraftService.createDraft(any(), isNull(), eq("gut_flora"),
+                anyString(), anyString(), anyString(), anyString(), isNull())).thenReturn(draft);
+
+        mockMvc.perform(multipart("/api/health/report/upload-only")
+                        .file(pdf)
+                        .param("type", "pdf")
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.draftId").value(777));
+
+        verify(storageService).storeFile(any(MultipartFile.class), eq("health-reports"));
+        verify(reportCollectService).submitAsyncCollect(777L);
+    }
+
+    // ────────────────────────── /report/parse-preview(已废弃,同风险补覆盖) ──────────────────────────
+
+    @Test
+    @DisplayName("report/parse-preview:上传非 PDF → 400 拒绝")
+    public void parsePreview_rejectsNonPdf_returns400() throws Exception {
+        MockMultipartFile exe = new MockMultipartFile(
+                "file", "malware.exe", "application/octet-stream",
+                "MZ\u0090\u0000evil".getBytes(StandardCharsets.UTF_8));
+
+        mockMvc.perform(multipart("/api/health/report/parse-preview")
+                        .file(exe)
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400));
+
+        verify(pdfParseService, never()).parse(any(InputStream.class));
+        verify(storageService, never()).storeFile(any(MultipartFile.class), anyString());
+    }
+}

+ 254 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ReportUploadValidationContractTest.java

@@ -0,0 +1,254 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.service.DanReportUploadService;
+import com.etotem.cfc.service.HealthReportDraftService;
+import com.etotem.cfc.service.ReportCollectService;
+import com.etotem.cfc.service.StorageService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * TDD 契约单测(B-3 / R6 · 报告上传文件类型/内容安全校验)。
+ *
+ * <p>覆盖端点:
+ * <ul>
+ *   <li>POST /api/dan-report/upload  (DanReportController.upload)</li>
+ *   <li>POST /api/report-parser/upload(ReportParserController.uploadAndParse)</li>
+ * </ul>
+ *
+ * <p>契约(三条验收,两条端点均覆盖第 1/3 条):
+ * <ol>
+ *   <li>上传非 PDF(.exe/.txt/伪造扩展名与 MIME 的非 PDF 内容)→ Result.code=400(非 500、非成功),
+ *       且不进入存储/解析/采集链路(下游 service 不被调用)</li>
+ *   <li>上传空文件 → Result.code=400</li>
+ *   <li>上传合法 PDF(文件头含 %PDF- 魔数)→ 走原成功路径返回 Result.code=200</li>
+ * </ol>
+ *
+ * <p>校验语义沿用项目惯例:Result 业务码(400)+ HTTP 200 信封。
+ * 测试不依赖数据库/外部服务:下游 service 全 mock,不启 Spring 上下文。
+ */
+@DisplayName("B-3 报告上传文件类型校验契约(dan-report / report-parser)")
+public class ReportUploadValidationContractTest {
+
+    /** 合法 PDF 文件头(含 %PDF- 魔数) */
+    private static final byte[] PDF_BYTES = ("%PDF-1.7\n%\u00e2\u00e3\u00cf\u00d3\n"
+            + "1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF\n").getBytes(StandardCharsets.UTF_8);
+
+    /** EXE 文件头(MZ 魔数),用于伪造场景 */
+    private static final byte[] EXE_BYTES = "MZ\u0090\u0000evil_executable_content".getBytes(StandardCharsets.UTF_8);
+
+    private MockMvc danMvc;
+    private MockMvc parserMvc;
+
+    @Mock private DanReportUploadService danUploadService;
+    @Mock private StorageService storageService;
+    @Mock private HealthReportDraftService healthReportDraftService;
+    @Mock private ReportCollectService reportCollectService;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+
+        DanReportController danController = new DanReportController();
+        ReflectionTestUtils.setField(danController, "uploadService", danUploadService);
+        danMvc = MockMvcBuilders.standaloneSetup(danController).build();
+
+        ReportParserController parserController = new ReportParserController();
+        ReflectionTestUtils.setField(parserController, "storageService", storageService);
+        ReflectionTestUtils.setField(parserController, "healthReportDraftService", healthReportDraftService);
+        ReflectionTestUtils.setField(parserController, "reportCollectService", reportCollectService);
+        parserMvc = MockMvcBuilders.standaloneSetup(parserController).build();
+    }
+
+    // ────────────────────────── dan-report/upload ──────────────────────────
+
+    @Test
+    @DisplayName("dan-report/upload:上传 .exe 非 PDF → 400 拒绝且不触达解析服务")
+    public void danReportUpload_rejectsExecutable_returns400() throws Exception {
+        MockMultipartFile exe = new MockMultipartFile(
+                "file", "malware.exe", "application/octet-stream", EXE_BYTES);
+
+        danMvc.perform(multipart("/api/dan-report/upload")
+                        .file(exe)
+                        .param("dimension", "mind")
+                        .param("memberId", "7")
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("仅支持上传 PDF 格式报告文件"));
+
+        verify(danUploadService, never()).uploadAndParse(anyLong(), anyLong(), any(), any());
+    }
+
+    @Test
+    @DisplayName("dan-report/upload:上传 .txt(text/plain MIME)→ 400 拒绝")
+    public void danReportUpload_rejectsTextFile_returns400() throws Exception {
+        MockMultipartFile txt = new MockMultipartFile(
+                "file", "notes.txt", "text/plain", "hello world".getBytes(StandardCharsets.UTF_8));
+
+        danMvc.perform(multipart("/api/dan-report/upload")
+                        .file(txt)
+                        .param("dimension", "wisdom")
+                        .param("memberId", "7")
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400));
+
+        verify(danUploadService, never()).uploadAndParse(anyLong(), anyLong(), any(), any());
+    }
+
+    @Test
+    @DisplayName("dan-report/upload:伪造 .pdf 扩展名 + application/pdf MIME 但内容非 PDF → 魔数兜底 400 拒绝")
+    public void danReportUpload_rejectsFakePdfByMagicBytes_returns400() throws Exception {
+        MockMultipartFile fakePdf = new MockMultipartFile(
+                "file", "report.pdf", "application/pdf", "definitely not a pdf".getBytes(StandardCharsets.UTF_8));
+
+        danMvc.perform(multipart("/api/dan-report/upload")
+                        .file(fakePdf)
+                        .param("dimension", "mind")
+                        .param("memberId", "7")
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400));
+
+        verify(danUploadService, never()).uploadAndParse(anyLong(), anyLong(), any(), any());
+    }
+
+    @Test
+    @DisplayName("dan-report/upload:空文件 → 400 拒绝")
+    public void danReportUpload_rejectsEmptyFile_returns400() throws Exception {
+        MockMultipartFile empty = new MockMultipartFile(
+                "file", "empty.pdf", "application/pdf", new byte[0]);
+
+        danMvc.perform(multipart("/api/dan-report/upload")
+                        .file(empty)
+                        .param("dimension", "mind")
+                        .param("memberId", "7")
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("文件不能为空"));
+
+        verify(danUploadService, never()).uploadAndParse(anyLong(), anyLong(), any(), any());
+    }
+
+    @Test
+    @DisplayName("dan-report/upload:合法 PDF(文件头含 %PDF- 魔数)→ 走原成功路径 code=200")
+    public void danReportUpload_validPdf_returnsSuccess() throws Exception {
+        MockMultipartFile pdf = new MockMultipartFile(
+                "file", "dan-report.pdf", "application/pdf", PDF_BYTES);
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("draftId", 11L);
+        when(danUploadService.uploadAndParse(eq(1L), eq(7L), eq("mind"), any(MultipartFile.class)))
+                .thenReturn(Result.success(data));
+
+        danMvc.perform(multipart("/api/dan-report/upload")
+                        .file(pdf)
+                        .param("dimension", "mind")
+                        .param("memberId", "7")
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.draftId").value(11));
+
+        verify(danUploadService).uploadAndParse(eq(1L), eq(7L), eq("mind"), any(MultipartFile.class));
+    }
+
+    // ────────────────────────── report-parser/upload ──────────────────────────
+
+    @Test
+    @DisplayName("report-parser/upload:上传非 PDF → 400 拒绝且不进入存储链路")
+    public void reportParserUpload_rejectsNonPdf_returns400() throws Exception {
+        MockMultipartFile exe = new MockMultipartFile(
+                "file", "malware.exe", "application/octet-stream", EXE_BYTES);
+
+        parserMvc.perform(multipart("/api/report-parser/upload")
+                        .file(exe)
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("仅支持上传 PDF 格式报告文件"));
+
+        verify(storageService, never()).storeFile(any(MultipartFile.class), any());
+    }
+
+    @Test
+    @DisplayName("report-parser/upload:伪造 .pdf 扩展名但内容非 PDF → 魔数兜底 400 拒绝")
+    public void reportParserUpload_rejectsFakePdfByMagicBytes_returns400() throws Exception {
+        MockMultipartFile fakePdf = new MockMultipartFile(
+                "file", "report.pdf", "application/pdf", "MZ fake pdf binary".getBytes(StandardCharsets.UTF_8));
+
+        parserMvc.perform(multipart("/api/report-parser/upload")
+                        .file(fakePdf)
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400));
+
+        verify(storageService, never()).storeFile(any(MultipartFile.class), any());
+    }
+
+    @Test
+    @DisplayName("report-parser/upload:空文件 → 400 拒绝")
+    public void reportParserUpload_rejectsEmptyFile_returns400() throws Exception {
+        MockMultipartFile empty = new MockMultipartFile(
+                "file", "empty.pdf", "application/pdf", new byte[0]);
+
+        parserMvc.perform(multipart("/api/report-parser/upload")
+                        .file(empty)
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("文件不能为空"));
+
+        verify(storageService, never()).storeFile(any(MultipartFile.class), any());
+    }
+
+    @Test
+    @DisplayName("report-parser/upload:合法 PDF(%PDF- 魔数)→ 走原成功路径(存储+建草稿+异步采集)")
+    public void reportParserUpload_validPdf_returnsSuccess() throws Exception {
+        MockMultipartFile pdf = new MockMultipartFile(
+                "file", "legit.pdf", "application/pdf", PDF_BYTES);
+
+        when(storageService.storeFile(any(MultipartFile.class), eq("health-reports")))
+                .thenReturn("/uploads/health-reports/legit.pdf");
+        HealthReportDraft draft = new HealthReportDraft();
+        draft.setId(55L);
+        when(healthReportDraftService.createDraft(any(), any(), any(), any(), any(), any()))
+                .thenReturn(draft);
+
+        parserMvc.perform(multipart("/api/report-parser/upload")
+                        .file(pdf)
+                        .requestAttr("userId", 1L))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.draftId").value(55));
+
+        verify(storageService).storeFile(any(MultipartFile.class), eq("health-reports"));
+        verify(reportCollectService).submitAsyncCollect(55L);
+    }
+}