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 } 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) VerifyNotify(params map[string]string) bool { key := firstNonEmpty(c.cfg.NotifyKey, c.cfg.SignKey) if key == "" { return false } got := strings.ToUpper(params["sign"]) if got == "" { return false } expected := Sign(params, key, SignOptions{ IncludeEmpty: true, ExcludeKeys: []string{"error_code", "sign"}, }) return got == expected } 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 { 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]) } parts = append(parts, "key="+key) sum := md5.Sum([]byte(strings.Join(parts, "&"))) return strings.ToUpper(hex.EncodeToString(sum[:])) } 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 for { token, err := decoder.Token() if err == io.EOF { break } if err != nil { return nil, err } switch item := token.(type) { case xml.StartElement: current = item.Name.Local case xml.CharData: value := strings.TrimSpace(string(item)) if current != "" && current != "xml" && value != "" { out[current] = value } case xml.EndElement: current = "" } } 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 }