Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions src/controller/comm/pay_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand All @@ -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 支付状态检测
Expand Down
28 changes: 17 additions & 11 deletions src/model/response/pay_response.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
56 changes: 56 additions & 0 deletions src/model/service/pay_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package service

import (
"errors"
"strings"

"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
Expand All @@ -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)
Expand All @@ -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
}
85 changes: 85 additions & 0 deletions src/model/service/pay_service_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
56 changes: 56 additions & 0 deletions src/route/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
}
4 changes: 2 additions & 2 deletions src/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -539,9 +539,9 @@
createdAt: "{{.CreatedAt}}",
is_selected: "{{.IsSelected}}"
};
var PAYMENT_OPTIONS = null;
var PAYMENT_OPTIONS = {{.PaymentOptionsJSON}};
</script>
<script defer src="/static/payment.js"></script>
</body>

</html>
</html>
18 changes: 5 additions & 13 deletions src/static/payment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 },
];
}

Expand All @@ -968,5 +962,3 @@ document.addEventListener('DOMContentLoaded', () => {
const walletBtn = $('btn-connect-wallet');
if (walletBtn) walletBtn.style.display = CONFIG.wallet.enabled ? '' : 'none';
});