钱包结算所有, 第三方充值到钱包

This commit is contained in:
yml
2026-06-03 17:41:24 +08:00
parent eb7c0045cd
commit a1942c8453
9 changed files with 211 additions and 102 deletions
@@ -72,6 +72,14 @@ type QueryPaymentResponse struct {
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 {
return &Client{
cfg: cfg,
@@ -171,19 +179,60 @@ func (c *Client) QueryPayment(ctx context.Context, thirdOrderID, providerOrderID
}
func (c *Client) VerifyNotify(params map[string]string) bool {
key := firstNonEmpty(c.cfg.NotifyKey, c.cfg.SignKey)
if key == "" {
return false
}
return c.VerifyNotifyDetail(params).OK
}
func (c *Client) VerifyNotifyDetail(params map[string]string) VerifyNotifyResult {
got := strings.ToUpper(params["sign"])
if got == "" {
return false
result := VerifyNotifyResult{
Got: got,
Expected: map[string]string{},
ParamKeys: notifyParamKeys(params),
}
expected := Sign(params, key, SignOptions{
IncludeEmpty: true,
ExcludeKeys: []string{"error_code", "sign"},
})
return got == expected
if got == "" {
return result
}
for _, item := range c.notifyKeyCandidates() {
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 {
@@ -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) {
form, err := ParsePayload([]byte("third_order_id=NO1&status=2&amount=100"))
if err != nil {
@@ -3,6 +3,7 @@ package payment
import (
"errors"
"io"
"log"
"net/http"
"strconv"
@@ -109,11 +110,14 @@ func (h *Handler) LeshuaNotify(c *gin.Context) {
c.String(http.StatusBadRequest, "FAIL")
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)
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")
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)
}
+82 -31
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
"math"
"time"
@@ -30,6 +31,13 @@ type Repository struct {
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 {
provider := cfg.Provider
if provider == "" {
@@ -61,7 +69,7 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques
"third_order_id": payment.ThirdOrderID,
"leshua_order_id": payment.ProviderOrderID,
"status": "2",
}); err != nil {
}, channelSourceMock); err != nil {
return nil, err
}
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_info": resp.JSPayInfo,
"raw_request": jsonMap(rawReq),
"raw_response": jsonMap(resp.Raw),
"raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)),
}).Error; err != nil {
return nil, err
}
@@ -130,7 +138,7 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen
"third_order_id": payment.ThirdOrderID,
"leshua_order_id": payment.ProviderOrderID,
"status": "2",
}); err != nil {
}, channelSourceMock); err != nil {
return nil, err
}
latest, err := r.findPaymentByID(payment.ID)
@@ -167,7 +175,7 @@ func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymen
"jspay_url": resp.JSPayURL,
"jspay_info": resp.JSPayInfo,
"raw_request": jsonMap(rawReq),
"raw_response": jsonMap(resp.Raw),
"raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)),
}).Error; err != nil {
return nil, err
}
@@ -195,7 +203,7 @@ func (r *Repository) QueryWalletRecharge(userID uint64, paymentID uint64) (*Paym
if err != nil {
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
}
latest, err := r.findPaymentByID(payment.ID)
@@ -222,7 +230,7 @@ func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) {
if err != nil {
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
}
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) {
if !r.isMockMode && !r.leshua.VerifyNotify(params) {
return nil, ErrPaymentVerifyFailed
if !r.isMockMode {
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"]
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 {
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 &NotifyResult{OK: true, Message: "000000"}, nil
@@ -366,7 +385,7 @@ func (r *Repository) createWalletRechargePayment(userID uint64, amountCent int64
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 {
case "2", "30":
paidAt := parseLeshuaTime(payTime)
@@ -374,29 +393,28 @@ func (r *Repository) applyChannelStatus(payment *model.PaymentOrder, status stri
now := time.Now()
paidAt = &now
}
return r.confirmPaid(payment, status, *paidAt, raw)
return r.confirmPaid(payment, status, *paidAt, raw, source)
case "6":
return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
"status": "closed",
"raw_response": jsonMap(raw),
"notified_at": time.Now(),
}).Error
return r.updateChannelStatus(payment.ID, "closed", raw, source)
case "8":
return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
"status": "failed",
"raw_response": jsonMap(raw),
"notified_at": time.Now(),
}).Error
return r.updateChannelStatus(payment.ID, "failed", raw, source)
default:
return r.db.Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
"status": "paying",
"raw_response": jsonMap(raw),
"notified_at": time.Now(),
}).Error
return r.updateChannelStatus(payment.ID, "paying", raw, source)
}
}
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.OrderID == 0 {
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",
"provider_order_id": firstNonEmpty(raw["leshua_order_id"], payment.ProviderOrderID),
"raw_response": jsonMap(raw),
"raw_response": jsonMap(withRawSource(raw, source)),
"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 {
@@ -498,6 +519,18 @@ func jsonMap(value map[string]string) datatypes.JSON {
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) {
buf := make([]byte, 4)
if _, err := rand.Read(buf); err != nil {
@@ -514,3 +547,21 @@ func firstNonEmpty(values ...string) string {
}
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] + "..."
}
+1 -2
View File
@@ -222,8 +222,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
orderRoutes.GET("", orderHandler.List)
orderRoutes.GET("/:id", orderHandler.Detail)
orderRoutes.GET("/:id/chat", chatHandler.OrderConversation)
orderRoutes.POST("/:id/pay", paymentHandler.Start)
orderRoutes.POST("/:id/pay/query", paymentHandler.Query)
orderRoutes.POST("/:id/pay", orderHandler.Pay)
orderRoutes.POST("/:id/cancel", orderHandler.Cancel)
orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff)
orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords)