|
|
@@ -87,6 +87,8 @@ public class UnifiedCallbackService {
|
|
|
|
|
|
/** IPv6 字面量的字符集(含内嵌 IPv4 的点);含 {@code %} 的 zone id 一律 fail-closed。 */
|
|
|
private static final Pattern IPV6_LITERAL = Pattern.compile("[0-9a-f:.]+");
|
|
|
+ /** {@code 0x} 整数 IPv4:`0x` 之后必须**全部**是十六进制数字,否则是普通域名(`0xpay.example.com`)。 */
|
|
|
+ private static final Pattern HEX_INTEGER_HOST = Pattern.compile("0x[0-9a-f]+");
|
|
|
|
|
|
/**
|
|
|
* {@code content.upstream} 的白名单(R35,逐字取 spec §7.5)。只拷贝存在的字段;
|
|
|
@@ -134,7 +136,11 @@ public class UnifiedCallbackService {
|
|
|
if (order == null) {
|
|
|
return;
|
|
|
}
|
|
|
- String notifyId = orderNotifyId(order.getOrderNo(), order.getTradeStatus());
|
|
|
+ // 事件判别键在提交异步任务前快照:任务体只读这份局部值。调用方可能在 submit 之后
|
|
|
+ // 把同一实体改成下一个事件(trade_status 2→5),届时再从实体读会把 event2 的 2xx
|
|
|
+ // 写到 event5 的行上,覆盖掉 event5 的 FAILED。
|
|
|
+ String eventTradeStatus = order.getTradeStatus();
|
|
|
+ String notifyId = orderNotifyId(order.getOrderNo(), eventTradeStatus);
|
|
|
String url;
|
|
|
String content;
|
|
|
try {
|
|
|
@@ -142,7 +148,7 @@ public class UnifiedCallbackService {
|
|
|
if (!isAllowedCallbackUrl(url)) {
|
|
|
log.warn("[统一支付回调] 订单无可用的回调地址,记 FAILED 待重试: order_no={}, url={}",
|
|
|
order.getOrderNo(), UnifiedLogSanitizer.flatten(url));
|
|
|
- writeOrderNotifyStatus(order, UnifiedNotifyStatus.FAILED, true);
|
|
|
+ writeOrderNotifyStatus(order, eventTradeStatus, UnifiedNotifyStatus.FAILED, true);
|
|
|
return;
|
|
|
}
|
|
|
content = buildOrderContent(order, upstream);
|
|
|
@@ -151,19 +157,19 @@ public class UnifiedCallbackService {
|
|
|
// 行若留在 PENDING,重试扫描(只选 FAILED)永远捞不到它,事件永久丢失。
|
|
|
log.warn("[统一支付回调] 准备订单回调异常,记 FAILED 待重试: order_no={}, error={}",
|
|
|
order.getOrderNo(), UnifiedLogSanitizer.flatten(e.getMessage()));
|
|
|
- writeOrderNotifyStatus(order, UnifiedNotifyStatus.FAILED, true);
|
|
|
+ writeOrderNotifyStatus(order, eventTradeStatus, UnifiedNotifyStatus.FAILED, true);
|
|
|
return;
|
|
|
}
|
|
|
if (content == null) {
|
|
|
- writeOrderNotifyStatus(order, UnifiedNotifyStatus.FAILED, true);
|
|
|
+ writeOrderNotifyStatus(order, eventTradeStatus, UnifiedNotifyStatus.FAILED, true);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 先落 FAILED + 计数:崩溃窗口里这一单仍会被重试扫描捞起
|
|
|
- writeOrderNotifyStatus(order, UnifiedNotifyStatus.FAILED, true);
|
|
|
+ writeOrderNotifyStatus(order, eventTradeStatus, UnifiedNotifyStatus.FAILED, true);
|
|
|
submit(() -> {
|
|
|
if (pushOnce(url, notifyId, content)) {
|
|
|
- writeOrderNotifyStatus(order, UnifiedNotifyStatus.SUCCESS, false);
|
|
|
+ writeOrderNotifyStatus(order, eventTradeStatus, UnifiedNotifyStatus.SUCCESS, false);
|
|
|
}
|
|
|
}, "order_no=" + order.getOrderNo());
|
|
|
}
|
|
|
@@ -179,7 +185,9 @@ public class UnifiedCallbackService {
|
|
|
if (refund == null) {
|
|
|
return;
|
|
|
}
|
|
|
- String notifyId = refundNotifyId(refund.getRefundNo(), refund.getState());
|
|
|
+ // 同 notifyOrder:判别键在 submit 前快照,异步任务体只读快照。
|
|
|
+ String eventState = refund.getState();
|
|
|
+ String notifyId = refundNotifyId(refund.getRefundNo(), eventState);
|
|
|
String url;
|
|
|
String content;
|
|
|
try {
|
|
|
@@ -187,25 +195,25 @@ public class UnifiedCallbackService {
|
|
|
if (!isAllowedCallbackUrl(url)) {
|
|
|
log.warn("[统一支付回调] 退款无可用的回调地址,记 FAILED 待重试: refund_no={}, url={}",
|
|
|
refund.getRefundNo(), UnifiedLogSanitizer.flatten(url));
|
|
|
- writeRefundNotifyStatus(refund, UnifiedNotifyStatus.FAILED, true);
|
|
|
+ writeRefundNotifyStatus(refund, eventState, UnifiedNotifyStatus.FAILED, true);
|
|
|
return;
|
|
|
}
|
|
|
content = buildRefundContent(refund, order, upstream);
|
|
|
} catch (Exception e) {
|
|
|
log.warn("[统一支付回调] 准备退款回调异常,记 FAILED 待重试: refund_no={}, error={}",
|
|
|
refund.getRefundNo(), UnifiedLogSanitizer.flatten(e.getMessage()));
|
|
|
- writeRefundNotifyStatus(refund, UnifiedNotifyStatus.FAILED, true);
|
|
|
+ writeRefundNotifyStatus(refund, eventState, UnifiedNotifyStatus.FAILED, true);
|
|
|
return;
|
|
|
}
|
|
|
if (content == null) {
|
|
|
- writeRefundNotifyStatus(refund, UnifiedNotifyStatus.FAILED, true);
|
|
|
+ writeRefundNotifyStatus(refund, eventState, UnifiedNotifyStatus.FAILED, true);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- writeRefundNotifyStatus(refund, UnifiedNotifyStatus.FAILED, true);
|
|
|
+ writeRefundNotifyStatus(refund, eventState, UnifiedNotifyStatus.FAILED, true);
|
|
|
submit(() -> {
|
|
|
if (pushOnce(url, notifyId, content)) {
|
|
|
- writeRefundNotifyStatus(refund, UnifiedNotifyStatus.SUCCESS, false);
|
|
|
+ writeRefundNotifyStatus(refund, eventState, UnifiedNotifyStatus.SUCCESS, false);
|
|
|
}
|
|
|
}, "refund_no=" + refund.getRefundNo());
|
|
|
}
|
|
|
@@ -515,13 +523,17 @@ public class UnifiedCallbackService {
|
|
|
* <p><b>WHERE 必须带事件判别键 {@code trade_status}</b>:同一订单行会产生多个 ORDER
|
|
|
* 事件(2、2→5)。旧事件的推送如果晚拿到 2xx,只按 {@code order_no} 回写会把新事件的
|
|
|
* FAILED 覆盖成 SUCCESS,新事件就永远不再被重试扫描捞起。
|
|
|
+ *
|
|
|
+ * <p>判别键由调用方传入({@code eventTradeStatus}),不再从实体读:异步任务执行时
|
|
|
+ * 同一实体可能已被调用方推进到下一个事件,实时读会把判别键换成新事件。
|
|
|
*/
|
|
|
- private void writeOrderNotifyStatus(UnifiedOrderEntity order, String status, boolean countAttempt) {
|
|
|
+ private void writeOrderNotifyStatus(UnifiedOrderEntity order, String eventTradeStatus,
|
|
|
+ String status, boolean countAttempt) {
|
|
|
try {
|
|
|
OffsetDateTime now = OffsetDateTime.now();
|
|
|
LambdaUpdateWrapper<UnifiedOrderEntity> update = new LambdaUpdateWrapper<UnifiedOrderEntity>()
|
|
|
.eq(UnifiedOrderEntity::getOrderNo, order.getOrderNo())
|
|
|
- .eq(UnifiedOrderEntity::getTradeStatus, order.getTradeStatus())
|
|
|
+ .eq(UnifiedOrderEntity::getTradeStatus, eventTradeStatus)
|
|
|
.set(UnifiedOrderEntity::getNotifyStatus, status)
|
|
|
.set(UnifiedOrderEntity::getLastNotifyTime, now)
|
|
|
.set(UnifiedOrderEntity::getUpdatedTime, now);
|
|
|
@@ -537,13 +549,14 @@ public class UnifiedCallbackService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /** 退款侧同理:WHERE 带事件判别键 {@code state}。 */
|
|
|
- private void writeRefundNotifyStatus(UnifiedRefundEntity refund, String status, boolean countAttempt) {
|
|
|
+ /** 退款侧同理:WHERE 带事件判别键 {@code state},值由调用方在 submit 前快照。 */
|
|
|
+ private void writeRefundNotifyStatus(UnifiedRefundEntity refund, String eventState,
|
|
|
+ String status, boolean countAttempt) {
|
|
|
try {
|
|
|
OffsetDateTime now = OffsetDateTime.now();
|
|
|
LambdaUpdateWrapper<UnifiedRefundEntity> update = new LambdaUpdateWrapper<UnifiedRefundEntity>()
|
|
|
.eq(UnifiedRefundEntity::getRefundNo, refund.getRefundNo())
|
|
|
- .eq(UnifiedRefundEntity::getState, refund.getState())
|
|
|
+ .eq(UnifiedRefundEntity::getState, eventState)
|
|
|
.set(UnifiedRefundEntity::getNotifyStatus, status)
|
|
|
.set(UnifiedRefundEntity::getLastNotifyTime, now)
|
|
|
.set(UnifiedRefundEntity::getUpdatedTime, now);
|
|
|
@@ -573,8 +586,9 @@ public class UnifiedCallbackService {
|
|
|
* <ol>
|
|
|
* <li>host 先规范化:trim、小写、去尾部所有点、去 IPv6 方括号;</li>
|
|
|
* <li>非数字 host:只额外拒绝 {@code localhost} 与 {@code *.localhost},其余放行;</li>
|
|
|
- * <li>数字形态 host(纯数字 / {@code 0x} 开头 / 只含数字与点 / 含冒号的 IPv6):
|
|
|
- * 按**字面量**解析后再判地址类别,解析失败一律拒绝(fail-closed)。</li>
|
|
|
+ * <li>数字形态 host(纯数字 / {@code 0x} + 全十六进制 / 只含数字与点 / 含冒号的 IPv6):
|
|
|
+ * 按**字面量**解析后再判地址类别,解析失败一律拒绝(fail-closed)。
|
|
|
+ * 仅 {@code 0x} 前缀不算数字形态,`0x.example.com` 这类是域名。</li>
|
|
|
* </ol>
|
|
|
*
|
|
|
* <p>这里只会对已在字符集上验证过的 IPv6 字面量调用 {@code InetAddress.getByName};
|
|
|
@@ -628,12 +642,18 @@ public class UnifiedCallbackService {
|
|
|
return host;
|
|
|
}
|
|
|
|
|
|
- /** 数字形态:含冒号(IPv6)、{@code 0x} 开头(十六进制整数)、或只含数字与点。 */
|
|
|
+ /**
|
|
|
+ * 数字形态:含冒号(IPv6)、{@code 0x} + 全十六进制数字(十六进制整数)、或只含数字与点。
|
|
|
+ *
|
|
|
+ * <p>只看 {@code 0x} 前缀会把 {@code 0x.example.com}、{@code 0xpay.example.com} 这类合法
|
|
|
+ * 域名误判成整数并 fail-closed 拒绝,所以必须整串匹配 {@link #HEX_INTEGER_HOST};
|
|
|
+ * 不匹配的 `0x…` 落回域名分支。
|
|
|
+ */
|
|
|
private static boolean isNumericHost(String host) {
|
|
|
if (host.indexOf(':') >= 0) {
|
|
|
return true;
|
|
|
}
|
|
|
- if (host.startsWith("0x")) {
|
|
|
+ if (HEX_INTEGER_HOST.matcher(host).matches()) {
|
|
|
return true;
|
|
|
}
|
|
|
for (int i = 0; i < host.length(); i++) {
|
|
|
@@ -654,7 +674,7 @@ public class UnifiedCallbackService {
|
|
|
return false;
|
|
|
}
|
|
|
address = InetAddress.getByName(host);
|
|
|
- } else if (host.startsWith("0x")) {
|
|
|
+ } else if (HEX_INTEGER_HOST.matcher(host).matches()) {
|
|
|
address = InetAddress.getByAddress(parseNumericIpv4(host.substring(2), 16));
|
|
|
} else if (host.indexOf('.') >= 0) {
|
|
|
address = InetAddress.getByAddress(parseDottedIpv4(host));
|