feat(payment): 接入扫呗普通支付2.0
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user