Browse Source

Merge branch 'fyc-vpp' of uskycloud/usky-modules into feature/service-vpp-20260701

fuyuchuan 1 ngày trước cách đây
mục cha
commit
36b05eb571
18 tập tin đã thay đổi với 1451 bổ sung92 xóa
  1. 8 7
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ArchiveController.java
  2. 119 21
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ContractController.java
  3. 42 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContract.java
  4. 1 34
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContractAuditLog.java
  5. 16 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContractTemplate.java
  6. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppContractTemplateMapper.java
  7. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppFileArchiveMapper.java
  8. 1 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppArchiveService.java
  9. 40 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppContractService.java
  10. 50 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppContractTemplateService.java
  11. 110 27
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppArchiveServiceImpl.java
  12. 325 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppContractServiceImpl.java
  13. 296 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppContractTemplateServiceImpl.java
  14. 86 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractRequestVO.java
  15. 124 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractResponseVO.java
  16. 53 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractTemplateRequestVO.java
  17. 83 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractTemplateResponseVO.java
  18. 93 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppFieldValidator.java

+ 8 - 7
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ArchiveController.java

@@ -6,7 +6,6 @@ import com.usky.vpp.service.VppArchiveService;
 import com.usky.vpp.service.vo.VppFileArchiveRequestVO;
 import com.usky.vpp.service.vo.VppFileArchiveResponseVO;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
 import javax.servlet.http.HttpServletResponse;
