feat: verify manual mark-paid payments

- add on-chain validation before manual mark-paid
- verify recipient address, token contract, amount, confirmations, and tx time
- reject duplicate transaction hashes and non on-chain orders
- add manual payment verification tests for EVM, TRON, and Solana
- change BSC network key to binance
- add display_name to supported_assets while keeping network unchanged
This commit is contained in:
line-6000
2026-05-20 23:38:22 +08:00
parent 864eb5969c
commit 2a36a10e7f
202 changed files with 1947 additions and 255 deletions
+187
View File
@@ -2,6 +2,7 @@ package route
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -11,6 +12,7 @@ import (
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/labstack/echo/v4"
)
@@ -647,6 +649,138 @@ func TestAdminOrders_MarkPaidNotFound(t *testing.T) {
}
}
func TestAdminOrders_MarkPaidSuccessAfterVerification(t *testing.T) {
e, token := setupAdminTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-admin-mark-paid-ok",
OrderId: "order-admin-mark-paid-ok",
Amount: 10,
Currency: "CNY",
ActualAmount: 1.23,
ReceiveAddress: "TTestTronAddress001",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/notify",
PayProvider: mdb.PaymentProviderOnChain,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
verified := false
restore := service.SetManualOrderPaymentValidatorForTest(func(got *mdb.Orders, blockID string) (string, error) {
verified = true
if got.TradeId != order.TradeId {
t.Fatalf("validator trade_id = %s, want %s", got.TradeId, order.TradeId)
}
if blockID != "block-admin-ok" {
t.Fatalf("validator block id = %s, want block-admin-ok", blockID)
}
return "canonical-block-admin-ok", nil
})
defer restore()
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
"block_transaction_id": "block-admin-ok",
}, token)
t.Logf("MarkOrderPaid success: status=%d body=%s", rec.Code, rec.Body.String())
assertOK(t, rec)
if !verified {
t.Fatal("expected chain verifier to be called")
}
paid, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if paid.Status != mdb.StatusPaySuccess {
t.Fatalf("status = %d, want %d", paid.Status, mdb.StatusPaySuccess)
}
if paid.BlockTransactionId != "canonical-block-admin-ok" {
t.Fatalf("block_transaction_id = %q", paid.BlockTransactionId)
}
if paid.CallBackConfirm != mdb.CallBackConfirmNo {
t.Fatalf("callback_confirm = %d, want %d", paid.CallBackConfirm, mdb.CallBackConfirmNo)
}
}
func TestAdminOrders_MarkPaidRejectsVerificationFailure(t *testing.T) {
e, token := setupAdminTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-admin-mark-paid-bad-proof",
OrderId: "order-admin-mark-paid-bad-proof",
Amount: 10,
Currency: "CNY",
ActualAmount: 1.23,
ReceiveAddress: "TTestTronAddress001",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/notify",
PayProvider: mdb.PaymentProviderOnChain,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
return "", errors.New("transaction amount mismatch")
})
defer restore()
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
"block_transaction_id": "block-admin-bad",
}, token)
t.Logf("MarkOrderPaid verifier failure: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
}
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != mdb.StatusWaitPay || reloaded.BlockTransactionId != "" {
t.Fatalf("order changed after failed verification: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
}
}
func TestAdminOrders_MarkPaidRejectsNonOnChainOrder(t *testing.T) {
e, token := setupAdminTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-admin-mark-paid-provider",
OrderId: "order-admin-mark-paid-provider",
Amount: 10,
Currency: "CNY",
ActualAmount: 1.23,
ReceiveAddress: "TTestTronAddress001",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/notify",
PayProvider: mdb.PaymentProviderOkPay,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
called := false
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
called = true
return "block-admin-provider", nil
})
defer restore()
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
"block_transaction_id": "block-admin-provider",
}, token)
t.Logf("MarkOrderPaid provider order: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
}
if called {
t.Fatal("verifier should not run for non-on-chain order")
}
}
// TestAdminOrders_ResendCallbackNotFound verifies resend-callback graceful error.
func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
e, token := setupAdminTestEnv(t)
@@ -657,6 +791,59 @@ func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
}
}
func TestAdminOrders_ResendCallbackRejectsEmptyNotifyURL(t *testing.T) {
e, token := setupAdminTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-admin-resend-empty-url",
OrderId: "order-admin-resend-empty-url",
Status: mdb.StatusPaySuccess,
CallBackConfirm: mdb.CallBackConfirmOk,
CallbackNum: 3,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/resend-callback", nil, token)
t.Logf("ResendCallback empty notify_url: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
}
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.CallBackConfirm != mdb.CallBackConfirmOk || reloaded.CallbackNum != 3 {
t.Fatalf("callback state changed: confirm=%d num=%d", reloaded.CallBackConfirm, reloaded.CallbackNum)
}
}
func TestAdminOrders_ResendCallbackRequeuesPaidOrder(t *testing.T) {
e, token := setupAdminTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-admin-resend-ok",
OrderId: "order-admin-resend-ok",
Status: mdb.StatusPaySuccess,
NotifyUrl: "https://merchant.example/notify",
CallBackConfirm: mdb.CallBackConfirmOk,
CallbackNum: 3,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/resend-callback", nil, token)
t.Logf("ResendCallback success: status=%d body=%s", rec.Code, rec.Body.String())
assertOK(t, rec)
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.CallBackConfirm != mdb.CallBackConfirmNo || reloaded.CallbackNum != 0 {
t.Fatalf("callback state = confirm %d num %d, want no/0", reloaded.CallBackConfirm, reloaded.CallbackNum)
}
}
// ─── Dashboard ───────────────────────────────────────────────────────────────
// TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200.
+173 -36
View File
@@ -20,6 +20,7 @@ import (
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/sign"
"github.com/dromara/carbon/v2"
"github.com/go-resty/resty/v2"
"github.com/labstack/echo/v4"
"github.com/spf13/viper"
@@ -578,6 +579,12 @@ func TestGetPublicConfig(t *testing.T) {
row := item.(map[string]interface{})
network := row["network"].(string)
seen[network] = true
if _, ok := row["display_name"].(string); !ok {
t.Fatalf("supported_assets[%s].display_name missing or not string: %#v", network, row["display_name"])
}
if network == "tron" && row["display_name"] != "TRON" {
t.Fatalf("supported_assets.tron.display_name = %v, want TRON", row["display_name"])
}
}
for _, n := range []string{"tron", "solana"} {
if !seen[n] {
@@ -989,51 +996,64 @@ func TestCheckStatus_NotFound(t *testing.T) {
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
t.Logf("CheckStatus(not found): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code >= 500 {
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Code == http.StatusNotFound && rec.Body.Len() == 0 {
t.Fatalf("route returned 404 with empty body — route may not be registered")
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal check-status error response: %v", err)
}
if got := int(resp["status_code"].(float64)); got != 10008 {
t.Fatalf("status_code = %d, want 10008; response=%v", got, resp)
}
}
// TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns 200
// and a status field when the order exists.
// TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns the
// real order status for existing orders in all checkout-relevant states.
func TestCheckStatus_WithOrder(t *testing.T) {
e := setupTestEnv(t)
// Create an order first via the GMPAY route.
body := signBody(map[string]interface{}{
"order_id": "check-status-001",
"amount": 1.00,
"token": "usdt",
"currency": "cny",
"network": "tron",
"notify_url": "http://localhost/notify",
})
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
if createRec.Code != http.StatusOK {
t.Fatalf("create order failed: %d %s", createRec.Code, createRec.Body.String())
}
var createResp map[string]interface{}
json.Unmarshal(createRec.Body.Bytes(), &createResp)
tradeId, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
if tradeId == "" {
t.Fatal("no trade_id in create response")
cases := []struct {
name string
status int
}{
{name: "wait-pay", status: mdb.StatusWaitPay},
{name: "paid", status: mdb.StatusPaySuccess},
{name: "expired", status: mdb.StatusExpired},
}
// Now check status.
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeId, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
t.Logf("CheckStatus: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["data"] == nil {
t.Fatal("expected data in check-status response")
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tradeID := createCheckoutCounterRespTestOrder(t, e, "check-status-"+tc.name)
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", tc.status).Error; err != nil {
t.Fatalf("update status: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
t.Logf("CheckStatus(%s): status=%d body=%s", tc.name, rec.Code, rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal check-status response: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected data in check-status response, got: %v", resp)
}
if got, _ := data["trade_id"].(string); got != tradeID {
t.Fatalf("trade_id = %q, want %q", got, tradeID)
}
if got := int(data["status"].(float64)); got != tc.status {
t.Fatalf("status = %d, want %d", got, tc.status)
}
})
}
}
@@ -1061,6 +1081,123 @@ func TestCheckoutCounter_NotFound(t *testing.T) {
}
}
func TestCheckoutCounterResp_ReturnsPaidOrder(t *testing.T) {
e := setupTestEnv(t)
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-paid-001")
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
data := getCheckoutCounterRespData(t, e, tradeID)
if got, _ := data["trade_id"].(string); got != tradeID {
t.Fatalf("trade_id = %q, want %q; data=%v", got, tradeID, data)
}
if got, _ := data["redirect_url"].(string); got != "https://merchant.example/return" {
t.Fatalf("redirect_url = %q", got)
}
}
func TestCheckoutCounterResp_ReturnsExpiredOrder(t *testing.T) {
e := setupTestEnv(t)
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-expired-001")
expiredCreatedAt := carbon.Now().SubMinutes(config.GetOrderExpirationTime() + 1)
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Updates(map[string]interface{}{
"status": mdb.StatusExpired,
"created_at": expiredCreatedAt,
}).Error; err != nil {
t.Fatalf("mark order expired: %v", err)
}
data := getCheckoutCounterRespData(t, e, tradeID)
if got, _ := data["trade_id"].(string); got != tradeID {
t.Fatalf("trade_id = %q, want %q; data=%v", got, tradeID, data)
}
expirationTime, _ := data["expiration_time"].(float64)
if int64(expirationTime) > carbon.Now().TimestampMilli() {
t.Fatalf("expiration_time = %.0f, want expired timestamp", expirationTime)
}
}
func TestCheckoutCounterResp_UnknownOrderReturnsClearError(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter-resp/nonexistent-trade-id", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal checkout counter error response: %v", err)
}
if got := int(resp["status_code"].(float64)); got != 10008 {
t.Fatalf("status_code = %d, want 10008; response=%v", got, resp)
}
if resp["message"] == "" {
t.Fatalf("expected error message, got: %v", resp)
}
}
func createCheckoutCounterRespTestOrder(t *testing.T, e *echo.Echo, orderID string) string {
t.Helper()
body := signBody(map[string]interface{}{
"order_id": orderID,
"amount": 1.00,
"token": "usdt",
"currency": "cny",
"network": "tron",
"notify_url": "http://localhost/notify",
"redirect_url": "https://merchant.example/return",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
if rec.Code != http.StatusOK {
t.Fatalf("create order failed: %d %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal create response: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected create response data, got: %v", resp)
}
tradeID, _ := data["trade_id"].(string)
if tradeID == "" {
t.Fatalf("missing trade_id in create response: %v", resp)
}
return tradeID
}
func getCheckoutCounterRespData(t *testing.T, e *echo.Echo, tradeID string) map[string]interface{} {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter-resp/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal checkout counter response: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected checkout counter data, got: %v", resp)
}
return data
}
// TestSwitchNetwork_MissingFields verifies that /pay/switch-network validates
// required fields and returns a graceful error when they are missing.
func TestSwitchNetwork_MissingFields(t *testing.T) {