feat(payment): 虎皮椒聚合支付 - 官方插件与跨平台可执行文件
新增 xunhupay 官方包,支付 RPC 与宿主只保留订单和验签边界;官方支付包同时带上各系统 provider,避免把 Linux ELF 当成本机入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,6 +24,8 @@ var (
|
||||
const (
|
||||
ProviderWeChatNative = "wechat-native"
|
||||
ProviderAlipayPage = "alipay-page-pay"
|
||||
ProviderXunHuPay = "xunhupay-aggregate"
|
||||
PluginWeChatNative = "official-payment-wechat-native"
|
||||
PluginAlipayPage = "official-payment-alipay-page"
|
||||
PluginXunHuPay = "official-payment-xunhupay"
|
||||
)
|
||||
|
||||
499
backend/payment-plugins/xunhupay.go
Normal file
499
backend/payment-plugins/xunhupay.go
Normal file
@@ -0,0 +1,499 @@
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultXunHuPayGateway = "https://api.xunhupay.com"
|
||||
xunhupayAPIVersion = "1.1"
|
||||
xunhupayPluginName = "yingce-xunhupay"
|
||||
xunhupayPayPath = "/payment/do.html"
|
||||
xunhupayQueryPath = "/payment/query.html"
|
||||
xunhupayQRLifetime = 5 * time.Minute
|
||||
)
|
||||
|
||||
var xunhupayGatewayHosts = map[string]struct{}{
|
||||
"api.xunhupay.com": {},
|
||||
"api.dpweixin.com": {},
|
||||
"api.diypc.com.cn": {},
|
||||
}
|
||||
|
||||
type XunHuPayProvider struct {
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
baseURL string
|
||||
nonce func() string
|
||||
}
|
||||
|
||||
func NewXunHuPayProvider(client *http.Client) *XunHuPayProvider {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &XunHuPayProvider{client: client, now: time.Now, nonce: randomNonce}
|
||||
}
|
||||
|
||||
func (p *XunHuPayProvider) Descriptor() Descriptor {
|
||||
return Descriptor{
|
||||
ID: ProviderXunHuPay, PluginID: PluginXunHuPay, PluginVersion: "1.0.0",
|
||||
Name: "虎皮椒聚合支付", Icon: "assets/icon.svg", CheckoutMode: "qr_code",
|
||||
IdentityFields: []string{"appId"},
|
||||
NotificationSuccess: NotificationResponse{Status: 200, ContentType: "text/plain; charset=utf-8", Body: "success"},
|
||||
NotificationFailure: NotificationResponse{Status: 400, ContentType: "text/plain; charset=utf-8", Body: "failure"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *XunHuPayProvider) ValidateConfig(config Config) error {
|
||||
if strings.TrimSpace(config["appId"]) == "" {
|
||||
return errors.New("虎皮椒配置缺少 appId")
|
||||
}
|
||||
if strings.TrimSpace(config["appSecret"]) == "" {
|
||||
return errors.New("虎皮椒配置缺少 appSecret")
|
||||
}
|
||||
mode := xunhupayCheckoutMode(config)
|
||||
if mode != "qr_code" && mode != "redirect" {
|
||||
return errors.New("虎皮椒 checkoutMode 必须是 qr_code 或 redirect")
|
||||
}
|
||||
if _, err := xunhupayGatewayOrigin(config); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *XunHuPayProvider) CreateOrder(ctx context.Context, config Config, request CreateRequest) (Checkout, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return Checkout{}, err
|
||||
}
|
||||
if request.AmountFen <= 0 || request.Currency != "CNY" || request.MerchantOrderNo == "" || request.NotifyURL == "" {
|
||||
return Checkout{}, errors.New("虎皮椒下单参数无效")
|
||||
}
|
||||
params := map[string]string{
|
||||
"version": xunhupayAPIVersion,
|
||||
"appid": strings.TrimSpace(config["appId"]),
|
||||
"trade_order_id": request.MerchantOrderNo,
|
||||
"total_fee": formatFen(request.AmountFen),
|
||||
"title": xunhupayTitle(request.Description),
|
||||
"time": strconv.FormatInt(p.now().Unix(), 10),
|
||||
"notify_url": request.NotifyURL,
|
||||
"plugins": xunhupayPluginName,
|
||||
"nonce_str": p.nonce(),
|
||||
}
|
||||
if request.ReturnURL != "" {
|
||||
params["return_url"] = request.ReturnURL
|
||||
params["callback_url"] = request.ReturnURL
|
||||
}
|
||||
var response xunhupayPayResponse
|
||||
if err := p.call(ctx, config, xunhupayPayPath, params, &response); err != nil {
|
||||
return Checkout{}, err
|
||||
}
|
||||
mode := xunhupayCheckoutMode(config)
|
||||
// url_qrcode 是网关已经画好的二维码图片地址,钱包按图片展示,不能再编码成新二维码。
|
||||
value := strings.TrimSpace(response.URLQrcode)
|
||||
if mode == "redirect" {
|
||||
value = strings.TrimSpace(response.URL)
|
||||
}
|
||||
if value == "" {
|
||||
if mode == "redirect" {
|
||||
return Checkout{}, errors.New("虎皮椒下单未返回支付跳转地址")
|
||||
}
|
||||
return Checkout{}, errors.New("虎皮椒下单未返回支付二维码")
|
||||
}
|
||||
expiresAt := request.ExpiresAt
|
||||
if mode == "qr_code" {
|
||||
qrExpires := p.now().Add(xunhupayQRLifetime)
|
||||
if expiresAt.IsZero() || qrExpires.Before(expiresAt) {
|
||||
expiresAt = qrExpires
|
||||
}
|
||||
}
|
||||
return Checkout{Mode: mode, Value: value, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
func (p *XunHuPayProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if strings.TrimSpace(request.MerchantOrderNo) == "" {
|
||||
return Result{}, errors.New("虎皮椒查单缺少商户订单号")
|
||||
}
|
||||
order, err := p.query(ctx, config, request.MerchantOrderNo)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return xunhupayResult(request.MerchantOrderNo, order), nil
|
||||
}
|
||||
|
||||
func (p *XunHuPayProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
order, err := p.query(ctx, config, request.MerchantOrderNo)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
result := xunhupayResult(request.MerchantOrderNo, order)
|
||||
if result.Paid {
|
||||
return result, nil
|
||||
}
|
||||
result.Closed = true
|
||||
if result.ProviderStatus == "" || result.ProviderStatus == "WP" {
|
||||
result.ProviderStatus = "CD"
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *XunHuPayProvider) VerifyNotification(_ context.Context, config Config, _ http.Header, rawBody []byte) (Notification, error) {
|
||||
if err := p.ValidateConfig(config); err != nil {
|
||||
return Notification{}, err
|
||||
}
|
||||
values, err := parseXunHuPayNotification(rawBody)
|
||||
if err != nil {
|
||||
return Notification{}, err
|
||||
}
|
||||
if !xunhupayHashEqual(values, config["appSecret"], values["hash"]) {
|
||||
return Notification{}, errors.New("虎皮椒异步通知签名无效")
|
||||
}
|
||||
if strings.TrimSpace(values["appid"]) != strings.TrimSpace(config["appId"]) {
|
||||
return Notification{}, errors.New("虎皮椒异步通知商户身份不匹配")
|
||||
}
|
||||
status := strings.TrimSpace(values["status"])
|
||||
merchantOrderNo := firstNonEmpty(values["trade_order_id"], values["out_trade_order"])
|
||||
if merchantOrderNo == "" {
|
||||
return Notification{}, errors.New("虎皮椒异步通知缺少商户订单号")
|
||||
}
|
||||
amount, err := parseYuanToFen(firstNonEmpty(values["total_fee"], values["total_amount"]))
|
||||
if err != nil {
|
||||
return Notification{}, errors.New("虎皮椒异步通知金额无效")
|
||||
}
|
||||
paidAt := parseXunHuPayTime(values["time"])
|
||||
eventID := firstNonEmpty(values["transaction_id"], values["open_order_id"], merchantOrderNo) + ":" + status + ":" + values["time"]
|
||||
return Notification{
|
||||
EventID: eventID,
|
||||
Result: Result{
|
||||
MerchantOrderNo: merchantOrderNo,
|
||||
ProviderTradeNo: firstNonEmpty(values["transaction_id"], values["open_order_id"]),
|
||||
ProviderStatus: status,
|
||||
AmountFen: amount,
|
||||
Currency: "CNY",
|
||||
Paid: status == "OD",
|
||||
Closed: status == "CD",
|
||||
PaidAt: paidAt,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *XunHuPayProvider) DownloadTradeBill(context.Context, Config, time.Time) ([]BillRecord, error) {
|
||||
return nil, fmt.Errorf("%w: 虎皮椒未提供交易账单下载接口", ErrTradeBillNotFound)
|
||||
}
|
||||
|
||||
func (p *XunHuPayProvider) query(ctx context.Context, config Config, merchantOrderNo string) (xunhupayOrderData, error) {
|
||||
params := map[string]string{
|
||||
"appid": strings.TrimSpace(config["appId"]),
|
||||
"out_trade_order": merchantOrderNo,
|
||||
"time": strconv.FormatInt(p.now().Unix(), 10),
|
||||
"nonce_str": p.nonce(),
|
||||
}
|
||||
var envelope xunhupayQueryResponse
|
||||
if err := p.call(ctx, config, xunhupayQueryPath, params, &envelope); err != nil {
|
||||
return xunhupayOrderData{}, err
|
||||
}
|
||||
if strings.TrimSpace(envelope.Data.Status) == "" {
|
||||
return xunhupayOrderData{}, errors.New("虎皮椒查单未返回订单状态")
|
||||
}
|
||||
return envelope.Data, nil
|
||||
}
|
||||
|
||||
func (p *XunHuPayProvider) call(ctx context.Context, config Config, path string, params map[string]string, output any) error {
|
||||
params["hash"] = xunhupayHash(params, config["appSecret"])
|
||||
payload, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint(config, path), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
response, err := p.client.Do(request)
|
||||
if err != nil {
|
||||
return &ProviderError{Code: "xunhupay_transport_error", Message: "虎皮椒网络请求失败", Temporary: true, Cause: err}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
|
||||
if err != nil {
|
||||
return &ProviderError{Code: "xunhupay_response_read_error", Message: "读取虎皮椒响应失败", Temporary: true, Cause: err}
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return &ProviderError{Code: "xunhupay_http_error", Message: "虎皮椒接口返回失败", Temporary: response.StatusCode >= 500}
|
||||
}
|
||||
var envelope xunhupayEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return fmt.Errorf("解析虎皮椒响应:%w", err)
|
||||
}
|
||||
if envelope.Hash != "" {
|
||||
values, err := jsonObjectStrings(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析虎皮椒响应签名字段:%w", err)
|
||||
}
|
||||
if !xunhupayHashEqual(values, config["appSecret"], envelope.Hash) {
|
||||
return errors.New("虎皮椒响应签名无效")
|
||||
}
|
||||
}
|
||||
if envelope.ErrCode != 0 {
|
||||
message := strings.TrimSpace(envelope.ErrMsg)
|
||||
if message == "" {
|
||||
message = "虎皮椒业务请求失败"
|
||||
}
|
||||
if isXunHuPayOrderMissing(message) {
|
||||
return fmt.Errorf("%w: %s", ErrOrderNotFound, message)
|
||||
}
|
||||
return &ProviderError{Code: strconv.Itoa(int(envelope.ErrCode)), Message: message}
|
||||
}
|
||||
if output == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(body, output)
|
||||
}
|
||||
|
||||
func (p *XunHuPayProvider) endpoint(config Config, path string) string {
|
||||
if strings.TrimSpace(p.baseURL) != "" {
|
||||
return strings.TrimRight(p.baseURL, "/") + path
|
||||
}
|
||||
origin, err := xunhupayGatewayOrigin(config)
|
||||
if err != nil {
|
||||
return defaultXunHuPayGateway + path
|
||||
}
|
||||
return origin + path
|
||||
}
|
||||
|
||||
type xunhupayEnvelope struct {
|
||||
ErrCode xunhupayCode `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
type xunhupayCode int
|
||||
|
||||
func (code *xunhupayCode) UnmarshalJSON(raw []byte) error {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
*code = 0
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '"' {
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
*code = 0
|
||||
return nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*code = xunhupayCode(parsed)
|
||||
return nil
|
||||
}
|
||||
var value int
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
*code = xunhupayCode(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type xunhupayPayResponse struct {
|
||||
xunhupayEnvelope
|
||||
URLQrcode string `json:"url_qrcode"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type xunhupayQueryResponse struct {
|
||||
xunhupayEnvelope
|
||||
Data xunhupayOrderData `json:"data"`
|
||||
}
|
||||
|
||||
type xunhupayOrderData struct {
|
||||
Status string `json:"status"`
|
||||
OpenOrderID string `json:"open_order_id"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
TotalAmount string `json:"total_amount"`
|
||||
TotalFee string `json:"total_fee"`
|
||||
OutTradeOrder string `json:"out_trade_order"`
|
||||
PaidDate string `json:"paid_date"`
|
||||
}
|
||||
|
||||
func xunhupayResult(merchantOrderNo string, order xunhupayOrderData) Result {
|
||||
amount, _ := parseYuanToFen(firstNonEmpty(order.TotalFee, order.TotalAmount))
|
||||
status := strings.TrimSpace(order.Status)
|
||||
return Result{
|
||||
MerchantOrderNo: firstNonEmpty(order.OutTradeOrder, merchantOrderNo),
|
||||
ProviderTradeNo: firstNonEmpty(order.TransactionID, order.OpenOrderID),
|
||||
ProviderStatus: status,
|
||||
AmountFen: amount,
|
||||
Currency: "CNY",
|
||||
Paid: status == "OD",
|
||||
Closed: status == "CD",
|
||||
PaidAt: parseXunHuPayTime(order.PaidDate),
|
||||
}
|
||||
}
|
||||
|
||||
func xunhupayGatewayOrigin(config Config) (string, error) {
|
||||
raw := strings.TrimSpace(config["gateway"])
|
||||
if raw == "" {
|
||||
raw = defaultXunHuPayGateway
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || (parsed.Port() != "" && parsed.Port() != "443") {
|
||||
return "", errors.New("虎皮椒网关必须是官方 HTTPS 地址")
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if _, ok := xunhupayGatewayHosts[host]; !ok {
|
||||
return "", errors.New("虎皮椒网关仅支持官方生产或备用域名")
|
||||
}
|
||||
if parsed.Path != "" && parsed.Path != "/" && !strings.HasPrefix(parsed.Path, "/payment/") {
|
||||
return "", errors.New("虎皮椒网关路径无效")
|
||||
}
|
||||
return "https://" + parsed.Hostname(), nil
|
||||
}
|
||||
|
||||
func xunhupayCheckoutMode(config Config) string {
|
||||
mode := strings.TrimSpace(config["checkoutMode"])
|
||||
if mode == "" {
|
||||
return "qr_code"
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
func xunhupayTitle(value string) string {
|
||||
title := strings.ReplaceAll(truncateUTF8(value, 120), "%", "")
|
||||
if title == "" {
|
||||
return "积分充值"
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func xunhupayHash(params map[string]string, secret string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for key, value := range params {
|
||||
if key == "hash" || value == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+params[key])
|
||||
}
|
||||
sum := md5.Sum([]byte(strings.Join(parts, "&") + secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func xunhupayHashEqual(params map[string]string, secret, actual string) bool {
|
||||
expected := xunhupayHash(params, secret)
|
||||
return actual != "" && strings.EqualFold(expected, strings.TrimSpace(actual))
|
||||
}
|
||||
|
||||
func parseXunHuPayNotification(rawBody []byte) (map[string]string, error) {
|
||||
trimmed := bytes.TrimSpace(rawBody)
|
||||
if len(trimmed) == 0 {
|
||||
return nil, errors.New("虎皮椒异步通知为空")
|
||||
}
|
||||
if trimmed[0] == '{' {
|
||||
values, err := jsonObjectStrings(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析虎皮椒异步通知:%w", err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
form, err := url.ParseQuery(string(trimmed))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析虎皮椒异步通知:%w", err)
|
||||
}
|
||||
values := make(map[string]string, len(form))
|
||||
for key, items := range form {
|
||||
if len(items) > 0 {
|
||||
values[key] = items[0]
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func jsonObjectStrings(raw []byte) (map[string]string, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var object map[string]any
|
||||
if err := decoder.Decode(&object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make(map[string]string, len(object))
|
||||
for key, value := range object {
|
||||
if key == "" || value == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
values[key] = typed
|
||||
case json.Number:
|
||||
values[key] = typed.String()
|
||||
case bool:
|
||||
values[key] = strconv.FormatBool(typed)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func parseXunHuPayTime(value string) time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if unix, err := strconv.ParseInt(value, 10, 64); err == nil && unix > 0 {
|
||||
return time.Unix(unix, 0)
|
||||
}
|
||||
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.FixedZone("CST", 8*60*60)); err == nil {
|
||||
return parsed
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return parsed
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func isXunHuPayOrderMissing(message string) bool {
|
||||
lower := strings.ToLower(message)
|
||||
return strings.Contains(lower, "not exist") || strings.Contains(lower, "not found") || strings.Contains(message, "不存在")
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func randomNonce() string {
|
||||
var raw [16]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
}
|
||||
return hex.EncodeToString(raw[:])
|
||||
}
|
||||
168
backend/payment-plugins/xunhupay_test.go
Normal file
168
backend/payment-plugins/xunhupay_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestXunHuPayCreateOrderSignsJSONAndReturnsQRCode(t *testing.T) {
|
||||
fixedNow := time.Date(2026, 9, 17, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
config := testXunHuPayConfig()
|
||||
var captured map[string]string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != "/payment/do.html" {
|
||||
t.Fatalf("request = %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
if !strings.Contains(request.Header.Get("Content-Type"), "application/json") {
|
||||
t.Fatalf("content-type = %q", request.Header.Get("Content-Type"))
|
||||
}
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &captured); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if captured["trade_order_id"] != "order-1" || captured["total_fee"] != "1.00" || captured["version"] != "1.1" {
|
||||
t.Fatalf("payload = %#v", captured)
|
||||
}
|
||||
if captured["hash"] != xunhupayHash(captured, config["appSecret"]) {
|
||||
t.Fatalf("hash = %q", captured["hash"])
|
||||
}
|
||||
response := map[string]string{
|
||||
"errcode": "0", "errmsg": "success!", "url_qrcode": "https://api.xunhupay.com/qrcode.png",
|
||||
"url": "https://api.xunhupay.com/pay/jump",
|
||||
}
|
||||
response["hash"] = xunhupayHash(response, config["appSecret"])
|
||||
_ = json.NewEncoder(writer).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewXunHuPayProvider(server.Client())
|
||||
provider.baseURL = server.URL
|
||||
provider.now = func() time.Time { return fixedNow }
|
||||
provider.nonce = func() string { return "fixed-nonce" }
|
||||
checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{
|
||||
MerchantOrderNo: "order-1", Description: "100 积分", AmountFen: 100, Currency: "CNY",
|
||||
ExpiresAt: fixedNow.Add(30 * time.Minute), NotifyURL: "https://merchant.example/api/payments/notify/xunhupay-aggregate/cfg",
|
||||
ReturnURL: "https://merchant.example/api/payments/return/xunhupay-aggregate?orderId=1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if checkout.Mode != "qr_code" || checkout.Value != "https://api.xunhupay.com/qrcode.png" {
|
||||
t.Fatalf("checkout = %#v", checkout)
|
||||
}
|
||||
if !checkout.ExpiresAt.Equal(fixedNow.Add(5 * time.Minute)) {
|
||||
t.Fatalf("expiresAt = %s", checkout.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXunHuPayCreateOrderCanReturnRedirectCheckout(t *testing.T) {
|
||||
config := testXunHuPayConfig()
|
||||
config["checkoutMode"] = "redirect"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
response := map[string]string{"errcode": "0", "errmsg": "success!", "url": "https://api.xunhupay.com/pay/jump"}
|
||||
response["hash"] = xunhupayHash(response, config["appSecret"])
|
||||
_ = json.NewEncoder(writer).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
provider := NewXunHuPayProvider(server.Client())
|
||||
provider.baseURL = server.URL
|
||||
provider.nonce = func() string { return "fixed-nonce" }
|
||||
checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{
|
||||
MerchantOrderNo: "order-2", Description: "充值", AmountFen: 200, Currency: "CNY",
|
||||
ExpiresAt: time.Now().Add(20 * time.Minute), NotifyURL: "https://merchant.example/notify",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if checkout.Mode != "redirect" || checkout.Value != "https://api.xunhupay.com/pay/jump" {
|
||||
t.Fatalf("checkout = %#v", checkout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXunHuPayQueryAndCloseMapOfficialStatuses(t *testing.T) {
|
||||
config := testXunHuPayConfig()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/payment/query.html" {
|
||||
t.Fatalf("path = %s", request.URL.Path)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["out_trade_order"] != "order-3" || payload["hash"] != xunhupayHash(payload, config["appSecret"]) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
response := map[string]any{
|
||||
"errcode": 0, "errmsg": "success!",
|
||||
"data": map[string]string{"status": "WP", "out_trade_order": "order-3", "open_order_id": "open-3", "total_fee": "1.50"},
|
||||
}
|
||||
_ = json.NewEncoder(writer).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
provider := NewXunHuPayProvider(server.Client())
|
||||
provider.baseURL = server.URL
|
||||
provider.nonce = func() string { return "fixed-nonce" }
|
||||
result, err := provider.QueryOrder(context.Background(), config, QueryRequest{MerchantOrderNo: "order-3"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Paid || result.Closed || result.AmountFen != 150 || result.ProviderStatus != "WP" {
|
||||
t.Fatalf("query = %#v", result)
|
||||
}
|
||||
closed, err := provider.CloseOrder(context.Background(), config, CloseRequest{MerchantOrderNo: "order-3"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !closed.Closed || closed.Paid || closed.ProviderStatus != "CD" {
|
||||
t.Fatalf("close = %#v", closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXunHuPayNotificationVerifiesFormHash(t *testing.T) {
|
||||
config := testXunHuPayConfig()
|
||||
values := map[string]string{
|
||||
"trade_order_id": "order-4", "total_fee": "2.00", "transaction_id": "tx-4",
|
||||
"open_order_id": "open-4", "status": "OD", "appid": config["appId"], "time": "1788321600", "nonce_str": "cb-nonce",
|
||||
}
|
||||
values["hash"] = xunhupayHash(values, config["appSecret"])
|
||||
body := "trade_order_id=order-4&total_fee=2.00&transaction_id=tx-4&open_order_id=open-4&status=OD&appid=" + config["appId"] + "&time=1788321600&nonce_str=cb-nonce&hash=" + values["hash"]
|
||||
notification, err := NewXunHuPayProvider(http.DefaultClient).VerifyNotification(context.Background(), config, nil, []byte(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !notification.Paid || notification.AmountFen != 200 || notification.MerchantOrderNo != "order-4" || notification.ProviderTradeNo != "tx-4" {
|
||||
t.Fatalf("notification = %#v", notification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXunHuPayRejectsUnknownGatewayAndMissingBills(t *testing.T) {
|
||||
provider := NewXunHuPayProvider(http.DefaultClient)
|
||||
config := testXunHuPayConfig()
|
||||
config["gateway"] = "https://evil.example/payment/do.html"
|
||||
if err := provider.ValidateConfig(config); err == nil {
|
||||
t.Fatal("expected untrusted gateway to fail")
|
||||
}
|
||||
_, err := provider.DownloadTradeBill(context.Background(), testXunHuPayConfig(), time.Now())
|
||||
if !errors.Is(err, ErrTradeBillNotFound) {
|
||||
t.Fatalf("bill error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testXunHuPayConfig() Config {
|
||||
return Config{
|
||||
"publicBaseUrl": "https://merchant.example",
|
||||
"appId": "20146122002",
|
||||
"appSecret": "test-app-secret",
|
||||
"gateway": defaultXunHuPayGateway,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user