能否先向 client 发送 partial reply,再返回完整 response?
结论
不能在一次 HTTP 请求中先用 render "Please wait..." 返回内容,然后再 redirect 或返回另一份完整响应。
每次请求只能对应一个最终响应。render 写入响应后,如果再调用 redirect,就会尝试修改已经提交的状态码和响应头,可能引发异常、导致重定向失效,或者让响应内容残缺。
流式输出也无法可靠避开 10 秒超时。只要连接没有结束,支付网关通常就会继续等待完整响应。
正确的做法是让回调接口只处理验签、去重和持久化等必要操作,并在 10 秒内返回支付网关规定的确认内容。其余耗时操作交给后台任务。
推荐处理流程
- 验证支付网关的签名、商户号等必要参数。
- 使用网关提供的 transaction ID 做幂等检查。
- 将原始回调和交易状态可靠地保存到数据库。
- 提交后台任务。
- 立即返回网关协议规定的 ACK 或其他响应。
- 由后台任务生成收据、发送邮件并更新其他系统。
- 用户通过单独的收据页面查询最终结果。
回调接口的首要任务是确认已经收到交易通知。发送邮件、生成 PDF、调用远程 API 等耗时操作,不应在回调请求中同步执行。
Grails 2 示例
以下示例假设项目已经配置了 Spring taskExecutor。具体配置取决于项目使用的任务插件或 Spring 配置。后台任务不能继续依赖当前的 request、response 或 Hibernate Session。
class PaymentController {
def paymentService
def taskExecutor
def receipt() {
if (!paymentService.validSignature(params)) {
render status: 400, text: 'INVALID'
return
}
def transaction = paymentService.recordCallback(params)
if (transaction.needsProcessing) {
Long transactionId = transaction.id
taskExecutor.execute {
paymentService.processTransaction(transactionId)
}
}
// 必须返回支付网关协议规定的内容,
// 这里的 OK 仅为示例。
render status: 200, contentType: 'text/plain', text: 'OK'
}
def receiptFinal(Long id) {
def receipt = paymentService.findReceipt(id)
if (!receipt) {
render status: 404, text: 'Receipt not found'
return
}
render view: 'receipt', model: [receipt: receipt]
}
}
后台服务应通过 ID 重新加载数据,并在自己的事务中完成处理:
class PaymentService {
static transactional = true
def processTransaction(Long transactionId) {
def transaction = PaymentTransaction.get(transactionId)
if (!transaction || transaction.processed) {
return
}
// 执行耗时处理:
// 生成收据、调用内部系统、发送邮件等。
transaction.processed = true
transaction.processedAt = new Date()
transaction.save(failOnError: true)
}
}
后台任务只能在数据库事务成功提交后读取这笔交易。更稳妥的方式是把任务写入持久化队列,再由独立 worker 消费,以免应用重启或进程崩溃时丢失任务。
为什么原来的代码不可行
def receipt() {
render "Please wait..."
redirect(controller: 'payment', action: 'receiptFinal')
}
这段代码有两个问题:
render和redirect都在构造同一个 HTTP 响应,不能把它们当作前后两次响应。redirect只是向支付网关返回3xx状态码和Location响应头。是否继续请求receiptFinal,取决于支付网关。它不是服务器内部的方法跳转。
即使网关愿意跟随重定向,第二次请求仍可能超时,而且许多支付回调协议根本不接受重定向。
如果只是想在服务器内部复用代码,应把相关逻辑提取到 service,而不是通过 redirect 调用另一个 action。
流式响应通常也解决不了问题
Servlet 可以分段写入响应并调用 flush():
response.writer.write('Please wait...')
response.writer.flush()
// heavy processing
response.writer.write('Complete')
response.writer.flush()
但它不适合用来解决支付回调超时,原因包括:
- 网关可能以响应是否结束来判断超时,而不是以是否收到首字节来判断。
- Web 服务器、反向代理或负载均衡器可能缓存较小的响应块。
- 网关可能要求响应体严格符合固定格式。
- 网络断开后,服务器无法确认网关是否收到了最终内容。
- 请求线程仍会被长时间占用,降低系统容量。
除非支付网关文档明确说明超时从首字节开始计算,并且支持 chunked/streaming response,否则不应依赖这种方式。
重要注意事项
- 返回内容必须符合支付网关协议,不能自行用
"Please wait..."替代规定的 ACK。 - 使用 transaction ID、订单号或网关事件 ID 实现幂等,防止网关重试造成重复扣款、重复入账或重复发送收据。
- 返回 ACK 前,必须完成验签并持久化原始回调。否则,系统可能确认了一条尚未可靠保存的通知。
- 后台任务应支持失败重试,并记录失败原因。
- 如果网关要求在同一次请求中返回完整收据,异步处理就无法满足协议。此时只能精简同步处理流程、优化慢查询和外部调用,或者调整网关配置与集成方式。
- 不要在后台线程中直接使用原请求的
params、request、response或延迟加载对象。应复制必要数据,或者只传递持久化记录的 ID。
备注:内容仅供参考。
