钱包结算所有, 第三方充值到钱包
This commit is contained in:
@@ -72,6 +72,14 @@ type QueryPaymentResponse struct {
|
|||||||
Raw map[string]string
|
Raw map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VerifyNotifyResult struct {
|
||||||
|
OK bool
|
||||||
|
MatchedKey string
|
||||||
|
Got string
|
||||||
|
Expected map[string]string
|
||||||
|
ParamKeys []string
|
||||||
|
}
|
||||||
|
|
||||||
func NewClient(cfg config.LeshuaPaymentConfig) *Client {
|
func NewClient(cfg config.LeshuaPaymentConfig) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@@ -171,19 +179,60 @@ func (c *Client) QueryPayment(ctx context.Context, thirdOrderID, providerOrderID
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) VerifyNotify(params map[string]string) bool {
|
func (c *Client) VerifyNotify(params map[string]string) bool {
|
||||||
key := firstNonEmpty(c.cfg.NotifyKey, c.cfg.SignKey)
|
return c.VerifyNotifyDetail(params).OK
|
||||||
if key == "" {
|
}
|
||||||
return false
|
|
||||||
}
|
func (c *Client) VerifyNotifyDetail(params map[string]string) VerifyNotifyResult {
|
||||||
got := strings.ToUpper(params["sign"])
|
got := strings.ToUpper(params["sign"])
|
||||||
if got == "" {
|
result := VerifyNotifyResult{
|
||||||
return false
|
Got: got,
|
||||||
|
Expected: map[string]string{},
|
||||||
|
ParamKeys: notifyParamKeys(params),
|
||||||
}
|
}
|
||||||
expected := Sign(params, key, SignOptions{
|
if got == "" {
|
||||||
IncludeEmpty: true,
|
return result
|
||||||
ExcludeKeys: []string{"error_code", "sign"},
|
}
|
||||||
})
|
for _, item := range c.notifyKeyCandidates() {
|
||||||
return got == expected
|
expected := Sign(params, item.key, SignOptions{
|
||||||
|
IncludeEmpty: true,
|
||||||
|
ExcludeKeys: []string{"error_code", "sign"},
|
||||||
|
})
|
||||||
|
result.Expected[item.name] = expected
|
||||||
|
if got == expected {
|
||||||
|
result.OK = true
|
||||||
|
result.MatchedKey = item.name
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type notifyKeyCandidate struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) notifyKeyCandidates() []notifyKeyCandidate {
|
||||||
|
candidates := []notifyKeyCandidate{}
|
||||||
|
if c.cfg.NotifyKey != "" {
|
||||||
|
candidates = append(candidates, notifyKeyCandidate{name: "notify_key", key: c.cfg.NotifyKey})
|
||||||
|
}
|
||||||
|
if c.cfg.SignKey != "" && c.cfg.SignKey != c.cfg.NotifyKey {
|
||||||
|
candidates = append(candidates, notifyKeyCandidate{name: "sign_key", key: c.cfg.SignKey})
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyParamKeys(params map[string]string) []string {
|
||||||
|
keys := make([]string, 0, len(params))
|
||||||
|
for key := range params {
|
||||||
|
if key == "sign" || key == "error_code" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) validate() error {
|
func (c *Client) validate() error {
|
||||||
|
|||||||
@@ -50,6 +50,32 @@ func TestVerifyNotifyIncludesEmptyAndExcludesErrorCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVerifyNotifyFallsBackToSignKey(t *testing.T) {
|
||||||
|
client := NewClient(config.LeshuaPaymentConfig{
|
||||||
|
NotifyKey: "wrong-notify-secret",
|
||||||
|
SignKey: "sign-secret",
|
||||||
|
})
|
||||||
|
params := map[string]string{
|
||||||
|
"merchant_id": "1234567890",
|
||||||
|
"third_order_id": "NO1",
|
||||||
|
"leshua_order_id": "LS1",
|
||||||
|
"amount": "100",
|
||||||
|
"status": "2",
|
||||||
|
}
|
||||||
|
params["sign"] = Sign(params, "sign-secret", SignOptions{
|
||||||
|
IncludeEmpty: true,
|
||||||
|
ExcludeKeys: []string{"error_code", "sign"},
|
||||||
|
})
|
||||||
|
|
||||||
|
result := client.VerifyNotifyDetail(params)
|
||||||
|
if !result.OK {
|
||||||
|
t.Fatal("VerifyNotifyDetail().OK = false, want true")
|
||||||
|
}
|
||||||
|
if result.MatchedKey != "sign_key" {
|
||||||
|
t.Fatalf("MatchedKey = %s, want sign_key", result.MatchedKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParsePayloadSupportsFormAndXML(t *testing.T) {
|
func TestParsePayloadSupportsFormAndXML(t *testing.T) {
|
||||||
form, err := ParsePayload([]byte("third_order_id=NO1&status=2&amount=100"))
|
form, err := ParsePayload([]byte("third_order_id=NO1&status=2&amount=100"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package payment
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -109,11 +110,14 @@ func (h *Handler) LeshuaNotify(c *gin.Context) {
|
|||||||
c.String(http.StatusBadRequest, "FAIL")
|
c.String(http.StatusBadRequest, "FAIL")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("[payment] leshua notify received third_order_id=%s leshua_order_id=%s status=%s amount=%s", params["third_order_id"], params["leshua_order_id"], params["status"], params["amount"])
|
||||||
result, err := h.service.HandleLeshuaNotify(params)
|
result, err := h.service.HandleLeshuaNotify(params)
|
||||||
if err != nil || result == nil || !result.OK {
|
if err != nil || result == nil || !result.OK {
|
||||||
|
log.Printf("[payment] leshua notify failed third_order_id=%s err=%v", params["third_order_id"], err)
|
||||||
c.String(http.StatusOK, "FAIL")
|
c.String(http.StatusOK, "FAIL")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("[payment] leshua notify processed third_order_id=%s status=%s", params["third_order_id"], params["status"])
|
||||||
c.String(http.StatusOK, result.Message)
|
c.String(http.StatusOK, result.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -30,6 +31,13 @@ type Repository struct {
|
|||||||
isMockMode bool
|
isMockMode bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
channelSourceCreate = "create"
|
||||||
|
channelSourceQuery = "query"
|
||||||
|
channelSourceNotify = "notify"
|
||||||
|
channelSourceMock = "mock"
|
||||||
|
)
|
||||||
|
|
||||||
func NewRepository(db *gorm.DB, cfg config.PaymentConfig, orderRepo *order.Repository, walletRepo *wallet.Repository) *Repository {
|
func NewRepository(db *gorm.DB, cfg config.PaymentConfig, orderRepo *order.Repository, walletRepo *wallet.Repository) *Repository {
|
||||||
provider := cfg.Provider
|
provider := cfg.Provider
|
||||||
if provider == "" {
|
if provider == "" {
|
||||||
@@ -61,7 +69,7 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques
|
|||||||
"third_order_id": payment.ThirdOrderID,
|
"third_order_id": payment.ThirdOrderID,
|
||||||
"leshua_order_id": payment.ProviderOrderID,
|
"leshua_order_id": payment.ProviderOrderID,
|
||||||
"status": "2",
|
"status": "2",
|
||||||
}); err != nil {
|
}, channelSourceMock); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
latest, err := r.findPaymentByID(payment.ID)
|
latest, err := r.findPaymentByID(payment.ID)
|
||||||
@@ -103,7 +111,7 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques
|
|||||||
"jspay_url": resp.JSPayURL,
|
"jspay_url": resp.JSPayURL,
|
||||||
"jspay_info": resp.JSPayInfo,
|
"jspay_info": resp.JSPayInfo,
|
||||||
"raw_request": jsonMap(rawReq),
|
"raw_request": jsonMap(rawReq),
|
||||||
"raw_response": jsonMap(resp.Raw),
|
"raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)),
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -130,7 +138,7 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen
|
|||||||
"third_order_id": payment.ThirdOrderID,
|
"third_order_id": payment.ThirdOrderID,
|
||||||
"leshua_order_id": payment.ProviderOrderID,
|
"leshua_order_id": payment.ProviderOrderID,
|
||||||
"status": "2",
|
"status": "2",
|
||||||
}); err != nil {
|
}, channelSourceMock); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
latest, err := r.findPaymentByID(payment.ID)
|
latest, err := r.findPaymentByID(payment.ID)
|
||||||
@@ -167,7 +175,7 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen
|
|||||||
"jspay_url": resp.JSPayURL,
|
"jspay_url": resp.JSPayURL,
|
||||||
"jspay_info": resp.JSPayInfo,
|
"jspay_info": resp.JSPayInfo,
|
||||||
"raw_request": jsonMap(rawReq),
|
"raw_request": jsonMap(rawReq),
|
||||||
"raw_response": jsonMap(resp.Raw),
|
"raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)),
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -195,7 +203,7 @@ func (r *Repository) QueryWalletRecharge(userID uint64, paymentID uint64) (*Paym
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := r.applyChannelStatus(&payment, resp.Status, resp.PayTime, resp.Raw); err != nil {
|
if err := r.applyChannelStatus(&payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
latest, err := r.findPaymentByID(payment.ID)
|
latest, err := r.findPaymentByID(payment.ID)
|
||||||
@@ -222,7 +230,7 @@ func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := r.applyChannelStatus(&payment, resp.Status, resp.PayTime, resp.Raw); err != nil {
|
if err := r.applyChannelStatus(&payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
latest, err := r.findPaymentByID(payment.ID)
|
latest, err := r.findPaymentByID(payment.ID)
|
||||||
@@ -234,8 +242,19 @@ func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) HandleLeshuaNotify(params map[string]string) (*NotifyResult, error) {
|
func (r *Repository) HandleLeshuaNotify(params map[string]string) (*NotifyResult, error) {
|
||||||
if !r.isMockMode && !r.leshua.VerifyNotify(params) {
|
if !r.isMockMode {
|
||||||
return nil, ErrPaymentVerifyFailed
|
verify := r.leshua.VerifyNotifyDetail(params)
|
||||||
|
if !verify.OK {
|
||||||
|
log.Printf(
|
||||||
|
"[payment] leshua notify verify failed third_order_id=%s got=%s expected=%v keys=%v",
|
||||||
|
params["third_order_id"],
|
||||||
|
shortSign(verify.Got),
|
||||||
|
shortExpectedSigns(verify.Expected),
|
||||||
|
verify.ParamKeys,
|
||||||
|
)
|
||||||
|
return nil, ErrPaymentVerifyFailed
|
||||||
|
}
|
||||||
|
log.Printf("[payment] leshua notify verified third_order_id=%s matched_key=%s", params["third_order_id"], verify.MatchedKey)
|
||||||
}
|
}
|
||||||
thirdOrderID := params["third_order_id"]
|
thirdOrderID := params["third_order_id"]
|
||||||
if thirdOrderID == "" {
|
if thirdOrderID == "" {
|
||||||
@@ -251,7 +270,7 @@ func (r *Repository) HandleLeshuaNotify(params map[string]string) (*NotifyResult
|
|||||||
if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent {
|
if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent {
|
||||||
return nil, ErrPaymentVerifyFailed
|
return nil, ErrPaymentVerifyFailed
|
||||||
}
|
}
|
||||||
if err := r.applyChannelStatus(&payment, params["status"], params["pay_time"], params); err != nil {
|
if err := r.applyChannelStatus(&payment, params["status"], params["pay_time"], params, channelSourceNotify); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &NotifyResult{OK: true, Message: "000000"}, nil
|
return &NotifyResult{OK: true, Message: "000000"}, nil
|
||||||
@@ -366,7 +385,7 @@ func (r *Repository) createWalletRechargePayment(userID uint64, amountCent int64
|
|||||||
return &payment, nil
|
return &payment, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) applyChannelStatus(payment *model.PaymentOrder, status string, payTime string, raw map[string]string) error {
|
func (r *Repository) applyChannelStatus(payment *model.PaymentOrder, status string, payTime string, raw map[string]string, source string) error {
|
||||||
switch status {
|
switch status {
|
||||||
case "2", "30":
|
case "2", "30":
|
||||||
paidAt := parseLeshuaTime(payTime)
|
paidAt := parseLeshuaTime(payTime)
|
||||||
@@ -374,29 +393,28 @@ func (r *Repository) applyChannelStatus(payment *model.PaymentOrder, status stri
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
paidAt = &now
|
paidAt = &now
|
||||||
}
|
}
|
||||||
return r.confirmPaid(payment, status, *paidAt, raw)
|
return r.confirmPaid(payment, status, *paidAt, raw, source)
|
||||||
case "6":
|
case "6":
|
||||||
return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
|
return r.updateChannelStatus(payment.ID, "closed", raw, source)
|
||||||
"status": "closed",
|
|
||||||
"raw_response": jsonMap(raw),
|
|
||||||
"notified_at": time.Now(),
|
|
||||||
}).Error
|
|
||||||
case "8":
|
case "8":
|
||||||
return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
|
return r.updateChannelStatus(payment.ID, "failed", raw, source)
|
||||||
"status": "failed",
|
|
||||||
"raw_response": jsonMap(raw),
|
|
||||||
"notified_at": time.Now(),
|
|
||||||
}).Error
|
|
||||||
default:
|
default:
|
||||||
return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
|
return r.updateChannelStatus(payment.ID, "paying", raw, source)
|
||||||
"status": "paying",
|
|
||||||
"raw_response": jsonMap(raw),
|
|
||||||
"notified_at": time.Now(),
|
|
||||||
}).Error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) confirmPaid(payment *model.PaymentOrder, status string, paidAt time.Time, raw map[string]string) error {
|
func (r *Repository) updateChannelStatus(paymentID uint64, status string, raw map[string]string, source string) error {
|
||||||
|
updates := map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"raw_response": jsonMap(withRawSource(raw, source)),
|
||||||
|
}
|
||||||
|
if source == channelSourceNotify {
|
||||||
|
updates["notified_at"] = time.Now()
|
||||||
|
}
|
||||||
|
return r.db.Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) confirmPaid(payment *model.PaymentOrder, status string, paidAt time.Time, raw map[string]string, source string) error {
|
||||||
if payment.Status != "paid" {
|
if payment.Status != "paid" {
|
||||||
if payment.OrderID == 0 {
|
if payment.OrderID == 0 {
|
||||||
if r.walletRepo == nil {
|
if r.walletRepo == nil {
|
||||||
@@ -414,13 +432,16 @@ func (r *Repository) confirmPaid(payment *model.PaymentOrder, status string, pai
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
|
updates := map[string]any{
|
||||||
"status": "paid",
|
"status": "paid",
|
||||||
"provider_order_id": firstNonEmpty(raw["leshua_order_id"], payment.ProviderOrderID),
|
"provider_order_id": firstNonEmpty(raw["leshua_order_id"], payment.ProviderOrderID),
|
||||||
"raw_response": jsonMap(raw),
|
"raw_response": jsonMap(withRawSource(raw, source)),
|
||||||
"paid_at": paidAt,
|
"paid_at": paidAt,
|
||||||
"notified_at": time.Now(),
|
}
|
||||||
}).Error
|
if source == channelSourceNotify {
|
||||||
|
updates["notified_at"] = time.Now()
|
||||||
|
}
|
||||||
|
return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(updates).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) markPaymentFailed(paymentID uint64, raw map[string]string, message string) error {
|
func (r *Repository) markPaymentFailed(paymentID uint64, raw map[string]string, message string) error {
|
||||||
@@ -498,6 +519,18 @@ func jsonMap(value map[string]string) datatypes.JSON {
|
|||||||
return datatypes.JSON(raw)
|
return datatypes.JSON(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withRawSource(raw map[string]string, source string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for key, value := range raw {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
if source != "" {
|
||||||
|
out["_source"] = source
|
||||||
|
}
|
||||||
|
out["_recorded_at"] = time.Now().Format(time.RFC3339)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func newPaymentNo() (string, error) {
|
func newPaymentNo() (string, error) {
|
||||||
buf := make([]byte, 4)
|
buf := make([]byte, 4)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
@@ -514,3 +547,21 @@ func firstNonEmpty(values ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shortExpectedSigns(values map[string]string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for key, value := range values {
|
||||||
|
out[key] = shortSign(value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortSign(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(value) <= 12 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value[:12] + "..."
|
||||||
|
}
|
||||||
|
|||||||
@@ -222,8 +222,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
orderRoutes.GET("", orderHandler.List)
|
orderRoutes.GET("", orderHandler.List)
|
||||||
orderRoutes.GET("/:id", orderHandler.Detail)
|
orderRoutes.GET("/:id", orderHandler.Detail)
|
||||||
orderRoutes.GET("/:id/chat", chatHandler.OrderConversation)
|
orderRoutes.GET("/:id/chat", chatHandler.OrderConversation)
|
||||||
orderRoutes.POST("/:id/pay", paymentHandler.Start)
|
orderRoutes.POST("/:id/pay", orderHandler.Pay)
|
||||||
orderRoutes.POST("/:id/pay/query", paymentHandler.Query)
|
|
||||||
orderRoutes.POST("/:id/cancel", orderHandler.Cancel)
|
orderRoutes.POST("/:id/cancel", orderHandler.Cancel)
|
||||||
orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff)
|
orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff)
|
||||||
orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords)
|
orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords)
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
|||||||
- `GET /api/orders`
|
- `GET /api/orders`
|
||||||
- `GET /api/orders/{id}`
|
- `GET /api/orders/{id}`
|
||||||
- `POST /api/orders/{id}/pay`
|
- `POST /api/orders/{id}/pay`
|
||||||
- `POST /api/orders/{id}/pay/query`
|
|
||||||
- `POST /api/orders/{id}/cancel`
|
- `POST /api/orders/{id}/cancel`
|
||||||
- `POST /api/orders/{id}/handoff`
|
- `POST /api/orders/{id}/handoff`
|
||||||
- `GET /api/orders/{id}/handoff-records`
|
- `GET /api/orders/{id}/handoff-records`
|
||||||
|
|||||||
+7
-6
@@ -354,18 +354,19 @@ MD5 签名步骤:
|
|||||||
|
|
||||||
| API | 说明 |
|
| API | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `POST /api/orders/:id/pay` | 创建或复用支付单,mock 模式会立即模拟渠道支付成功。 |
|
| `POST /api/wallet/recharge/pay` | 创建钱包充值支付单,返回扫码支付二维码链接;mock 模式会立即模拟充值成功。 |
|
||||||
| `POST /api/orders/:id/pay/query` | 查询支付单并主动向乐刷查单。 |
|
| `POST /api/wallet/recharge/pay/:id/query` | 查询钱包充值支付单,并主动向乐刷查单。 |
|
||||||
|
| `POST /api/orders/:id/pay` | 订单只使用钱包余额支付,不再直接调用乐刷。 |
|
||||||
| `POST /api/payments/leshua/notify` | 乐刷支付通知回调,不需要登录鉴权,成功返回 `000000`。 |
|
| `POST /api/payments/leshua/notify` | 乐刷支付通知回调,不需要登录鉴权,成功返回 `000000`。 |
|
||||||
|
|
||||||
数据模型:
|
数据模型:
|
||||||
|
|
||||||
- 新增 `payment_orders` 表保存支付单、渠道单号、支付链接、请求/响应原文、状态和支付时间。
|
- 新增 `payment_orders` 表保存支付单、渠道单号、支付链接、请求/响应原文、状态和支付时间。
|
||||||
- `third_order_id` 当前使用租号订单号,后续如果要支持关闭后重新发起多次支付,应改为支付单号或订单号加支付轮次。
|
- 乐刷支付单仅用于钱包充值,`order_id = 0`,`third_order_id` 使用 `payment_no`。
|
||||||
- 渠道确认支付成功后,不扣用户 `available`,直接向租客 `frozen` 写入 `channel_order_lock`,并推进订单到 `pending_handoff`,后续结账沿用现有冻结释放/结算逻辑。
|
- 渠道确认钱包充值成功后,向用户 `available` 写入 `channel_recharge` 流水。
|
||||||
|
- 订单支付不再创建乐刷支付单,只扣用户钱包 `available` 并转入 `frozen`,后续结账沿用现有冻结释放/结算逻辑。
|
||||||
|
|
||||||
当前范围:
|
当前范围:
|
||||||
|
|
||||||
- 已接:扫码/简易支付下单、查单、支付成功通知、mock 跑通链路。
|
- 已接:钱包扫码/简易支付充值、查单、支付成功通知、mock 跑通链路;订单钱包余额支付。
|
||||||
- 暂缓:条码支付、JSAPI/小程序必要 openid 获取、退款、退款通知、关单、刷卡交易查询、SM3 签名。
|
- 暂缓:条码支付、JSAPI/小程序必要 openid 获取、退款、退款通知、关单、刷卡交易查询、SM3 签名。
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ export interface PaymentOrder {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PayOrderResult {
|
||||||
|
paid: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubmitCheckoutPayload {
|
export interface SubmitCheckoutPayload {
|
||||||
content: string
|
content: string
|
||||||
consumable_amount: number
|
consumable_amount: number
|
||||||
@@ -116,12 +120,7 @@ export async function createOrder(listingId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function payOrder(id: number) {
|
export async function payOrder(id: number) {
|
||||||
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>(`/orders/${id}/pay`)
|
const { data } = await apiClient.post<ApiResponse<PayOrderResult>>(`/orders/${id}/pay`)
|
||||||
return data.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function queryOrderPayment(id: number) {
|
|
||||||
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>(`/orders/${id}/pay/query`)
|
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, computed } from "vue";
|
import { onMounted, ref, computed } from "vue";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { showDialog, showToast } from "vant";
|
import { showConfirmDialog, showToast } from "vant";
|
||||||
import MobileBottomNav from "@/components/MobileBottomNav.vue";
|
import MobileBottomNav from "@/components/MobileBottomNav.vue";
|
||||||
|
|
||||||
import { fetchOrders, payOrder, queryOrderPayment, type Order, type PaymentOrder } from "@/api/orders";
|
import { fetchOrders, payOrder, type Order } from "@/api/orders";
|
||||||
import { useSessionStore } from "@/stores/session";
|
import { useSessionStore } from "@/stores/session";
|
||||||
import { formatDateMinute } from "@/utils/time";
|
import { formatDateMinute } from "@/utils/time";
|
||||||
|
|
||||||
@@ -75,63 +75,44 @@ function goDetail(id: number) {
|
|||||||
async function handlePay(order: Order) {
|
async function handlePay(order: Order) {
|
||||||
payingOrderId.value = order.id;
|
payingOrderId.value = order.id;
|
||||||
try {
|
try {
|
||||||
const payment = await payOrder(order.id);
|
await payOrder(order.id);
|
||||||
if (payment.paid) {
|
showToast({ message: "支付成功,等待号主交接", icon: "passed" });
|
||||||
showToast({ message: "支付成功", icon: "passed" });
|
|
||||||
} else {
|
|
||||||
await showPaymentDialog(order, payment);
|
|
||||||
}
|
|
||||||
await loadOrders();
|
await loadOrders();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast({ message: readError(error, "支付失败"), icon: "cross" });
|
const message = readError(error, "支付失败");
|
||||||
|
if (isInsufficientBalance(error)) {
|
||||||
|
await showRechargeGuide(message);
|
||||||
|
} else {
|
||||||
|
showToast({ message, icon: "cross" });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
payingOrderId.value = null;
|
payingOrderId.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showPaymentDialog(order: Order, payment: PaymentOrder) {
|
async function showRechargeGuide(message: string) {
|
||||||
const payURL = payment.jspay_url || payment.td_code || payment.jspay_info || "";
|
await showConfirmDialog({
|
||||||
const message = payURL
|
title: "余额不足",
|
||||||
? `支付单已创建,金额 ¥${(payment.amount_cent / 100).toFixed(2)}。请复制或打开支付链接完成付款,付款后可刷新订单状态。\n\n${payURL}`
|
message: `${message}。订单支付只使用钱包余额,请先充值后再支付订单。`,
|
||||||
: "支付单已创建,请完成付款后刷新订单状态。";
|
confirmButtonText: "去充值",
|
||||||
await showDialog({
|
cancelButtonText: "稍后再说",
|
||||||
title: "去支付",
|
|
||||||
message,
|
|
||||||
confirmButtonText: payURL ? "打开/复制" : "刷新状态",
|
|
||||||
cancelButtonText: "刷新状态",
|
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(() => {
|
||||||
if (payURL) {
|
router.push("/wallet");
|
||||||
await openOrCopyPayURL(payURL);
|
|
||||||
}
|
|
||||||
await refreshPayment(order.id);
|
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(() => {
|
||||||
await refreshPayment(order.id);
|
// 用户取消引导时不需要额外提示。
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openOrCopyPayURL(payURL: string) {
|
function isInsufficientBalance(error: unknown) {
|
||||||
if (/^https?:\/\//.test(payURL)) {
|
if (typeof error === "object" && error && "response" in error) {
|
||||||
window.open(payURL, "_blank");
|
const response = (error as { response?: { data?: { code?: string } } })
|
||||||
return;
|
.response;
|
||||||
}
|
return response?.data?.code === "insufficient_balance";
|
||||||
try {
|
|
||||||
await navigator.clipboard?.writeText(payURL);
|
|
||||||
showToast({ message: "支付链接已复制", icon: "passed" });
|
|
||||||
} catch {
|
|
||||||
showToast({ message: "请手动复制支付链接", icon: "warning-o" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshPayment(orderId: number) {
|
|
||||||
const payment = await queryOrderPayment(orderId);
|
|
||||||
if (payment.paid) {
|
|
||||||
showToast({ message: "支付成功", icon: "passed" });
|
|
||||||
} else {
|
|
||||||
showToast({ message: "支付未完成", icon: "clock-o" });
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readError(error: unknown, fallback: string) {
|
function readError(error: unknown, fallback: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user