1029 lines
27 KiB
Go
1029 lines
27 KiB
Go
package shuncheng
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
ErrConfigIncomplete = errors.New("shuncheng payment config incomplete")
|
|
ErrUnsupportedSign = errors.New("unsupported shuncheng sign type")
|
|
ErrTokenUnavailable = errors.New("shuncheng token unavailable")
|
|
)
|
|
|
|
const (
|
|
endpointToken = "/api/app/backend-account/pay-token"
|
|
endpointSimplePay = "/api/app/payOreder/simplePay"
|
|
endpointPaymentQuery = "/api/app/payOreder/PaymentResultQuery"
|
|
endpointRefund = "/api/app/refundOrder/orderRefund"
|
|
endpointRefundQuery = "/api/app/refundOrder/orderRefundQuery"
|
|
)
|
|
|
|
type Config struct {
|
|
GatewayURL string
|
|
MerchantID string
|
|
SecretID string
|
|
SecretKey string
|
|
NotifyKey string
|
|
NotifyURL string
|
|
JumpURL string
|
|
PayWay string
|
|
JSPayFlag string
|
|
SignType string
|
|
ShopNo string
|
|
RefundType string
|
|
TokenMargin time.Duration
|
|
}
|
|
|
|
type Client struct {
|
|
cfg Config
|
|
httpClient *http.Client
|
|
tokenKey string
|
|
}
|
|
|
|
type CreatePaymentRequest struct {
|
|
ThirdOrderID string
|
|
AmountCent int64
|
|
PayWay string
|
|
JSPayFlag string
|
|
NotifyURL string
|
|
JumpURL string
|
|
ClientIP string
|
|
Body string
|
|
Attach string
|
|
}
|
|
|
|
type CreatePaymentResponse struct {
|
|
OK bool
|
|
ErrorMessage string
|
|
MerchantID string
|
|
ThirdOrderID string
|
|
ProviderOrderID string
|
|
PayWay string
|
|
Status string
|
|
PayTime string
|
|
TDCode string
|
|
JSPayURL string
|
|
JSPayInfo string
|
|
Raw map[string]string
|
|
RawRequest map[string]string
|
|
}
|
|
|
|
type QueryPaymentResponse struct {
|
|
OK bool
|
|
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
|
|
ProviderOrderID string
|
|
MerchantRefundID string
|
|
RefundAmountCent int64
|
|
NotifyURL string
|
|
Attach string
|
|
RefundReason string
|
|
ClientIP string
|
|
}
|
|
|
|
type CreateRefundResponse struct {
|
|
OK bool
|
|
ErrorMessage string
|
|
MerchantID string
|
|
ThirdOrderID string
|
|
ProviderOrderID string
|
|
MerchantRefundID string
|
|
ProviderRefundID string
|
|
Status string
|
|
RefundAmount string
|
|
Raw map[string]string
|
|
RawRequest map[string]string
|
|
}
|
|
|
|
type QueryRefundRequest struct {
|
|
ThirdOrderID string
|
|
ProviderOrderID string
|
|
MerchantRefundID string
|
|
ProviderRefundID string
|
|
}
|
|
|
|
type QueryRefundResponse struct {
|
|
OK bool
|
|
ErrorMessage string
|
|
MerchantID string
|
|
ThirdOrderID string
|
|
ProviderOrderID string
|
|
MerchantRefundID string
|
|
ProviderRefundID string
|
|
Status string
|
|
RefundAmount 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
|
|
}
|
|
|
|
type tokenEntry struct {
|
|
token string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
var tokenCache = struct {
|
|
sync.Mutex
|
|
items map[string]tokenEntry
|
|
}{
|
|
items: map[string]tokenEntry{},
|
|
}
|
|
|
|
func NewClient(cfg Config) *Client {
|
|
return &Client{
|
|
cfg: cfg,
|
|
httpClient: &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
},
|
|
tokenKey: tokenCacheKey(cfg),
|
|
}
|
|
}
|
|
|
|
func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*CreatePaymentResponse, error) {
|
|
if err := c.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
payWay := firstNonEmpty(req.PayWay, c.cfg.PayWay, "ZFBZF")
|
|
params := map[string]string{
|
|
"service": "get_tdcode",
|
|
"jspay_flag": firstNonEmpty(req.JSPayFlag, c.cfg.JSPayFlag, "2"),
|
|
"sign_type": firstNonEmpty(c.cfg.SignType, "MD5"),
|
|
"merchant_id": c.cfg.MerchantID,
|
|
"third_order_id": req.ThirdOrderID,
|
|
"amount": strconv.FormatInt(req.AmountCent, 10),
|
|
"nonce_str": Nonce(32),
|
|
"req_serial_no": Nonce(32),
|
|
"body": sanitizeText(req.Body, 256),
|
|
"attach": sanitizeText(req.Attach, 96),
|
|
}
|
|
if req.NotifyURL != "" {
|
|
params["notify_url"] = req.NotifyURL
|
|
} else if c.cfg.NotifyURL != "" {
|
|
params["notify_url"] = c.cfg.NotifyURL
|
|
}
|
|
if req.JumpURL != "" {
|
|
params["jump_url"] = req.JumpURL
|
|
} else if c.cfg.JumpURL != "" {
|
|
params["jump_url"] = c.cfg.JumpURL
|
|
}
|
|
if req.ClientIP != "" {
|
|
params["client_ip"] = req.ClientIP
|
|
}
|
|
if c.cfg.ShopNo != "" {
|
|
params["shop_no"] = c.cfg.ShopNo
|
|
}
|
|
params["sign"] = SignStrings(params, c.cfg.SecretKey)
|
|
rawReq := copyStringMap(params)
|
|
raw, err := c.postTrade(ctx, endpointSimplePay, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &CreatePaymentResponse{
|
|
OK: responseOK(raw),
|
|
ErrorMessage: responseMessage(raw),
|
|
MerchantID: firstNonEmpty(raw["merchant_id"], c.cfg.MerchantID),
|
|
ThirdOrderID: firstNonEmpty(raw["third_order_id"], req.ThirdOrderID),
|
|
ProviderOrderID: raw["schc_order_id"],
|
|
PayWay: firstNonEmpty(raw["pay_way"], payWay),
|
|
Status: normalizePaymentStatus(raw["status"]),
|
|
PayTime: firstNonEmpty(raw["pay_time"], raw["channel_datetime"]),
|
|
TDCode: raw["jspay_url"],
|
|
JSPayURL: raw["jspay_url"],
|
|
JSPayInfo: raw["jspay_info"],
|
|
Raw: raw,
|
|
RawRequest: rawReq,
|
|
}, 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",
|
|
"sign_type": firstNonEmpty(c.cfg.SignType, "MD5"),
|
|
"merchant_id": c.cfg.MerchantID,
|
|
"nonce_str": Nonce(32),
|
|
"req_serial_no": Nonce(32),
|
|
}
|
|
if providerOrderID != "" {
|
|
params["schc_order_id"] = providerOrderID
|
|
} else {
|
|
params["third_order_id"] = thirdOrderID
|
|
}
|
|
params["sign"] = SignStrings(params, c.cfg.SecretKey)
|
|
raw, err := c.postTrade(ctx, endpointPaymentQuery, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &QueryPaymentResponse{
|
|
OK: responseOK(raw),
|
|
ErrorMessage: responseMessage(raw),
|
|
MerchantID: firstNonEmpty(raw["merchant_id"], c.cfg.MerchantID),
|
|
ThirdOrderID: firstNonEmpty(raw["third_order_id"], thirdOrderID),
|
|
ProviderOrderID: firstNonEmpty(raw["schc_order_id"], providerOrderID),
|
|
Status: normalizePaymentStatus(raw["status"]),
|
|
Amount: raw["amount"],
|
|
PayWay: raw["pay_way"],
|
|
PayTime: firstNonEmpty(raw["pay_time"], raw["channel_datetime"]),
|
|
Raw: raw,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) CreateRefund(ctx context.Context, req CreateRefundRequest) (*CreateRefundResponse, error) {
|
|
if err := c.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
params := map[string]string{
|
|
"service": "unified_refund",
|
|
"sign_type": firstNonEmpty(c.cfg.SignType, "MD5"),
|
|
"merchant_id": c.cfg.MerchantID,
|
|
"merchant_refund_id": req.MerchantRefundID,
|
|
"refund_amount": strconv.FormatInt(req.RefundAmountCent, 10),
|
|
"nonce_str": Nonce(32),
|
|
"req_serial_no": Nonce(32),
|
|
}
|
|
if req.ProviderOrderID != "" {
|
|
params["schc_order_id"] = req.ProviderOrderID
|
|
} else if req.ThirdOrderID != "" {
|
|
params["third_order_id"] = req.ThirdOrderID
|
|
}
|
|
if req.NotifyURL != "" {
|
|
params["notify_url"] = req.NotifyURL
|
|
} else if c.cfg.NotifyURL != "" {
|
|
params["notify_url"] = c.cfg.NotifyURL
|
|
}
|
|
if req.Attach != "" {
|
|
params["attach"] = sanitizeText(req.Attach, 96)
|
|
}
|
|
if c.cfg.ShopNo != "" {
|
|
params["shop_no"] = c.cfg.ShopNo
|
|
}
|
|
if c.cfg.RefundType != "" {
|
|
params["refund_type"] = c.cfg.RefundType
|
|
}
|
|
params["sign"] = SignStrings(params, c.cfg.SecretKey)
|
|
rawReq := copyStringMap(params)
|
|
raw, err := c.postTrade(ctx, endpointRefund, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &CreateRefundResponse{
|
|
OK: responseOK(raw),
|
|
ErrorMessage: responseMessage(raw),
|
|
MerchantID: firstNonEmpty(raw["merchant_id"], c.cfg.MerchantID),
|
|
ThirdOrderID: firstNonEmpty(raw["third_order_id"], req.ThirdOrderID),
|
|
ProviderOrderID: firstNonEmpty(raw["schc_order_id"], req.ProviderOrderID),
|
|
MerchantRefundID: firstNonEmpty(raw["merchant_refund_id"], req.MerchantRefundID),
|
|
ProviderRefundID: raw["schc_refund_id"],
|
|
Status: normalizeRefundStatus(raw["status"]),
|
|
RefundAmount: raw["refund_amount"],
|
|
Raw: raw,
|
|
RawRequest: rawReq,
|
|
}, 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",
|
|
"sign_type": firstNonEmpty(c.cfg.SignType, "MD5"),
|
|
"merchant_id": c.cfg.MerchantID,
|
|
"nonce_str": Nonce(32),
|
|
"req_serial_no": Nonce(32),
|
|
}
|
|
if req.ProviderOrderID != "" {
|
|
params["schc_order_id"] = req.ProviderOrderID
|
|
} else if req.ThirdOrderID != "" {
|
|
params["third_order_id"] = req.ThirdOrderID
|
|
}
|
|
if req.ProviderRefundID != "" {
|
|
params["schc_refund_id"] = req.ProviderRefundID
|
|
} else if req.MerchantRefundID != "" {
|
|
params["merchant_refund_id"] = req.MerchantRefundID
|
|
}
|
|
params["sign"] = SignStrings(params, c.cfg.SecretKey)
|
|
raw, err := c.postTrade(ctx, endpointRefundQuery, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &QueryRefundResponse{
|
|
OK: responseOK(raw),
|
|
ErrorMessage: responseMessage(raw),
|
|
MerchantID: firstNonEmpty(raw["merchant_id"], c.cfg.MerchantID),
|
|
ThirdOrderID: firstNonEmpty(raw["third_order_id"], req.ThirdOrderID),
|
|
ProviderOrderID: firstNonEmpty(raw["schc_order_id"], req.ProviderOrderID),
|
|
MerchantRefundID: firstNonEmpty(raw["merchant_refund_id"], req.MerchantRefundID),
|
|
ProviderRefundID: firstNonEmpty(raw["schc_refund_id"], req.ProviderRefundID),
|
|
Status: normalizeRefundStatus(raw["status"]),
|
|
RefundAmount: raw["refund_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.TrimSpace(params["sign"])
|
|
result := VerifyNotifyResult{
|
|
Got: got,
|
|
Expected: map[string]string{},
|
|
BaseString: map[string]string{},
|
|
ParamKeys: notifyParamKeys(params),
|
|
}
|
|
if got == "" {
|
|
return result
|
|
}
|
|
key := firstNonEmpty(c.cfg.NotifyKey, c.cfg.SecretKey)
|
|
if key == "" {
|
|
result.Expected["error"] = ErrConfigIncomplete.Error()
|
|
return result
|
|
}
|
|
for _, alg := range []struct {
|
|
name string
|
|
sign func(map[string]string, string) string
|
|
}{
|
|
{name: "notify_key", sign: SignStrings},
|
|
{name: "notify_key_upper_md5", sign: SignStringsUpperMD5},
|
|
} {
|
|
expected := alg.sign(params, key)
|
|
result.Expected[alg.name] = expected
|
|
result.BaseString[alg.name] = SignBaseString(stringMapToAny(params))
|
|
if got == expected {
|
|
result.OK = true
|
|
result.MatchedKey = alg.name
|
|
return result
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (c *Client) validate() error {
|
|
if strings.TrimSpace(c.cfg.GatewayURL) == "" ||
|
|
strings.TrimSpace(c.cfg.MerchantID) == "" ||
|
|
strings.TrimSpace(c.cfg.SecretID) == "" ||
|
|
strings.TrimSpace(c.cfg.SecretKey) == "" {
|
|
return ErrConfigIncomplete
|
|
}
|
|
if c.cfg.SignType != "" && !strings.EqualFold(c.cfg.SignType, "MD5") {
|
|
return ErrUnsupportedSign
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) postTrade(ctx context.Context, endpoint string, params map[string]string) (map[string]string, error) {
|
|
return c.postTradeWithRetry(ctx, endpoint, params, true)
|
|
}
|
|
|
|
func (c *Client) postTradeWithRetry(ctx context.Context, endpoint string, params map[string]string, retryOnUnauthorized bool) (map[string]string, error) {
|
|
token, err := c.token(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body, err := json.Marshal(params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpointURL(endpoint), bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
|
req.Header.Set("Accept", "application/xml,application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode == http.StatusUnauthorized && retryOnUnauthorized {
|
|
c.clearToken()
|
|
return c.postTradeWithRetry(ctx, endpoint, params, false)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("shuncheng http status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
raw, err := ParsePayload(respBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("shuncheng parse response failed endpoint=%s status=%d body=%s: %w", endpoint, resp.StatusCode, payloadSnippet(respBody), err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func (c *Client) token(ctx context.Context) (string, error) {
|
|
margin := c.cfg.TokenMargin
|
|
if margin <= 0 {
|
|
margin = 5 * time.Minute
|
|
}
|
|
now := time.Now()
|
|
tokenCache.Lock()
|
|
if item, ok := tokenCache.items[c.tokenKey]; ok && item.token != "" && now.Add(margin).Before(item.expiresAt) {
|
|
tokenCache.Unlock()
|
|
return item.token, nil
|
|
}
|
|
token, err := c.fetchToken(ctx)
|
|
if err != nil {
|
|
tokenCache.Unlock()
|
|
return "", err
|
|
}
|
|
tokenCache.items[c.tokenKey] = tokenEntry{
|
|
token: token,
|
|
expiresAt: time.Now().Add(time.Hour),
|
|
}
|
|
tokenCache.Unlock()
|
|
return token, nil
|
|
}
|
|
|
|
func (c *Client) fetchToken(ctx context.Context) (string, error) {
|
|
body, err := json.Marshal(map[string]string{
|
|
"secretId": c.cfg.SecretID,
|
|
"secretKey": c.cfg.SecretKey,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpointURL(endpointToken), bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("shuncheng token http status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
var payload struct {
|
|
RespCode string `json:"respCode"`
|
|
RespMsg string `json:"respMsg"`
|
|
ReqSerialNo string `json:"reqSerialNo"`
|
|
Version string `json:"version"`
|
|
Data string `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &payload); err != nil {
|
|
return "", err
|
|
}
|
|
if payload.RespCode != "200" || strings.TrimSpace(payload.Data) == "" {
|
|
return "", fmt.Errorf("%w: %s %s", ErrTokenUnavailable, payload.RespCode, payload.RespMsg)
|
|
}
|
|
return payload.Data, nil
|
|
}
|
|
|
|
func (c *Client) clearToken() {
|
|
tokenCache.Lock()
|
|
delete(tokenCache.items, c.tokenKey)
|
|
tokenCache.Unlock()
|
|
}
|
|
|
|
func (c *Client) endpointURL(endpoint string) string {
|
|
return strings.TrimRight(strings.TrimSpace(c.cfg.GatewayURL), "/") + endpoint
|
|
}
|
|
|
|
func ParsePayload(body []byte) (map[string]string, error) {
|
|
trimmed := trimPayload(body)
|
|
if len(trimmed) == 0 {
|
|
return map[string]string{}, nil
|
|
}
|
|
if trimmed[0] == '"' {
|
|
var xmlText string
|
|
if err := json.Unmarshal(trimmed, &xmlText); err == nil && strings.TrimSpace(xmlText) != "" {
|
|
return ParsePayload([]byte(xmlText))
|
|
}
|
|
}
|
|
if xmlBodies := extractXMLPayloads(trimmed); len(xmlBodies) > 0 {
|
|
var lastErr error
|
|
for _, xmlBody := range xmlBodies {
|
|
raw, err := parseXMLPayload(xmlBody)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
if !looksLikeShunchengPayload(raw) {
|
|
lastErr = fmt.Errorf("unsupported shuncheng xml payload %s", payloadSnippet(trimmed))
|
|
continue
|
|
}
|
|
normalizeAliases(raw)
|
|
return raw, nil
|
|
}
|
|
if lastErr != nil {
|
|
return nil, fmt.Errorf("unsupported shuncheng xml payload %s: %w", payloadSnippet(trimmed), lastErr)
|
|
}
|
|
return nil, fmt.Errorf("unsupported shuncheng xml payload %s", payloadSnippet(trimmed))
|
|
}
|
|
switch trimmed[0] {
|
|
case '<':
|
|
raw, err := parseXMLPayload(trimmed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !looksLikeShunchengPayload(raw) {
|
|
return nil, fmt.Errorf("unsupported shuncheng xml payload %s", payloadSnippet(trimmed))
|
|
}
|
|
normalizeAliases(raw)
|
|
return raw, nil
|
|
case '{', '[':
|
|
var payload any
|
|
if err := json.Unmarshal(trimmed, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
raw := flattenJSON(payload)
|
|
if !looksLikeShunchengPayload(raw) {
|
|
return nil, fmt.Errorf("unsupported shuncheng json payload %s", payloadSnippet(trimmed))
|
|
}
|
|
normalizeAliases(raw)
|
|
return raw, nil
|
|
default:
|
|
values, err := url.ParseQuery(string(trimmed))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unsupported shuncheng payload %s: %w", payloadSnippet(trimmed), 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 shuncheng payload %s", payloadSnippet(trimmed))
|
|
}
|
|
if !looksLikeShunchengPayload(out) {
|
|
return nil, fmt.Errorf("unsupported shuncheng form payload %s", payloadSnippet(trimmed))
|
|
}
|
|
normalizeAliases(out)
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
func looksLikeShunchengPayload(raw map[string]string) bool {
|
|
for _, key := range []string{
|
|
"resp_code",
|
|
"respCode",
|
|
"result_code",
|
|
"third_order_id",
|
|
"schc_order_id",
|
|
"schc_refund_id",
|
|
"merchant_refund_id",
|
|
"status",
|
|
"error_code",
|
|
"error_msg",
|
|
} {
|
|
if strings.TrimSpace(raw[key]) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func extractXMLPayloads(body []byte) [][]byte {
|
|
candidates := [][]byte{
|
|
body,
|
|
[]byte(html.UnescapeString(string(body))),
|
|
[]byte(unescapeXMLTagEntities(string(body))),
|
|
}
|
|
out := make([][]byte, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
payload := extractRawXMLPayload(candidate)
|
|
if len(payload) == 0 || hasPayload(out, payload) {
|
|
continue
|
|
}
|
|
out = append(out, payload)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractRawXMLPayload(candidate []byte) []byte {
|
|
start := bytes.Index(candidate, []byte("<schc"))
|
|
if start < 0 {
|
|
return nil
|
|
}
|
|
end := bytes.Index(candidate[start:], []byte("</schc>"))
|
|
if end < 0 {
|
|
return nil
|
|
}
|
|
end += start + len("</schc>")
|
|
return bytes.TrimSpace(candidate[start:end])
|
|
}
|
|
|
|
func hasPayload(items [][]byte, candidate []byte) bool {
|
|
for _, item := range items {
|
|
if bytes.Equal(item, candidate) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func unescapeXMLTagEntities(value string) string {
|
|
replacer := strings.NewReplacer(
|
|
"<", "<",
|
|
"<", "<",
|
|
"<", "<",
|
|
"<", "<",
|
|
"<", "<",
|
|
"<", "<",
|
|
"<", "<",
|
|
"<", "<",
|
|
">", ">",
|
|
">", ">",
|
|
">", ">",
|
|
">", ">",
|
|
">", ">",
|
|
">", ">",
|
|
">", ">",
|
|
">", ">",
|
|
)
|
|
return replacer.Replace(value)
|
|
}
|
|
|
|
func trimPayload(body []byte) []byte {
|
|
trimmed := bytes.TrimSpace(body)
|
|
trimmed = bytes.TrimPrefix(trimmed, []byte{0xEF, 0xBB, 0xBF})
|
|
return bytes.TrimSpace(trimmed)
|
|
}
|
|
|
|
func payloadSnippet(body []byte) string {
|
|
trimmed := trimPayload(body)
|
|
text := strings.Join(strings.Fields(string(trimmed)), " ")
|
|
if text == "" {
|
|
return "<empty>"
|
|
}
|
|
runes := []rune(text)
|
|
if len(runes) > 300 {
|
|
text = string(runes[:300]) + "..."
|
|
}
|
|
return strconv.Quote(text)
|
|
}
|
|
|
|
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 SignStrings(params map[string]string, secretKey string) string {
|
|
return Sign(stringMapToAny(params), secretKey)
|
|
}
|
|
|
|
func Sign(params map[string]any, secretKey string) string {
|
|
baseString := SignBaseString(params)
|
|
signString := "key=" + secretKey
|
|
if baseString != "" {
|
|
signString = baseString + "&key=" + secretKey
|
|
}
|
|
sum := md5.Sum([]byte(signString))
|
|
hexValue := strings.ToLower(hex.EncodeToString(sum[:]))
|
|
return base64.StdEncoding.EncodeToString([]byte(hexValue))
|
|
}
|
|
|
|
func SignStringsUpperMD5(params map[string]string, secretKey string) string {
|
|
baseString := SignBaseString(stringMapToAny(params))
|
|
signString := "key=" + secretKey
|
|
if baseString != "" {
|
|
signString = baseString + "&key=" + secretKey
|
|
}
|
|
sum := md5.Sum([]byte(signString))
|
|
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
|
}
|
|
|
|
func SignBaseString(params map[string]any) string {
|
|
filtered := map[string]string{}
|
|
for key, value := range params {
|
|
if key == "sign" || value == nil {
|
|
continue
|
|
}
|
|
processed, ok := signValue(value)
|
|
if !ok || processed == "" {
|
|
continue
|
|
}
|
|
filtered[key] = processed
|
|
}
|
|
keys := make([]string, 0, len(filtered))
|
|
for key := range filtered {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
parts = append(parts, key+"="+filtered[key])
|
|
}
|
|
return strings.Join(parts, "&")
|
|
}
|
|
|
|
func signValue(value any) (string, bool) {
|
|
switch item := value.(type) {
|
|
case nil:
|
|
return "", false
|
|
case string:
|
|
return item, true
|
|
case map[string]any:
|
|
return sortedJSONObject(item), true
|
|
case map[string]string:
|
|
converted := make(map[string]any, len(item))
|
|
for key, child := range item {
|
|
converted[key] = child
|
|
}
|
|
return sortedJSONObject(converted), true
|
|
case []any:
|
|
raw, _ := json.Marshal(item)
|
|
return string(raw), true
|
|
case []string:
|
|
raw, _ := json.Marshal(item)
|
|
return string(raw), true
|
|
case bool:
|
|
return strconv.FormatBool(item), true
|
|
case int:
|
|
return strconv.Itoa(item), true
|
|
case int64:
|
|
return strconv.FormatInt(item, 10), true
|
|
case float64:
|
|
if math.Trunc(item) == item {
|
|
return strconv.FormatInt(int64(item), 10), true
|
|
}
|
|
return strconv.FormatFloat(item, 'f', -1, 64), true
|
|
default:
|
|
return fmt.Sprint(item), true
|
|
}
|
|
}
|
|
|
|
func sortedJSONObject(value map[string]any) string {
|
|
filtered := map[string]any{}
|
|
keys := make([]string, 0, len(value))
|
|
for key, item := range value {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
filtered[key] = item
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
buf := bytes.NewBufferString("{")
|
|
for idx, key := range keys {
|
|
if idx > 0 {
|
|
buf.WriteByte(',')
|
|
}
|
|
keyRaw, _ := json.Marshal(key)
|
|
buf.Write(keyRaw)
|
|
buf.WriteByte(':')
|
|
valueRaw, _ := json.Marshal(filtered[key])
|
|
buf.Write(valueRaw)
|
|
}
|
|
buf.WriteByte('}')
|
|
return buf.String()
|
|
}
|
|
|
|
func normalizeAliases(raw map[string]string) {
|
|
raw["third_order_id"] = firstNonEmpty(raw["third_order_id"], raw["out_order_no"])
|
|
raw["provider_order_id"] = firstNonEmpty(raw["provider_order_id"], raw["schc_order_id"])
|
|
raw["amount"] = firstNonEmpty(raw["amount"], raw["total_amount"])
|
|
raw["pay_time"] = firstNonEmpty(raw["pay_time"], raw["channel_datetime"], raw["refund_time"])
|
|
if raw["merchant_refund_id"] != "" || raw["schc_refund_id"] != "" || raw["refund_amount"] != "" {
|
|
raw["provider_refund_id"] = firstNonEmpty(raw["provider_refund_id"], raw["schc_refund_id"])
|
|
}
|
|
}
|
|
|
|
func normalizePaymentStatus(status string) string {
|
|
switch strings.ToUpper(strings.TrimSpace(status)) {
|
|
case "2", "SUCCESS", "PAID", "PAY_SUCCESS", "TRADE_SUCCESS":
|
|
return "paid"
|
|
case "6", "CLOSED", "CLOSE", "CANCEL", "CANCELLED", "CANCELED":
|
|
return "closed"
|
|
case "8", "FAIL", "FAILED", "PAY_FAIL", "TRADE_FAIL":
|
|
return "failed"
|
|
default:
|
|
return "paying"
|
|
}
|
|
}
|
|
|
|
func normalizeRefundStatus(status string) string {
|
|
switch strings.ToUpper(strings.TrimSpace(status)) {
|
|
case "11", "SUCCESS", "REFUNDED", "REFUND_SUCCESS", "TRADE_SUCCESS":
|
|
return "refunded"
|
|
case "12", "FAIL", "FAILED", "REFUND_FAIL", "TRADE_FAIL":
|
|
return "failed"
|
|
default:
|
|
return "refunding"
|
|
}
|
|
}
|
|
|
|
func responseOK(raw map[string]string) bool {
|
|
respCode := strings.ToUpper(firstNonEmpty(raw["resp_code"], raw["respCode"]))
|
|
resultCode := strings.ToUpper(raw["result_code"])
|
|
if respCode == "" && resultCode == "" {
|
|
return false
|
|
}
|
|
respOK := respCode == "" || respCode == "200" || respCode == "0" || respCode == "SUCCESS"
|
|
resultOK := resultCode == "" || resultCode == "0" || resultCode == "SUCCESS"
|
|
return respOK && resultOK
|
|
}
|
|
|
|
func responseMessage(raw map[string]string) string {
|
|
return firstNonEmpty(raw["error_msg"], raw["resp_msg"], raw["respMsg"], raw["message"], raw["msg"])
|
|
}
|
|
|
|
func flattenJSON(value any) map[string]string {
|
|
out := map[string]string{}
|
|
var walk func(prefix string, v any)
|
|
walk = func(prefix string, v any) {
|
|
switch item := v.(type) {
|
|
case map[string]any:
|
|
for key, child := range item {
|
|
if key == "data" || key == "resp_data" || key == "req_data" {
|
|
walk("", child)
|
|
continue
|
|
}
|
|
walk(key, child)
|
|
}
|
|
case []any:
|
|
raw, _ := json.Marshal(item)
|
|
if prefix != "" {
|
|
out[prefix] = string(raw)
|
|
}
|
|
case nil:
|
|
if prefix != "" {
|
|
out[prefix] = ""
|
|
}
|
|
case string:
|
|
if prefix != "" {
|
|
out[prefix] = item
|
|
}
|
|
case float64:
|
|
if prefix != "" {
|
|
if math.Trunc(item) == item {
|
|
out[prefix] = strconv.FormatInt(int64(item), 10)
|
|
} else {
|
|
out[prefix] = strconv.FormatFloat(item, 'f', -1, 64)
|
|
}
|
|
}
|
|
case bool:
|
|
if prefix != "" {
|
|
out[prefix] = strconv.FormatBool(item)
|
|
}
|
|
default:
|
|
if prefix != "" {
|
|
out[prefix] = fmt.Sprint(item)
|
|
}
|
|
}
|
|
}
|
|
walk("", value)
|
|
return out
|
|
}
|
|
|
|
func stringMapToAny(params map[string]string) map[string]any {
|
|
out := make(map[string]any, len(params))
|
|
for key, value := range params {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func copyStringMap(params map[string]string) map[string]string {
|
|
out := make(map[string]string, len(params))
|
|
for key, value := range params {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func notifyParamKeys(params map[string]string) []string {
|
|
keys := make([]string, 0, len(params))
|
|
for key, value := range params {
|
|
if key == "sign" || value == "" {
|
|
continue
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func tokenCacheKey(cfg Config) string {
|
|
hash := sha256.Sum256([]byte(cfg.SecretKey))
|
|
return strings.TrimRight(strings.TrimSpace(cfg.GatewayURL), "/") + "\x00" + cfg.SecretID + "\x00" + hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
func Nonce(length int) string {
|
|
const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
if length <= 0 {
|
|
length = 32
|
|
}
|
|
buf := make([]byte, length)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
|
}
|
|
for idx := range buf {
|
|
buf[idx] = chars[int(buf[idx])%len(chars)]
|
|
}
|
|
return string(buf)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func sanitizeText(value string, maxRunes int) string {
|
|
value = strings.ReplaceAll(value, "\n", " ")
|
|
value = strings.ReplaceAll(value, "\r", " ")
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || maxRunes <= 0 {
|
|
return value
|
|
}
|
|
runes := []rune(value)
|
|
if len(runes) <= maxRunes {
|
|
return value
|
|
}
|
|
return string(runes[:maxRunes])
|
|
}
|