瀏覽代碼

feat:1.新增数字证书管理
2.新增存证管理
3.新增应用管理

wangkun 1 天之前
父節點
當前提交
7945c803ca
共有 40 個文件被更改,包括 3491 次插入 和 1 次删除
  1. 23 0
      pom.xml
  2. 8 1
      src/main/java/com/ymwk/supervisionserver/SupervisionServerApplication.java
  3. 64 0
      src/main/java/com/ymwk/supervisionserver/controller/ApplicationCredentialsController.java
  4. 65 0
      src/main/java/com/ymwk/supervisionserver/controller/EvidenceController.java
  5. 56 0
      src/main/java/com/ymwk/supervisionserver/entity/ApplicationCredentials.java
  6. 59 0
      src/main/java/com/ymwk/supervisionserver/entity/Evidence.java
  7. 9 0
      src/main/java/com/ymwk/supervisionserver/mapper/ApplicationCredentialsMapper.java
  8. 9 0
      src/main/java/com/ymwk/supervisionserver/mapper/EvidenceMapper.java
  9. 19 0
      src/main/java/com/ymwk/supervisionserver/model/CredentialsAddRequest.java
  10. 25 0
      src/main/java/com/ymwk/supervisionserver/model/CredentialsListRequest.java
  11. 23 0
      src/main/java/com/ymwk/supervisionserver/model/CredentialsUpdateRequest.java
  12. 14 0
      src/main/java/com/ymwk/supervisionserver/model/EvidenceByIdenRequest.java
  13. 22 0
      src/main/java/com/ymwk/supervisionserver/model/EvidenceListRequest.java
  14. 20 0
      src/main/java/com/ymwk/supervisionserver/model/EvidenceTextAddRequest.java
  15. 23 0
      src/main/java/com/ymwk/supervisionserver/model/EvidenceTextVerifyRequest.java
  16. 121 0
      src/main/java/com/ymwk/supervisionserver/pki/controller/CertificateController.java
  17. 42 0
      src/main/java/com/ymwk/supervisionserver/pki/entity/CertificateEntity.java
  18. 9 0
      src/main/java/com/ymwk/supervisionserver/pki/mapper/CertificateMapper.java
  19. 12 0
      src/main/java/com/ymwk/supervisionserver/pki/model/CertificateFileInfo.java
  20. 10 0
      src/main/java/com/ymwk/supervisionserver/pki/model/DownloadRequest.java
  21. 14 0
      src/main/java/com/ymwk/supervisionserver/pki/model/DownloadResponse.java
  22. 12 0
      src/main/java/com/ymwk/supervisionserver/pki/model/FreezeCertificateRequest.java
  23. 66 0
      src/main/java/com/ymwk/supervisionserver/pki/model/IssueRequest.java
  24. 14 0
      src/main/java/com/ymwk/supervisionserver/pki/model/Pkcs12DownloadRequest.java
  25. 13 0
      src/main/java/com/ymwk/supervisionserver/pki/model/RenewRequest.java
  26. 12 0
      src/main/java/com/ymwk/supervisionserver/pki/model/RevokeCertificateRequest.java
  27. 16 0
      src/main/java/com/ymwk/supervisionserver/pki/model/certListRequest.java
  28. 44 0
      src/main/java/com/ymwk/supervisionserver/pki/service/CertificateService.java
  29. 279 0
      src/main/java/com/ymwk/supervisionserver/pki/service/impl/CertificateAssembleService.java
  30. 1222 0
      src/main/java/com/ymwk/supervisionserver/pki/service/impl/CertificateServiceImpl.java
  31. 96 0
      src/main/java/com/ymwk/supervisionserver/pki/service/impl/CertificateStorageService.java
  32. 73 0
      src/main/java/com/ymwk/supervisionserver/pki/service/impl/CryptoService.java
  33. 17 0
      src/main/java/com/ymwk/supervisionserver/pki/utils/Base64Utils.java
  34. 55 0
      src/main/java/com/ymwk/supervisionserver/pki/utils/issueCertUtil.java
  35. 29 0
      src/main/java/com/ymwk/supervisionserver/service/ApplicationCredentialsService.java
  36. 33 0
      src/main/java/com/ymwk/supervisionserver/service/EvidenceService.java
  37. 220 0
      src/main/java/com/ymwk/supervisionserver/service/impl/ApplicationCredentialsServiceImpl.java
  38. 362 0
      src/main/java/com/ymwk/supervisionserver/service/impl/EvidenceServiceImpl.java
  39. 269 0
      src/main/java/com/ymwk/supervisionserver/utils/MinioClientService.java
  40. 12 0
      src/main/resources/application.yaml

+ 23 - 0
pom.xml

@@ -89,6 +89,29 @@
             <artifactId>guava</artifactId>
             <version>31.1-jre</version> <!-- 或更新的版本 -->
         </dependency>
+        <!-- MinIO 对象存储客户端(证书文件存储) -->
+        <dependency>
+            <groupId>io.minio</groupId>
+            <artifactId>minio</artifactId>
+            <version>8.6.0</version>
+        </dependency>
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>okhttp</artifactId>
+            <version>4.12.0</version>
+        </dependency>
+
+        <!-- BouncyCastle:证书签发/OCSP/CRL 等加密功能 -->
+        <dependency>
+            <groupId>org.bouncycastle</groupId>
+            <artifactId>bcprov-jdk18on</artifactId>
+            <version>1.84</version>
+        </dependency>
+        <dependency>
+            <groupId>org.bouncycastle</groupId>
+            <artifactId>bcpkix-jdk18on</artifactId>
+            <version>1.84</version>
+        </dependency>
 
 
         <dependency>

+ 8 - 1
src/main/java/com/ymwk/supervisionserver/SupervisionServerApplication.java

@@ -1,14 +1,21 @@
 package com.ymwk.supervisionserver;
 
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.mybatis.spring.annotation.MapperScan;
 
+import java.security.Security;
+
 @SpringBootApplication
-@MapperScan("com.ymwk.supervisionserver.mapper")
+@MapperScan({"com.ymwk.supervisionserver.mapper", "com.ymwk.supervisionserver.pki.mapper"})
 public class SupervisionServerApplication {
 
     public static void main(String[] args) {
+        // 注册 BouncyCastle Security Provider:pki 模块的证书签发/OCSP/CRL 均按 "BC" 提供者名调用
+        if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+            Security.addProvider(new BouncyCastleProvider());
+        }
         SpringApplication.run(SupervisionServerApplication.class, args);
     }
 

+ 64 - 0
src/main/java/com/ymwk/supervisionserver/controller/ApplicationCredentialsController.java

@@ -0,0 +1,64 @@
+package com.ymwk.supervisionserver.controller;
+
+import com.ymwk.supervisionserver.component.PageResult;
+import com.ymwk.supervisionserver.component.ResponseDTO;
+import com.ymwk.supervisionserver.entity.ApplicationCredentials;
+import com.ymwk.supervisionserver.model.CredentialsAddRequest;
+import com.ymwk.supervisionserver.model.CredentialsListRequest;
+import com.ymwk.supervisionserver.model.CredentialsUpdateRequest;
+import com.ymwk.supervisionserver.service.ApplicationCredentialsService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 应用凭证管理:t_application_credentials 增删改查
+ */
+@RestController
+@RequestMapping("/api/v1/appCredentials")
+public class ApplicationCredentialsController {
+
+    @Autowired
+    private ApplicationCredentialsService credentialsService;
+
+    //列表查询(api_secret 脱敏:前4后4明文,中间为*)
+    @PostMapping("/list")
+    public ResponseDTO<PageResult<ApplicationCredentials>> list(@RequestBody CredentialsListRequest request) {
+        return credentialsService.list(request);
+    }
+
+    //创建(标识/密钥服务端生成,仅传名称与描述)
+    @PostMapping("/create")
+    public ResponseDTO<ApplicationCredentials> create(@RequestBody CredentialsAddRequest request) {
+        return credentialsService.create(request);
+    }
+
+    //删除
+    @PostMapping("/delete/{id}")
+    public ResponseDTO<Void> delete(@PathVariable Long id) {
+        return credentialsService.delete(id);
+    }
+
+    //编辑唯一提供的操作:重新随机生成 api_secret
+    @PostMapping("/resetSecret/{id}")
+    public ResponseDTO<ApplicationCredentials> resetSecret(@PathVariable Long id) {
+        return credentialsService.resetSecret(id);
+    }
+
+    //修改应用名称/描述(标识与密钥不受影响)
+    @PostMapping("/update")
+    public ResponseDTO<ApplicationCredentials> update(@RequestBody CredentialsUpdateRequest request) {
+        return credentialsService.update(request);
+    }
+
+    //明文查询 api_secret
+    @PostMapping("/getSecret/{id}")
+    public ResponseDTO<String> getSecret(@PathVariable Long id) {
+        return credentialsService.getSecret(id);
+    }
+
+    //启用/禁用:1-启用,2-禁用
+    @PostMapping("/changeStatus/{id}/{status}")
+    public ResponseDTO<ApplicationCredentials> changeStatus(@PathVariable Long id, @PathVariable Integer status) {
+        return credentialsService.changeStatus(id, status);
+    }
+}

+ 65 - 0
src/main/java/com/ymwk/supervisionserver/controller/EvidenceController.java

@@ -0,0 +1,65 @@
+package com.ymwk.supervisionserver.controller;
+
+import com.ymwk.supervisionserver.component.PageResult;
+import com.ymwk.supervisionserver.component.ResponseDTO;
+import com.ymwk.supervisionserver.entity.Evidence;
+import com.ymwk.supervisionserver.model.EvidenceByIdenRequest;
+import com.ymwk.supervisionserver.model.EvidenceListRequest;
+import com.ymwk.supervisionserver.model.EvidenceTextAddRequest;
+import com.ymwk.supervisionserver.model.EvidenceTextVerifyRequest;
+import com.ymwk.supervisionserver.service.EvidenceService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.Map;
+
+/**
+ * 存证:文本/文件存证签发、三层核验(摘要/HMAC/签名)、查询
+ */
+@RestController
+@RequestMapping("/api/v1/evidence")
+public class EvidenceController {
+
+    @Autowired
+    private EvidenceService evidenceService;
+
+    //文本存证签发:返回 {evidenceIden, hmac, url}
+    @PostMapping("/textEvidenceAdd")
+    public ResponseDTO<Map<String, String>> textEvidenceAdd(@RequestBody EvidenceTextAddRequest request) {
+        return evidenceService.textEvidenceAdd(request);
+    }
+
+    //文件存证签发(multipart):文件上传 MinIO,返回 {evidenceIden, hmac, url}
+    @PostMapping("/fileEvidenceAdd")
+    public ResponseDTO<Map<String, String>> fileEvidenceAdd(@RequestParam("file") MultipartFile file,
+                                                            @RequestParam(value = "credIden", required = false) String credIden) {
+        return evidenceService.fileEvidenceAdd(file, credIden);
+    }
+
+    //文本存证核验:返回 {hashEqual, hmacEqual, signEqual}
+    @PostMapping("/textEvidenceVerify")
+    public ResponseDTO<Map<String, Boolean>> textEvidenceVerify(@RequestBody EvidenceTextVerifyRequest request) {
+        return evidenceService.textEvidenceVerify(request);
+    }
+
+    //文件存证核验(multipart):返回 {hashEqual, hmacEqual, signEqual}
+    @PostMapping("/fileEvidenceVerify")
+    public ResponseDTO<Map<String, Boolean>> fileEvidenceVerify(@RequestParam("file") MultipartFile file,
+                                                                @RequestParam("evidenceIden") String evidenceIden,
+                                                                @RequestParam("hmac") String hmac) {
+        return evidenceService.fileEvidenceVerify(file, evidenceIden, hmac);
+    }
+
+    //存证分页列表(不含正文/文件URL,详情走 getEvidenceDataByIden)
+    @PostMapping("/list")
+    public ResponseDTO<PageResult<Evidence>> list(@RequestBody EvidenceListRequest request) {
+        return evidenceService.list(request);
+    }
+
+    //按存证标识查询凭证数据(签名/HMAC/摘要/时间戳/原文引用)
+    @PostMapping("/getEvidenceDataByIden")
+    public ResponseDTO<Map<String, Object>> getEvidenceDataByIden(@RequestBody EvidenceByIdenRequest request) {
+        return evidenceService.getEvidenceDataByIden(request);
+    }
+}

+ 56 - 0
src/main/java/com/ymwk/supervisionserver/entity/ApplicationCredentials.java

@@ -0,0 +1,56 @@
+package com.ymwk.supervisionserver.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_application_credentials")
+public class ApplicationCredentials {
+
+    /**
+     * 状态:1-启用
+     */
+    public static final Integer STATUS_ENABLED = 1;
+    /**
+     * 状态:2-禁用
+     */
+    public static final Integer STATUS_DISABLED = 2;
+
+    /**
+     * 签名算法:HMAC-SM3(当前固定值)
+     */
+    public static final String HMAC_ALGO = "HMAC-SM3";
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /** 应用标识(32位UUID,无连字符,创建时生成) */
+    private String credIden;
+
+    /** 应用名称(唯一) */
+    private String name;
+
+    /** 对外公钥(32位UUID,无连字符,创建时生成) */
+    private String apiKey;
+
+    /** HMAC 原始密钥(32位UUID,无连字符;列表查询时脱敏) */
+    private String apiSecret;
+
+    /** 签名算法,当前固定 HMAC-SM3 */
+    private String hmacAlgo;
+
+    /** 启用状态:1=启用,2=禁用 */
+    private Integer status;
+
+    /** 应用描述 */
+    private String description;
+
+    private String createdBy;
+    private LocalDateTime createdAt;
+    private String updatedBy;
+    private LocalDateTime updatedAt;
+}

+ 59 - 0
src/main/java/com/ymwk/supervisionserver/entity/Evidence.java

@@ -0,0 +1,59 @@
+package com.ymwk.supervisionserver.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 存证:证据JSON(证据标识+内容摘要+时间戳)的 SM2 签名 + SM3摘要 + 防伪HMAC
+ */
+@Data
+@TableName("t_evidence")
+public class Evidence {
+
+    /** 类型:文本存证 */
+    public static final Integer TYPE_TEXT = 1;
+    /** 类型:文件存证 */
+    public static final Integer TYPE_FILE = 2;
+    /** 状态:有效 */
+    public static final Integer STATUS_VALID = 1;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /** 存证标识(32位UUID,参与签名,不可变) */
+    private String evidenceIden;
+
+    /** 签发应用标识(可选,仅记录;用于 HMAC 密钥归属) */
+    private String credIden;
+
+    /** 类型:1=文本存证,2=文件存证 */
+    private Integer type;
+
+    /** 状态:1=有效 */
+    private Integer status;
+
+    /** 签名(SM2/DER,hex) */
+    private String signature;
+
+    /** 证据 JSON 的 SM3 摘要(hex) */
+    private String hashText;
+
+    /** 文本原文(type=1 时保存) */
+    private String originalText;
+
+    /** 文件在 MinIO 的访问URL(type=2 时保存) */
+    private String originalFileKey;
+
+    /** 防伪凭证 HMAC(SM3-HMAC,hex),签发后返回给调用方 */
+    private String hmac;
+
+    /** 参与签名的证据 JSON 原文(用于验证时恢复时间戳等) */
+    private String evidenceData;
+
+    private LocalDateTime createdAt;
+    private LocalDateTime updatedAt;
+}

+ 9 - 0
src/main/java/com/ymwk/supervisionserver/mapper/ApplicationCredentialsMapper.java

@@ -0,0 +1,9 @@
+package com.ymwk.supervisionserver.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ymwk.supervisionserver.entity.ApplicationCredentials;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ApplicationCredentialsMapper extends BaseMapper<ApplicationCredentials> {
+}

+ 9 - 0
src/main/java/com/ymwk/supervisionserver/mapper/EvidenceMapper.java

@@ -0,0 +1,9 @@
+package com.ymwk.supervisionserver.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ymwk.supervisionserver.entity.Evidence;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface EvidenceMapper extends BaseMapper<Evidence> {
+}

+ 19 - 0
src/main/java/com/ymwk/supervisionserver/model/CredentialsAddRequest.java

@@ -0,0 +1,19 @@
+package com.ymwk.supervisionserver.model;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 应用凭证创建请求:仅传名称和描述,标识/密钥/算法均由服务端生成
+ */
+@Data
+public class CredentialsAddRequest {
+
+    @NotBlank(message = "应用名称不能为空")
+    @Size(max = 255, message = "应用名称不能超过255个字符")
+    private String name;
+
+    @Size(max = 512, message = "描述不能超过512个字符")
+    private String description;
+}

+ 25 - 0
src/main/java/com/ymwk/supervisionserver/model/CredentialsListRequest.java

@@ -0,0 +1,25 @@
+package com.ymwk.supervisionserver.model;
+
+import com.ymwk.supervisionserver.component.PageParam;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class CredentialsListRequest extends PageParam {
+
+    /**
+     * 应用名称,模糊查询;null-不过滤
+     */
+    private String name;
+
+    /**
+     * 应用标识,模糊查询;null-不过滤
+     */
+    private String credIden;
+
+    /**
+     * 状态:1-启用,2-禁用;null-不过滤
+     */
+    private Integer status;
+}

+ 23 - 0
src/main/java/com/ymwk/supervisionserver/model/CredentialsUpdateRequest.java

@@ -0,0 +1,23 @@
+package com.ymwk.supervisionserver.model;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 应用凭证修改请求:仅支持改名称/描述,标识与密钥不受影响
+ */
+@Data
+public class CredentialsUpdateRequest {
+
+    @NotNull(message = "凭证ID不能为空")
+    private Long id;
+
+    @NotBlank(message = "应用名称不能为空")
+    @Size(max = 255, message = "应用名称不能超过255个字符")
+    private String name;
+
+    @Size(max = 512, message = "描述不能超过512个字符")
+    private String description;
+}

