package leshua import ( "bytes" "context" "crypto/md5" "crypto/rand" "encoding/hex" "encoding/json" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "sort" "strings" "time" "hfb_sys/backend/internal/config" ) var ( ErrConfigIncomplete = errors.New("leshua payment config incomplete") ErrUnsupportedSign = errors.New("unsupported leshua sign type") ) type Client struct { cfg config.LeshuaPaymentConfig httpClient *http.Client } type CreatePaymentRequest struct { ThirdOrderID string AmountCent int64 PayWay string JSPayFlag string NotifyURL string JumpURL string ClientIP string Body string Attach string } type CreatePaymentResponse struct { RespCode string ResultCode string ErrorCode string ErrorMessage string MerchantID string ThirdOrderID string ProviderOrderID string PayWay string TDCode string JSPayURL string JSPayInfo string Raw map[string]string } type QueryPaymentResponse struct { RespCode string ResultCode string ErrorCode string ErrorMessage string MerchantID string ThirdOrderID string ProviderOrderID string Status string Amount string PayWay string PayTime string Raw map[string]string } type CreateRefundRequest struct { ThirdOrderID string // 原支付商户订单号 LeshuaOrderID string // 原支付乐刷订单号(优先使用) MerchantRefundID string // 商户退款单号(唯一) RefundAmountCent int64 // 退款金额(分) NotifyURL string Attach string } type CreateRefundResponse struct { RespCode string ResultCode string ErrorCode string ErrorMessage string MerchantID string ThirdOrderID string LeshuaOrderID string MerchantRefundID string LeshuaRefundID string RefundAmount string TotalAmount string OrderBalance string Status string Raw map[string]string } type QueryRefundRequest struct { ThirdOrderID string LeshuaOrderID string MerchantRefundID string LeshuaRefundID string } type QueryRefundResponse struct { RespCode string ResultCode string ErrorCode string ErrorMessage string MerchantID string ThirdOrderID string LeshuaOrderID string MerchantRefundID string LeshuaRefundID string Status string RefundAmount string TotalAmount string RefundTime string Raw map[string]string } type VerifyNotifyResult struct { OK bool MatchedKey string Got string Expected map[string]string BaseString map[string]string ParamKeys []string } func NewClient(cfg config.LeshuaPaymentConfig) *Client { return &Client{ cfg: cfg, httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*CreatePaymentResponse, map[string]string, error) { if err := c.validate(); err != nil { return nil, nil, err } payWay := firstNonEmpty(req.PayWay, c.cfg.PayWay, "ZFBZF") jsPayFlag := firstNonEmpty(req.JSPayFlag, c.cfg.JSPayFlag, "2") params := map[string]string{ "service": "get_tdcode", "merchant_id": c.cfg.MerchantID, "third_order_id": req.ThirdOrderID, "amount": fmt.Sprintf("%d", req.AmountCent), "pay_way": payWay, "jspay_flag": jsPayFlag, "nonce_str": Nonce(32), "body": sanitizeText(req.Body, 128), "attach": sanitizeText(req.Attach, 64), } if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") { params["sign_type"] = c.cfg.SignType } if req.NotifyURL != "" { params["notify_url"] = req.NotifyURL } if req.JumpURL != "" { params["jump_url"] = req.JumpURL } if req.ClientIP != "" { params["client_ip"] = req.ClientIP } params["sign"] = Sign(params, c.cfg.SignKey, SignOptions{}) raw, err := c.post(ctx, params) if err != nil { return nil, params, err } resp := &CreatePaymentResponse{ RespCode: raw["resp_code"], ResultCode: raw["result_code"], ErrorCode: raw["error_code"], ErrorMessage: firstNonEmpty(raw["error_msg"], raw["resp_msg"]), MerchantID: raw["merchant_id"], ThirdOrderID: raw["third_order_id"], ProviderOrderID: raw["leshua_order_id"], PayWay: raw["pay_way"], TDCode: raw["td_code"], JSPayURL: raw["jspay_url"], JSPayInfo: raw["jspay_info"], Raw: raw, } return resp, params, nil } func (c *Client) QueryPayment(ctx context.Context, thirdOrderID, providerOrderID string) (*QueryPaymentResponse, error) { if err := c.validate(); err != nil { return nil, err } params := map[string]string{ "service": "query_status", "merchant_id": c.cfg.MerchantID, "nonce_str": Nonce(32), } if providerOrderID != "" { params["leshua_order_id"] = providerOrderID } else { params["third_order_id"] = thirdOrderID } if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") { params["sign_type"] = c.cfg.SignType } params["sign"] = Sign(params, c.cfg.SignKey, SignOptions{}) raw, err := c.post(ctx, params) if err != nil { return nil, err } return &QueryPaymentResponse{ RespCode: raw["resp_code"], ResultCode: raw["result_code"], ErrorCode: raw["error_code"], ErrorMessage: firstNonEmpty(raw["error_msg"], raw["resp_msg"]), MerchantID: raw["merchant_id"], ThirdOrderID: raw["third_order_id"], ProviderOrderID: raw["leshua_order_id"], Status: raw["status"], Amount: raw["amount"], PayWay: raw["pay_way"], PayTime: raw["pay_time"], Raw: raw, }, nil } func (c *Client) CreateRefund(ctx context.Context, req CreateRefundRequest) (*CreateRefundResponse, map[string]string, error) { if err := c.validate(); err != nil { return nil, nil, err } params := map[string]string{ "service": "unified_refund", "merchant_id": c.cfg.MerchantID, "merchant_refund_id": req.MerchantRefundID, "refund_amount": fmt.Sprintf("%d", req.RefundAmountCent), "nonce_str": Nonce(32), } if req.LeshuaOrderID != "" { params["leshua_order_id"] = req.LeshuaOrderID } else if req.ThirdOrderID != "" { params["third_order_id"] = req.ThirdOrderID } if req.Attach != "" { params["attach"] = sanitizeText(req.Attach, 64) } if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") { params["sign_type"] = c.cfg.SignType } if req.NotifyURL != "" { params["notify_url"] = req.NotifyURL } params["sign"] = Sign(params, c.cfg.SignKey, SignOptions{}) raw, err := c.post(ctx, params) if err != nil { return nil, params, err } resp := &CreateRefundResponse{ RespCode: raw["resp_code"], ResultCode: raw["result_code"], ErrorCode: raw["error_code"], ErrorMessage: firstNonEmpty(raw["error_msg"], raw["resp_msg"]), MerchantID: raw["merchant_id"], ThirdOrderID: raw["third_order_id"], LeshuaOrderID: raw["leshua_order_id"], MerchantRefundID: raw["merchant_refund_id"], LeshuaRefundID: raw["leshua_refund_id"], RefundAmount: raw["refund_amount"], TotalAmount: raw["total_amount"], OrderBalance: raw["order_balance"], Status: raw["status"], Raw: raw, } return resp, params, nil } func (c *Client) QueryRefund(ctx context.Context, req QueryRefundRequest) (*QueryRefundResponse, error) { if err := c.validate(); err != nil { return nil, err } params := map[string]string{ "service": "unified_query_refund", "merchant_id": c.cfg.MerchantID, "nonce_str": Nonce(32), } if req.LeshuaOrderID != "" { params["leshua_order_id"] = req.LeshuaOrderID } else if req.ThirdOrderID != "" { params["third_order_id"] = req.ThirdOrderID } if req.LeshuaRefundID != "" { params["leshua_refund_id"] = req.LeshuaRefundID } else if req.MerchantRefundID != "" { params["merchant_refund_id"] = req.MerchantRefundID } if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") { params["sign_type"] = c.cfg.SignType } params["sign"] = Sign(params, c.cfg.SignKey, SignOptions{}) raw, err := c.post(ctx, params) if err != nil { return nil, err } return &QueryRefundResponse{ RespCode: raw["resp_code"], ResultCode: raw["result_code"], ErrorCode: raw["error_code"], ErrorMessage: firstNonEmpty(raw["error_msg"], raw["resp_msg"]), MerchantID: raw["merchant_id"], ThirdOrderID: raw["third_order_id"], LeshuaOrderID: raw["leshua_order_id"], MerchantRefundID: raw["merchant_refund_id"], LeshuaRefundID: raw["leshua_refund_id"], Status: raw["status"], RefundAmount: raw["refund_amount"], TotalAmount: raw["total_amount"], RefundTime: raw["refund_time"], Raw: raw, }, nil } func (c *Client) VerifyNotify(params map[string]string) bool { return c.VerifyNotifyDetail(params).OK } func (c *Client) VerifyNotifyDetail(params map[string]string) VerifyNotifyResult { got := strings.ToUpper(params["sign"]) result := VerifyNotifyResult{ 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, 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 return result } } return result } func notifySignOptions() SignOptions { return SignOptions{ IncludeEmpty: true, ExcludeKeys: []string{"error_code", "leshua", "sign"}, } } 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}) } 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" || key == "leshua" { continue } keys = append(keys, key) } sort.Strings(keys) return keys } func (c *Client) validate() error { if c.cfg.GatewayURL == "" || c.cfg.MerchantID == "" || c.cfg.SignKey == "" { return ErrConfigIncomplete } if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") { return ErrUnsupportedSign } return nil } func (c *Client) post(ctx context.Context, params map[string]string) (map[string]string, error) { values := url.Values{} for key, value := range params { values.Set(key, value) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.GatewayURL, strings.NewReader(values.Encode())) if err != nil { return nil, err } httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") httpResp, err := c.httpClient.Do(httpReq) if err != nil { return nil, err } defer httpResp.Body.Close() body, err := io.ReadAll(io.LimitReader(httpResp.Body, 1<<20)) if err != nil { return nil, err } if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { return nil, fmt.Errorf("leshua http status %d: %s", httpResp.StatusCode, string(body)) } return ParsePayload(body) } type SignOptions struct { IncludeEmpty bool ExcludeKeys []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{} for _, item := range opts.ExcludeKeys { excluded[item] = true } if len(opts.ExcludeKeys) == 0 { excluded["sign"] = true } keys := make([]string, 0, len(params)) for name, value := range params { if excluded[name] { continue } if !opts.IncludeEmpty && value == "" { continue } keys = append(keys, name) } sort.Strings(keys) parts := make([]string, 0, len(keys)+1) for _, name := range keys { parts = append(parts, name+"="+params[name]) } return strings.Join(parts, "&") } func ParsePayload(body []byte) (map[string]string, error) { trimmed := bytes.TrimSpace(body) if len(trimmed) == 0 { return map[string]string{}, nil } if trimmed[0] == '<' { return parseXMLPayload(trimmed) } if trimmed[0] == '{' { var raw map[string]any if err := json.Unmarshal(trimmed, &raw); err != nil { return nil, err } out := map[string]string{} for key, value := range raw { out[key] = fmt.Sprint(value) } return out, nil } values, err := url.ParseQuery(string(trimmed)) if err != nil { return nil, err } out := map[string]string{} for key, item := range values { if len(item) > 0 { out[key] = item[0] } } if len(out) == 0 { return nil, fmt.Errorf("unsupported leshua payload: %s", string(trimmed)) } return out, nil } 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 { break } if err != nil { return nil, err } switch item := token.(type) { case xml.StartElement: 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 != "" && value != "" { out[current] = value } case xml.EndElement: if current == item.Name.Local { current = "" } if depth > 0 { depth-- } } } return out, nil } func Nonce(length int) string { const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" if length <= 0 { length = 32 } buf := make([]byte, length) random := make([]byte, length) if _, err := rand.Read(random); err != nil { for i := range buf { buf[i] = alphabet[int(time.Now().UnixNano())%len(alphabet)] } return string(buf) } for i, item := range random { buf[i] = alphabet[int(item)%len(alphabet)] } return string(buf) } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" } func sanitizeText(value string, max int) string { value = strings.ReplaceAll(value, "\n", " ") value = strings.ReplaceAll(value, "\r", " ") value = strings.TrimSpace(value) if max > 0 && len([]rune(value)) > max { return string([]rune(value)[:max]) } return value }