PAYATHON 2026

CakePHP 4 无法接收 Tpay payment notification 的 POST 数据

支付小周

结论

最常见的原因是 CakePHP 4 的 CSRF 防护在请求到达 controller 前拒绝了 Tpay 回调。Tpay 服务器不会携带 CakePHP 生成的 CSRF token,所以 action 根本没有执行,$this->request->getData('tr_crc') 也就无法取得数据。

解决办法是只为指定的 Tpay webhook 路由跳过 CSRF 检查,其他页面仍保留 CSRF 防护。同时,还要按照 Tpay 的协议验证通知签名或真实性,不要关闭整个应用的 CSRF。

先确认请求是否真正到达应用

可以在 action 中临时记录请求方法、Content-Type 和解析后的数据:

public function payment()
{
    $this->request->allowMethod(['post']);

    debug($this->request->getMethod());
    debug($this->request->getHeaderLine('Content-Type'));
    debug($this->request->getData());

    return $this->response
        ->withType('text/plain')
        ->withStringBody('TRUE');
}

如果没有任何输出或日志,依次检查以下项目:

  • Tpay 后台配置的通知 URL 是否准确。
  • URL 是否发生 HTTP 到 HTTPS、带 www 到不带 www 等重定向。
  • 路由是否指向正确的 controller 和 action。
  • Web 服务器、反向代理、WAF 或 CDN 是否拦截了请求。
  • CakePHP 日志中是否出现 403、CSRF token 缺失或 token mismatch。
  • Tpay 的通知历史记录了什么 HTTP 状态码和响应内容。

Webhook URL 最好直接返回成功响应,不要经过登录验证、语言跳转或其他重定向。

仅对 Tpay 回调跳过 CSRF 检查

如果项目在 Application.php 中使用 CsrfProtectionMiddleware,可以为中间件设置范围严格的跳过条件。具体写法会因 CakePHP 4 的小版本和项目的中间件结构略有不同,常见形式如下:

use Cake\Http\Middleware\CsrfProtectionMiddleware;
use Psr\Http\Message\ServerRequestInterface;

$csrf = new CsrfProtectionMiddleware([
    'httponly' => true,
]);

$csrf->skipCheckCallback(
    function (ServerRequestInterface $request): bool {
        return $request->getParam('controller') === 'Payments'
            && $request->getParam('action') === 'payment';
    }
);

$middlewareQueue->add($csrf);

这种方式要求路由在 CSRF 中间件之前完成解析,否则 controller 和 action 参数可能还不存在。因此,需要检查 Application::middleware() 中 RoutingMiddleware 与 CSRF 中间件的顺序。

还可以根据项目的路由结构,将 webhook 放入独立的路由范围,只对普通网页路由应用 CSRF 中间件。无论采用哪种方式,都应只豁免一个明确的 webhook endpoint,而不是关闭整个站点的 CSRF。

如果项目使用旧式组件配置或自定义安全处理,还要检查 SecurityComponent、认证中间件和授权中间件是否在 action 执行前拒绝了请求。

正确读取通知数据

Tpay 通常通过表单 POST 发送通知。对于 application/x-www-form-urlencoded 或 multipart/form-data 请求,可以直接读取:

$crc = $this->request->getData('tr_crc');

建议先查看完整数据,确认字段名与实际请求一致:

$data = $this->request->getData();

$crc = $data['tr_crc'] ?? null;

可以按下面的方式处理并保存数据:

public function payment()
{
    $this->request->allowMethod(['post']);
    $this->autoRender = false;

    $data = $this->request->getData();
    $crc = $data['tr_crc'] ?? null;

    if ($crc === null || $crc === '') {
        return $this->response
            ->withStatus(400)
            ->withType('text/plain')
            ->withStringBody('Missing tr_crc');
    }

    // 在这里按照 Tpay 当前接口文档验证通知签名、金额、币种和交易状态。
    // 验证通过后再更新订单。

    $query = $this->Payments->query();
    $query->insert([
        'name',
        'crc',
        'description',
        'created',
    ])->values([
        'name' => 'TR-532',
        'crc' => $crc,
        'description' => 'My description',
        'created' => date('Y-m-d H:i:s'),
    ])->execute();

    return $this->response
        ->withType('text/plain')
        ->withStringBody('TRUE');
}

allowMethod(['post']) 不会让 CakePHP 识别 POST。它只是拒绝其他 HTTP method,便于确认通知是否被错误地以 GET 请求送达,或者在重定向后变成了 GET 请求。

如果 getData() 仍然为空

先检查通知的 Content-Type:

$contentType = $this->request->getHeaderLine('Content-Type');
$data = $this->request->getData();
$rawBody = (string)$this->request->getBody();

不同请求格式的处理方式如下:

  • application/x-www-form-urlencoded:通常可以由 getData() 直接解析。
  • multipart/form-data:通常也可以由 getData() 解析。
  • application/json:需要确认应用已经配置相应的 body parser,然后通过 getData() 读取。
  • Content-Type 缺失或格式异常:getData() 可能为空,此时可以临时记录原始 body 进行排查。

记录原始 body 时,不要长期保存签名、个人信息或其他敏感支付数据。除非已经确认 Tpay 发送的是 URL-encoded 数据,否则不要直接对所有请求使用 parse_str():

$rawBody = (string)$this->request->getBody();
$parsed = [];

parse_str($rawBody, $parsed);

$crc = $parsed['tr_crc'] ?? null;

这种写法只能作为特定 Content-Type 下的兼容或诊断方案,不能代替对请求格式的确认。

支付回调的安全注意事项

跳过 CSRF 不代表可以信任请求。CSRF token 用于浏览器会话,不适合服务器之间的 webhook。支付通知应采用支付平台规定的验证机制。处理回调时至少要做到以下几点:

  • 根据 Tpay 当前接口文档验证签名或校验和。
  • 核对订单标识、金额、币种和支付状态。
  • 防止重复通知造成重复入账,更新操作必须具备幂等性。
  • 使用数据库事务或条件更新,避免并发重复处理。
  • 只有数据验证和业务处理成功后,才返回 Tpay 要求的成功响应。
  • 不要只凭 tr_crc 就将订单标记为已付款。
  • 不要通过关闭全局 CSRF、认证或授权来解决单个 webhook 的接收问题。

排查时不要只盯着 $this->request->getData(),还要确认请求是否在到达 action 前被 CSRF、中间件、路由或服务器配置拦截。只有请求成功进入 action,并且采用标准的表单 POST 格式,$this->request->getData('tr_crc') 才能正常读取对应字段。

备注:内容仅供参考。