+ 14 - 0
src/main/java/com/ymwk/supervisionserver/model/EvidenceByIdenRequest.java

@@ -0,0 +1,14 @@
+package com.ymwk.supervisionserver.model;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * 按存证标识查询凭证数据
+ */
+@Data
+public class EvidenceByIdenRequest {
+
+    @NotBlank(message = "存证标识不能为空")
+    private String evidenceIden;
+}

+ 22 - 0
src/main/java/com/ymwk/supervisionserver/model/EvidenceListRequest.java

@@ -0,0 +1,22 @@
+package com.ymwk.supervisionserver.model;
+
+import com.ymwk.supervisionserver.component.PageParam;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class EvidenceListRequest extends PageParam {
+
+    /** 签发应用标识,模糊查询;null-不过滤 */
+    private String credIden;
+
+    /** 存证标识,模糊查询;null-不过滤 */
+    private String evidenceIden;
+
+    /** 类型:1=文本,2=文件;null-不过滤 */
+    private Integer type;
+
+    /** 状态:1=有效;null-不过滤 */
+    private Integer status;
+}

+ 20 - 0
src/main/java/com/ymwk/supervisionserver/model/EvidenceTextAddRequest.java

@@ -0,0 +1,20 @@
+package com.ymwk.supervisionserver.model;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 文本存证签发请求
+ */
+@Data
+public class EvidenceTextAddRequest {
+
+    /** 签发应用标识,可选,仅记录 */
+    @Size(max = 64, message = "应用标识不能超过64个字符")
+    private String credIden;
+
+    /** 待存证文本 */
+    @NotBlank(message = "存证文本不能为空")
+    private String text;
+}

+ 23 - 0
src/main/java/com/ymwk/supervisionserver/model/EvidenceTextVerifyRequest.java

@@ -0,0 +1,23 @@
+package com.ymwk.supervisionserver.model;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * 文本存证核验请求
+ */
+@Data
+public class EvidenceTextVerifyRequest {
+
+    /** 存证标识 */
+    @NotBlank(message = "存证标识不能为空")
+    private String evidenceIden;
+
+    /** 待核验文本(签发时的原文) */
+    @NotBlank(message = "存证文本不能为空")
+    private String text;
+
+    /** 签发时返回的 HMAC 凭证 */
+    @NotBlank(message = "HMAC不能为空")
+    private String hmac;
+}

+ 121 - 0
src/main/java/com/ymwk/supervisionserver/pki/controller/CertificateController.java

@@ -0,0 +1,121 @@
+package com.ymwk.supervisionserver.pki.controller;
+
+import com.ymwk.supervisionserver.component.PageResult;
+import com.ymwk.supervisionserver.component.ResponseDTO;
+import com.ymwk.supervisionserver.pki.entity.CertificateEntity;
+import com.ymwk.supervisionserver.pki.model.*;
+import com.ymwk.supervisionserver.pki.service.CertificateService;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+
+@RestController
+@RequestMapping("/api/v1/certificate")
+public class CertificateController {
+
+    @Autowired
+    private  CertificateService certificateService;
+
+
+    //证书列表
+    @PostMapping("/list")
+    public ResponseDTO<PageResult<CertificateEntity>> getCertificateList(@RequestBody certListRequest certListRequest) {
+        return certificateService.getCertificateList(certListRequest);
+    }
+    //删除证书记录(不删除MinIO文件)
+    @PostMapping("/delete")
+    public ResponseDTO<String> deleteCertificate(@RequestParam Long id) {
+        return certificateService.deleteCertificate(id);
+    }
+    //证书延期
+    @PostMapping("/renew")
+    public ResponseDTO<CertificateEntity> renewCertificate(@RequestBody RenewRequest request) throws Exception {
+        return certificateService.renewCertificate(request);
+    }
+    //证书冻结
+    @PostMapping("/freeze")
+    public ResponseDTO<CertificateEntity> freezeCertificate(@RequestBody FreezeCertificateRequest request) {
+        return certificateService.freezeCertificate(request);
+    }
+    //证书解冻
+    @PostMapping("/unfreeze")
+    public ResponseDTO<CertificateEntity> unfreezeCertificate(@RequestBody FreezeCertificateRequest request) throws Exception {
+        return certificateService.unfreezeCertificate(request);
+    }
+    //证书吊销
+    @PostMapping("/revoke")
+    public ResponseDTO<CertificateEntity> revokeCertificate(@RequestBody RevokeCertificateRequest request) throws Exception {
+        return certificateService.revokeCertificate(request);
+    }
+    // Deprecated: use OCSP for certificate status checks.
+    @Deprecated
+    @PostMapping("/crl")
+    public ResponseEntity<String> downloadCrl(@RequestBody DownloadRequest request, HttpServletResponse response) {
+        return certificateService.downloadCrl(request.getId(), response);
+    }
+    @PostMapping(value = "/ocsp", consumes = "application/ocsp-request", produces = "application/ocsp-response")
+    public ResponseEntity<byte[]> ocsp(@RequestBody byte[] requestBytes) throws Exception {
+        return certificateService.respondToOcspRequest(requestBytes);
+    }
+    // 上传根证书(接收合并 PEM:cert [+ chain] + private key)
+    @PostMapping("/uploadRoot")
+    public ResponseDTO<String> uploadRootCertificate(@RequestParam("file") MultipartFile file,
+                                                          @RequestParam(value = "certName", required = false) String certName) throws Exception {
+        String content = new String(file.getBytes(), StandardCharsets.UTF_8);
+        return certificateService.uploadRootCertificate(content, certName);
+    }
+    //生成根证书
+    @PostMapping("/issueRootCertificate")
+    public ResponseDTO<CertificateEntity> issueRootCertificate(@RequestBody IssueRequest issueRequest) throws Exception {
+
+            return certificateService.issueRootCertificate(issueRequest);
+    }
+    // 生成普通证书(由已有 Root CA 签发)
+    @PostMapping("/issuePersonalCertificate")
+    public ResponseDTO<CertificateEntity> issuePersonalCertificate(@RequestBody IssueRequest issueRequest) throws Exception {
+        return certificateService.issuePersonalCertificate(issueRequest);
+    }
+    /**
+     * 下载PEM格式(三份分开)
+     */
+    @PostMapping("/download/pem-separate")
+    public ResponseEntity<Map<String, String>> downloadPemSeparate(@RequestBody DownloadRequest downloadRequest) {
+
+        return certificateService.downloadPemSeparate(downloadRequest.getId());
+    }
+
+    /**
+     * 下载合并的PEM(cert + private-key)
+     * GET /api/v1/ca/certificate/{serialNumber}/download/pem-combined
+     */
+    @PostMapping("/download/pem-combined")
+    public ResponseEntity<byte[]> downloadPemCombined(@RequestBody DownloadRequest downloadRequest, HttpServletResponse response) {
+       return certificateService.downloadPemCombined(downloadRequest.getId(), response);
+    }
+
+    /**
+     * 下载PKCS#12格式(需要提供密码)
+     * POST /api/v1/ca/certificate/{serialNumber}/download/pkcs12
+     */
+    @PostMapping("/download/pkcs12")
+    public ResponseEntity<byte[]> downloadPkcs12(@RequestBody Pkcs12DownloadRequest request, HttpServletResponse response) {
+      return certificateService.downloadPkcs12(request, response);
+    }
+
+    /**
+     * 下载证书链
+     * GET /api/v1/ca/certificate/{serialNumber}/download/chain
+     */
+    @PostMapping("/download/chain")
+    public ResponseEntity<String> downloadChain(@RequestBody String id, HttpServletResponse response) {
+       return certificateService.downloadChain(id, response);
+    }
+
+
+}

+ 42 - 0
src/main/java/com/ymwk/supervisionserver/pki/entity/CertificateEntity.java

@@ -0,0 +1,42 @@
+package com.ymwk.supervisionserver.pki.entity;
+
+import java.time.LocalDateTime;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+@Data
+@TableName("t_certificate")
+public class CertificateEntity {
+
+    //TODO id不传,数据库自增
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+    private String certName;
+    private String certSerialNumber;
+    private String certSubject;
+    private String certAlgo;
+    private String certUsage;
+    private String status;
+    private String url;
+    private LocalDateTime validFrom;
+    private LocalDateTime validTo;
+    private LocalDateTime revokedTime;
+    private String revokeReason;
+
+    private String isCa;
+    private String isRoot;
+    private String parentSerial;
+
+    private String certFilePath;
+    private String privateKeyPath;
+    private String publicKeyPath;
+    private String csrFilePath;
+    private String chainPath;
+
+    private LocalDateTime createdTime;
+
+}

+ 9 - 0
src/main/java/com/ymwk/supervisionserver/pki/mapper/CertificateMapper.java

@@ -0,0 +1,9 @@
+package com.ymwk.supervisionserver.pki.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ymwk.supervisionserver.pki.entity.CertificateEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface CertificateMapper extends BaseMapper<CertificateEntity> {
+}

+ 12 - 0
src/main/java/com/ymwk/supervisionserver/pki/model/CertificateFileInfo.java

@@ -0,0 +1,12 @@
+package com.ymwk.supervisionserver.pki.model;
+
+import lombok.Data;
+
+@Data
+public class CertificateFileInfo {
+    private String serialNumber;
+    private String certPath;
+    private String privateKeyPath;
+    private String publicKeyPath;
+    private String chainPath;
+}

+ 10 - 0
src/main/java/com/ymwk/supervisionserver/pki/model/DownloadRequest.java

@@ -0,0 +1,10 @@
+package com.ymwk.supervisionserver.pki.model;
+
+import lombok.Data;
+
+@Data
+public class DownloadRequest {
+
+    private Long id;
+
+}

+ 14 - 0
src/main/java/com/ymwk/supervisionserver/pki/model/DownloadResponse.java

@@ -0,0 +1,14 @@
+package com.ymwk.supervisionserver.pki.model;
+
+import lombok.Data;
+
+@Data
+public class DownloadResponse {
+    private String certContent;      // PEM文本内容
+    private String privateKeyContent;
+    private String publicKeyContent;
+    private byte[] binaryContent;    // PKCS#12二进制内容
+    private String format;           // PEM_SEPARATE, PEM_COMBINED, PKCS12
+    private String fileName;
+    private String password;         // PKCS#12密码
+}

+ 12 - 0
src/main/java/com/ymwk/supervisionserver/pki/model/FreezeCertificateRequest.java

@@ -0,0 +1,12 @@
+package com.ymwk.supervisionserver.pki.model;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+public class FreezeCertificateRequest {
+    @NotNull
+    private Long id;
+
+    private String reason;
+}

+ 66 - 0
src/main/java/com/ymwk/supervisionserver/pki/model/IssueRequest.java

@@ -0,0 +1,66 @@
+package com.ymwk.supervisionserver.pki.model;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+public class IssueRequest {
+
+    private String certName;
+
+    @NotBlank(message = "通用名称不能为空")
+    private String commonName;
+
+    /**
+     * 组织名称(O)
+     */
+    private String organization;
+
+    /**
+     * 国家代码(C)
+     */
+    private String country ;
+
+    /**
+     * 省份/州(ST)
+     */
+    private String state;
+
+    /**
+     * 城市/地区(L)
+     */
+    private String locality;
+
+    /**
+     * 部门(OU)
+     */
+    private String organizationalUnit;
+
+    private String phone;
+
+    @NotBlank(message = "证书用途不能为空")
+    private String certUsage;
+
+    @Min(1)
+    @Max(7300)
+    private Integer validityDays = 365;
+
+    /**
+     * 密钥算法
+     * RSA: 默认
+     * EC: 椭圆曲线
+     */
+    private String keyAlgorithm;
+
+    private Integer keySize = 2048;
+
+    private Integer isCa;
+
+    private Integer isRoot;
+
+    private Long issuerId;
+
+    private String remark;
+}

+ 14 - 0
src/main/java/com/ymwk/supervisionserver/pki/model/Pkcs12DownloadRequest.java

@@ -0,0 +1,14 @@
+package com.ymwk.supervisionserver.pki.model;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class Pkcs12DownloadRequest {
+    @NotBlank(message = "密码不能为空")
+    @Size(min = 6, message = "密码至少6位")
+    private String password;
+
+    private Long id;
+}

+ 13 - 0
src/main/java/com/ymwk/supervisionserver/pki/model/RenewRequest.java

@@ -0,0 +1,13 @@
+package com.ymwk.supervisionserver.pki.model;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+@Data
+public class RenewRequest {
+
+    @NotNull
+    private Long id;
+    @NotNull
+    @Min(1)
+    private Integer validityDays;
+}

+ 12 - 0
src/main/java/com/ymwk/supervisionserver/pki/model/RevokeCertificateRequest.java

@@ -0,0 +1,12 @@
+package com.ymwk.supervisionserver.pki.model;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+public class RevokeCertificateRequest {
+    @NotNull
+    private Long id;
+
+    private String reason;
+}

+ 16 - 0
src/main/java/com/ymwk/supervisionserver/pki/model/certListRequest.java

@@ -0,0 +1,16 @@
+package com.ymwk.supervisionserver.pki.model;
+
+import com.ymwk.supervisionserver.component.PageParam;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class certListRequest extends PageParam {
+
+
+    private String certName;
+    private String certUsage;
+    private String status;
+    private Integer certAlgo;
+}

+ 44 - 0
src/main/java/com/ymwk/supervisionserver/pki/service/CertificateService.java

@@ -0,0 +1,44 @@
+package com.ymwk.supervisionserver.pki.service;
+
+
+import com.ymwk.supervisionserver.component.PageResult;
+import com.ymwk.supervisionserver.component.ResponseDTO;
+import com.ymwk.supervisionserver.pki.entity.CertificateEntity;
+import com.ymwk.supervisionserver.pki.model.*;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.ResponseEntity;
+
+import java.util.Map;
+
+public interface CertificateService {
+
+    ResponseDTO<PageResult<CertificateEntity>>  getCertificateList(certListRequest certListRequest);
+
+    ResponseDTO<String> deleteCertificate(Long id);
+
+    ResponseDTO<CertificateEntity> issuePersonalCertificate(IssueRequest issueRequest) throws Exception;
+
+    ResponseDTO<CertificateEntity> issueRootCertificate(IssueRequest issueRequest) throws Exception;
+
+    ResponseEntity<Map<String, String>> downloadPemSeparate(Long id);
+
+    ResponseEntity<byte[]> downloadPemCombined(Long id, HttpServletResponse response);
+
+    ResponseEntity<byte[]> downloadPkcs12(Pkcs12DownloadRequest request, HttpServletResponse response);
+
+    ResponseEntity<String> downloadChain(java.lang.String id, jakarta.servlet.http.HttpServletResponse response);
+
+    ResponseDTO<CertificateEntity> renewCertificate(RenewRequest request) throws Exception;
+
+    ResponseDTO<CertificateEntity> freezeCertificate(FreezeCertificateRequest request);
+
+    ResponseDTO<CertificateEntity> unfreezeCertificate(FreezeCertificateRequest request);
+
+    ResponseDTO<CertificateEntity> revokeCertificate(RevokeCertificateRequest request);
+
+    ResponseEntity<String> downloadCrl(Long id, HttpServletResponse response);
+
+    ResponseEntity<byte[]> respondToOcspRequest(byte[] requestBytes) throws Exception;
+
+    ResponseDTO<String> uploadRootCertificate(String content, String certName) throws Exception;
+}

+ 279 - 0
src/main/java/com/ymwk/supervisionserver/pki/service/impl/CertificateAssembleService.java

