PAYATHON 2026

支付宝 notify_verify 消息验证频繁报错及参数不一致

支付小周

结论

notify_id 是支付宝生成的“不透明通知标识”。不能根据它看起来是否“加密”来判断通知是否有效,也不能用成功和失败回调的参数数量作为验证依据。

此类问题常见的原因包括:

  • 混用同步跳转参数和异步通知参数;
  • 对回调参数重复执行 URL 解码;
  • 拼接 notify_verify 请求时,没有对 notify_id 做 URL 编码;
  • 使用了经过转义、过滤或重新序列化的 notify_id;
  • 合并 GET 和 POST 参数,导致参数被覆盖;
  • 只调用 notify_verify,没有同时验证通知签名和业务字段。

处理时,应从支付宝异步通知的原始请求中读取参数,每个参数只做一次标准表单解码。签名验证使用完整的通知参数,notify_verify 使用同一次解析得到的 notify_id。发送验证请求时,再按照查询参数规则对参数值进行 URL 编码。

为什么参数数量会不一致

支付宝的同步跳转、异步通知以及不同交易状态的通知,本来就可能包含不同的参数。例如:

  • return_url 通常用于浏览器同步跳转;
  • notify_url 用于服务器异步通知;
  • 不同产品或交易状态可能增加或省略部分可选字段;
  • 有些字段只会出现在特定支付方式或业务场景中。

因此,不要通过参数个数判断通知是否合法,也不要要求每次回调都与某个固定参数模板完全一致。应检查协议要求的必填字段,并验证签名、通知来源和业务数据。

如果同一个接收地址同时处理同步跳转和异步通知,应根据请求入口或请求方法明确区分,避免直接合并两类参数。

notify_id 为什么看起来不一样

HTTP 表单和 URL 查询字符串会对特殊字符进行百分号编码。例如:

原始值:abc+def/ghi=
传输值:abc%2Bdef%2Fghi%3D

其中 %2B、%2F 和 %3D 都是 URL 编码,不是加密。

Web 容器或框架通常已经自动解码一次。如果业务代码又调用 URLDecoder.decode()、urldecode() 等方法,就会重复解码。例如,原值中的 + 可能在第二次解码时被错误地转换为空格,导致 notify_verify 返回失败。

如果直接把含有 +、&、= 等字符的 notify_id 拼接到验证地址中,也可能破坏查询字符串。应使用 HTTP 客户端提供的查询参数功能,或者单独对参数值进行 URL 编码。

推荐的验证流程

1. 只从异步通知请求中读取参数

如果支付宝通过 POST 发送异步通知,应优先读取 POST 表单参数,不要无条件合并 GET、POST、Cookie 等数据源。

所有通知参数都应保留,供后续签名验证使用。

2. 不要重复解码参数

框架解析出的参数通常可以直接使用:

request.getParameter("notify_id")
$_POST['notify_id']

除非拿到的是未经解析的原始请求体,否则不要再次调用 URL 解码函数。

3. 验证支付宝通知签名

签名验证和 notify_verify 是两个不同的环节。除了验证 notify_id,还必须按照当前使用的支付宝接口协议验证 sign。

一般需要:

  • 排除 sign 和 sign_type;
  • 排除协议规定不参与签名的空值参数;
  • 按参数名排序;
  • 使用支付宝公钥,或按照旧接口对应的签名方式验证 sign;
  • 遵循当前接入产品规定的字符集和签名规则。

不同支付宝产品、签名算法和历史接口可能采用不同规则。应以商户实际接入的协议为准,不能混用 RSA、RSA2、MD5 等实现。

4. 使用 notify_id 调用验证接口

使用旧版 mapi 接口时,请求通常包含:

service=notify_verify
partner=商户合作伙伴身份 ID
notify_id=通知中的 notify_id

不要手工拼接未经编码的 notify_id。

5. 同时检查业务字段

即使通知来源和签名都验证通过,仍需核对:

  • out_trade_no 是否属于本系统;
  • 商户身份字段是否与当前商户一致;
  • 收款方账号或收款方标识是否正确;
  • 订单金额和币种是否与本地订单一致;
  • trade_status 是否属于允许处理的成功状态;
  • 订单是否已经处理,避免重复入账。

支付宝可能重复发送异步通知,因此回调处理必须具备幂等性。

Java 示例

下面的示例主要展示参数读取和 notify_verify 请求的构造。签名验证部分应调用当前支付宝 SDK,或使用项目中与实际接口匹配的实现。

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

public class AlipayNotifyHandler {

    private static final String VERIFY_URL =
            "https://mapi.alipay.com/gateway.do";

    public static boolean verifyNotifyId(String partner, String notifyId)
            throws Exception {

        if (notifyId == null || notifyId.isBlank()) {
            return false;
        }

        String query =
                "service=" + encode("notify_verify") +
                "&partner=" + encode(partner) +
                "&notify_id=" + encode(notifyId);

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(VERIFY_URL + "?" + query))
                .GET()
                .build();

