Files
wangjia dbdadd1b10 feat(order): 下单支持显式 client_type 端型(契约 v1.1.0)
resolveIsMobile:mobile/pc 显式优先,空值回退 isMobileUA(请求 UA),非法 400;
修复业务方服务端下单被 Go client UA 误判为 PC、手机用户拿不到 wap 拉起页

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-04 10:28:17 +08:00

207 lines
7.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/service"
"github.com/wangjia/pay/internal/util"
)
// maxOrderBodyBytes 下单请求体上限(防超大 body 内存 DoS)。
const maxOrderBodyBytes = 64 << 10 // 64KB
// isMobileUA 粗略判断是否手机浏览器(决定支付宝走 wap.pay 拉起 App 还是 page.pay 扫码)。
func isMobileUA(ua string) bool {
ua = strings.ToLower(ua)
for _, kw := range []string{"android", "iphone", "ipod", "mobile", "harmony", "windows phone"} {
if strings.Contains(ua, kw) {
return true
}
}
return false
}
// resolveIsMobile 决定收银台端型:业务方显式 client_type 优先(服务端间调用时
// 下单请求 UA 是业务后端的,不代表终端用户,如 jiu 后端 Go client 恒为 PC UA);
// 缺省回退按下单请求 UA 判断(兼容旧接入方 / 浏览器直连 /paytest)。契约 v1.1.0。
func resolveIsMobile(clientType, ua string) (bool, error) {
switch clientType {
case "mobile":
return true, nil
case "pc":
return false, nil
case "":
return isMobileUA(ua), nil
default:
return false, errors.New("client_type 仅支持 pc / mobile")
}
}
type OrderHandler struct {
svc *service.OrderService
}
func NewOrderHandler(svc *service.OrderService) *OrderHandler {
return &OrderHandler{svc: svc}
}
type createOrderRequest struct {
ProductID uint64 `json:"product_id"`
BizSystem string `json:"biz_system,omitempty"` // 业务对接来源(如 jiu);带则需签名鉴权
BizRef string `json:"biz_ref,omitempty"` // 业务引用(jiu purchase_id
ReturnURL string `json:"return_url,omitempty"` // 自定义付款后跳回地址
ClientType string `json:"client_type,omitempty"` // 显式端类型 pc/mobilev1.1.0);缺省按下单请求 UA 兜底
}
// verifyBizSign 校验业务方下单请求的 HMAC 签名(防外部乱下单/伪造业务单)。
func verifyBizSign(c *gin.Context, system string, rawBody []byte) error {
cfg, ok := config.C.BizByName(system)
if !ok {
return fmt.Errorf("未知或未配置的业务系统: %s", system)
}
if c.GetHeader("X-Pay-System") != system {
return errors.New("X-Pay-System 与 biz_system 不一致")
}
ts := c.GetHeader("X-Pay-Timestamp")
nonce := c.GetHeader("X-Pay-Nonce")
sign := c.GetHeader("X-Pay-Sign")
if ts == "" || nonce == "" || sign == "" {
return errors.New("缺少签名头 X-Pay-Timestamp/Nonce/Sign")
}
tsi, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return errors.New("时间戳格式错误")
}
if d := time.Now().Unix() - tsi; d > 300 || d < -300 {
return errors.New("请求已过期(时间戳超 5 分钟窗口)")
}
if !util.HMACVerify(cfg.Secret, sign, system, ts, nonce, string(rawBody)) {
return errors.New("签名校验失败")
}
return nil
}
// Create POST /api/v1/orders —— 下单,返回支付宝收银台跳转 URL。
// 独立收款(浏览器 /paytest):只传 product_id,无需签名。
// 业务对接(jiu 后端):带 biz_system/biz_ref + HMAC 签名头,服务端校验后受理。
func (h *OrderHandler) Create(c *gin.Context) {
raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes))
if err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大或读取失败")
return
}
var req createOrderRequest
if err := json.Unmarshal(raw, &req); err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误")
return
}
if req.ProductID == 0 {
util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 product_id")
return
}
if req.BizSystem != "" {
if err := verifyBizSign(c, req.BizSystem, raw); err != nil {
util.RespondError(c, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
}
isMobile, err := resolveIsMobile(req.ClientType, c.Request.UserAgent())
if err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", err.Error())
return
}
payURL, order, err := h.svc.Create(c.Request.Context(), req.ProductID, c.ClientIP(),
isMobile,
service.BizParams{System: req.BizSystem, Ref: req.BizRef, ReturnURL: req.ReturnURL})
if err != nil {
if errors.Is(err, service.ErrProductNotFound) {
util.RespondError(c, http.StatusNotFound, "product_not_found", err.Error())
return
}
// 内部错误详情只进日志,不回给客户端(防信息泄露)。
log.Printf("[order] 下单失败 product_id=%d biz=%q: %v", req.ProductID, req.BizSystem, err)
util.RespondError(c, http.StatusInternalServerError, "create_failed", "下单失败,请稍后重试")
return
}
util.RespondSuccess(c, gin.H{
"pay_url": payURL,
"out_trade_no": order.OutTradeNo,
"amount": order.Amount,
"subject": order.Subject,
})
}
// CreateQR POST /api/v1/orders/qr —— 扫码下单,返回二维码码串(前端渲染成二维码)。
func (h *OrderHandler) CreateQR(c *gin.Context) {
var req createOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数错误:"+err.Error())
return
}
qr, order, err := h.svc.CreateQR(c.Request.Context(), req.ProductID, c.ClientIP())
if err != nil {
if errors.Is(err, service.ErrProductNotFound) {
util.RespondError(c, http.StatusNotFound, "product_not_found", err.Error())
return
}
log.Printf("[order] 扫码下单失败 product_id=%d: %v", req.ProductID, err)
util.RespondError(c, http.StatusInternalServerError, "create_failed", "下单失败,请稍后重试")
return
}
util.RespondSuccess(c, gin.H{
"qr_code": qr,
"out_trade_no": order.OutTradeNo,
"amount": order.Amount,
"subject": order.Subject,
})
}
// AlipayNotify POST /api/v1/notify/alipay —— 异步回调。
// 成功必须返回纯文本 "success",否则支付宝会按策略重发。
func (h *OrderHandler) AlipayNotify(c *gin.Context) {
if err := h.svc.HandleAlipayNotify(c.Request.Context(), c.Request); err != nil {
c.String(http.StatusOK, "failure") // 回 failure 让支付宝重试(也可记录后人工处理)
return
}
c.String(http.StatusOK, "success")
}
// WechatNotify POST /api/v1/notify/wechat —— 微信异步回调。
// 成功回 200 + {"code":"SUCCESS"};失败回非 200 + {"code":"FAIL"} 让微信按策略重发。
func (h *OrderHandler) WechatNotify(c *gin.Context) {
if err := h.svc.HandleWechatNotify(c.Request.Context(), c.Request); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "OK"})
}
// GetStatus GET /api/v1/orders/:out_trade_no —— 供结果页轮询订单状态。
func (h *OrderHandler) GetStatus(c *gin.Context) {
out := c.Param("out_trade_no")
o, err := h.svc.GetByOutTradeNo(out)
if err != nil {
util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在")
return
}
util.RespondSuccess(c, gin.H{
"out_trade_no": o.OutTradeNo,
"subject": o.Subject,
"amount": o.Amount,
"status": o.Status,
"trade_no": o.TradeNo,
"paid_at": o.PaidAt,
})
}