优化乐刷通知与钱包充值流程
This commit is contained in:
@@ -77,6 +77,7 @@ type VerifyNotifyResult struct {
|
|||||||
MatchedKey string
|
MatchedKey string
|
||||||
Got string
|
Got string
|
||||||
Expected map[string]string
|
Expected map[string]string
|
||||||
|
BaseString map[string]string
|
||||||
ParamKeys []string
|
ParamKeys []string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,19 +186,19 @@ func (c *Client) VerifyNotify(params map[string]string) bool {
|
|||||||
func (c *Client) VerifyNotifyDetail(params map[string]string) VerifyNotifyResult {
|
func (c *Client) VerifyNotifyDetail(params map[string]string) VerifyNotifyResult {
|
||||||
got := strings.ToUpper(params["sign"])
|
got := strings.ToUpper(params["sign"])
|
||||||
result := VerifyNotifyResult{
|
result := VerifyNotifyResult{
|
||||||
Got: got,
|
Got: got,
|
||||||
Expected: map[string]string{},
|
Expected: map[string]string{},
|
||||||
ParamKeys: notifyParamKeys(params),
|
BaseString: map[string]string{},
|
||||||
|
ParamKeys: notifyParamKeys(params),
|
||||||
}
|
}
|
||||||
if got == "" {
|
if got == "" {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
for _, item := range c.notifyKeyCandidates() {
|
for _, item := range c.notifyKeyCandidates() {
|
||||||
expected := Sign(params, item.key, SignOptions{
|
expected := Sign(params, item.key, notifySignOptions())
|
||||||
IncludeEmpty: true,
|
baseString := SignBaseString(params, notifySignOptions())
|
||||||
ExcludeKeys: []string{"error_code", "sign"},
|
|
||||||
})
|
|
||||||
result.Expected[item.name] = expected
|
result.Expected[item.name] = expected
|
||||||
|
result.BaseString[item.name] = baseString
|
||||||
if got == expected {
|
if got == expected {
|
||||||
result.OK = true
|
result.OK = true
|
||||||
result.MatchedKey = item.name
|
result.MatchedKey = item.name
|
||||||
@@ -207,6 +208,13 @@ func (c *Client) VerifyNotifyDetail(params map[string]string) VerifyNotifyResult
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func notifySignOptions() SignOptions {
|
||||||
|
return SignOptions{
|
||||||
|
IncludeEmpty: true,
|
||||||
|
ExcludeKeys: []string{"error_code", "leshua", "sign"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type notifyKeyCandidate struct {
|
type notifyKeyCandidate struct {
|
||||||
name string
|
name string
|
||||||
key string
|
key string
|
||||||
@@ -217,16 +225,13 @@ func (c *Client) notifyKeyCandidates() []notifyKeyCandidate {
|
|||||||
if c.cfg.NotifyKey != "" {
|
if c.cfg.NotifyKey != "" {
|
||||||
candidates = append(candidates, notifyKeyCandidate{name: "notify_key", key: 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
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
func notifyParamKeys(params map[string]string) []string {
|
func notifyParamKeys(params map[string]string) []string {
|
||||||
keys := make([]string, 0, len(params))
|
keys := make([]string, 0, len(params))
|
||||||
for key := range params {
|
for key := range params {
|
||||||
if key == "sign" || key == "error_code" {
|
if key == "sign" || key == "error_code" || key == "leshua" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
keys = append(keys, key)
|
keys = append(keys, key)
|
||||||
@@ -276,6 +281,16 @@ type SignOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Sign(params map[string]string, key string, opts SignOptions) string {
|
func Sign(params map[string]string, key string, opts SignOptions) string {
|
||||||
|
baseString := SignBaseString(params, opts)
|
||||||
|
stringSignTemp := "key=" + key
|
||||||
|
if baseString != "" {
|
||||||
|
stringSignTemp = baseString + "&key=" + key
|
||||||
|
}
|
||||||
|
sum := md5.Sum([]byte(stringSignTemp))
|
||||||
|
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func SignBaseString(params map[string]string, opts SignOptions) string {
|
||||||
excluded := map[string]bool{}
|
excluded := map[string]bool{}
|
||||||
for _, item := range opts.ExcludeKeys {
|
for _, item := range opts.ExcludeKeys {
|
||||||
excluded[item] = true
|
excluded[item] = true
|
||||||
@@ -298,9 +313,7 @@ func Sign(params map[string]string, key string, opts SignOptions) string {
|
|||||||
for _, name := range keys {
|
for _, name := range keys {
|
||||||
parts = append(parts, name+"="+params[name])
|
parts = append(parts, name+"="+params[name])
|
||||||
}
|
}
|
||||||
parts = append(parts, "key="+key)
|
return strings.Join(parts, "&")
|
||||||
sum := md5.Sum([]byte(strings.Join(parts, "&")))
|
|
||||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParsePayload(body []byte) (map[string]string, error) {
|
func ParsePayload(body []byte) (map[string]string, error) {
|
||||||
@@ -342,6 +355,7 @@ func parseXMLPayload(body []byte) (map[string]string, error) {
|
|||||||
decoder := xml.NewDecoder(bytes.NewReader(body))
|
decoder := xml.NewDecoder(bytes.NewReader(body))
|
||||||
out := map[string]string{}
|
out := map[string]string{}
|
||||||
var current string
|
var current string
|
||||||
|
depth := 0
|
||||||
for {
|
for {
|
||||||
token, err := decoder.Token()
|
token, err := decoder.Token()
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
@@ -352,14 +366,25 @@ func parseXMLPayload(body []byte) (map[string]string, error) {
|
|||||||
}
|
}
|
||||||
switch item := token.(type) {
|
switch item := token.(type) {
|
||||||
case xml.StartElement:
|
case xml.StartElement:
|
||||||
current = item.Name.Local
|
depth++
|
||||||
|
if depth > 1 {
|
||||||
|
current = item.Name.Local
|
||||||
|
if _, exists := out[current]; !exists {
|
||||||
|
out[current] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
case xml.CharData:
|
case xml.CharData:
|
||||||
value := strings.TrimSpace(string(item))
|
value := strings.TrimSpace(string(item))
|
||||||
if current != "" && current != "xml" && value != "" {
|
if current != "" && value != "" {
|
||||||
out[current] = value
|
out[current] = value
|
||||||
}
|
}
|
||||||
case xml.EndElement:
|
case xml.EndElement:
|
||||||
current = ""
|
if current == item.Name.Local {
|
||||||
|
current = ""
|
||||||
|
}
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package leshua
|
package leshua
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/config"
|
"hfb_sys/backend/internal/config"
|
||||||
@@ -22,6 +23,12 @@ func TestSignUsesASCIISortedNonEmptyParams(t *testing.T) {
|
|||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("Sign() = %s, want %s", got, want)
|
t.Fatalf("Sign() = %s, want %s", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
baseString := SignBaseString(params, SignOptions{})
|
||||||
|
wantBaseString := "amount=100&merchant_id=1234567890&nonce_str=abc&service=get_tdcode&third_order_id=NO1"
|
||||||
|
if baseString != wantBaseString {
|
||||||
|
t.Fatalf("SignBaseString() = %s, want %s", baseString, wantBaseString)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVerifyNotifyIncludesEmptyAndExcludesErrorCode(t *testing.T) {
|
func TestVerifyNotifyIncludesEmptyAndExcludesErrorCode(t *testing.T) {
|
||||||
@@ -33,11 +40,12 @@ func TestVerifyNotifyIncludesEmptyAndExcludesErrorCode(t *testing.T) {
|
|||||||
"amount": "100",
|
"amount": "100",
|
||||||
"status": "2",
|
"status": "2",
|
||||||
"attach": "",
|
"attach": "",
|
||||||
|
"leshua": "",
|
||||||
"error_code": "-20001",
|
"error_code": "-20001",
|
||||||
}
|
}
|
||||||
params["sign"] = Sign(params, "notify-secret", SignOptions{
|
params["sign"] = Sign(params, "notify-secret", SignOptions{
|
||||||
IncludeEmpty: true,
|
IncludeEmpty: true,
|
||||||
ExcludeKeys: []string{"error_code", "sign"},
|
ExcludeKeys: []string{"error_code", "leshua", "sign"},
|
||||||
})
|
})
|
||||||
|
|
||||||
if !client.VerifyNotify(params) {
|
if !client.VerifyNotify(params) {
|
||||||
@@ -50,7 +58,7 @@ func TestVerifyNotifyIncludesEmptyAndExcludesErrorCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVerifyNotifyFallsBackToSignKey(t *testing.T) {
|
func TestVerifyNotifyDoesNotFallBackToSignKey(t *testing.T) {
|
||||||
client := NewClient(config.LeshuaPaymentConfig{
|
client := NewClient(config.LeshuaPaymentConfig{
|
||||||
NotifyKey: "wrong-notify-secret",
|
NotifyKey: "wrong-notify-secret",
|
||||||
SignKey: "sign-secret",
|
SignKey: "sign-secret",
|
||||||
@@ -67,12 +75,64 @@ func TestVerifyNotifyFallsBackToSignKey(t *testing.T) {
|
|||||||
ExcludeKeys: []string{"error_code", "sign"},
|
ExcludeKeys: []string{"error_code", "sign"},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
result := client.VerifyNotifyDetail(params)
|
||||||
|
if result.OK {
|
||||||
|
t.Fatal("VerifyNotifyDetail().OK = true, want false")
|
||||||
|
}
|
||||||
|
if _, ok := result.Expected["sign_key"]; ok {
|
||||||
|
t.Fatal("VerifyNotifyDetail() unexpectedly used sign_key fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyNotifyUsesDocumentedNotifySignature(t *testing.T) {
|
||||||
|
client := NewClient(config.LeshuaPaymentConfig{NotifyKey: "notify-secret"})
|
||||||
|
params := map[string]string{
|
||||||
|
"merchant_id": "1234567890",
|
||||||
|
"third_order_id": "NO1",
|
||||||
|
"leshua_order_id": "LS1",
|
||||||
|
"amount": "100",
|
||||||
|
"status": "2",
|
||||||
|
"sign_type": "MD5",
|
||||||
|
}
|
||||||
|
params["sign"] = Sign(params, "notify-secret", SignOptions{
|
||||||
|
IncludeEmpty: true,
|
||||||
|
ExcludeKeys: []string{"error_code", "leshua", "sign"},
|
||||||
|
})
|
||||||
|
|
||||||
result := client.VerifyNotifyDetail(params)
|
result := client.VerifyNotifyDetail(params)
|
||||||
if !result.OK {
|
if !result.OK {
|
||||||
t.Fatal("VerifyNotifyDetail().OK = false, want true")
|
t.Fatal("VerifyNotifyDetail().OK = false, want true")
|
||||||
}
|
}
|
||||||
if result.MatchedKey != "sign_key" {
|
if result.MatchedKey != "notify_key" {
|
||||||
t.Fatalf("MatchedKey = %s, want sign_key", result.MatchedKey)
|
t.Fatalf("MatchedKey = %s, want notify_key", result.MatchedKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyNotifyKeepsEmptyXMLFieldsInSignature(t *testing.T) {
|
||||||
|
client := NewClient(config.LeshuaPaymentConfig{NotifyKey: "notify-secret"})
|
||||||
|
params, err := ParsePayload([]byte(`<leshua>
|
||||||
|
<amount>100</amount>
|
||||||
|
<goods_tag></goods_tag>
|
||||||
|
<merchant_id>1234567890</merchant_id>
|
||||||
|
<sign_type>MD5</sign_type>
|
||||||
|
<status>2</status>
|
||||||
|
<third_order_id>NO1</third_order_id>
|
||||||
|
</leshua>`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParsePayload(xml) error = %v", err)
|
||||||
|
}
|
||||||
|
params["sign"] = Sign(params, "notify-secret", SignOptions{
|
||||||
|
IncludeEmpty: true,
|
||||||
|
ExcludeKeys: []string{"error_code", "leshua", "sign"},
|
||||||
|
})
|
||||||
|
|
||||||
|
result := client.VerifyNotifyDetail(params)
|
||||||
|
if !result.OK {
|
||||||
|
t.Fatal("VerifyNotifyDetail().OK = false, want true")
|
||||||
|
}
|
||||||
|
baseString := result.BaseString[result.MatchedKey]
|
||||||
|
if !strings.Contains(baseString, "goods_tag=") {
|
||||||
|
t.Fatalf("baseString = %s, want goods_tag included", baseString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,11 +145,17 @@ func TestParsePayloadSupportsFormAndXML(t *testing.T) {
|
|||||||
t.Fatalf("ParsePayload(form) = %#v", form)
|
t.Fatalf("ParsePayload(form) = %#v", form)
|
||||||
}
|
}
|
||||||
|
|
||||||
xml, err := ParsePayload([]byte("<xml><third_order_id>NO2</third_order_id><status>6</status></xml>"))
|
xml, err := ParsePayload([]byte("<xml><third_order_id>NO2</third_order_id><status>6</status><goods_tag></goods_tag><coupon/></xml>"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParsePayload(xml) error = %v", err)
|
t.Fatalf("ParsePayload(xml) error = %v", err)
|
||||||
}
|
}
|
||||||
if xml["third_order_id"] != "NO2" || xml["status"] != "6" {
|
if xml["third_order_id"] != "NO2" || xml["status"] != "6" {
|
||||||
t.Fatalf("ParsePayload(xml) = %#v", xml)
|
t.Fatalf("ParsePayload(xml) = %#v", xml)
|
||||||
}
|
}
|
||||||
|
if value, ok := xml["goods_tag"]; !ok || value != "" {
|
||||||
|
t.Fatalf("ParsePayload(xml).goods_tag = %q, exists=%v; want empty value", value, ok)
|
||||||
|
}
|
||||||
|
if value, ok := xml["coupon"]; !ok || value != "" {
|
||||||
|
t.Fatalf("ParsePayload(xml).coupon = %q, exists=%v; want empty value", value, ok)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,8 +110,18 @@ 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"])
|
rawPayload := string(body)
|
||||||
result, err := h.service.HandleLeshuaNotify(params)
|
contentType := c.GetHeader("Content-Type")
|
||||||
|
log.Printf(
|
||||||
|
"[payment] leshua notify received third_order_id=%s leshua_order_id=%s status=%s amount=%s content_type=%s raw_payload=%s",
|
||||||
|
params["third_order_id"],
|
||||||
|
params["leshua_order_id"],
|
||||||
|
params["status"],
|
||||||
|
params["amount"],
|
||||||
|
contentType,
|
||||||
|
rawPayload,
|
||||||
|
)
|
||||||
|
result, err := h.service.HandleLeshuaNotify(params, rawPayload, contentType)
|
||||||
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)
|
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")
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func (r *Repository) Start(userID uint64, orderID uint64, req StartPaymentReques
|
|||||||
|
|
||||||
func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymentRequest, clientIP string) (*PaymentDTO, error) {
|
func (r *Repository) StartWalletRecharge(userID uint64, req WalletRechargePaymentRequest, clientIP string) (*PaymentDTO, error) {
|
||||||
amountCent := moneyCent(req.Amount)
|
amountCent := moneyCent(req.Amount)
|
||||||
if userID == 0 || amountCent <= 0 {
|
if userID == 0 || req.Amount < MinWalletRechargeAmount || amountCent <= 0 {
|
||||||
return nil, ErrPaymentCannotStart
|
return nil, ErrPaymentCannotStart
|
||||||
}
|
}
|
||||||
payment, err := r.createWalletRechargePayment(userID, amountCent, req)
|
payment, err := r.createWalletRechargePayment(userID, amountCent, req)
|
||||||
@@ -241,17 +241,22 @@ func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) {
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) HandleLeshuaNotify(params map[string]string) (*NotifyResult, error) {
|
func (r *Repository) HandleLeshuaNotify(params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) {
|
||||||
|
var verify leshua.VerifyNotifyResult
|
||||||
if !r.isMockMode {
|
if !r.isMockMode {
|
||||||
verify := r.leshua.VerifyNotifyDetail(params)
|
verify = r.leshua.VerifyNotifyDetail(params)
|
||||||
if !verify.OK {
|
if !verify.OK {
|
||||||
log.Printf(
|
log.Printf(
|
||||||
"[payment] leshua notify verify failed third_order_id=%s got=%s expected=%v keys=%v",
|
"[payment] leshua notify verify failed third_order_id=%s got=%s expected=%s keys=%v base_string=%s",
|
||||||
params["third_order_id"],
|
params["third_order_id"],
|
||||||
shortSign(verify.Got),
|
verify.Got,
|
||||||
shortExpectedSigns(verify.Expected),
|
verify.Expected["notify_key"],
|
||||||
verify.ParamKeys,
|
verify.ParamKeys,
|
||||||
|
verify.BaseString["notify_key"],
|
||||||
)
|
)
|
||||||
|
if err := r.recordNotifyDiagnostic(params, rawPayload, contentType, verify, "verify_failed"); err != nil {
|
||||||
|
log.Printf("[payment] leshua notify diagnostic save failed third_order_id=%s err=%v", params["third_order_id"], err)
|
||||||
|
}
|
||||||
return nil, ErrPaymentVerifyFailed
|
return nil, ErrPaymentVerifyFailed
|
||||||
}
|
}
|
||||||
log.Printf("[payment] leshua notify verified third_order_id=%s matched_key=%s", params["third_order_id"], verify.MatchedKey)
|
log.Printf("[payment] leshua notify verified third_order_id=%s matched_key=%s", params["third_order_id"], verify.MatchedKey)
|
||||||
@@ -268,9 +273,13 @@ func (r *Repository) HandleLeshuaNotify(params map[string]string) (*NotifyResult
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent {
|
if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent {
|
||||||
|
if err := r.recordNotifyDiagnostic(params, rawPayload, contentType, verify, "amount_mismatch"); err != nil {
|
||||||
|
log.Printf("[payment] leshua notify diagnostic save failed third_order_id=%s err=%v", params["third_order_id"], err)
|
||||||
|
}
|
||||||
return nil, ErrPaymentVerifyFailed
|
return nil, ErrPaymentVerifyFailed
|
||||||
}
|
}
|
||||||
if err := r.applyChannelStatus(&payment, params["status"], params["pay_time"], params, channelSourceNotify); err != nil {
|
raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified")
|
||||||
|
if err := r.applyChannelStatus(&payment, params["status"], params["pay_time"], raw, channelSourceNotify); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &NotifyResult{OK: true, Message: "000000"}, nil
|
return &NotifyResult{OK: true, Message: "000000"}, nil
|
||||||
@@ -531,6 +540,40 @@ func withRawSource(raw map[string]string, source string) map[string]string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) recordNotifyDiagnostic(params map[string]string, rawPayload string, contentType string, verify leshua.VerifyNotifyResult, status string) error {
|
||||||
|
thirdOrderID := params["third_order_id"]
|
||||||
|
if thirdOrderID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, status)
|
||||||
|
return r.db.Model(&model.PaymentOrder{}).
|
||||||
|
Where("third_order_id = ?", thirdOrderID).
|
||||||
|
Update("raw_response", jsonMap(raw)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func withNotifyDiagnostic(params map[string]string, rawPayload string, contentType string, verify leshua.VerifyNotifyResult, status string) map[string]string {
|
||||||
|
raw := withRawSource(params, channelSourceNotify)
|
||||||
|
raw["_notify_diagnostic_status"] = status
|
||||||
|
raw["_raw_payload"] = rawPayload
|
||||||
|
raw["_raw_content_type"] = contentType
|
||||||
|
raw["_sign_got"] = verify.Got
|
||||||
|
raw["_sign_matched_key"] = verify.MatchedKey
|
||||||
|
raw["_sign_expected"] = jsonString(verify.Expected)
|
||||||
|
raw["_sign_base_strings"] = jsonString(verify.BaseString)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonString(value map[string]string) string {
|
||||||
|
if len(value) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -547,21 +590,3 @@ 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] + "..."
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ var (
|
|||||||
ErrPaymentNotFound = errors.New("payment not found")
|
ErrPaymentNotFound = errors.New("payment not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const MinWalletRechargeAmount = 0.01
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo *Repository
|
repo *Repository
|
||||||
}
|
}
|
||||||
@@ -42,7 +44,7 @@ func (s *Service) StartWalletRecharge(userID uint64, req WalletRechargePaymentRe
|
|||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
if userID == 0 || req.Amount <= 0 {
|
if userID == 0 || req.Amount < MinWalletRechargeAmount {
|
||||||
return nil, ErrPaymentCannotStart
|
return nil, ErrPaymentCannotStart
|
||||||
}
|
}
|
||||||
return s.repo.StartWalletRecharge(userID, req, clientIP)
|
return s.repo.StartWalletRecharge(userID, req, clientIP)
|
||||||
@@ -58,9 +60,9 @@ func (s *Service) QueryWalletRecharge(userID uint64, paymentID uint64) (*Payment
|
|||||||
return s.repo.QueryWalletRecharge(userID, paymentID)
|
return s.repo.QueryWalletRecharge(userID, paymentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) HandleLeshuaNotify(params map[string]string) (*NotifyResult, error) {
|
func (s *Service) HandleLeshuaNotify(params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.HandleLeshuaNotify(params)
|
return s.repo.HandleLeshuaNotify(params, rawPayload, contentType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ var (
|
|||||||
ErrInsufficientBalance = errors.New("insufficient balance")
|
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const MinRechargeAmount = 0.01
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo *Repository
|
repo *Repository
|
||||||
}
|
}
|
||||||
@@ -34,7 +36,7 @@ func (s *Service) Recharge(userID uint64, req RechargeRequest) (*AccountDTO, err
|
|||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
if req.Amount <= 0 {
|
if req.Amount < MinRechargeAmount {
|
||||||
return nil, ErrInvalidAmount
|
return nil, ErrInvalidAmount
|
||||||
}
|
}
|
||||||
return s.repo.Recharge(userID, req.Amount)
|
return s.repo.Recharge(userID, req.Amount)
|
||||||
|
|||||||
+1699
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -44,7 +44,8 @@ MD5 签名步骤:
|
|||||||
|
|
||||||
- 请求签名:一般不包含 `sign` 本身。
|
- 请求签名:一般不包含 `sign` 本身。
|
||||||
- 应答验签:按乐刷返回参数验签,实际返回字段可能因升级增加,验签时要允许新增字段。
|
- 应答验签:按乐刷返回参数验签,实际返回字段可能因升级增加,验签时要允许新增字段。
|
||||||
- 支付/退款通知验签:`error_code` 和 `sign` 不参与签名,空值参与签名,密钥使用乐刷提供的通知验签密钥。当前实现为 `LESHUA_NOTIFY_KEY`,为空时回退 `LESHUA_SIGN_KEY`。
|
- 支付/退款通知验签:`error_code`、`leshua` 和 `sign` 不参与签名,其他返回字段按原样参与;空值参与签名;密钥只使用乐刷提供的通知验签密钥 `LESHUA_NOTIFY_KEY`。实测通知携带 `sign_type=MD5`,按普通返回字段参与签名。
|
||||||
|
- 乐刷 XML 通知里的空标签也属于空值参数,必须保留并参与签名,例如 `<goods_tag></goods_tag>` 应进入待签名串为 `goods_tag=`。
|
||||||
- `sign_type=SM3` 时签名结果为 64 位;不上传 `sign_type` 默认 MD5。
|
- `sign_type=SM3` 时签名结果为 64 位;不上传 `sign_type` 默认 MD5。
|
||||||
|
|
||||||
## 统一下单
|
## 统一下单
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
@@ -27,8 +27,11 @@ const rechargeDialogVisible = ref(false)
|
|||||||
const activeRechargePayment = ref<PaymentOrder | null>(null)
|
const activeRechargePayment = ref<PaymentOrder | null>(null)
|
||||||
const rechargeQRCodeURL = ref('')
|
const rechargeQRCodeURL = ref('')
|
||||||
const qrGenerating = ref(false)
|
const qrGenerating = ref(false)
|
||||||
|
const checkingRecharge = ref(false)
|
||||||
|
let rechargePollingTimer: number | undefined
|
||||||
|
|
||||||
onMounted(loadWallet)
|
onMounted(loadWallet)
|
||||||
|
onBeforeUnmount(stopRechargePolling)
|
||||||
|
|
||||||
async function loadWallet() {
|
async function loadWallet() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -71,7 +74,8 @@ async function handleRecharge() {
|
|||||||
function showRechargePaymentDialog(payment: PaymentOrder) {
|
function showRechargePaymentDialog(payment: PaymentOrder) {
|
||||||
activeRechargePayment.value = payment
|
activeRechargePayment.value = payment
|
||||||
rechargeDialogVisible.value = true
|
rechargeDialogVisible.value = true
|
||||||
renderRechargeQRCode(payment)
|
void renderRechargeQRCode(payment)
|
||||||
|
startRechargePolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
function rechargePayURL(payment = activeRechargePayment.value) {
|
function rechargePayURL(payment = activeRechargePayment.value) {
|
||||||
@@ -102,24 +106,54 @@ async function renderRechargeQRCode(payment = activeRechargePayment.value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRechargePayment(paymentID: number) {
|
function startRechargePolling() {
|
||||||
const payment = await queryWalletRechargePayment(paymentID)
|
stopRechargePolling()
|
||||||
if (payment.paid) {
|
rechargePollingTimer = window.setInterval(() => {
|
||||||
ElMessage.success('充值成功')
|
void refreshRechargePayment(true)
|
||||||
rechargeDialogVisible.value = false
|
}, 3000)
|
||||||
await loadWallet()
|
}
|
||||||
} else {
|
|
||||||
activeRechargePayment.value = payment
|
function stopRechargePolling() {
|
||||||
await renderRechargeQRCode(payment)
|
if (rechargePollingTimer !== undefined) {
|
||||||
ElMessage.info('支付未完成')
|
window.clearInterval(rechargePollingTimer)
|
||||||
|
rechargePollingTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRechargePayment(silent = false) {
|
||||||
|
const paymentID = activeRechargePayment.value?.id
|
||||||
|
if (!paymentID || checkingRecharge.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkingRecharge.value = true
|
||||||
|
const currentPayURL = rechargePayURL()
|
||||||
|
try {
|
||||||
|
const payment = await queryWalletRechargePayment(paymentID)
|
||||||
|
if (payment.paid) {
|
||||||
|
stopRechargePolling()
|
||||||
|
ElMessage.success('充值成功')
|
||||||
|
rechargeDialogVisible.value = false
|
||||||
|
await loadWallet()
|
||||||
|
} else {
|
||||||
|
activeRechargePayment.value = payment
|
||||||
|
if (rechargePayURL(payment) !== currentPayURL) {
|
||||||
|
await renderRechargeQRCode(payment)
|
||||||
|
}
|
||||||
|
if (!silent) {
|
||||||
|
ElMessage.info('支付未完成')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) {
|
||||||
|
ElMessage.error(readError(error, '刷新支付状态失败'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
checkingRecharge.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRefreshRechargePayment() {
|
async function handleRefreshRechargePayment() {
|
||||||
if (!activeRechargePayment.value) {
|
await refreshRechargePayment(false)
|
||||||
return
|
|
||||||
}
|
|
||||||
await refreshRechargePayment(activeRechargePayment.value.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readError(error: unknown, fallback: string) {
|
function readError(error: unknown, fallback: string) {
|
||||||
@@ -156,7 +190,7 @@ function readError(error: unknown, fallback: string) {
|
|||||||
|
|
||||||
<div class="table-panel recharge-panel">
|
<div class="table-panel recharge-panel">
|
||||||
<h2>支付充值</h2>
|
<h2>支付充值</h2>
|
||||||
<el-input-number v-model="rechargeAmount" :min="1" :precision="0" controls-position="right" />
|
<el-input-number v-model="rechargeAmount" :min="0.01" :step="0.01" :precision="2" controls-position="right" />
|
||||||
<el-button type="primary" :loading="recharging" @click="handleRecharge">发起充值</el-button>
|
<el-button type="primary" :loading="recharging" @click="handleRecharge">发起充值</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -196,6 +230,7 @@ function readError(error: unknown, fallback: string) {
|
|||||||
append-to-body
|
append-to-body
|
||||||
:z-index="4000"
|
:z-index="4000"
|
||||||
class="wallet-pay-dialog"
|
class="wallet-pay-dialog"
|
||||||
|
@closed="stopRechargePolling"
|
||||||
>
|
>
|
||||||
<div v-if="activeRechargePayment" class="pay-dialog-body">
|
<div v-if="activeRechargePayment" class="pay-dialog-body">
|
||||||
<div class="pay-summary">
|
<div class="pay-summary">
|
||||||
@@ -209,14 +244,14 @@ function readError(error: unknown, fallback: string) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="pay-scan-copy">
|
<div class="pay-scan-copy">
|
||||||
<strong>请使用微信或支付宝扫码支付</strong>
|
<strong>请使用微信或支付宝扫码支付</strong>
|
||||||
<span>不要在电脑浏览器直接打开该链接。扫码完成后点击“我已支付”刷新充值状态。</span>
|
<span>不要在电脑浏览器直接打开该链接。扫码完成后将自动刷新,也可手动确认。</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="pay-hint">充值支付单已创建,请完成付款后刷新状态。</p>
|
<p v-else class="pay-hint">充值支付单已创建,请完成付款后刷新状态。</p>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="pay-dialog-footer">
|
<div class="pay-dialog-footer">
|
||||||
<el-button type="primary" @click="handleRefreshRechargePayment">我已支付,刷新状态</el-button>
|
<el-button type="primary" :loading="checkingRecharge" @click="handleRefreshRechargePayment">我已支付,刷新状态</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user