Network
+Currency
+diff --git a/src/.env.example b/src/.env.example index 18c0d62..b595454 100644 --- a/src/.env.example +++ b/src/.env.example @@ -64,3 +64,8 @@ tron_grid_api_key= solana_rpc_url= ethereum_ws_url=wss://ethereum.publicnode.com + + +# epay +epay_pid= +epay_key= diff --git a/src/config/config.go b/src/config/config.go index a4bc937..fa96b18 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -340,3 +340,11 @@ func GetSolanaRpcUrl() string { func GetEthereumWsUrl() string { return strings.TrimSpace(viper.GetString("ethereum_ws_url")) } + +func GetEpayPid() int { + return viper.GetInt("epay_pid") +} + +func GetEpayKey() string { + return viper.GetString("epay_key") +} diff --git a/src/controller/comm/order_controller.go b/src/controller/comm/order_controller.go index f745711..4c58c4d 100644 --- a/src/controller/comm/order_controller.go +++ b/src/controller/comm/order_controller.go @@ -1,6 +1,10 @@ package comm import ( + "encoding/json" + "fmt" + "log" + "github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/service" "github.com/assimon/luuu/util/constant" @@ -22,3 +26,53 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) { } return c.SucJson(ctx, resp) } + +// SwitchNetwork 切换支付网络,创建或返回子订单 +func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) { + req := new(request.SwitchNetworkRequest) + if err = ctx.Bind(req); err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + if err = c.ValidateStruct(ctx, req); err != nil { + return c.FailJson(ctx, err) + } + resp, err := service.SwitchNetwork(req) + if err != nil { + return c.FailJson(ctx, err) + } + + jsonBytes, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return c.FailJson(ctx, err) + } + + fmt.Printf("switch network response: \n%s", string(jsonBytes)) + + return c.SucJson(ctx, resp) +} + +func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) { + req := new(request.CreateTransactionRequest) + if err = ctx.Bind(req); err != nil { + log.Println("bind request error:", err) + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + if err = c.ValidateStruct(ctx, req); err != nil { + log.Println("validate request error:", err) + return c.FailJson(ctx, err) + } + resp, err := service.CreateTransaction(req) + if err != nil { + log.Println("create transaction error:", err) + return c.FailJson(ctx, err) + } + + fmt.Printf("create transaction response: %+v\n", resp) + + tradeID := resp.TradeId + + ctx.Redirect(302, "/pay/checkout-counter/"+tradeID) + + return nil + +} diff --git a/src/controller/comm/pay_controller.go b/src/controller/comm/pay_controller.go index 1bc9ebd..66e6850 100644 --- a/src/controller/comm/pay_controller.go +++ b/src/controller/comm/pay_controller.go @@ -1,6 +1,8 @@ package comm import ( + "encoding/json" + "fmt" "html/template" "net/http" "path/filepath" @@ -30,7 +32,13 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) { if err != nil { return ctx.String(http.StatusOK, err.Error()) } - resp.Network = "TRON" + + jsonByte, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return ctx.String(http.StatusOK, err.Error()) + } + fmt.Printf("%v\n", string(jsonByte)) + return tmpl.Execute(ctx.Response(), resp) } diff --git a/src/model/data/order_data.go b/src/model/data/order_data.go index b5a8e16..430264b 100644 --- a/src/model/data/order_data.go +++ b/src/model/data/order_data.go @@ -108,6 +108,97 @@ func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, e return result.RowsAffected > 0, result.Error } +// CountActiveSubOrders counts sub-orders with status=WaitPay under a parent. +func CountActiveSubOrders(parentTradeId string) (int64, error) { + var count int64 + err := dao.Mdb.Model(&mdb.Orders{}). + Where("parent_trade_id = ?", parentTradeId). + Where("status = ?", mdb.StatusWaitPay). + Count(&count).Error + return count, err +} + +// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent. +func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) { + order := new(mdb.Orders) + err := dao.Mdb.Model(order). + Where("parent_trade_id = ?", parentTradeId). + Where("token = ?", token). + Where("network = ?", network). + Where("status = ?", mdb.StatusWaitPay). + Limit(1). + Find(order).Error + return order, err +} + +// GetSiblingSubOrders returns active sub-orders under the same parent, excluding the given trade_id. +func GetSiblingSubOrders(parentTradeId string, excludeTradeId string) ([]mdb.Orders, error) { + var orders []mdb.Orders + err := dao.Mdb.Model(&mdb.Orders{}). + Where("parent_trade_id = ?", parentTradeId). + Where("trade_id != ?", excludeTradeId). + Where("status = ?", mdb.StatusWaitPay). + Find(&orders).Error + return orders, err +} + +// MarkParentOrderSuccess updates the parent order with the sub-order's payment details. +// Token and network are NOT overwritten — the parent keeps its original values. +func MarkParentOrderSuccess(parentTradeId string, sub *mdb.Orders) (bool, error) { + result := dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", parentTradeId). + Where("status = ?", mdb.StatusWaitPay). + Updates(map[string]interface{}{ + "status": mdb.StatusPaySuccess, + "block_transaction_id": sub.BlockTransactionId, + "callback_confirm": mdb.CallBackConfirmNo, + "actual_amount": sub.ActualAmount, + "receive_address": sub.ReceiveAddress, + }) + return result.RowsAffected > 0, result.Error +} + +// MarkOrderSelected sets is_selected=true for the given trade_id. +func MarkOrderSelected(tradeId string) error { + return dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", tradeId). + Update("is_selected", true).Error +} + +// RefreshOrderExpiration resets created_at to now so the expiration timer restarts. +// Called on the parent order when a sub-order is created or returned. +func RefreshOrderExpiration(tradeId string) error { + return dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", tradeId). + Update("created_at", time.Now()).Error +} + +// ResetCallbackConfirmOk sets callback_confirm back to Ok. +// Prevents the callback worker from retrying a sub-order with an empty notify_url. +func ResetCallbackConfirmOk(tradeId string) error { + return dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", tradeId). + Update("callback_confirm", mdb.CallBackConfirmOk).Error +} + +// GetActiveSubOrders returns all active sub-orders under a parent. +func GetActiveSubOrders(parentTradeId string) ([]mdb.Orders, error) { + var orders []mdb.Orders + err := dao.Mdb.Model(&mdb.Orders{}). + Where("parent_trade_id = ?", parentTradeId). + Where("status = ?", mdb.StatusWaitPay). + Find(&orders).Error + return orders, err +} + +// ExpireOrderByTradeId marks a single order as expired if still waiting. +func ExpireOrderByTradeId(tradeId string) error { + return dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", tradeId). + Where("status = ?", mdb.StatusWaitPay). + Update("status", mdb.StatusExpired).Error +} + // GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by network, address, token and amount. func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) { scaledAmount, _ := normalizeLockAmount(amount) diff --git a/src/model/mdb/orders_mdb.go b/src/model/mdb/orders_mdb.go index 5d262f5..9350b2c 100644 --- a/src/model/mdb/orders_mdb.go +++ b/src/model/mdb/orders_mdb.go @@ -8,9 +8,14 @@ const ( CallBackConfirmNo = 2 ) +const ( + PaymentTypeEpay = "Epay" +) + type Orders struct { TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"` - OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` + OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` // the order id is generated by client, and will notify client when order is paid + ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"` BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` Amount float64 `gorm:"column:amount" json:"amount"` Currency string `gorm:"column:currency" json:"currency"` @@ -21,8 +26,11 @@ type Orders struct { Status int `gorm:"column:status;default:1" json:"status"` NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` + Name string `gorm:"column:name" json:"name"` CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"` CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"` + IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected"` + PaymentType string `gorm:"column:payment_type" json:"payment_type"` BaseModel } diff --git a/src/model/mdb/wallet_address_mdb.go b/src/model/mdb/wallet_address_mdb.go index 310b58d..c9a7dc1 100644 --- a/src/model/mdb/wallet_address_mdb.go +++ b/src/model/mdb/wallet_address_mdb.go @@ -8,7 +8,7 @@ const ( const ( NetworkTron = "tron" NetworkSolana = "solana" - NetworkEthereum = "eth" + NetworkEthereum = "ethereum" NetworkBsc = "bsc" NetworkPolygon = "polygon" NetworkPlasma = "plasma" diff --git a/src/model/request/order_request.go b/src/model/request/order_request.go index f861088..b42e38e 100644 --- a/src/model/request/order_request.go +++ b/src/model/request/order_request.go @@ -12,6 +12,8 @@ type CreateTransactionRequest struct { NotifyUrl string `json:"notify_url" validate:"required"` Signature string `json:"signature" validate:"required"` RedirectUrl string `json:"redirect_url"` + Name string `json:"name"` + PaymentType string `json:"payment_type"` } func (r CreateTransactionRequest) Translates() map[string]string { @@ -36,3 +38,18 @@ type OrderProcessingRequest struct { TradeId string BlockTransactionId string } + +// SwitchNetworkRequest 切换支付网络 +type SwitchNetworkRequest struct { + TradeId string `json:"trade_id" validate:"required"` + Token string `json:"token" validate:"required"` + Network string `json:"network" validate:"required"` +} + +func (r SwitchNetworkRequest) Translates() map[string]string { + return validate.MS{ + "TradeId": "订单号", + "Token": "币种", + "Network": "网络", + } +} diff --git a/src/model/response/order_response.go b/src/model/response/order_response.go index a04902f..729ab7d 100644 --- a/src/model/response/order_response.go +++ b/src/model/response/order_response.go @@ -25,3 +25,16 @@ type OrderNotifyResponse struct { Signature string `json:"signature"` // 签名 Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期 } + +// OrderNotifyResponseEpay epay订单异步回调结构体 +type OrderNotifyResponseEpay struct { + PID int `json:"pid"` // 商户ID + TradeNo string `json:"trade_no"` // 平台订单号 + OutTradeNo string `json:"out_trade_no"` // 商户订单号 + Type string `json:"type"` // 订单类型 + Name string `json:"name"` // 商品名称 + Money string `json:"money"` // 订单金额,保留4位小数 + Sign string `json:"sign"` // 签名 + SignType string `json:"sign_type"` // 签名类型 // MD5 + TradeStatus string `json:"trade_status"` // 订单状态 // only has "TRADE_SUCCESS" for now +} diff --git a/src/model/response/pay_response.go b/src/model/response/pay_response.go index 6db0429..190194e 100644 --- a/src/model/response/pay_response.go +++ b/src/model/response/pay_response.go @@ -11,6 +11,7 @@ type CheckoutCounterResponse struct { ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳 RedirectUrl string `json:"redirect_url"` CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳 + IsSelected bool `json:"is_selected"` } type CheckStatusResponse struct { diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index 4a78c6c..27aced1 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -95,6 +95,8 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT Status: mdb.StatusWaitPay, NotifyUrl: req.NotifyUrl, RedirectUrl: req.RedirectUrl, + Name: req.Name, + PaymentType: req.PaymentType, } if err = data.CreateOrderWithTransaction(tx, order); err != nil { tx.Rollback() @@ -155,6 +157,69 @@ func OrderProcessing(req *request.OrderProcessingRequest) error { if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil { log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err) } + + // Load order to check parent-child relationship + order, err := data.GetOrderInfoByTradeId(req.TradeId) + if err != nil { + return nil + } + + // Parent order paid directly: expire all sub-orders and release their locks + if order.ParentTradeId == "" { + subs, subErr := data.GetActiveSubOrders(order.TradeId) + if subErr != nil { + log.Sugar.Errorf("[order] get sub-orders for parent failed, trade_id=%s, err=%v", order.TradeId, subErr) + return nil + } + for _, sub := range subs { + 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 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) + } + } + return nil + } + + // Sub-order should not trigger its own callback (notify_url is empty). + // OrderSuccessWithTransaction unconditionally sets callback_confirm=No, + // reset it here to prevent the callback worker from retrying an empty URL. + 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) + } + + parent, err := data.GetOrderInfoByTradeId(order.ParentTradeId) + if err != nil { + log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err) + return nil + } + + // Mark parent as paid with sub-order's payment details + if _, err = data.MarkParentOrderSuccess(parent.TradeId, order); err != nil { + log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, err) + } + + // Release parent's own wallet lock + 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) + } + + // Expire sibling sub-orders and release their locks + 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) + return nil + } + for _, sib := range siblings { + if err = data.ExpireOrderByTradeId(sib.TradeId); err != nil { + log.Sugar.Warnf("[order] expire sibling failed, trade_id=%s, err=%v", sib.TradeId, err) + } + 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) + } + } + return nil } @@ -212,3 +277,141 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { } return order, nil } + +const MaxSubOrders = 2 + +// SwitchNetwork creates or returns an existing sub-order for a different token+network. +func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) { + gCreateTransactionLock.Lock() + defer gCreateTransactionLock.Unlock() + + token := strings.ToUpper(strings.TrimSpace(req.Token)) + network := strings.ToLower(strings.TrimSpace(req.Network)) + + // 1. Load parent order + parent, err := data.GetOrderInfoByTradeId(req.TradeId) + if err != nil { + return nil, err + } + if parent.ID <= 0 { + return nil, constant.OrderNotExists + } + if parent.ParentTradeId != "" { + return nil, constant.CannotSwitchSubOrder + } + if parent.Status != mdb.StatusWaitPay { + return nil, constant.OrderNotWaitPay + } + + // 2. Same token+network as parent → mark selected and return + if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) { + _ = data.MarkOrderSelected(parent.TradeId) + parent.IsSelected = true + return buildCheckoutResponse(parent), nil + } + + // 3. Existing active sub-order for this token+network → return it + existing, err := data.GetSubOrderByTokenNetwork(parent.TradeId, token, network) + if err != nil { + return nil, err + } + if existing.ID > 0 { + _ = data.MarkOrderSelected(parent.TradeId) + _ = data.MarkOrderSelected(existing.TradeId) + _ = data.RefreshOrderExpiration(parent.TradeId) + existing.IsSelected = true + return buildCheckoutResponse(existing), nil + } + + // 4. Check sub-order limit + count, err := data.CountActiveSubOrders(parent.TradeId) + if err != nil { + return nil, err + } + if count >= MaxSubOrders { + return nil, constant.SubOrderLimitExceeded + } + + // 5. Calculate amount for the new network + rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency)) + if rate <= 0 { + return nil, constant.RateAmountErr + } + decimalPayAmount := decimal.NewFromFloat(parent.Amount) + decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate)) + if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 { + return nil, constant.PayAmountErr + } + + // 6. Find and lock wallet + walletAddress, err := data.GetAvailableWalletAddressByNetwork(network) + if err != nil { + return nil, err + } + if len(walletAddress) <= 0 { + return nil, constant.NotAvailableWalletAddress + } + + subTradeID := GenerateCode() + amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2) + availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(subTradeID, network, token, amount, walletAddress) + if err != nil { + return nil, err + } + if availableAddress == "" { + return nil, constant.NotAvailableAmountErr + } + + // 7. Create sub-order + tx := dao.Mdb.Begin() + subOrder := &mdb.Orders{ + TradeId: subTradeID, + OrderId: subTradeID, // sub-order uses its own trade_id as order_id (unique constraint) + ParentTradeId: parent.TradeId, + Amount: parent.Amount, + Currency: parent.Currency, + ActualAmount: availableAmount, + ReceiveAddress: availableAddress, + Token: token, + Network: network, + Status: mdb.StatusWaitPay, + IsSelected: true, + NotifyUrl: "", + RedirectUrl: parent.RedirectUrl, + Name: parent.Name, + CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order + PaymentType: parent.PaymentType, + } + if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil { + tx.Rollback() + _ = data.UnLockTransactionByTradeId(subTradeID) + return nil, err + } + if err = tx.Commit().Error; err != nil { + tx.Rollback() + _ = data.UnLockTransactionByTradeId(subTradeID) + return nil, err + } + + // Mark parent as selected and refresh its expiration to match the sub-order + _ = data.MarkOrderSelected(parent.TradeId) + _ = data.RefreshOrderExpiration(parent.TradeId) + + return buildCheckoutResponse(subOrder), nil +} + +func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse { + return &response.CheckoutCounterResponse{ + TradeId: order.TradeId, + Amount: order.Amount, + ActualAmount: order.ActualAmount, + Token: order.Token, + Currency: order.Currency, + ReceiveAddress: order.ReceiveAddress, + Network: order.Network, + ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(), + RedirectUrl: order.RedirectUrl, + CreatedAt: order.CreatedAt.TimestampMilli(), + IsSelected: order.IsSelected, + } +} diff --git a/src/model/service/pay_service.go b/src/model/service/pay_service.go index c1e54bf..011707d 100644 --- a/src/model/service/pay_service.go +++ b/src/model/service/pay_service.go @@ -32,6 +32,7 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(), RedirectUrl: orderInfo.RedirectUrl, CreatedAt: orderInfo.CreatedAt.TimestampMilli(), + IsSelected: orderInfo.IsSelected, } return resp, nil } diff --git a/src/mq/worker.go b/src/mq/worker.go index 9c11dea..3797c92 100644 --- a/src/mq/worker.go +++ b/src/mq/worker.go @@ -2,7 +2,10 @@ package mq import ( "errors" + "fmt" + "io" "net/http" + "net/url" "strings" "time" @@ -162,36 +165,86 @@ func processCallback(tradeID string) { } func sendOrderCallback(order *mdb.Orders) error { - client := http_client.GetHttpClient() - orderResp := response.OrderNotifyResponse{ - TradeId: order.TradeId, - OrderId: order.OrderId, - Amount: order.Amount, - ActualAmount: order.ActualAmount, - ReceiveAddress: order.ReceiveAddress, - Token: order.Token, - BlockTransactionId: order.BlockTransactionId, - Status: mdb.StatusPaySuccess, - } - signature, err := sign.Get(orderResp, config.GetApiAuthToken()) - if err != nil { - return err - } - orderResp.Signature = signature - resp, err := client.R(). - SetHeader("powered-by", "Epusdt(https://github.com/GMwalletApp/epusdt)"). - SetBody(orderResp). - Post(order.NotifyUrl) - if err != nil { - return err - } - if resp.StatusCode() != http.StatusOK { - return errors.New(resp.Status()) - } - if string(resp.Body()) != "ok" { - return errors.New("not ok") + switch order.PaymentType { + case mdb.PaymentTypeEpay: + // 构造 EPay 标准回调参数 + notifyData := response.OrderNotifyResponseEpay{ + PID: config.GetEpayPid(), + TradeNo: order.TradeId, // epusdt 订单号作为 EPay 平台订单号 + OutTradeNo: order.OrderId, // 注意:EPay 回调要求商户订单号使用 out_trade_no 参数 + + Type: "alipay", + Name: order.Name, + Money: fmt.Sprintf("%.4f", order.Amount), + TradeStatus: "TRADE_SUCCESS", + } + + signstr2, err := sign.Get(notifyData, config.GetEpayKey()) + if err != nil { + return err + } + + // 使用 form-encoded POST(EPay 标准协议格式) + formData := url.Values{ + "pid": {fmt.Sprintf("%d", notifyData.PID)}, + "trade_no": {notifyData.TradeNo}, + "out_trade_no": {notifyData.OutTradeNo}, + "type": {notifyData.Type}, + "name": {notifyData.Name}, + "money": {notifyData.Money}, + "trade_status": {notifyData.TradeStatus}, + "sign": {signstr2}, + "sign_type": {"MD5"}, + } + + resp, err := http.PostForm(order.NotifyUrl, formData) + if err != nil { + return err + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + fmt.Printf("notify_url response status: %d, body: %s\n", resp.StatusCode, string(responseBody)) + + default: + + client := http_client.GetHttpClient() + orderResp := response.OrderNotifyResponse{ + TradeId: order.TradeId, + OrderId: order.OrderId, + Amount: order.Amount, + ActualAmount: order.ActualAmount, + ReceiveAddress: order.ReceiveAddress, + Token: order.Token, + BlockTransactionId: order.BlockTransactionId, + Status: mdb.StatusPaySuccess, + } + signature, err := sign.Get(orderResp, config.GetApiAuthToken()) + if err != nil { + return err + } + orderResp.Signature = signature + + resp, err := client.R(). + SetHeader("powered-by", "Epusdt(https://github.com/GMwalletApp/epusdt)"). + SetBody(orderResp). + Post(order.NotifyUrl) + if err != nil { + return err + } + if resp.StatusCode() != http.StatusOK { + return errors.New(resp.Status()) + } + if string(resp.Body()) != "ok" { + return errors.New("not ok") + } } + return nil } diff --git a/src/route/router.go b/src/route/router.go index 4170df8..bad3e50 100644 --- a/src/route/router.go +++ b/src/route/router.go @@ -3,11 +3,17 @@ package route import ( "bytes" "encoding/json" + "fmt" "io" "net/http" + "strconv" + "github.com/assimon/luuu/config" "github.com/assimon/luuu/controller/comm" "github.com/assimon/luuu/middleware" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/util/constant" + "github.com/assimon/luuu/util/sign" "github.com/labstack/echo/v4" ) @@ -20,6 +26,7 @@ func RegisterRoute(e *echo.Echo) { payRoute := e.Group("/pay") payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter) payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus) + payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork) // payment routes paymentRoute := e.Group("/payments") @@ -49,6 +56,7 @@ func RegisterRoute(e *echo.Echo) { return comm.Ctrl.FailJson(ctx, err) } ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes)) + ctx.Request().ContentLength = int64(len(jsonBytes)) return comm.Ctrl.CreateTransaction(ctx) }, middleware.CheckApiSign()) @@ -70,4 +78,80 @@ func RegisterRoute(e *echo.Echo) { walletV1.GET("/:id", comm.Ctrl.GetWallet) walletV1.POST("/:id/status", comm.Ctrl.ChangeWalletStatus) walletV1.POST("/:id/delete", comm.Ctrl.DeleteWallet) + + // epay v1 routes + epayV1 := paymentRoute.Group("/epay/v1") + epayV1.Match([]string{http.MethodPost, http.MethodGet}, "/order/create-transaction/submit.php", func(ctx echo.Context) error { + params := make(map[string]interface{}) + for k, v := range ctx.QueryParams() { + params[k] = v[0] + } + + signstr := params["sign"].(string) + delete(params, "sign") + delete(params, "sign_type") + + // we need to add pid to params for signature verification + params["pid"] = config.GetEpayPid() + + checkSignature, err := sign.Get(params, config.GetApiAuthToken()) + if err != nil { + return constant.SignatureErr + } + if checkSignature != signstr { + return constant.SignatureErr + } + + // safely get string value from map + getString := func(m map[string]interface{}, key string) string { + v, ok := m[key] + if !ok { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return s + } + + money := getString(params, "money") + name := getString(params, "name") + notifyURL := getString(params, "notify_url") + outTradeNo := getString(params, "out_trade_no") + returnURL := getString(params, "return_url") + + amountFloat, err := strconv.ParseFloat(money, 64) + if err != nil { + return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid money value: %s", money)) + } + + body := map[string]interface{}{ + "token": "usdt", + "currency": "cny", + "network": "tron", + "amount": amountFloat, + "notify_url": notifyURL, + "order_id": outTradeNo, + "redirect_url": returnURL, + "signature": signstr, + "name": name, + "payment_type": mdb.PaymentTypeEpay, + } + + ctx.Set("request_body", body) + + jsonBytes, err := json.Marshal(body) + if err != nil { + return comm.Ctrl.FailJson(ctx, err) + } + + ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes)) + ctx.Request().ContentLength = int64(len(jsonBytes)) + ctx.Request().Method = http.MethodPost + ctx.Request().Header.Set("Content-Type", "application/json") + + return comm.Ctrl.CreateTransactionAndRedirect(ctx) + + }) } diff --git a/src/static/index.html b/src/static/index.html index 2b4e4e1..71d00f9 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -1,5 +1,6 @@ +
@@ -13,8 +14,8 @@ @variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); @theme inline { - --font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif; - --font-nunito: "Nunito Variable", "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif; + --font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif; + --font-nunito: "Nunito Variable", "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif; --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -23,249 +24,524 @@ --color-popover-foreground:var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground:var(--primary-foreground); - --color-primary-hover: var(--primary-hover); --color-secondary: var(--secondary); + --color-secondary-foreground:var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); --color-border: var(--border); - --color-border-hover: var(--border-hover); + --color-input: var(--input); + --color-ring: var(--ring); --color-success: var(--success); --color-warning: var(--warning); - --shadow-card: var(--card-shadow); - --shadow-popover: var(--popover-shadow); --animate-state-in: state-in .28s cubic-bezier(.34,1.56,.64,1); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + } + + /* ── Design tokens ── */ + :root, [data-theme="light"] { + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground:oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground:oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground:oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --radius: 0.875rem; + --success: oklch(0.721 0.193 143.56); + --warning: oklch(0.741 0.188 55.68); + } + [data-theme="dark"] { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground:oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground:oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground:oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --success: oklch(0.747 0.201 143.5); + --warning: oklch(0.762 0.182 61.47); + } + + /* ── Ambient blobs ── */ + @keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } + } + + @keyframes fill-bar { + from { width: 0%; } + to { width: 100%; } + } + + @keyframes slide-out-l { + from { transform: translateX(0); } + to { transform: translateX(calc(-100% - 20px)); } + } + @keyframes slide-out-r { + from { transform: translateX(0); } + to { transform: translateX(calc(100% + 20px)); } + } + @keyframes slide-in-r { + from { transform: translateX(calc(100% + 20px)); } + to { transform: translateX(0); } + } + @keyframes slide-in-l { + from { transform: translateX(calc(-100% - 20px)); } + to { transform: translateX(0); } + } + + @keyframes blob-drift { + 0% { transform: translate(0,0) scale(1); } + 50% { transform: translate(30px,-20px) scale(1.06); } + 100% { transform: translate(-20px,30px) scale(0.96); } + } + + @layer components { + /* Card */ + .card { + @apply bg-card border border-border rounded-2xl shadow-md transition-colors duration-300; + } + + /* Chip / pill button */ + .chip { + @apply bg-card border border-border rounded-full px-3.5 py-1.5 text-card-foreground shadow-xs transition-all; + } + .chip:hover { @apply bg-accent border-ring; } + .chip:active { @apply opacity-60; } + .chip.w-9 { @apply p-0; } + + /* Copy rows */ + .row { + @apply flex items-center px-4 py-3.5 transition-colors; + } + .row:active { @apply bg-muted; } + + /* Icon button */ + .icon-btn { + @apply flex items-center justify-center size-8 rounded-sm text-muted-foreground bg-secondary border border-transparent shrink-0 transition-colors; + } + .icon-btn:hover { @apply bg-accent text-card-foreground border-border; } + + /* Primary button */ + .btn-primary { + @apply flex items-center justify-center bg-primary text-primary-foreground border border-border rounded-xl px-6 py-3 text-base font-semibold tracking-tight cursor-pointer shadow-md transition-[background,border-color,transform,opacity]; + } + .btn-primary:hover { @apply bg-primary/90; } + .btn-primary:active { @apply scale-[0.97] opacity-90; } + .btn-primary:disabled { @apply opacity-45 cursor-not-allowed scale-100; } + + /* Secondary button */ + .btn-secondary { + @apply inline-flex items-center justify-center bg-card text-card-foreground border border-border rounded-xl px-7 py-3 text-base font-medium cursor-pointer shadow-sm transition-all; + } + .btn-secondary:hover { @apply bg-accent border-ring; } + .btn-secondary:active { @apply opacity-70; } + + /* State icon circle */ + .state-icon { + @apply size-20 rounded-full flex items-center justify-center mx-auto; + } + + /* Dropdown menu */ + .menu { + @apply bg-popover border border-border rounded-xl shadow-xl overflow-hidden; + opacity: 0; + transform: translateY(-8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + } + .menu--up { transform: translateY(8px); } + .menu.is-open { opacity: 1; transform: translateY(0); pointer-events: all; } + .menu-item { + @apply flex items-center gap-2.5 px-4 py-2.5 text-sm font-medium text-card-foreground cursor-pointer transition-colors; + } + .menu-item:hover { @apply bg-accent; } + .menu-item.is-selected { @apply text-primary font-semibold; } + .menu-item + .menu-item { @apply border-t border-border; } + .select-trigger.is-open { opacity: 0.8; } + .select-trigger.is-open .select-chevron { transform: rotate(180deg); } + + /* QR wrapper */ + .qr-wrapper { + @apply bg-white rounded-xl p-3.5 border border-border shadow-md inline-block; + } + + /* Timer SVG rings */ + .timer-ring-track { stroke: var(--border); transition: stroke .3s; } + .timer-ring-progress { transform: rotate(-90deg); transform-origin: 50% 50%; transition: stroke-dashoffset 1s linear, stroke .5s; } + .timer-icon { @apply text-muted-foreground; } + + /* Flippable status card */ + .status-card { + position: relative; + perspective: 1600px; + transition: height 0.38s cubic-bezier(.22,1,.36,1); + } + .status-card-inner { + position: relative; width: 100%; + transform-style: preserve-3d; + transition: transform 0.82s cubic-bezier(.22,.9,.24,1); + } + .status-card.is-flipped .status-card-inner { transform: rotateY(180deg); } + .status-face { + @apply absolute inset-0 w-full; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + transform-style: preserve-3d; + } + .status-face-front { z-index: 2; } + .status-face-back { transform: rotateY(180deg); } + .state-panel-shell { @apply pt-px; } + .state-screen { @apply hidden; } + .state-screen.is-active { @apply flex; } + } + + /* ── Panel viewport: hide scrollbars ── */ + #panel-viewport { scrollbar-width: none; } + #panel-viewport::-webkit-scrollbar { display: none; } + + /* ── Toast ── */ + #toast { + @apply fixed left-1/2 bottom-9 text-sm font-medium px-5 py-2.5 rounded-xl border border-border shadow-xl opacity-0 pointer-events-none whitespace-nowrap z-9999; + @apply bg-foreground/95 text-background; + @apply dark:bg-card/95 dark:text-card-foreground; + transform: translateX(-50%) translateY(12px); + transition: opacity 0.22s, transform 0.22s; + } + + @keyframes state-in { + from { opacity: 0; transform: translateY(12px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + + ::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; } + ::view-transition-old(root) { z-index: 1; } + ::view-transition-new(root) { z-index: 9; } + + @media (prefers-reduced-motion: reduce) { + .status-card, .status-card-inner, .status-face, .timer-ring-progress { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } } - - - - - - -Amount to pay
-Scan or copy address to pay
---:--
-Amount to pay
---
-Payment address
---
-Payment Successful
-Redirecting…
- -Payment Expired
-Please initiate a new payment
-Connection Timeout
-Unable to connect to the payment server
-Order Not Found
-The order does not exist or has already expired
-Network
+Currency
+