2 Commits

Author SHA1 Message Date
line-6000 2dc538c0e1 feat(epay): support type selectors and preserve callback type 2026-06-28 11:56:58 +08:00
line-6000 b4d1a4d8d4 Merge pull request #91 from GMWalletApp/dev
Add Aptos USDT/USDC payment support and drive token RPC routing
2026-06-20 14:31:03 +08:00
12 changed files with 546 additions and 25 deletions
+4 -4
View File
@@ -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.
+12 -3
View File
@@ -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)
+2 -1
View File
@@ -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
+1
View File
@@ -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"`
+1
View File
@@ -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 {
+3 -3
View File
@@ -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 {
+11 -1
View File
@@ -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",
+21 -2
View File
@@ -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,
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",
+8
View File
@@ -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,
+88 -2
View File
@@ -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()
+42 -1
View File
@@ -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,17 +160,27 @@ 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")
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 selectorMatched {
token = selectorToken
network = selectorNetwork
} else {
if token == "" {
token = data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "")
}
network := strings.TrimSpace(getString(params, "network"))
if network == "" {
network = data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "")
}
}
currency := strings.TrimSpace(getString(params, "currency"))
if currency == "" {
currency = data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "")
@@ -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)
+345
View File
@@ -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 == "" {