@@ -0,0 +1,279 @@
+package com.ymwk.supervisionserver.pki.service.impl;
+
+import com.ymwk.supervisionserver.pki.model.DownloadResponse;
+import com.ymwk.supervisionserver.utils.MinioClientService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.security.KeyFactory;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+
+@Service
+@Slf4j
+public class CertificateAssembleService {
+
+    @Autowired
+    private CertificateStorageService storageService;
+
+    @Autowired
+    private MinioClientService minioUtil;
+
+    /**
+     * 下载PEM格式(三份分开)
+     */
+    public DownloadResponse downloadPemSeparate(String serialNumber) throws Exception {
+        String certPem = storageService.readCertificateFile(serialNumber);
+        String privateKeyPem = storageService.readPrivateKeyFile(serialNumber);
+        String publicKeyPem = storageService.readPublicKeyFile(serialNumber);
+
+        DownloadResponse response = new DownloadResponse();
+        response.setCertContent(certPem);
+        response.setPrivateKeyContent(privateKeyPem);
+        response.setPublicKeyContent(publicKeyPem);
+        response.setFormat("PEM_SEPARATE");
+        response.setFileName(serialNumber + ".pem");
+
+        return response;
+    }
+
+    /**
+     * 下载合并的PEM(cert + private-key)
+     * 适用于Nginx/Apache配置
+     */
+    public DownloadResponse downloadPemCombined(String serialNumber) throws Exception {
+        String certPem = storageService.readCertificateFile(serialNumber);
+        String privateKeyPem = storageService.readPrivateKeyFile(serialNumber);
+        String chainPem = storageService.readChainFile(serialNumber);
+
+        StringBuilder combined = new StringBuilder();
+        combined.append(certPem).append("\n");
+
+        String normalizedChain = normalizePemChain(certPem, chainPem);
+        if (!normalizedChain.trim().isEmpty()) {
+            combined.append(normalizedChain).append("\n");
+        }
+
+        combined.append(privateKeyPem);
+
+        DownloadResponse response = new DownloadResponse();
+        response.setCertContent(combined.toString());
+        response.setFormat("PEM_COMBINED");
+        response.setFileName(serialNumber + "_full.pem");
+        return response;
+    }
+    private String normalizePemChain(String certPem, String chainPem) {
+        if (chainPem == null || chainPem.trim().isEmpty()) {
+            return "";
+        }
+        String normalized = chainPem.replace(certPem, "").trim();
+        normalized = normalized.replaceAll("(?s)\\n{3,}", "\n\n");
+        return normalized;
+    }
+
+    /**
+     * 下载PKCS#12格式(.p12/.pfx)
+     * 适用于浏览器导入、Windows/IIS
+     */
+    public DownloadResponse downloadPkcs12(String serialNumber, String password) throws Exception {
+        // 1. 从MinIO读取证书和私钥
+        String certPem = storageService.readCertificateFile(serialNumber);
+        String privateKeyPem = storageService.readPrivateKeyFile(serialNumber);
+        String chainPem = storageService.readChainFile(serialNumber);
+
+        // 2. 解析PEM为Java对象
+        X509Certificate certificate = parseCertificate(certPem);
+        PrivateKey privateKey = parsePrivateKey(
+                privateKeyPem, certificate.getPublicKey().getAlgorithm());
+
+        // 3. 解析证书链(如果有)
+        List<X509Certificate> chain = parseCertificateChain(chainPem);
+
+        // 调试信息
+        System.out.println("=== Certificate Info ===");
+        System.out.println("Subject: " + certificate.getSubjectDN());
+        System.out.println("Issuer: " + certificate.getIssuerDN());
+        System.out.println("Is Self-Signed: " + certificate.getSubjectDN().equals(certificate.getIssuerDN()));
+        System.out.println("Chain size: " + chain.size());
+
+        // 4. 构建PKCS#12 KeyStore
+        byte[] pkcs12Data = buildPkcs12(certificate, privateKey, chain, password);
+
+        DownloadResponse response = new DownloadResponse();
+        response.setBinaryContent(pkcs12Data);
+        response.setFormat("PKCS12");
+        response.setFileName(serialNumber + ".p12");
+        response.setPassword(password);
+
+        return response;
+    }
+
+    /**
+     * 构建PKCS#12
+     */
+    private byte[] buildPkcs12(X509Certificate certificate, PrivateKey privateKey,
+                               List<X509Certificate> chain, String password) throws Exception {
+        KeyStore keyStore = KeyStore.getInstance("PKCS12");
+        keyStore.load(null, null);
+
+        X509Certificate[] chainArray;
+        if (certificate.getSubjectX500Principal().equals(certificate.getIssuerX500Principal())) {
+            chainArray = new X509Certificate[]{certificate};
+        } else {
+            List<X509Certificate> fullChain = new ArrayList<>();
+            fullChain.add(certificate);
+            if (chain != null && !chain.isEmpty()) {
+                for (X509Certificate c : chain) {
+                    if (c == null) continue;
+                    if (isSameCertificate(c, certificate)) continue;
+                    if (containsCertificate(fullChain, c)) continue;
+                    fullChain.add(c);
+                }
+            }
+            chainArray = fullChain.toArray(new X509Certificate[0]);
+        }
+
+        keyStore.setKeyEntry("certificate", privateKey, password.toCharArray(), chainArray);
+
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+            keyStore.store(baos, password.toCharArray());
+            return baos.toByteArray();
+        }
+    }
+    private boolean isSameCertificate(X509Certificate a, X509Certificate b) {
+        return a.getSerialNumber().equals(b.getSerialNumber())
+                && a.getSubjectX500Principal().equals(b.getSubjectX500Principal())
+                && a.getIssuerX500Principal().equals(b.getIssuerX500Principal());
+    }
+
+    private boolean containsCertificate(List<X509Certificate> certs, X509Certificate target) {
+        for (X509Certificate cert : certs) {
+            if (isSameCertificate(cert, target)) {
+                return true;
+            }
+        }
+        return false;
+    }
+    /**
+     * 解析PEM证书
+     */
+    private X509Certificate parseCertificate(String certPem) throws Exception {
+        java.security.cert.CertificateFactory cf =
+                java.security.cert.CertificateFactory.getInstance("X.509", "BC");
+        try (ByteArrayInputStream bais = new ByteArrayInputStream(certPem.getBytes())) {
+            return (X509Certificate) cf.generateCertificate(bais);
+        }
+    }
+
+    /**
+     * 解析PEM私钥
+     */
+    private PrivateKey parsePrivateKey(String privateKeyPem) throws Exception {
+        return parsePrivateKey(privateKeyPem, "RSA");
+    }
+
+    private PrivateKey parsePrivateKey(String privateKeyPem, String algorithm) throws Exception {
+        boolean pkcs1RsaKey = privateKeyPem.contains("-----BEGIN RSA PRIVATE KEY-----");
+        String base64 = privateKeyPem
+                .replace("-----BEGIN PRIVATE KEY-----", "")
+                .replace("-----END PRIVATE KEY-----", "")
+                .replace("-----BEGIN RSA PRIVATE KEY-----", "")
+                .replace("-----END RSA PRIVATE KEY-----", "")
+                .replaceAll("\\s", "");
+
+        byte[] keyBytes = Base64.getDecoder().decode(base64);
+
+        try {
+            PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
+            boolean ecKey = isEcKeyAlgorithm(algorithm);
+            String keyAlgorithm = ecKey
+                    ? "EC" : "RSA";
+            return KeyFactory.getInstance(keyAlgorithm, "BC").generatePrivate(spec);
+        } catch (Exception e) {
+            if (isEcKeyAlgorithm(algorithm) || !pkcs1RsaKey) {
+                throw e;
+            }
+            org.bouncycastle.asn1.pkcs.RSAPrivateKey key =
+                    org.bouncycastle.asn1.pkcs.RSAPrivateKey.getInstance(keyBytes);
+
+            java.security.spec.RSAPrivateCrtKeySpec spec =
+                    new java.security.spec.RSAPrivateCrtKeySpec(
+                            key.getModulus(),
+                            key.getPublicExponent(),
+                            key.getPrivateExponent(),
+                            key.getPrime1(),
+                            key.getPrime2(),
+                            key.getExponent1(),
+                            key.getExponent2(),
+                            key.getCoefficient()
+                    );
+            return KeyFactory.getInstance("RSA", "BC").generatePrivate(spec);
+        }
+    }
+
+    private boolean isEcKeyAlgorithm(String algorithm) {
+        return "2".equalsIgnoreCase(algorithm)
+                || "SM2".equalsIgnoreCase(algorithm)
+                || "EC".equalsIgnoreCase(algorithm);
+    }
+
+    /**
+     * 解析证书链
+     */
+    private List<X509Certificate> parseCertificateChain(String chainPem) throws Exception {
+        List<X509Certificate> chain = new ArrayList<>();
+
+        // 如果链为空,直接返回空列表
+        if (chainPem == null || chainPem.trim().isEmpty()) {
+            System.out.println("No certificate chain provided");
+            return chain;
+        }
+
+        java.security.cert.CertificateFactory cf =
+                java.security.cert.CertificateFactory.getInstance("X.509", "BC");
+
+        String[] certs = chainPem.split("-----BEGIN CERTIFICATE-----");
+        for (String cert : certs) {
+            if (cert.trim().isEmpty()) continue;
+            String pem = "-----BEGIN CERTIFICATE-----" + cert;
+            try (ByteArrayInputStream bais = new ByteArrayInputStream(pem.getBytes())) {
+                X509Certificate x509Cert = (X509Certificate) cf.generateCertificate(bais);
+                chain.add(x509Cert);
+                System.out.println("Added chain certificate: " + x509Cert.getSubjectDN());
+            }
+        }
+
+        System.out.println("Total chain certificates parsed: " + chain.size());
+        return chain;
+    }
+
+    /**
+     * 根据序列号获取 X509 证书(供制章等内部流程使用)
+     */
+    public X509Certificate getCertificate(String serialNumber) throws Exception {
+        String certPem = storageService.readCertificateFile(serialNumber);
+        return parseCertificate(certPem);
+    }
+
+    /**
+     * 根据序列号获取私钥(签章阶段使用)
+     */
+    public PrivateKey getPrivateKey(String serialNumber) throws Exception {
+        return getPrivateKey(serialNumber, "RSA");
+    }
+
+    public PrivateKey getPrivateKey(String serialNumber, String algorithm) throws Exception {
+        String privateKeyPem = storageService.readPrivateKeyFile(serialNumber);
+        return parsePrivateKey(privateKeyPem, algorithm);
+    }
+
+}

+ 1222 - 0
src/main/java/com/ymwk/supervisionserver/pki/service/impl/CertificateServiceImpl.java

