From d4e33c4fd305206cea656e555d98e558b228b3a4 Mon Sep 17 00:00:00 2001 From: chenhao Date: Wed, 16 Sep 2026 12:10:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=A2=E5=8D=95=E9=80=80=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E7=94=A8crm=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IOpportunityIntegrationService.java | 16 +++ .../impl/ExecutionTrackServiceImpl.java | 21 +++ .../OpportunityIntegrationServiceImpl.java | 129 ++++++++++++++++-- 3 files changed, 152 insertions(+), 14 deletions(-) diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/service/IOpportunityIntegrationService.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/service/IOpportunityIntegrationService.java index e74d8752..978e877c 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/service/IOpportunityIntegrationService.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/service/IOpportunityIntegrationService.java @@ -1,9 +1,25 @@ package com.ruoyi.sip.service; +import com.ruoyi.sip.dto.integration.OpportunityOrderReturnRequestDto; import com.ruoyi.sip.dto.integration.OpportunityUpdateRequestDto; public interface IOpportunityIntegrationService { Long updateOpportunity(OpportunityUpdateRequestDto requestDto); + /** + * 同步调用CRM退单回调接口,失败抛出异常 + * + * @param requestDto 退单回调参数 + */ + void notifyOrderReturn(OpportunityOrderReturnRequestDto requestDto); + + /** + * 事务提交后异步通知CRM退单,失败仅记录推送CRM错误日志,不影响退单业务 + * + * @param requestDto 退单回调参数 + * @param projectId 项目主键,仅用于失败日志定位 + */ + void scheduleOrderReturnNotify(OpportunityOrderReturnRequestDto requestDto, Long projectId); + } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/service/impl/ExecutionTrackServiceImpl.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/service/impl/ExecutionTrackServiceImpl.java index 9b14fb5a..af40d980 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/service/impl/ExecutionTrackServiceImpl.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/service/impl/ExecutionTrackServiceImpl.java @@ -11,6 +11,7 @@ import com.ruoyi.common.utils.StringUtils; import com.ruoyi.sip.domain.*; import com.ruoyi.sip.dto.inventory.ProductDetail; import com.ruoyi.sip.dto.inventory.ProductWarehouseInfo; +import com.ruoyi.sip.dto.integration.OpportunityOrderReturnRequestDto; import com.ruoyi.sip.flowable.domain.Todo; import com.ruoyi.sip.flowable.mapper.TodoMapper; import com.ruoyi.sip.flowable.service.TodoCommonTemplate; @@ -85,6 +86,8 @@ public class ExecutionTrackServiceImpl implements IExecutionTrackService, TodoCo private IInventoryDeliveryService inventoryDeliveryService; @Autowired private ProcessConfig processConfig; + @Autowired + private IOpportunityIntegrationService opportunityIntegrationService; @Override public ExecutionOrderVo selectInfo(Long id) { ExecutionOrderVo vo = new ExecutionOrderVo(); @@ -540,6 +543,8 @@ public class ExecutionTrackServiceImpl implements IExecutionTrackService, TodoCo projectOrderInfoMapper.updateProjectOrderInfo(updateOrder); // 撤单审批通过不重置订单审批状态(orderStatus),保持原状态 recall(orderId, updateBy, false); + // 退单审批整体完成后回调CRM:商机阶段回退为S4并清空签约信息 + notifyCrmOrderReturn(orderId); } } return TodoCommonTemplate.super.multiInstanceApproveCallback(activityName, processInstance); @@ -569,4 +574,20 @@ public class ExecutionTrackServiceImpl implements IExecutionTrackService, TodoCo result.put("projectOrderInfo", projectOrderInfo); return result; } + + /** + * 退单完成后异步通知CRM,商机编号取订单的项目编号 project_code + * + * @param orderId 订单主键 + */ + private void notifyCrmOrderReturn(Long orderId) { + ProjectOrderInfo order = projectOrderInfoService.selectProjectOrderInfoById(orderId); + if (order == null) { + return; + } + OpportunityOrderReturnRequestDto requestDto = new OpportunityOrderReturnRequestDto(); + requestDto.setOpportunityCode(order.getProjectCode()); + requestDto.setOrderNo(order.getOrderCode()); + opportunityIntegrationService.scheduleOrderReturnNotify(requestDto, order.getProjectId()); + } } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/service/impl/OpportunityIntegrationServiceImpl.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/service/impl/OpportunityIntegrationServiceImpl.java index fcdd9c10..5bc4a66a 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/service/impl/OpportunityIntegrationServiceImpl.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/service/impl/OpportunityIntegrationServiceImpl.java @@ -3,9 +3,13 @@ package com.ruoyi.sip.service.impl; import com.alibaba.fastjson.JSONObject; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.sip.domain.OmsSendCrmErrorLog; +import com.ruoyi.sip.dto.integration.OpportunityOrderReturnRequestDto; import com.ruoyi.sip.dto.integration.OpportunityUpdateRequestDto; +import com.ruoyi.sip.mapper.OmsSendCrmErrorLogMapper; import com.ruoyi.sip.service.IOpportunityIntegrationService; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -13,26 +17,40 @@ import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.client.HttpStatusCodeException; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import java.lang.reflect.Field; import java.util.Map; +import java.util.concurrent.CompletableFuture; @Slf4j @Service public class OpportunityIntegrationServiceImpl implements IOpportunityIntegrationService { + /** + * CRM 内部接口鉴权请求头,与 CRM 端 unisbase.internal-auth.header-name 对应 + */ + private static final String SECRET_HEADER = "X-Internal-Secret"; + @Value("${opportunity.integration.base-url:http://localhost:8080}") private String baseUrl; @Value("${opportunity.integration.update-path:/api/opportunities/integration/update}") private String updatePath; + @Value("${opportunity.integration.order-return-path:/api/oms/callback/order-return}") + private String orderReturnPath; + @Value("${opportunity.integration.secret:f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77}") private String secret; + @Autowired + private OmsSendCrmErrorLogMapper sendCrmErrorLogMapper; + private final RestTemplate restTemplate = new RestTemplate(); @Override @@ -40,18 +58,7 @@ public class OpportunityIntegrationServiceImpl implements IOpportunityIntegratio validateRequest(requestDto); JSONObject payload = buildPayload(requestDto); log.info("调用商机更新接口请求体, opportunityCode:{}, payload:{}", requestDto.getOpportunityCode(), payload.toJSONString()); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.add("X-Internal-Secret", secret); - HttpEntity requestEntity = new HttpEntity<>(payload, headers); - ResponseEntity responseEntity; - try { - responseEntity = restTemplate.exchange(buildUrl(), HttpMethod.PUT, requestEntity, Map.class); - } catch (HttpStatusCodeException e) { - throw new ServiceException("调用商机更新接口失败: HTTP " + e.getRawStatusCode() + " " + e.getStatusText() + ", 响应: " + e.getResponseBodyAsString()); - } catch (RestClientException e) { - throw new ServiceException("调用商机更新接口失败: " + e.getMessage()); - } + ResponseEntity responseEntity = exchange(buildUrl(updatePath), HttpMethod.PUT, payload, "调用商机更新接口"); Map responseBody = responseEntity.getBody(); if (responseBody == null) { throw new ServiceException("调用商机更新接口失败: 响应为空"); @@ -75,9 +82,91 @@ public class OpportunityIntegrationServiceImpl implements IOpportunityIntegratio } } - private String buildUrl() { + @Override + public void notifyOrderReturn(OpportunityOrderReturnRequestDto requestDto) { + validateOrderReturnRequest(requestDto); + JSONObject payload = new JSONObject(); + payload.put("opportunityCode", requestDto.getOpportunityCode()); + if (StringUtils.isNotEmpty(requestDto.getOrderNo())) { + payload.put("orderNo", requestDto.getOrderNo()); + } + log.info("调用CRM退单回调接口请求体, opportunityCode:{}, payload:{}", + requestDto.getOpportunityCode(), payload.toJSONString()); + ResponseEntity responseEntity = exchange(buildUrl(orderReturnPath), HttpMethod.POST, payload, "调用CRM退单回调接口"); + Map responseBody = responseEntity.getBody(); + if (responseBody == null) { + throw new ServiceException("调用CRM退单回调接口失败: 响应为空"); + } + if (!"0".equals(String.valueOf(responseBody.get("code")))) { + Object message = responseBody.get("message") != null ? responseBody.get("message") : responseBody.get("msg"); + throw new ServiceException("调用CRM退单回调接口失败: " + message); + } + log.info("CRM退单回调接口调用成功, opportunityCode:{}, data:{}", + requestDto.getOpportunityCode(), responseBody.get("data")); + } + + @Override + public void scheduleOrderReturnNotify(OpportunityOrderReturnRequestDto requestDto, Long projectId) { + if (requestDto == null || StringUtils.isEmpty(requestDto.getOpportunityCode())) { + log.warn("商机编号为空,跳过CRM退单回调, projectId:{}", projectId); + return; + } + Runnable task = () -> { + try { + notifyOrderReturn(requestDto); + } catch (Exception e) { + log.error("退单后同步CRM失败, projectId:{}, opportunityCode:{}", + projectId, requestDto.getOpportunityCode(), e); + saveOrderReturnErrorLog(projectId, requestDto.getOpportunityCode(), e); + } + }; + // 退单业务与CRM推送解耦:事务提交后再异步通知,CRM异常不回滚退单,失败落错误日志便于重试 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + CompletableFuture.runAsync(task); + } + }); + return; + } + CompletableFuture.runAsync(task); + } + + private void saveOrderReturnErrorLog(Long projectId, String opportunityCode, Exception cause) { + try { + OmsSendCrmErrorLog errorLog = new OmsSendCrmErrorLog(); + errorLog.setProjectId(projectId); + errorLog.setProjectCode(opportunityCode); + String errMsg = "退单回调失败: " + cause.getMessage(); + errorLog.setMsg(errMsg.length() > 10240 ? errMsg.substring(0, 10240) : errMsg); + sendCrmErrorLogMapper.insert(errorLog); + } catch (Exception e) { + log.error("记录CRM退单回调错误日志失败, projectId:{}", projectId, e); + } + } + + /** + * 统一发起带鉴权头的 JSON 请求,失败时抛出带响应内容的业务异常 + */ + private ResponseEntity exchange(String url, HttpMethod method, JSONObject payload, String operation) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add(SECRET_HEADER, secret); + HttpEntity requestEntity = new HttpEntity<>(payload, headers); + try { + return restTemplate.exchange(url, method, requestEntity, Map.class); + } catch (HttpStatusCodeException e) { + throw new ServiceException(operation + "失败: HTTP " + e.getRawStatusCode() + " " + e.getStatusText() + + ", 响应: " + e.getResponseBodyAsString()); + } catch (RestClientException e) { + throw new ServiceException(operation + "失败: " + e.getMessage()); + } + } + + private String buildUrl(String path) { String trimmedBaseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; - String trimmedPath = updatePath.startsWith("/") ? updatePath : "/" + updatePath; + String trimmedPath = path.startsWith("/") ? path : "/" + path; return trimmedBaseUrl + trimmedPath; } @@ -96,6 +185,18 @@ public class OpportunityIntegrationServiceImpl implements IOpportunityIntegratio } } + private void validateOrderReturnRequest(OpportunityOrderReturnRequestDto requestDto) { + if (requestDto == null) { + throw new ServiceException("请求参数不能为空"); + } + if (StringUtils.isEmpty(requestDto.getOpportunityCode())) { + throw new ServiceException("opportunityCode 不能为空"); + } + if (StringUtils.isEmpty(secret)) { + throw new ServiceException("opportunity.integration.secret 未配置,无法调用CRM退单回调接口"); + } + } + private boolean hasAtLeastOneUpdateField(OpportunityUpdateRequestDto requestDto) { for (Field field : OpportunityUpdateRequestDto.class.getDeclaredFields()) { if ("opportunityCode".equals(field.getName())) {