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")
{
+13 -7
View File
@@ -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
+50
View File
@@ -0,0 +1,50 @@
# 扫呗普通支付 2.0 接入说明
接口文档来源:<https://help.lcsw.cn/xrmpic/tisnldchblgxohfl/rinsc3>
## 已接入能力
| 业务 | 扫呗接口 | 项目入口 |
| --- | --- | --- |
| 聚合码下单(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 回调域名。
测试环境域名和正式环境域名不同,不能混用。正式切换前应至少验证:支付成功、主动查询、重复回调、金额篡改、退款成功、退款失败和超时查询。
@@ -45,6 +45,7 @@ const providerLabels: Record<string, string> = {
leshua: '乐刷',
lakala: '拉卡拉',
shuncheng: '顺诚',
saobei: '扫呗',
mock: 'Mock',
}
@@ -48,6 +48,7 @@ const providerLabels: Record<string, string> = {
leshua: '乐刷',
lakala: '拉卡拉',
shuncheng: '顺诚',
saobei: '扫呗',
mock: 'Mock',
}
@@ -85,6 +86,7 @@ const defaultNotifyPaths: Record<string, string> = {
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<string, unknown> {
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<AdminPaymentConfigQuery>({})
const [filters, setFilters] = useState<AdminPaymentConfigQuery>({})
@@ -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 ? (
<Alert
type="info"
showIcon
className="admin-table-alert"
message="扫呗聚合码支付"
description="使用普通支付 2.0 的 /pay/open/qrpay 创建收银台链接。接口域名、商户号、终端号和 access_token 必须与扫呗技术人员提供的环境一致。"
/>
) : null}
<Form<ConfigFormValues> form={form} layout="vertical" disabled={submitting}>
<Row gutter={16}>
<Col span={12}>
@@ -754,7 +796,10 @@ export function AdminPaymentConfigsPage() {
label="网关地址"
rules={[{ required: true, message: '请输入支付网关地址' }]}
>
<Input maxLength={512} />
<Input
maxLength={512}
placeholder={isSaobei ? '请输入扫呗技术人员提供的接口域名' : 'https://支付网关地址'}
/>
</Form.Item>
) : null}
@@ -800,6 +845,30 @@ export function AdminPaymentConfigsPage() {
</Form.Item>
) : null}
{isSaobei ? (
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="extra_terminal_id"
label="终端号"
rules={[{ required: true, message: '请输入扫呗终端号' }]}
>
<Input maxLength={8} placeholder="terminal_id" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="extra_device_no" label="设备号" extra="平台已配置映射时才填写">
<Input maxLength={32} placeholder="device_no(可选)" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="extra_expire_seconds" label="链接有效期(秒)">
<InputNumber min={1} max={7200} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
) : null}
{!isMock ? (
<Row gutter={16}>
<Col span={12}>
@@ -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"
/>
) : (
<Input.Password placeholder="请输入签名密钥" autoComplete="new-password" />
)}
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="notify_key"
label={notifyKeyLabel}
rules={
editing
? undefined
: [
{
required: true,
message: isLakala ? '请输入通知验签证书 PEM' : '请输入通知密钥',
},
]
}
extra={editing ? '留空不修改' : undefined}
>
{isLakala ? (
<Input.TextArea
rows={5}
placeholder="请输入拉卡拉通知验签证书 PEM"
<Input.Password
placeholder={isSaobei ? '请输入终端密钥 access_token' : '请输入签名密钥'}
autoComplete="new-password"
/>
) : (
<Input.Password placeholder="请输入通知密钥" autoComplete="new-password" />
)}
</Form.Item>
</Col>
{!isSaobei ? (
<Col span={12}>
<Form.Item
name="notify_key"
label={notifyKeyLabel}
rules={
editing
? undefined
: [
{
required: true,
message: isLakala ? '请输入通知验签证书 PEM' : '请输入通知密钥',
},
]
}
extra={editing ? '留空不修改' : undefined}
>
{isLakala ? (
<Input.TextArea
rows={5}
placeholder="请输入拉卡拉通知验签证书 PEM"
autoComplete="new-password"
/>
) : (
<Input.Password placeholder="请输入通知密钥" autoComplete="new-password" />
)}
</Form.Item>
</Col>
) : null}
</Row>
) : null}
@@ -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: '模拟支付' },
]
@@ -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 {
@@ -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('支付已发起,请在支付渠道完成付款')
}
+9 -4
View File
@@ -9,6 +9,7 @@ import type {
CrashProduct,
CrashProductPage,
} from '@/features/crash/types'
import type { UserPayment } from '@/features/orders/types'
export function fetchCrashCategories() {
return unwrapData<CrashCategory[]>(
@@ -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<UserPayment>(
apiClient.post<ApiResponse<UserPayment>>(`/crash/orders/${id}/start-payment`)
)
}
export function crashPaymentURL(
payment?: Pick<UserPayment, 'jspay_url' | 'td_code' | 'jspay_info'>
) {
return payment?.jspay_url || payment?.td_code || payment?.jspay_info || ''
}
@@ -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 {
@@ -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: '支付已发起,请在支付渠道完成付款' })
}
@@ -8,6 +8,7 @@ import {
Input,
InputNumber,
Modal,
QRCode,
Radio,
Select,
Space,
@@ -508,10 +509,11 @@ export function OrderActionPanel({
showIcon
message="支付单已创建"
description={
<Space direction="vertical">
<span>请在支付页面完成付款,完成后回到本页检查支付状态。</span>
<Space direction="vertical" align="center">
<QRCode value={payURL} size={180} />
<span>请使用微信、支付宝或云闪付扫码付款,完成后回到本页检查支付状态。</span>
<Button type="primary" href={payURL} target="_blank">
打开支付页面
在支付应用中打开
</Button>
</Space>
}
+7
View File
@@ -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 # 正常启动(后端 + 主前后台 + 分销前台)