|
|
@@ -1,8 +1,11 @@
|
|
|
package com.payment.platform.module.payment.leshouying.service;
|
|
|
|
|
|
import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
|
|
|
+import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
|
|
+import java.time.OffsetDateTime;
|
|
|
+import java.time.ZoneOffset;
|
|
|
import java.util.LinkedHashMap;
|
|
|
import java.util.Map;
|
|
|
import java.util.Set;
|
|
|
@@ -26,7 +29,14 @@ class LsyQueryDetailAssemblerTest {
|
|
|
private final LsyQueryDetailAssembler assembler =
|
|
|
new LsyQueryDetailAssembler(new LeshouyingResponseParser());
|
|
|
|
|
|
- /** 一份「什么都有」的上游查单报文,含全部**不得外泄**的字段。 */
|
|
|
+ /**
|
|
|
+ * 一份「什么都有」的上游查单报文,含全部**不得外泄**的字段。
|
|
|
+ *
|
|
|
+ * <p>⚠ 2026-09-30:`code_url` / `code_url_expire_time` 两个键**从这里删掉了** ——
|
|
|
+ * 原先夹具塞了它们,而 `/v3/queryorder` **从不下发**这两个键(三次真实抓包 + 上游自己的
|
|
|
+ * 「交易查询」字段表都没有)。正是这个假夹具让「映射恒为 null」这一行永远绿。
|
|
|
+ * 现在它们的取值来源是**订单行**(见 {@link #orderRow()}),上游报文里没有它们才是真的。
|
|
|
+ */
|
|
|
private Map<String, Object> fullUpstreamOrder() {
|
|
|
Map<String, Object> m = new LinkedHashMap<>();
|
|
|
m.put("return_code", "SUCCESS");
|
|
|
@@ -44,14 +54,20 @@ class LsyQueryDetailAssemblerTest {
|
|
|
m.put("total_amount", "100");
|
|
|
m.put("receipt_amount", "100");
|
|
|
m.put("discount_amount", "0");
|
|
|
- m.put("code_url", "https://qr.example/x");
|
|
|
- m.put("code_url_expire_time", "20260922104500");
|
|
|
return m;
|
|
|
}
|
|
|
|
|
|
+ /** 订单行:`code_url` / `code_url_expire_time` 的唯一来源(下单时落库,见 LsyOrderService)。 */
|
|
|
+ private LsyOrderEntity orderRow() {
|
|
|
+ LsyOrderEntity o = new LsyOrderEntity();
|
|
|
+ o.setCodeUrl("https://qr.example/x");
|
|
|
+ o.setCodeUrlExpireTime(OffsetDateTime.of(2026, 9, 22, 10, 45, 0, 0, ZoneOffset.ofHours(8)));
|
|
|
+ return o;
|
|
|
+ }
|
|
|
+
|
|
|
@Test
|
|
|
void orderDetail_exposesExactlyTheWhitelist() {
|
|
|
- Map<String, Object> detail = assembler.forOrder(fullUpstreamOrder());
|
|
|
+ Map<String, Object> detail = assembler.forOrder(fullUpstreamOrder(), orderRow());
|
|
|
assertEquals(Set.of("pay_result", "total_amount", "receipt_amount",
|
|
|
"discount_amount", "pay_time", "code_url", "code_url_expire_time"),
|
|
|
detail.keySet(),
|
|
|
@@ -67,7 +83,7 @@ class LsyQueryDetailAssemblerTest {
|
|
|
*/
|
|
|
@Test
|
|
|
void orderDetail_amountsAreNumbersNotUpstreamStrings() {
|
|
|
- Map<String, Object> detail = assembler.forOrder(fullUpstreamOrder());
|
|
|
+ Map<String, Object> detail = assembler.forOrder(fullUpstreamOrder(), orderRow());
|
|
|
assertEquals(100L, detail.get("total_amount"));
|
|
|
assertEquals(100L, detail.get("receipt_amount"));
|
|
|
assertEquals(0L, detail.get("discount_amount"));
|
|
|
@@ -75,10 +91,76 @@ class LsyQueryDetailAssemblerTest {
|
|
|
|
|
|
Map<String, Object> broken = new LinkedHashMap<>();
|
|
|
broken.put("total_amount", "一百块");
|
|
|
- assertNull(assembler.forOrder(broken).get("total_amount"),
|
|
|
+ assertNull(assembler.forOrder(broken, null).get("total_amount"),
|
|
|
"转不出来的金额必须是 null,不得编造");
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * ★ 2026-09-30 用户裁定:`detail.code_url` 改从**订单行**取。
|
|
|
+ *
|
|
|
+ * <p>【为什么原先恒为 null】旧实现从**上游查单应答**里读 `code_url`,而 `/v3/queryorder`
|
|
|
+ * 从不下发这个键(真实键集实证:logs/_lsy-query-probe.txt 的 FAIL 全字段回显、
|
|
|
+ * _lsy-query-by-tradeno-probe.txt、_lsy-query-by-traceno-probe.txt 两次 SUCCESS,
|
|
|
+ * 三份键集一致;上游自己的「交易查询」字段表里也没有)。而与上游无关的那份拷贝
|
|
|
+ * **下单时就落库了**(`LsyOrderService` 的 urlPatch / stageCodeUrl)—— 数据一直在,
|
|
|
+ * 只是取错了地方。
|
|
|
+ *
|
|
|
+ * <p>【两向】正:报文里没有、行里有 ⇒ 取到行里的值。
|
|
|
+ * 反:报文里**有**、行里没有 ⇒ 仍必须是 null —— 这一半才证明来源真的换了地方,
|
|
|
+ * 否则「从上游读」这个旧行为在报文恰好带该键时也能让上半条绿。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void orderDetail_codeUrl取自订单行_不是上游报文() {
|
|
|
+ Map<String, Object> fromRow = assembler.forOrder(fullUpstreamOrder(), orderRow());
|
|
|
+ assertEquals("https://qr.example/x", fromRow.get("code_url"));
|
|
|
+
|
|
|
+ Map<String, Object> upstreamPretends = new LinkedHashMap<>(fullUpstreamOrder());
|
|
|
+ upstreamPretends.put("code_url", "https://upstream-从不发这个键.example/x");
|
|
|
+ Map<String, Object> fromUpstream = assembler.forOrder(upstreamPretends, new LsyOrderEntity());
|
|
|
+ assertNull(fromUpstream.get("code_url"),
|
|
|
+ "上游报文里的 code_url 不得被采信 —— 这条腿不下发它,采信等于把这个键留在原处");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 过期时间:**同一对象内的格式一致性**优先 —— 与紧邻的 `pay_time` 一样是
|
|
|
+ * `yyyyMMddHHmmss`,且**按北京时间渲染**。
|
|
|
+ *
|
|
|
+ * <p>时区那一半是承重的:行里存的是 `OffsetDateTime`(列类型 timestamptz),
|
|
|
+ * 本平台固定用北京时间对外(见 JacksonConfig:序列化时区 Asia/Shanghai)。
|
|
|
+ * 若直接 `format` 那个 OffsetDateTime,一台 UTC 的服务器就会吐出早 8 小时的墙钟时间
|
|
|
+ * —— 而租户拿它跟「现在」比,正好会把还有 8 小时的码判成已过期。
|
|
|
+ * 向量是算得出来的:2026-09-30T18:15:00Z == 2026-10-01 02:15:00 +08:00。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void orderDetail_codeUrl过期时间按北京时间格式化为上游同款字符串() {
|
|
|
+ LsyOrderEntity utcRow = new LsyOrderEntity();
|
|
|
+ utcRow.setCodeUrlExpireTime(OffsetDateTime.of(2026, 9, 30, 18, 15, 0, 0, ZoneOffset.UTC));
|
|
|
+
|
|
|
+ assertEquals("20261001021500", assembler.forOrder(Map.of(), utcRow).get("code_url_expire_time"),
|
|
|
+ "跨时区渲染:UTC 18:15 就是北京次日 02:15 —— 直接 format 会得到 20260930181500");
|
|
|
+
|
|
|
+ LsyOrderEntity cnRow = new LsyOrderEntity();
|
|
|
+ cnRow.setCodeUrlExpireTime(OffsetDateTime.of(2026, 9, 22, 10, 45, 0, 0, ZoneOffset.ofHours(8)));
|
|
|
+ assertEquals("20260922104500", assembler.forOrder(Map.of(), cnRow).get("code_url_expire_time"),
|
|
|
+ "本就在东八区的值不得被搬移");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 订单行没有这个值时给 null,**不给空串**。
|
|
|
+ *
|
|
|
+ * <p>空白 `code_url` 是上游真实出现过的状态({@code LsyReconcileScheduler} 的
|
|
|
+ * EXPIRED 兜底分支专门处理它)。租户拿空串去渲染二维码只会得到一张空图,
|
|
|
+ * 而 null 的含义是明确的「没有内容」—— 同本类「转不出来就给 null,不编」的一贯口径。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void orderDetail_空白的codeUrl归一为null() {
|
|
|
+ LsyOrderEntity blank = new LsyOrderEntity();
|
|
|
+ blank.setCodeUrl(" ");
|
|
|
+ assertNull(assembler.forOrder(fullUpstreamOrder(), blank).get("code_url"));
|
|
|
+ assertNull(assembler.forOrder(fullUpstreamOrder(), null).get("code_url"),
|
|
|
+ "订单行缺席时不得抛异常");
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* ★ 上游身份类字段一个都不能出现。
|
|
|
*
|
|
|
@@ -88,7 +170,7 @@ class LsyQueryDetailAssemblerTest {
|
|
|
*/
|
|
|
@Test
|
|
|
void orderDetail_neverLeaksUpstreamIdentity() {
|
|
|
- Map<String, Object> detail = assembler.forOrder(fullUpstreamOrder());
|
|
|
+ Map<String, Object> detail = assembler.forOrder(fullUpstreamOrder(), orderRow());
|
|
|
for (String forbidden : new String[]{"mch_no", "shop_no", "inst_no", "pay_trace_no",
|
|
|
"qd_trade_no", "td_trade_no", "user_id", "return_code", "result_code",
|
|
|
"return_msg", "upstream"}) {
|
|
|
@@ -125,11 +207,11 @@ class LsyQueryDetailAssemblerTest {
|
|
|
|
|
|
@Test
|
|
|
void orderDetail_blankOrNullUpstreamYieldsUnknownWithoutThrowing() {
|
|
|
- Map<String, Object> detail = assembler.forOrder(Map.of());
|
|
|
+ Map<String, Object> detail = assembler.forOrder(Map.of(), null);
|
|
|
assertEquals("UNKNOWN", detail.get("pay_result"));
|
|
|
assertNull(detail.get("total_amount"));
|
|
|
|
|
|
- Map<String, Object> nullDetail = assembler.forOrder(null);
|
|
|
+ Map<String, Object> nullDetail = assembler.forOrder(null, null);
|
|
|
assertEquals("UNKNOWN", nullDetail.get("pay_result"),
|
|
|
"上游查询失败时不得抛异常把整个查单接口带崩");
|
|
|
}
|
|
|
@@ -183,6 +265,6 @@ class LsyQueryDetailAssemblerTest {
|
|
|
private Object resultOf(String resultCode) {
|
|
|
Map<String, Object> m = new LinkedHashMap<>();
|
|
|
m.put("result_code", resultCode);
|
|
|
- return assembler.forOrder(m).get("pay_result");
|
|
|
+ return assembler.forOrder(m, null).get("pay_result");
|
|
|
}
|
|
|
}
|