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
This commit is contained in:
@@ -1,15 +1,33 @@
|
||||
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
|
||||
}
|
||||
@@ -19,17 +37,67 @@ func NewOrderHandler(svc *service.OrderService) *OrderHandler {
|
||||
}
|
||||
|
||||
type createOrderRequest struct {
|
||||
ProductID uint64 `json:"product_id" binding:"required"`
|
||||
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 := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := json.Unmarshal(raw, &req); 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())
|
||||
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())
|
||||
@@ -80,6 +148,16 @@ func (h *OrderHandler) AlipayNotify(c *gin.Context) {
|
||||
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")
|
||||
|
||||
@@ -20,11 +20,6 @@ func NewPageHandler(db *gorm.DB, webDir string) *PageHandler {
|
||||
return &PageHandler{db: db, webDir: webDir}
|
||||
}
|
||||
|
||||
// PayPage GET / —— 收款页。
|
||||
func (h *PageHandler) PayPage(c *gin.Context) {
|
||||
c.File(h.webDir + "/pay.html")
|
||||
}
|
||||
|
||||
// ResultPage GET /result —— 支付结果页(return_url 落地)。
|
||||
func (h *PageHandler) ResultPage(c *gin.Context) {
|
||||
c.File(h.webDir + "/result.html")
|
||||
@@ -59,6 +54,7 @@ func (h *PageHandler) ListProducts(c *gin.Context) {
|
||||
"name": p.Name,
|
||||
"description": p.Description,
|
||||
"price": p.Price,
|
||||
"biz_code": p.BizCode, // 业务方按 biz_code 查 product_id
|
||||
})
|
||||
}
|
||||
util.RespondSuccess(c, out)
|
||||
|
||||
Reference in New Issue
Block a user