Compare commits

...
Author SHA1 Message Date
shaw 1b92038eaf fix(payment): fix EasyPay order queries and confirm notifications before crediting
Order query:
- 彩虹易支付 and ZPay read act=order parameters from the URL query only;
  a body-only request is answered with "No Act!", so order status queries
  (reconcile, payment status checks) never reached the order. Parameters
  are now sent in the URL query and mirrored in the form body for forks
  that read the body.
- Decode status/money/trade_no leniently: PHP deployments commonly return
  integers as strings ("status":"1"), which previously failed the whole
  response decode. JSON null falls back to the nested data object.
- Treat a non-success code as a query error instead of an unpaid order,
  and require an echoed out_trade_no to match the queried order.
- Strip the request URL (which carries the merchant key) from transport
  errors before they are returned.

Notifications:
- Add payment.NotificationConfirmer. After a notification is verified, the
  webhook asks the same provider instance to confirm it; EasyPay queries
  the upstream order and requires it to be paid with a matching amount and
  trade_no (a missing notify trade_no is filled from the query). Failures
  are recorded as PAYMENT_UPSTREAM_UNCONFIRMED and answered with 502 so the
  gateway retries; the order stays pending and reconcile still applies.
- EasyPay notifications must carry the instance's configured pid, and
  field values other than name must be plain tokens.
- Return URLs must use a plain hostname or IP literal without userinfo.
2026-10-16 09:39:55 +08:00
Wesley Liddick 52b3ca9609 Merge pull request #8122 from Wei-Shaw/fix/payment-order-type-refund-hold
fix(payment): validate order types, keep refund deductions while pending, load Stripe secret from local snapshot
2026-10-16 08:32:48 +08:00
10 changed files with 692 additions and 59 deletions
@@ -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)
}
+145 -47
View File
@@ -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)
}
+10
View File
@@ -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()