mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +00:00
2a36a10e7f
- 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
447 lines
17 KiB
Go
447 lines
17 KiB
Go
package admin
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/GMWalletApp/epusdt/model/data"
|
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
|
"github.com/GMWalletApp/epusdt/model/request"
|
|
"github.com/GMWalletApp/epusdt/model/service"
|
|
"github.com/GMWalletApp/epusdt/util/log"
|
|
"github.com/labstack/echo/v4"
|
|
)
|
|
|
|
// MarkPaidRequest is the payload for manually marking an order as paid.
|
|
type MarkPaidRequest struct {
|
|
BlockTransactionId string `json:"block_transaction_id" validate:"required" example:"0xabc123def456..."`
|
|
}
|
|
|
|
// OrderListResponse wraps the paginated order list.
|
|
type OrderListResponse struct {
|
|
List []mdb.Orders `json:"list"`
|
|
Total int64 `json:"total" example:"100"`
|
|
Page int `json:"page" example:"1"`
|
|
PageSize int `json:"page_size" example:"20"`
|
|
}
|
|
|
|
// OrderWithSub embeds a parent order together with its child orders.
|
|
type OrderWithSub struct {
|
|
mdb.Orders
|
|
SubOrders []mdb.Orders `json:"sub_orders"`
|
|
}
|
|
|
|
// OrderWithSubListResponse wraps the paginated list-with-sub result.
|
|
type OrderWithSubListResponse struct {
|
|
List []OrderWithSub `json:"list"`
|
|
Total int64 `json:"total" example:"100"`
|
|
Page int `json:"page" example:"1"`
|
|
PageSize int `json:"page_size" example:"20"`
|
|
}
|
|
|
|
// ListOrders handles the admin order list with filters + pagination.
|
|
// @Summary List orders
|
|
// @Description Returns paginated orders with optional filters
|
|
// @Tags Admin Orders
|
|
// @Security AdminJWT
|
|
// @Produce json
|
|
// @Param network query string false "Network filter"
|
|
// @Param token query string false "Token filter"
|
|
// @Param address query string false "Receive address filter"
|
|
// @Param keyword query string false "Keyword search (trade_id or order_id)"
|
|
// @Param status query int false "Order status filter"
|
|
// @Param start_at query int false "Start time (Unix seconds)"
|
|
// @Param end_at query int false "End time (Unix seconds)"
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param page_size query int false "Page size" default(20)
|
|
// @Success 200 {object} response.ApiResponse{data=admin.OrderListResponse}
|
|
// @Failure 400 {object} response.ApiResponse
|
|
// @Router /admin/api/v1/orders [get]
|
|
func (c *BaseAdminController) ListOrders(ctx echo.Context) error {
|
|
f := parseOrderFilter(ctx)
|
|
rows, total, err := data.ListOrders(f)
|
|
if err != nil {
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
return c.SucJson(ctx, map[string]interface{}{
|
|
"list": rows,
|
|
"total": total,
|
|
"page": f.Page,
|
|
"page_size": f.PageSize,
|
|
})
|
|
}
|
|
|
|
// GetOrder returns full order detail by trade_id.
|
|
// @Summary Get order
|
|
// @Description Returns full order detail by trade_id
|
|
// @Tags Admin Orders
|
|
// @Security AdminJWT
|
|
// @Produce json
|
|
// @Param trade_id path string true "Trade ID"
|
|
// @Success 200 {object} response.ApiResponse{data=mdb.Orders}
|
|
// @Failure 400 {object} response.ApiResponse
|
|
// @Router /admin/api/v1/orders/{trade_id} [get]
|
|
func (c *BaseAdminController) GetOrder(ctx echo.Context) error {
|
|
tradeID := ctx.Param("trade_id")
|
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
|
if err != nil {
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
if order.ID == 0 {
|
|
return c.FailJson(ctx, errors.New("order not found"))
|
|
}
|
|
return c.SucJson(ctx, order)
|
|
}
|
|
|
|
// CloseOrder flips a waiting order to expired and releases its lock.
|
|
// @Summary Close order
|
|
// @Description Manually close a waiting order (mark as expired)
|
|
// @Tags Admin Orders
|
|
// @Security AdminJWT
|
|
// @Produce json
|
|
// @Param trade_id path string true "Trade ID"
|
|
// @Success 200 {object} response.ApiResponse
|
|
// @Failure 400 {object} response.ApiResponse
|
|
// @Router /admin/api/v1/orders/{trade_id}/close [post]
|
|
func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
|
tradeID := ctx.Param("trade_id")
|
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
|
if err != nil {
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
if order.ID == 0 {
|
|
return c.FailJson(ctx, errors.New("order not found"))
|
|
}
|
|
if order.Status != mdb.StatusWaitPay {
|
|
return c.FailJson(ctx, errors.New("order is not waiting payment"))
|
|
}
|
|
ok, err := data.CloseOrderManually(tradeID)
|
|
if err != nil {
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
if !ok {
|
|
return c.FailJson(ctx, errors.New("close failed (concurrent state change)"))
|
|
}
|
|
// Release the transaction lock so the amount slot becomes reusable.
|
|
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
|
|
return c.SucJson(ctx, nil)
|
|
}
|
|
|
|
// MarkOrderPaid manually marks an order paid (operator补单). Uses the
|
|
// same OrderProcessing path as on-chain confirmation so downstream
|
|
// callback + notification fire normally.
|
|
// @Summary Mark order paid
|
|
// @Description Manually mark a waiting order as paid (operator补单)
|
|
// @Tags Admin Orders
|
|
// @Security AdminJWT
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param trade_id path string true "Trade ID"
|
|
// @Param request body admin.MarkPaidRequest true "Block transaction ID"
|
|
// @Success 200 {object} response.ApiResponse
|
|
// @Failure 400 {object} response.ApiResponse
|
|
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
|
|
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
|
tradeID := ctx.Param("trade_id")
|
|
adminUserID := currentAdminUserID(ctx)
|
|
req := new(MarkPaidRequest)
|
|
if err := ctx.Bind(req); err != nil {
|
|
log.Sugar.Warnf("[admin-order] mark-paid bind failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
req.BlockTransactionId = strings.TrimSpace(req.BlockTransactionId)
|
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
|
log.Sugar.Warnf("[admin-order] mark-paid validation failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
|
if err != nil {
|
|
log.Sugar.Warnf("[admin-order] mark-paid load failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
if order.ID == 0 {
|
|
err = errors.New("order not found")
|
|
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
if order.Status != mdb.StatusWaitPay {
|
|
err = errors.New("order is not waiting payment")
|
|
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s status=%d block_transaction_id=%s err=%v", adminUserID, tradeID, order.Status, req.BlockTransactionId, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
if !isOnChainOrder(order.PayProvider) {
|
|
err = errors.New("order is not an on-chain payment order")
|
|
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s pay_provider=%s block_transaction_id=%s err=%v", adminUserID, tradeID, order.PayProvider, req.BlockTransactionId, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
verifiedBlockTransactionID, err := service.ValidateManualOrderPayment(order, req.BlockTransactionId)
|
|
if err != nil {
|
|
log.Sugar.Warnf("[admin-order] mark-paid chain verification failed admin_user_id=%d trade_id=%s network=%s token=%s amount=%.8f block_transaction_id=%s err=%v", adminUserID, tradeID, order.Network, order.Token, order.ActualAmount, req.BlockTransactionId, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
req.BlockTransactionId = verifiedBlockTransactionID
|
|
err = service.OrderProcessing(&request.OrderProcessingRequest{
|
|
ReceiveAddress: order.ReceiveAddress,
|
|
Currency: order.Currency,
|
|
Token: order.Token,
|
|
Network: order.Network,
|
|
Amount: order.ActualAmount,
|
|
TradeId: order.TradeId,
|
|
BlockTransactionId: req.BlockTransactionId,
|
|
})
|
|
if err != nil {
|
|
log.Sugar.Warnf("[admin-order] mark-paid processing failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
log.Sugar.Infof("[admin-order] mark-paid success admin_user_id=%d trade_id=%s block_transaction_id=%s", adminUserID, tradeID, req.BlockTransactionId)
|
|
return c.SucJson(ctx, nil)
|
|
}
|
|
|
|
// ResendCallback requeues the order callback by flipping
|
|
// callback_confirm back to NO. The mq worker will grab it on the
|
|
// next poll tick.
|
|
// @Summary Resend callback
|
|
// @Description Re-queue the order callback notification
|
|
// @Tags Admin Orders
|
|
// @Security AdminJWT
|
|
// @Produce json
|
|
// @Param trade_id path string true "Trade ID"
|
|
// @Success 200 {object} response.ApiResponse
|
|
// @Failure 400 {object} response.ApiResponse
|
|
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
|
|
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
|
|
tradeID := ctx.Param("trade_id")
|
|
adminUserID := currentAdminUserID(ctx)
|
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
|
if err != nil {
|
|
log.Sugar.Warnf("[admin-order] resend-callback load failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
if order.ID == 0 {
|
|
err = errors.New("order not found")
|
|
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
if order.Status != mdb.StatusPaySuccess {
|
|
err = errors.New("order is not paid (callback not applicable)")
|
|
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s status=%d err=%v", adminUserID, tradeID, order.Status, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
if strings.TrimSpace(order.NotifyUrl) == "" {
|
|
err = errors.New("order has empty notify_url")
|
|
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
ok, err := data.ReopenOrderCallback(tradeID)
|
|
if err != nil {
|
|
log.Sugar.Warnf("[admin-order] resend-callback reopen failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
if !ok {
|
|
err = errors.New("resend callback failed (concurrent state change)")
|
|
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
log.Sugar.Infof("[admin-order] resend-callback queued admin_user_id=%d trade_id=%s", adminUserID, tradeID)
|
|
return c.SucJson(ctx, nil)
|
|
}
|
|
|
|
func isOnChainOrder(payProvider string) bool {
|
|
payProvider = strings.TrimSpace(payProvider)
|
|
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
|
|
}
|
|
|
|
// ExportOrders streams matching orders as CSV. No pagination — the
|
|
// caller drives what rows to include via filter params. Capped at
|
|
// 10k rows to keep the response bounded.
|
|
// @Summary Export orders as CSV
|
|
// @Description Stream matching orders as a CSV file (max 10k rows)
|
|
// @Tags Admin Orders
|
|
// @Security AdminJWT
|
|
// @Produce text/csv
|
|
// @Param network query string false "Network filter"
|
|
// @Param token query string false "Token filter"
|
|
// @Param address query string false "Receive address filter"
|
|
// @Param keyword query string false "Keyword search"
|
|
// @Param status query int false "Order status filter"
|
|
// @Param start_at query int false "Start time (Unix seconds)"
|
|
// @Param end_at query int false "End time (Unix seconds)"
|
|
// @Success 200 {string} string "CSV file"
|
|
// @Failure 400 {object} response.ApiResponse
|
|
// @Router /admin/api/v1/orders/export [get]
|
|
func (c *BaseAdminController) ExportOrders(ctx echo.Context) error {
|
|
f := parseOrderFilter(ctx)
|
|
f.Page = 1
|
|
f.PageSize = 10000
|
|
rows, _, err := data.ListOrders(f)
|
|
if err != nil {
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
resp := ctx.Response()
|
|
resp.Header().Set(echo.HeaderContentType, "text/csv; charset=utf-8")
|
|
resp.Header().Set(echo.HeaderContentDisposition, "attachment; filename=orders.csv")
|
|
resp.WriteHeader(http.StatusOK)
|
|
|
|
w := csv.NewWriter(resp.Writer)
|
|
_ = w.Write([]string{"trade_id", "order_id", "amount", "actual_amount", "currency", "token", "network", "receive_address", "status", "block_transaction_id", "created_at"})
|
|
for _, r := range rows {
|
|
_ = w.Write([]string{
|
|
r.TradeId,
|
|
r.OrderId,
|
|
fmt.Sprintf("%.4f", r.Amount),
|
|
fmt.Sprintf("%.4f", r.ActualAmount),
|
|
r.Currency,
|
|
r.Token,
|
|
r.Network,
|
|
r.ReceiveAddress,
|
|
strconv.Itoa(r.Status),
|
|
r.BlockTransactionId,
|
|
r.CreatedAt.ToDateTimeString(),
|
|
})
|
|
}
|
|
w.Flush()
|
|
return nil
|
|
}
|
|
|
|
// parseOrderFilter extracts filter + pagination values from the query
|
|
// string. Time params are Unix seconds.
|
|
func parseOrderFilter(ctx echo.Context) data.OrderListFilter {
|
|
f := data.OrderListFilter{
|
|
Network: strings.ToLower(strings.TrimSpace(ctx.QueryParam("network"))),
|
|
Token: strings.ToUpper(strings.TrimSpace(ctx.QueryParam("token"))),
|
|
Address: strings.TrimSpace(ctx.QueryParam("address")),
|
|
Keyword: strings.TrimSpace(ctx.QueryParam("keyword")),
|
|
}
|
|
if s := ctx.QueryParam("status"); s != "" {
|
|
if n, err := strconv.Atoi(s); err == nil {
|
|
f.Status = n
|
|
}
|
|
}
|
|
if s := ctx.QueryParam("start_at"); s != "" {
|
|
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
|
t := time.Unix(n, 0)
|
|
f.StartAt = &t
|
|
}
|
|
}
|
|
if s := ctx.QueryParam("end_at"); s != "" {
|
|
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
|
t := time.Unix(n, 0)
|
|
f.EndAt = &t
|
|
}
|
|
}
|
|
if s := ctx.QueryParam("page"); s != "" {
|
|
if n, err := strconv.Atoi(s); err == nil {
|
|
f.Page = n
|
|
}
|
|
}
|
|
if s := ctx.QueryParam("page_size"); s != "" {
|
|
if n, err := strconv.Atoi(s); err == nil {
|
|
f.PageSize = n
|
|
}
|
|
}
|
|
if f.Page < 1 {
|
|
f.Page = 1
|
|
}
|
|
if f.PageSize < 1 {
|
|
f.PageSize = 20
|
|
}
|
|
return f
|
|
}
|
|
|
|
// ListOrdersWithSub returns the same paginated order list as ListOrders but
|
|
// each row has a sub_orders field containing all child orders under it.
|
|
// @Summary List orders with sub-orders
|
|
// @Description Returns paginated orders; each item embeds its child orders in sub_orders
|
|
// @Tags Admin Orders
|
|
// @Security AdminJWT
|
|
// @Produce json
|
|
// @Param network query string false "Network filter"
|
|
// @Param token query string false "Token filter"
|
|
// @Param address query string false "Receive address filter"
|
|
// @Param keyword query string false "Keyword search (trade_id or order_id)"
|
|
// @Param status query int false "Order status filter"
|
|
// @Param start_at query int false "Start time (Unix seconds)"
|
|
// @Param end_at query int false "End time (Unix seconds)"
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param page_size query int false "Page size" default(20)
|
|
// @Success 200 {object} response.ApiResponse{data=admin.OrderWithSubListResponse}
|
|
// @Failure 400 {object} response.ApiResponse
|
|
// @Router /admin/api/v1/orders/list-with-sub [get]
|
|
func (c *BaseAdminController) ListOrdersWithSub(ctx echo.Context) error {
|
|
f := parseOrderFilter(ctx)
|
|
// Only parent orders at the top level; sub-orders appear nested inside sub_orders.
|
|
f.ParentOnly = true
|
|
rows, total, err := data.ListOrders(f)
|
|
if err != nil {
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
|
|
// Batch-fetch all sub-orders for the current page in one query.
|
|
tradeIds := make([]string, 0, len(rows))
|
|
for _, r := range rows {
|
|
tradeIds = append(tradeIds, r.TradeId)
|
|
}
|
|
subs, err := data.GetSubOrdersByParentTradeIds(tradeIds)
|
|
if err != nil {
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
|
|
// Group sub-orders by parent_trade_id.
|
|
subMap := make(map[string][]mdb.Orders, len(subs))
|
|
for _, s := range subs {
|
|
subMap[s.ParentTradeId] = append(subMap[s.ParentTradeId], s)
|
|
}
|
|
|
|
list := make([]OrderWithSub, 0, len(rows))
|
|
for _, r := range rows {
|
|
children := subMap[r.TradeId]
|
|
if children == nil {
|
|
children = []mdb.Orders{}
|
|
}
|
|
list = append(list, OrderWithSub{Orders: r, SubOrders: children})
|
|
}
|
|
|
|
return c.SucJson(ctx, OrderWithSubListResponse{
|
|
List: list,
|
|
Total: total,
|
|
Page: f.Page,
|
|
PageSize: f.PageSize,
|
|
})
|
|
}
|
|
|
|
// GetOrderWithSub returns a single parent order together with all its child
|
|
// orders (any status), regardless of whether the caller passes a parent or
|
|
// child trade_id — if it is a child, the parent is resolved first.
|
|
// @Summary Get order with sub-orders
|
|
// @Description Returns an order and its child orders embedded in sub_orders
|
|
// @Tags Admin Orders
|
|
// @Security AdminJWT
|
|
// @Produce json
|
|
// @Param trade_id path string true "Trade ID"
|
|
// @Success 200 {object} response.ApiResponse{data=admin.OrderWithSub}
|
|
// @Failure 400 {object} response.ApiResponse
|
|
// @Router /admin/api/v1/orders/{trade_id}/with-sub [get]
|
|
func (c *BaseAdminController) GetOrderWithSub(ctx echo.Context) error {
|
|
tradeID := ctx.Param("trade_id")
|
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
|
if err != nil {
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
if order.ID == 0 {
|
|
return c.FailJson(ctx, errors.New("order not found"))
|
|
}
|
|
subOrders, err := data.GetAllSubOrders(tradeID)
|
|
if err != nil {
|
|
return c.FailJson(ctx, err)
|
|
}
|
|
if subOrders == nil {
|
|
subOrders = []mdb.Orders{}
|
|
}
|
|
return c.SucJson(ctx, OrderWithSub{Orders: *order, SubOrders: subOrders})
|
|
}
|