From 29a5a6ca7e45bda9005dac84e1d6517cdce51cc5 Mon Sep 17 00:00:00 2001 From: mala Date: Mon, 13 Jul 2026 03:37:36 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20Base=20?= =?UTF-8?q?=E4=B8=8E=20Arbitrum=20=E5=9F=BA=E7=A1=80=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/internal/testutil/testdb.go | 5 +- src/model/dao/mdb_table_init.go | 9 ++ src/model/dao/mdb_table_init_test.go | 41 +++++++ src/model/data/order_data_test.go | 18 +++ src/model/data/wallet_address_data.go | 2 +- src/model/data/wallet_address_data_test.go | 17 +++ src/model/mdb/wallet_address_mdb.go | 2 + src/model/service/manual_payment_verify.go | 6 +- src/model/service/order_service.go | 2 +- src/model/service/task_service.go | 8 ++ src/task/listen.go | 2 + src/task/listen_chain_common.go | 4 + src/task/listen_chain_common_test.go | 28 +++++ src/task/listen_evm_added_chains.go | 130 +++++++++++++++++++++ wiki/API.md | 8 ++ 16 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 src/task/listen_evm_added_chains.go diff --git a/README.md b/README.md index 9ddb8ebc..48c46b5c 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Epusdt ## 实现原理 -Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或 RPC 节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属: +Epusdt 通过监听多条区块链网络(TRON、Ethereum、BSC、Polygon、Base、Arbitrum、Solana、TON、Aptos 等)的 API 或 RPC 节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属: ```text 工作流程: diff --git a/src/internal/testutil/testdb.go b/src/internal/testutil/testdb.go index 6f578768..b99de8d1 100644 --- a/src/internal/testutil/testdb.go +++ b/src/internal/testutil/testdb.go @@ -66,7 +66,7 @@ func SetupTestDatabases(t testing.TB) func() { // Seed all standard chains as enabled so IsChainEnabled checks pass. for _, network := range []string{ mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum, - mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkTon, + mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkBase, mdb.NetworkArbitrum, mdb.NetworkTon, } { mainDB.Create(&mdb.Chain{Network: network, Enabled: true}) } @@ -75,6 +75,9 @@ func SetupTestDatabases(t testing.TB) func() { {Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true}, {Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6, Enabled: true}, {Network: mdb.NetworkBsc, Symbol: "USDT", ContractAddress: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18, Enabled: true}, + {Network: mdb.NetworkBase, Symbol: "USDC", ContractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", Decimals: 6, Enabled: true}, + {Network: mdb.NetworkArbitrum, Symbol: "USDC", ContractAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", Decimals: 6, Enabled: true}, + {Network: mdb.NetworkArbitrum, Symbol: "USDT", ContractAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", Decimals: 6, Enabled: true}, {Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true}, {Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true}, }) diff --git a/src/model/dao/mdb_table_init.go b/src/model/dao/mdb_table_init.go index 73865353..2f5d672f 100644 --- a/src/model/dao/mdb_table_init.go +++ b/src/model/dao/mdb_table_init.go @@ -70,6 +70,8 @@ func seedChains() { {Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5}, {Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5}, {Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5}, + {Network: mdb.NetworkBase, DisplayName: "Base", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5, Extra: `{"chain_id":8453}`}, + {Network: mdb.NetworkArbitrum, DisplayName: "Arbitrum One", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5, Extra: `{"chain_id":42161}`}, {Network: mdb.NetworkTon, DisplayName: "TON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5}, {Network: mdb.NetworkAptos, DisplayName: "Aptos", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5}, } @@ -106,6 +108,11 @@ func seedChainTokens() { {Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true}, // Plasma {Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true}, + // Base(Circle 原生 USDC,暂不包含跨链 USDbC) + {Network: mdb.NetworkBase, Symbol: "USDC", ContractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", Decimals: 6, Enabled: true}, + // Arbitrum One(Circle 原生 USDC,以及已升级为 USDT0 的官方 USDT 合约) + {Network: mdb.NetworkArbitrum, Symbol: "USDC", ContractAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", Decimals: 6, Enabled: true}, + {Network: mdb.NetworkArbitrum, Symbol: "USDT", ContractAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", Decimals: 6, Enabled: true}, // TON {Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true}, {Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true}, @@ -146,6 +153,8 @@ func defaultRpcNodes() []mdb.RpcNode { {Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, + {Network: mdb.NetworkBase, Url: "wss://base-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, + {Network: mdb.NetworkArbitrum, Url: "wss://arbitrum-one-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkTon, Url: "https://ton-blockchain.github.io/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkAptos, Url: "https://aptos-rest.publicnode.com/", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown}, {Network: mdb.NetworkEthereum, Url: "https://rpc.epusdt.com/ethereum", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown}, diff --git a/src/model/dao/mdb_table_init_test.go b/src/model/dao/mdb_table_init_test.go index 7d30738d..7751411c 100644 --- a/src/model/dao/mdb_table_init_test.go +++ b/src/model/dao/mdb_table_init_test.go @@ -149,6 +149,47 @@ func TestSeedChainTokensIncludesAptosAssets(t *testing.T) { } } +func TestSeedChainsAndTokensIncludeBaseAndArbitrum(t *testing.T) { + db := setupSeedTableTestDB(t, &mdb.Chain{}, &mdb.ChainToken{}) + Mdb = db + + seedChains() + seedChainTokens() + + chains := map[string]struct { + name string + chainID string + }{ + mdb.NetworkBase: {name: "Base", chainID: `"chain_id":8453`}, + mdb.NetworkArbitrum: {name: "Arbitrum One", chainID: `"chain_id":42161`}, + } + for network, want := range chains { + var row mdb.Chain + if err := Mdb.Where("network = ?", network).Take(&row).Error; err != nil { + t.Fatalf("load %s chain seed: %v", network, err) + } + if !row.Enabled || row.DisplayName != want.name || !strings.Contains(row.Extra, want.chainID) { + t.Fatalf("unexpected %s chain seed: %+v", network, row) + } + } + + contracts := map[string]string{ + mdb.NetworkBase + "/USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + mdb.NetworkArbitrum + "/USDC": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + mdb.NetworkArbitrum + "/USDT": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + } + for key, contract := range contracts { + parts := strings.Split(key, "/") + var row mdb.ChainToken + if err := Mdb.Where("network = ? AND symbol = ?", parts[0], parts[1]).Take(&row).Error; err != nil { + t.Fatalf("load token seed %s: %v", key, err) + } + if !row.Enabled || row.Decimals != 6 || !strings.EqualFold(row.ContractAddress, contract) { + t.Fatalf("unexpected token seed %s: %+v", key, row) + } + } +} + func TestSeedDefaultSettingsIncludesSystemLogLevel(t *testing.T) { db := setupSeedSettingsTestDB(t) Mdb = db diff --git a/src/model/data/order_data_test.go b/src/model/data/order_data_test.go index 9f48536f..cd3df793 100644 --- a/src/model/data/order_data_test.go +++ b/src/model/data/order_data_test.go @@ -200,3 +200,21 @@ func TestAptosTransactionLockAddressUsesCanonicalKey(t *testing.T) { t.Fatalf("aptos lock lookup = %q, want trade-aptos", gotTradeID) } } + +func TestAddedEvmNetworkTransactionLocksAreIsolated(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + address := "0xA1B2c3D4e5F60718293aBcDeF001122334455667" + if err := LockTransaction(mdb.NetworkBase, address, "USDC", "base-trade", 1.23, time.Hour); err != nil { + t.Fatalf("lock Base: %v", err) + } + if err := LockTransaction(mdb.NetworkArbitrum, address, "USDC", "arb-trade", 1.23, time.Hour); err != nil { + t.Fatalf("lock Arbitrum: %v", err) + } + for network, want := range map[string]string{mdb.NetworkBase: "base-trade", mdb.NetworkArbitrum: "arb-trade"} { + got, err := GetTradeIdByWalletAddressAndAmountAndToken(network, address, "USDC", 1.23) + if err != nil || got != want { + t.Fatalf("%s lock = %q, %v; want %q", network, got, err, want) + } + } +} diff --git a/src/model/data/wallet_address_data.go b/src/model/data/wallet_address_data.go index 14902c1f..dd34e557 100644 --- a/src/model/data/wallet_address_data.go +++ b/src/model/data/wallet_address_data.go @@ -17,7 +17,7 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) { // isEVMNetwork 判断是否是 EVM 网络 func isEVMNetwork(network string) bool { switch network { - case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: + case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkBase, mdb.NetworkArbitrum: return true } return false diff --git a/src/model/data/wallet_address_data_test.go b/src/model/data/wallet_address_data_test.go index 65fa5dcd..704a27b4 100644 --- a/src/model/data/wallet_address_data_test.go +++ b/src/model/data/wallet_address_data_test.go @@ -120,3 +120,20 @@ func TestAddWalletAddressWithNetworkNormalizesMoveAddressVariants(t *testing.T) t.Fatalf("add equivalent aptos wallet error = %v, want already exists", err) } } + +func TestAddedEvmNetworksNormalizeWalletAddresses(t *testing.T) { + for _, network := range []string{mdb.NetworkBase, mdb.NetworkArbitrum} { + t.Run(network, func(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + input := "0xA1B2c3D4e5F60718293aBcDeF001122334455667" + row, err := AddWalletAddressWithNetwork(network, input) + if err != nil { + t.Fatalf("add wallet: %v", err) + } + if row.Address != strings.ToLower(input) { + t.Fatalf("wallet address = %q", row.Address) + } + }) + } +} diff --git a/src/model/mdb/wallet_address_mdb.go b/src/model/mdb/wallet_address_mdb.go index 423c4ec6..3eda6f88 100644 --- a/src/model/mdb/wallet_address_mdb.go +++ b/src/model/mdb/wallet_address_mdb.go @@ -12,6 +12,8 @@ const ( NetworkBsc = "binance" NetworkPolygon = "polygon" NetworkPlasma = "plasma" + NetworkBase = "base" + NetworkArbitrum = "arbitrum" NetworkTon = "ton" NetworkAptos = "aptos" ) diff --git a/src/model/service/manual_payment_verify.go b/src/model/service/manual_payment_verify.go index 3fe82e69..883c4d26 100644 --- a/src/model/service/manual_payment_verify.go +++ b/src/model/service/manual_payment_verify.go @@ -91,7 +91,7 @@ func validateManualOrderPaymentDefault(order *mdb.Orders, blockTransactionID str canonicalTxID, err = validateManualTonPayment(order, txID) case mdb.NetworkAptos: canonicalTxID, err = ValidateManualAptosPayment(order, txID) - case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: + case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkBase, mdb.NetworkArbitrum: canonicalTxID, err = validateManualEvmPayment(order, txID) default: return "", fmt.Errorf("unsupported manual payment verification network: %s", order.Network) @@ -125,7 +125,7 @@ func ensureManualBlockTransactionUnused(order *mdb.Orders, canonicalTxID string) func manualBlockTransactionIDIsHex(network string) bool { switch strings.ToLower(strings.TrimSpace(network)) { - case mdb.NetworkTron, mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkAptos: + case mdb.NetworkTron, mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkBase, mdb.NetworkArbitrum, mdb.NetworkAptos: return true default: return false @@ -151,7 +151,7 @@ func equivalentManualBlockTransactionIDs(network, canonicalTxID string) []string add(canonicalTxID) switch network { - case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkAptos: + case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkBase, mdb.NetworkArbitrum, mdb.NetworkAptos: body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X") body = strings.ToLower(body) add("0x" + body) diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index 82a47cd3..e9a3c73b 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -57,7 +57,7 @@ func normalizeOrderAddressByNetwork(network, address string) string { network = strings.ToLower(strings.TrimSpace(network)) address = strings.TrimSpace(address) switch network { - case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: + case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkBase, mdb.NetworkArbitrum: return strings.ToLower(address) case mdb.NetworkTon: if normalized, err := addressutil.NormalizeTonAddress(address); err == nil { diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go index 40b1d948..aa329fdd 100644 --- a/src/model/service/task_service.go +++ b/src/model/service/task_service.go @@ -193,6 +193,10 @@ func evmChainLogLabel(chainNetwork string) string { return "POLYGON" case mdb.NetworkPlasma: return "PLASMA" + case mdb.NetworkBase: + return "BASE" + case mdb.NetworkArbitrum: + return "ARBITRUM" default: return "EVM" } @@ -350,6 +354,10 @@ func networkDisplay(n string) string { return "Polygon" case mdb.NetworkPlasma: return "Plasma" + case mdb.NetworkBase: + return "Base" + case mdb.NetworkArbitrum: + return "Arbitrum One" default: if n == "" { return "Tron" diff --git a/src/task/listen.go b/src/task/listen.go index 452813c9..5d506d97 100644 --- a/src/task/listen.go +++ b/src/task/listen.go @@ -13,6 +13,8 @@ func Start() { go StartBscWebSocketListener() go StartPolygonWebSocketListener() go StartPlasmaWebSocketListener() + go StartBaseWebSocketListener() + go StartArbitrumWebSocketListener() go StartTronBlockScannerListener() go StartTonBlockScannerListener() go StartAptosLedgerScannerListener() diff --git a/src/task/listen_chain_common.go b/src/task/listen_chain_common.go index 301dc058..4ebb3d0a 100644 --- a/src/task/listen_chain_common.go +++ b/src/task/listen_chain_common.go @@ -84,6 +84,10 @@ func loadChainTokenContracts(network, logPrefix string) []common.Address { if c == "" { continue } + if !common.IsHexAddress(c) { + log.Sugar.Warnf("%s skip invalid token contract network=%s symbol=%s contract=%q", logPrefix, network, t.Symbol, c) + continue + } addrs = append(addrs, common.HexToAddress(c)) } return addrs diff --git a/src/task/listen_chain_common_test.go b/src/task/listen_chain_common_test.go index e6666fda..4971eb5f 100644 --- a/src/task/listen_chain_common_test.go +++ b/src/task/listen_chain_common_test.go @@ -10,6 +10,7 @@ import ( "github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/mdb" epLog "github.com/GMWalletApp/epusdt/util/log" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "go.uber.org/zap" ) @@ -212,3 +213,30 @@ func TestResolveChainWsURLDisabledRow(t *testing.T) { t.Fatalf("resolveChainWsURL() = (%q, true), want false", got) } } + +func TestAddedEvmRecipientSnapshotsAreIsolated(t *testing.T) { + base := common.HexToAddress("0x1111111111111111111111111111111111111111") + arbitrum := common.HexToAddress("0x2222222222222222222222222222222222222222") + storeAddedEvmRecipients(mdb.NetworkBase, []mdb.WalletAddress{{Address: base.Hex()}}) + storeAddedEvmRecipients(mdb.NetworkArbitrum, []mdb.WalletAddress{{Address: arbitrum.Hex()}}) + if !isWatchedAddedEvmRecipient(mdb.NetworkBase, base) || isWatchedAddedEvmRecipient(mdb.NetworkBase, arbitrum) { + t.Fatal("Base recipient snapshot is not isolated") + } + if !isWatchedAddedEvmRecipient(mdb.NetworkArbitrum, arbitrum) || isWatchedAddedEvmRecipient(mdb.NetworkArbitrum, base) { + t.Fatal("Arbitrum recipient snapshot is not isolated") + } +} + +func TestLoadChainTokenContractsSkipsInvalidEvmContracts(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + if err := dao.Mdb.Create(&mdb.ChainToken{Network: mdb.NetworkBase, Symbol: "BAD", ContractAddress: "not-an-address", Enabled: true}).Error; err != nil { + t.Fatalf("create invalid token: %v", err) + } + contracts := loadChainTokenContracts(mdb.NetworkBase, "[TEST]") + for _, contract := range contracts { + if contract == (common.Address{}) { + t.Fatal("invalid contract was converted to zero address") + } + } +} diff --git a/src/task/listen_evm_added_chains.go b/src/task/listen_evm_added_chains.go new file mode 100644 index 00000000..41e605bb --- /dev/null +++ b/src/task/listen_evm_added_chains.go @@ -0,0 +1,130 @@ +package task + +import ( + "context" + "math/big" + "strings" + "sync" + "time" + + "github.com/GMWalletApp/epusdt/model/data" + "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/GMWalletApp/epusdt/model/service" + "github.com/GMWalletApp/epusdt/util/log" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +type addedEvmRecipientSnapshot struct { + addrs map[string]struct{} +} + +var addedEvmRecipients sync.Map + +func StartBaseWebSocketListener() { + startAddedEvmWebSocketListener(mdb.NetworkBase, "[BASE-WS]") +} + +func StartArbitrumWebSocketListener() { + startAddedEvmWebSocketListener(mdb.NetworkArbitrum, "[ARBITRUM-WS]") +} + +func startAddedEvmWebSocketListener(network, logPrefix string) { + for { + if data.IsChainEnabled(network) { + if contracts := loadChainTokenContracts(network, logPrefix); len(contracts) > 0 { + runAddedEvmListener(network, logPrefix, contracts) + } + } + time.Sleep(10 * time.Second) + } +} + +func runAddedEvmListener(network, logPrefix string, contracts []common.Address) { + ctx, cancel := chainEnabledWatchdog(network, logPrefix, chainTokenFingerprint(network)) + defer cancel() + + wallets, err := data.GetAvailableWalletAddressByNetwork(network) + if err != nil { + log.Sugar.Errorf("%s failed to get wallet addresses: %v", logPrefix, err) + return + } + storeAddedEvmRecipients(network, wallets) + go refreshAddedEvmRecipients(ctx, network, logPrefix) + + wsNode, ok := resolveChainWsNode(network, logPrefix) + if !ok { + return + } + log.Sugar.Infof("%s connecting using WSS node %s watching %d contract(s)", logPrefix, data.RpcNodeLogLabel(wsNode), len(contracts)) + query := ethereum.FilterQuery{Addresses: contracts, Topics: [][]common.Hash{}} + runEvmWsLogListener(ctx, network, logPrefix, wsNode, query, func(client *ethclient.Client, vLog types.Log) { + processAddedEvmLog(client, network, logPrefix, vLog) + }) +} + +func refreshAddedEvmRecipients(ctx context.Context, network, logPrefix string) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + wallets, err := data.GetAvailableWalletAddressByNetwork(network) + if err != nil { + log.Sugar.Warnf("%s refresh wallet addresses: %v", logPrefix, err) + continue + } + storeAddedEvmRecipients(network, wallets) + } + } +} + +func processAddedEvmLog(client *ethclient.Client, network, logPrefix string, vLog types.Log) { + if len(vLog.Topics) < 3 || vLog.Topics[0] != transferEventHash { + return + } + toAddr := common.HexToAddress(vLog.Topics[2].Hex()) + if !isWatchedAddedEvmRecipient(network, toAddr) { + return + } + amount := new(big.Int).SetBytes(vLog.Data) + blockTsMs := time.Now().UnixMilli() + header, err := client.HeaderByNumber(context.Background(), new(big.Int).SetUint64(vLog.BlockNumber)) + if err != nil { + data.RecordRpcFailure(network) + log.Sugar.Warnf("%s HeaderByNumber block=%d: %v, using local time", logPrefix, vLog.BlockNumber, err) + } else { + data.RecordRpcSuccess(network) + blockTsMs = int64(header.Time) * 1000 + } + service.TryProcessEvmERC20Transfer(network, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs) +} + +func storeAddedEvmRecipients(network string, wallets []mdb.WalletAddress) int { + addrs := make(map[string]struct{}) + for _, wallet := range wallets { + address := strings.TrimSpace(wallet.Address) + if common.IsHexAddress(address) { + addrs[strings.ToLower(common.HexToAddress(address).Hex())] = struct{}{} + } + } + addedEvmRecipients.Store(network, &addedEvmRecipientSnapshot{addrs: addrs}) + return len(addrs) +} + +func isWatchedAddedEvmRecipient(network string, address common.Address) bool { + value, ok := addedEvmRecipients.Load(network) + if !ok { + return false + } + snapshot, ok := value.(*addedEvmRecipientSnapshot) + if !ok || snapshot == nil { + return false + } + _, ok = snapshot.addrs[strings.ToLower(address.Hex())] + return ok +} diff --git a/wiki/API.md b/wiki/API.md index f30eb7d4..42173188 100644 --- a/wiki/API.md +++ b/wiki/API.md @@ -262,6 +262,14 @@ function epaySign(array $params, string $secretKey): string } ``` +当前内置的新增 EVM 主网资产为: + +- Base(`base`,Chain ID `8453`):Circle 原生 `USDC`。 +- Arbitrum One(`arbitrum`,Chain ID `42161`):Circle 原生 `USDC`、`USDT`(同合约已升级为 USDT0)。 +- 首期不支持两条链的原生 ETH,也不默认接收 Base USDbC 或 Arbitrum USDC.e。 + +链、代币合约、RPC 节点和钱包地址仍由后台数据库配置决定。Base 与 Arbitrum 都需要配置可用的 WebSocket RPC;手动补单需要额外配置 HTTP RPC(`purpose=manual_verify` 或 `both`)。 + `supported_assets` 只包含同时满足以下条件的组合: - 链已启用。 From 6706b5e6bcdf6b28fd4f2dc1eef6eed43887e154 Mon Sep 17 00:00:00 2001 From: mala Date: Mon, 13 Jul 2026 04:30:48 +0800 Subject: [PATCH 02/11] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E6=94=AF=E6=8C=81=E4=B8=8E=20API=20=E6=9A=B4=E9=9C=B2?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 48c46b5c..9b2ca8c8 100644 --- a/README.md +++ b/README.md @@ -36,19 +36,23 @@ 私有部署,按 HTTP API 接入,几分钟内就可以开始接收 **Crypto Payments**。 -### 已支持网络与代币 +### 默认内置网络与代币 | 网络 | 代币 | |------|------| | **TRC20** (Tron) | USDT、TRX | -| **ERC20** (Ethereum) | USDT、USDC、ETH | -| **Solana** | USDT、USDC | -| **BEP20** (BSC) | USDT、USDC、BNB | -| **Polygon** | USDT、USDC | +| **ERC20** (Ethereum) | USDT、USDC | +| **Solana** | USDT、USDC、SOL | +| **BEP20** (BSC) | USDT、USDC | +| **Polygon** | USDT、USDC、USDC.e | +| **Plasma** | USDT | +| **Base** (Chain ID 8453) | USDC | +| **Arbitrum One** (Chain ID 42161) | USDC、USDT(官方合约已升级为 USDT0) | +| **TON** | TON、USDT | | **Aptos** | USDC、USDT | | **更多** | 持续扩展中… | -> 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。 +> Base 默认使用 Circle 原生 USDC,不包含 USDbC;Arbitrum One 默认使用 Circle 原生 USDC 和官方 USDT/USDT0 合约,暂不支持两条链的原生 ETH。实际可用资产还取决于后台是否启用对应链、代币,以及是否配置了该链钱包地址和可用 RPC 节点,可通过 `GET /payments/gmpay/v1/config` 查询。 --- @@ -75,7 +79,7 @@ Epusdt 已完成第三方安全审计。 ## 核心特性 -- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon、Aptos 等主流网络 +- **多链多币种** — 支持 TRON、Ethereum、Solana、BSC、Polygon、Plasma、Base、Arbitrum One、TON、Aptos 等网络 - **私有化部署** — 资金完全自主掌控 - **零依赖运行** — 单个二进制即可启动,低并发场景无需 MySQL + Redis - **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac @@ -99,6 +103,7 @@ Epusdt 已完成第三方安全审计。 | [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 | | [手动部署](https://epusdt.com/guide/installation/manual.html) | 完全手动控制 | | [开发者 API 文档](https://epusdt.com/zh/guide/integration/gmpay.html) | 接口集成指南 | +| [仓库内:完整 API 文档](wiki/API.md) | 当前代码路由、签名、请求参数、回调与示例 | 仓库内还提供顶层脚本: @@ -107,6 +112,62 @@ Epusdt 已完成第三方安全审计。 --- +## API 暴露与认证边界 + +正常运行时,HTTP 端口同时承载收银台、商户支付接口和管理后台接口。部署时应通过 HTTPS 反向代理对外提供服务,并根据下表限制不需要公开的路径。 + +### 公开及订单访问接口 + +| 方法 | 路径 | 认证方式 | 用途 | +|------|------|----------|------| +| `POST` | `/` | 无 | 服务探测 | +| `GET` | `/payments/gmpay/v1/config` | 无 | 获取公开站点配置及当前可用资产 | +| `GET` | `/pay/checkout-counter/{trade_id}` | 无 | 跳转到收银台页面 | +| `GET` | `/pay/checkout-counter-resp/{trade_id}` | 无 | 获取收银台订单数据 | +| `GET` | `/pay/check-status/{trade_id}` | 无 | 查询订单状态 | +| `GET` | `/pay/return/{trade_id}` | 无 | EPay 支付完成后的商户跳转 | +| `POST` | `/pay/submit-tx-hash/{trade_id}` | `trade_id` 能力凭证 | 用户提交链上交易哈希进行补单验证 | +| `POST` | `/pay/switch-network` | `trade_id` 能力凭证 | 为订单选择或切换支付网络/通道 | + +`trade_id` 可用于读取订单状态、切换支付目标或提交交易哈希,应当视为不可公开传播的能力凭证,不要写入公开日志、统计参数或第三方页面。 + +### 商户及支付平台接口 + +| 方法 | 路径 | 认证方式 | +|------|------|----------| +| `POST` | `/payments/gmpay/v1/order/create-transaction` | 商户 PID、API Key 签名及可选 IP 白名单 | +| `GET/POST` | `/payments/epay/v1/order/create-transaction/submit.php` | EPay 签名及可选 IP 白名单 | +| `POST` | `/payments/okpay/v1/notify` | OkPay 平台签名 | + +Base 与 Arbitrum One 复用上述通用接口,不提供单独的链专用 API: + +| 网络 | GMPay 参数 | EPay `type` 示例 | +|------|------------|------------------| +| Base | `network=base`、`token=USDC` | `USDC.base` | +| Arbitrum One | `network=arbitrum`、`token=USDC` | `USDC.arbitrum` | +| Arbitrum One | `network=arbitrum`、`token=USDT` | `USDT.arbitrum` | + +### 管理后台接口 + +- `POST /admin/api/v1/auth/login` 和 `GET /admin/api/v1/auth/init-password-hash` 不要求 JWT。 +- 其余 `/admin/api/v1/*` 接口均要求管理员 JWT,覆盖 API Key、通知渠道、链与代币、RPC、钱包、订单、仪表盘和系统设置管理。 +- `GET /admin/api/v1/dashboard/rpc-stats` 是需要 JWT 的 SSE 长连接接口。 + +### 首次安装接口 + +当 `.env` 不存在或配置了 `install=true` 时,程序会先开放以下安装接口,完成安装后才启动正常业务 API: + +| 方法 | 路径 | 认证方式 | +|------|------|----------| +| `GET` | `/api/install/defaults` | 无 | +| `POST` | `/api/install` | 无 | + +安装服务默认监听 `:8000`,`POST /api/install` 会初始化数据库并返回初始管理员密码。首次启动必须限制在本机或可信内网完成,不要在未安装状态下直接将 `8000` 端口暴露到公网。 + +完整字段、签名算法、响应结构和回调示例请查看 [仓库内 API 文档](wiki/API.md)。 + +--- + ## 项目结构 ```text @@ -145,7 +206,7 @@ Epusdt ## 实现原理 -Epusdt 通过监听多条区块链网络(TRON、Ethereum、BSC、Polygon、Base、Arbitrum、Solana、TON、Aptos 等)的 API 或 RPC 节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属: +Epusdt 通过监听多条区块链网络(TRON、Ethereum、BSC、Polygon、Base、Arbitrum One、Solana、TON、Aptos 等)的 API 或 RPC 节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属: ```text 工作流程: From 91a8c25c816565381594c30a61ee00b49f9bbc26 Mon Sep 17 00:00:00 2001 From: mala Date: Mon, 13 Jul 2026 04:38:37 +0800 Subject: [PATCH 03/11] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E4=BB=A3=E5=B8=81=E5=90=88=E7=BA=A6=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 9b2ca8c8..4cf3c35f 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,33 @@ > Base 默认使用 Circle 原生 USDC,不包含 USDbC;Arbitrum One 默认使用 Circle 原生 USDC 和官方 USDT/USDT0 合约,暂不支持两条链的原生 ETH。实际可用资产还取决于后台是否启用对应链、代币,以及是否配置了该链钱包地址和可用 RPC 节点,可通过 `GET /payments/gmpay/v1/config` 查询。 +### 默认监控合约与资产标识 + +以下地址是新数据库首次启动时写入的默认配置。EVM/TRON 使用代币合约地址,Solana 使用 Mint 地址,TON 使用 Jetton Master 地址,Aptos 使用 Fungible Asset Metadata 地址;原生资产没有合约地址。已有数据库中的同网络、同代币配置不会被启动过程覆盖,运行时应以管理后台和数据库中的 `chain_tokens` 实际记录为准。 + +| 网络 | `network` 参数 | 代币 | 合约或资产标识 | 精度 | +|------|-----------------|------|------------------|------| +| TRON | `tron` | USDT | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` | 6 | +| TRON | `tron` | TRX | 原生资产,无合约地址 | 6 | +| Ethereum | `ethereum` | USDT | `0xdAC17F958D2ee523a2206206994597C13D831ec7` | 6 | +| Ethereum | `ethereum` | USDC | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | 6 | +| Solana | `solana` | USDT | `Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB` | 6 | +| Solana | `solana` | USDC | `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` | 6 | +| Solana | `solana` | SOL | 原生资产,无合约地址 | 9 | +| BSC | `binance` | USDT | `0x55d398326f99059fF775485246999027B3197955` | 18 | +| BSC | `binance` | USDC | `0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d` | 18 | +| Polygon | `polygon` | USDT | `0xc2132D05D31c914a87C6611C10748AEb04B58e8F` | 6 | +| Polygon | `polygon` | USDC | `0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359` | 6 | +| Polygon | `polygon` | USDC.e | `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174` | 6 | +| Plasma | `plasma` | USDT | `0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb` | 6 | +| Base | `base` | USDC | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | 6 | +| Arbitrum One | `arbitrum` | USDC | `0xaf88d065e77c8cC2239327C5EDb3A432268e5831` | 6 | +| Arbitrum One | `arbitrum` | USDT | `0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9` | 6 | +| TON | `ton` | TON | 原生资产,无合约地址 | 9 | +| TON | `ton` | USDT | `0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe` | 6 | +| Aptos | `aptos` | USDC | `0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b` | 6 | +| Aptos | `aptos` | USDT | `0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b` | 6 | + --- ## 安全审计 From b677c440f89e56f79464ee79fdb319ce4213078c Mon Sep 17 00:00:00 2001 From: mala Date: Mon, 13 Jul 2026 04:55:17 +0800 Subject: [PATCH 04/11] =?UTF-8?q?chore:=20=E5=BF=BD=E7=95=A5=20dist=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index cb03dd37..ba5a9d57 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ src/*.db-shm src/*.db-wal .DS_Store .env +dist/ From 5850280b5028d726cf1ab3fa019f95db8f5a66f8 Mon Sep 17 00:00:00 2001 From: mala Date: Mon, 13 Jul 2026 05:02:41 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E7=BC=96=E8=AF=91=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 +++++ build.sh | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100755 build.sh diff --git a/README.md b/README.md index 4cf3c35f..c569562e 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,25 @@ Epusdt 已完成第三方安全审计。 仓库内还提供顶层脚本: +- [`./build.sh`](./build.sh) 用于一键编译当前平台、指定平台或全部平台,产物输出到 `dist/` - [`./epctl`](./epctl) 用于 Linux 二进制安装、升级、查看配置、状态和初始化密码 - [`./epctl-docker-test.sh`](./epctl-docker-test.sh) 用于在本机 Docker 里跑 Ubuntu + systemd 的真实安装验收 +一键编译当前平台: + +```bash +./build.sh +``` + +编译 Linux AMD64 或全部支持平台: + +```bash +./build.sh linux-amd64 +./build.sh all +``` + +脚本会自动写入版本号、Git 提交号和编译时间,并生成压缩包及 SHA-256 校验文件。可通过 `BUILD_VERSION=v1.2.3 ./build.sh linux-amd64` 指定版本号。 + --- ## API 暴露与认证边界 diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..0b332b86 --- /dev/null +++ b/build.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SRC_DIR="${ROOT_DIR}/src" +readonly DIST_DIR="${ROOT_DIR}/dist" +readonly VERSION_PACKAGE="github.com/GMWalletApp/epusdt/config" +readonly SUPPORTED_TARGETS=( + "linux-amd64" + "linux-arm64" + "linux-armv7" + "darwin-amd64" + "darwin-arm64" + "windows-amd64" + "windows-arm64" +) + +log() { + printf '[编译] %s\n' "$*" +} + +die() { + printf '[编译] 错误:%s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +用法: + ./build.sh 编译当前系统和架构 + ./build.sh linux-amd64 编译指定目标 + ./build.sh linux-amd64 darwin-arm64 + ./build.sh all 编译全部支持目标 + +支持的目标: + linux-amd64、linux-arm64、linux-armv7 + darwin-amd64、darwin-arm64 + windows-amd64、windows-arm64 + +可选环境变量: + BUILD_VERSION=v1.2.3 ./build.sh linux-amd64 +EOF +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "缺少命令:$1" +} + +current_target() { + local os arch + + case "$(uname -s)" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + *) die "不支持当前操作系统:$(uname -s)" ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + armv7l|armv7) arch="armv7" ;; + *) die "不支持当前处理器架构:$(uname -m)" ;; + esac + + printf '%s-%s\n' "${os}" "${arch}" +} + +target_supported() { + local target="$1" + local supported + + for supported in "${SUPPORTED_TARGETS[@]}"; do + if [[ "${target}" == "${supported}" ]]; then + return 0 + fi + done + return 1 +} + +safe_version_name() { + printf '%s' "$1" | sed 's/[^A-Za-z0-9._-]/-/g' +} + +write_checksum() { + local archive_name="$1" + + if command -v sha256sum >/dev/null 2>&1; then + (cd "${DIST_DIR}" && sha256sum "${archive_name}" > "${archive_name}.sha256") + return + fi + if command -v shasum >/dev/null 2>&1; then + (cd "${DIST_DIR}" && shasum -a 256 "${archive_name}" > "${archive_name}.sha256") + return + fi + die "缺少 SHA-256 校验工具,请安装 sha256sum 或 shasum" +} + +build_target() { + local target="$1" + local goos="${target%%-*}" + local goarch="${target#*-}" + local goarm="" + local binary_name="epusdt" + local package_name="epusdt-${SAFE_VERSION}-${target}" + local package_dir="${DIST_DIR}/${package_name}" + local archive_name + local ldflags + + if [[ "${goarch}" == "armv7" ]]; then + goarch="arm" + goarm="7" + fi + if [[ "${goos}" == "windows" ]]; then + binary_name="epusdt.exe" + archive_name="${package_name}.zip" + require_command zip + else + archive_name="${package_name}.tar.gz" + require_command tar + fi + + rm -rf "${package_dir}" + rm -f "${DIST_DIR}/${archive_name}" "${DIST_DIR}/${archive_name}.sha256" + mkdir -p "${package_dir}" + + ldflags="-s -w -X ${VERSION_PACKAGE}.BuildVersion=${BUILD_VERSION_VALUE} -X ${VERSION_PACKAGE}.BuildCommit=${BUILD_COMMIT} -X ${VERSION_PACKAGE}.BuildDate=${BUILD_DATE}" + log "正在编译 ${target},版本 ${BUILD_VERSION_VALUE}" + + if [[ -n "${goarm}" ]]; then + (cd "${SRC_DIR}" && CGO_ENABLED=0 GOOS="${goos}" GOARCH="${goarch}" GOARM="${goarm}" go build --trimpath -ldflags "${ldflags}" -o "${package_dir}/${binary_name}" .) + else + (cd "${SRC_DIR}" && CGO_ENABLED=0 GOOS="${goos}" GOARCH="${goarch}" go build --trimpath -ldflags "${ldflags}" -o "${package_dir}/${binary_name}" .) + fi + + cp "${SRC_DIR}/.env.example" "${package_dir}/.env.example" + [[ -s "${package_dir}/${binary_name}" ]] || die "编译产物为空:${package_dir}/${binary_name}" + go version -m "${package_dir}/${binary_name}" >/dev/null + + if [[ "${goos}" == "windows" ]]; then + (cd "${DIST_DIR}" && zip -qr "${archive_name}" "${package_name}") + else + (cd "${DIST_DIR}" && tar -czf "${archive_name}" "${package_name}") + fi + write_checksum "${archive_name}" + + log "编译完成:dist/${archive_name}" + log "校验文件:dist/${archive_name}.sha256" +} + +main() { + local requested_targets=() + local target + + if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + return + fi + + require_command go + require_command git + require_command sed + + BUILD_VERSION_VALUE="${BUILD_VERSION:-$(git -C "${ROOT_DIR}" describe --tags --always --dirty 2>/dev/null || printf '0.0.0-dev')}" + BUILD_COMMIT="$(git -C "${ROOT_DIR}" rev-parse --short HEAD 2>/dev/null || printf 'none')" + BUILD_DATE="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + SAFE_VERSION="$(safe_version_name "${BUILD_VERSION_VALUE}")" + [[ -n "${SAFE_VERSION}" ]] || die "构建版本不能为空" + + if [[ $# -eq 0 || "${1:-}" == "current" ]]; then + requested_targets+=("$(current_target)") + elif [[ "${1:-}" == "all" ]]; then + requested_targets=("${SUPPORTED_TARGETS[@]}") + else + requested_targets=("$@") + fi + + mkdir -p "${DIST_DIR}" + for target in "${requested_targets[@]}"; do + target_supported "${target}" || { + usage >&2 + die "不支持的编译目标:${target}" + } + build_target "${target}" + done +} + +main "$@" From 83774b3ebb72664417b11d0776feb5e998e75a40 Mon Sep 17 00:00:00 2001 From: mala Date: Mon, 13 Jul 2026 05:04:36 +0800 Subject: [PATCH 06/11] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84=E6=94=AF?= =?UTF-8?q?=E4=BB=98=20API=20=E6=8E=A5=E5=85=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wiki/API.md | 188 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 177 insertions(+), 11 deletions(-) diff --git a/wiki/API.md b/wiki/API.md index 42173188..c8cc2980 100644 --- a/wiki/API.md +++ b/wiki/API.md @@ -13,8 +13,10 @@ | 收银台页面 | GET | `/pay/checkout-counter/{trade_id}` | 否 | | 收银台初始化数据 | GET | `/pay/checkout-counter-resp/{trade_id}` | 否 | | 查询支付状态 | GET | `/pay/check-status/{trade_id}` | 否 | +| 提交链上交易哈希 | POST | `/pay/submit-tx-hash/{trade_id}` | 否 | | 切换支付网络/通道 | POST | `/pay/switch-network` | 否 | | EPay 兼容创建交易 | GET/POST | `/payments/epay/v1/order/create-transaction/submit.php` | 是 | +| EPay 同步返回商户 | GET | `/pay/return/{trade_id}` | 否 | | OkPay 平台回调 | POST | `/payments/okpay/v1/notify` | OkPay 签名 | ## 统一响应格式 @@ -157,6 +159,25 @@ function epaySign(array $params, string $secretKey): string } ``` +对应的 `curl` 请求: + +```bash +curl -X POST 'https://pay.example.com/payments/gmpay/v1/order/create-transaction' \ + -H 'Content-Type: application/json' \ + -d '{ + "pid": "1000", + "order_id": "ORD202605230001", + "currency": "cny", + "token": "usdt", + "network": "tron", + "amount": 100, + "notify_url": "https://merchant.example/notify", + "redirect_url": "https://merchant.example/return", + "name": "VIP", + "signature": "476412c422f4dd75c3d533f5c47a9cac" + }' +``` + ### 请求参数 | 字段 | 类型 | 必填 | 说明 | @@ -164,10 +185,10 @@ function epaySign(array $params, string $secretKey): string | `pid` | string | 是 | 商户 PID,用于查找 API Key,并参与签名。 | | `order_id` | string | 是 | 商户订单号,最长 32 字符,不能重复。 | | `currency` | string | 是 | 法币币种,如 `cny`、`usd`。 | -| `token` | string | 条件必填 | 收款币种,如 `usdt`、`trx`、`usdc`、`sol`。GMPay 可与 `network` 同时省略以创建状态 `4` 占位订单。 | -| `network` | string | 条件必填 | 收款网络,如 `tron`、`solana`、`ethereum`、`bsc`、`polygon`、`plasma`。GMPay 可与 `token` 同时省略以创建状态 `4` 占位订单。 | -| `amount` | number | 是 | 法币金额,必须大于 `0.01`。 | -| `notify_url` | string | 是 | 支付成功异步回调地址。 | +| `token` | string | 条件必填 | 收款币种,如 `usdt`、`trx`、`usdc`、`sol`、`ton`。GMPay 可与 `network` 同时省略以创建状态 `4` 占位订单。 | +| `network` | string | 条件必填 | 收款网络,如 `tron`、`solana`、`ton`、`aptos`、`ethereum`、`bsc`、`polygon`、`plasma`、`base`、`arbitrum`。GMPay 可与 `token` 同时省略以创建状态 `4` 占位订单。 | +| `amount` | number | 是 | 法币金额,请求值必须大于 `0.01`;保存和返回时会按 `system.amount_precision` 归一化。 | +| `notify_url` | string | 是 | 支付成功异步回调地址。必须是可解析到公网地址的 HTTP/HTTPS URL。 | | `redirect_url` | string | 否 | 支付完成后的同步跳转地址。 | | `name` | string | 否 | 商品/订单名称。 | | `payment_type` | string | 否 | GMPay 兼容字段,不要求必须传;如果传了非空值,必须参与 GMPay `signature` 计算。普通 GMPay 不传时后台会存为 `Gmpay`;传 `Epay`(大小写不敏感)会统一存为 `Epay` 并使用 EPay 回调格式,且 PID 必须是数字。 | @@ -175,7 +196,9 @@ function epaySign(array $params, string $secretKey): string `token` 和 `network` 必须同传或同缺。两者同缺时只创建包含 `amount/currency` 的占位订单,状态为 `4`,不会分配钱包、不会计算链上支付金额,也不会锁定交易金额;后续由收银台调用 `/pay/switch-network` 选择具体链和币种或 OkPay。只缺其中一个会返回参数错误。 -建议先调用 `/payments/gmpay/v1/config` 获取可用的 `network` 和 `token` 组合。 +`notify_url` 在创建订单时会执行 URL 和 DNS 安全检查。协议只能是 `http` 或 `https`,并且不能指向 `localhost`、回环地址、内网地址、链路本地地址、组播地址或其他非公网地址。域名无法解析时也会返回 `10041`。 + +建议先调用 `/payments/gmpay/v1/config` 获取当前实例实际可用的 `network` 和 `token` 组合。上表中的网络和币种仅作为示例,不代表每个部署都已启用。 ### 成功响应 @@ -203,7 +226,7 @@ function epaySign(array $params, string $secretKey): string | --- | --- | --- | | `trade_id` | string | Epusdt 交易号。 | | `order_id` | string | 商户订单号。 | -| `amount` | number | 商户提交的法币金额。 | +| `amount` | number | 按 `system.amount_precision` 归一化后的法币金额。 | | `currency` | string | 法币币种。 | | `actual_amount` | number | 实际需支付的加密货币数量。 | | `receive_address` | string | 收款地址。 | @@ -220,6 +243,10 @@ function epaySign(array $params, string $secretKey): string 返回收银台展示配置、可用链/币种、EPay 默认配置和 OkPay 公共配置。 +```bash +curl 'https://pay.example.com/payments/gmpay/v1/config' +``` + ### 成功响应示例 ```json @@ -262,6 +289,18 @@ function epaySign(array $params, string $secretKey): string } ``` +顶层字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `supported_assets` | array | 当前实例可创建订单的链和币种组合。 | +| `site` | object | 收银台名称、站点标题、Logo、客服链接和背景配置。 | +| `epay` | object | EPay 的默认币种、默认法币和默认网络。 | +| `okpay` | object | OkPay 是否启用以及允许使用的币种。 | +| `version` | string | 当前服务版本。 | + +公开接口的 `okpay` 对象只返回 `enabled` 和 `allow_tokens`,不会返回 `shop_id`、`shop_token`、API 地址、回调地址等内部配置。只有经过管理员认证的 `/admin/api/v1/config` 才会返回这些字段。 + 当前内置的新增 EVM 主网资产为: - Base(`base`,Chain ID `8453`):Circle 原生 `USDC`。 @@ -274,7 +313,9 @@ function epaySign(array $params, string $secretKey): string - 链已启用。 - 该链有可用钱包地址。 -- 该链至少有一个启用中的 token。 +- 该链至少有一个启用且配置完整的 token。 + +对于 TRX、SOL、TON 等原生币,不要求配置代币合约;其他代币必须配置非空的合约地址或链上资产 ID,否则即使已经启用,也不会出现在 `supported_assets` 中,并且不能用于创建订单。 ## 收银台页面 @@ -356,6 +397,10 @@ function epaySign(array $params, string $secretKey): string `GET /pay/check-status/{trade_id}` +```bash +curl 'https://pay.example.com/pay/check-status/20260523171652123456001' +``` + ### 成功响应示例 ```json @@ -379,6 +424,61 @@ function epaySign(array $params, string $secretKey): string | `3` | 已过期 | | `4` | 等待选择支付网络/币种 | +## 提交链上交易哈希 + +`POST /pay/submit-tx-hash/{trade_id}` + +该接口供收银台在用户已经完成链上付款、但自动监听尚未入账时提交交易哈希。服务端会通过对应网络的 RPC 核验交易状态、收款地址、币种、金额、交易时间和确认数;验证成功后将订单更新为支付成功并进入商户回调流程。 + +```bash +curl -X POST 'https://pay.example.com/pay/submit-tx-hash/20260523171652123456001' \ + -H 'Content-Type: application/json' \ + -d '{ + "block_transaction_id": "0xabc123def456..." + }' +``` + +### 请求参数 + +| 字段 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| `trade_id` | path | string | 是 | 要补单的 Epusdt 交易号。 | +| `block_transaction_id` | JSON body | string | 是 | 用户已支付交易的链上交易哈希或交易引用。 | + +当前支持人工验证的网络包括 `tron`、`solana`、`ton`、`aptos`、`ethereum`、`bsc`、`polygon`、`plasma`、`base` 和 `arbitrum`。 + +TON 支持以下三种交易引用格式: + +```text +ton::: +: + +``` + +只提交 TON 哈希时,该哈希必须能在订单收款地址的近期交易中唯一定位。其他网络通常直接提交标准交易哈希或 Solana 交易签名。 + +### 成功响应 + +```json +{ + "status_code": 200, + "message": "success", + "data": { + "trade_id": "20260523171652123456001", + "status": 2, + "block_transaction_id": "0xabc123def456..." + }, + "request_id": "b1344d70-ff19-4543-b601-37abfb3b3686" +} +``` + +限制: + +- 仅支持状态 `1` 的等待支付订单,不接受状态 `3` 的过期订单或状态 `4` 的占位订单。 +- 仅支持普通链上订单,不支持 OkPay 等第三方支付服务商订单。 +- 同一交易哈希不能用于多个订单;重复使用返回 `10007`。 +- RPC 验证失败返回 `10038`,不会把订单改为已支付;修正配置或等待交易确认后可以再次提交。 + ## 切换支付网络/通道 `POST /pay/switch-network` @@ -395,6 +495,18 @@ function epaySign(array $params, string $secretKey): string } ``` +对应的 `curl` 请求: + +```bash +curl -X POST 'https://pay.example.com/pay/switch-network' \ + -H 'Content-Type: application/json' \ + -d '{ + "trade_id": "20260523171652123456001", + "token": "USDT", + "network": "solana" + }' +``` + 切换到 OkPay: ```json @@ -444,9 +556,9 @@ function epaySign(array $params, string $secretKey): string | 字段 | 位置 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | --- | | `pid` | query/form | string | 是 | 商户 PID。建议使用数字 PID;EPay 回调会按数字 PID 输出。 | -| `money` | query/form | number | 是 | 法币金额。 | +| `money` | query/form | number | 是 | 法币金额,请求值必须大于 `0.01`;保存和返回时会按 `system.amount_precision` 归一化。 | | `out_trade_no` | query/form | string | 是 | 商户订单号。 | -| `notify_url` | query/form | string | 是 | 异步回调地址。 | +| `notify_url` | query/form | string | 是 | 异步回调地址,必须是可解析到公网地址的 HTTP/HTTPS URL。 | | `return_url` | query/form | string | 否 | 支付完成后的同步跳转地址。 | | `name` | query/form | string | 否 | 商品/订单名称。 | | `type` | query/form | string | 否 | 仅支持空值、`alipay`,或当前已启用并可收款的 `token.network` selector(如 `usdt.tron`)。推荐使用小写 `alipay`。 | @@ -474,6 +586,21 @@ money=100&name=VIP¬ify_url=https://merchant.example/notify&out_trade_no=ORD20 sign=b865b0acbb2b01554c35a1bd33351452 ``` +对应的 GET 请求示例: + +```bash +curl -G 'https://pay.example.com/payments/epay/v1/order/create-transaction/submit.php' \ + --data-urlencode 'pid=1000' \ + --data-urlencode 'money=100' \ + --data-urlencode 'out_trade_no=ORD202605230001' \ + --data-urlencode 'notify_url=https://merchant.example/notify' \ + --data-urlencode 'return_url=https://merchant.example/return' \ + --data-urlencode 'name=VIP' \ + --data-urlencode 'type=alipay' \ + --data-urlencode 'sign=b865b0acbb2b01554c35a1bd33351452' \ + --data-urlencode 'sign_type=MD5' +``` + EPay 接口解析 `type/token/network/currency` 的规则: - `type` 只接受三类输入:空值、`alipay`、命中的 `token.network` selector。 @@ -487,10 +614,40 @@ EPay 接口解析 `type/token/network/currency` 的规则: 后台默认配置可通过 `/payments/gmpay/v1/config` 的 `epay` 字段查看;新安装默认只预置 `epay.default_currency=cny`,`epay.default_token` 和 `epay.default_network` 为空,因此 EPay 未显式传 token/network 时会创建状态 `4` 占位订单。已有数据库的配置不会被 seed 覆盖,删除或置空 `epay.default_token` 和 `epay.default_network` 后,这两个字段会返回空字符串。 +## EPay 同步返回商户 + +`GET /pay/return/{trade_id}` + +该接口是浏览器支付完成后的同步返回中转页,不需要商户主动调用。对于已支付的 EPay 订单,服务端会在商户原始 `return_url` 后追加一组已签名的 EPay 参数,并返回 HTTP 302: + +```text +pid=1000 +trade_no=20260523171652123456001 +out_trade_no=ORD202605230001 +type=alipay +name=VIP +money=100.0000 +trade_status=TRADE_SUCCESS +sign=a1b2c3d4... +sign_type=MD5 +``` + +验签方式与 EPay 异步回调一致:排除 `sign` 和 `sign_type`,其余非空参数按 ASCII 字典序拼接后追加 `secret_key` 并计算 MD5。 + +行为说明: + +- EPay 订单的收银台初始化数据会把 `redirect_url` 改写为该中转地址,数据库仍保存商户原始 `return_url`。 +- 订单尚未支付,或者不是 EPay 订单时,会 302 返回 `/pay/checkout-counter/{trade_id}`。 +- 跳转到商户时会设置 `Cache-Control: no-store`,避免浏览器缓存带签名的返回地址。 +- 商户 `return_url` 为空返回 `10044`;订单 API Key 不可用返回 `10045`;无法构造 EPay 返回签名返回 `10046`。 +- 同步跳转只用于改善用户体验,最终支付结果必须以异步回调或主动查询订单状态为准。 + ## 商户异步回调 订单支付成功后,Epusdt 会向订单的 `notify_url` 发送异步通知。目标服务器处理完成后需返回 HTTP 200,响应体为 `ok` 或 `success`(大小写不敏感)。否则会按队列配置重试:首次失败后最多重试 `order_notice_max_retry` 次,重试间隔按 `callback_retry_base_seconds` 指数退避,最大 5 分钟。 +商户回调处理必须具备幂等性。建议以 `trade_id` 为支付平台唯一键,并同时校验 `order_id`、订单金额、回调签名和本地订单状态;同一订单重复收到成功通知时,不得重复发货、重复充值或重复记账。业务处理完成并持久化后再返回纯文本 `ok` 或 `success`。 + ### GMPay 回调 普通 GMPay 订单使用 POST JSON 回调。 @@ -515,7 +672,7 @@ EPay 接口解析 `type/token/network/currency` 的规则: | `pid` | string | 订单所属 API Key 的 PID。商户应使用该 PID 查本地密钥验签。 | | `trade_id` | string | Epusdt 交易号。 | | `order_id` | string | 商户订单号。 | -| `amount` | number | 商户提交的法币金额。 | +| `amount` | number | 按 `system.amount_precision` 归一化后的法币金额。 | | `actual_amount` | number | 实际到账的加密货币数量。 | | `receive_address` | string | 收款地址。 | | `token` | string | 收款币种。 | @@ -567,7 +724,9 @@ fail Epusdt 会按配置的 OkPay shop token 验证 OkPay 签名,成功后将对应 OkPay 订单标记为已支付,并触发商户回调;这个 OkPay 订单可能是由 `status=4` 占位父单原地补全而来,也可能是后续切换创建的子订单。 -## status_code 返回状态码及含义 +## 支付端 status_code 返回状态码及含义 + +下表覆盖本文档中的商户接入、收银台和支付回调接口。后台管理接口还会使用其他管理类错误码。 | 状态码 | HTTP 状态 | 说明 | | --- | --- | --- | @@ -592,3 +751,10 @@ Epusdt 会按配置的 OkPay shop token 验证 OkPay 签名,成功后将对应 | `10017` | 400 | 支付服务商未启用 | | `10018` | 400 | 支付服务商配置不完整 | | `10019` | 400 | 支付服务商不支持该币种或网络 | +| `10038` | 400 | 手动提交的链上交易验证失败 | +| `10039` | 400 | 当前订单不是支持手动补单的链上订单 | +| `10041` | 400 | `notify_url` 无效、无法解析或指向非公网地址 | +| `10042` | 400 | 第三方支付服务商订单创建失败 | +| `10044` | 400 | EPay 同步返回地址无效或为空 | +| `10045` | 400 | 订单关联的 API Key 不可用 | +| `10046` | 400 | EPay 同步返回签名构造失败 | From 5b8c7d5e18d814d09bab43a4cd36d7fcac7b0ce7 Mon Sep 17 00:00:00 2001 From: mala Date: Sun, 16 Aug 2026 19:22:59 +0800 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20=E5=85=BC=E5=AE=B9=20GMPay=20?= =?UTF-8?q?=E6=96=B0=E6=97=A7=E7=AD=BE=E5=90=8D=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/admin/api_key_controller.go | 57 +++++--- src/controller/comm/order_controller.go | 20 ++- src/internal/testutil/testdb.go | 16 ++- src/middleware/check_sign.go | 15 +- src/model/dao/mdb_table_init.go | 17 +++ src/model/dao/mdb_table_init_test.go | 58 ++++++++ src/model/data/api_key_data.go | 10 +- src/model/data/api_key_data_test.go | 33 +++++ src/model/mdb/api_key.go | 2 + src/model/mdb/orders_mdb.go | 2 + src/model/response/order_response.go | 2 +- src/model/service/order_service.go | 40 ++++-- src/model/service/order_service_test.go | 59 ++++++++ src/mq/worker.go | 2 +- src/mq/worker_test.go | 55 ++++++++ src/route/admin_router_test.go | 27 +++- src/route/router_test.go | 155 +++++++++++++++++++-- src/util/sign/gmpay.go | 104 ++++++++++++++ src/util/sign/sign_test.go | 59 +++++++- wiki/API.md | 42 +++++- 20 files changed, 704 insertions(+), 71 deletions(-) create mode 100644 src/model/data/api_key_data_test.go create mode 100644 src/util/sign/gmpay.go diff --git a/src/controller/admin/api_key_controller.go b/src/controller/admin/api_key_controller.go index 9645a996..0c5bf3f6 100644 --- a/src/controller/admin/api_key_controller.go +++ b/src/controller/admin/api_key_controller.go @@ -9,6 +9,7 @@ import ( "github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/util/constant" + "github.com/GMWalletApp/epusdt/util/sign" "github.com/labstack/echo/v4" ) @@ -17,16 +18,18 @@ import ( // no gateway_type. PID is auto-generated (incrementing from 1000); // no manual override. type CreateApiKeyRequest struct { - Name string `json:"name" validate:"required|maxLen:128" example:"My API Key"` - IpWhitelist string `json:"ip_whitelist" example:""` - NotifyUrl string `json:"notify_url" example:"https://example.com/notify"` + Name string `json:"name" validate:"required|maxLen:128" example:"My API Key"` + IpWhitelist string `json:"ip_whitelist" example:""` + NotifyUrl string `json:"notify_url" example:"https://example.com/notify"` + GMPaySignMode string `json:"gmpay_sign_mode" enums:"dual,hmac_sha256,md5" example:"hmac_sha256"` } // CreateApiKeyResponse is the response for a newly created API key. type CreateApiKeyResponse struct { - ID uint64 `json:"id" example:"1"` - Name string `json:"name" example:"My API Key"` - Pid string `json:"pid" example:"1003"` + ID uint64 `json:"id" example:"1"` + Name string `json:"name" example:"My API Key"` + Pid string `json:"pid" example:"1003"` + GMPaySignMode string `json:"gmpay_sign_mode" example:"hmac_sha256"` // SecretKey is returned ONCE on creation. After that, fetch via // GET /api-keys/:id/secret or rotate to generate a new one. SecretKey string `json:"secret_key" example:"secret123abc456"` @@ -34,9 +37,10 @@ type CreateApiKeyResponse struct { // UpdateApiKeyRequest is the payload for updating an API key. type UpdateApiKeyRequest struct { - Name *string `json:"name" example:"Updated Key Name"` - IpWhitelist *string `json:"ip_whitelist" example:"10.0.0.1,192.168.0.0/24"` - NotifyUrl *string `json:"notify_url" example:"https://example.com/notify"` + Name *string `json:"name" example:"Updated Key Name"` + IpWhitelist *string `json:"ip_whitelist" example:"10.0.0.1,192.168.0.0/24"` + NotifyUrl *string `json:"notify_url" example:"https://example.com/notify"` + GMPaySignMode *string `json:"gmpay_sign_mode" enums:"dual,hmac_sha256,md5" example:"hmac_sha256"` } // ChangeApiKeyStatusRequest is the payload for toggling API key status. @@ -84,6 +88,13 @@ func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error { if err := c.ValidateStruct(ctx, req); err != nil { return c.FailJson(ctx, err) } + signMode := sign.GMPaySignModeHMACSHA256 + if strings.TrimSpace(req.GMPaySignMode) != "" { + if !sign.IsExplicitGMPaySignMode(req.GMPaySignMode) { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + signMode = sign.NormalizeGMPaySignMode(req.GMPaySignMode) + } // Retry on unique-index violation: two concurrent creates could // both see the same max PID from NextPid() and race on INSERT. @@ -97,12 +108,13 @@ func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error { return c.FailJson(ctx, err) } row = &mdb.ApiKey{ - Name: req.Name, - Pid: strconv.Itoa(pid), - SecretKey: secret, - IpWhitelist: req.IpWhitelist, - NotifyUrl: req.NotifyUrl, - Status: mdb.ApiKeyStatusEnable, + Name: req.Name, + Pid: strconv.Itoa(pid), + SecretKey: secret, + IpWhitelist: req.IpWhitelist, + NotifyUrl: req.NotifyUrl, + GMPaySignMode: signMode, + Status: mdb.ApiKeyStatusEnable, } err = data.CreateApiKey(row) if err == nil { @@ -113,10 +125,11 @@ func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error { } } return c.SucJson(ctx, CreateApiKeyResponse{ - ID: row.ID, - Name: row.Name, - Pid: row.Pid, - SecretKey: secret, + ID: row.ID, + Name: row.Name, + Pid: row.Pid, + GMPaySignMode: row.GMPaySignMode, + SecretKey: secret, }) } @@ -166,6 +179,12 @@ func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error { if req.NotifyUrl != nil { fields["notify_url"] = *req.NotifyUrl } + if req.GMPaySignMode != nil { + if !sign.IsExplicitGMPaySignMode(*req.GMPaySignMode) { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + fields["gmpay_sign_mode"] = sign.NormalizeGMPaySignMode(*req.GMPaySignMode) + } if err := data.UpdateApiKeyFields(id, fields); err != nil { return c.FailJson(ctx, err) } diff --git a/src/controller/comm/order_controller.go b/src/controller/comm/order_controller.go index 96cca1de..8009750c 100644 --- a/src/controller/comm/order_controller.go +++ b/src/controller/comm/order_controller.go @@ -11,6 +11,7 @@ import ( "github.com/GMWalletApp/epusdt/model/service" "github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/log" + "github.com/GMWalletApp/epusdt/util/sign" "github.com/labstack/echo/v4" ) @@ -25,12 +26,21 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey { return nil } +// gmpaySignAlgorithmFromContext 读取中间件实际验签成功的算法。 +func gmpaySignAlgorithmFromContext(ctx echo.Context) (string, bool) { + algorithm, ok := ctx.Get(middleware.SignAlgorithmKey).(string) + if !ok || sign.NormalizeAlgorithm(algorithm) == "" { + return "", false + } + return algorithm, true +} + // CreateTransaction 创建交易 // @Summary Create transaction // @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded). // @Description GMPay may omit both token and network to create a status=4 placeholder order; EPay submit.php can also create one when neither request parameters nor database defaults provide token/network. Supplying only one of token/network is invalid. // @Description payment_type is optional for GMPay. If it is sent, it is a normal signed parameter and must be included when calculating signature. -// @Description GMPay signature uses lowercase hexadecimal HMAC-SHA256 with secret_key as the HMAC key. Legacy MD5 signatures are not accepted. +// @Description GMPay 签名算法由 API Key 的 gmpay_sign_mode 控制;新建 Key 默认 HMAC-SHA256,升级前 Key 默认 dual 以兼容旧 MD5。 // @Tags Payment // @Accept json // @Accept x-www-form-urlencoded @@ -42,7 +52,7 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey { // @Param network formData string false "Network (e.g. ton, tron); omit together with token to create a placeholder where supported" // @Param amount formData number false "Amount" // @Param notify_url formData string false "Callback URL" -// @Param signature formData string false "Lowercase hexadecimal HMAC-SHA256 signature" +// @Param signature formData string false "GMPay 签名:64 位 HMAC-SHA256,兼容模式下也可使用 32 位 MD5" // @Param redirect_url formData string false "Redirect URL" // @Param name formData string false "Order name" // @Param payment_type formData string false "Optional GMPay compatibility flag; include in signature when sent" @@ -57,7 +67,11 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) { if err = c.ValidateStruct(ctx, req); err != nil { return c.FailJson(ctx, err) } - resp, err := service.CreateTransaction(req, apiKeyFromContext(ctx)) + algorithm, ok := gmpaySignAlgorithmFromContext(ctx) + if !ok { + return c.FailJson(ctx, constant.SignatureErr) + } + resp, err := service.CreateTransactionWithSignAlgorithm(req, apiKeyFromContext(ctx), algorithm) if err != nil { return c.FailJson(ctx, err) } diff --git a/src/internal/testutil/testdb.go b/src/internal/testutil/testdb.go index 3d6baf00..d4e11b2d 100644 --- a/src/internal/testutil/testdb.go +++ b/src/internal/testutil/testdb.go @@ -128,14 +128,18 @@ func SetupTestDatabases(t testing.TB) func() { // flows; the numeric PID 1001 row lets legacy tests that submit // `pid=1001` still match. mainDB.Create(&mdb.ApiKey{ - Name: "test-default", - Pid: "test-token", SecretKey: "test-token", - Status: mdb.ApiKeyStatusEnable, + Name: "test-default", + Pid: "test-token", + SecretKey: "test-token", + GMPaySignMode: "hmac_sha256", + Status: mdb.ApiKeyStatusEnable, }) mainDB.Create(&mdb.ApiKey{ - Name: "test-pid-1001", - Pid: "1001", SecretKey: "test-token", - Status: mdb.ApiKeyStatusEnable, + Name: "test-pid-1001", + Pid: "1001", + SecretKey: "test-token", + GMPaySignMode: "hmac_sha256", + Status: mdb.ApiKeyStatusEnable, }) if err := dao.Mdb.Create(&mdb.Setting{ Group: "rate", diff --git a/src/middleware/check_sign.go b/src/middleware/check_sign.go index b597141c..23e130c6 100644 --- a/src/middleware/check_sign.go +++ b/src/middleware/check_sign.go @@ -2,7 +2,6 @@ package middleware import ( "bytes" - "crypto/subtle" "io" "net" "net/url" @@ -19,8 +18,9 @@ import ( // Context keys populated by CheckApiSign after successful verification. // Handlers (pay/order creation) pull ApiKeyIDKey to stamp order.api_key_id. const ( - ApiKeyIDKey = "api_key_id" - ApiKeyRowKey = "api_key_row" + ApiKeyIDKey = "api_key_id" + ApiKeyRowKey = "api_key_row" + SignAlgorithmKey = "gmpay_sign_algorithm" ) // CheckApiSign validates the body signature against the secret_key of @@ -75,12 +75,12 @@ func CheckApiSign() echo.MiddlewareFunc { return constant.SignatureErr } - checkSignature, err := sign.GetHMACSHA256(m, row.SecretKey) - if err != nil { + signatureStr, ok := signature.(string) + if !ok || strings.TrimSpace(signatureStr) == "" { return constant.SignatureErr } - signatureStr, _ := signature.(string) - if subtle.ConstantTimeCompare([]byte(checkSignature), []byte(signatureStr)) != 1 { + algorithm, err := sign.VerifyGMPay(m, row.SecretKey, signatureStr, row.GMPaySignMode) + if err != nil { return constant.SignatureErr } @@ -92,6 +92,7 @@ func CheckApiSign() echo.MiddlewareFunc { ctx.Set(ApiKeyIDKey, row.ID) ctx.Set(ApiKeyRowKey, row) + ctx.Set(SignAlgorithmKey, algorithm) return next(ctx) } } diff --git a/src/model/dao/mdb_table_init.go b/src/model/dao/mdb_table_init.go index d93e1f66..7ff49c76 100644 --- a/src/model/dao/mdb_table_init.go +++ b/src/model/dao/mdb_table_init.go @@ -8,6 +8,7 @@ import ( "github.com/GMWalletApp/epusdt/config" "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/GMWalletApp/epusdt/util/sign" "github.com/gookit/color" "gorm.io/gorm/clause" ) @@ -51,6 +52,10 @@ func MdbTableInit() { return } } + if err := backfillSignatureCompatibility(); err != nil { + color.Red.Printf("[store_db] 回填签名兼容字段失败,err=%s\n", err) + return + } seedChains() backfillRpcNodePurpose() @@ -61,6 +66,18 @@ func MdbTableInit() { }) } +// backfillSignatureCompatibility 为升级前数据补齐安全的兼容默认值。 +func backfillSignatureCompatibility() error { + if err := Mdb.Model(&mdb.ApiKey{}). + Where("gmpay_sign_mode IS NULL OR gmpay_sign_mode = ?", ""). + Update("gmpay_sign_mode", sign.GMPaySignModeDual).Error; err != nil { + return err + } + return Mdb.Model(&mdb.Orders{}). + Where("sign_algorithm IS NULL OR sign_algorithm = ?", ""). + Update("sign_algorithm", sign.AlgorithmMD5).Error +} + // seedChains inserts the built-in networks as enabled rows. Uses // ON CONFLICT DO NOTHING so re-runs are no-ops and admin edits persist. func seedChains() { diff --git a/src/model/dao/mdb_table_init_test.go b/src/model/dao/mdb_table_init_test.go index fe1e9a97..6dbd845d 100644 --- a/src/model/dao/mdb_table_init_test.go +++ b/src/model/dao/mdb_table_init_test.go @@ -7,11 +7,69 @@ import ( "github.com/GMWalletApp/epusdt/config" "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/GMWalletApp/epusdt/util/sign" "github.com/libtnb/sqlite" "github.com/spf13/viper" "gorm.io/gorm" ) +type legacyApiKeyForSignMigration struct { + ID uint64 `gorm:"primaryKey"` + Name string + Pid string + SecretKey string + Status int +} + +func (legacyApiKeyForSignMigration) TableName() string { return "api_keys" } + +type legacyOrderForSignMigration struct { + ID uint64 `gorm:"primaryKey"` + TradeID string `gorm:"column:trade_id"` + OrderID string `gorm:"column:order_id"` +} + +func (legacyOrderForSignMigration) TableName() string { return "orders" } + +func TestBackfillSignatureCompatibilityMigratesLegacyRows(t *testing.T) { + db := setupSeedTableTestDB(t) + if err := db.AutoMigrate(&legacyApiKeyForSignMigration{}, &legacyOrderForSignMigration{}); err != nil { + t.Fatalf("创建旧版表结构失败: %v", err) + } + if err := db.Create(&legacyApiKeyForSignMigration{ + Name: "legacy", Pid: "1000", SecretKey: "legacy-secret", Status: mdb.ApiKeyStatusEnable, + }).Error; err != nil { + t.Fatalf("写入旧版 API Key 失败: %v", err) + } + if err := db.Create(&legacyOrderForSignMigration{TradeID: "legacy-trade", OrderID: "legacy-order"}).Error; err != nil { + t.Fatalf("写入旧版订单失败: %v", err) + } + + if err := db.AutoMigrate(&mdb.ApiKey{}, &mdb.Orders{}); err != nil { + t.Fatalf("升级签名字段失败: %v", err) + } + Mdb = db + if err := backfillSignatureCompatibility(); err != nil { + t.Fatalf("回填签名兼容字段失败: %v", err) + } + + var apiKey mdb.ApiKey + if err := db.Where("pid = ?", "1000").Take(&apiKey).Error; err != nil { + t.Fatalf("读取升级后的 API Key 失败: %v", err) + } + if apiKey.GMPaySignMode != sign.GMPaySignModeDual { + t.Fatalf("历史 API Key 模式 = %q, want %q", apiKey.GMPaySignMode, sign.GMPaySignModeDual) + } + + var order mdb.Orders + if err := db.Where("trade_id = ?", "legacy-trade").Take(&order).Error; err != nil { + t.Fatalf("读取升级后的订单失败: %v", err) + } + if order.SignAlgorithm != sign.AlgorithmMD5 { + t.Fatalf("历史订单算法 = %q, want %q", order.SignAlgorithm, sign.AlgorithmMD5) + } +} + func TestDefaultRpcNodesIncludesManualVerifyEpusdtEvmNodes(t *testing.T) { want := map[string]string{ mdb.NetworkEthereum: "https://rpc.epusdt.com/ethereum", diff --git a/src/model/data/api_key_data.go b/src/model/data/api_key_data.go index 1f14ed51..ff39a621 100644 --- a/src/model/data/api_key_data.go +++ b/src/model/data/api_key_data.go @@ -8,6 +8,7 @@ import ( "github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/GMWalletApp/epusdt/util/sign" "github.com/dromara/carbon/v2" "gorm.io/gorm" ) @@ -115,10 +116,11 @@ func EnsureDefaultApiKey() (*SeededApiKey, error) { } secret := generateHex(32) row := &mdb.ApiKey{ - Name: "default", - Pid: strconv.Itoa(basePid), - SecretKey: secret, - Status: mdb.ApiKeyStatusEnable, + Name: "default", + Pid: strconv.Itoa(basePid), + SecretKey: secret, + GMPaySignMode: sign.GMPaySignModeHMACSHA256, + Status: mdb.ApiKeyStatusEnable, } if err := dao.Mdb.Create(row).Error; err != nil { return nil, err diff --git a/src/model/data/api_key_data_test.go b/src/model/data/api_key_data_test.go new file mode 100644 index 00000000..36a6a409 --- /dev/null +++ b/src/model/data/api_key_data_test.go @@ -0,0 +1,33 @@ +package data + +import ( + "testing" + + "github.com/GMWalletApp/epusdt/internal/testutil" + "github.com/GMWalletApp/epusdt/model/dao" + "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/GMWalletApp/epusdt/util/sign" +) + +func TestEnsureDefaultApiKeyUsesHMACSHA256(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if err := dao.Mdb.Unscoped().Where("1 = 1").Delete(&mdb.ApiKey{}).Error; err != nil { + t.Fatalf("清理测试 API Key 失败: %v", err) + } + seeded, err := EnsureDefaultApiKey() + if err != nil { + t.Fatalf("创建默认 API Key 失败: %v", err) + } + if seeded == nil { + t.Fatal("未返回新建的默认 API Key") + } + row, err := GetEnabledApiKey(seeded.Pid) + if err != nil { + t.Fatalf("读取默认 API Key 失败: %v", err) + } + if row.GMPaySignMode != sign.GMPaySignModeHMACSHA256 { + t.Fatalf("默认 API Key 模式 = %q, want %q", row.GMPaySignMode, sign.GMPaySignModeHMACSHA256) + } +} diff --git a/src/model/mdb/api_key.go b/src/model/mdb/api_key.go index 30c5a618..ad97a053 100644 --- a/src/model/mdb/api_key.go +++ b/src/model/mdb/api_key.go @@ -17,6 +17,8 @@ type ApiKey struct { SecretKey string `gorm:"column:secret_key;size:255" json:"-"` IpWhitelist string `gorm:"column:ip_whitelist;type:text" json:"ip_whitelist" example:"192.168.1.0/24,10.0.0.1"` NotifyUrl string `gorm:"column:notify_url;size:512" json:"notify_url" example:"https://example.com/notify"` + // GMPaySignMode 控制 GMPay 接口允许的签名算法;EPay 始终使用 MD5。 + GMPaySignMode string `gorm:"column:gmpay_sign_mode;size:32;not null;default:dual" json:"gmpay_sign_mode" enums:"dual,hmac_sha256,md5" example:"hmac_sha256"` // 状态 1=启用 2=禁用 Status int `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"` CallCount int64 `gorm:"column:call_count;default:0" json:"call_count" example:"342"` diff --git a/src/model/mdb/orders_mdb.go b/src/model/mdb/orders_mdb.go index 14d1a9ce..f02c85e4 100644 --- a/src/model/mdb/orders_mdb.go +++ b/src/model/mdb/orders_mdb.go @@ -70,6 +70,8 @@ type Orders struct { // can rely on AutoMigrate without rewriting old orders. PayProvider string `gorm:"column:pay_provider;size:32;default:on_chain;index:orders_pay_provider_index" json:"pay_provider" example:"on_chain"` ApiKeyID uint64 `gorm:"column:api_key_id;default:0;index:orders_api_key_id_index" json:"api_key_id" example:"1"` + // SignAlgorithm 固化创建订单时实际通过的 GMPay 算法,确保后续回调不受 API Key 模式切换影响。 + SignAlgorithm string `gorm:"column:sign_algorithm;size:32;not null;default:md5" json:"-"` // PayBySubId holds the primary-key ID of the sub-order that settled this parent order. // Zero when the parent order was paid directly (no sub-order involved). PayBySubId uint64 `gorm:"column:pay_by_sub_id;default:0" json:"pay_by_sub_id" example:"0"` diff --git a/src/model/response/order_response.go b/src/model/response/order_response.go index 46cc818f..98123b7e 100644 --- a/src/model/response/order_response.go +++ b/src/model/response/order_response.go @@ -24,7 +24,7 @@ type OrderNotifyResponse struct { ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址 Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT...... BlockTransactionId string `json:"block_transaction_id" example:"0xabc123..."` // 区块id - Signature string `json:"signature" example:"498975a97bc34563bdb14df53fc18054645df9684d6c67d9b9dd90ec62be1018"` // GMPay 签名 HMAC-SHA256(secret_key, sorted_params) + Signature string `json:"signature" example:"498975a97bc34563bdb14df53fc18054645df9684d6c67d9b9dd90ec62be1018"` // GMPay 签名,算法与订单创建时实际通过的算法一致 // 订单状态 1=等待支付 2=支付成功 3=已过期 Status int `json:"status" enums:"1,2,3" example:"2"` } diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index 278b5965..da060cb8 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -19,6 +19,7 @@ import ( "github.com/GMWalletApp/epusdt/util/log" "github.com/GMWalletApp/epusdt/util/math" "github.com/GMWalletApp/epusdt/util/security" + "github.com/GMWalletApp/epusdt/util/sign" "github.com/dromara/carbon/v2" "github.com/shopspring/decimal" ) @@ -110,8 +111,17 @@ func buildCreateTransactionResponse(order *mdb.Orders) *response.CreateTransacti } } -// CreateTransaction creates a new payment order. +// CreateTransaction 使用历史 MD5 默认值创建订单,供 EPay 和内部兼容调用使用。 func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) { + return CreateTransactionWithSignAlgorithm(req, apiKey, sign.AlgorithmMD5) +} + +// CreateTransactionWithSignAlgorithm 创建订单并固化本次 GMPay 实际通过的签名算法。 +func CreateTransactionWithSignAlgorithm(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey, signAlgorithm string) (*response.CreateTransactionResponse, error) { + signAlgorithm = sign.NormalizeAlgorithm(signAlgorithm) + if signAlgorithm == "" { + return nil, constant.SignatureErr + } token := strings.ToUpper(strings.TrimSpace(req.Token)) currency := strings.ToUpper(strings.TrimSpace(req.Currency)) network := strings.ToLower(strings.TrimSpace(req.Network)) @@ -152,18 +162,19 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey if token == "" && network == "" { tradeID := GenerateCode() order := &mdb.Orders{ - TradeId: tradeID, - OrderId: req.OrderId, - Amount: payAmount, - Currency: currency, - Status: mdb.StatusWaitSelect, - NotifyUrl: notifyURL, - RedirectUrl: req.RedirectUrl, - Name: req.Name, - EpayType: epayType, - PaymentType: paymentType, - PayProvider: mdb.PaymentProviderOnChain, - ApiKeyID: apiKeyID(apiKey), + TradeId: tradeID, + OrderId: req.OrderId, + Amount: payAmount, + Currency: currency, + Status: mdb.StatusWaitSelect, + NotifyUrl: notifyURL, + RedirectUrl: req.RedirectUrl, + Name: req.Name, + EpayType: epayType, + PaymentType: paymentType, + PayProvider: mdb.PaymentProviderOnChain, + ApiKeyID: apiKeyID(apiKey), + SignAlgorithm: signAlgorithm, } if err = data.CreateOrderWithTransaction(dao.Mdb, order); err != nil { return nil, err @@ -224,6 +235,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey PaymentType: paymentType, PayProvider: mdb.PaymentProviderOnChain, ApiKeyID: apiKeyID(apiKey), + SignAlgorithm: signAlgorithm, } if err = data.CreateOrderWithTransaction(tx, order); err != nil { tx.Rollback() @@ -686,6 +698,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter PaymentType: parent.PaymentType, PayProvider: mdb.PaymentProviderOnChain, ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails + SignAlgorithm: parent.SignAlgorithm, } if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil { tx.Rollback() @@ -967,6 +980,7 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR PaymentType: parent.PaymentType, PayProvider: mdb.PaymentProviderOkPay, ApiKeyID: parent.ApiKeyID, + SignAlgorithm: parent.SignAlgorithm, } if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil { tx.Rollback() diff --git a/src/model/service/order_service_test.go b/src/model/service/order_service_test.go index 4a36b40f..6ccd8a67 100644 --- a/src/model/service/order_service_test.go +++ b/src/model/service/order_service_test.go @@ -19,6 +19,7 @@ import ( "github.com/GMWalletApp/epusdt/model/request" "github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/http_client" + "github.com/GMWalletApp/epusdt/util/sign" "github.com/go-resty/resty/v2" "github.com/xssnick/tonutils-go/address" ) @@ -129,6 +130,64 @@ func TestCreateTransactionCreatesWaitSelectPlaceholderWithoutTokenNetwork(t *tes } } +func TestGMPaySignAlgorithmPersistsThroughPlaceholderAndSubOrder(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if _, err := data.AddWalletAddress("TSignAlgorithmAddress001"); err != nil { + t.Fatalf("添加 TRON 钱包失败: %v", err) + } + if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455669"); err != nil { + t.Fatalf("添加 Ethereum 钱包失败: %v", err) + } + + req := newCreateTransactionRequest("order_sign_algorithm_1", 10) + req.Token = "" + req.Network = "" + parentResp, err := CreateTransactionWithSignAlgorithm(req, nil, sign.AlgorithmHMACSHA256) + if err != nil { + t.Fatalf("创建 HMAC 占位订单失败: %v", err) + } + parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId) + if err != nil { + t.Fatalf("读取占位订单失败: %v", err) + } + if parent.SignAlgorithm != sign.AlgorithmHMACSHA256 { + t.Fatalf("占位订单算法 = %q, want %q", parent.SignAlgorithm, sign.AlgorithmHMACSHA256) + } + + if _, err = SwitchNetwork(&request.SwitchNetworkRequest{ + TradeId: parentResp.TradeId, + Token: "USDT", + Network: mdb.NetworkTron, + }); err != nil { + t.Fatalf("占位订单原地选择网络失败: %v", err) + } + parent, err = data.GetOrderInfoByTradeId(parentResp.TradeId) + if err != nil { + t.Fatalf("读取原地补全订单失败: %v", err) + } + if parent.SignAlgorithm != sign.AlgorithmHMACSHA256 { + t.Fatalf("原地补全后算法 = %q, want %q", parent.SignAlgorithm, sign.AlgorithmHMACSHA256) + } + + subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{ + TradeId: parentResp.TradeId, + Token: "USDT", + Network: mdb.NetworkEthereum, + }) + if err != nil { + t.Fatalf("创建切换网络子订单失败: %v", err) + } + subOrder, err := data.GetOrderInfoByTradeId(subResp.TradeId) + if err != nil { + t.Fatalf("读取切换网络子订单失败: %v", err) + } + if subOrder.SignAlgorithm != sign.AlgorithmHMACSHA256 { + t.Fatalf("子订单算法 = %q, want %q", subOrder.SignAlgorithm, sign.AlgorithmHMACSHA256) + } +} + func TestCreateTransactionRejectsPartialTokenNetwork(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() diff --git a/src/mq/worker.go b/src/mq/worker.go index f4f73954..79c74c35 100644 --- a/src/mq/worker.go +++ b/src/mq/worker.go @@ -231,7 +231,7 @@ func sendOrderCallback(order *mdb.Orders) error { BlockTransactionId: order.BlockTransactionId, Status: mdb.StatusPaySuccess, } - signature, err := sign.GetHMACSHA256(orderResp, apiKeyRow.SecretKey) + signature, err := sign.GetByAlgorithm(orderResp, apiKeyRow.SecretKey, order.SignAlgorithm) if err != nil { return err } diff --git a/src/mq/worker_test.go b/src/mq/worker_test.go index b8086806..002a4e18 100644 --- a/src/mq/worker_test.go +++ b/src/mq/worker_test.go @@ -485,6 +485,7 @@ func TestSendOrderCallbackGmpayUsesApiKeySecretByPid(t *testing.T) { BlockTransactionId: "block_gmpay_sign", ApiKeyID: key.ID, PaymentType: mdb.PaymentTypeGmpay, + SignAlgorithm: sign.AlgorithmHMACSHA256, } if err := sendOrderCallback(order); err != nil { @@ -524,6 +525,60 @@ func TestSendOrderCallbackGmpayUsesApiKeySecretByPid(t *testing.T) { } } +func TestSendOrderCallbackGmpayHistoricalOrderKeepsMD5AfterKeyModeChange(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + key := &mdb.ApiKey{ + Name: "historical-gmpay-key", + Pid: "9051", + SecretKey: "historical-secret-9051", + GMPaySignMode: sign.GMPaySignModeHMACSHA256, + Status: mdb.ApiKeyStatusEnable, + } + if err := dao.Mdb.Create(key).Error; err != nil { + t.Fatalf("创建 API Key 失败: %v", err) + } + + var received map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &received) + _, _ = io.WriteString(w, "ok") + })) + defer server.Close() + + order := &mdb.Orders{ + TradeId: "trade_historical_md5", + OrderId: "order_historical_md5", + Amount: 1, + Currency: "CNY", + ActualAmount: 1, + ReceiveAddress: "wallet_historical_md5", + Token: "USDT", + Status: mdb.StatusPaySuccess, + NotifyUrl: server.URL, + BlockTransactionId: "block_historical_md5", + ApiKeyID: key.ID, + PaymentType: mdb.PaymentTypeGmpay, + SignAlgorithm: "", + } + if err := sendOrderCallback(order); err != nil { + t.Fatalf("发送历史订单回调失败: %v", err) + } + + receivedSignature, _ := received["signature"].(string) + delete(received, "signature") + want, err := sign.Get(received, key.SecretKey) + if err != nil { + t.Fatalf("生成预期 MD5 签名失败: %v", err) + } + if receivedSignature != want { + t.Fatalf("历史订单回调签名 = %q, want MD5 %q", receivedSignature, want) + } +} + func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() diff --git a/src/route/admin_router_test.go b/src/route/admin_router_test.go index 9226725e..a678714c 100644 --- a/src/route/admin_router_test.go +++ b/src/route/admin_router_test.go @@ -22,6 +22,7 @@ import ( "github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/http_client" appLog "github.com/GMWalletApp/epusdt/util/log" + "github.com/GMWalletApp/epusdt/util/sign" "github.com/go-resty/resty/v2" "github.com/labstack/echo/v4" ) @@ -408,13 +409,37 @@ func TestAdminApiKeys_CRUD(t *testing.T) { t.Fatal("CreateApiKey response missing id") } keyIDStr := fmt.Sprintf("%.0f", keyID.(float64)) + if got, _ := dataObj["gmpay_sign_mode"].(string); got != sign.GMPaySignModeHMACSHA256 { + t.Fatalf("新建 API Key 的签名模式 = %q, want %q", got, sign.GMPaySignModeHMACSHA256) + } // Update. rec = doPatchAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr, map[string]interface{}{ - "name": "renamed-key", + "name": "renamed-key", + "gmpay_sign_mode": sign.GMPaySignModeDual, }, token) t.Logf("UpdateApiKey: %s", rec.Body.String()) assertOK(t, rec) + row, err := data.GetApiKeyByID(uint64(keyID.(float64))) + if err != nil { + t.Fatalf("读取更新后的 API Key 失败: %v", err) + } + if row.GMPaySignMode != sign.GMPaySignModeDual { + t.Fatalf("更新后的签名模式 = %q, want %q", row.GMPaySignMode, sign.GMPaySignModeDual) + } + + // 未知模式必须拒绝,且不能改变已保存的模式。 + rec = doPatchAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr, map[string]interface{}{ + "gmpay_sign_mode": "unknown", + }, token) + assertErrorCode(t, rec, 10009) + row, err = data.GetApiKeyByID(uint64(keyID.(float64))) + if err != nil { + t.Fatalf("读取拒绝更新后的 API Key 失败: %v", err) + } + if row.GMPaySignMode != sign.GMPaySignModeDual { + t.Fatalf("非法更新改变了签名模式: %q", row.GMPaySignMode) + } // Get secret. rec = doGetAdmin(e, "/admin/api/v1/api-keys/"+keyIDStr+"/secret", token) diff --git a/src/route/router_test.go b/src/route/router_test.go index 0315f368..53997a73 100644 --- a/src/route/router_test.go +++ b/src/route/router_test.go @@ -133,17 +133,19 @@ func setupTestEnv(t *testing.T) *echo.Echo { // as both pid and secret_key so signing helper calls // stay valid. dao.Mdb.Create(&mdb.ApiKey{ - Name: "test-universal", - Pid: testAPIToken, - SecretKey: testAPIToken, - Status: mdb.ApiKeyStatusEnable, + Name: "test-universal", + Pid: testAPIToken, + SecretKey: testAPIToken, + GMPaySignMode: sign.GMPaySignModeHMACSHA256, + Status: mdb.ApiKeyStatusEnable, }) // Additional numeric-PID row for EPAY tests (EPAY pid must be numeric). dao.Mdb.Create(&mdb.ApiKey{ - Name: "test-epay-pid-1", - Pid: "1", - SecretKey: testAPIToken, - Status: mdb.ApiKeyStatusEnable, + Name: "test-epay-pid-1", + Pid: "1", + SecretKey: testAPIToken, + GMPaySignMode: sign.GMPaySignModeHMACSHA256, + Status: mdb.ApiKeyStatusEnable, }) e := echo.New() @@ -449,6 +451,102 @@ func TestCreateOrderGmpayV1RejectsLegacyMD5Signature(t *testing.T) { } } +func TestCreateOrderGmpayV1DualAcceptsLegacyMD5AndPersistsAlgorithm(t *testing.T) { + e := setupTestEnv(t) + if err := dao.Mdb.Model(&mdb.ApiKey{}). + Where("pid = ?", testAPIToken). + Update("gmpay_sign_mode", sign.GMPaySignModeDual).Error; err != nil { + t.Fatalf("设置双兼容模式失败: %v", err) + } + + body := map[string]interface{}{ + "pid": testAPIToken, + "order_id": "test-dual-md5-001", + "amount": 1, + "token": "usdt", + "currency": "cny", + "network": "solana", + "notify_url": "https://93.184.216.34/notify", + } + legacySignature, err := sign.Get(body, testAPIToken) + if err != nil { + t.Fatalf("生成旧版 MD5 签名失败: %v", err) + } + body["signature"] = legacySignature + + rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body) + if rec.Code != http.StatusOK { + t.Fatalf("双兼容模式应接受 MD5,得到 %d: %s", rec.Code, rec.Body.String()) + } + respData, _ := parseResp(t, rec)["data"].(map[string]interface{}) + tradeID, _ := respData["trade_id"].(string) + order, err := data.GetOrderInfoByTradeId(tradeID) + if err != nil { + t.Fatalf("读取订单失败: %v", err) + } + if order.SignAlgorithm != sign.AlgorithmMD5 { + t.Fatalf("订单签名算法 = %q, want %q", order.SignAlgorithm, sign.AlgorithmMD5) + } +} + +func TestCreateOrderGmpayV1MD5ModeRejectsHMACAndAcceptsMD5(t *testing.T) { + e := setupTestEnv(t) + useRspErrorHTTPStatuses(e) + if err := dao.Mdb.Model(&mdb.ApiKey{}). + Where("pid = ?", testAPIToken). + Update("gmpay_sign_mode", sign.GMPaySignModeMD5).Error; err != nil { + t.Fatalf("设置 MD5 模式失败: %v", err) + } + + hmacBody := signBody(map[string]interface{}{ + "order_id": "test-md5-mode-hmac-001", + "amount": 1, + "token": "usdt", + "currency": "cny", + "network": "solana", + "notify_url": "https://93.184.216.34/notify", + }) + if rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", hmacBody); rec.Code != http.StatusUnauthorized { + t.Fatalf("MD5 模式应拒绝 HMAC,得到 %d: %s", rec.Code, rec.Body.String()) + } + + md5Body := map[string]interface{}{ + "pid": testAPIToken, + "order_id": "test-md5-mode-md5-001", + "amount": 1, + "token": "usdt", + "currency": "cny", + "network": "solana", + "notify_url": "https://93.184.216.34/notify", + } + md5Body["signature"], _ = sign.Get(md5Body, testAPIToken) + if rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", md5Body); rec.Code != http.StatusOK { + t.Fatalf("MD5 模式应接受 MD5,得到 %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestCreateOrderGmpayV1UnknownModeFailsClosed(t *testing.T) { + e := setupTestEnv(t) + useRspErrorHTTPStatuses(e) + if err := dao.Mdb.Model(&mdb.ApiKey{}). + Where("pid = ?", testAPIToken). + Update("gmpay_sign_mode", "unknown").Error; err != nil { + t.Fatalf("设置未知模式失败: %v", err) + } + + body := signBody(map[string]interface{}{ + "order_id": "test-unknown-mode-001", + "amount": 1, + "token": "usdt", + "currency": "cny", + "network": "solana", + "notify_url": "https://93.184.216.34/notify", + }) + if rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body); rec.Code != http.StatusUnauthorized { + t.Fatalf("未知模式必须拒绝请求,得到 %d: %s", rec.Code, rec.Body.String()) + } +} + func TestCreateOrderGmpayV1PaymentTypeEpayUsesHMACSHA256(t *testing.T) { e := setupTestEnv(t) useRspErrorHTTPStatuses(e) @@ -484,6 +582,9 @@ func TestCreateOrderGmpayV1PaymentTypeEpayUsesHMACSHA256(t *testing.T) { if order.PaymentType != mdb.PaymentTypeEpay { t.Fatalf("payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay) } + if order.SignAlgorithm != sign.AlgorithmHMACSHA256 { + t.Fatalf("sign_algorithm = %q, want %q", order.SignAlgorithm, sign.AlgorithmHMACSHA256) + } tampered := signBody(map[string]interface{}{ "pid": "1", @@ -548,6 +649,44 @@ func TestCreateOrderGmpayV1FormData(t *testing.T) { t.Logf("Form-data order created: trade_id=%v", data["trade_id"]) } +func TestCreateOrderGmpayV1DualAcceptsLegacyMD5FormData(t *testing.T) { + e := setupTestEnv(t) + if err := dao.Mdb.Model(&mdb.ApiKey{}). + Where("pid = ?", testAPIToken). + Update("gmpay_sign_mode", sign.GMPaySignModeDual).Error; err != nil { + t.Fatalf("设置双兼容模式失败: %v", err) + } + + values := url.Values{ + "pid": {testAPIToken}, + "order_id": {"test-form-md5-001"}, + "amount": {"1.00"}, + "token": {"usdt"}, + "currency": {"cny"}, + "network": {"solana"}, + "notify_url": {"https://93.184.216.34/notify"}, + } + params := make(map[string]interface{}, len(values)) + for key, items := range values { + params[key] = items[0] + } + values.Set("signature", mustLegacyGMPaySignature(t, params)) + + rec := doFormPost(e, "/payments/gmpay/v1/order/create-transaction", values) + if rec.Code != http.StatusOK { + t.Fatalf("双兼容模式应接受表单 MD5,得到 %d: %s", rec.Code, rec.Body.String()) + } +} + +func mustLegacyGMPaySignature(t *testing.T, params map[string]interface{}) string { + t.Helper() + signature, err := sign.Get(params, testAPIToken) + if err != nil { + t.Fatalf("生成旧版 GMPay 签名失败: %v", err) + } + return signature +} + func TestCreateOrderGmpayV1PlaceholderWithoutTokenNetwork(t *testing.T) { e := setupTestEnv(t) diff --git a/src/util/sign/gmpay.go b/src/util/sign/gmpay.go new file mode 100644 index 00000000..7a41ebcd --- /dev/null +++ b/src/util/sign/gmpay.go @@ -0,0 +1,104 @@ +package sign + +import ( + "crypto/subtle" + "errors" + "strings" +) + +const ( + AlgorithmMD5 = "md5" + AlgorithmHMACSHA256 = "hmac_sha256" + + GMPaySignModeDual = "dual" + GMPaySignModeMD5 = AlgorithmMD5 + GMPaySignModeHMACSHA256 = AlgorithmHMACSHA256 +) + +var ( + ErrInvalidSignature = errors.New("签名校验失败") + ErrUnsupportedSignMode = errors.New("不支持的签名模式") + ErrUnsupportedAlgorithm = errors.New("不支持的签名算法") +) + +// NormalizeGMPaySignMode 统一 GMPay 签名模式;历史空值按双兼容处理。 +func NormalizeGMPaySignMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "": + return GMPaySignModeDual + case GMPaySignModeDual: + return GMPaySignModeDual + case GMPaySignModeMD5: + return GMPaySignModeMD5 + case GMPaySignModeHMACSHA256: + return GMPaySignModeHMACSHA256 + default: + return "" + } +} + +// IsExplicitGMPaySignMode 判断管理接口传入的非空模式是否有效。 +func IsExplicitGMPaySignMode(mode string) bool { + return strings.TrimSpace(mode) != "" && NormalizeGMPaySignMode(mode) != "" +} + +// NormalizeAlgorithm 统一订单签名算法;升级前订单和历史空值按 MD5 处理。 +func NormalizeAlgorithm(algorithm string) string { + switch strings.ToLower(strings.TrimSpace(algorithm)) { + case "", AlgorithmMD5: + return AlgorithmMD5 + case AlgorithmHMACSHA256: + return AlgorithmHMACSHA256 + default: + return "" + } +} + +// GetByAlgorithm 按订单保存的算法生成签名,未知算法直接失败。 +func GetByAlgorithm(data interface{}, bizKey, algorithm string) (string, error) { + switch NormalizeAlgorithm(algorithm) { + case AlgorithmMD5: + return Get(data, bizKey) + case AlgorithmHMACSHA256: + return GetHMACSHA256(data, bizKey) + default: + return "", ErrUnsupportedAlgorithm + } +} + +// VerifyGMPay 按商户配置验签,并返回本次实际命中的算法。 +func VerifyGMPay(data interface{}, bizKey, providedSignature, mode string) (string, error) { + mode = NormalizeGMPaySignMode(mode) + if mode == "" { + return "", ErrUnsupportedSignMode + } + + verify := func(algorithm string) (bool, error) { + expected, err := GetByAlgorithm(data, bizKey, algorithm) + if err != nil { + return false, err + } + return subtle.ConstantTimeCompare([]byte(expected), []byte(providedSignature)) == 1, nil + } + + if mode == GMPaySignModeDual || mode == GMPaySignModeHMACSHA256 { + ok, err := verify(AlgorithmHMACSHA256) + if err != nil { + return "", err + } + if ok { + return AlgorithmHMACSHA256, nil + } + } + if mode == GMPaySignModeDual || mode == GMPaySignModeMD5 { + ok, err := verify(AlgorithmMD5) + if err != nil { + return "", err + } + if ok { + return AlgorithmMD5, nil + } + } + + return "", ErrInvalidSignature +} diff --git a/src/util/sign/sign_test.go b/src/util/sign/sign_test.go index 9bf2564d..fa83aa44 100644 --- a/src/util/sign/sign_test.go +++ b/src/util/sign/sign_test.go @@ -1,6 +1,9 @@ package sign -import "testing" +import ( + "errors" + "testing" +) func TestGetHMACSHA256FixedVector(t *testing.T) { params := map[string]interface{}{ @@ -62,3 +65,57 @@ func TestGetMD5RemainsUnchanged(t *testing.T) { t.Fatalf("Get() = %q, want %q", got, want) } } + +func TestVerifyGMPayModes(t *testing.T) { + params := map[string]interface{}{"pid": "1000", "amount": 100} + hmacSignature, err := GetHMACSHA256(params, "test-secret") + if err != nil { + t.Fatalf("生成 HMAC 签名失败: %v", err) + } + md5Signature, err := Get(params, "test-secret") + if err != nil { + t.Fatalf("生成 MD5 签名失败: %v", err) + } + + tests := []struct { + name string + mode string + signature string + want string + wantErr error + }{ + {name: "双兼容命中 HMAC", mode: GMPaySignModeDual, signature: hmacSignature, want: AlgorithmHMACSHA256}, + {name: "双兼容命中 MD5", mode: GMPaySignModeDual, signature: md5Signature, want: AlgorithmMD5}, + {name: "历史空模式命中 MD5", mode: "", signature: md5Signature, want: AlgorithmMD5}, + {name: "HMAC 模式拒绝 MD5", mode: GMPaySignModeHMACSHA256, signature: md5Signature, wantErr: ErrInvalidSignature}, + {name: "MD5 模式拒绝 HMAC", mode: GMPaySignModeMD5, signature: hmacSignature, wantErr: ErrInvalidSignature}, + {name: "未知模式关闭验签", mode: "unknown", signature: hmacSignature, wantErr: ErrUnsupportedSignMode}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := VerifyGMPay(params, "test-secret", tt.signature, tt.mode) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("VerifyGMPay() error = %v, want %v", err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("VerifyGMPay() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetByAlgorithmUsesMD5ForHistoricalEmptyValue(t *testing.T) { + params := map[string]interface{}{"pid": "1000", "amount": 100} + want, err := Get(params, "test-secret") + if err != nil { + t.Fatalf("生成 MD5 签名失败: %v", err) + } + got, err := GetByAlgorithm(params, "test-secret", "") + if err != nil { + t.Fatalf("GetByAlgorithm() error = %v", err) + } + if got != want { + t.Fatalf("GetByAlgorithm() = %q, want %q", got, want) + } +} diff --git a/wiki/API.md b/wiki/API.md index 160faaef..84e2faf7 100644 --- a/wiki/API.md +++ b/wiki/API.md @@ -45,17 +45,25 @@ ## 签名规则 -当前版本使用统一商户凭证。请求必须携带 `pid`,服务端用 `pid` 查询对应的 `secret_key` 作为签名密钥。默认安装会创建一个 PID 为 `1000` 的默认密钥。 +当前版本使用统一商户凭证。请求必须携带 `pid`,服务端用 `pid` 查询对应的 `secret_key` 作为签名密钥。全新安装创建的默认密钥使用 HMAC-SHA256;从旧版本升级的已有密钥进入 `dual` 模式,以便平滑迁移旧 MD5 调用方。 ### GMPay 签名 1. 将所有非空参数按参数名 ASCII 字典序升序排序。 2. 使用 `key=value` 形式以 `&` 拼接。 3. 不参与签名的字段:`signature`。 -4. 使用 `secret_key` 作为 HMAC 密钥,对拼接字符串计算 HMAC-SHA256。 +4. 推荐算法:使用 `secret_key` 作为 HMAC 密钥,对拼接字符串计算 HMAC-SHA256。 5. 将结果编码为 64 位小写十六进制字符串,作为 `signature`。 -> GMPay 已硬切至 HMAC-SHA256,不再接受旧版 MD5 签名,也不提供算法协商或回退。 +每个 API Key 的 `gmpay_sign_mode` 决定允许的 GMPay 算法: + +| 模式 | 行为 | +| --- | --- | +| `hmac_sha256` | 仅接受 HMAC-SHA256;全新 API Key 的默认值。 | +| `md5` | 仅接受旧版 `MD5(待签名字符串 + secret_key)`。 | +| `dual` | 先校验 HMAC-SHA256,失败后再校验旧版 MD5;已有 API Key 升级后的默认值。 | + +管理员可通过 `PATCH /admin/api/v1/api-keys/{id}` 设置模式,例如 `{"gmpay_sign_mode":"dual"}`。算法由服务端配置决定,客户端不能通过额外请求字段选择算法;额外字段反而会参与签名并导致校验失败。 注意: @@ -115,6 +123,26 @@ function gmpaySign(array $params, string $secretKey): string } ``` +迁移期旧版 MD5 计算方式如下,仅适用于 API Key 已设置为 `dual` 或 `md5` 的情况: + +```php +function gmpayLegacyMd5Sign(array $params, string $secretKey): string +{ + unset($params['signature']); + ksort($params, SORT_STRING); + + $pairs = []; + foreach ($params as $key => $value) { + if ($value === '' || $value === null) { + continue; + } + $pairs[] = $key . '=' . $value; + } + + return strtolower(md5(implode('&', $pairs) . $secretKey)); +} +``` + EPay 兼容接口使用 `sign` 字段,签名时排除 `sign` 和 `sign_type`: ```php @@ -176,7 +204,7 @@ curl -X POST 'https://pay.example.com/payments/gmpay/v1/order/create-transaction "notify_url": "https://merchant.example/notify", "redirect_url": "https://merchant.example/return", "name": "VIP", - "signature": "476412c422f4dd75c3d533f5c47a9cac" + "signature": "6f874b1919d95081835e2809b620e354a5866f5a6dbb2e432d1627f1eb10059d" }' ``` @@ -194,7 +222,7 @@ curl -X POST 'https://pay.example.com/payments/gmpay/v1/order/create-transaction | `redirect_url` | string | 否 | 支付完成后的同步跳转地址。 | | `name` | string | 否 | 商品/订单名称。 | | `payment_type` | string | 否 | GMPay 兼容字段,不要求必须传;如果传了非空值,必须参与 GMPay `signature` 计算。普通 GMPay 不传时后台会存为 `Gmpay`;传 `Epay`(大小写不敏感)会统一存为 `Epay` 并使用 EPay 回调格式,且 PID 必须是数字。 | -| `signature` | string | 是 | 64 位小写十六进制 GMPay HMAC-SHA256 签名。 | +| `signature` | string | 是 | 推荐使用 64 位小写十六进制 HMAC-SHA256;`dual` 或 `md5` 模式也接受旧版 32 位 MD5。 | `token` 和 `network` 必须同传或同缺。两者同缺时只创建包含 `amount/currency` 的占位订单,状态为 `4`,不会分配钱包、不会计算链上支付金额,也不会锁定交易金额;后续由收银台调用 `/pay/switch-network` 选择具体链和币种或 OkPay。只缺其中一个会返回参数错误。 @@ -679,10 +707,10 @@ sign_type=MD5 | `receive_address` | string | 收款地址。 | | `token` | string | 收款币种。 | | `block_transaction_id` | string | 链上交易哈希或第三方支付订单号。 | -| `signature` | string | 64 位小写十六进制 HMAC-SHA256 回调签名。 | +| `signature` | string | GMPay 回调签名;HMAC-SHA256 为 64 位小写十六进制,旧版 MD5 为 32 位。 | | `status` | integer | 当前仅支付成功时回调,值为 `2`。 | -GMPay 回调验签方式与创建订单一致:排除 `signature` 字段后,使用商户 `secret_key` 对规范化参数字符串计算 HMAC-SHA256。回调体不包含 `payment_type` 字段。 +GMPay 回调使用订单创建时实际通过的算法,不受之后修改 API Key 模式影响。验签时排除 `signature` 字段:HMAC 订单使用 `secret_key` 作为密钥计算 HMAC-SHA256;旧版订单计算 `MD5(规范化参数字符串 + secret_key)`。回调体不包含 `payment_type` 和算法标识字段。 ### EPay 兼容回调 From 526d149c11d448cbc0cfec080761277ec148078f Mon Sep 17 00:00:00 2001 From: mala Date: Sun, 16 Aug 2026 19:38:43 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=20EVM=20?= =?UTF-8?q?=E8=A1=A5=E6=89=AB=E4=B8=8E=E6=94=AF=E4=BB=98=E6=94=B6=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/model/service/order_service.go | 71 ++++++++------ src/model/service/order_service_test.go | 65 ++++++++++++ src/model/service/task_service.go | 66 +++++++++---- src/model/service/task_service_test.go | 125 ++++++++++++++++++++++++ src/task/listen.go | 2 + src/task/listen_evm_added_chains.go | 36 ++++++- src/task/listen_evm_backfill.go | 81 ++++++++++++--- src/task/listen_evm_backfill_test.go | 115 ++++++++++++++++++++++ 8 files changed, 502 insertions(+), 59 deletions(-) create mode 100644 src/task/listen_evm_backfill_test.go diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index da060cb8..1228a510 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -278,6 +278,11 @@ func orderProcessing(req *request.OrderProcessingRequest, opts orderProcessingOp } if exist.ID > 0 { tx.Rollback() + if exist.TradeId == req.TradeId { + if err = finalizePaidOrder(exist, opts); err != nil { + return fmt.Errorf("继续完成已支付订单失败, trade_id=%s: %w", req.TradeId, err) + } + } return constant.OrderBlockAlreadyProcess } @@ -295,7 +300,6 @@ func orderProcessing(req *request.OrderProcessingRequest, opts orderProcessingOp return err } - // Load order to check parent-child relationship order, err := data.GetOrderInfoByTradeId(req.TradeId) if err != nil { if strings.TrimSpace(req.Network) != "" && strings.TrimSpace(req.ReceiveAddress) != "" && strings.TrimSpace(req.Token) != "" && req.Amount > 0 { @@ -305,13 +309,20 @@ func orderProcessing(req *request.OrderProcessingRequest, opts orderProcessingOp } return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err) } + return finalizePaidOrder(order, opts) +} + +func finalizePaidOrder(order *mdb.Orders, opts orderProcessingOptions) error { + if order == nil || order.ID == 0 || order.Status != mdb.StatusPaySuccess { + return fmt.Errorf("订单尚未进入支付成功状态") + } if hasTransactionLock(order) { - if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil { + if err := data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil { log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", order.TradeId, err) } } - // Parent order paid directly: expire all sub-orders and release their locks + // 父单直接支付时,重复执行也会继续清理仍处于待支付状态的子单。 if order.ParentTradeId == "" { subs, subErr := data.GetActiveSubOrders(order.TradeId) if subErr != nil { @@ -319,16 +330,16 @@ func orderProcessing(req *request.OrderProcessingRequest, opts orderProcessingOp return fmt.Errorf("load sub-orders failed, parent_trade_id=%s: %w", order.TradeId, subErr) } for _, sub := range subs { - if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil { + if err := data.ExpireOrderByTradeId(sub.TradeId); err != nil { log.Sugar.Warnf("[order] expire sub-order failed, trade_id=%s, err=%v", sub.TradeId, err) } if sub.PayProvider != "" && sub.PayProvider != mdb.PaymentProviderOnChain { - if err = data.MarkProviderOrderExpired(sub.TradeId, sub.PayProvider); err != nil { + if err := data.MarkProviderOrderExpired(sub.TradeId, sub.PayProvider); err != nil { log.Sugar.Warnf("[order] expire provider order failed, trade_id=%s, provider=%s, err=%v", sub.TradeId, sub.PayProvider, err) } } if hasTransactionLock(&sub) { - if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil { + if err := data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil { log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err) } } @@ -341,8 +352,10 @@ func orderProcessing(req *request.OrderProcessingRequest, opts orderProcessingOp log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err) return fmt.Errorf("load parent order failed, parent_trade_id=%s: %w", order.ParentTradeId, err) } + if parent == nil || parent.ID == 0 { + return fmt.Errorf("父订单不存在, parent_trade_id=%s", order.ParentTradeId) + } - // Snapshot siblings for lock release after DB state transition commits. siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId) if err != nil { log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err) @@ -351,51 +364,53 @@ func orderProcessing(req *request.OrderProcessingRequest, opts orderProcessingOp finalizeTx := dao.Mdb.Begin() - // Mark parent as paid with sub-order's payment details - updatedParent, markErr := data.MarkParentOrderSuccessWithStatusesWithTransaction(finalizeTx, parent.TradeId, order, opts.parentAllowedStatuses) - if markErr != nil { - finalizeTx.Rollback() - log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, markErr) - return fmt.Errorf("mark parent success failed, parent_trade_id=%s: %w", parent.TradeId, markErr) - } - if !updatedParent { - finalizeTx.Rollback() - return fmt.Errorf("parent order not updated, trade_id=%s is not in an allowed status", parent.TradeId) + if parent.Status == mdb.StatusPaySuccess { + if parent.PayBySubId != order.ID { + finalizeTx.Rollback() + return fmt.Errorf("父订单已由其他支付路径完成, parent_trade_id=%s pay_by_sub_id=%d", parent.TradeId, parent.PayBySubId) + } + } else { + updatedParent, markErr := data.MarkParentOrderSuccessWithStatusesWithTransaction(finalizeTx, parent.TradeId, order, opts.parentAllowedStatuses) + if markErr != nil { + finalizeTx.Rollback() + log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, markErr) + return fmt.Errorf("mark parent success failed, parent_trade_id=%s: %w", parent.TradeId, markErr) + } + if !updatedParent { + finalizeTx.Rollback() + return fmt.Errorf("parent order not updated, trade_id=%s is not in an allowed status", parent.TradeId) + } } - if err = data.ExpireSiblingSubOrdersWithTransaction(finalizeTx, parent.TradeId, order.TradeId); err != nil { + if err := data.ExpireSiblingSubOrdersWithTransaction(finalizeTx, parent.TradeId, order.TradeId); err != nil { finalizeTx.Rollback() return fmt.Errorf("expire sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err) } - if err = finalizeTx.Commit().Error; err != nil { + if err := finalizeTx.Commit().Error; err != nil { finalizeTx.Rollback() return fmt.Errorf("commit parent finalize tx failed, parent_trade_id=%s: %w", parent.TradeId, err) } - // Sub-order should not trigger its own callback (notify_url is empty). - // OrderSuccessWithTransaction unconditionally sets callback_confirm=No, - // reset it only after the parent order is successfully finalized. - if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil { + // 子单不独立回调;即使此前在提交后中断,重试也会恢复为无需回调。 + if err := data.ResetCallbackConfirmOk(order.TradeId); err != nil { log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err) } - // Release parent's own wallet lock if hasTransactionLock(parent) { - if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil { + if err := data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil { log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err) } } - // Release sibling locks after their status transitions commit. for _, sib := range siblings { if sib.PayProvider != "" && sib.PayProvider != mdb.PaymentProviderOnChain { - if err = data.MarkProviderOrderExpired(sib.TradeId, sib.PayProvider); err != nil { + if err := data.MarkProviderOrderExpired(sib.TradeId, sib.PayProvider); err != nil { log.Sugar.Warnf("[order] expire sibling provider order failed, trade_id=%s, provider=%s, err=%v", sib.TradeId, sib.PayProvider, err) } } if hasTransactionLock(&sib) { - if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil { + if err := data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil { log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err) } } diff --git a/src/model/service/order_service_test.go b/src/model/service/order_service_test.go index 6ccd8a67..54d03371 100644 --- a/src/model/service/order_service_test.go +++ b/src/model/service/order_service_test.go @@ -1179,6 +1179,71 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) { } } +func TestOrderProcessingDuplicateBlockResumesSubOrderFinalization(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + if _, err := data.AddWalletAddress("TResumeParentWallet001"); err != nil { + t.Fatalf("添加父单钱包失败: %v", err) + } + if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xD1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil { + t.Fatalf("添加子单钱包失败: %v", err) + } + + parentReq := newCreateTransactionRequest("order_resume_finalize", 1) + parentReq.Network = mdb.NetworkTron + parentResp, err := CreateTransaction(parentReq, nil) + if err != nil { + t.Fatalf("创建父单失败: %v", err) + } + subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{ + TradeId: parentResp.TradeId, + Token: "usdt", + Network: mdb.NetworkEthereum, + }) + if err != nil { + t.Fatalf("创建子单失败: %v", err) + } + + const blockID = "block_resume_finalize" + if err = dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", subResp.TradeId). + Updates(map[string]interface{}{ + "status": mdb.StatusPaySuccess, + "block_transaction_id": blockID, + "callback_confirm": mdb.CallBackConfirmNo, + }).Error; err != nil { + t.Fatalf("模拟子单提交成功失败: %v", err) + } + + err = OrderProcessing(&request.OrderProcessingRequest{ + ReceiveAddress: subResp.ReceiveAddress, + Token: strings.ToUpper(subResp.Token), + Network: strings.ToLower(subResp.Network), + TradeId: subResp.TradeId, + Amount: subResp.ActualAmount, + BlockTransactionId: blockID, + }) + if err != constant.OrderBlockAlreadyProcess { + t.Fatalf("重复区块处理结果 = %v, want %v", err, constant.OrderBlockAlreadyProcess) + } + + sub, err := data.GetOrderInfoByTradeId(subResp.TradeId) + if err != nil { + t.Fatalf("读取子单失败: %v", err) + } + parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId) + if err != nil { + t.Fatalf("读取父单失败: %v", err) + } + if parent.Status != mdb.StatusPaySuccess || parent.PayBySubId != sub.ID { + t.Fatalf("父单未完成收尾: status=%d pay_by_sub_id=%d want_sub_id=%d", parent.Status, parent.PayBySubId, sub.ID) + } + if sub.CallBackConfirm != mdb.CallBackConfirmOk { + t.Fatalf("子单 callback_confirm = %d, want %d", sub.CallBackConfirm, mdb.CallBackConfirmOk) + } +} + func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go index 2989c6bf..746127d8 100644 --- a/src/model/service/task_service.go +++ b/src/model/service/task_service.go @@ -205,9 +205,16 @@ func evmChainLogLabel(chainNetwork string) string { } func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) { + if err := ProcessEvmERC20Transfer(chainNetwork, contract, toAddr, rawValue, txHash, blockTsMs); err != nil { + log.Sugar.Errorf("[%s-WS] processing transfer failed hash=%s: %v", evmChainLogLabel(chainNetwork), txHash, err) + } +} + +func ProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) (processErr error) { defer func() { - if err := recover(); err != nil { - log.Sugar.Errorf("[%s-WS] TryProcessEvmERC20Transfer panic: %v", evmChainLogLabel(chainNetwork), err) + if recovered := recover(); recovered != nil { + processErr = fmt.Errorf("处理 EVM 转账发生 panic: %v", recovered) + log.Sugar.Errorf("[%s-WS] TryProcessEvmERC20Transfer panic: %v", evmChainLogLabel(chainNetwork), recovered) } }() @@ -215,16 +222,16 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to tokenConfig, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex()) if err != nil { log.Sugar.Warnf("[%s-WS] load chain token contract=%s: %v", net, contract.Hex(), err) - return + return fmt.Errorf("读取链代币配置 network=%s contract=%s: %w", chainNetwork, contract.Hex(), err) } if tokenConfig == nil || tokenConfig.ID == 0 { log.Sugar.Warnf("[%s-WS] skip unconfigured contract %s", net, contract.Hex()) - return + return fmt.Errorf("链代币配置已失效 network=%s contract=%s", chainNetwork, contract.Hex()) } tokenSym := strings.ToUpper(strings.TrimSpace(tokenConfig.Symbol)) if tokenSym == "" { log.Sugar.Warnf("[%s-WS] skip contract %s with empty token symbol", net, contract.Hex()) - return + return fmt.Errorf("链代币符号为空 network=%s contract=%s", chainNetwork, contract.Hex()) } walletAddr := strings.ToLower(toAddr.Hex()) if rawValue == nil || rawValue.Sign() <= 0 { @@ -233,7 +240,7 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to } decimals := tokenConfig.Decimals if decimals < 0 { - decimals = 0 + return fmt.Errorf("链代币精度无效 network=%s contract=%s decimals=%d", chainNetwork, contract.Hex(), decimals) } pow := decimal.New(1, int32(decimals)) @@ -253,7 +260,14 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to order, fromLock, err := resolveEvmTransferOrder(chainNetwork, walletAddr, tokenSym, amount, blockTsMs) if err != nil { log.Sugar.Warnf("[%s-%s][%s] load order candidate: %v", net, tokenSym, walletAddr, err) - return + return fmt.Errorf("读取待支付订单 network=%s token=%s address=%s: %w", chainNetwork, tokenSym, walletAddr, err) + } + if order == nil || order.ID == 0 { + order, err = data.GetOrderByBlockTransactionIDsCaseInsensitive([]string{txHash}) + if err != nil { + return fmt.Errorf("按交易哈希读取已提交订单 hash=%s: %w", txHash, err) + } + fromLock = false } if order == nil || order.ID == 0 { log.Sugar.Warnf("[%s-%s][%s] skip unmatched tx hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount) @@ -261,25 +275,28 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to } if strings.ToLower(strings.TrimSpace(order.Network)) != chainNetwork { log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s network=%q", net, tokenSym, walletAddr, order.TradeId, order.Network) - return + return fmt.Errorf("订单网络与转账不一致 trade_id=%s order_network=%s transfer_network=%s", order.TradeId, order.Network, chainNetwork) } if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym { log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, order.TradeId, order.Token) - return + return fmt.Errorf("订单代币与转账不一致 trade_id=%s order_token=%s transfer_token=%s", order.TradeId, order.Token, tokenSym) + } + if !strings.EqualFold(strings.TrimSpace(order.ReceiveAddress), walletAddr) { + return fmt.Errorf("订单收款地址与转账不一致 trade_id=%s order_address=%s transfer_address=%s", order.TradeId, order.ReceiveAddress, walletAddr) + } + precision := int32(data.GetAmountPrecision()) + if !decimal.NewFromFloat(order.ActualAmount).Round(precision).Equal(decimal.NewFromFloat(amount).Round(precision)) { + return fmt.Errorf("订单金额与转账不一致 trade_id=%s order_amount=%v transfer_amount=%v", order.TradeId, order.ActualAmount, amount) } if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() { log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block time %d is before order create time %d", net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli()) return } - allowedStatuses := []int{mdb.StatusWaitPay} - if order.Status == mdb.StatusExpired { - expirationTs := order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli() - if blockTsMs <= 0 || blockTsMs > expirationTs { - log.Sugar.Warnf("[%s-%s][%s] skip expired trade_id=%s because block time %d is after expiration %d", net, tokenSym, walletAddr, order.TradeId, blockTsMs, expirationTs) - return - } - allowedStatuses = []int{mdb.StatusWaitPay, mdb.StatusExpired} + allowedStatuses, expirationTs, payable := evmTransferAllowedStatuses(order, blockTsMs) + if !payable { + log.Sugar.Warnf("[%s-%s][%s] skip expired trade_id=%s because block time %d is after expiration %d", net, tokenSym, walletAddr, order.TradeId, blockTsMs, expirationTs) + return } if fromLock { @@ -303,11 +320,24 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to return } log.Sugar.Errorf("[%s-%s][%s] OrderProcessing: %v", net, tokenSym, walletAddr, err) - return + return fmt.Errorf("处理 EVM 支付订单 trade_id=%s hash=%s: %w", order.TradeId, txHash, err) } sendPaymentNotification(order) log.Sugar.Infof("[%s-%s][%s] payment processed trade_id=%s hash=%s", net, tokenSym, walletAddr, order.TradeId, txHash) + return nil +} + +func evmTransferAllowedStatuses(order *mdb.Orders, blockTsMs int64) ([]int, int64, bool) { + expirationTs := order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli() + if blockTsMs > 0 && blockTsMs <= expirationTs { + // 支付发生在有效期内时同时允许 WaitPay/Expired,消除过期任务与链监听的状态竞态。 + return []int{mdb.StatusWaitPay, mdb.StatusExpired}, expirationTs, true + } + if order.Status == mdb.StatusExpired { + return nil, expirationTs, false + } + return []int{mdb.StatusWaitPay}, expirationTs, true } func resolveEvmTransferOrder(chainNetwork string, walletAddr string, tokenSym string, amount float64, blockTsMs int64) (*mdb.Orders, bool, error) { diff --git a/src/model/service/task_service_test.go b/src/model/service/task_service_test.go index 9c52933a..7ab9f973 100644 --- a/src/model/service/task_service_test.go +++ b/src/model/service/task_service_test.go @@ -12,6 +12,7 @@ import ( "github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/notify" + "github.com/dromara/carbon/v2" "github.com/ethereum/go-ethereum/common" "gorm.io/gorm/clause" ) @@ -342,3 +343,127 @@ func TestTryProcessEvmERC20TransferSkipsExpiredOrderAfterPaymentWindow(t *testin t.Fatalf("block transaction id = %q, want empty", got.BlockTransactionId) } } + +func TestTryProcessEvmERC20TransferReturnsDatabaseFailure(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + sqlDB, err := dao.Mdb.DB() + if err != nil { + t.Fatalf("读取数据库句柄失败: %v", err) + } + if err = sqlDB.Close(); err != nil { + t.Fatalf("关闭测试数据库失败: %v", err) + } + + err = ProcessEvmERC20Transfer( + mdb.NetworkBase, + common.HexToAddress("0x9999999999999999999999999999999999999999"), + common.HexToAddress("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + big.NewInt(1), + "0xdatabase-error", + time.Now().UnixMilli(), + ) + if err == nil { + t.Fatal("数据库失败时应返回错误,避免补扫游标提前推进") + } +} + +func TestProcessEvmERC20TransferRejectsMissingTokenConfiguration(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + err := ProcessEvmERC20Transfer( + mdb.NetworkBase, + common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + common.HexToAddress("0xcccccccccccccccccccccccccccccccccccccccc"), + big.NewInt(1), + "0xmissing-token", + time.Now().UnixMilli(), + ) + if err == nil { + t.Fatal("补扫命中的合约配置消失时应阻止游标推进") + } +} + +func TestEvmTransferAllowedStatusesIncludesExpiredWithinPaymentWindow(t *testing.T) { + order := &mdb.Orders{Status: mdb.StatusWaitPay} + order.CreatedAt = *carbon.NewTime(carbon.Now()) + blockTsMs := order.CreatedAt.AddMinute().TimestampMilli() + + statuses, _, payable := evmTransferAllowedStatuses(order, blockTsMs) + if !payable { + t.Fatal("有效期内的转账应允许支付") + } + if len(statuses) != 2 || statuses[0] != mdb.StatusWaitPay || statuses[1] != mdb.StatusExpired { + t.Fatalf("allowed statuses = %v, want [%d %d]", statuses, mdb.StatusWaitPay, mdb.StatusExpired) + } +} + +func TestProcessEvmERC20TransferResumesPaidSubOrderWithoutRuntimeLock(t *testing.T) { + cleanup := testutil.SetupTestDatabases(t) + defer cleanup() + + const ( + parentTradeID = "T202608160100" + subTradeID = "T202608160101" + blockID = "0x1234567890abcdef" + ) + receiveAddress := common.HexToAddress("0xdddddddddddddddddddddddddddddddddddddddd") + parent := &mdb.Orders{ + TradeId: parentTradeID, + OrderId: "ORDER-PARENT-RESUME", + Status: mdb.StatusWaitPay, + PaymentType: mdb.PaymentTypeGmpay, + PayProvider: mdb.PaymentProviderOnChain, + SignAlgorithm: "md5", + } + if err := dao.Mdb.Create(parent).Error; err != nil { + t.Fatalf("创建父单失败: %v", err) + } + sub := &mdb.Orders{ + TradeId: subTradeID, + OrderId: "ORDER-SUB-RESUME", + ParentTradeId: parentTradeID, + BlockTransactionId: blockID, + ActualAmount: 1.25, + ReceiveAddress: strings.ToLower(receiveAddress.Hex()), + Token: "USDC", + Network: mdb.NetworkBase, + Status: mdb.StatusPaySuccess, + CallBackConfirm: mdb.CallBackConfirmNo, + PaymentType: mdb.PaymentTypeGmpay, + PayProvider: mdb.PaymentProviderOnChain, + SignAlgorithm: "md5", + } + if err := dao.Mdb.Create(sub).Error; err != nil { + t.Fatalf("创建已提交子单失败: %v", err) + } + + err := ProcessEvmERC20Transfer( + mdb.NetworkBase, + common.HexToAddress("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), + receiveAddress, + big.NewInt(1_250_000), + blockID, + time.Now().UnixMilli(), + ) + if err != nil { + t.Fatalf("继续处理已提交子单失败: %v", err) + } + + gotParent, err := data.GetOrderInfoByTradeId(parentTradeID) + if err != nil { + t.Fatalf("读取父单失败: %v", err) + } + gotSub, err := data.GetOrderInfoByTradeId(subTradeID) + if err != nil { + t.Fatalf("读取子单失败: %v", err) + } + if gotParent.Status != mdb.StatusPaySuccess || gotParent.PayBySubId != gotSub.ID { + t.Fatalf("父单未完成收尾: status=%d pay_by_sub_id=%d want_sub_id=%d", gotParent.Status, gotParent.PayBySubId, gotSub.ID) + } + if gotSub.CallBackConfirm != mdb.CallBackConfirmOk { + t.Fatalf("子单 callback_confirm = %d, want %d", gotSub.CallBackConfirm, mdb.CallBackConfirmOk) + } +} diff --git a/src/task/listen.go b/src/task/listen.go index c2929e89..e3b61180 100644 --- a/src/task/listen.go +++ b/src/task/listen.go @@ -19,6 +19,8 @@ func Start() { go StartBscBackfillScannerListener() go StartPolygonBackfillScannerListener() go StartPlasmaBackfillScannerListener() + go StartBaseBackfillScannerListener() + go StartArbitrumBackfillScannerListener() go StartTronBlockScannerListener() go StartTonBlockScannerListener() go StartAptosLedgerScannerListener() diff --git a/src/task/listen_evm_added_chains.go b/src/task/listen_evm_added_chains.go index 41e605bb..b44c7f6b 100644 --- a/src/task/listen_evm_added_chains.go +++ b/src/task/listen_evm_added_chains.go @@ -31,6 +31,32 @@ func StartArbitrumWebSocketListener() { startAddedEvmWebSocketListener(mdb.NetworkArbitrum, "[ARBITRUM-WS]") } +func StartBaseBackfillScannerListener() { + startEvmBackfillScanner( + mdb.NetworkBase, + "[BASE-BACKFILL]", + func(wallets []mdb.WalletAddress) int { + return storeAddedEvmRecipients(mdb.NetworkBase, wallets) + }, + func(address common.Address) bool { + return isWatchedAddedEvmRecipient(mdb.NetworkBase, address) + }, + ) +} + +func StartArbitrumBackfillScannerListener() { + startEvmBackfillScanner( + mdb.NetworkArbitrum, + "[ARBITRUM-BACKFILL]", + func(wallets []mdb.WalletAddress) int { + return storeAddedEvmRecipients(mdb.NetworkArbitrum, wallets) + }, + func(address common.Address) bool { + return isWatchedAddedEvmRecipient(mdb.NetworkArbitrum, address) + }, + ) +} + func startAddedEvmWebSocketListener(network, logPrefix string) { for { if data.IsChainEnabled(network) { @@ -51,6 +77,11 @@ func runAddedEvmListener(network, logPrefix string, contracts []common.Address) log.Sugar.Errorf("%s failed to get wallet addresses: %v", logPrefix, err) return } + recipientTopics := evmRecipientTopicsFromWallets(wallets) + if len(recipientTopics) == 0 { + log.Sugar.Warnf("%s no enabled wallet addresses, listener idle", logPrefix) + return + } storeAddedEvmRecipients(network, wallets) go refreshAddedEvmRecipients(ctx, network, logPrefix) @@ -59,7 +90,10 @@ func runAddedEvmListener(network, logPrefix string, contracts []common.Address) return } log.Sugar.Infof("%s connecting using WSS node %s watching %d contract(s)", logPrefix, data.RpcNodeLogLabel(wsNode), len(contracts)) - query := ethereum.FilterQuery{Addresses: contracts, Topics: [][]common.Hash{}} + query := ethereum.FilterQuery{ + Addresses: contracts, + Topics: evmTransferTopics(recipientTopics), + } runEvmWsLogListener(ctx, network, logPrefix, wsNode, query, func(client *ethclient.Client, vLog types.Log) { processAddedEvmLog(client, network, logPrefix, vLog) }) diff --git a/src/task/listen_evm_backfill.go b/src/task/listen_evm_backfill.go index b80ca1d2..2ed5205d 100644 --- a/src/task/listen_evm_backfill.go +++ b/src/task/listen_evm_backfill.go @@ -2,6 +2,7 @@ package task import ( "context" + "errors" "fmt" "math/big" "time" @@ -28,6 +29,32 @@ const ( type evmRecipientStoreFunc func([]mdb.WalletAddress) int type evmRecipientCheckerFunc func(common.Address) bool +type evmBlockHeaderFetcherFunc func(context.Context, *ethclient.Client, uint64) (*types.Header, error) +type evmTransferProcessorFunc func(string, common.Address, common.Address, *big.Int, string, int64) error + +type evmBackfillRPCError struct { + err error +} + +func (e *evmBackfillRPCError) Error() string { + return e.err.Error() +} + +func (e *evmBackfillRPCError) Unwrap() error { + return e.err +} + +func wrapEvmBackfillRPCError(err error) error { + if err == nil { + return nil + } + return &evmBackfillRPCError{err: err} +} + +func isEvmBackfillRPCError(err error) bool { + var rpcErr *evmBackfillRPCError + return errors.As(err, &rpcErr) +} func StartEthereumBackfillScannerListener() { startEvmBackfillScanner(mdb.NetworkEthereum, "[ETH-BACKFILL]", StoreEthRecipientsFromWallets, isWatchedEthRecipient) @@ -132,7 +159,7 @@ func runEvmBackfillScanner(network, logPrefix string, contracts []common.Address } if err != nil { log.Sugar.Warnf("%s backfill loop stopped: %v, retry in %s", logPrefix, err, failWait) - if recordEvmNodeFailure(logPrefix, network, node, err.Error()) { + if isEvmBackfillRPCError(err) && recordEvmNodeFailure(logPrefix, network, node, err.Error()) { return } if !sleepOrDone(ctx, failWait) { @@ -167,7 +194,7 @@ func runEvmBackfillLoop(ctx context.Context, client *ethclient.Client, network, latest, err := latestEvmHeader(ctx, client, network, logPrefix) if err != nil { - return err + return wrapEvmBackfillRPCError(err) } if latest == nil { return nil @@ -178,7 +205,8 @@ func runEvmBackfillLoop(ctx context.Context, client *ethclient.Client, network, confirmedHead := confirmedEvmHead(latest.Number.Int64(), chain.MinConfirmations) if !initialized { - lastBlock = confirmedHead - evmBackfillInitialLookbackBlocks + lookbackBlocks := evmBackfillInitialLookback(network) + lastBlock = confirmedHead - lookbackBlocks if lastBlock < 0 { lastBlock = 0 } @@ -186,7 +214,7 @@ func runEvmBackfillLoop(ctx context.Context, client *ethclient.Client, network, return fmt.Errorf("initialize backfill cursor: %w", err) } initialized = true - log.Sugar.Infof("%s initialized backfill cursor at block=%d confirmed_head=%d lookback=%d", logPrefix, lastBlock, confirmedHead, evmBackfillInitialLookbackBlocks) + log.Sugar.Infof("%s initialized backfill cursor at block=%d confirmed_head=%d lookback=%d", logPrefix, lastBlock, confirmedHead, lookbackBlocks) } if lastBlock >= confirmedHead { @@ -218,7 +246,7 @@ func runEvmBackfillLoop(ctx context.Context, client *ethclient.Client, network, logs, err := client.FilterLogs(rpcCtx, batchQuery) cancel() if err != nil { - return fmt.Errorf("filter logs range=%d-%d: %w", fromBlock, toBlock, err) + return wrapEvmBackfillRPCError(fmt.Errorf("filter logs range=%d-%d: %w", fromBlock, toBlock, err)) } if err := processEvmBackfillLogs(ctx, client, network, logPrefix, logs, isWatchedRecipient); err != nil { @@ -244,7 +272,29 @@ func runEvmBackfillLoop(ctx context.Context, client *ethclient.Client, network, } } -func processEvmBackfillLogs(ctx context.Context, client *ethclient.Client, network, logPrefix string, logs []types.Log, isWatchedRecipient evmRecipientCheckerFunc) error { +func processEvmBackfillLogs(ctx context.Context, client *ethclient.Client, network, _ string, logs []types.Log, isWatchedRecipient evmRecipientCheckerFunc) error { + return processEvmBackfillLogsWith( + ctx, + client, + network, + logs, + isWatchedRecipient, + latestEvmBlockHeader, + service.ProcessEvmERC20Transfer, + ) +} + +func processEvmBackfillLogsWith( + ctx context.Context, + client *ethclient.Client, + network string, + logs []types.Log, + _ evmRecipientCheckerFunc, + fetchHeader evmBlockHeaderFetcherFunc, + processTransfer evmTransferProcessorFunc, +) error { + // RPC 批次已按当次数据库地址主题精确过滤,不能再用异步快照二次过滤, + // 否则新地址可能被旧快照跳过并随游标推进而永久漏扫。 headerCache := make(map[uint64]int64) for _, vLog := range logs { if len(vLog.Topics) < 3 { @@ -255,15 +305,12 @@ func processEvmBackfillLogs(ctx context.Context, client *ethclient.Client, netwo } toAddr := common.HexToAddress(vLog.Topics[2].Hex()) - if !isWatchedRecipient(toAddr) { - continue - } blockTsMs, ok := headerCache[vLog.BlockNumber] if !ok { - header, err := latestEvmBlockHeader(ctx, client, vLog.BlockNumber) + header, err := fetchHeader(ctx, client, vLog.BlockNumber) if err != nil { - return fmt.Errorf("fetch block header network=%s block=%d: %w", network, vLog.BlockNumber, err) + return wrapEvmBackfillRPCError(fmt.Errorf("fetch block header network=%s block=%d: %w", network, vLog.BlockNumber, err)) } if header == nil || header.Time == 0 { return fmt.Errorf("missing block header timestamp network=%s block=%d", network, vLog.BlockNumber) @@ -272,7 +319,9 @@ func processEvmBackfillLogs(ctx context.Context, client *ethclient.Client, netwo headerCache[vLog.BlockNumber] = blockTsMs } - service.TryProcessEvmERC20Transfer(network, vLog.Address, toAddr, new(big.Int).SetBytes(vLog.Data), vLog.TxHash.Hex(), blockTsMs) + if err := processTransfer(network, vLog.Address, toAddr, new(big.Int).SetBytes(vLog.Data), vLog.TxHash.Hex(), blockTsMs); err != nil { + return fmt.Errorf("process transfer network=%s block=%d tx=%s: %w", network, vLog.BlockNumber, vLog.TxHash.Hex(), err) + } } return nil } @@ -307,6 +356,14 @@ func evmBackfillBatchSize(network string) int64 { return evmBackfillBatchBlocks } +func evmBackfillInitialLookback(network string) int64 { + if network == mdb.NetworkArbitrum { + // Arbitrum 出块更快,需要扩大首次回看范围以覆盖订单有效期。 + return 8192 + } + return evmBackfillInitialLookbackBlocks +} + func confirmedEvmHead(head int64, minConfirmations int) int64 { if minConfirmations <= 1 { return head diff --git a/src/task/listen_evm_backfill_test.go b/src/task/listen_evm_backfill_test.go new file mode 100644 index 00000000..352ec0c3 --- /dev/null +++ b/src/task/listen_evm_backfill_test.go @@ -0,0 +1,115 @@ +package task + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +func TestProcessEvmBackfillLogsUsesQuerySnapshotAsRecipientSource(t *testing.T) { + recipient := common.HexToAddress("0x1111111111111111111111111111111111111111") + contract := common.HexToAddress("0x2222222222222222222222222222222222222222") + txHash := common.HexToHash("0x1234") + called := false + + err := processEvmBackfillLogsWith( + context.Background(), + nil, + mdb.NetworkBase, + []types.Log{{ + Address: contract, + Topics: []common.Hash{transferEventHash, common.Hash{}, common.BytesToHash(recipient.Bytes())}, + Data: big.NewInt(100).Bytes(), + BlockNumber: 42, + TxHash: txHash, + }}, + func(common.Address) bool { return false }, + func(context.Context, *ethclient.Client, uint64) (*types.Header, error) { + return &types.Header{Time: 123}, nil + }, + func(network string, gotContract, gotRecipient common.Address, amount *big.Int, gotTxHash string, blockTsMs int64) error { + called = true + if network != mdb.NetworkBase || gotContract != contract || gotRecipient != recipient { + t.Fatalf("转账参数不匹配: network=%s contract=%s recipient=%s", network, gotContract.Hex(), gotRecipient.Hex()) + } + if amount.Cmp(big.NewInt(100)) != 0 || gotTxHash != txHash.Hex() || blockTsMs != 123000 { + t.Fatalf("转账数据不匹配: amount=%s hash=%s timestamp=%d", amount.String(), gotTxHash, blockTsMs) + } + return nil + }, + ) + if err != nil { + t.Fatalf("processEvmBackfillLogsWith() error = %v", err) + } + if !called { + t.Fatal("批次查询已命中的收款地址未进入处理流程") + } +} + +func TestProcessEvmBackfillLogsReturnsTransferError(t *testing.T) { + wantErr := errors.New("数据库暂时不可用") + err := processEvmBackfillLogsWith( + context.Background(), + nil, + mdb.NetworkArbitrum, + []types.Log{{ + Address: common.HexToAddress("0x3333333333333333333333333333333333333333"), + Topics: []common.Hash{transferEventHash, common.Hash{}, common.HexToHash("0x4444")}, + Data: big.NewInt(1).Bytes(), + BlockNumber: 99, + TxHash: common.HexToHash("0x5678"), + }}, + func(common.Address) bool { return true }, + func(context.Context, *ethclient.Client, uint64) (*types.Header, error) { + return &types.Header{Time: 456}, nil + }, + func(string, common.Address, common.Address, *big.Int, string, int64) error { + return wantErr + }, + ) + if !errors.Is(err, wantErr) { + t.Fatalf("processEvmBackfillLogsWith() error = %v, want %v", err, wantErr) + } + if isEvmBackfillRPCError(err) { + t.Fatalf("订单处理错误不应计入 RPC 节点故障: %v", err) + } +} + +func TestProcessEvmBackfillLogsMarksHeaderFailureAsRPCError(t *testing.T) { + wantErr := errors.New("区块头查询失败") + err := processEvmBackfillLogsWith( + context.Background(), + nil, + mdb.NetworkBase, + []types.Log{{ + Topics: []common.Hash{transferEventHash, common.Hash{}, common.HexToHash("0x5555")}, + BlockNumber: 100, + }}, + func(common.Address) bool { return true }, + func(context.Context, *ethclient.Client, uint64) (*types.Header, error) { + return nil, wantErr + }, + func(string, common.Address, common.Address, *big.Int, string, int64) error { + t.Fatal("区块头失败后不应处理转账") + return nil + }, + ) + if !errors.Is(err, wantErr) || !isEvmBackfillRPCError(err) { + t.Fatalf("header error = %v, want wrapped RPC error %v", err, wantErr) + } +} + +func TestEvmBackfillInitialLookbackCoversArbitrumFastBlocks(t *testing.T) { + if got := evmBackfillInitialLookback(mdb.NetworkArbitrum); got != 8192 { + t.Fatalf("Arbitrum lookback = %d, want 8192", got) + } + if got := evmBackfillInitialLookback(mdb.NetworkBase); got != evmBackfillInitialLookbackBlocks { + t.Fatalf("Base lookback = %d, want %d", got, evmBackfillInitialLookbackBlocks) + } +} From 4b0f4488a35bcb42cdfcbffab385895c22c41b66 Mon Sep 17 00:00:00 2001 From: mala Date: Sun, 16 Aug 2026 19:47:42 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E6=97=A7?= =?UTF-8?q?=E7=89=88=E6=B1=87=E7=8E=87=E4=BC=98=E5=85=88=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/config_test.go | 25 +++++++++++++ src/config/rate.go | 6 +-- src/controller/admin/settings_controller.go | 2 +- src/model/dao/mdb_table_init.go | 6 +-- src/model/dao/mdb_table_init_test.go | 41 ++++++++++++--------- 5 files changed, 53 insertions(+), 27 deletions(-) diff --git a/src/config/config_test.go b/src/config/config_test.go index b546414f..6f6a6d39 100644 --- a/src/config/config_test.go +++ b/src/config/config_test.go @@ -316,6 +316,31 @@ func TestFixedModeDoesNotFallBackToRateAPI(t *testing.T) { } } +func TestAutoModePrefersForcedRateBeforeAPI(t *testing.T) { + installSettingsGetter(t, map[string]string{ + "rate.mode": RateModeAuto, + "rate.forced_rate_list": `{"cny":{"usdt":0.125}}`, + "rate.api_url": "https://rate.example.test", + }) + installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) { + t.Fatal("auto 模式命中强制汇率时不应请求 API") + return nil, nil + }) + if got := GetRateForCoin("usdt", "cny"); got != 0.125 { + t.Fatalf("auto forced rate = %v, want 0.125", got) + } +} + +func TestAutoModeForcedRateOverridesUSDTPeg(t *testing.T) { + installSettingsGetter(t, map[string]string{ + "rate.mode": RateModeAuto, + "rate.forced_rate_list": `{"usd":{"usdt":0.99}}`, + }) + if got := GetRateForCoin("usdt", "usd"); got != 0.99 { + t.Fatalf("auto USD/USDT rate = %v, want configured 0.99", got) + } +} + func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) { viper.Reset() t.Cleanup(viper.Reset) diff --git a/src/config/rate.go b/src/config/rate.go index 3f0eda91..f855db36 100644 --- a/src/config/rate.go +++ b/src/config/rate.go @@ -82,10 +82,10 @@ func GetRateForCoin(coin string, base string) float64 { if coin == base { return 1 } + if forcedRate := getForcedRateForCoin(coin, base); forcedRate > 0 { + return forcedRate + } if GetRateMode() == RateModeFixed { - if forcedRate := getForcedRateForCoin(coin, base); forcedRate > 0 { - return forcedRate - } if coin == "usdt" && base == "usd" { return 1 } diff --git a/src/controller/admin/settings_controller.go b/src/controller/admin/settings_controller.go index c8ac893b..a8496754 100644 --- a/src/controller/admin/settings_controller.go +++ b/src/controller/admin/settings_controller.go @@ -102,7 +102,7 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error { // @Description Supported groups: brand, rate, system, epay, okpay. // @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.mode (fixed|auto, default fixed), rate.cache_ttl_seconds (10-86400, default 300), rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase; empty is restored to the built-in CNY USDT/USDC default), rate.api_url (optional; non-empty value must be a public http/https URL), rate.adjust_percent, rate.okx_c2c_enabled. +// @Description rate group keys: rate.mode (fixed|auto, default fixed; auto uses forced rates first and API fallback), rate.cache_ttl_seconds (10-86400, default 300), rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase; empty is restored to the built-in CNY USDT/USDC default), rate.api_url (optional; non-empty value must be a public http/https 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. // @Description system group keys: system.order_expiration_time, system.amount_precision (int, 2-6, default 2), system.log_level (debug|info|warn|error, default error). // @Tags Admin Settings diff --git a/src/model/dao/mdb_table_init.go b/src/model/dao/mdb_table_init.go index 7ff49c76..447ff669 100644 --- a/src/model/dao/mdb_table_init.go +++ b/src/model/dao/mdb_table_init.go @@ -218,11 +218,7 @@ func seedDefaultSettings() { } } -// defaultRateModeForSeed keeps fresh installations on the safe fixed default, -// while migrating installations created before rate.mode existed to auto when -// they already relied on an external rate API. This is the closest equivalent -// to the legacy forced-rate-then-API-fallback behavior available with the new -// two explicit modes. +// 新安装保持 fixed;已有安装若曾配置外部 API,则迁移到保留旧版优先级的 auto 模式。 func defaultRateModeForSeed() string { var existingSettings int64 if err := Mdb.Unscoped().Model(&mdb.Setting{}).Count(&existingSettings).Error; err != nil { diff --git a/src/model/dao/mdb_table_init_test.go b/src/model/dao/mdb_table_init_test.go index 6dbd845d..fe11c8e9 100644 --- a/src/model/dao/mdb_table_init_test.go +++ b/src/model/dao/mdb_table_init_test.go @@ -378,24 +378,29 @@ func TestSeedDefaultSettingsMigratesLegacyEnvAPIInstallToAuto(t *testing.T) { } func TestSeedDefaultSettingsPreservesExplicitLegacyRateMode(t *testing.T) { - db := setupSeedSettingsTestDB(t) - Mdb = db - legacy := []mdb.Setting{ - {Group: mdb.SettingGroupRate, Key: mdb.SettingKeyRateApiUrl, Value: "https://rate.example.test", Type: mdb.SettingTypeString}, - {Group: mdb.SettingGroupRate, Key: mdb.SettingKeyRateMode, Value: config.RateModeFixed, Type: mdb.SettingTypeString}, - } - if err := Mdb.Create(&legacy).Error; err != nil { - t.Fatalf("seed explicit mode settings: %v", err) - } - - seedDefaultSettings() - - var row mdb.Setting - if err := Mdb.Where("`key` = ?", mdb.SettingKeyRateMode).Take(&row).Error; err != nil { - t.Fatalf("load explicit rate.mode: %v", err) - } - if row.Value != config.RateModeFixed { - t.Fatalf("explicit rate.mode = %q, want fixed", row.Value) + for _, mode := range []string{config.RateModeFixed, config.RateModeAuto} { + t.Run(mode, func(t *testing.T) { + db := setupSeedSettingsTestDB(t) + Mdb = db + legacy := []mdb.Setting{ + {Group: mdb.SettingGroupRate, Key: mdb.SettingKeyRateApiUrl, Value: "https://rate.example.test", Type: mdb.SettingTypeString}, + {Group: mdb.SettingGroupRate, Key: mdb.SettingKeyRateMode, Value: mode, Type: mdb.SettingTypeString}, + } + if err := Mdb.Create(&legacy).Error; err != nil { + t.Fatalf("seed explicit mode settings: %v", err) + } + + seedDefaultSettings() + seedDefaultSettings() + + var row mdb.Setting + if err := Mdb.Where("`key` = ?", mdb.SettingKeyRateMode).Take(&row).Error; err != nil { + t.Fatalf("load explicit rate.mode: %v", err) + } + if row.Value != mode { + t.Fatalf("explicit rate.mode = %q, want %q", row.Value, mode) + } + }) } } From 5ceffeb0d38aaaecd1f985b5ab44e4a73ebbbdbb Mon Sep 17 00:00:00 2001 From: mala Date: Sun, 16 Aug 2026 19:56:01 +0800 Subject: [PATCH 10/11] =?UTF-8?q?docs:=20=E6=A0=A1=E5=87=86=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E6=8E=A5=E5=8F=A3=E7=AD=BE=E5=90=8D=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/admin/api_key_controller.go | 12 ++--- src/controller/comm/order_controller.go | 62 +++++++++++++--------- src/middleware/check_sign.go | 23 ++++---- src/model/request/order_request.go | 25 +++++++-- src/model/response/pay_response.go | 6 +-- wiki/API.md | 24 +++++---- 6 files changed, 92 insertions(+), 60 deletions(-) diff --git a/src/controller/admin/api_key_controller.go b/src/controller/admin/api_key_controller.go index 0c5bf3f6..79b29d0f 100644 --- a/src/controller/admin/api_key_controller.go +++ b/src/controller/admin/api_key_controller.go @@ -21,7 +21,7 @@ type CreateApiKeyRequest struct { Name string `json:"name" validate:"required|maxLen:128" example:"My API Key"` IpWhitelist string `json:"ip_whitelist" example:""` NotifyUrl string `json:"notify_url" example:"https://example.com/notify"` - GMPaySignMode string `json:"gmpay_sign_mode" enums:"dual,hmac_sha256,md5" example:"hmac_sha256"` + GMPaySignMode string `json:"gmpay_sign_mode" enums:"dual,hmac_sha256,md5" example:"hmac_sha256"` // 省略时默认 hmac_sha256,仅控制 GMPay } // CreateApiKeyResponse is the response for a newly created API key. @@ -40,7 +40,7 @@ type UpdateApiKeyRequest struct { Name *string `json:"name" example:"Updated Key Name"` IpWhitelist *string `json:"ip_whitelist" example:"10.0.0.1,192.168.0.0/24"` NotifyUrl *string `json:"notify_url" example:"https://example.com/notify"` - GMPaySignMode *string `json:"gmpay_sign_mode" enums:"dual,hmac_sha256,md5" example:"hmac_sha256"` + GMPaySignMode *string `json:"gmpay_sign_mode" enums:"dual,hmac_sha256,md5" example:"hmac_sha256"` // 仅影响后续 GMPay 入站验签,不影响 EPay 或历史订单回调 } // ChangeApiKeyStatusRequest is the payload for toggling API key status. @@ -71,7 +71,7 @@ func (c *BaseAdminController) ListApiKeys(ctx echo.Context) error { // the highest existing numeric PID (starting at 1000). Secret is // randomly generated and returned once. // @Summary Create API key -// @Description Create a new universal API key. Usable for both gateway flows (epay/gmpay). PID auto-incremented; secret returned once. +// @Description 创建同时适用于 GMPay 和 EPay 的通用 API Key;PID 自动递增,密钥仅在创建时返回。省略 gmpay_sign_mode 时默认 hmac_sha256;该模式不影响始终使用 MD5 的 EPay。 // @Tags Admin API Keys // @Security AdminJWT // @Accept json @@ -147,10 +147,10 @@ func isUniqueViolation(err error) bool { strings.Contains(msg, "constraint failed") } -// UpdateApiKey patches name / ip_whitelist / notify_url. Secret rotation -// and enable/disable have their own endpoints. +// UpdateApiKey 更新名称、IP 白名单、回调地址或 GMPay 签名模式。 +// 密钥轮换和启用状态由独立接口处理。 // @Summary Update API key -// @Description Patch API key name / ip_whitelist / notify_url +// @Description 更新 API Key 的 name、ip_whitelist、notify_url 或 gmpay_sign_mode。签名模式仅影响后续 GMPay 入站验签;EPay 始终使用 MD5,已创建订单的回调继续使用订单记录的算法。 // @Tags Admin API Keys // @Security AdminJWT // @Accept json diff --git a/src/controller/comm/order_controller.go b/src/controller/comm/order_controller.go index 8009750c..30230749 100644 --- a/src/controller/comm/order_controller.go +++ b/src/controller/comm/order_controller.go @@ -17,8 +17,8 @@ import ( 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). +// apiKeyFromContext 返回验签流程写入上下文的 API Key。 +// 受保护路由正常执行时该值不应为空。 func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey { if v, ok := ctx.Get(middleware.ApiKeyRowKey).(*mdb.ApiKey); ok { return v @@ -39,25 +39,29 @@ func gmpaySignAlgorithmFromContext(ctx echo.Context) (string, bool) { // @Summary Create transaction // @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded). // @Description GMPay may omit both token and network to create a status=4 placeholder order; EPay submit.php can also create one when neither request parameters nor database defaults provide token/network. Supplying only one of token/network is invalid. -// @Description payment_type is optional for GMPay. If it is sent, it is a normal signed parameter and must be included when calculating signature. -// @Description GMPay 签名算法由 API Key 的 gmpay_sign_mode 控制;新建 Key 默认 HMAC-SHA256,升级前 Key 默认 dual 以兼容旧 MD5。 +// @Description GMPay 认证必须提供 pid 与 signature。原始请求中除 signature 外的所有非空字符串或数字字段都会参与签名;未知字段不会写入订单,但客户端仍必须把它们计入签名。 +// @Description payment_type 可省略;非空时参与 GMPay 签名。即使值为 Epay,也只切换回调格式,不会把本接口的入站验签切换为 EPay MD5。 +// @Description GMPay 签名算法由 API Key 的 gmpay_sign_mode 控制;新建 Key 默认 HMAC-SHA256,升级前已有 Key 在升级后默认 dual 以兼容旧 MD5。该设置不影响独立的 EPay 接口。 +// @Description network 使用公开配置返回的真实标识,例如 tron、ethereum、binance、base;BSC 的接口标识是 binance,不是 bsc。 // @Tags Payment // @Accept json // @Accept x-www-form-urlencoded // @Produce json -// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)" -// @Param order_id formData string false "Merchant order ID" -// @Param currency formData string false "Fiat currency (e.g. cny)" +// @Param request body request.GMPayCreateTransactionDocRequest true "GMPay 创建订单 JSON 请求体" +// @Param pid formData string true "API Key 的 PID" +// @Param order_id formData string true "商户订单号" +// @Param currency formData string true "法币币种,例如 cny" // @Param token formData string false "Crypto token (e.g. TON, USDT); omit together with network to create a placeholder where supported" -// @Param network formData string false "Network (e.g. ton, tron); omit together with token to create a placeholder where supported" -// @Param amount formData number false "Amount" -// @Param notify_url formData string false "Callback URL" -// @Param signature formData string false "GMPay 签名:64 位 HMAC-SHA256,兼容模式下也可使用 32 位 MD5" +// @Param network formData string false "网络标识,例如 tron、binance;支持时可与 token 同时省略以创建占位订单" +// @Param amount formData number true "法币金额" +// @Param notify_url formData string true "异步回调地址" +// @Param signature formData string true "GMPay 签名:64 位 HMAC-SHA256,兼容模式下也可使用 32 位 MD5" // @Param redirect_url formData string false "Redirect URL" // @Param name formData string false "Order name" // @Param payment_type formData string false "Optional GMPay compatibility flag; include in signature when sent" // @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse} // @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel" +// @Failure 401 {object} response.ApiResponse "pid/signature 缺失、API Key 不可用、IP 不在白名单或签名错误" // @Router /payments/gmpay/v1/order/create-transaction [post] func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) { req := new(request.CreateTransactionRequest) @@ -114,38 +118,46 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) { return c.SucJson(ctx, resp) } -// CreateTransactionAndRedirect creates a transaction and redirects to -// the checkout counter. The route accepts BOTH GET (query string) and -// POST (form) per the legacy EPAY protocol; swagger documents POST as -// the canonical form — the GET variant is identical save the transport. +// CreateTransactionAndRedirect 创建 EPay 兼容订单并跳转到收银台。 +// 路由同时接受 GET 查询参数和 POST 表单,二者采用相同的参数及验签规则。 // @Summary Create transaction and redirect (EPAY compat) // @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid. // @Description After signature verification, type accepts only either alipay or a supported type=token.network selector (for example usdt.tron). Token/network resolution is: supported selector first; otherwise request token/network; otherwise epay.default_token / epay.default_network. If token and network are still both empty, the order is created as status=4 placeholder. Supplying only one of token/network remains invalid. // @Description Currency resolution is unchanged: request currency -> epay.default_currency -> cny. Supported type selectors bypass only token/network defaults, not currency fallback. // @Description Success return/notify reuse the stored request type. On this branch that means either alipay or a supported token.network selector; when the request omitted type, outbound fallback remains alipay. The server injects internal payment_type=Epay after EPay signature verification; merchants do not send GMPay payment_type to this endpoint. +// @Description EPay 认证必须提供 pid 与 sign,并始终使用 MD5;sign_type 不参与签名。API Key 的 gmpay_sign_mode 只作用于 GMPay,不改变本接口算法。 +// @Description 除 sign、sign_type 外,原始请求中的所有非空参数都会参与 EPay 签名;未知参数不会写入订单,但仍必须计入签名。 +// @Description network 使用真实标识,例如 tron、ethereum、binance、base;BSC 的接口标识是 binance,不是 bsc。 // @Tags Payment // @Accept x-www-form-urlencoded // @Produce html -// @Param pid query integer false "API key PID (GET query)" -// @Param money query number false "Amount (fiat, GET query)" -// @Param out_trade_no query string false "Merchant order ID (GET query)" -// @Param notify_url query string false "Callback URL (GET query)" +// @Param pid query string true "API Key 的 PID(GET 查询参数)" +// @Param money query number true "法币金额(GET 查询参数)" +// @Param out_trade_no query string true "商户订单号(GET 查询参数)" +// @Param notify_url query string true "异步回调地址(GET 查询参数)" // @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 "Either alipay or a supported token.network selector such as usdt.tron (GET query)" -// @Param sign query string false "MD5 signature (GET query)" +// @Param token query string false "type 未命中选择器时使用的币种(GET 查询参数)" +// @Param network query string false "type 未命中选择器时使用的网络,例如 tron、binance(GET 查询参数)" +// @Param currency query string false "法币币种(GET 查询参数)" +// @Param sign query string true "MD5 签名(GET 查询参数)" // @Param sign_type query string false "Signature type (MD5, GET query)" -// @Param pid formData integer true "API key PID" -// @Param money formData number true "Amount (fiat)" -// @Param out_trade_no formData string true "Merchant order ID" -// @Param notify_url formData string true "Callback URL" +// @Param pid formData string true "API Key 的 PID" +// @Param money formData number true "法币金额" +// @Param out_trade_no formData string true "商户订单号" +// @Param notify_url formData string true "异步回调地址" // @Param return_url formData string false "Redirect URL after payment" // @Param name formData string false "Order name" // @Param type formData string false "Either alipay or a supported token.network selector such as usdt.tron" -// @Param sign formData string true "MD5 signature" +// @Param token formData string false "type 未命中选择器时使用的币种" +// @Param network formData string false "type 未命中选择器时使用的网络,例如 tron、binance" +// @Param currency formData string false "法币币种" +// @Param sign formData string true "MD5 签名" // @Param sign_type formData string false "Signature type (MD5)" // @Success 302 "Redirect to checkout counter" // @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel" +// @Failure 401 {object} response.ApiResponse "pid/sign 缺失、API Key 不可用、IP 不在白名单或签名错误" // @Router /payments/epay/v1/order/create-transaction/submit.php [post] // @Router /payments/epay/v1/order/create-transaction/submit.php [get] func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) { diff --git a/src/middleware/check_sign.go b/src/middleware/check_sign.go index 23e130c6..fbb859b7 100644 --- a/src/middleware/check_sign.go +++ b/src/middleware/check_sign.go @@ -15,25 +15,24 @@ import ( "github.com/labstack/echo/v4" ) -// Context keys populated by CheckApiSign after successful verification. -// Handlers (pay/order creation) pull ApiKeyIDKey to stamp order.api_key_id. +// 以下上下文键由 CheckApiSign 在 GMPay 验签成功后写入。 +// 创建订单处理器使用 API Key 信息和本次实际命中的算法固化订单签名语义。 const ( ApiKeyIDKey = "api_key_id" ApiKeyRowKey = "api_key_row" SignAlgorithmKey = "gmpay_sign_algorithm" ) -// CheckApiSign validates the body signature against the secret_key of -// the api_keys row matching the submitted "pid" field. A single row is -// valid for all gateway flows — identification is always by pid. +// CheckApiSign 使用请求 pid 对应 API Key 的 secret_key 校验 GMPay 签名。 +// 该中间件不处理 EPay;EPay 路由始终执行独立的 MD5 验签。 // -// Flow: -// 1. Extract the pid from the request body. -// 2. Look up the enabled row by pid; if missing, return signature error. -// 3. Verify signature == lowercase hex HMAC-SHA256(sorted_params, secret_key). -// 4. Enforce IP whitelist (empty = allow any). -// 5. Bump call_count / last_used_at (best-effort). -// 6. Stash api_key_id + row in context and rewind the body. +// 流程: +// 1. 读取并回填原始请求体,解析 pid 和 signature。 +// 2. 按 pid 查询启用的 API Key,缺失时返回签名错误。 +// 3. 按 gmpay_sign_mode 校验 HMAC-SHA256、旧版 MD5 或双兼容模式。 +// 4. 校验 IP 白名单,空白名单表示不限制来源。 +// 5. 尝试更新调用次数和最后使用时间,不让统计失败中断交易。 +// 6. 将 API Key 与实际命中的算法写入上下文,供订单固化后续回调算法。 func CheckApiSign() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { diff --git a/src/model/request/order_request.go b/src/model/request/order_request.go index a9a24ff4..9cfd45a7 100644 --- a/src/model/request/order_request.go +++ b/src/model/request/order_request.go @@ -13,14 +13,29 @@ type CreateTransactionRequest struct { Signature string `json:"signature" form:"signature" validate:"required" example:"6f874b1919d95081835e2809b620e354a5866f5a6dbb2e432d1627f1eb10059d"` RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"` Name string `json:"name" form:"name" example:"VIP月卡"` - // PaymentType is a compatibility flag, not a gateway selector. Only - // "Epay" (case-insensitive) switches callback format to legacy EPay; - // 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 是回调格式兼容标记,不用于选择入站签名协议。 + // 仅 Epay(不区分大小写)会切换为旧版 EPay 回调格式;空值或其他值按 Gmpay 保存。 + // GMPay 请求可省略该字段;非空时必须作为原始请求字段参与 GMPay 签名。 PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"` EpayType string `json:"-" form:"-"` } +// GMPayCreateTransactionDocRequest 仅用于生成 GMPay 创建订单的 Swagger 请求模型。 +// pid 由验签中间件从原始请求读取,不写入业务绑定模型。 +type GMPayCreateTransactionDocRequest struct { + Pid string `json:"pid" validate:"required" example:"1000"` + OrderId string `json:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"` + Currency string `json:"currency" validate:"required" example:"cny"` + Token string `json:"token" example:"usdt"` + Network string `json:"network" example:"binance"` + Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"` + NotifyUrl string `json:"notify_url" validate:"required" example:"https://example.com/notify"` + Signature string `json:"signature" validate:"required" example:"6f874b1919d95081835e2809b620e354a5866f5a6dbb2e432d1627f1eb10059d"` + RedirectUrl string `json:"redirect_url" example:"https://example.com/success"` + Name string `json:"name" example:"VIP月卡"` + PaymentType string `json:"payment_type" example:"Gmpay"` +} + func (r CreateTransactionRequest) Translates() map[string]string { return validate.MS{ "OrderId": "订单号", @@ -59,7 +74,7 @@ func (r ManualPaymentRequest) Translates() map[string]string { type SwitchNetworkRequest struct { TradeId string `json:"trade_id" validate:"required" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` Token string `json:"token" validate:"required" example:"USDT"` - Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum,aptos"` + Network string `json:"network" validate:"required" example:"binance"` } func (r SwitchNetworkRequest) Translates() map[string]string { diff --git a/src/model/response/pay_response.go b/src/model/response/pay_response.go index f542d2fc..868a603b 100644 --- a/src/model/response/pay_response.go +++ b/src/model/response/pay_response.go @@ -10,11 +10,11 @@ type CheckoutCounterResponse struct { Network string `json:"network" example:"tron"` // 网络 TRON ETH;status=4 占位订单为空 Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种;status=4 时前端应引导选择链上 token/network 或 OkPay PaymentType string `json:"payment_type" enums:"gmpay,epay" example:"gmpay"` // 支付接入类型;底层 Epay/Gmpay 转为小写 epay/gmpay 返回 - ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳 + ExpirationTime int64 `json:"expiration_time" example:"1713264600000"` // 过期时间,毫秒级时间戳 RedirectUrl string `json:"redirect_url" example:"https://example.com/success"` // 非 EPay 时为商户原始回跳地址;EPay 时为内部中转地址 /pay/return/{trade_id} PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单为空,OkPay 订单为第三方 payLink - CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳 - ServerTime int64 `json:"server_time" example:"1713264100"` // 服务器当前时间 时间戳 + CreatedAt int64 `json:"created_at" example:"1713264000000"` // 订单创建时间,毫秒级时间戳 + ServerTime int64 `json:"server_time" example:"1713264100000"` // 服务器当前时间,毫秒级时间戳 IsSelected bool `json:"is_selected" example:"false"` // 是否已选择当前支付方式;status=4 占位订单和刚补全的占位父单为 false } diff --git a/wiki/API.md b/wiki/API.md index 84e2faf7..caa8558a 100644 --- a/wiki/API.md +++ b/wiki/API.md @@ -63,12 +63,14 @@ | `md5` | 仅接受旧版 `MD5(待签名字符串 + secret_key)`。 | | `dual` | 先校验 HMAC-SHA256,失败后再校验旧版 MD5;已有 API Key 升级后的默认值。 | -管理员可通过 `PATCH /admin/api/v1/api-keys/{id}` 设置模式,例如 `{"gmpay_sign_mode":"dual"}`。算法由服务端配置决定,客户端不能通过额外请求字段选择算法;额外字段反而会参与签名并导致校验失败。 +管理员可通过 `PATCH /admin/api/v1/api-keys/{id}` 设置模式,例如 `{"gmpay_sign_mode":"dual"}`。算法由服务端配置决定,客户端不能通过额外请求字段选择算法。原始请求中除 `signature` 外的非空字符串或数字字段都会进入签名串,包括服务端业务模型不认识的额外字段:客户端将它们一并签名时可以通过验签,但这些未知字段不会因此写入订单;漏签、布尔值、对象或数组等不支持的值会导致 HTTP 401。 + +`gmpay_sign_mode` 仅控制 GMPay 入站验签。EPay 接口始终使用独立的 MD5 规则,不读取该模式;反过来,GMPay 请求携带 `sign` 或 `sign_type` 也不会切换到 EPay 协议,这些非空字段只会作为 GMPay 的额外签名参数处理。 注意: - `pid` 必须参与签名。 -- GMPay 的 `payment_type` 不是必填;如果请求里传了非空 `payment_type`,它和其他非空参数一样必须参与签名。 +- GMPay 的 `payment_type` 不是必填;如果请求里传了非空 `payment_type`,它和其他非空参数一样必须参与签名。即使值为 `Epay`,也只切换订单的回调格式,本次入站请求仍按 GMPay 规则验签。 - 空字符串和 `null` 不参与签名。 - 参数名区分大小写。 - JSON 数字会按服务端数字格式参与签名,例如 `100.00` 会被解析为 `100`;如果需要保留字符串格式,可使用 `application/x-www-form-urlencoded`。 @@ -216,7 +218,7 @@ curl -X POST 'https://pay.example.com/payments/gmpay/v1/order/create-transaction | `order_id` | string | 是 | 商户订单号,最长 32 字符,不能重复。 | | `currency` | string | 是 | 法币币种,如 `cny`、`usd`。 | | `token` | string | 条件必填 | 收款币种,如 `usdt`、`trx`、`usdc`、`sol`、`ton`。GMPay 可与 `network` 同时省略以创建状态 `4` 占位订单。 | -| `network` | string | 条件必填 | 收款网络,如 `tron`、`solana`、`ton`、`aptos`、`ethereum`、`bsc`、`polygon`、`plasma`、`base`、`arbitrum`。GMPay 可与 `token` 同时省略以创建状态 `4` 占位订单。 | +| `network` | string | 条件必填 | 收款网络,如 `tron`、`solana`、`ton`、`aptos`、`ethereum`、`binance`、`polygon`、`plasma`、`base`、`arbitrum`。GMPay 可与 `token` 同时省略以创建状态 `4` 占位订单;BSC 的接口标识为 `binance`。 | | `amount` | number | 是 | 法币金额,请求值必须大于 `0.01`;保存和返回时会按 `system.amount_precision` 归一化。 | | `notify_url` | string | 是 | 支付成功异步回调地址。必须是可解析到公网地址的 HTTP/HTTPS URL。 | | `redirect_url` | string | 否 | 支付完成后的同步跳转地址。 | @@ -385,13 +387,14 @@ curl 'https://pay.example.com/payments/gmpay/v1/config' "redirect_url": "https://merchant.example/return", "payment_url": "", "created_at": 1779530212000, + "server_time": 1779530312000, "is_selected": false }, "request_id": "b1344d70-ff19-4543-b601-37abfb3b3686" } ``` -注意:该接口的 `expiration_time` 和 `created_at` 是毫秒级时间戳。 +注意:该接口的 `expiration_time`、`created_at` 和 `server_time` 都是毫秒级 Unix 时间戳。前端应以 `server_time` 校准倒计时,不要把它当作秒级时间戳再次乘以 `1000`。 如果订单是状态 `4` 占位订单,返回的仍是同一个父订单 `trade_id`,但链上支付字段尚未生成。该状态可能来自 GMPay 空 token/network 创建,也可能来自 EPay submit.php 在请求和数据库默认值都没有完整 token/network 时创建: @@ -413,6 +416,7 @@ curl 'https://pay.example.com/payments/gmpay/v1/config' "redirect_url": "https://merchant.example/return", "payment_url": "", "created_at": 1779530212000, + "server_time": 1779530312000, "is_selected": false }, "request_id": "b1344d70-ff19-4543-b601-37abfb3b3686" @@ -475,7 +479,7 @@ curl -X POST 'https://pay.example.com/pay/submit-tx-hash/20260523171652123456001 | `trade_id` | path | string | 是 | 要补单的 Epusdt 交易号。 | | `block_transaction_id` | JSON body | string | 是 | 用户已支付交易的链上交易哈希或交易引用。 | -当前支持人工验证的网络包括 `tron`、`solana`、`ton`、`aptos`、`ethereum`、`bsc`、`polygon`、`plasma`、`base` 和 `arbitrum`。 +当前支持人工验证的网络包括 `tron`、`solana`、`ton`、`aptos`、`ethereum`、`binance`、`polygon`、`plasma`、`base` 和 `arbitrum`。其中 BSC 的接口标识为 `binance`。 TON 支持以下三种交易引用格式: @@ -581,6 +585,8 @@ curl -X POST 'https://pay.example.com/pay/switch-network' \ /pay/checkout-counter/{trade_id} ``` +EPay 认证必须提供 `pid` 和 `sign`。本接口始终使用 EPay MD5,与 API Key 的 `gmpay_sign_mode` 相互独立;`sign_type` 仅为兼容字段,不参与签名。缺少认证参数、API Key 不可用、IP 不在白名单或签名错误时返回 HTTP 401。 + ### 请求参数 | 字段 | 位置 | 类型 | 必填 | 说明 | @@ -602,7 +608,7 @@ curl -X POST 'https://pay.example.com/pay/switch-network' \ - 使用 `pid` 对应的 `secret_key`。 - 排除 `sign` 和 `sign_type`。 -- 其他非空参数按 ASCII 字典序拼接后追加 `secret_key` 并 MD5;如果接入插件额外传了 `sitename` 等字段,也要一起参与签名。 +- 其他非空参数按 ASCII 字典序拼接后追加 `secret_key` 并 MD5;如果接入插件额外传了 `sitename` 等字段,也要一起参与签名。未知字段可以通过验签但不会写入订单,不能用额外字段切换到 GMPay 或改变业务参数解析。 示例待签名字符串: @@ -640,7 +646,7 @@ EPay 接口解析 `type/token/network/currency` 的规则: - `currency` 解析不受 selector 影响:请求参数 `currency` > 数据库 `epay.default_currency` > `cny`。 - 最终解析结果里,`token/network` 同时有值时创建具体链上订单;同时为空时创建状态 `4` 占位订单;最终只缺一个时返回参数错误。 - 这意味着“请求里只传了一个值”不一定报错;如果另一个值能被 default 补齐,仍会成功。只有最终解析后仍然只剩一个值,才返回 `10009`。 -- 服务端会在 EPay 签名校验通过后内部注入 `payment_type=Epay`,该字段不参与 EPay 入站签名;但请求里显式传入的 `type/token/network/currency` 仍属于原始 EPay 参数,必须参与签名。 +- 服务端会在 EPay 签名校验通过后内部注入 `payment_type=Epay`,该字段不参与 EPay 入站签名;但请求里显式传入的 `type/token/network/currency` 仍属于原始 EPay 参数,必须参与签名。客户端不要发送 GMPay 的 `signature` 来代替 EPay 的 `sign`。 后台默认配置可通过 `/payments/gmpay/v1/config` 的 `epay` 字段查看;新安装默认只预置 `epay.default_currency=cny`,`epay.default_token` 和 `epay.default_network` 为空,因此 EPay 未显式传 token/network 时会创建状态 `4` 占位订单。已有数据库的配置不会被 seed 覆盖,删除或置空 `epay.default_token` 和 `epay.default_network` 后,这两个字段会返回空字符串。 @@ -714,7 +720,7 @@ GMPay 回调使用订单创建时实际通过的算法,不受之后修改 API ### EPay 兼容回调 -通过 EPay 兼容接口创建的订单,会使用 GET 请求回调 `notify_url`,参数如下: +通过 EPay 兼容接口创建,或经 GMPay 接口显式传入 `payment_type=Epay` 的订单,会使用 GET 请求回调 `notify_url`,参数如下: > EPay 回调会把 `pid` 输出为数字;使用 EPay 兼容接口或 `payment_type=Epay` 时,请确保 API Key 的 PID 是数字。 > @@ -732,7 +738,7 @@ sign=a1b2c3d4... sign_type=MD5 ``` -验签时排除 `sign` 和 `sign_type`,其余非空参数按 ASCII 字典序拼接后追加 `secret_key` 并 MD5。 +验签时排除 `sign` 和 `sign_type`,其余非空参数按 ASCII 字典序拼接后追加 `secret_key` 并 MD5。该出站规则始终为 EPay MD5,不受 API Key 当前 `gmpay_sign_mode` 影响。 ## OkPay 平台回调 From 53319719858d1b57f70dd87b2a28fdc17909ba14 Mon Sep 17 00:00:00 2001 From: mala Date: Sun, 16 Aug 2026 19:59:49 +0800 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20=E6=B6=88=E9=99=A4=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E4=BB=BB=E5=8A=A1=E6=B5=8B=E8=AF=95=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mq/worker.go | 9 +++++---- src/mq/worker_test.go | 20 +++++++++++++------- src/notify/dispatcher.go | 12 ++++++++++-- src/route/router_test.go | 6 ++++++ src/task/listen_aptos_scanner_test.go | 12 +++++++----- src/telegram/telegram.go | 8 ++++++++ 6 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/mq/worker.go b/src/mq/worker.go index 79c74c35..3c1de29c 100644 --- a/src/mq/worker.go +++ b/src/mq/worker.go @@ -155,9 +155,10 @@ func dispatchPendingCallbacks() { continue } + limiter := callbackLimiter select { - case callbackLimiter <- struct{}{}: - go processCallback(tradeID) + case limiter <- struct{}{}: + go processCallback(tradeID, limiter) default: callbackInflight.Delete(tradeID) return @@ -165,9 +166,9 @@ func dispatchPendingCallbacks() { } } -func processCallback(tradeID string) { +func processCallback(tradeID string, limiter chan struct{}) { defer func() { - <-callbackLimiter + <-limiter callbackInflight.Delete(tradeID) }() diff --git a/src/mq/worker_test.go b/src/mq/worker_test.go index 002a4e18..353ca53a 100644 --- a/src/mq/worker_test.go +++ b/src/mq/worker_test.go @@ -5,7 +5,6 @@ import ( "io" "net/http" "net/http/httptest" - "sync" "sync/atomic" "testing" "time" @@ -17,6 +16,13 @@ import ( "github.com/GMWalletApp/epusdt/util/sign" ) +func resetCallbackInflight() { + callbackInflight.Range(func(key, _ interface{}) bool { + callbackInflight.Delete(key) + return true + }) +} + func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() @@ -161,7 +167,7 @@ func TestDispatchPendingCallbacksHonorsBackoffAndPersistsSuccess(t *testing.T) { defer cleanup() callbackLimiter = make(chan struct{}, 1) - callbackInflight = sync.Map{} + resetCallbackInflight() var requestCount int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -218,7 +224,7 @@ func TestDispatchPendingCallbacksResumesRetryAfterRestart(t *testing.T) { defer cleanup() callbackLimiter = make(chan struct{}, 1) - callbackInflight = sync.Map{} + resetCallbackInflight() var requestCount int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -265,7 +271,7 @@ func TestDispatchPendingCallbacksResumesRetryAfterRestart(t *testing.T) { } callbackLimiter = make(chan struct{}, 1) - callbackInflight = sync.Map{} + resetCallbackInflight() if err := dao.Mdb.Model(order).UpdateColumn("updated_at", time.Now().Add(-2*time.Second)).Error; err != nil { t.Fatalf("age callback order for retry: %v", err) @@ -291,7 +297,7 @@ func TestDispatchPendingCallbacksEpayRequiresAck(t *testing.T) { defer cleanup() callbackLimiter = make(chan struct{}, 1) - callbackInflight = sync.Map{} + resetCallbackInflight() epayKey, err := data.GetEnabledApiKey("1001") if err != nil || epayKey == nil || epayKey.ID == 0 { @@ -345,7 +351,7 @@ func TestDispatchPendingCallbacksEpayAcceptsTrimmedOk(t *testing.T) { defer cleanup() callbackLimiter = make(chan struct{}, 1) - callbackInflight = sync.Map{} + resetCallbackInflight() epayKey, err := data.GetEnabledApiKey("1001") if err != nil || epayKey == nil || epayKey.ID == 0 { @@ -768,7 +774,7 @@ func TestDispatchPendingCallbacksEpayAcceptsSuccessAck(t *testing.T) { defer cleanup() callbackLimiter = make(chan struct{}, 1) - callbackInflight = sync.Map{} + resetCallbackInflight() epayKey, err := data.GetEnabledApiKey("1001") if err != nil || epayKey == nil || epayKey.ID == 0 { diff --git a/src/notify/dispatcher.go b/src/notify/dispatcher.go index eee34d3e..48a4e432 100644 --- a/src/notify/dispatcher.go +++ b/src/notify/dispatcher.go @@ -18,8 +18,9 @@ import ( type Sender func(config, text string) error var ( - sendersMu sync.RWMutex - senders = map[string]Sender{} + sendersMu sync.RWMutex + senders = map[string]Sender{} + dispatchWG sync.WaitGroup ) // RegisterSender wires a Sender for a channel type. Called once at @@ -52,7 +53,9 @@ func Dispatch(event, text string) { log.Sugar.Warnf("[notify] no sender registered for type=%s (channel_id=%d)", ch.Type, ch.ID) continue } + dispatchWG.Add(1) go func(c mdb.NotificationChannel) { + defer dispatchWG.Done() if err := sender(c.Config, text); err != nil { log.Sugar.Errorf("[notify] send failed type=%s channel_id=%d: %v", c.Type, c.ID, err) } @@ -60,6 +63,11 @@ func Dispatch(event, text string) { } } +// WaitForDispatchesForTest 等待已经提交的异步通知发送完成,供测试隔离全局状态时使用。 +func WaitForDispatchesForTest() { + dispatchWG.Wait() +} + // ParseConfig helper for senders: unmarshal channel Config JSON into // an arbitrary struct, returning a typed error on invalid JSON. func ParseConfig(raw string, out interface{}) error { diff --git a/src/route/router_test.go b/src/route/router_test.go index 53997a73..f314a99f 100644 --- a/src/route/router_test.go +++ b/src/route/router_test.go @@ -19,6 +19,8 @@ import ( "github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/service" + "github.com/GMWalletApp/epusdt/notify" + "github.com/GMWalletApp/epusdt/telegram" "github.com/GMWalletApp/epusdt/util/constant" "github.com/GMWalletApp/epusdt/util/http_client" "github.com/GMWalletApp/epusdt/util/log" @@ -35,6 +37,8 @@ const testAPIToken = "test-secret-token" func setupTestEnv(t *testing.T) *echo.Echo { t.Helper() + notify.WaitForDispatchesForTest() + telegram.WaitForReloadsForTest() tmpDir := t.TempDir() @@ -94,6 +98,8 @@ func setupTestEnv(t *testing.T) *echo.Echo { config.RateCacheSave = data.SaveRateCacheSnapshot config.ResetRateCacheRuntime() t.Cleanup(func() { + notify.WaitForDispatchesForTest() + telegram.WaitForReloadsForTest() config.SettingsGetString = nil config.RateCacheLoad = nil config.RateCacheLoadAll = nil diff --git a/src/task/listen_aptos_scanner_test.go b/src/task/listen_aptos_scanner_test.go index c50a8bcd..6cf2e098 100644 --- a/src/task/listen_aptos_scanner_test.go +++ b/src/task/listen_aptos_scanner_test.go @@ -322,7 +322,8 @@ func TestProcessAptosLedgerRoundMarksMatchingUSDTOrderPaid(t *testing.T) { amount := 3.1 tradeID := "aptos_trade_1" usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" - body := aptosFungibleTransferBody(t, "0xabc", 101, receive, usdt, "3100000") + version := time.Now().UnixNano() + txID := fmt.Sprintf("0x%x", version) order := &mdb.Orders{ TradeId: tradeID, @@ -343,22 +344,23 @@ func TestProcessAptosLedgerRoundMarksMatchingUSDTOrderPaid(t *testing.T) { if err := data.LockTransaction(mdb.NetworkAptos, receive, "USDT", tradeID, amount, time.Hour); err != nil { t.Fatalf("lock transaction: %v", err) } + body := aptosFungibleTransferBody(t, txID, version, receive, usdt, "3100000") state, err := loadMoveWatchState(mdb.NetworkAptos) if err != nil { t.Fatalf("load state: %v", err) } - provider := &fakeAptosProvider{bodiesByStart: map[int64][]byte{101: body}} - cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: 100} + provider := &fakeAptosProvider{bodiesByStart: map[int64][]byte{version: body}} + cursor := &aptosRuntimeCursor{initialized: true, lastSeenVersion: version - 1} - if _, err = processAptosLedgerRound(context.Background(), provider, state, cursor, 101); err != nil { + if _, err = processAptosLedgerRound(context.Background(), provider, state, cursor, version); err != nil { t.Fatalf("processAptosLedgerRound(): %v", err) } paid, err := data.GetOrderInfoByTradeId(tradeID) if err != nil { t.Fatalf("reload order: %v", err) } - if paid.Status != mdb.StatusPaySuccess || paid.CallBackConfirm != mdb.CallBackConfirmNo || paid.BlockTransactionId != "0xabc" { + if paid.Status != mdb.StatusPaySuccess || paid.CallBackConfirm != mdb.CallBackConfirmNo || paid.BlockTransactionId != txID { t.Fatalf("paid order = %#v", paid) } lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkAptos, receive, "USDT", amount) diff --git a/src/telegram/telegram.go b/src/telegram/telegram.go index cf8c1659..cfebcea4 100644 --- a/src/telegram/telegram.go +++ b/src/telegram/telegram.go @@ -22,6 +22,7 @@ var ( bots *tb.Bot adminChatID int64 reloadMu sync.Mutex + reloadWG sync.WaitGroup ) // BotStart connects the command bot. If no telegram channel is @@ -38,13 +39,20 @@ func BotStart() { // It is used by admin API handlers after telegram channel create/update/ // status/delete so operators don't need to restart the service. func ReloadBotAsync(reason string) { + reloadWG.Add(1) go func() { + defer reloadWG.Done() if err := reloadBot(reason); err != nil { log.Sugar.Errorf("[telegram] reload failed, reason=%s err=%v", reason, err) } }() } +// WaitForReloadsForTest 等待已经提交的异步重载完成,供测试隔离全局状态时使用。 +func WaitForReloadsForTest() { + reloadWG.Wait() +} + // loadCommandBotConfig reads the command-bot config from the settings // table (system.telegram_bot_token + system.telegram_chat_id). // Returns (nil, "", nil) when the keys are absent so the caller can