PAYATHON 2026

Android 集成 Instamojo SDK 提示 Transaction ID not unique

支付阿杰

结论

问题不在 PHP 的唯一 ID 生成函数,而是 Android 端没有使用后端返回的 transaction_id。

后端已经返回了交易 ID:

$response['transaction_id'] = $this->getGUIDnoHash();

但 Android 收到响应后只读取 access_token,接着又调用本地的 createTransactionID() 生成了一个 ID:

accessToken = response.getString("access_token");
transactionID = createTransactionID();

所以,最终传给 Order 的并不是 PHP 生成的 ID。如果 createTransactionID() 返回固定值、生成了重复值,或者应用重试时沿用了旧值,Instamojo 就会返回:

Transaction ID is not unique

Android 应直接读取后端返回的 transaction_id。每次创建新订单时,都应由服务器生成并保存交易 ID,同时校验其唯一性。

修正 Android 端的参数传递

将:

accessToken = response.getString("access_token");

transactionID = createTransactionID();
Log.e("Transaction Id", transactionID);

createOrder(accessToken, transactionID);

改为:

accessToken = response.getString("access_token");
transactionID = response.getString("transaction_id");

Log.d("Transaction Id", transactionID);
createOrder(accessToken, transactionID);

完整的关键逻辑可以这样写:

@Override
public void onResponse(JSONObject response) {
    try {
        if (response.has("error")) {
            showToast(response.getString("error"));
            return;
        }

        String accessToken = response.getString("access_token");
        String transactionID = response.getString("transaction_id");

        if (transactionID.trim().isEmpty()) {
            showToast("Transaction ID is empty");
            return;
        }

        Log.d("Transaction Id", transactionID);
        createOrder(accessToken, transactionID);
    } catch (JSONException e) {
        Log.e("Instamojo", "Invalid server response", e);
        showToast("Invalid server response");
    }
}

收到服务器响应后,不要再次调用客户端的 createTransactionID()。同一笔订单从创建到支付回调都应使用同一个服务端订单号。

更可靠的服务端 ID 生成方式

当前的 getGUIDnoHash() 处理得比较复杂:

$charid = md5(uniqid(rand(), true));
$c = unpack("C*", $charid);
$c = implode("", $c);
return substr($c, 0, 20);

这段代码先生成 MD5 十六进制字符串,再将其转换为字符编码并截断。这样做不一定会立即产生碰撞,但没有必要,而且仅靠随机生成也无法从业务层面保证绝对唯一。

如果字段允许使用字母和数字,可以改用密码学安全随机数:

private function createTransactionId(): string
{
    return 'ORD' . strtoupper(bin2hex(random_bytes(8)));
}

生成结果类似:

ORD8F3A94D96C217BE2

如果 transaction ID 只能包含数字,可以组合时间和安全随机数。不过在使用前,需要先确认 SDK/API 对长度和字符集的限制:

private function createNumericTransactionId(): string
{
    return date('ymdHis') . random_int(100000, 999999);
}

更稳妥的做法是根据数据库订单主键生成交易号:

private function buildTransactionId(int $orderId): string
{
    return 'ORD' . date('Ymd') . str_pad(
        (string) $orderId,
        8,
        '0',
        STR_PAD_LEFT
    );
}

还应为数据库中的 transaction_id 字段添加唯一索引。即使并发请求意外生成了相同的值,数据库也能在调用支付接口前将其拦截。

正确的订单创建流程

订单创建流程可以按以下步骤处理:

  1. Android 请求自己的后端创建业务订单。
  2. 后端生成唯一的 transaction_id。
  3. 后端先将订单号写入数据库,并用唯一索引防止重复。
  4. 后端获取 Instamojo 凭证,或直接调用相应的订单接口。
  5. 后端将本次订单需要的信息返回给 Android。
  6. Android 直接使用返回的 transaction_id,不要重新生成或修改。
  7. 发生网络重试时,要明确区分是重试同一订单,还是创建新订单。

例如,后端返回:

{
  "access_token": "...",
  "transaction_id": "ORD8F3A94D96C217BE2"
}

Android 必须使用 JSON 中的同一个值:

String transactionID = response.getString("transaction_id");

Order order = new Order(
        accessToken,
        transactionID,
        name,
        email,
        phone,
        amount,
        description
);

还要防止用户重复点击支付按钮,导致多个 createOrder() 并发执行。可以在请求开始后暂时禁用按钮,等回调结束再恢复:

payButton.setEnabled(false);

Request request = new Request(order, new OrderRequestCallBack() {
    @Override
    public void onFinish(final Order order, final Exception error) {
        runOnUiThread(() -> {
            payButton.setEnabled(true);
            dialog.dismiss();

            if (error != null) {
                showToast(error.getMessage());
                return;
            }

            startPreCreatedUI(order);
        });
    }
});

request.execute();

如何确认实际发送的 ID

排查时,分别记录后端返回的值和 Android 最终传入 Order 的值:

String serverTransactionId = response.getString("transaction_id");
Log.d("Instamojo", "Server transaction ID: " + serverTransactionId);

createOrder(accessToken, serverTransactionId);

然后在 createOrder() 入口再次记录:

private void createOrder(String accessToken, String transactionID) {
    Log.d("Instamojo", "Order transaction ID: " + transactionID);

    // ...
}

两处日志中的值必须完全一致。如果值一致,但服务端仍然提示重复,常见原因包括:

  • 这个 ID 之前已经成功提交,或者请求已部分成功;
  • 网络重试时再次提交了同一个 ID;
  • 用户重复点击,触发了多个请求;
  • 服务端没有为新订单生成新的 ID;
  • 测试环境中已经存在相同的交易号。

遇到 “重复” 错误后,不要直接不断生成新 ID 并重试。应先查询本地订单状态,确认之前的请求是否已经创建成功,以免同一笔业务订单被重复支付。

安全注意事项

示例中公开了 client_id 和 client_secret。如果它们是真实凭证,应立即在 Instamojo 后台撤销或轮换,不能继续使用。

还需要处理以下问题:

  • 不要将 client_secret 放进 Android 应用,也不要把它返回给客户端。
  • 最好由后端获取令牌并创建支付订单,客户端只接收完成支付所需的数据。
  • 不要关闭 TLS 证书验证:
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);

生产环境应启用证书验证:

curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
  • 后端接口和 webhook 应使用有效的 HTTPS 地址。
  • 获取 access token 失败时,应先检查响应内容和 HTTP 状态码,不能直接读取 $jsonOutput->access_token。

当前代码最直接的修正,是将 Android 端的:

transactionID = createTransactionID();

替换为:

transactionID = response.getString("transaction_id");

之后再通过数据库唯一索引、服务端订单记录和防重复提交机制,保证 transaction ID 在整个支付流程中始终唯一,并且前后一致。

备注:内容仅供参考。