|
@@ -53,6 +53,10 @@ import static org.mockito.Mockito.*;
|
|
|
* 一个卡住的商户地址会占满回调池,而上游通知还等着我们尽快返回 success。
|
|
* 一个卡住的商户地址会占满回调池,而上游通知还等着我们尽快返回 success。
|
|
|
* ③ {@link HarCallbackService#retryFailed} 的扫描形状五要素(状态字面量、
|
|
* ③ {@link HarCallbackService#retryFailed} 的扫描形状五要素(状态字面量、
|
|
|
* 计数上界、排序方向、LIMIT、取值来源)—— 任一写错都是「重试静默失效而套件全绿」。
|
|
* 计数上界、排序方向、LIMIT、取值来源)—— 任一写错都是「重试静默失效而套件全绿」。
|
|
|
|
|
+ * ④ 回调地址的三级取址序:订单快照 → Key → 租户默认(2026-10-14 加逐单回调地址)——
|
|
|
|
|
+ * 快照被取址层忽略、或优先级写反时,套件其余全绿,只有
|
|
|
|
|
+ * {@link HarCallbackServiceTest#notifyEnterprise_snapshotUrlWinsOverKeyConfig} 与
|
|
|
|
|
+ * {@code retryFailed_scannedOrderWithSnapshotUrl_pushesToSnapshot} 会红。
|
|
|
*
|
|
*
|
|
|
* 【为什么断言 SET 段而不是实体】写库刻意**不走实体**(实体带 @Version:回调线程
|
|
* 【为什么断言 SET 段而不是实体】写库刻意**不走实体**(实体带 @Version:回调线程
|
|
|
* 可能在 syncFromUpstream 事务提交前读到旧行版本,updateById 会静默丢写)⇒ 只能走
|
|
* 可能在 syncFromUpstream 事务提交前读到旧行版本,updateById 会静默丢写)⇒ 只能走
|
|
@@ -108,6 +112,18 @@ class HarCallbackServiceTest {
|
|
|
when(apiKeyMapper.selectOne(any())).thenReturn(key);
|
|
when(apiKeyMapper.selectOne(any())).thenReturn(key);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 同上,但**宽松**(lenient)—— 专用作「订单快照存在时」的反例对照组。
|
|
|
|
|
+ * 正确实现读快照即短路、根本走不到 Key,严格的 Mockito 会把这条存根判成
|
|
|
|
|
+ * UnnecessaryStubbing 而误红;可一旦取址优先级写反(先 Key 后快照),
|
|
|
|
|
+ * 这条存根就会被读到、把推送引到 key 地址上,让快照断言变红。
|
|
|
|
|
+ */
|
|
|
|
|
+ private void stubKeyWithUrlAsCounterControl(String url) {
|
|
|
|
|
+ TenantApiKeyEntity key = new TenantApiKeyEntity();
|
|
|
|
|
+ key.setReturnUrl(url);
|
|
|
|
|
+ lenient().when(apiKeyMapper.selectOne(any())).thenReturn(key);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private void stubPushOk() {
|
|
private void stubPushOk() {
|
|
|
when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
|
|
when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
|
|
|
.thenReturn(new ResponseEntity<>("ok", HttpStatus.OK));
|
|
.thenReturn(new ResponseEntity<>("ok", HttpStatus.OK));
|
|
@@ -215,6 +231,50 @@ class HarCallbackServiceTest {
|
|
|
verify(restTemplate, times(1)).postForEntity(anyString(), any(HttpEntity.class), eq(String.class));
|
|
verify(restTemplate, times(1)).postForEntity(anyString(), any(HttpEntity.class), eq(String.class));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 逐单回调地址(2026-10-14):订单上有快照 ⇒ **只推它**,Key 上配的地址靠边站。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 【为什么是主断言】「传了回调地址 ⇒ 整个生命周期走这个地址」的承重点:字段、落库、
|
|
|
|
|
+ * 提示词都齐了但取址层没读快照(或先读 Key)时,套件其余全绿 —— 只有这条会红。
|
|
|
|
|
+ * Key 上的地址是反例对照组(宽松存根,理由见 helper)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void notifyEnterprise_snapshotUrlWinsOverKeyConfig() {
|
|
|
|
|
+ stubKeyWithUrlAsCounterControl("https://key.example.com/cb");
|
|
|
|
|
+ stubPushOk();
|
|
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ HarOrderEntity o = order("K1");
|
|
|
|
|
+ o.setNotifyUrl("https://snapshot.example.com/cb");
|
|
|
|
|
+ service.notifyEnterprise(o);
|
|
|
|
|
+
|
|
|
|
|
+ verify(restTemplate, timeout(3000)).postForEntity(
|
|
|
|
|
+ eq("https://snapshot.example.com/cb"), any(HttpEntity.class), eq(String.class));
|
|
|
|
|
+ verify(restTemplate, never()).postForEntity(
|
|
|
|
|
+ eq("https://key.example.com/cb"), any(HttpEntity.class), eq(String.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 快照是空白(脏值)⇒ 落回 Key 配置 —— 取址层只认**非空白**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 空白当地址用会把推送推到一个空白 URL 上(HTTP 客户端解析必炸),等于把本来
|
|
|
|
|
+ * 有地址可推的单弄成永远 FAILED。本用例对「isNotEmpty」式写法是红的(空白被
|
|
|
|
|
+ * 当成快照 ⇒ 推到 " " 上);正常路径上它是绿的 —— 咬合由变异验证出示。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void notifyEnterprise_blankSnapshotFallsBackToKeyConfig() {
|
|
|
|
|
+ stubKeyWithUrl("https://key.example.com/cb");
|
|
|
|
|
+ stubPushOk();
|
|
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ HarOrderEntity o = order("K1");
|
|
|
|
|
+ o.setNotifyUrl(" ");
|
|
|
|
|
+ service.notifyEnterprise(o);
|
|
|
|
|
+
|
|
|
|
|
+ verify(restTemplate, timeout(3000)).postForEntity(
|
|
|
|
|
+ eq("https://key.example.com/cb"), any(HttpEntity.class), eq(String.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* {@code retryFailed()} 的扫描形状 —— 与乐收赢 LsyCallbackService#retryFailed 对齐。
|
|
* {@code retryFailed()} 的扫描形状 —— 与乐收赢 LsyCallbackService#retryFailed 对齐。
|
|
|
*
|
|
*
|
|
@@ -268,6 +328,24 @@ class HarCallbackServiceTest {
|
|
|
verify(orderMapper, timeout(3000).times(2)).update(isNull(), any());
|
|
verify(orderMapper, timeout(3000).times(2)).update(isNull(), any());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 重试腿同样认快照:这一单当时传了逐单地址,重推必须推回那个地址(Key 是反例对照组) */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void retryFailed_scannedOrderWithSnapshotUrl_pushesToSnapshot() {
|
|
|
|
|
+ HarOrderEntity o = order("K1");
|
|
|
|
|
+ o.setNotifyUrl("https://snapshot.example.com/cb");
|
|
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of(o));
|
|
|
|
|
+ stubKeyWithUrlAsCounterControl("https://key.example.com/cb");
|
|
|
|
|
+ stubPushOk();
|
|
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ service.retryFailed();
|
|
|
|
|
+
|
|
|
|
|
+ verify(restTemplate, timeout(3000)).postForEntity(
|
|
|
|
|
+ eq("https://snapshot.example.com/cb"), any(HttpEntity.class), eq(String.class));
|
|
|
|
|
+ verify(restTemplate, never()).postForEntity(
|
|
|
|
|
+ eq("https://key.example.com/cb"), any(HttpEntity.class), eq(String.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 重试时**仍然**没有地址 ⇒ 再记一次 FAILED(计数 +1)。
|
|
* 重试时**仍然**没有地址 ⇒ 再记一次 FAILED(计数 +1)。
|
|
|
*
|
|
*
|
|
@@ -337,6 +415,26 @@ class HarCallbackServiceTest {
|
|
|
"重试扫描的部分索引不见了 —— 该查询唯一的热路径(谓词 + 排序键一个索引全包)");
|
|
"重试扫描的部分索引不见了 —— 该查询唯一的热路径(谓词 + 排序键一个索引全包)");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 逐单回调地址那一列只存在于**交付给用户的建表脚本**里(同上一条的理由:本工程
|
|
|
|
|
+ * 未接 Flyway,脚本由用户手工执行)。部署顺序是**先跑脚本再换新 jar** —— 反过来的话
|
|
|
|
|
+ * 新代码 insert 带上未知列,每笔下单(热路径)直接抛「column does not exist」,全停级。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>列长必须与 DTO 的 {@code @Size(max=512)} 同值:列短于校验上限时,超长值
|
|
|
|
|
+ * 过了 DTO 校验、在 insert 处被 DB 拒收 —— 报错点离校验点很远,排查要来回看两处。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void schemaScript_addsNotifyUrlColumn() throws Exception {
|
|
|
|
|
+ Path sql = Path.of("src/main/resources/db/har-order-notify-url.sql");
|
|
|
|
|
+ assertTrue(Files.exists(sql), "找不到建表脚本(本用例读的是相对 java/ 的仓库路径): " + sql.toAbsolutePath());
|
|
|
|
|
+ String text = new String(Files.readAllBytes(sql), StandardCharsets.UTF_8);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(text.matches("(?s).*ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS notify_url varchar\\(512\\).*"),
|
|
|
|
|
+ "notify_url 列不见了/长度不是 512 —— 与 HarOrderCreateDTO.notifyUrl 的 @Size(max=512) 必须同步");
|
|
|
|
|
+ assertTrue(text.contains("COMMENT ON COLUMN pay_har_order.notify_url"),
|
|
|
|
|
+ "列注释不见了 —— 「下单时快照、空白存 NULL」这层语义在这一格上没有别处可读");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 取 SET 子句里某一列**绑定**的值 —— 只看 SET,不看 WHERE。
|
|
* 取 SET 子句里某一列**绑定**的值 —— 只看 SET,不看 WHERE。
|
|
|
*
|
|
*
|