616 lines
16 KiB
Go
616 lines
16 KiB
Go
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": sanitizeOrderBody(req.Body),
|
|
"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.verifyResponseOrChannelError(raw); err != nil {
|
|
return &CreatePaymentResponse{
|
|
ErrorMessage: responseMessage(raw),
|
|
Raw: raw,
|
|
RawRequest: copyMap(params),
|
|
}, 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.verifyResponseOrChannelError(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.verifyResponseOrChannelError(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.verifyResponseOrChannelError(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 (c *Client) verifyResponseOrChannelError(raw map[string]string) error {
|
|
if err := c.verifyResponse(raw); err == nil {
|
|
return nil
|
|
}
|
|
// 扫呗的失败响应通常不带 key_sign。失败响应只会阻断支付或触发退款查询,
|
|
// 可以安全地保留为业务错误;成功响应仍必须严格验签。
|
|
if responseCode(raw) != "01" {
|
|
return nil
|
|
}
|
|
return ErrVerifyFailed
|
|
}
|
|
|
|
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 sanitizeOrderBody(value string) string {
|
|
value = strings.Join(strings.Fields(value), "-")
|
|
value = strings.NewReplacer(
|
|
"+", "-",
|
|
"/", "-",
|
|
"?", "",
|
|
"%", "",
|
|
"#", "",
|
|
"&", "",
|
|
"=", "",
|
|
"【", "[",
|
|
"】", "]",
|
|
).Replace(value)
|
|
return sanitizeText(value, 128)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|