@@ -0,0 +1,1222 @@
+package com.ymwk.supervisionserver.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.supervisionserver.component.PageResult;
+import com.ymwk.supervisionserver.component.ResponseDTO;
+import com.ymwk.supervisionserver.component.SmartPageUtil;
+import com.ymwk.supervisionserver.pki.entity.CertificateEntity;
+import com.ymwk.supervisionserver.pki.mapper.CertificateMapper;
+import com.ymwk.supervisionserver.pki.model.*;
+import com.ymwk.supervisionserver.pki.service.CertificateService;
+
+import com.ymwk.supervisionserver.pki.utils.issueCertUtil;
+import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletResponse;
+import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
+import org.bouncycastle.asn1.x509.Extensions;
+import org.bouncycastle.cert.ocsp.BasicOCSPResp;
+import org.bouncycastle.cert.ocsp.BasicOCSPRespBuilder;
+import org.bouncycastle.cert.ocsp.CertificateID;
+import org.bouncycastle.cert.ocsp.CertificateStatus;
+import org.bouncycastle.cert.ocsp.OCSPReq;
+import org.bouncycastle.cert.ocsp.OCSPRespBuilder;
+import org.bouncycastle.cert.ocsp.RevokedStatus;
+import org.bouncycastle.cert.ocsp.UnknownStatus;
+import org.bouncycastle.cert.ocsp.jcajce.JcaBasicOCSPRespBuilder;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x500.X500NameBuilder;
+import org.bouncycastle.asn1.x500.style.BCStyle;
+import org.bouncycastle.asn1.x509.*;
+import org.bouncycastle.cert.X509CRLHolder;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.X509v2CRLBuilder;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.cert.jcajce.JcaX509CRLConverter;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.DigestCalculatorProvider;
+import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.springframework.beans.factory.annotation.Value;
+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;
+
+import java.io.ByteArrayInputStream;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509CRL;
+import java.security.cert.X509Certificate;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.*;
+
+@Service
+@Slf4j
+public class CertificateServiceImpl implements CertificateService {
+
+    @Resource
+    private CertificateMapper certificateMapper;
+
+    @Resource
+    private CertificateStorageService certificateStorageService;
+
+    @Resource
+    private CertificateAssembleService assembleService;
+    @Resource
+    private CertificateStorageService storageService;
+
+    @Value("${ocsp.responder-url:}")
+    private String ocspResponderUrl;
+
+    @Override
+    public ResponseDTO<PageResult<CertificateEntity>> getCertificateList(certListRequest certListRequest) {
+        LambdaQueryWrapper<CertificateEntity> wrapper = new LambdaQueryWrapper<>();
+
+        wrapper.eq(certListRequest.getCertUsage() != null, CertificateEntity::getCertUsage, certListRequest.getCertUsage());
+        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);
+        PageResult<CertificateEntity> pageResult = SmartPageUtil.convert2PageResult(resultPage, resultPage.getRecords());
+        return ResponseDTO.ok(pageResult);
+    }
+
+    @Override
+    public ResponseDTO<String> deleteCertificate(Long id) {
+        if (id == null) {
+            return ResponseDTO.error(ResponseDTO.ERROR_CODE, "证书ID不能为空");
+        }
+        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("删除成功");
+    }
+
+    @Override
+    public ResponseDTO<CertificateEntity> issuePersonalCertificate(IssueRequest request) throws Exception {
+        // 1. 找到用于签发的 Issuer(这里默认用 isRoot=1 的 Root CA,取第一个有效的)
+        Long issuerId = request.getIssuerId();
+        if (issuerId == null) {
+            throw new IllegalArgumentException("issuerId is required");
+        }
+        CertificateEntity issuerEntity = certificateMapper.selectById(issuerId);
+        if (issuerEntity == null) {
+            throw new IllegalArgumentException("Issuer certificate not found: " + issuerId);
+        }
+// 可选:校验 issuer 是否为有效 CA
+        if (!"1".equals(issuerEntity.getIsCa()) && !"1".equals(issuerEntity.getIsRoot())) {
+            throw new IllegalArgumentException("Issuer is not a CA");
+        }
+        if ("3".equals(issuerEntity.getStatus())) {
+            throw new IllegalStateException("冻结证书不能用于签发");
+        }
+
+        // 2. 从存储读取 Issuer 的证书与私钥(privateKey 已在存储中加密,readPrivateKeyFile 会解密)
+        String issuerSerial = issuerEntity.getCertSerialNumber();
+        String issuerCertPem = storageService.readCertificateFile(issuerSerial);
+        String issuerPrivateKeyPem = storageService.readPrivateKeyFile(issuerSerial);
+
+        // 3. 解析 Issuer 证书与私钥
+        java.security.cert.CertificateFactory cf =
+                java.security.cert.CertificateFactory.getInstance("X.509", "BC");
+        X509Certificate issuerCert;
+        try (ByteArrayInputStream bais = new ByteArrayInputStream(issuerCertPem.getBytes(StandardCharsets.UTF_8))) {
+            issuerCert = (X509Certificate) cf.generateCertificate(bais);
+        }
+
+        int validityDays = request.getValidityDays() == null ? 365 : request.getValidityDays();
+        Instant issueTime = Instant.now();
+        long availableDays = Math.max(0, Duration.between(issueTime, issuerCert.getNotAfter().toInstant()).toDays());
+        if (validityDays > availableDays) {
+            return ResponseDTO.error("申请有效期超出颁发者证书有效期,当前颁发者证书最多可用 " + availableDays + " 天", null);
+        }
+
+        // 4. 生成密钥对(申请者自己的密钥对)
+        KeyPair keyPair = issueCertUtil.generateKeyPair(request.getKeyAlgorithm(), request.getKeySize());
+        PrivateKey issuerPrivateKey = parsePrivateKeyPem(issuerPrivateKeyPem, issuerEntity.getCertAlgo());
+
+        // 5. 构造 Subject DN(和根证书构造类似,但 BasicConstraints(false))
+        X500NameBuilder x500NameBuilder = new X500NameBuilder(BCStyle.INSTANCE);
+        if (request.getCountry() != null) x500NameBuilder.addRDN(BCStyle.C, request.getCountry());
+        if (request.getOrganization() != null) x500NameBuilder.addRDN(BCStyle.O, request.getOrganization());
+        if (request.getOrganizationalUnit() != null) x500NameBuilder.addRDN(BCStyle.OU, request.getOrganizationalUnit());
+        if (request.getState() != null) x500NameBuilder.addRDN(BCStyle.ST, request.getState());
+        if (request.getLocality() != null) x500NameBuilder.addRDN(BCStyle.L, request.getLocality());
+        if (request.getPhone() != null) x500NameBuilder.addRDN(BCStyle.TELEPHONE_NUMBER, request.getPhone());
+        x500NameBuilder.addRDN(BCStyle.CN, request.getCommonName());
+        String serialNumberStr = UUID.randomUUID().toString().replace("-", "").toUpperCase();
+        x500NameBuilder.addRDN(BCStyle.SERIALNUMBER, serialNumberStr);
+        X500Name subjectDN = x500NameBuilder.build();
+
+        BigInteger serialNumber = new BigInteger(serialNumberStr, 16);
+        Date notBefore = Date.from(issueTime);
+        Date notAfter = Date.from(issueTime.plusSeconds(validityDays * 86400L));
+
+        // 6. 构建证书(Issuer = issuerCert.getSubjectX500Principal())
+        X500Name issuerDN = X500Name.getInstance(issuerCert.getSubjectX500Principal().getEncoded());
+        X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
+                issuerDN,
+                serialNumber,
+                notBefore,
+                notAfter,
+                subjectDN,
+                keyPair.getPublic()
+        );
+
+        // BasicConstraints:普通证书 false
+        certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
+        // KeyUsage:普通证书常见 digitalSignature + keyEncipherment
+        certBuilder.addExtension(Extension.keyUsage, true,
+                new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment));
+        addOcspAiaExtension(certBuilder);
+
+        // ExtendedKeyUsage:根据请求 certUsage 可定制;这里默认 serverAuth + clientAuth
+        KeyPurposeId[] purposes = new KeyPurposeId[] {
+                KeyPurposeId.id_kp_serverAuth,
+                KeyPurposeId.id_kp_clientAuth
+        };
+        ExtendedKeyUsage eku = new ExtendedKeyUsage(purposes);
+        certBuilder.addExtension(Extension.extendedKeyUsage, false, eku);
+
+        // 7. 签名(用 Issuer 的私钥和 Issuer 的签名算法)
+        ContentSigner signer = new JcaContentSignerBuilder(getSignatureAlgorithm(issuerEntity.getCertAlgo()))
+                .setProvider("BC")
+                .build(issuerPrivateKey);
+
+        X509CertificateHolder certHolder = certBuilder.build(signer);
+        X509Certificate issuedCert = new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder);
+
+        // 验证(可选)
+        issuedCert.verify(issuerCert.getPublicKey());
+
+        // 8. 转 PEM 并存储(同根证书逻辑)
+        String certPem = convertCertificateToPem(issuedCert);
+        String privateKeyPem = convertPrivateKeyToPem(keyPair.getPrivate());
+        String publicKeyPem = convertPublicKeyToPem(keyPair.getPublic());
+       // String chainPem = certPem; // 如果需要完整链,可把 issuerCert prepended;此处简单只放自身证书
+        // 若想把链做成 [issuedCert + issuerCert], 使用:
+        String  chainPem = certPem + convertCertificateToPem(issuerCert);
+
+        CertificateFileInfo fileInfo = certificateStorageService.storeCertificateFiles(
+                serialNumberStr,
+                certPem,
+                privateKeyPem,
+                publicKeyPem,
+                chainPem
+        );
+
+        // 9. 保存到数据库
+        CertificateEntity entity = new CertificateEntity();
+        // 不设置 id(数据库自增)
+        entity.setCertName(request.getCertName());
+        entity.setCertSerialNumber(serialNumberStr);
+        entity.setCertSubject(issuedCert.getSubjectX500Principal().getName());
+        entity.setCertAlgo(request.getKeyAlgorithm());
+        entity.setValidFrom(LocalDateTime.ofInstant(issuedCert.getNotBefore().toInstant(), ZoneId.systemDefault()));
+        entity.setValidTo(LocalDateTime.ofInstant(issuedCert.getNotAfter().toInstant(), ZoneId.systemDefault()));
+        entity.setStatus("1");
+        entity.setIsCa("0");
+        entity.setIsRoot("0");
+        entity.setParentSerial(issuerSerial);
+        entity.setCertUsage(request.getCertUsage() == null ? "PERSONAL" : request.getCertUsage());
+        entity.setCertFilePath(fileInfo.getCertPath());
+        entity.setPrivateKeyPath(fileInfo.getPrivateKeyPath());
+        entity.setPublicKeyPath(fileInfo.getPublicKeyPath());
+        entity.setChainPath(fileInfo.getChainPath());
+
+        certificateMapper.insert(entity);
+
+        log.info("普通证书签发成功: serial={}", serialNumberStr);
+        return ResponseDTO.ok(entity);
+    }
+
+
+    @Override
+    public ResponseDTO<CertificateEntity> issueRootCertificate(IssueRequest request) throws Exception {
+        //  生成密钥对
+        KeyPair keyPair = issueCertUtil.generateKeyPair(request.getKeyAlgorithm(), request.getKeySize());
+
+        // 生成序列号
+        String serialNumberStr = UUID.randomUUID()
+                .toString()
+                .replace("-", "")
+                .toUpperCase();
+
+        BigInteger serialNumber = new BigInteger(serialNumberStr, 16);
+        // 生成证书请求
+        X500NameBuilder x500NameBuilder = new X500NameBuilder(BCStyle.INSTANCE);
+
+// 必填字段:CN(建议校验非空,否则证书主题无意义)
+        if (StringUtils.hasText(request.getCommonName())) {
+            x500NameBuilder.addRDN(BCStyle.CN, request.getCommonName());
+        } else {
+            throw new IllegalArgumentException("Common Name (CN) is required");
+        }
+
+// 选填字段:非空才添加
+        if (StringUtils.hasText(request.getCountry())) {
+            x500NameBuilder.addRDN(BCStyle.C, request.getCountry());
+        }
+        if (StringUtils.hasText(request.getOrganizationalUnit())) {
+            x500NameBuilder.addRDN(BCStyle.OU, request.getOrganizationalUnit());
+        }
+        if (StringUtils.hasText(request.getOrganization())) {
+            x500NameBuilder.addRDN(BCStyle.O, request.getOrganization());
+        }
+        if (StringUtils.hasText(request.getState())) {
+            x500NameBuilder.addRDN(BCStyle.ST, request.getState());
+        }
+        if (StringUtils.hasText(request.getLocality())) {
+            x500NameBuilder.addRDN(BCStyle.L, request.getLocality());
+        }
+        if (StringUtils.hasText(serialNumberStr)) {
+            x500NameBuilder.addRDN(BCStyle.SERIALNUMBER, serialNumberStr);
+        }
+        if (StringUtils.hasText(request.getPhone())) {
+            x500NameBuilder.addRDN(BCStyle.TELEPHONE_NUMBER, request.getPhone());
+        }
+        X500Name subjectDN = x500NameBuilder.build();
+        // 设置证书有效期
+        Date notBefore = new Date();
+        Date notAfter = Date.from(
+                Instant.now().plusSeconds(request.getValidityDays() * 86400L)
+        );
+
+        // 创建证书构建器(颁发者=主体,因为是自签名)
+        X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
+                subjectDN,          // 颁发者(Issuer)= 主体(Subject)
+                serialNumber,             // 序列号
+                notBefore,          // 生效时间
+                notAfter,           // 过期时间
+                subjectDN,          // 主体
+                keyPair.getPublic() // 公钥
+        );
+        // 基本约束:标记为CA证书(true表示是CA)
+        certBuilder.addExtension(
+                Extension.basicConstraints,
+                true,  // critical = true
+                new BasicConstraints(true) // true = 是CA,可以签发子证书
+        );
+
+        // 密钥用途:CA需要证书签名和CRL签名能力
+        certBuilder.addExtension(
+                Extension.keyUsage,
+                true,  // critical = true
+                new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)
+        );
+
+        // 5.3 使用私钥签名证书
+        ContentSigner signer = new JcaContentSignerBuilder(
+                getSignatureAlgorithm(request.getKeyAlgorithm())
+        )
+                .setProvider("BC")
+                .build(keyPair.getPrivate());
+
+        X509CertificateHolder certHolder = certBuilder.build(signer);
+        X509Certificate rootCert = new JcaX509CertificateConverter()
+                .setProvider("BC")
+                .getCertificate(certHolder);
+
+        // ====== 6. 验证自签名证书(可选但推荐) ======
+        rootCert.verify(keyPair.getPublic());
+
+        // ====== 7. 转换为PEM格式 ======
+        String chainPem = convertCertificateToPem(rootCert);
+        String privateKeyPem = convertPrivateKeyToPem(keyPair.getPrivate());
+        String publicKeyPem = convertPublicKeyToPem(keyPair.getPublic());
+
+        // 根证书的证书链就是自己
+
+        // ====== 8. 存储到MinIO(分开存储,私钥加密) ======
+        CertificateFileInfo fileInfo = certificateStorageService.storeCertificateFiles(
+                serialNumberStr,
+                chainPem,
+                privateKeyPem,
+                publicKeyPem,
+                chainPem
+        );
+
+        // ====== 9. 保存到数据库 ======
+        CertificateEntity entity = new CertificateEntity();
+        entity.setCertName(request.getCertName());
+        entity.setCertSerialNumber(serialNumberStr);
+        entity.setCertSubject(rootCert.getSubjectX500Principal().getName());
+        entity.setCertAlgo(request.getKeyAlgorithm());
+        entity.setValidFrom(LocalDateTime.ofInstant(rootCert.getNotBefore().toInstant(), ZoneId.systemDefault()));
+        entity.setValidTo(LocalDateTime.ofInstant(rootCert.getNotAfter().toInstant(), ZoneId.systemDefault()));
+        entity.setStatus("1");
+        entity.setIsCa("1");
+        entity.setIsRoot("1");
+        entity.setParentSerial(null);
+        entity.setCertUsage("ROOT_CA");
+
+        // 只存储文件路径,不存储下载URL(动态生成)
+        entity.setCertFilePath(fileInfo.getCertPath());
+        entity.setPrivateKeyPath(fileInfo.getPrivateKeyPath());
+        entity.setPublicKeyPath(fileInfo.getPublicKeyPath());
+        entity.setChainPath(fileInfo.getChainPath());
+
+        certificateMapper.insert(entity);
+
+        log.info("根证书签发成功: serial={}", serialNumberStr);
+
+        return ResponseDTO.ok(entity);
+
+    }
+    @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();
+
+        // 1. 查询证书数据库记录
+        CertificateEntity entity = certificateMapper.selectById(id);
+        if (entity == null) {
+            return ResponseDTO.error("当前证书未找到", null);
+        }
+        if ("3".equals(entity.getStatus())) {
+            return ResponseDTO.error("冻结状态证书不能续期", null);
+        }
+        String oldSerial = entity.getCertSerialNumber();
+
+        // 2. 读取原有证书文件、公私钥文件(复用原有密钥)
+        String subjectCertPem = storageService.readCertificateFile(oldSerial);
+        String subjectPublicKeyPem = storageService.readPublicKeyFile(oldSerial);
+        String subjectPrivateKeyPem = storageService.readPrivateKeyFile(oldSerial);
+
+        // 3. 解析旧证书 & 公钥
+        CertificateFactory cf = CertificateFactory.getInstance("X.509", "BC");
+        X509Certificate subjectOldCert;
+        try (ByteArrayInputStream bais = new ByteArrayInputStream(subjectCertPem.getBytes(StandardCharsets.UTF_8))) {
+            subjectOldCert = (X509Certificate) cf.generateCertificate(bais);
+        }
+        X500Name subjectDN = X500Name.getInstance(subjectOldCert.getSubjectX500Principal().getEncoded());
+        PublicKey subjectPublicKey = parsePublicKeyPem(subjectPublicKeyPem, entity.getCertAlgo());
+
+        // 4. 获取签发者信息:根证书自签 / 非根证书找父签发者
+        X509Certificate issuerCert;
+        PrivateKey issuerPrivateKey;
+        X500Name issuerDN;
+        String issuerAlgo = entity.getCertAlgo();
+
+        if ("1".equals(entity.getIsRoot())) {
+            // 根证书:使用自身作为签发者,自签名
+            issuerCert = subjectOldCert;
+            issuerPrivateKey = parsePrivateKeyPem(subjectPrivateKeyPem, entity.getCertAlgo());
+            issuerDN = subjectDN;
+            issuerAlgo = entity.getCertAlgo();
+        } else {
+            // 子证书:根据 parentSerial 查找签发CA证书
+            String issuerSerial = entity.getParentSerial();
+            if (issuerSerial == null) {
+                return ResponseDTO.error("证书缺少父级序列号,无法续期", null);
+            }
+            CertificateEntity issuerEntity = certificateMapper.selectOne(
+                    new LambdaQueryWrapper<CertificateEntity>()
+                            .eq(CertificateEntity::getCertSerialNumber, issuerSerial)
+            );
+            if (issuerEntity == null) {
+                return ResponseDTO.error("签发者未找到: serial=" + issuerSerial, null);
+            }
+            String issuerCertPem = storageService.readCertificateFile(issuerSerial);
+            String issuerPrivateKeyPem = storageService.readPrivateKeyFile(issuerSerial);
+            try (ByteArrayInputStream bais = new ByteArrayInputStream(issuerCertPem.getBytes(StandardCharsets.UTF_8))) {
+                issuerCert = (X509Certificate) cf.generateCertificate(bais);
+            }
+            issuerPrivateKey = parsePrivateKeyPem(issuerPrivateKeyPem, issuerEntity.getCertAlgo());
+            issuerDN = X500Name.getInstance(issuerCert.getSubjectX500Principal().getEncoded());
+            issuerAlgo = issuerEntity.getCertAlgo();
+        }
+
+        Instant newNotAfter = subjectOldCert.getNotAfter().toInstant()
+                .plusSeconds((long) validityDays * 86400L);
+        if (!"1".equals(entity.getIsRoot())) {
+            if (newNotAfter.isAfter(issuerCert.getNotAfter().toInstant())) {
+                return ResponseDTO.error("延期后的证书有效期不能超过签发者证书有效期", null);
+            }
+        }
+
+        // 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,
+                serialNumber,
+                notBefore,
+                notAfter,
+                subjectDN,
+                subjectPublicKey
+        );
+
+        // 设置证书扩展项:CA证书 / 业务证书区分
+        if ("1".equals(entity.getIsCa()) || "1".equals(entity.getIsRoot())) {
+            certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true));
+            certBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign));
+        } else {
+            certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
+            certBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment));
+            KeyPurposeId[] purposes = new KeyPurposeId[]{KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth};
+            certBuilder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(purposes));
+        }
+        if (!"1".equals(entity.getIsRoot())) {
+            addOcspAiaExtension(certBuilder);
+        }
+
+        // 使用签发者私钥签名
+        ContentSigner signer = new JcaContentSignerBuilder(getSignatureAlgorithm(issuerAlgo))
+                .setProvider("BC")
+                .build(issuerPrivateKey);
+
+        X509CertificateHolder certHolder = certBuilder.build(signer);
+        X509Certificate newCert = new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder);
+
+        // 校验新证书签名合法性
+        newCert.verify(issuerCert.getPublicKey());
+
+        // 6. PEM格式化,使用原有密钥对,并按新序列号存储证书文件
+        String newCertPem = convertCertificateToPem(newCert);
+        String newPublicKeyPem = subjectPublicKeyPem;
+        String newPrivateKeyPem = subjectPrivateKeyPem;
+
+        String chainPem;
+        if ("1".equals(entity.getIsRoot())) {
+            chainPem = newCertPem;
+        } else {
+            String issuerCertPem = storageService.readCertificateFile(entity.getParentSerial());
+            chainPem = newCertPem + issuerCertPem;
+        }
+
+        CertificateFileInfo fileInfo = certificateStorageService.storeCertificateFiles(
+                newSerial,
+                newCertPem,
+                newPrivateKeyPem,
+                newPublicKeyPem,
+                chainPem
+        );
+
+        // 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("证书续期成功: oldSerial={}, newSerial={}, id={}", oldSerial, newSerial, id);
+        return ResponseDTO.ok(entity);
+    }
+
+    @Override
+    public ResponseDTO<CertificateEntity> freezeCertificate(FreezeCertificateRequest request) {
+        if (request == null || request.getId() == null) {
+            throw new IllegalArgumentException("证书ID不能为空");
+        }
+
+        CertificateEntity entity = certificateMapper.selectById(request.getId());
+        if (entity == null) {
+            throw new IllegalArgumentException("证书未找到: id=" + request.getId());
+        }
+
+        // 防止重复冻结
+        if ("3".equals(entity.getStatus())) {
+            return ResponseDTO.ok(entity);
+        }
+
+        entity.setStatus("3");
+        entity.setRevokedTime(LocalDateTime.now());
+        entity.setRevokeReason("certificateHold");
+
+        certificateMapper.updateById(entity);
+
+        log.info("证书已冻结: id={}, serial={}", entity.getId(), entity.getCertSerialNumber());
+        return ResponseDTO.ok(entity);
+    }
+    @Override
+    public ResponseDTO<CertificateEntity> unfreezeCertificate(FreezeCertificateRequest request) {
+        if (request == null || request.getId() == null) {
+            throw new IllegalArgumentException("证书ID不能为空");
+        }
+
+        CertificateEntity entity = certificateMapper.selectById(request.getId());
+        if (entity == null) {
+            throw new IllegalArgumentException("证书未找到: id=" + request.getId());
+        }
+
+        if (!"3".equals(entity.getStatus())) {
+            return ResponseDTO.ok(entity);
+        }
+
+        entity.setStatus("1");
+        entity.setRevokedTime(null);
+        entity.setRevokeReason(null);
+
+        certificateMapper.updateById(entity);
+
+        log.info("证书已解冻: id={}, serial={}", entity.getId(), entity.getCertSerialNumber());
+        return ResponseDTO.ok(entity);
+    }
+
+    @Override
+    public ResponseDTO<CertificateEntity> revokeCertificate(RevokeCertificateRequest request) {
+        if (request == null || request.getId() == null) {
+            throw new IllegalArgumentException("证书ID不能为空");
+        }
+
+        CertificateEntity cert = certificateMapper.selectById(request.getId());
+        if (cert == null) {
+            throw new IllegalArgumentException("证书未找到: id=" + request.getId());
+        }
+
+        if ("4".equals(cert.getStatus())) {
+            return ResponseDTO.ok(cert);
+        }
+
+        cert.setStatus("4");
+        cert.setRevokedTime(LocalDateTime.now());
+        cert.setRevokeReason(
+                request.getReason() == null || request.getReason().trim().isEmpty()
+                        ? "certificate revoked"
+                        : request.getReason()
+        );
+
+        certificateMapper.updateById(cert);
+
+        log.info("证书已吊销: id={}, serial={}", cert.getId(), cert.getCertSerialNumber());
+        return ResponseDTO.ok(cert);
+    }
+
+    @Override
+    public ResponseEntity<String> downloadCrl(Long issuerId, HttpServletResponse response) {
+        try {
+            CertificateEntity issuer = certificateMapper.selectById(issuerId);
+            if (issuer == null) {
+                return ResponseEntity.notFound().build();
+            }
+
+            String issuerSerial = issuer.getCertSerialNumber();
+
+            List<CertificateEntity> revokedList = certificateMapper.selectList(
+                    new LambdaQueryWrapper<CertificateEntity>()
+                            .eq(CertificateEntity::getStatus, "2")
+                            .and(w -> w.eq(CertificateEntity::getParentSerial, issuerSerial)
+                                    .or()
+                                    .eq(CertificateEntity::getCertSerialNumber, issuerSerial))
+            );
+
+            String issuerCertPem = storageService.readCertificateFile(issuerSerial);
+            String issuerPrivateKeyPem = storageService.readPrivateKeyFile(issuerSerial);
+
+            CertificateFactory cf = CertificateFactory.getInstance("X.509", "BC");
+            X509Certificate issuerCert;
+            try (ByteArrayInputStream bais = new ByteArrayInputStream(issuerCertPem.getBytes(StandardCharsets.UTF_8))) {
+                issuerCert = (X509Certificate) cf.generateCertificate(bais);
+            }
+
+            PrivateKey issuerPrivateKey = parsePrivateKeyPem(issuerPrivateKeyPem, issuer.getCertAlgo());
+
+            X509v2CRLBuilder crlBuilder = new X509v2CRLBuilder(
+                    X500Name.getInstance(issuerCert.getSubjectX500Principal().getEncoded()),
+                    new Date()
+            );
+            crlBuilder.setNextUpdate(Date.from(Instant.now().plusSeconds(86400L)));
+
+            for (CertificateEntity revoked : revokedList) {
+                if (revoked.getRevokedTime() == null) {
+                    continue;
+                }
+                BigInteger serial = new BigInteger(revoked.getCertSerialNumber(), 16);
+                Date revocationDate = Date.from(revoked.getRevokedTime().atZone(ZoneId.systemDefault()).toInstant());
+                crlBuilder.addCRLEntry(serial, revocationDate, CRLReason.privilegeWithdrawn);
+            }
+
+            ContentSigner signer = new JcaContentSignerBuilder(getSignatureAlgorithm(issuer.getCertAlgo()))
+                    .setProvider("BC")
+                    .build(issuerPrivateKey);
+
+            X509CRLHolder crlHolder = crlBuilder.build(signer);
+            X509CRL crl = new JcaX509CRLConverter().setProvider("BC").getCRL(crlHolder);
+
+            String crlPem = convertCrlToPem(crl);
+
+            response.setContentType("application/pkix-crl");
+            response.setHeader("Content-Disposition", "attachment; filename=" + issuerSerial + "_crl.pem");
+
+            return ResponseEntity.ok()
+                    .header("Content-Disposition", "attachment; filename=" + issuerSerial + "_crl.pem")
+                    .header("Content-Type", "application/pkix-crl")
+                    .body(crlPem);
+
+        } catch (Exception e) {
+            log.error("生成CRL失败", e);
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+        }
+    }
+
+    @Override
+    public ResponseEntity<byte[]> respondToOcspRequest(byte[] requestBytes) throws Exception {
+        OCSPReq request;
+        try {
+            request = new OCSPReq(requestBytes);
+        } catch (Exception e) {
+            return ocspResponse(OCSPRespBuilder.MALFORMED_REQUEST, null);
+        }
+
+        org.bouncycastle.cert.ocsp.Req[] requests = request.getRequestList();
+        if (requests.length == 0) {
+            return ocspResponse(OCSPRespBuilder.MALFORMED_REQUEST, null);
+        }
+
+        DigestCalculatorProvider digestProvider = new JcaDigestCalculatorProviderBuilder()
+                .setProvider("BC")
+                .build();
+        CertificateID firstCertId = requests[0].getCertID();
+        CertificateEntity issuerEntity = null;
+        X509Certificate issuerCert = null;
+        X509CertificateHolder issuerHolder = null;
+
+        List<CertificateEntity> issuers = certificateMapper.selectList(
+                new LambdaQueryWrapper<CertificateEntity>()
+                        .eq(CertificateEntity::getIsCa, "1")
+                        .or()
+                        .eq(CertificateEntity::getIsRoot, "1")
+        );
+        for (CertificateEntity candidate : issuers) {
+            X509Certificate candidateCert = readCertificate(candidate.getCertSerialNumber());
+            X509CertificateHolder candidateHolder = new X509CertificateHolder(candidateCert.getEncoded());
+            if (firstCertId.matchesIssuer(candidateHolder, digestProvider)) {
+                issuerEntity = candidate;
+                issuerCert = candidateCert;
+                issuerHolder = candidateHolder;
+                break;
+            }
+        }
+        if (issuerEntity == null) {
+            return ocspResponse(OCSPRespBuilder.UNAUTHORIZED, null);
+        }
+
+        BasicOCSPRespBuilder responseBuilder = new JcaBasicOCSPRespBuilder(
+                issuerCert.getPublicKey(),
+                digestProvider.get(CertificateID.HASH_SHA1)
+        );
+        Extension nonce = request.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce);
+        if (nonce != null) {
+            responseBuilder.setResponseExtensions(new Extensions(
+                    new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce, false, nonce.getExtnValue())
+            ));
+        }
+        Date thisUpdate = new Date();
+        Date nextUpdate = Date.from(Instant.now().plusSeconds(300));
+        String issuerSerial = issuerEntity.getCertSerialNumber();
+        for (org.bouncycastle.cert.ocsp.Req ocspRequest : requests) {
+            CertificateID certId = ocspRequest.getCertID();
+            if (!certId.matchesIssuer(issuerHolder, digestProvider)) {
+                return ocspResponse(OCSPRespBuilder.UNAUTHORIZED, null);
+            }
+
+            String serialNumber = certId.getSerialNumber().toString(16).toUpperCase(Locale.ROOT);
+            String paddedSerialNumber = String.format("%032X", certId.getSerialNumber());
+            CertificateEntity certificate = certificateMapper.selectOne(
+                    new LambdaQueryWrapper<CertificateEntity>()
+                            .in(CertificateEntity::getCertSerialNumber, serialNumber, paddedSerialNumber)
+                            .and(wrapper -> wrapper.eq(CertificateEntity::getParentSerial, issuerSerial)
+                                    .or()
+                                    .eq(CertificateEntity::getCertSerialNumber, issuerSerial))
+                            .last("LIMIT 1")
+            );
+
+            CertificateStatus status;
+            String certificateStatus = certificate == null ? null : certificate.getStatus();
+            if (certificateStatus == null
+                    || !certificateStatus.equals("1")
+                    && !certificateStatus.equals("2")
+                    && !certificateStatus.equals("3")
+                    && !certificateStatus.equals("4")) {
+                status = new UnknownStatus();
+            } else if ("3".equals(certificateStatus) || "4".equals(certificateStatus)) {
+                LocalDateTime revokedAt = certificate.getRevokedTime() == null
+                        ? LocalDateTime.now()
+                        : certificate.getRevokedTime();
+                int reason = "3".equals(certificateStatus)
+                        ? CRLReason.certificateHold
+                        : CRLReason.unspecified;
+                status = new RevokedStatus(
+                        Date.from(revokedAt.atZone(ZoneId.systemDefault()).toInstant()),
+                        reason
+                );
+            } else {
+                // OCSP has no expired status; clients validate the certificate validity period.
+                status = CertificateStatus.GOOD;
+            }
+            responseBuilder.addResponse(certId, status, thisUpdate, nextUpdate);
+        }
+
+        String issuerPrivateKeyPem = storageService.readPrivateKeyFile(issuerSerial);
+        PrivateKey issuerPrivateKey = parsePrivateKeyPem(issuerPrivateKeyPem, issuerEntity.getCertAlgo());
+        ContentSigner signer = new JcaContentSignerBuilder(getSignatureAlgorithm(issuerEntity.getCertAlgo()))
+                .setProvider("BC")
+                .build(issuerPrivateKey);
+        BasicOCSPResp basicResponse = responseBuilder.build(
+                signer,
+                new X509CertificateHolder[]{issuerHolder},
+                thisUpdate
+        );
+        return ocspResponse(OCSPRespBuilder.SUCCESSFUL, basicResponse);
+    }
+
+    private X509Certificate readCertificate(String serialNumber) throws Exception {
+        String certificatePem = storageService.readCertificateFile(serialNumber);
+        CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509", "BC");
+        try (ByteArrayInputStream input = new ByteArrayInputStream(certificatePem.getBytes(StandardCharsets.UTF_8))) {
+            return (X509Certificate) certificateFactory.generateCertificate(input);
+        }
+    }
+
+    private ResponseEntity<byte[]> ocspResponse(int status, BasicOCSPResp response) throws Exception {
+        byte[] encoded = new OCSPRespBuilder().build(status, response).getEncoded();
+        return ResponseEntity.ok()
+                .contentType(MediaType.parseMediaType("application/ocsp-response"))
+                .body(encoded);
+    }
+
+    private void addOcspAiaExtension(X509v3CertificateBuilder certificateBuilder) throws Exception {
+        if (ocspResponderUrl == null || ocspResponderUrl.isBlank()) {
+            return;
+        }
+        AuthorityInformationAccess aia = new AuthorityInformationAccess(
+                new AccessDescription(
+                        AccessDescription.id_ad_ocsp,
+                        new GeneralName(GeneralName.uniformResourceIdentifier, ocspResponderUrl)
+                )
+        );
+        certificateBuilder.addExtension(Extension.authorityInfoAccess, false, aia);
+    }
+
+    @Override
+    public ResponseDTO<String> uploadRootCertificate(String pemCombined, String certName) throws Exception {
+        if (pemCombined == null || pemCombined.trim().isEmpty()) {
+            return ResponseDTO.error(100001, "上传文件内容为空");
+        }
+
+        // 提取第一个证书 PEM
+        int certBegin = pemCombined.indexOf("-----BEGIN CERTIFICATE-----");
+        int certEnd = pemCombined.indexOf("-----END CERTIFICATE-----", certBegin);
+        if (certBegin < 0 || certEnd < 0) {
+            return ResponseDTO.error(100001, "未找到证书内容(PEM 格式)");
+        }
+        String certPem = pemCombined.substring(certBegin, certEnd + "-----END CERTIFICATE-----".length()).trim();
+
+        // 提取私钥 PEM(支持 PKCS#8 与 RSA PKCS#1)
+        String privateKeyPem = null;
+        String[][] privMarkers = {
+                {"-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----"},
+                {"-----BEGIN RSA PRIVATE KEY-----", "-----END RSA PRIVATE KEY-----"},
+                {"-----BEGIN EC PRIVATE KEY-----", "-----END EC PRIVATE KEY-----"}
+        };
+        for (String[] m : privMarkers) {
+            int b = pemCombined.indexOf(m[0]);
+            if (b >= 0) {
+                int e = pemCombined.indexOf(m[1], b);
+                if (e > b) {
+                    privateKeyPem = pemCombined.substring(b, e + m[1].length()).trim();
+                    break;
+                }
+            }
+        }
+        if (privateKeyPem == null) {
+            return ResponseDTO.error(100001, "未找到私钥内容(PEM 格式),上传文件必须包含私钥");
+        }
+
+        // 解析证书对象
+        java.security.cert.CertificateFactory cf =
+                java.security.cert.CertificateFactory.getInstance("X.509", "BC");
+        X509Certificate cert;
+        try (ByteArrayInputStream bais = new ByteArrayInputStream(certPem.getBytes(StandardCharsets.UTF_8))) {
+            cert = (X509Certificate) cf.generateCertificate(bais);
+        }
+
+        if (cert.getBasicConstraints() < 0) {
+            return ResponseDTO.error(100001, "上传证书不是 CA 证书(BasicConstraints 未标记 CA)");
+        }
+        boolean[] keyUsage = cert.getKeyUsage();
+        if (keyUsage == null || keyUsage.length <= 5 || !keyUsage[5]) {
+            return ResponseDTO.error(100001, "上传证书的 KeyUsage 未包含 keyCertSign");
+        }
+        if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) {
+            return ResponseDTO.error(100001, "上传的根证书不是自签名证书");
+        }
+        cert.verify(cert.getPublicKey());
+        cert.checkValidity();
+
+        String pubAlgo = cert.getPublicKey().getAlgorithm();
+        String certAlgo = ("EC".equalsIgnoreCase(pubAlgo) || "SM2".equalsIgnoreCase(pubAlgo)) ? "2" : "RSA";
+        PrivateKey privateKey = parsePrivateKeyPem(privateKeyPem, certAlgo);
+        java.security.Signature keyMatchSignature =
+                java.security.Signature.getInstance(cert.getSigAlgName(), "BC");
+        byte[] challenge = UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8);
+        keyMatchSignature.initSign(privateKey);
+        keyMatchSignature.update(challenge);
+        byte[] proof = keyMatchSignature.sign();
+        keyMatchSignature.initVerify(cert.getPublicKey());
+        keyMatchSignature.update(challenge);
+        if (!keyMatchSignature.verify(proof)) {
+            return ResponseDTO.error(100001, "上传的私钥与证书公钥不匹配");
+        }
+
+        // 构造序列号(与系统其它处一致的十六进制大写)
+        String serialHex = cert.getSerialNumber().toString(16).toUpperCase();
+
+        // 唯一性检查:若已有该序列的记录,幂等返回
+        CertificateEntity exist = certificateMapper.selectOne(
+                new LambdaQueryWrapper<CertificateEntity>().eq(CertificateEntity::getCertSerialNumber, serialHex)
+        );
+        if (exist != null) {
+            log.info("根证书已存在,返回已有记录: serial={}", serialHex);
+            return ResponseDTO.error(100001,"证书已存在");
+        }
+
+        // 公钥 PEM
+        String publicKeyPem = convertPublicKeyToPem(cert.getPublicKey());
+
+        // 证书链:这里以单个根证书为链(如需要可从 pemCombined 提取更多证书)
+        String chainPem = certPem;
+
+        // 存储文件(MinIO),storeCertificateFiles 对私钥做加密存储
+        CertificateFileInfo fileInfo = certificateStorageService.storeCertificateFiles(
+                serialHex,
+                certPem,
+                privateKeyPem,
+                publicKeyPem,
+                chainPem
+        );
+
+        // 保存到数据库(字段与 issueRootCertificate 保持一致)
+        CertificateEntity entity = new CertificateEntity();
+        entity.setCertName((certName != null && !certName.trim().isEmpty()) ? certName : cert.getSubjectX500Principal().getName());
+        entity.setCertSerialNumber(serialHex);
+        entity.setCertSubject(cert.getSubjectX500Principal().getName());
+        entity.setCertAlgo(certAlgo);
+        entity.setValidFrom(LocalDateTime.ofInstant(cert.getNotBefore().toInstant(), ZoneId.systemDefault()));
+        entity.setValidTo(LocalDateTime.ofInstant(cert.getNotAfter().toInstant(), ZoneId.systemDefault()));
+        entity.setStatus("1");
+        entity.setIsCa("1");
+        entity.setIsRoot("1");
+        entity.setParentSerial(null);
+        entity.setCertUsage("ROOT_CA");
+        entity.setCertFilePath(fileInfo.getCertPath());
+        entity.setPrivateKeyPath(fileInfo.getPrivateKeyPath());
+        entity.setPublicKeyPath(fileInfo.getPublicKeyPath());
+        entity.setChainPath(fileInfo.getChainPath());
+
+        certificateMapper.insert(entity);
+
+        log.info("上传根证书并保存成功: serial={}", serialHex);
+        return ResponseDTO.ok();
+    }
+    @Override
+    public ResponseEntity<Map<String, String>> downloadPemSeparate(Long id) {
+        try {
+            CertificateEntity cert = certificateMapper.selectById(id);
+            if (cert == null) {
+                return ResponseEntity.notFound().build();
+            }
+
+            DownloadResponse response = assembleService.downloadPemSeparate(cert.getCertSerialNumber());
+
+            Map<String, String> result = new HashMap<>();
+            result.put("certificate", response.getCertContent());
+            result.put("privateKey", response.getPrivateKeyContent());
+            result.put("publicKey", response.getPublicKeyContent());
+
+            return ResponseEntity.ok(result);
+
+        } catch (Exception e) {
+            log.error("下载PEM失败", e);
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+        }
+    }
+
+    @Override
+    public ResponseEntity<byte[]> downloadPemCombined(Long id, HttpServletResponse response) {
+        try {
+            CertificateEntity cert = certificateMapper.selectById(id);
+            if (cert == null) {
+                return ResponseEntity.notFound().build();
+            }
+
+            DownloadResponse downloadResponse = assembleService.downloadPemCombined(cert.getCertSerialNumber());
+
+            response.setContentType("application/x-pem-file");
+            response.setHeader("Content-Disposition",
+                    "attachment; filename=" + cert.getCertSerialNumber() + "_full.pem");
+
+
+            return ResponseEntity.ok()
+                    .header("Content-Disposition",
+                            "attachment; filename=" + cert.getCertSerialNumber() + "_full.pem")
+                    .header("Content-Type", "application/x-pem-file")
+                    .body(downloadResponse.getCertContent().getBytes(StandardCharsets.UTF_8));
+
+        } catch (Exception e) {
+            log.error("下载合并PEM失败", e);
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+        }
+    }
+
+    @Override
+    public ResponseEntity<byte[]> downloadPkcs12(Pkcs12DownloadRequest request, HttpServletResponse response) {
+        try {
+            CertificateEntity cert = certificateMapper.selectById(request.getId());
+            if (cert == null) {
+                return ResponseEntity.notFound().build();
+            }
+
+            // 验证密码强度
+            String password = request.getPassword();
+            if (password == null || password.length() < 6) {
+                return ResponseEntity.badRequest()
+                        .header("X-Error", "密码至少6位")
+                        .build();
+            }
+
+            DownloadResponse downloadResponse = assembleService.downloadPkcs12(
+                    cert.getCertSerialNumber(),
+                    password
+            );
+
+            // 设置响应头
+            response.setContentType("application/x-pkcs12");
+            response.setHeader("Content-Disposition",
+                    "attachment; filename=" + cert.getCertSerialNumber() + ".p12");
+            response.setHeader("X-Password", password); // 返回密码给用户(可选)
+
+            return ResponseEntity.ok()
+                    .header("Content-Disposition",
+                            "attachment; filename=" + cert.getCertSerialNumber() + ".p12")
+                    .header("Content-Type", "application/x-pkcs12")
+                    .body(downloadResponse.getBinaryContent());
+
+        } catch (Exception e) {
+            log.error("下载PKCS#12失败", e);
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                    .header("X-Error", e.getMessage())
+                    .build();
+        }
+    }
+
+    @Override
+    public ResponseEntity<String> downloadChain(String id, HttpServletResponse response) {
+        try {
+            CertificateEntity cert = certificateMapper.selectById(id);
+            if (cert == null) {
+                return ResponseEntity.notFound().build();
+            }
+
+            // 从MinIO读取证书链(注意:MinIO 路径按证书序列号组织,不是数据库 id)
+            String chain = storageService.readChainFile(cert.getCertSerialNumber());
+            if (chain == null || chain.isEmpty()) {
+                // 如果没有证书链,返回证书本身
+                chain = storageService.readCertificateFile(cert.getCertSerialNumber());
+            }
+
+            response.setContentType("application/x-pem-file");
+            response.setHeader("Content-Disposition",
+                    "attachment; filename=" + id + "_chain.pem");
+
+            return ResponseEntity.ok()
+                    .header("Content-Disposition",
+                            "attachment; filename=" + id + "_chain.pem")
+                    .header("Content-Type", "application/x-pem-file")
+                    .body(chain);
+
+        } catch (Exception e) {
+            log.error("下载证书链失败", e);
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
+        }
+    }
+
+    private String getSignatureAlgorithm(String keyAlgorithm) {
+        if ("2".equalsIgnoreCase(keyAlgorithm)) {
+            return "SM3withSM2";
+        } else {
+            return "SHA256WithRSA"; // RSA默认
+        }
+    }
+    /**
+     * 证书转PEM
+     */
+    private String convertCertificateToPem(X509Certificate cert) throws Exception {
+        String base64 = Base64.getEncoder().encodeToString(cert.getEncoded());
+        StringBuilder pem = new StringBuilder();
+        pem.append("-----BEGIN CERTIFICATE-----\n");
+        for (int i = 0; i < base64.length(); i += 64) {
+            int end = Math.min(i + 64, base64.length());
+            pem.append(base64, i, end).append("\n");
+        }
+        pem.append("-----END CERTIFICATE-----\n");
+        return pem.toString();
+    }
+
+    /**
+     * 公钥转PEM
+     */
+    private String convertPublicKeyToPem(PublicKey publicKey) throws Exception {
+        String base64 = Base64.getEncoder().encodeToString(publicKey.getEncoded());
+        StringBuilder pem = new StringBuilder();
+        pem.append("-----BEGIN PUBLIC KEY-----\n");
+        for (int i = 0; i < base64.length(); i += 64) {
+            int end = Math.min(i + 64, base64.length());
+            pem.append(base64, i, end).append("\n");
+        }
+        pem.append("-----END PUBLIC KEY-----\n");
+        return pem.toString();
+    }
+
+    /**
+     * 私钥转PEM
+     */
+    private String convertPrivateKeyToPem(PrivateKey privateKey) throws Exception {
+        String base64 = Base64.getEncoder().encodeToString(privateKey.getEncoded());
+        StringBuilder pem = new StringBuilder();
+        pem.append("-----BEGIN PRIVATE KEY-----\n");
+        for (int i = 0; i < base64.length(); i += 64) {
+            int end = Math.min(i + 64, base64.length());
+            pem.append(base64, i, end).append("\n");
+        }
+        pem.append("-----END PRIVATE KEY-----\n");
+        return pem.toString();
+    }
+    /**
+     * 解析 PEM 私钥(支持 PKCS8 与 RSA PKCS#1),根据算法选择 KeyFactory(RSA 或 EC)
+     */
+    private PrivateKey parsePrivateKeyPem(String privateKeyPem, String algoHint) throws Exception {
+        String base64 = privateKeyPem
+                .replace("-----BEGIN PRIVATE KEY-----", "")
+                .replace("-----END PRIVATE KEY-----", "")
+                .replace("-----BEGIN RSA PRIVATE KEY-----", "")
+                .replace("-----END RSA PRIVATE KEY-----", "")
+                .replaceAll("\\s", "");
+        byte[] keyBytes = Base64.getDecoder().decode(base64);
+        boolean ecKey = isEcKeyAlgorithm(algoHint);
+
+        try {
+            PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
+            if (ecKey) {
+                return KeyFactory.getInstance("EC", "BC").generatePrivate(spec);
+            } else {
+                return KeyFactory.getInstance("RSA", "BC").generatePrivate(spec);
+            }
+        } catch (Exception e) {
+            if (ecKey) {
+                throw e;
+            }
+
+            org.bouncycastle.asn1.pkcs.RSAPrivateKey rsa = org.bouncycastle.asn1.pkcs.RSAPrivateKey.getInstance(keyBytes);
+            java.security.spec.RSAPrivateCrtKeySpec crtSpec =
+                    new java.security.spec.RSAPrivateCrtKeySpec(
+                            rsa.getModulus(),
+                            rsa.getPublicExponent(),
+                            rsa.getPrivateExponent(),
+                            rsa.getPrime1(),
+                            rsa.getPrime2(),
+                            rsa.getExponent1(),
+                            rsa.getExponent2(),
+                            rsa.getCoefficient()
+                    );
+            return KeyFactory.getInstance("RSA", "BC").generatePrivate(crtSpec);
+        }
+    }
+
+    /**
+     * PEM公钥解析工具
+     */
+    private PublicKey parsePublicKeyPem(String publicKeyPem, String algoHint) throws Exception {
+        String base64 = publicKeyPem
+                .replace("-----BEGIN PUBLIC KEY-----", "")
+                .replace("-----END PUBLIC KEY-----", "")
+                .replaceAll("\\s", "");
+        byte[] keyBytes = Base64.getDecoder().decode(base64);
+        X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
+        if (isEcKeyAlgorithm(algoHint)) {
+            return KeyFactory.getInstance("EC", "BC").generatePublic(spec);
+        } else {
+            return KeyFactory.getInstance("RSA", "BC").generatePublic(spec);
+        }
+    }
+
+    private boolean isEcKeyAlgorithm(String algorithm) {
+        return "2".equalsIgnoreCase(algorithm)
+                || "SM2".equalsIgnoreCase(algorithm)
+                || "EC".equalsIgnoreCase(algorithm);
+    }
+
+    private String convertCrlToPem(X509CRL crl) throws Exception {
+        String base64 = Base64.getEncoder().encodeToString(crl.getEncoded());
+        StringBuilder pem = new StringBuilder();
+        pem.append("-----BEGIN X509 CRL-----\n");
+        for (int i = 0; i < base64.length(); i += 64) {
+            int end = Math.min(i + 64, base64.length());
+            pem.append(base64, i, end).append("\n");
+        }
+        pem.append("-----END X509 CRL-----\n");
+        return pem.toString();
+    }
+}

