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。
备注:内容仅供参考。
