diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsSyncController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsSyncController.java new file mode 100644 index 00000000..8392512f --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/HcsSyncController.java @@ -0,0 +1,106 @@ +/** + * 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.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tool.api.R; +import org.springblade.transport.constant.HcsSyncConstant; +import org.springblade.transport.support.hcs.HcsContractSyncSupport; +import org.springblade.transport.support.hcs.HcsMasterDataSupport; +import org.springblade.transport.support.hcs.HcsProjectSyncSupport; +import org.springblade.transport.task.HcsPushTask; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * HCS 合规推送端点(设计文档 §5.0 手动入口) + *

+ * 前端列表页"推送合规/重推"按钮(照"同步金蝶"按钮模式): + * 单条重推重置 hcs_sync_status=unsynced 并清零重试计数后立即推送;批量端点复用定时扫描逻辑。 + * 排查推送问题以 hcs_sync_status/hcs_sync_msg 为准,不以审批日志为准(ADR-0001)。 + * + * @author Chill + */ +@Slf4j +@RestController +@AllArgsConstructor +@RequestMapping("hcs") +@Tag(name = "HCS合规推送", description = "HCS合规推送端点") +public class HcsSyncController { + + private final HcsPushTask hcsPushTask; + private final HcsProjectSyncSupport hcsProjectSyncSupport; + private final HcsContractSyncSupport hcsContractSyncSupport; + private final HcsMasterDataSupport hcsMasterDataSupport; + + /** + * 单条重推(bizType:project=项目立项 / contract=合同) + */ + @PostMapping("/sync/{bizType}/{id}") + @ApiOperationSupport(order = 1) + @Operation(summary = "单条推送/重推合规", description = "重置待同步状态后立即推送,bizType:project=项目立项、contract=合同") + public R repush(@Parameter(description = "业务类型", required = true) @PathVariable String bizType, + @Parameter(description = "单据ID", required = true) @PathVariable Long id) { + String result = switch (bizType) { + case HcsSyncConstant.BIZ_TYPE_PROJECT -> hcsProjectSyncSupport.repushProject(id); + case HcsSyncConstant.BIZ_TYPE_CONTRACT -> hcsContractSyncSupport.repushContract(id); + default -> throw new IllegalArgumentException("不支持的HCS推送业务类型:" + bizType); + }; + return R.data(result); + } + + /** + * 批量推送(bizType:project=项目立项 / contract=合同;复用定时扫描逻辑,单轮批量上限 push.batch-size) + */ + @PostMapping("/sync/{bizType}") + @ApiOperationSupport(order = 2) + @Operation(summary = "批量推送合规", description = "扫描待同步/待补偿单据推送一轮,bizType:project=项目立项、contract=合同") + public R pushBatch(@Parameter(description = "业务类型", required = true) @PathVariable String bizType) { + String result = switch (bizType) { + case HcsSyncConstant.BIZ_TYPE_PROJECT -> hcsPushTask.pushUnsyncedProjects(); + case HcsSyncConstant.BIZ_TYPE_CONTRACT -> hcsPushTask.pushUnsyncedContracts(); + default -> throw new IllegalArgumentException("不支持的HCS推送业务类型:" + bizType); + }; + return R.data(result); + } + + /** + * 刷新主数据缓存(C103-6 模板 / C103-7 分类 / C103-12 币种) + */ + @PostMapping("/master-data/refresh") + @ApiOperationSupport(order = 3) + @Operation(summary = "刷新HCS主数据缓存", description = "全量重拉模板/分类/币种列表并缓存,同时校验模板映射配置") + public R refreshMasterData() { + return R.data(hcsMasterDataSupport.refreshAll()); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSyncSupport.java index 734f0e3d..4b06fcf8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsContractSyncSupport.java @@ -28,6 +28,7 @@ package org.springblade.transport.support.hcs; 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.config.HcsProperties; import org.springblade.hcs.dto.HcsContractDraftDTO; @@ -342,6 +343,32 @@ public class HcsContractSyncSupport { return result.getData().getFdContractId(); } + /** + * 单条手动重推:重置为待同步并清零重试计数后立即推送(页面"推送合规/重推"按钮入口) + * + * @param id 合同单ID + * @return 推送结果描述 + */ + public String repushContract(Long id) { + ContractManage contractManage = contractManageMapper.selectById(id); + if (contractManage == null) { + throw new ServiceException("合同单不存在:" + id); + } + if (!List.of("approved", "change_approved").contains(contractManage.getApprovalStatus())) { + throw new ServiceException("仅审批通过的合同可推送合规(当前状态:" + contractManage.getApprovalStatus() + ")"); + } + contractManage.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED); + contractManage.setHcsRetryCount(0); + contractManageMapper.updateById(contractManage); + try { + return pushContract(contractManage); + } catch (Exception exception) { + String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage(); + markFailed(contractManage, message); + throw new ServiceException("HCS合同推送失败:" + message); + } + } + /** * 回填合规 fdId 并置 synced */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java index 6a162c95..52aefe9e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/hcs/HcsProjectSyncSupport.java @@ -28,6 +28,7 @@ package org.springblade.transport.support.hcs; 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.config.HcsProperties; import org.springblade.hcs.dto.HcsBpmOperationDTO; @@ -234,6 +235,32 @@ public class HcsProjectSyncSupport { return page.getContent().get(0).getFdId(); } + /** + * 单条手动重推:重置为待同步并清零重试计数后立即推送(页面"推送合规/重推"按钮入口) + * + * @param id 项目立项单ID + * @return 推送结果描述 + */ + public String repushProject(Long id) { + ProjectApply projectApply = projectApplyMapper.selectById(id); + if (projectApply == null) { + throw new ServiceException("项目立项单不存在:" + id); + } + if (!List.of("approved", "change_approved").contains(projectApply.getApprovalStatus())) { + throw new ServiceException("仅审批通过的项目立项可推送合规(当前状态:" + projectApply.getApprovalStatus() + ")"); + } + projectApply.setHcsSyncStatus(HcsSyncConstant.HCS_STATUS_UNSYNCED); + projectApply.setHcsRetryCount(0); + projectApplyMapper.updateById(projectApply); + try { + return pushProject(projectApply); + } catch (Exception exception) { + String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage(); + markFailed(projectApply, message); + throw new ServiceException("HCS项目立项推送失败:" + message); + } + } + /** * 回填合规 fdId 并置 synced */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java new file mode 100644 index 00000000..896a3d21 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/HcsPushTask.java @@ -0,0 +1,178 @@ +/** + * 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.task; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +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.transport.constant.HcsSyncConstant; +import org.springblade.transport.mapper.ContractManageMapper; +import org.springblade.transport.mapper.ProjectApplyMapper; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.ProjectApply; +import org.springblade.transport.support.hcs.HcsContractSyncSupport; +import org.springblade.transport.support.hcs.HcsProjectSyncSupport; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +/** + * HCS 合规推送定时扫描任务(设计文档 §5.0,ADR-0001) + *

+ * 审批代码零埋点:扫描只认 approvalStatus=approved/change_approved 且 hcs_sync_status=unsynced/failed(重试未超限), + * 与审批入口(/approve、MK 回调)无关;失败落 failed 由下轮扫描自动补偿,超限仅人工重推(D7)。 + * 任务顺序:项目 → 合同(内含风险校验→附件转传→C103-1→履约规则 P2 接线)。 + * 多实例部署时以 Redis 分布式锁防重扫(照既有 BladeRedis 用法,单实例部署行为与既有金蝶任务一致)。 + * 扫描与推送逻辑收敛在 pushUnsyncedProjects/pushUnsyncedContracts,供手动端点(HcsSyncController)复用。 + *

+ * 调度配置(Nacos thirdParty.hcs.push.*):sync-enabled 总开关(默认关,灰度打开)、cron、batch-size、max-retry。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HcsPushTask { + + /** + * 分布式锁 TTL(秒):须小于 cron 间隔(默认 5 分钟)且大于单轮预期执行时长,实例崩溃后锁自动过期 + */ + private static final long PUSH_TASK_LOCK_TTL_SECONDS = 240L; + + /** + * 审批通过状态集合(含变更通过) + */ + private static final List APPROVED_STATUSES = List.of("approved", "change_approved"); + + private final HcsProperties hcsProperties; + private final ProjectApplyMapper projectApplyMapper; + private final ContractManageMapper contractManageMapper; + private final HcsProjectSyncSupport hcsProjectSyncSupport; + private final HcsContractSyncSupport hcsContractSyncSupport; + private final BladeRedis bladeRedis; + + /** + * HCS 推送任务开关,默认关闭(灰度打开) + */ + @Value("${thirdParty.hcs.push.sync-enabled:false}") + private boolean syncEnabled; + + /** + * HCS 推送调度,默认每 5 分钟(准实时首推) + */ + @Scheduled(cron = "${thirdParty.hcs.push.cron:0 */5 * * * ?}") + public void pushHcs() { + if (!syncEnabled) { + return; + } + // 多实例部署防重扫:setIfAbsent 抢锁,持有值比对后释放 + String lockHolder = UUID.randomUUID().toString(); + Boolean locked = bladeRedis.getValueOps() + .setIfAbsent(HcsSyncConstant.PUSH_TASK_LOCK_KEY, lockHolder, Duration.ofSeconds(PUSH_TASK_LOCK_TTL_SECONDS)); + if (!Boolean.TRUE.equals(locked)) { + log.info("HCS推送任务其他实例执行中,本轮跳过"); + return; + } + try { + // 任务顺序(设计文档 §5.0):项目 → 合同;两类互不影响 + log.info("定时推送HCS项目立项:{}", pushUnsyncedProjects()); + log.info("定时推送HCS合同:{}", pushUnsyncedContracts()); + } finally { + if (lockHolder.equals(bladeRedis.get(HcsSyncConstant.PUSH_TASK_LOCK_KEY))) { + bladeRedis.del(HcsSyncConstant.PUSH_TASK_LOCK_KEY); + } + } + } + + /** + * 扫描并推送待同步/待补偿的项目立项(C102),返回结果汇总 + * + * @return 汇总描述 + */ + public String pushUnsyncedProjects() { + int maxRetry = hcsProperties.getPush().getMaxRetry(); + List projectList = projectApplyMapper.selectList(Wrappers.lambdaQuery() + .in(ProjectApply::getApprovalStatus, APPROVED_STATUSES) + .in(ProjectApply::getHcsSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED)) + .and(wrapper -> wrapper.isNull(ProjectApply::getHcsRetryCount).or().lt(ProjectApply::getHcsRetryCount, maxRetry)) + .last("limit " + hcsProperties.getPush().getBatchSize())); + int successCount = 0; + int failCount = 0; + for (ProjectApply projectApply : projectList) { + try { + hcsProjectSyncSupport.pushProject(projectApply); + successCount++; + } catch (Exception exception) { + String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage(); + hcsProjectSyncSupport.markFailed(projectApply, message); + failCount++; + } + } + String summary = String.format("扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", projectList.size(), successCount, failCount, maxRetry); + if (failCount > 0) { + log.warn("HCS项目立项推送存在失败:{}", summary); + } + return summary; + } + + /** + * 扫描并推送待同步/待补偿的合同(C103),返回结果汇总 + * + * @return 汇总描述 + */ + public String pushUnsyncedContracts() { + int maxRetry = hcsProperties.getPush().getMaxRetry(); + List contractList = contractManageMapper.selectList(Wrappers.lambdaQuery() + .in(ContractManage::getApprovalStatus, APPROVED_STATUSES) + .in(ContractManage::getHcsSyncStatus, List.of(HcsSyncConstant.HCS_STATUS_UNSYNCED, HcsSyncConstant.HCS_STATUS_FAILED)) + .and(wrapper -> wrapper.isNull(ContractManage::getHcsRetryCount).or().lt(ContractManage::getHcsRetryCount, maxRetry)) + .last("limit " + hcsProperties.getPush().getBatchSize())); + int successCount = 0; + int failCount = 0; + for (ContractManage contractManage : contractList) { + try { + hcsContractSyncSupport.pushContract(contractManage); + successCount++; + } catch (Exception exception) { + String message = StringUtil.isBlank(exception.getMessage()) ? "推送异常" : exception.getMessage(); + hcsContractSyncSupport.markFailed(contractManage, message); + failCount++; + } + } + String summary = String.format("扫描 %d 条,成功 %d 条,失败 %d 条(重试上限 %d)", contractList.size(), successCount, failCount, maxRetry); + if (failCount > 0) { + log.warn("HCS合同推送存在失败:{}", summary); + } + return summary; + } +}