+ 96 - 0
src/main/java/com/ymwk/supervisionserver/pki/service/impl/CertificateStorageService.java

@@ -0,0 +1,96 @@
+package com.ymwk.supervisionserver.pki.service.impl;
+
+import com.ymwk.supervisionserver.pki.model.CertificateFileInfo;
+import com.ymwk.supervisionserver.utils.MinioClientService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+
+@Component
+@Slf4j
+public class CertificateStorageService {
+
+    @Autowired
+    private MinioClientService minioUtil;
+
+    @Autowired
+    private CryptoService cryptoService; // 加密服务
+
+    /**
+     * 存储证书相关文件(分开存储)
+     */
+    public CertificateFileInfo storeCertificateFiles(
+            String serialNumber,
+            String certPem,
+            String privateKeyPem,
+            String publicKeyPem,
+            String chainPem) throws Exception {
+
+        CertificateFileInfo fileInfo = new CertificateFileInfo();
+        fileInfo.setSerialNumber(serialNumber);
+
+        // 1. 存储证书文件(不加密)
+        String certPath = String.format("certs/%s/cert.pem", serialNumber);
+        minioUtil.uploadObject(certPath, certPem, "application/x-pem-file");
+        fileInfo.setCertPath(certPath);
+        log.info("证书文件已存储: {}", certPath);
+
+        // 2. 存储私钥文件(AES加密)
+        String encryptedPrivateKey = cryptoService.encrypt(privateKeyPem);
+        String privateKeyPath = String.format("certs/%s/private-key.enc", serialNumber);
+        minioUtil.uploadObject(privateKeyPath, encryptedPrivateKey, "application/octet-stream");
+        fileInfo.setPrivateKeyPath(privateKeyPath);
+        log.info("私钥文件已加密存储: {}", privateKeyPath);
+
+        // 3. 存储公钥文件(不加密)
+        String publicKeyPath = String.format("certs/%s/public-key.pem", serialNumber);
+        minioUtil.uploadObject(publicKeyPath, publicKeyPem, "application/x-pem-file");
+        fileInfo.setPublicKeyPath(publicKeyPath);
+        log.info("公钥文件已存储: {}", publicKeyPath);
+
+        // 4. 存储证书链(如果有)
+        if (chainPem != null && !chainPem.isEmpty()) {
+            String chainPath = String.format("certs/%s/chain.pem", serialNumber);
+            minioUtil.uploadObject(chainPath, chainPem, "application/x-pem-file");
+            fileInfo.setChainPath(chainPath);
+            log.info("证书链文件已存储: {}", chainPath);
+        }
+
+        return fileInfo;
+    }
+
+    /**
+     * 读取证书文件
+     */
+    public String readCertificateFile(String serialNumber) throws Exception {
+        String path = String.format("certs/%s/cert.pem", serialNumber);
+        return minioUtil.downloadFileAsString(path);
+    }
+
+    /**
+     * 读取并解密私钥文件
+     */
+    public String readPrivateKeyFile(String serialNumber) throws Exception {
+        String path = String.format("certs/%s/private-key.enc", serialNumber);
+        String encrypted = minioUtil.downloadFileAsString(path);
+        return cryptoService.decrypt(encrypted);
+    }
+
+    /**
+     * 读取公钥文件
+     */
+    public String readPublicKeyFile(String serialNumber) throws Exception {
+        String path = String.format("certs/%s/public-key.pem", serialNumber);
+        return minioUtil.downloadFileAsString(path);
+    }
+
+    /**
+     * 读取证书链
+     */
+    public String readChainFile(String serialNumber) throws Exception {
+        String path = String.format("certs/%s/chain.pem", serialNumber);
+        return minioUtil.downloadFileAsString(path);
+    }
+}

