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)
}
}