安卓钉钉内手机网站支付成功后不跳转 return_url
结论
这类问题通常不是 return_url 配置错误,也不代表支付失败。更常见的原因是安卓钉钉内置 WebView、支付宝 App 和业务页面之间的返回链路没有完整恢复。
return_url 只是支付宝收银台结束后的前端跳转地址,不能作为支付成功的可靠依据。安卓钉钉调起支付宝后,支付宝可能只能回到钉钉原来的 WebView 页面,无法继续执行从收银台结果页跳转到 return_url 的流程。此时,页面会停留在支付发起页,也不会收到任何前端回调。
支付结果应以服务端 notify_url 异步通知或主动查询接口为准。要让安卓端获得接近苹果端的体验,可以增加支付中间页和订单状态轮询,必要时引导用户通过系统浏览器完成支付。
原因分析
手机网站支付的页面链路大致如下:
业务页面
→ 支付宝手机网站收银台
→ 支付宝 App
→ 返回收银台结果页
→ 跳转 return_url
在安卓钉钉中,业务页面运行在钉钉内置 WebView 里。调起支付宝 App 后,系统需要恢复支付宝、钉钉和原 WebView 页面之间的上下文。不同宿主 App、WebView 内核和系统版本,对外部应用跳转、URL Scheme 和页面栈恢复的处理并不完全相同。
如果支付宝完成支付后只恢复了钉钉中的原页面,没有恢复支付宝收银台页面,后续的结果页展示和 return_url 跳转也就不会执行。
这也能解释不同环境下的表现差异:
- iOS 钉钉处理页面返回链路的方式不同;
- 手机浏览器通常有更完整的页面栈;
- 安卓钉钉内置 WebView 可能无法按相同方式恢复支付宝收银台页面。
实际表现还可能受到钉钉版本、支付宝版本、Android 系统和默认浏览器设置的影响。没有对应版本和复现信息时,不能直接认定这是某个特定版本的缺陷。
推荐解决方案
1. 通过服务端异步通知确认支付结果
调用 alipay.trade.wap.pay 时,同时配置 notify_url 和 return_url:
AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest();
request.setNotifyUrl("https://www.example.com/alipay/notify");
request.setReturnUrl("https://www.example.com/payment/result");
request.setBizContent("""
{
"out_trade_no": "ORDER_202609090001",
"total_amount": "99.00",
"subject": "订单支付",
"product_code": "QUICK_WAP_WAY"
}
""");
String form = alipayClient.pageExecute(request).getBody();
两者用途不同:
notify_url接收支付宝服务端发送的异步通知;return_url只处理支付后的页面跳转;- 订单是否支付成功,必须根据异步通知的验签结果判断,不能看浏览器有没有跳回。
处理异步通知时,至少需要:
- 验证支付宝签名;
- 校验
app_id、out_trade_no、金额和收款方等字段; - 判断
trade_status; - 幂等更新订单;
- 处理成功后返回纯文本
success。
示例逻辑:
@PostMapping("/alipay/notify")
public String notify(HttpServletRequest request) throws Exception {
Map<String, String> params = new HashMap<>();
request.getParameterMap().forEach((key, values) -> {
if (values != null && values.length > 0) {
params.put(key, values[0]);
}
});
boolean verified = AlipaySignature.rsaCheckV1(
params,
alipayPublicKey,
"UTF-8",
"RSA2"
);
if (!verified) {
return "failure";
}
String outTradeNo = params.get("out_trade_no");
String tradeStatus = params.get("trade_status");
String totalAmount = params.get("total_amount");
// 还应校验 app_id、seller_id 或收款方信息,以及订单金额。
if ("TRADE_SUCCESS".equals(tradeStatus)
|| "TRADE_FINISHED".equals(tradeStatus)) {
paymentService.markPaidIdempotently(outTradeNo, totalAmount);
return "success";
}
return "failure";
}
收到通知后,不要跳过校验直接将订单改为已支付。
2. 在支付发起页轮询订单状态
安卓钉钉可能会回到原页面,因此可以在页面重新获得焦点或恢复可见时查询订单状态。服务端确认订单已支付后,再由业务页面跳转到结果页。
<script>
const orderNo = "ORDER_202609090001";
let pollingTimer = null;
let pollingCount = 0;
const maxPollingCount = 30;
async function checkPaymentStatus() {
try {
const response = await fetch(
`/api/orders/${encodeURIComponent(orderNo)}/payment-status`,
{
method: "GET",
credentials: "include",
cache: "no-store"
}
);
if (!response.ok) {
return;
}
const result = await response.json();
if (result.status === "PAID") {
stopPolling();
location.replace(
`/payment/result?orderNo=${encodeURIComponent(orderNo)}`
);
}
} catch (error) {
// 网络暂时不可用时保留页面,等待下一次查询。
}
}
function startPolling() {
if (pollingTimer !== null) {
return;
}
checkPaymentStatus();
pollingTimer = window.setInterval(() => {
pollingCount += 1;
if (pollingCount >= maxPollingCount) {
stopPolling();
return;
}
checkPaymentStatus();
}, 2000);
}
function stopPolling() {
if (pollingTimer !== null) {
window.clearInterval(pollingTimer);
pollingTimer = null;
}
}
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
startPolling();
}
});
window.addEventListener("focus", startPolling);
window.addEventListener("pageshow", startPolling);
startPolling();
</script>
前端查询的是业务服务端记录的订单状态,不能自行认定支付成功。业务服务端可以读取 notify_url 已经更新的状态。如果异步通知尚未到达,也可以在受控条件下调用支付宝订单查询接口补查。
3. 提供”我已完成支付”按钮
部分 Android WebView 恢复页面后,不一定能可靠触发 focus 或 visibilitychange。支付发起页还应提供手动查询入口:
<button type="button" id="check-payment">
我已完成支付
</button>
<script>
document
.getElementById("check-payment")
.addEventListener("click", checkPaymentStatus);
</script>
页面可以同时显示明确提示:
支付完成后页面未自动跳转,请点击”我已完成支付”查询订单结果,请勿重复付款。
4. 使用独立的支付中间页
复杂的业务页面不适合直接承担支付返回逻辑。可以改用下面的结构:
订单页面
→ 支付中间页
→ 支付宝收银台
→ 支付中间页查询订单状态
→ 业务支付结果页
支付中间页只处理以下事务:
- 保存订单号;
- 提交支付宝返回的支付表单;
- 页面恢复后查询订单状态;
- 显示”支付处理中”和手动查询按钮;
- 确认支付后跳转到业务结果页。
如果需要通过钉钉 JSAPI 打开独立页面,可以在钉钉环境初始化完成后调用 dd.biz.util.openLink,打开自有支付中间页,再由该页面提交支付宝支付表单:
dd.biz.util.openLink({
url: "https://www.example.com/payment/bridge?orderNo=ORDER_202609090001",
onSuccess() {},
onFail(error) {
location.href =
"https://www.example.com/payment/bridge?orderNo=ORDER_202609090001";
}
});
这种方式可以隔离页面导航栈,但无法保证所有安卓钉钉、支付宝和系统版本都能恢复支付宝结果页。服务端通知和订单查询机制仍然不能省略。
5. 必要时引导用户使用系统浏览器
如果业务必须展示支付宝原生支付结果页,并要求用户点击”完成”后进入 return_url,那么在内置 WebView 中通常无法完全保证这一流程。
可以检测安卓钉钉环境,并提示用户通过系统浏览器打开:
const ua = navigator.userAgent.toLowerCase();
const isAndroid = ua.includes("android");
const isDingTalk = ua.includes("dingtalk");
if (isAndroid && isDingTalk) {
document.getElementById("browser-tip").hidden = false;
}
不建议只靠自定义 URL Scheme 强制拉起某个浏览器。不同厂商系统和浏览器的支持情况不同,这种操作也可能被钉钉的安全策略拦截。相对稳妥的方式是显示”在浏览器中打开”的操作说明,或者让用户复制支付链接。
return_url 页面的正确处理方式
即使页面成功进入 return_url,也不能只根据 URL 参数把订单显示为支付成功。结果页仍需向服务端查询订单状态:
async function loadResult() {
const query = new URLSearchParams(location.search);
const orderNo = query.get("orderNo");
if (!orderNo) {
return;
}
const response = await fetch(
`/api/orders/${encodeURIComponent(orderNo)}/payment-status`,
{
credentials: "include",
cache: "no-store"
}
);
const result = await response.json();
if (result.status === "PAID") {
renderPaidResult(result);
} else {
renderProcessingResult();
}
}
如果异步通知还没有到达,页面应显示”支付结果确认中”并进行短时间轮询,不要直接显示失败,也不要让用户立即重复付款。
注意事项
return_url是同步页面跳转地址,不是支付成功的前端回调函数。- 页面没有跳转,不能说明支付失败。页面成功跳转,也不能单独证明支付成功。
notify_url必须是支付宝服务器可以通过公网访问的 HTTPS 地址,不能依赖登录状态或前端 Cookie。- 支付宝可能重复发送异步通知,因此订单更新必须幂等。
- 用户回到原页面后,应先查询原订单,不要立即创建新订单或再次发起扣款。
- 支付表单应使用支付宝 SDK 生成的完整内容,不要自行删改隐藏字段、签名参数或编码。
- 不要只根据 User-Agent 判断支付结果。环境识别只能用来选择交互方案。
- 如果问题只出现在特定环境组合中,应记录 Android、钉钉、支付宝和 WebView 的版本,并分别测试安装支付宝和未安装支付宝的场景。
- 如果必须追查具体的兼容性原因,需要携带复现时间、订单号、客户端版本和跳转链路日志,向钉钉及支付宝技术支持确认。仅凭页面停留现象,无法认定是哪一方的确定缺陷。
实际落地时,可以把支付宝自动跳转作为体验上的补充,把异步通知确认订单和页面恢复后主动查询设为主流程。即使安卓钉钉没有进入支付宝结果页,用户回到原页面后仍能看到正确的支付结果,整体体验也能与苹果端基本一致。
备注:内容仅供参考。