+ 73 - 0
src/main/java/com/ymwk/supervisionserver/pki/service/impl/CryptoService.java

@@ -0,0 +1,73 @@
+package com.ymwk.supervisionserver.pki.service.impl;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Base64;
+
+@Component
+@Slf4j
+public class CryptoService {
+
+
+    private final String masterKey = "12345678901234567890123456789012"; // 32字节的主密钥
+
+    private static final String ALGORITHM = "AES/GCM/NoPadding";
+    private static final int GCM_IV_LENGTH = 12;
+    private static final int GCM_TAG_LENGTH = 128;
+
+    /**
+     * AES-GCM加密
+     */
+    public String encrypt(String plaintext) throws Exception {
+        byte[] iv = new byte[GCM_IV_LENGTH];
+        SecureRandom random = new SecureRandom();
+        random.nextBytes(iv);
+
+        Cipher cipher = Cipher.getInstance(ALGORITHM);
+        SecretKeySpec keySpec = new SecretKeySpec(
+                masterKey.getBytes(StandardCharsets.UTF_8),
+                "AES"
+        );
+        GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
+        cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
+
+        byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
+
+        // 将IV和密文合并(Base64编码)
+        byte[] combined = new byte[iv.length + ciphertext.length];
+        System.arraycopy(iv, 0, combined, 0, iv.length);
+        System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);
+
+        return Base64.getEncoder().encodeToString(combined);
+    }
+
+    /**
+     * AES-GCM解密
+     */
+    public String decrypt(String ciphertextBase64) throws Exception {
+        byte[] combined = Base64.getDecoder().decode(ciphertextBase64);
+
+        // 提取IV和密文
+        byte[] iv = new byte[GCM_IV_LENGTH];
+        byte[] ciphertext = new byte[combined.length - GCM_IV_LENGTH];
+        System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH);
+        System.arraycopy(combined, GCM_IV_LENGTH, ciphertext, 0, ciphertext.length);
+
+        Cipher cipher = Cipher.getInstance(ALGORITHM);
+        SecretKeySpec keySpec = new SecretKeySpec(
+                masterKey.getBytes(StandardCharsets.UTF_8),
+                "AES"
+        );
+        GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
+        cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
+
+        byte[] plaintext = cipher.doFinal(ciphertext);
+        return new String(plaintext, StandardCharsets.UTF_8);
+    }
+}

+ 17 - 0
src/main/java/com/ymwk/supervisionserver/pki/utils/Base64Utils.java

@@ -0,0 +1,17 @@
+package com.ymwk.supervisionserver.pki.utils;
+
+
+import java.io.IOException;
+import java.util.Base64;
+
+
+public class Base64Utils {
+
+    public static byte[] decode(String str) throws IOException{
+		return Base64.getDecoder().decode(str);
+    }
+
+    public static String encode(byte[] bytes){
+		return new String(Base64.getEncoder().encode(bytes));
+    }
+}

+ 55 - 0
src/main/java/com/ymwk/supervisionserver/pki/utils/issueCertUtil.java

@@ -0,0 +1,55 @@
+package com.ymwk.supervisionserver.pki.utils;
+
+import com.ymwk.supervisionserver.pki.model.IssueRequest;
+import org.bouncycastle.jce.ECNamedCurveTable;
+import org.bouncycastle.jce.spec.ECParameterSpec;
+import java.security.spec.ECGenParameterSpec;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.SecureRandom;
+
+public class issueCertUtil {
+
+    public static KeyPair generateKeyPair(String algorithm, Integer keySize) throws Exception {
+        KeyPairGenerator generator;
+
+        if ("2".equalsIgnoreCase(algorithm)) {
+            // 1. SM2密钥生成(使用 BouncyCastle 提供者)
+            generator = KeyPairGenerator.getInstance("EC", "BC");
+            // SM2 算法使用特定椭圆曲线参数: sm2p256v1
+            ECGenParameterSpec sm2Spec = new ECGenParameterSpec("sm2p256v1");
+            generator.initialize(sm2Spec, new SecureRandom());
+
+        } else { // 默认为 RSA
+            // 2. RSA 密钥生成(使用 BouncyCastle 提供者)
+            generator = KeyPairGenerator.getInstance("RSA", "BC");
+            int strength = (keySize != null && keySize >= 2048) ? keySize : 2048;
+            generator.initialize(strength, new SecureRandom());
+        }
+
+        return generator.generateKeyPair();
+    }
+    /**
+     * 构建主题DN
+     */
+    public static String buildSubjectDn(IssueRequest request) {
+        StringBuilder dn = new StringBuilder();
+        dn.append("CN=").append(request.getCommonName());
+        if (request.getOrganization() != null && !request.getOrganization().isEmpty()) {
+            dn.append(", O=").append(request.getOrganization());
+        }
+        if (request.getOrganizationalUnit() != null && !request.getOrganizationalUnit().isEmpty()) {
+            dn.append(", OU=").append(request.getOrganizationalUnit());
+        }
+        if (request.getLocality() != null && !request.getLocality().isEmpty()) {
+            dn.append(", L=").append(request.getLocality());
+        }
+        if (request.getState() != null && !request.getState().isEmpty()) {
+            dn.append(", ST=").append(request.getState());
+        }
+        if (request.getCountry() != null && !request.getCountry().isEmpty()) {
+            dn.append(", C=").append(request.getCountry());
+        }
+        return dn.toString();
+    }
+}

+ 29 - 0
src/main/java/com/ymwk/supervisionserver/service/ApplicationCredentialsService.java

@@ -0,0 +1,29 @@
+package com.ymwk.supervisionserver.service;
+
+import com.ymwk.supervisionserver.component.PageResult;
+import com.ymwk.supervisionserver.component.ResponseDTO;
+import com.ymwk.supervisionserver.entity.ApplicationCredentials;
+import com.ymwk.supervisionserver.model.CredentialsAddRequest;
+import com.ymwk.supervisionserver.model.CredentialsListRequest;
+import com.ymwk.supervisionserver.model.CredentialsUpdateRequest;
+
+public interface ApplicationCredentialsService {
+
+    ResponseDTO<PageResult<ApplicationCredentials>> list(CredentialsListRequest request);
+
+    ResponseDTO<ApplicationCredentials> create(CredentialsAddRequest request);
+
+    ResponseDTO<Void> delete(Long id);
+
+    /** 编辑唯一提供的操作:重新随机生成 api_secret */
+    ResponseDTO<ApplicationCredentials> resetSecret(Long id);
+
+    /** 修改应用名称/描述(标识与密钥不受影响) */
+    ResponseDTO<ApplicationCredentials> update(CredentialsUpdateRequest request);
+
+    /** 明文查询 api_secret(列表接口中该字段是脱敏的) */
+    ResponseDTO<String> getSecret(Long id);
+
+    /** 启用/禁用:1-启用,2-禁用 */
+    ResponseDTO<ApplicationCredentials> changeStatus(Long id, Integer status);
+}

+ 33 - 0
src/main/java/com/ymwk/supervisionserver/service/EvidenceService.java

