diff --git a/src/controller/admin/settings_controller.go b/src/controller/admin/settings_controller.go index c8c4203..a87979e 100644 --- a/src/controller/admin/settings_controller.go +++ b/src/controller/admin/settings_controller.go @@ -26,9 +26,9 @@ import ( // rate.okx_c2c_enabled (bool) — use OKX C2C rate feed // // - group=epay: -// epay.default_token (string) — default token for EPAY submit.php, e.g. "usdt"; empty allows status=4 placeholders when request token/network are also absent -// epay.default_currency (string) — default fiat currency for EPAY submit.php, e.g. "cny"; empty falls back to cny -// epay.default_network (string) — default network for EPAY submit.php, e.g. "tron" or "ton"; empty allows status=4 placeholders when request token/network are also absent +// epay.default_token (string) — default token for EPAY submit.php, e.g. "usdt"; ignored when a supported type=token.network selector is supplied; empty allows status=4 placeholders when request token/network are also absent +// epay.default_currency (string) — default fiat currency for EPAY submit.php, e.g. "cny"; still applies when a supported type selector is supplied; empty falls back to cny +// epay.default_network (string) — default network for EPAY submit.php, e.g. "tron" or "ton"; ignored when a supported type=token.network selector is supplied; empty allows status=4 placeholders when request token/network are also absent // // - group=okpay: // okpay.enabled (bool) — enable OkPay as a switch-network payment option @@ -95,7 +95,7 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error { // @Summary Upsert settings // @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n. // @Description Supported groups: brand, rate, system, epay, okpay. -// @Description epay group keys: epay.default_token (e.g. "usdt" or "ton", empty allows status=4 placeholders), epay.default_currency (e.g. "cny", empty falls back to cny), epay.default_network (e.g. "tron" or "ton", empty allows status=4 placeholders). +// @Description epay group keys: epay.default_token (e.g. "usdt" or "ton", ignored when a supported type=token.network selector is supplied, empty allows status=4 placeholders), epay.default_currency (e.g. "cny", still applies when a supported type selector is supplied, empty falls back to cny), epay.default_network (e.g. "tron" or "ton", ignored when a supported type=token.network selector is supplied, empty allows status=4 placeholders). // @Description okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens. // @Description rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled. // @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported. diff --git a/src/controller/comm/order_controller.go b/src/controller/comm/order_controller.go index 8fb49de..74df3be 100644 --- a/src/controller/comm/order_controller.go +++ b/src/controller/comm/order_controller.go @@ -3,6 +3,7 @@ package comm import ( "encoding/json" "net/http" + "strings" "github.com/GMWalletApp/epusdt/middleware" "github.com/GMWalletApp/epusdt/model/mdb" @@ -13,6 +14,8 @@ import ( "github.com/labstack/echo/v4" ) +const EPayTypeContextKey = "epay_type" + // apiKeyFromContext returns the api_keys row stamped by CheckApiSign. // Returns nil when the middleware didn't run (should not happen on authed routes). func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey { @@ -101,7 +104,10 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) { // POST (form) per the legacy EPAY protocol; swagger documents POST as // 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. Optional request token/network/currency override database defaults and must be included in the EPay signature when sent. The server injects internal payment_type=Epay after EPay signature verification; merchants do not send GMPay payment_type to this endpoint. +// @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 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. // @Tags Payment // @Accept x-www-form-urlencoded // @Produce html @@ -111,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 "Payment type (e.g. alipay, GET query)" +// @Param type query string false "Opaque merchant type or supported token.network selector such as alipay or 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" @@ -120,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 "Payment type (e.g. alipay)" +// @Param type formData string false "Opaque merchant type or supported token.network selector such as alipay or usdt.tron" // @Param sign formData string true "MD5 signature" // @Param sign_type formData string false "Signature type (MD5)" // @Success 302 "Redirect to checkout counter" @@ -133,6 +139,9 @@ func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err log.Sugar.Errorf("bind request error: %v", err) return c.FailJson(ctx, constant.ParamsMarshalErr) } + if raw, ok := ctx.Get(EPayTypeContextKey).(string); ok { + req.EpayType = strings.TrimSpace(raw) + } if err = c.ValidateStruct(ctx, req); err != nil { log.Sugar.Errorf("validate request error: %v", err) return c.FailJson(ctx, err) diff --git a/src/controller/comm/pay_controller.go b/src/controller/comm/pay_controller.go index b6703e1..6aef2d4 100644 --- a/src/controller/comm/pay_controller.go +++ b/src/controller/comm/pay_controller.go @@ -14,7 +14,7 @@ import ( // CheckoutCounter 收银台 // @Summary Checkout counter page // @Description Return checkout initialization data when the order exists. This endpoint only confirms order existence and returns base order data; call /pay/check-status/{trade_id} for the current order status (1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection). -// @Description When status=4, actual_amount is 0 and token/network/receive_address are empty; this state is produced by GMPay placeholders or EPay submit.php when no token/network request values or database defaults exist. The cashier should guide the payer to choose an on-chain token/network or OkPay and then call /pay/switch-network. +// @Description When status=4, actual_amount is 0 and token/network/receive_address are empty; this state is produced by GMPay placeholders or EPay submit.php when no token/network request values or defaults resolve to a concrete payment asset. The cashier should guide the payer to choose an on-chain token/network or OkPay and then call /pay/switch-network. // @Description For EPay orders with a merchant return_url, the response redirect_url is rewritten to the internal /pay/return/{trade_id} hop; the database still stores the merchant's raw return_url. // @Tags Payment // @Produce json @@ -36,6 +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 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/model/mdb/orders_mdb.go b/src/model/mdb/orders_mdb.go index ba0a061..14d1a9c 100644 --- a/src/model/mdb/orders_mdb.go +++ b/src/model/mdb/orders_mdb.go @@ -51,6 +51,7 @@ type Orders struct { NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"` RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"` Name string `gorm:"column:name" json:"name" example:"VIP月卡"` + EpayType string `gorm:"column:epay_type;size:64;default:''" json:"-"` CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"` // 回调确认状态 1=回调成功 2=未回调/回调失败 CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"` diff --git a/src/model/request/order_request.go b/src/model/request/order_request.go index 0ecfc96..0513c02 100644 --- a/src/model/request/order_request.go +++ b/src/model/request/order_request.go @@ -18,6 +18,7 @@ type CreateTransactionRequest struct { // empty or any other value is stored as "Gmpay" and uses GMPay JSON. // It is optional for GMPay, but must be included in the signature when sent. PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"` + EpayType string `json:"-" form:"-"` } func (r CreateTransactionRequest) Translates() map[string]string { diff --git a/src/model/response/support_response.go b/src/model/response/support_response.go index cf4913c..eef56e8 100644 --- a/src/model/response/support_response.go +++ b/src/model/response/support_response.go @@ -7,9 +7,9 @@ type NetworkTokenSupport struct { } type EpayPublicConfig struct { - DefaultToken string `json:"default_token" example:""` // EPay default token; empty means submit.php can create a status=4 placeholder when request token/network are also absent. - DefaultCurrency string `json:"default_currency" example:"cny"` // EPay default fiat currency; falls back to cny when unset. - DefaultNetwork string `json:"default_network" example:""` // EPay default network; empty means submit.php can create a status=4 placeholder when request token/network are also absent. + DefaultToken string `json:"default_token" example:""` // EPay default token; ignored when a supported type=token.network selector is supplied. Empty means submit.php can create a status=4 placeholder when request token/network are also absent. + DefaultCurrency string `json:"default_currency" example:"cny"` // EPay default fiat currency; still applies when a supported type selector is supplied and falls back to cny when unset. + DefaultNetwork string `json:"default_network" example:""` // EPay default network; ignored when a supported type=token.network selector is supplied. Empty means submit.php can create a status=4 placeholder when request token/network are also absent. } type SitePublicConfig struct { diff --git a/src/model/service/epay_return.go b/src/model/service/epay_return.go index 78a6ffb..04a0aa2 100644 --- a/src/model/service/epay_return.go +++ b/src/model/service/epay_return.go @@ -71,6 +71,16 @@ func ResolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) { return row, nil } +func epayResultType(order *mdb.Orders) string { + if order == nil { + return "alipay" + } + if epayType := strings.TrimSpace(order.EpayType); epayType != "" { + return epayType + } + return "alipay" +} + func BuildEPayResultParams(order *mdb.Orders, apiKeyRow *mdb.ApiKey) (map[string]string, error) { if order == nil || apiKeyRow == nil { return nil, constant.EPayReturnSignatureErr @@ -85,7 +95,7 @@ func BuildEPayResultParams(order *mdb.Orders, apiKeyRow *mdb.ApiKey) (map[string PID: pidInt, TradeNo: order.TradeId, OutTradeNo: order.OrderId, - Type: "alipay", + Type: epayResultType(order), Name: order.Name, Money: fmt.Sprintf("%.4f", order.Amount), TradeStatus: "TRADE_SUCCESS", diff --git a/src/model/service/epay_return_test.go b/src/model/service/epay_return_test.go index 35d49f7..7c5e232 100644 --- a/src/model/service/epay_return_test.go +++ b/src/model/service/epay_return_test.go @@ -40,12 +40,13 @@ func TestBuildPublicRedirectURLRewritesOnlyEpayOrders(t *testing.T) { } } -func TestBuildEPayResultParamsMatchesLegacyFields(t *testing.T) { +func TestBuildEPayResultParamsUsesOriginalType(t *testing.T) { params, err := BuildEPayResultParams(&mdb.Orders{ - TradeId: "trade_epay_params", - OrderId: "order_epay_params", - Name: "VIP", - Amount: 1, + TradeId: "trade_epay_params", + OrderId: "order_epay_params", + Name: "VIP", + Amount: 1, + EpayType: "usdt.tron", }, &mdb.ApiKey{ Pid: "1001", SecretKey: "epay-secret", @@ -58,7 +59,7 @@ func TestBuildEPayResultParamsMatchesLegacyFields(t *testing.T) { "pid": "1001", "trade_no": "trade_epay_params", "out_trade_no": "order_epay_params", - "type": "alipay", + "type": "usdt.tron", "name": "VIP", "money": "1.0000", "trade_status": "TRADE_SUCCESS", @@ -88,6 +89,24 @@ func TestBuildEPayResultParamsMatchesLegacyFields(t *testing.T) { } } +func TestBuildEPayResultParamsFallsBackToAlipayWhenTypeMissing(t *testing.T) { + params, err := BuildEPayResultParams(&mdb.Orders{ + TradeId: "trade_epay_fallback", + OrderId: "order_epay_fallback", + Name: "VIP", + Amount: 1, + }, &mdb.ApiKey{ + Pid: "1001", + SecretKey: "epay-secret", + }) + if err != nil { + t.Fatalf("BuildEPayResultParams(): %v", err) + } + if got := params["type"]; got != "alipay" { + t.Fatalf("type = %q, want alipay", got) + } +} + func TestBuildEPayResultParamsRejectsNonNumericPid(t *testing.T) { _, err := BuildEPayResultParams(&mdb.Orders{ TradeId: "trade_bad_pid", diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index 1101386..ac49245 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -116,6 +116,10 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey if strings.EqualFold(req.PaymentType, mdb.PaymentTypeEpay) { paymentType = mdb.PaymentTypeEpay } + epayType := "" + if paymentType == mdb.PaymentTypeEpay { + epayType = strings.TrimSpace(req.EpayType) + } gCreateTransactionLock.Lock() defer gCreateTransactionLock.Unlock() @@ -146,6 +150,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey NotifyUrl: notifyURL, RedirectUrl: req.RedirectUrl, Name: req.Name, + EpayType: epayType, PaymentType: paymentType, PayProvider: mdb.PaymentProviderOnChain, ApiKeyID: apiKeyID(apiKey), @@ -205,6 +210,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey NotifyUrl: notifyURL, RedirectUrl: req.RedirectUrl, Name: req.Name, + EpayType: epayType, PaymentType: paymentType, PayProvider: mdb.PaymentProviderOnChain, ApiKeyID: apiKeyID(apiKey), @@ -637,6 +643,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter NotifyUrl: "", RedirectUrl: parent.RedirectUrl, Name: parent.Name, + EpayType: parent.EpayType, CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order PaymentType: parent.PaymentType, PayProvider: mdb.PaymentProviderOnChain, @@ -916,6 +923,7 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR NotifyUrl: "", RedirectUrl: parent.RedirectUrl, Name: parent.Name, + EpayType: parent.EpayType, CallBackConfirm: mdb.CallBackConfirmOk, PaymentType: parent.PaymentType, PayProvider: mdb.PaymentProviderOkPay, diff --git a/src/mq/worker_test.go b/src/mq/worker_test.go index 4261a1d..93ac2e1 100644 --- a/src/mq/worker_test.go +++ b/src/mq/worker_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "sync" "sync/atomic" "testing" @@ -573,6 +574,7 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) { Status: mdb.StatusPaySuccess, NotifyUrl: server.URL, BlockTransactionId: "block_epay_sign", + EpayType: "usdt.tron", PaymentType: "epay", ApiKeyID: key.ID, } @@ -587,8 +589,8 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) { if formPayload["sign_type"] != "MD5" { t.Fatalf("sign_type = %q, want MD5", formPayload["sign_type"]) } - if formPayload["type"] != "alipay" { - t.Fatalf("type = %q, want alipay", formPayload["type"]) + if formPayload["type"] != "usdt.tron" { + t.Fatalf("type = %q, want usdt.tron", formPayload["type"]) } if formPayload["trade_status"] != "TRADE_SUCCESS" { t.Fatalf("trade_status = %q, want TRADE_SUCCESS", formPayload["trade_status"]) @@ -629,6 +631,90 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) { } } +func TestSendOrderCallbackEpayPreservesStoredTypeValues(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + key := &mdb.ApiKey{ + Name: "epay-key-stored-type", + Pid: "9201", + SecretKey: "epay-secret-9201", + Status: mdb.ApiKeyStatusEnable, + } + if err := dao.Mdb.Create(key).Error; err != nil { + 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{} + 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_" + 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) + } + }) + } +} + func TestDispatchPendingCallbacksEpayAcceptsSuccessAck(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() diff --git a/src/route/router.go b/src/route/router.go index 4797914..aee31eb 100644 --- a/src/route/router.go +++ b/src/route/router.go @@ -15,12 +15,42 @@ import ( "github.com/GMWalletApp/epusdt/middleware" "github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/GMWalletApp/epusdt/model/service" "github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/sign" "github.com/labstack/echo/v4" echoMiddleware "github.com/labstack/echo/v4/middleware" ) +// 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. +func resolveEPayTypeSelector(rawType string) (string, string, bool, error) { + rawType = strings.TrimSpace(rawType) + if rawType == "" || strings.Count(rawType, ".") != 1 { + return "", "", false, nil + } + + parts := strings.SplitN(rawType, ".", 2) + token := strings.TrimSpace(parts[0]) + network := strings.ToLower(strings.TrimSpace(parts[1])) + if token == "" || network == "" { + return "", "", false, nil + } + if !data.IsChainEnabled(network) { + return "", "", false, nil + } + + tokenRow, err := data.GetEnabledChainTokenBySymbol(network, token) + if err != nil { + return "", "", false, err + } + if tokenRow == nil || tokenRow.ID == 0 || !service.ChainTokenReadyForPayment(*tokenRow) { + return "", "", false, nil + } + return token, network, true, nil +} + // RegisterRoute 路由注册 func RegisterRoute(e *echo.Echo) { e.POST("/", func(c echo.Context) error { @@ -130,16 +160,26 @@ func RegisterRoute(e *echo.Echo) { money := getString(params, "money") name := getString(params, "name") + epayType := strings.TrimSpace(getString(params, "type")) notifyURL := getString(params, "notify_url") outTradeNo := getString(params, "out_trade_no") returnURL := getString(params, "return_url") - token := strings.TrimSpace(getString(params, "token")) - if token == "" { - token = data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "") + selectorToken, selectorNetwork, selectorMatched, err := resolveEPayTypeSelector(epayType) + if err != nil { + return comm.Ctrl.FailJson(ctx, constant.SystemErr) } + token := strings.TrimSpace(getString(params, "token")) network := strings.TrimSpace(getString(params, "network")) - if network == "" { - network = data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "") + if selectorMatched { + token = selectorToken + network = selectorNetwork + } else { + if token == "" { + token = data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "") + } + if network == "" { + network = data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "") + } } currency := strings.TrimSpace(getString(params, "currency")) if currency == "" { @@ -168,6 +208,7 @@ func RegisterRoute(e *echo.Echo) { } ctx.Set("request_body", body) + ctx.Set(comm.EPayTypeContextKey, epayType) ctx.Set(middleware.ApiKeyIDKey, apiKeyRow.ID) ctx.Set(middleware.ApiKeyRowKey, apiKeyRow) diff --git a/src/route/router_test.go b/src/route/router_test.go index 65dc15e..18bbc70 100644 --- a/src/route/router_test.go +++ b/src/route/router_test.go @@ -1272,6 +1272,9 @@ func TestEpaySubmitPhpGetCompatible(t *testing.T) { if order.PaymentType != mdb.PaymentTypeEpay { t.Fatalf("epay payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay) } + if order.EpayType != "alipay" { + t.Fatalf("epay_type = %q, want alipay", order.EpayType) + } checkoutData := getCheckoutCounterRespData(t, e, tradeID) if checkoutData["payment_type"] != "epay" { t.Fatalf("epay checkout payment_type = %v, want epay", checkoutData["payment_type"]) @@ -1327,6 +1330,100 @@ func TestEpaySubmitPhpRequestTokenNetworkOverrideDefaults(t *testing.T) { } } +func TestEpaySubmitPhpTypeSelectorOverridesRequestAndDefaults(t *testing.T) { + e := setupTestEnv(t) + + if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdc", mdb.SettingTypeString); err != nil { + t.Fatalf("seed epay.default_token: %v", err) + } + if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "solana", mdb.SettingTypeString); err != nil { + t.Fatalf("seed epay.default_network: %v", err) + } + if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultCurrency, "", mdb.SettingTypeString); err != nil { + t.Fatalf("clear epay.default_currency: %v", err) + } + + values := signEpayValues(url.Values{ + "pid": {"1"}, + "name": {"epay-type-selector-001"}, + "type": {"usdt.tron"}, + "money": {"1.00"}, + "out_trade_no": {"epay-type-selector-001"}, + "notify_url": {"https://93.184.216.34/notify"}, + "return_url": {"http://localhost/return"}, + "token": {"usdc"}, + "network": {"solana"}, + }) + + 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 selector order: %v", err) + } + if order.Token != "USDT" || order.Network != mdb.NetworkTron || order.ReceiveAddress != "TTestTronAddress001" { + t.Fatalf("selector order fields = token %q network %q address %q", order.Token, order.Network, order.ReceiveAddress) + } + if order.Currency != "CNY" { + t.Fatalf("currency = %q, want CNY", order.Currency) + } + if order.EpayType != "usdt.tron" { + t.Fatalf("epay_type = %q, want usdt.tron", order.EpayType) + } +} + +func TestEpaySubmitPhpTypeSelectorUsesDefaultCurrencyWhenMissing(t *testing.T) { + e := setupTestEnv(t) + + if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdc", mdb.SettingTypeString); err != nil { + t.Fatalf("seed epay.default_token: %v", err) + } + if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "solana", mdb.SettingTypeString); err != nil { + t.Fatalf("seed epay.default_network: %v", err) + } + if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultCurrency, "usd", mdb.SettingTypeString); err != nil { + t.Fatalf("seed epay.default_currency: %v", err) + } + if err := data.SetSetting(mdb.SettingGroupRate, "rate.forced_rate_list", `{"cny":{"usdt":0.14285714285714285},"usd":{"usdt":1}}`, mdb.SettingTypeJSON); err != nil { + t.Fatalf("seed rate.forced_rate_list for usd: %v", err) + } + + values := signEpayValues(url.Values{ + "pid": {"1"}, + "name": {"epay-type-default-cur-001"}, + "type": {"usdt.tron"}, + "money": {"1.00"}, + "out_trade_no": {"epay-type-default-cur-001"}, + "notify_url": {"https://93.184.216.34/notify"}, + "return_url": {"http://localhost/return"}, + "token": {"usdc"}, + "network": {"solana"}, + }) + + rec := doFormPost(e, "/payments/epay/v1/order/create-transaction/submit.php", values) + + 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 selector order: %v", err) + } + if order.Token != "USDT" || order.Network != mdb.NetworkTron || order.ReceiveAddress != "TTestTronAddress001" { + t.Fatalf("selector order fields = token %q network %q address %q", order.Token, order.Network, order.ReceiveAddress) + } + if order.Currency != "USD" { + t.Fatalf("currency = %q, want USD", order.Currency) + } +} + func TestEpaySubmitPhpWithoutTokenNetworkDefaultsCreatesPlaceholder(t *testing.T) { e := setupTestEnv(t) @@ -1365,12 +1462,95 @@ func TestEpaySubmitPhpWithoutTokenNetworkDefaultsCreatesPlaceholder(t *testing.T if order.PaymentType != mdb.PaymentTypeEpay { t.Fatalf("payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay) } + if order.EpayType != "alipay" { + t.Fatalf("epay_type = %q, want alipay", order.EpayType) + } checkoutData := getCheckoutCounterRespData(t, e, tradeID) if checkoutData["payment_type"] != "epay" || int(checkoutData["status"].(float64)) != mdb.StatusWaitSelect { t.Fatalf("checkout placeholder data = %#v", checkoutData) } } +func TestEpaySubmitPhpNonSelectorTypeKeepsOldLogic(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-non-selector-001"}, + "type": {"usdt-tron"}, + "money": {"1.00"}, + "out_trade_no": {"epay-non-selector-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 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) { + 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-unsupported-selector-001"}, + "type": {"usdc.tron"}, + "money": {"1.00"}, + "out_trade_no": {"epay-unsupported-selector-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 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) + } +} + func TestEpaySubmitPhpRejectsPartialResolvedTokenNetwork(t *testing.T) { e := setupTestEnv(t) @@ -1428,6 +1608,40 @@ func TestEpaySubmitPhpPostFormCompatible(t *testing.T) { } } +func TestEpaySubmitPhpTypeSelectorPostFormCompatible(t *testing.T) { + e := setupTestEnv(t) + + if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "solana", mdb.SettingTypeString); err != nil { + t.Fatalf("seed epay.default_network: %v", err) + } + + values := signEpayValues(url.Values{ + "pid": {"1"}, + "name": {"epay-post-selector-001"}, + "type": {"usdt.tron"}, + "money": {"1.00"}, + "out_trade_no": {"epay-post-selector-001"}, + "notify_url": {"https://93.184.216.34/notify"}, + "return_url": {"http://localhost/return"}, + }) + + rec := doFormPost(e, "/payments/epay/v1/order/create-transaction/submit.php", values) + 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 post selector order: %v", err) + } + if order.Token != "USDT" || order.Network != mdb.NetworkTron || order.ReceiveAddress != "TTestTronAddress001" { + t.Fatalf("post 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) + } +} + // TestCheckStatus_NotFound verifies that /pay/check-status/:trade_id returns a // graceful JSON error (not 500) when the trade_id doesn't exist. func TestCheckStatus_NotFound(t *testing.T) { @@ -1656,6 +1870,93 @@ func TestPayReturn_PaidEpayRedirectsToMerchantWithSignedParams(t *testing.T) { } } +func TestPayReturn_PaidEpayRedirectsWithOriginalType(t *testing.T) { + e := setupTestEnv(t) + tradeID := mustCreateEPayOrder(t, e, "epay-return-type-001", "https://merchant.example/return", url.Values{ + "type": {"usdt.tron"}, + }) + 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_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{ @@ -2501,6 +2802,9 @@ func TestSwitchNetwork_EpayPlaceholderCompletesChainInPlace(t *testing.T) { if parent.Status != mdb.StatusWaitPay || parent.PaymentType != mdb.PaymentTypeEpay || parent.Network != mdb.NetworkTron || parent.Token != "USDT" || parent.ReceiveAddress == "" { t.Fatalf("parent fields = status %d payment_type %q network %q token %q address %q", parent.Status, parent.PaymentType, parent.Network, parent.Token, parent.ReceiveAddress) } + if parent.EpayType != "alipay" { + t.Fatalf("parent epay_type = %q, want alipay", parent.EpayType) + } if parent.RedirectUrl != "http://localhost/return" { t.Fatalf("stored redirect_url = %q, want merchant raw return_url", parent.RedirectUrl) } @@ -2616,6 +2920,47 @@ func TestSwitchNetwork_OkPayFromEpayWaitSelectPlaceholder(t *testing.T) { } } +func TestSwitchNetwork_EpayChildInheritsOriginalType(t *testing.T) { + e := setupTestEnv(t) + tradeID := mustCreateEPayOrder(t, e, "epay-switch-child-type-001", "http://localhost/return", url.Values{ + "type": {"usdt.tron"}, + }) + + rec := doPost(e, "/pay/switch-network", map[string]interface{}{ + "trade_id": tradeID, + "token": "USDT", + "network": "solana", + }) + if rec.Code != http.StatusOK { + t.Fatalf("switch epay parent to solana failed: %d %s", rec.Code, rec.Body.String()) + } + resp := parseResp(t, rec) + respData, _ := resp["data"].(map[string]interface{}) + subTradeID, _ := respData["trade_id"].(string) + if subTradeID == "" || subTradeID == tradeID { + t.Fatalf("expected child trade_id, got %q", subTradeID) + } + + subOrder, err := data.GetOrderInfoByTradeId(subTradeID) + if err != nil { + t.Fatalf("load sub-order: %v", err) + } + if subOrder.EpayType != "usdt.tron" { + t.Fatalf("sub-order epay_type = %q, want usdt.tron", subOrder.EpayType) + } + apiKeyRow, err := data.GetApiKeyByID(subOrder.ApiKeyID) + if err != nil { + t.Fatalf("load api key: %v", err) + } + params, err := service.BuildEPayResultParams(subOrder, apiKeyRow) + if err != nil { + t.Fatalf("BuildEPayResultParams(): %v", err) + } + if params["type"] != "usdt.tron" { + t.Fatalf("callback type = %q, want usdt.tron", params["type"]) + } +} + func TestSwitchNetwork_OkPayIntegration(t *testing.T) { shopID := strings.TrimSpace(os.Getenv("EPUSDT_OKPAY_ID")) if shopID == "" {