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. // the canonical form — the GET variant is identical save the transport.
// @Summary Create transaction and redirect (EPAY compat) // @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 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 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 // @Tags Payment
// @Accept x-www-form-urlencoded // @Accept x-www-form-urlencoded
// @Produce html // @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 notify_url query string false "Callback URL (GET query)"
// @Param return_url query string false "Redirect URL after payment (GET query)" // @Param return_url query string false "Redirect URL after payment (GET query)"
// @Param name query string false "Order name (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 query string false "MD5 signature (GET query)"
// @Param sign_type query string false "Signature type (MD5, GET query)" // @Param sign_type query string false "Signature type (MD5, GET query)"
// @Param pid formData integer true "API key PID" // @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 notify_url formData string true "Callback URL"
// @Param return_url formData string false "Redirect URL after payment" // @Param return_url formData string false "Redirect URL after payment"
// @Param name formData string false "Order name" // @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 formData string true "MD5 signature"
// @Param sign_type formData string false "Signature type (MD5)" // @Param sign_type formData string false "Signature type (MD5)"
// @Success 302 "Redirect to checkout counter" // @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. // Non-EPay or not-yet-paid orders are sent back to the checkout counter.
// @Summary Return to merchant (EPAY compat) // @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 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. // @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 // @Tags Payment
// @Produce html // @Produce html
+7 -21
View File
@@ -5,7 +5,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -631,7 +630,7 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
} }
} }
func TestSendOrderCallbackEpayPreservesStoredTypeValues(t *testing.T) { func TestSendOrderCallbackEpayPreservesStoredAlipayType(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
@@ -645,17 +644,6 @@ func TestSendOrderCallbackEpayPreservesStoredTypeValues(t *testing.T) {
t.Fatalf("create epay api key: %v", err) 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"},
}
for idx, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
formPayload := map[string]string{} formPayload := map[string]string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
@@ -672,8 +660,8 @@ func TestSendOrderCallbackEpayPreservesStoredTypeValues(t *testing.T) {
defer server.Close() defer server.Close()
order := &mdb.Orders{ order := &mdb.Orders{
TradeId: "trade_epay_type_case_" + strconv.Itoa(idx), TradeId: "trade_epay_type_case_alipay",
OrderId: "order_epay_type_case_" + strconv.Itoa(idx), OrderId: "order_epay_type_case_alipay",
Amount: 1, Amount: 1,
Currency: "CNY", Currency: "CNY",
ActualAmount: 1, ActualAmount: 1,
@@ -682,8 +670,8 @@ func TestSendOrderCallbackEpayPreservesStoredTypeValues(t *testing.T) {
Name: "VIP", Name: "VIP",
Status: mdb.StatusPaySuccess, Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL, NotifyUrl: server.URL,
BlockTransactionId: "block_epay_type_case_" + strconv.Itoa(idx), BlockTransactionId: "block_epay_type_case_alipay",
EpayType: tc.epayType, EpayType: "alipay",
PaymentType: "epay", PaymentType: "epay",
ApiKeyID: key.ID, ApiKeyID: key.ID,
} }
@@ -691,8 +679,8 @@ func TestSendOrderCallbackEpayPreservesStoredTypeValues(t *testing.T) {
if err := sendOrderCallback(order); err != nil { if err := sendOrderCallback(order); err != nil {
t.Fatalf("send epay callback: %v", err) t.Fatalf("send epay callback: %v", err)
} }
if got := formPayload["type"]; got != tc.epayType { if got := formPayload["type"]; got != "alipay" {
t.Fatalf("type = %q, want %q", got, tc.epayType) t.Fatalf("type = %q, want alipay", got)
} }
signParams := map[string]interface{}{ signParams := map[string]interface{}{
@@ -711,8 +699,6 @@ func TestSendOrderCallbackEpayPreservesStoredTypeValues(t *testing.T) {
if got := formPayload["sign"]; got != calcSig { if got := formPayload["sign"]; got != calcSig {
t.Fatalf("sign = %q, want %q", got, calcSig) t.Fatalf("sign = %q, want %q", got, calcSig)
} }
})
}
} }
func TestDispatchPendingCallbacksEpayAcceptsSuccessAck(t *testing.T) { func TestDispatchPendingCallbacksEpayAcceptsSuccessAck(t *testing.T) {
+6 -2
View File
@@ -23,8 +23,9 @@ import (
) )
// resolveEPayTypeSelector only claims the type as a selector when it maps to a // resolveEPayTypeSelector only claims the type as a selector when it maps to a
// currently supported payment asset; otherwise the caller should keep the // currently supported payment asset. Non-selector values are handled by the
// legacy token/network/default resolution and treat type as opaque. // 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) { func resolveEPayTypeSelector(rawType string) (string, string, bool, error) {
rawType = strings.TrimSpace(rawType) rawType = strings.TrimSpace(rawType)
if rawType == "" || strings.Count(rawType, ".") != 1 { if rawType == "" || strings.Count(rawType, ".") != 1 {
@@ -168,6 +169,9 @@ func RegisterRoute(e *echo.Echo) {
if err != nil { if err != nil {
return comm.Ctrl.FailJson(ctx, constant.SystemErr) 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")) token := strings.TrimSpace(getString(params, "token"))
network := strings.TrimSpace(getString(params, "network")) network := strings.TrimSpace(getString(params, "network"))
if selectorMatched { 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) e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil { 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() rec := httptest.NewRecorder()
e.ServeHTTP(rec, req) e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound { if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String()) t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
} }
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/") resp := parseResp(t, rec)
order, err := data.GetOrderInfoByTradeId(tradeID) if got := int(resp["status_code"].(float64)); got != 10009 {
if err != nil { t.Fatalf("status_code = %d, want 10009", got)
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)
} }
} }
func TestEpaySubmitPhpUnsupportedSelectorFallsBackToOldLogic(t *testing.T) { func TestEpaySubmitPhpRejectsUnsupportedSelectorType(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil { 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() rec := httptest.NewRecorder()
e.ServeHTTP(rec, req) e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound { if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String()) t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
} }
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/") resp := parseResp(t, rec)
order, err := data.GetOrderInfoByTradeId(tradeID) if got := int(resp["status_code"].(float64)); got != 10009 {
if err != nil { t.Fatalf("status_code = %d, want 10009", got)
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)
} }
} }
@@ -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) { func TestPayReturn_UnpaidEpayRedirectsBackToCheckout(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-unpaid-001", "https://merchant.example/return", url.Values{ tradeID := mustCreateEPayOrder(t, e, "epay-return-unpaid-001", "https://merchant.example/return", url.Values{
+14 -9
View File
@@ -441,9 +441,9 @@ function epaySign(array $params, string $secretKey): string
| `notify_url` | query/form | string | 是 | 异步回调地址。 | | `notify_url` | query/form | string | 是 | 异步回调地址。 |
| `return_url` | query/form | string | 否 | 支付完成后的同步跳转地址。 | | `return_url` | query/form | string | 否 | 支付完成后的同步跳转地址。 |
| `name` | query/form | string | 否 | 商品/订单名称。 | | `name` | query/form | string | 否 | 商品/订单名称。 |
| `type` | query/form | string | 否 | 兼容字段,如 `alipay`。创建订单时不决定实际链上币种。 | | `type` | query/form | string | 否 | 仅支持空值、`alipay`,或当前已启用并可收款的 `token.network` selector(如 `usdt.tron`)。推荐使用小写 `alipay`。 |
| `token` | query/form | string | 否 | 可选收款币种。优先级高于后台 `epay.default_token`;传了就必须参与 EPay 签名。 | | `token` | query/form | string | 否 | 可选收款币种。仅在 `type` 不是命中的 selector 时参与解析;传了就必须参与 EPay 签名。 |
| `network` | query/form | string | 否 | 可选收款网络。优先级高于后台 `epay.default_network`;传了就必须参与 EPay 签名。 | | `network` | query/form | string | 否 | 可选收款网络。仅在 `type` 不是命中的 selector 时参与解析;传了就必须参与 EPay 签名。 |
| `currency` | query/form | string | 否 | 可选法币币种。优先级高于后台 `epay.default_currency`;传了就必须参与 EPay 签名。 | | `currency` | query/form | string | 否 | 可选法币币种。优先级高于后台 `epay.default_currency`;传了就必须参与 EPay 签名。 |
| `sign` | query/form | string | 是 | EPay 签名。 | | `sign` | query/form | string | 是 | EPay 签名。 |
| `sign_type` | query/form | string | 否 | 通常为 `MD5`。 | | `sign_type` | query/form | string | 否 | 通常为 `MD5`。 |
@@ -466,13 +466,16 @@ money=100&name=VIP&notify_url=https://merchant.example/notify&out_trade_no=ORD20
sign=b865b0acbb2b01554c35a1bd33351452 sign=b865b0acbb2b01554c35a1bd33351452
``` ```
EPay 接口解析 `token/network/currency`优先级 EPay 接口解析 `type/token/network/currency`规则
- `token`:请求参数 `token` > 数据库 `epay.default_token` > 空 - `type` 只接受三类输入:空值、`alipay`、命中的 `token.network` selector
- `network`请求参数 `network` > 数据库 `epay.default_network` > 空 - `type=token.network` 且命中当前已启用支付资产时,会直接确定本次订单的 `token/network`,并覆盖请求参数里的 `token/network` 以及后台 `epay.default_token` / `epay.default_network`
- `currency`:请求参数 `currency` > 数据库 `epay.default_currency` > `cny` - `type` 非空但既不是命中的 selector,也不是 `alipay` 时,直接返回 `10009 invalid params`。例如 `usdt-tron`、未启用的 `usdc.tron` 都会被拒绝
- 最终 `token/network` 同时有值时,创建具体链上订单;同时为空时,创建状态 `4` 占位订单;只缺一个时返回参数错误 - `type` 为空或为 `alipay` 时,`token/network` 继续走原有解析:先看请求参数,再分别用数据库 `epay.default_token` / `epay.default_network` 补齐
- 服务端会在 EPay 签名校验通过后内部注入 `payment_type=Epay`,该字段不参与 EPay 入站签名;但请求里显式传入的 `token/network/currency` 属于原始 EPay 参数,必须参与签名 - `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` 后,这两个字段会返回空字符串。 后台默认配置可通过 `/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 兼容接口创建的订单,会使用 GET 请求回调 `notify_url`,参数如下:
> EPay 回调会把 `pid` 输出为数字;使用 EPay 兼容接口或 `payment_type=Epay` 时,请确保 API Key 的 PID 是数字。 > EPay 回调会把 `pid` 输出为数字;使用 EPay 兼容接口或 `payment_type=Epay` 时,请确保 API Key 的 PID 是数字。
>
> `type` 出站时使用订单里保存的请求值。当前主分支正常入站能保存下来的只会是 `alipay` 或命中的 `token.network` selector;如果入站请求没传 `type`,出站才回退为 `alipay`
```text ```text
pid=1000 pid=1000