Golang 请求手机网站支付接口2.0为何返回完整 HTML 页面
结论
手机网站支付接口 2.0 是“页面跳转类”接口,不是普通的 JSON 查询接口。它返回的通常是一段由浏览器渲染并自动提交的 HTML,其中可能包含 <form>、隐藏字段和 JavaScript。因此,收到完整的 HTML 文档并不一定代表接口异常。
如果程序收到的是表单提交后的收银台页面,常见原因是 Go 的 http.Client 默认跟随了 302、303 等重定向。此时程序读取的是跳转后的响应,而非第一次响应。建议按以下方式处理:
- 不要让服务端 HTTP 客户端代替用户浏览器完成支付跳转。
- 检查是否发生了自动重定向,并根据需要将其关闭。
- 将接口生成的 HTML 表单原样返回给前端浏览器,由浏览器提交并进入支付页面。
- 不要用正则表达式从完整 HTML 中截取
<form>。
为什么会返回完整 HTML
手机网站支付需要把用户带到支付收银台,调用流程与普通 API 不同:
业务服务端生成并签名支付参数
↓
生成 HTML form 或支付跳转页面
↓
将 HTML 返回给用户浏览器
↓
浏览器提交 form
↓
进入支付收银台
在线调试工具展示“接口返回代码”时,可能只突出表单部分,但这不代表正式请求只会返回一个孤立的 <form> 标签。实际响应可能是一份完整的 HTML 文档,例如:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<form name="punchout_form" method="post" action="...">
<input type="hidden" name="..." value="...">
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>
这类响应需要由浏览器解析和执行。Go 的普通 HTTP 客户端不会执行其中的 JavaScript,也不会因为 HTML 中有 <form> 就自动提交表单。
但 Go 会默认跟随 HTTP 重定向。如果支付网关先返回 302 或 303,http.Client 可能继续请求 Location 指向的地址,程序最终拿到的就是收银台完整页面的 HTML。
先确认是否发生了自动重定向
可以暂时关闭重定向,然后检查首次响应的状态码、Location 和 Content-Type:
package main
import (
"fmt"
"io"
"net/http"
"net/url"
)
func requestPayment(gatewayURL string, params url.Values) ([]byte, error) {
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// 保留第一次重定向响应,不继续访问 Location。
return http.ErrUseLastResponse
},
}
resp, err := client.PostForm(gatewayURL, params)
if err != nil {
return nil, err
}
defer resp.Body.Close()
fmt.Println("status:", resp.Status)
fmt.Println("content-type:", resp.Header.Get("Content-Type"))
fmt.Println("location:", resp.Header.Get("Location"))
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return body, nil
}
可以根据响应判断具体情况:
- 如果状态码是
302或303,并且响应中有Location,说明此前很可能发生了自动跳转。 - 如果首次响应就是
200 OK,Content-Type为text/html,正文中还包含表单和自动提交脚本,那么完整 HTML 通常就是页面支付接口的正常返回结果。 - 如果收到的是错误页面、登录页面或网关提示页面,需要继续检查请求地址、签名、字符集、沙箱配置和必填参数。
调试时也可以输出实际响应地址:
fmt.Println("final URL:", resp.Request.URL.String())
如果这个地址不是最初请求的网关地址,说明客户端已经跟随过重定向。
正确处理返回的表单
方式一:使用 SDK 生成页面表单
如果使用的 Go SDK 提供 pageExecute、PageExecute 或“生成 WAP 支付表单”之类的方法,应优先调用该方法。不同 SDK 的具体方法名可能不同,不能直接照搬。
通常的做法是取得 SDK 生成的 HTML,再将其原样返回给浏览器:
func payHandler(w http.ResponseWriter, r *http.Request) {
// formHTML 应由所使用的支付 SDK 根据已签名参数生成。
formHTML, err := buildPaymentForm()
if err != nil {
http.Error(w, "生成支付请求失败", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(formHTML)); err != nil {
// 此处按项目的日志规范记录错误。
return
}
}
浏览器收到响应后,会渲染表单,并按照页面中的脚本自动提交。
方式二:自行生成自动提交表单
如果 SDK 只负责生成参数和签名,也可以按照接口规范自行生成表单。参数值必须经过 HTML 转义,不能直接拼接未经处理的业务数据:
package main
import (
"bytes"
"html/template"
)
var paymentFormTemplate = template.Must(template.New("payment").Parse(`
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>正在跳转到支付页面</title>
</head>
<body>
<form id="paymentForm" method="post" action="{{.Action}}">
{{range .Fields}}
<input type="hidden" name="{{.Name}}" value="{{.Value}}">
{{end}}
</form>
<script>
document.getElementById("paymentForm").submit();
</script>
</body>
</html>
`))
type FormField struct {
Name string
Value string
}
type PaymentForm struct {
Action string
Fields []FormField
}
func renderPaymentForm(action string, fields []FormField) (string, error) {
var output bytes.Buffer
err := paymentFormTemplate.Execute(&output, PaymentForm{
Action: action,
Fields: fields,
})
if err != nil {
return "", err
}
return output.String(), nil
}
签名参数的生成方式、参数排序、编码规则和签名算法,必须以当前接口规范及所用 SDK 的要求为准,不能只参照上面的 HTML 示例实现。
不建议截取 <form> 字符串
不要用正则表达式从完整页面中截取 <form>...</form>。支付页面还可能依赖以下内容:
- 自动提交脚本;
- 指定的字符编码;
- 页面中的其他隐藏字段;
- 特定的表单提交方式;
- 后续版本新增的页面结构。
仅保留 <form> 可能导致页面无法自动提交,也可能遗漏必要信息。只要响应来源可信,并且确认页面由支付接口生成,通常应该原样返回整份 HTML。
注意事项
- 沙箱网关和正式网关不能混用,对应的应用配置、密钥和账号也要保持一致。
return_url只用于用户完成支付后的页面跳转,不能作为判断支付结果的唯一依据。- 最终支付状态应通过异步通知或主动查询接口确认,同时验证通知签名。
- 不要在日志中完整记录签名、用户隐私、订单敏感字段或支付页面的全部隐藏参数。
- 如果通过 AJAX 发起支付请求,浏览器通常不会自动用返回的 HTML 替换当前页面。更稳妥的做法是直接导航到后端支付入口,或者由前端将返回的 HTML 写入新页面,再提交表单。
- 如果关闭重定向后,首次响应仍然是完整 HTML,通常说明网关返回的本来就是可执行的支付页面,并不是 Go 客户端误提交了表单。
备注:内容仅供参考。
