From d403ffc734d4214e805cbaf09a4c45f3f030cdd6 Mon Sep 17 00:00:00 2001 From: yml Date: Wed, 11 Nov 2026 15:57:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(payment):=20=E6=8E=A5=E5=85=A5=E6=89=AB?= =?UTF-8?q?=E5=91=97=E6=99=AE=E9=80=9A=E6=94=AF=E4=BB=982.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integrations/payment/saobei/client.go | 583 ++++++++++++++++++ .../payment/saobei/client_test.go | 220 +++++++ .../modules/payment/channel_adapter.go | 168 ++++- .../modules/payment/channel_breaker.go | 4 +- .../internal/modules/payment/channel_probe.go | 6 +- backend/internal/modules/payment/handler.go | 24 + .../modules/payment/mohong_payment.go | 6 +- backend/internal/modules/payment/notify.go | 32 +- backend/internal/modules/payment/presenter.go | 17 + backend/internal/modules/payment/query.go | 6 +- backend/internal/modules/payment/refund.go | 24 +- .../internal/modules/payment/refund_mohong.go | 15 +- .../internal/modules/payment/repository.go | 2 +- backend/internal/modules/paymentconfig/dto.go | 3 +- .../internal/modules/paymentconfig/handler.go | 4 +- .../modules/paymentconfig/validation.go | 16 +- .../modules/paymentconfig/validation_test.go | 28 + backend/internal/router/router.go | 1 + deploy/docker-compose.dev.yml | 20 +- docs/扫呗支付接入说明.md | 50 ++ .../AdminDistributionPaymentChannelsPage.tsx | 1 + .../admin/AdminPaymentConfigsPage.tsx | 138 ++++- .../src/features/admin/AdminPaymentsPage.tsx | 4 +- .../src/features/crash/CrashDetailPage.tsx | 12 +- .../src/features/crash/CrashOrdersPage.tsx | 12 +- frontend/src/features/crash/api.ts | 13 +- .../features/mobile/MobileCrashDetailPage.tsx | 12 +- .../features/mobile/MobileCrashOrdersPage.tsx | 12 +- .../src/features/orders/OrderActionPanel.tsx | 8 +- scripts/dev.sh | 7 + 30 files changed, 1337 insertions(+), 111 deletions(-) create mode 100644 backend/internal/integrations/payment/saobei/client.go create mode 100644 backend/internal/integrations/payment/saobei/client_test.go create mode 100644 backend/internal/modules/paymentconfig/validation_test.go create mode 100644 docs/扫呗支付接入说明.md diff --git a/backend/internal/integrations/payment/saobei/client.go b/backend/internal/integrations/payment/saobei/client.go new file mode 100644 index 0000000..be30485 --- /dev/null +++ b/backend/internal/integrations/payment/saobei/client.go @@ -0,0 +1,583 @@ +package saobei + +import ( + "bytes" + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "hfb_jysh/backend/internal/timeutil" +) + +var ( + ErrConfigIncomplete = errors.New("saobei payment config incomplete") + ErrVerifyFailed = errors.New("saobei signature verify failed") +) + +const ( + endpointQRPay = "/pay/open/qrpay" + endpointQuery = "/pay/open/query" + endpointRefund = "/pay/open/refund" + endpointQueryRefund = "/pay/open/queryrefund" +) + +type Config struct { + GatewayURL string + MerchantID string + TerminalID string + AccessToken string + NotifyURL string + DeviceNo string + ExpireSecond int +} + +type Client struct { + cfg Config + httpClient *http.Client +} + +func NewClient(cfg Config) *Client { + return &Client{ + cfg: cfg, + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +type CreatePaymentRequest struct { + ThirdOrderID string + AmountCent int64 + NotifyURL string + Body string + Attach string +} + +type CreatePaymentResponse struct { + OK bool + ErrorMessage string + MerchantID string + ThirdOrderID string + ProviderOrderID string + Status string + QRURL string + Raw map[string]string + RawRequest map[string]string +} + +type QueryPaymentRequest struct { + ThirdOrderID string + TerminalTime string + ProviderOrderID string +} + +type QueryPaymentResponse struct { + OK bool + ErrorMessage string + MerchantID string + ThirdOrderID string + ProviderOrderID string + Status string + Amount string + PayTime string + Raw map[string]string +} + +type CreateRefundRequest struct { + ThirdOrderID string + TerminalTime string + ProviderOrderID string + MerchantRefundID string + RefundAmountCent int64 + Attach string + Remark string +} + +type CreateRefundResponse struct { + OK bool + ErrorMessage string + MerchantID string + ProviderRefundID string + Status string + RefundAmount string + RawRequest map[string]string + Raw map[string]string +} + +type QueryRefundRequest struct { + MerchantRefundID string + MerchantRefundTime string + ProviderRefundID string +} + +type QueryRefundResponse struct { + OK bool + ErrorMessage 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 +} + +func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*CreatePaymentResponse, error) { + if err := c.validate(); err != nil { + return nil, err + } + now := timeutil.ShanghaiNow() + expireSeconds := c.cfg.ExpireSecond + if expireSeconds <= 0 { + expireSeconds = 900 + } + if expireSeconds > 7200 { + expireSeconds = 7200 + } + params := map[string]string{ + "pay_ver": "202", + "pay_type": "000", + "service_id": "016", + "merchant_no": c.cfg.MerchantID, + "terminal_id": c.cfg.TerminalID, + "terminal_trace": req.ThirdOrderID, + "terminal_time": now.Format("20060102150405"), + "total_fee": strconv.FormatInt(req.AmountCent, 10), + "order_body": sanitizeText(req.Body, 128), + "notify_url": firstNonEmpty(req.NotifyURL, c.cfg.NotifyURL), + "attach": sanitizeText(req.Attach, 128), + "timeout_express": strconv.Itoa(expireSeconds), + "repeated_trace": "3", + "auto_pay": "1", + } + if c.cfg.DeviceNo != "" { + params["device_no"] = c.cfg.DeviceNo + } + params["key_sign"] = Sign(params, c.cfg.AccessToken) + raw, err := c.post(ctx, endpointQRPay, params) + if err != nil { + return nil, err + } + if err := c.verifyResponse(raw); err != nil { + return nil, err + } + return &CreatePaymentResponse{ + OK: responseOK(raw), + ErrorMessage: responseMessage(raw), + MerchantID: firstNonEmpty(raw["merchant_no"], c.cfg.MerchantID), + ThirdOrderID: firstNonEmpty(raw["terminal_trace"], req.ThirdOrderID), + Status: "paying", + QRURL: raw["qr_url"], + Raw: raw, + RawRequest: copyMap(params), + }, nil +} + +func (c *Client) QueryPayment(ctx context.Context, req QueryPaymentRequest) (*QueryPaymentResponse, error) { + if err := c.validate(); err != nil { + return nil, err + } + now := timeutil.ShanghaiNow() + params := map[string]string{ + "pay_ver": "202", + "pay_type": "000", + "service_id": "020", + "merchant_no": c.cfg.MerchantID, + "terminal_id": c.cfg.TerminalID, + "terminal_trace": newTrace("QRY"), + "terminal_time": now.Format("20060102150405"), + } + if req.ProviderOrderID != "" { + params["out_trade_no"] = req.ProviderOrderID + } else { + params["pay_trace"] = req.ThirdOrderID + payTime := req.TerminalTime + if payTime == "" { + payTime = now.Format("20060102150405") + } + params["pay_time"] = payTime + } + params["key_sign"] = Sign(params, c.cfg.AccessToken) + raw, err := c.post(ctx, endpointQuery, params) + if err != nil { + return nil, err + } + if err := c.verifyResponse(raw); err != nil { + return nil, err + } + return &QueryPaymentResponse{ + OK: responseCode(raw) == "01", + ErrorMessage: responseMessage(raw), + MerchantID: firstNonEmpty(raw["merchant_no"], c.cfg.MerchantID), + ThirdOrderID: firstNonEmpty(raw["pay_trace"], raw["terminal_trace"], req.ThirdOrderID), + ProviderOrderID: firstNonEmpty(raw["out_trade_no"], req.ProviderOrderID), + Status: paymentStatus(raw), + Amount: raw["total_fee"], + PayTime: firstNonEmpty(raw["end_time"], raw["terminal_time"]), + Raw: raw, + }, nil +} + +func (c *Client) CreateRefund(ctx context.Context, req CreateRefundRequest) (*CreateRefundResponse, error) { + if err := c.validate(); err != nil { + return nil, err + } + now := timeutil.ShanghaiNow() + params := map[string]string{ + "pay_ver": "202", + "pay_type": "000", + "service_id": "030", + "merchant_no": c.cfg.MerchantID, + "terminal_id": c.cfg.TerminalID, + "terminal_trace": req.MerchantRefundID, + "terminal_time": now.Format("20060102150405"), + "refund_fee": strconv.FormatInt(req.RefundAmountCent, 10), + "order_body": sanitizeText(req.Remark, 80), + "attach": sanitizeText(req.Attach, 128), + "repeated_trace": "1", + } + if c.cfg.DeviceNo != "" { + params["device_no"] = c.cfg.DeviceNo + } + if req.ProviderOrderID != "" { + params["out_trade_no"] = req.ProviderOrderID + } else { + params["pay_trace"] = req.ThirdOrderID + if req.TerminalTime != "" { + params["pay_time"] = req.TerminalTime + } + } + params["key_sign"] = Sign(params, c.cfg.AccessToken) + raw, err := c.post(ctx, endpointRefund, params) + if err != nil { + return nil, err + } + if err := c.verifyResponse(raw); err != nil { + return nil, err + } + return &CreateRefundResponse{ + OK: responseCode(raw) == "01" || responseCode(raw) == "02", + ErrorMessage: responseMessage(raw), + MerchantID: firstNonEmpty(raw["merchant_no"], c.cfg.MerchantID), + ProviderRefundID: raw["out_refund_no"], + Status: refundStatus(raw), + RefundAmount: raw["refund_fee"], + RawRequest: copyMap(params), + Raw: raw, + }, nil +} + +func (c *Client) QueryRefund(ctx context.Context, req QueryRefundRequest) (*QueryRefundResponse, error) { + if err := c.validate(); err != nil { + return nil, err + } + now := timeutil.ShanghaiNow() + params := map[string]string{ + "pay_ver": "202", + "pay_type": "000", + "service_id": "031", + "merchant_no": c.cfg.MerchantID, + "terminal_id": c.cfg.TerminalID, + "terminal_trace": newTrace("QRF"), + "terminal_time": now.Format("20060102150405"), + } + if req.ProviderRefundID != "" { + params["out_refund_no"] = req.ProviderRefundID + } else { + params["pay_trace"] = req.MerchantRefundID + refundTime := req.MerchantRefundTime + if refundTime == "" { + refundTime = now.Format("20060102150405") + } + params["pay_time"] = refundTime + } + params["key_sign"] = Sign(params, c.cfg.AccessToken) + raw, err := c.post(ctx, endpointQueryRefund, params) + if err != nil { + return nil, err + } + if err := c.verifyResponse(raw); err != nil { + return nil, err + } + return &QueryRefundResponse{ + OK: responseCode(raw) == "01" || responseCode(raw) == "02", + ErrorMessage: responseMessage(raw), + ProviderRefundID: firstNonEmpty(raw["out_refund_no"], req.ProviderRefundID), + Status: refundStatus(raw), + RefundAmount: raw["refund_fee"], + RefundTime: raw["end_time"], + Raw: raw, + }, nil +} + +func (c *Client) VerifyNotify(params map[string]string) VerifyNotifyResult { + got := strings.TrimSpace(params["key_sign"]) + result := VerifyNotifyResult{ + Got: got, + Expected: map[string]string{}, + BaseString: map[string]string{}, + ParamKeys: paramKeys(params), + } + if got == "" || c.cfg.AccessToken == "" { + return result + } + expected := Sign(params, c.cfg.AccessToken) + result.Expected["access_token"] = expected + result.BaseString["access_token"] = SignBaseString(params) + result.OK = strings.EqualFold(got, expected) + if result.OK { + result.MatchedKey = "access_token" + } + return result +} + +func VerifyNotify(params map[string]string, accessToken string) bool { + return NewClient(Config{AccessToken: accessToken}).VerifyNotify(params).OK +} + +func ParsePayload(body []byte) (map[string]string, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + out := make(map[string]string, len(payload)) + for key, value := range payload { + switch typed := value.(type) { + case nil: + out[key] = "" + case string: + out[key] = typed + case float64: + if typed == float64(int64(typed)) { + out[key] = strconv.FormatInt(int64(typed), 10) + } else { + out[key] = strconv.FormatFloat(typed, 'f', -1, 64) + } + case bool: + out[key] = strconv.FormatBool(typed) + default: + raw, _ := json.Marshal(typed) + out[key] = string(raw) + } + } + return out, nil +} + +func CanonicalizePayload(params map[string]string) map[string]string { + out := copyMap(params) + out["third_order_id"] = firstNonEmpty(params["terminal_trace"], params["third_order_id"]) + out["provider_order_id"] = firstNonEmpty(params["out_trade_no"], params["channel_trade_no"], params["channel_order_no"], params["provider_order_id"]) + out["amount"] = firstNonEmpty(params["total_fee"], params["amount"]) + out["pay_time"] = firstNonEmpty(params["end_time"], params["pay_time"]) + out["status"] = paymentStatus(params) + return out +} + +func Sign(params map[string]string, accessToken string) string { + base := SignBaseString(params) + signString := "access_token=" + accessToken + if base != "" { + signString = base + "&access_token=" + accessToken + } + sum := md5.Sum([]byte(signString)) + return hex.EncodeToString(sum[:]) +} + +func SignBaseString(params map[string]string) string { + keys := make([]string, 0, len(params)) + for key := range params { + if key == "key_sign" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+params[key]) + } + return strings.Join(parts, "&") +} + +func (c *Client) validate() error { + if strings.TrimSpace(c.cfg.GatewayURL) == "" || + strings.TrimSpace(c.cfg.MerchantID) == "" || + strings.TrimSpace(c.cfg.TerminalID) == "" || + strings.TrimSpace(c.cfg.AccessToken) == "" { + return ErrConfigIncomplete + } + return nil +} + +func (c *Client) post(ctx context.Context, endpoint string, params map[string]string) (map[string]string, error) { + body, err := json.Marshal(params) + if err != nil { + return nil, err + } + url := strings.TrimRight(strings.TrimSpace(c.cfg.GatewayURL), "/") + endpoint + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, 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 nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("saobei http status %d: %s", resp.StatusCode, string(respBody)) + } + raw, err := ParsePayload(respBody) + if err != nil { + return nil, fmt.Errorf("saobei parse response: %w", err) + } + return raw, nil +} + +func (c *Client) verifyResponse(raw map[string]string) error { + if raw["key_sign"] == "" || c.cfg.AccessToken == "" { + return ErrVerifyFailed + } + expected := Sign(raw, c.cfg.AccessToken) + if !strings.EqualFold(raw["key_sign"], expected) { + return ErrVerifyFailed + } + return nil +} + +func responseCode(raw map[string]string) string { + return strings.TrimSpace(raw["return_code"]) +} + +func responseOK(raw map[string]string) bool { + return responseCode(raw) == "01" && strings.TrimSpace(raw["result_code"]) == "01" +} + +func responseMessage(raw map[string]string) string { + return firstNonEmpty(raw["return_msg"], raw["result_msg"], raw["message"], raw["msg"]) +} + +func paymentStatus(raw map[string]string) string { + state := strings.ToUpper(strings.TrimSpace(raw["trade_state"])) + switch state { + case "SUCCESS": + return "paid" + case "REFUND": + return "paid" + case "CLOSED", "REVOKED", "NOPAY": + return "closed" + case "PAYERROR": + return "failed" + case "NOTPAY", "USERPAYING": + return "paying" + } + if responseCode(raw) == "01" && raw["result_code"] == "01" { + return "paid" + } + if responseCode(raw) == "01" && raw["result_code"] == "03" { + return "paying" + } + return "paying" +} + +func refundStatus(raw map[string]string) string { + state := strings.ToUpper(strings.TrimSpace(raw["trade_state"])) + switch state { + case "SUCCESS": + return "refunded" + case "FAIL", "NOREFUND", "PAYERROR": + return "failed" + case "REFUNDING": + return "refunding" + } + switch raw["result_code"] { + case "01": + return "refunded" + case "03": + return "refunding" + case "02": + if responseCode(raw) == "01" { + return "failed" + } + } + return "refunding" +} + +func paramKeys(params map[string]string) []string { + keys := make([]string, 0, len(params)) + for key := range params { + if key == "key_sign" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func copyMap(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 newTrace(prefix string) string { + now := time.Now() + return fmt.Sprintf("%s%d%04d", prefix, now.UnixNano(), randomInt(10000)) +} + +func randomInt(max int) int { + if max <= 0 { + return 0 + } + var buf [4]byte + if _, err := rand.Read(buf[:]); err != nil { + return int(time.Now().UnixNano() % int64(max)) + } + value := uint32(buf[0])<<24 | uint32(buf[1])<<16 | uint32(buf[2])<<8 | uint32(buf[3]) + return int(value % uint32(max)) +} + +func sanitizeText(value string, max int) string { + value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "\r", " ") + value = strings.TrimSpace(value) + if max > 0 && len([]rune(value)) > max { + return string([]rune(value)[:max]) + } + return value +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/backend/internal/integrations/payment/saobei/client_test.go b/backend/internal/integrations/payment/saobei/client_test.go new file mode 100644 index 0000000..1f9d642 --- /dev/null +++ b/backend/internal/integrations/payment/saobei/client_test.go @@ -0,0 +1,220 @@ +package saobei + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSignIncludesEmptyFieldsAndUsesAccessToken(t *testing.T) { + params := map[string]string{ + "merchant_no": "M1", + "terminal_id": "T1", + "amount": "100", + "attach": "", + "key_sign": "ignored", + } + base := SignBaseString(params) + wantBase := "amount=100&attach=&merchant_no=M1&terminal_id=T1" + if base != wantBase { + t.Fatalf("SignBaseString() = %q, want %q", base, wantBase) + } + if got, want := Sign(params, "secret"), "7258c86e8a1e4a2f1ae66c2855f46d24"; got != want { + t.Fatalf("Sign() = %q, want %q", got, want) + } +} + +func TestCreatePaymentPostsQRPayAndVerifiesResponse(t *testing.T) { + var captured map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != endpointQRPay { + t.Fatalf("path = %q, want %q", r.URL.Path, endpointQRPay) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decode request: %v", err) + } + if captured["service_id"] != "016" || captured["pay_type"] != "000" { + t.Fatalf("unexpected qrpay request: %#v", captured) + } + if captured["key_sign"] != Sign(captured, "token-1") { + t.Fatalf("request key_sign = %q", captured["key_sign"]) + } + response := map[string]string{ + "return_code": "01", + "return_msg": "预支付请求成功", + "result_code": "01", + "merchant_no": "M1", + "terminal_id": "T1", + "terminal_trace": "PAY1", + "terminal_time": captured["terminal_time"], + "total_fee": "100", + "qr_url": "https://pay.example/qr1", + } + response["key_sign"] = Sign(response, "token-1") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := NewClient(Config{ + GatewayURL: server.URL, + MerchantID: "M1", + TerminalID: "T1", + AccessToken: "token-1", + NotifyURL: "https://example.com/api/payments/saobei/notify", + }) + resp, err := client.CreatePayment(context.Background(), CreatePaymentRequest{ + ThirdOrderID: "PAY1", + AmountCent: 100, + Body: "租号订单", + Attach: "ORD1", + }) + if err != nil { + t.Fatalf("CreatePayment() error = %v", err) + } + if !resp.OK || resp.QRURL != "https://pay.example/qr1" || resp.Status != "paying" { + t.Fatalf("CreatePayment() response = %+v", resp) + } + if captured["notify_url"] != "https://example.com/api/payments/saobei/notify" { + t.Fatalf("notify_url = %q", captured["notify_url"]) + } + if captured["repeated_trace"] != "3" || captured["auto_pay"] != "1" { + t.Fatalf("qrpay safety fields = %#v", captured) + } +} + +func TestQueryPaymentUsesTraceTimeAndMapsSuccess(t *testing.T) { + var captured map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decode query: %v", err) + } + response := map[string]string{ + "return_code": "01", + "return_msg": "支付成功", + "result_code": "01", + "merchant_no": "M1", + "pay_trace": "PAY1", + "pay_time": "20261111120000", + "out_trade_no": "SB1", + "total_fee": "100", + "trade_state": "SUCCESS", + "end_time": "20261111120100", + "terminal_time": captured["terminal_time"], + } + response["key_sign"] = Sign(response, "token-1") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := NewClient(Config{ + GatewayURL: server.URL, + MerchantID: "M1", + TerminalID: "T1", + AccessToken: "token-1", + }) + resp, err := client.QueryPayment(context.Background(), QueryPaymentRequest{ + ThirdOrderID: "PAY1", + TerminalTime: "20261111120000", + }) + if err != nil { + t.Fatalf("QueryPayment() error = %v", err) + } + if captured["pay_trace"] != "PAY1" || captured["pay_time"] != "20261111120000" { + t.Fatalf("query trace fields = %#v", captured) + } + if resp.Status != "paid" || resp.ProviderOrderID != "SB1" || resp.Amount != "100" { + t.Fatalf("QueryPayment() response = %+v", resp) + } +} + +func TestParseNotifyAndVerifySignature(t *testing.T) { + params := map[string]string{ + "pay_ver": "202", + "return_code": "01", + "return_msg": "支付成功", + "result_code": "01", + "merchant_no": "M1", + "terminal_id": "T1", + "terminal_trace": "PAY1", + "terminal_time": "20261111120000", + "end_time": "20261111120100", + "out_trade_no": "SB1", + "total_fee": "100", + } + params["key_sign"] = Sign(params, "token-1") + body, _ := json.Marshal(params) + parsed, err := ParsePayload(body) + if err != nil { + t.Fatalf("ParsePayload() error = %v", err) + } + canonical := CanonicalizePayload(parsed) + if canonical["third_order_id"] != "PAY1" || canonical["provider_order_id"] != "SB1" || + canonical["amount"] != "100" || canonical["status"] != "paid" { + t.Fatalf("canonical notify = %#v", canonical) + } + if result := NewClient(Config{AccessToken: "token-1"}).VerifyNotify(parsed); !result.OK { + t.Fatalf("VerifyNotify() failed: got=%s expected=%v", result.Got, result.Expected) + } + parsed["total_fee"] = "101" + if NewClient(Config{AccessToken: "token-1"}).VerifyNotify(parsed).OK { + t.Fatal("VerifyNotify() accepted a tampered amount") + } +} + +func TestRefundAndRefundQueryStatusMapping(t *testing.T) { + responses := []map[string]string{ + { + "return_code": "01", + "return_msg": "退款成功", + "result_code": "01", + "merchant_no": "M1", + "refund_fee": "100", + "out_refund_no": "RF1", + }, + { + "return_code": "01", + "return_msg": "退款查询成功", + "result_code": "01", + "merchant_no": "M1", + "refund_fee": "100", + "out_refund_no": "RF1", + "trade_state": "SUCCESS", + "end_time": "20261111121000", + }, + } + var index int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := responses[index] + index++ + response["key_sign"] = Sign(response, "token-1") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := NewClient(Config{ + GatewayURL: server.URL, + MerchantID: "M1", + TerminalID: "T1", + AccessToken: "token-1", + }) + refund, err := client.CreateRefund(context.Background(), CreateRefundRequest{ + ProviderOrderID: "SB1", + MerchantRefundID: "REF1", + RefundAmountCent: 100, + }) + if err != nil { + t.Fatalf("CreateRefund() error = %v", err) + } + if refund.Status != "refunded" || refund.ProviderRefundID != "RF1" { + t.Fatalf("CreateRefund() response = %+v", refund) + } + query, err := client.QueryRefund(context.Background(), QueryRefundRequest{ProviderRefundID: "RF1"}) + if err != nil { + t.Fatalf("QueryRefund() error = %v", err) + } + if query.Status != "refunded" || query.RefundTime == "" { + t.Fatalf("QueryRefund() response = %+v", query) + } +} diff --git a/backend/internal/modules/payment/channel_adapter.go b/backend/internal/modules/payment/channel_adapter.go index 30e72de..950db48 100644 --- a/backend/internal/modules/payment/channel_adapter.go +++ b/backend/internal/modules/payment/channel_adapter.go @@ -9,13 +9,14 @@ import ( "hfb_jysh/backend/internal/integrations/payment/lakala" "hfb_jysh/backend/internal/integrations/payment/leshua" + "hfb_jysh/backend/internal/integrations/payment/saobei" "hfb_jysh/backend/internal/integrations/payment/shuncheng" "hfb_jysh/backend/internal/modules/paymentconfig" ) type channelClient interface { CreatePayment(ctx context.Context, req channelCreatePaymentRequest) (*channelCreatePaymentResponse, error) - QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) + QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) CreateRefund(ctx context.Context, req channelCreateRefundRequest) (*channelCreateRefundResponse, error) QueryRefund(ctx context.Context, req channelQueryRefundRequest) (*channelQueryRefundResponse, error) VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) @@ -47,6 +48,12 @@ type channelCreatePaymentResponse struct { Raw map[string]string } +type channelQueryPaymentRequest struct { + ThirdOrderID string + TraceTime string + ProviderOrderID string +} + type channelQueryPaymentResponse struct { OK bool ErrorMessage string @@ -59,14 +66,15 @@ type channelQueryPaymentResponse struct { } type channelCreateRefundRequest struct { - ThirdOrderID string - ProviderOrderID string - MerchantRefundID string - RefundAmountCent int64 - NotifyURL string - Attach string - Remark string - ClientIP string + ThirdOrderID string + OriginalTraceTime string + ProviderOrderID string + MerchantRefundID string + RefundAmountCent int64 + NotifyURL string + Attach string + Remark string + ClientIP string } type channelCreateRefundResponse struct { @@ -80,10 +88,11 @@ type channelCreateRefundResponse struct { } type channelQueryRefundRequest struct { - ThirdOrderID string - ProviderOrderID string - MerchantRefundID string - ProviderRefundID string + ThirdOrderID string + ProviderOrderID string + MerchantRefundID string + MerchantRefundTime string + ProviderRefundID string } type channelQueryRefundResponse struct { @@ -144,8 +153,8 @@ func (c leshuaChannel) CreatePayment(ctx context.Context, req channelCreatePayme }, nil } -func (c leshuaChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) { - resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID) +func (c leshuaChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) { + resp, err := c.client.QueryPayment(ctx, req.ThirdOrderID, req.ProviderOrderID) if err != nil { return nil, err } @@ -264,8 +273,8 @@ func (c lakalaChannel) CreatePayment(ctx context.Context, req channelCreatePayme }, nil } -func (c lakalaChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) { - resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID) +func (c lakalaChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) { + resp, err := c.client.QueryPayment(ctx, req.ThirdOrderID, req.ProviderOrderID) if err != nil { return nil, err } @@ -392,8 +401,8 @@ func (c shunchengChannel) CreatePayment(ctx context.Context, req channelCreatePa }, nil } -func (c shunchengChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) { - resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID) +func (c shunchengChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) { + resp, err := c.client.QueryPayment(ctx, req.ThirdOrderID, req.ProviderOrderID) if err != nil { return nil, err } @@ -471,6 +480,117 @@ func (c shunchengChannel) VerifyNotify(params map[string]string, rawPayload stri return result, nil } +type saobeiChannel struct { + client *saobei.Client +} + +func newSaobeiChannel(cfg saobei.Config) channelClient { + return saobeiChannel{client: saobei.NewClient(cfg)} +} + +func (c saobeiChannel) CreatePayment(ctx context.Context, req channelCreatePaymentRequest) (*channelCreatePaymentResponse, error) { + resp, err := c.client.CreatePayment(ctx, saobei.CreatePaymentRequest{ + ThirdOrderID: req.ThirdOrderID, + AmountCent: req.AmountCent, + NotifyURL: req.NotifyURL, + Body: req.Body, + Attach: req.Attach, + }) + if err != nil { + return nil, err + } + return &channelCreatePaymentResponse{ + OK: resp.OK, + ErrorMessage: resp.ErrorMessage, + ProviderOrderID: resp.ProviderOrderID, + Status: resp.Status, + TDCode: resp.QRURL, + JSPayURL: resp.QRURL, + RawRequest: resp.RawRequest, + Raw: resp.Raw, + }, nil +} + +func (c saobeiChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) { + resp, err := c.client.QueryPayment(ctx, saobei.QueryPaymentRequest{ + ThirdOrderID: req.ThirdOrderID, + TerminalTime: req.TraceTime, + ProviderOrderID: req.ProviderOrderID, + }) + if err != nil { + return nil, err + } + return &channelQueryPaymentResponse{ + OK: resp.OK, + ErrorMessage: resp.ErrorMessage, + ProviderOrderID: resp.ProviderOrderID, + Status: resp.Status, + Amount: resp.Amount, + PayTime: resp.PayTime, + Raw: resp.Raw, + }, nil +} + +func (c saobeiChannel) CreateRefund(ctx context.Context, req channelCreateRefundRequest) (*channelCreateRefundResponse, error) { + resp, err := c.client.CreateRefund(ctx, saobei.CreateRefundRequest{ + ThirdOrderID: req.ThirdOrderID, + TerminalTime: req.OriginalTraceTime, + ProviderOrderID: req.ProviderOrderID, + MerchantRefundID: req.MerchantRefundID, + RefundAmountCent: req.RefundAmountCent, + Attach: req.Attach, + Remark: req.Remark, + }) + 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 saobeiChannel) QueryRefund(ctx context.Context, req channelQueryRefundRequest) (*channelQueryRefundResponse, error) { + resp, err := c.client.QueryRefund(ctx, saobei.QueryRefundRequest{ + MerchantRefundID: req.MerchantRefundID, + MerchantRefundTime: req.MerchantRefundTime, + ProviderRefundID: req.ProviderRefundID, + }) + if err != nil { + return nil, err + } + return &channelQueryRefundResponse{ + OK: resp.OK, + ErrorMessage: resp.ErrorMessage, + ProviderRefundID: resp.ProviderRefundID, + Status: resp.Status, + RefundAmount: resp.RefundAmount, + RefundTime: resp.RefundTime, + Raw: resp.Raw, + }, nil +} + +func (c saobeiChannel) VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) { + verify := c.client.VerifyNotify(params) + 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 +} + func buildChannelClient(dto *paymentconfig.ConfigDTO) (channelClient, error) { switch strings.ToLower(strings.TrimSpace(dto.Provider)) { case "leshua": @@ -518,6 +638,16 @@ func buildChannelClient(dto *paymentconfig.ConfigDTO) (channelClient, error) { RefundType: extraString(dto.ExtraConfig, "refund_type"), TokenMargin: time.Duration(extraInt(dto.ExtraConfig, "token_refresh_margin_seconds")) * time.Second, })), nil + case "saobei": + return withChannelBreaker("saobei", newSaobeiChannel(saobei.Config{ + GatewayURL: dto.GatewayURL, + MerchantID: dto.MerchantID, + TerminalID: extraString(dto.ExtraConfig, "terminal_id"), + AccessToken: dto.SignKey, + NotifyURL: dto.NotifyURL, + DeviceNo: extraString(dto.ExtraConfig, "device_no"), + ExpireSecond: extraInt(dto.ExtraConfig, "expire_seconds"), + })), nil case "mock": return nil, nil default: diff --git a/backend/internal/modules/payment/channel_breaker.go b/backend/internal/modules/payment/channel_breaker.go index 0c942be..d16170c 100644 --- a/backend/internal/modules/payment/channel_breaker.go +++ b/backend/internal/modules/payment/channel_breaker.go @@ -45,9 +45,9 @@ func (c breakerChannel) CreatePayment(ctx context.Context, req channelCreatePaym }) } -func (c breakerChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) { +func (c breakerChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) { return c.queryPayment.Execute(func() (*channelQueryPaymentResponse, error) { - return c.next.QueryPayment(ctx, thirdOrderID, providerOrderID) + return c.next.QueryPayment(ctx, req) }) } diff --git a/backend/internal/modules/payment/channel_probe.go b/backend/internal/modules/payment/channel_probe.go index 0208d34..a4fef60 100644 --- a/backend/internal/modules/payment/channel_probe.go +++ b/backend/internal/modules/payment/channel_probe.go @@ -61,7 +61,11 @@ func (r *Repository) probeChannelPaid(ctx context.Context, payment *model.Paymen if runtimeConfig.isMockMode() || runtimeConfig.Channel == nil { return nil } - resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID) + resp, err := runtimeConfig.Channel.QueryPayment(ctx, channelQueryPaymentRequest{ + ThirdOrderID: payment.ThirdOrderID, + TraceTime: paymentTraceTime(payment.RawRequest), + ProviderOrderID: payment.ProviderOrderID, + }) if err != nil { return err } diff --git a/backend/internal/modules/payment/handler.go b/backend/internal/modules/payment/handler.go index 442ab3e..5f53212 100644 --- a/backend/internal/modules/payment/handler.go +++ b/backend/internal/modules/payment/handler.go @@ -9,6 +9,7 @@ import ( "hfb_jysh/backend/internal/integrations/payment/lakala" "hfb_jysh/backend/internal/integrations/payment/leshua" + "hfb_jysh/backend/internal/integrations/payment/saobei" "hfb_jysh/backend/internal/integrations/payment/shuncheng" "hfb_jysh/backend/internal/middleware" "hfb_jysh/backend/internal/modules/order" @@ -263,6 +264,29 @@ func (h *Handler) ShunchengNotify(c *gin.Context) { c.String(http.StatusOK, result.Message) } +func (h *Handler) SaobeiNotify(c *gin.Context) { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) + if err != nil { + c.JSON(http.StatusOK, gin.H{"return_code": "02", "return_msg": "读取失败"}) + return + } + params, err := saobei.ParsePayload(body) + if err != nil { + c.JSON(http.StatusOK, gin.H{"return_code": "02", "return_msg": "解析失败"}) + return + } + rawPayload := string(body) + contentType := c.GetHeader("Content-Type") + result, err := h.service.HandleNotify(c.Request.Context(), "saobei", params, rawPayload, contentType, "") + if err != nil || result == nil || !result.OK { + h.log().Warn("扫呗支付回调处理失败", notifyLogFields(c.Request.Context(), "saobei", params, contentType, len(body), zap.Error(err))...) + c.JSON(http.StatusOK, gin.H{"return_code": "02", "return_msg": "失败"}) + return + } + h.log().Info("扫呗支付回调处理完成", notifyLogFields(c.Request.Context(), "saobei", params, contentType, len(body))...) + c.JSON(http.StatusOK, gin.H{"return_code": "01", "return_msg": "success"}) +} + func currentUserID(c *gin.Context) (uint64, bool) { value, ok := c.Get(middleware.ContextUserID) if !ok { diff --git a/backend/internal/modules/payment/mohong_payment.go b/backend/internal/modules/payment/mohong_payment.go index 8a9b351..496286a 100644 --- a/backend/internal/modules/payment/mohong_payment.go +++ b/backend/internal/modules/payment/mohong_payment.go @@ -229,7 +229,11 @@ func (r *Repository) QueryMohong(ctx context.Context, userID uint64, orderID uin if runtimeConfig.Channel == nil { return nil, ErrPaymentUnavailable } - resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID) + resp, err := runtimeConfig.Channel.QueryPayment(ctx, channelQueryPaymentRequest{ + ThirdOrderID: payment.ThirdOrderID, + TraceTime: paymentTraceTime(payment.RawRequest), + ProviderOrderID: payment.ProviderOrderID, + }) if err != nil { return nil, err } diff --git a/backend/internal/modules/payment/notify.go b/backend/internal/modules/payment/notify.go index fce1861..9543ad5 100644 --- a/backend/internal/modules/payment/notify.go +++ b/backend/internal/modules/payment/notify.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "hfb_jysh/backend/internal/integrations/payment/saobei" "hfb_jysh/backend/internal/model" "go.uber.org/zap" @@ -22,7 +23,11 @@ func (r *Repository) HandleNotify(ctx context.Context, provider string, params m return r.HandleRefundNotify(ctx, provider, params, rawPayload, contentType, authorization) } - payment, err := r.findPaymentForNotify(ctx, params) + lookupParams := params + if provider == "saobei" { + lookupParams = saobei.CanonicalizePayload(params) + } + payment, err := r.findPaymentForNotify(ctx, lookupParams) if err != nil { return nil, err } @@ -35,7 +40,19 @@ func (r *Repository) HandleNotify(ctx context.Context, provider string, params m return nil, err } - if amount := parseCent(params["amount"]); amount > 0 && amount != payment.AmountCent { + processingParams := params + if provider == "saobei" { + processingParams = saobei.CanonicalizePayload(params) + } + if runtimeConfig.Provider == "saobei" { + if merchantNo := processingParams["merchant_no"]; merchantNo == "" || merchantNo != payment.MerchantID { + return nil, ErrPaymentVerifyFailed + } + if amount := parseCent(processingParams["amount"]); amount <= 0 || amount != payment.AmountCent { + return nil, ErrPaymentVerifyFailed + } + } + if amount := parseCent(processingParams["amount"]); amount > 0 && amount != payment.AmountCent { if err := r.recordNotifyDiagnostic(ctx, payment.ID, params, rawPayload, contentType, verify, "amount_mismatch"); err != nil { r.log().Warn("支付回调诊断保存失败", paymentLogFields(ctx, appendFields( paymentOrderFields(payment), @@ -49,9 +66,9 @@ func (r *Repository) HandleNotify(ctx context.Context, provider string, params m } return nil, ErrPaymentVerifyFailed } - raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified") - channelStatus := normalizeNotifyPaymentStatus(runtimeConfig.Provider, params["status"]) - if err := r.applyChannelStatus(ctx, payment, channelStatus, params["pay_time"], raw, channelSourceNotify); err != nil { + raw := withNotifyDiagnostic(processingParams, rawPayload, contentType, verify, "verified") + channelStatus := normalizeNotifyPaymentStatus(runtimeConfig.Provider, processingParams["status"]) + if err := r.applyChannelStatus(ctx, payment, channelStatus, processingParams["pay_time"], raw, channelSourceNotify); err != nil { // 入口处的 WARN 只有渠道参数,定位不到是哪个订单哪笔支付单,这里补齐上下文。 r.log().Warn("支付回调入账失败", paymentLogFields(ctx, appendFields( paymentOrderFields(payment), @@ -182,10 +199,11 @@ func redactNotifyParams(params map[string]string) map[string]string { allowed := map[string]bool{ "service": true, "merchant_id": true, "third_order_id": true, "provider_order_id": true, "leshua_order_id": true, "schc_order_id": true, + "terminal_trace": true, "out_trade_no": true, "merchant_refund_id": true, "provider_refund_id": true, "leshua_refund_id": true, "schc_refund_id": true, - "status": true, "amount": true, "refund_amount": true, - "pay_time": true, "refund_time": true, + "status": true, "amount": true, "total_fee": true, "refund_amount": true, + "pay_time": true, "end_time": true, "refund_time": true, "code": true, "resp_code": true, "result_code": true, "error_code": true, } redacted := make(map[string]string, len(allowed)) diff --git a/backend/internal/modules/payment/presenter.go b/backend/internal/modules/payment/presenter.go index 5b137b3..c29e9a8 100644 --- a/backend/internal/modules/payment/presenter.go +++ b/backend/internal/modules/payment/presenter.go @@ -102,6 +102,23 @@ func jsonString(value map[string]string) string { } return string(raw) } + +func paymentTraceTime(raw datatypes.JSON) string { + if len(raw) == 0 { + return "" + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return "" + } + for _, key := range []string{"terminal_time", "pay_time"} { + if value, ok := payload[key].(string); ok && value != "" { + return value + } + } + return "" +} + func newPaymentNo() (string, error) { buf := make([]byte, 4) if _, err := rand.Read(buf); err != nil { diff --git a/backend/internal/modules/payment/query.go b/backend/internal/modules/payment/query.go index 0f22c7a..c169f20 100644 --- a/backend/internal/modules/payment/query.go +++ b/backend/internal/modules/payment/query.go @@ -29,7 +29,11 @@ func (r *Repository) Query(ctx context.Context, userID uint64, orderID uint64) ( if runtimeConfig.Channel == nil { return nil, ErrPaymentUnavailable } - resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID) + resp, err := runtimeConfig.Channel.QueryPayment(ctx, channelQueryPaymentRequest{ + ThirdOrderID: payment.ThirdOrderID, + TraceTime: paymentTraceTime(payment.RawRequest), + ProviderOrderID: payment.ProviderOrderID, + }) if err != nil { return nil, err } diff --git a/backend/internal/modules/payment/refund.go b/backend/internal/modules/payment/refund.go index 146e7ee..ddc4582 100644 --- a/backend/internal/modules/payment/refund.go +++ b/backend/internal/modules/payment/refund.go @@ -86,13 +86,14 @@ func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmou return nil, ErrPaymentUnavailable } resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{ - ThirdOrderID: originalPayment.ThirdOrderID, - ProviderOrderID: refundOriginProviderOrderID(originalPayment), - MerchantRefundID: refundOrder.ThirdOrderID, - RefundAmountCent: refundAmountCent, - NotifyURL: runtimeConfig.NotifyURL, - Attach: originalPayment.OrderNo, - Remark: remark, + ThirdOrderID: originalPayment.ThirdOrderID, + OriginalTraceTime: paymentTraceTime(originalPayment.RawRequest), + ProviderOrderID: refundOriginProviderOrderID(originalPayment), + MerchantRefundID: refundOrder.ThirdOrderID, + RefundAmountCent: refundAmountCent, + NotifyURL: runtimeConfig.NotifyURL, + Attach: originalPayment.OrderNo, + Remark: remark, }) if err != nil { var rawRequest map[string]string @@ -380,10 +381,11 @@ func isRefundBizType(bizType string) bool { } func refundQueryRequest(payment model.PaymentOrder, originalPayment model.PaymentOrder) channelQueryRefundRequest { return channelQueryRefundRequest{ - ThirdOrderID: originalPayment.ThirdOrderID, - ProviderOrderID: refundOriginProviderOrderID(originalPayment), - MerchantRefundID: payment.ThirdOrderID, - ProviderRefundID: payment.ProviderOrderID, + ThirdOrderID: originalPayment.ThirdOrderID, + ProviderOrderID: refundOriginProviderOrderID(originalPayment), + MerchantRefundID: payment.ThirdOrderID, + MerchantRefundTime: paymentTraceTime(payment.RawRequest), + ProviderRefundID: payment.ProviderOrderID, } } diff --git a/backend/internal/modules/payment/refund_mohong.go b/backend/internal/modules/payment/refund_mohong.go index 6c95305..c348142 100644 --- a/backend/internal/modules/payment/refund_mohong.go +++ b/backend/internal/modules/payment/refund_mohong.go @@ -73,13 +73,14 @@ func (r *Repository) StartMohongRefund(ctx context.Context, orderID uint64, refu return nil, ErrPaymentUnavailable } resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{ - ThirdOrderID: originalPayment.ThirdOrderID, - ProviderOrderID: refundOriginProviderOrderID(originalPayment), - MerchantRefundID: refundOrder.ThirdOrderID, - RefundAmountCent: refundOrder.AmountCent, - NotifyURL: runtimeConfig.NotifyURL, - Attach: originalPayment.OrderNo, - Remark: remark, + ThirdOrderID: originalPayment.ThirdOrderID, + OriginalTraceTime: paymentTraceTime(originalPayment.RawRequest), + ProviderOrderID: refundOriginProviderOrderID(originalPayment), + MerchantRefundID: refundOrder.ThirdOrderID, + RefundAmountCent: refundOrder.AmountCent, + NotifyURL: runtimeConfig.NotifyURL, + Attach: originalPayment.OrderNo, + Remark: remark, }) if err != nil { var rawRequest map[string]string diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go index a6e077a..3de69ec 100644 --- a/backend/internal/modules/payment/repository.go +++ b/backend/internal/modules/payment/repository.go @@ -233,7 +233,7 @@ func (r *Repository) runtimeConfigForPayment(ctx context.Context, payment *model MerchantID: merchantID, }, nil } - if provider != "leshua" && provider != "lakala" && provider != "shuncheng" { + if provider != "leshua" && provider != "lakala" && provider != "shuncheng" && provider != "saobei" { return &runtimePaymentConfig{ Provider: provider, MerchantID: merchantID, diff --git a/backend/internal/modules/paymentconfig/dto.go b/backend/internal/modules/paymentconfig/dto.go index ea86d72..2f14a23 100644 --- a/backend/internal/modules/paymentconfig/dto.go +++ b/backend/internal/modules/paymentconfig/dto.go @@ -25,6 +25,7 @@ var ( 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") + ErrTerminalIDRequired = errors.New("extra_config.terminal_id is required for saobei provider") ) // DTO 数据传输对象 @@ -59,7 +60,7 @@ type ConfigDTO struct { // CreateRequest 创建支付配置请求 type CreateRequest struct { Name string `json:"name" binding:"required"` - Provider string `json:"provider" binding:"required,oneof=leshua lakala shuncheng mock"` + Provider string `json:"provider" binding:"required,oneof=leshua lakala shuncheng saobei 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/handler.go b/backend/internal/modules/paymentconfig/handler.go index 270231a..5b583af 100644 --- a/backend/internal/modules/paymentconfig/handler.go +++ b/backend/internal/modules/paymentconfig/handler.go @@ -148,7 +148,7 @@ func (h *Handler) ImportBackup(c *gin.Context) { err == ErrNameRequired || err == ErrMerchantIDRequired || err == ErrGatewayURLRequired || err == ErrSignKeyRequired || err == ErrNotifyKeyRequired || err == ErrNotifyURLRequired || err == ErrInvalidSignType || err == ErrInvalidStatus || err == ErrInvalidPayWay || err == ErrAppIDRequired || - err == ErrSerialNoRequired || err == ErrTermNoRequired { + err == ErrSerialNoRequired || err == ErrTermNoRequired || err == ErrTerminalIDRequired { response.BadRequest(c, err.Error()) return } @@ -189,7 +189,7 @@ func (h *Handler) Create(c *gin.Context) { if err == ErrNameRequired || err == ErrMerchantIDRequired || err == ErrGatewayURLRequired || err == ErrSignKeyRequired || err == ErrNotifyKeyRequired || err == ErrInvalidProvider || err == ErrNotifyURLRequired || err == ErrInvalidSignType || err == ErrInvalidPayWay || err == ErrAppIDRequired || - err == ErrInvalidStatus || err == ErrSerialNoRequired || err == ErrTermNoRequired { + err == ErrInvalidStatus || err == ErrSerialNoRequired || err == ErrTermNoRequired || err == ErrTerminalIDRequired { response.BadRequest(c, err.Error()) return } diff --git a/backend/internal/modules/paymentconfig/validation.go b/backend/internal/modules/paymentconfig/validation.go index f791fcc..798366e 100644 --- a/backend/internal/modules/paymentconfig/validation.go +++ b/backend/internal/modules/paymentconfig/validation.go @@ -79,12 +79,26 @@ func (r *Repository) validateCreateRequest(req CreateRequest) error { return ErrSecretIDRequired } } + if req.Provider == "saobei" { + if req.GatewayURL == "" { + return ErrGatewayURLRequired + } + if req.SignKey == "" { + return ErrSignKeyRequired + } + if req.NotifyURL == "" { + return ErrNotifyURLRequired + } + if extraString(req.ExtraConfig, "terminal_id") == "" { + return ErrTerminalIDRequired + } + } return nil } func isValidProvider(value string) bool { - return value == "leshua" || value == "lakala" || value == "shuncheng" || value == "mock" + return value == "leshua" || value == "lakala" || value == "shuncheng" || value == "saobei" || value == "mock" } func isValidSignType(value string) bool { diff --git a/backend/internal/modules/paymentconfig/validation_test.go b/backend/internal/modules/paymentconfig/validation_test.go new file mode 100644 index 0000000..74ee9dd --- /dev/null +++ b/backend/internal/modules/paymentconfig/validation_test.go @@ -0,0 +1,28 @@ +package paymentconfig + +import ( + "errors" + "testing" +) + +func TestValidateCreateRequestSaobeiRequiresTerminalID(t *testing.T) { + repo := &Repository{} + req := CreateRequest{ + Name: "扫呗测试", + Provider: "saobei", + MerchantID: "839305812000286", + GatewayURL: "https://pay.example", + SignKey: "access-token", + NotifyURL: "https://example.com/api/payments/saobei/notify", + ExtraConfig: map[string]any{ + "terminal_id": "", + }, + } + if err := repo.validateCreateRequest(req); !errors.Is(err, ErrTerminalIDRequired) { + t.Fatalf("validateCreateRequest() error = %v, want %v", err, ErrTerminalIDRequired) + } + req.ExtraConfig["terminal_id"] = "30616969" + if err := repo.validateCreateRequest(req); err != nil { + t.Fatalf("validateCreateRequest() error = %v, want nil", err) + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b5a3524..fbcf0de 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -452,6 +452,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { api.POST("/payments/leshua/notify", paymentHandler.LeshuaNotify) api.POST("/payments/lakala/notify", paymentHandler.LakalaNotify) api.POST("/payments/shuncheng/notify", paymentHandler.ShunchengNotify) + api.POST("/payments/saobei/notify", paymentHandler.SaobeiNotify) openRoutes := api.Group("/open") { diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 939b5be..af9bb5f 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -37,21 +37,27 @@ services: retries: 10 minio: - image: minio/minio:RELEASE.2025-09-07T16-13-09Z + # MinIO 已停止发布官方 Docker Hub 镜像;开发环境使用 S3 兼容的 SeaweedFS。 + image: chrislusf/seaweedfs:4.53 container_name: jysh-minio restart: unless-stopped - command: server /data --console-address ":9001" + command: + - mini + - -dir=/data + - -bucket=jieyou-sys + - -admin.user=admin + - -admin.password=jieyou-admin environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin TZ: Asia/Shanghai ports: - - "${DEV_MINIO_API_PORT:-29090}:9000" - - "${DEV_MINIO_CONSOLE_PORT:-29091}:9001" + - "${DEV_MINIO_API_PORT:-29090}:8333" + - "${DEV_MINIO_CONSOLE_PORT:-29091}:12535" volumes: - minio_data:/data healthcheck: - test: ["CMD", "mc", "ready", "local"] + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9333/cluster/status"] interval: 10s timeout: 5s retries: 10 diff --git a/docs/扫呗支付接入说明.md b/docs/扫呗支付接入说明.md new file mode 100644 index 0000000..5dd5812 --- /dev/null +++ b/docs/扫呗支付接入说明.md @@ -0,0 +1,50 @@ +# 扫呗普通支付 2.0 接入说明 + +接口文档来源: + +## 已接入能力 + +| 业务 | 扫呗接口 | 项目入口 | +| --- | --- | --- | +| 聚合码下单(C 扫 B) | `POST /pay/open/qrpay` | `POST /api/orders/:id/start-payment` | +| 支付查询 | `POST /pay/open/query` | `GET /api/orders/:id/query-payment` | +| 退款申请 | `POST /pay/open/refund` | 订单退款审核、人工退款、仲裁退款 | +| 退款查询 | `POST /pay/open/queryrefund` | 退款补偿任务、后台退款状态查询 | +| 支付回调 | 商户 `notify_url` | `POST /api/payments/saobei/notify` | + +撞车商城复用同一组支付能力。 + +## 后台配置 + +在后台「支付配置」中新增服务商为「扫呗」的配置: + +| 配置项 | 必填 | 说明 | +| --- | --- | --- | +| 商户号 | 是 | 扫呗 `merchant_no` | +| 终端号 | 是 | 扫呗 `terminal_id`,保存在 `extra_config.terminal_id` | +| 终端密钥 | 是 | 扫呗 `access_token`,加密保存 | +| 网关地址 | 是 | 扫呗技术人员提供的接口域名 | +| 回调地址 | 是 | `https://正式域名/api/payments/saobei/notify` | +| 设备号 | 否 | 平台已经配置设备映射时才填写 | +| 链接有效期 | 否 | 聚合码链接有效期,默认 900 秒,最大 7200 秒 | +| 支付方式 | 是 | 项目按微信/支付宝分别选择支付通道,扫呗下单时使用 `pay_type=000` 自动识别 | + +扫呗没有单独的“通知密钥”。请求、响应和支付回调统一使用终端密钥 `access_token` 按字典序加签。 + +## 支付流程 + +1. 后端调用 `/pay/open/qrpay` 创建收银台短链。 +2. 前端 PC 将短链渲染成二维码;移动端可直接在微信、支付宝或云闪付中打开。 +3. 用户付款后,扫呗向 `/api/payments/saobei/notify` 推送 JSON。 +4. 后端验签、核对商户号和金额,再将支付单和订单按现有事务写入。 +5. 没有收到回调时,前端“检查支付状态”会调用扫呗支付查询接口。 + +## 联调前需要准备 + +- 扫呗测试环境接口域名。 +- 测试商户号 `merchant_no`。 +- 测试终端号 `terminal_id`。 +- 测试终端密钥 `access_token`。 +- 可用于扫呗访问的 HTTPS 回调域名。 + +测试环境域名和正式环境域名不同,不能混用。正式切换前应至少验证:支付成功、主动查询、重复回调、金额篡改、退款成功、退款失败和超时查询。 diff --git a/frontend/src/features/admin/AdminDistributionPaymentChannelsPage.tsx b/frontend/src/features/admin/AdminDistributionPaymentChannelsPage.tsx index f7fdbe3..955ec0b 100644 --- a/frontend/src/features/admin/AdminDistributionPaymentChannelsPage.tsx +++ b/frontend/src/features/admin/AdminDistributionPaymentChannelsPage.tsx @@ -45,6 +45,7 @@ const providerLabels: Record = { leshua: '乐刷', lakala: '拉卡拉', shuncheng: '顺诚', + saobei: '扫呗', mock: 'Mock', } diff --git a/frontend/src/features/admin/AdminPaymentConfigsPage.tsx b/frontend/src/features/admin/AdminPaymentConfigsPage.tsx index eb6872d..7aff4e0 100644 --- a/frontend/src/features/admin/AdminPaymentConfigsPage.tsx +++ b/frontend/src/features/admin/AdminPaymentConfigsPage.tsx @@ -48,6 +48,7 @@ const providerLabels: Record = { leshua: '乐刷', lakala: '拉卡拉', shuncheng: '顺诚', + saobei: '扫呗', mock: 'Mock', } @@ -85,6 +86,7 @@ const defaultNotifyPaths: Record = { leshua: '/api/payments/leshua/notify', lakala: '/api/payments/lakala/notify', shuncheng: '/api/payments/shuncheng/notify', + saobei: '/api/payments/saobei/notify', mock: '', } @@ -135,6 +137,13 @@ function defaultExtraConfig(provider: string): Record { token_refresh_margin_seconds: 300, } } + if (provider === 'saobei') { + return { + terminal_id: '', + device_no: '', + expire_seconds: 900, + } + } return {} } @@ -151,6 +160,9 @@ function extraFormDefaults(provider: string) { extra_shop_no: String(extra.shop_no ?? ''), extra_refund_type: String(extra.refund_type ?? ''), extra_token_refresh_margin_seconds: Number(extra.token_refresh_margin_seconds ?? 300), + extra_terminal_id: String(extra.terminal_id ?? ''), + extra_device_no: String(extra.device_no ?? ''), + extra_expire_seconds: Number(extra.expire_seconds ?? 900), } } @@ -180,6 +192,9 @@ interface ConfigFormValues { extra_shop_no?: string extra_refund_type?: string extra_token_refresh_margin_seconds?: number + extra_terminal_id?: string + extra_device_no?: string + extra_expire_seconds?: number } function errorText(error: unknown) { @@ -196,7 +211,14 @@ export function AdminPaymentConfigsPage() { const isMock = providerValue === 'mock' const isLakala = providerValue === 'lakala' const isShuncheng = providerValue === 'shuncheng' - const signKeyLabel = isLakala ? '商户私钥' : isShuncheng ? 'SecretKey' : '签名密钥' + const isSaobei = providerValue === 'saobei' + const signKeyLabel = isLakala + ? '商户私钥' + : isShuncheng + ? 'SecretKey' + : isSaobei + ? '终端密钥 access_token' + : '签名密钥' const notifyKeyLabel = isLakala ? '通知证书' : '通知密钥' const [draft, setDraft] = useState({}) const [filters, setFilters] = useState({}) @@ -290,6 +312,9 @@ export function AdminPaymentConfigsPage() { extra_shop_no: String(extra.shop_no ?? ''), extra_refund_type: String(extra.refund_type ?? ''), extra_token_refresh_margin_seconds: Number(extra.token_refresh_margin_seconds ?? 300), + extra_terminal_id: String(extra.terminal_id ?? ''), + extra_device_no: String(extra.device_no ?? ''), + extra_expire_seconds: Number(extra.expire_seconds ?? 900), }) setEditorOpen(true) } @@ -346,6 +371,14 @@ export function AdminPaymentConfigsPage() { extra.refund_type = values.extra_refund_type } extraConfig = extra + } else if (values.provider === 'saobei') { + extraConfig = { + terminal_id: values.extra_terminal_id?.trim() ?? '', + expire_seconds: values.extra_expire_seconds ?? 900, + } + if (values.extra_device_no?.trim()) { + extraConfig.device_no = values.extra_device_no.trim() + } } const payload: AdminPaymentConfigPayload = { name: values.name.trim(), @@ -692,7 +725,7 @@ export function AdminPaymentConfigsPage() { description={ editing ? 'SignKey / NotifyKey 留空表示保持原值;额外配置需提交完整 JSON。' - : '乐刷/拉卡拉/顺诚需要完整密钥与回调地址;创建前请确认商户环境。' + : '乐刷/拉卡拉/顺诚/扫呗需要完整密钥与回调地址;创建前请确认商户环境。' } /> {providerValue === 'mock' ? ( @@ -704,6 +737,15 @@ export function AdminPaymentConfigsPage() { description="无需网关、密钥和回调地址;用户发起支付后会直接标记为已支付,请勿在生产环境启用。" /> ) : null} + {isSaobei ? ( + + ) : null} form={form} layout="vertical" disabled={submitting}> @@ -754,7 +796,10 @@ export function AdminPaymentConfigsPage() { label="网关地址" rules={[{ required: true, message: '请输入支付网关地址' }]} > - + ) : null} @@ -800,6 +845,30 @@ export function AdminPaymentConfigsPage() { ) : null} + {isSaobei ? ( + + + + + + + + + + + + + + + + + + ) : null} + {!isMock ? ( @@ -812,7 +881,11 @@ export function AdminPaymentConfigsPage() { : [ { required: true, - message: isLakala ? '请输入商户私钥 PEM' : '请输入签名密钥', + message: isLakala + ? '请输入商户私钥 PEM' + : isSaobei + ? '请输入扫呗终端密钥' + : '请输入签名密钥', }, ] } @@ -825,37 +898,42 @@ export function AdminPaymentConfigsPage() { autoComplete="new-password" /> ) : ( - - )} - - - - - {isLakala ? ( - - ) : ( - )} + {!isSaobei ? ( + + + {isLakala ? ( + + ) : ( + + )} + + + ) : null} ) : null} diff --git a/frontend/src/features/admin/AdminPaymentsPage.tsx b/frontend/src/features/admin/AdminPaymentsPage.tsx index feeb488..9a198ce 100644 --- a/frontend/src/features/admin/AdminPaymentsPage.tsx +++ b/frontend/src/features/admin/AdminPaymentsPage.tsx @@ -33,8 +33,10 @@ const bizTypeOptions = [ ] const providerOptions = [ - { value: 'leshua', label: '拉卡拉' }, + { value: 'leshua', label: '乐刷' }, + { value: 'lakala', label: '拉卡拉' }, { value: 'shuncheng', label: '顺诚' }, + { value: 'saobei', label: '扫呗' }, { value: 'mock', label: '模拟支付' }, ] diff --git a/frontend/src/features/crash/CrashDetailPage.tsx b/frontend/src/features/crash/CrashDetailPage.tsx index 2a89f62..b83a7dd 100644 --- a/frontend/src/features/crash/CrashDetailPage.tsx +++ b/frontend/src/features/crash/CrashDetailPage.tsx @@ -3,7 +3,12 @@ import { Alert, App, Button, Card, Image, InputNumber, Space, Tag, Typography } import { ArrowLeftOutlined } from '@ant-design/icons' import { useNavigate, useParams } from 'react-router-dom' -import { createCrashOrder, startCrashPayment, useCrashProduct } from '@/features/crash/api' +import { + crashPaymentURL, + createCrashOrder, + startCrashPayment, + useCrashProduct, +} from '@/features/crash/api' import { formatMoney } from '@/shared/utils/format' function errorText(error: unknown) { @@ -28,8 +33,9 @@ export function CrashDetailPage() { message.success('下单成功') try { const payment = await startCrashPayment(order.id) - if (payment?.pay_url) { - window.location.href = payment.pay_url + const payURL = crashPaymentURL(payment) + if (payURL) { + window.location.href = payURL return } } catch { diff --git a/frontend/src/features/crash/CrashOrdersPage.tsx b/frontend/src/features/crash/CrashOrdersPage.tsx index 85643a7..ceccd0f 100644 --- a/frontend/src/features/crash/CrashOrdersPage.tsx +++ b/frontend/src/features/crash/CrashOrdersPage.tsx @@ -4,7 +4,12 @@ import { Alert, App, Button, Card, Empty, Image, Space, Tag, Typography } from ' import { ArrowLeftOutlined } from '@ant-design/icons' import { useNavigate } from 'react-router-dom' -import { cancelCrashOrder, startCrashPayment, useMyCrashOrders } from '@/features/crash/api' +import { + cancelCrashOrder, + crashPaymentURL, + startCrashPayment, + useMyCrashOrders, +} from '@/features/crash/api' import { useSessionStore } from '@/stores/session' import { formatDateTime, formatMoney } from '@/shared/utils/format' @@ -104,8 +109,9 @@ export function CrashOrdersPage() { onClick={async () => { try { const payment = await startCrashPayment(order.id) - if (payment?.pay_url) { - window.location.href = payment.pay_url + const payURL = crashPaymentURL(payment) + if (payURL) { + window.location.href = payURL } else { message.success('支付已发起,请在支付渠道完成付款') } diff --git a/frontend/src/features/crash/api.ts b/frontend/src/features/crash/api.ts index ad91270..aa8ea33 100644 --- a/frontend/src/features/crash/api.ts +++ b/frontend/src/features/crash/api.ts @@ -9,6 +9,7 @@ import type { CrashProduct, CrashProductPage, } from '@/features/crash/types' +import type { UserPayment } from '@/features/orders/types' export function fetchCrashCategories() { return unwrapData( @@ -90,9 +91,13 @@ export function cancelCrashOrder(id: number) { } export function startCrashPayment(id: number) { - return unwrapData<{ pay_url?: string; jsapi?: string; qrcode?: string; status?: string }>( - apiClient.post< - ApiResponse<{ pay_url?: string; jsapi?: string; qrcode?: string; status?: string }> - >(`/crash/orders/${id}/start-payment`) + return unwrapData( + apiClient.post>(`/crash/orders/${id}/start-payment`) ) } + +export function crashPaymentURL( + payment?: Pick +) { + return payment?.jspay_url || payment?.td_code || payment?.jspay_info || '' +} diff --git a/frontend/src/features/mobile/MobileCrashDetailPage.tsx b/frontend/src/features/mobile/MobileCrashDetailPage.tsx index 6de4373..c0ac2e0 100644 --- a/frontend/src/features/mobile/MobileCrashDetailPage.tsx +++ b/frontend/src/features/mobile/MobileCrashDetailPage.tsx @@ -2,7 +2,12 @@ import { useState } from 'react' import { Button, Card, Dialog, Image, NavBar, Stepper, Toast } from 'antd-mobile' import { useNavigate, useParams } from 'react-router-dom' -import { createCrashOrder, startCrashPayment, useCrashProduct } from '@/features/crash/api' +import { + crashPaymentURL, + createCrashOrder, + startCrashPayment, + useCrashProduct, +} from '@/features/crash/api' import { formatMoney } from '@/shared/utils/format' function errorText(error: unknown) { @@ -25,8 +30,9 @@ export function MobileCrashDetailPage() { const order = await createCrashOrder({ product_id: product.id, quantity }) try { const payment = await startCrashPayment(order.id) - if (payment?.pay_url) { - window.location.href = payment.pay_url + const payURL = crashPaymentURL(payment) + if (payURL) { + window.location.href = payURL return } } catch { diff --git a/frontend/src/features/mobile/MobileCrashOrdersPage.tsx b/frontend/src/features/mobile/MobileCrashOrdersPage.tsx index 0fd51c4..7c52c23 100644 --- a/frontend/src/features/mobile/MobileCrashOrdersPage.tsx +++ b/frontend/src/features/mobile/MobileCrashOrdersPage.tsx @@ -2,7 +2,12 @@ import { Button, Card, Dialog, ErrorBlock, Image, NavBar, Skeleton, Tag, Toast } import { useNavigate } from 'react-router-dom' import { useQueryClient } from '@tanstack/react-query' -import { cancelCrashOrder, startCrashPayment, useMyCrashOrders } from '@/features/crash/api' +import { + cancelCrashOrder, + crashPaymentURL, + startCrashPayment, + useMyCrashOrders, +} from '@/features/crash/api' import { useSessionStore } from '@/stores/session' import { formatDateTime, formatMoney } from '@/shared/utils/format' @@ -78,8 +83,9 @@ export function MobileCrashOrdersPage() { onClick={async () => { try { const payment = await startCrashPayment(order.id) - if (payment?.pay_url) { - window.location.href = payment.pay_url + const payURL = crashPaymentURL(payment) + if (payURL) { + window.location.href = payURL } else { Toast.show({ content: '支付已发起,请在支付渠道完成付款' }) } diff --git a/frontend/src/features/orders/OrderActionPanel.tsx b/frontend/src/features/orders/OrderActionPanel.tsx index c6dabe1..bb0e924 100644 --- a/frontend/src/features/orders/OrderActionPanel.tsx +++ b/frontend/src/features/orders/OrderActionPanel.tsx @@ -8,6 +8,7 @@ import { Input, InputNumber, Modal, + QRCode, Radio, Select, Space, @@ -508,10 +509,11 @@ export function OrderActionPanel({ showIcon message="支付单已创建" description={ - - 请在支付页面完成付款,完成后回到本页检查支付状态。 + + + 请使用微信、支付宝或云闪付扫码付款,完成后回到本页检查支付状态。 } diff --git a/scripts/dev.sh b/scripts/dev.sh index 877dc60..53a9cbc 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -36,6 +36,11 @@ FORCE_NPM_CI="${DEV_FORCE_NPM_CI:-0}" GOOSE_VERSION="${GOOSE_VERSION:-v3.27.1}" GOOSE_BIN="${GOOSE_BIN:-}" +# 国内网络通常无法稳定访问 proxy.golang.org;显式导出的 GOPROXY 优先。 +if [[ -z "${GOPROXY:-}" ]]; then + export GOPROXY="${DEV_GOPROXY:-https://goproxy.cn,direct}" +fi + BACKEND_PID="" FRONTEND_PID="" DISTRIBUTION_PID="" @@ -116,6 +121,8 @@ show_help() { 前后端端口被占用时自动停止占用的 Docker 容器或本机进程 DEV_FORCE_NPM_CI=0 强制重新执行前端 npm ci + DEV_GOPROXY=https://goproxy.cn,direct + 未显式设置 GOPROXY 时使用的 Go 模块代理 示例: ./scripts/dev.sh # 正常启动(后端 + 主前后台 + 分销前台)