PAYATHON 2026

CodeIgniter 4 更换账号后 FedEx API 返回 400 错误

排障赵师傅

结论

更换 accountNumber 后收到 400 Bad Request,根因通常在 FedEx 的账号、API project 与 OAuth 凭据校验,而非 CodeIgniter 4。

请求 ACCOUNT 费率时,必须使用当前 API project 已获授权的 FedEx account number。修改 JSON 里的账号并不会让现有 access token 自动获得该账号的访问权限。要使用其他账号,需要先在 FedEx Developer Portal 中将账号添加到相应项目,并完成所需的授权或验证。生产环境、测试环境及不同账号层级的关联方式可能不同。如果无法在门户中添加账号,需要联系 FedEx 支持确认账号关系。

file_get_contents() 收到 HTTP 400 时会产生 PHP warning,默认不会抛出 Exception,因此普通的 try...catch 无法捕获。程序需要读取 HTTP 状态码和响应正文,也可以改用 CodeIgniter 4 的 HTTP Client。

为什么更换账号会失败

通过 /oauth/token 获得 access token,只能说明 client_id 和 client_secret 有效。该 token 能否访问某个 FedEx account number,还要取决于账号授权。

下面的配置要求接口返回账号协议价:

$this->requestedShipment->set_rateRequestType(['ACCOUNT']);

ACCOUNT 费率可能包含该账号的合同折扣和协议价格,因此 FedEx 会检查当前 API project 是否有权使用这个账号。

将 rateRequestType 改为 LIST 只会改变请求的费率类型,无法绕过账号授权。API 请求中的 accountNumber 仍须通过 FedEx 的账号校验。

建议按以下顺序排查:

  1. 确认使用的是生产账号还是测试账号,避免混用不同环境的凭据和账号。
  2. 在 FedEx Developer Portal 中检查目标 account number 是否已添加到当前 API project。
  3. 如果账号属于其他组织、客户或结算主体,确认当前项目是否已获得该账号的授权。
  4. 关联新账号后,根据门户提示确认是否需要更新项目凭据。
  5. 保存并检查 FedEx 返回的 JSON 错误正文。正文中的错误代码和消息可以区分账号无效、权限不足及请求字段错误。

当前请求中还应检查的字段

请求数据本身也可能导致 HTTP 400。示例代码中有几个字段需要检查。

邮政编码应使用字符串:

$this->shipper->set_postalCode('12345');
$this->recipient->set_postalCode('45678');

示例中的收件人国家是 US,邮政编码却写成了 456789。美国 ZIP Code 通常为 5 位,也可以使用接口支持的 ZIP+4 格式。用整数保存邮政编码还可能丢失开头的 0。

发货日期不应使用已经过去的固定日期:

$this->requestedShipment->set_shipDateStamp(date('Y-m-d'));

还需确认下面的方法名是否由当前使用的模型类提供,尤其要检查 tranist 的拼写:

$this->rateRequestControlParameters->set_return_tranist(true);

如果模型类最终生成的字段名同样有拼写错误,FedEx 可能会拒绝请求。发送请求前,应记录最终生成的 JSON,并与当前 API schema 对照,不能只检查 PHP 对象。

accountNumber 一般需要生成如下结构:

{
  "accountNumber": {
    "value": "111111111"
  }
}

具体结构以当前 FedEx API 文档及所用 SDK 的序列化结果为准。

使用 CodeIgniter 4 HTTP Client 处理响应

CodeIgniter 4 的 HTTP Client 可以关闭针对 4xx、5xx 状态码的自动异常处理。这样即使 FedEx 返回 400,程序仍能读取响应正文。

use Config\Services;
use Throwable;

