Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d7fedd915 | ||
|
|
0963e58fa3 | ||
|
|
d403ffc734 |
@@ -1,5 +1,6 @@
|
|||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
|
.vite
|
||||||
coverage
|
coverage
|
||||||
auto-imports.d.ts
|
auto-imports.d.ts
|
||||||
components.d.ts
|
components.d.ts
|
||||||
|
|||||||
Vendored
+8
@@ -20,8 +20,13 @@ declare module 'vue' {
|
|||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
|
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||||
|
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
|
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||||
|
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
|
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
@@ -33,6 +38,7 @@ declare module 'vue' {
|
|||||||
ElImage: typeof import('element-plus/es')['ElImage']
|
ElImage: typeof import('element-plus/es')['ElImage']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
|
ElLink: typeof import('element-plus/es')['ElLink']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
@@ -52,6 +58,8 @@ declare module 'vue' {
|
|||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
GuideImage: typeof import('./src/components/GuideImage.vue')['default']
|
GuideImage: typeof import('./src/components/GuideImage.vue')['default']
|
||||||
InAppBrowserPrompt: typeof import('./src/components/InAppBrowserPrompt.vue')['default']
|
InAppBrowserPrompt: typeof import('./src/components/InAppBrowserPrompt.vue')['default']
|
||||||
MobileBottomNav: typeof import('./src/components/MobileBottomNav.vue')['default']
|
MobileBottomNav: typeof import('./src/components/MobileBottomNav.vue')['default']
|
||||||
|
|||||||
@@ -0,0 +1,615 @@
|
|||||||
|
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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
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: "租号订单 ORD1",
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if captured["order_body"] != "租号订单-ORD1" {
|
||||||
|
t.Fatalf("order_body = %q, want safe characters only", captured["order_body"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePaymentTreatsUnsignedFailureAsBusinessError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"return_code":"02","return_msg":"签名未通过校验!","result_code":"02"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(Config{
|
||||||
|
GatewayURL: server.URL,
|
||||||
|
MerchantID: "M1",
|
||||||
|
TerminalID: "T1",
|
||||||
|
AccessToken: "token-1",
|
||||||
|
})
|
||||||
|
resp, err := client.CreatePayment(context.Background(), CreatePaymentRequest{
|
||||||
|
ThirdOrderID: "PAY1",
|
||||||
|
AmountCent: 100,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePayment() error = %v, want nil business error", err)
|
||||||
|
}
|
||||||
|
if resp.OK || resp.ErrorMessage == "" || resp.Raw["return_code"] != "02" {
|
||||||
|
t.Fatalf("CreatePayment() response = %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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/lakala"
|
||||||
"hfb_jysh/backend/internal/integrations/payment/leshua"
|
"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/integrations/payment/shuncheng"
|
||||||
"hfb_jysh/backend/internal/modules/paymentconfig"
|
"hfb_jysh/backend/internal/modules/paymentconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
type channelClient interface {
|
type channelClient interface {
|
||||||
CreatePayment(ctx context.Context, req channelCreatePaymentRequest) (*channelCreatePaymentResponse, error)
|
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)
|
CreateRefund(ctx context.Context, req channelCreateRefundRequest) (*channelCreateRefundResponse, error)
|
||||||
QueryRefund(ctx context.Context, req channelQueryRefundRequest) (*channelQueryRefundResponse, error)
|
QueryRefund(ctx context.Context, req channelQueryRefundRequest) (*channelQueryRefundResponse, error)
|
||||||
VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, 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
|
Raw map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type channelQueryPaymentRequest struct {
|
||||||
|
ThirdOrderID string
|
||||||
|
TraceTime string
|
||||||
|
ProviderOrderID string
|
||||||
|
}
|
||||||
|
|
||||||
type channelQueryPaymentResponse struct {
|
type channelQueryPaymentResponse struct {
|
||||||
OK bool
|
OK bool
|
||||||
ErrorMessage string
|
ErrorMessage string
|
||||||
@@ -60,6 +67,7 @@ type channelQueryPaymentResponse struct {
|
|||||||
|
|
||||||
type channelCreateRefundRequest struct {
|
type channelCreateRefundRequest struct {
|
||||||
ThirdOrderID string
|
ThirdOrderID string
|
||||||
|
OriginalTraceTime string
|
||||||
ProviderOrderID string
|
ProviderOrderID string
|
||||||
MerchantRefundID string
|
MerchantRefundID string
|
||||||
RefundAmountCent int64
|
RefundAmountCent int64
|
||||||
@@ -83,6 +91,7 @@ type channelQueryRefundRequest struct {
|
|||||||
ThirdOrderID string
|
ThirdOrderID string
|
||||||
ProviderOrderID string
|
ProviderOrderID string
|
||||||
MerchantRefundID string
|
MerchantRefundID string
|
||||||
|
MerchantRefundTime string
|
||||||
ProviderRefundID string
|
ProviderRefundID string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +153,8 @@ func (c leshuaChannel) CreatePayment(ctx context.Context, req channelCreatePayme
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c leshuaChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) {
|
func (c leshuaChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) {
|
||||||
resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID)
|
resp, err := c.client.QueryPayment(ctx, req.ThirdOrderID, req.ProviderOrderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -264,8 +273,8 @@ func (c lakalaChannel) CreatePayment(ctx context.Context, req channelCreatePayme
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c lakalaChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) {
|
func (c lakalaChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) {
|
||||||
resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID)
|
resp, err := c.client.QueryPayment(ctx, req.ThirdOrderID, req.ProviderOrderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -392,8 +401,8 @@ func (c shunchengChannel) CreatePayment(ctx context.Context, req channelCreatePa
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c shunchengChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) {
|
func (c shunchengChannel) QueryPayment(ctx context.Context, req channelQueryPaymentRequest) (*channelQueryPaymentResponse, error) {
|
||||||
resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID)
|
resp, err := c.client.QueryPayment(ctx, req.ThirdOrderID, req.ProviderOrderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -471,6 +480,124 @@ func (c shunchengChannel) VerifyNotify(params map[string]string, rawPayload stri
|
|||||||
return result, nil
|
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 {
|
||||||
|
if resp != nil {
|
||||||
|
return &channelCreatePaymentResponse{
|
||||||
|
ErrorMessage: resp.ErrorMessage,
|
||||||
|
RawRequest: resp.RawRequest,
|
||||||
|
Raw: resp.Raw,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
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) {
|
func buildChannelClient(dto *paymentconfig.ConfigDTO) (channelClient, error) {
|
||||||
switch strings.ToLower(strings.TrimSpace(dto.Provider)) {
|
switch strings.ToLower(strings.TrimSpace(dto.Provider)) {
|
||||||
case "leshua":
|
case "leshua":
|
||||||
@@ -518,6 +645,16 @@ func buildChannelClient(dto *paymentconfig.ConfigDTO) (channelClient, error) {
|
|||||||
RefundType: extraString(dto.ExtraConfig, "refund_type"),
|
RefundType: extraString(dto.ExtraConfig, "refund_type"),
|
||||||
TokenMargin: time.Duration(extraInt(dto.ExtraConfig, "token_refresh_margin_seconds")) * time.Second,
|
TokenMargin: time.Duration(extraInt(dto.ExtraConfig, "token_refresh_margin_seconds")) * time.Second,
|
||||||
})), nil
|
})), 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":
|
case "mock":
|
||||||
return nil, nil
|
return nil, nil
|
||||||
default:
|
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.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 {
|
if runtimeConfig.isMockMode() || runtimeConfig.Channel == nil {
|
||||||
return 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"hfb_jysh/backend/internal/integrations/payment/lakala"
|
"hfb_jysh/backend/internal/integrations/payment/lakala"
|
||||||
"hfb_jysh/backend/internal/integrations/payment/leshua"
|
"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/integrations/payment/shuncheng"
|
||||||
"hfb_jysh/backend/internal/middleware"
|
"hfb_jysh/backend/internal/middleware"
|
||||||
"hfb_jysh/backend/internal/modules/order"
|
"hfb_jysh/backend/internal/modules/order"
|
||||||
@@ -293,6 +294,29 @@ func (h *Handler) ShunchengNotify(c *gin.Context) {
|
|||||||
c.String(http.StatusOK, result.Message)
|
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) {
|
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||||
value, ok := c.Get(middleware.ContextUserID)
|
value, ok := c.Get(middleware.ContextUserID)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -68,11 +68,15 @@ func (r *Repository) StartMohong(ctx context.Context, userID uint64, orderID uin
|
|||||||
NotifyURL: runtimeConfig.NotifyURL,
|
NotifyURL: runtimeConfig.NotifyURL,
|
||||||
JumpURL: runtimeConfig.JumpURL,
|
JumpURL: runtimeConfig.JumpURL,
|
||||||
ClientIP: clientIP,
|
ClientIP: clientIP,
|
||||||
Body: "撞车订单 " + orderRow.OrderNo,
|
Body: "撞车订单-" + orderRow.OrderNo,
|
||||||
Attach: orderRow.OrderNo,
|
Attach: orderRow.OrderNo,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
var raw map[string]string
|
||||||
|
if resp != nil {
|
||||||
|
raw = resp.Raw
|
||||||
|
}
|
||||||
|
_ = r.markPaymentFailed(ctx, payment.ID, raw, err.Error())
|
||||||
r.log().Warn("魔哄支付下单失败", paymentLogFields(ctx, appendFields(
|
r.log().Warn("魔哄支付下单失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(payment),
|
paymentOrderFields(payment),
|
||||||
runtimeConfigFields(runtimeConfig),
|
runtimeConfigFields(runtimeConfig),
|
||||||
@@ -229,7 +233,11 @@ func (r *Repository) QueryMohong(ctx context.Context, userID uint64, orderID uin
|
|||||||
if runtimeConfig.Channel == nil {
|
if runtimeConfig.Channel == nil {
|
||||||
return nil, ErrPaymentUnavailable
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"hfb_jysh/backend/internal/integrations/payment/saobei"
|
||||||
"hfb_jysh/backend/internal/model"
|
"hfb_jysh/backend/internal/model"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"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)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -35,7 +40,19 @@ func (r *Repository) HandleNotify(ctx context.Context, provider string, params m
|
|||||||
return nil, err
|
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 {
|
if err := r.recordNotifyDiagnostic(ctx, payment.ID, params, rawPayload, contentType, verify, "amount_mismatch"); err != nil {
|
||||||
r.log().Warn("支付回调诊断保存失败", paymentLogFields(ctx, appendFields(
|
r.log().Warn("支付回调诊断保存失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(payment),
|
paymentOrderFields(payment),
|
||||||
@@ -49,9 +66,9 @@ func (r *Repository) HandleNotify(ctx context.Context, provider string, params m
|
|||||||
}
|
}
|
||||||
return nil, ErrPaymentVerifyFailed
|
return nil, ErrPaymentVerifyFailed
|
||||||
}
|
}
|
||||||
raw := withNotifyDiagnostic(params, rawPayload, contentType, verify, "verified")
|
raw := withNotifyDiagnostic(processingParams, rawPayload, contentType, verify, "verified")
|
||||||
channelStatus := normalizeNotifyPaymentStatus(runtimeConfig.Provider, params["status"])
|
channelStatus := normalizeNotifyPaymentStatus(runtimeConfig.Provider, processingParams["status"])
|
||||||
if err := r.applyChannelStatus(ctx, payment, channelStatus, params["pay_time"], raw, channelSourceNotify); err != nil {
|
if err := r.applyChannelStatus(ctx, payment, channelStatus, processingParams["pay_time"], raw, channelSourceNotify); err != nil {
|
||||||
// 入口处的 WARN 只有渠道参数,定位不到是哪个订单哪笔支付单,这里补齐上下文。
|
// 入口处的 WARN 只有渠道参数,定位不到是哪个订单哪笔支付单,这里补齐上下文。
|
||||||
r.log().Warn("支付回调入账失败", paymentLogFields(ctx, appendFields(
|
r.log().Warn("支付回调入账失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(payment),
|
paymentOrderFields(payment),
|
||||||
@@ -182,10 +199,11 @@ func redactNotifyParams(params map[string]string) map[string]string {
|
|||||||
allowed := map[string]bool{
|
allowed := map[string]bool{
|
||||||
"service": true, "merchant_id": true, "third_order_id": true,
|
"service": true, "merchant_id": true, "third_order_id": true,
|
||||||
"provider_order_id": true, "leshua_order_id": true, "schc_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,
|
"merchant_refund_id": true, "provider_refund_id": true,
|
||||||
"leshua_refund_id": true, "schc_refund_id": true,
|
"leshua_refund_id": true, "schc_refund_id": true,
|
||||||
"status": true, "amount": true, "refund_amount": true,
|
"status": true, "amount": true, "total_fee": true, "refund_amount": true,
|
||||||
"pay_time": true, "refund_time": true,
|
"pay_time": true, "end_time": true, "refund_time": true,
|
||||||
"code": true, "resp_code": true, "result_code": true, "error_code": true,
|
"code": true, "resp_code": true, "result_code": true, "error_code": true,
|
||||||
}
|
}
|
||||||
redacted := make(map[string]string, len(allowed))
|
redacted := make(map[string]string, len(allowed))
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ func (r *Repository) startPreparedPayment(ctx context.Context, payment *model.Pa
|
|||||||
Attach: attach,
|
Attach: attach,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
var raw map[string]string
|
||||||
|
if resp != nil {
|
||||||
|
raw = resp.Raw
|
||||||
|
}
|
||||||
|
_ = r.markPaymentFailed(ctx, payment.ID, raw, err.Error())
|
||||||
r.log().Warn("支付下单请求失败", paymentLogFields(ctx, appendFields(
|
r.log().Warn("支付下单请求失败", paymentLogFields(ctx, appendFields(
|
||||||
paymentOrderFields(payment),
|
paymentOrderFields(payment),
|
||||||
runtimeConfigFields(runtimeConfig),
|
runtimeConfigFields(runtimeConfig),
|
||||||
|
|||||||
@@ -104,6 +104,23 @@ func jsonString(value map[string]string) string {
|
|||||||
}
|
}
|
||||||
return string(raw)
|
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) {
|
func newPaymentNo() (string, error) {
|
||||||
buf := make([]byte, 4)
|
buf := make([]byte, 4)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
|||||||
@@ -151,7 +151,11 @@ func (r *Repository) QueryQualityOwner(ctx context.Context, userID uint64) (*Pay
|
|||||||
if runtimeConfig.Channel == nil {
|
if runtimeConfig.Channel == nil {
|
||||||
return nil, ErrPaymentUnavailable
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ func (r *Repository) Query(ctx context.Context, userID uint64, orderID uint64) (
|
|||||||
if runtimeConfig.Channel == nil {
|
if runtimeConfig.Channel == nil {
|
||||||
return nil, ErrPaymentUnavailable
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ func (r *Repository) StartRefund(ctx context.Context, orderID uint64, refundAmou
|
|||||||
}
|
}
|
||||||
resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{
|
resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{
|
||||||
ThirdOrderID: originalPayment.ThirdOrderID,
|
ThirdOrderID: originalPayment.ThirdOrderID,
|
||||||
|
OriginalTraceTime: paymentTraceTime(originalPayment.RawRequest),
|
||||||
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
|
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
|
||||||
MerchantRefundID: refundOrder.ThirdOrderID,
|
MerchantRefundID: refundOrder.ThirdOrderID,
|
||||||
RefundAmountCent: refundAmountCent,
|
RefundAmountCent: refundAmountCent,
|
||||||
@@ -383,6 +384,7 @@ func refundQueryRequest(payment model.PaymentOrder, originalPayment model.Paymen
|
|||||||
ThirdOrderID: originalPayment.ThirdOrderID,
|
ThirdOrderID: originalPayment.ThirdOrderID,
|
||||||
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
|
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
|
||||||
MerchantRefundID: payment.ThirdOrderID,
|
MerchantRefundID: payment.ThirdOrderID,
|
||||||
|
MerchantRefundTime: paymentTraceTime(payment.RawRequest),
|
||||||
ProviderRefundID: payment.ProviderOrderID,
|
ProviderRefundID: payment.ProviderOrderID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func (r *Repository) StartMohongRefund(ctx context.Context, orderID uint64, refu
|
|||||||
}
|
}
|
||||||
resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{
|
resp, err := runtimeConfig.Channel.CreateRefund(ctx, channelCreateRefundRequest{
|
||||||
ThirdOrderID: originalPayment.ThirdOrderID,
|
ThirdOrderID: originalPayment.ThirdOrderID,
|
||||||
|
OriginalTraceTime: paymentTraceTime(originalPayment.RawRequest),
|
||||||
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
|
ProviderOrderID: refundOriginProviderOrderID(originalPayment),
|
||||||
MerchantRefundID: refundOrder.ThirdOrderID,
|
MerchantRefundID: refundOrder.ThirdOrderID,
|
||||||
RefundAmountCent: refundOrder.AmountCent,
|
RefundAmountCent: refundOrder.AmountCent,
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ func (r *Repository) runtimeConfigForPayment(ctx context.Context, payment *model
|
|||||||
MerchantID: merchantID,
|
MerchantID: merchantID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
if provider != "leshua" && provider != "lakala" && provider != "shuncheng" {
|
if provider != "leshua" && provider != "lakala" && provider != "shuncheng" && provider != "saobei" {
|
||||||
return &runtimePaymentConfig{
|
return &runtimePaymentConfig{
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
MerchantID: merchantID,
|
MerchantID: merchantID,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ var (
|
|||||||
ErrSerialNoRequired = errors.New("extra_config.serial_no is required for lakala provider")
|
ErrSerialNoRequired = errors.New("extra_config.serial_no is required for lakala provider")
|
||||||
ErrTermNoRequired = errors.New("extra_config.term_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")
|
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 数据传输对象
|
// DTO 数据传输对象
|
||||||
@@ -59,7 +60,7 @@ type ConfigDTO struct {
|
|||||||
// CreateRequest 创建支付配置请求
|
// CreateRequest 创建支付配置请求
|
||||||
type CreateRequest struct {
|
type CreateRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
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"`
|
MerchantID string `json:"merchant_id" binding:"required"`
|
||||||
GatewayURL string `json:"gateway_url"`
|
GatewayURL string `json:"gateway_url"`
|
||||||
SignKey string `json:"sign_key"`
|
SignKey string `json:"sign_key"`
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func (h *Handler) ImportBackup(c *gin.Context) {
|
|||||||
err == ErrNameRequired || err == ErrMerchantIDRequired || err == ErrGatewayURLRequired ||
|
err == ErrNameRequired || err == ErrMerchantIDRequired || err == ErrGatewayURLRequired ||
|
||||||
err == ErrSignKeyRequired || err == ErrNotifyKeyRequired || err == ErrNotifyURLRequired ||
|
err == ErrSignKeyRequired || err == ErrNotifyKeyRequired || err == ErrNotifyURLRequired ||
|
||||||
err == ErrInvalidSignType || err == ErrInvalidStatus || err == ErrInvalidPayWay || err == ErrAppIDRequired ||
|
err == ErrInvalidSignType || err == ErrInvalidStatus || err == ErrInvalidPayWay || err == ErrAppIDRequired ||
|
||||||
err == ErrSerialNoRequired || err == ErrTermNoRequired {
|
err == ErrSerialNoRequired || err == ErrTermNoRequired || err == ErrTerminalIDRequired {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ func (h *Handler) Create(c *gin.Context) {
|
|||||||
if err == ErrNameRequired || err == ErrMerchantIDRequired || err == ErrGatewayURLRequired ||
|
if err == ErrNameRequired || err == ErrMerchantIDRequired || err == ErrGatewayURLRequired ||
|
||||||
err == ErrSignKeyRequired || err == ErrNotifyKeyRequired || err == ErrInvalidProvider ||
|
err == ErrSignKeyRequired || err == ErrNotifyKeyRequired || err == ErrInvalidProvider ||
|
||||||
err == ErrNotifyURLRequired || err == ErrInvalidSignType || err == ErrInvalidPayWay || err == ErrAppIDRequired ||
|
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())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,12 +79,26 @@ func (r *Repository) validateCreateRequest(req CreateRequest) error {
|
|||||||
return ErrSecretIDRequired
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isValidProvider(value string) bool {
|
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 {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -469,6 +469,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
api.POST("/payments/leshua/notify", paymentHandler.LeshuaNotify)
|
api.POST("/payments/leshua/notify", paymentHandler.LeshuaNotify)
|
||||||
api.POST("/payments/lakala/notify", paymentHandler.LakalaNotify)
|
api.POST("/payments/lakala/notify", paymentHandler.LakalaNotify)
|
||||||
api.POST("/payments/shuncheng/notify", paymentHandler.ShunchengNotify)
|
api.POST("/payments/shuncheng/notify", paymentHandler.ShunchengNotify)
|
||||||
|
api.POST("/payments/saobei/notify", paymentHandler.SaobeiNotify)
|
||||||
|
|
||||||
openRoutes := api.Group("/open")
|
openRoutes := api.Group("/open")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,24 +37,27 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
# minio/minio is no longer available on Docker Hub; Bitnami Legacy packages the same server binary.
|
# MinIO 已停止发布官方 Docker Hub 镜像;开发环境使用 S3 兼容的 SeaweedFS。
|
||||||
image: bitnamilegacy/minio:2025.7.23-debian-12-r5
|
image: chrislusf/seaweedfs:4.53
|
||||||
container_name: jysh-minio
|
container_name: jysh-minio
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "0"
|
command:
|
||||||
entrypoint: ["/opt/bitnami/minio/bin/minio"]
|
- mini
|
||||||
command: ["server", "/data", "--console-address", ":9001"]
|
- -dir=/data
|
||||||
|
- -bucket=jieyou-sys
|
||||||
|
- -admin.user=admin
|
||||||
|
- -admin.password=jieyou-admin
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: minioadmin
|
AWS_ACCESS_KEY_ID: minioadmin
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
AWS_SECRET_ACCESS_KEY: minioadmin
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
ports:
|
ports:
|
||||||
- "${DEV_MINIO_API_PORT:-29090}:9000"
|
- "${DEV_MINIO_API_PORT:-29090}:8333"
|
||||||
- "${DEV_MINIO_CONSOLE_PORT:-29091}:9001"
|
- "${DEV_MINIO_CONSOLE_PORT:-29091}:12535"
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data_v2:/data
|
- minio_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mc", "ready", "local"]
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9333/cluster/status"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -62,5 +65,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
minio_data_v2:
|
minio_data:
|
||||||
name: hfb_jysh_dev_minio_data_v2
|
|
||||||
|
|||||||
@@ -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: '乐刷',
|
leshua: '乐刷',
|
||||||
lakala: '拉卡拉',
|
lakala: '拉卡拉',
|
||||||
shuncheng: '顺诚',
|
shuncheng: '顺诚',
|
||||||
|
saobei: '扫呗',
|
||||||
mock: 'Mock',
|
mock: 'Mock',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const providerLabels: Record<string, string> = {
|
|||||||
leshua: '乐刷',
|
leshua: '乐刷',
|
||||||
lakala: '拉卡拉',
|
lakala: '拉卡拉',
|
||||||
shuncheng: '顺诚',
|
shuncheng: '顺诚',
|
||||||
|
saobei: '扫呗',
|
||||||
mock: 'Mock',
|
mock: 'Mock',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ const defaultNotifyPaths: Record<string, string> = {
|
|||||||
leshua: '/api/payments/leshua/notify',
|
leshua: '/api/payments/leshua/notify',
|
||||||
lakala: '/api/payments/lakala/notify',
|
lakala: '/api/payments/lakala/notify',
|
||||||
shuncheng: '/api/payments/shuncheng/notify',
|
shuncheng: '/api/payments/shuncheng/notify',
|
||||||
|
saobei: '/api/payments/saobei/notify',
|
||||||
mock: '',
|
mock: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +137,13 @@ function defaultExtraConfig(provider: string): Record<string, unknown> {
|
|||||||
token_refresh_margin_seconds: 300,
|
token_refresh_margin_seconds: 300,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (provider === 'saobei') {
|
||||||
|
return {
|
||||||
|
terminal_id: '',
|
||||||
|
device_no: '',
|
||||||
|
expire_seconds: 900,
|
||||||
|
}
|
||||||
|
}
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +160,9 @@ function extraFormDefaults(provider: string) {
|
|||||||
extra_shop_no: String(extra.shop_no ?? ''),
|
extra_shop_no: String(extra.shop_no ?? ''),
|
||||||
extra_refund_type: String(extra.refund_type ?? ''),
|
extra_refund_type: String(extra.refund_type ?? ''),
|
||||||
extra_token_refresh_margin_seconds: Number(extra.token_refresh_margin_seconds ?? 300),
|
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_shop_no?: string
|
||||||
extra_refund_type?: string
|
extra_refund_type?: string
|
||||||
extra_token_refresh_margin_seconds?: number
|
extra_token_refresh_margin_seconds?: number
|
||||||
|
extra_terminal_id?: string
|
||||||
|
extra_device_no?: string
|
||||||
|
extra_expire_seconds?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorText(error: unknown) {
|
function errorText(error: unknown) {
|
||||||
@@ -196,7 +211,14 @@ export function AdminPaymentConfigsPage() {
|
|||||||
const isMock = providerValue === 'mock'
|
const isMock = providerValue === 'mock'
|
||||||
const isLakala = providerValue === 'lakala'
|
const isLakala = providerValue === 'lakala'
|
||||||
const isShuncheng = providerValue === 'shuncheng'
|
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 notifyKeyLabel = isLakala ? '通知证书' : '通知密钥'
|
||||||
const [draft, setDraft] = useState<AdminPaymentConfigQuery>({})
|
const [draft, setDraft] = useState<AdminPaymentConfigQuery>({})
|
||||||
const [filters, setFilters] = useState<AdminPaymentConfigQuery>({})
|
const [filters, setFilters] = useState<AdminPaymentConfigQuery>({})
|
||||||
@@ -290,6 +312,9 @@ export function AdminPaymentConfigsPage() {
|
|||||||
extra_shop_no: String(extra.shop_no ?? ''),
|
extra_shop_no: String(extra.shop_no ?? ''),
|
||||||
extra_refund_type: String(extra.refund_type ?? ''),
|
extra_refund_type: String(extra.refund_type ?? ''),
|
||||||
extra_token_refresh_margin_seconds: Number(extra.token_refresh_margin_seconds ?? 300),
|
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)
|
setEditorOpen(true)
|
||||||
}
|
}
|
||||||
@@ -346,6 +371,14 @@ export function AdminPaymentConfigsPage() {
|
|||||||
extra.refund_type = values.extra_refund_type
|
extra.refund_type = values.extra_refund_type
|
||||||
}
|
}
|
||||||
extraConfig = extra
|
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 = {
|
const payload: AdminPaymentConfigPayload = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
@@ -692,7 +725,7 @@ export function AdminPaymentConfigsPage() {
|
|||||||
description={
|
description={
|
||||||
editing
|
editing
|
||||||
? 'SignKey / NotifyKey 留空表示保持原值;额外配置需提交完整 JSON。'
|
? 'SignKey / NotifyKey 留空表示保持原值;额外配置需提交完整 JSON。'
|
||||||
: '乐刷/拉卡拉/顺诚需要完整密钥与回调地址;创建前请确认商户环境。'
|
: '乐刷/拉卡拉/顺诚/扫呗需要完整密钥与回调地址;创建前请确认商户环境。'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{providerValue === 'mock' ? (
|
{providerValue === 'mock' ? (
|
||||||
@@ -704,6 +737,15 @@ export function AdminPaymentConfigsPage() {
|
|||||||
description="无需网关、密钥和回调地址;用户发起支付后会直接标记为已支付,请勿在生产环境启用。"
|
description="无需网关、密钥和回调地址;用户发起支付后会直接标记为已支付,请勿在生产环境启用。"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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}>
|
<Form<ConfigFormValues> form={form} layout="vertical" disabled={submitting}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
@@ -754,7 +796,10 @@ export function AdminPaymentConfigsPage() {
|
|||||||
label="网关地址"
|
label="网关地址"
|
||||||
rules={[{ required: true, message: '请输入支付网关地址' }]}
|
rules={[{ required: true, message: '请输入支付网关地址' }]}
|
||||||
>
|
>
|
||||||
<Input maxLength={512} />
|
<Input
|
||||||
|
maxLength={512}
|
||||||
|
placeholder={isSaobei ? '请输入扫呗技术人员提供的接口域名' : 'https://支付网关地址'}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -800,6 +845,30 @@ export function AdminPaymentConfigsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : null}
|
) : 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 ? (
|
{!isMock ? (
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
@@ -812,7 +881,11 @@ export function AdminPaymentConfigsPage() {
|
|||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: isLakala ? '请输入商户私钥 PEM' : '请输入签名密钥',
|
message: isLakala
|
||||||
|
? '请输入商户私钥 PEM'
|
||||||
|
: isSaobei
|
||||||
|
? '请输入扫呗终端密钥'
|
||||||
|
: '请输入签名密钥',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -825,10 +898,14 @@ export function AdminPaymentConfigsPage() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input.Password placeholder="请输入签名密钥" autoComplete="new-password" />
|
<Input.Password
|
||||||
|
placeholder={isSaobei ? '请输入终端密钥 access_token' : '请输入签名密钥'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
{!isSaobei ? (
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="notify_key"
|
name="notify_key"
|
||||||
@@ -856,6 +933,7 @@ export function AdminPaymentConfigsPage() {
|
|||||||
)}
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { Alert, App, Button, Card, Image, InputNumber, Space, Tag, Typography }
|
|||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
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'
|
import { formatMoney } from '@/shared/utils/format'
|
||||||
|
|
||||||
function errorText(error: unknown) {
|
function errorText(error: unknown) {
|
||||||
@@ -28,8 +33,9 @@ export function CrashDetailPage() {
|
|||||||
message.success('下单成功')
|
message.success('下单成功')
|
||||||
try {
|
try {
|
||||||
const payment = await startCrashPayment(order.id)
|
const payment = await startCrashPayment(order.id)
|
||||||
if (payment?.pay_url) {
|
const payURL = crashPaymentURL(payment)
|
||||||
window.location.href = payment.pay_url
|
if (payURL) {
|
||||||
|
window.location.href = payURL
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { Alert, App, Button, Card, Empty, Image, Space, Tag, Typography } from '
|
|||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { useSessionStore } from '@/stores/session'
|
||||||
import { formatDateTime, formatMoney } from '@/shared/utils/format'
|
import { formatDateTime, formatMoney } from '@/shared/utils/format'
|
||||||
|
|
||||||
@@ -104,8 +109,9 @@ export function CrashOrdersPage() {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const payment = await startCrashPayment(order.id)
|
const payment = await startCrashPayment(order.id)
|
||||||
if (payment?.pay_url) {
|
const payURL = crashPaymentURL(payment)
|
||||||
window.location.href = payment.pay_url
|
if (payURL) {
|
||||||
|
window.location.href = payURL
|
||||||
} else {
|
} else {
|
||||||
message.success('支付已发起,请在支付渠道完成付款')
|
message.success('支付已发起,请在支付渠道完成付款')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
CrashProduct,
|
CrashProduct,
|
||||||
CrashProductPage,
|
CrashProductPage,
|
||||||
} from '@/features/crash/types'
|
} from '@/features/crash/types'
|
||||||
|
import type { UserPayment } from '@/features/orders/types'
|
||||||
|
|
||||||
export function fetchCrashCategories() {
|
export function fetchCrashCategories() {
|
||||||
return unwrapData<CrashCategory[]>(
|
return unwrapData<CrashCategory[]>(
|
||||||
@@ -90,9 +91,13 @@ export function cancelCrashOrder(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startCrashPayment(id: number) {
|
export function startCrashPayment(id: number) {
|
||||||
return unwrapData<{ pay_url?: string; jsapi?: string; qrcode?: string; status?: string }>(
|
return unwrapData<UserPayment>(
|
||||||
apiClient.post<
|
apiClient.post<ApiResponse<UserPayment>>(`/crash/orders/${id}/start-payment`)
|
||||||
ApiResponse<{ pay_url?: string; jsapi?: string; qrcode?: string; status?: string }>
|
|
||||||
>(`/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 { Button, Card, Dialog, Image, NavBar, Stepper, Toast } from 'antd-mobile'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
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'
|
import { formatMoney } from '@/shared/utils/format'
|
||||||
|
|
||||||
function errorText(error: unknown) {
|
function errorText(error: unknown) {
|
||||||
@@ -25,8 +30,9 @@ export function MobileCrashDetailPage() {
|
|||||||
const order = await createCrashOrder({ product_id: product.id, quantity })
|
const order = await createCrashOrder({ product_id: product.id, quantity })
|
||||||
try {
|
try {
|
||||||
const payment = await startCrashPayment(order.id)
|
const payment = await startCrashPayment(order.id)
|
||||||
if (payment?.pay_url) {
|
const payURL = crashPaymentURL(payment)
|
||||||
window.location.href = payment.pay_url
|
if (payURL) {
|
||||||
|
window.location.href = payURL
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { Button, Card, Dialog, ErrorBlock, Image, NavBar, Skeleton, Tag, Toast }
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
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 { useSessionStore } from '@/stores/session'
|
||||||
import { formatDateTime, formatMoney } from '@/shared/utils/format'
|
import { formatDateTime, formatMoney } from '@/shared/utils/format'
|
||||||
|
|
||||||
@@ -78,8 +83,9 @@ export function MobileCrashOrdersPage() {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const payment = await startCrashPayment(order.id)
|
const payment = await startCrashPayment(order.id)
|
||||||
if (payment?.pay_url) {
|
const payURL = crashPaymentURL(payment)
|
||||||
window.location.href = payment.pay_url
|
if (payURL) {
|
||||||
|
window.location.href = payURL
|
||||||
} else {
|
} else {
|
||||||
Toast.show({ content: '支付已发起,请在支付渠道完成付款' })
|
Toast.show({ content: '支付已发起,请在支付渠道完成付款' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
QRCode,
|
||||||
Radio,
|
Radio,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
@@ -75,6 +76,7 @@ export function OrderActionPanel({
|
|||||||
const [busy, setBusy] = useState<string | null>(null)
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
const [payURL, setPayURL] = useState('')
|
const [payURL, setPayURL] = useState('')
|
||||||
const autoPayTriggered = useRef(false)
|
const autoPayTriggered = useRef(false)
|
||||||
|
const payPolling = useRef<number | null>(null)
|
||||||
const actions = getOrderActionAvailability(order, user?.id)
|
const actions = getOrderActionAvailability(order, user?.id)
|
||||||
const checkout = order.checkout
|
const checkout = order.checkout
|
||||||
|
|
||||||
@@ -143,6 +145,42 @@ export function OrderActionPanel({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [autoPay, actions.canPay])
|
}, [autoPay, actions.canPay])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dialog !== 'pay' || !payURL) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let attempts = 0
|
||||||
|
const poll = async () => {
|
||||||
|
if (cancelled) return
|
||||||
|
attempts += 1
|
||||||
|
try {
|
||||||
|
const payment = await queryOrderPayment(order.id)
|
||||||
|
if (cancelled) return
|
||||||
|
if (payment.paid) {
|
||||||
|
message.success('支付成功,订单已进入待交接')
|
||||||
|
setDialog(null)
|
||||||
|
setPayURL('')
|
||||||
|
await onChanged()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 自动轮询不打断用户;保留手动检查作为明确反馈。
|
||||||
|
}
|
||||||
|
if (!cancelled && attempts < 200) {
|
||||||
|
payPolling.current = window.setTimeout(() => void poll(), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payPolling.current = window.setTimeout(() => void poll(), 3000)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (payPolling.current !== null) {
|
||||||
|
window.clearTimeout(payPolling.current)
|
||||||
|
payPolling.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [dialog, message, onChanged, order.id, payURL])
|
||||||
|
|
||||||
const run = async (key: string, fn: () => Promise<void>) => {
|
const run = async (key: string, fn: () => Promise<void>) => {
|
||||||
setBusy(key)
|
setBusy(key)
|
||||||
try {
|
try {
|
||||||
@@ -173,7 +211,7 @@ export function OrderActionPanel({
|
|||||||
const url = payment.jspay_url || payment.td_code || payment.jspay_info || ''
|
const url = payment.jspay_url || payment.td_code || payment.jspay_info || ''
|
||||||
if (url) {
|
if (url) {
|
||||||
setPayURL(url)
|
setPayURL(url)
|
||||||
message.info('支付单已创建,请打开支付页面完成付款')
|
message.info('支付单已创建,系统会自动检查支付结果')
|
||||||
} else {
|
} else {
|
||||||
message.warning('支付单已创建,但渠道没有返回支付链接,请稍后检查支付状态')
|
message.warning('支付单已创建,但渠道没有返回支付链接,请稍后检查支付状态')
|
||||||
}
|
}
|
||||||
@@ -492,7 +530,7 @@ export function OrderActionPanel({
|
|||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message="选择支付方式"
|
message="选择支付方式"
|
||||||
description="支付成功后才进入交接流程;支付完成后回到本页点击「检查支付状态」。"
|
description="支付成功后才进入交接流程;二维码创建后系统会自动检查支付结果。"
|
||||||
/>
|
/>
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
<Form.Item name="payWay" label="支付方式">
|
<Form.Item name="payWay" label="支付方式">
|
||||||
@@ -508,10 +546,11 @@ export function OrderActionPanel({
|
|||||||
showIcon
|
showIcon
|
||||||
message="支付单已创建"
|
message="支付单已创建"
|
||||||
description={
|
description={
|
||||||
<Space direction="vertical">
|
<Space direction="vertical" align="center">
|
||||||
<span>请在支付页面完成付款,完成后回到本页检查支付状态。</span>
|
<QRCode value={payURL} size={180} />
|
||||||
|
<span>请使用微信、支付宝或云闪付扫码付款,支付成功后本页会自动刷新。</span>
|
||||||
<Button type="primary" href={payURL} target="_blank">
|
<Button type="primary" href={payURL} target="_blank">
|
||||||
打开支付页面
|
在支付应用中打开
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ GOOSE_VERSION="${GOOSE_VERSION:-v3.27.1}"
|
|||||||
GOOSE_BIN="${GOOSE_BIN:-}"
|
GOOSE_BIN="${GOOSE_BIN:-}"
|
||||||
GOOSE_GOPROXY="${GOOSE_GOPROXY:-https://goproxy.cn,direct}"
|
GOOSE_GOPROXY="${GOOSE_GOPROXY:-https://goproxy.cn,direct}"
|
||||||
|
|
||||||
|
# 国内网络通常无法稳定访问 proxy.golang.org;显式导出的 GOPROXY 优先。
|
||||||
|
if [[ -z "${GOPROXY:-}" ]]; then
|
||||||
|
export GOPROXY="${DEV_GOPROXY:-https://goproxy.cn,direct}"
|
||||||
|
fi
|
||||||
|
|
||||||
BACKEND_PID=""
|
BACKEND_PID=""
|
||||||
FRONTEND_PID=""
|
FRONTEND_PID=""
|
||||||
ADMIN_WEB_PID=""
|
ADMIN_WEB_PID=""
|
||||||
@@ -129,6 +134,8 @@ show_help() {
|
|||||||
前后端端口被占用时自动停止占用的 Docker 容器或本机进程
|
前后端端口被占用时自动停止占用的 Docker 容器或本机进程
|
||||||
DEV_FORCE_NPM_CI=0
|
DEV_FORCE_NPM_CI=0
|
||||||
强制重新执行前端 npm ci
|
强制重新执行前端 npm ci
|
||||||
|
DEV_GOPROXY=https://goproxy.cn,direct
|
||||||
|
未显式设置 GOPROXY 时使用的 Go 模块代理
|
||||||
GOOSE_GOPROXY=https://goproxy.cn,direct
|
GOOSE_GOPROXY=https://goproxy.cn,direct
|
||||||
Goose 安装失败时使用的 Go 模块代理
|
Goose 安装失败时使用的 Go 模块代理
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user