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("")) if end < 0 { return nil } end += start + len("") 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 "" } 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]) }