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; + } } - - - - - - -
+ -
+
- logo - GM Pay + logo + GM Pay
-
- EN - +
+ EN +
-
-
🇺🇸  English
-
🇨🇳  中文
-
🇯🇵  日本語
-
🇰🇷  한국어
-
🇭🇰  繁體中文
-
🇷🇺  Русский
+
- -
-
- - -
-

Amount to pay

-
- -- - -
-
--
-
- - - - - -
-
-
- - -
-
-
-
- - -
- -
-

Scan or copy address to pay

-
- - - - - -
- -
-
-
- -

--:--

-
-
-
-
-
-
- - - - -
- -
-
-

Amount to pay

-

--

-
- - - -
- -
- -
-
-

Payment address

-

--

-
- - - -
-
- - - - - -
- - Checking blockchain -
- + + - -
-
-
- - -
-
- -
-

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

+
+
+ + +
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + +
+ +
-
+ +
Copied
- -
Copied
- - - - + + + - + + \ No newline at end of file diff --git a/src/static/payment.css b/src/static/payment.css deleted file mode 100644 index 67e1b4b..0000000 --- a/src/static/payment.css +++ /dev/null @@ -1,395 +0,0 @@ -/* ============================================= - iOS 26 Liquid Glass Design System - ============================================= */ - -/* ---------- CSS Variables ---------- */ -:root, [data-theme="light"] { - --background: #f2f2f7; - --foreground: #1c1c1e; - --card: rgba(255,255,255,0.72); - --card-foreground: #1c1c1e; - --popover: rgba(255,255,255,0.88); - --popover-foreground:#1c1c1e; - --primary: #ffffff; - --primary-foreground:#1c1c1e; - --primary-hover: #e5e5ea; - --secondary: rgba(120,120,128,0.12); - --muted: rgba(120,120,128,0.08); - --muted-foreground: #8e8e93; - --accent: rgba(255,255,255,0.12); - --accent-foreground: #1c1c1e; - --border: rgba(60,60,67,0.12); - --border-hover: rgba(60,60,67,0.22); - --success: #34c759; - --warning: #ff9500; - --destructive: #ff3b30; - - --glass-bg: rgba(255,255,255,0.65); - --glass-border: rgba(255,255,255,0.8); - --glass-shadow: 0 8px 40px rgba(0,0,0,.10), 0 1.5px 0 rgba(255,255,255,.9) inset; - --card-shadow: 0 4px 24px rgba(0,0,0,.08); - --popover-shadow: 0 12px 40px rgba(0,0,0,.14); - - --blob-1: rgba(200,200,200,0.18); - --blob-2: rgba(52,199,89,0.14); - --blob-3: rgba(255,149,0,0.10); -} - -[data-theme="dark"] { - --background: #000000; - --foreground: #f5f5f7; - --card: rgba(28,28,30,0.82); - --card-foreground: #f5f5f7; - --popover: rgba(44,44,46,0.92); - --popover-foreground:#e5e5ea; - --primary: #ffffff; - --primary-foreground:#1c1c1e; - --primary-hover: #e5e5ea; - --secondary: rgba(120,120,128,0.18); - --muted: rgba(120,120,128,0.12); - --muted-foreground: #636366; - --accent: rgba(255,255,255,0.15); - --accent-foreground: #ffffff; - --border: rgba(255,255,255,0.1); - --border-hover: rgba(255,255,255,0.18); - --success: #30d158; - --warning: #ff9f0a; - --destructive: #ff453a; - - --glass-bg: rgba(28,28,30,0.75); - --glass-border: rgba(255,255,255,0.10); - --glass-shadow: 0 8px 40px rgba(0,0,0,.55), 0 1px 0 rgba(255,255,255,.06) inset; - --card-shadow: 0 4px 32px rgba(0,0,0,.55); - --popover-shadow: 0 12px 40px rgba(0,0,0,.5); - - --blob-1: rgba(255,255,255,0.14); - --blob-2: rgba(48,209,88,0.10); - --blob-3: rgba(255,159,10,0.08); -} - -/* ---------- Ambient blobs ---------- */ -.blob { - position: absolute; - border-radius: 50%; - filter: blur(80px); - animation: blob-drift 18s ease-in-out infinite alternate; -} -.blob-1 { - width: 420px; height: 420px; - background: var(--blob-1); - top: -120px; left: -100px; - animation-delay: 0s; -} -.blob-2 { - width: 340px; height: 340px; - background: var(--blob-2); - bottom: -80px; right: -60px; - animation-delay: -6s; -} -.blob-3 { - width: 260px; height: 260px; - background: var(--blob-3); - top: 45%; left: 55%; - animation-delay: -12s; -} -@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); } -} - -/* ---------- Liquid Glass Card ---------- */ -.glass-card { - background: var(--glass-bg); - -webkit-backdrop-filter: blur(28px) saturate(1.6); - backdrop-filter: blur(28px) saturate(1.6); - border: 1px solid var(--glass-border); - border-radius: 22px; - box-shadow: var(--glass-shadow); - transition: background 0.3s, border-color 0.3s, box-shadow 0.3s; -} - -/* ---------- 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 { - position: absolute; - inset: 0; - width: 100%; - -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 { - padding-top: 2px; -} -.state-screen { - display: none; -} -.state-screen.is-active { - display: flex; -} - -/* ---------- Timer ---------- */ -.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 { - color: var(--muted-foreground); -} - -/* ---------- QR wrapper ---------- */ -.qr-wrapper { - background: #ffffff; - border-radius: 18px; - padding: 14px; - box-shadow: 0 2px 16px rgba(0,0,0,.12), 0 0 0 1px rgba(0,0,0,.04); - display: inline-block; -} - -/* ---------- iOS Pill button ---------- */ -.ios-pill { - background: var(--glass-bg); - -webkit-backdrop-filter: blur(16px) saturate(1.4); - backdrop-filter: blur(16px) saturate(1.4); - border: 1px solid var(--glass-border); - border-radius: 99px; - padding: 6px 13px; - color: var(--card-foreground); - transition: background 0.15s, opacity 0.15s; -} -.ios-pill:hover { opacity: 0.82; } -.ios-pill:active { opacity: 0.6; } -.ios-pill.w-9 { padding: 0; justify-content: center; } - -/* ---------- iOS Rows ---------- */ -.ios-row { - display: flex; - align-items: center; - padding: 14px 16px; - transition: background 0.12s; -} -.ios-row:active { background: var(--muted); } - -/* ---------- Icon button ---------- */ -.ios-icon-btn { - display: flex; - align-items: center; - justify-content: center; - width: 32px; height: 32px; - border-radius: 10px; - color: var(--muted-foreground); - background: var(--secondary); - transition: background 0.15s, color 0.15s; - flex-shrink: 0; -} -.ios-icon-btn:hover { background: var(--accent); color: var(--primary); } - -/* ---------- Primary CTA button ---------- */ -.ios-btn-primary { - display: flex; - align-items: center; - justify-content: center; - background: var(--primary); - color: var(--primary-foreground); - border: none; - border-radius: 16px; - padding: 16px 24px; - font-size: 15px; - font-weight: 600; - letter-spacing: -0.2px; - cursor: pointer; - transition: background 0.15s, transform 0.1s, opacity 0.15s; - box-shadow: 0 4px 18px rgba(0,0,0,0.12), 0 1px 0 rgba(255,255,255,.2) inset; -} -[data-theme="dark"] .ios-btn-primary { - box-shadow: 0 4px 18px rgba(255,255,255,0.18), 0 1px 0 rgba(255,255,255,.12) inset; -} -.ios-btn-primary:hover { background: var(--primary-hover); } -.ios-btn-primary:active { transform: scale(0.97); opacity: 0.9; } -.ios-btn-primary:disabled { opacity: 0.45; cursor: not-allowed; transform: none; } - -/* ---------- Secondary button ---------- */ -.ios-btn-secondary { - display: inline-flex; - align-items: center; - justify-content: center; - background: var(--secondary); - color: var(--card-foreground); - border: 1px solid var(--border); - border-radius: 13px; - padding: 11px 28px; - font-size: 15px; - font-weight: 500; - cursor: pointer; - transition: background 0.15s, opacity 0.15s; -} -.ios-btn-secondary:hover { background: var(--accent); } -.ios-btn-secondary:active { opacity: 0.7; } - -/* ---------- State icon circle ---------- */ -.ios-state-icon { - width: 76px; height: 76px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - margin: 0 auto; -} - -/* ---------- Context Menu ---------- */ -.ios-menu { - background: var(--popover); - -webkit-backdrop-filter: blur(30px) saturate(1.8); - backdrop-filter: blur(30px) saturate(1.8); - border: 1px solid var(--glass-border); - border-radius: 16px; - box-shadow: var(--popover-shadow); - overflow: hidden; -} -.ios-menu.is-open { - opacity: 1; - transform: translateY(0); - pointer-events: all; -} -.ios-menu-item { - display: flex; - align-items: center; - gap: 10px; - padding: 11px 15px; - font-size: 14px; - font-weight: 500; - color: var(--card-foreground); - cursor: pointer; - transition: background 0.1s; -} -.ios-menu-item:hover { background: var(--accent); } -.ios-menu-item.is-selected { color: var(--primary); font-weight: 600; } -.ios-menu-item + .ios-menu-item { border-top: 1px solid var(--border); } - -/* ---------- Select states ---------- */ -.select-trigger.is-open { opacity: 0.8; } -.select-trigger.is-open .select-chevron { transform: rotate(180deg); } - -/* ---------- Toast ---------- */ -#toast { - position: fixed; - bottom: 36px; - left: 50%; - transform: translateX(-50%) translateY(12px); - background: rgba(28,28,30,0.88); - -webkit-backdrop-filter: blur(20px); - backdrop-filter: blur(20px); - color: #fff; - font-size: 13px; - font-weight: 500; - padding: 10px 20px; - border-radius: 20px; - border: 1px solid rgba(255,255,255,0.12); - box-shadow: 0 4px 20px rgba(0,0,0,.3); - opacity: 0; - pointer-events: none; - transition: opacity 0.22s, transform 0.22s; - z-index: 9999; - white-space: nowrap; -} -[data-theme="light"] #toast { - background: rgba(44,44,46,0.9); -} - -/* ---------- Animations ---------- */ -@keyframes state-in { - from { opacity: 0; transform: translateY(12px) scale(0.96); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -/* ---------- View Transition ---------- */ -::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; } - -/* ---------- Debug toolbar ---------- */ -#debug-bar { - position: fixed; bottom: 20px; right: 20px; z-index: 9999; - display: flex; flex-direction: column; align-items: flex-end; gap: 8px; -} -#debug-toggle { - width: 36px; height: 36px; border-radius: 50%; border: none; - background: #7c3aed; color: #fff; cursor: pointer; - display: flex; align-items: center; justify-content: center; - box-shadow: 0 2px 12px rgba(124,58,237,.5); - transition: background .15s, transform .1s; -} -#debug-toggle:hover { background: #6d28d9; } -#debug-toggle:active { transform: scale(.92); } -#debug-panel { - display: none; flex-direction: column; gap: 5px; - background: rgba(15,15,20,.92); - -webkit-backdrop-filter: blur(10px); - backdrop-filter: blur(10px); - border: 1px solid rgba(124,58,237,.35); border-radius: 14px; - padding: 12px; min-width: 150px; - box-shadow: 0 8px 32px rgba(0,0,0,.5); -} -#debug-panel.is-open { display: flex; } -.debug-label { - font-size: 10px; font-weight: 700; color: #a78bfa; - letter-spacing: .12em; text-transform: uppercase; - margin-bottom: 4px; padding-bottom: 6px; - border-bottom: 1px solid rgba(255,255,255,.08); -} -.debug-btn { - background: rgba(255,255,255,.07); color: #d1d5db; - border: 1px solid rgba(255,255,255,.1); border-radius: 8px; - padding: 6px 10px; font-size: 12px; font-weight: 500; - cursor: pointer; text-align: left; font-family: inherit; - transition: background .12s, color .12s; -} -.debug-btn:hover { background: rgba(255,255,255,.14); color: #fff; } -.debug-btn--reset:hover { border-color: #60a5fa; color: #93c5fd; } -.debug-btn--success:hover { border-color: #34d399; color: #6ee7b7; } -.debug-btn--expired:hover { border-color: #f87171; color: #fca5a5; } -.debug-btn--timeout:hover { border-color: #fb923c; color: #fdba74; } -.debug-btn--urgent:hover { border-color: #facc15; color: #fde68a; } -.debug-btn--not-found { - background: rgba(142,142,147,.15); - color: #8e8e93; -} - -@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; - } -} diff --git a/src/static/payment.js b/src/static/payment.js index 8e2b252..ab4eee3 100644 --- a/src/static/payment.js +++ b/src/static/payment.js @@ -1,24 +1,86 @@ +/* ============================================================= + * GM Pay — payment.js + * 后端对接只需关注: + * 1. CONFIG.api.selectMethod — Step 1 确认接口(POST 币种/网络,返回收款地址) + * 2. CONFIG.api.checkStatus — 轮询接口 URL + * 3. CONFIG.api.statusMap — 状态码映射 + * 4. index.html 底部