Twilio Studio 中 Function Widget 的 PCI Compliant <Gather> 问题
结论
<Gather action="..."> 调用的是语音 webhook。Twilio 会把这个 webhook 返回的内容当作下一段通话指令处理,不会自动回到原来的 Studio Widget,也不会把内容保存为该 Widget 的输出变量。
响应方式取决于 Function 的调用来源:
- Studio 的 Run Function Widget 调用 Function 时,返回
application/json,后续 Widget 可以从parsed中读取 token。 <Gather>的action调用 Function 时,必须返回合法的 Voice TwiML。比如使用<Redirect>将呼叫重新交给 Studio,不能直接返回 token 字符串。- 代码中的
event.callback(null, response.body)应改为真正的callback(...)。event.callback不是 Function 的完成回调。 - 处理信用卡信息时,不要使用
GET,也不要把卡号放进 URL。应优先评估 Twilio<Pay>。如果必须自行处理,需要确认整个链路都符合 PCI DSS,而不只是采集页面或某个 Widget。
为什么 token 会进入语音响应
<Gather> 提交完成后,Twilio 会向 action 指定的地址发起请求。该地址需要返回新的 Voice TwiML,告诉 Twilio 接下来是播放内容、重定向、转接还是结束呼叫。
当前代码直接返回了支付网关的响应正文:
event.callback(null, response.body);
这里有三个问题:
<Gather action>的响应用于控制通话,不是 Studio Function Widget 的数据响应。- 返回的 token 不是合法的 Voice TwiML。
- 代码使用了
event.callback,而不是 handler 参数中的callback。
第二个 Function 还创建了 MessagingResponse。当前处理的是语音呼叫,应使用 Twilio.twiml.VoiceResponse。
方案一:由 Studio 的 Run Function Widget 获取 JSON
如果 Function 由 Studio 的 Run Function Widget 直接调用,可以明确返回 JSON:
const got = require('got');
exports.handler = async function (context, event, callback) {
try {
const gatewayResponse = await got.post(context.PAYMENT_GATEWAY_URL, {
form: {
cardNumber: event.Digits,
expDate: event.ExpDate,
cardType: event.CardType
},
responseType: 'json'
});
const response = new Twilio.Response();
response.appendHeader('Content-Type', 'application/json');
response.setStatusCode(200);
response.setBody({
token: gatewayResponse.body.token
});
callback(null, response);
} catch (error) {
callback(error);
}
};
假设 Studio 中的 Widget 名为 tokenize_card,后续 Widget 通常可以通过下面的表达式读取结果:
{{widgets.tokenize_card.parsed.token}}
前提是这个 Function 确实由 Studio 的 Run Function Widget 调用。把相同的 JSON 返回给 <Gather action>,不会让 JSON 自动出现在原 Studio Widget 的 parsed 数据中。
方案二:Gather 的 action 返回 TwiML,并把呼叫交回 Studio
如果必须在 Function 中执行 <Gather>,处理 digits 的 Function 在取得 token 后应返回 Voice TwiML。一种常见做法是使用 <Redirect>,将呼叫交给 Studio Webhook Trigger。
const got = require('got');
exports.handler = async function (context, event, callback) {
const twiml = new Twilio.twiml.VoiceResponse();
try {
const gatewayResponse = await got.post(context.PAYMENT_GATEWAY_URL, {
form: {
cardNumber: event.Digits,
expDate: event.ExpDate,
cardType: event.CardType
},
responseType: 'json'
});
const token = gatewayResponse.body.token;
const studioUrl = new URL(context.STUDIO_WEBHOOK_URL);
studioUrl.searchParams.set('paymentToken', token);
twiml.redirect(
{ method: 'POST' },
studioUrl.toString()
);
callback(null, twiml);
} catch (error) {
console.error('Tokenization failed:', error.message);
twiml.say('We were unable to process the payment information.');
callback(null, twiml);
}
};
重新进入 Studio 后,可以从 Webhook Trigger 的请求参数中取得 token。具体的 Liquid 路径取决于 Flow 的触发方式和 Studio 版本,通常需要在 Studio 的 Execution/Widget 调试数据中确认,例如:
{{trigger.request.parameters.paymentToken}}
调用 Studio Webhook 通常意味着流程会从 Webhook Trigger 重新进入。不要假定它会恢复已经离开的 Widget 上下文。流程中应单独设计一个”支付完成后继续”的入口。
更安全的传递方式
不建议把支付 token 直接拼进 URL,因为 URL 可能被代理、请求日志或调试记录保存。更稳妥的处理方式是:
- Function 取得 token。
- 将 token 临时保存在服务端,并以
CallSid或随机的一次性 ID 为键。 - 使用
<Redirect>返回 Studio 时,只携带一次性 ID。 - Studio 再通过 Run Function Widget 查询 token。
例如,重定向时只传递引用:
const studioUrl = new URL(context.STUDIO_WEBHOOK_URL);
studioUrl.searchParams.set('paymentRef', paymentRef);
twiml.redirect(
{ method: 'POST' },
studioUrl.toString()
);
后续 Studio Widget 使用 paymentRef 获取实际 token。临时数据应设置较短的有效期,并在读取一次后删除,或及时清理。
也可以合并为一个 Function
不必仅仅因为需要调用支付服务就拆成两个 Function。同一个 Function 可以根据是否收到 event.Digits,分别完成信息采集和 tokenization:
const got = require('got');
exports.handler = async function (context, event, callback) {
const twiml = new Twilio.twiml.VoiceResponse();
if (!event.Digits) {
const gather = twiml.gather({
input: 'dtmf',
finishOnKey: '#',
timeout: 10,
action: context.PAYMENT_FUNCTION_URL,
method: 'POST'
});
gather.say('Enter card information.');
return callback(null, twiml);
}
try {
const gatewayResponse = await got.post(context.PAYMENT_GATEWAY_URL, {
form: {
cardNumber: event.Digits,
expDate: event.ExpDate,
cardType: event.CardType
},
responseType: 'json'
});
const paymentRef = await saveTokenTemporarily(
event.CallSid,
gatewayResponse.body.token
);
const studioUrl = new URL(context.STUDIO_WEBHOOK_URL);
studioUrl.searchParams.set('paymentRef', paymentRef);
twiml.redirect({ method: 'POST' }, studioUrl.toString());
return callback(null, twiml);
} catch (error) {
console.error('Payment request failed:', error.message);
twiml.say('The payment could not be processed.');
return callback(null, twiml);
}
};
saveTokenTemporarily 只是示意代码,需要替换为实际的安全存储实现。
PCI 方面的注意事项
原代码将 event.Digits 拼接到 URL 中,并使用 GET 请求:
const url = 'my payment gateway' + event.Digits;
如果 Digits 是完整卡号,这种写法会明显增加卡号进入访问日志、监控系统和错误记录的风险。至少应改用 POST,将数据放进请求体,并避免记录请求正文:
await got.post(context.PAYMENT_GATEWAY_URL, {
form: {
cardNumber: event.Digits,
expDate: '1220',
cardType: 'VI'
}
});
不过,改用 POST 不等于实现了 PCI 合规。只要 Function 接触完整卡号,Function、日志、依赖、支付网关、数据保存方式和运维流程都可能进入 PCI DSS 范围。业务条件允许时,应优先使用专门采集支付信息并执行 tokenization 的 <Pay> 流程,同时根据 Twilio 当前的 PCI 配置要求,核实账户、产品和支付处理器是否满足条件。
备注:内容仅供参考。
