fix(epay): restrict submit type to alipay or supported selectors

This commit is contained in:
line-6000
2026-06-28 17:33:49 +08:00
parent 2dc538c0e1
commit 73aef80499
6 changed files with 128 additions and 168 deletions
+4 -4
View File
@@ -105,9 +105,9 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// the canonical form — the GET variant is identical save the transport.
// @Summary Create transaction and redirect (EPAY compat)
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid.
// @Description After signature verification, token/network resolution is: supported type=token.network selector (for example usdt.tron) first; otherwise request token/network; otherwise epay.default_token / epay.default_network. If token and network are still both empty, the order is created as status=4 placeholder. Supplying only one of token/network remains invalid.
// @Description After signature verification, type accepts only either alipay or a supported type=token.network selector (for example usdt.tron). Token/network resolution is: supported selector first; otherwise request token/network; otherwise epay.default_token / epay.default_network. If token and network are still both empty, the order is created as status=4 placeholder. Supplying only one of token/network remains invalid.
// @Description Currency resolution is unchanged: request currency -> epay.default_currency -> cny. Supported type selectors bypass only token/network defaults, not currency fallback.
// @Description Success return/notify currently use the stored request type when present, and fall back to alipay only when type was missing. The server injects internal payment_type=Epay after EPay signature verification; merchants do not send GMPay payment_type to this endpoint.
// @Description Success return/notify reuse the stored request type. On this branch that means either alipay or a supported token.network selector; when the request omitted type, outbound fallback remains alipay. The server injects internal payment_type=Epay after EPay signature verification; merchants do not send GMPay payment_type to this endpoint.
// @Tags Payment
// @Accept x-www-form-urlencoded
// @Produce html
@@ -117,7 +117,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// @Param notify_url query string false "Callback URL (GET query)"
// @Param return_url query string false "Redirect URL after payment (GET query)"
// @Param name query string false "Order name (GET query)"
// @Param type query string false "Opaque merchant type or supported token.network selector such as alipay or usdt.tron (GET query)"
// @Param type query string false "Either alipay or a supported token.network selector such as usdt.tron (GET query)"
// @Param sign query string false "MD5 signature (GET query)"
// @Param sign_type query string false "Signature type (MD5, GET query)"
// @Param pid formData integer true "API key PID"
@@ -126,7 +126,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// @Param notify_url formData string true "Callback URL"
// @Param return_url formData string false "Redirect URL after payment"
// @Param name formData string false "Order name"
// @Param type formData string false "Opaque merchant type or supported token.network selector such as alipay or usdt.tron"
// @Param type formData string false "Either alipay or a supported token.network selector such as usdt.tron"
// @Param sign formData string true "MD5 signature"
// @Param sign_type formData string false "Signature type (MD5)"
// @Success 302 "Redirect to checkout counter"
+1 -1
View File
@@ -36,7 +36,7 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
// Non-EPay or not-yet-paid orders are sent back to the checkout counter.
// @Summary Return to merchant (EPAY compat)
// @Description Browser-facing success return hop for EPay orders. Paid EPay orders are redirected to the merchant return_url with signed legacy EPay query params. Orders that are not EPay or not yet paid are redirected back to the checkout counter.
// @Description The signed query params currently use the stored request type when present; otherwise type=alipay is returned for compatibility.
// @Description The signed query params reuse the stored request type. On this branch that means either alipay or a supported token.network selector; if the original request omitted type, type=alipay is returned for compatibility.
// @Description This route also returns explicit business errors when the merchant return_url is missing, the order API key is unavailable, or EPay signature construction fails.
// @Tags Payment
// @Produce html
+52 -66
View File
@@ -5,7 +5,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"sync/atomic"
"testing"
@@ -631,7 +630,7 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
}
}
func TestSendOrderCallbackEpayPreservesStoredTypeValues(t *testing.T) {
func TestSendOrderCallbackEpayPreservesStoredAlipayType(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
@@ -645,73 +644,60 @@ func TestSendOrderCallbackEpayPreservesStoredTypeValues(t *testing.T) {
t.Fatalf("create epay api key: %v", err)
}
cases := []struct {
name string
epayType string
}{
{name: "alipay", epayType: "alipay"},
{name: "non_selector", epayType: "usdt-tron"},
{name: "unsupported_selector", epayType: "usdc.tron"},
formPayload := map[string]string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for k, v := range r.Form {
if len(v) > 0 {
formPayload[k] = v[0]
}
}
_, _ = io.WriteString(w, "ok")
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_epay_type_case_alipay",
OrderId: "order_epay_type_case_alipay",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_epay_type_case",
Token: "USDT",
Name: "VIP",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_epay_type_case_alipay",
EpayType: "alipay",
PaymentType: "epay",
ApiKeyID: key.ID,
}
for idx, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
formPayload := map[string]string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for k, v := range r.Form {
if len(v) > 0 {
formPayload[k] = v[0]
}
}
_, _ = io.WriteString(w, "ok")
}))
defer server.Close()
if err := sendOrderCallback(order); err != nil {
t.Fatalf("send epay callback: %v", err)
}
if got := formPayload["type"]; got != "alipay" {
t.Fatalf("type = %q, want alipay", got)
}
order := &mdb.Orders{
TradeId: "trade_epay_type_case_" + strconv.Itoa(idx),
OrderId: "order_epay_type_case_" + strconv.Itoa(idx),
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_epay_type_case",
Token: "USDT",
Name: "VIP",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_epay_type_case_" + strconv.Itoa(idx),
EpayType: tc.epayType,
PaymentType: "epay",
ApiKeyID: key.ID,
}
if err := sendOrderCallback(order); err != nil {
t.Fatalf("send epay callback: %v", err)
}
if got := formPayload["type"]; got != tc.epayType {
t.Fatalf("type = %q, want %q", got, tc.epayType)
}
signParams := map[string]interface{}{
"pid": formPayload["pid"],
"trade_no": formPayload["trade_no"],
"out_trade_no": formPayload["out_trade_no"],
"type": formPayload["type"],
"name": formPayload["name"],
"money": formPayload["money"],
"trade_status": formPayload["trade_status"],
}
calcSig, err := sign.Get(signParams, key.SecretKey)
if err != nil {
t.Fatalf("calc epay signature: %v", err)
}
if got := formPayload["sign"]; got != calcSig {
t.Fatalf("sign = %q, want %q", got, calcSig)
}
})
signParams := map[string]interface{}{
"pid": formPayload["pid"],
"trade_no": formPayload["trade_no"],
"out_trade_no": formPayload["out_trade_no"],
"type": formPayload["type"],
"name": formPayload["name"],
"money": formPayload["money"],
"trade_status": formPayload["trade_status"],
}
calcSig, err := sign.Get(signParams, key.SecretKey)
if err != nil {
t.Fatalf("calc epay signature: %v", err)
}
if got := formPayload["sign"]; got != calcSig {
t.Fatalf("sign = %q, want %q", got, calcSig)
}
}
+6 -2
View File
@@ -23,8 +23,9 @@ import (
)
// resolveEPayTypeSelector only claims the type as a selector when it maps to a
// currently supported payment asset; otherwise the caller should keep the
// legacy token/network/default resolution and treat type as opaque.
// currently supported payment asset. Non-selector values are handled by the
// caller, which now only accepts empty type or alipay before falling back to
// request token/network/default resolution.
func resolveEPayTypeSelector(rawType string) (string, string, bool, error) {
rawType = strings.TrimSpace(rawType)
if rawType == "" || strings.Count(rawType, ".") != 1 {
@@ -168,6 +169,9 @@ func RegisterRoute(e *echo.Echo) {
if err != nil {
return comm.Ctrl.FailJson(ctx, constant.SystemErr)
}
if epayType != "" && !selectorMatched && !strings.EqualFold(epayType, "alipay") {
return comm.Ctrl.FailJson(ctx, constant.ParamsMarshalErr)
}
token := strings.TrimSpace(getString(params, "token"))
network := strings.TrimSpace(getString(params, "network"))
if selectorMatched {
+51 -86
View File
@@ -1471,7 +1471,46 @@ func TestEpaySubmitPhpWithoutTokenNetworkDefaultsCreatesPlaceholder(t *testing.T
}
}
func TestEpaySubmitPhpNonSelectorTypeKeepsOldLogic(t *testing.T) {
func TestEpaySubmitPhpWithoutTypeKeepsCurrentBehavior(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "tron", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_network: %v", err)
}
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-empty-type-001"},
"money": {"1.00"},
"out_trade_no": {"epay-empty-type-001"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
})
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
}
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload epay empty-type order: %v", err)
}
if order.Token != "USDT" || order.Network != mdb.NetworkTron || order.ReceiveAddress != "TTestTronAddress001" {
t.Fatalf("empty-type order fields = token %q network %q address %q", order.Token, order.Network, order.ReceiveAddress)
}
if order.EpayType != "" {
t.Fatalf("epay_type = %q, want empty", order.EpayType)
}
}
func TestEpaySubmitPhpRejectsNonSelectorType(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil {
@@ -1495,23 +1534,16 @@ func TestEpaySubmitPhpNonSelectorTypeKeepsOldLogic(t *testing.T) {
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload epay non-selector order: %v", err)
}
if order.Token != "USDT" || order.Network != mdb.NetworkTron || order.ReceiveAddress != "TTestTronAddress001" {
t.Fatalf("non-selector order fields = token %q network %q address %q", order.Token, order.Network, order.ReceiveAddress)
}
if order.EpayType != "usdt-tron" {
t.Fatalf("epay_type = %q, want usdt-tron", order.EpayType)
resp := parseResp(t, rec)
if got := int(resp["status_code"].(float64)); got != 10009 {
t.Fatalf("status_code = %d, want 10009", got)
}
}
func TestEpaySubmitPhpUnsupportedSelectorFallsBackToOldLogic(t *testing.T) {
func TestEpaySubmitPhpRejectsUnsupportedSelectorType(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil {
@@ -1535,19 +1567,12 @@ func TestEpaySubmitPhpUnsupportedSelectorFallsBackToOldLogic(t *testing.T) {
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload epay unsupported selector order: %v", err)
}
if order.Token != "USDT" || order.Network != mdb.NetworkTron || order.ReceiveAddress != "TTestTronAddress001" {
t.Fatalf("unsupported selector order fields = token %q network %q address %q", order.Token, order.Network, order.ReceiveAddress)
}
if order.EpayType != "usdc.tron" {
t.Fatalf("epay_type = %q, want usdc.tron", order.EpayType)
resp := parseResp(t, rec)
if got := int(resp["status_code"].(float64)); got != 10009 {
t.Fatalf("status_code = %d, want 10009", got)
}
}
@@ -1897,66 +1922,6 @@ func TestPayReturn_PaidEpayRedirectsWithOriginalType(t *testing.T) {
}
}
func TestPayReturn_PaidEpayRedirectsWithNonSelectorType(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-non-selector-001", "https://merchant.example/return", url.Values{
"type": {"usdt-tron"},
"token": {"usdt"},
"network": {"tron"},
"currency": {"cny"},
})
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d: %s", rec.Code, rec.Body.String())
}
targetURL, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatalf("parse redirect location: %v", err)
}
if got := targetURL.Query().Get("type"); got != "usdt-tron" {
t.Fatalf("type = %q, want usdt-tron", got)
}
}
func TestPayReturn_PaidEpayRedirectsWithUnsupportedSelectorType(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-unsel-001", "https://merchant.example/return", url.Values{
"type": {"usdc.tron"},
"token": {"usdt"},
"network": {"tron"},
"currency": {"cny"},
})
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d: %s", rec.Code, rec.Body.String())
}
targetURL, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatalf("parse redirect location: %v", err)
}
if got := targetURL.Query().Get("type"); got != "usdc.tron" {
t.Fatalf("type = %q, want usdc.tron", got)
}
}
func TestPayReturn_UnpaidEpayRedirectsBackToCheckout(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-unpaid-001", "https://merchant.example/return", url.Values{