diff --git a/internal/handler/gateway.go b/internal/handler/gateway.go new file mode 100644 index 0000000..8526656 --- /dev/null +++ b/internal/handler/gateway.go @@ -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+"失败,请稍后重试") + } +} diff --git a/internal/handler/gateway_test.go b/internal/handler/gateway_test.go new file mode 100644 index 0000000..84f6644 --- /dev/null +++ b/internal/handler/gateway_test.go @@ -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-。 + // 直接命中:构造 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) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index c916172..ad53b4d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) + } +} diff --git a/main.go b/main.go index ca51627..f1bccc4 100644 --- a/main.go +++ b/main.go @@ -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) }