PAYATHON 2026

能否先向 client 发送 partial reply,再返回完整 response?

支付老李

结论

不能在一次 HTTP 请求中先用 render "Please wait..." 返回内容,然后再 redirect 或返回另一份完整响应。

每次请求只能对应一个最终响应。render 写入响应后,如果再调用 redirect,就会尝试修改已经提交的状态码和响应头,可能引发异常、导致重定向失效,或者让响应内容残缺。

流式输出也无法可靠避开 10 秒超时。只要连接没有结束,支付网关通常就会继续等待完整响应。

正确的做法是让回调接口只处理验签、去重和持久化等必要操作,并在 10 秒内返回支付网关规定的确认内容。其余耗时操作交给后台任务。

推荐处理流程

  1. 验证支付网关的签名、商户号等必要参数。
  2. 使用网关提供的 transaction ID 做幂等检查。
  3. 将原始回调和交易状态可靠地保存到数据库。
  4. 提交后台任务。
  5. 立即返回网关协议规定的 ACK 或其他响应。
  6. 由后台任务生成收据、发送邮件并更新其他系统。
  7. 用户通过单独的收据页面查询最终结果。

回调接口的首要任务是确认已经收到交易通知。发送邮件、生成 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。

备注:内容仅供参考。