Просмотр исходного кода

Merge branch 'task/S-cfc-197' into cfclub

Sisyphus Agent 2 часов назад
Родитель
Сommit
563e6c62d0

+ 616 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminReportParserManageContractTest.java

@@ -0,0 +1,616 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.admin.ReportParseAdminController;
+import com.etotem.cfc.controller.admin.ReportParserAdminController;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ReportFingerprint;
+import com.etotem.cfc.entity.ReportParserImport;
+import com.etotem.cfc.entity.ReportTypeRegistry;
+import com.etotem.cfc.entity.ReportUnknownCluster;
+import com.etotem.cfc.entity.ReportUnknownUpload;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ReportFingerprintMapper;
+import com.etotem.cfc.mapper.ReportParserImportMapper;
+import com.etotem.cfc.mapper.ReportTemplateMapper;
+import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
+import com.etotem.cfc.mapper.ReportUnknownClusterMapper;
+import com.etotem.cfc.mapper.ReportUnknownUploadMapper;
+import com.etotem.cfc.service.DanReportUploadService;
+import com.etotem.cfc.service.HealthReportDraftService;
+import com.etotem.cfc.service.ReportCollectService;
+import com.etotem.cfc.service.ReportImportService;
+import com.etotem.cfc.service.ReportScriptGeneratorService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:管理端报告解析能力(能力键 {@code admin.reportParser.manage},SC-A-02 报告解析中台·类型/聚类 +
+ * SC-A-16 报告解析自学习/未知类型标注)。
+ *
+ * <p>契约来源:
+ * <ul>
+ *   <li>API_REFERENCE.md §4.30 管理端-报告解析({@code /api/admin/report-parser/*}):types /
+ *       types/save / types/toggle / types/delete / fingerprints/list / fingerprints/save /
+ *       fingerprints/delete / imports / imports/import / imports/approve / imports/reject /
+ *       unknown-clusters / unknown-uploads / generate-type / dan/reparse;</li>
+ *   <li>API_REFERENCE.md §4.59.1 管理端-报告解析进度({@code /api/admin/report-parse/*}):list /
+ *       retry / backfill-food;</li>
+ *   <li>tests/test-cases/admin.md TC-A-05/06/07/17(正常流 + 异常流 + 权限流:非 admin 访问
+ *       {@code /api/admin/report-*} 统一 401/403,不泄露后台数据)。</li>
+ * </ul>
+ *
+ * <p>实现现状(contract 核对,2026-11-13):{@code ReportParserAdminController}(/api/admin/report-parser)
+ * 与 {@code ReportParseAdminController}(/api/admin/report-parse)均已落地于 origin/cfclub;
+ * 既有 {@code ReportParserAdminContractTest} 仅覆盖 generateType / listTypes 两个方法。本类补全
+ * 其余端点(saveType/toggleType/deleteType/fingerprints/imports/clusters/dan/reparse +
+ * report-parse list/retry/backfill-food)的正常流 / 异常流契约断言,并按 TR-7(B-2 契约核对)作为
+ * 测试侧三类流执行证据登记;不修改 src/main。</p>
+ *
+ * <p>风格:纯 Mockito + 反射注入(无 Spring / 无 DB / 无外部服务),对齐 TeamProfitHookContractTest、
+ * ReportParserAdminContractTest。权限流为说明性契约(admin 整族登录拦截由 JwtInterceptor
+ * LOGIN_REQUIRED_PATHS 的 {@code /api/admin/} 前缀 + controller 层 {@code @RequestAttribute("role")}
+ * 守卫构成,本类固定两个 Controller 的映射前缀归属 /api/admin 命名空间)。</p>
+ */
+class AdminReportParserManageContractTest {
+
+    private ReportParserAdminController parserController;
+    private ReportParseAdminController parseAdminController;
+
+    private ReportTypeRegistryMapper typeRegistryMapper;
+    private ReportFingerprintMapper fingerprintMapper;
+    private ReportUnknownClusterMapper unknownClusterMapper;
+    private ReportUnknownUploadMapper unknownUploadMapper;
+    private ReportParserImportMapper importMapper;
+    private ProductMapper productMapper;
+    private ReportImportService reportImportService;
+    private DanReportUploadService danReportUploadService;
+    private ReportTemplateMapper reportTemplateMapper;
+    private ReportScriptGeneratorService reportScriptGeneratorService;
+
+    private HealthReportDraftMapper draftMapper;
+    private HealthReportDraftService healthReportDraftService;
+    private ReportCollectService reportCollectService;
+
+    @BeforeAll
+    static void initMybatisPlusTableInfo() {
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, ReportTypeRegistry.class);
+        TableInfoHelper.initTableInfo(assistant, ReportFingerprint.class);
+        TableInfoHelper.initTableInfo(assistant, ReportUnknownCluster.class);
+        TableInfoHelper.initTableInfo(assistant, ReportUnknownUpload.class);
+        TableInfoHelper.initTableInfo(assistant, ReportParserImport.class);
+        TableInfoHelper.initTableInfo(assistant, HealthReportDraft.class);
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+    }
+
+    @BeforeEach
+    void setUp() throws Exception {
+        parserController = new ReportParserAdminController();
+        injectAll(parserController);
+        typeRegistryMapper = (ReportTypeRegistryMapper) readField(parserController, "typeRegistryMapper");
+        fingerprintMapper = (ReportFingerprintMapper) readField(parserController, "fingerprintMapper");
+        unknownClusterMapper = (ReportUnknownClusterMapper) readField(parserController, "unknownClusterMapper");
+        unknownUploadMapper = (ReportUnknownUploadMapper) readField(parserController, "unknownUploadMapper");
+        importMapper = (ReportParserImportMapper) readField(parserController, "importMapper");
+        productMapper = (ProductMapper) readField(parserController, "productMapper");
+        reportImportService = (ReportImportService) readField(parserController, "reportImportService");
+        danReportUploadService = (DanReportUploadService) readField(parserController, "danReportUploadService");
+        reportTemplateMapper = (ReportTemplateMapper) readField(parserController, "reportTemplateMapper");
+        reportScriptGeneratorService =
+                (ReportScriptGeneratorService) readField(parserController, "reportScriptGeneratorService");
+
+        parseAdminController = new ReportParseAdminController();
+        injectAll(parseAdminController);
+        draftMapper = (HealthReportDraftMapper) readField(parseAdminController, "draftMapper");
+        healthReportDraftService = (HealthReportDraftService) readField(parseAdminController, "healthReportDraftService");
+        reportCollectService = (ReportCollectService) readField(parseAdminController, "reportCollectService");
+    }
+
+    private static Object readField(Object target, String name) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        return f.get(target);
+    }
+
+    private static void injectAll(Object target) throws Exception {
+        for (Field f : target.getClass().getDeclaredFields()) {
+            if (Modifier.isStatic(f.getModifiers()) || Modifier.isFinal(f.getModifiers())) {
+                continue;
+            }
+            f.setAccessible(true);
+            f.set(target, mock(f.getType()));
+        }
+    }
+
+    private ReportTypeRegistry newType(String typeId, String displayName) {
+        ReportTypeRegistry t = new ReportTypeRegistry();
+        t.setTypeId(typeId);
+        t.setDisplayName(displayName);
+        return t;
+    }
+
+    private Map<String, Object> params(Object... kv) {
+        Map<String, Object> p = new HashMap<>();
+        for (int i = 0; i < kv.length; i += 2) {
+            p.put((String) kv[i], kv[i + 1]);
+        }
+        return p;
+    }
+
+    // ==================== ReportParserAdminController · 类型 CRUD ====================
+
+    @Test
+    void 正常流_listTypes_分页返回类型列表并回填商品名() {
+        ReportTypeRegistry t = newType("gut_flora", "肠道菌群");
+        t.setId(1L);
+        t.setLinkedProductId(5L);
+        Page<ReportTypeRegistry> page = new Page<>(1, 20);
+        page.setRecords(Collections.singletonList(t));
+        page.setTotal(1);
+        when(typeRegistryMapper.selectPage(any(), any())).thenReturn(page);
+        Product p = new Product();
+        p.setId(5L);
+        p.setName("肠道菌群检测");
+        when(productMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(p));
+
+        Result<Page<ReportTypeRegistry>> result = parserController.listTypes(params("page", 1, "size", 20));
+
+        assertEquals(200, result.getCode());
+        assertEquals(1, result.getData().getRecords().size());
+        assertEquals("gut_flora", result.getData().getRecords().get(0).getTypeId());
+        assertEquals("肠道菌群检测", result.getData().getRecords().get(0).getProductName(),
+                "linkedProductId 非空时应回填商品名(§4.30 types)");
+    }
+
+    @Test
+    void 正常流_saveType_新增插入并落默认值() {
+        ReportTypeRegistry t = newType("physical_exam", "体检报告");
+        when(typeRegistryMapper.selectCount(any())).thenReturn(0L);
+
+        Result<Void> result = parserController.saveType(t);
+
+        assertEquals(200, result.getCode());
+        ArgumentCaptor<ReportTypeRegistry> captor = ArgumentCaptor.forClass(ReportTypeRegistry.class);
+        verify(typeRegistryMapper).insert(captor.capture());
+        ReportTypeRegistry saved = captor.getValue();
+        assertTrue(saved.getIsActive(), "新增类型默认启用(isActive=true)");
+        assertEquals("prebuilt", saved.getSource());
+        assertEquals("approved", saved.getReviewStatus());
+        assertEquals("1.0", saved.getVersion());
+        assertEquals(10, saved.getMinConfidence());
+    }
+
+    @Test
+    void 正常流_saveType_已存在id走更新并允许清空商品链接() {
+        ReportTypeRegistry t = newType("gut_flora", "肠道菌群");
+        t.setId(9L);
+        t.setLinkedProductId(null);
+
+        Result<Void> result = parserController.saveType(t);
+
+        assertEquals(200, result.getCode());
+        verify(typeRegistryMapper).updateById(t);
+        verify(typeRegistryMapper).update(isNull(),
+                any(com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper.class));
+    }
+
+    @Test
+    void 异常流_saveType_typeId缺失_拒绝() {
+        ReportTypeRegistry t = newType("", "肠道菌群");
+        Result<Void> result = parserController.saveType(t);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("typeId"), "typeId 缺失应报错");
+        verify(typeRegistryMapper, never()).insert(any());
+    }
+
+    @Test
+    void 异常流_saveType_displayName缺失_拒绝() {
+        ReportTypeRegistry t = newType("gut_flora", "");
+        Result<Void> result = parserController.saveType(t);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("displayName"), "displayName 缺失应报错");
+    }
+
+    @Test
+    void 异常流_saveType_expectedIndicators非法JSON_拒绝() {
+        ReportTypeRegistry t = newType("gut_flora", "肠道菌群");
+        t.setExpectedIndicators("{不是数组}");
+        Result<Void> result = parserController.saveType(t);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("expectedIndicators"), "expectedIndicators 非法应报错");
+        verify(typeRegistryMapper, never()).insert(any());
+    }
+
+    @Test
+    void 异常流_saveType_重复typeId_拒绝不插入() {
+        ReportTypeRegistry t = newType("gut_flora", "肠道菌群");
+        when(typeRegistryMapper.selectCount(any())).thenReturn(1L);
+        Result<Void> result = parserController.saveType(t);
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("已存在"), "重复 typeId 应报错(TC-A-06:重复 typeId 拒绝)");
+        verify(typeRegistryMapper, never()).insert(any());
+    }
+
+    @Test
+    void 正常流_toggleType_停用报告类型() {
+        ReportTypeRegistry t = newType("gut_flora", "肠道菌群");
+        when(typeRegistryMapper.selectOne(any())).thenReturn(t);
+        Result<Void> result = parserController.toggleType(params("typeId", "gut_flora", "isActive", false));
+        assertEquals(200, result.getCode());
+        verify(typeRegistryMapper).updateById(t);
+        assertEquals(false, t.getIsActive(), "isActive=false 应停用类型");
+    }
+
+    @Test
+    void 异常流_toggleType_typeId缺失_拒绝() {
+        Result<Void> result = parserController.toggleType(params("isActive", false));
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("typeId"));
+        verify(typeRegistryMapper, never()).updateById(any());
+    }
+
+    @Test
+    void 异常流_toggleType_类型不存在_拒绝() {
+        when(typeRegistryMapper.selectOne(any())).thenReturn(null);
+        Result<Void> result = parserController.toggleType(params("typeId", "nope"));
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("不存在"));
+        verify(typeRegistryMapper, never()).updateById(any());
+    }
+
+    @Test
+    void 正常流_deleteType_删除类型并级联删除指纹() {
+        Result<Void> result = parserController.deleteType(params("typeId", "gut_flora"));
+        assertEquals(200, result.getCode());
+        verify(typeRegistryMapper).delete(any());
+        verify(fingerprintMapper).delete(any());
+    }
+
+    @Test
+    void 异常流_deleteType_typeId缺失_拒绝() {
+        Result<Void> result = parserController.deleteType(params());
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("typeId"));
+        verify(typeRegistryMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
+    }
+
+    // ==================== ReportParserAdminController · 指纹规则 ====================
+
+    @Test
+    void 正常流_listFingerprints_按typeId查询并按排序返回() {
+        ReportFingerprint fp = new ReportFingerprint();
+        fp.setId(1L);
+        fp.setTypeId("gut_flora");
+        fp.setPattern("菌群多样性");
+        when(fingerprintMapper.selectList(any())).thenReturn(Collections.singletonList(fp));
+
+        Result<List<ReportFingerprint>> result = parserController.listFingerprints(params("typeId", "gut_flora"));
+
+        assertEquals(200, result.getCode());
+        assertEquals(1, result.getData().size());
+        assertEquals("菌群多样性", result.getData().get(0).getPattern());
+        verify(fingerprintMapper).selectList(any());
+    }
+
+    @Test
+    void 异常流_listFingerprints_typeId缺失_拒绝() {
+        Result<List<ReportFingerprint>> result = parserController.listFingerprints(params());
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("typeId"));
+    }
+
+    @Test
+    void 正常流_saveFingerprint_插入并落默认权重与排序() {
+        ReportFingerprint fp = new ReportFingerprint();
+        fp.setTypeId("gut_flora");
+        fp.setPattern("菌群多样性");
+
+        Result<Void> result = parserController.saveFingerprint(fp);
+
+        assertEquals(200, result.getCode());
+        ArgumentCaptor<ReportFingerprint> captor = ArgumentCaptor.forClass(ReportFingerprint.class);
+        verify(fingerprintMapper).insert(captor.capture());
+        ReportFingerprint saved = captor.getValue();
+        assertEquals(10, saved.getWeight(), "指纹默认权重 10");
+        assertEquals(false, saved.getIsExclusion(), "默认非排除规则");
+        assertEquals(0, saved.getSortOrder(), "默认排序 0");
+    }
+
+    @Test
+    void 异常流_saveFingerprint_typeId或pattern缺失_拒绝() {
+        Result<Void> result = parserController.saveFingerprint(new ReportFingerprint());
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("typeId") && result.getMessage().contains("pattern"));
+        verify(fingerprintMapper, never()).insert(any());
+    }
+
+    @Test
+    void 正常流_deleteFingerprint_按id删除() {
+        Result<Void> result = parserController.deleteFingerprint(params("id", 7L));
+        assertEquals(200, result.getCode());
+        verify(fingerprintMapper).deleteById(7L);
+    }
+
+    @Test
+    void 异常流_deleteFingerprint_id缺失_拒绝() {
+        Result<Void> result = parserController.deleteFingerprint(params());
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("id"));
+        verify(fingerprintMapper, never()).deleteById(anyLong());
+    }
+
+    // ==================== ReportParserAdminController · 导入审批链 ====================
+
+    @Test
+    void 正常流_listImports_分页返回导入记录() {
+        Page<ReportParserImport> page = new Page<>(1, 20);
+        ReportParserImport imp = new ReportParserImport();
+        imp.setId(1L);
+        imp.setPackageName("gut_flora_v2.json");
+        imp.setStatus("pending");
+        page.setRecords(Collections.singletonList(imp));
+        page.setTotal(1);
+        when(importMapper.selectPage(any(), any())).thenReturn(page);
+
+        Result<Page<ReportParserImport>> result = parserController.listImports(params("page", 1, "size", 20));
+
+        assertEquals(200, result.getCode());
+        assertEquals(1, result.getData().getRecords().size());
+        assertEquals("pending", result.getData().getRecords().get(0).getStatus());
+    }
+
+    @Test
+    void 正常流_uploadImport_委托导入服务解析导入包() {
+        ReportParserImport imp = new ReportParserImport();
+        imp.setId(1L);
+        when(reportImportService.importPackage(any(), any(), any())).thenReturn(Result.success(imp));
+
+        Result<ReportParserImport> result = parserController.uploadImport(
+                params("jsonContent", "{\"typeId\":\"gut_flora\"}", "fileName", "pkg.json", "userId", 3L));
+
+        assertEquals(200, result.getCode());
+        verify(reportImportService).importPackage(eq("pkg.json"), eq("{\"typeId\":\"gut_flora\"}"), eq(3L));
+    }
+
+    @Test
+    void 异常流_uploadImport_jsonContent缺失_拒绝() {
+        Result<ReportParserImport> result = parserController.uploadImport(params("fileName", "pkg.json"));
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("jsonContent"));
+        verify(reportImportService, never()).importPackage(any(), any(), any());
+    }
+
+    @Test
+    void 正常流_approveImport_委托审批导入包() {
+        when(reportImportService.approveImport(anyLong(), any())).thenReturn(Result.success());
+        Result<Map<String, Object>> result = parserController.approveImport(params("id", 2L, "reviewerId", 9L));
+        assertEquals(200, result.getCode());
+        verify(reportImportService).approveImport(2L, 9L);
+    }
+
+    @Test
+    void 异常流_approveImport_id缺失_拒绝() {
+        Result<Map<String, Object>> result = parserController.approveImport(params("reviewerId", 9L));
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("id"));
+        verify(reportImportService, never()).approveImport(anyLong(), any());
+    }
+
+    @Test
+    void 正常流_rejectImport_委托驳回导入包() {
+        when(reportImportService.rejectImport(anyLong(), any(), any())).thenReturn(Result.success());
+        Result<Void> result = parserController.rejectImport(params("id", 2L, "reviewerId", 9L, "note", "字段缺"));
+        assertEquals(200, result.getCode());
+        verify(reportImportService).rejectImport(2L, 9L, "字段缺");
+    }
+
+    @Test
+    void 异常流_rejectImport_id缺失_拒绝() {
+        Result<Void> result = parserController.rejectImport(params("note", "x"));
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("id"));
+        verify(reportImportService, never()).rejectImport(anyLong(), any(), any());
+    }
+
+    // ==================== ReportParserAdminController · 未知聚类 ====================
+
+    @Test
+    void 正常流_listClusters_分页并按状态过滤() {
+        Page<ReportUnknownCluster> page = new Page<>(1, 20);
+        ReportUnknownCluster c = new ReportUnknownCluster();
+        c.setId(3L);
+        c.setClusterKey("City Hospital");
+        c.setStatus(ReportUnknownCluster.STATUS_READY);
+        c.setReportCount(12);
+        page.setRecords(Collections.singletonList(c));
+        page.setTotal(1);
+        when(unknownClusterMapper.selectPage(any(), any())).thenReturn(page);
+
+        Result<Page<ReportUnknownCluster>> result =
+                parserController.listClusters(params("page", 1, "size", 20, "status", "ready"));
+
+        assertEquals(200, result.getCode());
+        assertEquals(1, result.getData().getRecords().size());
+        assertEquals("ready", result.getData().getRecords().get(0).getStatus());
+    }
+
+    @Test
+    void 正常流_listUploads_按clusterId返回聚类上传记录() {
+        ReportUnknownUpload u = new ReportUnknownUpload();
+        u.setId(1L);
+        u.setClusterId(3L);
+        u.setFileHash("abc");
+        when(unknownUploadMapper.selectList(any())).thenReturn(Collections.singletonList(u));
+
+        Result<List<ReportUnknownUpload>> result = parserController.listUploads(params("clusterId", 3L));
+
+        assertEquals(200, result.getCode());
+        assertEquals(1, result.getData().size());
+        assertEquals("abc", result.getData().get(0).getFileHash());
+    }
+
+    @Test
+    void 异常流_listUploads_clusterId缺失_拒绝() {
+        Result<List<ReportUnknownUpload>> result = parserController.listUploads(params());
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("clusterId"));
+    }
+
+    // ==================== ReportParserAdminController · DAN 重解析 ====================
+
+    @Test
+    void 正常流_reparseDan_委托DAN报告按维度重解析() {
+        Map<String, Object> reparse = new HashMap<>();
+        reparse.put("uploadId", 6L);
+        reparse.put("dimension", "learning");
+        reparse.put("parsed", true);
+        when(danReportUploadService.reparseUpload(anyLong(), any())).thenReturn(Result.success(reparse));
+
+        Result<Map<String, Object>> result =
+                parserController.reparseDan(params("uploadId", 6L, "dimension", "learning"));
+
+        assertEquals(200, result.getCode());
+        assertEquals(true, result.getData().get("parsed"));
+        verify(danReportUploadService).reparseUpload(6L, "learning");
+    }
+
+    @Test
+    void 异常流_reparseDan_uploadId或dimension缺失_拒绝() {
+        Result<Map<String, Object>> result = parserController.reparseDan(params("uploadId", 6L));
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("uploadId") || result.getMessage().contains("dimension"));
+        verify(danReportUploadService, never()).reparseUpload(anyLong(), any());
+    }
+
+    // ==================== ReportParseAdminController · 解析进度管理 ====================
+
+    @Test
+    void 正常流_list_分页返回草稿解析进度记录字段集() {
+        HealthReportDraft d = new HealthReportDraft();
+        d.setId(11L);
+        d.setUserId(22L);
+        d.setReportType("gut_flora");
+        d.setOriginalFilename("child_report.pdf");
+        d.setFileHash("hash1");
+        d.setParseStatus("failed");
+        d.setParseMethod("langgraph");
+        d.setParseError("parse timeout");
+        d.setParseAttempt(2);
+        d.setStatus("drafting");
+        d.setConfirmedReportId(null);
+        d.setCreatedAt(new Date());
+        d.setUpdatedAt(new Date());
+        Page<HealthReportDraft> page = new Page<>(1, 20);
+        page.setRecords(Collections.singletonList(d));
+        page.setTotal(1);
+        when(draftMapper.selectPage(any(), any())).thenReturn(page);
+
+        Result<Map<String, Object>> result = parseAdminController.list(params("page", 1, "size", 20, "parseStatus", "failed"));
+
+        assertEquals(200, result.getCode());
+        assertEquals(1L, ((Number) result.getData().get("total")).longValue());
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> records = (List<Map<String, Object>>) result.getData().get("records");
+        assertEquals(1, records.size());
+        Map<String, Object> r = records.get(0);
+        assertEquals(11L, r.get("draftId"));
+        assertEquals("gut_flora", r.get("reportType"));
+        assertEquals("failed", r.get("parseStatus"));
+        assertEquals(2, r.get("parseAttempt"));
+        assertEquals("drafting", r.get("draftStatus"));
+        assertNull(r.get("confirmedReportId"));
+    }
+
+    @Test
+    void 正常流_retry_重置解析进度并重新提交异步采集() {
+        Result<Map<String, Object>> result = parseAdminController.retry(params("draftId", 11L));
+        assertEquals(200, result.getCode());
+        assertEquals(11L, result.getData().get("draftId"));
+        assertEquals("collecting", result.getData().get("parseStatus"));
+        verify(healthReportDraftService).resetForManualRetry(11L);
+        verify(reportCollectService).submitAsyncCollect(11L);
+    }
+
+    @Test
+    void 异常流_retry_draftId缺失_拒绝() {
+        Result<Map<String, Object>> result = parseAdminController.retry(params());
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("draftId"));
+        verify(healthReportDraftService, never()).resetForManualRetry(anyLong());
+        verify(reportCollectService, never()).submitAsyncCollect(anyLong());
+    }
+
+    @Test
+    void 正常流_backfillFood_重新解析并覆盖写入食物清单() {
+        when(reportCollectService.reparseReportFoods(7L)).thenReturn(42);
+        Result<Map<String, Object>> result = parseAdminController.backfillFood(params("reportId", 7L));
+        assertEquals(200, result.getCode());
+        assertEquals(7L, result.getData().get("reportId"));
+        assertEquals(42, result.getData().get("foodCount"));
+        verify(reportCollectService).reparseReportFoods(7L);
+    }
+
+    @Test
+    void 异常流_backfillFood_reportId缺失_拒绝() {
+        Result<Map<String, Object>> result = parseAdminController.backfillFood(params());
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("reportId"));
+        verify(reportCollectService, never()).reparseReportFoods(anyLong());
+    }
+
+    @Test
+    void 异常流_backfillFood_服务非法状态_错误返回保留旧载荷() {
+        when(reportCollectService.reparseReportFoods(7L))
+                .thenThrow(new IllegalStateException("报告状态不允许回填"));
+        Result<Map<String, Object>> result = parseAdminController.backfillFood(params("reportId", 7L));
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("不允许回填"),
+                "IllegalStateException 应转 Result.error(失败保留旧载荷,§4.59.1 backfill-food)");
+    }
+
+    // ==================== 权限流 ====================
+
+    @Test
+    void 权限流_管理端报告解析端点归属admin命名空间_由JwtInterceptor统一登录拦截() {
+        assertEquals("/api/admin/report-parser", parserController.getClass()
+                .getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0],
+                "ReportParserAdminController 前缀归属 /api/admin 命名空间(JwtInterceptor LOGIN_REQUIRED_PATHS /api/admin/)");
+        assertEquals("/api/admin/report-parse", parseAdminController.getClass()
+                .getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0],
+                "ReportParseAdminController 前缀归属 /api/admin 命名空间(JwtInterceptor LOGIN_REQUIRED_PATHS /api/admin/)");
+        // 说明性契约:无 token → 401(LOGIN_REQUIRED_PATHS "/api/admin/"),非 admin token → controller 层
+        // @RequestAttribute("role") 守卫 403(B-142-1 双层防线);本类不重复实现鉴权逻辑(TC-A-05/06/17-P)。
+        assertTrue(true, "权限流说明:admin.reportParser.manage 鉴权由 JwtInterceptor(/api/admin/) + controller role 守卫构成");
+    }
+}