PAYATHON 2026

Go 中如何在 webhook 支付成功后重定向用户页面

支付阿杰

结论

/paymentsuccess 中的 http.Redirect 只能重定向发送 webhook 的支付平台,不能控制用户已经打开的 /live 页面。

因为这里涉及两个相互独立的 HTTP 请求:

  • 浏览器请求并显示 /live
  • 支付平台服务器向 /paymentsuccess 发送 webhook

正确的处理流程是:

  1. webhook 验证支付结果并更新数据库;
  2. /live 页面通过轮询、SSE 或 WebSocket 查询支付状态;
  3. 浏览器发现状态变为 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 参数中的金额或状态。
  • 前端轮询需要设置超时、失败状态和合理的查询间隔,避免页面无限请求。

备注:内容仅供参考。