Files
hfb_sys/backend/internal/integrations/payment/leshua/client.go
T
2026-06-03 12:21:23 +08:00

356 lines
8.7 KiB
Go

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
}