405 lines
9.9 KiB
Go
405 lines
9.9 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
|
|
}
|
|
|
|
type VerifyNotifyResult struct {
|
|
OK bool
|
|
MatchedKey string
|
|
Got string
|
|
Expected 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) 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{},
|
|
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"},
|
|
})
|
|
result.Expected[item.name] = expected
|
|
if got == expected {
|
|
result.OK = true
|
|
result.MatchedKey = item.name
|
|
return result
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
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})
|
|
}
|
|
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" {
|
|
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 {
|
|
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
|
|
}
|