diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java new file mode 100644 index 00000000..e505f910 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsAttachmentSupport.java @@ -0,0 +1,290 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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) + *

+ * 流程:解析单据附件 JSON 元素(去重键照 ContractManage#attachmentKey 惯例)→ + * 按元数据 URL 从文件服务拉取流(基址可配 attachment.file-download-base)→ + * C103-2 上传取得合规附件ID → Redis 映射缓存(blade:third:hcs:att:{attachmentKey},TTL 7 天), + * 未命中重传(重复上传仅浪费存储,无业务风险,故不做持久化映射表)。 + * 任一附件转传失败 → 抛出异常由整单推送失败进补偿重试。 + *

+ * TODO 待联调确认(§9-U8):v4 的 C103-1 入参未定义附件字段,合同附件向 HCS 的挂载方式待合规方确认, + * 本组件先建设通用转传能力并将合规附件ID 缓存备用,确认后按其要求挂载。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HcsAttachmentSupport { + + /** + * 附件元数据去重键候选字段(照 ContractManage#attachmentKey 既有惯例) + */ + private static final List 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 transferAttachments(String attachmentsJson, String operatorLoginName) { + if (StringUtil.isBlank(attachmentsJson)) { + return List.of(); + } + List> attachments = parseJsonArray(attachmentsJson); + List 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 transferContractAttachments(String contractFileJson, String attachmentsJson, String operatorLoginName) { + List> attachments = new ArrayList<>(); + for (Map file : parseJsonArray(contractFileJson)) { + if (isApproved(file)) { + attachments.add(file); + } + } + attachments.addAll(parseJsonArray(attachmentsJson)); + List 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 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> 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); + } + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsMasterDataSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsMasterDataSupport.java new file mode 100644 index 00000000..d09c6836 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsMasterDataSupport.java @@ -0,0 +1,206 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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) + *

+ * 启动/首次使用时拉取并缓存(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 getTemplates() { + List cached = readCache(HcsSyncConstant.MASTER_CACHE_TEMPLATES, HcsTemplateVO.class); + if (cached != null) { + return cached; + } + return loadTemplates(); + } + + /** + * 获取合同分类列表(缓存优先,未命中全量拉取) + */ + public List getCategories() { + List cached = readCache(HcsSyncConstant.MASTER_CACHE_CATEGORIES, HcsCategoryVO.class); + if (cached != null) { + return cached; + } + return loadCategories(); + } + + /** + * 获取币种列表(缓存优先,未命中全量拉取) + */ + public List getCurrencies() { + List 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 loadTemplates() { + HcsPageQueryDTO query = baseQuery(); + List 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 loadCategories() { + HcsPageQueryDTO query = baseQuery(); + List 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 loadCurrencies() { + HcsPageQueryDTO query = baseQuery(); + List 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 mapping = hcsProperties.getContract().getTemplateMapping(); + if (mapping.isEmpty()) { + log.warn("HCS合同模板映射(thirdParty.hcs.contract.template-mapping)未配置,合同推送将失败(§9-U7 待合规方提供模板ID清单)"); + return; + } + List 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 List readCache(String cacheKey, Class 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())); + } +} diff --git a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java index a753f6b4..93f2fefb 100644 --- a/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java +++ b/blade-third-party-api/blade-hcs-api/src/main/java/org/springblade/hcs/config/HcsProperties.java @@ -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; + } } diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index 2345de68..db3628d9 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -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}