Explorar o código

1.删除根证书时检查是否有未吊销的下级证书
2.证书延期天数大于0,改为由原到期时间延长。
3.新增文件转换风险检测,提示转换可能存在风险。
4.下载链接改成非预览式的。
5.印章有效期改为直接采用证书有效期
6.签章前检查当前印章绑定的证书状态

wangkun hai 3 semanas
pai
achega
29c95b5b9b

+ 27 - 1
pom.xml

@@ -80,6 +80,11 @@
             <version>3.5.17</version>
             <scope>compile</scope>
         </dependency>
+        <dependency>
+            <groupId>com.baomidou</groupId>
+            <artifactId>mybatis-plus-jsqlparser</artifactId>
+            <version>3.5.17</version>
+        </dependency>
         <dependency>
             <groupId>io.minio</groupId>
             <artifactId>minio</artifactId>
@@ -96,6 +101,21 @@
             <artifactId>itextpdf</artifactId>
             <version>5.5.13.3</version>
         </dependency>
+        <dependency>
+            <groupId>com.github.jai-imageio</groupId>
+            <artifactId>jai-imageio-core</artifactId>
+            <version>1.4.0</version>
+        </dependency>
+        <dependency>
+            <groupId>com.github.jai-imageio</groupId>
+            <artifactId>jai-imageio-jpeg2000</artifactId>
+            <version>1.3.0</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.pdfbox</groupId>
+            <artifactId>jbig2-imageio</artifactId>
+            <version>3.0.3</version>
+        </dependency>
 
         <dependency>
             <groupId>org.hibernate</groupId>
@@ -153,7 +173,7 @@
         <dependency>
             <groupId>org.apache.pdfbox</groupId>
             <artifactId>pdfbox</artifactId>
-            <version>2.0.20</version>
+            <version>2.0.37</version>
         </dependency>
         <dependency>
             <groupId>commons-io</groupId>
@@ -165,6 +185,12 @@
             <artifactId>dom4j</artifactId>
             <version>2.1.3</version>
         </dependency>
+        <dependency>
+            <groupId>org.ofdrw</groupId>
+            <artifactId>ofdrw-archive</artifactId>
+            <version>2.4.0</version>
+            <scope>compile</scope>
+        </dependency>
         <dependency>
             <groupId>org.ofdrw</groupId>
             <artifactId>ofdrw-full</artifactId>

+ 18 - 0
src/main/java/com/ymwk/icvp/config/MybatisPlusConfig.java

@@ -0,0 +1,18 @@
+package com.ymwk.icvp.config;
+
+import com.baomidou.mybatisplus.annotation.DbType;
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class MybatisPlusConfig {
+
+    @Bean
+    public MybatisPlusInterceptor mybatisPlusInterceptor() {
+        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
+        return interceptor;
+    }
+}

+ 8 - 3
src/main/java/com/ymwk/icvp/pdfToOfdBao/OFDPageDrawer.java

