mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 15:43:13 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b92038eaf | ||
|
|
52b3ca9609 |
@@ -103,7 +103,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
|
||||
headers[strings.ToLower(k)] = c.GetHeader(k)
|
||||
}
|
||||
|
||||
resolvedProviderKey, notification, err := verifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers)
|
||||
verifiedProvider, notification, err := verifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers)
|
||||
if err != nil {
|
||||
truncatedBody := rawBody
|
||||
if len(truncatedBody) > webhookLogTruncateLen {
|
||||
@@ -115,12 +115,20 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
|
||||
return
|
||||
}
|
||||
|
||||
resolvedProviderKey := verifiedProvider.ProviderKey()
|
||||
|
||||
// nil notification means irrelevant event (e.g. Stripe non-payment event); return success.
|
||||
if notification == nil {
|
||||
writeSuccessResponse(c, resolvedProviderKey)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.paymentService.ConfirmNotificationUpstream(c.Request.Context(), verifiedProvider, notification); err != nil {
|
||||
slog.Error("[Payment Webhook] upstream confirmation failed", "provider", resolvedProviderKey, "outTradeNo", notification.OrderID, "tradeNo", notification.TradeNo, "error", err)
|
||||
c.String(http.StatusBadGateway, "confirm failed")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.paymentService.HandlePaymentNotification(c.Request.Context(), notification, resolvedProviderKey); err != nil {
|
||||
// Unknown order: ack with 2xx so the provider stops retrying. This
|
||||
// guards against foreign environments whose webhook endpoints are
|
||||
@@ -170,7 +178,10 @@ func extractOutTradeNo(rawBody, providerKey string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func verifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (string, *payment.PaymentNotification, error) {
|
||||
// verifyNotificationWithProviders returns the provider instance that verified
|
||||
// the notification so later steps (upstream confirmation) use the same
|
||||
// merchant configuration.
|
||||
func verifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (payment.Provider, *payment.PaymentNotification, error) {
|
||||
var lastErr error
|
||||
for _, provider := range providers {
|
||||
if provider == nil {
|
||||
@@ -181,12 +192,12 @@ func verifyNotificationWithProviders(ctx context.Context, providers []payment.Pr
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return provider.ProviderKey(), notification, nil
|
||||
return provider, notification, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", nil, lastErr
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
return "", nil, fmt.Errorf("no webhook provider could verify notification")
|
||||
return nil, nil, fmt.Errorf("no webhook provider could verify notification")
|
||||
}
|
||||
|
||||
// wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook.
|
||||
|
||||
@@ -204,9 +204,9 @@ func TestVerifyNotificationWithProvidersReturnsMatchedProvider(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
providerKey, notification, err := verifyNotificationWithProviders(context.Background(), providers, "{}", map[string]string{"wechatpay-signature": "sig"})
|
||||
provider, notification, err := verifyNotificationWithProviders(context.Background(), providers, "{}", map[string]string{"wechatpay-signature": "sig"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, payment.TypeWxpay, providerKey)
|
||||
require.Equal(t, payment.TypeWxpay, provider.ProviderKey())
|
||||
require.NotNil(t, notification)
|
||||
require.Equal(t, "sub2_42", notification.OrderID)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
@@ -27,6 +29,7 @@ const (
|
||||
easypayHTTPTimeout = 10 * time.Second
|
||||
maxEasypayResponseSize = 1 << 20 // 1MB
|
||||
maxEasypayErrorSummary = 512
|
||||
easypayAmountTolerance = 0.005
|
||||
tradeStatusSuccess = "TRADE_SUCCESS"
|
||||
signTypeMD5 = "MD5"
|
||||
paymentModePopup = "popup"
|
||||
@@ -301,32 +304,86 @@ func (e *EasyPay) upstreamPaymentType(paymentType string) string {
|
||||
}
|
||||
|
||||
func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
|
||||
result, err := e.queryOrder(ctx, tradeNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responseTradeNo := tradeNo
|
||||
if result.tradeNo != "" {
|
||||
responseTradeNo = result.tradeNo
|
||||
}
|
||||
amount, _ := strconv.ParseFloat(result.money, 64)
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: responseTradeNo,
|
||||
Status: result.status,
|
||||
Amount: amount,
|
||||
Metadata: e.MerchantIdentityMetadata(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// easyPayQueryResult is the normalized api.php?act=order answer. tradeNo is
|
||||
// the upstream trade number exactly as returned (empty when absent).
|
||||
type easyPayQueryResult struct {
|
||||
status string
|
||||
money string
|
||||
tradeNo string
|
||||
}
|
||||
|
||||
func (e *EasyPay) queryOrder(ctx context.Context, outTradeNo string) (*easyPayQueryResult, error) {
|
||||
params := map[string]string{
|
||||
"act": "order", "pid": e.config["pid"],
|
||||
"key": e.config["pkey"], "out_trade_no": tradeNo,
|
||||
"key": e.config["pkey"], "out_trade_no": outTradeNo,
|
||||
}
|
||||
body, err := e.post(ctx, e.apiBase()+"/api.php", params)
|
||||
// 彩虹易支付 and ZPay read act=order parameters from $_GET only (a
|
||||
// body-only request is answered with "No Act!"), while other forks read
|
||||
// the form body; send them in both places.
|
||||
query := url.Values{}
|
||||
for k, v := range params {
|
||||
query.Set(k, v)
|
||||
}
|
||||
body, err := e.post(ctx, e.apiBase()+"/api.php?"+query.Encode(), params)
|
||||
if err != nil {
|
||||
// The request URL carries the merchant key; never let it reach logs
|
||||
// or audit records through *url.Error.
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
err = urlErr.Err
|
||||
}
|
||||
return nil, fmt.Errorf("easypay query: %w", err)
|
||||
}
|
||||
// Scalars are decoded leniently: PHP deployments commonly serialize
|
||||
// integer columns as strings ("status":"1").
|
||||
type easyPayQueryData struct {
|
||||
TradeStatus *string `json:"trade_status"`
|
||||
Status *int `json:"status"`
|
||||
Money *string `json:"money"`
|
||||
TradeNo *string `json:"trade_no"`
|
||||
TradeStatus *string `json:"trade_status"`
|
||||
Status json.RawMessage `json:"status"`
|
||||
Money json.RawMessage `json:"money"`
|
||||
TradeNo json.RawMessage `json:"trade_no"`
|
||||
OutTradeNo json.RawMessage `json:"out_trade_no"`
|
||||
}
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TradeStatus *string `json:"trade_status"`
|
||||
Status *int `json:"status"`
|
||||
Money *string `json:"money"`
|
||||
TradeNo *string `json:"trade_no"`
|
||||
Data easyPayQueryData `json:"data"`
|
||||
easyPayQueryData
|
||||
Code any `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data easyPayQueryData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("easypay parse query: %w", err)
|
||||
}
|
||||
// An explicit failure code wins over any paid-looking status field that
|
||||
// may accompany it.
|
||||
if resp.Code != nil && !easyPayResponseCodeIsSuccess(resp.Code) {
|
||||
msg := strings.TrimSpace(resp.Msg)
|
||||
if msg == "" {
|
||||
msg = summarizeEasyPayResponse(body)
|
||||
}
|
||||
return nil, fmt.Errorf("easypay query failed: %s", msg)
|
||||
}
|
||||
// When the upstream echoes the order identity it must be this order; some
|
||||
// implementations silently ignore unknown lookup parameters.
|
||||
if echoed := easyPayJSONScalar(pickEasyPayJSON(resp.OutTradeNo, resp.Data.OutTradeNo)); echoed != "" && echoed != strings.TrimSpace(outTradeNo) {
|
||||
return nil, fmt.Errorf("easypay query out_trade_no mismatch: expected %s, got %s", strings.TrimSpace(outTradeNo), echoed)
|
||||
}
|
||||
|
||||
status := payment.ProviderStatusPending
|
||||
if resp.TradeStatus != nil {
|
||||
if *resp.TradeStatus == tradeStatusSuccess {
|
||||
@@ -336,38 +393,74 @@ func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Quer
|
||||
if *resp.Data.TradeStatus == tradeStatusSuccess {
|
||||
status = payment.ProviderStatusPaid
|
||||
}
|
||||
} else if resp.Status != nil {
|
||||
if *resp.Status == easypayStatusPaid {
|
||||
} else if raw := pickEasyPayJSON(resp.Status, resp.Data.Status); raw != nil {
|
||||
if easyPayJSONScalar(raw) == strconv.Itoa(easypayStatusPaid) {
|
||||
status = payment.ProviderStatusPaid
|
||||
}
|
||||
} else if resp.Data.Status != nil && *resp.Data.Status == easypayStatusPaid {
|
||||
status = payment.ProviderStatusPaid
|
||||
}
|
||||
|
||||
money := ""
|
||||
if resp.Money != nil {
|
||||
money = *resp.Money
|
||||
} else if resp.Data.Money != nil {
|
||||
money = *resp.Data.Money
|
||||
}
|
||||
responseTradeNo := tradeNo
|
||||
if resp.TradeNo != nil {
|
||||
if *resp.TradeNo != "" {
|
||||
responseTradeNo = *resp.TradeNo
|
||||
}
|
||||
} else if resp.Data.TradeNo != nil && *resp.Data.TradeNo != "" {
|
||||
responseTradeNo = *resp.Data.TradeNo
|
||||
}
|
||||
|
||||
amount, _ := strconv.ParseFloat(money, 64)
|
||||
return &payment.QueryOrderResponse{
|
||||
TradeNo: responseTradeNo,
|
||||
Status: status,
|
||||
Amount: amount,
|
||||
Metadata: e.MerchantIdentityMetadata(),
|
||||
return &easyPayQueryResult{
|
||||
status: status,
|
||||
money: easyPayJSONScalar(pickEasyPayJSON(resp.Money, resp.Data.Money)),
|
||||
tradeNo: easyPayJSONScalar(pickEasyPayJSON(resp.TradeNo, resp.Data.TradeNo)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ConfirmNotification checks a verified success notification against the
|
||||
// upstream order before it is fulfilled, so crediting follows the gateway's
|
||||
// own order state (paid, amount, trade_no). The query is sent to this
|
||||
// instance's own apiBase with its own credentials.
|
||||
func (e *EasyPay) ConfirmNotification(ctx context.Context, n *payment.PaymentNotification) error {
|
||||
if n == nil || n.Status != payment.NotificationStatusSuccess {
|
||||
return nil
|
||||
}
|
||||
result, err := e.queryOrder(ctx, n.OrderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("easypay confirm: %w", err)
|
||||
}
|
||||
if result.status != payment.ProviderStatusPaid {
|
||||
return fmt.Errorf("easypay confirm: upstream reports order %s as not paid", n.OrderID)
|
||||
}
|
||||
queriedAmount, err := strconv.ParseFloat(result.money, 64)
|
||||
if err != nil || math.Abs(queriedAmount-n.Amount) > easypayAmountTolerance {
|
||||
return fmt.Errorf("easypay confirm: amount mismatch: notified %v, upstream %q", n.Amount, result.money)
|
||||
}
|
||||
if result.tradeNo != "" {
|
||||
if n.TradeNo != "" && n.TradeNo != result.tradeNo {
|
||||
return fmt.Errorf("easypay confirm: trade_no mismatch: notified %s, upstream %s", n.TradeNo, result.tradeNo)
|
||||
}
|
||||
n.TradeNo = result.tradeNo
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func easyPayJSONScalar(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var str string
|
||||
if err := json.Unmarshal(raw, &str); err == nil {
|
||||
return strings.TrimSpace(str)
|
||||
}
|
||||
var num json.Number
|
||||
if err := json.Unmarshal(raw, &num); err == nil {
|
||||
return num.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pickEasyPayJSON returns the top-level field when present, otherwise the
|
||||
// nested data field. JSON null counts as absent.
|
||||
func pickEasyPayJSON(top, nested json.RawMessage) json.RawMessage {
|
||||
if len(top) > 0 && string(top) != "null" {
|
||||
return top
|
||||
}
|
||||
if len(nested) > 0 && string(nested) != "null" {
|
||||
return nested
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
|
||||
values, err := url.ParseQuery(rawBody)
|
||||
if err != nil {
|
||||
@@ -379,7 +472,14 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st
|
||||
if !easyPayNotifyAllowedParams[k] {
|
||||
return nil, fmt.Errorf("unexpected notify param: %s", k)
|
||||
}
|
||||
params[k] = values.Get(k)
|
||||
v := values.Get(k)
|
||||
// Notify values are plain tokens; only the merchant-defined product
|
||||
// name is free text. Delimiter characters elsewhere are rejected so
|
||||
// each value maps to exactly one field of the signed string.
|
||||
if k != "name" && strings.ContainsAny(v, "&=") {
|
||||
return nil, fmt.Errorf("invalid notify param value: %s", k)
|
||||
}
|
||||
params[k] = v
|
||||
}
|
||||
sign := params["sign"]
|
||||
if sign == "" {
|
||||
@@ -388,22 +488,20 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st
|
||||
if !easyPayVerifySign(params, e.config["pkey"], sign) {
|
||||
return nil, fmt.Errorf("invalid signature")
|
||||
}
|
||||
// Bind the notification to this instance's merchant directly instead of
|
||||
// relying on the order snapshot, which legacy orders do not carry.
|
||||
if expected, actual := strings.TrimSpace(e.config["pid"]), strings.TrimSpace(params["pid"]); actual != expected {
|
||||
return nil, fmt.Errorf("easypay pid mismatch: expected %s, got %s", expected, actual)
|
||||
}
|
||||
status := payment.ProviderStatusFailed
|
||||
if params["trade_status"] == tradeStatusSuccess {
|
||||
status = payment.ProviderStatusSuccess
|
||||
}
|
||||
amount, _ := strconv.ParseFloat(params["money"], 64)
|
||||
|
||||
metadata := e.MerchantIdentityMetadata()
|
||||
if pid := strings.TrimSpace(params["pid"]); pid != "" {
|
||||
if metadata == nil {
|
||||
metadata = map[string]string{}
|
||||
}
|
||||
metadata["pid"] = pid
|
||||
}
|
||||
return &payment.PaymentNotification{
|
||||
TradeNo: params["trade_no"], OrderID: params["out_trade_no"],
|
||||
Amount: amount, Status: status, RawData: rawBody, Metadata: metadata,
|
||||
Amount: amount, Status: status, RawData: rawBody, Metadata: e.MerchantIdentityMetadata(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
)
|
||||
|
||||
func newConfirmTestEasyPay(t *testing.T, body string, status int) (*EasyPay, *atomic.Int32) {
|
||||
t.Helper()
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("ParseForm: %v", err)
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if r.URL.Path != "/api.php" || q.Get("act") != "order" || q.Get("pid") != "1001" || q.Get("key") != "pkey-1" || q.Get("out_trade_no") != "sub2_ORDER1" {
|
||||
t.Errorf("unexpected query request: path=%s query=%v", r.URL.Path, q)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
provider, err := NewEasyPay("test-instance", map[string]string{
|
||||
"pid": "1001",
|
||||
"pkey": "pkey-1",
|
||||
"apiBase": server.URL,
|
||||
"notifyUrl": "https://example.com/notify",
|
||||
"returnUrl": "https://example.com/return",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEasyPay: %v", err)
|
||||
}
|
||||
return provider, &calls
|
||||
}
|
||||
|
||||
func confirmTestNotification() *payment.PaymentNotification {
|
||||
return &payment.PaymentNotification{
|
||||
TradeNo: "2026101600001",
|
||||
OrderID: "sub2_ORDER1",
|
||||
Amount: 10,
|
||||
Status: payment.NotificationStatusSuccess,
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayConfirmNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
notifyTrade string
|
||||
wantErr string
|
||||
wantTradeNo string
|
||||
}{
|
||||
{
|
||||
name: "paid with matching amount and trade_no",
|
||||
body: `{"code":1,"pid":1001,"out_trade_no":"sub2_ORDER1","trade_no":"2026101600001","money":"10.00","status":1}`,
|
||||
notifyTrade: "2026101600001",
|
||||
wantTradeNo: "2026101600001",
|
||||
},
|
||||
{
|
||||
name: "missing notify trade_no is filled from upstream",
|
||||
body: `{"code":1,"trade_no":"2026101600001","money":"10.00","trade_status":"TRADE_SUCCESS"}`,
|
||||
wantTradeNo: "2026101600001",
|
||||
},
|
||||
{
|
||||
name: "upstream not paid",
|
||||
body: `{"code":1,"trade_no":"2026101600001","money":"10.00","status":0}`,
|
||||
notifyTrade: "2026101600001",
|
||||
wantErr: "not paid",
|
||||
},
|
||||
{
|
||||
name: "upstream failure code with paid status",
|
||||
body: `{"code":-1,"msg":"订单号不存在","status":1,"money":"10.00"}`,
|
||||
notifyTrade: "2026101600001",
|
||||
wantErr: "订单号不存在",
|
||||
},
|
||||
{
|
||||
name: "amount mismatch",
|
||||
body: `{"code":1,"trade_no":"2026101600001","money":"0.01","status":1}`,
|
||||
notifyTrade: "2026101600001",
|
||||
wantErr: "amount mismatch",
|
||||
},
|
||||
{
|
||||
name: "missing upstream amount",
|
||||
body: `{"code":1,"trade_no":"2026101600001","status":1}`,
|
||||
notifyTrade: "2026101600001",
|
||||
wantErr: "amount mismatch",
|
||||
},
|
||||
{
|
||||
name: "trade_no conflict",
|
||||
body: `{"code":1,"trade_no":"2026101699999","money":"10.00","status":1}`,
|
||||
notifyTrade: "2026101600001",
|
||||
wantErr: "trade_no mismatch",
|
||||
},
|
||||
{
|
||||
name: "string status from PHP deployment",
|
||||
body: `{"code":"1","pid":"1001","out_trade_no":"sub2_ORDER1","trade_no":"2026101600001","money":"10.00","status":"1"}`,
|
||||
notifyTrade: "2026101600001",
|
||||
wantTradeNo: "2026101600001",
|
||||
},
|
||||
{
|
||||
name: "foreign order echo",
|
||||
body: `{"code":1,"out_trade_no":"sub2_OTHER","trade_no":"2026101600001","money":"10.00","status":1}`,
|
||||
notifyTrade: "2026101600001",
|
||||
wantErr: "out_trade_no mismatch",
|
||||
},
|
||||
{
|
||||
name: "upstream HTTP error body",
|
||||
body: `<html>502 Bad Gateway</html>`,
|
||||
status: http.StatusBadGateway,
|
||||
notifyTrade: "2026101600001",
|
||||
wantErr: "parse query",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
status := tt.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
provider, calls := newConfirmTestEasyPay(t, tt.body, status)
|
||||
n := confirmTestNotification()
|
||||
n.TradeNo = tt.notifyTrade
|
||||
err := provider.ConfirmNotification(context.Background(), n)
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("upstream query calls = %d, want 1", calls.Load())
|
||||
}
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("ConfirmNotification error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ConfirmNotification returned error: %v", err)
|
||||
}
|
||||
if n.TradeNo != tt.wantTradeNo {
|
||||
t.Fatalf("trade_no = %q, want %q", n.TradeNo, tt.wantTradeNo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayConfirmNotificationSkipsNonSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider, calls := newConfirmTestEasyPay(t, `{}`, http.StatusOK)
|
||||
n := confirmTestNotification()
|
||||
n.Status = payment.ProviderStatusFailed
|
||||
if err := provider.ConfirmNotification(context.Background(), n); err != nil {
|
||||
t.Fatalf("ConfirmNotification returned error: %v", err)
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatalf("upstream query calls = %d, want 0", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayConfirmNotificationQueryTransportError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
apiBase := server.URL
|
||||
server.Close()
|
||||
provider := newTestEasyPay(t, apiBase)
|
||||
err := provider.ConfirmNotification(context.Background(), confirmTestNotification())
|
||||
if err == nil {
|
||||
t.Fatal("ConfirmNotification must fail when the upstream is unreachable")
|
||||
}
|
||||
// The query URL carries the merchant key; errors are logged and stored in
|
||||
// the payment audit trail, so they must not echo the URL.
|
||||
if strings.Contains(err.Error(), "pkey-1") || strings.Contains(err.Error(), "key=") {
|
||||
t.Fatalf("error contains the merchant key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayNotifyRejectsForeignPid(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := easyPayPoCProvider()
|
||||
|
||||
for _, pid := range []string{"2000", ""} {
|
||||
params := map[string]string{
|
||||
"trade_no": "T1",
|
||||
"out_trade_no": "ORDER123",
|
||||
"type": "alipay",
|
||||
"name": "balance recharge",
|
||||
"money": "650.00",
|
||||
"trade_status": tradeStatusSuccess,
|
||||
}
|
||||
if pid != "" {
|
||||
params["pid"] = pid
|
||||
}
|
||||
params["sign"] = easyPaySign(params, e.config["pkey"])
|
||||
q := url.Values{}
|
||||
for k, v := range params {
|
||||
q.Set(k, v)
|
||||
}
|
||||
if _, err := e.VerifyNotification(context.Background(), q.Encode(), nil); err == nil {
|
||||
t.Fatalf("callback with pid %q must be rejected", pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyPayNotifyAllowsFreeTextNameOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := easyPayPoCProvider()
|
||||
|
||||
base := map[string]string{
|
||||
"pid": "1000",
|
||||
"trade_no": "T1",
|
||||
"out_trade_no": "ORDER123",
|
||||
"type": "alipay",
|
||||
"name": "Plan A&B = Pro",
|
||||
"money": "650.00",
|
||||
"trade_status": tradeStatusSuccess,
|
||||
}
|
||||
encode := func(params map[string]string) string {
|
||||
signed := map[string]string{}
|
||||
for k, v := range params {
|
||||
signed[k] = v
|
||||
}
|
||||
signed["sign"] = easyPaySign(params, e.config["pkey"])
|
||||
q := url.Values{}
|
||||
for k, v := range signed {
|
||||
q.Set(k, v)
|
||||
}
|
||||
return q.Encode()
|
||||
}
|
||||
if _, err := e.VerifyNotification(context.Background(), encode(base), nil); err != nil {
|
||||
t.Fatalf("free-text name must be accepted: %v", err)
|
||||
}
|
||||
for _, field := range []string{"trade_no", "out_trade_no", "type", "money", "param"} {
|
||||
params := map[string]string{}
|
||||
for k, v := range base {
|
||||
params[k] = v
|
||||
}
|
||||
params[field] = "v&k=x"
|
||||
if _, err := e.VerifyNotification(context.Background(), encode(params), nil); err == nil {
|
||||
t.Fatalf("value with &/= in %s must be rejected", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) {
|
||||
wantStatus string
|
||||
wantTradeNo string
|
||||
wantAmount float64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "top level trade success is paid",
|
||||
@@ -64,10 +65,64 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) {
|
||||
wantAmount: 3.21,
|
||||
},
|
||||
{
|
||||
name: "query failure with missing status is pending",
|
||||
body: `{"code":0,"msg":"订单不存在"}`,
|
||||
name: "query failure code is an error",
|
||||
body: `{"code":0,"msg":"订单不存在"}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "explicit failure code wins over paid status fields",
|
||||
body: `{"code":-1,"msg":"签名错误","status":1,"trade_status":"TRADE_SUCCESS","money":"12.34"}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "string failure code is an error",
|
||||
body: `{"code":"-1","msg":"error","status":1}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "foreign order echo is rejected",
|
||||
body: `{"code":1,"pid":"pid-1","out_trade_no":"order-999","trade_status":"TRADE_SUCCESS","money":"12.34"}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "foreign order echo in nested data is rejected",
|
||||
body: `{"code":1,"data":{"out_trade_no":"order-999","trade_status":"TRADE_SUCCESS","money":"12.34"}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "string numeric status from PHP deployments is paid",
|
||||
body: `{"code":"1","msg":"查询订单号成功!","trade_no":"2026101600001","out_trade_no":"order-123","pid":"1001","money":"12.34","status":"1"}`,
|
||||
wantStatus: payment.ProviderStatusPaid,
|
||||
wantTradeNo: "2026101600001",
|
||||
wantAmount: 12.34,
|
||||
},
|
||||
{
|
||||
name: "string numeric unpaid status is pending",
|
||||
body: `{"code":1,"trade_no":"2026101600001","money":"12.34","status":"0"}`,
|
||||
wantStatus: payment.ProviderStatusPending,
|
||||
wantTradeNo: orderID,
|
||||
wantTradeNo: "2026101600001",
|
||||
wantAmount: 12.34,
|
||||
},
|
||||
{
|
||||
name: "documented caihong response with integer pid and status is paid",
|
||||
body: `{"code":1,"msg":"查询订单号成功!","trade_no":"2016080622555342651","out_trade_no":"order-123","api_trade_no":"4200001","type":"alipay","pid":1001,"addtime":"2016-08-06 22:55:52","endtime":"2016-08-06 22:55:52","name":"VIP会员","money":"1.00","status":1,"param":"","buyer":""}`,
|
||||
wantStatus: payment.ProviderStatusPaid,
|
||||
wantTradeNo: "2016080622555342651",
|
||||
wantAmount: 1,
|
||||
},
|
||||
{
|
||||
name: "null top-level fields fall back to nested data",
|
||||
body: `{"code":1,"status":null,"money":null,"trade_no":null,"data":{"status":1,"money":"5.00","trade_no":"data-789"}}`,
|
||||
wantStatus: payment.ProviderStatusPaid,
|
||||
wantTradeNo: "data-789",
|
||||
wantAmount: 5,
|
||||
},
|
||||
{
|
||||
name: "matching identity echo with numeric fields is paid",
|
||||
body: `{"code":"1","pid":"pid-1","out_trade_no":"order-123","trade_status":"TRADE_SUCCESS","money":12.34,"trade_no":2026101600001}`,
|
||||
wantStatus: payment.ProviderStatusPaid,
|
||||
wantTradeNo: "2026101600001",
|
||||
wantAmount: 12.34,
|
||||
},
|
||||
{
|
||||
name: "missing fields are pending",
|
||||
@@ -93,9 +148,16 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("ParseForm: %v", err)
|
||||
}
|
||||
gotForm = make(url.Values, len(r.PostForm))
|
||||
// Parameters must be in the URL query (彩虹易支付 reads $_GET)
|
||||
// and mirrored in the body for forks that read $_POST.
|
||||
gotForm = r.URL.Query()
|
||||
for key, values := range r.PostForm {
|
||||
gotForm[key] = append([]string(nil), values...)
|
||||
if got := gotForm.Get(key); got != values[0] {
|
||||
t.Errorf("body[%s] = %q, query has %q", key, values[0], got)
|
||||
}
|
||||
}
|
||||
if len(r.PostForm) != len(gotForm) {
|
||||
t.Errorf("body params %v do not mirror query params %v", r.PostForm, gotForm)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
@@ -104,6 +166,12 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) {
|
||||
|
||||
provider := newTestEasyPay(t, server.URL)
|
||||
resp, err := provider.QueryOrder(context.Background(), orderID)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("QueryOrder succeeded, want error (response=%+v)", resp)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("QueryOrder returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -239,6 +239,16 @@ type RefundQueryProvider interface {
|
||||
QueryRefund(ctx context.Context, req RefundQueryRequest) (*RefundResponse, error)
|
||||
}
|
||||
|
||||
// NotificationConfirmer extends Provider for gateways whose verified
|
||||
// notifications are not sufficient proof of payment on their own. The webhook
|
||||
// path calls ConfirmNotification on the instance that verified the
|
||||
// notification before fulfillment; implementations query the upstream order
|
||||
// and may fill in missing notification fields (e.g. TradeNo) from it.
|
||||
type NotificationConfirmer interface {
|
||||
Provider
|
||||
ConfirmNotification(ctx context.Context, n *PaymentNotification) error
|
||||
}
|
||||
|
||||
// CancelableProvider extends Provider with the ability to cancel pending payments.
|
||||
type CancelableProvider interface {
|
||||
Provider
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
|
||||
)
|
||||
|
||||
// ErrOrderNotFound is returned by HandlePaymentNotification when the webhook
|
||||
@@ -56,6 +57,33 @@ func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payme
|
||||
return s.confirmPayment(ctx, order.ID, n.TradeNo, n.Amount, pk, n.Metadata)
|
||||
}
|
||||
|
||||
// ConfirmNotificationUpstream asks providers that implement
|
||||
// payment.NotificationConfirmer to confirm a verified success notification
|
||||
// against the upstream order, using the same instance that verified it.
|
||||
// Failures are recorded on the order's audit trail and returned so the webhook
|
||||
// answers non-success and the gateway retries.
|
||||
func (s *PaymentService) ConfirmNotificationUpstream(ctx context.Context, prov payment.Provider, n *payment.PaymentNotification) error {
|
||||
confirmer, ok := prov.(payment.NotificationConfirmer)
|
||||
if !ok || n == nil || n.Status != payment.NotificationStatusSuccess {
|
||||
return nil
|
||||
}
|
||||
notifiedTradeNo := n.TradeNo
|
||||
finishProviderCall := servertiming.ObserveDependency(ctx, "payment")
|
||||
err := confirmer.ConfirmNotification(ctx, n)
|
||||
finishProviderCall()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if order, lookupErr := s.entClient.PaymentOrder.Query().Where(paymentorder.OutTradeNo(n.OrderID)).Only(ctx); lookupErr == nil {
|
||||
s.writeAuditLog(ctx, order.ID, "PAYMENT_UPSTREAM_UNCONFIRMED", prov.ProviderKey(), map[string]any{
|
||||
"detail": err.Error(),
|
||||
"tradeNo": notifiedTradeNo,
|
||||
"amount": n.Amount,
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func parseLegacyPaymentOrderID(orderID string, lookupErr error) (int64, bool) {
|
||||
if !dbent.IsNotFound(lookupErr) {
|
||||
return 0, false
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type paymentConfirmTestProvider struct {
|
||||
paymentFulfillmentTestProvider
|
||||
err error
|
||||
calls *int
|
||||
}
|
||||
|
||||
func (p paymentConfirmTestProvider) ConfirmNotification(_ context.Context, n *payment.PaymentNotification) error {
|
||||
*p.calls++
|
||||
if p.err == nil && n.TradeNo == "" {
|
||||
n.TradeNo = "upstream-trade"
|
||||
}
|
||||
return p.err
|
||||
}
|
||||
|
||||
func TestConfirmNotificationUpstreamRecordsAuditOnFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := newPaymentConfigServiceTestClient(t)
|
||||
user, err := client.User.Create().
|
||||
SetEmail("confirm-upstream@example.com").
|
||||
SetPasswordHash("hash").
|
||||
SetUsername("confirm-upstream-user").
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
order, err := client.PaymentOrder.Create().
|
||||
SetUserID(user.ID).
|
||||
SetUserEmail(user.Email).
|
||||
SetUserName(user.Username).
|
||||
SetAmount(10).
|
||||
SetPayAmount(10).
|
||||
SetFeeRate(0).
|
||||
SetRechargeCode("PAY-CONFIRM-UPSTREAM").
|
||||
SetOutTradeNo("sub2_confirm_upstream").
|
||||
SetPaymentType(payment.TypeAlipay).
|
||||
SetPaymentTradeNo("").
|
||||
SetOrderType(payment.OrderTypeBalance).
|
||||
SetStatus(OrderStatusPending).
|
||||
SetExpiresAt(time.Now().Add(time.Hour)).
|
||||
SetClientIP("127.0.0.1").
|
||||
SetSrcHost("api.example.com").
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &PaymentService{entClient: client}
|
||||
calls := 0
|
||||
prov := paymentConfirmTestProvider{
|
||||
paymentFulfillmentTestProvider: paymentFulfillmentTestProvider{key: payment.TypeEasyPay},
|
||||
err: errors.New("easypay confirm: upstream reports order sub2_confirm_upstream as not paid"),
|
||||
calls: &calls,
|
||||
}
|
||||
n := &payment.PaymentNotification{OrderID: order.OutTradeNo, TradeNo: "T-unconfirmed", Amount: 10, Status: payment.NotificationStatusSuccess}
|
||||
|
||||
err = svc.ConfirmNotificationUpstream(ctx, prov, n)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, 1, calls)
|
||||
|
||||
count, err := client.PaymentAuditLog.Query().
|
||||
Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("PAYMENT_UPSTREAM_UNCONFIRMED")).
|
||||
Count(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
|
||||
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OrderStatusPending, reloaded.Status)
|
||||
}
|
||||
|
||||
func TestConfirmNotificationUpstreamPassThrough(t *testing.T) {
|
||||
svc := &PaymentService{}
|
||||
n := &payment.PaymentNotification{OrderID: "sub2_x", Amount: 10, Status: payment.NotificationStatusSuccess}
|
||||
|
||||
// Providers without the confirmer capability keep their existing flow.
|
||||
require.NoError(t, svc.ConfirmNotificationUpstream(context.Background(), paymentFulfillmentTestProvider{key: payment.TypeStripe}, n))
|
||||
|
||||
calls := 0
|
||||
prov := paymentConfirmTestProvider{paymentFulfillmentTestProvider: paymentFulfillmentTestProvider{key: payment.TypeEasyPay}, calls: &calls}
|
||||
|
||||
// Non-success notifications are never fulfilled, so they are not queried.
|
||||
failed := &payment.PaymentNotification{OrderID: "sub2_x", Status: payment.ProviderStatusFailed}
|
||||
require.NoError(t, svc.ConfirmNotificationUpstream(context.Background(), prov, failed))
|
||||
require.Equal(t, 0, calls)
|
||||
|
||||
require.NoError(t, svc.ConfirmNotificationUpstream(context.Background(), prov, n))
|
||||
require.Equal(t, 1, calls)
|
||||
require.Equal(t, "upstream-trade", n.TradeNo)
|
||||
}
|
||||
@@ -244,7 +244,16 @@ func CanonicalizeReturnURL(raw string, srcHost string, srcURL string) (string, e
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use http or https")
|
||||
}
|
||||
// The return URL is handed to payment gateways verbatim: keep it to a
|
||||
// plain hostname or IP literal and the canonical path, without userinfo.
|
||||
if parsed.User != nil {
|
||||
return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must not contain user info")
|
||||
}
|
||||
if !isPlainReturnURLHost(parsed.Hostname()) {
|
||||
return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url host is invalid")
|
||||
}
|
||||
parsed.Fragment = ""
|
||||
parsed.RawPath = ""
|
||||
// Client-supplied query parameters are dropped: buildPaymentReturnURL adds
|
||||
// its own signed set, and user-controlled pairs such as
|
||||
// "trade_status=TRADE_SUCCESS" inside the signed return_url value can be
|
||||
@@ -264,6 +273,25 @@ func CanonicalizeReturnURL(raw string, srcHost string, srcURL string) (string, e
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// isPlainReturnURLHost reports whether host is an IP literal or a hostname
|
||||
// made only of letters, digits, '.', '-' and '_'.
|
||||
func isPlainReturnURLHost(host string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
return true
|
||||
}
|
||||
for _, c := range host {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '.', c == '-', c == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func allowedReturnURLHost(returnURLHost string, requestHost string, refererURL string) bool {
|
||||
if sameOriginHost(returnURLHost, requestHost) {
|
||||
return true
|
||||
|
||||
@@ -109,6 +109,42 @@ func TestCanonicalizeReturnURLStripsSmuggledTradeStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeReturnURLRejectsNonPlainHostAndUserinfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
badHost := "a&b=c.example.com"
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
srcHost string
|
||||
srcURL string
|
||||
}{
|
||||
{"host matched via referer", "https://" + badHost + "/payment/result", "app.example.com", "https://" + badHost + "/purchase"},
|
||||
{"host matched via request host", "https://" + badHost + "/payment/result", badHost, ""},
|
||||
{"userinfo", "https://a&b=c@app.example.com/payment/result", "app.example.com", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got, err := CanonicalizeReturnURL(tc.raw, tc.srcHost, tc.srcURL); err == nil {
|
||||
t.Fatalf("%s: CanonicalizeReturnURL accepted %q", tc.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeReturnURLAllowsIPAndPortHosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, raw := range []string{
|
||||
"http://127.0.0.1:3000/payment/result",
|
||||
"http://[::1]:8080/payment/result",
|
||||
"https://pay_site-1.example.com/payment/result",
|
||||
} {
|
||||
parsed, _ := url.Parse(raw)
|
||||
if _, err := CanonicalizeReturnURL(raw, parsed.Host, ""); err != nil {
|
||||
t.Fatalf("CanonicalizeReturnURL(%q) returned error: %v", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeReturnURLRejectsRelativeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user