@@ -0,0 +1,33 @@
+package com.ymwk.supervisionserver.service;
+
+import com.ymwk.supervisionserver.component.PageResult;
+import com.ymwk.supervisionserver.component.ResponseDTO;
+import com.ymwk.supervisionserver.entity.Evidence;
+import com.ymwk.supervisionserver.model.EvidenceByIdenRequest;
+import com.ymwk.supervisionserver.model.EvidenceListRequest;
+import com.ymwk.supervisionserver.model.EvidenceTextAddRequest;
+import com.ymwk.supervisionserver.model.EvidenceTextVerifyRequest;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.Map;
+
+public interface EvidenceService {
+
+    /** 文本存证签发:返回 {evidenceIden, hmac, url} */
+    ResponseDTO<Map<String, String>> textEvidenceAdd(EvidenceTextAddRequest request);
+
+    /** 文件存证签发:文件上传 MinIO,返回 {evidenceIden, hmac, url} */
+    ResponseDTO<Map<String, String>> fileEvidenceAdd(MultipartFile file, String credIden);
+
+    /** 文本存证核验:返回 {hashEqual, hmacEqual, signEqual} */
+    ResponseDTO<Map<String, Boolean>> textEvidenceVerify(EvidenceTextVerifyRequest request);
+
+    /** 文件存证核验:返回 {hashEqual, hmacEqual, signEqual} */
+    ResponseDTO<Map<String, Boolean>> fileEvidenceVerify(MultipartFile file, String evidenceIden, String hmac);
+
+    /** 存证分页列表 */
+    ResponseDTO<PageResult<Evidence>> list(EvidenceListRequest request);
+
+    /** 按存证标识查询凭证数据(签名/HMAC/摘要/时间戳/原文) */
+    ResponseDTO<Map<String, Object>> getEvidenceDataByIden(EvidenceByIdenRequest request);
+}

+ 220 - 0
src/main/java/com/ymwk/supervisionserver/service/impl/ApplicationCredentialsServiceImpl.java

