优化乐刷通知与钱包充值流程

This commit is contained in:
yml
2026-06-03 18:51:56 +08:00
parent a1942c8453
commit 783999f396
9 changed files with 1938 additions and 73 deletions
@@ -77,6 +77,7 @@ type VerifyNotifyResult struct {
MatchedKey string
Got string
Expected map[string]string
BaseString map[string]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 {
got := strings.ToUpper(params["sign"])
result := VerifyNotifyResult{
Got: got,
Expected: map[string]string{},
ParamKeys: notifyParamKeys(params),
Got: got,
Expected: map[string]string{},
BaseString: map[string]string{},
ParamKeys: notifyParamKeys(params),
}
if got == "" {
return result
}
for _, item := range c.notifyKeyCandidates() {
expected := Sign(params, item.key, SignOptions{
IncludeEmpty: true,
ExcludeKeys: []string{"error_code", "sign"},
})
expected := Sign(params, item.key, notifySignOptions())
baseString := SignBaseString(params, notifySignOptions())
result.Expected[item.name] = expected
result.BaseString[item.name] = baseString
if got == expected {
result.OK = true
result.MatchedKey = item.name
@@ -207,6 +208,13 @@ func (c *Client) VerifyNotifyDetail(params map[string]string) VerifyNotifyResult
return result
}
func notifySignOptions() SignOptions {
return SignOptions{
IncludeEmpty: true,
ExcludeKeys: []string{"error_code", "leshua", "sign"},
}
}
type notifyKeyCandidate struct {
name string
key string
@@ -217,16 +225,13 @@ func (c *Client) notifyKeyCandidates() []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" {
if key == "sign" || key == "error_code" || key == "leshua" {
continue
}
keys = append(keys, key)
@@ -276,6 +281,16 @@ type SignOptions struct {
}
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{}
for _, item := range opts.ExcludeKeys {
excluded[item] = true
@@ -298,9 +313,7 @@ func Sign(params map[string]string, key string, opts SignOptions) string {
for _, name := range keys {
parts = append(parts, name+"="+params[name])
}
parts = append(parts, "key="+key)
sum := md5.Sum([]byte(strings.Join(parts, "&")))
return strings.ToUpper(hex.EncodeToString(sum[:]))
return strings.Join(parts, "&")
}
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))
out := map[string]string{}
var current string
depth := 0
for {
token, err := decoder.Token()
if err == io.EOF {
@@ -352,14 +366,25 @@ func parseXMLPayload(body []byte) (map[string]string, error) {
}
switch item := token.(type) {
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:
value := strings.TrimSpace(string(item))
if current != "" && current != "xml" && value != "" {
if current != "" && value != "" {
out[current] = value
}
case xml.EndElement:
current = ""
if current == item.Name.Local {
current = ""
}
if depth > 0 {
depth--
}
}
}
return out, nil
@@ -1,6 +1,7 @@
package leshua
import (
"strings"
"testing"
"hfb_sys/backend/internal/config"
@@ -22,6 +23,12 @@ func TestSignUsesASCIISortedNonEmptyParams(t *testing.T) {
if 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) {
@@ -33,11 +40,12 @@ func TestVerifyNotifyIncludesEmptyAndExcludesErrorCode(t *testing.T) {
"amount": "100",
"status": "2",
"attach": "",
"leshua": "",
"error_code": "-20001",
}
params["sign"] = Sign(params, "notify-secret", SignOptions{
IncludeEmpty: true,
ExcludeKeys: []string{"error_code", "sign"},
ExcludeKeys: []string{"error_code", "leshua", "sign"},
})
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{
NotifyKey: "wrong-notify-secret",
SignKey: "sign-secret",
@@ -67,12 +75,64 @@ func TestVerifyNotifyFallsBackToSignKey(t *testing.T) {
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)
if !result.OK {
t.Fatal("VerifyNotifyDetail().OK = false, want true")
}
if result.MatchedKey != "sign_key" {
t.Fatalf("MatchedKey = %s, want sign_key", result.MatchedKey)
if result.MatchedKey != "notify_key" {
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)
}
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 {
t.Fatalf("ParsePayload(xml) error = %v", err)
}
if xml["third_order_id"] != "NO2" || xml["status"] != "6" {
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)
}
}
+12 -2
View File
@@ -110,8 +110,18 @@ 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)
rawPayload := string(body)
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 {
log.Printf("[payment] leshua notify failed third_order_id=%s err=%v", params["third_order_id"], err)
c.String(http.StatusOK, "FAIL")
+50 -25
View File
@@ -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) {
amountCent := moneyCent(req.Amount)
if userID == 0 || amountCent <= 0 {
if userID == 0 || req.Amount < MinWalletRechargeAmount || amountCent <= 0 {
return nil, ErrPaymentCannotStart
}
payment, err := r.createWalletRechargePayment(userID, amountCent, req)
@@ -241,17 +241,22 @@ func (r *Repository) Query(userID uint64, orderID uint64) (*PaymentDTO, error) {
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 {
verify := r.leshua.VerifyNotifyDetail(params)
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",
"[payment] leshua notify verify failed third_order_id=%s got=%s expected=%s keys=%v base_string=%s",
params["third_order_id"],
shortSign(verify.Got),
shortExpectedSigns(verify.Expected),
verify.Got,
verify.Expected["notify_key"],
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
}
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
}
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
}
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 &NotifyResult{OK: true, Message: "000000"}, nil
@@ -531,6 +540,40 @@ func withRawSource(raw map[string]string, source string) map[string]string {
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) {
buf := make([]byte, 4)
if _, err := rand.Read(buf); err != nil {
@@ -547,21 +590,3 @@ 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] + "..."
}
+5 -3
View File
@@ -10,6 +10,8 @@ var (
ErrPaymentNotFound = errors.New("payment not found")
)
const MinWalletRechargeAmount = 0.01
type Service struct {
repo *Repository
}
@@ -42,7 +44,7 @@ func (s *Service) StartWalletRecharge(userID uint64, req WalletRechargePaymentRe
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 || req.Amount <= 0 {
if userID == 0 || req.Amount < MinWalletRechargeAmount {
return nil, ErrPaymentCannotStart
}
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)
}
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 {
return nil, ErrDependencyUnavailable
}
return s.repo.HandleLeshuaNotify(params)
return s.repo.HandleLeshuaNotify(params, rawPayload, contentType)
}
+3 -1
View File
@@ -8,6 +8,8 @@ var (
ErrInsufficientBalance = errors.New("insufficient balance")
)
const MinRechargeAmount = 0.01
type Service struct {
repo *Repository
}
@@ -34,7 +36,7 @@ func (s *Service) Recharge(userID uint64, req RechargeRequest) (*AccountDTO, err
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if req.Amount <= 0 {
if req.Amount < MinRechargeAmount {
return nil, ErrInvalidAmount
}
return s.repo.Recharge(userID, req.Amount)