From a5d7a1dc000049553b7a64024338b6730b8b1db8 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 20:33:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20pay=20=E4=B8=8B=E5=8D=95/?= =?UTF-8?q?=E6=9F=A5=E5=8D=95=E5=88=87=20v2=20=E5=A5=91=E7=BA=A6=E2=80=94?= =?UTF-8?q?=E2=80=94sku=20=E7=9B=B4=E7=94=A8=20biz=5Fcode=E3=80=81session?= =?UTF-8?q?=20=E5=A4=9A=E6=80=81=E5=93=8D=E5=BA=94=E3=80=81=E9=87=91?= =?UTF-8?q?=E9=A2=9D=20int64=20=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/internal/service/pay.go | 178 ++++++++++++++------------- backend/internal/service/pay_test.go | 38 ++++-- 2 files changed, 116 insertions(+), 100 deletions(-) diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go index 279f47f..218c622 100644 --- a/backend/internal/service/pay.go +++ b/backend/internal/service/pay.go @@ -9,7 +9,6 @@ import ( "net/http" "strconv" "strings" - "sync" "time" "github.com/google/uuid" @@ -20,7 +19,8 @@ import ( "github.com/wangjia/jiu/backend/internal/util" ) -// PayService jiu ↔ pay 收款中枢对接(契约 ~/code/pay-contract openapi.yaml v1.0.0)。 +// PayService jiu ↔ pay 收款中枢对接(下单/查单已切 pay v2 契约 /api/v2/orders, +// webhook 回调仍为 v1 契约,见 HandleCallback)。 // 四块职责:下单(CreatePurchase)、webhook 入账(HandleCallback)、续期(entitle)、查单兜底(reconcileOnce)。 type PayService struct { db *gorm.DB @@ -28,10 +28,6 @@ type PayService struct { secret string retURL string client *http.Client - - prodMu sync.Mutex - prodCache map[string]int64 // biz_code -> pay product_id - prodAt time.Time } var ( @@ -82,17 +78,22 @@ func (s *PayService) Configured() bool { return s.secret != "" } // ---------- ① 购买下单 ---------- +// PurchaseResult 下单结果。RenderType/Payload/AmountMinor/Currency/Subject 是 pay v2 契约的一手字段; +// PayURL/Amount 是给官网 checkout 与旧客户端读的兼容字段(Deprecated,观察一版后视情况收敛)。 type PurchaseResult struct { - PayURL string `json:"pay_url"` - OutTradeNo string `json:"out_trade_no"` - Amount string `json:"amount"` - Subject string `json:"subject"` + OutTradeNo string `json:"out_trade_no"` + RenderType string `json:"render_type"` + Payload map[string]any `json:"payload"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + Subject string `json:"subject"` + PayURL string `json:"pay_url"` // Deprecated: render_type==redirect 时 = payload.url + Amount string `json:"amount"` // Deprecated: formatMinor(AmountMinor) 分转元字符串 } -// CreatePurchase 建购买记录并调 pay 下单,返回收银台跳转 URL。 -// clientType(契约 v1.1.0,"pc"/"mobile"/""):显式端型透传给 pay 决定收银台形态 -// (mobile=手机网站支付拉起支付宝 App / pc=电脑扫码页);服务端间调用下单请求 UA -// 是本后端的,不传则 pay 会按 Go client UA 误判为 PC。 +// CreatePurchase 建购买记录并调 pay 下单,返回收银台会话(session)。 +// clientType("pc"/"mobile"/""):pay v2 契约暂未透传端型决定收银台形态的参数, +// 端型透传能力欠账,pay 补契约后跟进;本参数先保留签名不 breaking 调用方。 func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode, clientType string) (*PurchaseResult, error) { if !s.Configured() { return nil, ErrPayNotConfigured @@ -110,44 +111,73 @@ func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode, clientType s } } - productID, err := s.productID(bizCode) - if err != nil { - return nil, fmt.Errorf("获取套餐信息失败: %w", err) - } - p := model.LicensePurchase{ShopID: shopID, UserID: userID, ProductBizCode: bizCode, Status: "pending"} if err := s.db.Create(&p).Error; err != nil { return nil, err } payload := map[string]any{ - "product_id": productID, + "sku": bizCode, + "method": "alipay", "biz_system": "jiu", "biz_ref": strconv.FormatUint(p.ID, 10), "return_url": s.retURL, } - if clientType != "" { - payload["client_type"] = clientType - } reqBody, _ := json.Marshal(payload) - respBody, err := s.signedPost("/api/v1/orders", reqBody) + respBody, err := s.signedPost("/api/v2/orders", reqBody) if err != nil { return nil, fmt.Errorf("pay 下单失败: %w", err) } var resp struct { - Data PurchaseResult `json:"data"` + Data struct { + OrderNo string `json:"order_no"` + Session struct { + RenderType string `json:"render_type"` + Payload map[string]any `json:"payload"` + } `json:"session"` + } `json:"data"` } - if err := json.Unmarshal(respBody, &resp); err != nil || resp.Data.PayURL == "" || resp.Data.OutTradeNo == "" { + if err := json.Unmarshal(respBody, &resp); err != nil || resp.Data.OrderNo == "" || resp.Data.Session.RenderType == "" { return nil, fmt.Errorf("pay 下单响应异常") } - if err := s.db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(map[string]any{ - "out_trade_no": resp.Data.OutTradeNo, - "amount": resp.Data.Amount, - }).Error; err != nil { + result := &PurchaseResult{ + OutTradeNo: resp.Data.OrderNo, + RenderType: resp.Data.Session.RenderType, + Payload: resp.Data.Session.Payload, + } + if result.RenderType == "redirect" { + if u, ok := result.Payload["url"].(string); ok { + result.PayURL = u + } + } + + updates := map[string]any{ + "out_trade_no": result.OutTradeNo, + "pay_url": result.PayURL, + } + // best-effort 查单回填金额:查单失败不阻断下单,金额留 0 由 D1 兜底(对账/结果页轮询会补) + if st, err := s.queryOrder(result.OutTradeNo); err == nil { + result.AmountMinor = st.AmountMinor + result.Currency = st.Currency + result.Subject = st.Subject + updates["amount_minor"] = st.AmountMinor + updates["currency"] = st.Currency + } + result.Amount = formatMinor(result.AmountMinor) + + if err := s.db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(updates).Error; err != nil { return nil, err } - return &resp.Data, nil + return result, nil +} + +// formatMinor 分→元字符串(仅 2 位小数币种如 CNY)。minor<=0 时留空(金额未回填)。 +func formatMinor(minor int64) string { + if minor <= 0 { + return "" + } + return fmt.Sprintf("%d.%02d", minor/100, minor%100) } // signedPost 按契约对原始 body 签名后 POST 到 pay。 @@ -176,47 +206,6 @@ func (s *PayService) signedPost(path string, rawBody []byte) ([]byte, error) { return body, nil } -// productID 按 biz_code 查 pay 套餐 id(GET /api/v1/products,内存缓存 10 分钟)。 -func (s *PayService) productID(bizCode string) (int64, error) { - s.prodMu.Lock() - defer s.prodMu.Unlock() - if s.prodCache != nil && time.Since(s.prodAt) < 10*time.Minute { - if id, ok := s.prodCache[bizCode]; ok { - return id, nil - } - } - resp, err := s.client.Get(s.baseURL + "/api/v1/products") - if err != nil { - return 0, err - } - defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if resp.StatusCode != http.StatusOK { - return 0, fmt.Errorf("pay HTTP %d", resp.StatusCode) - } - var pr struct { - Data []struct { - ID int64 `json:"id"` - BizCode string `json:"biz_code"` - } `json:"data"` - } - if err := json.Unmarshal(body, &pr); err != nil { - return 0, err - } - cache := make(map[string]int64, len(pr.Data)) - for _, p := range pr.Data { - if p.BizCode != "" { - cache[p.BizCode] = p.ID - } - } - s.prodCache, s.prodAt = cache, time.Now() - id, ok := cache[bizCode] - if !ok { - return 0, fmt.Errorf("pay 侧无 biz_code=%s 的套餐", bizCode) - } - return id, nil -} - // ---------- ② webhook 入账 ---------- type payNotification struct { @@ -422,12 +411,14 @@ func toCents(s string) (int64, error) { // ---------- ③ 状态查询(结果页轮询) ---------- type PurchaseStatus struct { - OutTradeNo string `json:"out_trade_no"` - Status string `json:"status"` - BizCode string `json:"product_biz_code"` - Amount string `json:"amount"` - PaidAt *time.Time `json:"paid_at,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` // 续期后的门店授权到期时间 + OutTradeNo string `json:"out_trade_no"` + Status string `json:"status"` + BizCode string `json:"product_biz_code"` + Amount string `json:"amount"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + PaidAt *time.Time `json:"paid_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` // 续期后的门店授权到期时间 } func (s *PayService) Status(shopID uint64, outTradeNo string) (*PurchaseStatus, error) { @@ -438,7 +429,14 @@ func (s *PayService) Status(shopID uint64, outTradeNo string) (*PurchaseStatus, } return nil, err } - st := &PurchaseStatus{OutTradeNo: p.OutTradeNo, Status: p.Status, BizCode: p.ProductBizCode, Amount: p.Amount, PaidAt: p.PaidAt} + amount := formatMinor(p.AmountMinor) + if amount == "" { + amount = p.Amount // 残单回退:v1 遗留/查单未回填时用旧列 + } + st := &PurchaseStatus{ + OutTradeNo: p.OutTradeNo, Status: p.Status, BizCode: p.ProductBizCode, + Amount: amount, AmountMinor: p.AmountMinor, Currency: p.Currency, PaidAt: p.PaidAt, + } if p.Status == "paid" { var lic model.License if err := s.db.Where("shop_id = ? AND is_active = ?", shopID, true). @@ -487,13 +485,15 @@ func (s *PayService) reconcileOnce() { if err != nil { continue } + // 状态语义暂沿用旧的三态判断(v2 八态 created|pending|paid|canceled|expired| + // refunding|partially_refunded|refunded 的完整适配留 Task 4);这里仅做字段对齐保编译。 switch st.Status { case "paid": paidAt := time.Now() if st.PaidAt != nil { paidAt = *st.PaidAt } - if err := s.settle(p.OutTradeNo, p.ProductBizCode, st.Amount, st.TradeNo, "", paidAt); err != nil { + if err := s.settle(p.OutTradeNo, p.ProductBizCode, formatMinor(st.AmountMinor), "", "", paidAt); err != nil { log.Printf("[pay] reconcile settle failed out_trade_no=%s: %v", p.OutTradeNo, err) } else { log.Printf("[pay] reconcile settled out_trade_no=%s (webhook missed)", p.OutTradeNo) @@ -508,17 +508,21 @@ func (s *PayService) reconcileOnce() { } } +// payOrderStatus 查单响应(pay v2 契约,GET /api/v2/orders/:order_no,无鉴权)。 +// 不回传 biz_ref/trade_no。status 八态:created|pending|paid|canceled|expired| +// refunding|partially_refunded|refunded。 type payOrderStatus struct { - OutTradeNo string `json:"out_trade_no"` - Amount string `json:"amount"` - Status string `json:"status"` // pending | paid | closed | refunded - TradeNo string `json:"trade_no"` - PaidAt *time.Time `json:"paid_at"` + OrderNo string `json:"order_no"` + Status string `json:"status"` + Subject string `json:"subject"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + PaidAt *time.Time `json:"paid_at"` } // queryOrder 查单(契约未要求签名头)。 -func (s *PayService) queryOrder(outTradeNo string) (*payOrderStatus, error) { - resp, err := s.client.Get(s.baseURL + "/api/v1/orders/" + outTradeNo) +func (s *PayService) queryOrder(orderNo string) (*payOrderStatus, error) { + resp, err := s.client.Get(s.baseURL + "/api/v2/orders/" + orderNo) if err != nil { return nil, err } diff --git a/backend/internal/service/pay_test.go b/backend/internal/service/pay_test.go index 9cf711a..3a2e96a 100644 --- a/backend/internal/service/pay_test.go +++ b/backend/internal/service/pay_test.go @@ -229,13 +229,10 @@ func TestCreatePurchase_HappyPath(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PAY006") - // 假 pay 服务:/products 列表 + /orders 验签后返回 pay_url + // 假 pay v2 服务:POST /api/v2/orders 验签后返回 session(redirect)+ GET /api/v2/orders/:no 回填金额 mux := http.NewServeMux() - mux.HandleFunc("GET /api/v1/products", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `{"data":[{"id":3,"biz_code":"annual_standard"},{"id":4,"biz_code":"monthly_pro"}]}`) - }) - var gotBizRef, gotClientType string - mux.HandleFunc("POST /api/v1/orders", func(w http.ResponseWriter, r *http.Request) { + var gotSku, gotMethod, gotBizSystem, gotBizRef string + mux.HandleFunc("POST /api/v2/orders", func(w http.ResponseWriter, r *http.Request) { body := make([]byte, r.ContentLength) _, _ = r.Body.Read(body) if !util.PaySignVerify(testPaySecret, r.Header.Get("X-Pay-Sign"), @@ -245,9 +242,14 @@ func TestCreatePurchase_HappyPath(t *testing.T) { } var req map[string]any _ = json.Unmarshal(body, &req) + gotSku, _ = req["sku"].(string) + gotMethod, _ = req["method"].(string) + gotBizSystem, _ = req["biz_system"].(string) gotBizRef, _ = req["biz_ref"].(string) - gotClientType, _ = req["client_type"].(string) - fmt.Fprint(w, `{"data":{"pay_url":"https://openapi.alipay.com/gateway","out_trade_no":"yanmei-new-1","amount":"2999.00","subject":"年付标准"}}`) + fmt.Fprint(w, `{"data":{"order_no":"pay-x1","session":{"render_type":"redirect","payload":{"url":"https://pay.test/cashier"},"expires_at":"2026-07-10T12:00:00Z"}}}`) + }) + mux.HandleFunc("GET /api/v2/orders/pay-x1", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"order_no":"pay-x1","status":"pending","subject":"岩美酒库·标准版年付","amount_minor":299900,"currency":"CNY"}}`) }) payServer := httptest.NewServer(mux) defer payServer.Close() @@ -255,15 +257,25 @@ func TestCreatePurchase_HappyPath(t *testing.T) { svc := newTestPaySvc(db, payServer.URL) res, err := svc.CreatePurchase(shop.ID, 1, "annual_standard", "mobile") require.NoError(t, err) - assert.Equal(t, "https://openapi.alipay.com/gateway", res.PayURL) - assert.Equal(t, "yanmei-new-1", res.OutTradeNo) + assert.Equal(t, "pay-x1", res.OutTradeNo) + assert.Equal(t, "redirect", res.RenderType) + assert.Equal(t, "https://pay.test/cashier", res.Payload["url"]) + assert.Equal(t, int64(299900), res.AmountMinor) + assert.Equal(t, "CNY", res.Currency) + assert.Equal(t, "https://pay.test/cashier", res.PayURL, "兼容字段:redirect 时 = payload.url") + assert.Equal(t, "2999.00", res.Amount, "兼容字段:分转元字符串") + + assert.Equal(t, "annual_standard", gotSku, "sku 应直用 biz_code") + assert.Equal(t, "alipay", gotMethod) + assert.Equal(t, "jiu", gotBizSystem) var p model.LicensePurchase - require.NoError(t, db.Where("out_trade_no = ?", "yanmei-new-1").First(&p).Error) + require.NoError(t, db.Where("out_trade_no = ?", "pay-x1").First(&p).Error) assert.Equal(t, "pending", p.Status) - assert.Equal(t, "2999.00", p.Amount) + assert.Equal(t, int64(299900), p.AmountMinor) + assert.Equal(t, "CNY", p.Currency) + assert.Equal(t, "https://pay.test/cashier", p.PayURL) assert.Equal(t, strconv.FormatUint(p.ID, 10), gotBizRef, "biz_ref 应为购买记录 id") - assert.Equal(t, "mobile", gotClientType, "client_type 应透传给 pay(契约 v1.1.0)") } func TestCreatePurchase_UnknownPlanAndUnconfigured(t *testing.T) {