feat(payment): 接入扫呗普通支付2.0

This commit is contained in:
yml
2026-11-11 15:57:30 +08:00
parent 58d9f50bac
commit d403ffc734
30 changed files with 1337 additions and 111 deletions
@@ -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 ""
}
@@ -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)
}
}
@@ -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:
@@ -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)
})
}
@@ -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
}
@@ -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 {
@@ -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
}
+25 -7
View File
@@ -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))
@@ -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 {
+5 -1
View File
@@ -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
}
+13 -11
View File
@@ -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,
}
}
@@ -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
@@ -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,
@@ -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"`
@@ -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
}
@@ -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 {
@@ -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)
}
}
+1
View File
@@ -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")
{