diff --git a/src/controller/comm/pay_controller.go b/src/controller/comm/pay_controller.go index 66e68506..2a5f9b9a 100644 --- a/src/controller/comm/pay_controller.go +++ b/src/controller/comm/pay_controller.go @@ -17,14 +17,33 @@ import ( func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) { tradeId := ctx.Param("trade_id") resp, err := service.GetCheckoutCounterByTradeId(tradeId) + + type checkoutCounterPageData struct { + response.CheckoutCounterResponse + PaymentOptionsJSON template.JS + } + buildPageData := func(resp response.CheckoutCounterResponse) (checkoutCounterPageData, error) { + paymentOptionsJSON, err := json.Marshal(resp.PaymentOptions) + if err != nil { + return checkoutCounterPageData{}, err + } + return checkoutCounterPageData{ + CheckoutCounterResponse: resp, + PaymentOptionsJSON: template.JS(string(paymentOptionsJSON)), + }, nil + } + if err != nil { if err == service.ErrOrder { tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html")) if err != nil { return ctx.String(http.StatusOK, err.Error()) } - emptyResp := response.CheckoutCounterResponse{} - return tmpl.Execute(ctx.Response(), emptyResp) + pageData, err := buildPageData(response.CheckoutCounterResponse{}) + if err != nil { + return ctx.String(http.StatusOK, err.Error()) + } + return tmpl.Execute(ctx.Response(), pageData) } return ctx.String(http.StatusOK, err.Error()) } @@ -39,7 +58,11 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) { } fmt.Printf("%v\n", string(jsonByte)) - return tmpl.Execute(ctx.Response(), resp) + pageData, err := buildPageData(*resp) + if err != nil { + return ctx.String(http.StatusOK, err.Error()) + } + return tmpl.Execute(ctx.Response(), pageData) } // CheckStatus 支付状态检测 diff --git a/src/model/response/pay_response.go b/src/model/response/pay_response.go index 190194eb..08c7e7d8 100644 --- a/src/model/response/pay_response.go +++ b/src/model/response/pay_response.go @@ -1,17 +1,23 @@ package response +type PaymentOption struct { + Token string `json:"token"` + Network string `json:"network"` +} + type CheckoutCounterResponse struct { - TradeId string `json:"trade_id"` // epusdt订单号 - Amount float64 `json:"amount"` // 订单金额,保留4位小数 法币金额 - ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 加密货币金额 - Token string `json:"token"` // 所属币种 TRX USDT...... - Currency string `json:"currency"` // 法币币种 CNY USD ... - ReceiveAddress string `json:"receive_address"` // 收款钱包地址 - Network string `json:"network"` // 网络 TRON ETH ... - ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳 - RedirectUrl string `json:"redirect_url"` - CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳 - IsSelected bool `json:"is_selected"` + TradeId string `json:"trade_id"` // epusdt订单号 + Amount float64 `json:"amount"` // 订单金额,保留4位小数 法币金额 + ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 加密货币金额 + Token string `json:"token"` // 所属币种 TRX USDT...... + Currency string `json:"currency"` // 法币币种 CNY USD ... + ReceiveAddress string `json:"receive_address"` // 收款钱包地址 + Network string `json:"network"` // 网络 TRON ETH ... + ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳 + RedirectUrl string `json:"redirect_url"` + CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳 + IsSelected bool `json:"is_selected"` + PaymentOptions []PaymentOption `json:"payment_options,omitempty"` } type CheckStatusResponse struct { diff --git a/src/model/service/pay_service.go b/src/model/service/pay_service.go index 011707d1..48f0bca9 100644 --- a/src/model/service/pay_service.go +++ b/src/model/service/pay_service.go @@ -2,6 +2,7 @@ package service import ( "errors" + "strings" "github.com/assimon/luuu/config" "github.com/assimon/luuu/model/data" @@ -11,6 +12,57 @@ import ( var ErrOrder = errors.New("不存在待支付订单或已过期") +var checkoutPaymentOptionCatalog = []response.PaymentOption{ + {Token: "USDT", Network: mdb.NetworkTron}, + {Token: "TRX", Network: mdb.NetworkTron}, + {Token: "USDT", Network: mdb.NetworkSolana}, + {Token: "USDC", Network: mdb.NetworkSolana}, + {Token: "USDT", Network: mdb.NetworkEthereum}, + {Token: "USDC", Network: mdb.NetworkEthereum}, +} + +func buildCheckoutPaymentOptions(order *mdb.Orders) ([]response.PaymentOption, error) { + wallets, err := data.GetAvailableWalletAddress() + if err != nil { + return nil, err + } + + enabledNetworks := make(map[string]struct{}, len(wallets)) + for _, wallet := range wallets { + enabledNetworks[strings.ToLower(strings.TrimSpace(wallet.Network))] = struct{}{} + } + + options := make([]response.PaymentOption, 0, len(checkoutPaymentOptionCatalog)) + seen := make(map[string]struct{}, len(checkoutPaymentOptionCatalog)+1) + addOption := func(token string, network string) { + token = strings.ToUpper(strings.TrimSpace(token)) + network = strings.ToLower(strings.TrimSpace(network)) + if token == "" || network == "" { + return + } + key := network + ":" + token + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + options = append(options, response.PaymentOption{ + Token: token, + Network: network, + }) + } + + if order != nil { + addOption(order.Token, order.Network) + } + for _, option := range checkoutPaymentOptionCatalog { + if _, ok := enabledNetworks[strings.ToLower(option.Network)]; !ok { + continue + } + addOption(option.Token, option.Network) + } + return options, nil +} + // GetCheckoutCounterByTradeId returns checkout info for a pending order. func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) { orderInfo, err := data.GetOrderInfoByTradeId(tradeId) @@ -34,5 +86,9 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo CreatedAt: orderInfo.CreatedAt.TimestampMilli(), IsSelected: orderInfo.IsSelected, } + resp.PaymentOptions, err = buildCheckoutPaymentOptions(orderInfo) + if err != nil { + return nil, err + } return resp, nil } diff --git a/src/model/service/pay_service_test.go b/src/model/service/pay_service_test.go new file mode 100644 index 00000000..20497ede --- /dev/null +++ b/src/model/service/pay_service_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "testing" + + "github.com/assimon/luuu/internal/testutil" + "github.com/assimon/luuu/model/data" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/model/response" +) + +func paymentOptionSet(options []response.PaymentOption) map[string]struct{} { + set := make(map[string]struct{}, len(options)) + for _, option := range options { + set[option.Network+":"+option.Token] = struct{}{} + } + return set +} + +func TestGetCheckoutCounterByTradeIdFiltersPaymentOptionsByConfiguredNetworks(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTron, "tron_wallet_1"); err != nil { + t.Fatalf("add tron wallet: %v", err) + } + if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkSolana, "sol_wallet_1"); err != nil { + t.Fatalf("add sol wallet: %v", err) + } + + order, err := CreateTransaction(newCreateTransactionRequest("checkout-options-1", 1)) + if err != nil { + t.Fatalf("create transaction: %v", err) + } + + resp, err := GetCheckoutCounterByTradeId(order.TradeId) + if err != nil { + t.Fatalf("get checkout counter: %v", err) + } + + options := paymentOptionSet(resp.PaymentOptions) + for _, key := range []string{ + "tron:USDT", + "tron:TRX", + "solana:USDT", + "solana:USDC", + } { + if _, ok := options[key]; !ok { + t.Fatalf("expected payment option %s, got %#v", key, resp.PaymentOptions) + } + } + if _, ok := options["ethereum:USDT"]; ok { + t.Fatalf("did not expect ethereum options without ethereum wallet, got %#v", resp.PaymentOptions) + } +} + +func TestGetCheckoutCounterByTradeIdDoesNotAdvertiseUnconfiguredNetworks(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTron, "tron_wallet_1"); err != nil { + t.Fatalf("add tron wallet: %v", err) + } + + order, err := CreateTransaction(newCreateTransactionRequest("checkout-options-2", 1)) + if err != nil { + t.Fatalf("create transaction: %v", err) + } + + resp, err := GetCheckoutCounterByTradeId(order.TradeId) + if err != nil { + t.Fatalf("get checkout counter: %v", err) + } + + options := paymentOptionSet(resp.PaymentOptions) + if _, ok := options["tron:USDT"]; !ok { + t.Fatalf("expected current tron option, got %#v", resp.PaymentOptions) + } + if _, ok := options["solana:USDT"]; ok { + t.Fatalf("did not expect solana options without sol wallet, got %#v", resp.PaymentOptions) + } + if _, ok := options["ethereum:USDT"]; ok { + t.Fatalf("did not expect ethereum options without ethereum wallet, got %#v", resp.PaymentOptions) + } +} diff --git a/src/route/router_test.go b/src/route/router_test.go index ddc0a5ca..924b901b 100644 --- a/src/route/router_test.go +++ b/src/route/router_test.go @@ -6,9 +6,11 @@ import ( "net/http" "net/http/httptest" "os" + "regexp" "strings" "testing" + "github.com/assimon/luuu/config" "github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/util/log" @@ -37,6 +39,8 @@ func setupTestEnv(t *testing.T) *echo.Echo { viper.Set("sqlite_database_filename", tmpDir+"/test.db") viper.Set("runtime_sqlite_filename", tmpDir+"/runtime.db") + config.StaticFilePath = "/tmp/epusdt-src/src/static" + log.Init() // init config paths @@ -207,6 +211,21 @@ func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interfac return resp } +func extractPaymentOptions(t *testing.T, body string) []map[string]string { + t.Helper() + re := regexp.MustCompile(`(?m)^\s*var PAYMENT_OPTIONS = (.+);$`) + match := re.FindStringSubmatch(body) + if len(match) != 2 { + t.Fatalf("payment options script not found in body: %s", body) + } + + var options []map[string]string + if err := json.Unmarshal([]byte(match[1]), &options); err != nil { + t.Fatalf("unmarshal payment options: %v", err) + } + return options +} + // TestWalletAddAndList tests adding wallets via API and listing them. func TestWalletAddAndList(t *testing.T) { e := setupTestEnv(t) @@ -401,3 +420,40 @@ func TestCreateOrderNetworkIsolation(t *testing.T) { t.Errorf("expected SolTestAddress001, got %v", data["receive_address"]) } } + +func TestCheckoutCounterPageAdvertisesOnlyConfiguredNetworks(t *testing.T) { + e := setupTestEnv(t) + + if err := dao.Mdb.Model(&mdb.WalletAddress{}). + Where("network = ?", mdb.NetworkSolana). + Update("status", mdb.TokenStatusDisable).Error; err != nil { + t.Fatalf("disable solana wallets: %v", err) + } + + body := signBody(map[string]interface{}{ + "order_id": "checkout-page-1", + "amount": 1.00, + "token": "usdt", + "currency": "cny", + "network": "tron", + "notify_url": "http://localhost/notify", + }) + rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body) + resp := parseResp(t, rec) + data := resp["data"].(map[string]interface{}) + tradeID := data["trade_id"].(string) + + req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter/"+tradeID, nil) + page := httptest.NewRecorder() + e.ServeHTTP(page, req) + + options := extractPaymentOptions(t, page.Body.String()) + if len(options) == 0 { + t.Fatal("expected at least one payment option") + } + for _, option := range options { + if option["network"] != "tron" { + t.Fatalf("expected only tron payment options, got %#v", options) + } + } +} diff --git a/src/static/index.html b/src/static/index.html index 71d00f9f..07158abe 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -539,9 +539,9 @@ createdAt: "{{.CreatedAt}}", is_selected: "{{.IsSelected}}" }; - var PAYMENT_OPTIONS = null; + var PAYMENT_OPTIONS = {{.PaymentOptionsJSON}}; - \ No newline at end of file + diff --git a/src/static/payment.js b/src/static/payment.js index ab4eee39..1c88199c 100644 --- a/src/static/payment.js +++ b/src/static/payment.js @@ -676,12 +676,12 @@ async function confirmStep1() { history.replaceState(null, '', _url.toString()); renderOrderId(); } - ORDER.token = data?.token ?? opt.token; - ORDER.network = data?.network ?? opt.network; - ORDER.actualAmount = data?.actual_amount != null ? String(data.actual_amount) : opt.actualAmount; + ORDER.token = data?.token ?? opt.token ?? ORDER.token; + ORDER.network = data?.network ?? opt.network ?? ORDER.network; + ORDER.actualAmount = data?.actual_amount != null ? String(data.actual_amount) : ORDER.actualAmount; ORDER.amount = data?.amount != null ? String(data.amount) : ORDER.amount; ORDER.currency = data?.currency ?? ORDER.currency; - ORDER.receiveAddress = data?.receive_address ?? opt.receiveAddress; + ORDER.receiveAddress = data?.receive_address ?? ORDER.receiveAddress; if (data?.expiration_time != null) ORDER.expirationTime = String(data.expiration_time); if (data?.created_at != null) ORDER.createdAt = String(data.created_at); if (data?.redirect_url) ORDER.redirectUrl = data.redirect_url; @@ -939,15 +939,9 @@ document.addEventListener('DOMContentLoaded', () => { } renderOrderId(); - // 确保 PAYMENT_OPTIONS 存在(服务端未注入时降级为内置默认选项) if (typeof PAYMENT_OPTIONS === 'undefined' || !Array.isArray(PAYMENT_OPTIONS) || !PAYMENT_OPTIONS.length) { window.PAYMENT_OPTIONS = [ - { token: 'USDT', network: 'TRON', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress }, - { token: 'TRX', network: 'TRON', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress }, - { token: 'USDT', network: 'Solana', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress }, - { token: 'USDC', network: 'Solana', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress }, - { token: 'USDT', network: 'Ethereum', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress }, - { token: 'USDC', network: 'Ethereum', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress }, + { token: ORDER.token, network: ORDER.network }, ]; } @@ -968,5 +962,3 @@ document.addEventListener('DOMContentLoaded', () => { const walletBtn = $('btn-connect-wallet'); if (walletBtn) walletBtn.style.display = CONFIG.wallet.enabled ? '' : 'none'; }); - -