feat: HCS主数据缓存与附件转传支撑(C103-6/7/12模板分类币种缓存、C103-2附件转传与合规附件ID映射缓存)

This commit is contained in:
刘泉佳
2026-09-27 22:47:40 +08:00
parent 2cf71afeb4
commit f01cd9ec71
4 changed files with 512 additions and 0 deletions
@@ -0,0 +1,290 @@
/**
* 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.http.HttpUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.redis.cache.BladeRedis;
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.HcsAttachmentVO;
import org.springblade.hcs.dto.HcsResult;
import org.springblade.hcs.exception.HcsException;
import org.springblade.hcs.feign.IHcsClient;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* HCS 附件转传支撑组件(C103-2 multipart 上传,设计文档 §6)
* <p>
* 流程:解析单据附件 JSON 元素(去重键照 ContractManage#attachmentKey 惯例)→
* 按元数据 URL 从文件服务拉取流(基址可配 attachment.file-download-base)→
* C103-2 上传取得合规附件ID → Redis 映射缓存(blade:third:hcs:att:{attachmentKey},TTL 7 天),
* 未命中重传(重复上传仅浪费存储,无业务风险,故不做持久化映射表)。
* 任一附件转传失败 → 抛出异常由整单推送失败进补偿重试。
* <p>
* TODO 待联调确认(§9-U8):v4 的 C103-1 入参未定义附件字段,合同附件向 HCS 的挂载方式待合规方确认,
* 本组件先建设通用转传能力并将合规附件ID 缓存备用,确认后按其要求挂载。
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HcsAttachmentSupport {
/**
* 附件元数据去重键候选字段(照 ContractManage#attachmentKey 既有惯例)
*/
private static final List<String> KEY_FIELDS = List.of("url", "link", "fileUrl", "downloadUrl", "uid", "originalName", "name");
private final IHcsClient hcsClient;
private final HcsProperties hcsProperties;
private final BladeRedis bladeRedis;
/**
* 解析单据附件 JSON 并转传,返回合规附件ID 列表(保持行序)
*
* @param attachmentsJson 附件元数据 JSON 数组
* @param operatorLoginName 操作人登录名(C103-2 fdLoginName)
* @return 合规附件ID 列表
*/
public List<String> transferAttachments(String attachmentsJson, String operatorLoginName) {
if (StringUtil.isBlank(attachmentsJson)) {
return List.of();
}
List<Map<?, ?>> attachments = parseJsonArray(attachmentsJson);
List<String> attachmentFdIds = new ArrayList<>();
for (Map<?, ?> attachment : attachments) {
attachmentFdIds.add(transferOne(attachment, operatorLoginName));
}
return attachmentFdIds;
}
/**
* 解析合同主文件 JSON(仅 approved=true 的文件)与其它附件 JSON 并转传
*
* @param contractFileJson 合同主文件 JSON
* @param attachmentsJson 其它附件 JSON
* @param operatorLoginName 操作人登录名
* @return 合规附件ID 列表
*/
public List<String> transferContractAttachments(String contractFileJson, String attachmentsJson, String operatorLoginName) {
List<Map<?, ?>> attachments = new ArrayList<>();
for (Map<?, ?> file : parseJsonArray(contractFileJson)) {
if (isApproved(file)) {
attachments.add(file);
}
}
attachments.addAll(parseJsonArray(attachmentsJson));
List<String> attachmentFdIds = new ArrayList<>();
for (Map<?, ?> attachment : attachments) {
attachmentFdIds.add(transferOne(attachment, operatorLoginName));
}
return attachmentFdIds;
}
/**
* 转传单个附件:映射缓存命中直接返回,未命中下载上传后写缓存
*/
private String transferOne(Map<?, ?> attachment, String operatorLoginName) {
String attachmentKey = attachmentKey(attachment);
String cacheKey = HcsSyncConstant.ATTACHMENT_CACHE_KEY_PREFIX + attachmentKey;
String cachedFdId = bladeRedis.get(cacheKey);
if (StringUtil.isNotBlank(cachedFdId)) {
return cachedFdId;
}
String url = resolveUrl(attachment);
if (StringUtil.isBlank(url)) {
throw new HcsException("HCS附件转传失败,附件元数据缺少下载地址(attachmentKey=" + attachmentKey + ")");
}
byte[] fileBytes = downloadFile(url);
String fileName = resolveFileName(attachment, url);
log.info("HCS附件转传开始:{},大小 {} 字节", fileName, fileBytes.length);
HcsResult<HcsAttachmentVO> result = hcsClient.uploadAttachment(
new InMemoryMultipartFile("fdFile", fileName, fileBytes), "false", operatorLoginName);
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()));
}
String attachmentFdId = result.getData().getFdId();
bladeRedis.getValueOps().setIfAbsent(cacheKey, attachmentFdId, Duration.ofDays(HcsSyncConstant.ATTACHMENT_CACHE_TTL_DAYS));
log.info("HCS附件转传完成:{} → 合规附件ID {}", fileName, attachmentFdId);
return attachmentFdId;
}
/**
* 按元数据 URL 从文件服务拉取流:完整 URL 直接下载,相对路径拼可配基址
*/
private byte[] downloadFile(String url) {
String target = url;
if (!url.startsWith("http://") && !url.startsWith("https://")) {
String base = hcsProperties.getAttachment().getFileDownloadBase();
if (StringUtil.isBlank(base)) {
throw new HcsException("HCS附件转传失败,文件服务下载基址未配置(thirdParty.hcs.attachment.file-download-base)");
}
target = base + url;
}
try {
return HttpUtil.downloadBytes(target);
} catch (Exception exception) {
log.error("HCS附件转传下载失败:{}", target, exception);
throw new HcsException("HCS附件转传下载失败:" + target, exception);
}
}
/**
* 附件元数据去重键(url|link|fileUrl|downloadUrl|uid|originalName|name 依次取首个非空)
*/
private String attachmentKey(Map<?, ?> attachment) {
for (String field : KEY_FIELDS) {
Object value = attachment.get(field);
if (value != null && !String.valueOf(value).isBlank()) {
return field + ":" + value;
}
}
return String.valueOf(attachment);
}
/**
* 提取附件下载地址
*/
private String resolveUrl(Map<?, ?> attachment) {
for (String field : List.of("url", "link", "fileUrl", "downloadUrl", "path")) {
Object value = attachment.get(field);
if (value != null && !String.valueOf(value).isBlank()) {
return String.valueOf(value).trim();
}
}
return null;
}
/**
* 提取附件文件名(name/originalName 优先,缺失时取 URL 末段)
*/
private String resolveFileName(Map<?, ?> attachment, String url) {
for (String field : List.of("name", "originalName", "fileName")) {
Object value = attachment.get(field);
if (value != null && !String.valueOf(value).isBlank()) {
return String.valueOf(value);
}
}
String lastSegment = url.substring(url.lastIndexOf('/') + 1);
return StringUtil.isBlank(lastSegment) ? "hcs-attachment" : lastSegment;
}
/**
* 主文件审批标记判定(照 ContractManage#isAttachmentApproved 惯例)
*/
private boolean isApproved(Map<?, ?> attachment) {
Object approved = attachment.get("approved");
if (approved == null) {
return false;
}
return Boolean.TRUE.equals(approved) || Objects.equals(approved, 1)
|| "1".equals(String.valueOf(approved)) || "true".equalsIgnoreCase(String.valueOf(approved));
}
/**
* 解析 JSON 数组(照 ContractManage#parseJsonArray 惯例,异常返回空列表)
*/
private List<Map<?, ?>> parseJsonArray(String json) {
if (StringUtil.isBlank(json)) {
return List.of();
}
try {
return JsonUtil.parse(json, List.class);
} catch (Exception exception) {
log.warn("HCS附件元数据解析失败,按无附件处理:{}", json);
return List.of();
}
}
/**
* 内存 MultipartFile(下载字节流转传 C103-2 用)
*/
private record InMemoryMultipartFile(String name, String originalFilename, byte[] bytes) implements MultipartFile {
@Override
public String getName() {
return name;
}
@Override
public String getOriginalFilename() {
return originalFilename;
}
@Override
public String getContentType() {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
@Override
public boolean isEmpty() {
return bytes == null || bytes.length == 0;
}
@Override
public long getSize() {
return bytes == null ? 0 : bytes.length;
}
@Override
public byte[] getBytes() {
return bytes;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(bytes);
}
@Override
public void transferTo(File dest) throws IOException {
try (FileOutputStream outputStream = new FileOutputStream(dest)) {
outputStream.write(bytes);
}
}
}
}
@@ -0,0 +1,206 @@
/**
* 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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.redis.cache.BladeRedis;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.hcs.config.HcsProperties;
import org.springblade.hcs.dto.HcsCategoryVO;
import org.springblade.hcs.dto.HcsCurrencyVO;
import org.springblade.hcs.dto.HcsPageQueryDTO;
import org.springblade.hcs.dto.HcsPageVO;
import org.springblade.hcs.dto.HcsTemplateVO;
import org.springblade.hcs.exception.HcsException;
import org.springblade.hcs.feign.IHcsClient;
import org.springblade.hcs.util.HcsPageUtil;
import org.springblade.transport.constant.HcsSyncConstant;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* HCS 主数据缓存支撑组件(C103-6 模板 / C103-7 分类 / C103-12 币种,设计文档 §5.9)
* <p>
* 启动/首次使用时拉取并缓存(Redis,照 BFI token 缓存惯例,TTL 可配 master-data.ttl-minutes):
* 模板列表供 template-mapping 校验,币种表供 currency-id-mapping 取值。
* 提供管理端"刷新缓存"入口(HcsSyncController)。
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HcsMasterDataSupport {
/**
* 主数据分页拉取页大小
*/
private static final int PAGE_SIZE = 200;
private final IHcsClient hcsClient;
private final HcsProperties hcsProperties;
private final BladeRedis bladeRedis;
/**
* 获取合同模板列表(缓存优先,未命中全量拉取)
*/
public List<HcsTemplateVO> getTemplates() {
List<HcsTemplateVO> cached = readCache(HcsSyncConstant.MASTER_CACHE_TEMPLATES, HcsTemplateVO.class);
if (cached != null) {
return cached;
}
return loadTemplates();
}
/**
* 获取合同分类列表(缓存优先,未命中全量拉取)
*/
public List<HcsCategoryVO> getCategories() {
List<HcsCategoryVO> cached = readCache(HcsSyncConstant.MASTER_CACHE_CATEGORIES, HcsCategoryVO.class);
if (cached != null) {
return cached;
}
return loadCategories();
}
/**
* 获取币种列表(缓存优先,未命中全量拉取)
*/
public List<HcsCurrencyVO> getCurrencies() {
List<HcsCurrencyVO> cached = readCache(HcsSyncConstant.MASTER_CACHE_CURRENCIES, HcsCurrencyVO.class);
if (cached != null) {
return cached;
}
return loadCurrencies();
}
/**
* 强制刷新三类主数据缓存(管理端入口),并对 template-mapping 做存在性校验
*
* @return 刷新结果汇总
*/
public String refreshAll() {
int templateCount = loadTemplates().size();
int categoryCount = loadCategories().size();
int currencyCount = loadCurrencies().size();
validateTemplateMapping();
String summary = String.format("HCS主数据缓存已刷新:模板 %d 条、分类 %d 条、币种 %d 条", templateCount, categoryCount, currencyCount);
log.info(summary);
return summary;
}
/**
* 全量拉取合同模板并缓存
*/
private List<HcsTemplateVO> loadTemplates() {
HcsPageQueryDTO query = baseQuery();
List<HcsTemplateVO> rows = HcsPageUtil.queryAllPages(offset -> {
query.setOffset(offset);
return hcsClient.templateList(query).getData();
});
writeCache(HcsSyncConstant.MASTER_CACHE_TEMPLATES, rows);
log.info("HCS合同模板列表已缓存,共 {} 条", rows.size());
return rows;
}
/**
* 全量拉取合同分类并缓存
*/
private List<HcsCategoryVO> loadCategories() {
HcsPageQueryDTO query = baseQuery();
List<HcsCategoryVO> rows = HcsPageUtil.queryAllPages(offset -> {
query.setOffset(offset);
return hcsClient.categoryList(query).getData();
});
writeCache(HcsSyncConstant.MASTER_CACHE_CATEGORIES, rows);
log.info("HCS合同分类列表已缓存,共 {} 条", rows.size());
return rows;
}
/**
* 全量拉取币种并缓存
*/
private List<HcsCurrencyVO> loadCurrencies() {
HcsPageQueryDTO query = baseQuery();
List<HcsCurrencyVO> rows = HcsPageUtil.queryAllPages(offset -> {
query.setOffset(offset);
return hcsClient.currencyList(query).getData();
});
writeCache(HcsSyncConstant.MASTER_CACHE_CURRENCIES, rows);
log.info("HCS币种列表已缓存,共 {} 条", rows.size());
return rows;
}
/**
* 校验 contract.template-mapping 中的模板ID是否存在于 HCS 模板列表(§9-U7,仅告警不阻断)
*/
private void validateTemplateMapping() {
Map<String, String> mapping = hcsProperties.getContract().getTemplateMapping();
if (mapping.isEmpty()) {
log.warn("HCS合同模板映射(thirdParty.hcs.contract.template-mapping)未配置,合同推送将失败(§9-U7 待合规方提供模板ID清单)");
return;
}
List<String> templateIds = getTemplates().stream().map(HcsTemplateVO::getFdId).toList();
mapping.forEach((category, templateId) -> {
if (StringUtil.isNotBlank(templateId) && !templateIds.contains(templateId)) {
log.warn("HCS合同模板映射校验告警:合同类别 [{}] 映射的模板ID [{}] 不在合规模板列表中", category, templateId);
}
});
}
/**
* 分页查询基础参数(columns 必填)
*/
private HcsPageQueryDTO baseQuery() {
HcsPageQueryDTO query = new HcsPageQueryDTO();
query.setPageSize(PAGE_SIZE);
return query;
}
/**
* 读缓存(JSON 字符串反序列化)
*/
private <T> List<T> readCache(String cacheKey, Class<T> itemType) {
String json = bladeRedis.get(cacheKey);
if (StringUtil.isBlank(json)) {
return null;
}
return JSONUtil.toList(json, itemType);
}
/**
* 写缓存(JSON 字符串 + 可配 TTL)
*/
private void writeCache(String cacheKey, List<?> rows) {
bladeRedis.getValueOps().setIfAbsent(cacheKey, JSONUtil.toJsonStr(rows),
Duration.ofMinutes(hcsProperties.getMasterData().getTtlMinutes()));
}
}
@@ -112,6 +112,10 @@ public class HcsProperties {
* 附件转传(C103-2)配置
*/
private Attachment attachment = new Attachment();
/**
* 主数据缓存(C103-6/7/12)配置
*/
private MasterData masterData = new MasterData();
@Data
public static class Push {
@@ -252,4 +256,12 @@ public class HcsProperties {
*/
private String fileDownloadBase = "";
}
@Data
public static class MasterData {
/**
* 模板/分类/币种列表缓存时长(分钟),启动/首次使用时拉取并缓存(设计文档 §5.9)
*/
private int ttlMinutes = 120;
}
}
+4
View File
@@ -144,3 +144,7 @@ thirdParty:
transfer-enabled: ${HCS_ATTACHMENT_TRANSFER_ENABLED:true}
# TMS 文件服务下载基址(拉取附件流用)
file-download-base: ${HCS_ATTACHMENT_FILE_DOWNLOAD_BASE:}
# 主数据缓存(C103-6/7/12,启动/首次使用时拉取并缓存)
master-data:
# 缓存时长(分钟)
ttl-minutes: ${HCS_MASTER_DATA_TTL_MINUTES:120}