From c22eff8950476373ca68b59713d549e516cfd0e3 Mon Sep 17 00:00:00 2001 From: dark Date: Wed, 29 Apr 2026 11:25:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(sync):=20=E5=B0=86=20pullConfig=20?= =?UTF-8?q?=E7=9A=84=20ACK=20=E7=A1=AE=E8=AE=A4=E6=8B=86=E5=88=86=E4=B8=BA?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 正常 pullConfig 请求不再夹带 ackSuc/ackFail,只保留业务过滤参数 - 在本轮生产数据处理完成后,新增一次 ACK-only 的 pullConfig 确认调用 - ACK 确认请求仅回传 ackSuc/ackFail,不再携带 airportId/appName/configVersion/fileName - 保持本地 ackFail 落库与定向重拉机制不变,确认成功后再标记 reported - 更新相关 HTTP/集成测试,并同步 current.md 与 prod-api-v1.md 文档 --- current.md | 62 ++++++++++ docs/prod-api-v1.md | 34 ++++-- .../orchestrator/ProdSyncCoordinator.java | 26 +++-- .../sync/service/ProdConfigApiService.java | 109 ++++++++++++------ .../ProdSyncCoordinatorIntegrationTest.java | 16 ++- .../service/ProdConfigApiServiceHttpTest.java | 68 ++++++++++- 6 files changed, 251 insertions(+), 64 deletions(-) diff --git a/current.md b/current.md index 7081941..1f445c3 100644 --- a/current.md +++ b/current.md @@ -11,6 +11,7 @@ pushConfig:POST + JSON数组 pullConfig:GET + JSON响应 login:已支持 token 获取与缓存 ackSuc/ackFail:已接入回传与本地落库 +ackSuc/ackFail:已改为 pull 数据处理完成后,再补发一次 ACK-only pullConfig 确认请求(只带 ackSuc/ackFail) ConfigCryptoService:已抽出,当前默认透传实现,后续只需替换该服务内算法 Git -> PROD:已支持最小增量推送,删除场景自动回退全量 Git -> PROD:已改为先按 git.repo.scan-branch-pattern 批量扫描版本分支,再逐个按“版本分支 + 机场目录 + 模块目录”解析参数 @@ -45,6 +46,7 @@ PROD -> Git: - 当前提交目标分支为:git.repo.snapshot-branch/ - 例如:config-prod-snapshot/R_XXX_V3.0.3_XXX - 若 pullConfig 返回缺少统一版本号,则按当前 result 的 sourceVersion 退化生成动态分支名 +- pull 数据处理完成后,需要再次调用一次 pullConfig 做 ACK 确认,请求只传 ackSuc/ackFail 关键文件 @@ -64,6 +66,66 @@ ftp-sync-tool-detail-design.md configContent 推送前加密 configContent 拉取后解密 + +本轮评估结论(2026-04-29) + +目标补充 + +最终目标希望 Git 仓库内容与生产接口库保持一致。 + +当前结论 + +- 当前正式模式不变: + - Git -> PROD:版本分支直推生产 + - PROD -> Git:回写到 git.repo.snapshot-branch/ +- 当前不建议直接把生产回流写回对应版本分支。 +- 原因不是“代码不能写”,而是“当前 pullConfig 语义还不够安全”: + - pullConfig 不带过滤时,返回的是“未同步到 Git 的已审核配置” + - 这不是某个 configVersion 的全量快照 + - 但当前 Git 回写语义是“整目录覆盖目标分支” + - 如果直接覆盖版本分支,可能误删未出现在本次 pull 结果里的文件 +- 当前 snapshot 分支仍然有必要保留,作为生产回流的隔离区和审计区。 + +保守方案(推荐) + +1. 保持当前双分支职责不变 + - 版本分支:作为 Git -> PROD 的发布源 + - snapshot 动态分支:作为 PROD -> Git 的生产回流镜像分支 + +2. 把 snapshot 分支定义为“生产待对账视图” + - 生产回流继续先写到 git.repo.snapshot-branch/ + - 不直接改动 R_XXX_* 等版本分支 + +3. 增加“版本分支 vs snapshot 分支”自动对账能力 + - 对比同一 configVersion 下两边目录差异 + - 差异至少要区分:新增、修改、删除 + - 输出可读结果,供人工确认是否需要把生产变更回收进版本分支 + +4. 增加“受控回收”动作,而不是自动覆盖 + - 建议后续增加管理入口或命令: + - promoteSnapshotToVersion(configVersion) + - 行为: + - 读取 snapshot 分支 + - 与对应版本分支做差异确认 + - 经人工确认后再回写版本分支 + - 这样可以避免生产临时变更直接污染发布分支 + +5. 明确回环控制 + - Git -> PROD 只扫描版本分支 + - snapshot 前缀分支不得进入 git.repo.scan-branch-pattern + - 避免生产回流提交再次被推回生产形成闭环 + +6. 后续如果要升级成“版本分支直接等于生产态”,前提是: + - 生产接口能按 configVersion 提供全量数据 + - 或者返回结果具备明确删除语义 + - 在此前,不建议取消 snapshot 隔离层 + +下一步建议 + +如果继续推进,最稳妥的下一步是: + +- 先实现“snapshot 分支 与 对应版本分支”的差异对账能力 +- 再决定是否补一个人工确认后的 promote 流程 验证状态 mvn -s build-support/maven-settings.xml test 已通过 diff --git a/docs/prod-api-v1.md b/docs/prod-api-v1.md index fe1c4c6..6a603fb 100644 --- a/docs/prod-api-v1.md +++ b/docs/prod-api-v1.md @@ -197,25 +197,36 @@ PEK/monitor/jobs/sync-job.json 说明: - 由于接口是 `GET`,当前实现按 query string 传参 -- 当前实现已经支持: +- 正常拉取请求当前已经支持: - `airportId` 可选过滤 - `appName` 可选过滤 - - `ackSuc` 自动回传 - - `ackFail` 自动回传 -- 当前已经支持: - `configVersion` 可选过滤 - `fileName` 可选过滤 +- ACK 确认请求当前已经支持: + - `ackSuc` 自动回传 + - `ackFail` 自动回传 ### 5.3 当前请求行为 -当前代码只有在配置了过滤条件时才会发送: +当前代码发起两类 `pullConfig` 请求: -- `prod.api.airport-id` -- `prod.api.app-name` -- `prod.api.pull-config-version` -- `prod.api.pull-file-name` +1. 正常拉取请求: -如果这两个配置为空,则不会在请求中携带它们,此时依赖生产端返回所有已审核且未同步的配置。 +- 只有在配置了过滤条件时才会发送: + - `prod.api.airport-id` + - `prod.api.app-name` + - `prod.api.pull-config-version` + - `prod.api.pull-file-name` + +如果这些配置为空,则不会在请求中携带它们,此时依赖生产端返回所有已审核且未同步的配置。 + +2. ACK 确认请求: + +- 在本轮 pull 数据处理完成后额外发起一次 +- 请求只传: + - `ackSuc` + - `ackFail` +- 不再夹带 `airportId/appName/configVersion/fileName` 过滤参数 ### 5.4 响应体 @@ -253,6 +264,7 @@ PEK/monitor/jobs/sync-job.json - 如果某组内所有项的 `configVersion` 相同,则该值作为该组的 `sourceVersion` - 如果某组缺少统一版本号,则该组退回为内容哈希作为 `sourceVersion` - `configContent` 当前已统一经过 `ConfigCryptoService`,默认实现仍为透传 +- pull 结果本地处理完成后,会再补发一次 ACK-only `pullConfig` 请求,只带 `ackSuc/ackFail` - 当本地处理失败时,会把失败项写入 ACK 重试表,并在下次按 `airportId/appName/configVersion/fileName` 定向重拉 ## 6. 接口三:获取 token @@ -302,7 +314,7 @@ PEK/monitor/jobs/sync-job.json - `pushConfig` 使用 `POST + JSON 数组` - `pullConfig` 使用 `GET + JSON 响应` - `login` 使用 `POST + JSON` -- `ackSuc/ackFail` 已接入请求回传和本地状态更新 +- `ackSuc/ackFail` 已接入 ACK-only 二次确认请求和本地状态更新 - `pushConfig` 参数已按 `airportId/appName/fileName` 目录结构解析 - `pullConfig` 响应已按 `airportId/appName/fileName` 结构恢复到本地目录 diff --git a/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java b/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java index 37ad0e3..5c7e288 100644 --- a/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java +++ b/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java @@ -129,15 +129,19 @@ public class ProdSyncCoordinator { gitRepoProperties.getSnapshotBranch() ); - retryFailedProdPulls(); - try { - List pullResults = prodConfigApiService.pullConfigSnapshots(); - for (ProdPullResult pullResult : pullResults) { - syncSingleProdSnapshotToGit(pullResult, false); + retryFailedProdPulls(); + + try { + List pullResults = prodConfigApiService.pullConfigSnapshots(); + for (ProdPullResult pullResult : pullResults) { + syncSingleProdSnapshotToGit(pullResult, false); + } + } catch (Exception e) { + log.error("PROD prod->git sync failed before new snapshot groups were processed", e); } - } catch (Exception e) { - log.error("PROD prod->git sync failed before new snapshot groups were processed", e); + } finally { + confirmPendingPullAck(); } } @@ -301,6 +305,14 @@ public class ProdSyncCoordinator { } } + private void confirmPendingPullAck() { + try { + prodConfigApiService.confirmPendingPullAck(); + } catch (Exception e) { + log.error("PROD prod->git ACK confirm failed", e); + } + } + /** * 计算本轮 Git -> PROD 实际要推送的目录: * 1. 首次同步走全量 diff --git a/src/main/java/com/ftptool/sync/service/ProdConfigApiService.java b/src/main/java/com/ftptool/sync/service/ProdConfigApiService.java index 2f797b4..4ec0b79 100644 --- a/src/main/java/com/ftptool/sync/service/ProdConfigApiService.java +++ b/src/main/java/com/ftptool/sync/service/ProdConfigApiService.java @@ -115,7 +115,6 @@ public class ProdConfigApiService { /** * 按指定过滤条件拉取生产配置。 - * 这里会把 ACK 待回传参数一并带上。 */ public List pullConfigSnapshots( String airportIdFilter, @@ -123,45 +122,15 @@ public class ProdConfigApiService { String configVersionFilter, String fileNameFilter ) throws IOException { - String url = buildUrl(prodApiProperties.getPullPath()); - HttpHeaders headers = defaultJsonHeaders(); - - UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url); - ProdPullAckService.PendingAckSummary pendingAckSummary = prodPullAckService.getPendingAckSummary(); - // 过滤条件为空时,依赖生产端返回“已审核且未同步”的全量数据。 - if (StringUtils.hasText(airportIdFilter) && !isPlaceholder(airportIdFilter)) { - builder.queryParam("airportId", airportIdFilter.trim()); - } - if (StringUtils.hasText(appNameFilter) && !isPlaceholder(appNameFilter)) { - builder.queryParam("appName", appNameFilter.trim()); - } - if (StringUtils.hasText(configVersionFilter) && !isPlaceholder(configVersionFilter)) { - builder.queryParam("configVersion", configVersionFilter.trim()); - } - if (StringUtils.hasText(fileNameFilter) && !isPlaceholder(fileNameFilter)) { - builder.queryParam("fileName", fileNameFilter.trim()); - } - if (pendingAckSummary.getAckSucIds() != null && !pendingAckSummary.getAckSucIds().isEmpty()) { - builder.queryParam("ackSuc", StringUtils.collectionToCommaDelimitedString(pendingAckSummary.getAckSucIds())); - } - if (pendingAckSummary.getAckFailIds() != null && !pendingAckSummary.getAckFailIds().isEmpty()) { - builder.queryParam("ackFail", StringUtils.collectionToCommaDelimitedString(pendingAckSummary.getAckFailIds())); - } - - ResponseEntity>> response = restTemplate.exchange( - builder.build(true).toUri(), - HttpMethod.GET, - new HttpEntity(headers), - new ParameterizedTypeReference>>() { - } + ProdApiResponse> body = executePullConfigRequest( + airportIdFilter, + appNameFilter, + configVersionFilter, + fileNameFilter, + null ); - - ProdApiResponse> body = response.getBody(); validateSuccess(body, "Production pullConfig call failed"); - if (pendingAckSummary.hasPendingAck()) { - prodPullAckService.markPendingAsReported(); - } List items = body.getData(); if (items == null || items.isEmpty()) { @@ -170,6 +139,32 @@ public class ProdConfigApiService { return buildPullResults(items); } + /** + * 生产快照处理完成后,使用 ACK-only pull 请求向生产侧确认本轮处理结果。 + * 当前约定只传 ackSuc / ackFail,不再夹带业务过滤参数。 + */ + public void confirmPendingPullAck() throws IOException { + ProdPullAckService.PendingAckSummary pendingAckSummary = prodPullAckService.getPendingAckSummary(); + if (!pendingAckSummary.hasPendingAck()) { + return; + } + + ProdApiResponse> body = executePullConfigRequest( + null, + null, + null, + null, + pendingAckSummary + ); + validateSuccess(body, "Production pullConfig ACK confirm failed"); + prodPullAckService.markPendingAsReported(); + log.info( + "Prod pullConfig ACK confirm finished. ackSucCount={}, ackFailCount={}", + pendingAckSummary.getAckSucIds() == null ? 0 : pendingAckSummary.getAckSucIds().size(), + pendingAckSummary.getAckFailIds() == null ? 0 : pendingAckSummary.getAckFailIds().size() + ); + } + private HttpHeaders defaultJsonHeaders() { HttpHeaders headers = new HttpHeaders(); headers.setAccept(java.util.Collections.singletonList(MediaType.APPLICATION_JSON)); @@ -231,6 +226,46 @@ public class ProdConfigApiService { return cachedToken; } + private ProdApiResponse> executePullConfigRequest( + String airportIdFilter, + String appNameFilter, + String configVersionFilter, + String fileNameFilter, + ProdPullAckService.PendingAckSummary pendingAckSummary + ) { + String url = buildUrl(prodApiProperties.getPullPath()); + HttpHeaders headers = defaultJsonHeaders(); + + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url); + if (StringUtils.hasText(airportIdFilter) && !isPlaceholder(airportIdFilter)) { + builder.queryParam("airportId", airportIdFilter.trim()); + } + if (StringUtils.hasText(appNameFilter) && !isPlaceholder(appNameFilter)) { + builder.queryParam("appName", appNameFilter.trim()); + } + if (StringUtils.hasText(configVersionFilter) && !isPlaceholder(configVersionFilter)) { + builder.queryParam("configVersion", configVersionFilter.trim()); + } + if (StringUtils.hasText(fileNameFilter) && !isPlaceholder(fileNameFilter)) { + builder.queryParam("fileName", fileNameFilter.trim()); + } + if (pendingAckSummary != null && pendingAckSummary.getAckSucIds() != null && !pendingAckSummary.getAckSucIds().isEmpty()) { + builder.queryParam("ackSuc", StringUtils.collectionToCommaDelimitedString(pendingAckSummary.getAckSucIds())); + } + if (pendingAckSummary != null && pendingAckSummary.getAckFailIds() != null && !pendingAckSummary.getAckFailIds().isEmpty()) { + builder.queryParam("ackFail", StringUtils.collectionToCommaDelimitedString(pendingAckSummary.getAckFailIds())); + } + + ResponseEntity>> response = restTemplate.exchange( + builder.build(true).toUri(), + HttpMethod.GET, + new HttpEntity(headers), + new ParameterizedTypeReference>>() { + } + ); + return response.getBody(); + } + /** * 把 Git 分支快照目录转换成 pushConfig 所需的 JSON 数组。 * 当前目录约定必须是:airportId/appName/fileName diff --git a/src/test/java/com/ftptool/sync/orchestrator/ProdSyncCoordinatorIntegrationTest.java b/src/test/java/com/ftptool/sync/orchestrator/ProdSyncCoordinatorIntegrationTest.java index 1a96da6..d2d8a00 100644 --- a/src/test/java/com/ftptool/sync/orchestrator/ProdSyncCoordinatorIntegrationTest.java +++ b/src/test/java/com/ftptool/sync/orchestrator/ProdSyncCoordinatorIntegrationTest.java @@ -15,6 +15,7 @@ import com.ftptool.sync.repository.SyncTaskRepository; import com.ftptool.sync.service.GitClientService; import com.ftptool.sync.service.PackageService; import com.ftptool.sync.service.ProdConfigApiService; +import com.ftptool.sync.service.ProdPullAckService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -70,6 +71,9 @@ class ProdSyncCoordinatorIntegrationTest { @Autowired private ProdSyncCoordinator prodSyncCoordinator; + @Autowired + private ProdPullAckService prodPullAckService; + @Autowired private SyncTaskRepository syncTaskRepository; @@ -93,6 +97,10 @@ class ProdSyncCoordinatorIntegrationTest { syncTaskRepository.deleteAll(); syncCheckpointRepository.deleteAll(); prodPullAckRecordRepository.deleteAll(); + doAnswer(invocation -> { + prodPullAckService.markPendingAsReported(); + return null; + }).when(prodConfigApiService).confirmPendingPullAck(); } @Test @@ -189,7 +197,7 @@ class ProdSyncCoordinatorIntegrationTest { assertEquals(2, ackRecords.size()); for (ProdPullAckRecord ackRecord : ackRecords) { assertEquals(ProdPullAckStatus.SUCCESS, ackRecord.getAckStatus()); - assertFalse(ackRecord.getReported()); + assertTrue(ackRecord.getReported()); } verify(gitClientService, times(1)).syncDirectoryToBranch( @@ -197,6 +205,7 @@ class ProdSyncCoordinatorIntegrationTest { eq("config-prod-snapshot/prod-v1"), contains("prod-v1") ); + verify(prodConfigApiService, times(2)).confirmPendingPullAck(); } @Test @@ -255,7 +264,7 @@ class ProdSyncCoordinatorIntegrationTest { assertEquals(1, ackRecords.size()); assertEquals("9", ackRecords.get(0).getRemoteConfigId()); assertEquals(ProdPullAckStatus.FAILED, ackRecords.get(0).getAckStatus()); - assertFalse(ackRecords.get(0).getReported()); + assertTrue(ackRecords.get(0).getReported()); } @Test @@ -306,7 +315,7 @@ class ProdSyncCoordinatorIntegrationTest { assertEquals(1, ackRecords.size()); ProdPullAckRecord ackRecord = ackRecords.get(0); assertEquals(ProdPullAckStatus.SUCCESS, ackRecord.getAckStatus()); - assertFalse(ackRecord.getReported()); + assertTrue(ackRecord.getReported()); assertEquals(Integer.valueOf(0), ackRecord.getRetryCount()); assertEquals("PEK", ackRecord.getAirportId()); assertEquals("monitor", ackRecord.getAppName()); @@ -314,6 +323,7 @@ class ProdSyncCoordinatorIntegrationTest { verify(prodConfigApiService, times(1)) .pullConfigSnapshots("PEK", "monitor", "prod-v3", "jobs/sync-job.json"); + verify(prodConfigApiService, times(2)).confirmPendingPullAck(); } @Test diff --git a/src/test/java/com/ftptool/sync/service/ProdConfigApiServiceHttpTest.java b/src/test/java/com/ftptool/sync/service/ProdConfigApiServiceHttpTest.java index e980506..6dda6d9 100644 --- a/src/test/java/com/ftptool/sync/service/ProdConfigApiServiceHttpTest.java +++ b/src/test/java/com/ftptool/sync/service/ProdConfigApiServiceHttpTest.java @@ -19,10 +19,12 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.client.ExpectedCount.once; @@ -139,7 +141,7 @@ class ProdConfigApiServiceHttpTest { } @Test - void shouldSendAckParamsWhenPullingConfig() throws Exception { + void shouldPullConfigWithoutAckParams() throws Exception { RestTemplate restTemplate = new RestTemplate(); MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); @@ -158,9 +160,6 @@ class ProdConfigApiServiceHttpTest { prodApiProperties.setPullFileName("a.txt"); ProdPullAckService prodPullAckService = mock(ProdPullAckService.class); - when(prodPullAckService.getPendingAckSummary()).thenReturn( - new ProdPullAckService.PendingAckSummary(Arrays.asList("1", "2"), Arrays.asList("9")) - ); ConfigCryptoService configCryptoService = mock(ConfigCryptoService.class); when(configCryptoService.decryptAfterPull("test-airport", "test-app", "v1", "a.txt", "content")) .thenReturn("decoded-content"); @@ -180,8 +179,8 @@ class ProdConfigApiServiceHttpTest { assertTrue(uri.contains("appName=test-app")); assertTrue(uri.contains("configVersion=v1")); assertTrue(uri.contains("fileName=a.txt")); - assertTrue(uri.contains("ackSuc=1,2") || uri.contains("ackSuc=1%2C2")); - assertTrue(uri.contains("ackFail=9")); + assertFalse(uri.contains("ackSuc=")); + assertFalse(uri.contains("ackFail=")); }) .andExpect(method(HttpMethod.GET)) .andExpect(header("token", "static-token")) @@ -205,6 +204,63 @@ class ProdConfigApiServiceHttpTest { assertEquals("decoded-content", new String(Files.readAllBytes(restoredFile), StandardCharsets.UTF_8)); verify(configCryptoService).decryptAfterPull("test-airport", "test-app", "v1", "a.txt", "content"); + verify(prodPullAckService, times(0)).getPendingAckSummary(); + verify(prodPullAckService, times(0)).markPendingAsReported(); + server.verify(); + } + + @Test + void shouldConfirmPendingAckByAckOnlyPullCall() throws Exception { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + + SyncProperties syncProperties = newSyncProperties("./target/http-test-work-pull-ack"); + WorkDirectoryService workDirectoryService = new WorkDirectoryService(syncProperties); + workDirectoryService.initialize(); + + ProdApiProperties prodApiProperties = new ProdApiProperties(); + prodApiProperties.setBaseUrl("https://prod.example.com"); + prodApiProperties.setPullPath("/pic_bus_manage_monitor/configSync/pullConfig"); + prodApiProperties.setToken("static-token"); + prodApiProperties.setTokenHeaderName("token"); + prodApiProperties.setAirportId("test-airport"); + prodApiProperties.setAppName("test-app"); + prodApiProperties.setPullConfigVersion("v1"); + prodApiProperties.setPullFileName("a.txt"); + + ProdPullAckService prodPullAckService = mock(ProdPullAckService.class); + when(prodPullAckService.getPendingAckSummary()).thenReturn( + new ProdPullAckService.PendingAckSummary(Arrays.asList("1", "2"), Arrays.asList("9")) + ); + + ProdConfigApiService service = new ProdConfigApiService( + prodApiProperties, + syncProperties, + restTemplate, + workDirectoryService, + prodPullAckService, + new ConfigCryptoService() + ); + + server.expect(once(), request -> { + String uri = request.getURI().toString(); + assertFalse(uri.contains("airportId=")); + assertFalse(uri.contains("appName=")); + assertFalse(uri.contains("configVersion=")); + assertFalse(uri.contains("fileName=")); + assertTrue(uri.contains("ackSuc=1,2") || uri.contains("ackSuc=1%2C2")); + assertTrue(uri.contains("ackFail=9")); + }) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("token", "static-token")) + .andRespond(withSuccess( + "{\"code\":\"0\",\"data\":[],\"msg\":\"ok\"}", + MediaType.APPLICATION_JSON + )); + + service.confirmPendingPullAck(); + + verify(prodPullAckService).getPendingAckSummary(); verify(prodPullAckService).markPendingAsReported(); server.verify(); }