feat(backend): pay 下单/查单切 v2 契约——sku 直用 biz_code、session 多态响应、金额 int64 分
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -20,7 +19,8 @@ import (
|
|||||||
"github.com/wangjia/jiu/backend/internal/util"
|
"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)。
|
// 四块职责:下单(CreatePurchase)、webhook 入账(HandleCallback)、续期(entitle)、查单兜底(reconcileOnce)。
|
||||||
type PayService struct {
|
type PayService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
@@ -28,10 +28,6 @@ type PayService struct {
|
|||||||
secret string
|
secret string
|
||||||
retURL string
|
retURL string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|
||||||
prodMu sync.Mutex
|
|
||||||
prodCache map[string]int64 // biz_code -> pay product_id
|
|
||||||
prodAt time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
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 {
|
type PurchaseResult struct {
|
||||||
PayURL string `json:"pay_url"`
|
|
||||||
OutTradeNo string `json:"out_trade_no"`
|
OutTradeNo string `json:"out_trade_no"`
|
||||||
Amount string `json:"amount"`
|
RenderType string `json:"render_type"`
|
||||||
|
Payload map[string]any `json:"payload"`
|
||||||
|
AmountMinor int64 `json:"amount_minor"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
Subject string `json:"subject"`
|
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。
|
// CreatePurchase 建购买记录并调 pay 下单,返回收银台会话(session)。
|
||||||
// clientType(契约 v1.1.0,"pc"/"mobile"/""):显式端型透传给 pay 决定收银台形态
|
// clientType("pc"/"mobile"/""):pay v2 契约暂未透传端型决定收银台形态的参数,
|
||||||
// (mobile=手机网站支付拉起支付宝 App / pc=电脑扫码页);服务端间调用下单请求 UA
|
// 端型透传能力欠账,pay 补契约后跟进;本参数先保留签名不 breaking 调用方。
|
||||||
// 是本后端的,不传则 pay 会按 Go client UA 误判为 PC。
|
|
||||||
func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode, clientType string) (*PurchaseResult, error) {
|
func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode, clientType string) (*PurchaseResult, error) {
|
||||||
if !s.Configured() {
|
if !s.Configured() {
|
||||||
return nil, ErrPayNotConfigured
|
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"}
|
p := model.LicensePurchase{ShopID: shopID, UserID: userID, ProductBizCode: bizCode, Status: "pending"}
|
||||||
if err := s.db.Create(&p).Error; err != nil {
|
if err := s.db.Create(&p).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"product_id": productID,
|
"sku": bizCode,
|
||||||
|
"method": "alipay",
|
||||||
"biz_system": "jiu",
|
"biz_system": "jiu",
|
||||||
"biz_ref": strconv.FormatUint(p.ID, 10),
|
"biz_ref": strconv.FormatUint(p.ID, 10),
|
||||||
"return_url": s.retURL,
|
"return_url": s.retURL,
|
||||||
}
|
}
|
||||||
if clientType != "" {
|
|
||||||
payload["client_type"] = clientType
|
|
||||||
}
|
|
||||||
reqBody, _ := json.Marshal(payload)
|
reqBody, _ := json.Marshal(payload)
|
||||||
respBody, err := s.signedPost("/api/v1/orders", reqBody)
|
respBody, err := s.signedPost("/api/v2/orders", reqBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("pay 下单失败: %w", err)
|
return nil, fmt.Errorf("pay 下单失败: %w", err)
|
||||||
}
|
}
|
||||||
var resp struct {
|
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 下单响应异常")
|
return nil, fmt.Errorf("pay 下单响应异常")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(map[string]any{
|
result := &PurchaseResult{
|
||||||
"out_trade_no": resp.Data.OutTradeNo,
|
OutTradeNo: resp.Data.OrderNo,
|
||||||
"amount": resp.Data.Amount,
|
RenderType: resp.Data.Session.RenderType,
|
||||||
}).Error; err != nil {
|
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 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。
|
// signedPost 按契约对原始 body 签名后 POST 到 pay。
|
||||||
@@ -176,47 +206,6 @@ func (s *PayService) signedPost(path string, rawBody []byte) ([]byte, error) {
|
|||||||
return body, nil
|
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 入账 ----------
|
// ---------- ② webhook 入账 ----------
|
||||||
|
|
||||||
type payNotification struct {
|
type payNotification struct {
|
||||||
@@ -426,6 +415,8 @@ type PurchaseStatus struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
BizCode string `json:"product_biz_code"`
|
BizCode string `json:"product_biz_code"`
|
||||||
Amount string `json:"amount"`
|
Amount string `json:"amount"`
|
||||||
|
AmountMinor int64 `json:"amount_minor"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
PaidAt *time.Time `json:"paid_at,omitempty"`
|
PaidAt *time.Time `json:"paid_at,omitempty"`
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // 续期后的门店授权到期时间
|
ExpiresAt *time.Time `json:"expires_at,omitempty"` // 续期后的门店授权到期时间
|
||||||
}
|
}
|
||||||
@@ -438,7 +429,14 @@ func (s *PayService) Status(shopID uint64, outTradeNo string) (*PurchaseStatus,
|
|||||||
}
|
}
|
||||||
return nil, err
|
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" {
|
if p.Status == "paid" {
|
||||||
var lic model.License
|
var lic model.License
|
||||||
if err := s.db.Where("shop_id = ? AND is_active = ?", shopID, true).
|
if err := s.db.Where("shop_id = ? AND is_active = ?", shopID, true).
|
||||||
@@ -487,13 +485,15 @@ func (s *PayService) reconcileOnce() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 状态语义暂沿用旧的三态判断(v2 八态 created|pending|paid|canceled|expired|
|
||||||
|
// refunding|partially_refunded|refunded 的完整适配留 Task 4);这里仅做字段对齐保编译。
|
||||||
switch st.Status {
|
switch st.Status {
|
||||||
case "paid":
|
case "paid":
|
||||||
paidAt := time.Now()
|
paidAt := time.Now()
|
||||||
if st.PaidAt != nil {
|
if st.PaidAt != nil {
|
||||||
paidAt = *st.PaidAt
|
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)
|
log.Printf("[pay] reconcile settle failed out_trade_no=%s: %v", p.OutTradeNo, err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[pay] reconcile settled out_trade_no=%s (webhook missed)", p.OutTradeNo)
|
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 {
|
type payOrderStatus struct {
|
||||||
OutTradeNo string `json:"out_trade_no"`
|
OrderNo string `json:"order_no"`
|
||||||
Amount string `json:"amount"`
|
Status string `json:"status"`
|
||||||
Status string `json:"status"` // pending | paid | closed | refunded
|
Subject string `json:"subject"`
|
||||||
TradeNo string `json:"trade_no"`
|
AmountMinor int64 `json:"amount_minor"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
PaidAt *time.Time `json:"paid_at"`
|
PaidAt *time.Time `json:"paid_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryOrder 查单(契约未要求签名头)。
|
// queryOrder 查单(契约未要求签名头)。
|
||||||
func (s *PayService) queryOrder(outTradeNo string) (*payOrderStatus, error) {
|
func (s *PayService) queryOrder(orderNo string) (*payOrderStatus, error) {
|
||||||
resp, err := s.client.Get(s.baseURL + "/api/v1/orders/" + outTradeNo)
|
resp, err := s.client.Get(s.baseURL + "/api/v2/orders/" + orderNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,13 +229,10 @@ func TestCreatePurchase_HappyPath(t *testing.T) {
|
|||||||
db := testutil.SetupTestDB()
|
db := testutil.SetupTestDB()
|
||||||
shop := testutil.CreateTestShop(db, "PAY006")
|
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 := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /api/v1/products", func(w http.ResponseWriter, r *http.Request) {
|
var gotSku, gotMethod, gotBizSystem, gotBizRef string
|
||||||
fmt.Fprint(w, `{"data":[{"id":3,"biz_code":"annual_standard"},{"id":4,"biz_code":"monthly_pro"}]}`)
|
mux.HandleFunc("POST /api/v2/orders", func(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
|
||||||
var gotBizRef, gotClientType string
|
|
||||||
mux.HandleFunc("POST /api/v1/orders", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body := make([]byte, r.ContentLength)
|
body := make([]byte, r.ContentLength)
|
||||||
_, _ = r.Body.Read(body)
|
_, _ = r.Body.Read(body)
|
||||||
if !util.PaySignVerify(testPaySecret, r.Header.Get("X-Pay-Sign"),
|
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
|
var req map[string]any
|
||||||
_ = json.Unmarshal(body, &req)
|
_ = json.Unmarshal(body, &req)
|
||||||
|
gotSku, _ = req["sku"].(string)
|
||||||
|
gotMethod, _ = req["method"].(string)
|
||||||
|
gotBizSystem, _ = req["biz_system"].(string)
|
||||||
gotBizRef, _ = req["biz_ref"].(string)
|
gotBizRef, _ = req["biz_ref"].(string)
|
||||||
gotClientType, _ = req["client_type"].(string)
|
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"}}}`)
|
||||||
fmt.Fprint(w, `{"data":{"pay_url":"https://openapi.alipay.com/gateway","out_trade_no":"yanmei-new-1","amount":"2999.00","subject":"年付标准"}}`)
|
})
|
||||||
|
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)
|
payServer := httptest.NewServer(mux)
|
||||||
defer payServer.Close()
|
defer payServer.Close()
|
||||||
@@ -255,15 +257,25 @@ func TestCreatePurchase_HappyPath(t *testing.T) {
|
|||||||
svc := newTestPaySvc(db, payServer.URL)
|
svc := newTestPaySvc(db, payServer.URL)
|
||||||
res, err := svc.CreatePurchase(shop.ID, 1, "annual_standard", "mobile")
|
res, err := svc.CreatePurchase(shop.ID, 1, "annual_standard", "mobile")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "https://openapi.alipay.com/gateway", res.PayURL)
|
assert.Equal(t, "pay-x1", res.OutTradeNo)
|
||||||
assert.Equal(t, "yanmei-new-1", 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
|
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, "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, 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) {
|
func TestCreatePurchase_UnknownPlanAndUnconfigured(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user