@@ -33,23 +32,25 @@ public class ArchiveController {
 
     /**
      * 查询电子档案
+     * id          主键ID 精确匹配
      * archiveName 档案名称 模糊匹配
      * archiveType 档案类型 1图纸 2文档 3设计稿 4验收报告 5其他
      * siteId 站点id
      * bizType 业务类型
      * bizId 业务主键
-     * pageNum 页码
-     * pageSize 页大小
+     * current 页码
+     * size 页大小
      */
     @GetMapping
-    public ApiResult<CommonPage<VppFileArchiveResponseVO>> page(@RequestParam(value = "archiveName", required = false) String archiveName,
+    public ApiResult<CommonPage<VppFileArchiveResponseVO>> page(@RequestParam(value = "id", required = false) Long id,
+                                                                @RequestParam(value = "archiveName", required = false) String archiveName,
                                                                 @RequestParam(value = "archiveType", required = false) Integer archiveType,
                                                                 @RequestParam(value = "siteId", required = false) Long siteId,
                                                                 @RequestParam(value = "bizType", required = false) String bizType,
                                                                 @RequestParam(value = "bizId", required = false) Long bizId,
-                                                                @RequestParam(value = "pageNum", required = false, defaultValue = "1") Integer pageNum,
-                                                                @RequestParam(value = "pageSize", required = false, defaultValue = "20") Integer pageSize) {
-        return ApiResult.success(vppArchiveService.page(archiveName, archiveType, siteId, bizType, bizId, pageNum, pageSize));
+                                                                @RequestParam(value = "current", required = false, defaultValue = "1") Integer current,
+                                                                @RequestParam(value = "size", required = false, defaultValue = "20") Integer size) {
+        return ApiResult.success(vppArchiveService.page(id, archiveName, archiveType, siteId, bizType, bizId, current, size));
     }
 
     /**

+ 119 - 21
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ContractController.java

@@ -1,10 +1,20 @@
 package com.usky.vpp.controller.web;
 
 import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.service.VppContractService;
+import com.usky.vpp.service.VppContractTemplateService;
+import com.usky.vpp.service.vo.VppContractRequestVO;
+import com.usky.vpp.service.vo.VppContractResponseVO;
+import com.usky.vpp.service.vo.VppContractTemplateRequestVO;
+import com.usky.vpp.service.vo.VppContractTemplateResponseVO;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.web.bind.annotation.*;
 
+import javax.servlet.http.HttpServletResponse;
+import java.time.LocalDate;
+
 /**
  * 虚拟电厂 - Contract 接口
  * 网关前缀: /prod-api/service-vpp
@@ -13,35 +23,123 @@ import org.springframework.web.bind.annotation.*;
 @RequestMapping("/contract")
 public class ContractController {
 
+    @Autowired
+    private VppContractTemplateService vppContractTemplateService;
     @Autowired
     private VppContractService vppContractService;
 
-    @GetMapping(value = "/template")
-    public ApiResult<Object> listTemplate(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+    // ==================== 合同模板 ====================
+
+    /**
+     * 新增合同模板
+     */
+    @PostMapping("/template")
+    public ApiResult<Boolean> createTemplate(@RequestBody VppContractTemplateRequestVO vo) {
+        return vppContractTemplateService.create(vo) ? ApiResult.success(true) : ApiResult.error("新增合同模板失败!请重试!");
     }
-    @PostMapping
-    public ApiResult<Object> createContract() {
-        return ApiResult.success(null);
+
+    /**
+     * 修改合同模板
+     */
+    @PutMapping("/template")
+    public ApiResult<Boolean> updateTemplate(@RequestBody VppContractTemplateRequestVO vo) {
+        return vppContractTemplateService.update(vo) ? ApiResult.success(true) : ApiResult.error("修改或启用合同模板失败!请重试!");
     }
-    @GetMapping
-    public ApiResult<Object> pageContract() {
-        return ApiResult.success(null);
+
+    /**
+     * 分页查询合同模板
+     * id           主键ID 精确匹配
+     * templateName 模板名称 模糊匹配
+     * contractType 合同类型
+     * isEnabled    是否启用 0-禁用 1-启用
+     * current      页码
+     * size         页大小
+     */
+    @GetMapping("/template")
+    public ApiResult<CommonPage<VppContractTemplateResponseVO>> pageTemplate(
+            @RequestParam(value = "id", required = false) Long id,
+            @RequestParam(value = "templateName", required = false) String templateName,
+            @RequestParam(value = "contractType", required = false) Integer contractType,
+            @RequestParam(value = "isEnabled", required = false) Integer isEnabled,
+            @RequestParam(value = "current", required = false, defaultValue = "1") Integer current,
+            @RequestParam(value = "size", required = false, defaultValue = "20") Integer size) {
+        return ApiResult.success(vppContractTemplateService.page(id, templateName, contractType, isEnabled, current, size));
+    }
+
+    /**
+     * 删除合同模板(软删除)
+     */
+    @DeleteMapping("/template/{id}")
+    public ApiResult<Boolean> deleteTemplate(@PathVariable("id") Long id) {
+        return vppContractTemplateService.delete(id) ? ApiResult.success(true) : ApiResult.error("删除合同模板失败!请重试!");
+    }
+
+    /**
+     * 下载合同模板文件(单个文件流下载,不打包)
+     */
+    @GetMapping("/template/{id}/download")
+    public void downloadTemplate(@PathVariable("id") Long id, HttpServletResponse response) {
+        vppContractTemplateService.download(id, response);
+    }
+
+    // ==================== 合同 ====================
+
+    /**
+     * 新增合同
+     */
+    @PostMapping
+    public ApiResult<Boolean> create(@RequestBody VppContractRequestVO vo) {
+        return vppContractService.create(vo) ? ApiResult.success(true) : ApiResult.error("新增合同失败!请重试!");
     }
-    @GetMapping(value = "/{id}")
-    public ApiResult<Object> getContract(@PathVariable("id") Long id, @RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+
+    /**
+     * 修改合同
+     */
+    @PutMapping
+    public ApiResult<Boolean> update(@RequestBody VppContractRequestVO vo) {
+        return vppContractService.update(vo) ? ApiResult.success(true) : ApiResult.error("修改合同失败!请重试!");
     }
-    @PostMapping(value = "/{id}/submit")
-    public ApiResult<Void> submitContract(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
-        return ApiResult.success();
+
+    /**
+     * 分页查询合同
+     * id             主键ID 精确匹配
+     * signDateStart  签约日期起始
+     * signDateEnd    签约日期截止
+     * templateId     模板ID
+     * contractType   合同类型
+     * customerId     客户ID
+     * contractStatus 合同状态
+     * current        页码
+     * size           页大小
+     */
+    @GetMapping
+    public ApiResult<CommonPage<VppContractResponseVO>> page(
+            @RequestParam(value = "id", required = false) Long id,
+            @RequestParam(value = "startDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
+            @RequestParam(value = "endDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
+            @RequestParam(value = "templateId", required = false) Long templateId,
+            @RequestParam(value = "contractType", required = false) Integer contractType,
+            @RequestParam(value = "customerId", required = false) Long customerId,
+            @RequestParam(value = "contractStatus", required = false) Integer contractStatus,
+            @RequestParam(value = "current", required = false, defaultValue = "1") Integer current,
+            @RequestParam(value = "size", required = false, defaultValue = "20") Integer size) {
+        return ApiResult.success(vppContractService.page(id, startDate, endDate,
+                templateId, contractType, customerId, contractStatus, current, size));
     }
-    @PutMapping(value = "/{id}/audit")
-    public ApiResult<Void> auditContract(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
-        return ApiResult.success();
+
+    /**
+     * 删除合同(软删除)
+     */
+    @DeleteMapping("/{id}")
+    public ApiResult<Boolean> delete(@PathVariable("id") Long id) {
+        return vppContractService.delete(id) ? ApiResult.success(true) : ApiResult.error("删除合同失败!请重试!");
     }
-    @PostMapping(value = "/{id}/archive")
-    public ApiResult<Void> archiveContract(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
-        return ApiResult.success();
+
+    /**
+     * 下载合同文件(单个文件流下载,不打包)
+     */
+    @GetMapping("/{id}/download")
+    public void download(@PathVariable("id") Long id, HttpServletResponse response) {
+        vppContractService.download(id, response);
     }
 }

+ 42 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContract.java

@@ -4,8 +4,12 @@ import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
 import java.io.Serializable;
 import java.math.BigDecimal;
 import java.time.LocalDate;
@@ -23,45 +27,83 @@ public class VppContract implements Serializable {
 
     @TableId(value = "id", type = IdType.ASSIGN_ID)
     private Long id;
+
     @TableField("contract_no")
+    @NotBlank(message = "合同编号不能为空!")
     private String contractNo;
+
     @TableField("customer_id")
+    @NotNull(message = "客户ID不能为空!")
     private Long customerId;
+
     @TableField("template_id")
+    @NotNull(message = "合同模板ID不能为空!")
     private Long templateId;
+
+    /**
+     * 1购售电 2需求响应合作 3聚合代理 4服务代理 5居民充电桩 6自有资产
+     */
     @TableField("contract_type")
+    @NotNull(message = "合同类型不能为空!")
     private Integer contractType;
+
     @TableField("contract_name")
+    @NotNull(message = "合同名称不能为空!")
     private String contractName;
+
+    /**
+     * 0草稿 1审核中 2已生效 3已到期 4已终止
+     */
+    @NotNull(message = "合同状态不能为空!")
     @TableField("contract_status")
     private Integer contractStatus;
+
     @TableField("sign_date")
     private LocalDate signDate;
+
     @TableField("effective_date")
+    @JsonFormat(pattern = "yyyy-MM-dd")
     private LocalDate effectiveDate;
+
     @TableField("expire_date")
+    @JsonFormat(pattern = "yyyy-MM-dd")
     private LocalDate expireDate;
+
     @TableField("file_url")
     private String fileUrl;
+
     @TableField("share_ratio")
     private BigDecimal shareRatio;
+
     @TableField("price_json")
     private String priceJson;
+
     @TableField("account_info_json")
     private String accountInfoJson;
+
+    @TableField("remark")
     private String remark;
+
     @TableField("tenant_id")
     private Integer tenantId;
+
     @TableField("create_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private LocalDateTime createTime;
+
     @TableField("update_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private LocalDateTime updateTime;
+
     @TableField("created_by")
     private String createdBy;
+
     @TableField("updated_by")
     private String updatedBy;
+
     @TableField("delete_flag")
     private Integer deleteFlag;
+
     @TableField("deleted_at")
     private LocalDateTime deletedAt;
 }

+ 1 - 34
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContractAuditLog.java

@@ -1,34 +1 @@
-package com.usky.vpp.domain;

-

-import com.baomidou.mybatisplus.annotation.IdType;

-import com.baomidou.mybatisplus.annotation.TableField;

-import com.baomidou.mybatisplus.annotation.TableId;

-import com.baomidou.mybatisplus.annotation.TableName;

-import lombok.Data;

-import lombok.EqualsAndHashCode;

-import java.io.Serializable;

-import java.time.LocalDateTime;

-

-/**

- * vpp_contract_audit_log

- */

-@Data

-@EqualsAndHashCode(callSuper = false)

-@TableName("vpp_contract_audit_log")

-public class VppContractAuditLog implements Serializable {

-

-    private static final long serialVersionUID = 1L;

-

-    @TableId(value = "id", type = IdType.ASSIGN_ID)

-    private Long id;

-    @TableField("contract_id")

-    private Long contractId;

-    private Integer action;

-    private String opinion;

-    @TableField("tenant_id")

-    private Integer tenantId;

-    @TableField("create_time")

-    private LocalDateTime createTime;

-    @TableField("created_by")

-    private String createdBy;

-}

+package com.usky.vpp.domain;



import com.baomidou.mybatisplus.annotation.IdType;

import com.baomidou.mybatisplus.annotation.TableField;

import com.baomidou.mybatisplus.annotation.TableId;

import com.baomidou.mybatisplus.annotation.TableName;

import lombok.Data;

import lombok.EqualsAndHashCode;

import java.io.Serializable;

import java.time.LocalDateTime;



/**

 * vpp_contract_audit_log

 */

@Data

@EqualsAndHashCode(callSuper = false)

@TableName("vpp_contract_audit_log")

public class VppContractAuditLog implements Serializable {



    private static final long serialVersionUID = 1L;



    @TableId(value = "id", type = IdType.ASSIGN_ID)

    private Long id;

    @TableField("contract_id")

    private Long contractId;

    private Integer action;

    private String opinion;

    @TableField("tenant_id")

    private Integer tenantId;

    @TableField("create_time")

    private LocalDateTime createTime;

    @TableField("created_by")

    private String createdBy;

}


+ 16 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContractTemplate.java

@@ -4,8 +4,12 @@ import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
 import java.io.Serializable;
 import java.time.LocalDateTime;
 
@@ -22,23 +26,34 @@ public class VppContractTemplate implements Serializable {
     @TableId(value = "id", type = IdType.ASSIGN_ID)
     private Long id;
     @TableField("template_code")
+    @NotBlank(message = "模板编码不能为空!")
     private String templateCode;
     @TableField("template_name")
+    @NotBlank(message = "模板名称不能为空!")
     private String templateName;
+
+    /**
+     * 1、购售电合同 2、需求响应合作协议 3、聚合代理协议(2024 版) 4、虚拟电厂服务代理协议 5、居民个人充电桩协议
+     */
     @TableField("contract_type")
+    @NotNull(message = "合同类型不能为空!")
     private Integer contractType;
     private String version;
     @TableField("file_url")
+    @NotBlank(message = "文件路径不能为空!")
     private String fileUrl;
     @TableField("variables_json")
     private String variablesJson;
     @TableField("is_enabled")
+    @NotNull(message = "是否启用不能为空!")
     private Integer isEnabled;
     @TableField("tenant_id")
     private Integer tenantId;
     @TableField("create_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private LocalDateTime createTime;
     @TableField("update_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private LocalDateTime updateTime;
     @TableField("created_by")
     private String createdBy;
@@ -47,5 +62,6 @@ public class VppContractTemplate implements Serializable {
     @TableField("delete_flag")
     private Integer deleteFlag;
     @TableField("deleted_at")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private LocalDateTime deletedAt;
 }

+ 2 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppContractTemplateMapper.java

@@ -2,9 +2,11 @@ package com.usky.vpp.mapper;
 
 import com.usky.common.mybatis.core.CrudMapper;
 import com.usky.vpp.domain.VppContractTemplate;
+import org.springframework.stereotype.Repository;
 
 /**
  * vpp_contract_template Mapper
  */
+@Repository
 public interface VppContractTemplateMapper extends CrudMapper<VppContractTemplate> {
 }

+ 2 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppFileArchiveMapper.java

@@ -2,9 +2,11 @@ package com.usky.vpp.mapper;
 
 import com.usky.common.mybatis.core.CrudMapper;
 import com.usky.vpp.domain.VppFileArchive;
+import org.springframework.stereotype.Repository;
 
 /**
  * vpp_file_archive Mapper
  */
+@Repository
 public interface VppFileArchiveMapper extends CrudMapper<VppFileArchive> {
 }

+ 1 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppArchiveService.java

@@ -3,7 +3,6 @@ package com.usky.vpp.service;
 import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.service.vo.VppFileArchiveRequestVO;
 import com.usky.vpp.service.vo.VppFileArchiveResponseVO;
-import org.springframework.http.ResponseEntity;
 
 import javax.servlet.http.HttpServletResponse;
 import java.util.List;
@@ -20,7 +19,7 @@ public interface VppArchiveService {
 
     Boolean create(VppFileArchiveRequestVO vo);
 
-    CommonPage<VppFileArchiveResponseVO> page(String archiveName, Integer archiveType, Long siteId, String bizType, Long bizId, Integer pageNum, Integer pageSize);
+    CommonPage<VppFileArchiveResponseVO> page(Long id, String archiveName, Integer archiveType, Long siteId, String bizType, Long bizId, Integer pageNum, Integer pageSize);
 
     Boolean upload(VppFileArchiveRequestVO vo);
 

+ 40 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppContractService.java

@@ -1,7 +1,11 @@
 package com.usky.vpp.service;
 
 import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.vo.VppContractRequestVO;
+import com.usky.vpp.service.vo.VppContractResponseVO;
 
+import javax.servlet.http.HttpServletResponse;
+import java.time.LocalDate;
 import java.util.Map;
 
 /**
@@ -12,4 +16,40 @@ public interface VppContractService {
     default Object stub(String action, Map<String, Object> params) {
         return null;
     }
+
+    /**
+     * 新增合同
+     */
+    Boolean create(VppContractRequestVO vo);
+
+    /**
+     * 修改合同
+     */
+    Boolean update(VppContractRequestVO vo);
+
+    /**
+     * 分页查询合同
+     * @param id             主键ID(精确匹配)
+     * @param signDateStart  签约日期起始
+     * @param signDateEnd    签约日期截止
+     * @param templateId     模板ID
+     * @param contractType   合同类型
+     * @param customerId     客户ID
+     * @param contractStatus 合同状态
+     * @param pageNum        页码
+     * @param pageSize       页大小
+     */
+    CommonPage<VppContractResponseVO> page(Long id, LocalDate signDateStart, LocalDate signDateEnd,
+                                           Long templateId, Integer contractType, Long customerId,
+                                           Integer contractStatus, Integer pageNum, Integer pageSize);
+
+    /**
+     * 删除合同(软删除)
+     */
+    Boolean delete(Long id);
+
+    /**
+     * 下载合同文件
+     */
+    void download(Long id, HttpServletResponse response);
 }

+ 50 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppContractTemplateService.java

@@ -0,0 +1,50 @@
+package com.usky.vpp.service;
+
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.vo.VppContractTemplateRequestVO;
+import com.usky.vpp.service.vo.VppContractTemplateResponseVO;
+
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * VppContractTemplateService 业务接口
+ * @author fyc
+ * @email yuchuan.fu@chinausky.com
+ * @date 2026/7/4
+ */
+public interface VppContractTemplateService {
+
+    /**
+     * 新增合同模板
+     */
+    Boolean create(VppContractTemplateRequestVO vo);
+
+    /**
+     * 修改合同模板
+     */
+    Boolean update(VppContractTemplateRequestVO vo);
+
+    /**
+     * 分页查询合同模板
+     * @param id           主键ID(精确匹配)
+     * @param templateName 模板名称(模糊匹配)
+     * @param contractType 合同类型
+     * @param isEnabled    是否启用
+     * @param pageNum      页码
+     * @param pageSize     页大小
+     */
+    CommonPage<VppContractTemplateResponseVO> page(Long id, String templateName, Integer contractType, Integer isEnabled,
+                                                     Integer pageNum, Integer pageSize);
+
+    /**
+     * 删除合同模板(软删除)
+     */
+    Boolean delete(Long id);
+
+    /**
+     * 下载合同模板文件
+     * @param id       主键ID
+     * @param response HTTP响应
+     */
+    void download(Long id, HttpServletResponse response);
+}

+ 110 - 27
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppArchiveServiceImpl.java

@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.file.domain.FilesUpload;
 import com.ruoyi.file.mapper.FilesMapper;
 import com.usky.common.core.bean.CommonPage;
+import com.usky.common.core.exception.BusinessException;
 import com.usky.common.security.utils.SecurityUtils;
 import com.usky.vpp.domain.VppFileArchive;
 import com.usky.vpp.mapper.VppFileArchiveMapper;
@@ -13,17 +14,19 @@ import com.usky.vpp.service.VppArchiveService;
 import com.usky.vpp.service.vo.VppFileArchiveRequestVO;
 import com.usky.vpp.service.vo.VppFileArchiveResponseVO;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
-import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
 
 import javax.servlet.ServletOutputStream;
 import javax.servlet.http.HttpServletResponse;
-import java.io.ByteArrayOutputStream;
+import java.io.BufferedOutputStream;
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
@@ -31,9 +34,16 @@ import java.nio.charset.StandardCharsets;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
 
@@ -48,6 +58,37 @@ public class VppArchiveServiceImpl implements VppArchiveService {
     @Autowired
     private VppFileArchiveMapper vppFileArchiveMapper;
 
+    /**
+     * 连接池化的 RestTemplate,避免每次创建新连接
+     */
+    private final RestTemplate restTemplate;
+
+    /**
+     * 并行下载线程池
+     */
+    private static final ExecutorService DOWNLOAD_EXECUTOR = new ThreadPoolExecutor(
+            4, 8, 60L, TimeUnit.SECONDS,
+            new LinkedBlockingQueue<>(200),
+            r -> {
+                Thread t = new Thread(r, "archive-download-");
+                t.setDaemon(true);
+                return t;
+            },
+            new ThreadPoolExecutor.CallerRunsPolicy()
+    );
+
+    {
+        // 配置 HttpClient 连接池(最大连接数、超时时间)
+        CloseableHttpClient httpClient = HttpClientBuilder.create()
+                .setMaxConnTotal(50)
+                .setMaxConnPerRoute(20)
+                .build();
+        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient);
+        factory.setConnectTimeout(10_000);
+        factory.setReadTimeout(30_000);
+        this.restTemplate = new RestTemplate(factory);
+    }
+
     @Override
     public Boolean create(VppFileArchiveRequestVO vo) {
         String username = SecurityUtils.getUsername();
@@ -82,12 +123,13 @@ public class VppArchiveServiceImpl implements VppArchiveService {
     }
 
     @Override
-    public CommonPage<VppFileArchiveResponseVO> page(String archiveName, Integer archiveType, Long siteId, String bizType, Long bizId, Integer pageNum, Integer pageSize) {
+    public CommonPage<VppFileArchiveResponseVO> page(Long id, String archiveName, Integer archiveType, Long siteId, String bizType, Long bizId, Integer pageNum, Integer pageSize) {
         IPage<VppFileArchive> page = new Page<>(pageNum, pageSize);
 
         LambdaQueryWrapper<VppFileArchive> queryWrapper = new LambdaQueryWrapper<>();
         queryWrapper.eq(VppFileArchive::getDeleteFlag, 0)
                 .eq(VppFileArchive::getTenantId, SecurityUtils.getTenantId())
+                .eq(id != null, VppFileArchive::getId, id)
                 .eq(siteId != null, VppFileArchive::getSiteId, siteId)
                 .eq(archiveType != null, VppFileArchive::getArchiveType, archiveType)
                 .eq(StringUtils.isNotBlank(bizType), VppFileArchive::getBizType, bizType)
@@ -126,7 +168,7 @@ public class VppArchiveServiceImpl implements VppArchiveService {
     @Override
     public Boolean upload(VppFileArchiveRequestVO vo) {
         if (vo.getId() == null) {
-            throw new RuntimeException("主键ID不能为空!");
+            throw new BusinessException("主键ID不能为空!");
         }
 
         LambdaQueryWrapper<VppFileArchive> queryWrapper = new LambdaQueryWrapper<>();
@@ -136,7 +178,7 @@ public class VppArchiveServiceImpl implements VppArchiveService {
         VppFileArchive existingRecord = vppFileArchiveMapper.selectOne(queryWrapper);
 
         if (existingRecord == null) {
-            throw new RuntimeException("档案记录不存在或已被删除!");
+            throw new BusinessException("档案记录不存在或已被删除!");
         }
 
         // 根据 name + url 关联 FilesUpload 表,同步更新文件信息
@@ -153,7 +195,7 @@ public class VppArchiveServiceImpl implements VppArchiveService {
                 existingRecord.setFileType(filesUpload.getType());
                 existingRecord.setFileSize(filesUpload.getSize());
             } else {
-                throw new RuntimeException("未找到匹配的文件记录,请确认文件名与路径是否正确!");
+                throw new BusinessException("未找到匹配的文件记录,请确认文件名与路径是否正确!");
             }
         }
 
@@ -223,6 +265,25 @@ public class VppArchiveServiceImpl implements VppArchiveService {
             return;
         }
 
+        // ========== 预解析文件名(去重处理,必须在并行下载之前完成)==========
+        List<DownloadTask> tasks = new ArrayList<>();
+        Set<String> usedNames = new HashSet<>();
+        for (VppFileArchive archive : archives) {
+            String fileUrl = archive.getFileUrl();
+            String fileName = archive.getFileName();
+            if (StringUtils.isBlank(fileUrl) || StringUtils.isBlank(fileName)) {
+                continue;
+            }
+            String zipEntryName = resolveDuplicateName(fileName, usedNames);
+            usedNames.add(zipEntryName);
+            tasks.add(new DownloadTask(zipEntryName, fileUrl));
+        }
+
+        if (tasks.isEmpty()) {
+            writeErrorResponse(response, HttpStatus.BAD_REQUEST.value(), "没有可下载的文件!");
+            return;
+        }
+
         // ========== 构造 ZIP 文件名 ==========
         String zipFileName = "电子档案_"
                 + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
@@ -233,36 +294,43 @@ public class VppArchiveServiceImpl implements VppArchiveService {
         response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
                 "attachment; filename=" + encodeFileName(zipFileName));
 
-        // ========== 生成 ZIP 并写入响应流 ==========
-        RestTemplate restTemplate = new RestTemplate();
-        Set<String> usedNames = new HashSet<>();
+        // ========== 并行下载所有文件 ==========
+        List<CompletableFuture<DownloadTask>> futures = tasks.stream()
+                .map(task -> CompletableFuture.supplyAsync(() -> {
+                    try {
+                        byte[] bytes = restTemplate.getForObject(task.fileUrl, byte[].class);
+                        task.fileBytes = bytes;
+                    } catch (Exception e) {
+                        task.error = e.getMessage();
+                    }
+                    return task;
+                }, DOWNLOAD_EXECUTOR))
+                .collect(Collectors.toList());
 
+        // ========== 生成 ZIP 并写入响应流 ==========
         try (ServletOutputStream sos = response.getOutputStream();
-             ZipOutputStream zos = new ZipOutputStream(sos)) {
-
-            for (VppFileArchive archive : archives) {
-                String fileUrl = archive.getFileUrl();
-                String fileName = archive.getFileName();
-
-                if (StringUtils.isBlank(fileUrl) || StringUtils.isBlank(fileName)) {
-                    continue;
-                }
-
-                String zipEntryName = resolveDuplicateName(fileName, usedNames);
-                usedNames.add(zipEntryName);
+             BufferedOutputStream bos = new BufferedOutputStream(sos, 8192);
+             ZipOutputStream zos = new ZipOutputStream(bos)) {
 
+            for (CompletableFuture<DownloadTask> future : futures) {
+                DownloadTask task = future.join(); // 阻塞等待当前文件下载完成
                 try {
-                    byte[] fileBytes = restTemplate.getForObject(fileUrl, byte[].class);
-                    if (fileBytes != null && fileBytes.length > 0) {
-                        ZipEntry zipEntry = new ZipEntry(zipEntryName);
+                    if (task.error != null) {
+                        // 下载失败,写入错误信息
+                        ZipEntry errorEntry = new ZipEntry(task.zipEntryName + ".error.txt");
+                        zos.putNextEntry(errorEntry);
+                        zos.write(("下载失败: " + task.error).getBytes(StandardCharsets.UTF_8));
+                        zos.closeEntry();
+                    } else if (task.fileBytes != null && task.fileBytes.length > 0) {
+                        ZipEntry zipEntry = new ZipEntry(task.zipEntryName);
                         zos.putNextEntry(zipEntry);
-                        zos.write(fileBytes);
+                        zos.write(task.fileBytes);
                         zos.closeEntry();
                     }
                 } catch (Exception e) {
-                    ZipEntry errorEntry = new ZipEntry(zipEntryName + ".error.txt");
+                    ZipEntry errorEntry = new ZipEntry(task.zipEntryName + ".error.txt");
                     zos.putNextEntry(errorEntry);
-                    zos.write(("下载失败: " + e.getMessage()).getBytes(StandardCharsets.UTF_8));
+                    zos.write(("写入ZIP失败: " + e.getMessage()).getBytes(StandardCharsets.UTF_8));
                     zos.closeEntry();
                 }
             }
@@ -277,6 +345,21 @@ public class VppArchiveServiceImpl implements VppArchiveService {
         }
     }
 
+    /**
+     * 下载任务内部类,承载文件名、URL、下载结果
+     */
+    private static class DownloadTask {
+        final String zipEntryName;
+        final String fileUrl;
+        volatile byte[] fileBytes;
+        volatile String error;
+
+        DownloadTask(String zipEntryName, String fileUrl) {
+            this.zipEntryName = zipEntryName;
+            this.fileUrl = fileUrl;
+        }
+    }
+
     /**
      * 处理 ZIP 内重名文件,自动添加编号后缀
      * @param fileName  原始文件名

+ 325 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppContractServiceImpl.java

@@ -1,11 +1,335 @@
 package com.usky.vpp.service.impl;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.vpp.domain.VppContract;
+import com.usky.vpp.domain.VppContractTemplate;
+import com.usky.vpp.domain.VppCustomer;
+import com.usky.vpp.mapper.VppContractMapper;
+import com.usky.vpp.mapper.VppContractTemplateMapper;
+import com.usky.vpp.mapper.VppCustomerMapper;
 import com.usky.vpp.service.VppContractService;
+import com.usky.vpp.service.vo.VppContractRequestVO;
+import com.usky.vpp.service.vo.VppContractResponseVO;
+import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppFieldValidator;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 
 /**
- * VppContractService 默认实现(待按业务完善)
+ * VppContractService 默认实现
+ * @author fyc
+ * @email yuchuan.fu@chinausky.com
+ * @date 2026/7/4
  */
 @Service
 public class VppContractServiceImpl implements VppContractService {
+
+    @Autowired
+    private VppContractMapper vppContractMapper;
+    @Autowired
+    private VppContractTemplateMapper vppContractTemplateMapper;
+    @Autowired
+    private VppCustomerMapper vppCustomerMapper;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+
+    @Override
+    public Boolean create(VppContractRequestVO vo) {
+        // ========== 根据 templateId 查询模板编码并生成合同编号 ==========
+        VppContractTemplate template = getTemplateById(vo.getTemplateId());
+        String contractNo = VppFieldValidator.generateContractNo(template.getTemplateCode());
+
+        // ========== 合同编号全租户唯一性校验(极小概率冲突) ==========
+        checkContractNoUnique(contractNo, null);
+
+        VppContract contract = new VppContract();
+        contract.setContractNo(contractNo);
+        contract.setCustomerId(vo.getCustomerId());
+        contract.setTemplateId(vo.getTemplateId());
+        contract.setContractType(vo.getContractType());
+        contract.setContractName(vo.getContractName());
+        contract.setContractStatus(vo.getContractStatus());
+        contract.setSignDate(vo.getSignDate());
+        contract.setEffectiveDate(vo.getEffectiveDate());
+        contract.setExpireDate(vo.getExpireDate());
+        contract.setFileUrl(vo.getFileUrl());
+        contract.setShareRatio(vo.getShareRatio());
+        contract.setPriceJson(VppFieldValidator.validateJson(vo.getPriceJson(), "价格定义"));
+        contract.setAccountInfoJson(VppFieldValidator.validateJson(vo.getAccountInfoJson(), "账户信息"));
+        contract.setRemark(vo.getRemark());
+
+        VppAuditHelper.fillCreate(contract);
+
+        int insert = vppContractMapper.insert(contract);
+        return insert > 0;
+    }
+
+    @Override
+    public Boolean update(VppContractRequestVO vo) {
+        if (vo.getId() == null) {
+            throw new BusinessException("主键ID不能为空!");
+        }
+
+        LambdaQueryWrapper<VppContract> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(VppContract::getId, vo.getId())
+                .eq(VppContract::getDeleteFlag, 0);
+        VppContract existingRecord = vppContractMapper.selectOne(queryWrapper);
+
+        if (existingRecord == null) {
+            throw new BusinessException("合同不存在或已被删除!");
+        }
+
+        // 仅更新非空字段
+        if (vo.getCustomerId() != null) {
+            existingRecord.setCustomerId(vo.getCustomerId());
+        }
+        if (vo.getTemplateId() != null) {
+            existingRecord.setTemplateId(vo.getTemplateId());
+        }
+        if (vo.getContractType() != null) {
+            existingRecord.setContractType(vo.getContractType());
+        }
+        if (StringUtils.isNotBlank(vo.getContractName())) {
+            existingRecord.setContractName(vo.getContractName());
+        }
+        if (vo.getContractStatus() != null) {
+            existingRecord.setContractStatus(vo.getContractStatus());
+        }
+        if (vo.getSignDate() != null) {
+            existingRecord.setSignDate(vo.getSignDate());
+        }
+        if (vo.getEffectiveDate() != null) {
+            existingRecord.setEffectiveDate(vo.getEffectiveDate());
+        }
+        if (vo.getExpireDate() != null) {
+            existingRecord.setExpireDate(vo.getExpireDate());
+        }
+        if (StringUtils.isNotBlank(vo.getFileUrl())) {
+            existingRecord.setFileUrl(vo.getFileUrl());
+        }
+        if (vo.getShareRatio() != null) {
+            existingRecord.setShareRatio(vo.getShareRatio());
+        }
+        if (vo.getPriceJson() != null) {
+            existingRecord.setPriceJson(VppFieldValidator.validateJson(vo.getPriceJson(), "价格定义"));
+        }
+        if (vo.getAccountInfoJson() != null) {
+            existingRecord.setAccountInfoJson(VppFieldValidator.validateJson(vo.getAccountInfoJson(), "账户信息"));
+        }
+        if (vo.getRemark() != null) {
+            existingRecord.setRemark(vo.getRemark());
+        }
+
+        VppAuditHelper.fillUpdate(existingRecord);
+
+        int result = vppContractMapper.updateById(existingRecord);
+        return result > 0;
+    }
+
+    @Override
+    public CommonPage<VppContractResponseVO> page(Long id, LocalDate signDateStart, LocalDate signDateEnd,
+                                                   Long templateId, Integer contractType, Long customerId,
+                                                   Integer contractStatus, Integer pageNum, Integer pageSize) {
+        IPage<VppContract> page = new Page<>(pageNum, pageSize);
+
+        LambdaQueryWrapper<VppContract> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(VppContract::getDeleteFlag, 0)
+                .eq(id != null, VppContract::getId, id)
+                .ge(signDateStart != null, VppContract::getSignDate, signDateStart)
+                .le(signDateEnd != null, VppContract::getSignDate, signDateEnd)
+                .eq(templateId != null, VppContract::getTemplateId, templateId)
+                .eq(contractType != null, VppContract::getContractType, contractType)
+                .eq(customerId != null, VppContract::getCustomerId, customerId)
+                .eq(contractStatus != null, VppContract::getContractStatus, contractStatus)
+                .orderByDesc(VppContract::getCreateTime);
+        page = vppContractMapper.selectPage(page, queryWrapper);
+
+        List<VppContract> records = page.getRecords();
+        if (records == null || records.isEmpty()) {
+            return new CommonPage<>(Collections.emptyList(), page.getTotal(), pageSize, pageNum);
+        }
+
+        // ========== 批量反查客户名称和模板名称 ==========
+        Set<Long> customerIds = records.stream().map(VppContract::getCustomerId).filter(Objects::nonNull).collect(Collectors.toSet());
+        Set<Long> templateIds = records.stream().map(VppContract::getTemplateId).filter(Objects::nonNull).collect(Collectors.toSet());
+
+        final Map<Long, String> customerNameMap = customerIds.isEmpty() ? Collections.emptyMap()
+                : vppCustomerMapper.selectBatchIds(customerIds).stream()
+                        .collect(Collectors.toMap(VppCustomer::getId, VppCustomer::getCustomerName, (a, b) -> a));
+
+        final Map<Long, String> templateNameMap = templateIds.isEmpty() ? Collections.emptyMap()
+                : vppContractTemplateMapper.selectBatchIds(templateIds).stream()
+                        .collect(Collectors.toMap(VppContractTemplate::getId, VppContractTemplate::getTemplateName, (a, b) -> a));
+
+        // ========== 组装响应VO ==========
+        List<VppContractResponseVO> list = records.stream().map(entity -> {
+            VppContractResponseVO vo = new VppContractResponseVO();
+            vo.setId(entity.getId());
+            vo.setContractNo(entity.getContractNo());
+            vo.setCustomerId(entity.getCustomerId());
+            vo.setCustomerName(customerNameMap.get(entity.getCustomerId()));
+            vo.setTemplateId(entity.getTemplateId());
+            vo.setTemplateName(templateNameMap.get(entity.getTemplateId()));
+            vo.setContractType(entity.getContractType());
+            vo.setContractName(entity.getContractName());
+            vo.setContractStatus(entity.getContractStatus());
+            vo.setSignDate(entity.getSignDate());
+            vo.setEffectiveDate(entity.getEffectiveDate());
+            vo.setExpireDate(entity.getExpireDate());
+            vo.setFileUrl(entity.getFileUrl());
+            vo.setShareRatio(entity.getShareRatio());
+            vo.setPriceJson(entity.getPriceJson());
+            vo.setAccountInfoJson(entity.getAccountInfoJson());
+            vo.setRemark(entity.getRemark());
+            vo.setTenantId(entity.getTenantId());
+            vo.setCreateTime(entity.getCreateTime());
+            vo.setUpdateTime(entity.getUpdateTime());
+            vo.setCreatedBy(entity.getCreatedBy());
+            vo.setUpdatedBy(entity.getUpdatedBy());
+            return vo;
+        }).collect(Collectors.toList());
+
+        return new CommonPage<>(list, page.getTotal(), pageSize, pageNum);
+    }
+
+    @Override
+    public Boolean delete(Long id) {
+        LambdaQueryWrapper<VppContract> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(VppContract::getId, id)
+                .eq(VppContract::getDeleteFlag, 0);
+        VppContract contract = vppContractMapper.selectOne(queryWrapper);
+
+        if (contract == null) {
+            throw new BusinessException("合同不存在或已被删除!");
+        }
+
+        VppAuditHelper.fillSoftDelete(contract);
+
+        int i = vppContractMapper.updateById(contract);
+        return i > 0;
+    }
+
+    @Override
+    public void download(Long id, HttpServletResponse response) {
+        LambdaQueryWrapper<VppContract> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(VppContract::getId, id)
+                .eq(VppContract::getDeleteFlag, 0);
+        VppContract contract = vppContractMapper.selectOne(queryWrapper);
+
+        if (contract == null) {
+            writeErrorResponse(response, HttpStatus.BAD_REQUEST.value(), "合同不存在或已被删除!");
+            return;
+        }
+
+        String fileUrl = contract.getFileUrl();
+        String fileName = contract.getContractName();
+        if (StringUtils.isBlank(fileUrl)) {
+            writeErrorResponse(response, HttpStatus.BAD_REQUEST.value(), "合同文件URL为空,无法下载!");
+            return;
+        }
+
+        // ========== 设置响应头 ==========
+        String downloadFileName = fileName;
+        int dotIndex = fileUrl.lastIndexOf('.');
+        if (dotIndex > 0 && !fileName.contains(".")) {
+            downloadFileName = fileName + fileUrl.substring(dotIndex);
+        }
+        response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
+        response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
+                "attachment; filename=" + encodeFileName(downloadFileName));
+
+        // ========== 下载文件并写入响应流 ==========
+        try {
+            byte[] fileBytes = restTemplate.getForObject(fileUrl, byte[].class);
+            if (fileBytes == null || fileBytes.length == 0) {
+                writeErrorResponse(response, HttpStatus.NOT_FOUND.value(), "合同文件内容为空!");
+                return;
+            }
+            ServletOutputStream sos = response.getOutputStream();
+            sos.write(fileBytes);
+            sos.flush();
+        } catch (Exception e) {
+            if (!response.isCommitted()) {
+                writeErrorResponse(response, HttpStatus.INTERNAL_SERVER_ERROR.value(),
+                        "下载合同文件失败: " + e.getMessage());
+            }
+        }
+    }
+
+    // ========== 内部辅助方法 ==========
+
+    /**
+     * 根据模板ID查询模板信息
+     */
+    private VppContractTemplate getTemplateById(Long templateId) {
+        if (templateId == null) {
+            throw new BusinessException("模板ID不能为空!");
+        }
+        LambdaQueryWrapper<VppContractTemplate> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppContractTemplate::getId, templateId)
+                .eq(VppContractTemplate::getDeleteFlag, 0);
+        VppContractTemplate template = vppContractTemplateMapper.selectOne(wrapper);
+        if (template == null) {
+            throw new BusinessException("合同模板不存在或已被删除!");
+        }
+        return template;
+    }
+
+    /**
+     * 合同编号全租户唯一性校验
+     */
+    private void checkContractNoUnique(String contractNo, Long excludeId) {
+        LambdaQueryWrapper<VppContract> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppContract::getContractNo, contractNo)
+                .eq(VppContract::getDeleteFlag, 0)
+                .ne(excludeId != null, VppContract::getId, excludeId);
+        Integer count = vppContractMapper.selectCount(wrapper);
+        if (count != null && count > 0) {
+            throw new BusinessException("合同编号【" + contractNo + "】已存在,请重新提交!");
+        }
+    }
+
+    private void writeErrorResponse(HttpServletResponse response, int status, String message) {
+        response.setStatus(status);
+        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        try {
+            response.getWriter().write("{\"code\":\"" + status + "\",\"msg\":\"" + message + "\"}");
+        } catch (IOException e) {
+            // 忽略写入异常
+        }
+    }
+
+    private String encodeFileName(String fileName) {
+        try {
+            return URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replace("+", "%20");
+        } catch (UnsupportedEncodingException e) {
+            return fileName;
+        }
+    }
 }

+ 296 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppContractTemplateServiceImpl.java

@@ -0,0 +1,296 @@
+package com.usky.vpp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.common.security.utils.SecurityUtils;
+import com.usky.vpp.domain.VppContractTemplate;
+import com.usky.vpp.mapper.VppContractTemplateMapper;
+import com.usky.vpp.service.VppContractTemplateService;
+import com.usky.vpp.service.vo.VppContractTemplateRequestVO;
+import com.usky.vpp.service.vo.VppContractTemplateResponseVO;
+import com.usky.vpp.util.VppFieldValidator;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * VppContractTemplateService 默认实现
+ * @author fyc
+ * @email yuchuan.fu@chinausky.com
+ * @date 2026/7/4
+ */
+@Service
+public class VppContractTemplateServiceImpl implements VppContractTemplateService {
+
+    @Autowired
+    private VppContractTemplateMapper vppContractTemplateMapper;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+
+    @Override
+    public Boolean create(VppContractTemplateRequestVO vo) {
+        String username = SecurityUtils.getUsername();
+        Integer tenantId = SecurityUtils.getTenantId();
+
+        // ========== templateCode 格式校验 ==========
+        VppFieldValidator.validateTemplateCode(vo.getTemplateCode());
+
+        // ========== templateCode 全租户唯一性校验 ==========
+        checkTemplateCodeUnique(vo.getTemplateCode(), null, tenantId);
+
+        VppContractTemplate template = new VppContractTemplate();
+        template.setTemplateCode(vo.getTemplateCode());
+        template.setTemplateName(vo.getTemplateName());
+        template.setContractType(vo.getContractType());
+        template.setVersion("V1.0");
+        template.setFileUrl(vo.getFileUrl());
+        template.setVariablesJson(VppFieldValidator.validateJson(vo.getVariablesJson(), "占位符变量定义"));
+        template.setIsEnabled(vo.getIsEnabled() != null ? vo.getIsEnabled() : 1);
+        template.setCreateTime(LocalDateTime.now());
+        template.setCreatedBy(username);
+        template.setTenantId(tenantId);
+        template.setDeleteFlag(0);
+
+        int insert = vppContractTemplateMapper.insert(template);
+        return insert > 0;
+    }
+
+    @Override
+    public Boolean update(VppContractTemplateRequestVO vo) {
+        if (vo.getId() == null) {
+            throw new BusinessException("主键ID不能为空!");
+        }
+
+        LambdaQueryWrapper<VppContractTemplate> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(VppContractTemplate::getId, vo.getId())
+                .eq(VppContractTemplate::getTenantId, SecurityUtils.getTenantId())
+                .eq(VppContractTemplate::getDeleteFlag, 0);
+        VppContractTemplate existingRecord = vppContractTemplateMapper.selectOne(queryWrapper);
+
+        if (existingRecord == null) {
+            throw new BusinessException("合同模板不存在或已被删除!");
+        }
+
+        // 仅更新非空字段,追踪是否仅变更 isEnabled
+        boolean hasNonEnabledChange = false;
+
+        if (StringUtils.isNotBlank(vo.getTemplateCode())) {
+            // ========== templateCode 格式校验 ==========
+            VppFieldValidator.validateTemplateCode(vo.getTemplateCode());
+
+            // ========== 仅当与现有值不同时做唯一性校验 ==========
+            if (!vo.getTemplateCode().equals(existingRecord.getTemplateCode())) {
+                checkTemplateCodeUnique(vo.getTemplateCode(), vo.getId(), SecurityUtils.getTenantId());
+            }
+
+            existingRecord.setTemplateCode(vo.getTemplateCode());
+            hasNonEnabledChange = true;
+        }
+        if (StringUtils.isNotBlank(vo.getTemplateName())) {
+            existingRecord.setTemplateName(vo.getTemplateName());
+            hasNonEnabledChange = true;
+        }
+        if (vo.getContractType() != null) {
+            existingRecord.setContractType(vo.getContractType());
+            hasNonEnabledChange = true;
+        }
+        if (StringUtils.isNotBlank(vo.getFileUrl())) {
+            existingRecord.setFileUrl(vo.getFileUrl());
+            hasNonEnabledChange = true;
+        }
+        if (vo.getVariablesJson() != null) {
+            existingRecord.setVariablesJson(VppFieldValidator.validateJson(vo.getVariablesJson(), "占位符变量定义"));
+            hasNonEnabledChange = true;
+        }
+        // isEnabled 单独变更不触发版本号累加
+        if (vo.getIsEnabled() != null) {
+            existingRecord.setIsEnabled(vo.getIsEnabled());
+        }
+
+        // 版本号自动累加:仅当非 isEnabled 字段有变更时 +0.1
+        if (hasNonEnabledChange) {
+            String currentVersion = existingRecord.getVersion();
+            if (StringUtils.isNotBlank(currentVersion) && currentVersion.startsWith("V")) {
+                try {
+                    double versionNum = Double.parseDouble(currentVersion.substring(1));
+                    versionNum += 0.1;
+                    existingRecord.setVersion("V" + String.format("%.1f", versionNum));
+                } catch (NumberFormatException e) {
+                    existingRecord.setVersion(currentVersion + ".1");
+                }
+            }
+        }
+
+        existingRecord.setUpdateTime(LocalDateTime.now());
+        existingRecord.setUpdatedBy(SecurityUtils.getUsername());
+
+        int result = vppContractTemplateMapper.updateById(existingRecord);
+        return result > 0;
+    }
+
+    @Override
+    public CommonPage<VppContractTemplateResponseVO> page(Long id, String templateName, Integer contractType,
+                                                          Integer isEnabled, Integer pageNum, Integer pageSize) {
+        IPage<VppContractTemplate> page = new Page<>(pageNum, pageSize);
+
+        LambdaQueryWrapper<VppContractTemplate> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(VppContractTemplate::getDeleteFlag, 0)
+                .eq(VppContractTemplate::getTenantId, SecurityUtils.getTenantId())
+                .eq(id != null, VppContractTemplate::getId, id)
+                .eq(contractType != null, VppContractTemplate::getContractType, contractType)
+                .eq(isEnabled != null, VppContractTemplate::getIsEnabled, isEnabled)
+                .like(StringUtils.isNotBlank(templateName), VppContractTemplate::getTemplateName, templateName)
+                .orderByDesc(VppContractTemplate::getCreateTime);
+        page = vppContractTemplateMapper.selectPage(page, queryWrapper);
+
+        List<VppContractTemplateResponseVO> list = page.getRecords().stream().map(entity -> {
+            VppContractTemplateResponseVO responseVO = new VppContractTemplateResponseVO();
+            responseVO.setId(entity.getId());
+            responseVO.setTemplateCode(entity.getTemplateCode());
+            responseVO.setTemplateName(entity.getTemplateName());
+            responseVO.setContractType(entity.getContractType());
+            responseVO.setVersion(entity.getVersion());
+            responseVO.setFileUrl(entity.getFileUrl());
+            responseVO.setVariablesJson(entity.getVariablesJson());
+            responseVO.setIsEnabled(entity.getIsEnabled());
+            responseVO.setCreatedBy(entity.getCreatedBy());
+            responseVO.setCreateTime(entity.getCreateTime());
+            responseVO.setUpdatedBy(entity.getUpdatedBy());
+            responseVO.setUpdateTime(entity.getUpdateTime());
+            responseVO.setTenantId(entity.getTenantId());
+            return responseVO;
+        }).collect(Collectors.toList());
+
+        return new CommonPage<>(list, page.getTotal(), pageSize, pageNum);
+    }
+
+    @Override
+    public Boolean delete(Long id) {
+        LambdaQueryWrapper<VppContractTemplate> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(VppContractTemplate::getId, id)
+                .eq(VppContractTemplate::getTenantId, SecurityUtils.getTenantId());
+        VppContractTemplate template = vppContractTemplateMapper.selectOne(queryWrapper);
+
+        if (template == null) {
+            throw new BusinessException("合同模板不存在!");
+        }
+
+        template.setDeleteFlag(1);
+        template.setIsEnabled(0);
+        template.setDeletedAt(LocalDateTime.now());
+        template.setUpdatedBy(SecurityUtils.getUsername());
+        int i = vppContractTemplateMapper.updateById(template);
+        return i > 0;
+    }
+
+    @Override
+    public void download(Long id, HttpServletResponse response) {
+        // ========== 查询模板记录 ==========
+        LambdaQueryWrapper<VppContractTemplate> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(VppContractTemplate::getId, id)
+                .eq(VppContractTemplate::getDeleteFlag, 0)
+                .eq(VppContractTemplate::getTenantId, SecurityUtils.getTenantId());
+        VppContractTemplate template = vppContractTemplateMapper.selectOne(queryWrapper);
+
+        if (template == null) {
+            writeErrorResponse(response, HttpStatus.BAD_REQUEST.value(), "合同模板不存在或已被删除!");
+            return;
+        }
+
+        String fileUrl = template.getFileUrl();
+        String fileName = template.getTemplateName();
+        if (StringUtils.isBlank(fileUrl)) {
+            writeErrorResponse(response, HttpStatus.BAD_REQUEST.value(), "模板文件URL为空,无法下载!");
+            return;
+        }
+
+        // ========== 设置响应头 ==========
+        // 根据 fileUrl 推断原始文件名(优先使用模板名称 + 扩展名)
+        String downloadFileName = fileName;
+        int dotIndex = fileUrl.lastIndexOf('.');
+        if (dotIndex > 0 && !fileName.contains(".")) {
+            downloadFileName = fileName + fileUrl.substring(dotIndex);
+        }
+        response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
+        response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
+                "attachment; filename=" + encodeFileName(downloadFileName));
+
+        // ========== 下载文件并写入响应流 ==========
+        try {
+            byte[] fileBytes = restTemplate.getForObject(fileUrl, byte[].class);
+            if (fileBytes == null || fileBytes.length == 0) {
+                writeErrorResponse(response, HttpStatus.NOT_FOUND.value(), "模板文件内容为空!");
+                return;
+            }
+            ServletOutputStream sos = response.getOutputStream();
+            sos.write(fileBytes);
+            sos.flush();
+        } catch (Exception e) {
+            if (!response.isCommitted()) {
+                writeErrorResponse(response, HttpStatus.INTERNAL_SERVER_ERROR.value(),
+                        "下载合同模板失败: " + e.getMessage());
+            }
+        }
+    }
+
+    /**
+     * 向响应中写入 JSON 错误信息
+     */
+    private void writeErrorResponse(HttpServletResponse response, int status, String message) {
+        response.setStatus(status);
+        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        try {
+            response.getWriter().write("{\"code\":\"" + status + "\",\"msg\":\"" + message + "\"}");
+        } catch (IOException e) {
+            // 忽略写入异常
+        }
+    }
+
+    /**
+     * 编码文件名,兼容各浏览器
+     */
+    private String encodeFileName(String fileName) {
+        try {
+            return URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replace("+", "%20");
+        } catch (UnsupportedEncodingException e) {
+            return fileName;
+        }
+    }
+
+    /**
+     * 全租户唯一性校验:检查 templateCode 是否已被占用
+     *
+     * @param templateCode 模板编码
+     * @param excludeId    排除的记录ID(更新时传入当前记录ID,创建时传 null)
+     * @throws BusinessException 编码已被占用时抛出
+     */
+    private void checkTemplateCodeUnique(String templateCode, Long excludeId, Integer tenantId) {
+        LambdaQueryWrapper<VppContractTemplate> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppContractTemplate::getTemplateCode, templateCode)
+                .eq(VppContractTemplate::getDeleteFlag, 0)
+                .eq(VppContractTemplate::getTenantId, tenantId)
+                .ne(excludeId != null, VppContractTemplate::getId, excludeId);
+        Integer count = vppContractTemplateMapper.selectCount(wrapper);
+        if (count != null && count > 0) {
+            throw new BusinessException("模板编码【" + templateCode + "】已存在,请使用其他编码!");
+        }
+    }
+}

+ 86 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractRequestVO.java

@@ -0,0 +1,86 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+/**
+ * 合同 请求VO
+ * @author fyc
+ * @email yuchuan.fu@chinausky.com
+ * @date 2026/7/4
+ */
+@Data
+public class VppContractRequestVO {
+
+    /**
+     * 主键(更新时必传)
+     */
+    private Long id;
+
+    /**
+     * 客户ID
+     */
+    private Long customerId;
+
+    /**
+     * 模板ID
+     */
+    private Long templateId;
+
+    /**
+     * 合同类型
+     */
+    private Integer contractType;
+
+    /**
+     * 合同名称
+     */
+    private String contractName;
+
+    /**
+     * 合同状态
+     */
+    private Integer contractStatus;
+
+    /**
+     * 签约日期
+     */
+    private LocalDate signDate;
+
+    /**
+     * 生效日期
+     */
+    private LocalDate effectiveDate;
+
+    /**
+     * 到期日期
+     */
+    private LocalDate expireDate;
+
+    /**
+     * 合同文件URL
+     */
+    private String fileUrl;
+
+    /**
+     * 分成比例
+     */
+    private BigDecimal shareRatio;
+
+    /**
+     * 价格定义(JSON)
+     */
+    private String priceJson;
+
+    /**
+     * 账户信息(JSON)
+     */
+    private String accountInfoJson;
+
+    /**
+     * 备注
+     */
+    private String remark;
+}

+ 124 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractResponseVO.java

@@ -0,0 +1,124 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 合同 响应VO
+ * @author fyc
+ * @email yuchuan.fu@chinausky.com
+ * @date 2026/7/4
+ */
+@Data
+public class VppContractResponseVO {
+
+    private Long id;
+
+    /**
+     * 合同编号(自动生成)
+     */
+    private String contractNo;
+
+    /**
+     * 客户ID
+     */
+    private Long customerId;
+
+    /**
+     * 客户名称(关联查询)
+     */
+    private String customerName;
+
+    /**
+     * 模板ID
+     */
+    private Long templateId;
+
+    /**
+     * 模板名称(关联查询)
+     */
+    private String templateName;
+
+    /**
+     * 合同类型
+     */
+    private Integer contractType;
+
+    /**
+     * 合同名称
+     */
+    private String contractName;
+
+    /**
+     * 合同状态
+     */
+    private Integer contractStatus;
+
+    /**
+     * 签约日期
+     */
+    private LocalDate signDate;
+
+    /**
+     * 生效日期
+     */
+    private LocalDate effectiveDate;
+
+    /**
+     * 到期日期
+     */
+    private LocalDate expireDate;
+
+    /**
+     * 合同文件URL
+     */
+    private String fileUrl;
+
+    /**
+     * 分成比例
+     */
+    private BigDecimal shareRatio;
+
+    /**
+     * 价格定义(JSON)
+     */
+    private String priceJson;
+
+    /**
+     * 账户信息(JSON)
+     */
+    private String accountInfoJson;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**
+     * 租户ID
+     */
+    private Integer tenantId;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updateTime;
+
+    /**
+     * 创建人
+     */
+    private String createdBy;
+
+    /**
+     * 更新人
+     */
+    private String updatedBy;
+}

+ 53 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractTemplateRequestVO.java

@@ -0,0 +1,53 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+/**
+ * 合同模板 请求VO
+ * @author fyc
+ * @email yuchuan.fu@chinausky.com
+ * @date 2026/7/4
+ */
+@Data
+public class VppContractTemplateRequestVO {
+
+    /**
+     * 主键(更新时必传)
+     */
+    private Long id;
+
+    /**
+     * 模板编码
+     */
+    private String templateCode;
+
+    /**
+     * 模板名称
+     */
+    private String templateName;
+
+    /**
+     * 合同类型
+     */
+    private Integer contractType;
+
+    /**
+     * 版本号
+     */
+    private String version;
+
+    /**
+     * 模板文件URL
+     */
+    private String fileUrl;
+
+    /**
+     * 占位符变量定义(JSON)
+     */
+    private String variablesJson;
+
+    /**
+     * 是否启用 0-禁用 1-启用
+     */
+    private Integer isEnabled;
+}

+ 83 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractTemplateResponseVO.java

@@ -0,0 +1,83 @@
+package com.usky.vpp.service.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 合同模板 响应VO
+ * @author fyc
+ * @email yuchuan.fu@chinausky.com
+ * @date 2026/7/4
+ */
+@Data
+public class VppContractTemplateResponseVO {
+
+    /**
+     * 主键
+     */
+    private Long id;
+
+    /**
+     * 模板编码
+     */
+    private String templateCode;
+
+    /**
+     * 模板名称
+     */
+    private String templateName;
+
+    /**
+     * 合同类型
+     */
+    private Integer contractType;
+
+    /**
+     * 版本号
+     */
+    private String version;
+
+    /**
+     * 模板文件URL
+     */
+    private String fileUrl;
+
+    /**
+     * 占位符变量定义(JSON)
+     */
+    private String variablesJson;
+
+    /**
+     * 是否启用 0-禁用 1-启用
+     */
+    private Integer isEnabled;
+
+    /**
+     * 创建人
+     */
+    private String createdBy;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime createTime;
+
+    /**
+     * 更新人
+     */
+    private String updatedBy;
+
+    /**
+     * 更新时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime updateTime;
+
+    /**
+     * 租户ID
+     */
+    private Integer tenantId;
+}

+ 93 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppFieldValidator.java

@@ -0,0 +1,93 @@
+package com.usky.vpp.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.usky.common.core.exception.BusinessException;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.regex.Pattern;
+
+/**
+ * VPP 字段校验工具类
+ *
+ * @author fyc
+ * @email yuchuan.fu@chinausky.com
+ * @date 2026/7/4
+ */
+public final class VppFieldValidator {
+
+    /**
+     * templateCode 校验正则:仅允许大写英文字母,长度 1~5
+     */
+    private static final Pattern TEMPLATE_CODE_PATTERN = Pattern.compile("^[A-Z]{1,5}$");
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    private VppFieldValidator() {
+        // 工具类禁止实例化
+    }
+
+    /**
+     * 校验并规范化模板编码 templateCode:
+     * <ul>
+     *   <li>不允许为空白</li>
+     *   <li>最大长度不超过 5 个字符</li>
+     *   <li>仅允许大写英文字母(A-Z)</li>
+     * </ul>
+     *
+     * @param templateCode 原始模板编码
+     * @throws BusinessException 校验不通过时抛出
+     */
+    public static void validateTemplateCode(String templateCode) {
+        if (StringUtils.isBlank(templateCode)) {
+            throw new BusinessException("模板编码不能为空!");
+        }
+        if (!TEMPLATE_CODE_PATTERN.matcher(templateCode).matches()) {
+            throw new BusinessException("模板编码格式错误:仅允许大写英文字母(A-Z),且长度不超过5个字符!");
+        }
+    }
+
+    /**
+     * 校验 JSON 格式字符串的合法性:
+     * <ul>
+     *   <li>null:视为合法,字段允许为空</li>
+     *   <li>空字符串:不允许,抛出异常</li>
+     *   <li>非空时:必须能解析为合法的 JSON 对象或数组</li>
+     * </ul>
+     *
+     * @param json      待校验的 JSON 字符串
+     * @param fieldName 字段名称(用于错误提示)
+     * @return 规范化后的值:null → null;合法 JSON → 原值
+     * @throws BusinessException 空字符串或 JSON 格式不合法时抛出
+     */
+    public static String validateJson(String json, String fieldName) {
+        if (json == null) {
+            return null;
+        }
+        if (json.trim().isEmpty()) {
+            throw new BusinessException(fieldName + "不能为空字符串!");
+        }
+        try {
+            JsonNode node = OBJECT_MAPPER.readTree(json);
+            if (!node.isObject() && !node.isArray()) {
+                throw new BusinessException(fieldName + "格式错误:仅支持JSON对象({})或数组([])格式!");
+            }
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new BusinessException(fieldName + "格式错误:不是合法的JSON格式,请检查!");
+        }
+        return json;
+    }
+
+    /**
+     * 根据模板编码生成合同编号,格式:{templateCode}-{13位毫秒时间戳}
+     * <p>示例:templateCode=CG → CG-1783154814855</p>
+     *
+     * @param templateCode 模板编码
+     * @return 合同编号
+     */
+    public static String generateContractNo(String templateCode) {
+        return templateCode + "-" + System.currentTimeMillis();
+    }
+}