From 6d385478a638f2fb99a808c02c7a29d4d6ffe1b2 Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 18 Jun 2026 21:33:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E9=A1=BA=E6=88=90=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E5=B9=B6=E4=BC=98=E5=8C=96=E9=85=8D=E7=BD=AE=E5=88=97?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integrations/payment/shuncheng/client.go | 1028 +++++++++++++++++ .../payment/shuncheng/client_test.go | 266 +++++ .../modules/payment/channel_adapter.go | 164 ++- .../modules/payment/channel_status.go | 2 +- backend/internal/modules/payment/handler.go | 35 + .../internal/modules/payment/repository.go | 15 +- backend/internal/modules/paymentconfig/dto.go | 3 +- .../modules/paymentconfig/repository_test.go | 22 + .../modules/paymentconfig/validation.go | 19 +- backend/internal/router/router.go | 1 + .../admin/components/PaymentConfigDialog.vue | 81 +- .../admin/views/AdminPaymentConfigsView.vue | 166 ++- .../admin/views/AdminPaymentsView.vue | 1 + 13 files changed, 1763 insertions(+), 40 deletions(-) create mode 100644 backend/internal/integrations/payment/shuncheng/client.go create mode 100644 backend/internal/integrations/payment/shuncheng/client_test.go 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 @@