|
|
@@ -39,10 +39,16 @@ import java.util.Map;
|
|
|
* ② 关单({@link #closeExpiredQrOrders} → {@link #applyCloseResult})走 closeOrder 的
|
|
|
* QRCODE 分支:值来自**上游 `/v3/qrclose` 应答**的解析结果({@code isPaySuccess(resp)}
|
|
|
* → "CLOSED")—— 它和 ① 的区别是**入口不同(关 vs 查)**,**不是**「本地 vs 上游」;
|
|
|
- * 该分支唯一的本地例外是 `code_url` 非 null 但为空白串时的兜底常量 "EXPIRED"
|
|
|
- * (扫描条件只保证非 null、不保证非空);
|
|
|
+ * 该分支有**两个**本地例外,写的都是常量 "EXPIRED":
|
|
|
+ * (a) `code_url` 非 null 但为空白串时的兜底(扫描条件只保证非 null、不保证非空);
|
|
|
+ * (b) 2026-09-30 加的:上游**拒绝**关单并回「该聚合码链接不存在或已过期!」时收敛
|
|
|
+ * ({@link #upstreamSaysQrLinkGone})。⚠ (b) 是本类里**唯一**一个「触发条件是上游原文、
|
|
|
+ * 写下去的值却是本地常量」的分支 —— 它按**触发**像 ①、按**取值**像 ③,
|
|
|
+ * 这正是为什么这三条必须分开写、不许合并成一句;
|
|
|
* ③ 过期收敛({@link #expireStaleOrders} → {@link #markExpired}):写的是本地时间阈值
|
|
|
* 判定出的常量 "EXPIRED",**不看任何上游输入**。
|
|
|
+ * ⚠ 这句话的主语是本条 ③ 的**触发**,不是那个写入口的方法体:{@link #markExpired}
|
|
|
+ * 在 2026-09-30 之后有**两个**调用方(本条,以及 ②(b) 那个触发含上游原文的分支)。
|
|
|
* 第 ④ 条下游回调重试只动推送状态(`notify_status`),不写支付状态。
|
|
|
* (回调侧还有两条依据上游输入写支付状态的路径,都不在本调度器内:
|
|
|
* {@code LsyNotifyService.handlePayNotify} → {@code LsyOrderService.applyPayResult}
|
|
|
@@ -129,6 +135,15 @@ public class LsyReconcileScheduler {
|
|
|
*/
|
|
|
private static final String NOT_FOUND_MSG = "订单信息不存在";
|
|
|
|
|
|
+ /**
|
|
|
+ * 「上游说这个聚合码链接已经没了」的原文片段({@link #closeExpiredQrOrders} 用)。
|
|
|
+ *
|
|
|
+ * <p>实测来源:拿一个上游不认的 `code_url` 打 `/v3/qrclose`,它回的就是这句
|
|
|
+ * (原始字节 `logs/_lsy-lc4/qrclose-bogus-url.bin`)。判据同
|
|
|
+ * {@link #upstreamSaysNotFound} —— 只比原文,不比异常类型。
|
|
|
+ */
|
|
|
+ private static final String QR_LINK_GONE_MSG = "该聚合码链接不存在或已过期";
|
|
|
+
|
|
|
/**
|
|
|
* 实退补写窗口:只看最近这么多天内**结单**的退款(见 {@link #backfillRefundSettle})。
|
|
|
*
|
|
|
@@ -177,6 +192,72 @@ public class LsyReconcileScheduler {
|
|
|
&& api.getRawReturnMsg().contains(NOT_FOUND_MSG);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 这个异常是不是「上游说这个聚合码链接已经没了」
|
|
|
+ * (是则返回 true,调用方**本地收敛为 EXPIRED**)。
|
|
|
+ *
|
|
|
+ * <p>【为什么它不是 {@link #upstreamSaysNotFound} 那种「只降级、不动状态」的处理】
|
|
|
+ * 本腿的扫描条件是「已过 `code_url_expire_time` + 仍 PAYING + `code_url` 非空」
|
|
|
+ * (见 {@link #closeExpiredQrOrders} 的谓词)⇒ 扫到的**每一笔都已经过了本地有效期**,
|
|
|
+ * 这张码**不可能再被支付**。而原实现把这条拒绝记成带栈 ERROR 之后什么都不做:
|
|
|
+ * 订单留在 PAYING、每 5 分钟重试一次,直到 {@link #expireStaleOrders} 的
|
|
|
+ * `absoluteExpireHours`(24 小时)才收走 —— 对外契约
|
|
|
+ * (`collection-openapi.md` 的 FAQ「用户一直不付款,二维码会怎样?」)写的是
|
|
|
+ * 「有效期默认 15 分钟(可用 time_expire 指定)。超时后订单状态变为 EXPIRED」,
|
|
|
+ * 实现与契约相反:那个 EXPIRED 要等 24 小时。
|
|
|
+ *
|
|
|
+ * <p>【判据是复发的读数,不是形状】本分支的原注释写着「我没有它复发的读数,所以维持
|
|
|
+ * ERROR + 栈不变」。现在有了,且三次读数同向:
|
|
|
+ * <ul>
|
|
|
+ * <li>2026-09-30 那次运行(`logs/payment-platform.log`):同一笔 `QR1790675073`,
|
|
|
+ * 19 次 `/v3/qrclose` 请求 / 19 条这条拒绝 / **0 次成功**;</li>
|
|
|
+ * <li>2026-09-22 那份(`java/logs/payment-platform-error.log`):3 笔单共 71 次拒绝,
|
|
|
+ * 同样 **0 次成功**;</li>
|
|
|
+ * <li>历史上**唯一成功过**的 qrclose 是探针拿**刚建出来**的码关的
|
|
|
+ * (`logs/_lsy-close-control.txt` 对照二,建单后约 1 秒即关)。</li>
|
|
|
+ * </ul>
|
|
|
+ * 90 次实测里本腿一次都没成功过 ⇒「上游不认这个 `code_url` 属反常」这个前提被复发率
|
|
|
+ * 推翻:它不是反常,它就是这个腿的**正常遭遇**。
|
|
|
+ *
|
|
|
+ * <p>【措辞的两个读法都指向同一结论】原文是「不存在**或**已过期」,本处分不出是哪个
|
|
|
+ * (拿一个假 `code_url` 打过去回的是同一句)。但在这里不必分辨:扫描谓词已经独立证明
|
|
|
+ * 这笔**已过本地有效期**,两个读法下这张码都不可用、这笔单都收不到钱。
|
|
|
+ *
|
|
|
+ * <p>【为什么收敛成 EXPIRED 而不是 CLOSED】上游**没有**关掉它 —— 它回的是**拒绝**。
|
|
|
+ * 写 CLOSED 等于替上游宣布一个它没做的动作,而 {@code CLOSED} 还在
|
|
|
+ * {@link LsyOrderStatus#REFUND_WRITE_FROM} 里(退款可写)。EXPIRED 才是这里的事实:
|
|
|
+ * 码已失效、谁也付不进来。同族的另外两处「句柄不可用」分支
|
|
|
+ * ({@code LsyCancelService.closeQrOrder} 的 `code_url` 缺失、
|
|
|
+ * {@code closeH5Order} 的 `trade_no` 缺失)返回的也都是 EXPIRED。
|
|
|
+ *
|
|
|
+ * <p>【失败方向:故意不对称,同 {@link #upstreamSaysNotFound}】只有**这一句原文**触发
|
|
|
+ * 收敛。「验签失败」「商户未配置」这类真故障,以及将来上游改了措辞,都退回
|
|
|
+ * ERROR + 栈 + **状态不动**(一笔**还能被支付**的单不会被本地判死,也不会不再重试)。
|
|
|
+ * 这条不对称由 `LsySchedulerTest#closeExpiredQrOrders_其他上游失败_仍是带栈ERROR且不动状态`
|
|
|
+ * 钉住(把收敛改成无条件 ⇒ 那条必须红)。
|
|
|
+ *
|
|
|
+ * <p>【已知残余风险 —— 写下来是为了别让它消失】EXPIRED 不在
|
|
|
+ * {@link LsyOrderStatus#PAY_WRITE_FROM} 里 ⇒ 这一步**一旦写错就不可逆**:
|
|
|
+ * 真付了款的那笔再也写不进 SUCCESS(钱在上游,状态在我们这儿是「过期」)。
|
|
|
+ * 能误判的窗口:用户付款**恰好落在同一轮里 ① 与 ④ 之间**(① 问上游时那笔还不可见、
|
|
|
+ * ④ 紧接着把码判死),且回调丢失。比这更松的窗口不存在 —— 码一过期上游就不收新扫码,
|
|
|
+ * 而 ① 排在 ④ 之前、每 5 分钟问一次,付款一旦可见就会先被 ① settle 成 SUCCESS
|
|
|
+ * (settle 后本腿的谓词 `status='PAYING'` 也不再命中它)。
|
|
|
+ * 这条窗口**不是本次引入的**:H5 分支({@code h5ExpireMinutes} 后直接 {@code markExpired})
|
|
|
+ * 连上游都不问,{@link #expireStaleOrders} 的 24 小时绝对阈值同样只看本地时钟 ——
|
|
|
+ * 本分支比两者都**强**,因为上游明确说了这张码不能用。取这个方向,是因为对外契约
|
|
|
+ * (15 分钟 → EXPIRED)已经在卖,而维持原样会让「过期」在 24 小时内都不成立。
|
|
|
+ *
|
|
|
+ * <p>【用哪个 getter】同 {@link #upstreamSaysNotFound}:判据读 {@code getRawReturnMsg()}
|
|
|
+ * (上游原文,未截断),写日志一律用 {@code getMessage()}(已 sanitize + 封顶)——
|
|
|
+ * 直接把 raw 文本写进日志是 CWE-117。
|
|
|
+ */
|
|
|
+ private static boolean upstreamSaysQrLinkGone(Throwable e) {
|
|
|
+ return e instanceof LeshouyingApiException api
|
|
|
+ && api.getRawReturnMsg() != null
|
|
|
+ && api.getRawReturnMsg().contains(QR_LINK_GONE_MSG);
|
|
|
+ }
|
|
|
+
|
|
|
@Scheduled(fixedDelay = 5 * 60 * 1000)
|
|
|
public void reconcile() {
|
|
|
try {
|
|
|
@@ -390,7 +471,24 @@ public class LsyReconcileScheduler {
|
|
|
refundMapper.update(patch, w);
|
|
|
}
|
|
|
|
|
|
- /** 聚合码到期 → 调 v3/qrclose 关单 */
|
|
|
+ /**
|
|
|
+ * 聚合码到期 → 调 v3/qrclose 关单;上游回「链接不存在或已过期」时**本地收敛为 EXPIRED**。
|
|
|
+ *
|
|
|
+ * <p>谓词是「已过 `code_url_expire_time` + 仍 PAYING + `code_url` 非空」——
|
|
|
+ * 三个条件缺一不可,且第三个是本腿的**唯一入口凭证**:`code_url` 只在 qrpay **成功**
|
|
|
+ * 之后才落库。这条谓词同时是下游收敛判据的一部分(见
|
|
|
+ * {@link #upstreamSaysQrLinkGone}:扫到的每一笔都已过本地有效期)。
|
|
|
+ *
|
|
|
+ * <p>两条出口,都**不**把订单留在原地:
|
|
|
+ * <ul>
|
|
|
+ * <li>上游说关掉了 ⇒ {@link #applyCloseResult} 写 CLOSED(值来自上游应答);</li>
|
|
|
+ * <li>上游**拒绝**(这一句原文)⇒ 这里直接 {@link #markExpired}
|
|
|
+ * (值是本地上限常量的推断,不是上游的话)。</li>
|
|
|
+ * </ul>
|
|
|
+ * 「关单未成功」那种**没抛异常**的失败({@link #applyCloseResult} 里 target 为 null)
|
|
|
+ * **不在**本分支内:它保持原状态等下轮,仍未收敛 —— 那条路由
|
|
|
+ * {@link #expireStaleOrders} 的 24 小时绝对阈值兜底,本次改动没动它。
|
|
|
+ */
|
|
|
void closeExpiredQrOrders() {
|
|
|
List<LsyOrderEntity> orders = orderMapper.selectList(
|
|
|
new LambdaQueryWrapper<LsyOrderEntity>()
|
|
|
@@ -404,15 +502,22 @@ public class LsyReconcileScheduler {
|
|
|
try {
|
|
|
applyCloseResult(order);
|
|
|
} catch (Exception e) {
|
|
|
- // 【这条腿为什么**不**跟着降级 —— 有读数,但没到判据】
|
|
|
- // 同一个探针实测:拿一个上游不认的 code_url 打 /v3/qrclose,回
|
|
|
- // return_code=FAIL +「该聚合码链接不存在或已过期!」,也会抛异常走到这里
|
|
|
- // (原始字节 logs/_lsy-lc4/qrclose-bogus-url.bin)。
|
|
|
- // 形状与上面两条腿一样,但**形状不是判据**:本腿的扫描条件是
|
|
|
- // 「过期 + 仍 PAYING + code_url 非空」,而 code_url 是 qrpay **成功**之后
|
|
|
- // 才落库的 ⇒ 上游不认这个 code_url 属反常,不是设计内状态。
|
|
|
- // 我没有它复发的读数,所以维持 ERROR + 栈不变。
|
|
|
- log.error("聚合码关单异常: outTradeNo={}", order.getOutTradeNo(), e);
|
|
|
+ // ★ 2026-09-30:上游回「该聚合码链接不存在或已过期!」⇒ **本地收敛为 EXPIRED**。
|
|
|
+ // 判据、读数与失败方向全在 upstreamSaysQrLinkGone 的 javadoc 里;这里只记
|
|
|
+ // 两件**代码事实**:
|
|
|
+ // ① 顺序是「先记 WARN、再动状态」—— 收敛那一步(含下游推送)若抛异常,
|
|
|
+ // 上游原文已经落在日志里了,排障不用去猜;
|
|
|
+ // ② 级别从 ERROR 降到 WARN 是因为**这是可解释的业务回答**、不是故障:
|
|
|
+ // 同一个探针对一个上游不认的 code_url 回的就是这句
|
|
|
+ // (原始字节 logs/_lsy-lc4/qrclose-bogus-url.bin)。
|
|
|
+ // 其余异常一个字都不改:仍是 ERROR + 栈 + 状态不动。
|
|
|
+ if (upstreamSaysQrLinkGone(e)) {
|
|
|
+ log.warn("聚合码已失效,本地收敛为 EXPIRED: outTradeNo={}, upstream={}",
|
|
|
+ order.getOutTradeNo(), e.getMessage());
|
|
|
+ markExpired(order);
|
|
|
+ } else {
|
|
|
+ log.error("聚合码关单异常: outTradeNo={}", order.getOutTradeNo(), e);
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
}
|