From 73aef804990cab77a32c7e199f29944fb07b1224 Mon Sep 17 00:00:00 2001 From: line-6000 <154492442+line-6000@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:33:49 +0800 Subject: [PATCH] fix(epay): restrict submit type to alipay or supported selectors --- src/controller/comm/order_controller.go | 8 +- src/controller/comm/pay_controller.go | 2 +- src/mq/worker_test.go | 118 +++++++++----------- src/route/router.go | 8 +- src/route/router_test.go | 137 +++++++++--------------- wiki/API.md | 23 ++-- 6 files changed, 128 insertions(+), 168 deletions(-) diff --git a/src/controller/comm/order_controller.go b/src/controller/comm/order_controller.go index 74df3be..2c3b1de 100644 --- a/src/controller/comm/order_controller.go +++ b/src/controller/comm/order_controller.go @@ -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" diff --git a/src/controller/comm/pay_controller.go b/src/controller/comm/pay_controller.go index 6aef2d4..2dc1566 100644 --- a/src/controller/comm/pay_controller.go +++ b/src/controller/comm/pay_controller.go @@ -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 diff --git a/src/mq/worker_test.go b/src/mq/worker_test.go index 93ac2e1..59efdff 100644 --- a/src/mq/worker_test.go +++ b/src/mq/worker_test.go @@ -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) } } diff --git a/src/route/router.go b/src/route/router.go index aee31eb..70a4721 100644 --- a/src/route/router.go +++ b/src/route/router.go @@ -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 { diff --git a/src/route/router_test.go b/src/route/router_test.go index 18bbc70..94b0377 100644 --- a/src/route/router_test.go +++ b/src/route/router_test.go @@ -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{ diff --git a/wiki/API.md b/wiki/API.md index 10d40f5..f30eb7d 100644 --- a/wiki/API.md +++ b/wiki/API.md @@ -441,9 +441,9 @@ function epaySign(array $params, string $secretKey): string | `notify_url` | query/form | string | 是 | 异步回调地址。 | | `return_url` | query/form | string | 否 | 支付完成后的同步跳转地址。 | | `name` | query/form | string | 否 | 商品/订单名称。 | -| `type` | query/form | string | 否 | 兼容字段,如 `alipay`。创建订单时不决定实际链上币种。 | -| `token` | query/form | string | 否 | 可选收款币种。优先级高于后台 `epay.default_token`;传了就必须参与 EPay 签名。 | -| `network` | query/form | string | 否 | 可选收款网络。优先级高于后台 `epay.default_network`;传了就必须参与 EPay 签名。 | +| `type` | query/form | string | 否 | 仅支持空值、`alipay`,或当前已启用并可收款的 `token.network` selector(如 `usdt.tron`)。推荐使用小写 `alipay`。 | +| `token` | query/form | string | 否 | 可选收款币种。仅在 `type` 不是命中的 selector 时参与解析;传了就必须参与 EPay 签名。 | +| `network` | query/form | string | 否 | 可选收款网络。仅在 `type` 不是命中的 selector 时参与解析;传了就必须参与 EPay 签名。 | | `currency` | query/form | string | 否 | 可选法币币种。优先级高于后台 `epay.default_currency`;传了就必须参与 EPay 签名。 | | `sign` | query/form | string | 是 | EPay 签名。 | | `sign_type` | query/form | string | 否 | 通常为 `MD5`。 | @@ -466,13 +466,16 @@ money=100&name=VIP¬ify_url=https://merchant.example/notify&out_trade_no=ORD20 sign=b865b0acbb2b01554c35a1bd33351452 ``` -EPay 接口解析 `token/network/currency` 的优先级: +EPay 接口解析 `type/token/network/currency` 的规则: -- `token`:请求参数 `token` > 数据库 `epay.default_token` > 空。 -- `network`:请求参数 `network` > 数据库 `epay.default_network` > 空。 -- `currency`:请求参数 `currency` > 数据库 `epay.default_currency` > `cny`。 -- 最终 `token/network` 同时有值时,创建具体链上订单;同时为空时,创建状态 `4` 占位订单;只缺一个时返回参数错误。 -- 服务端会在 EPay 签名校验通过后内部注入 `payment_type=Epay`,该字段不参与 EPay 入站签名;但请求里显式传入的 `token/network/currency` 属于原始 EPay 参数,必须参与签名。 +- `type` 只接受三类输入:空值、`alipay`、命中的 `token.network` selector。 +- `type=token.network` 且命中当前已启用支付资产时,会直接确定本次订单的 `token/network`,并覆盖请求参数里的 `token/network` 以及后台 `epay.default_token` / `epay.default_network`。 +- `type` 非空但既不是命中的 selector,也不是 `alipay` 时,直接返回 `10009 invalid params`。例如 `usdt-tron`、未启用的 `usdc.tron` 都会被拒绝。 +- `type` 为空或为 `alipay` 时,`token/network` 继续走原有解析:先看请求参数,再分别用数据库 `epay.default_token` / `epay.default_network` 补齐。 +- `currency` 解析不受 selector 影响:请求参数 `currency` > 数据库 `epay.default_currency` > `cny`。 +- 最终解析结果里,`token/network` 同时有值时创建具体链上订单;同时为空时创建状态 `4` 占位订单;最终只缺一个时返回参数错误。 +- 这意味着“请求里只传了一个值”不一定报错;如果另一个值能被 default 补齐,仍会成功。只有最终解析后仍然只剩一个值,才返回 `10009`。 +- 服务端会在 EPay 签名校验通过后内部注入 `payment_type=Epay`,该字段不参与 EPay 入站签名;但请求里显式传入的 `type/token/network/currency` 仍属于原始 EPay 参数,必须参与签名。 后台默认配置可通过 `/payments/gmpay/v1/config` 的 `epay` 字段查看;新安装默认只预置 `epay.default_currency=cny`,`epay.default_token` 和 `epay.default_network` 为空,因此 EPay 未显式传 token/network 时会创建状态 `4` 占位订单。已有数据库的配置不会被 seed 覆盖,删除或置空 `epay.default_token` 和 `epay.default_network` 后,这两个字段会返回空字符串。 @@ -519,6 +522,8 @@ GMPay 回调验签方式与创建订单一致,但排除 `signature` 字段。 通过 EPay 兼容接口创建的订单,会使用 GET 请求回调 `notify_url`,参数如下: > EPay 回调会把 `pid` 输出为数字;使用 EPay 兼容接口或 `payment_type=Epay` 时,请确保 API Key 的 PID 是数字。 +> +> `type` 出站时使用订单里保存的请求值。当前主分支正常入站能保存下来的只会是 `alipay` 或命中的 `token.network` selector;如果入站请求没传 `type`,出站才回退为 `alipay`。 ```text pid=1000