fd5f56f7de
- 微信支付 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
178 lines
5.8 KiB
Go
178 lines
5.8 KiB
Go
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,
|
||
})
|
||
}
|