private function getRates(
    string $token,
    array $accountNumber,
    array $requestedShipment,
    array $rateRequestControlParameters
): array {
    $client = Services::curlrequest();

    $payload = [
        'accountNumber' => $accountNumber,
        'requestedShipment' => $requestedShipment,
        'rateRequestControlParameters' => $rateRequestControlParameters,
        'carrierCodes' => ['FDXG'],
    ];

    try {
        $response = $client->post(
            'https://apis.fedex.com/rate/v1/rates/quotes',
            [
                'headers' => [
                    'Authorization' => 'Bearer ' . $token,
                    'Content-Type'  => 'application/json',
                    'X-locale'      => 'en_US',
                ],
                'json'        => $payload,
                'http_errors' => false,
                'timeout'     => 20,
            ]
        );

        $statusCode = $response->getStatusCode();
        $body = (string) $response->getBody();
        $decoded = json_decode($body, true);

        if ($statusCode < 200 || $statusCode >= 300) {
            log_message(
                'error',
                'FedEx Rates API returned HTTP {status}: {body}',
                [
                    'status' => $statusCode,
                    'body'   => $body,
                ]
            );

            return [
                'ok' => false,
                'status' => $statusCode,
                'error' => is_array($decoded) ? $decoded : $body,
            ];
        }

        if (!is_array($decoded)) {
            return [
                'ok' => false,
                'status' => $statusCode,
                'error' => 'FedEx returned an invalid JSON response.',
            ];
        }

        return [
            'ok' => true,
            'status' => $statusCode,
            'data' => $decoded,
        ];
    } catch (Throwable $e) {
        log_message('error', 'FedEx request failed: {message}', [
            'message' => $e->getMessage(),
        ]);

        return [
            'ok' => false,
            'status' => null,
            'error' => $e->getMessage(),
        ];
    }
}

调用端再根据结果决定向用户返回什么内容,不要直接 echo 第三方接口的响应:

$result = $this->getRates(
    $accessToken,
    ['value' => '111111111'],
    $requestedShipment,
    $rateRequestControlParameters
);

if (!$result['ok']) {
    return $this->response
        ->setStatusCode(502)
        ->setJSON([
            'message' => '无法取得 FedEx 费率。',
            'fedexStatus' => $result['status'],
            'details' => $result['error'],
        ]);
}

return $this->response->setJSON($result['data']);

生产环境中,FedEx 的完整错误内容可能包含账号、请求标识或其他内部信息,不应直接显示给最终用户。可以将完整响应写入受保护的日志,对外仅返回简化后的信息。

继续使用 file_get_contents() 的处理方式

暂时无法改用 HTTP Client 时,可以设置 ignore_errors。PHP 收到 HTTP 400、401、500 等响应后,仍会继续读取响应正文:

function get_rates($token, $acct, $ship, $rateReqCtrlPara): array
{
    $url = 'https://apis.fedex.com/rate/v1/rates/quotes';

    $data = [
        'accountNumber' => $acct,
        'requestedShipment' => $ship,
        'rateRequestControlParameters' => $rateReqCtrlPara,
        'carrierCodes' => ['FDXG'],
    ];

    try {
        $json = json_encode($data, JSON_THROW_ON_ERROR);
    } catch (JsonException $e) {
        return [
            'ok' => false,
            'status' => null,
            'error' => 'Request JSON encoding failed: ' . $e->getMessage(),
        ];
    }

    $options = [
        'http' => [
            'header' => [
                'Content-Type: application/json',
                "Authorization: Bearer {$token}",
                'X-locale: en_US',
            ],
            'method' => 'POST',
            'content' => $json,
            'ignore_errors' => true,
            'timeout' => 20,
        ],
    ];

    $context = stream_context_create($options);
    $responseBody = @file_get_contents($url, false, $context);

    $statusCode = null;

    foreach ($http_response_header ?? [] as $header) {
        if (preg_match('#^HTTP/\S+\s+(\d{3})#', $header, $matches)) {
            $statusCode = (int) $matches[1];
        }
    }

    if ($responseBody === false) {
        $lastError = error_get_last();

        return [
            'ok' => false,
            'status' => $statusCode,
            'error' => $lastError['message'] ?? 'Network request failed.',
        ];
    }

    $decoded = json_decode($responseBody, true);

    if ($statusCode === null || $statusCode < 200 || $statusCode >= 300) {
        return [
            'ok' => false,
            'status' => $statusCode,
            'error' => is_array($decoded) ? $decoded : $responseBody,
        ];
    }

    return [
        'ok' => true,
        'status' => $statusCode,
        'data' => $decoded,
    ];
}

这里的 @ 只负责阻止 warning 直接输出。程序仍会通过返回值、error_get_last()、HTTP 状态码和响应正文处理实际错误。原代码中的 $resp_data 在请求失败后可能尚未赋值,此时直接 return $resp_data 还会触发新的 warning。

应先取得 FedEx 返回的错误 JSON,再根据其中的错误代码和消息判断是账号授权问题还是请求字段有误。单凭 400 Bad Request 无法确定具体原因。

备注:内容仅供参考。