diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCreditInfoController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCreditInfoController.java index 4d447ffd..01b49323 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCreditInfoController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiCreditInfoController.java @@ -11,6 +11,8 @@ import com.ruoyi.common.enums.BusinessType; import com.ruoyi.info.collection.domain.dto.CcdiCreditInfoQueryDTO; import com.ruoyi.info.collection.domain.vo.CreditInfoListVO; import com.ruoyi.info.collection.service.ICcdiCreditInfoService; +import com.ruoyi.lsfx.domain.CallerContext; +import com.ruoyi.common.utils.SecurityUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; @@ -42,7 +44,8 @@ public class CcdiCreditInfoController extends BaseController { @Log(title = "征信维护", businessType = BusinessType.IMPORT) @PostMapping("/upload") public AjaxResult upload(@RequestParam("files") MultipartFile[] files) { - return AjaxResult.success("上传成功", creditInfoService.upload(Arrays.asList(files))); + CallerContext caller = CallerContext.from(SecurityUtils.getLoginUser()); + return AjaxResult.success("上传成功", creditInfoService.upload(Arrays.asList(files), caller)); } @Operation(summary = "查询征信维护列表") diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiCreditInfoService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiCreditInfoService.java index 3a0f6d83..19b40ea3 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiCreditInfoService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiCreditInfoService.java @@ -7,6 +7,7 @@ import com.ruoyi.info.collection.domain.dto.CcdiCreditInfoQueryDTO; import com.ruoyi.info.collection.domain.vo.CreditInfoDetailVO; import com.ruoyi.info.collection.domain.vo.CreditInfoListVO; import com.ruoyi.info.collection.domain.vo.CreditInfoUploadResultVO; +import com.ruoyi.lsfx.domain.CallerContext; import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -16,7 +17,7 @@ import java.util.List; */ public interface ICcdiCreditInfoService { - CreditInfoUploadResultVO upload(List files); + CreditInfoUploadResultVO upload(List files, CallerContext caller); Page selectCreditInfoPage(Page page, CcdiCreditInfoQueryDTO queryDTO); diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCreditInfoServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCreditInfoServiceImpl.java index b6e556bd..6bc04706 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCreditInfoServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiCreditInfoServiceImpl.java @@ -1,7 +1,6 @@ package com.ruoyi.info.collection.service.impl; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.info.collection.domain.CcdiCreditNegativeInfo; import com.ruoyi.info.collection.domain.CcdiDebtsInfo; import com.ruoyi.info.collection.domain.dto.CcdiCreditInfoQueryDTO; @@ -17,6 +16,7 @@ import com.ruoyi.info.collection.service.ICcdiCreditInfoService; import com.ruoyi.info.collection.service.support.CreditHtmlStorageService; import com.ruoyi.info.collection.service.support.CreditInfoPayloadAssembler; import com.ruoyi.lsfx.client.CreditParseClient; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse; import com.ruoyi.lsfx.domain.response.CreditParsePayload; import com.ruoyi.lsfx.domain.response.CreditParseResponse; @@ -59,12 +59,12 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService { private CcdiCreditInfoQueryMapper queryMapper; @Override - public CreditInfoUploadResultVO upload(List files) { + public CreditInfoUploadResultVO upload(List files, CallerContext caller) { CreditInfoUploadResultVO result = new CreditInfoUploadResultVO(); List failures = new ArrayList<>(); int totalCount = files == null ? 0 : files.size(); int successCount = 0; - String userName = currentUserName(); + String userName = caller.username(); if (files == null || files.isEmpty()) { result.setTotalCount(0); @@ -77,7 +77,7 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService { for (MultipartFile file : files) { try { validateHtmlFile(file); - handleSingleFile(file, userName); + handleSingleFile(file, userName, caller); successCount++; } catch (Exception e) { failures.add(buildFailure(file, null, null, e.getMessage())); @@ -148,9 +148,9 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService { } } - private void handleSingleFile(MultipartFile multipartFile, String userName) throws Exception { + private void handleSingleFile(MultipartFile multipartFile, String userName, CallerContext caller) throws Exception { CreditHtmlStorageService.StoredCreditHtml storedHtml = creditHtmlStorageService.save(multipartFile); - CreditParseInvokeResponse response = creditParseClient.parse(storedHtml.remotePath()); + CreditParseInvokeResponse response = creditParseClient.parse(caller, storedHtml.remotePath()); CreditParsePayload payload = requireResponse(response).getPayload(); Map header = requireHeader(payload); String personId = stringValue(header.get("query_cert_no")); @@ -286,11 +286,4 @@ public class CcdiCreditInfoServiceImpl implements ICcdiCreditInfoService { return negativeVO; } - private String currentUserName() { - try { - return SecurityUtils.getUsername(); - } catch (Exception e) { - return "system"; - } - } } diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiCreditInfoServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiCreditInfoServiceImplTest.java index db3176f7..e5b52326 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiCreditInfoServiceImplTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiCreditInfoServiceImplTest.java @@ -11,6 +11,7 @@ import com.ruoyi.info.collection.service.impl.CcdiCreditInfoServiceImpl; import com.ruoyi.info.collection.service.support.CreditHtmlStorageService; import com.ruoyi.info.collection.service.support.CreditInfoPayloadAssembler; import com.ruoyi.lsfx.client.CreditParseClient; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.domain.response.CreditParseInvokeData; import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse; import com.ruoyi.lsfx.domain.response.CreditParsePayload; @@ -43,6 +44,8 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class CcdiCreditInfoServiceImplTest { + private static final CallerContext CALLER = CallerContext.of(7L, "tester"); + @InjectMocks private CcdiCreditInfoServiceImpl service; @@ -73,18 +76,18 @@ class CcdiCreditInfoServiceImplTest { .thenReturn(new CreditHtmlStorageService.StoredCreditHtml( "/profile/credit-html/2026/05/12/family_1.html", "http://127.0.0.1:62318/profile/credit-html/2026/05/12/family_1.html")); - when(creditParseClient.parse(anyString())) + when(creditParseClient.parse(any(CallerContext.class), anyString())) .thenReturn(successResponse("330101199202020022", "李四", "2026-03-24")); when(assembler.buildDebts(anyString(), anyString(), any(LocalDate.class), any(CreditParsePayload.class))) .thenReturn(List.of(buildDebt("330101199202020022"))); when(assembler.buildNegative(anyString(), anyString(), any(LocalDate.class), any(CreditParsePayload.class))) .thenReturn(buildNegative("330101199202020022")); - CreditInfoUploadResultVO result = service.upload(List.of(file)); + CreditInfoUploadResultVO result = service.upload(List.of(file), CALLER); assertEquals(1, result.getSuccessCount()); assertEquals(0, result.getFailureCount()); - verify(creditParseClient).parse("http://127.0.0.1:62318/profile/credit-html/2026/05/12/family_1.html"); + verify(creditParseClient).parse(CALLER, "http://127.0.0.1:62318/profile/credit-html/2026/05/12/family_1.html"); verify(debtsInfoMapper).deleteByPersonId("330101199202020022"); verify(negativeInfoMapper).deleteByPersonId("330101199202020022"); } @@ -97,12 +100,12 @@ class CcdiCreditInfoServiceImplTest { .thenReturn(new CreditHtmlStorageService.StoredCreditHtml( "/profile/credit-html/2026/05/12/a_1.html", "http://127.0.0.1:62318/profile/credit-html/2026/05/12/a_1.html")); - when(creditParseClient.parse(anyString())) + when(creditParseClient.parse(any(CallerContext.class), anyString())) .thenReturn(successResponse("330101199001010011", "张三", "2026-03-03")); when(queryMapper.selectLatestQueryDate("330101199001010011")) .thenReturn(LocalDate.parse("2026-03-05")); - CreditInfoUploadResultVO result = service.upload(List.of(file)); + CreditInfoUploadResultVO result = service.upload(List.of(file), CALLER); assertEquals(0, result.getSuccessCount()); assertEquals("上传征信日期早于当前已维护最新记录", result.getFailures().get(0).getReason()); @@ -118,9 +121,9 @@ class CcdiCreditInfoServiceImplTest { "http://127.0.0.1:62318/profile/credit-html/2026/05/12/a_1.html")); CreditParseInvokeResponse response = successResponse("330101199001010011", "张三", "2026-03-03"); response.setCode(99999); - when(creditParseClient.parse(anyString())).thenReturn(response); + when(creditParseClient.parse(any(CallerContext.class), anyString())).thenReturn(response); - CreditInfoUploadResultVO result = service.upload(List.of(file)); + CreditInfoUploadResultVO result = service.upload(List.of(file), CALLER); assertEquals(0, result.getSuccessCount()); assertEquals("征信解析平台状态码异常: 99999", result.getFailures().get(0).getReason()); @@ -138,9 +141,9 @@ class CcdiCreditInfoServiceImplTest { response.getData().setStatus(0); response.getData().setReasonCode(500); response.getData().setReasonMessage("结果解析失败"); - when(creditParseClient.parse(anyString())).thenReturn(response); + when(creditParseClient.parse(any(CallerContext.class), anyString())).thenReturn(response); - CreditInfoUploadResultVO result = service.upload(List.of(file)); + CreditInfoUploadResultVO result = service.upload(List.of(file), CALLER); assertEquals(0, result.getSuccessCount()); assertEquals("结果解析失败", result.getFailures().get(0).getReason()); diff --git a/ccdi-lsfx/pom.xml b/ccdi-lsfx/pom.xml index 2fac92c9..ca6a7fba 100644 --- a/ccdi-lsfx/pom.xml +++ b/ccdi-lsfx/pom.xml @@ -20,6 +20,12 @@ ruoyi-common + + + com.ruoyi + ruoyi-system + + org.springframework.boot diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java index 1c4bbcdc..6a70a2a4 100644 --- a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/CreditParseClient.java @@ -3,6 +3,7 @@ package com.ruoyi.lsfx.client; import com.fasterxml.jackson.databind.ObjectMapper; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.uuid.IdUtils; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse; import com.ruoyi.lsfx.exception.LsfxApiException; import com.ruoyi.lsfx.util.HttpUtil; @@ -45,20 +46,20 @@ public class CreditParseClient { @Value("${credit-parse.api.model:LXCUSTALL}") private String defaultModel; - public CreditParseInvokeResponse parse(String remotePath) { - return parse(defaultModel, remotePath); + public CreditParseInvokeResponse parse(CallerContext caller, String remotePath) { + return parse(caller, defaultModel, remotePath); } - public CreditParseInvokeResponse parse(String model, String remotePath) { + public CreditParseInvokeResponse parse(CallerContext caller, String model, String remotePath) { long startTime = System.currentTimeMillis(); String actualModel = StringUtils.isBlank(model) ? defaultModel : model; String serialNum = buildSerialNum(); try { Map initiateParams = buildInitiateParams(serialNum, actualModel, remotePath); - CreditParseInvokeResponse initiateResponse = request(creditParseUrl, initiateParams, "发起接口"); + CreditParseInvokeResponse initiateResponse = request(caller, creditParseUrl, initiateParams, "发起接口"); requireSuccessfulInitiateResponse(initiateResponse, "征信解析发起接口"); - CreditParseInvokeResponse response = queryResult(serialNum); + CreditParseInvokeResponse response = queryResult(caller, serialNum); long elapsed = System.currentTimeMillis() - startTime; log.info("【征信解析】调用完成: success={}, code={}, businessStatusCode={}, cost={}ms", @@ -94,10 +95,10 @@ public class CreditParseClient { return params; } - private CreditParseInvokeResponse queryResult(String serialNum) { + private CreditParseInvokeResponse queryResult(CallerContext caller, String serialNum) { Map params = buildBaseParams(serialNum); for (int attempt = 1; attempt <= RESULT_QUERY_MAX_ATTEMPTS; attempt++) { - CreditParseInvokeResponse response = request(creditParseResultUrl, params, + CreditParseInvokeResponse response = request(caller, creditParseResultUrl, params, "结果接口第" + attempt + "次查询"); requireSuccessfulServiceResponse(response, "征信解析结果接口"); if (response.getData() == null || response.getData().getMappingOutputFields() == null) { @@ -130,10 +131,10 @@ public class CreditParseClient { Thread.sleep(intervalMillis); } - private CreditParseInvokeResponse request(String url, Map params, String stage) { + private CreditParseInvokeResponse request(CallerContext caller, String url, Map params, String stage) { try { log.info("【征信解析】{}请求: url={}, params={}", stage, url, toJson(params)); - String responseJson = httpUtil.postUrlEncodedFormForString(url, params, null); + String responseJson = httpUtil.postUrlEncodedFormForString(caller, url, params, null); log.info("【征信解析】{}返回JSON: {}", stage, responseJson); return objectMapper.readValue(responseJson, CreditParseInvokeResponse.class); } catch (LsfxApiException e) { diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java index 643bb983..5698ff9a 100644 --- a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java @@ -1,6 +1,7 @@ package com.ruoyi.lsfx.client; import com.ruoyi.lsfx.constants.LsfxConstants; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.domain.request.DeleteFilesRequest; import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest; import com.ruoyi.lsfx.domain.request.GetBankStatementRequest; @@ -68,7 +69,7 @@ public class LsfxAnalysisClient { /** * 获取Token */ - public GetTokenResponse getToken(GetTokenRequest request) { + public GetTokenResponse getToken(CallerContext caller, GetTokenRequest request) { log.info("【流水分析】获取Token请求: projectNo={}, entityName={}", request.getProjectNo(), request.getEntityName()); long startTime = System.currentTimeMillis(); @@ -88,7 +89,7 @@ public class LsfxAnalysisClient { params.put("analysisType", request.getAnalysisType() != null ? request.getAnalysisType() : LsfxConstants.ANALYSIS_TYPE); String url = baseUrl + getTokenEndpoint; - GetTokenResponse response = httpUtil.postFormData(url, params, null, GetTokenResponse.class); + GetTokenResponse response = httpUtil.postFormData(caller, url, params, null, GetTokenResponse.class); long elapsed = System.currentTimeMillis() - startTime; if (response != null && response.getData() != null) { @@ -110,14 +111,14 @@ public class LsfxAnalysisClient { /** * 上传文件 */ - public UploadFileResponse uploadFile(Integer groupId, File file) { - return uploadFile(groupId, file, file.getName()); + public UploadFileResponse uploadFile(CallerContext caller, Integer groupId, File file) { + return uploadFile(caller, groupId, file, file.getName()); } /** * 上传文件 */ - public UploadFileResponse uploadFile(Integer groupId, File file, String uploadFileName) { + public UploadFileResponse uploadFile(CallerContext caller, Integer groupId, File file, String uploadFileName) { String multipartFileName = StringUtils.hasText(uploadFileName) ? uploadFileName : file.getName(); log.info("【流水分析】上传文件请求: groupId={}, fileName={}", groupId, multipartFileName); long startTime = System.currentTimeMillis(); @@ -132,7 +133,7 @@ public class LsfxAnalysisClient { Map headers = new HashMap<>(); headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId); - UploadFileResponse response = httpUtil.uploadFile(url, params, headers, UploadFileResponse.class); + UploadFileResponse response = httpUtil.uploadFile(caller, url, params, headers, UploadFileResponse.class); long elapsed = System.currentTimeMillis() - startTime; if (response != null && response.getData() != null) { @@ -155,7 +156,7 @@ public class LsfxAnalysisClient { /** * 拉取行内流水 */ - public FetchInnerFlowResponse fetchInnerFlow(FetchInnerFlowRequest request) { + public FetchInnerFlowResponse fetchInnerFlow(CallerContext caller, FetchInnerFlowRequest request) { log.info("【流水分析】拉取行内流水请求: groupId={}, customerNo={}", request.getGroupId(), request.getCustomerNo()); long startTime = System.currentTimeMillis(); @@ -168,7 +169,7 @@ public class LsfxAnalysisClient { Map headers = new HashMap<>(); headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId); - FetchInnerFlowResponse response = httpUtil.postFormData(url, params, headers, FetchInnerFlowResponse.class); + FetchInnerFlowResponse response = httpUtil.postFormData(caller, url, params, headers, FetchInnerFlowResponse.class); long elapsed = System.currentTimeMillis() - startTime; if (response != null && response.getData() != null) { @@ -190,7 +191,7 @@ public class LsfxAnalysisClient { /** * 检查文件解析状态 */ - public CheckParseStatusResponse checkParseStatus(Integer groupId, String inprogressList) { + public CheckParseStatusResponse checkParseStatus(CallerContext caller, Integer groupId, String inprogressList) { log.info("【流水分析】检查文件解析状态: groupId={}, inprogressList={}", groupId, inprogressList); long startTime = System.currentTimeMillis(); @@ -205,7 +206,7 @@ public class LsfxAnalysisClient { Map headers = new HashMap<>(); headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId); - CheckParseStatusResponse response = httpUtil.postFormData(url, params, headers, CheckParseStatusResponse.class); + CheckParseStatusResponse response = httpUtil.postFormData(caller, url, params, headers, CheckParseStatusResponse.class); long elapsed = System.currentTimeMillis() - startTime; if (response != null && response.getData() != null) { @@ -234,7 +235,7 @@ public class LsfxAnalysisClient { * @param request 请求参数(groupId, logId, pageNow, pageSize) * @return 流水明细列表 */ - public GetBankStatementResponse getBankStatement(GetBankStatementRequest request) { + public GetBankStatementResponse getBankStatement(CallerContext caller, GetBankStatementRequest request) { log.info("【流水分析】获取银行流水请求: groupId={}, logId={}, pageNow={}, pageSize={}", request.getGroupId(), request.getLogId(), request.getPageNow(), request.getPageSize()); long startTime = System.currentTimeMillis(); @@ -248,7 +249,7 @@ public class LsfxAnalysisClient { Map headers = new HashMap<>(); headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId); - GetBankStatementResponse response = httpUtil.postFormData(url, params, headers, GetBankStatementResponse.class); + GetBankStatementResponse response = httpUtil.postFormData(caller, url, params, headers, GetBankStatementResponse.class); long elapsed = System.currentTimeMillis() - startTime; if (response != null && response.getData() != null) { @@ -281,7 +282,7 @@ public class LsfxAnalysisClient { * @param request 请求参数(groupId必填, logId可选) * @return 文件上传状态信息 */ - public GetFileUploadStatusResponse getFileUploadStatus(GetFileUploadStatusRequest request) { + public GetFileUploadStatusResponse getFileUploadStatus(CallerContext caller, GetFileUploadStatusRequest request) { log.info("【流水分析】获取文件上传状态: groupId={}, logId={}", request.getGroupId(), request.getLogId()); long startTime = System.currentTimeMillis(); @@ -299,7 +300,7 @@ public class LsfxAnalysisClient { Map headers = new HashMap<>(); headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId); - GetFileUploadStatusResponse response = httpUtil.get(url, params, headers, + GetFileUploadStatusResponse response = httpUtil.get(caller, url, params, headers, GetFileUploadStatusResponse.class); long elapsed = System.currentTimeMillis() - startTime; @@ -334,7 +335,7 @@ public class LsfxAnalysisClient { * @param request 请求参数(groupId, logIds, userId必填) * @return 删除结果 */ - public DeleteFilesResponse deleteFiles(DeleteFilesRequest request) { + public DeleteFilesResponse deleteFiles(CallerContext caller, DeleteFilesRequest request) { log.info("【流水分析】删除文件请求: groupId={}, logIds={}, userId={}", request.getGroupId(), Arrays.toString(request.getLogIds()), request.getUserId()); long startTime = System.currentTimeMillis(); @@ -351,7 +352,7 @@ public class LsfxAnalysisClient { Map headers = new HashMap<>(); headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId); - DeleteFilesResponse response = httpUtil.postFormData(url, params, headers, + DeleteFilesResponse response = httpUtil.postFormData(caller, url, params, headers, DeleteFilesResponse.class); long elapsed = System.currentTimeMillis() - startTime; diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java index 287a16c5..7223daff 100644 --- a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/CreditParseController.java @@ -1,22 +1,24 @@ package com.ruoyi.lsfx.controller; -import com.ruoyi.common.annotation.Anonymous; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.lsfx.client.CreditParseClient; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse; import com.ruoyi.lsfx.exception.LsfxApiException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Tag(name = "征信解析接口测试", description = "用于测试征信解析接口") -@Anonymous +@PreAuthorize("@ss.hasPermi('ccdi:project:edit')") @RestController @RequestMapping("/lsfx/credit") public class CreditParseController { @@ -37,7 +39,8 @@ public class CreditParseController { String actualModel = StringUtils.isBlank(model) ? DEFAULT_MODEL : model; try { - CreditParseInvokeResponse response = creditParseClient.parse(actualModel, remotePath); + CreditParseInvokeResponse response = creditParseClient.parse( + CallerContext.from(SecurityUtils.getLoginUser()), actualModel, remotePath); return AjaxResult.success(response); } catch (LsfxApiException e) { return AjaxResult.error(e.getMessage()); diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/LsfxTestController.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/LsfxTestController.java index 3b79f1fd..a26ebe1c 100644 --- a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/LsfxTestController.java +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/LsfxTestController.java @@ -1,10 +1,11 @@ package com.ruoyi.lsfx.controller; -import com.ruoyi.common.annotation.Anonymous; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.lsfx.client.LsfxAnalysisClient; import com.ruoyi.lsfx.constants.LsfxConstants; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.domain.request.DeleteFilesRequest; import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest; import com.ruoyi.lsfx.domain.request.GetBankStatementRequest; @@ -15,6 +16,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -28,7 +30,7 @@ import java.nio.file.StandardCopyOption; * 流水分析平台接口测试控制器 */ @Tag(name = "流水分析平台接口测试", description = "用于测试流水分析平台的7个接口") -@Anonymous +@PreAuthorize("@ss.hasPermi('ccdi:project:edit')") @RestController @RequestMapping("/lsfx/test") public class LsfxTestController { @@ -61,7 +63,7 @@ public class LsfxTestController { request.setDepartmentCode(LsfxConstants.DEFAULT_DEPARTMENT_CODE); } - GetTokenResponse response = lsfxAnalysisClient.getToken(request); + GetTokenResponse response = lsfxAnalysisClient.getToken(currentCaller(), request); return AjaxResult.success(response); } @@ -90,7 +92,7 @@ public class LsfxTestController { Files.copy(file.getInputStream(), tempFile, StandardCopyOption.REPLACE_EXISTING); File convertedFile = tempFile.toFile(); - UploadFileResponse response = lsfxAnalysisClient.uploadFile(groupId, convertedFile); + UploadFileResponse response = lsfxAnalysisClient.uploadFile(currentCaller(), groupId, convertedFile); return AjaxResult.success(response); } catch (IOException e) { return AjaxResult.error("文件转换失败:" + e.getMessage()); @@ -134,7 +136,7 @@ public class LsfxTestController { request.setDataChannelCode(LsfxConstants.DEFAULT_DATA_CHANNEL_CODE); } - FetchInnerFlowResponse response = lsfxAnalysisClient.fetchInnerFlow(request); + FetchInnerFlowResponse response = lsfxAnalysisClient.fetchInnerFlow(currentCaller(), request); return AjaxResult.success(response); } @@ -152,7 +154,7 @@ public class LsfxTestController { return AjaxResult.error("参数不完整:inprogressList为必填"); } - CheckParseStatusResponse response = lsfxAnalysisClient.checkParseStatus(groupId, inprogressList); + CheckParseStatusResponse response = lsfxAnalysisClient.checkParseStatus(currentCaller(), groupId, inprogressList); return AjaxResult.success(response); } @@ -174,7 +176,7 @@ public class LsfxTestController { return AjaxResult.error("参数不完整:pageSize为必填且大于0"); } - GetBankStatementResponse response = lsfxAnalysisClient.getBankStatement(request); + GetBankStatementResponse response = lsfxAnalysisClient.getBankStatement(currentCaller(), request); return AjaxResult.success(response); } @@ -194,7 +196,7 @@ public class LsfxTestController { request.setGroupId(groupId); request.setLogId(logId); - GetFileUploadStatusResponse response = lsfxAnalysisClient.getFileUploadStatus(request); + GetFileUploadStatusResponse response = lsfxAnalysisClient.getFileUploadStatus(currentCaller(), request); return AjaxResult.success(response); } @@ -213,7 +215,11 @@ public class LsfxTestController { return AjaxResult.error("参数不完整:userId为必填"); } - DeleteFilesResponse response = lsfxAnalysisClient.deleteFiles(request); + DeleteFilesResponse response = lsfxAnalysisClient.deleteFiles(currentCaller(), request); return AjaxResult.success(response); } + + private CallerContext currentCaller() { + return CallerContext.from(SecurityUtils.getLoginUser()); + } } diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/CallerContext.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/CallerContext.java new file mode 100644 index 00000000..9f91c97a --- /dev/null +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/CallerContext.java @@ -0,0 +1,31 @@ +package com.ruoyi.lsfx.domain; + +import com.ruoyi.common.core.domain.model.LoginUser; +import org.springframework.util.StringUtils; + +/** + * 业务外部接口调用发起人快照。 + */ +public record CallerContext(Long userId, String username) { + + public CallerContext { + if (!StringUtils.hasText(username)) { + throw new IllegalArgumentException("接口调用账号不能为空"); + } + } + + public static CallerContext from(LoginUser loginUser) { + if (loginUser == null) { + throw new IllegalArgumentException("登录用户不能为空"); + } + return new CallerContext(loginUser.getUserId(), loginUser.getUsername()); + } + + public static CallerContext of(Long userId, String username) { + return new CallerContext(userId, username); + } + + public static CallerContext system() { + return new CallerContext(null, "system"); + } +} diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java index bc7f15fc..c6304d07 100644 --- a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java @@ -1,30 +1,48 @@ package com.ruoyi.lsfx.util; import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.exception.LsfxApiException; +import com.ruoyi.system.domain.SysApiLog; +import com.ruoyi.system.service.ISysApiLogService; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.FileSystemResource; -import org.springframework.http.*; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** - * HTTP请求工具类 + * 业务外部HTTP请求工具,统一保存完整调用日志。 */ @Component public class HttpUtil { private static final Logger log = LoggerFactory.getLogger(HttpUtil.class); + private static final String CALL_SUCCESS = "0"; + private static final String CALL_FAILED = "1"; @Resource private RestTemplate restTemplate; @@ -32,6 +50,9 @@ public class HttpUtil { @Resource private ObjectMapper objectMapper; + @Resource + private ISysApiLogService apiLogService; + public static org.springframework.core.io.Resource namedFileResource(File file, String filename) { return new NamedFileSystemResource(file, filename); } @@ -50,299 +71,245 @@ public class HttpUtil { } } - /** - * 发送GET请求(带查询参数和请求头) - * @param url 请求URL - * @param params 查询参数 - * @param headers 请求头 - * @param responseType 响应类型 - * @return 响应对象 - */ - public T get(String url, Map params, Map headers, Class responseType) { + public T get(CallerContext caller, String url, Map params, + Map headers, Class responseType) { + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url); + if (params != null) { + params.forEach((key, value) -> { + if (value != null) { + builder.queryParam(key, value); + } + }); + } + String fullUrl = builder.toUriString(); + HttpHeaders httpHeaders = createHeaders(headers); + return execute(caller, fullUrl, HttpMethod.GET, httpHeaders, params, + new HttpEntity<>(httpHeaders), responseType, "API返回数据为空", "GET请求异常"); + } + + public T get(CallerContext caller, String url, Map headers, Class responseType) { + HttpHeaders httpHeaders = createHeaders(headers); + return execute(caller, url, HttpMethod.GET, httpHeaders, null, + new HttpEntity<>(httpHeaders), responseType, "API返回数据为空", "网络请求失败"); + } + + public T postJson(CallerContext caller, String url, Object request, + Map headers, Class responseType) { + HttpHeaders httpHeaders = createHeaders(headers); + httpHeaders.setContentType(MediaType.APPLICATION_JSON); + return execute(caller, url, HttpMethod.POST, httpHeaders, request, + new HttpEntity<>(request, httpHeaders), responseType, "API返回数据为空", "网络请求失败"); + } + + public T postFormData(CallerContext caller, String url, Map params, + Map headers, Class responseType) { + HttpHeaders httpHeaders = createHeaders(headers); + httpHeaders.setContentType(MediaType.MULTIPART_FORM_DATA); + MultiValueMap body = new LinkedMultiValueMap<>(); + if (params != null) { + params.forEach(body::add); + } + return execute(caller, url, HttpMethod.POST, httpHeaders, params, + new HttpEntity<>(body, httpHeaders), responseType, "API返回数据为空", "网络请求失败"); + } + + public T postUrlEncodedForm(CallerContext caller, String url, Map params, + Map headers, Class responseType) { + HttpHeaders httpHeaders = createHeaders(headers); + httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + MultiValueMap body = toUrlEncodedBody(params); + return execute(caller, url, HttpMethod.POST, httpHeaders, params, + new HttpEntity<>(body, httpHeaders), responseType, "API返回数据为空", "网络请求失败"); + } + + public String postUrlEncodedFormForString(CallerContext caller, String url, + Map params, Map headers) { + HttpHeaders httpHeaders = createHeaders(headers); + httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + MultiValueMap body = toUrlEncodedBody(params); + return execute(caller, url, HttpMethod.POST, httpHeaders, params, + new HttpEntity<>(body, httpHeaders), String.class, "API返回数据为空", "网络请求失败"); + } + + public T uploadFile(CallerContext caller, String url, Map params, + Map headers, Class responseType) { + HttpHeaders httpHeaders = createHeaders(headers); + httpHeaders.setContentType(MediaType.MULTIPART_FORM_DATA); + MultiValueMap body = new LinkedMultiValueMap<>(); + if (params != null) { + params.forEach((key, value) -> { + if (value instanceof File file) { + body.add(key, new FileSystemResource(file)); + } else { + body.add(key, value); + } + }); + } + return execute(caller, url, HttpMethod.POST, httpHeaders, params, + new HttpEntity<>(body, httpHeaders), responseType, "文件上传返回数据为空", "文件上传请求失败"); + } + + private T execute(CallerContext caller, String url, HttpMethod method, HttpHeaders requestHeaders, + Object requestParams, HttpEntity requestEntity, Class responseType, + String emptyResponseMessage, String requestFailureMessage) { + if (caller == null) { + throw new IllegalArgumentException("接口调用用户不能为空"); + } + + long startTime = System.currentTimeMillis(); + SysApiLog apiLog = createBaseApiLog(caller, url, method, startTime); try { - // 构建URL with查询参数 - UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url); - if (params != null && !params.isEmpty()) { - params.forEach((key, value) -> { - if (value != null) { - builder.queryParam(key, value); - } - }); - } - - String fullUrl = builder.toUriString(); - log.debug("【HTTP GET】请求URL: {}", fullUrl); - - // 创建请求头 - HttpHeaders httpHeaders = new HttpHeaders(); - if (headers != null) { - headers.forEach(httpHeaders::add); - } - - // 构建请求实体 - HttpEntity entity = new HttpEntity<>(httpHeaders); - - // 执行GET请求 - ResponseEntity response = restTemplate.exchange( - fullUrl, - HttpMethod.GET, - entity, - String.class - ); - - log.debug("【HTTP GET】响应状态: {}", response.getStatusCode()); - log.debug("【HTTP GET】响应内容: {}", response.getBody()); - - // 解析响应 - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - return objectMapper.readValue(response.getBody(), responseType); - } else { - throw new LsfxApiException("GET请求失败: " + response.getStatusCode()); - } + captureRequest(apiLog, requestHeaders, requestParams); } catch (Exception e) { - log.error("【HTTP GET】请求异常: url={}, error={}", url, e.getMessage(), e); - throw new LsfxApiException("GET请求异常: " + e.getMessage(), e); + log.error("接口日志请求快照构建失败: method={}, url={}", method, url, e); } - } - - /** - * 发送GET请求(带请求头) - * @param url 请求URL - * @param headers 请求头 - * @param responseType 响应类型 - * @return 响应对象 - */ - public T get(String url, Map headers, Class responseType) { try { - HttpHeaders httpHeaders = createHeaders(headers); - HttpEntity requestEntity = new HttpEntity<>(httpHeaders); - - ResponseEntity response = restTemplate.exchange( - url, HttpMethod.GET, requestEntity, responseType - ); - + ResponseEntity response = restTemplate.exchange(url, method, requestEntity, String.class); + captureResponse(apiLog, response); if (!response.getStatusCode().is2xxSuccessful()) { - throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode()); + throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode().value()); + } + if (!StringUtils.hasText(response.getBody())) { + throw new LsfxApiException(emptyResponseMessage); } - T body = response.getBody(); - if (body == null) { - throw new LsfxApiException("API返回数据为空"); - } - - return body; + T result = parseResponse(response.getBody(), responseType); + apiLog.setCallStatus(CALL_SUCCESS); + return result; + } catch (RestClientResponseException e) { + apiLog.setResponseStatus(e.getStatusCode().value()); + apiLog.setResponseHeaders(safeSerialize(e.getResponseHeaders())); + apiLog.setResponseBody(e.getResponseBodyAsString()); + apiLog.setErrorMsg(formatException(e)); + throw new LsfxApiException(requestFailureMessage + ": " + e.getMessage(), e); + } catch (LsfxApiException e) { + apiLog.setErrorMsg(formatException(e)); + throw e; } catch (RestClientException e) { - throw new LsfxApiException("网络请求失败: " + e.getMessage(), e); + apiLog.setErrorMsg(formatException(e)); + throw new LsfxApiException(requestFailureMessage + ": " + e.getMessage(), e); + } catch (Exception e) { + apiLog.setErrorMsg(formatException(e)); + throw new LsfxApiException("响应解析失败: " + e.getMessage(), e); + } finally { + apiLog.setCostTime(System.currentTimeMillis() - startTime); + persistApiLog(apiLog); } } - /** - * 发送POST请求(JSON格式,带请求头) - * @param url 请求URL - * @param request 请求对象 - * @param headers 请求头 - * @param responseType 响应类型 - * @return 响应对象 - */ - public T postJson(String url, Object request, Map headers, Class responseType) { - try { - HttpHeaders httpHeaders = createHeaders(headers); - httpHeaders.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity requestEntity = new HttpEntity<>(request, httpHeaders); - - ResponseEntity response = restTemplate.postForEntity(url, requestEntity, responseType); - - if (!response.getStatusCode().is2xxSuccessful()) { - throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode()); - } - - T body = response.getBody(); - if (body == null) { - throw new LsfxApiException("API返回数据为空"); - } - - return body; - } catch (RestClientException e) { - throw new LsfxApiException("网络请求失败: " + e.getMessage(), e); - } + private SysApiLog createBaseApiLog(CallerContext caller, String url, HttpMethod method, long startTime) { + SysApiLog apiLog = new SysApiLog(); + apiLog.setCallerUserId(caller.userId()); + apiLog.setCallerUsername(caller.username()); + apiLog.setApiUrl(url); + apiLog.setHttpMethod(method.name()); + apiLog.setCallStatus(CALL_FAILED); + apiLog.setCallTime(new Date(startTime)); + return apiLog; } - /** - * 发送POST请求(multipart/form-data格式,带请求头) - * 用于提交表单数据(非文件上传场景) - * @param url 请求URL - * @param params 表单参数 - * @param headers 请求头 - * @param responseType 响应类型 - * @return 响应对象 - */ - public T postFormData(String url, Map params, Map headers, Class responseType) { - try { - HttpHeaders httpHeaders = createHeaders(headers); - httpHeaders.setContentType(MediaType.MULTIPART_FORM_DATA); - - MultiValueMap body = new LinkedMultiValueMap<>(); - if (params != null) { - params.forEach(body::add); - } - - HttpEntity> requestEntity = new HttpEntity<>(body, httpHeaders); - - ResponseEntity response = restTemplate.postForEntity(url, requestEntity, responseType); - - if (!response.getStatusCode().is2xxSuccessful()) { - throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode()); - } - - T responseBody = response.getBody(); - if (responseBody == null) { - throw new LsfxApiException("API返回数据为空"); - } - - return responseBody; - } catch (RestClientException e) { - throw new LsfxApiException("网络请求失败: " + e.getMessage(), e); - } + private void captureRequest(SysApiLog apiLog, HttpHeaders headers, Object params) { + apiLog.setContentType(headers.getContentType() == null ? null : headers.getContentType().toString()); + apiLog.setRequestHeaders(safeSerialize(headers)); + apiLog.setRequestParams(safeSerialize(normalizeValue(params))); } - /** - * 发送POST请求(application/x-www-form-urlencoded格式,带请求头) - * @param url 请求URL - * @param params 表单参数 - * @param headers 请求头 - * @param responseType 响应类型 - * @return 响应对象 - */ - public T postUrlEncodedForm(String url, Map params, Map headers, Class responseType) { - try { - HttpHeaders httpHeaders = createHeaders(headers); - httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - - MultiValueMap body = new LinkedMultiValueMap<>(); - if (params != null) { - params.forEach((key, value) -> { - if (value != null) { - body.add(key, value.toString()); - } - }); - } - - HttpEntity> requestEntity = new HttpEntity<>(body, httpHeaders); - - ResponseEntity response = restTemplate.postForEntity(url, requestEntity, responseType); - - if (!response.getStatusCode().is2xxSuccessful()) { - throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode()); - } - - T responseBody = response.getBody(); - if (responseBody == null) { - throw new LsfxApiException("API返回数据为空"); - } - - return responseBody; - } catch (RestClientException e) { - throw new LsfxApiException("网络请求失败: " + e.getMessage(), e); - } + private void captureResponse(SysApiLog apiLog, ResponseEntity response) { + apiLog.setResponseStatus(response.getStatusCode().value()); + apiLog.setResponseHeaders(safeSerialize(response.getHeaders())); + apiLog.setResponseBody(response.getBody()); } - /** - * 发送POST请求(application/x-www-form-urlencoded格式)并返回原始JSON字符串 - * @param url 请求URL - * @param params 表单参数 - * @param headers 请求头 - * @return 原始响应内容 - */ - public String postUrlEncodedFormForString(String url, Map params, Map headers) { - try { - HttpHeaders httpHeaders = createHeaders(headers); - httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - - MultiValueMap body = new LinkedMultiValueMap<>(); - if (params != null) { - params.forEach((key, value) -> { - if (value != null) { - body.add(key, value.toString()); - } - }); - } - - HttpEntity> requestEntity = new HttpEntity<>(body, httpHeaders); - ResponseEntity response = restTemplate.postForEntity(url, requestEntity, String.class); - - if (!response.getStatusCode().is2xxSuccessful()) { - throw new LsfxApiException("API调用失败,HTTP状态码: " + response.getStatusCode()); - } - - String responseBody = response.getBody(); - if (responseBody == null) { - throw new LsfxApiException("API返回数据为空"); - } - - return responseBody; - } catch (RestClientException e) { - throw new LsfxApiException("网络请求失败: " + e.getMessage(), e); + @SuppressWarnings("unchecked") + private T parseResponse(String responseBody, Class responseType) throws Exception { + if (String.class.equals(responseType)) { + return (T) responseBody; } + return objectMapper.readValue(responseBody, responseType); } - /** - * 上传文件(Multipart格式) - * @param url 请求URL - * @param params 参数(包含文件) - * @param headers 请求头 - * @param responseType 响应类型 - * @return 响应对象 - */ - public T uploadFile(String url, Map params, Map headers, Class responseType) { - try { - HttpHeaders httpHeaders = createHeaders(headers); - httpHeaders.setContentType(MediaType.MULTIPART_FORM_DATA); - - MultiValueMap body = new LinkedMultiValueMap<>(); - if (params != null) { - params.forEach((key, value) -> { - // 如果是File对象,包装为FileSystemResource - if (value instanceof File) { - File file = (File) value; - body.add(key, new FileSystemResource(file)); - } else if (value instanceof org.springframework.core.io.Resource) { - body.add(key, value); - } else { - body.add(key, value); - } - }); - } - - HttpEntity> requestEntity = new HttpEntity<>(body, httpHeaders); - - ResponseEntity response = restTemplate.postForEntity(url, requestEntity, responseType); - - if (!response.getStatusCode().is2xxSuccessful()) { - throw new LsfxApiException("文件上传失败,HTTP状态码: " + response.getStatusCode()); - } - - T responseBody = response.getBody(); - if (responseBody == null) { - throw new LsfxApiException("文件上传返回数据为空"); - } - - return responseBody; - } catch (RestClientException e) { - throw new LsfxApiException("文件上传请求失败: " + e.getMessage(), e); + private MultiValueMap toUrlEncodedBody(Map params) { + MultiValueMap body = new LinkedMultiValueMap<>(); + if (params != null) { + params.forEach((key, value) -> { + if (value != null) { + body.add(key, value.toString()); + } + }); } + return body; } - /** - * 创建请求头 - * @param headers 请求头Map - * @return HttpHeaders对象 - */ private HttpHeaders createHeaders(Map headers) { HttpHeaders httpHeaders = new HttpHeaders(); - if (headers != null && !headers.isEmpty()) { + if (headers != null) { headers.forEach(httpHeaders::set); } return httpHeaders; } + + private Object normalizeValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof File file) { + return fileMetadata(file.getName(), file.length()); + } + if (value instanceof FileSystemResource resource) { + return fileMetadata(resource.getFilename(), resource.getFile().length()); + } + if (value instanceof org.springframework.core.io.Resource resource) { + return fileMetadata(resource.getFilename(), null); + } + if (value instanceof Map map) { + Map normalized = new LinkedHashMap<>(); + map.forEach((key, item) -> normalized.put(String.valueOf(key), normalizeValue(item))); + return normalized; + } + if (value instanceof Iterable iterable) { + List normalized = new ArrayList<>(); + iterable.forEach(item -> normalized.add(normalizeValue(item))); + return normalized; + } + if (value.getClass().isArray()) { + List normalized = new ArrayList<>(); + for (int i = 0; i < Array.getLength(value); i++) { + normalized.add(normalizeValue(Array.get(value, i))); + } + return normalized; + } + return value; + } + + private Map fileMetadata(String filename, Long size) { + Map metadata = new LinkedHashMap<>(); + metadata.put("filename", filename); + metadata.put("size", size); + metadata.put("contentType", MediaTypeFactory.getMediaType(filename == null ? "" : filename) + .map(MediaType::toString).orElse(null)); + return metadata; + } + + private String safeSerialize(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + log.error("接口日志序列化失败", e); + return String.valueOf(value); + } + } + + private String formatException(Exception exception) { + StringWriter writer = new StringWriter(); + exception.printStackTrace(new PrintWriter(writer)); + return writer.toString(); + } + + private void persistApiLog(SysApiLog apiLog) { + try { + apiLogService.recordApiLog(apiLog); + } catch (Exception e) { + log.error("接口日志入库失败: method={}, url={}", apiLog.getHttpMethod(), apiLog.getApiUrl(), e); + } + } } diff --git a/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java index 81623355..6d2c8395 100644 --- a/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java +++ b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java @@ -1,6 +1,7 @@ package com.ruoyi.lsfx.client; import com.ruoyi.lsfx.constants.LsfxConstants; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.domain.response.UploadFileResponse; import com.ruoyi.lsfx.util.HttpUtil; import org.junit.jupiter.api.Test; @@ -48,10 +49,11 @@ class LsfxAnalysisClientTest { ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); ArgumentCaptor> headersCaptor = ArgumentCaptor.forClass(Map.class); - when(httpUtil.uploadFile(eq("http://lsfx/upload"), paramsCaptor.capture(), headersCaptor.capture(), eq(UploadFileResponse.class))) + CallerContext caller = CallerContext.of(7L, "tester"); + when(httpUtil.uploadFile(eq(caller), eq("http://lsfx/upload"), paramsCaptor.capture(), headersCaptor.capture(), eq(UploadFileResponse.class))) .thenReturn(response); - client.uploadFile(200, tempFile.toFile(), "银行流水A.xlsx"); + client.uploadFile(caller, 200, tempFile.toFile(), "银行流水A.xlsx"); assertEquals(200, paramsCaptor.getValue().get("groupId")); Resource filePart = assertInstanceOf(Resource.class, paramsCaptor.getValue().get("files")); diff --git a/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java index 8f167078..5b7676ee 100644 --- a/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java +++ b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/controller/CreditParseControllerTest.java @@ -2,10 +2,14 @@ package com.ruoyi.lsfx.controller; import com.fasterxml.jackson.databind.ObjectMapper; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.lsfx.client.CreditParseClient; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.domain.response.CreditParseInvokeResponse; import com.ruoyi.lsfx.exception.LsfxApiException; import com.ruoyi.lsfx.util.HttpUtil; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -13,10 +17,14 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -24,6 +32,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; @@ -34,12 +43,19 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class CreditParseControllerTest { + private static final CallerContext CALLER = CallerContext.of(7L, "tester"); + @Mock private CreditParseClient client; @InjectMocks private CreditParseController controller; + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + @Test void parse_shouldRejectBlankRemotePath() { AjaxResult result = controller.parse(null, null); @@ -48,12 +64,13 @@ class CreditParseControllerTest { @Test void shouldUseDefaultModelWhenMissing() { + setLoginUser(CALLER.userId(), CALLER.username()); CreditParseInvokeResponse response = new CreditParseInvokeResponse(); response.setSuccess(true); response.setCode(10000); String remotePath = "http://127.0.0.1:62318/profile/credit-html/a.html"; - when(client.parse(eq("LXCUSTALL"), eq(remotePath))).thenReturn(response); + when(client.parse(eq(CALLER), eq("LXCUSTALL"), eq(remotePath))).thenReturn(response); AjaxResult result = controller.parse(remotePath, null); @@ -63,7 +80,8 @@ class CreditParseControllerTest { @Test void shouldReturnAjaxErrorWhenClientThrows() { - when(client.parse(anyString(), anyString())) + setLoginUser(CALLER.userId(), CALLER.username()); + when(client.parse(any(CallerContext.class), anyString(), anyString())) .thenThrow(new LsfxApiException("超时")); AjaxResult result = controller.parse("http://127.0.0.1:62318/profile/credit-html/a.html", null); @@ -71,6 +89,16 @@ class CreditParseControllerTest { assertEquals(500, result.get("code")); } + private void setLoginUser(Long userId, String username) { + SysUser user = new SysUser(); + user.setUserName(username); + LoginUser loginUser = new LoginUser(user, Set.of("*:*:*")); + loginUser.setUserId(userId); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + @Test @SuppressWarnings({"unchecked", "rawtypes"}) void creditParseClient_shouldInitiateAndQueryResultWithSameSerialNum() throws Exception { @@ -88,18 +116,20 @@ class CreditParseControllerTest { String payload = "{\"lx_header\":{\"query_cert_no\":\"330101199001010011\",\"query_cust_name\":\"张三\",\"report_time\":\"2026-03-24\"},\"lx_debt\":{\"uncle_bank_house_bal\":\"1\"},\"lx_publictype\":{\"civil_cnt\":1}}"; when(httpUtil.postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeature"), org.mockito.ArgumentMatchers.>any(), isNull() )).thenReturn(initiateSuccessResponse()); when(httpUtil.postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeatureResult"), org.mockito.ArgumentMatchers.>any(), isNull() )).thenReturn(resultSuccessResponse(objectMapper, payload, "ERR_SHOULD_IGNORE")); String remotePath = "http://127.0.0.1:62318/profile/credit-html/a.html"; - CreditParseInvokeResponse actual = parseClient.parse(remotePath); + CreditParseInvokeResponse actual = parseClient.parse(CALLER, remotePath); assertEquals(true, actual.getSuccess()); assertEquals(10000, actual.getCode()); @@ -107,12 +137,14 @@ class CreditParseControllerTest { .getPayload().getLxHeader().get("query_cert_no")); ArgumentCaptor> initiateParamsCaptor = ArgumentCaptor.forClass((Class) Map.class); verify(httpUtil).postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeature"), initiateParamsCaptor.capture(), isNull() ); ArgumentCaptor> resultParamsCaptor = ArgumentCaptor.forClass((Class) Map.class); verify(httpUtil).postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeatureResult"), resultParamsCaptor.capture(), isNull() @@ -152,21 +184,24 @@ class CreditParseControllerTest { String resultResponse = resultSuccessResponse(objectMapper, payload, "ERR_SHOULD_IGNORE"); when(httpUtil.postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeature"), org.mockito.ArgumentMatchers.>any(), isNull() )).thenReturn(initiateSuccessResponse()); when(httpUtil.postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeatureResult"), org.mockito.ArgumentMatchers.>any(), isNull() )).thenReturn(emptyPayloadResponse, emptyPayloadResponse, emptyPayloadResponse, emptyPayloadResponse, resultResponse); - CreditParseInvokeResponse actual = parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html"); + CreditParseInvokeResponse actual = parseClient.parse(CALLER, "http://127.0.0.1:62318/profile/credit-html/a.html"); assertEquals("330101199001010011", actual.getData().getMappingOutputFields() .getPayload().getLxHeader().get("query_cert_no")); verify(httpUtil, times(5)).postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeatureResult"), org.mockito.ArgumentMatchers.>any(), isNull() @@ -188,18 +223,20 @@ class CreditParseControllerTest { ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper); when(httpUtil.postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeature"), org.mockito.ArgumentMatchers.>any(), isNull() )).thenReturn(initiateSuccessResponse()); when(httpUtil.postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeatureResult"), org.mockito.ArgumentMatchers.>any(), isNull() )).thenReturn("{\"success\":true,\"code\":99999,\"data\":{\"mappingOutputFields\":{\"message\":\"\",\"status_code\":\"0\"}}}"); LsfxApiException exception = assertThrows(LsfxApiException.class, - () -> parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html")); + () -> parseClient.parse(CALLER, "http://127.0.0.1:62318/profile/credit-html/a.html")); assertTrue(exception.getMessage().contains("平台状态码异常")); } @@ -218,18 +255,20 @@ class CreditParseControllerTest { ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper); when(httpUtil.postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeature"), org.mockito.ArgumentMatchers.>any(), isNull() )).thenReturn(initiateSuccessResponse()); when(httpUtil.postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeatureResult"), org.mockito.ArgumentMatchers.>any(), isNull() )).thenReturn("{\"success\":true,\"code\":10000,\"data\":{\"reasonMessage\":\"解析失败\",\"reasonCode\":500,\"status\":0,\"mappingOutputFields\":{\"message\":\"结果异常\"}}}"); LsfxApiException exception = assertThrows(LsfxApiException.class, - () -> parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html")); + () -> parseClient.parse(CALLER, "http://127.0.0.1:62318/profile/credit-html/a.html")); assertTrue(exception.getMessage().contains("解析失败")); } @@ -248,16 +287,18 @@ class CreditParseControllerTest { ReflectionTestUtils.setField(parseClient, "objectMapper", objectMapper); when(httpUtil.postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeature"), org.mockito.ArgumentMatchers.>any(), isNull() )).thenReturn("{\"success\":true,\"code\":10000,\"data\":{\"mappingOutputFields\":{\"message\":\"文件写入失败\"},\"reasonMessage\":\"文件写入失败\",\"reasonCode\":500,\"status\":0}}"); LsfxApiException exception = assertThrows(LsfxApiException.class, - () -> parseClient.parse("http://127.0.0.1:62318/profile/credit-html/a.html")); + () -> parseClient.parse(CALLER, "http://127.0.0.1:62318/profile/credit-html/a.html")); assertTrue(exception.getMessage().contains("文件写入失败")); verify(httpUtil, times(0)).postUrlEncodedFormForString( + eq(CALLER), eq("http://tz/api/service/interface/invokeService/xfeatureResult"), org.mockito.ArgumentMatchers.>any(), isNull() diff --git a/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java index 346455dd..5dc22749 100644 --- a/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java +++ b/ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java @@ -1,5 +1,11 @@ package com.ruoyi.lsfx.util; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.lsfx.domain.CallerContext; +import com.ruoyi.lsfx.exception.LsfxApiException; +import com.ruoyi.system.domain.SysApiLog; +import com.ruoyi.system.service.ISysApiLogService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.extension.ExtendWith; @@ -8,52 +14,258 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.io.Resource; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; +import java.util.Iterator; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class HttpUtilTest { + private static final CallerContext CALLER = CallerContext.of(7L, "tester"); + @Mock private RestTemplate restTemplate; + @Mock + private ISysApiLogService apiLogService; + @TempDir Path tempDir; - @Test - void uploadFile_shouldUseExplicitResourceFilename() throws Exception { - HttpUtil httpUtil = new HttpUtil(); + private HttpUtil httpUtil; + + @BeforeEach + void setUp() { + httpUtil = new HttpUtil(); ReflectionTestUtils.setField(httpUtil, "restTemplate", restTemplate); + ReflectionTestUtils.setField(httpUtil, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(httpUtil, "apiLogService", apiLogService); + } + @Test + void uploadFile_shouldUseExplicitFilenameAndOnlyPersistFileMetadata() throws Exception { Path tempFile = tempDir.resolve("batch_0_123456.xlsx"); - Files.writeString(tempFile, "content"); + Files.writeString(tempFile, "binary-content-must-not-enter-log"); - ArgumentCaptor captor = ArgumentCaptor.forClass(HttpEntity.class); - when(restTemplate.postForEntity(eq("http://lsfx/upload"), captor.capture(), eq(String.class))) - .thenReturn(ResponseEntity.ok("ok")); + ArgumentCaptor> requestCaptor = httpEntityCaptor(); + when(restTemplate.exchange(eq("http://lsfx/upload"), eq(HttpMethod.POST), + requestCaptor.capture(), eq(String.class))).thenReturn(ResponseEntity.ok("ok")); Map params = new HashMap<>(); params.put("groupId", 200); params.put("files", HttpUtil.namedFileResource(tempFile.toFile(), "银行流水A.xlsx")); - String result = httpUtil.uploadFile("http://lsfx/upload", params, null, String.class); + assertEquals("ok", httpUtil.uploadFile(CALLER, "http://lsfx/upload", params, null, String.class)); - assertEquals("ok", result); - MultiValueMap body = (MultiValueMap) captor.getValue().getBody(); - Object filePart = body.getFirst("files"); - Resource resource = assertInstanceOf(Resource.class, filePart); + MultiValueMap body = (MultiValueMap) requestCaptor.getValue().getBody(); + Resource resource = assertInstanceOf(Resource.class, body.getFirst("files")); assertEquals("银行流水A.xlsx", resource.getFilename()); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(SysApiLog.class); + verify(apiLogService).recordApiLog(logCaptor.capture()); + SysApiLog saved = logCaptor.getValue(); + assertEquals(7L, saved.getCallerUserId()); + assertEquals("tester", saved.getCallerUsername()); + assertTrue(saved.getRequestParams().contains("银行流水A.xlsx")); + assertTrue(saved.getRequestParams().contains("\"size\":" + Files.size(tempFile))); + assertFalse(saved.getRequestParams().contains("binary-content-must-not-enter-log")); + } + + @Test + void non2xx_shouldPersistOriginalStatusHeadersAndBody() { + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.add("X-Trace-Id", "trace-1"); + HttpClientErrorException error = HttpClientErrorException.create( + HttpStatus.BAD_REQUEST, + "Bad Request", + responseHeaders, + "{\"error\":\"参数错误\"}".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8 + ); + when(restTemplate.exchange(eq("http://lsfx/fail"), eq(HttpMethod.POST), + any(HttpEntity.class), eq(String.class))).thenThrow(error); + + assertThrows(LsfxApiException.class, () -> + httpUtil.postJson(CALLER, "http://lsfx/fail", Map.of("token", "raw-token"), null, String.class)); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(SysApiLog.class); + verify(apiLogService).recordApiLog(logCaptor.capture()); + SysApiLog saved = logCaptor.getValue(); + assertEquals(400, saved.getResponseStatus()); + assertEquals("{\"error\":\"参数错误\"}", saved.getResponseBody()); + assertTrue(saved.getResponseHeaders().contains("trace-1")); + assertTrue(saved.getRequestParams().contains("raw-token")); + assertEquals("1", saved.getCallStatus()); + } + + @Test + void logPersistenceFailure_shouldNotChangeSuccessfulBusinessResult() { + when(restTemplate.exchange(eq("http://lsfx/success"), eq(HttpMethod.GET), + any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok("ok")); + doThrow(new RuntimeException("db unavailable")).when(apiLogService).recordApiLog(any(SysApiLog.class)); + + assertEquals("ok", httpUtil.get(CALLER, "http://lsfx/success", (Map) null, String.class)); + } + + @Test + void networkFailure_shouldPersistExceptionWithoutResponseStatus() { + when(restTemplate.exchange(eq("http://lsfx/network-error"), eq(HttpMethod.GET), + any(HttpEntity.class), eq(String.class))).thenThrow(new ResourceAccessException("connection refused")); + + assertThrows(LsfxApiException.class, () -> + httpUtil.get(CALLER, "http://lsfx/network-error", (Map) null, String.class)); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(SysApiLog.class); + verify(apiLogService).recordApiLog(logCaptor.capture()); + SysApiLog saved = logCaptor.getValue(); + assertEquals(null, saved.getResponseStatus()); + assertTrue(saved.getErrorMsg().contains("connection refused")); + assertEquals("1", saved.getCallStatus()); + } + + @Test + void emptyResponse_shouldPersistRawResponseBeforeFailing() { + when(restTemplate.exchange(eq("http://lsfx/empty"), eq(HttpMethod.GET), + any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok(" ")); + + assertThrows(LsfxApiException.class, () -> + httpUtil.get(CALLER, "http://lsfx/empty", (Map) null, String.class)); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(SysApiLog.class); + verify(apiLogService).recordApiLog(logCaptor.capture()); + SysApiLog saved = logCaptor.getValue(); + assertEquals(200, saved.getResponseStatus()); + assertEquals(" ", saved.getResponseBody()); + assertTrue(saved.getErrorMsg().contains("API返回数据为空")); + } + + @Test + void parseFailure_shouldPersistOriginalResponseBody() { + String rawBody = "{\"value\":\"not-an-integer\",\"unknown\":true}"; + when(restTemplate.exchange(eq("http://lsfx/parse-error"), eq(HttpMethod.GET), + any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok(rawBody)); + + assertThrows(LsfxApiException.class, () -> + httpUtil.get(CALLER, "http://lsfx/parse-error", (Map) null, Integer.class)); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(SysApiLog.class); + verify(apiLogService).recordApiLog(logCaptor.capture()); + SysApiLog saved = logCaptor.getValue(); + assertEquals(rawBody, saved.getResponseBody()); + assertEquals("1", saved.getCallStatus()); + assertTrue(saved.getErrorMsg() != null && !saved.getErrorMsg().isBlank()); + } + + @Test + void successfulResponse_shouldPersistCompleteRawBodyIncludingUnknownFields() { + String rawBody = "{\"known\":1,\"unknown\":{\"nested\":true}}"; + when(restTemplate.exchange(eq("http://lsfx/raw"), eq(HttpMethod.GET), + any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok(rawBody)); + + Map result = httpUtil.get(CALLER, "http://lsfx/raw", (Map) null, Map.class); + + assertEquals(Boolean.TRUE, ((Map) result.get("unknown")).get("nested")); + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(SysApiLog.class); + verify(apiLogService).recordApiLog(logCaptor.capture()); + assertEquals(rawBody, logCaptor.getValue().getResponseBody()); + assertEquals("0", logCaptor.getValue().getCallStatus()); + } + + @Test + void requestSnapshotFailure_shouldNotPreventExternalCallOrLogging() { + Iterable brokenSnapshotValue = () -> new Iterator<>() { + @Override + public boolean hasNext() { + throw new IllegalStateException("snapshot failed"); + } + + @Override + public String next() { + return "unused"; + } + }; + when(restTemplate.exchange(eq("http://lsfx/snapshot-error"), eq(HttpMethod.POST), + any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok("ok")); + + assertEquals("ok", httpUtil.postJson(CALLER, "http://lsfx/snapshot-error", + brokenSnapshotValue, null, String.class)); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(SysApiLog.class); + verify(apiLogService).recordApiLog(logCaptor.capture()); + assertEquals("0", logCaptor.getValue().getCallStatus()); + verify(restTemplate).exchange(eq("http://lsfx/snapshot-error"), eq(HttpMethod.POST), + any(HttpEntity.class), eq(String.class)); + } + + @Test + void allPublicRequestTypes_shouldUseUnifiedLoggingPath() { + when(restTemplate.exchange(any(String.class), any(HttpMethod.class), + any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok("ok")); + + Map params = Map.of("value", "完整参数"); + Map headers = Map.of("client-id", "client-1"); + assertEquals("ok", httpUtil.get(CALLER, "http://lsfx/get", params, headers, String.class)); + assertEquals("ok", httpUtil.get(CALLER, "http://lsfx/get", headers, String.class)); + assertEquals("ok", httpUtil.postJson(CALLER, "http://lsfx/json", params, headers, String.class)); + assertEquals("ok", httpUtil.postFormData(CALLER, "http://lsfx/form-data", params, headers, String.class)); + assertEquals("ok", httpUtil.postUrlEncodedForm(CALLER, "http://lsfx/form", params, headers, String.class)); + assertEquals("ok", httpUtil.postUrlEncodedFormForString(CALLER, "http://lsfx/form-string", params, headers)); + assertEquals("ok", httpUtil.uploadFile(CALLER, "http://lsfx/upload", params, headers, String.class)); + + verify(apiLogService, times(7)).recordApiLog(any(SysApiLog.class)); + } + + @Test + void missingCaller_shouldFailBeforeHttpRequest() { + assertThrows(IllegalArgumentException.class, () -> + httpUtil.postJson(null, "http://lsfx/json", Map.of(), null, String.class)); + verify(restTemplate, never()).exchange(any(String.class), any(HttpMethod.class), + any(HttpEntity.class), eq(String.class)); + verify(apiLogService, never()).recordApiLog(any(SysApiLog.class)); + } + + @Test + void systemCaller_shouldPersistNullUserIdAndSystemUsername() { + when(restTemplate.exchange(eq("http://lsfx/system"), eq(HttpMethod.GET), + any(HttpEntity.class), eq(String.class))).thenReturn(ResponseEntity.ok("ok")); + + assertEquals("ok", httpUtil.get(CallerContext.system(), "http://lsfx/system", + (Map) null, String.class)); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(SysApiLog.class); + verify(apiLogService).recordApiLog(logCaptor.capture()); + assertEquals(null, logCaptor.getValue().getCallerUserId()); + assertEquals("system", logCaptor.getValue().getCallerUsername()); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private ArgumentCaptor> httpEntityCaptor() { + return (ArgumentCaptor) ArgumentCaptor.forClass(HttpEntity.class); } } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java index 67dfd2a4..409d62fb 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java @@ -15,6 +15,7 @@ import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.core.page.TableSupport; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.lsfx.constants.LsfxConstants; +import com.ruoyi.lsfx.domain.CallerContext; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; @@ -77,8 +78,8 @@ public class CcdiFileUploadController extends BaseController { } try { - String username = SecurityUtils.getUsername(); - String batchId = fileUploadService.batchUploadFiles(projectId, files, username); + CallerContext caller = CallerContext.from(SecurityUtils.getLoginUser()); + String batchId = fileUploadService.batchUploadFiles(projectId, files, caller); return AjaxResult.success("上传任务已提交", batchId); } catch (RejectedExecutionException e) { log.warn("线程池已满,拒绝上传请求: projectId={}, fileCount={}", projectId, files.length); @@ -130,16 +131,14 @@ public class CcdiFileUploadController extends BaseController { return AjaxResult.error("开始日期和结束日期不能为空"); } - Long userId = SecurityUtils.getUserId(); - String username = SecurityUtils.getUsername(); + CallerContext caller = CallerContext.from(SecurityUtils.getLoginUser()); String batchId = fileUploadService.submitPullBankInfo( dto.getProjectId(), dto.getIdCards(), dataChannelCode, dto.getStartDate(), dto.getEndDate(), - userId, - username + caller ); return AjaxResult.success("拉取任务已提交", batchId); } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectController.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectController.java index 9c317699..66125560 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectController.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectController.java @@ -13,6 +13,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectHistoryListItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectStatusCountsVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectVO; import com.ruoyi.ccdi.project.service.ICcdiProjectService; +import com.ruoyi.lsfx.domain.CallerContext; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; @@ -43,7 +44,7 @@ public class CcdiProjectController extends BaseController { @Operation(summary = "创建项目") @PreAuthorize("@ss.hasPermi('ccdi:project:add')") public AjaxResult createProject(@Validated @RequestBody CcdiProjectSaveDTO dto) { - CcdiProjectVO vo = projectService.createProject(dto); + CcdiProjectVO vo = projectService.createProject(dto, CallerContext.from(SecurityUtils.getLoginUser())); return AjaxResult.success("项目创建成功", vo); } @@ -130,7 +131,7 @@ public class CcdiProjectController extends BaseController { @Operation(summary = "导入历史项目") @PreAuthorize("@ss.hasPermi('ccdi:project:add')") public AjaxResult importFromHistory(@Validated @RequestBody CcdiProjectImportHistoryDTO dto) { - CcdiProjectVO vo = projectService.importFromHistory(dto, SecurityUtils.getUsername()); + CcdiProjectVO vo = projectService.importFromHistory(dto, CallerContext.from(SecurityUtils.getLoginUser())); return AjaxResult.success("项目创建成功", vo); } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java index f9969e05..daa6bd73 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.ccdi.project.domain.dto.CcdiFileUploadQueryDTO; import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord; import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO; +import com.ruoyi.lsfx.domain.CallerContext; import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -24,7 +25,7 @@ public interface ICcdiFileUploadService { * @param username 上传人 * @return 批次ID */ - String batchUploadFiles(Long projectId, MultipartFile[] files, String username); + String batchUploadFiles(Long projectId, MultipartFile[] files, CallerContext caller); /** * 解析身份证文件 @@ -51,8 +52,7 @@ public interface ICcdiFileUploadService { String dataChannelCode, String startDate, String endDate, - Long userId, - String username); + CallerContext caller); /** * 删除上传记录并清理关联数据 diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java index 9bcbcd09..bd700ea0 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java @@ -7,6 +7,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectHistoryListItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectStatusCountsVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectVO; +import com.ruoyi.lsfx.domain.CallerContext; import java.util.List; @@ -22,7 +23,7 @@ public interface ICcdiProjectService { * @param dto 项目保存DTO * @return 项目VO */ - CcdiProjectVO createProject(CcdiProjectSaveDTO dto); + CcdiProjectVO createProject(CcdiProjectSaveDTO dto, CallerContext caller); /** * 更新项目 @@ -81,7 +82,7 @@ public interface ICcdiProjectService { * @param operator 操作人 * @return 新建项目 */ - CcdiProjectVO importFromHistory(CcdiProjectImportHistoryDTO dto, String operator); + CcdiProjectVO importFromHistory(CcdiProjectImportHistoryDTO dto, CallerContext caller); /** * 查询各状态的项目总数(不受搜索条件影响) diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java index 51e29ef3..6404ae71 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java @@ -19,6 +19,7 @@ import com.ruoyi.ccdi.project.service.ICcdiProjectService; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.lsfx.client.LsfxAnalysisClient; import com.ruoyi.lsfx.constants.LsfxConstants; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest; import com.ruoyi.lsfx.domain.request.GetBankStatementRequest; import com.ruoyi.lsfx.domain.request.GetFileUploadStatusRequest; @@ -157,8 +158,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { String dataChannelCode, String startDate, String endDate, - Long userId, - String username) { + CallerContext caller) { if (projectId == null) { throw new IllegalArgumentException("项目ID不能为空"); } @@ -209,7 +209,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { record.setFileStatus("uploading"); record.setAccountNos(normalized); record.setUploadTime(now); - record.setUploadUser(username); + record.setUploadUser(caller.username()); records.add(record); } if (records.isEmpty()) { @@ -223,7 +223,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { public void afterCommit() { CompletableFuture.runAsync(() -> submitPullBankInfoTasks( projectId, lsfxProjectId, records, normalizedIdCards, - normalizedDataChannelCode, startDate, endDate, batchId + normalizedDataChannelCode, startDate, endDate, batchId, caller )); } }); @@ -345,9 +345,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { @Transactional @Override - public String batchUploadFiles(Long projectId, MultipartFile[] files, String username) { + public String batchUploadFiles(Long projectId, MultipartFile[] files, CallerContext caller) { log.info("【文件上传】开始批量上传: projectId={}, 文件数量={}, username={}", - projectId, files.length, username); + projectId, files.length, caller.username()); projectService.ensureProjectNotArchived(projectId, "已归档项目暂不允许上传或拉取数据"); projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据"); @@ -406,7 +406,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { record.setFileSize(file.getSize()); record.setFileStatus("uploading"); record.setUploadTime(now); - record.setUploadUser(username); + record.setUploadUser(caller.username()); records.add(record); } } catch (IOException e) { @@ -438,7 +438,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { public void afterCommit() { log.info("【文件上传】事务已提交,启动异步任务"); CompletableFuture.runAsync(() -> { - submitTasksAsync(projectId, finalLsfxProjectId, tempFilePaths, records, batchId); + submitTasksAsync(projectId, finalLsfxProjectId, tempFilePaths, records, batchId, caller); }); } }); @@ -496,7 +496,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { private void submitTasksAsync(Long projectId, Integer lsfxProjectId, List tempFilePaths, List records, - String batchId) { + String batchId, + CallerContext caller) { log.info("【文件上传】调度线程启动: projectId={}, batchId={}", projectId, batchId); List> futures = new ArrayList<>(); @@ -519,7 +520,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { try { // 尝试提交异步任务 CompletableFuture future = CompletableFuture.supplyAsync( - () -> processFileAsync(projectId, lsfxProjectId, tempFilePath, record.getId(), batchId, record), + () -> processFileAsync(projectId, lsfxProjectId, tempFilePath, record.getId(), batchId, record, caller), fileUploadExecutor ); futures.add(future); @@ -600,7 +601,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { String dataChannelCode, String startDate, String endDate, - String batchId) { + String batchId, + CallerContext caller) { log.info("【拉取本行信息】调度线程启动: projectId={}, batchId={}", projectId, batchId); List> futures = new ArrayList<>(); @@ -619,7 +621,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { while (!submitted && retryCount < 2) { try { CompletableFuture future = CompletableFuture.supplyAsync( - () -> processPullBankInfoAsync(projectId, lsfxProjectId, record, idCard, dataChannelCode, startDate, endDate), + () -> processPullBankInfoAsync(projectId, lsfxProjectId, record, idCard, + dataChannelCode, startDate, endDate, caller), fileUploadExecutor ); futures.add(future); @@ -660,7 +663,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { String idCard, String dataChannelCode, String startDate, - String endDate ) { + String endDate, + CallerContext caller) { try { String normalizedDataChannelCode = normalizePullBankInfoDataChannelCode(dataChannelCode); FetchInnerFlowRequest request = new FetchInnerFlowRequest(); @@ -677,7 +681,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { } request.setUploadUserId(LsfxConstants.DEFAULT_USER_ID); - FetchInnerFlowResponse response = lsfxClient.fetchInnerFlow(request); + FetchInnerFlowResponse response = lsfxClient.fetchInnerFlow(caller, request); if (response == null || response.getData() == null || response.getData().isEmpty()) { throw new RuntimeException("拉取本行信息失败: 未返回logId"); } @@ -687,7 +691,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { throw new RuntimeException("拉取本行信息失败: 未返回logId"); } - processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId); + processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, caller); return true; } catch (Exception e) { log.error("【拉取本行信息】处理失败: idCard={}, recordId={}", idCard, record.getId(), e); @@ -709,7 +713,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { */ @Async("fileUploadExecutor") public boolean processFileAsync(Long projectId, Integer lsfxProjectId, String tempFilePath, - Long recordId, String batchId, CcdiFileUploadRecord record) { + Long recordId, String batchId, CcdiFileUploadRecord record, + CallerContext caller) { log.info("【文件上传】开始处理文件: fileName={}, recordId={}, tempPath={}", record.getFileName(), recordId, tempFilePath); @@ -730,7 +735,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { throw new RuntimeException("临时文件不存在: " + tempFilePath); } - UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file, record.getFileName()); + UploadFileResponse uploadResponse = lsfxClient.uploadFile(caller, lsfxProjectId, file, record.getFileName()); if (uploadResponse == null || uploadResponse.getData() == null || uploadResponse.getData().getUploadLogList() == null || uploadResponse.getData().getUploadLogList().isEmpty()) { @@ -744,7 +749,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { } log.info("【文件上传】文件上传成功: logId={}", logId); - processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, true); + processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, true, caller); log.info("【文件上传】处理完成: fileName={}", record.getFileName()); return true; @@ -780,22 +785,24 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { private void processRecordAfterLogIdReady(Long projectId, Integer lsfxProjectId, CcdiFileUploadRecord record, - Integer logId) { - processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false); + Integer logId, + CallerContext caller) { + processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false, caller); } private void processRecordAfterLogIdReady(Long projectId, Integer lsfxProjectId, CcdiFileUploadRecord record, Integer logId, - boolean preserveRecordFileName) { + boolean preserveRecordFileName, + CallerContext caller) { log.info("【文件上传】步骤3: 更新状态为解析中, logId={}", logId); record.setLogId(logId); record.setFileStatus("parsing"); recordMapper.updateById(record); log.info("【文件上传】步骤4: 开始轮询解析状态"); - boolean parsingComplete = waitForParsingComplete(lsfxProjectId, logId.toString()); + boolean parsingComplete = waitForParsingComplete(caller, lsfxProjectId, logId.toString()); if (!parsingComplete) { throw new RuntimeException("解析超时(超过10分钟),请检查文件格式是否正确"); } @@ -805,7 +812,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { statusRequest.setGroupId(lsfxProjectId); statusRequest.setLogId(logId); - GetFileUploadStatusResponse statusResponse = lsfxClient.getFileUploadStatus(statusRequest); + GetFileUploadStatusResponse statusResponse = lsfxClient.getFileUploadStatus(caller, statusRequest); if (statusResponse == null || statusResponse.getData() == null || statusResponse.getData().getLogs() == null || statusResponse.getData().getLogs().isEmpty()) { @@ -846,8 +853,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { log.info("【文件上传】步骤7: 获取流水数据"); String fallbackCretNo = extractIdCardFromFileName(record.getFileName()); - FetchBankStatementResult fetchResult = fetchAndSaveBankStatements(projectId, lsfxProjectId, logId, - fallbackCretNo); + FetchBankStatementResult fetchResult = fetchAndSaveBankStatements(caller, projectId, lsfxProjectId, + logId, fallbackCretNo); if (!fetchResult.isSuccess()) { updateFailedRecord(record, fetchResult.getErrorMessage()); return; @@ -867,7 +874,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { * @param logId 文件ID * @return true=解析完成,false=超时未完成 */ - private boolean waitForParsingComplete(Integer groupId, String logId) { + private boolean waitForParsingComplete(CallerContext caller, Integer groupId, String logId) { log.info("【文件上传】开始轮询解析状态: groupId={}, logId={}", groupId, logId); int maxRetries = 300; @@ -876,7 +883,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { for (int i = 1; i <= maxRetries; i++) { try { // 调用检查解析状态接口 - CheckParseStatusResponse response = lsfxClient.checkParseStatus(groupId, logId); + CheckParseStatusResponse response = lsfxClient.checkParseStatus(caller, groupId, logId); if (response == null || response.getData() == null) { log.warn("【文件上传】轮询第{}次: 响应数据为空", i); @@ -919,9 +926,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { * @param groupId 流水分析平台项目ID * @param logId 文件ID */ - private FetchBankStatementResult fetchAndSaveBankStatements(Long projectId, Integer groupId, - Integer logId, - String fallbackCretNo) { + private FetchBankStatementResult fetchAndSaveBankStatements(CallerContext caller, Long projectId, Integer groupId, + Integer logId, String fallbackCretNo) { log.info("【文件上传】开始获取流水数据: projectId={}, groupId={}, logId={}", projectId, groupId, logId); @@ -934,7 +940,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { firstRequest.setPageNow(1); firstRequest.setPageSize(1); - GetBankStatementResponse firstResponse = lsfxClient.getBankStatement(firstRequest); + GetBankStatementResponse firstResponse = lsfxClient.getBankStatement(caller, firstRequest); if (firstResponse == null || firstResponse.getData() == null) { result.setSuccess(false); result.setErrorMessage("获取流水数据失败: 响应数据为空"); @@ -968,7 +974,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { request.setPageNow(pageNow); request.setPageSize(pageSize); - GetBankStatementResponse response = lsfxClient.getBankStatement(request); + GetBankStatementResponse response = lsfxClient.getBankStatement(caller, request); if (response == null || response.getData() == null || response.getData().getBankStatementList() == null) { result.setSuccess(false); diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java index c773841b..84ad2395 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java @@ -19,6 +19,7 @@ import com.ruoyi.ccdi.project.service.CcdiProjectAccessService; import com.ruoyi.ccdi.project.service.ICcdiProjectService; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.lsfx.client.LsfxAnalysisClient; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.domain.request.GetTokenRequest; import com.ruoyi.lsfx.domain.response.GetTokenResponse; import jakarta.annotation.Resource; @@ -61,9 +62,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { @Override @Transactional(rollbackFor = Exception.class) - public CcdiProjectVO createProject(CcdiProjectSaveDTO dto) { + public CcdiProjectVO createProject(CcdiProjectSaveDTO dto, CallerContext caller) { // 1. 调用流水分析平台获取projectId - Integer lsfxProjectId = callLsfxPlatform(dto.getProjectName()); + Integer lsfxProjectId = callLsfxPlatform(dto.getProjectName(), caller); // 2. 创建项目实体 CcdiProject project = new CcdiProject(); @@ -163,18 +164,18 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { @Override @Transactional(rollbackFor = Exception.class) - public CcdiProjectVO importFromHistory(CcdiProjectImportHistoryDTO dto, String operator) { + public CcdiProjectVO importFromHistory(CcdiProjectImportHistoryDTO dto, CallerContext caller) { projectAccessService.assertSourceProjectsReadable(dto.getSourceProjectIds()); CcdiProjectSaveDTO saveDTO = new CcdiProjectSaveDTO(); saveDTO.setProjectName(dto.getProjectName()); saveDTO.setDescription(dto.getDescription()); saveDTO.setConfigType("default"); - CcdiProjectVO project = createProject(saveDTO); + CcdiProjectVO project = createProject(saveDTO, caller); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { applicationEventPublisher.publishEvent( - new CcdiProjectHistoryImportSubmittedEvent(project.getProjectId(), project.getLsfxProjectId(), dto, operator) + new CcdiProjectHistoryImportSubmittedEvent(project.getProjectId(), project.getLsfxProjectId(), dto, caller.username()) ); } }); @@ -380,7 +381,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { * @return 流水分析平台项目ID * @throws ServiceException 调用失败或响应无效时抛出 */ - private Integer callLsfxPlatform(String projectName) { + private Integer callLsfxPlatform(String projectName, CallerContext caller) { // 构建请求参数 GetTokenRequest request = new GetTokenRequest(); request.setProjectNo("902000_" + System.currentTimeMillis()); @@ -393,7 +394,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { request.setDepartmentCode("902000"); // 调用流水分析平台(异常处理和日志已在 LsfxAnalysisClient 中完成) - GetTokenResponse response = lsfxAnalysisClient.getToken(request); + GetTokenResponse response = lsfxAnalysisClient.getToken(caller, request); // 业务层校验:确保响应有效 if (response == null || response.getData() == null) { diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java index 5c973082..146c923b 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java @@ -6,6 +6,7 @@ import com.ruoyi.ccdi.project.service.ICcdiFileUploadService; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.lsfx.domain.CallerContext; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,6 +31,7 @@ import static org.mockito.Mockito.when; class CcdiFileUploadControllerTest { private static final Long PROJECT_ID = 100L; + private static final CallerContext CALLER = CallerContext.of(9527L, "admin"); @InjectMocks private CcdiFileUploadController controller; @@ -72,7 +74,7 @@ class CcdiFileUploadControllerTest { }; setLoginUser(9527L, "admin"); - when(fileUploadService.batchUploadFiles(PROJECT_ID, files, "admin")) + when(fileUploadService.batchUploadFiles(PROJECT_ID, files, CALLER)) .thenReturn("batch-1"); AjaxResult result = controller.batchUpload(PROJECT_ID, files); @@ -80,7 +82,7 @@ class CcdiFileUploadControllerTest { assertEquals(200, result.get("code")); assertEquals("batch-1", result.get("data")); verify(projectAccessService).assertCanOperate(PROJECT_ID); - verify(fileUploadService).batchUploadFiles(PROJECT_ID, files, "admin"); + verify(fileUploadService).batchUploadFiles(PROJECT_ID, files, CALLER); } @Test @@ -93,7 +95,8 @@ class CcdiFileUploadControllerTest { dto.setEndDate("2026-03-10"); setLoginUser(9527L, "admin"); - when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "ZJRCU", "2026-03-01", "2026-03-10", 9527L, "admin")) + when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "ZJRCU", + "2026-03-01", "2026-03-10", CALLER)) .thenReturn("batch-1"); AjaxResult result = controller.pullBankInfo(dto); @@ -109,7 +112,7 @@ class CcdiFileUploadControllerTest { dto.setDataChannelCode("JZL"); setLoginUser(9527L, "admin"); - when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "JZL", null, null, 9527L, "admin")) + when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "JZL", null, null, CALLER)) .thenReturn("batch-1"); AjaxResult result = controller.pullBankInfo(dto); diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java index 1fc2ffbb..f8303c7e 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java @@ -17,6 +17,7 @@ import com.ruoyi.ccdi.project.service.ICcdiProjectService; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.lsfx.client.LsfxAnalysisClient; import com.ruoyi.lsfx.constants.LsfxConstants; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest; import com.ruoyi.lsfx.domain.request.GetBankStatementRequest; import com.ruoyi.lsfx.domain.response.CheckParseStatusResponse; @@ -34,6 +35,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.LoggerFactory; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; @@ -63,6 +65,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import org.mockito.ArgumentCaptor; @ExtendWith(MockitoExtension.class) class CcdiFileUploadServiceImplTest { @@ -71,6 +74,7 @@ class CcdiFileUploadServiceImplTest { private static final Integer LSFX_PROJECT_ID = 200; private static final Long RECORD_ID = 300L; private static final Integer LOG_ID = 400; + private static final CallerContext CALLER = CallerContext.of(9527L, "admin"); private static final int MAX_ERROR_MESSAGE_LENGTH = 2000; @InjectMocks @@ -149,8 +153,7 @@ class CcdiFileUploadServiceImplTest { LsfxConstants.DATA_CHANNEL_ZJRCU, "2026-03-01", "2026-03-10", - 9527L, - "admin" + CALLER ); assertNotNull(batchId); @@ -177,8 +180,7 @@ class CcdiFileUploadServiceImplTest { LsfxConstants.DATA_CHANNEL_ZJRCU, "2026-01-01", "2026-01-31", - 1L, - "tester" + CALLER )); } @@ -195,7 +197,7 @@ class CcdiFileUploadServiceImplTest { ); assertThrows(ServiceException.class, - () -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester")); + () -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, CALLER)); } @Test @@ -222,7 +224,7 @@ class CcdiFileUploadServiceImplTest { TransactionSynchronizationManager.initSynchronization(); try { - String batchId = service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"); + String batchId = service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, CALLER); assertNotNull(batchId); assertNotNull(inserted.get()); @@ -251,12 +253,12 @@ class CcdiFileUploadServiceImplTest { ); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester")); + () -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, CALLER)); assertTrue(exception.getMessage().contains("身份证")); assertFalse(Files.exists(tempDir.resolve("temp"))); verify(recordMapper, never()).insertBatch(any()); - verify(lsfxClient, never()).uploadFile(any(), org.mockito.ArgumentMatchers.any(), any()); + verify(lsfxClient, never()).uploadFile(any(), any(), org.mockito.ArgumentMatchers.any(), any()); } @Test @@ -272,12 +274,12 @@ class CcdiFileUploadServiceImplTest { ); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester")); + () -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, CALLER)); assertTrue(exception.getMessage().contains("文件名不能为空")); assertFalse(Files.exists(tempDir.resolve("temp"))); verify(recordMapper, never()).insertBatch(any()); - verify(lsfxClient, never()).uploadFile(any(), org.mockito.ArgumentMatchers.any(), any()); + verify(lsfxClient, never()).uploadFile(any(), any(), org.mockito.ArgumentMatchers.any(), any()); } @Test @@ -293,6 +295,29 @@ class CcdiFileUploadServiceImplTest { assertFalse(Files.exists(batchLogDir)); } + @Test + void submitTasksAsync_shouldKeepEachCapturedCallerAfterSecurityContextIsCleared() throws Exception { + setField("fileUploadExecutor", (Executor) Runnable::run); + CallerContext callerA = CallerContext.of(101L, "userA"); + CallerContext callerB = CallerContext.of(202L, "userB"); + Path fileA = createTempFile(); + Path fileB = createTempFile(); + CcdiFileUploadRecord recordA = buildRecord(); + CcdiFileUploadRecord recordB = buildRecord(); + recordB.setId(RECORD_ID + 1); + when(lsfxClient.uploadFile(any(CallerContext.class), eq(LSFX_PROJECT_ID), any(), any())) + .thenThrow(new RuntimeException("stop after caller capture")); + + SecurityContextHolder.clearContext(); + invokeSubmitTasksAsync(List.of(fileA.toString()), List.of(recordA), "batch-a", callerA); + invokeSubmitTasksAsync(List.of(fileB.toString()), List.of(recordB), "batch-b", callerB); + + ArgumentCaptor callerCaptor = ArgumentCaptor.forClass(CallerContext.class); + verify(lsfxClient, org.mockito.Mockito.times(2)).uploadFile( + callerCaptor.capture(), eq(LSFX_PROJECT_ID), any(), any()); + assertEquals(List.of(callerA, callerB), callerCaptor.getAllValues()); + } + @Test void handleTagRebuildAfterBatchCompletion_shouldLogSkipWhenAllRecordsFailed() { Logger logger = (Logger) LoggerFactory.getLogger(CcdiFileUploadServiceImpl.class); @@ -352,18 +377,18 @@ class CcdiFileUploadServiceImplTest { AtomicInteger sequence = new AtomicInteger(); captureRecordStatus(events, sequence); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) .thenReturn(buildUploadResponse()); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse()); + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenThrow(new RuntimeException("bank statement fetch failed")); CcdiFileUploadRecord record = buildRecord(); Path tempFile = createTempFile(); - service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER); assertTrue(events.stream().anyMatch(event -> event.endsWith("record:parsed_failed"))); assertFalse(events.stream().anyMatch(event -> event.endsWith("record:parsed_success"))); @@ -379,12 +404,12 @@ class CcdiFileUploadServiceImplTest { when(projectMapper.selectById(PROJECT_ID)).thenReturn(project); when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(1); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) .thenReturn(buildUploadResponse()); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse()); + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenAnswer(invocation -> { events.add(sequence.incrementAndGet() + ":bank-fetch"); return buildEmptyBankStatementResponse(); @@ -393,7 +418,7 @@ class CcdiFileUploadServiceImplTest { CcdiFileUploadRecord record = buildRecord(); Path tempFile = createTempFile(); - service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER); int fetchIndex = findEventIndex(events, "bank-fetch"); int successIndex = findEventIndex(events, "record:parsed_success"); @@ -406,18 +431,18 @@ class CcdiFileUploadServiceImplTest { @Test void processFileAsync_shouldCleanupInsertedStatementsWhenFetchFails() throws IOException { - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) .thenReturn(buildUploadResponse()); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse()); + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenThrow(new RuntimeException("bank statement fetch failed")); CcdiFileUploadRecord record = buildRecord(); Path tempFile = createTempFile(); - service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER); verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID); } @@ -427,11 +452,11 @@ class CcdiFileUploadServiceImplTest { GetFileUploadStatusResponse statusResponse = buildParsedSuccessStatusResponse("XX身份证.xlsx"); statusResponse.getData().getLogs().get(0).setFileSize(2048L); - when(lsfxClient.fetchInnerFlow(any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID)); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.fetchInnerFlow(eq(CALLER), any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID)); + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse); - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(statusResponse); + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenReturn(buildEmptyBankStatementResponse()); CcdiFileUploadRecord record = buildRecord(); @@ -444,7 +469,8 @@ class CcdiFileUploadServiceImplTest { "110101199001018888", LsfxConstants.DATA_CHANNEL_ZJRCU, "2026-03-01", - "2026-03-10" + "2026-03-10", + CALLER ); verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById( @@ -457,11 +483,11 @@ class CcdiFileUploadServiceImplTest { @Test void processPullBankInfoAsync_shouldFetchJzlWithZeroDateRange() { - when(lsfxClient.fetchInnerFlow(any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID)); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.fetchInnerFlow(eq(CALLER), any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID)); + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse()); + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenReturn(buildEmptyBankStatementResponse()); CcdiFileUploadRecord record = buildRecord(); @@ -473,10 +499,11 @@ class CcdiFileUploadServiceImplTest { "110101199001018888", LsfxConstants.DATA_CHANNEL_JZL, null, - null + null, + CALLER ); - verify(lsfxClient).fetchInnerFlow(argThat((FetchInnerFlowRequest request) -> + verify(lsfxClient).fetchInnerFlow(eq(CALLER), argThat((FetchInnerFlowRequest request) -> LsfxConstants.DATA_CHANNEL_JZL.equals(request.getDataChannelCode()) && Integer.valueOf(0).equals(request.getDataStartDateId()) && Integer.valueOf(0).equals(request.getDataEndDateId()) @@ -485,21 +512,21 @@ class CcdiFileUploadServiceImplTest { @Test void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException { - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx"))) + when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx"))) .thenReturn(buildUploadResponse()); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse()); + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenReturn(buildEmptyBankStatementResponse()); CcdiFileUploadRecord record = buildRecord(); record.setFileName("原始流水.xlsx"); Path tempFile = createTempFile(); - service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER); - verify(lsfxClient).uploadFile(eq(LSFX_PROJECT_ID), argThat(file -> + verify(lsfxClient).uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), argThat(file -> file.getName().startsWith("upload-") && file.getName().endsWith(".xlsx") ), eq("原始流水.xlsx")); } @@ -517,14 +544,15 @@ class CcdiFileUploadServiceImplTest { project.setProjectId(PROJECT_ID); when(projectMapper.selectById(PROJECT_ID)).thenReturn(project); when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(1); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("张三_330101199001010011_流水.xlsx"))) + when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), + eq("张三_330101199001010011_流水.xlsx"))) .thenReturn(buildUploadResponse()); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse()); + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenAnswer(invocation -> { - GetBankStatementRequest request = invocation.getArgument(0); + GetBankStatementRequest request = invocation.getArgument(1); if (Integer.valueOf(1).equals(request.getPageSize())) { return buildBankStatementCountResponse(1); } @@ -535,7 +563,8 @@ class CcdiFileUploadServiceImplTest { record.setFileName("张三_330101199001010011_流水.xlsx"); Path tempFile = createTempFile(); - service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, + CALLER); assertNotNull(insertedStatements.get()); assertEquals(1, insertedStatements.get().size()); @@ -544,20 +573,20 @@ class CcdiFileUploadServiceImplTest { @Test void processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName() throws IOException { - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) .thenReturn(buildUploadResponse()); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())) + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())) .thenReturn(buildParsedSuccessStatusResponse("平台返回文件名.xlsx")); - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenReturn(buildEmptyBankStatementResponse()); CcdiFileUploadRecord record = buildRecord(); record.setFileName("原始流水.xlsx"); Path tempFile = createTempFile(); - service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER); verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById( org.mockito.ArgumentMatchers.argThat(item -> @@ -573,17 +602,17 @@ class CcdiFileUploadServiceImplTest { logItem.setStatus(-1); logItem.setUploadStatusDesc("parse.failed"); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) .thenReturn(buildUploadResponse()); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse); + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(statusResponse); CcdiFileUploadRecord record = buildRecord(); record.setFileName("原始流水.xlsx"); Path tempFile = createTempFile(); - service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER); verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById( org.mockito.ArgumentMatchers.argThat(item -> @@ -610,7 +639,7 @@ class CcdiFileUploadServiceImplTest { String result = service.deleteFileUploadRecord(RECORD_ID, 9527L); assertEquals("删除成功,已开始项目重新打标", result); - verify(lsfxClient, never()).deleteFiles(any()); + verify(lsfxClient, never()).deleteFiles(any(), any()); verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID); verify(recordMapper).updateById(org.mockito.ArgumentMatchers.argThat(item -> RECORD_ID.equals(item.getId()) && "deleted".equals(item.getFileStatus()) @@ -644,7 +673,7 @@ class CcdiFileUploadServiceImplTest { () -> service.deleteFileUploadRecord(RECORD_ID, 9527L)); assertTrue(exception.getMessage().contains("历史导入文件不支持删除")); - verify(lsfxClient, never()).deleteFiles(any()); + verify(lsfxClient, never()).deleteFiles(any(), any()); } @Test @@ -659,7 +688,7 @@ class CcdiFileUploadServiceImplTest { String result = service.deleteFileUploadRecord(RECORD_ID, 9527L); assertEquals("删除成功,已开始项目重新打标", result); - verify(lsfxClient, never()).deleteFiles(any()); + verify(lsfxClient, never()).deleteFiles(any(), any()); verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID); verify(recordMapper).updateById(org.mockito.ArgumentMatchers.argThat(item -> "deleted".equals(item.getFileStatus()) @@ -669,7 +698,7 @@ class CcdiFileUploadServiceImplTest { // @Test // void processPullBankInfoAsync_shouldMarkParsedFailedWhenFetchInnerFlowThrows() { -// when(lsfxClient.fetchInnerFlow(any())).thenThrow(new RuntimeException("fetch inner flow failed")); +// when(lsfxClient.fetchInnerFlow(eq(CALLER), any())).thenThrow(new RuntimeException("fetch inner flow failed")); // // CcdiFileUploadRecord record = buildRecord(); // service.processPullBankInfoAsync( @@ -692,19 +721,19 @@ class CcdiFileUploadServiceImplTest { AtomicInteger sequence = new AtomicInteger(); captureRecordStatus(events, sequence); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) .thenReturn(buildUploadResponse()); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse()); + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenReturn(buildBankStatementResponseWithTotalCount(1)) .thenThrow(new RuntimeException("paged fetch failed")); CcdiFileUploadRecord record = buildRecord(); Path tempFile = createTempFile(); - service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER); assertTrue(events.stream().anyMatch(event -> event.endsWith("record:parsed_failed"))); assertFalse(events.stream().anyMatch(event -> event.endsWith("record:parsed_success"))); @@ -716,18 +745,18 @@ class CcdiFileUploadServiceImplTest { List updates = new ArrayList<>(); captureUpdatedRecords(updates); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) .thenReturn(buildUploadResponse()); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse()); + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenThrow(new RuntimeException("bank statement fetch failed:" + "x".repeat(3000))); CcdiFileUploadRecord record = buildRecord(); Path tempFile = createTempFile(); - service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER); CcdiFileUploadRecord failedRecord = findLastUpdatedRecordByStatus(updates, "parsed_failed"); assertTrue(failedRecord.getErrorMessage().length() <= MAX_ERROR_MESSAGE_LENGTH); @@ -738,13 +767,13 @@ class CcdiFileUploadServiceImplTest { List updates = new ArrayList<>(); captureUpdatedRecords(updates); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) .thenThrow(new RuntimeException("upload failed:" + "x".repeat(3000))); CcdiFileUploadRecord record = buildRecord(); Path tempFile = createTempFile(); - service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER); CcdiFileUploadRecord failedRecord = findLastUpdatedRecordByStatus(updates, "parsed_failed"); assertTrue(failedRecord.getErrorMessage().length() <= MAX_ERROR_MESSAGE_LENGTH); @@ -752,19 +781,19 @@ class CcdiFileUploadServiceImplTest { @Test void fetchAndSaveBankStatements_shouldTrimLeAccountNoBeforeInsert() throws IOException { - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) .thenReturn(buildUploadResponse()); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse()); + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem(" 62220001 ")))) .thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem(" 62220001 ")))); CcdiFileUploadRecord record = buildRecord(); Path tempFile = createTempFile(); - service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER); verify(bankStatementMapper).insertBatch(any()); verify(bankStatementMapper).insertBatch(org.mockito.ArgumentMatchers.argThat(list -> @@ -773,7 +802,7 @@ class CcdiFileUploadServiceImplTest { @Test void fetchAndSaveBankStatements_shouldLogConservativeCountsWhenAffectedRowsAreAmbiguous() { - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem("62220001")))) .thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem("62220001")))); when(bankStatementMapper.insertBatch(any())).thenReturn(1); @@ -787,6 +816,7 @@ class CcdiFileUploadServiceImplTest { Object result = ReflectionTestUtils.invokeMethod( service, "fetchAndSaveBankStatements", + CALLER, PROJECT_ID, LSFX_PROJECT_ID, LOG_ID, @@ -825,12 +855,12 @@ class CcdiFileUploadServiceImplTest { AtomicInteger sequence = new AtomicInteger(); captureRecordStatus(events, sequence); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) .thenReturn(buildUploadResponse()); - when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + when(lsfxClient.checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); - when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); - when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse()); + when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class))) .thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem("62220001")))) .thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem("62220001")))); when(bankStatementMapper.insertBatch(any())) @@ -839,7 +869,7 @@ class CcdiFileUploadServiceImplTest { CcdiFileUploadRecord record = buildRecord(); Path tempFile = createTempFile(); - service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record, CALLER); assertTrue(events.stream().anyMatch(event -> event.endsWith("record:parsed_failed"))); assertFalse(events.stream().anyMatch(event -> event.endsWith("record:parsed_success"))); @@ -1026,10 +1056,17 @@ class CcdiFileUploadServiceImplTest { private void invokeSubmitTasksAsync(List tempFilePaths, List records, String batchId) throws Exception { + invokeSubmitTasksAsync(tempFilePaths, records, batchId, CALLER); + } + + private void invokeSubmitTasksAsync(List tempFilePaths, + List records, + String batchId, + CallerContext caller) throws Exception { Method method = CcdiFileUploadServiceImpl.class.getDeclaredMethod("submitTasksAsync", - Long.class, Integer.class, List.class, List.class, String.class); + Long.class, Integer.class, List.class, List.class, String.class, CallerContext.class); method.setAccessible(true); - method.invoke(service, PROJECT_ID, LSFX_PROJECT_ID, tempFilePaths, records, batchId); + method.invoke(service, PROJECT_ID, LSFX_PROJECT_ID, tempFilePaths, records, batchId, caller); } private void setField(String fieldName, Object value) throws Exception { diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java index f23a873e..6368ad93 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java @@ -18,6 +18,7 @@ import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper; import com.ruoyi.ccdi.project.service.CcdiProjectAccessService; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.lsfx.client.LsfxAnalysisClient; +import com.ruoyi.lsfx.domain.CallerContext; import com.ruoyi.lsfx.domain.response.GetTokenResponse; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -48,6 +49,8 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class CcdiProjectServiceImplTest { + private static final CallerContext CALLER = CallerContext.of(7L, "tester"); + @InjectMocks private CcdiProjectServiceImpl service; @@ -282,7 +285,7 @@ class CcdiProjectServiceImplTest { dto.setStartDate("2026-01-01"); dto.setEndDate("2026-01-31"); - when(lsfxAnalysisClient.getToken(any())).thenReturn(buildTokenResponse(3001)); + when(lsfxAnalysisClient.getToken(any(CallerContext.class), any())).thenReturn(buildTokenResponse(3001)); doAnswer(invocation -> { CcdiProject project = invocation.getArgument(0); project.setProjectId(90L); @@ -291,7 +294,7 @@ class CcdiProjectServiceImplTest { TransactionSynchronizationManager.initSynchronization(); try { - CcdiProjectVO project = service.importFromHistory(dto, "tester"); + CcdiProjectVO project = service.importFromHistory(dto, CALLER); assertNotNull(project); assertEquals(90L, project.getProjectId()); @@ -320,7 +323,7 @@ class CcdiProjectServiceImplTest { dto.setDescription("测试项目"); dto.setConfigType("default"); - when(lsfxAnalysisClient.getToken(any())).thenReturn(buildTokenResponse(2001)); + when(lsfxAnalysisClient.getToken(any(CallerContext.class), any())).thenReturn(buildTokenResponse(2001)); doAnswer(invocation -> { CcdiProject project = invocation.getArgument(0); project.setProjectId(88L); @@ -333,7 +336,7 @@ class CcdiProjectServiceImplTest { logger.addAppender(logAppender); try { - service.createProject(dto); + service.createProject(dto, CALLER); assertTrue(logAppender.list.stream().map(ILoggingEvent::getFormattedMessage) .anyMatch(message -> message.contains("项目状态初始化") diff --git a/docs/plans/backend/2026-07-20-external-api-log-backend-implementation.md b/docs/plans/backend/2026-07-20-external-api-log-backend-implementation.md new file mode 100644 index 00000000..430996aa --- /dev/null +++ b/docs/plans/backend/2026-07-20-external-api-log-backend-implementation.md @@ -0,0 +1,26 @@ +# 业务外部接口日志后端实施计划 + +## 目标 + +在不改变业务外部接口调用结果的前提下,统一记录流水分析与征信解析请求的发起用户、完整请求、原始响应、HTTP状态、耗时和异常,并提供接口日志分页查询与详情接口。 + +## 实施内容 + +1. 新建`sys_api_log`,请求头、请求参数、响应头、响应正文和异常使用`LONGTEXT`,表和字符字段统一使用`utf8mb4_general_ci`。 +2. 在`ruoyi-system`新增独立Entity、查询DTO、列表VO、详情VO、Mapper和事务Service;列表SQL不读取大字段,详情按日志ID读取完整内容。 +3. 在`ruoyi-admin`新增`/monitor/apilog/list`与`/monitor/apilog/{logId}`,分别校验`monitor:apilog:list`和`monitor:apilog:query`权限。 +4. 在`ccdi-lsfx`新增不可变`CallerContext`,替换流水分析、征信解析和`HttpUtil`原有调用签名,所有调用方必须显式传入发起人。 +5. 批量上传与拉取本行信息在提交异步任务前生成调用用户快照,并贯穿上传、拉取、轮询、状态查询和流水分页查询全链路。 +6. `HttpUtil`统一获取原始响应文本后再反序列化;非2xx保存状态、响应头和错误正文,网络异常和解析异常同样保存失败日志。 +7. 认证字段按原文保存;multipart文件仅保存文件名、大小和媒体类型,不读取或保存文件内容。 +8. 日志写入使用`REQUIRES_NEW`事务;日志序列化或入库失败只记录应用错误,不覆盖原业务结果。 +9. 通过增量SQL幂等创建表、接口日志菜单和查询权限按钮,不自动写入`sys_role_menu`。 +10. 流水分析和征信解析测试控制器取消匿名访问,统一校验`ccdi:project:edit`,人工测试调用按当前登录用户记录,不再记为`system`。 + +## 验证标准 + +- JDK 21下后端主工程编译通过。 +- 全部`HttpUtil`调用类型均能保存成功或失败日志,原始响应字段不因DTO反序列化丢失。 +- 异步任务在线程安全上下文清理后仍保存最初发起用户,不同用户连续任务不串号。 +- 未授权用户不能访问接口日志列表和详情。 +- 日志入库失败不影响外部接口返回或原始业务异常。 diff --git a/docs/plans/frontend/2026-07-20-external-api-log-frontend-implementation.md b/docs/plans/frontend/2026-07-20-external-api-log-frontend-implementation.md new file mode 100644 index 00000000..39705228 --- /dev/null +++ b/docs/plans/frontend/2026-07-20-external-api-log-frontend-implementation.md @@ -0,0 +1,23 @@ +# 业务外部接口日志前端实施计划 + +## 目标 + +在“系统管理 > 日志管理”下新增“接口日志”页面,以紧凑列表和独立详情展示业务外部接口调用情况及完整请求响应。 + +## 实施内容 + +1. 新增`src/api/monitor/apilog.js`,封装分页列表和详情接口。 +2. 新增`src/views/monitor/apilog/index.vue`,支持接口地址、HTTP方法、HTTP调用状态和调用时间筛选。 +3. 列表展示日志编号、调用账号、接口地址、HTTP方法、HTTP状态、HTTP调用状态、耗时和调用时间。 +4. 详情按需二次加载,展示调用账号和用户ID、URL、Content-Type、请求头、请求参数、响应头、原始返回正文和异常。 +5. 请求响应长文本使用等宽字体和固定最大高度;可解析的JSON基于原始字符串进行无损结构美化并按2空格缩进展示,不将数值反序列化后再输出,普通文本保留原始换行,长字段按容器自动折行;使用文本插值,禁止`v-html`。 +6. 页面只提供查询和详情,不增加删除、清空或导出操作。 +7. 详情弹窗使用响应式宽度和桌面最大宽度,长URL和长文本不得撑破页面。 + +## 验证标准 + +- 使用项目`.nvmrc`切换Node版本后,生产构建通过。 +- 管理员可从真实系统菜单进入接口日志页,列表、筛选、分页和详情正常。 +- 成功、失败、空响应和超长响应均能稳定展示,JSON缩进、超大整数原值、中文、原始换行和长字段自动折行正确。 +- 外部响应中的HTML只作为文本显示,不产生脚本或页面节点。 +- 未授权用户看不到详情入口且直接请求详情接口被拒绝。 diff --git a/docs/reports/implementation/2026-07-20-external-api-log-implementation.md b/docs/reports/implementation/2026-07-20-external-api-log-implementation.md new file mode 100644 index 00000000..3ea29ced --- /dev/null +++ b/docs/reports/implementation/2026-07-20-external-api-log-implementation.md @@ -0,0 +1,43 @@ +# 业务外部接口日志实施记录 + +## 修改内容 + +- 新增`sys_api_log`表及“日志管理 > 接口日志”菜单增量SQL。 +- 新增接口日志持久化、列表查询、详情查询及对应权限控制。 +- 将流水分析和征信解析调用统一接入完整请求响应日志。 +- 新增显式调用用户快照,并贯穿项目创建、征信解析、批量上传和拉取本行信息异步链路。 +- 流水分析和征信解析测试控制器取消匿名访问,改为校验`ccdi:project:edit`并记录当前登录用户。 +- 新增接口日志前端列表、筛选和完整详情页面。 +- 接口日志详情支持JSON无损结构美化、2空格缩进展示和长文本自动换行;超大整数不经过JavaScript数值再序列化,非JSON内容保持原文。 + +## 影响范围 + +- `ruoyi-system`:接口日志实体、DTO/VO、Mapper和事务Service。 +- `ruoyi-admin`:接口日志监控接口。 +- `ccdi-lsfx`:调用用户上下文、HTTP原始响应处理与日志写入。 +- `ccdi-project`、`ccdi-info-collection`:调用用户显式透传。 +- `ruoyi-ui`:接口日志菜单页面和API封装。 +- `sql/migration`:表结构与菜单权限增量脚本。 + +## 验证记录 + +- JDK 21主工程编译及`ruoyi-admin`全模块跳过测试打包:已通过。 +- `ccdi-lsfx`定向测试:20个测试全部通过,覆盖7类HTTP调用、成功原始正文、非2xx、网络异常、空响应、反序列化失败、请求快照失败、multipart文件元数据、日志入库失败、调用用户必填及`NULL/system`系统调用人。 +- `ccdi-project`定向测试:57个既有回归测试全部通过;新增异步调用人隔离用例后,`CcdiFileUploadServiceImplTest`共33个测试全部通过,验证清理`SecurityContext`后用户A、用户B连续任务不串号。 +- `ccdi-info-collection`征信调用人透传测试:5个测试全部通过。 +- 前端生产构建:通过`.nvmrc`切换Node 14.21.3后执行`npm run build:prod`成功,仅保留项目既有资源体积告警。 +- 数据库迁移:使用`bin/mysql_utf8_exec.sh`连续执行两次均成功;`sys_api_log`表排序规则为`utf8mb4_general_ci`,`caller_username`无默认值,`api_url`为`TEXT`,请求参数和响应正文为`LONGTEXT`;菜单与查询按钮均保持单条。 +- 真实接口日志:本地Mock下生成6条真实调用日志,覆盖Token、multipart上传、征信URL-encoded发起及结果轮询;日志调用人为`admin`、用户ID为1,multipart仅保存文件名、10423字节大小和媒体类型,详情保留完整原始响应。 +- 权限验证:未登录请求`/monitor/apilog/list`返回业务码401;管理员列表与详情接口返回正常。 +- Browser use真实页面验收:应用内浏览器从“系统管理 > 日志管理 > 接口日志”进入成功;列表显示调用账号,按`xfeature`筛选从6条收敛为2条,详情完整展示用户、URL、请求响应头、请求参数、原始正文、HTTP状态及耗时,长文本滚动区域无溢出。 +- 详情格式化验收:可解析JSON按层级换行缩进展示,`9223372036854775807`等超大整数保持原值,长字段在内容区域内自动折行;异常堆栈等非JSON文本保留原始换行。 +- 测试清理:Browser use验收完成后删除本轮生成的6条接口日志测试数据,并关闭后端、前端和Mock测试进程。 + +## 说明 + +- 认证字段按已确认需求原文保存,接口日志访问权限必须严格控制。 +- `call_status`表示HTTP传输与响应反序列化状态,页面统一标注为“HTTP调用状态”,不表示外部系统业务码结果。 +- multipart日志仅保存文件元数据,不保存文件字节。 +- 失败日志的异常字段保存完整异常堆栈,不只保存异常消息。 +- 日志入库异常不会改变外部接口调用结果。 +- 临时测试数据、临时测试文件和浏览器验收产物不纳入Git提交范围;既有回归测试因公共方法签名变化产生的适配随业务代码一并交付。 diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/SysApiLogController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/SysApiLogController.java new file mode 100644 index 00000000..d4ed5635 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/SysApiLogController.java @@ -0,0 +1,46 @@ +package com.ruoyi.web.controller.monitor; + +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.system.domain.dto.SysApiLogQueryDTO; +import com.ruoyi.system.domain.vo.SysApiLogListVO; +import com.ruoyi.system.service.ISysApiLogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 业务外部接口日志。 + */ +@Tag(name = "接口日志") +@RestController +@RequestMapping("/monitor/apilog") +public class SysApiLogController extends BaseController { + + @Resource + private ISysApiLogService apiLogService; + + @Operation(summary = "查询接口日志列表") + @PreAuthorize("@ss.hasPermi('monitor:apilog:list')") + @GetMapping("/list") + public TableDataInfo list(SysApiLogQueryDTO queryDTO) { + startPage(); + List list = apiLogService.selectApiLogList(queryDTO); + return getDataTable(list); + } + + @Operation(summary = "查询接口日志详情") + @PreAuthorize("@ss.hasPermi('monitor:apilog:query')") + @GetMapping("/{logId}") + public AjaxResult detail(@PathVariable Long logId) { + return success(apiLogService.selectApiLogById(logId)); + } +} diff --git a/ruoyi-system/pom.xml b/ruoyi-system/pom.xml index cf18b456..e60dc59a 100644 --- a/ruoyi-system/pom.xml +++ b/ruoyi-system/pom.xml @@ -23,6 +23,12 @@ ruoyi-common + + org.projectlombok + lombok + true + + - \ No newline at end of file + diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/SysApiLog.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/SysApiLog.java new file mode 100644 index 00000000..0b88d91b --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/SysApiLog.java @@ -0,0 +1,28 @@ +package com.ruoyi.system.domain; + +import lombok.Data; + +import java.util.Date; + +/** + * 业务外部接口调用日志。 + */ +@Data +public class SysApiLog { + + private Long logId; + private Long callerUserId; + private String callerUsername; + private String apiUrl; + private String httpMethod; + private String contentType; + private String requestHeaders; + private String requestParams; + private Integer responseStatus; + private String responseHeaders; + private String responseBody; + private String callStatus; + private String errorMsg; + private Long costTime; + private Date callTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/SysApiLogQueryDTO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/SysApiLogQueryDTO.java new file mode 100644 index 00000000..9596250d --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/SysApiLogQueryDTO.java @@ -0,0 +1,23 @@ +package com.ruoyi.system.domain.dto; + +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; + +/** + * 接口日志查询条件。 + */ +@Data +public class SysApiLogQueryDTO { + + private String apiUrl; + private String httpMethod; + private String callStatus; + + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date beginTime; + + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date endTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/SysApiLogDetailVO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/SysApiLogDetailVO.java new file mode 100644 index 00000000..11adb6f2 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/SysApiLogDetailVO.java @@ -0,0 +1,31 @@ +package com.ruoyi.system.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** + * 接口日志完整详情。 + */ +@Data +public class SysApiLogDetailVO { + + private Long logId; + private Long callerUserId; + private String callerUsername; + private String apiUrl; + private String httpMethod; + private String contentType; + private String requestHeaders; + private String requestParams; + private Integer responseStatus; + private String responseHeaders; + private String responseBody; + private String callStatus; + private String errorMsg; + private Long costTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date callTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/SysApiLogListVO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/SysApiLogListVO.java new file mode 100644 index 00000000..dce952d3 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/SysApiLogListVO.java @@ -0,0 +1,25 @@ +package com.ruoyi.system.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** + * 接口日志列表项,不包含请求响应大字段。 + */ +@Data +public class SysApiLogListVO { + + private Long logId; + private Long callerUserId; + private String callerUsername; + private String apiUrl; + private String httpMethod; + private Integer responseStatus; + private String callStatus; + private Long costTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date callTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysApiLogMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysApiLogMapper.java new file mode 100644 index 00000000..98fb8409 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysApiLogMapper.java @@ -0,0 +1,20 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.system.domain.SysApiLog; +import com.ruoyi.system.domain.dto.SysApiLogQueryDTO; +import com.ruoyi.system.domain.vo.SysApiLogDetailVO; +import com.ruoyi.system.domain.vo.SysApiLogListVO; + +import java.util.List; + +/** + * 接口日志数据层。 + */ +public interface SysApiLogMapper { + + int insertApiLog(SysApiLog apiLog); + + List selectApiLogList(SysApiLogQueryDTO queryDTO); + + SysApiLogDetailVO selectApiLogById(Long logId); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysApiLogService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysApiLogService.java new file mode 100644 index 00000000..73df0f72 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysApiLogService.java @@ -0,0 +1,20 @@ +package com.ruoyi.system.service; + +import com.ruoyi.system.domain.SysApiLog; +import com.ruoyi.system.domain.dto.SysApiLogQueryDTO; +import com.ruoyi.system.domain.vo.SysApiLogDetailVO; +import com.ruoyi.system.domain.vo.SysApiLogListVO; + +import java.util.List; + +/** + * 接口日志服务。 + */ +public interface ISysApiLogService { + + void recordApiLog(SysApiLog apiLog); + + List selectApiLogList(SysApiLogQueryDTO queryDTO); + + SysApiLogDetailVO selectApiLogById(Long logId); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysApiLogServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysApiLogServiceImpl.java new file mode 100644 index 00000000..fa7c03a3 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysApiLogServiceImpl.java @@ -0,0 +1,40 @@ +package com.ruoyi.system.service.impl; + +import com.ruoyi.system.domain.SysApiLog; +import com.ruoyi.system.domain.dto.SysApiLogQueryDTO; +import com.ruoyi.system.domain.vo.SysApiLogDetailVO; +import com.ruoyi.system.domain.vo.SysApiLogListVO; +import com.ruoyi.system.mapper.SysApiLogMapper; +import com.ruoyi.system.service.ISysApiLogService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 接口日志服务实现。 + */ +@Service +public class SysApiLogServiceImpl implements ISysApiLogService { + + @Resource + private SysApiLogMapper apiLogMapper; + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordApiLog(SysApiLog apiLog) { + apiLogMapper.insertApiLog(apiLog); + } + + @Override + public List selectApiLogList(SysApiLogQueryDTO queryDTO) { + return apiLogMapper.selectApiLogList(queryDTO); + } + + @Override + public SysApiLogDetailVO selectApiLogById(Long logId) { + return apiLogMapper.selectApiLogById(logId); + } +} diff --git a/ruoyi-system/src/main/resources/mapper/system/SysApiLogMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysApiLogMapper.xml new file mode 100644 index 00000000..5e383af1 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/system/SysApiLogMapper.xml @@ -0,0 +1,72 @@ + + + + + + insert into sys_api_log ( + caller_user_id, caller_username, api_url, http_method, content_type, + request_headers, request_params, response_status, response_headers, + response_body, call_status, error_msg, cost_time, call_time + ) values ( + #{callerUserId}, #{callerUsername}, #{apiUrl}, #{httpMethod}, #{contentType}, + #{requestHeaders}, #{requestParams}, #{responseStatus}, #{responseHeaders}, + #{responseBody}, #{callStatus}, #{errorMsg}, #{costTime}, #{callTime} + ) + + + + + + + diff --git a/ruoyi-ui/src/api/monitor/apilog.js b/ruoyi-ui/src/api/monitor/apilog.js new file mode 100644 index 00000000..362098dd --- /dev/null +++ b/ruoyi-ui/src/api/monitor/apilog.js @@ -0,0 +1,18 @@ +import request from '@/utils/request' + +// 查询业务外部接口日志列表 +export function listApiLog(query) { + return request({ + url: '/monitor/apilog/list', + method: 'get', + params: query + }) +} + +// 查询业务外部接口日志详情 +export function getApiLog(logId) { + return request({ + url: '/monitor/apilog/' + logId, + method: 'get' + }) +} diff --git a/ruoyi-ui/src/views/monitor/apilog/index.vue b/ruoyi-ui/src/views/monitor/apilog/index.vue new file mode 100644 index 00000000..199765e1 --- /dev/null +++ b/ruoyi-ui/src/views/monitor/apilog/index.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/sql/migration/2026-07-20-add-external-api-log.sql b/sql/migration/2026-07-20-add-external-api-log.sql new file mode 100644 index 00000000..1c9b74f8 --- /dev/null +++ b/sql/migration/2026-07-20-add-external-api-log.sql @@ -0,0 +1,75 @@ +-- 业务外部接口调用日志及菜单 +-- 可重复执行,不自动授予普通角色权限 + +CREATE TABLE IF NOT EXISTS sys_api_log ( + log_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '日志主键', + caller_user_id BIGINT NULL COMMENT '调用发起用户ID', + caller_username VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '调用发起账号', + api_url TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '接口完整地址', + http_method VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'HTTP方法', + content_type VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '请求内容类型', + request_headers LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '完整请求头', + request_params LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '完整请求参数', + response_status INT NULL COMMENT 'HTTP响应状态码', + response_headers LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '完整响应头', + response_body LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '完整响应正文', + call_status CHAR(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'HTTP调用状态(0成功 1失败)', + error_msg LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '异常信息', + cost_time BIGINT NOT NULL DEFAULT 0 COMMENT 'HTTP调用耗时(毫秒)', + call_time DATETIME NOT NULL COMMENT '调用开始时间', + PRIMARY KEY (log_id), + KEY idx_sys_api_log_time (call_time, log_id), + KEY idx_sys_api_log_status_time (call_status, call_time, log_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='业务外部接口调用日志'; + +SET @log_parent_id = ( + SELECT menu_id + FROM sys_menu + WHERE menu_name = '日志管理' + AND menu_type = 'M' + LIMIT 1 +); + +INSERT INTO sys_menu ( + menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, + create_by, create_time, update_by, update_time, remark +) +SELECT + '接口日志', @log_parent_id, 3, 'apilog', 'monitor/apilog/index', '', '', + 1, 0, 'C', '0', '0', 'monitor:apilog:list', 'log', + 'admin', NOW(), '', NULL, '业务外部接口日志菜单' +FROM dual +WHERE @log_parent_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM sys_menu + WHERE parent_id = @log_parent_id + AND path = 'apilog' + ); + +SET @api_log_menu_id = ( + SELECT menu_id + FROM sys_menu + WHERE parent_id = @log_parent_id + AND path = 'apilog' + LIMIT 1 +); + +INSERT INTO sys_menu ( + menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, + create_by, create_time, update_by, update_time, remark +) +SELECT + '接口日志查询', @api_log_menu_id, 1, '#', '', '', '', + 1, 0, 'F', '0', '0', 'monitor:apilog:query', '#', + 'admin', NOW(), '', NULL, '' +FROM dual +WHERE @api_log_menu_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM sys_menu + WHERE parent_id = @api_log_menu_id + AND perms = 'monitor:apilog:query' + );