        HttpResponse<String> response = HttpClient.newHttpClient()
                .send(request, HttpResponse.BodyHandlers.ofString(
                        StandardCharsets.UTF_8));

        return response.statusCode() == 200
                && "true".equalsIgnoreCase(response.body().trim());
    }

    private static String encode(String value) {
        return URLEncoder.encode(value, StandardCharsets.UTF_8);
    }

    public static boolean handle(
            Map<String, String[]> requestParameterMap,
            String partner) throws Exception {

        Map<String, String> params = new HashMap<>();

        for (Map.Entry<String, String[]> entry
                : requestParameterMap.entrySet()) {
            String[] values = entry.getValue();
            if (values != null && values.length > 0) {
                params.put(entry.getKey(), values[0]);
            }
        }

        String notifyId = params.get("notify_id");
        String sign = params.get("sign");

        if (notifyId == null || sign == null) {
            return false;
        }

        // 此处应使用与实际支付宝接口匹配的 SDK 或签名算法。
        boolean signatureValid = verifySignature(params);
        if (!signatureValid) {
            return false;
        }

        boolean notifyIdValid = verifyNotifyId(partner, notifyId);
        if (!notifyIdValid) {
            return false;
        }

        return verifyBusinessFields(params);
    }

    private static boolean verifySignature(Map<String, String> params) {
        throw new UnsupportedOperationException(
                "请接入与当前支付宝产品和签名类型匹配的验签实现");
    }

    private static boolean verifyBusinessFields(Map<String, String> params) {
        throw new UnsupportedOperationException(
                "请核对订单号、金额、商户身份及交易状态");
    }
}

requestParameterMap 已经由 Web 容器完成表单解码,因此不能再对 notify_id 调用 URLDecoder.decode()。只有在构造新的查询字符串时,才需要对参数值调用 URLEncoder.encode()。

PHP 示例

<?php

function verifyNotifyId(string $partner, string $notifyId): bool
{
    if ($notifyId === '') {
        return false;
    }

    $query = http_build_query(
        [
            'service'   => 'notify_verify',
            'partner'   => $partner,
            'notify_id' => $notifyId,
        ],
        '',
        '&',
        PHP_QUERY_RFC3986
    );

    $url = 'https://mapi.alipay.com/gateway.do?' . $query;

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);

    $body = curl_exec($ch);
    $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($body === false || $statusCode !== 200) {
        return false;
    }

    return strtolower(trim($body)) === 'true';
}

$partner = '实际商户合作伙伴身份ID';

// PHP 已经解析 application/x-www-form-urlencoded,
// 不要再次对 $_POST['notify_id'] 调用 urldecode()。
$notifyId = isset($_POST['notify_id'])
    ? (string) $_POST['notify_id']
    : '';

if ($notifyId === '') {
    http_response_code(400);
    exit('fail');
}

// 还必须先按实际支付宝接口协议验证 $_POST['sign']。
$signatureValid = verifyAlipaySignature($_POST);

if (!$signatureValid || !verifyNotifyId($partner, $notifyId)) {
    http_response_code(400);
    exit('fail');
}

// 核对 out_trade_no、金额、商户身份、trade_status,
// 并使用数据库唯一约束或状态机保证幂等。
$businessValid = verifyBusinessFields($_POST);

if (!$businessValid) {
    http_response_code(400);
    exit('fail');
}

processOrderIdempotently($_POST);

exit('success');

排查时应记录什么

排查问题时,可以记录以下信息,以判断问题出在传输、解析还是业务处理:

  • 回调请求方法和 Content-Type;
  • 参数名称列表;
  • 解析后的 notify_id 长度;
  • 调用验证接口时的 HTTP 状态码;
  • 验证接口返回值;
  • 签名类型和验签结果;
  • 订单号、交易状态以及幂等处理结果。

不要在日志中长期以明文保存完整签名、密钥、身份证号、手机号等敏感信息。排查 notify_id 编码问题时,可以记录脱敏后的值和十六进制字节,用来检查空格、额外引号、换行或字符集转换等异常。

注意事项

  • 不要根据回调参数数量判断通知真假。
  • 不要把 notify_id 的不同表现形式理解为“加密版”和“未加密版”。
  • 不要再次 URL 解码框架已经解析的参数。
  • 不要把未经编码的 notify_id 直接拼接到 URL 中。
  • 不要用重新编码的参数覆盖验签所需的原始语义值。
  • 不能只验证 notify_id,还要验签并核对业务字段。
  • 只有全部验证和业务处理成功后,才能向支付宝返回协议要求的成功响应。
  • 回调处理必须具备幂等性,不能因支付宝重试而重复发货或重复入账。
  • mapi.alipay.com 和 notify_verify 属于旧式接口。如果商户已经迁移到新版支付宝开放平台接口,应使用当前产品对应的官方 SDK 和异步通知验签方式,不再沿用旧接口流程。

备注:内容仅供参考。