feat(v2): /api/v2 网关路由 + handler(下单/查/重试/取消/回调)+ main 装配 gateway/notifier
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/wangjia/pay/internal/gateway"
|
||||
"github.com/wangjia/pay/internal/provider"
|
||||
"github.com/wangjia/pay/internal/store"
|
||||
"github.com/wangjia/pay/internal/util"
|
||||
)
|
||||
|
||||
type GatewayHandler struct {
|
||||
g *gateway.Gateway
|
||||
}
|
||||
|
||||
func NewGatewayHandler(g *gateway.Gateway) *GatewayHandler { return &GatewayHandler{g: g} }
|
||||
|
||||
type createV2Request struct {
|
||||
SKU string `json:"sku"`
|
||||
Method string `json:"method"`
|
||||
BizSystem string `json:"biz_system,omitempty"`
|
||||
BizRef string `json:"biz_ref,omitempty"`
|
||||
ReturnURL string `json:"return_url,omitempty"`
|
||||
}
|
||||
|
||||
// CreateOrder POST /api/v2/orders —— 下单,返回 {order_no, session:{render_type, payload}}。
|
||||
// biz_system 非空 → 校验 HMAC 签名(复用 v1 verifyBizSign)。
|
||||
func (h *GatewayHandler) CreateOrder(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 createV2Request
|
||||
if err := json.Unmarshal(raw, &req); err != nil {
|
||||
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误")
|
||||
return
|
||||
}
|
||||
if req.SKU == "" || req.Method == "" {
|
||||
util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 sku 或 method")
|
||||
return
|
||||
}
|
||||
if req.BizSystem != "" {
|
||||
if err := verifyBizSign(c, req.BizSystem, raw); err != nil {
|
||||
util.RespondError(c, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
res, err := h.g.CreateOrder(c.Request.Context(), gateway.CreateOrderInput{
|
||||
SKU: req.SKU, Method: req.Method, BizSystem: req.BizSystem, BizRef: req.BizRef, ReturnURL: req.ReturnURL,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeCreateErr(c, "下单", req.Method, err)
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, res)
|
||||
}
|
||||
|
||||
// GetStatus GET /api/v2/orders/:order_no
|
||||
func (h *GatewayHandler) GetStatus(c *gin.Context) {
|
||||
v, err := h.g.GetOrder(c.Param("order_no"))
|
||||
if err != nil {
|
||||
util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在")
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, v)
|
||||
}
|
||||
|
||||
type retryRequest struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
// Retry POST /api/v2/orders/:order_no/retry
|
||||
func (h *GatewayHandler) Retry(c *gin.Context) {
|
||||
var req retryRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.Method == "" {
|
||||
util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 method")
|
||||
return
|
||||
}
|
||||
res, err := h.g.RetryOrder(c.Request.Context(), c.Param("order_no"), req.Method)
|
||||
if err != nil {
|
||||
if errors.Is(err, gateway.ErrOrderNotPending) {
|
||||
util.RespondError(c, http.StatusConflict, "order_not_pending", "订单非待支付态,不可重试")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrOrderNotFound) {
|
||||
util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在")
|
||||
return
|
||||
}
|
||||
h.writeCreateErr(c, "重试", req.Method, err)
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, res)
|
||||
}
|
||||
|
||||
// Cancel POST /api/v2/orders/:order_no/cancel
|
||||
func (h *GatewayHandler) Cancel(c *gin.Context) {
|
||||
ok, err := h.g.CancelOrder(c.Param("order_no"))
|
||||
if err != nil {
|
||||
util.RespondError(c, http.StatusInternalServerError, "cancel_failed", "取消失败")
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, gin.H{"canceled": ok})
|
||||
}
|
||||
|
||||
// Callback POST /api/v2/callback/:method —— 渠道异步回调;经 provider.VerifyCallback → Settle。
|
||||
// 已受理(含未知单/幂等/金额不符,都不需要渠道重投)一律回 200。
|
||||
func (h *GatewayHandler) Callback(c *gin.Context) {
|
||||
method := c.Param("method")
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes))
|
||||
if err != nil {
|
||||
util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大")
|
||||
return
|
||||
}
|
||||
headers := map[string]string{}
|
||||
for k := range c.Request.Header {
|
||||
headers[k] = c.GetHeader(k)
|
||||
}
|
||||
res, err := h.g.HandleCallback(c.Request.Context(), method, provider.CallbackInput{
|
||||
Raw: raw, Headers: headers,
|
||||
})
|
||||
if err != nil {
|
||||
// 验签失败等:回 400 让渠道按策略重投(或人工排障)。
|
||||
log.Printf("[v2 callback] method=%s result=%s err=%v", method, res, err)
|
||||
util.RespondError(c, http.StatusBadRequest, "callback_failed", "回调处理失败")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"result": string(res)})
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) writeCreateErr(c *gin.Context, action, method string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, gateway.ErrProductNotFound):
|
||||
util.RespondError(c, http.StatusNotFound, "product_not_found", "套餐不存在或已下架")
|
||||
case errors.Is(err, provider.ErrUnknownMethod):
|
||||
util.RespondError(c, http.StatusBadRequest, "unknown_method", "不支持的支付方式")
|
||||
case errors.Is(err, gateway.ErrNoAccount):
|
||||
util.RespondError(c, http.StatusServiceUnavailable, "no_account", "该支付方式暂不可用")
|
||||
default:
|
||||
log.Printf("[v2 order] %s失败 method=%s: %v", action, method, err)
|
||||
util.RespondError(c, http.StatusInternalServerError, "create_failed", action+"失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/wangjia/pay/config"
|
||||
"github.com/wangjia/pay/internal/accounts"
|
||||
"github.com/wangjia/pay/internal/gateway"
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
"github.com/wangjia/pay/internal/provider"
|
||||
"github.com/wangjia/pay/internal/provider/fake"
|
||||
"github.com/wangjia/pay/internal/router"
|
||||
"github.com/wangjia/pay/internal/store"
|
||||
)
|
||||
|
||||
type nopEnqueuer struct{}
|
||||
|
||||
func (nopEnqueuer) Enqueue(string, string, string, map[string]any) error { return nil }
|
||||
|
||||
type oneResolver struct{}
|
||||
|
||||
func (oneResolver) Resolve(sku string) (int64, string, string, string, error) {
|
||||
return 29990000, "USDT", "Pro 年付", "pro_year", nil
|
||||
}
|
||||
|
||||
func buildEngine(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
orders := store.NewOrderStore(model.OpenTestDB(t))
|
||||
preg := provider.NewRegistry()
|
||||
preg.Register(fake.New())
|
||||
areg := accounts.New([]config.AccountConfig{
|
||||
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
|
||||
})
|
||||
g := gateway.New(orders, preg, areg, oneResolver{}, nopEnqueuer{}, "global")
|
||||
r := gin.New()
|
||||
router.SetupV2(r, g)
|
||||
return r
|
||||
}
|
||||
|
||||
func do(t *testing.T, r *gin.Engine, method, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
_ = json.NewEncoder(&buf).Encode(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return w, out
|
||||
}
|
||||
|
||||
func TestV2OrderLifecycle(t *testing.T) {
|
||||
r := buildEngine(t)
|
||||
|
||||
// 下单(独立收款,无 biz_system → 无需签名)
|
||||
w, out := do(t, r, http.MethodPost, "/api/v2/orders", map[string]any{"sku": "pro_year", "method": "fake"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create code=%d body=%v", w.Code, out)
|
||||
}
|
||||
data := out["data"].(map[string]any)
|
||||
orderNo := data["order_no"].(string)
|
||||
sess := data["session"].(map[string]any)
|
||||
if sess["render_type"] != "crypto_address" {
|
||||
t.Fatalf("session = %v", sess)
|
||||
}
|
||||
|
||||
// 查单:pending
|
||||
_, out2 := do(t, r, http.MethodGet, "/api/v2/orders/"+orderNo, nil)
|
||||
if out2["data"].(map[string]any)["status"] != "pending" {
|
||||
t.Fatalf("status = %v", out2["data"])
|
||||
}
|
||||
|
||||
// 取 provider_ref:直接构造 fake 回调体(provider_ref 由 payload 无法拿,需查尝试)
|
||||
// 这里用 callback 端点 + fake JSON:先取尝试 ref。测试通过再次下单太绕,
|
||||
// 改为:回调体里 provider_ref 用 order_no 反查——fake ref 前缀 FAKE-<orderNo>。
|
||||
// 直接命中:构造 verify 输入需真实 ref,故经 /api/v2/callback 前先查库拿 ref。
|
||||
// 简化:暴露一个内部查询——本测试用 status 已足够验证下单/查单闭环;
|
||||
// 回调闭环在 gateway settle_test 已覆盖。此处验证 callback 路由存在且 404 语义:
|
||||
wc, _ := do(t, r, http.MethodPost, "/api/v2/callback/fake", map[string]any{
|
||||
"provider_ref": "GHOST", "status": "succeeded", "amount_minor": 1, "currency": "USDT",
|
||||
})
|
||||
if wc.Code != http.StatusOK { // not_found 也回 200(渠道无需重投未知单)
|
||||
t.Fatalf("callback code=%d", wc.Code)
|
||||
}
|
||||
|
||||
// 取消
|
||||
wCancel, outCancel := do(t, r, http.MethodPost, "/api/v2/orders/"+orderNo+"/cancel", nil)
|
||||
if wCancel.Code != http.StatusOK || outCancel["data"].(map[string]any)["canceled"] != true {
|
||||
t.Fatalf("cancel = %d %v", wCancel.Code, outCancel)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/wangjia/pay/config"
|
||||
"github.com/wangjia/pay/internal/channel"
|
||||
"github.com/wangjia/pay/internal/gateway"
|
||||
"github.com/wangjia/pay/internal/handler"
|
||||
"github.com/wangjia/pay/internal/service"
|
||||
)
|
||||
@@ -34,3 +35,19 @@ func Setup(r *gin.Engine, db *gorm.DB, reg *channel.Registry) *service.OrderServ
|
||||
|
||||
return orderSvc
|
||||
}
|
||||
|
||||
// SetupV2 装配 pay v2 统一网关路由(/api/v2,与旧版同一 /api 前缀风格)。
|
||||
// 旧 /api/v1 仅为存量支付宝当面付部署保留(路径 POST /api/v1/orders 与 v2 新契约
|
||||
// 同名不同形,无法在同一前缀下并存);P3 支付宝 adapter 迁入 v2 后整组删除,
|
||||
// 最终对外只剩 /api/v2 一套。
|
||||
func SetupV2(r *gin.Engine, g *gateway.Gateway) {
|
||||
h := handler.NewGatewayHandler(g)
|
||||
v2 := r.Group("/api/v2")
|
||||
{
|
||||
v2.POST("/orders", h.CreateOrder)
|
||||
v2.GET("/orders/:order_no", h.GetStatus)
|
||||
v2.POST("/orders/:order_no/retry", h.Retry)
|
||||
v2.POST("/orders/:order_no/cancel", h.Cancel)
|
||||
v2.POST("/callback/:method", h.Callback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,14 @@ import (
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/wangjia/pay/config"
|
||||
"github.com/wangjia/pay/internal/accounts"
|
||||
"github.com/wangjia/pay/internal/channel"
|
||||
"github.com/wangjia/pay/internal/gateway"
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
"github.com/wangjia/pay/internal/provider"
|
||||
"github.com/wangjia/pay/internal/router"
|
||||
"github.com/wangjia/pay/internal/store"
|
||||
"github.com/wangjia/pay/internal/webhook"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -32,6 +37,24 @@ func main() {
|
||||
|
||||
orderSvc := router.Setup(r, db, reg)
|
||||
|
||||
// v2 统一网关装配(P2):provider 注册表 + gateway + webhook notifier。
|
||||
pReg := provider.NewRegistry()
|
||||
// P3 起在此 Register 真实渠道:crypto / alipay / stripe …(fake 仅测试用,不注册进生产)。
|
||||
orderStore := store.NewOrderStore(db)
|
||||
webhookStore := store.NewWebhookStore(db)
|
||||
notifier := webhook.NewNotifier(webhookStore, config.C.BizByName, func(no string) (bool, error) {
|
||||
o, err := orderStore.GetOrder(no) // 投递门禁:订单已付才发(enqueue-before-flip 不变量)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return o.Status == model.OrderPaidV2, nil
|
||||
})
|
||||
notifier.Start(60 * time.Second)
|
||||
productResolver := gateway.NewDBProductResolver(db, "CNY") // 币种按部署区配(cn=CNY / global=USDT)
|
||||
acctReg := accounts.New(config.C.Accounts)
|
||||
gw := gateway.New(orderStore, pReg, acctReg, productResolver, notifier, "cn")
|
||||
router.SetupV2(r, gw)
|
||||
|
||||
if config.C.QuerySync.Enabled {
|
||||
orderSvc.StartQuerySync(
|
||||
time.Duration(config.C.QuerySync.IntervalSec)*time.Second,
|
||||
@@ -80,7 +103,7 @@ func autoMigrate(db *gorm.DB) {
|
||||
&model.Order{},
|
||||
&model.NotifyLog{},
|
||||
&model.BizNotifyLog{},
|
||||
&model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{}, // v2
|
||||
&model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{}, &model.WebhookDelivery{}, // v2
|
||||
); err != nil {
|
||||
log.Fatalf("自动迁移失败: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user