Go 中如何在 webhook 支付成功后重定向用户页面
结论
/paymentsuccess 中的 http.Redirect 只能重定向发送 webhook 的支付平台,不能控制用户已经打开的 /live 页面。
因为这里涉及两个相互独立的 HTTP 请求:
- 浏览器请求并显示
/live - 支付平台服务器向
/paymentsuccess发送 webhook
正确的处理流程是:
- webhook 验证支付结果并更新数据库;
/live页面通过轮询、SSE 或 WebSocket 查询支付状态;- 浏览器发现状态变为
successful后,通过 JavaScript 跳转到/thankyou。
为什么 http.Redirect 不起作用
下面这行代码:
http.Redirect(w, r, "/thankyou", http.StatusSeeOther)
会把重定向响应写入当前 r 所对应的连接。
在 webhook handler 中,发起当前请求的是支付平台:
支付平台服务器
POST /paymentsuccess
因此,即使服务端返回 303 See Other,收到响应的也是支付平台服务器,而不是正在浏览 /live 的用户。一个请求的 ResponseWriter 无法操作另一个已经完成的浏览器请求。
webhook 通常只需返回一个简单的成功状态:
w.WriteHeader(http.StatusOK)
推荐实现:浏览器轮询支付状态
1. 为每次支付生成唯一标识
创建支付时,保存一个不可预测的支付标识,例如 paymentID。数据库至少需要记录:
payment_id
user_id
status
amount
created_at
然后将 paymentID 提供给 /live 页面。不要只用 ShopName、金额等可能重复的字段关联支付记录。
2. webhook 更新数据库
func paymentWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var event struct {
PaymentID string `json:"payment_id"`
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
// 必须先验证支付平台提供的 webhook 签名。
if !verifyWebhookSignature(r) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
if event.Status == "success" {
if err := markPaymentSuccessful(r.Context(), event.PaymentID); err != nil {
http.Error(w, "failed to update payment", http.StatusInternalServerError)
return
}
}
// webhook 只需确认事件已经成功处理。
w.WriteHeader(http.StatusOK)
}
具体的字段名和签名验证方式取决于支付平台的 webhook 协议。
3. 提供支付状态查询接口
func paymentStatus(w http.ResponseWriter, r *http.Request) {
if !alreadyLoggedIn(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
paymentID := r.URL.Query().Get("payment_id")
if paymentID == "" {
http.Error(w, "missing payment_id", http.StatusBadRequest)
return
}
user := getUser(w, r)
// 查询时必须同时校验 paymentID 是否属于当前用户。
status, err := queryPaymentStatus(r.Context(), user.ID, paymentID)
if err != nil {
http.Error(w, "failed to query payment", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(struct {
Status string `json:"status"`
}{
Status: status,
}); err != nil {
return
}
}
注册对应的路由:
http.HandleFunc("/paymentsuccess", paymentWebhook)
http.HandleFunc("/payment-status", paymentStatus)
4. 在 /live 页面中轮询
假设模板已经安全输出当前支付的 paymentID:
<script>
const paymentID = {{ printf "%q" .PaymentID }};
const timer = setInterval(async () => {
try {
const response = await fetch(
`/payment-status?payment_id=${encodeURIComponent(paymentID)}`,
{
method: "GET",
credentials: "same-origin",
cache: "no-store"
}
);
if (!response.ok) {
return;
}
const result = await response.json();
if (result.status === "successful") {
clearInterval(timer);
window.location.assign(
`/thankyou?payment_id=${encodeURIComponent(paymentID)}`
);
}
if (result.status === "failed" || result.status === "cancelled") {
clearInterval(timer);
// 在页面上显示失败或取消提示。
}
} catch (error) {
console.error("Unable to check payment status:", error);
}
}, 2000);
</script>
实际执行跳转的是浏览器中的代码:
window.location.assign("/thankyou");
服务端的状态接口只返回支付状态。
不要在后台 goroutine 中使用 ResponseWriter
下面的做法行不通:
go startPolling1(...)
即使将 http.ResponseWriter 传给 startPolling1,也不应在 goroutine 中等待支付成功后再执行:
http.Redirect(res, req, "/thankyou", http.StatusSeeOther)
livemode 执行下面的代码后,HTTP 响应通常已经开始,甚至已经结束:
tpl.ExecuteTemplate(res, "golive.html", data1)
此时调用 http.Redirect 会再次尝试写入响应头,可能产生以下错误:
http: superfluous response.WriteHeader call
handler 返回后继续使用它的 ResponseWriter 也不安全。服务端 goroutine 可以发现数据库状态发生变化,但无法自动通知已经加载完毕的浏览器页面。
采用轮询时,应由浏览器请求 /payment-status,不必为每位用户启动一个长期运行的 Go goroutine。
可选方案
如果需要在支付成功后立即通知页面,可以考虑:
- Server-Sent Events(SSE):适合由服务器单向通知浏览器;
- WebSocket:适合已经存在双向实时通信需求的场景;
- 支付平台提供的
return_url或success_url:支付流程结束后,直接将浏览器带回站点。
即使使用 return_url,仍要以通过签名验证的 webhook 或服务端主动查询到的结果为准。用户访问了 /thankyou,并不代表支付一定成功。
注意事项
- 验证 webhook 签名,不能直接相信 JSON 中的
status。 - webhook 可能重复发送,因此数据库更新应具备幂等性。
- webhook 应尽快返回
2xx。耗时业务可以交给可靠的任务队列处理。 - 查询支付状态时,应验证当前用户是否拥有该
paymentID,防止越权查询。 /thankyou页面也要从服务端确认支付状态,不要相信 URL 参数中的金额或状态。- 前端轮询需要设置超时、失败状态和合理的查询间隔,避免页面无限请求。
备注:内容仅供参考。
