PAYATHON 2026

H5 deeplink 唤起支付宝免密签约报 ILLEGAL_EXTERFACE

支付阿杰

结论

问题大概率不在签约接口本身,而在这个 APP 的 WebView 对 alipays:// 链接的处理。URI 可能被截断、解码、重新编码或拆分参数,导致支付宝最终收到的接口参数不完整。

这条链接包含两层查询参数:

  1. 外层:alipays://platformapi/startapp
  2. 内层:https://mapi.alipay.com/gateway.do?...

内层 URL 必须作为一个整体进行百分号编码。否则,其中的 &、=、|、JSON 以及 Base64 签名字符可能会被当作外层参数解析。支付宝无法获得正确的 service、sign 等字段时,就可能返回 ILLEGAL_EXTERFACE。

同一服务端生成的链接在其他 APP 中能够使用,只能说明业务参数和商户配置基本有效,不能证明故障 APP 交给支付宝的 URI 与原链接完全一致。

首先检查最终唤起的 URI

不要只比较服务端的返回值。需要分别记录:

  • 服务端返回的原始 deeplink;
  • H5 页面实际使用的字符串;
  • WebView 拦截回调收到的 URI;
  • 调用系统跳转 API 前的 URI;
  • 支付宝客户端最终收到的参数,如能通过日志或抓包确认。

检查时重点留意:

  • url= 后的内层 URL 是否完整编码;
  • URI 是否在第一个内层 & 处被拆开;
  • 是否执行了两次 decodeURIComponent;
  • %26 是否提前还原成 &;
  • + 是否变成了空格;
  • Base64 签名中的 +、/、= 是否被破坏;
  • JSON 中的引号、空格和花括号是否发生变化;
  • WebView 是否把 URI 交给了 HTTP 加载流程,而不是系统 Scheme;
  • 字符串中是否确实存在示例里的空格,例如 appId =、url =。如果空格只是排版产生的,可以忽略;如果原始 URI 中也有这些空格,则必须去除或正确编码。

正确构造两层 URL

先生成完整的支付宝网关 URL,再将整个网关 URL 编码为外层 url 参数的值。

const gatewayParams = new URLSearchParams({
  _input_charset: "utf-8",
  access_info: JSON.stringify({ channel: "ALIPAYAPP" }),
  external_sign_no: externalSignNo,
  notify_url: notifyUrl,
  partner: partner,
  product_code: "GENERAL_WITHHOLDING_P",
  return_url: returnUrl,
  scene: "INDUSTRY|B2B_TICKET_AGENT",
  service: "alipay.dut.customer.agreement.page.sign",
  sign: sign,
  sign_type: "RSA"
});

const gatewayUrl =
  `https://mapi.alipay.com/gateway.do?${gatewayParams.toString()}`;

const deepLink =
  `alipays://platformapi/startapp?appId=20000067&url=${encodeURIComponent(gatewayUrl)}`;

生成结果应采用类似下面的结构:

alipays://platformapi/startapp?appId=20000067&url=https%3A%2F%2Fmapi.alipay.com%2Fgateway.do%3F_input_charset%3Dutf-8%26...

不要直接拼成这种形式:

alipays://platformapi/startapp?appId=20000067&url=https://mapi.alipay.com/gateway.do?_input_charset=utf-8&service=...

从 &_input_charset 或后续的 &service 开始,解析器可能会将这些内容识别为 startapp 的外层参数。

签名顺序也需要注意。通常应先按照接口规则对业务参数签名,再对传输时使用的参数值进行 URL 编码。不要对编码后的整段查询字符串重新签名,也不要在不同端重复编码或解码。

Android WebView 的处理方式

Android 端应在 WebView 导航回调中识别 alipays Scheme,并将其交给系统 Intent。不要再调用 WebView.loadUrl()。

override fun shouldOverrideUrlLoading(
    view: WebView?,
    request: WebResourceRequest?
): Boolean {
    val uri = request?.url ?: return false

    if (uri.scheme.equals("alipays", ignoreCase = true)) {
        return try {
            val intent = Intent(Intent.ACTION_VIEW, uri)
            view?.context?.startActivity(intent)
            true
        } catch (e: ActivityNotFoundException) {
            // 支付宝未安装,可在这里展示提示或进入受信任的备用页面
            true
        }
    }

    return false
}

旧版回调也应采用相同的处理原则:

override fun shouldOverrideUrlLoading(
    view: WebView?,
    url: String?
): Boolean {
    if (url.isNullOrBlank()) return false

    val uri = Uri.parse(url)
    if (uri.scheme.equals("alipays", ignoreCase = true)) {
        return try {
            view?.context?.startActivity(Intent(Intent.ACTION_VIEW, uri))
            true
        } catch (e: ActivityNotFoundException) {
            true
        }
    }

    return false
}

拦截后不要先对整个 URI 解码:

// 不建议:可能把内层编码恢复得过早,破坏两层参数边界
val decoded = URLDecoder.decode(url, "UTF-8")

还要检查宿主 APP 中是否有统一路由、安全 SDK、URL 白名单或重定向模块。这些组件可能会重新创建 URI,进而改变参数内容。

iOS WKWebView 的处理方式

iOS 应在导航代理中拦截非 HTTP Scheme,并通过系统 API 打开原始 URL。

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    guard let url = navigationAction.request.url else {
        decisionHandler(.cancel)
        return
    }

    if url.scheme?.lowercased() == "alipays" {
        UIApplication.shared.open(url, options: [:]) { success in
            if !success {
                // 支付宝未安装或系统拒绝打开
            }
        }
        decisionHandler(.cancel)
        return
    }

    decisionHandler(.allow)
}

不要先调用 removingPercentEncoding 再打开:

// 不建议:可能提前解码内层 URL
let decoded = url.absoluteString.removingPercentEncoding

如果 APP 配置了 Scheme 白名单、通用路由器或 URL 安全校验,还要确认 alipays 没有被拒绝或重写。LSApplicationQueriesSchemes 主要影响 canOpenURL 查询。是否需要配置,要看代码有没有调用该方法,不能将它简单归结为所有唤起失败的原因。

H5 侧的建议

页面只需使用已经构造好的 deeplink,不要反复编码整条链接:

function openAlipay(deepLink) {
  window.location.href = deepLink;
}

如果服务端返回的 deeplink 已经完成编码,前端不要再次执行:

// 不建议:可能造成整条 URI 双重编码
window.location.href = encodeURIComponent(deepLink);

如果字符串通过 JS Bridge 传给原生端,还要检查 Bridge 的序列化过程是否改写了 +、%、& 或引号。

仍需排查的接口侧问题

如果已经确认故障 APP 和正常 APP 传给支付宝的 URI 逐字符一致,再检查:

  • 商户是否开通了对应的免密签约产品;
  • service 是否与当前签约产品匹配;
  • partner、签约场景和产品码是否属于同一商户配置;
  • external_sign_no 是否重复或状态异常;
  • 签名算法、字符集和待签名字符串是否一致;
  • notify_url、return_url 是否经过了非预期编码;
  • 接口是否仍允许通过当前入口调用。

alipay.dut.customer.agreement.page.sign 是否适用于当前商户和产品,应以该商户接入时获得的支付宝接口文档及开放平台配置为准,不能只根据错误字符串判断。

最有效的定位方式,是分别取出正常 APP 和故障 APP 在调用系统唤起 API 前的完整 URI,进行逐字符或字节级比较。如果两者不同,问题就在 H5、JS Bridge、WebView 拦截器或 APP 路由层;如果完全相同,再排查商户权限、支付宝客户端环境以及接口参数状态。

备注:内容仅供参考。