|
@@ -44,10 +44,7 @@ public class LsyCancelService {
|
|
|
public String closeOrder(LsyOrderEntity order) {
|
|
public String closeOrder(LsyOrderEntity order) {
|
|
|
String scene = order.getPayScene();
|
|
String scene = order.getPayScene();
|
|
|
if ("H5".equals(scene)) {
|
|
if ("H5".equals(scene)) {
|
|
|
- // 上游 H5 目录下无独立关单接口,closeorder 正文也未提 H5。
|
|
|
|
|
- // v1 只做本地收敛,不动上游(见 spec §2.3 / §14 待确认项)。
|
|
|
|
|
- log.info("H5 订单无已知关单接口,本地收敛为 EXPIRED: outTradeNo={}", order.getOutTradeNo());
|
|
|
|
|
- return "EXPIRED";
|
|
|
|
|
|
|
+ return closeH5Order(order);
|
|
|
}
|
|
}
|
|
|
if ("QRCODE".equals(scene)) {
|
|
if ("QRCODE".equals(scene)) {
|
|
|
return closeQrOrder(order);
|
|
return closeQrOrder(order);
|
|
@@ -55,6 +52,72 @@ public class LsyCancelService {
|
|
|
return cancelBarcodeOrder(order);
|
|
return cancelBarcodeOrder(order);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * H5 关单:打上游 {@code /v3/closeorder}({@code trade_no} 寻址)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ <b>2026-09-28 联调改判 —— 这里原先是「不动上游、本地收敛为 EXPIRED」。</b>
|
|
|
|
|
+ * 原依据是「上游 H5 目录下无独立关单接口」。那句话**字面仍真**
|
|
|
|
|
+ * (142 页文档里标题含「关单」的只有三页:196236529 {@code /v3/closeorder}、
|
|
|
|
|
+ * 196236547 {@code /v3/qrclose}、196236550 云订单 {@code /v3/trade/closePayTrade},
|
|
|
|
|
+ * H5 目录下确实一页都没有),但由它推出的结论是**假的**:
|
|
|
|
|
+ * {@code /v3/closeorder} 落在「支付接口」目录下,正文只写了可供「二维码支付,JSAPI支付」,
|
|
|
|
|
+ * <b>实测对 H5 一样受理</b>。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>取证不是「关单成功」四个字(那只能证明它应了一声),而是**差分对照**:
|
|
|
|
|
+ * 同一时刻建两笔同参 H5,只关其中一笔 ——
|
|
|
|
|
+ * 被关的 A {@code PAY_IN_PROCESS → PAY_FAIL},没动的 B {@code PAY_IN_PROCESS → PAY_IN_PROCESS}。
|
|
|
|
|
+ * 读数在 {@code logs/_lsy-close-control.txt}。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么这件事必须改】只本地收敛 = 关单按钮对操作员**说谎**:
|
|
|
|
|
+ * 页面上写「已撤销」,而上游那条支付链接**还活着**,顾客随时能付。
|
|
|
|
|
+ * 顾客真付了的话,{@code EXPIRED} 不在 {@code LsyOrderStatus.PAY_WRITE_FROM} 里,
|
|
|
|
|
+ * {@code applyPayResult} 命中 0 行,只能由 {@code LsyNotifyService} 报一条
|
|
|
|
|
+ * LATE_PAYMENT_AFTER_TERMINAL —— 钱收了、单是「已过期」,账实不符它**能被发现,但已经发生了**。
|
|
|
|
|
+ * 打了上游这一跳,这件事根本不会发生。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【本地收敛没删】上游没给过订单号(下单那一跳就失败了)时**没有句柄可关**,
|
|
|
|
|
+ * 仍旧只能本地收敛 —— 见下面的空判分支。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【本方法**故意**不先查一次】与 {@link #cancelBarcodeOrder} 的「先查后撤」不同:
|
|
|
|
|
+ * 那边的查询是用来分辨「撤销会不会变成退款」的,撤销一旦撤到已付款的单,
|
|
|
|
|
+ * 上游会把钱退给用户(196236528 撤销接口正文明写),属**资金动作**,必须查。
|
|
|
|
|
+ * 而 196236529 关单接口正文没有这层语义,实测对一笔 PAY_FAIL 的单它直接拒绝
|
|
|
|
|
+ * (「关单失败:该订单交易失败,不支持关单」)。反过来,H5 单在顾客还没付款时的
|
|
|
|
|
+ * **正常态就是 {@code PAY_IN_PROCESS}**(实测四次全是「用户支付中」)——
|
|
|
|
|
+ * 照抄「先查后撤」会让这一支在最该关单的那一刻返回 null,按钮永远点不动,
|
|
|
|
|
+ * 比原来那个假的「本地收敛」还糟。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【仍未测到的】顾客**正在**付款的那一瞬间调关单会怎样(可能两边同时进行)。
|
|
|
|
|
+ * 上游与我都无法在无真实付款人的环境下造出这个时刻。本地这一侧有兜底:
|
|
|
|
|
+ * 控制器落库是 {@code in("status", LsyOrderStatus.PENDING)} 的条件更新,
|
|
|
|
|
+ * 回调若已抢先写 SUCCESS,这次更新命中 0 行,操作员会看到「这一单的状态刚刚变了」。
|
|
|
|
|
+ */
|
|
|
|
|
+ private String closeH5Order(LsyOrderEntity order) {
|
|
|
|
|
+ if (order.getTradeNo() == null || order.getTradeNo().isBlank()) {
|
|
|
|
|
+ log.warn("H5 订单没有上游订单号(下单未成功),无句柄可关,本地收敛: outTradeNo={}",
|
|
|
|
|
+ order.getOutTradeNo());
|
|
|
|
|
+ return "EXPIRED";
|
|
|
|
|
+ }
|
|
|
|
|
+ // 入参逐字照 196236529 的请求参数表:只发 mch_no / close_trace_no / close_time / trade_no,
|
|
|
|
|
+ // 不发 shop_no(该表里没有这一项)。这套形状就是联调实测被受理的那一套。
|
|
|
|
|
+ Map<String, Object> p = new LinkedHashMap<>();
|
|
|
|
|
+ p.put("mch_no", order.getMchNo());
|
|
|
|
|
+ p.put("close_trace_no", IdUtil.fastSimpleUUID());
|
|
|
|
|
+ p.put("close_time", LocalDateTime.now().format(FMT));
|
|
|
|
|
+ p.put("trade_no", order.getTradeNo());
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> resp = client.post("/v3/closeorder", p);
|
|
|
|
|
+ if (parser.isPaySuccess(resp)) {
|
|
|
|
|
+ return "CLOSED";
|
|
|
|
|
+ }
|
|
|
|
|
+ // 实测这一支的原文是「关单失败:该订单交易失败,不支持关单」(对已关/已失败的单)。
|
|
|
|
|
+ // 【两个上游实参过 LsyLogSanitizer.flatten】同 closeQrOrder:报文原文不经 DTO 校验。
|
|
|
|
|
+ log.warn("H5 关单失败: outTradeNo={}, result_code={}, return_msg={}",
|
|
|
|
|
+ order.getOutTradeNo(), LsyLogSanitizer.flatten(resp.get("result_code")),
|
|
|
|
|
+ LsyLogSanitizer.flatten(resp.get("return_msg")));
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/** 聚合码关单:入参是 code_url,不是 trade_no */
|
|
/** 聚合码关单:入参是 code_url,不是 trade_no */
|
|
|
private String closeQrOrder(LsyOrderEntity order) {
|
|
private String closeQrOrder(LsyOrderEntity order) {
|
|
|
if (order.getCodeUrl() == null || order.getCodeUrl().isBlank()) {
|
|
if (order.getCodeUrl() == null || order.getCodeUrl().isBlank()) {
|
|
@@ -73,14 +136,17 @@ public class LsyCancelService {
|
|
|
return "CLOSED";
|
|
return "CLOSED";
|
|
|
}
|
|
}
|
|
|
// ★ 必须把 result_code 一起打出来,不能只打 return_msg。
|
|
// ★ 必须把 result_code 一起打出来,不能只打 return_msg。
|
|
|
- // 上游文档里 `/v3/qrclose`(以及 closeorder / cancelorder)**正文把成功写成 `PAY_SUCEESS`**
|
|
|
|
|
- // —— 少一个 S。若那不是笔误而是真实返回值,`isPaySuccess` 对它为 false,
|
|
|
|
|
- // 于是关单**实际上成功了**,我们却在这里记一条「失败」,
|
|
|
|
|
- // 而 `return_msg` 大概率是「成功」二字 —— 运维看到「关单失败: msg=成功」将无从判断。
|
|
|
|
|
- // 带上 result_code,这一条日志自己就回答了「是不是那个拼写」。
|
|
|
|
|
- // ★ 不要为了这个去给 parser 加 `PAY_SUCEESS`:我们无法判定它是笔误还是真实值,
|
|
|
|
|
- // 而两种误判不对称 —— 猜错成真实值会让一个可疑拼写进入正常路径;
|
|
|
|
|
- // 猜成笔误只是让关单多走人工核对。**留到联调第一笔关单看日志定案。**
|
|
|
|
|
|
|
+ //
|
|
|
|
|
+ // 【那个 `PAY_SUCEESS` 拼写疑问 —— 2026-09-28 联调已定案:是**文档笔误**。】
|
|
|
|
|
+ // 文档有 5 页正文把成功写成 `PAY_SUCEESS`(少一个 S):关单 196236529、撤销 196236528、
|
|
|
|
|
+ // 云订单 196236548/196236549/196236550。而联调实测,五条关单应答回来的**全是
|
|
|
|
|
+ // `PAY_SUCCESS`**(正确拼写):`/v3/qrclose` 与 `/v3/closeorder` 各两次成功、
|
|
|
|
|
+ // 外加一次 PAY_FAIL —— 一次 `PAY_SUCEESS` 都没出现过。读数见 `logs/_lsy-lifecycle.txt`、
|
|
|
|
|
+ // `logs/_lsy-round3.txt`、`logs/_lsy-close-control.txt` 的原始 .bin。
|
|
|
|
|
+ // ⇒ 所以 `isPaySuccess` 的判据是对的,**parser 什么都不用加**(当初「不要加」的决定是对的,
|
|
|
|
|
+ // 而且理由现在从「无法判定」升级成了「已实测不是」)。
|
|
|
|
|
+ // result_code 仍留在日志里:它让这一条自己就能回答「是不是那个拼写」,
|
|
|
|
|
+ // 而这个价值不依赖那个疑问是否已解 —— 上游再冒出别的码时它照样是第一线索。
|
|
|
//
|
|
//
|
|
|
// 【两个上游实参过 LsyLogSanitizer.flatten】它们来自 HTTP 响应体,不经过任何 DTO 校验
|
|
// 【两个上游实参过 LsyLogSanitizer.flatten】它们来自 HTTP 响应体,不经过任何 DTO 校验
|
|
|
// (入口控制覆盖不到),必须在汇点压平(CWE-117)。
|
|
// (入口控制覆盖不到),必须在汇点压平(CWE-117)。
|
|
@@ -134,9 +200,19 @@ public class LsyCancelService {
|
|
|
if (parser.isPaySuccess(resp)) {
|
|
if (parser.isPaySuccess(resp)) {
|
|
|
return "CANCELED";
|
|
return "CANCELED";
|
|
|
}
|
|
}
|
|
|
- // ★ 同 closeQrOrder:`/v3/cancelorder` 的文档正文把成功写成 `PAY_SUCEESS`(少一个 S)。
|
|
|
|
|
- // 不带 result_code 的话,「撤销实际成功、我们记成失败」将表现为
|
|
|
|
|
- // 「撤销失败: outTradeNo=..., msg=成功」—— 一句自相矛盾、无法定位的日志。
|
|
|
|
|
|
|
+ // ★ 同 closeQrOrder:`/v3/cancelorder` 的文档正文把成功写成 `PAY_SUCEESS`(少一个 S)——
|
|
|
|
|
+ // 2026-09-28 联调已定案为**文档笔误**(该页正文那句的「参数示例」列自己写的就是
|
|
|
|
|
+ // 正确拼写 `PAY_SUCCESS`)。不带 result_code 的话,「撤销实际成功、我们记成失败」
|
|
|
|
|
+ // 将表现为「撤销失败: outTradeNo=..., msg=成功」—— 一句自相矛盾、无法定位的日志。
|
|
|
|
|
+ //
|
|
|
|
|
+ // 【这条腿在本商户上目前是死的 —— 但**不改**,见下】
|
|
|
|
|
+ // 实测四次(两笔真实被扫失败单 × trade_no / pay_trace_no+pay_time 两种寻址),
|
|
|
|
|
+ // 上游一律回 `return_code=FAIL` + 「暂不支持撤销!」(sign 有效,是我方收到的真应答)。
|
|
|
|
|
+ // 即:**本商户(测试环境 Y52211090004)没有开通撤销**。
|
|
|
|
|
+ // 不改的理由:这是**上游的商户配置**,不是我们的缺陷 —— 我们这边该发的都发了、
|
|
|
|
|
+ // 该守的都守了;它随时可能被上游开通。真要改善的只是**操作员看到的文案**
|
|
|
|
|
+ // (现在他会看到控制器那句笼统的「撤销失败」,拿不到「暂不支持撤销」这个真实原因),
|
|
|
|
|
+ // 那是 `LeshouyingCashierController.close` 的文案问题,**属另一个决定,本批不动**。
|
|
|
//
|
|
//
|
|
|
// 【两个上游实参过 LsyLogSanitizer.flatten】同 closeQrOrder:报文原文不经 DTO 校验。
|
|
// 【两个上游实参过 LsyLogSanitizer.flatten】同 closeQrOrder:报文原文不经 DTO 校验。
|
|
|
// outTradeNo 不套:它由入口 @Pattern 保证不含控制字符。
|
|
// outTradeNo 不套:它由入口 @Pattern 保证不含控制字符。
|