PAYATHON 2026

Salesforce mobile SDK for iOS 出现 Callout loop not allowed

支付阿杰

结论

问题不在 Authorize.net 拒绝请求,而在于 Salesforce 检测到了“代理请求再次发起 callout”的调用链。

Hybrid Remote App 应使用 OAuth access token 直接调用目标 org 的 /services/apexrest/...。不要让请求先经过 Salesforce AJAX Proxy、Visualforce Proxy、同一 org 中的中转 Apex,也不要再次调用本 org 的 REST 地址。否则调用链会变成:

Salesforce Proxy → Apex REST → Authorize.net

Salesforce 可能把第二次外呼识别为 callout loop,并返回 Callout loop not allowed。

如果支付结果不必同步返回,也可以通过 Queueable Apex 切断当前事务链,再异步调用支付网关。

先确认实际请求地址

forceTKClient.apexrest() 最终请求的地址应类似:

https://your-domain.my.salesforce.com/services/apexrest/v1/orders/

请求还应携带:

Authorization: Bearer <access_token>

如果浏览器或设备的网络日志显示,请求先发往以下类型的地址,就需要移除对应的代理层:

/services/proxy
/apex/某个Visualforce页面
自建的Salesforce中转接口

当前代码将 proxyUrl 设为 null:

forceTKClient = new forcetk.Client(
    creds.clientId,
    creds.loginUrl,
    null,
    cordova.require("salesforce/plugin/oauth").forcetkRefresh
);

这表示不使用 Force.com AJAX Proxy。不过,仍然需要检查实际发出的网络请求。旧版 Mobile SDK、定制版 forcetk.js 或外层封装都有可能重新配置代理。

还应升级到项目允许的受支持 Mobile SDK 版本。示例中的 v28.0 已经非常陈旧,具体升级到哪个版本,应以当前 org 与 Mobile SDK 的兼容范围为准。不要只改版本字符串而不验证客户端库。

直接调用 Apex REST

认证完成后,使用 OAuth 返回的 instanceUrl 和 access token:

var forceTKClient;

cordova.require("salesforce/plugin/oauth").authenticate(
    function (creds) {
        var apiVersion = "vXX.X";

        forceTKClient = new forcetk.Client(
            creds.clientId,
            creds.loginUrl,
            null,
            cordova.require("salesforce/plugin/oauth").forcetkRefresh
        );

        forceTKClient.setSessionToken(
            creds.accessToken,
            apiVersion,
            creds.instanceUrl
        );

        forceTKClient.setRefreshToken(creds.refreshToken);
        forceTKClient.setUserAgentString(creds.userAgent);

        forceTKClient.apexrest(
            "/v1/orders/",
            function (data) {
                console.log("Response Success: " + JSON.stringify(data));
            },
            function (jqXHR, textStatus, errorThrown) {
                console.error(
                    "Response Error:",
                    jqXHR.status,
                    jqXHR.responseText,
                    textStatus,
                    errorThrown
                );
            },
            "POST",
            JSON.stringify(orderRequest)
        );
    },
    function (error) {
        console.error("Authentication failed:", error);
    }
);

将 vXX.X 替换为项目实际支持的 API 版本。不同版本的 forcetk 对 apexrest() 参数顺序可能有不同定义,应以项目当前使用的 forcetk.js 为准。

如果 WebView 的跨域策略阻止直接请求,应改用 Mobile SDK 提供的原生网络桥接能力,或使用 Cordova 原生 HTTP 插件。不要为了绕过跨域限制而改用 Salesforce AJAX Proxy。

Apex 中调用 Authorize.net

建议使用 Named Credential 管理支付网关地址和认证信息。以下代码以 AuthorizeNet 为示例名称:

