diff --git a/backend/internal/integrations/payment/shuncheng/client.go b/backend/internal/integrations/payment/shuncheng/client.go
new file mode 100644
index 0000000..0abf54f
--- /dev/null
+++ b/backend/internal/integrations/payment/shuncheng/client.go
@@ -0,0 +1,1028 @@
+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])
+}
diff --git a/backend/internal/integrations/payment/shuncheng/client_test.go b/backend/internal/integrations/payment/shuncheng/client_test.go
new file mode 100644
index 0000000..6eed76c
--- /dev/null
+++ b/backend/internal/integrations/payment/shuncheng/client_test.go
@@ -0,0 +1,266 @@
+package shuncheng
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+)
+
+func TestSignUsesDocumentedBase64LowerMD5(t *testing.T) {
+ params := map[string]string{
+ "service": "get_tdcode",
+ "merchant_id": "1234567890",
+ "third_order_id": "NO1",
+ "amount": "100",
+ "nonce_str": "abc",
+ "req_serial_no": "req001",
+ "empty": "",
+ "sign": "ignored",
+ }
+
+ got := SignStrings(params, "secret")
+ want := "ZTM5YTQ1NDk4YmRhODJlYTYyOWQzMGI2N2M5YzFjYjQ="
+ if got != want {
+ t.Fatalf("SignStrings() = %s, want %s", got, want)
+ }
+
+ baseString := SignBaseString(stringMapToAny(params))
+ wantBaseString := "amount=100&merchant_id=1234567890&nonce_str=abc&req_serial_no=req001&service=get_tdcode&third_order_id=NO1"
+ if baseString != wantBaseString {
+ t.Fatalf("SignBaseString() = %s, want %s", baseString, wantBaseString)
+ }
+}
+
+func TestSignSortsNestedObjectLikeDocs(t *testing.T) {
+ params := map[string]any{
+ "reqSerialNo": "20260318110810747",
+ "data": map[string]any{
+ "merchantId": "M1",
+ "applyAmount": 10000,
+ "reqId": "R1",
+ "ignored": nil,
+ },
+ }
+
+ baseString := SignBaseString(params)
+ wantBaseString := `data={"applyAmount":10000,"merchantId":"M1","reqId":"R1"}&reqSerialNo=20260318110810747`
+ if baseString != wantBaseString {
+ t.Fatalf("SignBaseString() = %s, want %s", baseString, wantBaseString)
+ }
+ got := Sign(params, "secret")
+ want := "N2VlOWY3OWJlZmU4YThiNjEwN2Y1NWYxNzI0ZjY0NTY="
+ if got != want {
+ t.Fatalf("Sign() = %s, want %s", got, want)
+ }
+}
+
+func TestParsePayloadNormalizesXMLAliases(t *testing.T) {
+ params, err := ParsePayload([]byte(`
+
+
+
+
+
+
+
+`))
+ if err != nil {
+ t.Fatalf("ParsePayload() error = %v", err)
+ }
+ if params["provider_order_id"] != "SC1" {
+ t.Fatalf("provider_order_id = %q, want SC1", params["provider_order_id"])
+ }
+ if params["provider_refund_id"] != "RF1" {
+ t.Fatalf("provider_refund_id = %q, want RF1", params["provider_refund_id"])
+ }
+}
+
+func TestParsePayloadExtractsEscapedXMLFromHTML(t *testing.T) {
+ params, err := ParsePayload([]byte(`<schc><resp_code>200</resp_code><result_code>0</result_code><third_order_id>PAY1</third_order_id><schc_order_id>SC1</schc_order_id><jspay_url>https://pay.example/sc1</jspay_url></schc>`))
+ if err != nil {
+ t.Fatalf("ParsePayload() error = %v", err)
+ }
+ if params["provider_order_id"] != "SC1" || params["jspay_url"] != "https://pay.example/sc1" {
+ t.Fatalf("ParsePayload() = %#v, want extracted shuncheng xml", params)
+ }
+}
+
+func TestParsePayloadAcceptsEscapedURLQueryInHTMLXML(t *testing.T) {
+ params, err := ParsePayload([]byte(`<schc><resp_code>200</resp_code><result_code>0</result_code><third_order_id>PAY1</third_order_id><schc_order_id>SC1</schc_order_id><jspay_url>https://pay.example/sc1?mid=1&pu=2</jspay_url></schc>`))
+ if err != nil {
+ t.Fatalf("ParsePayload() error = %v", err)
+ }
+ if params["jspay_url"] != "https://pay.example/sc1?mid=1&pu=2" {
+ t.Fatalf("jspay_url = %q, want query decoded", params["jspay_url"])
+ }
+}
+
+func TestParsePayloadAcceptsJSONStringXML(t *testing.T) {
+ xmlText := `200516911845000EFB11258D694634F727A8FCCF922904MD5PAY1https://pay.example/sc1?mid=1&pu=20SC1`
+ body, err := json.Marshal(xmlText)
+ if err != nil {
+ t.Fatalf("json marshal xml text: %v", err)
+ }
+ params, err := ParsePayload(body)
+ if err != nil {
+ t.Fatalf("ParsePayload() error = %v", err)
+ }
+ if params["provider_order_id"] != "SC1" || params["jspay_url"] != "https://pay.example/sc1?mid=1&pu=2" {
+ t.Fatalf("ParsePayload() = %#v, want xml string decoded", params)
+ }
+}
+
+func TestParsePayloadTrimsBOMBeforeXML(t *testing.T) {
+ params, err := ParsePayload([]byte("\xef\xbb\xbf2000PAY1SC1"))
+ if err != nil {
+ t.Fatalf("ParsePayload() error = %v", err)
+ }
+ if params["provider_order_id"] != "SC1" {
+ t.Fatalf("provider_order_id = %q, want SC1", params["provider_order_id"])
+ }
+}
+
+func TestParsePayloadRejectsPlainHTML(t *testing.T) {
+ if _, err := ParsePayload([]byte(`invalid semicolon; separator`)); err == nil {
+ t.Fatal("ParsePayload() error = nil, want unsupported html error")
+ }
+}
+
+func TestVerifyNotifyAcceptsDocumentedAndLegacySignatures(t *testing.T) {
+ client := NewClient(Config{NotifyKey: "notify-secret"})
+ params := map[string]string{
+ "merchant_id": "1234567890",
+ "third_order_id": "NO1",
+ "schc_order_id": "SC1",
+ "amount": "100",
+ "status": "2",
+ "sign_type": "MD5",
+ }
+ params["sign"] = SignStrings(params, "notify-secret")
+ if result := client.VerifyNotifyDetail(params); !result.OK {
+ t.Fatalf("VerifyNotifyDetail().OK = false, got=%s expected=%v", result.Got, result.Expected)
+ }
+
+ params["sign"] = SignStringsUpperMD5(params, "notify-secret")
+ if result := client.VerifyNotifyDetail(params); !result.OK {
+ t.Fatalf("VerifyNotifyDetail() legacy upper md5 OK = false, got=%s expected=%v", result.Got, result.Expected)
+ }
+
+ params["amount"] = "101"
+ if client.VerifyNotify(params) {
+ t.Fatal("VerifyNotify() = true after amount changed, want false")
+ }
+}
+
+func TestCreatePaymentFetchesTokenOnceAndPostsSignedJSON(t *testing.T) {
+ resetTokenCacheForTest()
+ var tokenCount int
+ var createCount int
+ var captured map[string]string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case endpointToken:
+ tokenCount++
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"respCode":"200","data":"token-1"}`))
+ case endpointSimplePay:
+ createCount++
+ if r.Header.Get("Authorization") != "Bearer token-1" {
+ t.Fatalf("Authorization = %q, want Bearer token-1", r.Header.Get("Authorization"))
+ }
+ if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
+ t.Fatalf("decode create payment request: %v", err)
+ }
+ if captured["sign"] != SignStrings(captured, "secret-key") {
+ t.Fatalf("sign = %q, want recalculated %q", captured["sign"], SignStrings(captured, "secret-key"))
+ }
+ w.Header().Set("Content-Type", "application/xml")
+ _, _ = w.Write([]byte(`2000PAY1SC1https://pay.example/sc1`))
+ default:
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ client := NewClient(Config{
+ GatewayURL: server.URL,
+ MerchantID: "merchant-1",
+ SecretID: "secret-id",
+ SecretKey: "secret-key",
+ NotifyURL: "https://example.com/notify",
+ })
+ for i := 0; i < 2; i++ {
+ resp, err := client.CreatePayment(context.Background(), CreatePaymentRequest{
+ ThirdOrderID: "PAY1",
+ AmountCent: 100,
+ Body: "租号订单",
+ })
+ if err != nil {
+ t.Fatalf("CreatePayment() error = %v", err)
+ }
+ if !resp.OK || resp.ProviderOrderID != "SC1" || resp.JSPayURL == "" {
+ t.Fatalf("CreatePayment() response = %+v", resp)
+ }
+ }
+ if tokenCount != 1 {
+ t.Fatalf("tokenCount = %d, want 1", tokenCount)
+ }
+ if createCount != 2 {
+ t.Fatalf("createCount = %d, want 2", createCount)
+ }
+ if captured["notify_url"] != "https://example.com/notify" {
+ t.Fatalf("notify_url = %q, want configured notify url", captured["notify_url"])
+ }
+}
+
+func TestUnauthorizedRefreshesTokenOnce(t *testing.T) {
+ resetTokenCacheForTest()
+ var tokenCount int
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case endpointToken:
+ tokenCount++
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"respCode":"200","data":"token-` + strconv.Itoa(tokenCount) + `"}`))
+ case endpointPaymentQuery:
+ if r.Header.Get("Authorization") == "Bearer token-1" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ if r.Header.Get("Authorization") != "Bearer token-2" {
+ t.Fatalf("Authorization = %q, want Bearer token-2", r.Header.Get("Authorization"))
+ }
+ w.Header().Set("Content-Type", "application/xml")
+ _, _ = w.Write([]byte(`2000PAY1SC12`))
+ default:
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ client := NewClient(Config{
+ GatewayURL: server.URL,
+ MerchantID: "merchant-1",
+ SecretID: "secret-id",
+ SecretKey: "secret-key",
+ })
+ resp, err := client.QueryPayment(context.Background(), "PAY1", "")
+ if err != nil {
+ t.Fatalf("QueryPayment() error = %v", err)
+ }
+ if !resp.OK || resp.Status != "paid" || resp.ProviderOrderID != "SC1" {
+ t.Fatalf("QueryPayment() response = %+v", resp)
+ }
+ if tokenCount != 2 {
+ t.Fatalf("tokenCount = %d, want 2", tokenCount)
+ }
+}
+
+func resetTokenCacheForTest() {
+ tokenCache.Lock()
+ tokenCache.items = map[string]tokenEntry{}
+ tokenCache.Unlock()
+}
diff --git a/backend/internal/modules/payment/channel_adapter.go b/backend/internal/modules/payment/channel_adapter.go
index 628daf7..ea880e5 100644
--- a/backend/internal/modules/payment/channel_adapter.go
+++ b/backend/internal/modules/payment/channel_adapter.go
@@ -5,9 +5,11 @@ import (
"fmt"
"strconv"
"strings"
+ "time"
"hfb_sys/backend/internal/integrations/payment/lakala"
"hfb_sys/backend/internal/integrations/payment/leshua"
+ "hfb_sys/backend/internal/integrations/payment/shuncheng"
"hfb_sys/backend/internal/modules/paymentconfig"
)
@@ -91,6 +93,7 @@ type channelQueryRefundResponse struct {
Status string
RefundAmount string
RefundTime string
+ RawRequest map[string]string
Raw map[string]string
}
@@ -168,7 +171,12 @@ func (c leshuaChannel) CreateRefund(ctx context.Context, req channelCreateRefund
Attach: req.Attach,
})
if err != nil {
- return nil, err
+ if rawReq == nil {
+ return nil, err
+ }
+ return &channelCreateRefundResponse{
+ RawRequest: rawReq,
+ }, err
}
return &channelCreateRefundResponse{
OK: resp.RespCode == "0" && resp.ResultCode == "0",
@@ -285,7 +293,12 @@ func (c lakalaChannel) CreateRefund(ctx context.Context, req channelCreateRefund
ClientIP: req.ClientIP,
})
if err != nil {
- return nil, err
+ if resp == nil {
+ return nil, err
+ }
+ return &channelCreateRefundResponse{
+ RawRequest: resp.RawRequest,
+ }, err
}
return &channelCreateRefundResponse{
OK: resp.OK,
@@ -305,6 +318,129 @@ func (c lakalaChannel) QueryRefund(ctx context.Context, req channelQueryRefundRe
MerchantRefundID: req.MerchantRefundID,
ProviderRefundID: req.ProviderRefundID,
})
+ if err != nil {
+ if resp == nil {
+ return nil, err
+ }
+ return &channelQueryRefundResponse{
+ RawRequest: resp.RawRequest,
+ }, err
+ }
+ return &channelQueryRefundResponse{
+ OK: resp.OK,
+ ErrorMessage: resp.ErrorMessage,
+ ProviderRefundID: resp.ProviderRefundID,
+ Status: resp.Status,
+ RefundAmount: resp.RefundAmount,
+ RefundTime: resp.RefundTime,
+ RawRequest: resp.RawRequest,
+ Raw: resp.Raw,
+ }, nil
+}
+
+func (c lakalaChannel) VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) {
+ verify := c.client.VerifyNotifyDetail(rawPayload, authorization)
+ result := channelVerifyNotifyResult{
+ OK: verify.OK,
+ MatchedKey: verify.MatchedKey,
+ Got: verify.Got,
+ Expected: verify.Expected,
+ BaseString: verify.BaseString,
+ ParamKeys: verify.ParamKeys,
+ }
+ if !result.OK {
+ return result, ErrPaymentVerifyFailed
+ }
+ return result, nil
+}
+
+type shunchengChannel struct {
+ client *shuncheng.Client
+}
+
+func newShunchengChannel(cfg shuncheng.Config) channelClient {
+ return shunchengChannel{client: shuncheng.NewClient(cfg)}
+}
+
+func (c shunchengChannel) CreatePayment(ctx context.Context, req channelCreatePaymentRequest) (*channelCreatePaymentResponse, error) {
+ resp, err := c.client.CreatePayment(ctx, shuncheng.CreatePaymentRequest{
+ ThirdOrderID: req.ThirdOrderID,
+ AmountCent: req.AmountCent,
+ PayWay: req.PayWay,
+ JSPayFlag: req.JSPayFlag,
+ NotifyURL: req.NotifyURL,
+ JumpURL: req.JumpURL,
+ ClientIP: req.ClientIP,
+ Body: req.Body,
+ Attach: req.Attach,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &channelCreatePaymentResponse{
+ OK: resp.OK,
+ ErrorMessage: resp.ErrorMessage,
+ ProviderOrderID: resp.ProviderOrderID,
+ PayWay: resp.PayWay,
+ Status: resp.Status,
+ PayTime: resp.PayTime,
+ TDCode: resp.TDCode,
+ JSPayURL: resp.JSPayURL,
+ JSPayInfo: resp.JSPayInfo,
+ RawRequest: resp.RawRequest,
+ Raw: resp.Raw,
+ }, nil
+}
+
+func (c shunchengChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) {
+ resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID)
+ if err != nil {
+ return nil, err
+ }
+ return &channelQueryPaymentResponse{
+ OK: resp.OK,
+ ErrorMessage: resp.ErrorMessage,
+ ProviderOrderID: resp.ProviderOrderID,
+ Status: resp.Status,
+ Amount: resp.Amount,
+ PayWay: resp.PayWay,
+ PayTime: resp.PayTime,
+ Raw: resp.Raw,
+ }, nil
+}
+
+func (c shunchengChannel) CreateRefund(ctx context.Context, req channelCreateRefundRequest) (*channelCreateRefundResponse, error) {
+ resp, err := c.client.CreateRefund(ctx, shuncheng.CreateRefundRequest{
+ ThirdOrderID: req.ThirdOrderID,
+ ProviderOrderID: req.ProviderOrderID,
+ MerchantRefundID: req.MerchantRefundID,
+ RefundAmountCent: req.RefundAmountCent,
+ NotifyURL: req.NotifyURL,
+ Attach: req.Attach,
+ RefundReason: req.Remark,
+ ClientIP: req.ClientIP,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &channelCreateRefundResponse{
+ OK: resp.OK,
+ ErrorMessage: resp.ErrorMessage,
+ ProviderRefundID: resp.ProviderRefundID,
+ Status: resp.Status,
+ RefundAmount: resp.RefundAmount,
+ RawRequest: resp.RawRequest,
+ Raw: resp.Raw,
+ }, nil
+}
+
+func (c shunchengChannel) QueryRefund(ctx context.Context, req channelQueryRefundRequest) (*channelQueryRefundResponse, error) {
+ resp, err := c.client.QueryRefund(ctx, shuncheng.QueryRefundRequest{
+ ThirdOrderID: req.ThirdOrderID,
+ ProviderOrderID: req.ProviderOrderID,
+ MerchantRefundID: req.MerchantRefundID,
+ ProviderRefundID: req.ProviderRefundID,
+ })
if err != nil {
return nil, err
}
@@ -319,8 +455,8 @@ func (c lakalaChannel) QueryRefund(ctx context.Context, req channelQueryRefundRe
}, nil
}
-func (c lakalaChannel) VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) {
- verify := c.client.VerifyNotifyDetail(rawPayload, authorization)
+func (c shunchengChannel) VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) {
+ verify := c.client.VerifyNotifyDetail(params)
result := channelVerifyNotifyResult{
OK: verify.OK,
MatchedKey: verify.MatchedKey,
@@ -366,6 +502,22 @@ func buildChannelClient(dto *paymentconfig.ConfigDTO) (channelClient, error) {
PayMode: extraString(dto.ExtraConfig, "pay_mode"),
OrderExpireMinutes: extraInt(dto.ExtraConfig, "order_expire_minutes"),
})), nil
+ case "shuncheng":
+ return withChannelBreaker("shuncheng", newShunchengChannel(shuncheng.Config{
+ GatewayURL: dto.GatewayURL,
+ MerchantID: dto.MerchantID,
+ SecretID: extraString(dto.ExtraConfig, "secret_id"),
+ SecretKey: dto.SignKey,
+ NotifyKey: dto.NotifyKey,
+ NotifyURL: dto.NotifyURL,
+ JumpURL: dto.JumpURL,
+ PayWay: firstNonEmpty(dto.PayWay, "ZFBZF"),
+ JSPayFlag: firstNonEmpty(dto.JSPayFlag, "2"),
+ SignType: firstNonEmpty(dto.SignType, "MD5"),
+ ShopNo: extraString(dto.ExtraConfig, "shop_no"),
+ RefundType: extraString(dto.ExtraConfig, "refund_type"),
+ TokenMargin: time.Duration(extraInt(dto.ExtraConfig, "token_refresh_margin_seconds")) * time.Second,
+ })), nil
case "mock":
return nil, nil
default:
@@ -398,7 +550,7 @@ func normalizeLeshuaRefundStatus(status string) string {
}
func normalizeNotifyPaymentStatus(provider string, status string) string {
- if provider == "leshua" {
+ if provider == "leshua" || provider == "shuncheng" {
return normalizeLeshuaPaymentStatus(status)
}
switch status {
@@ -410,7 +562,7 @@ func normalizeNotifyPaymentStatus(provider string, status string) string {
}
func normalizeNotifyRefundStatus(provider string, status string) string {
- if provider == "leshua" {
+ if provider == "leshua" || provider == "shuncheng" {
return normalizeLeshuaRefundStatus(status)
}
switch status {
diff --git a/backend/internal/modules/payment/channel_status.go b/backend/internal/modules/payment/channel_status.go
index 7f18cec..1f7f2a8 100644
--- a/backend/internal/modules/payment/channel_status.go
+++ b/backend/internal/modules/payment/channel_status.go
@@ -63,7 +63,7 @@ func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrde
}
updates := map[string]any{
"status": "paid",
- "provider_order_id": firstNonEmpty(raw["provider_order_id"], raw["leshua_order_id"], raw["pay_order_no"], raw["trade_no"], latest.ProviderOrderID),
+ "provider_order_id": firstNonEmpty(raw["provider_order_id"], raw["schc_order_id"], raw["leshua_order_id"], raw["pay_order_no"], raw["trade_no"], latest.ProviderOrderID),
"raw_response": jsonMap(withRawSource(raw, source)),
"paid_at": paidAt,
}
diff --git a/backend/internal/modules/payment/handler.go b/backend/internal/modules/payment/handler.go
index d15701e..313c00c 100644
--- a/backend/internal/modules/payment/handler.go
+++ b/backend/internal/modules/payment/handler.go
@@ -9,6 +9,7 @@ import (
"hfb_sys/backend/internal/integrations/payment/lakala"
"hfb_sys/backend/internal/integrations/payment/leshua"
+ "hfb_sys/backend/internal/integrations/payment/shuncheng"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/modules/order"
"hfb_sys/backend/pkg/response"
@@ -154,6 +155,40 @@ func (h *Handler) LakalaNotify(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "执行成功"})
}
+func (h *Handler) ShunchengNotify(c *gin.Context) {
+ body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
+ if err != nil {
+ c.String(http.StatusOK, "FAIL")
+ return
+ }
+ params, err := shuncheng.ParsePayload(body)
+ if err != nil {
+ c.String(http.StatusOK, "FAIL")
+ return
+ }
+ rawPayload := string(body)
+ contentType := c.GetHeader("Content-Type")
+ authorization := c.GetHeader("Authorization")
+ log.Printf(
+ "[payment] shuncheng notify received third_order_id=%s schc_order_id=%s schc_refund_id=%s status=%s amount=%s content_type=%s raw_payload=%s",
+ params["third_order_id"],
+ params["schc_order_id"],
+ params["schc_refund_id"],
+ params["status"],
+ params["amount"],
+ contentType,
+ rawPayload,
+ )
+ result, err := h.service.HandleNotify(c.Request.Context(), "shuncheng", params, rawPayload, contentType, authorization)
+ if err != nil || result == nil || !result.OK {
+ log.Printf("[payment] shuncheng notify failed third_order_id=%s err=%v", params["third_order_id"], err)
+ c.String(http.StatusOK, "FAIL")
+ return
+ }
+ log.Printf("[payment] shuncheng notify processed third_order_id=%s status=%s", params["third_order_id"], params["status"])
+ c.String(http.StatusOK, result.Message)
+}
+
func currentUserID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextUserID)
if !ok {
diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go
index 7f4c76f..63a52e7 100644
--- a/backend/internal/modules/payment/repository.go
+++ b/backend/internal/modules/payment/repository.go
@@ -106,14 +106,13 @@ func (r *Repository) runtimeConfigForPayment(ctx context.Context, payment *model
return nil, err
}
}
- if provider != "leshua" {
- if provider == "lakala" && r.configRepo != nil {
- dto, err := r.configRepo.FindDefaultByProviderPayWay(ctx, provider, payWay, true)
- if err != nil {
- return nil, err
- }
- return runtimeConfigFromDTO(dto), nil
- }
+ if provider == "mock" {
+ return &runtimePaymentConfig{
+ Provider: provider,
+ MerchantID: merchantID,
+ }, nil
+ }
+ if provider != "leshua" && provider != "lakala" && provider != "shuncheng" {
return &runtimePaymentConfig{
Provider: provider,
MerchantID: merchantID,
diff --git a/backend/internal/modules/paymentconfig/dto.go b/backend/internal/modules/paymentconfig/dto.go
index f2f23b3..157a21b 100644
--- a/backend/internal/modules/paymentconfig/dto.go
+++ b/backend/internal/modules/paymentconfig/dto.go
@@ -24,6 +24,7 @@ var (
ErrAppIDRequired = errors.New("extra_config.app_id is required for lakala provider")
ErrSerialNoRequired = errors.New("extra_config.serial_no is required for lakala provider")
ErrTermNoRequired = errors.New("extra_config.term_no is required for lakala provider")
+ ErrSecretIDRequired = errors.New("extra_config.secret_id is required for shuncheng provider")
)
// DTO 数据传输对象
@@ -57,7 +58,7 @@ type ConfigDTO struct {
// CreateRequest 创建支付配置请求
type CreateRequest struct {
Name string `json:"name" binding:"required"`
- Provider string `json:"provider" binding:"required,oneof=leshua lakala mock"`
+ Provider string `json:"provider" binding:"required,oneof=leshua lakala shuncheng mock"`
MerchantID string `json:"merchant_id" binding:"required"`
GatewayURL string `json:"gateway_url"`
SignKey string `json:"sign_key"`
diff --git a/backend/internal/modules/paymentconfig/repository_test.go b/backend/internal/modules/paymentconfig/repository_test.go
index 5df8642..c1b2edf 100644
--- a/backend/internal/modules/paymentconfig/repository_test.go
+++ b/backend/internal/modules/paymentconfig/repository_test.go
@@ -48,6 +48,28 @@ func TestImportCreateRequestPreservesTestingConfig(t *testing.T) {
}
}
+func TestValidateCreateRequestRequiresShunchengSecretID(t *testing.T) {
+ repo := &Repository{}
+ req := CreateRequest{
+ Name: "顺成生产商户",
+ Provider: "shuncheng",
+ MerchantID: "0000000038",
+ GatewayURL: "http://jyzxapi.ydxnhb.com",
+ SignKey: "secret-key",
+ NotifyKey: "notify-key",
+ NotifyURL: "https://example.com/api/payments/shuncheng/notify",
+ PayWay: "ZFBZF",
+ }
+ if err := repo.validateCreateRequest(req); err != ErrSecretIDRequired {
+ t.Fatalf("validateCreateRequest() error = %v, want ErrSecretIDRequired", err)
+ }
+
+ req.ExtraConfig = map[string]any{"secret_id": "SEC-1"}
+ if err := repo.validateCreateRequest(req); err != nil {
+ t.Fatalf("validateCreateRequest() error = %v, want nil", err)
+ }
+}
+
// TestListUsesSuccessfulPaymentStats 验证配置页只统计成功支付,不把打开二维码或取消订单算作成交。
func TestListUsesSuccessfulPaymentStats(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
diff --git a/backend/internal/modules/paymentconfig/validation.go b/backend/internal/modules/paymentconfig/validation.go
index 747bad4..f791fcc 100644
--- a/backend/internal/modules/paymentconfig/validation.go
+++ b/backend/internal/modules/paymentconfig/validation.go
@@ -62,12 +62,29 @@ func (r *Repository) validateCreateRequest(req CreateRequest) error {
return ErrTermNoRequired
}
}
+ if req.Provider == "shuncheng" {
+ if req.GatewayURL == "" {
+ return ErrGatewayURLRequired
+ }
+ if req.SignKey == "" {
+ return ErrSignKeyRequired
+ }
+ if req.NotifyKey == "" {
+ return ErrNotifyKeyRequired
+ }
+ if req.NotifyURL == "" {
+ return ErrNotifyURLRequired
+ }
+ if extraString(req.ExtraConfig, "secret_id") == "" {
+ return ErrSecretIDRequired
+ }
+ }
return nil
}
func isValidProvider(value string) bool {
- return value == "leshua" || value == "lakala" || value == "mock"
+ return value == "leshua" || value == "lakala" || value == "shuncheng" || value == "mock"
}
func isValidSignType(value string) bool {
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index d199fa7..1b6188b 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -344,6 +344,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
api.GET("/public/files/object", fileHandler.PublicObject)
api.POST("/payments/leshua/notify", paymentHandler.LeshuaNotify)
api.POST("/payments/lakala/notify", paymentHandler.LakalaNotify)
+ api.POST("/payments/shuncheng/notify", paymentHandler.ShunchengNotify)
openRoutes := api.Group("/open")
{
diff --git a/frontend/src/features/admin/components/PaymentConfigDialog.vue b/frontend/src/features/admin/components/PaymentConfigDialog.vue
index 2c161fb..c9552ad 100644
--- a/frontend/src/features/admin/components/PaymentConfigDialog.vue
+++ b/frontend/src/features/admin/components/PaymentConfigDialog.vue
@@ -34,6 +34,11 @@ const defaultGateways: Record = {
mock: '',
}
+const shunchengGateways: Record = {
+ production: 'http://jyzxapi.ydxnhb.com',
+ sandbox: 'http://jyzxapi.ydxnhb.com',
+}
+
const lakalaGateways: Record = {
production: 'https://s2.lakala.com',
sandbox: 'https://test.wsmsd.cn/sit',
@@ -42,12 +47,14 @@ const lakalaGateways: Record = {
const defaultNotifyURLs: Record = {
leshua: 'https://hao858.com/api/payments/leshua/notify',
lakala: 'https://hao858.com/api/payments/lakala/notify',
+ shuncheng: 'https://hao858.com/api/payments/shuncheng/notify',
mock: '',
}
const defaultJumpURLs: Record = {
leshua: 'https://hao858.com/orders',
lakala: 'https://hao858.com/orders',
+ shuncheng: 'https://hao858.com/orders',
mock: '',
}
@@ -66,6 +73,9 @@ function defaultGateway(provider: string, environment = defaultEnvironment(provi
if (provider === 'lakala') {
return lakalaGateways[environment] || lakalaGateways.production
}
+ if (provider === 'shuncheng') {
+ return shunchengGateways[environment] || shunchengGateways.production
+ }
return defaultGateways[provider] || ''
}
@@ -74,6 +84,11 @@ function isDefaultLakalaGateway(value?: string) {
return !value || Object.values(lakalaGateways).includes(value)
}
+// isDefaultShunchengGateway 判断当前网关是否仍是顺成系统默认值。
+function isDefaultShunchengGateway(value?: string) {
+ return !value || Object.values(shunchengGateways).includes(value)
+}
+
function defaultExtraConfig(provider: string) {
if (provider === 'lakala') {
return {
@@ -85,6 +100,14 @@ function defaultExtraConfig(provider: string) {
order_expire_minutes: 15,
}
}
+ if (provider === 'shuncheng') {
+ return {
+ secret_id: '',
+ shop_no: '',
+ refund_type: '',
+ token_refresh_margin_seconds: 300,
+ }
+ }
return {}
}
@@ -125,8 +148,13 @@ const dialogTitle = computed(() => {
const isReadonly = computed(() => props.mode === 'view')
const isLakala = computed(() => formData.value.provider === 'lakala')
+const isShuncheng = computed(() => formData.value.provider === 'shuncheng')
const isMock = computed(() => formData.value.provider === 'mock')
-const signKeyLabel = computed(() => (isLakala.value ? '商户私钥' : '签名密钥'))
+const signKeyLabel = computed(() => {
+ if (isLakala.value) return '商户私钥'
+ if (isShuncheng.value) return 'SecretKey'
+ return '签名密钥'
+})
const notifyKeyLabel = computed(() => (isLakala.value ? '通知证书' : '通知密钥'))
const extraConfig = computed>(() => formData.value.extra_config || {})
@@ -200,10 +228,15 @@ function handlePayWayChange(payWay: string | number | boolean) {
syncLakalaPayMode(String(payWay))
}
-// handleEnvironmentChange 处理环境切换,并同步拉卡拉默认网关。
+// handleEnvironmentChange 处理环境切换,并同步渠道默认网关。
function handleEnvironmentChange(environment: string) {
- if (!isLakala.value || !isDefaultLakalaGateway(formData.value.gateway_url)) return
- formData.value.gateway_url = defaultGateway('lakala', environment)
+ if (isLakala.value && isDefaultLakalaGateway(formData.value.gateway_url)) {
+ formData.value.gateway_url = defaultGateway('lakala', environment)
+ return
+ }
+ if (isShuncheng.value && isDefaultShunchengGateway(formData.value.gateway_url)) {
+ formData.value.gateway_url = defaultGateway('shuncheng', environment)
+ }
}
async function handleViewSecrets() {
@@ -284,6 +317,7 @@ function handleClose() {
>
+
@@ -328,6 +362,14 @@ function handleClose() {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue
index 59aa042..8281e3a 100644
--- a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue
+++ b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue
@@ -1,7 +1,7 @@