@@ -567,8 +567,13 @@ public class OFDPageDrawer extends PDFGraphicsStreamEngine {
                 fontSize, (int) (fontSize * textMatrix.getScalingFactorX()));
         String fontName = "";
         if (font.getFontDescriptor() != null) {
-            fontName = font.getFontDescriptor().getFontFamily();
-            if (null == fontName || "".equals(fontName)) {
+            try {
+                fontName = font.getFontDescriptor().getFontFamily();
+            } catch (ClassCastException e) {
+                // PDFBox 2.0.37 已知 bug:字体族名是 COSName 而非 COSString
+                fontName = null;
+            }
+            if (fontName == null || fontName.isEmpty()) {
                 fontName = font.getName();
             }
         }
@@ -888,7 +893,7 @@ public class OFDPageDrawer extends PDFGraphicsStreamEngine {
 
     /**
 	 * 添加字符变换
-	 * 
+	 *
 	 * @param text
 	 */
 	private void addCGTransform(String text) {

+ 2 - 1
src/main/java/com/ymwk/icvp/pki/model/RenewRequest.java

@@ -7,6 +7,7 @@ public class RenewRequest {
 
     @NotNull
     private Long id;
+    @NotNull
     @Min(1)
-    private Integer validityDays = 365;
+    private Integer validityDays;
 }

+ 1 - 1
src/main/java/com/ymwk/icvp/pki/model/certListRequest.java

@@ -9,7 +9,7 @@ import lombok.EqualsAndHashCode;
 public class certListRequest extends PageParam {
 
 
-    private String serialNumberStr;
+    private String certName;
     private String certUsage;
     private String status;
     private Integer certAlgo;

+ 62 - 26
src/main/java/com/ymwk/icvp/pki/service/impl/CertificateServiceImpl.java

@@ -1,6 +1,7 @@
 package com.ymwk.icvp.pki.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ymwk.icvp.component.PageResult;
 import com.ymwk.icvp.component.ResponseDTO;
@@ -44,6 +45,7 @@ import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.util.StringUtils;
 
@@ -88,14 +90,12 @@ public class CertificateServiceImpl implements CertificateService {
         LambdaQueryWrapper<CertificateEntity> wrapper = new LambdaQueryWrapper<>();
 
         wrapper.eq(certListRequest.getCertUsage() != null, CertificateEntity::getCertUsage, certListRequest.getCertUsage());
-        wrapper.like(certListRequest.getSerialNumberStr() != null, CertificateEntity::getCertSerialNumber, certListRequest.getSerialNumberStr());
+        wrapper.like(certListRequest.getCertName() != null, CertificateEntity::getCertName, certListRequest.getCertName());
         wrapper.eq(certListRequest.getStatus() != null, CertificateEntity::getStatus, certListRequest.getStatus());
         wrapper.eq(certListRequest.getCertAlgo() != null, CertificateEntity::getCertAlgo, certListRequest.getCertAlgo());
         Page<CertificateEntity> page = new Page<>(certListRequest.getPageNum(), certListRequest.getPageSize());
         Page<CertificateEntity> resultPage = certificateMapper.selectPage(page, wrapper);
-        Long count = certificateMapper.selectCount(wrapper);
         PageResult<CertificateEntity> pageResult = SmartPageUtil.convert2PageResult(resultPage, resultPage.getRecords());
-        pageResult.setTotal(count);
         return ResponseDTO.ok(pageResult);
     }
 
@@ -104,10 +104,23 @@ public class CertificateServiceImpl implements CertificateService {
         if (id == null) {
             return ResponseDTO.error(ResponseDTO.ERROR_CODE, "证书ID不能为空");
         }
-        if (certificateMapper.selectById(id) == null) {
+        CertificateEntity certificate = certificateMapper.selectById(id);
+        if (certificate == null) {
             return ResponseDTO.error(ResponseDTO.ERROR_CODE, "证书不存在");
         }
 
+        long activeIssuedCertificateCount = certificateMapper.selectCount(
+                new LambdaQueryWrapper<CertificateEntity>()
+                        .eq(CertificateEntity::getParentSerial, certificate.getCertSerialNumber())
+                        .ne(CertificateEntity::getStatus, "4")
+        );
+        if (activeIssuedCertificateCount > 0) {
+            return ResponseDTO.error(
+                    ResponseDTO.ERROR_CODE,
+                    "该证书仍有未吊销的下级证书,不能删除"
+            );
+        }
+
         certificateMapper.deleteById(id);
         return ResponseDTO.ok("删除成功");
     }
@@ -384,24 +397,31 @@ public class CertificateServiceImpl implements CertificateService {
 
     }
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public ResponseDTO<CertificateEntity> renewCertificate(RenewRequest request) throws Exception {
+        if (request == null || request.getId() == null) {
+            return ResponseDTO.error("证书ID不能为空", null);
+        }
+        if (request.getValidityDays() == null || request.getValidityDays() < 1) {
+            return ResponseDTO.error("延期天数必须大于0", null);
+        }
         Long id = request.getId();
-        int validityDays = request.getValidityDays() == null ? 365 : request.getValidityDays();
+        int validityDays = request.getValidityDays();
 
         // 1. 查询证书数据库记录
         CertificateEntity entity = certificateMapper.selectById(id);
         if (entity == null) {
-            throw new IllegalArgumentException("证书未找到: id=" + id);
+            return ResponseDTO.error("当前证书未找到", null);
         }
         if ("3".equals(entity.getStatus())) {
-            throw new IllegalStateException("冻结状态证书不能续期");
+            return ResponseDTO.error("冻结状态证书不能续期", null);
         }
-        String serial = entity.getCertSerialNumber();
+        String oldSerial = entity.getCertSerialNumber();
 
         // 2. 读取原有证书文件、公私钥文件(复用原有密钥)
-        String subjectCertPem = storageService.readCertificateFile(serial);
-        String subjectPublicKeyPem = storageService.readPublicKeyFile(serial);
-        String subjectPrivateKeyPem = storageService.readPrivateKeyFile(serial);
+        String subjectCertPem = storageService.readCertificateFile(oldSerial);
+        String subjectPublicKeyPem = storageService.readPublicKeyFile(oldSerial);
+        String subjectPrivateKeyPem = storageService.readPrivateKeyFile(oldSerial);
 
         // 3. 解析旧证书 & 公钥
         CertificateFactory cf = CertificateFactory.getInstance("X.509", "BC");
@@ -428,14 +448,14 @@ public class CertificateServiceImpl implements CertificateService {
             // 子证书:根据 parentSerial 查找签发CA证书
             String issuerSerial = entity.getParentSerial();
             if (issuerSerial == null) {
-                throw new IllegalArgumentException("证书缺少父级序列号,无法续期");
+                return ResponseDTO.error("证书缺少父级序列号,无法续期", null);
             }
             CertificateEntity issuerEntity = certificateMapper.selectOne(
                     new LambdaQueryWrapper<CertificateEntity>()
                             .eq(CertificateEntity::getCertSerialNumber, issuerSerial)
             );
             if (issuerEntity == null) {
-                throw new IllegalArgumentException("签发者未找到: serial=" + issuerSerial);
+                return ResponseDTO.error("签发者未找到: serial=" + issuerSerial, null);
             }
             String issuerCertPem = storageService.readCertificateFile(issuerSerial);
             String issuerPrivateKeyPem = storageService.readPrivateKeyFile(issuerSerial);
@@ -447,18 +467,25 @@ public class CertificateServiceImpl implements CertificateService {
             issuerAlgo = issuerEntity.getCertAlgo();
         }
 
-        Instant issueTime = Instant.now();
+        Instant newNotAfter = subjectOldCert.getNotAfter().toInstant()
+                .plusSeconds((long) validityDays * 86400L);
         if (!"1".equals(entity.getIsRoot())) {
-            long availableDays = Math.max(0, Duration.between(issueTime, issuerCert.getNotAfter().toInstant()).toDays());
-            if (validityDays > availableDays) {
-                return ResponseDTO.error("申请有效期超出颁发者证书有效期,当前颁发者证书最多可用 " + availableDays + " 天", null);
+            if (newNotAfter.isAfter(issuerCert.getNotAfter().toInstant())) {
+                return ResponseDTO.error("延期后的证书有效期不能超过签发者证书有效期", null);
             }
         }
 
-        // 5. 构建新证书:沿用原序列号,更新有效期
-        BigInteger serialNumber = new BigInteger(serial, 16);
-        Date notBefore = Date.from(issueTime);
-        Date notAfter = Date.from(issueTime.plusSeconds((long) validityDays * 86400L));
+        // 5. 保留原证书起始时间,顺延到期时间,并生成新序列号
+        String newSerial;
+        do {
+            newSerial = UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
+        } while (certificateMapper.selectCount(
+                new LambdaQueryWrapper<CertificateEntity>()
+                        .eq(CertificateEntity::getCertSerialNumber, newSerial)
+        ) > 0);
+        BigInteger serialNumber = new BigInteger(newSerial, 16);
+        Date notBefore = subjectOldCert.getNotBefore();
+        Date notAfter = Date.from(newNotAfter);
 
         X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
                 issuerDN,
@@ -494,7 +521,7 @@ public class CertificateServiceImpl implements CertificateService {
         // 校验新证书签名合法性
         newCert.verify(issuerCert.getPublicKey());
 
-        // 6. PEM格式化,文件存储:**复用原有公私钥,只更新证书文件与证书链**
+        // 6. PEM格式化,使用原有密钥对,并按新序列号存储证书文件
         String newCertPem = convertCertificateToPem(newCert);
         String newPublicKeyPem = subjectPublicKeyPem;
         String newPrivateKeyPem = subjectPrivateKeyPem;
@@ -507,25 +534,34 @@ public class CertificateServiceImpl implements CertificateService {
             chainPem = newCertPem + issuerCertPem;
         }
 
-        // MinIO:相同serial覆盖原有文件
         CertificateFileInfo fileInfo = certificateStorageService.storeCertificateFiles(
-                serial,
+                newSerial,
                 newCertPem,
                 newPrivateKeyPem,
                 newPublicKeyPem,
                 chainPem
         );
 
-        // 7. 更新数据库:只更新有效期与文件路径,其余字段不变
+        // 7. 更新证书序列号、有效期与文件路径
+        entity.setCertSerialNumber(newSerial);
         entity.setValidFrom(LocalDateTime.ofInstant(newCert.getNotBefore().toInstant(), ZoneId.systemDefault()));
         entity.setValidTo(LocalDateTime.ofInstant(newCert.getNotAfter().toInstant(), ZoneId.systemDefault()));
+        if ("2".equals(entity.getStatus()) && entity.getValidTo().isAfter(LocalDateTime.now())) {
+            entity.setStatus("1");
+        }
         entity.setCertFilePath(fileInfo.getCertPath());
         entity.setPrivateKeyPath(fileInfo.getPrivateKeyPath());
         entity.setPublicKeyPath(fileInfo.getPublicKeyPath());
         entity.setChainPath(fileInfo.getChainPath());
         certificateMapper.updateById(entity);
+        certificateMapper.update(
+                null,
+                new LambdaUpdateWrapper<CertificateEntity>()
+                        .eq(CertificateEntity::getParentSerial, oldSerial)
+                        .set(CertificateEntity::getParentSerial, newSerial)
+        );
 
-        log.info("证书续期成功: serial={}, id={}", serial, id);
+        log.info("证书续期成功: oldSerial={}, newSerial={}, id={}", oldSerial, newSerial, id);
         return ResponseDTO.ok(entity);
     }
 

+ 79 - 16
src/main/java/com/ymwk/icvp/signature/controller/SignatureController.java

@@ -6,6 +6,7 @@ import com.itextpdf.text.pdf.PdfSignatureAppearance;
 import com.itextpdf.text.pdf.PdfStamper;
 import com.itextpdf.text.Image;
 import com.itextpdf.text.pdf.security.*;
+import com.ymwk.icvp.component.ResponseDTO;
 import com.ymwk.icvp.pki.entity.CertificateEntity;
 import com.ymwk.icvp.pki.mapper.CertificateMapper;
 import com.ymwk.icvp.pki.service.impl.CertificateAssembleService;
@@ -13,6 +14,8 @@ import com.ymwk.icvp.signature.entity.Picture;
 import com.ymwk.icvp.signature.entity.Seal;
 import com.ymwk.icvp.signature.mapper.PictureMapper;
 import com.ymwk.icvp.signature.mapper.SealMapper;
+import com.ymwk.icvp.signature.model.OfdConversionPreflightResult;
+import com.ymwk.icvp.signature.model.PdfFontRiskCheckResult;
 import com.ymwk.icvp.signature.model.SealFormatEnum;
 import com.ymwk.icvp.signature.utils.*;
 import com.ymwk.icvp.utils.MinioClientService;
@@ -105,8 +108,56 @@ public class SignatureController {
   private final CertificateMapper certificateMapper;
   private final CertificateAssembleService certificateAssembleService;
     /**
-     * PDF 转 OFD
+   * 检测 PDF 字体结构风险,前端应在 PDF 转 OFD 前调用。
      */
+  @PostMapping(
+          value = "/check-pdf-font-risk",
+          consumes = MediaType.MULTIPART_FORM_DATA_VALUE
+  )
+  public ResponseDTO<PdfFontRiskCheckResult> checkPdfFontRisk(
+          @RequestParam("file") MultipartFile file) {
+
+      validateFile(file);
+
+      try (InputStream input = file.getInputStream()) {
+          String riskMessage = fileConvert.detectPdfFontRisk(input);
+          return ResponseDTO.ok(new PdfFontRiskCheckResult(riskMessage == null, riskMessage));
+      } catch (IOException e) {
+          log.warn("PDF 字体风险检测失败", e);
+          return ResponseDTO.ok(new PdfFontRiskCheckResult(
+                  false,
+                  "PDF文件无法检测,可能已损坏或格式无效,请使用有效的原始PDF文件。"
+          ));
+      }
+  }
+
+  /**
+   * 预检 OFD 是否能够转换为 PDF。
+   */
+  @PostMapping(
+          value = "/check-ofd-to-pdf",
+          consumes = MediaType.MULTIPART_FORM_DATA_VALUE
+  )
+  public ResponseDTO<OfdConversionPreflightResult> checkOfdToPdf(
+          @RequestParam("file") MultipartFile file) {
+
+      validateFile(file);
+
+      try (InputStream input = file.getInputStream()) {
+          List<String> issues = fileConvert.checkOfdToPdf(input);
+          return ResponseDTO.ok(new OfdConversionPreflightResult(issues.isEmpty(), issues));
+      } catch (IOException e) {
+          log.warn("OFD 转 PDF 预检失败", e);
+          return ResponseDTO.ok(new OfdConversionPreflightResult(
+                  false,
+                  List.of("无法读取OFD文件:" + e.getMessage())
+          ));
+      }
+  }
+
+  /**
+   * PDF 转 OFD
+   */
     @PostMapping(
             value = "/pdf-to-ofd",
             consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
@@ -139,7 +190,6 @@ public class SignatureController {
                     .body(data);
         }
     }
-
     /**
      * OFD 转 PDF
      */
@@ -153,23 +203,27 @@ public class SignatureController {
 
         validateFile(file);
 
-        try (InputStream input = file.getInputStream();
-             InputStream output = fileConvert.ofdToPdfEx(input)) {
+        String risk;
+        try (InputStream check = file.getInputStream()) {
+            risk = fileConvert.detectOfdAttachmentRisk(check);
+        }
+        if (risk != null) {
+            throw new IllegalStateException(risk);
+        }
 
-            if (output == null) {
-                throw new IllegalStateException("OFD 转 PDF 失败");
+        try (InputStream input = file.getInputStream()) {
+            fileConvert.ConvertResult result = fileConvert.ofdToPdfWithResult(input);
+            if (result.stream == null) {
+                throw new IllegalStateException(
+                        result.errorMessage != null ? result.errorMessage : "OFD 转 PDF 失败");
             }
-
-            byte[] data = output.readAllBytes();
-
+            byte[] data = result.stream.readAllBytes();
             return ResponseEntity.ok()
-                    .header(
-                            HttpHeaders.CONTENT_DISPOSITION,
+                    .header(HttpHeaders.CONTENT_DISPOSITION,
                             ContentDisposition.attachment()
                                     .filename("converted.pdf")
                                     .build()
-                                    .toString()
-                    )
+                                    .toString())
                     .contentType(MediaType.APPLICATION_PDF)
                     .contentLength(data.length)
                     .body(data);
@@ -236,6 +290,11 @@ public class SignatureController {
                 resultMap.put("message", "关联数字证书或证书序列号不存在");
                 return resultMap;
             }
+            if (!"1".equals(certificate.getStatus())) {
+                resultMap.put("code", DataResponse.CODE_ERROR);
+                resultMap.put("message", "数字证书状态异常,请检查数字证书状态");
+                return resultMap;
+            }
 
             byte[] signatureImage;
             try (InputStream imageStream = minioClientService.getObject(extractObjectName(picture.getUrl()))) {
@@ -254,9 +313,9 @@ public class SignatureController {
                 signedPdf = signPdfPage(signedPdf, page, x, y, signatureImage, x509Certificate, privateKey);
             }
 
-            String objectName = "signed/" + UUID.randomUUID() + ".pdf";
+            String objectName = "signed/" + UUID.randomUUID().toString().replace("-", "") + ".pdf";
             minioClientService.uploadBytes(objectName, signedPdf, MediaType.APPLICATION_PDF_VALUE);
-            String pdfUrl = minioClientService.getPresignedUrl(objectName, 60);
+            String pdfUrl = minioClientService.getPresignedDownloadUrl(objectName, 60, objectName.substring(objectName.lastIndexOf('/') + 1));
 
             resultMap.put("code", DataResponse.CODE_SUCCESS);
             resultMap.put("message", "签章成功");
@@ -368,6 +427,10 @@ public class SignatureController {
             if (certificateEntity == null || certificateEntity.getCertSerialNumber() == null) {
                 return ofdSignatureResult(result, DataResponse.CODE_ERROR, "关联数字证书不存在", null);
             }
+            if (!"1".equals(certificateEntity.getStatus())) {
+                return ofdSignatureResult(result, DataResponse.CODE_ERROR,
+                        "数字证书状态异常,请检查数字证书状态", null);
+            }
 
             workingDirectory = Files.createTempDirectory("icvp-ofd-sign-");
             Path ofdFilePath = workingDirectory.resolve("source.ofd");
@@ -408,7 +471,7 @@ public class SignatureController {
 
             String objectName = "signed/" + UUID.randomUUID().toString().replace("-", "") + ".ofd";
             minioClientService.uploadBytes(objectName, Files.readAllBytes(out), "application/ofd");
-            String downloadUrl = minioClientService.getPresignedUrl(objectName, 60);
+            String downloadUrl = minioClientService.getPresignedDownloadUrl(objectName, 60, objectName.substring(objectName.lastIndexOf('/') + 1));
             Map<String, Object> data = new HashMap<>();
             data.put("ofdIden", objectName);
             data.put("ofdUrl", downloadUrl);

+ 9 - 0
src/main/java/com/ymwk/icvp/signature/model/OfdConversionPreflightResult.java

@@ -0,0 +1,9 @@
+package com.ymwk.icvp.signature.model;
+
+import java.util.List;
+
+public record OfdConversionPreflightResult(boolean convertible, List<String> issues) {
+    public OfdConversionPreflightResult {
+        issues = List.copyOf(issues);
+    }
+}

+ 4 - 0
src/main/java/com/ymwk/icvp/signature/model/PdfFontRiskCheckResult.java

@@ -0,0 +1,4 @@
+package com.ymwk.icvp.signature.model;
+
+public record PdfFontRiskCheckResult(boolean safe, String riskMessage) {
+}

+ 0 - 7
src/main/java/com/ymwk/icvp/signature/model/SealCreateDTO.java

@@ -3,7 +3,6 @@ package com.ymwk.icvp.signature.model;
 import lombok.Data;
 import javax.validation.constraints.NotBlank;
 import javax.validation.constraints.NotNull;
-import java.time.LocalDateTime;
 
 /**
  * 生成印章请求
@@ -27,12 +26,6 @@ public class SealCreateDTO {
     @NotBlank(message = "请选择印章算法")
     private String algorithm;
 
-    /** 有效期开始时间 */
-    private LocalDateTime validStartTime;
-
-    /** 有效期结束时间 */
-    private LocalDateTime validEndTime;
-
     /** 备注 */
     private String remark;
 }

+ 1 - 2
src/main/java/com/ymwk/icvp/signature/service/SealBuilder.java

@@ -3,13 +3,12 @@ package com.ymwk.icvp.signature.service;
 
 
 import com.ymwk.icvp.signature.model.SealBuildResult;
-import com.ymwk.icvp.signature.model.SealCreateDTO;
 import com.ymwk.icvp.signature.entity.Seal;
 
 public interface SealBuilder {
 
     /** 生成 SM2 .esl */
-    SealBuildResult buildEsl(Seal seal, SealCreateDTO dto);
+    SealBuildResult buildEsl(Seal seal);
 
     /** 重新生成 .esl(延期、换绑证书时用) */
     SealBuildResult rebuildEsl(Seal seal);

+ 7 - 15
src/main/java/com/ymwk/icvp/signature/service/impl/SealBuilderImpl.java

@@ -36,6 +36,7 @@ import java.security.PrivateKey;
 import java.security.Signature;
 
 import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.UUID;
@@ -58,7 +59,7 @@ public class SealBuilderImpl implements SealBuilder {
     private String sealerP12Password;
 
     @Override
-    public SealBuildResult buildEsl(Seal seal, SealCreateDTO dto) {
+    public SealBuildResult buildEsl(Seal seal) {
         try {
             // 1. 制章人证书 + 私钥(固定路径固定密码)
             Path sealerPath = Paths.get(sealerP12Path);
@@ -66,7 +67,7 @@ public class SealBuilderImpl implements SealBuilder {
             PrivateKey sealerPrvKey = PKCS12Tools.ReadPrvKey(sealerPath, "Sig", sealerP12Password);
 
             // 2. 签章人证书
-            Certificate userCert = loadUserCert(seal.getCertId());
+            X509Certificate userCert = loadUserCert(seal.getCertId());
 
             // 3. 印模图片
             byte[] pictureBytes = loadPictureBytes(seal.getImpressionId());
@@ -81,8 +82,8 @@ public class SealBuilderImpl implements SealBuilder {
                     .setCertListType(SES_ESPropertyInfo.CertListType)
                     .setCertList(SES_CertList.getInstance(SES_ESPropertyInfo.CertListType, new DERSequence(v)))
                     .setCreateDate(new ASN1GeneralizedTime(new Date()))
-                    .setValidStart(new ASN1GeneralizedTime(toDate(seal.getValidStartTime())))
-                    .setValidEnd(new ASN1GeneralizedTime(toDate(seal.getValidEndTime())));
+                    .setValidStart(new ASN1GeneralizedTime(userCert.getNotBefore()))
+                    .setValidEnd(new ASN1GeneralizedTime(userCert.getNotAfter()));
             SES_ESPictrueInfo pictrueInfo = new SES_ESPictrueInfo()
                     .setType("PNG")
                     .setData(pictureBytes)
@@ -126,17 +127,12 @@ public class SealBuilderImpl implements SealBuilder {
 
     @Override
     public SealBuildResult rebuildEsl(Seal seal) {
-        SealCreateDTO dto = new SealCreateDTO();
-        dto.setSealAlias(seal.getSealAlias());
-        dto.setImpressionId(seal.getImpressionId());
-        dto.setCertId(seal.getCertId());
-        dto.setAlgorithm(seal.getAlgorithm());
-        return buildEsl(seal, dto);
+        return buildEsl(seal);
     }
 
     // ---------------- 私有辅助 ----------------
 
-    private Certificate loadUserCert(Long certId) {
+    private X509Certificate loadUserCert(Long certId) {
         CertificateEntity cert = certificateMapper.selectById(certId);
         if (cert == null) {
             throw new RuntimeException("证书不存在:" + certId);
@@ -165,8 +161,4 @@ public class SealBuilderImpl implements SealBuilder {
         return idx >= 0 ? url.substring(idx + 1) : url;
     }
 
-    private Date toDate(java.time.LocalDateTime time) {
-        return time == null ? new Date()
-                : Date.from(time.atZone(java.time.ZoneId.systemDefault()).toInstant());
-    }
 }

+ 16 - 8
src/main/java/com/ymwk/icvp/signature/service/impl/SealServiceImpl.java

@@ -114,10 +114,11 @@ public class SealServiceImpl implements SealService {
         seal.setCertAlias(certificate.getCertName());
         seal.setAlgorithm(algorithm.getCode());
         seal.setSealStatus(SealStatusEnum.NORMAL.getCode());
-        seal.setValidStartTime(createDTO.getValidStartTime() != null
-                ? createDTO.getValidStartTime()
-                : LocalDateTime.now());
-        seal.setValidEndTime(createDTO.getValidEndTime());
+        if (certificate.getValidFrom() == null || certificate.getValidTo() == null) {
+            return ResponseDTO.error(ResponseDTO.ERROR_CODE, "证书有效期信息不完整");
+        }
+        seal.setValidStartTime(certificate.getValidFrom());
+        seal.setValidEndTime(certificate.getValidTo());
         seal.setRemark(createDTO.getRemark());
         seal.setCreateBy(getCurrentUser());
         seal.setUpdateBy(getCurrentUser());
@@ -129,7 +130,7 @@ public class SealServiceImpl implements SealService {
         // 4. 按算法分流构建印章结构体
         if (SealAlgorithmEnum.SM2 == algorithm) {
             seal.setSealFormat(SealFormatEnum.ESL.getCode());
-            SealBuildResult buildResult = sealBuilder.buildEsl(seal, createDTO);
+            SealBuildResult buildResult = sealBuilder.buildEsl(seal);
             seal.setSealFileData(buildResult.getFileData());
             seal.setSealFilePath(buildResult.getFilePath());
         } else {
@@ -278,17 +279,22 @@ public class SealServiceImpl implements SealService {
             return ResponseDTO.error(ResponseDTO.ERROR_CODE, "证书不存在:" + bindCertDTO.getCertId());
         }
 
-        Long boundSealCount = sealMapper.selectCount(new LambdaQueryWrapper<Seal>()
+       /* Long boundSealCount = sealMapper.selectCount(new LambdaQueryWrapper<Seal>()
                 .eq(Seal::getCertId, bindCertDTO.getCertId())
                 .ne(Seal::getId, seal.getId()));
         if (boundSealCount != null && boundSealCount > 0) {
             return ResponseDTO.error(ResponseDTO.ERROR_CODE, "该证书已绑定其他印章");
-        }
+        }*/
 
         Seal update = new Seal();
         update.setId(seal.getId());
         update.setCertId(bindCertDTO.getCertId());
         update.setCertAlias(certificate.getCertName());
+        if (certificate.getValidFrom() == null || certificate.getValidTo() == null) {
+            return ResponseDTO.error(ResponseDTO.ERROR_CODE, "证书有效期信息不完整");
+        }
+        update.setValidStartTime(certificate.getValidFrom());
+        update.setValidEndTime(certificate.getValidTo());
 
         // 用于重算校验值的对象
         Seal checkSeal = new Seal();
@@ -296,12 +302,14 @@ public class SealServiceImpl implements SealService {
         checkSeal.setCertId(bindCertDTO.getCertId());   // 新证书
         checkSeal.setImpressionId(seal.getImpressionId());
         checkSeal.setAlgorithm(seal.getAlgorithm());
-        checkSeal.setValidEndTime(seal.getValidEndTime());
+        checkSeal.setValidEndTime(certificate.getValidTo());
 
         if (SealAlgorithmEnum.SM2.getCode().equals(seal.getAlgorithm())) {
             // SM2:证书在 .esl 结构体内,必须重新生成
             seal.setCertId(bindCertDTO.getCertId());
             seal.setCertAlias(certificate.getCertName());
+            seal.setValidStartTime(certificate.getValidFrom());
+            seal.setValidEndTime(certificate.getValidTo());
             SealBuildResult buildResult = sealBuilder.rebuildEsl(seal);
             update.setSealFileData(buildResult.getFileData());
             update.setSealFilePath(buildResult.getFilePath());

+ 21 - 0
src/main/java/com/ymwk/icvp/utils/MinioClientService.java

@@ -180,6 +180,27 @@ public class MinioClientService {
                         .build()
         );
     }
+    /**
+     * 获取文件访问URL(带临时签名,强制浏览器下载)
+     */
+    public String getPresignedDownloadUrl(String objectName, int expiryMinutes, String downloadFilename) throws Exception {
+        Map<String, String> queryParams = new HashMap<>();
+        // 关键:告诉 MinIO 返回 Content-Disposition: attachment
+        queryParams.put("response-content-disposition",
+                "attachment; filename=\"" + downloadFilename + "\"");
+        // 可选:统一 content-type,避免浏览器再按 pdf 预览
+        queryParams.put("response-content-type", "application/octet-stream");
+
+        return minioClient.getPresignedObjectUrl(
+                GetPresignedObjectUrlArgs.builder()
+                        .bucket(bucketName)
+                        .object(objectName)
+                        .method(Method.GET)
+                        .expiry(expiryMinutes, TimeUnit.MINUTES)
+                        .extraQueryParams(queryParams)   // 关键
+                        .build()
+        );
+    }
     /**
      * 获取永久下载URL(如果MinIO配置了公开访问)
      */

+ 29 - 0
src/main/java/com/ymwk/icvp/utils/TrueTypeFontParser.java

@@ -0,0 +1,29 @@
+package com.ymwk.icvp.utils;
+
+import java.io.ByteArrayInputStream;
+import java.io.DataInputStream;
+
+public class TrueTypeFontParser {
+
+    public static boolean hasOS2Table(byte[] fontBytes) {
+        try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(fontBytes))) {
+            int sfntVersion = in.readInt(); // 0x00010000 或 'OTTO' 或 'true'
+            int numTables = in.readUnsignedShort();
+            in.readUnsignedShort(); // searchRange
+            in.readUnsignedShort(); // entrySelector
+            in.readUnsignedShort(); // rangeShift
+            for (int i = 0; i < numTables; i++) {
+                int tag = in.readInt();
+                in.readInt(); // checksum
+                in.readInt(); // offset
+                in.readInt(); // length
+                if (tag == 0x4F532F32) { // 'OS/2'
+                    return true;
+                }
+            }
+            return false;
+        } catch (Exception e) {
+            return true; // 解析失败不误报
+        }
+    }
+}

+ 389 - 16
src/main/java/com/ymwk/icvp/utils/fileConvert.java

@@ -1,17 +1,33 @@
 package com.ymwk.icvp.utils;
 
 import com.ymwk.icvp.pdfToOfdBao.OFDRender;
-import lombok.extern.slf4j.Slf4j;
+import org.apache.pdfbox.cos.*;
 import org.ofdrw.converter.ConvertHelper;
 import org.ofdrw.converter.FontLoader;
 import org.springframework.stereotype.Component;
 
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.InputStream;
+import java.io.*;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDResources;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSArray;
+import org.apache.pdfbox.cos.COSBase;
+import org.apache.pdfbox.cos.COSStream;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
 
 @Component
-@Slf4j
 public class fileConvert {
 
 
@@ -26,23 +42,380 @@ public class fileConvert {
         return null;
     }
 
-    public static InputStream ofdToPdfEx(InputStream in) {
+    public static class ConvertResult {
+        public final InputStream stream;
+        public final String errorMessage; // null 表示成功
+        public ConvertResult(InputStream stream, String errorMessage) {
+            this.stream = stream;
+            this.errorMessage = errorMessage;
+        }
+    }
+
+    public static ConvertResult ofdToPdfWithResult(InputStream in) {
         ByteArrayOutputStream pdfOut = new ByteArrayOutputStream();
         try {
-            FontLoader.getInstance()
-                    .addAliasMapping(null, "小标宋体", "方正小标宋简体", "方正小标宋简体")
-                    .addAliasMapping(null, "KaiTi_GB2312", "楷体", "楷体")
-                    .addSimilarFontReplaceRegexMapping(null, ".*Kai.*", null, "楷体")
-                    .addSimilarFontReplaceRegexMapping(null, ".*SimSun.*", null, "SimSun")
-                    .addSimilarFontReplaceRegexMapping(null, ".*Song.*", null, "宋体")
-                    .addSimilarFontReplaceRegexMapping(null, ".*MinionPro.*", null, "SimSun");
-
-            FontLoader.setSimilarFontReplace(true);
+            configureOfdFonts();
             ConvertHelper.toPdf(in, pdfOut);
-            return new ByteArrayInputStream(pdfOut.toByteArray());
+            return new ConvertResult(new ByteArrayInputStream(pdfOut.toByteArray()), null);
+        } catch (IllegalArgumentException e) {
+            String msg = e.getMessage() == null ? "" : e.getMessage();
+            if (msg.contains("容器名称") || msg.contains("name")) {
+                return new ConvertResult(null,
+                        "OFD 文件附件结构异常(附件名称为空),无法转换,请使用标准 OFD 工具重新保存后再试。");
+            }
+            return new ConvertResult(null, "OFD 转 PDF 失败:" + msg);
         } catch (Exception e) {
             e.printStackTrace();
+            return new ConvertResult(null, "OFD 转 PDF 失败:" + e.getMessage());
+        }
+    }
+
+    // 保留原方法,内部调用新方法,失败返回 null
+    public static InputStream ofdToPdfEx(InputStream in) {
+        return ofdToPdfWithResult(in).stream;
+    }
+    /**
+     * 执行与正式接口相同的转换,并验证转换产物可被 PDFBox 读取。
+     * @return 预检失败原因;可转换时返回空列表
+     */
+    public static List<String> checkOfdToPdf(InputStream input) throws IOException {
+        byte[] ofdBytes = input.readAllBytes();
+        if (!isZipPackage(ofdBytes)) {
+            return List.of("文件不是有效的OFD压缩包或压缩包已损坏。");
+        }
+
+        List<String> issues = new ArrayList<>();
+        String attachmentRisk = detectOfdAttachmentRisk(new ByteArrayInputStream(ofdBytes));
+        if (attachmentRisk != null) {
+            return List.of(attachmentRisk);
+        }
+
+        ByteArrayOutputStream pdfOutput = new ByteArrayOutputStream();
+        try {
+            configureOfdFonts();
+            ConvertHelper.toPdf(new ByteArrayInputStream(ofdBytes), pdfOutput);
+        } catch (RuntimeException e) {
+            issues.add("OFD结构、页面内容或所需资源无法转换为PDF");
+            return List.copyOf(issues);
+        }
+
+        byte[] pdfBytes = pdfOutput.toByteArray();
+        if (pdfBytes.length == 0) {
+            issues.add("OFD转换未生成PDF内容。");
+            return List.copyOf(issues);
+        }
+
+        try (PDDocument document = PDDocument.load(pdfBytes)) {
+            if (document.getNumberOfPages() == 0) {
+                issues.add("OFD未生成包含页面的有效PDF。");
+            }
+        } catch (IOException e) {
+            issues.add("OFD转换生成的PDF无效");
+        }
+        return List.copyOf(issues);
+    }
+
+    public static String detectOfdAttachmentRisk(InputStream in) {
+        try {
+            byte[] ofdBytes = in.readAllBytes();
+            byte[] attachXml = null;
+            try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(ofdBytes))) {
+                ZipEntry entry;
+                while ((entry = zis.getNextEntry()) != null) {
+                    String name = entry.getName().replace('\\', '/');
+                    String normalizedName = name.toLowerCase(java.util.Locale.ROOT);
+                    if (normalizedName.equals("attachments.xml")
+                            || normalizedName.endsWith("/attachments/attachments.xml")
+                            || normalizedName.endsWith("/attachs/attachments.xml")) {
+                        attachXml = zis.readAllBytes();
+                        break;
+                    }
+                }
+            }
+            if (attachXml == null) {
+                return null; // 没有附件,无风险
+            }
+
+            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+            factory.setNamespaceAware(true);
+            factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+            factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
+            factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+            factory.setAttribute(javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD, "");
+            factory.setAttribute(javax.xml.XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
+            Document doc = factory.newDocumentBuilder()
+                    .parse(new ByteArrayInputStream(attachXml));
+
+            // 不区分命名空间找 Attachment 元素
+            NodeList attachments = doc.getElementsByTagNameNS("*", "Attachment");
+            for (int i = 0; i < attachments.getLength(); i++) {
+                Element el = (Element) attachments.item(i);
+                String name = el.getAttribute("Name");
+                if (name == null || name.isBlank()) {
+                    return "OFD 文件附件名称为空,转换可能失败,建议使用标准 OFD 工具重新保存后再上传。";
+                }
+            }
+            return null;
+        } catch (Exception e) {
+            return "OFD 附件清单无法解析,无法确认附件名称是否有效:" + describeFailure(e);
+        }
+    }
+
+    private static void configureOfdFonts() {
+        FontLoader.getInstance()
+                .addAliasMapping(null, "小标宋体", "方正小标宋简体", "方正小标宋简体")
+                .addAliasMapping(null, "KaiTi_GB2312", "楷体", "楷体")
+                .addSimilarFontReplaceRegexMapping(null, ".*Kai.*", null, "楷体")
+                .addSimilarFontReplaceRegexMapping(null, ".*SimSun.*", null, "SimSun")
+                .addSimilarFontReplaceRegexMapping(null, ".*Song.*", null, "宋体")
+                .addSimilarFontReplaceRegexMapping(null, ".*MinionPro.*", null, "SimSun");
+        FontLoader.setSimilarFontReplace(true);
+    }
+
+    private static boolean isZipPackage(byte[] bytes) {
+        return bytes.length >= 4
+                && bytes[0] == 'P'
+                && bytes[1] == 'K'
+                && ((bytes[2] == 3 && bytes[3] == 4)
+                    || (bytes[2] == 5 && bytes[3] == 6)
+                    || (bytes[2] == 7 && bytes[3] == 8));
+    }
+
+    private static String describeFailure(Throwable failure) {
+        Throwable cause = failure;
+        while (cause.getCause() != null && cause.getCause() != cause) {
+            cause = cause.getCause();
+        }
+        String message = cause.getMessage();
+        return message == null || message.isBlank()
+                ? cause.getClass().getSimpleName()
+                : message;
+    }
+
+    /**
+     * 预检 PDF 字体结构是否存在多次转换后的损坏迹象
+     * @return 风险描述;无风险返回 null
+     */
+    public static String detectPdfFontRisk(InputStream input) throws IOException {
+        try (PDDocument doc = PDDocument.load(input)) {
+            // 逐字体收集到的风险特征
+            boolean anyMissingToUnicode = false;
+            boolean anyMissingOS2       = false;
+            boolean anyMissingCmap      = false;
+            boolean anyType1Or3         = false;
+            boolean anyCffFont          = false;
+            boolean anyUnknownSubtype   = false;
+            int embeddedFontCount       = 0;
+
+            for (PDPage page : doc.getPages()) {
+                PDResources resources = page.getResources();
+                if (resources == null) continue;
+
+                for (COSName fontName : resources.getFontNames()) {
+                    PDFont font;
+                    try {
+                        font = resources.getFont(fontName);
+                    } catch (Exception e) {
+                        // 字体本身加载失败,也算风险
+                        anyUnknownSubtype = true;
+                        continue;
+                    }
+                    if (font == null) continue;
+                    COSDictionary fontDict = font.getCOSObject();
+
+                    // 字体子类型
+                    COSName subtypeName = fontDict.getCOSName(COSName.SUBTYPE);
+                    String subtype = subtypeName == null ? "" : subtypeName.getName();
+
+                    // ---- 1. Type1 / Type3:转 OFD 高风险 ----
+                    if ("Type1".equals(subtype)
+                            || "MMType1".equals(subtype)
+                            || "Type3".equals(subtype)) {
+                        anyType1Or3 = true;
+                        continue; // 这两类不做后面的嵌入检查,风险已足够高
+                    }
+
+                    // ---- 2. ToUnicode 检查(所有类型都查)----
+                    if (!fontDict.containsKey(COSName.TO_UNICODE)) {
+                        anyMissingToUnicode = true;
+                    }
+
+                    // ---- 3. 找到真正的字体描述符和字体文件流 ----
+                    COSDictionary desc = findFontDescriptor(fontDict);
+                    if (desc == null) {
+                        // 非嵌入字体,跳过表结构检查
+                        continue;
+                    }
+
+                    // 嵌入字体计数
+                    COSBase fontFile  = desc.getDictionaryObject(COSName.FONT_FILE);   // Type1
+                    COSBase fontFile2 = desc.getDictionaryObject(COSName.FONT_FILE2);  // TrueType
+                    COSBase fontFile3 = desc.getDictionaryObject(COSName.FONT_FILE3);  // CFF/OpenType
+
+                    if (fontFile == null && fontFile2 == null && fontFile3 == null) {
+                        // 声明了 FontDescriptor 但没有嵌入文件,跳过
+                        continue;
+                    }
+                    embeddedFontCount++;
+
+                    // ---- 4. CFF / OpenType (FontFile3) ----
+                    if (fontFile3 instanceof COSStream) {
+                        anyCffFont = true;
+                        // CFF 的表结构和 TrueType 不同,不做 OS/2 / cmap 检查
+                        continue;
+                    }
+
+                    // ---- 5. TrueType (FontFile2):检查 OS/2 和 cmap ----
+                    if (fontFile2 instanceof COSStream) {
+                        TrueTypeTables tables;
+                        try (InputStream fs = ((COSStream) fontFile2).createInputStream()) {
+                            tables = parseTrueTypeTables(fs.readAllBytes());
+                        } catch (Exception e) {
+                            // 解析失败,视为风险
+                            anyMissingOS2 = true;
+                            anyMissingCmap = true;
+                            continue;
+                        }
+                        if (tables == null) {
+                            anyMissingOS2 = true;
+                            anyMissingCmap = true;
+                        } else {
+                            if (!tables.hasOS2)  anyMissingOS2  = true;
+                            if (!tables.hasCmap) anyMissingCmap = true;
+                        }
+                    }
+                }
+            }
+
+            // ===== 汇总,按严重程度输出 =====
+            List<String> highRisks = new ArrayList<>();
+            List<String> midRisks  = new ArrayList<>();
+
+            if (anyType1Or3)         highRisks.add("Type1/Type3 字体");
+            if (anyMissingOS2)       highRisks.add("字体缺少 OS/2 表");
+            if (anyMissingCmap)      highRisks.add("字体缺少 cmap 表");
+            if (anyMissingToUnicode) highRisks.add("字体缺少 Unicode 映射");
+            if (anyCffFont)          midRisks.add("CFF/OpenType 字体");
+            if (anyUnknownSubtype)   midRisks.add("存在无法加载的字体");
+
+            if (!highRisks.isEmpty()) {
+                return "检测到高风险字体特征(" + String.join("、", highRisks)
+                        + "),转换为 OFD 后可能出现乱码或黑块,建议使用原始文件重新生成后再上传。";
+            }
+            if (!midRisks.isEmpty()) {
+                return "检测到中等风险字体特征(" + String.join("、", midRisks)
+                        + "),转换后可能出现文字渲染异常,请预览确认结果。";
+            }
+            return null;
+        } catch (IOException e) {
+            throw e;
+        } catch (RuntimeException e) {
+            throw new IOException("PDF字体结构无法检测", e);
+        }
+    }
+
+    /** TrueType 表结构解析结果 */
+    private static class TrueTypeTables {
+        boolean hasOS2;
+        boolean hasCmap;
+    }
+
+    /**
+     * 解析 TrueType/OpenType 字体的表目录,提取关心的表是否存在。
+     * 返回 null 表示不是合法 sfnt 结构。
+     */
+    private static TrueTypeTables parseTrueTypeTables(byte[] fontBytes) {
+        try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(fontBytes))) {
+            int sfntVersion = in.readInt();
+            // 合法 sfnt 版本:0x00010000 (TrueType), 'OTTO' (CFF OpenType), 'true', 'typ1'
+            if (sfntVersion != 0x00010000
+                    && sfntVersion != 0x4F54544F   // 'OTTO'
+                    && sfntVersion != 0x74727565   // 'true'
+                    && sfntVersion != 0x74797031) { // 'typ1'
+                return null;
+            }
+            int numTables = in.readUnsignedShort();
+            in.readUnsignedShort(); // searchRange
+            in.readUnsignedShort(); // entrySelector
+            in.readUnsignedShort(); // rangeShift
+
+            TrueTypeTables result = new TrueTypeTables();
+            for (int i = 0; i < numTables; i++) {
+                int tag = in.readInt();
+                in.readInt(); // checksum
+                in.readInt(); // offset
+                in.readInt(); // length
+                if (tag == 0x4F532F32) result.hasOS2  = true; // 'OS/2'
+                if (tag == 0x636D6170) result.hasCmap = true; // 'cmap'
+            }
+            return result;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * 从字体字典中找到 FontDescriptor。
+     * 对 Type0(复合字体),需要深入 DescendantFonts 找。
+     */
+    private static COSDictionary findFontDescriptor(COSDictionary fontDict) {
+        // 普通字体直接有 FontDescriptor
+        COSBase desc = fontDict.getDictionaryObject(COSName.FONT_DESC);
+        if (desc instanceof COSDictionary) {
+            return (COSDictionary) desc;
+        }
+        // Type0:从 DescendantFonts[0] 里找
+        COSBase descendantsBase = fontDict.getDictionaryObject(COSName.DESCENDANT_FONTS);
+        if (descendantsBase instanceof COSArray) {
+            COSArray descendants = (COSArray) descendantsBase;
+            if (descendants.size() > 0) {
+                COSBase first = descendants.getObject(0);
+                if (first instanceof COSDictionary) {
+                    COSBase d = ((COSDictionary) first).getDictionaryObject(COSName.FONT_DESC);
+                    if (d instanceof COSDictionary) {
+                        return (COSDictionary) d;
+                    }
+                }
+            }
         }
         return null;
     }
+
+    private static boolean isEmbeddedTrueType(COSDictionary fontDict) {
+        COSName subtype = fontDict.getCOSName(COSName.SUBTYPE);
+        if (subtype == null) return false;
+        String name = subtype.getName();
+        if (!"TrueType".equals(name) && !"Type0".equals(name)) return false;
+        // Type0 需进一步看 DescendantFonts,这里简化处理
+        return fontDict.containsKey(COSName.FONT_DESC)
+                || fontDict.containsKey(COSName.DESCENDANT_FONTS);
+    }
+
+    private static boolean hasOS2Table(COSDictionary fontDict) {
+        // 从 FontDescriptor 中拿 FontFile2(TrueType)或 FontFile3(OpenType/CFF)
+        COSDictionary desc = null;
+        if (fontDict.containsKey(COSName.FONT_DESC)) {
+            desc = (COSDictionary) fontDict.getDictionaryObject(COSName.FONT_DESC);
+        } else if (fontDict.containsKey(COSName.DESCENDANT_FONTS)) {
+            COSArray descendants = (COSArray) fontDict.getDictionaryObject(COSName.DESCENDANT_FONTS);
+            if (descendants != null && descendants.size() > 0) {
+                COSDictionary df = (COSDictionary) descendants.getObject(0);
+                desc = (COSDictionary) df.getDictionaryObject(COSName.FONT_DESC);
+            }
+        }
+        if (desc == null) return true; // 非嵌入字体,不做 OS/2 检测
+
+        COSBase fontFile = desc.getDictionaryObject(COSName.FONT_FILE2);
+        if (fontFile == null) {
+            fontFile = desc.getDictionaryObject(COSName.FONT_FILE3);
+        }
+        if (!(fontFile instanceof COSStream)) return true;
+
+        try (InputStream fontStream = ((COSStream) fontFile).createInputStream()) {
+            byte[] fontBytes = fontStream.readAllBytes();
+            return TrueTypeFontParser.hasOS2Table(fontBytes);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
 }

+ 4 - 0
src/main/resources/application.yaml

@@ -1,6 +1,10 @@
 spring:
   application:
     name: ICVP
+  servlet:
+    multipart:
+      max-file-size: 300MB
+      max-request-size: 300MB
   datasource:
     # 驱动类 (使用 MySQL 8.0 及以上版本推荐驱动)
     driver-class-name: com.mysql.cj.jdbc.Driver

+ 52 - 0
src/test/java/com/ymwk/icvp/IcvpApplicationTests.java

@@ -3,10 +3,15 @@ package com.ymwk.icvp;
 import com.ymwk.icvp.utils.fileConvert;
 import org.junit.jupiter.api.Test;
 
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
 
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -75,6 +80,53 @@ class FileConvertTest {
         assertOutputFileExists(PDF_OUTPUT);
     }
 
+    @Test
+    void ofdToPdfPreflightTest() throws Exception {
+        assertInputFileExists(OFD_INPUT);
+
+        try (InputStream input = Files.newInputStream(OFD_INPUT)) {
+            var issues = fileConvert.checkOfdToPdf(input);
+            assertTrue(
+                    issues.isEmpty(),
+                    "OFD 转 PDF 预检失败:" + String.join(";", issues)
+            );
+        }
+    }
+
+    @Test
+    void ofdToPdfPreflightRejectsNonZipInput() throws Exception {
+        try (InputStream input = new ByteArrayInputStream(
+                "not an OFD".getBytes(StandardCharsets.US_ASCII))) {
+            assertTrue(
+                    !fileConvert.checkOfdToPdf(input).isEmpty(),
+                    "非OFD文件不应通过预检"
+            );
+        }
+    }
+
+    @Test
+    void ofdToPdfPreflightRejectsEmptyAttachmentName() throws Exception {
+        ByteArrayOutputStream ofd = new ByteArrayOutputStream();
+        try (ZipOutputStream zip = new ZipOutputStream(ofd, StandardCharsets.UTF_8)) {
+            zip.putNextEntry(new ZipEntry("Doc_0/Attachs/Attachments.xml"));
+            zip.write("""
+                    <?xml version="1.0" encoding="UTF-8"?>
+                    <ofd:Attachments xmlns:ofd="http://www.ofdspec.org/2016">
+                        <ofd:Attachment Name="" />
+                    </ofd:Attachments>
+                    """.getBytes(StandardCharsets.UTF_8));
+            zip.closeEntry();
+        }
+
+        try (InputStream input = new ByteArrayInputStream(ofd.toByteArray())) {
+            var issues = fileConvert.checkOfdToPdf(input);
+            assertTrue(
+                    issues.stream().anyMatch(issue -> issue.contains("OFD 文件附件名称为空")),
+                    "预检应报告空附件名称"
+            );
+        }
+    }
+
     private static void assertInputFileExists(Path inputPath) {
         assertTrue(
                 Files.isRegularFile(inputPath),