PAYATHON 2026

Android 集成 Paytm All-in-One SDK 时响应体为 null

支付阿杰

结论

response.isSuccessful() 为 true,只说明 HTTP 状态码在 200~299 范围内,不代表响应中一定有 JSON 数据。

日志已经把问题说明得很清楚:

HTTP/1.1 200 OK
Content-Length: 0
Content-Type: text/html; charset=UTF-8
0-byte body

服务器返回了一个响应体为空的 200 OK。Retrofit 没有内容可以交给 Gson 反序列化,而项目中的 NullOnEmptyConverterFactory 会把空响应转换成 null,所以 response.body() 必然是 null。

问题首先出在服务端响应。同时,客户端把 @Multipart 和 Content-Type: application/json 用在同一个请求中,也属于错误配置,可能导致服务器无法解析请求参数。

1. 修正 Multipart 请求头

接口使用了 @Multipart,请求的实际格式就是 multipart/form-data。不要再手动设置:

@Headers({"Content-Type: application/json;charset=UTF-8"})

Multipart 请求头必须带有自动生成的 boundary。Retrofit 和 OkHttp 会生成类似下面的请求头:

Content-Type: multipart/form-data; boundary=...

如果手动把它覆盖成 application/json,就会出现请求体是 Multipart 数据、请求头却声明为 JSON 的情况。服务器可能因此无法解析 MID、`ORDER_ID“ 等参数。

接口应改为:

@Multipart
@POST("paytm")
Call<Token_Res> generateTokenCall(
        @HeaderMap Map<String, String> headers,
        @Part("code") RequestBody code,
        @Part("MID") RequestBody mid,
        @Part("ORDER_ID") RequestBody orderId,
        @Part("AMOUNT") RequestBody amount
);

不要自行添加 Multipart 的 Content-Type,由 Retrofit 设置请求类型和对应的 boundary。

2. 确认服务端确实返回 JSON

客户端修正后,还需要检查 https://jagdishshop.com/api/paytm 对应的服务端控制器或 PHP 脚本。

服务端至少应该返回类似下面的 JSON:

{
  "body": {
    "txnToken": "generated-token"
  },
  "status": "success"
}

响应头应为:

Content-Type: application/json

例如,PHP 端可以明确输出 JSON:

<?php

header('Content-Type: application/json; charset=utf-8');

$result = [
    'body' => [
        'txnToken' => $txnToken
    ],
    'status' => 'success'
];

echo json_encode($result);
exit;

如果 /api/paytm 是框架路由,控制器必须显式返回响应。例如在 Laravel 中:

return response()->json([
    'body' => [
        'txnToken' => $txnToken,
    ],
    'status' => 'success',
]);

需要重点检查这些情况:

  • 控制器调用了 init_transaction.php,却没有 return 或 echo 最终结果。
  • Paytm 请求失败后,异常虽然被捕获,接口却仍返回空的 200 OK。
  • 请求字段没有被正确解析,代码提前结束。
  • 返回值只保存在变量中,没有写入 HTTP 响应。
  • 路由、中间件或身份验证逻辑返回了空响应。
  • PHP 的 curl_exec() 结果没有输出,也没有转换成 JSON。
  • 服务端只把错误写入日志,没有向客户端返回错误状态和错误信息。

请求失败时也不应返回空的 200。服务端可以返回明确的状态码和 JSON:

http_response_code(502);
header('Content-Type: application/json; charset=utf-8');

echo json_encode([
    'status' => 'error',
    'message' => 'Unable to generate Paytm transaction token'
]);
exit;

3. 根据服务端协议选择一种请求格式

客户端采用哪种请求格式,必须与服务端读取参数的方式一致。

如果服务端接收 Multipart

保留 @Multipart 和 @Part,删除手动设置的 Content-Type:

@Multipart
@POST("paytm")
Call<Token_Res> generateTokenCall(
        @HeaderMap Map<String, String> headers,
        @Part("code") RequestBody code,
        @Part("MID") RequestBody mid,
        @Part("ORDER_ID") RequestBody orderId,
        @Part("AMOUNT") RequestBody amount
);

如果服务端接收 JSON

不要使用 @Multipart 和 @Part,改用 @Body:

public class TokenRequest {
    private final String code;
    private final String MID;
    private final String ORDER_ID;
    private final String AMOUNT;

    public TokenRequest(
            String code,
            String MID,
            String ORDER_ID,
            String AMOUNT
    ) {
        this.code = code;
        this.MID = MID;
        this.ORDER_ID = ORDER_ID;
        this.AMOUNT = AMOUNT;
    }
}
@POST("paytm")
Call<Token_Res> generateTokenCall(
        @HeaderMap Map<String, String> headers,
        @Body TokenRequest request
);

调用方式如下:

TokenRequest request = new TokenRequest(
        "12345",
        midString,
        orderIdString,
        txnAmountString
);

Call<Token_Res> call =
        mServiceInterface.generateTokenCall(headers, request);

GsonConverterFactory 会把对象序列化成 JSON,并设置合适的请求头。

如果服务端读取普通表单字段

如果 PHP 通过类似 $_POST['MID'] 的方式读取参数,也可以使用 URL 编码表单:

@FormUrlEncoded
@POST("paytm")
Call<Token_Res> generateTokenCall(
        @HeaderMap Map<String, String> headers,
        @Field("code") String code,
        @Field("MID") String mid,
        @Field("ORDER_ID") String orderId,
        @Field("AMOUNT") String amount
);

同一个请求不能同时声明为 JSON、Multipart 和 URL 编码表单。

4. 理解 Converter 的作用

当前 Converter 的注册顺序是:

.addConverterFactory(new NullOnEmptyConverterFactory())
.addConverterFactory(GsonConverterFactory.create(gson))

如果 NullOnEmptyConverterFactory 的实现是在响应长度为零时返回 null,那么它现在的行为符合预期。它只是避免 Gson 解析空内容,并不能生成响应数据。

暂时删除它可能会暴露类似 “End of input” 的转换异常,但无法解决服务端没有返回内容的问题:

Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("https://jagdishshop.com/api/")
        .addConverterFactory(GsonConverterFactory.create(gson))
        .client(mOkHttpClient)
        .build();

GsonBuilder().setLenient() 也无法处理 0-byte body。宽松解析只会改变非标准 JSON 的解析规则,不会把空响应转换成 Token_Res。

5. 增加有效的响应诊断

处理响应时,应同时记录状态码、响应头和错误体:

@Override
public void onResponse(
        Call<Token_Res> call,
        Response<Token_Res> response
) {
    progressBar.setVisibility(View.GONE);

    Log.e(TAG, "HTTP code: " + response.code());
    Log.e(TAG, "HTTP message: " + response.message());
    Log.e(TAG, "Headers: " + response.headers());

    if (!response.isSuccessful()) {
        try {
            String error = response.errorBody() != null
                    ? response.errorBody().string()
                    : "<empty error body>";
            Log.e(TAG, "Error body: " + error);
        } catch (IOException e) {
            Log.e(TAG, "Failed to read error body", e);
        }
        return;
    }

    Token_Res tokenResponse = response.body();
    if (tokenResponse == null) {
        Log.e(TAG, "Successful HTTP response contained an empty body");
        return;
    }

    if (tokenResponse.getBody() == null) {
        Log.e(TAG, "Response JSON does not contain body");
        return;
    }

    String txnToken = tokenResponse.getBody().getTxnToken();
    if (txnToken == null || txnToken.trim().isEmpty()) {
        Log.e(TAG, "Response does not contain txnToken");
        return;
    }

    startPaytmPayment(txnToken);
}

errorBody() 通常用于非成功响应。当前服务端返回的是 200,所以空内容属于成功响应体,不在 errorBody() 中。

如果需要临时查看服务端的原始返回值,可以把调试接口改成 ResponseBody:

@Multipart
@POST("paytm")
Call<ResponseBody> generateTokenDebug(
        @HeaderMap Map<String, String> headers,
        @Part("code") RequestBody code,
        @Part("MID") RequestBody mid,
        @Part("ORDER_ID") RequestBody orderId,
        @Part("AMOUNT") RequestBody amount
);

然后读取响应:

String rawBody = response.body() != null
        ? response.body().string()
        : "<null>";

Log.e(TAG, "Raw response: " + rawBody);

不过,当前日志已经显示响应长度为零。即使改用 ResponseBody,读取到的仍然是空内容。这个调试方法更适合服务端已经返回内容,但 Gson 映射仍然失败的情况。

6. 检查 Token_Res 与实际 JSON 是否匹配

服务端开始返回 JSON 后,还要确认模型结构与 JSON 完全对应。例如:

{
  "body": {
    "txnToken": "abc123"
  }
}

对应的模型可以写成:

public class Token_Res {
    private Body body;

    public Body getBody() {
        return body;
    }

    public static class Body {
        private String txnToken;

        public String getTxnToken() {
            return txnToken;
        }
    }
}

如果服务端使用了不同的字段名,可以添加 @SerializedName:

@SerializedName("txnToken")
private String txnToken;

模型不匹配通常会导致对象内部的字段为 null,但无法解释日志中的 Content-Length: 0。排查时应先确认服务端返回了非空 JSON,再检查模型映射。

7. 修正字符串判断

下面的代码比较的是对象引用:

if (response.body().getBody().getTxnToken() != "")

Java 不能用 != 判断字符串内容,应改为:

String txnToken = response.body().getBody().getTxnToken();

if (txnToken != null && !txnToken.trim().isEmpty()) {
    startPaytmPayment(txnToken);
}

这个错误不会导致整个 response.body() 为 null,但会让 token 的判断结果不可靠。

最终排查顺序

  1. 删除 @Headers({"Content-Type: application/json;charset=UTF-8"}),确保 Multipart 请求头和请求体一致。
  2. 确认服务端是否成功读取 code、MID、ORDER_ID 和 AMOUNT。
  3. 查看服务端日志,以及调用 Paytm 初始化交易接口得到的原始结果。
  4. 确保 /api/paytm 显式返回非空 JSON,并设置 Content-Type: application/json。
  5. 失败时返回合适的 4xx/5xx 状态码和错误 JSON,不要返回空的 200 OK。
  6. 得到非空响应后,再核对 Token_Res 的字段结构。
  7. 使用 isEmpty() 判断 token,不要使用 != ""。

所以,响应体并不是被 Paytm SDK 清空的,Gson 也没有凭空返回 null。现有日志已经证明服务端实际发送了零字节响应。请求格式冲突可能是服务端返回空响应的原因之一,但服务端仍需修正,确保接口无论成功还是失败,都返回明确且可以解析的响应。

备注:内容仅供参考。