feat: 立项与合同HCS前端接入配套端点——C103-8合同特批发起/B103结果回传、C103-2/5附件代理、C101-1风险与C103-6/12主数据查询,合同推送高风险客商挂起待特批

This commit is contained in:
刘泉佳
2026-10-09 19:45:40 +08:00
parent e41863d7f7
commit 7855254a4f
12 changed files with 1018 additions and 9 deletions
@@ -84,6 +84,11 @@ public final class HcsSyncConstant {
*/
public static final String CHECK_TYPE_CREDIT = "credit";
/**
* 校验类型:合同特批(C103-8 发起 / B103 结果回传留痕)
*/
public static final String CHECK_TYPE_SPEC = "spec";
/**
* 校验结果:通过
*/
@@ -26,14 +26,27 @@
package org.springblade.transport.controller;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import feign.Response;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.secure.constant.AuthConstant;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.hcs.dto.HcsAttachmentRefDTO;
import org.springblade.hcs.dto.HcsCurrencyVO;
import org.springblade.hcs.dto.HcsRiskVO;
import org.springblade.hcs.dto.HcsTemplateVO;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.support.hcs.HcsAttachmentSupport;
import org.springblade.transport.support.hcs.HcsContractSpecSyncSupport;
import org.springblade.transport.support.hcs.HcsContractSyncSupport;
import org.springblade.transport.support.hcs.HcsCreditCheckSupport;
import org.springblade.transport.support.hcs.HcsInvoiceRegSyncSupport;
import org.springblade.transport.support.hcs.HcsPaymentSyncSupport;
import org.springblade.transport.support.hcs.HcsReceiptBillSyncSupport;
@@ -41,12 +54,22 @@ import org.springblade.transport.support.hcs.HcsReconSupport;
import org.springblade.transport.support.hcs.HcsMasterDataSupport;
import org.springblade.transport.support.hcs.HcsProjectSyncSupport;
import org.springblade.transport.task.HcsPushTask;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
/**
* HCS 合规推送端点(设计文档 §5.0 手动入口)
@@ -67,6 +90,9 @@ public class HcsSyncController {
private final HcsPushTask hcsPushTask;
private final HcsProjectSyncSupport hcsProjectSyncSupport;
private final HcsContractSyncSupport hcsContractSyncSupport;
private final HcsContractSpecSyncSupport hcsContractSpecSyncSupport;
private final HcsCreditCheckSupport hcsCreditCheckSupport;
private final HcsAttachmentSupport hcsAttachmentSupport;
private final HcsPaymentSyncSupport hcsPaymentSyncSupport;
private final HcsInvoiceRegSyncSupport hcsInvoiceRegSyncSupport;
private final HcsReceiptBillSyncSupport hcsReceiptBillSyncSupport;
@@ -171,4 +197,118 @@ public class HcsSyncController {
public R<?> reconInvoiceRegistration(@Parameter(description = "开票申请单号", required = true) @RequestParam String applicationNo) {
return R.data(hcsReconSupport.reconInvoiceRegistration(applicationNo));
}
/**
* 合规模板列表(C103-6,Redis 缓存优先,前端接入设计 §3.1)
*/
@GetMapping("/master-data/template")
@ApiOperationSupport(order = 9)
@Operation(summary = "合规模板列表查询", description = "C103-6 模板列表,走服务端 Redis 缓存,未命中自动全量拉取")
public R<List<HcsTemplateVO>> templates() {
return R.data(hcsMasterDataSupport.getTemplates());
}
/**
* 合规币种列表(C103-12,Redis 缓存优先,前端接入设计 §3.1)
*/
@GetMapping("/master-data/currency")
@ApiOperationSupport(order = 16)
@Operation(summary = "合规币种列表查询", description = "C103-12 币种列表,走服务端 Redis 缓存,未命中自动全量拉取")
public R<List<HcsCurrencyVO>> currencies() {
return R.data(hcsMasterDataSupport.getCurrencies());
}
/**
* 客商风险等级查询(C101-1,展示用,前端接入设计 §3.3;强制校验仍在推送/付款链路)
*/
@GetMapping("/risk")
@ApiOperationSupport(order = 10)
@Operation(summary = "客商风险等级查询", description = "C101-1 按客商编号查询风险等级与黑名单状态(展示用),结果留痕")
public R<HcsRiskVO> risk(@Parameter(description = "客商编号", required = true) @RequestParam String customerCode) {
return R.data(hcsCreditCheckSupport.queryRiskByCustomerCode(customerCode));
}
/**
* 附件上传到合同系统(C103-2 代理,前端接入设计 §3.2),返回合规附件ID
*/
@PostMapping(value = "/attachment/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ApiOperationSupport(order = 11)
@Operation(summary = "附件上传到合同系统", description = "C103-2 代理:单文件上传至合规,返回合规附件ID(fdId)")
public R<String> uploadAttachment(@Parameter(description = "附件文件", required = true) @RequestParam("fdFile") MultipartFile fdFile) {
return R.data(hcsAttachmentSupport.uploadOne(fdFile, AuthUtil.getUserAccount()));
}
/**
* 合同附件下载(C103-5 代理,前端接入设计 §3.2):文件流透传浏览器
*/
@GetMapping("/attachment/download")
@ApiOperationSupport(order = 12)
@Operation(summary = "合规附件下载", description = "C103-5 代理:按合规附件ID 下载文件流,name 参数可选用于指定下载文件名")
public void downloadAttachment(@Parameter(description = "合规附件ID", required = true) @RequestParam String fdId,
@Parameter(description = "下载文件名(可选)") @RequestParam(required = false) String name,
HttpServletResponse response) throws Exception {
String fileName = StringUtil.isBlank(name) ? "hcs-attachment-" + fdId : name;
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"));
try (Response hcsResponse = hcsAttachmentSupport.downloadRaw(fdId);
InputStream inputStream = hcsResponse.body().asInputStream();
OutputStream outputStream = response.getOutputStream()) {
inputStream.transferTo(outputStream);
outputStream.flush();
}
}
/**
* 合同发起特批(C103-8,前端接入设计 §3.4):body = { contractId, attachments: [{fdId, name}] }
*/
@PostMapping("/contract/spec-apply")
@ApiOperationSupport(order = 13)
@Operation(summary = "合同发起特批", description = "C103-8:高风险客商合同特批发起,attachments 为经 /hcs/attachment/upload 上传的合规附件引用")
@SuppressWarnings("unchecked")
public R<String> applyContractSpec(@RequestBody Map<String, Object> body) {
Object contractIdValue = body == null ? null : body.get("contractId");
if (contractIdValue == null || StringUtil.isBlank(String.valueOf(contractIdValue))) {
return R.fail("contractId 不能为空");
}
Long contractId = Long.valueOf(String.valueOf(contractIdValue));
Object attachmentsValue = body == null ? null : body.get("attachments");
List<HcsAttachmentRefDTO> attachments = (attachmentsValue instanceof List<?> list ? list : List.<Object>of()).stream()
.filter(Map.class::isInstance)
.map(item -> {
Map<String, Object> attachment = (Map<String, Object>) item;
HcsAttachmentRefDTO ref = new HcsAttachmentRefDTO();
// 附件ID:C103-2 上传返回的合规附件ID
ref.setFdId(String.valueOf(attachment.get("fdId")));
// 附件名
ref.setFdFileName(attachment.get("name") == null ? null : String.valueOf(attachment.get("name")));
return ref;
})
.toList();
return R.data(hcsContractSpecSyncSupport.applySpec(contractId, attachments, AuthUtil.getUserAccount()));
}
/**
* 合同特批结果人工确认(§6-U1 过渡方案:B103 报文未到位前与回调等效)
*/
@PostMapping("/contract/spec/confirm")
@ApiOperationSupport(order = 14)
@Operation(summary = "合同特批结果人工确认", description = "传入合同ID 与结果(approved/rejected),仅特批中(processing)允许确认")
public R<String> confirmContractSpec(@Parameter(description = "合同ID", required = true) @RequestParam Long id,
@Parameter(description = "确认结果 approved/rejected", required = true) @RequestParam String result) {
return R.data(hcsContractSpecSyncSupport.confirmSpecResult(id, result));
}
/**
* B103 特批结果回传接收(合规系统→TMS 回调,公开端点 + 原文留痕,前端接入设计 §3.4)
* <p>
* TODO 待联调(§6-U1):B103 报文未定,当前按候选字段(contractNo/fdId/result 等)解析,
* 解析失败仅留痕不置状态,报文口径确认后完善解析。
*/
@PostMapping("/callback/spec-result")
@PreAuth(AuthConstant.PERMIT_ALL)
@ApiOperationSupport(order = 15)
@Operation(summary = "B103特批结果回传", description = "合规系统回调:合同特批结果回传接收,原文留痕并回写合同特批状态")
public R<String> specResultCallback(@RequestBody(required = false) Map<String, Object> body) {
return R.data(hcsContractSpecSyncSupport.handleSpecCallback(body));
}
}
@@ -33,6 +33,7 @@ import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.hcs.config.HcsProperties;
import org.springblade.hcs.constant.HcsConstant;
import org.springblade.hcs.dto.HcsAttachmentDownloadDTO;
import org.springblade.hcs.dto.HcsAttachmentVO;
import org.springblade.hcs.dto.HcsResult;
import org.springblade.hcs.exception.HcsException;
@@ -123,6 +124,44 @@ public class HcsAttachmentSupport {
return attachmentFdIds;
}
/**
* 前端单文件直传合规(C103-2 代理,前端接入设计 §3.2):返回合规附件ID;
* 不写映射缓存(无 TMS 附件元数据键),调用方自行持久化 fdId(如特批材料 hcs_spec_files_json)
*
* @param file 上传文件
* @param loginName 操作人登录名(fdLoginName)
* @return 合规附件ID
*/
public String uploadOne(MultipartFile file, String loginName) {
if (file == null || file.isEmpty()) {
throw new HcsException("HCS附件上传失败:文件为空");
}
HcsResult<HcsAttachmentVO> result = hcsClient.uploadAttachment(file, "false", loginName);
if (result == null || !result.isSuccess() || result.getData() == null || StringUtil.isBlank(result.getData().getFdId())) {
log.error("HCS附件上传失败响应:{}", result == null ? "无响应" : String.format("code=%s, msg=%s, traceId=%s",
result.getCode(), result.getMsg(), result.getTraceId()));
throw new HcsException("HCS附件上传失败:" + (result == null ? "响应为空" : result.getMsg()));
}
return result.getData().getFdId();
}
/**
* 合规附件下载(C103-5 代理,前端接入设计 §3.2):返回原始文件流响应,由控制器透传浏览器
*
* @param fdId 合规附件ID
* @return Feign 文件流响应(调用方负责关闭)
*/
public feign.Response downloadRaw(String fdId) {
HcsAttachmentDownloadDTO param = new HcsAttachmentDownloadDTO();
// 【必填】附件ID:上传时返回的 fdId
param.setFdId(fdId);
feign.Response response = hcsClient.downloadAttachment(param);
if (response == null || response.body() == null) {
throw new HcsException("HCS附件下载失败:响应为空(fdId=" + fdId + ")");
}
return response;
}
/**
* 转传单个附件:映射缓存命中直接返回,未命中下载上传后写缓存
*/
@@ -0,0 +1,352 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support.hcs;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.hcs.dto.HcsAttachmentRefDTO;
import org.springblade.hcs.dto.HcsMechanismsDTO;
import org.springblade.hcs.dto.HcsResult;
import org.springblade.hcs.dto.HcsSpecContractSaveDTO;
import org.springblade.hcs.dto.HcsFormSaveVO;
import org.springblade.hcs.exception.HcsException;
import org.springblade.hcs.feign.IHcsClient;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.HcsContractCheckLogMapper;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.HcsContractCheckLog;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* HCS 合同特批同步支撑组件(C103-8 发起特批 / B103 特批结果回传,前端接入设计 §3.4)
* <p>
* 业务闭环:合同推送时 C101-1 风险校验发现黑名单 → 置 hcs_spec_status=need 挂起(推送链路门禁跳过)→
* 内部特批审批通过后,前端上传特批材料(C103-2 取合规附件ID)并发起 C103-8 → 状态 processing(流程挂起)→
* 集团审批结果经 B103 回传 TMS → approved 后既有扫描任务自动恢复 C103-1 推送,rejected 可重新发起。
* <p>
* TODO 待联调确认(前端接入设计 §6-U1):C103-8 与 B103 报文 v4 均未提供,
* C103-8 按 C106-3 模式预估(双层嵌套返回),B103 回调按候选字段解析、原文留痕 blade_hcs_check_log。
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HcsContractSpecSyncSupport {
/**
* 特批附件字段标识(照 C106-3 fd_spc_approval_attach 惯例预估,§6-U1 以合规方报文为准)
*/
private static final String SPEC_ATTACH_ENTITY_KEY = "fd_spc_approval_attach";
/**
* B103 原文留痕最大长度(blade_hcs_check_log.msg 为 varchar(512))
*/
private static final int CALLBACK_MSG_MAX_LENGTH = 500;
private final IHcsClient hcsClient;
private final ContractManageMapper contractManageMapper;
private final HcsContractCheckLogMapper hcsContractCheckLogMapper;
/**
* 发起合同特批(C103-8):校验合同与特批状态 → 组装预估报文 → 回填特批流程ID,状态置 processing
*
* @param contractId 合同ID
* @param attachments 特批附件引用(fdId=C103-2 合规附件ID,fdFileName=文件名)
* @param loginName 当前操作人登录名
* @return 结果描述
*/
public String applySpec(Long contractId, List<HcsAttachmentRefDTO> attachments, String loginName) {
ContractManage contractManage = requireAppliableContract(contractId);
if (attachments == null || attachments.isEmpty()) {
throw new ServiceException("请先上传特批申请材料(含内部审批流程附件)");
}
HcsSpecContractSaveDTO param = new HcsSpecContractSaveDTO();
// 【必填】操作人登录名:当前登录人(§9-U2)
param.setFdLoginName(loginName);
// 【必填】合规合同ID:blade_contract_manage.hcs_fd_id(非名称,照 C106-3 fd_contract_name 惯例)
param.setFdContractId(requireContractFdId(contractManage));
// 【必填】合同编号
param.setFdContractNo(contractManage.getContractNo());
// 【必填】单据主题:"合同特批-" + 合同编号
param.setFdDocSubject("合同特批-" + contractManage.getContractNo());
// 特批事由:合同名称(非必传)
param.setFdReason(contractManage.getContractName());
// 特批附件:发起带 fdBindType=ADD(entityKey 预估照 C106-3,§6-U1)
param.setMechanisms(buildMechanisms(attachments));
// 特批材料留痕(含合规附件ID,供重新发起与排查)
contractManage.setHcsSpecFilesJson(JSONUtil.toJsonStr(attachments));
try {
log.info("HCS合同特批发起开始:contractNo={},附件 {} 个", contractManage.getContractNo(), attachments.size());
// TODO 待联调(§6-U1):报文按 C106-3 模式预估,双层嵌套 data.data.fdId
HcsResult<HcsResult<HcsFormSaveVO>> result = hcsClient.addSpecContract(param);
String specFdId = extractNestedFdId(contractManage, result);
contractManage.setHcsSpecFdId(specFdId);
markSpec(contractManage, HcsSyncConstant.SPEC_STATUS_PROCESSING, "C103-8 合同特批发起成功");
writeCheckLog(contractManage, HcsSyncConstant.CHECK_RESULT_PASS, "C103-8 特批发起成功,特批流程ID=" + specFdId, result.getTraceId());
log.info("HCS合同特批发起成功:contractNo={},specFdId={}", contractManage.getContractNo(), specFdId);
return "特批发起成功,等待集团审批";
} catch (Exception exception) {
String message = StringUtil.isBlank(exception.getMessage()) ? "特批发起异常" : exception.getMessage();
// 失败保持待特批(need),允许重试发起
markSpec(contractManage, HcsSyncConstant.SPEC_STATUS_NEED, "C103-8 特批发起失败:" + message);
writeCheckLog(contractManage, HcsSyncConstant.CHECK_RESULT_FAIL, "C103-8 特批发起失败:" + message, null);
throw exception instanceof ServiceException serviceException ? serviceException
: new ServiceException("合同特批发起失败:" + message);
}
}
/**
* 特批结果人工确认(§6-U1 过渡方案:B103 报文未到位前,页面确认与回调等效)
*
* @param id 合同ID
* @param result approved/rejected
* @return 结果描述
*/
public String confirmSpecResult(Long id, String result) {
ContractManage contractManage = contractManageMapper.selectById(id);
if (contractManage == null) {
throw new ServiceException("合同单不存在:" + id);
}
if (!List.of(HcsSyncConstant.SPEC_STATUS_APPROVED, HcsSyncConstant.SPEC_STATUS_REJECTED).contains(result)) {
throw new ServiceException("特批结果仅支持 approved/rejected");
}
if (!HcsSyncConstant.SPEC_STATUS_PROCESSING.equals(contractManage.getHcsSpecStatus())) {
throw new ServiceException("仅特批中的合同允许确认结果(当前状态:" + contractManage.getHcsSpecStatus() + ")");
}
markSpec(contractManage, result, "特批结果人工确认:" + result);
writeCheckLog(contractManage, HcsSyncConstant.SPEC_STATUS_APPROVED.equals(result)
? HcsSyncConstant.CHECK_RESULT_PASS : HcsSyncConstant.CHECK_RESULT_FAIL, "特批结果人工确认:" + result, null);
log.info("HCS合同特批结果人工确认:contractNo={},specFdId={},结果={}", contractManage.getContractNo(), contractManage.getHcsSpecFdId(), result);
return "特批结果已确认:" + result;
}
/**
* B103 特批结果回传处理(回调端点入口):候选字段解析 + 原文留痕,
* approved 时合同保持待同步(unsynced),由既有扫描任务自动恢复 C103-1 推送
*
* @param body B103 回调报文(结构待联调,原文全量留痕)
* @return 处理结果描述
*/
public String handleSpecCallback(Map<String, Object> body) {
String raw = body == null ? "" : JSONUtil.toJsonStr(body);
String contractNo = firstNonBlank(body, "fdContractNo", "contractNo", "fd_contract_no", "fdNumber");
String specFdId = firstNonBlank(body, "fdId", "fdSpecId", "fd_spec_id", "specFdId");
String resultText = firstNonBlank(body, "result", "fdResult", "fd_result", "approvalResult", "status");
ContractManage contractManage = locateContract(contractNo, specFdId);
if (contractManage == null) {
log.warn("B103特批结果回传未定位到合同:contractNo={},specFdId={},报文已留痕", contractNo, specFdId);
writeCallbackLog(null, HcsSyncConstant.CHECK_RESULT_SKIP, "B103 回传未定位到合同:" + raw);
return "已留痕:未定位到合同(contractNo=" + contractNo + ")";
}
Boolean approved = parseSpecResult(resultText);
if (approved == null) {
log.warn("B103特批结果字段未识别:contractNo={},resultText={},报文已留痕", contractManage.getContractNo(), resultText);
writeCallbackLog(contractManage, HcsSyncConstant.CHECK_RESULT_SKIP, "B103 回传结果字段未识别:" + raw);
return "已留痕:结果字段未识别(contractNo=" + contractManage.getContractNo() + ")";
}
String status = approved ? HcsSyncConstant.SPEC_STATUS_APPROVED : HcsSyncConstant.SPEC_STATUS_REJECTED;
markSpec(contractManage, status, "B103 特批结果回传:" + raw);
writeCallbackLog(contractManage, approved ? HcsSyncConstant.CHECK_RESULT_PASS : HcsSyncConstant.CHECK_RESULT_FAIL,
"B103 特批结果回传:" + raw);
log.info("B103特批结果回传处理完成:contractNo={},结果={}", contractManage.getContractNo(), status);
return "特批结果已处理:" + status;
}
/**
* 校验可发起特批的合同:存在、审批通过、特批状态为待特批/已驳回
*/
private ContractManage requireAppliableContract(Long contractId) {
ContractManage contractManage = contractManageMapper.selectById(contractId);
if (contractManage == null) {
throw new ServiceException("合同单不存在:" + contractId);
}
if (!List.of("approved", "change_approved").contains(contractManage.getApprovalStatus())) {
throw new ServiceException("仅审批通过的合同可发起特批(当前状态:" + contractManage.getApprovalStatus() + ")");
}
if (!List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_REJECTED).contains(contractManage.getHcsSpecStatus())) {
throw new ServiceException("仅待特批/已驳回的合同可发起特批(当前状态:" + contractManage.getHcsSpecStatus() + ")");
}
return contractManage;
}
/**
* 合规合同ID(合同须已推送合规回填 hcs_fd_id)
*/
private String requireContractFdId(ContractManage contractManage) {
if (StringUtil.isBlank(contractManage.getHcsFdId())) {
throw new ServiceException("合同尚未推送合规,无法发起特批(contractNo=" + contractManage.getContractNo() + ")");
}
return contractManage.getHcsFdId();
}
/**
* 特批附件挂载(发起带 fdBindType=ADD)
*/
private HcsMechanismsDTO buildMechanisms(List<HcsAttachmentRefDTO> attachments) {
HcsMechanismsDTO mechanisms = new HcsMechanismsDTO();
mechanisms.setAttachment(attachments.stream().map(ref -> {
HcsAttachmentRefDTO attachment = new HcsAttachmentRefDTO();
// 附件ID:C103-2 转传返回的合规附件ID
attachment.setFdId(ref.getFdId());
// 附件名(预估字段,§6-U1)
attachment.setFdFileName(ref.getFdFileName());
// 特批附件字段标识
attachment.setFdEntityKey(SPEC_ATTACH_ENTITY_KEY);
// 绑定类型:发起传 ADD
attachment.setFdBindType("ADD");
return attachment;
}).toList());
return mechanisms;
}
/**
* 解析 C103-8 双层嵌套返回 data.data.fdId(照 C106-3 模式预估)
*/
private String extractNestedFdId(ContractManage contractManage, HcsResult<HcsResult<HcsFormSaveVO>> result) {
if (result == null || !result.isSuccess() || result.getData() == null || !result.getData().isSuccess()
|| result.getData().getData() == null || StringUtil.isBlank(result.getData().getData().getFdId())) {
log.error("HCS合同特批发起失败响应:{},contractNo={}", result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), contractManage.getContractNo());
throw new HcsException("C103-8 合同特批发起失败:" + (result == null ? "响应为空" : result.getMsg()));
}
return result.getData().getData().getFdId();
}
/**
* 按合同编号/特批流程ID 定位合同(B103 报文候选键)
*/
private ContractManage locateContract(String contractNo, String specFdId) {
if (StringUtil.isNotBlank(contractNo)) {
ContractManage contractManage = contractManageMapper.selectOne(Wrappers.<ContractManage>lambdaQuery()
.eq(ContractManage::getContractNo, contractNo)
.eq(ContractManage::getIsDeleted, 0)
.last("limit 1"));
if (contractManage != null) {
return contractManage;
}
}
if (StringUtil.isNotBlank(specFdId)) {
return contractManageMapper.selectOne(Wrappers.<ContractManage>lambdaQuery()
.eq(ContractManage::getHcsSpecFdId, specFdId)
.eq(ContractManage::getIsDeleted, 0)
.last("limit 1"));
}
return null;
}
/**
* 解析特批结果文本:approved/通过/pass/true → true;rejected/驳回/refuse/false → false;未识别 → null
*/
private Boolean parseSpecResult(String resultText) {
if (StringUtil.isBlank(resultText)) {
return null;
}
String text = resultText.trim().toLowerCase();
if (List.of("approved", "pass", "true", "通过", "审批通过", "同意").contains(text)) {
return Boolean.TRUE;
}
if (List.of("rejected", "refuse", "refused", "false", "驳回", "不通过", "不同意").contains(text)) {
return Boolean.FALSE;
}
return null;
}
/**
* 从候选字段名依次取首个非空字符串值
*/
private String firstNonBlank(Map<String, Object> body, String... keys) {
if (body == null) {
return null;
}
for (String key : keys) {
Object value = body.get(key);
if (value != null && StringUtil.isNotBlank(String.valueOf(value))) {
return String.valueOf(value);
}
}
return null;
}
/**
* 回写特批状态与动作时间
*/
private void markSpec(ContractManage contractManage, String status, String message) {
contractManage.setHcsSpecStatus(status);
contractManage.setHcsSpecTime(LocalDateTime.now());
contractManage.setHcsSpecMsg(StringUtil.left(message, 500));
contractManageMapper.updateById(contractManage);
}
/**
* 特批留痕(blade_hcs_check_log,biz_type=contract,check_type=spec,异常不影响主流程)
*/
private void writeCheckLog(ContractManage contractManage, String result, String msg, String traceId) {
try {
HcsContractCheckLog checkLog = new HcsContractCheckLog();
checkLog.setTenantId(contractManage.getTenantId());
checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT);
checkLog.setBizId(contractManage.getId());
checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_SPEC);
checkLog.setResult(result);
checkLog.setMsg(StringUtil.left(msg, CALLBACK_MSG_MAX_LENGTH));
checkLog.setTraceId(traceId);
checkLog.setCreateTime(LocalDateTime.now());
hcsContractCheckLogMapper.insert(checkLog);
} catch (Exception exception) {
log.error("HCS合同特批留痕写入失败:contractNo={}", contractManage.getContractNo(), exception);
}
}
/**
* B103 回传留痕(合同未定位到时 bizId 为空,原文留痕便于联调解析)
*/
private void writeCallbackLog(ContractManage contractManage, String result, String msg) {
if (contractManage == null) {
try {
HcsContractCheckLog checkLog = new HcsContractCheckLog();
checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT);
checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_SPEC);
checkLog.setResult(result);
checkLog.setMsg(StringUtil.left(msg, CALLBACK_MSG_MAX_LENGTH));
checkLog.setCreateTime(LocalDateTime.now());
hcsContractCheckLogMapper.insert(checkLog);
} catch (Exception exception) {
log.error("B103回传留痕写入失败", exception);
}
return;
}
writeCheckLog(contractManage, result, msg, null);
}
}
@@ -100,15 +100,22 @@ public class HcsContractSyncSupport {
private final HcsExeStageSyncSupport hcsExeStageSyncSupport;
/**
* 推送合同到 HCS:风险校验 → 附件转传 → 判重 → 起草/更新 → 回填合规 fdId 置 synced
* 推送合同到 HCS:特批挂起门禁 → 风险校验(黑名单挂起待特批)→ 附件转传 → 判重 → 起草/更新 → 回填合规 fdId 置 synced
*
* @param contractManage 合同单(approvalStatus 须为 approved/change_approved)
* @return 推送结果描述(供任务汇总与 hcs_sync_msg 落库)
*/
public String pushContract(ContractManage contractManage) {
// ① 客商风险校验(可配开关),黑名单且 block-blacklist=true 时阻断推送
// ⓪ 特批挂起门禁:待特批/特批中的合同跳过推送,等待 B103 特批结果回传后恢复(C103-8,前端接入设计 §3.4)
if (List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(contractManage.getHcsSpecStatus())) {
log.info("HCS合同推送跳过(特批挂起中):contractNo={},hcsSpecStatus={}", contractManage.getContractNo(), contractManage.getHcsSpecStatus());
return "合同特批挂起中(" + contractManage.getHcsSpecStatus() + "),跳过推送";
}
// ① 客商风险校验(可配开关),黑名单且 block-blacklist=true 时挂起待特批(不再直接失败)
if (hcsProperties.getContract().isRequireRiskCheck()) {
checkRelativeRisk(contractManage);
if (checkRelativeRisk(contractManage)) {
return "高风险客商,合同已挂起待特批(C103-8)";
}
}
// ② 附件转传(先转传并缓存合规附件ID,C103-1 挂载方式待 §9-U8 确认后接线)
transferAttachmentsIfEnabled(contractManage);
@@ -139,9 +146,13 @@ public class HcsContractSyncSupport {
}
/**
* C101-1 客商风险校验:甲乙双方各查一次,留痕 check_log 与合同 hcs_risk_* 字段,黑名单阻断
* C101-1 客商风险校验:甲乙双方各查一次,留痕 check_log 与合同 hcs_risk_* 字段;
* 黑名单且 block-blacklist=true 时不再直接失败,而是置 hcs_spec_status=need 挂起待特批(C103-8);
* 特批已通过(approved,单次有效)的合同不重复挂起,保证 B103 回传后推送链路可走通
*
* @return true=黑名单挂起待特批(调用方跳过本次推送)
*/
private void checkRelativeRisk(ContractManage contractManage) {
private boolean checkRelativeRisk(ContractManage contractManage) {
RiskResult riskPartyA = queryRisk(contractManage, "甲方", contractManage.getPartyA());
RiskResult riskPartyB = queryRisk(contractManage, "乙方", contractManage.getPartyB());
// 风险等级:甲乙双方取先查到的非空值(无法量化比较,留痕双方原始值,联调后细化)
@@ -155,11 +166,17 @@ public class HcsContractSyncSupport {
log.info("HCS合同风险校验完成:contractNo={},甲方[{}={}/{}],乙方[{}={}/{}]",
contractManage.getContractNo(), contractManage.getPartyA(), riskPartyA.blackStatus(), riskPartyA.riskLevel(),
contractManage.getPartyB(), riskPartyB.blackStatus(), riskPartyB.riskLevel());
if (blacklist && hcsProperties.getRisk().isBlockBlacklist()) {
String message = "客商黑名单,禁止推送(甲方[" + riskPartyA.blackStatus() + "]乙方[" + riskPartyB.blackStatus() + "])";
log.error("HCS合同推送被风险校验阻断:contractNo={},{}", contractManage.getContractNo(), message);
throw new HcsException(message);
boolean specApproved = HcsSyncConstant.SPEC_STATUS_APPROVED.equals(contractManage.getHcsSpecStatus());
if (blacklist && hcsProperties.getRisk().isBlockBlacklist() && !specApproved) {
String message = "高风险客商(甲方[" + riskPartyA.blackStatus() + "]乙方[" + riskPartyB.blackStatus() + "]),合同挂起待特批";
contractManage.setHcsSpecStatus(HcsSyncConstant.SPEC_STATUS_NEED);
contractManage.setHcsSpecTime(LocalDateTime.now());
contractManage.setHcsSpecMsg(message);
contractManageMapper.updateById(contractManage);
log.warn("HCS合同推送因高风险客商挂起待特批:contractNo={},{}", contractManage.getContractNo(), message);
return true;
}
return false;
}
/**
@@ -367,6 +384,9 @@ public class HcsContractSyncSupport {
if (!List.of("approved", "change_approved").contains(contractManage.getApprovalStatus())) {
throw new ServiceException("仅审批通过的合同可推送合规(当前状态:" + contractManage.getApprovalStatus() + ")");
}
if (List.of(HcsSyncConstant.SPEC_STATUS_NEED, HcsSyncConstant.SPEC_STATUS_PROCESSING).contains(contractManage.getHcsSpecStatus())) {
throw new ServiceException("高风险客商合同需先完成特批(C103-8,当前特批状态:" + contractManage.getHcsSpecStatus() + ")");
}
contractManage.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED);
contractManage.setHcsRetryCount(0);
contractManageMapper.updateById(contractManage);
@@ -170,6 +170,44 @@ public class HcsCreditCheckSupport {
log.warn("HCS付款提交额度超额(转特批):paymentNo={},{}", payment.getPaymentNo(), message);
}
/**
* 前端客商风险查询(C101-1,展示用,前端接入设计 §3.3):
* 按客商编号查询风险等级与黑名单状态并留痕(biz_id 为空);仅用于展示提示,不作为安全边界
*
* @param customerCode 客商编号
* @return 风险信息(fdBlackStatus/fdRiskLevel)
*/
public HcsRiskVO queryRiskByCustomerCode(String customerCode) {
if (StringUtil.isBlank(customerCode)) {
throw new ServiceException("客商编号不能为空");
}
HcsRiskQueryDTO param = new HcsRiskQueryDTO();
// 【必填】客商编号(用哪个编码待联调,§9-U6)
param.setFdNumber(customerCode);
HcsResult<HcsRiskVO> result = hcsClient.getRelativeRisk(param);
if (result == null || !result.isSuccess()) {
log.error("HCS客商风险查询失败响应:{},fdNumber={}",
result == null ? "无响应" : String.format("msg=%s, traceId=%s", result.getMsg(), result.getTraceId()), customerCode);
throw new HcsException("C101-1 客商风险查询失败:" + (result == null ? "响应为空" : result.getMsg()));
}
HcsRiskVO risk = result.getData();
String blackStatus = risk == null ? null : risk.getFdBlackStatus();
boolean blacklist = BLACK_STATUS_YES.equalsIgnoreCase(blackStatus);
try {
HcsContractCheckLog checkLog = new HcsContractCheckLog();
checkLog.setBizType(HcsSyncConstant.BIZ_TYPE_CONTRACT);
checkLog.setCheckType(HcsSyncConstant.CHECK_TYPE_RISK);
checkLog.setResult(blacklist ? HcsSyncConstant.CHECK_RESULT_FAIL : HcsSyncConstant.CHECK_RESULT_PASS);
checkLog.setMsg("页面风险查询[" + customerCode + "]黑名单=" + blackStatus + ",风险等级=" + (risk == null ? null : risk.getFdRiskLevel()));
checkLog.setTraceId(result.getTraceId());
checkLog.setCreateTime(LocalDateTime.now());
hcsContractCheckLogMapper.insert(checkLog);
} catch (Exception exception) {
log.error("HCS页面风险查询留痕写入失败:fdNumber={}", customerCode, exception);
}
return risk;
}
/**
* 按名称反查客商编码(照 KingdeeApBillSyncSupport#queryCustomerCode 惯例,查不到返回 null)
*/