Files
hfb_sys/backend/internal/integrations/payment/shuncheng/client.go
T

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(
"&lt;", "<",
"&LT;", "<",
"&#60;", "<",
"&#060;", "<",
"&#x3c;", "<",
"&#x3C;", "<",
"&#X3c;", "<",
"&#X3C;", "<",
"&gt;", ">",
"&GT;", ">",
"&#62;", ">",
"&#062;", ">",
"&#x3e;", ">",
"&#x3E;", ">",
"&#X3e;", ">",
"&#X3E;", ">",
)
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])
}