Files
pay/internal/handler/order.go
T
wangjia fd5f56f7de feat: 微信V3 + 支付宝手机站/UA自适应 + 业务对接(签名下单/webhook) + 通用多业务
- 微信支付 V3 Native 渠道 (wechat.go):Native下单/回调AES-GCM解密验签/查单
- 支付宝:手机网站支付 wap.pay + 按 UA 自适应(PC page.pay扫码 / 手机拉App);qr_pay_mode=2 完整扫码收银台
- 业务对接:下单接口扩展(biz_system/biz_ref/return_url)+ HMAC 签名鉴权;支付成功 webhook 主动推送业务方 + 60s 重试 + BizNotifyLog
- 通用多业务:config.biz 改 map,加业务只改配置(BIZ_<SYS>_SECRET/_CALLBACK_URL)
- seedPlans:四档真实套餐 + promo_first_month(¥1) + test_liandiao(0.01),均挂 biz_code;/products 暴露 biz_code
- 删除沙箱 pay.html;对接设计文档入 docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UQmqWmV67sXGLrb3U1XXn
2026-07-03 22:44:34 +08:00

178 lines
5.8 KiB
Go
Raw 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"
"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"
)
// 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
}
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"` // 自定义付款后跳回地址
}
// 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(c.Request.Body)
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", "参数错误:"+err.Error())
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
}
}
payURL, order, err := h.svc.Create(c.Request.Context(), req.ProductID, c.ClientIP(),
isMobileUA(c.Request.UserAgent()),
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
}
util.RespondError(c, http.StatusInternalServerError, "create_failed", err.Error())
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
}
util.RespondError(c, http.StatusInternalServerError, "create_failed", err.Error())
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,
})
}