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/mobile(v1.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, }) }