Prechádzať zdrojové kódy

合同管理-合同模板接口代码提交&优化电子档案接口代码

fuyuchuan 1 deň pred
rodič
commit
cc4ba9f65a

+ 6 - 4
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ArchiveController.java

@@ -32,6 +32,7 @@ public class ArchiveController {
 
     /**
      * 查询电子档案
+     * id          主键ID 精确匹配
      * archiveName 档案名称 模糊匹配
      * archiveType 档案类型 1图纸 2文档 3设计稿 4验收报告 5其他
      * siteId 站点id
@@ -41,14 +42,15 @@ public class ArchiveController {
      * pageSize 页大小
      */
     @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));
     }
 
     /**

+ 53 - 25
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/ContractController.java

@@ -1,10 +1,15 @@
 package com.usky.vpp.controller.web;
 
 import com.usky.common.core.bean.ApiResult;
-import com.usky.vpp.service.VppContractService;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.VppContractTemplateService;
+import com.usky.vpp.service.vo.VppContractTemplateRequestVO;
+import com.usky.vpp.service.vo.VppContractTemplateResponseVO;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
+import javax.servlet.http.HttpServletResponse;
+
 /**
  * 虚拟电厂 - Contract 接口
  * 网关前缀: /prod-api/service-vpp
@@ -14,34 +19,57 @@ import org.springframework.web.bind.annotation.*;
 public class ContractController {
 
     @Autowired
-    private VppContractService vppContractService;
+    private VppContractTemplateService vppContractTemplateService;
 
-    @GetMapping(value = "/template")
-    public ApiResult<Object> listTemplate(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
-    }
-    @PostMapping
-    public ApiResult<Object> createContract() {
-        return ApiResult.success(null);
+    /**
+     * 新增合同模板
+     */
+    @PostMapping("/template")
+    public ApiResult<Boolean> createTemplate(@RequestBody VppContractTemplateRequestVO vo) {
+        return vppContractTemplateService.create(vo) ? ApiResult.success(true) : ApiResult.error("新增合同模板失败!请重试!");
     }
-    @GetMapping
-    public ApiResult<Object> pageContract() {
-        return ApiResult.success(null);
-    }
-    @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("/template")
+    public ApiResult<Boolean> updateTemplate(@RequestBody VppContractTemplateRequestVO vo) {
+        return vppContractTemplateService.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 精确匹配
+     * templateName 模板名称 模糊匹配
+     * contractType 合同类型
+     * isEnabled    是否启用 0-禁用 1-启用
+     * pageNum      页码
+     * pageSize     页大小
+     */
+    @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 = "", 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));
     }
-    @PutMapping(value = "/{id}/audit")
-    public ApiResult<Void> auditContract(@PathVariable("id") Long id, @RequestBody(required = false) Object body) {
-        return ApiResult.success();
+
+    /**
+     * 删除合同模板(软删除)
+     */
+    @DeleteMapping("/template/{id}")
+    public ApiResult<Boolean> deleteTemplate(@PathVariable("id") Long id) {
+        return vppContractTemplateService.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("/template/{id}/download")
+    public void downloadTemplate(@PathVariable("id") Long id, HttpServletResponse response) {
+        vppContractTemplateService.download(id, response);
     }
 }

+ 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;
 }

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

@@ -19,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);
 

+ 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);
+}

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

@@ -123,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)

+ 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 + "】已存在,请使用其他编码!");
+        }
+    }
+}

+ 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;
+}

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

@@ -0,0 +1,82 @@
+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;
+    }
+}