@@ -0,0 +1,220 @@
+package com.ymwk.supervisionserver.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ymwk.supervisionserver.component.PageResult;
+import com.ymwk.supervisionserver.component.ResponseDTO;
+import com.ymwk.supervisionserver.component.SmartPageUtil;
+import com.ymwk.supervisionserver.entity.ApplicationCredentials;
+import com.ymwk.supervisionserver.mapper.ApplicationCredentialsMapper;
+import com.ymwk.supervisionserver.model.CredentialsAddRequest;
+import com.ymwk.supervisionserver.model.CredentialsListRequest;
+import com.ymwk.supervisionserver.model.CredentialsUpdateRequest;
+import com.ymwk.supervisionserver.service.ApplicationCredentialsService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+import java.util.UUID;
+
+@Service
+@Slf4j
+public class ApplicationCredentialsServiceImpl implements ApplicationCredentialsService {
+
+    private static final String DEFAULT_OPERATOR = "admin";
+
+    private final ApplicationCredentialsMapper credentialsMapper;
+
+    public ApplicationCredentialsServiceImpl(ApplicationCredentialsMapper credentialsMapper) {
+        this.credentialsMapper = credentialsMapper;
+    }
+
+    @Override
+    public ResponseDTO<PageResult<ApplicationCredentials>> list(CredentialsListRequest request) {
+        LambdaQueryWrapper<ApplicationCredentials> wrapper = new LambdaQueryWrapper<>();
+        wrapper.like(StringUtils.hasText(request.getName()), ApplicationCredentials::getName, request.getName());
+        wrapper.like(StringUtils.hasText(request.getCredIden()), ApplicationCredentials::getCredIden, request.getCredIden());
+        wrapper.eq(request.getStatus() != null, ApplicationCredentials::getStatus, request.getStatus());
+        wrapper.orderByDesc(ApplicationCredentials::getId);
+
+        Page<ApplicationCredentials> page = new Page<>(request.getPageNum(), request.getPageSize());
+        Page<ApplicationCredentials> resultPage = credentialsMapper.selectPage(page, wrapper);
+
+        // api_secret 脱敏:保留前4后4,中间以 * 代替
+        List<ApplicationCredentials> records = resultPage.getRecords();
+        records.forEach(record -> record.setApiSecret(maskSecret(record.getApiSecret())));
+
+        PageResult<ApplicationCredentials> pageResult = SmartPageUtil.convert2PageResult(resultPage, records);
+        return ResponseDTO.ok(pageResult);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public ResponseDTO<ApplicationCredentials> create(CredentialsAddRequest request) {
+        String name = request.getName().trim();
+
+        // name 有唯一索引,先查重给出友好提示
+        Long dupCount = credentialsMapper.selectCount(
+                new LambdaQueryWrapper<ApplicationCredentials>().eq(ApplicationCredentials::getName, name));
+        if (dupCount != null && dupCount > 0) {
+            return ResponseDTO.error(100001, "应用名称已存在:" + name);
+        }
+
+        ApplicationCredentials entity = new ApplicationCredentials();
+        entity.setCredIden(uuid32());
+        entity.setName(name);
+        entity.setApiKey(uuid32());
+        entity.setApiSecret(uuid32());
+        entity.setHmacAlgo(ApplicationCredentials.HMAC_ALGO);
+        entity.setStatus(ApplicationCredentials.STATUS_ENABLED);
+        entity.setDescription(request.getDescription());
+        entity.setCreatedBy(DEFAULT_OPERATOR);
+        entity.setUpdatedBy(DEFAULT_OPERATOR);
+
+        try {
+            credentialsMapper.insert(entity);
+        } catch (DuplicateKeyException e) {
+            // 并发下撞唯一索引(credIden/apiKey 理论上不可能重复,name 则已在前面查过)
+            log.warn("创建应用凭证撞唯一索引: name={}", name);
+            return ResponseDTO.error(100001, "应用名称已存在:" + name);
+        }
+
+        log.info("应用凭证创建成功: id={}, name={}, credIden={}", entity.getId(), entity.getName(), entity.getCredIden());
+        // 返回内容中秘密同样脱敏,需要明文走专用查询接口
+        entity.setApiSecret(maskSecret(entity.getApiSecret()));
+        return ResponseDTO.ok(entity);
+    }
+
+    @Override
+    public ResponseDTO<Void> delete(Long id) {
+        if (id == null) {
+            return ResponseDTO.error(100001, "凭证ID不能为空");
+        }
+        ApplicationCredentials entity = credentialsMapper.selectById(id);
+        if (entity == null) {
+            return ResponseDTO.error(100001, "凭证不存在");
+        }
+        credentialsMapper.deleteById(id);
+        log.info("应用凭证删除成功: id={}, name={}", id, entity.getName());
+        return ResponseDTO.okMsg("删除成功");
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public ResponseDTO<ApplicationCredentials> update(CredentialsUpdateRequest request) {
+        if (request == null || request.getId() == null) {
+            return ResponseDTO.error(100001, "凭证ID不能为空");
+        }
+        ApplicationCredentials entity = credentialsMapper.selectById(request.getId());
+        if (entity == null) {
+            return ResponseDTO.error(100001, "凭证不存在");
+        }
+
+        String name = request.getName().trim();
+
+        // name 有唯一索引,查重时排除自己
+        Long dupCount = credentialsMapper.selectCount(
+                new LambdaQueryWrapper<ApplicationCredentials>()
+                        .eq(ApplicationCredentials::getName, name)
+                        .ne(ApplicationCredentials::getId, request.getId()));
+        if (dupCount != null && dupCount > 0) {
+            return ResponseDTO.error(100001, "应用名称已存在:" + name);
+        }
+
+        entity.setName(name);
+        entity.setDescription(request.getDescription());
+        entity.setUpdatedBy(DEFAULT_OPERATOR);
+
+        try {
+            credentialsMapper.updateById(entity);
+        } catch (DuplicateKeyException e) {
+            log.warn("修改应用凭证撞唯一索引: id={}, name={}", request.getId(), name);
+            return ResponseDTO.error(100001, "应用名称已存在:" + name);
+        }
+
+        log.info("应用凭证修改成功: id={}, name={}", entity.getId(), name);
+        entity.setApiSecret(maskSecret(entity.getApiSecret()));
+        return ResponseDTO.ok(entity);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public ResponseDTO<ApplicationCredentials> resetSecret(Long id) {
+        if (id == null) {
+            return ResponseDTO.error(100001, "凭证ID不能为空");
+        }
+        ApplicationCredentials entity = credentialsMapper.selectById(id);
+        if (entity == null) {
+            return ResponseDTO.error(100001, "凭证不存在");
+        }
+
+        entity.setApiSecret(uuid32());
+        entity.setUpdatedBy(DEFAULT_OPERATOR);
+        credentialsMapper.updateById(entity);
+
+        log.info("应用凭证密钥已重新生成: id={}, name={}", id, entity.getName());
+        entity.setApiSecret(maskSecret(entity.getApiSecret()));
+        return ResponseDTO.ok(entity, "api_secret 已重新生成,请通过专用查询接口获取明文");
+    }
+
+    @Override
+    public ResponseDTO<String> getSecret(Long id) {
+        if (id == null) {
+            return ResponseDTO.error(100001, "凭证ID不能为空");
+        }
+        ApplicationCredentials entity = credentialsMapper.selectById(id);
+        if (entity == null) {
+            return ResponseDTO.error(100001, "凭证不存在");
+        }
+        return ResponseDTO.ok(entity.getApiSecret());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public ResponseDTO<ApplicationCredentials> changeStatus(Long id, Integer status) {
+        if (id == null) {
+            return ResponseDTO.error(100001, "凭证ID不能为空");
+        }
+        if (!ApplicationCredentials.STATUS_ENABLED.equals(status)
+                && !ApplicationCredentials.STATUS_DISABLED.equals(status)) {
+            return ResponseDTO.error(100001, "状态值只支持 1(启用)或 2(禁用)");
+        }
+        ApplicationCredentials entity = credentialsMapper.selectById(id);
+        if (entity == null) {
+            return ResponseDTO.error(100001, "凭证不存在");
+        }
+        if (!status.equals(entity.getStatus())) {
+            entity.setStatus(status);
+            entity.setUpdatedBy(DEFAULT_OPERATOR);
+            credentialsMapper.updateById(entity);
+        }
+        entity.setApiSecret(maskSecret(entity.getApiSecret()));
+        return ResponseDTO.ok(entity);
+    }
+
+    /**
+     * api_secret 脱敏:保留前4后4,中间替换为 *
+     */
+    private String maskSecret(String secret) {
+        if (secret == null) {
+            return null;
+        }
+        int length = secret.length();
+        if (length <= 8) {
+            return "*".repeat(length);
+        }
+        return secret.substring(0, 4)
+                + "*".repeat(length - 8)
+                + secret.substring(length - 4);
+    }
+
+    /**
+     * 生成32位无连字符大写UUID
+     */
+    private String uuid32() {
+        return UUID.randomUUID().toString().replace("-", "").toUpperCase();
+    }
+}

+ 362 - 0
src/main/java/com/ymwk/supervisionserver/service/impl/EvidenceServiceImpl.java

@@ -0,0 +1,362 @@
+package com.ymwk.supervisionserver.service.impl;
+
+import cn.hutool.core.util.HexUtil;
+import cn.hutool.crypto.SmUtil;
+import cn.hutool.crypto.asymmetric.SM2;
+import cn.hutool.crypto.digest.HMac;
+import cn.hutool.crypto.digest.HmacAlgorithm;
+import cn.hutool.crypto.digest.SM3;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ymwk.supervisionserver.component.PageResult;
+import com.ymwk.supervisionserver.component.ResponseDTO;
+import com.ymwk.supervisionserver.component.SmartPageUtil;
+import com.ymwk.supervisionserver.entity.ApplicationCredentials;
+import com.ymwk.supervisionserver.entity.Evidence;
+import com.ymwk.supervisionserver.mapper.ApplicationCredentialsMapper;
+import com.ymwk.supervisionserver.mapper.EvidenceMapper;
+import com.ymwk.supervisionserver.model.EvidenceByIdenRequest;
+import com.ymwk.supervisionserver.model.EvidenceListRequest;
+import com.ymwk.supervisionserver.model.EvidenceTextAddRequest;
+import com.ymwk.supervisionserver.model.EvidenceTextVerifyRequest;
+import com.ymwk.supervisionserver.service.EvidenceService;
+import com.ymwk.supervisionserver.utils.MinioClientService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+import tools.jackson.databind.ObjectMapper;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 存证服务:原文/文件 + 证据JSON(标识+摘要+时间戳)的 SM2 签名 + SM3 摘要 + 防伪 HMAC
+ * 密码机操作全部以 jar 包软实现(hutool + BouncyCastle,SM2/SM3 国密)
+ */
+@Service
+@Slf4j
+public class EvidenceServiceImpl implements EvidenceService {
+
+    private static final DateTimeFormatter TS_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    private final EvidenceMapper evidenceMapper;
+    private final ApplicationCredentialsMapper credentialsMapper;
+    private final MinioClientService minioUtil;
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /** 签名密钥(yaml 配置固定,hex 编码的 PKCS#8/X509 编码) */
+    @Value("${evidence.sign-private-key:}")
+    private String signPrivateKeyHex;
+    @Value("${evidence.sign-public-key:}")
+    private String signPublicKeyHex;
+    /** credIden 缺省/查不到凭证时使用的默认 HMAC 密钥 */
+    @Value("${evidence.hmac-key:}")
+    private String defaultHmacKey;
+
+    private volatile SM2 sm2;
+    private volatile SM3 sm3;
+
+    public EvidenceServiceImpl(EvidenceMapper evidenceMapper,
+                               ApplicationCredentialsMapper credentialsMapper,
+                               MinioClientService minioUtil) {
+        this.evidenceMapper = evidenceMapper;
+        this.credentialsMapper = credentialsMapper;
+        this.minioUtil = minioUtil;
+    }
+
+    // ==================================================== 签发 ====================================================
+
+    @Override
+    public ResponseDTO<Map<String, String>> textEvidenceAdd(EvidenceTextAddRequest request) {
+        if (request == null || !StringUtils.hasText(request.getText())) {
+            return ResponseDTO.error(100001, "存证文本不能为空");
+        }
+        return doAdd(request.getCredIden(), request.getText(), Evidence.TYPE_TEXT, null);
+    }
+
+    @Override
+    public ResponseDTO<Map<String, String>> fileEvidenceAdd(MultipartFile file, String credIden) {
+        if (file == null || file.isEmpty()) {
+            return ResponseDTO.error(100001, "存证文件不能为空");
+        }
+        try {
+            byte[] fileBytes = file.getBytes();
+            // ContentHash 位置放文件的十六进制摘要内容(与文本存证同构构造)
+            String contentHash = HexUtil.encodeHexStr(fileBytes);
+            return doAdd(credIden, contentHash, Evidence.TYPE_FILE, fileBytes);
+        } catch (Exception e) {
+            log.error("文件存证签发失败(读取上传文件异常)", e);
+            return ResponseDTO.error(100001, "读取上传文件失败:" + e.getMessage());
+        }
+    }
+
+    private ResponseDTO<Map<String, String>> doAdd(String credIden, String contentHash,
+                                                   Integer type, byte[] fileBytes) {
+        try {
+            String evidenceIden = UUID.randomUUID().toString().replace("-", "");
+
+            // 1. HMAC 密钥:传了 credIden 且凭证表里存在 → 用该应用的 api_secret,否则用默认密钥
+            String hmacKey = resolveHmacKey(credIden);
+
+            // 2. 构造证据结构并序列化:{EvidenceID, ContentHash, Timestamp}
+            String timestamp = TS_FORMAT.format(LocalDateTime.now());
+            Map<String, Object> evidenceStructure = buildEvidenceStructure(evidenceIden, contentHash, timestamp);
+            byte[] evidenceBytes = objectMapper.writeValueAsBytes(evidenceStructure);
+
+            // 3. 证据JSON的SM3摘要
+            String digestHex = getSm3().digestHex(evidenceBytes);
+
+            // 4. SM2签名(软实现,DER 格式)
+            String signatureHex = HexUtil.encodeHexStr(getSm2().sign(evidenceBytes));
+
+            // 5. 防伪凭证:SM3-HMAC(密钥, 签名)
+            String evidenceHmac = hexHmac(hmacKey, signatureHex.getBytes(StandardCharsets.UTF_8));
+
+            // 6. 文件存证先上传 MinIO
+            String fileUrl = null;
+            if (Evidence.TYPE_FILE.equals(type) && fileBytes != null) {
+                String objectName = "evidence/" + evidenceIden;
+                minioUtil.uploadBytes(objectName, fileBytes, "application/octet-stream");
+                fileUrl = minioUtil.getPublicUrl(objectName);
+            }
+
+            // 7. 入库
+            Evidence entity = new Evidence();
+            entity.setEvidenceIden(evidenceIden);
+            entity.setCredIden(StringUtils.hasText(credIden) ? credIden : null);
+            entity.setType(type);
+            entity.setStatus(Evidence.STATUS_VALID);
+            entity.setSignature(signatureHex);
+            entity.setHashText(digestHex);
+            if (Evidence.TYPE_TEXT.equals(type)) {
+                entity.setOriginalText(contentHash);
+            } else {
+                entity.setOriginalFileKey(fileUrl);
+            }
+            entity.setHmac(evidenceHmac);
+            entity.setEvidenceData(objectMapper.writeValueAsString(evidenceStructure));
+            evidenceMapper.insert(entity);
+
+            log.info("存证签发成功: iden={}, type={}, credIden={}", evidenceIden, type, credIden);
+
+            Map<String, String> result = new LinkedHashMap<>();
+            result.put("evidenceIden", evidenceIden);
+            result.put("hmac", evidenceHmac);
+            result.put("url", fileUrl);
+            return ResponseDTO.ok(result);
+        } catch (Exception e) {
+            log.error("存证签发失败: type={}", type, e);
+            return ResponseDTO.error(100001, "存证签发失败:" + e.getMessage());
+        }
+    }
+
+    // ==================================================== 核验 ====================================================
+
+    @Override
+    public ResponseDTO<Map<String, Boolean>> textEvidenceVerify(EvidenceTextVerifyRequest request) {
+        if (request == null || !StringUtils.hasText(request.getEvidenceIden())) {
+            return ResponseDTO.error(100001, "存证标识不能为空");
+        }
+        return runVerify(request.getEvidenceIden(), Evidence.TYPE_TEXT, request.getText(), request.getHmac());
+    }
+
+    @Override
+    public ResponseDTO<Map<String, Boolean>> fileEvidenceVerify(MultipartFile file, String evidenceIden, String hmac) {
+        if (file == null || file.isEmpty()) {
+            return ResponseDTO.error(100001, "存证文件不能为空");
+        }
+        if (!StringUtils.hasText(evidenceIden)) {
+            return ResponseDTO.error(100001, "存证标识不能为空");
+        }
+        try {
+            String contentHash = HexUtil.encodeHexStr(file.getBytes());
+            return runVerify(evidenceIden, Evidence.TYPE_FILE, contentHash, hmac);
+        } catch (Exception e) {
+            log.error("文件存证核验失败(读取上传文件异常): evidenceIden={}", evidenceIden, e);
+            return ResponseDTO.error(100001, "读取上传文件失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 三层核验:
+     * HashEqual —— 内容没被改过(重算 SM3 与库内一致)
+     * HmacEqual —— 返回给调用方的凭证没被改(库存 HMAC 与传入一致)
+     * SignEqual —— 签名确实是本服务密钥所签(SM2 验签)
+     */
+    private ResponseDTO<Map<String, Boolean>> runVerify(String evidenceIden, Integer expectedType,
+                                                        String contentHash, String hmac) {
+        Evidence evidence = evidenceMapper.selectOne(
+                new LambdaQueryWrapper<Evidence>().eq(Evidence::getEvidenceIden, evidenceIden));
+        if (evidence == null) {
+            return ResponseDTO.error(100001, "存证记录不存在:" + evidenceIden);
+        }
+        if (!expectedType.equals(evidence.getType())) {
+            return ResponseDTO.error(100001, "存证类型不符,请使用对应的核验接口");
+        }
+
+        try {
+            // 用库存 evidenceData 恢复原时间戳,按签发时完全一致的结构重建证据JSON
+            Map<String, Object> rebuilt = rebuildEvidenceStructure(
+                    evidence.getEvidenceIden(), contentHash, evidence.getEvidenceData());
+            byte[] evidenceBytes = objectMapper.writeValueAsBytes(rebuilt);
+            String digestHex = getSm3().digestHex(evidenceBytes);
+
+            boolean hashEqual = digestHex.equalsIgnoreCase(evidence.getHashText());
+            boolean hmacEqual = hmac != null && hmac.equalsIgnoreCase(evidence.getHmac());
+            boolean signEqual;
+            try {
+                signEqual = getSm2().verify(evidenceBytes, HexUtil.decodeHex(evidence.getSignature()));
+            } catch (Exception e) {
+                log.warn("签名验签异常: evidenceIden={}", evidenceIden, e);
+                signEqual = false;
+            }
+
+            Map<String, Boolean> result = new LinkedHashMap<>();
+            result.put("hashEqual", hashEqual);
+            result.put("hmacEqual", hmacEqual);
+            result.put("signEqual", signEqual);
+            return ResponseDTO.ok(result);
+        } catch (Exception e) {
+            log.error("存证核验异常: evidenceIden={}", evidenceIden, e);
+            return ResponseDTO.error(100001, "存证核验失败:" + e.getMessage());
+        }
+    }
+
+    // ==================================================== 查询 ====================================================
+
+    @Override
+    public ResponseDTO<PageResult<Evidence>> list(EvidenceListRequest request) {
+        LambdaQueryWrapper<Evidence> wrapper = new LambdaQueryWrapper<>();
+        wrapper.like(StringUtils.hasText(request.getCredIden()), Evidence::getCredIden, request.getCredIden());
+        wrapper.like(StringUtils.hasText(request.getEvidenceIden()), Evidence::getEvidenceIden, request.getEvidenceIden());
+        wrapper.eq(request.getType() != null, Evidence::getType, request.getType());
+        wrapper.eq(request.getStatus() != null, Evidence::getStatus, request.getStatus());
+        wrapper.orderByDesc(Evidence::getId);
+
+        Page<Evidence> page = new Page<>(request.getPageNum(), request.getPageSize());
+        Page<Evidence> resultPage = evidenceMapper.selectPage(page, wrapper);
+        List<Evidence> records = resultPage.getRecords();
+        // 列表不带正文/文件URL,正文通过 getEvidenceDataByIden 获取,列表更轻
+        records.forEach(record -> {
+            record.setOriginalText(null);
+            record.setOriginalFileKey(null);
+        });
+        PageResult<Evidence> pageResult = SmartPageUtil.convert2PageResult(resultPage, records);
+        return ResponseDTO.ok(pageResult);
+    }
+
+    @Override
+    public ResponseDTO<Map<String, Object>> getEvidenceDataByIden(EvidenceByIdenRequest request) {
+        if (request == null || !StringUtils.hasText(request.getEvidenceIden())) {
+            return ResponseDTO.error(100001, "存证标识不能为空");
+        }
+        Evidence evidence = evidenceMapper.selectOne(
+                new LambdaQueryWrapper<Evidence>().eq(Evidence::getEvidenceIden, request.getEvidenceIden()));
+        if (evidence == null) {
+            return ResponseDTO.error(100001, "存证记录不存在:" + request.getEvidenceIden());
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("evidenceIden", evidence.getEvidenceIden());
+        result.put("credIden", evidence.getCredIden());
+        result.put("type", evidence.getType());
+        result.put("status", evidence.getStatus());
+        result.put("signature", evidence.getSignature());
+        result.put("hmac", evidence.getHmac());
+        result.put("hashText", evidence.getHashText());
+        result.put("evidenceData", evidence.getEvidenceData());
+        if (Evidence.TYPE_TEXT.equals(evidence.getType())) {
+            result.put("originalText", evidence.getOriginalText());
+        } else {
+            result.put("url", evidence.getOriginalFileKey());
+        }
+        try {
+            // 便于前端直接展示签发时间
+            result.put("timestamp", objectMapper.readTree(evidence.getEvidenceData()).get("timestamp").asText());
+        } catch (Exception ignore) {
+            // evidenceData 异常不影响凭证数据返回
+        }
+        return ResponseDTO.ok(result);
+    }
+
+    // ==================================================== 内部工具 ====================================================
+
+    /** 证据结构:签发与核验必须使用同一构造方式(确定性顺序) */
+    private Map<String, Object> buildEvidenceStructure(String evidenceIden, String contentHash, String timestamp) {
+        Map<String, Object> structure = new LinkedHashMap<>();
+        structure.put("EvidenceID", evidenceIden);
+        structure.put("ContentHash", contentHash);
+        structure.put("Timestamp", timestamp);
+        return structure;
+    }
+
+    /** 核验时从库存 evidenceData 恢复原构造内容(主要是恢复时间戳) */
+    private Map<String, Object> rebuildEvidenceStructure(String evidenceIden, String contentHash, String evidenceData)
+            throws Exception {
+        String timestamp = "";
+        try {
+            timestamp = objectMapper.readTree(evidenceData).get("Timestamp").asText("");
+        } catch (Exception e) {
+            log.warn("恢复存证时间戳失败, 使用空时间戳重建: evidenceIden={}", evidenceIden);
+        }
+        return buildEvidenceStructure(evidenceIden, contentHash, timestamp);
+    }
+
+    /** resolve: HMAC 密钥——credIden 对应的应用存在则用其 api_secret,否则用默认密钥 */
+    private String resolveHmacKey(String credIden) {
+        if (StringUtils.hasText(credIden)) {
+            ApplicationCredentials credentials = credentialsMapper.selectOne(
+                    new LambdaQueryWrapper<ApplicationCredentials>()
+                            .eq(ApplicationCredentials::getCredIden, credIden));
+            if (credentials != null && StringUtils.hasText(credentials.getApiSecret())) {
+                return credentials.getApiSecret();
+            }
+            log.warn("credIden 未找到对应应用凭证,使用默认 HMAC 密钥: credIden={}", credIden);
+        }
+        return defaultHmacKey;
+    }
+
+    private String hexHmac(String hmacKey, byte[] data) {
+        HMac hmac = new HMac(HmacAlgorithm.HmacSM3, hmacKey.getBytes(StandardCharsets.UTF_8));
+        return HexUtil.encodeHexStr(hmac.digest(data));
+    }
+
+    private SM2 getSm2() {        SM2 local = sm2;
+        if (local == null) {
+            synchronized (this) {
+                local = sm2;
+                if (local == null) {
+                    if (StringUtils.hasText(signPrivateKeyHex) && StringUtils.hasText(signPublicKeyHex)) {
+                        local = SmUtil.sm2(signPrivateKeyHex, signPublicKeyHex);
+                    } else {
+                        log.warn("evidence.sign-private-key/sign-public-key 未配置,使用临时生成的 SM2 密钥对(重启后旧签名将无法验签)");
+                        local = SmUtil.sm2();
+                    }
+                    sm2 = local;
+                }
+            }
+        }
+        return local;
+    }
+
+    private SM3 getSm3() {
+        SM3 local = sm3;
+        if (local == null) {
+            synchronized (this) {
+                local = sm3;
+                if (local == null) {
+                    local = SmUtil.sm3();
+                    sm3 = local;
+                }
+            }
+        }
+        return local;
+    }
+}

+ 269 - 0
src/main/java/com/ymwk/supervisionserver/utils/MinioClientService.java

@@ -0,0 +1,269 @@
+package com.ymwk.supervisionserver.utils;
+
+
+import io.minio.*;
+import io.minio.http.Method;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+@Component
+@Slf4j
+public class MinioClientService {
+
+    @Value("${minio.endpoint}")
+    public String endpoint;
+
+    @Value("${minio.access-key}")
+    private String accessKey;
+
+    @Value("${minio.secret-key}")
+    private String secretKey;
+
+    @Value("${minio.bucket-name}")
+    private String bucketName;
+
+    private MinioClient minioClient;
+
+    @PostConstruct
+    public void init() {
+        try {
+            minioClient = MinioClient.builder()
+                    .endpoint(endpoint)
+                    .credentials(accessKey, secretKey)
+                    .build();
+
+            // 确保桶存在
+            boolean isExist = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
+            if (!isExist) {
+                minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
+            }
+        } catch (Exception e) {
+            throw new RuntimeException("MinIO client initialization failed", e);
+        }
+    }
+
+    /**
+     * 上传文件到 MinIO
+     * @param file 上传的文件
+     * @return 生成的文件名(用于后续下载)
+     */
+    public String uploadFile(MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            throw new IllegalArgumentException("File cannot be null or empty");
+        }
+
+        String fileName = generateUniqueFileName(file.getOriginalFilename());
+
+        try (InputStream inputStream = file.getInputStream()) {
+            PutObjectArgs args = PutObjectArgs.builder()
+                    .bucket(bucketName)
+                    .object(fileName)
+                    .stream(inputStream, file.getSize(), -1)
+                    .contentType(file.getContentType())
+                    .build();
+
+            minioClient.putObject(args);
+            return fileName;
+
+        } catch (Exception e) {
+            throw new RuntimeException("Error uploading file: " + e.getMessage(), e);
+        }
+    }
+    /**
+     * 上传证书文件(PEM格式)
+     * @param serialNumber 证书序列号(用作文件名)
+     * @param content 证书内容
+     * @param fileType 文件类型(cert / private-key / chain)
+     * @return 文件路径
+     */
+    public String uploadCertificate(String serialNumber, String content, String fileType) throws Exception {
+
+
+
+        // 构建文件路径: certs/{serialNumber}/cert.pem
+        String objectName = String.format("certs/%s/%s.pem", serialNumber, fileType);
+
+        // 转换为字节流
+        byte[] contentBytes = content.getBytes(java.nio.charset.StandardCharsets.UTF_8);
+        try (ByteArrayInputStream inputStream = new ByteArrayInputStream(contentBytes)) {
+
+            // 设置文件元数据
+            Map<String, String> userMetadata = new HashMap<>();
+            userMetadata.put("serial-number", serialNumber);
+            userMetadata.put("file-type", fileType);
+            userMetadata.put("upload-time", LocalDateTime.now().toString());
+
+            // 上传文件
+            minioClient.putObject(
+                    PutObjectArgs.builder()
+                            .bucket(bucketName)
+                            .object(objectName)
+                            .stream(inputStream, contentBytes.length, -1)
+                            .contentType("application/x-pem-file")
+                            .userMetadata(userMetadata)
+                            .build()
+            );
+
+            log.info("证书文件上传成功: {}", objectName);
+            return objectName;
+        }
+    }
+    public void uploadObject(String objectName, String content, String contentType) throws Exception {
+        byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
+        try (ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes)) {
+            minioClient.putObject(
+                    PutObjectArgs.builder()
+                            .bucket(bucketName)
+                            .object(objectName)
+                            .stream(inputStream, bytes.length, -1)
+                            .contentType(contentType)
+                            .build()
+            );
+        }
+    }
+    /**
+     * 下载文件(返回输入流,用于Web下载)
+     * @param fileName 文件名
+     * @return 文件输入流
+     */
+    public InputStream downloadFile(String fileName) {
+        if (fileName == null || fileName.trim().isEmpty()) {
+            throw new IllegalArgumentException("File name cannot be null or empty");
+        }
+
+        try {
+            GetObjectArgs args = GetObjectArgs.builder()
+                    .bucket(bucketName)
+                    .object(fileName)
+                    .build();
+
+            return minioClient.getObject(args);
+        } catch (Exception e) {
+            throw new RuntimeException("Error downloading file: " + e.getMessage(), e);
+        }
+    }
+    /**
+     * 获取文件输入流(用于压缩包下载)
+     * @param fileKey 文件在 MinIO 中的 key
+     * @return 文件输入流
+     */
+    public GetObjectResponse getObject(String fileKey) throws Exception {
+        return minioClient.getObject(
+                GetObjectArgs.builder()
+                        .bucket(bucketName)
+                        .object(fileKey)
+                        .build()
+        );
+    }
+    /**
+     * 获取文件访问URL(带临时签名)
+     */
+    public String getPresignedUrl(String objectName, int expiryMinutes) throws Exception {
+        return minioClient.getPresignedObjectUrl(
+                GetPresignedObjectUrlArgs.builder()
+                        .bucket(bucketName)
+                        .object(objectName)
+                        .method(Method.GET)
+                        .expiry(expiryMinutes, TimeUnit.MINUTES)
+                        .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配置了公开访问)
+     */
+    public String getPublicUrl(String objectName) {
+        return String.format("%s/%s/%s",
+                endpoint,
+                bucketName,
+                objectName
+        );
+    }
+    /**
+     * 删除证书文件
+     */
+    public void deleteFile(String objectName) throws Exception {
+        minioClient.removeObject(
+                RemoveObjectArgs.builder()
+                        .bucket(bucketName)
+                        .object(objectName)
+                        .build()
+        );
+    }
+    /**
+     * 下载文件为字符串
+     */
+    public String downloadFileAsString(String objectName) throws Exception {
+        try (InputStream inputStream = minioClient.getObject(
+                GetObjectArgs.builder()
+                        .bucket(bucketName)
+                        .object(objectName)
+                        .build()
+        )) {
+            byte[] content = inputStream.readAllBytes();
+            return new String(content, StandardCharsets.UTF_8);
+        }
+    }
+
+    /**
+     * 生成唯一文件名
+     */
+    private String generateUniqueFileName(String originalFileName) {
+        String extension = "";
+        if (originalFileName != null && originalFileName.contains(".")) {
+            extension = originalFileName.substring(originalFileName.lastIndexOf("."));
+        }
+        return UUID.randomUUID() + extension;
+    }
+
+    /**
+     * 上传字节数组到 MinIO
+     * @param objectName 对象名
+     * @param bytes 字节数据
+     * @param contentType MIME 类型
+     */
+    public void uploadBytes(String objectName, byte[] bytes, String contentType) throws Exception {
+        try (ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes)) {
+            minioClient.putObject(
+                    PutObjectArgs.builder()
+                            .bucket(bucketName)
+                            .object(objectName)
+                            .stream(inputStream, bytes.length, -1)
+                            .contentType(contentType)
+                            .build()
+            );
+        }
+    }
+}

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

@@ -25,6 +25,18 @@ minio:
   access-key: minio_bxM8k5
   secret-key: minio_mP6zC4
 
+# OCSP 响应服务对外地址(签发证书时写入 AIA 扩展,留空则不写入该扩展)
+# 例: ocsp.responder-url: http://10.10.10.30:9055/api/v1/certificate/ocsp
+ocsp:
+  responder-url: http://10.10.10.30:9055/api/v1/certificate/ocsp
+
+# 存证功能:SM2 签名密钥对(hex,PKCS#8/X509 编码)与默认 HMAC 密钥
+# credIden 能匹配到 t_application_credentials 时,HMAC 密钥改用该应用的 api_secret
+evidence:
+  sign-private-key: "308193020100301306072a8648ce3d020106082a811ccf5501822d047930770201010420848f0903b2b23f3b12ae42b68aa6b0df158bc70e11a5f29bedd7b15ad370ada7a00a06082a811ccf5501822da144034200048985158e11da9518d38c8c5a94bfaca150c392b43c66b1fc7524fd153efdcff7d2d56d84555e8b27af0a82123b549b97d9af979188221d243fdc388f88c430d8"
+  sign-public-key: "3059301306072a8648ce3d020106082a811ccf5501822d034200048985158e11da9518d38c8c5a94bfaca150c392b43c66b1fc7524fd153efdcff7d2d56d84555e8b27af0a82123b549b97d9af979188221d243fdc388f88c430d8"
+  hmac-key: "supervision-evidence-default-hmac-key"
+
 # 2. MyBatis-Plus 专属配置
 mybatis-plus:
   # 指定 XML 映射文件的位置 (用于自定义 SQL)