@RestResource(urlMapping='/v1/orders/*')
global with sharing class REST_ProcessOrder_V1 {
    @HttpPost
    global static ProcessOrderResponse doPost(ProcessOrderRequest orderRequest) {
        validateRequest(orderRequest);

        HttpRequest request = new HttpRequest();
        request.setEndpoint('callout:AuthorizeNet/transaction/path');
        request.setMethod('POST');
        request.setHeader('Content-Type', 'application/json');
        request.setTimeout(120000);
        request.setBody(buildPaymentRequest(orderRequest));

        // Callout 必须发生在本事务的 DML 之前。
        HttpResponse response = new Http().send(request);

        ProcessOrderResponse result =
            parsePaymentResponse(response.getStatusCode(), response.getBody());

        // 支付调用结束后再记录交易结果。
        updateTransactionResult(orderRequest, result);

        return result;
    }

    private static void validateRequest(ProcessOrderRequest request) {
        // 参数校验
    }

    private static String buildPaymentRequest(ProcessOrderRequest request) {
        // 构造支付网关请求
        return '{}';
    }

    private static ProcessOrderResponse parsePaymentResponse(
        Integer statusCode,
        String responseBody
    ) {
        ProcessOrderResponse result = new ProcessOrderResponse();
        result.success = statusCode >= 200 && statusCode < 300;
        result.gatewayResponse = responseBody;
        return result;
    }

    private static void updateTransactionResult(
        ProcessOrderRequest request,
        ProcessOrderResponse result
    ) {
        // 只保存必要的交易状态和网关交易 ID
    }

    global class ProcessOrderRequest {
        public String orderId;
        public Decimal amount;
        public String paymentToken;
    }

    global class ProcessOrderResponse {
        public Boolean success;
        public String gatewayResponse;
    }
}

重点检查 request.setEndpoint() 解析后的最终地址。它必须指向 Authorize.net 的外部地址,不能因为 Named Credential、重定向或配置错误而指回以下域名:

*.salesforce.com
*.force.com
*.my.salesforce.com

同时检查支付网关是否返回 HTTP 301、302、307 或 308,并将请求重定向到 Salesforce 或某个由 Salesforce 托管的页面。

需要异步处理时

如果前置代理无法移除,或者支付流程允许先返回“处理中”,可以把外部 callout 放入 Queueable Apex:

public class PaymentJob implements Queueable, Database.AllowsCallouts {
    private Id orderId;
    private String paymentToken;

    public PaymentJob(Id orderId, String paymentToken) {
        this.orderId = orderId;
        this.paymentToken = paymentToken;
    }

    public void execute(QueueableContext context) {
        HttpRequest request = new HttpRequest();
        request.setEndpoint('callout:AuthorizeNet/transaction/path');
        request.setMethod('POST');
        request.setHeader('Content-Type', 'application/json');
        request.setBody(JSON.serialize(new Map<String, Object>{
            'orderId' => orderId,
            'paymentToken' => paymentToken
        }));

        HttpResponse response = new Http().send(request);

        // 根据响应更新订单和支付状态。
    }
}

REST 方法只负责将任务加入队列:

@HttpPost
global static REST_ProcessOrder_V1.ProcessOrderResponse doPost(
    REST_ProcessOrder_V1.ProcessOrderRequest orderRequest
) {
    Id jobId = System.enqueueJob(
        new PaymentJob(
            (Id) orderRequest.orderId,
            orderRequest.paymentToken
        )
    );

    REST_ProcessOrder_V1.ProcessOrderResponse result =
        new REST_ProcessOrder_V1.ProcessOrderResponse();

    result.success = true;
    result.gatewayResponse = 'QUEUED:' + jobId;
    return result;
}

采用这种方式后,当前 HTTP 响应无法返回最终支付结果。客户端需要轮询订单状态,也可以由后端通过 webhook、Platform Event 等机制更新结果。

注意事项

  • Apex 应先完成 callout,再执行 DML,否则可能出现 You have uncommitted work pending。
  • 不要在 Salesforce、设备日志或调试日志中保存完整信用卡号、CVV、access token 或支付网关密钥。
  • 客户端应优先提交支付服务生成的 token,不要把原始卡数据传给 Apex。
  • 支付请求应设置幂等键,避免客户端重试造成重复扣款。
  • 不能只根据 HTTP 200 判断支付是否成功,还要解析网关返回的业务状态和交易编号。
  • Remote Site Settings 仅在未使用 Named Credential 时配置,无法解决 callout loop。
  • 如果直接调用仍然报错,应记录 Apex 的 Request URL、外呼 endpoint、HTTP 重定向和客户端最终访问地址。这些信息通常足以查出是哪一层把请求重新送回了 Salesforce。

备注:内容仅供参考。