feat: integrate okpay switch-network flow and notify handling

This commit is contained in:
mcmc
2026-05-09 18:06:27 +08:00
parent a4022df7b4
commit 377176a0f2
209 changed files with 1842 additions and 304 deletions
+11
View File
@@ -6,6 +6,7 @@ import (
"strings"
"sync"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/gookit/color"
"gorm.io/gorm/clause"
@@ -35,6 +36,7 @@ func MdbTableInit() {
{"Chain", &mdb.Chain{}},
{"ChainToken", &mdb.ChainToken{}},
{"RpcNode", &mdb.RpcNode{}},
{"ProviderOrder", &mdb.ProviderOrder{}},
}
for _, m := range migrations {
if err := Mdb.AutoMigrate(m.model); err != nil {
@@ -132,10 +134,19 @@ func seedRpcNodes() {
// seedDefaultSettings inserts built-in default settings.
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
func seedDefaultSettings() {
okPayCallbackURL := strings.TrimRight(strings.TrimSpace(config.GetAppUri()), "/")
if okPayCallbackURL != "" {
okPayCallbackURL += "/payments/okpay/v1/notify"
}
defaults := []mdb.Setting{
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayEnabled, Value: "false", Type: mdb.SettingTypeBool},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAPIURL, Value: "https://api.okaypay.me/shop/", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayCallbackURL, Value: okPayCallbackURL, Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayTimeoutSeconds, Value: "10", Type: mdb.SettingTypeInt},
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAllowTokens, Value: "USDT,TRX", Type: mdb.SettingTypeString},
}
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
color.Red.Printf("[store_db] seed default settings err=%s\n", err)
+12
View File
@@ -202,6 +202,18 @@ func MarkOrderSelected(tradeId string) error {
Update("is_selected", true).Error
}
// ExpireOrderByTradeID marks a waiting order as expired. Used to retire failed
// child-order attempts that should not remain selectable/reusable.
func ExpireOrderByTradeID(tradeId string) error {
return dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeId).
Where("status = ?", mdb.StatusWaitPay).
Updates(map[string]interface{}{
"status": mdb.StatusExpired,
"is_selected": false,
}).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 {
+76
View File
@@ -0,0 +1,76 @@
package data
import (
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm"
)
func GetProviderOrderByTradeIDAndProvider(tradeID string, provider string) (*mdb.ProviderOrder, error) {
row := new(mdb.ProviderOrder)
err := dao.Mdb.Model(row).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Limit(1).
Find(row).Error
return row, err
}
func CreateProviderOrderWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) error {
return tx.Model(row).Create(row).Error
}
func UpdateProviderOrderCreated(tradeID string, provider string, providerOrderID string, payURL string) error {
return dao.Mdb.Model(&mdb.ProviderOrder{}).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Updates(map[string]interface{}{
"provider_order_id": providerOrderID,
"pay_url": payURL,
"status": mdb.ProviderOrderStatusPending,
}).Error
}
func MarkProviderOrderFailed(tradeID string, provider string) error {
return dao.Mdb.Model(&mdb.ProviderOrder{}).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Update("status", mdb.ProviderOrderStatusFailed).Error
}
func SaveProviderOrderNotify(tradeID string, provider string, notifyRaw string) error {
return dao.Mdb.Model(&mdb.ProviderOrder{}).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Update("notify_raw", notifyRaw).Error
}
func MarkProviderOrderPaid(tradeID string, provider string, notifyRaw string) error {
return dao.Mdb.Model(&mdb.ProviderOrder{}).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Updates(map[string]interface{}{
"status": mdb.ProviderOrderStatusPaid,
"notify_raw": notifyRaw,
}).Error
}
func MarkProviderOrderExpired(tradeID string, provider string) error {
return dao.Mdb.Model(&mdb.ProviderOrder{}).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Where("status IN ?", []string{mdb.ProviderOrderStatusCreating, mdb.ProviderOrderStatusPending}).
Update("status", mdb.ProviderOrderStatusExpired).Error
}
func GetSubOrderByTokenPayProvider(parentTradeID string, token string, payProvider string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := dao.Mdb.Model(order).
Where("parent_trade_id = ?", parentTradeID).
Where("token = ?", token).
Where("pay_provider = ?", payProvider).
Where("status = ?", mdb.StatusWaitPay).
Limit(1).
Find(order).Error
return order, err
}
+58
View File
@@ -5,6 +5,7 @@ import (
"strings"
"sync"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm/clause"
@@ -157,6 +158,63 @@ var sensitiveSettingKeys = []string{
mdb.SettingKeyInitAdminPasswordChanged,
}
// OkPay settings helpers keep the provider-specific defaults in one place so
// business logic does not need to repeat raw settings keys.
func GetOkPayEnabled() bool {
return GetSettingBool(mdb.SettingKeyOkPayEnabled, false)
}
func GetOkPayShopID() string {
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayShopID, ""))
}
func GetOkPayShopToken() string {
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayShopToken, ""))
}
func GetOkPayAPIURL() string {
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayAPIURL, "https://api.okaypay.me/shop/"))
}
func GetOkPayCallbackURL() string {
if configured := strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayCallbackURL, "")); configured != "" {
return configured
}
appURI := strings.TrimSpace(config.GetAppUri())
if appURI == "" {
return ""
}
return strings.TrimRight(appURI, "/") + "/payments/okpay/v1/notify"
}
func GetOkPayReturnURL() string {
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayReturnURL, ""))
}
func GetOkPayTimeoutSeconds() int {
return GetSettingInt(mdb.SettingKeyOkPayTimeoutSeconds, 10)
}
func GetOkPayAllowTokens() []string {
raw := GetSettingString(mdb.SettingKeyOkPayAllowTokens, "USDT,TRX")
if strings.TrimSpace(raw) == "" {
return []string{"USDT", "TRX"}
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
token := strings.ToUpper(strings.TrimSpace(part))
if token == "" {
continue
}
out = append(out, token)
}
if len(out) == 0 {
return []string{"USDT", "TRX"}
}
return out
}
// ListSettingsByGroup returns all rows for a given group (empty group = all),
// excluding any keys in sensitiveSettingKeys.
func ListSettingsByGroup(group string) ([]mdb.Setting, error) {
+24 -1
View File
@@ -12,6 +12,19 @@ const (
PaymentTypeEpay = "Epay"
)
const (
// PaymentProviderOnChain means this concrete order record is settled by
// sending funds to a directly assigned wallet address on a supported chain.
PaymentProviderOnChain = "on_chain"
// PaymentProviderOkPay means this concrete order record is settled through
// the third-party OkayPay/OkPay hosted checkout flow.
//
// In the current design this is typically used by a switch-network-created
// child order, while the parent order keeps its original merchant-facing
// semantics and callback behavior.
PaymentProviderOkPay = "okpay"
)
type Orders struct {
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"T2026041612345678"`
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
@@ -33,7 +46,17 @@ type Orders struct {
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
ApiKeyID uint64 `gorm:"column:api_key_id;default:0;index:orders_api_key_id_index" json:"api_key_id" example:"1"`
// PayProvider identifies how this specific order row is collected.
//
// Semantics:
// - parent orders and regular chain child orders use on_chain
// - third-party hosted checkout child orders use their provider name
// (for example okpay)
//
// Existing rows default to on_chain for backward compatibility so upgrades
// can rely on AutoMigrate without rewriting old orders.
PayProvider string `gorm:"column:pay_provider;size:32;default:on_chain;index:orders_pay_provider_index" json:"pay_provider" example:"on_chain"`
ApiKeyID uint64 `gorm:"column:api_key_id;default:0;index:orders_api_key_id_index" json:"api_key_id" example:"1"`
// PayBySubId holds the primary-key ID of the sub-order that settled this parent order.
// Zero when the parent order was paid directly (no sub-order involved).
PayBySubId uint64 `gorm:"column:pay_by_sub_id;default:0" json:"pay_by_sub_id" example:"0"`
+48
View File
@@ -0,0 +1,48 @@
package mdb
const (
ProviderOrderStatusCreating = "creating"
ProviderOrderStatusPending = "pending"
ProviderOrderStatusPaid = "paid"
ProviderOrderStatusFailed = "failed"
ProviderOrderStatusExpired = "expired"
)
// ProviderOrder stores provider-specific checkout data for one internal order
// row. For OkPay this is expected to bind to the concrete child order created
// by switch-network, not to the parent merchant order.
//
// Binding rules:
// - trade_id always points to orders.trade_id
// - the bound orders row should use the same pay_provider value
// - provider identifies which downstream checkout created the provider row
// - provider_order_id is the provider's own order number (for OkPay this is
// the returned order_id from /payLink)
// - pay_url is the hosted payment URL returned by the provider
//
// The main orders table remains the source of truth for merchant-facing state,
// while this table keeps provider-specific identifiers and callback payloads.
type ProviderOrder struct {
TradeId string `gorm:"column:trade_id;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:1;index:provider_orders_trade_id_index" json:"trade_id" example:"T2026041612345678"`
// Provider values should match the bound Orders.PayProvider value.
Provider string `gorm:"column:provider;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:2;index:provider_orders_provider_status_index,priority:1" json:"provider" example:"okpay"`
// ProviderOrderID is the downstream provider's own order identifier for the
// bound concrete order row.
ProviderOrderID string `gorm:"column:provider_order_id;size:128;not null;default:'';index:provider_orders_provider_order_id_index" json:"provider_order_id" example:"ac7b86615fdb137576ae35879f7ed844"`
// PayURL is the hosted checkout URL returned by the downstream provider for
// the bound concrete order row.
PayURL string `gorm:"column:pay_url;size:512;not null;default:''" json:"pay_url" example:"https://pay.example.com/checkout/abc123"`
// Amount/Coin mirror the exact payload submitted to the provider, which may
// be useful when validating callbacks and troubleshooting mismatches.
Amount float64 `gorm:"column:amount" json:"amount" example:"14.2857"`
Coin string `gorm:"column:coin;size:16;not null;default:''" json:"coin" example:"USDT"`
Status string `gorm:"column:status;size:32;not null;default:pending;index:provider_orders_provider_status_index,priority:2" json:"status" example:"pending"`
// NotifyRaw stores the original provider callback payload for auditing and
// replay/debug use. It is provider-specific and intentionally opaque here.
NotifyRaw string `gorm:"column:notify_raw;type:text" json:"notify_raw"`
BaseModel
}
func (p *ProviderOrder) TableName() string {
return "provider_orders"
}
+16 -4
View File
@@ -1,9 +1,10 @@
package mdb
// Setting stores non-credential runtime configuration as key/value pairs.
// Groups: brand, rate, system, epay. Merchant credentials (pid + secret_key)
// live in the api_keys table; notification configs live in NotificationChannel.
// Default rows for the epay group are seeded on first startup (see
// Groups: brand, rate, system, epay, okpay. Merchant credentials (pid +
// secret_key) live in the api_keys table; notification configs live in
// NotificationChannel.
// Default rows for the epay/okpay groups are seeded on first startup (see
// model/dao/mdb_table_init.go seedDefaultSettings). All other groups start
// empty and fall back to hardcoded defaults until an admin sets them.
// Only exception: system.jwt_secret is auto-generated on first startup.
@@ -12,6 +13,7 @@ const (
SettingGroupRate = "rate"
SettingGroupSystem = "system"
SettingGroupEpay = "epay"
SettingGroupOkPay = "okpay"
)
const (
@@ -42,10 +44,20 @@ const (
SettingKeyEpayDefaultToken = "epay.default_token"
SettingKeyEpayDefaultCurrency = "epay.default_currency"
SettingKeyEpayDefaultNetwork = "epay.default_network"
// OkPay hosted-checkout settings.
SettingKeyOkPayEnabled = "okpay.enabled"
SettingKeyOkPayShopID = "okpay.shop_id"
SettingKeyOkPayShopToken = "okpay.shop_token"
SettingKeyOkPayAPIURL = "okpay.api_url"
SettingKeyOkPayCallbackURL = "okpay.callback_url"
SettingKeyOkPayReturnURL = "okpay.return_url"
SettingKeyOkPayTimeoutSeconds = "okpay.timeout_seconds"
SettingKeyOkPayAllowTokens = "okpay.allow_tokens"
)
type Setting struct {
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system" example:"rate"`
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system,epay,okpay" example:"rate"`
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_usdt_rate"`
Value string `gorm:"column:value;type:text" json:"value" example:"7.2"`
Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"string"`
+1 -1
View File
@@ -43,7 +43,7 @@ type OrderProcessingRequest struct {
type SwitchNetworkRequest struct {
TradeId string `json:"trade_id" validate:"required" example:"T2026041612345678"`
Token string `json:"token" validate:"required" example:"USDT"`
Network string `json:"network" validate:"required" example:"ethereum"`
Network string `json:"network" validate:"required" example:"okpay,tron,solana,ethereum"`
}
func (r SwitchNetworkRequest) Translates() map[string]string {
+9 -8
View File
@@ -1,16 +1,17 @@
package response
type CheckoutCounterResponse struct {
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数 法币金额
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数 法币金额
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 支付链接;链上订单通常为本地收银台,OkPay 子订单为第三方 payLink
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
IsSelected bool `json:"is_selected" example:"false"`
}
+21 -2
View File
@@ -5,6 +5,25 @@ type NetworkTokenSupport struct {
Tokens []string `json:"tokens" example:"USDT,USDC"`
}
type SupportedAssetsResponse struct {
Supports []NetworkTokenSupport `json:"supports"`
type EpayPublicConfig struct {
DefaultToken string `json:"default_token" example:"usdt"`
DefaultCurrency string `json:"default_currency" example:"cny"`
DefaultNetwork string `json:"default_network" example:"tron"`
}
type OkPayPublicConfig struct {
Enabled bool `json:"enabled" example:"true"`
AllowTokens []string `json:"allow_tokens" example:"USDT,TRX"`
ShopID string `json:"shop_id,omitempty" example:"okpay-shop-test"`
ShopToken string `json:"shop_token,omitempty" example:"secret-token"`
APIURL string `json:"api_url,omitempty" example:"https://api.okaypay.me/shop/"`
CallbackURL string `json:"callback_url,omitempty" example:"https://pay.example.com/payments/okpay/v1/notify"`
ReturnURL string `json:"return_url,omitempty" example:"https://pay.example.com/success"`
TimeoutSeconds int `json:"timeout_seconds,omitempty" example:"10"`
}
type PublicConfigResponse struct {
SupportedAssets []NetworkTokenSupport `json:"supported_assets"`
Epay EpayPublicConfig `json:"epay"`
OkPay OkPayPublicConfig `json:"okpay"`
}
+278
View File
@@ -0,0 +1,278 @@
package service
import (
"crypto/md5"
"encoding/hex"
"fmt"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log"
)
type okPayCreateDepositResponse struct {
Status string `json:"status"`
Code int `json:"code"`
Data struct {
OrderID string `json:"order_id"`
PayURL string `json:"pay_url"`
} `json:"data"`
Msg string `json:"msg"`
}
type okPayDepositOrder struct {
ProviderOrderID string
PayURL string
}
type okPayNotifyPayload struct {
ID string `json:"id"`
Status string `json:"status"`
Code string `json:"code"`
OrderID string `json:"order_id"`
UniqueID string `json:"unique_id"`
PayUserID string `json:"pay_user_id"`
Amount string `json:"amount"`
Coin string `json:"coin"`
PayStatus string `json:"pay_status"`
NotifyType string `json:"notify_type"`
Sign string `json:"sign"`
RawFormData string `json:"raw_form_data"`
}
func okPaySign(form map[string]string, shopID string, shopToken string) map[string]string {
values := url.Values{}
for key, value := range form {
if strings.TrimSpace(value) == "" {
continue
}
values.Set(key, value)
}
values.Set("id", shopID)
query, _ := url.QueryUnescape(values.Encode())
sum := md5.Sum([]byte(query + "&token=" + shopToken))
signed := make(map[string]string, len(values)+1)
for key, vals := range values {
if len(vals) == 0 {
continue
}
signed[key] = vals[0]
}
signed["sign"] = strings.ToUpper(hex.EncodeToString(sum[:]))
return signed
}
func createOkPayDepositOrder(uniqueID string, amount float64, coin string, returnURL string) (*okPayDepositOrder, error) {
shopID := data.GetOkPayShopID()
shopToken := data.GetOkPayShopToken()
apiURL := strings.TrimSpace(data.GetOkPayAPIURL())
callbackURL := data.GetOkPayCallbackURL()
if shopID == "" || shopToken == "" || apiURL == "" || callbackURL == "" {
return nil, fmt.Errorf("okpay config incomplete")
}
if returnURL == "" {
returnURL = data.GetOkPayReturnURL()
}
form := map[string]string{
"unique_id": uniqueID,
"name": uniqueID,
"amount": fmt.Sprintf("%.2f", amount),
"coin": strings.ToUpper(strings.TrimSpace(coin)),
"callback_url": callbackURL,
"return_url": returnURL,
}
form = okPaySign(form, shopID, shopToken)
base, err := url.Parse(apiURL)
if err != nil {
return nil, err
}
base.Path = path.Join(base.Path, "payLink")
client := http_client.GetHttpClient()
client.SetTimeout(time.Duration(data.GetOkPayTimeoutSeconds()) * time.Second)
var payload okPayCreateDepositResponse
resp, err := client.R().
SetFormData(form).
SetResult(&payload).
Post(base.String())
if err != nil {
log.Sugar.Warnf("[okpay] create order request failed unique_id=%s api_url=%s callback_url=%s return_url=%s err=%v", uniqueID, base.String(), callbackURL, returnURL, err)
return nil, err
}
if resp.StatusCode() >= 400 {
log.Sugar.Warnf("[okpay] create order http error unique_id=%s status=%s api_url=%s callback_url=%s return_url=%s body=%s", uniqueID, resp.Status(), base.String(), callbackURL, returnURL, resp.String())
return nil, fmt.Errorf("okpay http status: %s", resp.Status())
}
if payload.Data.OrderID == "" || payload.Data.PayURL == "" {
log.Sugar.Warnf("[okpay] create order rejected unique_id=%s api_url=%s callback_url=%s return_url=%s status=%s code=%d msg=%s", uniqueID, base.String(), callbackURL, returnURL, payload.Status, payload.Code, payload.Msg)
if payload.Msg != "" {
return nil, fmt.Errorf("okpay create order failed: %s (callback_url=%s)", payload.Msg, callbackURL)
}
return nil, fmt.Errorf("okpay create order failed (callback_url=%s)", callbackURL)
}
return &okPayDepositOrder{
ProviderOrderID: payload.Data.OrderID,
PayURL: payload.Data.PayURL,
}, nil
}
func verifyOkPayNotify(form map[string]string) bool {
shopID := data.GetOkPayShopID()
shopToken := data.GetOkPayShopToken()
signature := strings.TrimSpace(form["sign"])
if shopID == "" || shopToken == "" || signature == "" {
return false
}
if expected := okPayNotifySign(form, shopToken); expected != "" && strings.EqualFold(expected, signature) {
return true
}
unsigned := make(map[string]string, len(form))
for key, value := range form {
if strings.EqualFold(key, "sign") {
continue
}
unsigned[key] = value
}
signed := okPaySign(unsigned, shopID, shopToken)
return strings.EqualFold(strings.TrimSpace(signed["sign"]), signature)
}
func okPayNotifySign(form map[string]string, shopToken string) string {
orderedKeys := []string{
"code",
"data[order_id]",
"data[unique_id]",
"data[pay_user_id]",
"data[amount]",
"data[coin]",
"data[status]",
"data[type]",
"id",
"status",
}
pairs := make([]string, 0, len(orderedKeys))
for _, key := range orderedKeys {
value := strings.TrimSpace(form[key])
if value == "" {
continue
}
pairs = append(pairs, url.QueryEscape(key)+"="+url.QueryEscape(value))
}
if len(pairs) == 0 {
return ""
}
query, err := url.QueryUnescape(strings.Join(pairs, "&"))
if err != nil {
return ""
}
sum := md5.Sum([]byte(query + "&token=" + shopToken))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func parseOkPayNotify(form map[string]string, rawFormData string) *okPayNotifyPayload {
return &okPayNotifyPayload{
ID: strings.TrimSpace(form["id"]),
Status: strings.TrimSpace(form["status"]),
Code: strings.TrimSpace(form["code"]),
OrderID: strings.TrimSpace(form["data[order_id]"]),
UniqueID: strings.TrimSpace(form["data[unique_id]"]),
PayUserID: strings.TrimSpace(form["data[pay_user_id]"]),
Amount: strings.TrimSpace(form["data[amount]"]),
Coin: strings.TrimSpace(form["data[coin]"]),
PayStatus: strings.TrimSpace(form["data[status]"]),
NotifyType: strings.TrimSpace(form["data[type]"]),
Sign: strings.TrimSpace(form["sign"]),
RawFormData: rawFormData,
}
}
func HandleOkPayNotify(form map[string]string, rawFormData string) error {
if !verifyOkPayNotify(form) {
return fmt.Errorf("invalid okpay notify sign")
}
payload := parseOkPayNotify(form, rawFormData)
if payload.ID == "" || payload.ID != data.GetOkPayShopID() {
return fmt.Errorf("okpay notify shop id mismatch: %s", payload.ID)
}
if payload.UniqueID == "" || payload.OrderID == "" {
return fmt.Errorf("missing okpay notify identifiers")
}
order, err := data.GetOrderInfoByTradeId(payload.UniqueID)
if err != nil {
return err
}
if order.ID <= 0 {
return fmt.Errorf("okpay notify order not found: %s", payload.UniqueID)
}
if order.PayProvider != "okpay" {
return fmt.Errorf("okpay notify order provider mismatch: %s", payload.UniqueID)
}
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(payload.UniqueID, order.PayProvider)
if err != nil {
return err
}
if providerRow.ID <= 0 {
return fmt.Errorf("okpay provider order not found: %s", payload.UniqueID)
}
_ = data.SaveProviderOrderNotify(payload.UniqueID, order.PayProvider, payload.RawFormData)
if !strings.EqualFold(payload.Status, "success") || !strings.EqualFold(payload.NotifyType, "deposit") || payload.PayStatus != "1" {
log.Sugar.Infof("[okpay] notify ignored trade_id=%s status=%s pay_status=%s type=%s", payload.UniqueID, payload.Status, payload.PayStatus, payload.NotifyType)
return nil
}
if providerRow.ProviderOrderID != "" && providerRow.ProviderOrderID != payload.OrderID {
return fmt.Errorf("okpay provider order id mismatch: got=%s want=%s", payload.OrderID, providerRow.ProviderOrderID)
}
if !strings.EqualFold(strings.TrimSpace(order.Token), strings.TrimSpace(payload.Coin)) {
return fmt.Errorf("okpay coin mismatch: got=%s want=%s", payload.Coin, order.Token)
}
notifyAmount, err := strconv.ParseFloat(payload.Amount, 64)
if err != nil {
return fmt.Errorf("invalid okpay amount: %w", err)
}
if fmt.Sprintf("%.2f", notifyAmount) != fmt.Sprintf("%.2f", order.ActualAmount) {
return fmt.Errorf("okpay amount mismatch: got=%.2f want=%.2f", notifyAmount, order.ActualAmount)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: order.ReceiveAddress,
Currency: order.Currency,
Token: order.Token,
Network: order.Network,
Amount: order.ActualAmount,
TradeId: order.TradeId,
BlockTransactionId: payload.OrderID,
})
if err != nil && err != constant.OrderBlockAlreadyProcess {
return err
}
if err = data.MarkProviderOrderPaid(payload.UniqueID, order.PayProvider, payload.RawFormData); err != nil {
return err
}
return nil
}
+149
View File
@@ -120,6 +120,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
RedirectUrl: req.RedirectUrl,
Name: req.Name,
PaymentType: req.PaymentType,
PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: apiKeyID(apiKey),
}
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
@@ -199,6 +200,11 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil {
log.Sugar.Warnf("[order] expire sub-order failed, trade_id=%s, err=%v", sub.TradeId, err)
}
if sub.PayProvider != "" && sub.PayProvider != mdb.PaymentProviderOnChain {
if err = data.MarkProviderOrderExpired(sub.TradeId, sub.PayProvider); err != nil {
log.Sugar.Warnf("[order] expire provider order failed, trade_id=%s, provider=%s, err=%v", sub.TradeId, sub.PayProvider, 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)
}
@@ -257,6 +263,11 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
// Release sibling locks after their status transitions commit.
for _, sib := range siblings {
if sib.PayProvider != "" && sib.PayProvider != mdb.PaymentProviderOnChain {
if err = data.MarkProviderOrderExpired(sib.TradeId, sib.PayProvider); err != nil {
log.Sugar.Warnf("[order] expire sibling provider order failed, trade_id=%s, provider=%s, err=%v", sib.TradeId, sib.PayProvider, 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)
}
@@ -346,6 +357,10 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
return nil, constant.OrderNotWaitPay
}
if network == mdb.PaymentProviderOkPay {
return switchToOkPay(parent, token)
}
// 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)
@@ -427,6 +442,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
Name: parent.Name,
CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order
PaymentType: parent.PaymentType,
PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails
}
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
@@ -458,7 +474,140 @@ func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse
Network: order.Network,
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: order.RedirectUrl,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
CreatedAt: order.CreatedAt.TimestampMilli(),
IsSelected: order.IsSelected,
}
}
func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
if !data.GetOkPayEnabled() {
return nil, constant.PaymentProviderNotEnabled
}
if data.GetOkPayShopID() == "" || data.GetOkPayShopToken() == "" || data.GetOkPayAPIURL() == "" || data.GetOkPayCallbackURL() == "" {
return nil, constant.PaymentProviderConfigErr
}
if !okPayTokenAllowed(token) {
return nil, constant.PaymentProviderNotSupport
}
existing, err := data.GetSubOrderByTokenPayProvider(parent.TradeId, token, mdb.PaymentProviderOkPay)
if err != nil {
return nil, err
}
if existing.ID > 0 {
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(existing.TradeId, mdb.PaymentProviderOkPay)
if err != nil {
return nil, err
}
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
return nil, constant.SystemErr
}
_ = data.MarkOrderSelected(parent.TradeId)
_ = data.MarkOrderSelected(existing.TradeId)
_ = data.RefreshOrderExpiration(parent.TradeId)
existing.IsSelected = true
resp := buildCheckoutResponse(existing)
resp.PaymentUrl = providerRow.PayURL
return resp, nil
}
count, err := data.CountActiveSubOrders(parent.TradeId)
if err != nil {
return nil, err
}
if count >= MaxSubOrders {
return nil, constant.SubOrderLimitExceeded
}
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
}
subTradeID := GenerateCode()
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
returnURL := strings.TrimSpace(parent.RedirectUrl)
if returnURL == "" {
returnURL = data.GetOkPayReturnURL()
}
if returnURL == "" {
returnURL = fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), parent.TradeId)
}
tx := dao.Mdb.Begin()
subOrder := &mdb.Orders{
TradeId: subTradeID,
OrderId: subTradeID,
ParentTradeId: parent.TradeId,
Amount: parent.Amount,
Currency: parent.Currency,
ActualAmount: amount,
ReceiveAddress: "OKPAY",
Token: token,
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
IsSelected: true,
NotifyUrl: "",
RedirectUrl: parent.RedirectUrl,
Name: parent.Name,
CallBackConfirm: mdb.CallBackConfirmOk,
PaymentType: parent.PaymentType,
PayProvider: mdb.PaymentProviderOkPay,
ApiKeyID: parent.ApiKeyID,
}
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
tx.Rollback()
return nil, err
}
providerRow := &mdb.ProviderOrder{
TradeId: subTradeID,
Provider: mdb.PaymentProviderOkPay,
ProviderOrderID: "",
PayURL: "",
Amount: amount,
Coin: token,
Status: mdb.ProviderOrderStatusCreating,
}
if err = data.CreateProviderOrderWithTransaction(tx, providerRow); err != nil {
tx.Rollback()
return nil, err
}
if err = tx.Commit().Error; err != nil {
tx.Rollback()
return nil, err
}
okpayOrder, err := createOkPayDepositOrder(subTradeID, amount, token, returnURL)
if err != nil {
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
_ = data.ExpireOrderByTradeID(subTradeID)
return nil, err
}
if err = data.UpdateProviderOrderCreated(subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
_ = data.ExpireOrderByTradeID(subTradeID)
return nil, err
}
_ = data.MarkOrderSelected(parent.TradeId)
_ = data.RefreshOrderExpiration(parent.TradeId)
resp := buildCheckoutResponse(subOrder)
resp.PaymentUrl = okpayOrder.PayURL
return resp, nil
}
func okPayTokenAllowed(token string) bool {
for _, item := range data.GetOkPayAllowTokens() {
if strings.EqualFold(item, token) {
return true
}
}
return false
}
+75
View File
@@ -612,6 +612,81 @@ func TestOrderProcessingSubOrderPaidParentKeepsOwnFields(t *testing.T) {
}
}
func TestOrderProcessingParentDirectPayExpiresOkPayProviderOrder(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_parent_direct_okpay_expire", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
subOrder := &mdb.Orders{
TradeId: "okpay_sub_parent_direct_expire",
OrderId: "okpay_sub_parent_direct_expire",
ParentTradeId: parentResp.TradeId,
Amount: parentResp.Amount,
Currency: parentResp.Currency,
ActualAmount: 0.15,
ReceiveAddress: "OKPAY",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "",
CallBackConfirm: mdb.CallBackConfirmOk,
PayProvider: mdb.PaymentProviderOkPay,
}
if err := dao.Mdb.Create(subOrder).Error; err != nil {
t.Fatalf("create okpay sub-order: %v", err)
}
providerRow := &mdb.ProviderOrder{
TradeId: subOrder.TradeId,
Provider: mdb.PaymentProviderOkPay,
ProviderOrderID: "okp-parent-direct-expire",
PayURL: "https://t.me/ExampleWalletBot?start=shop_deposit--okpay-order-parent-direct-expire",
Amount: subOrder.ActualAmount,
Coin: subOrder.Token,
Status: mdb.ProviderOrderStatusPending,
}
if err := dao.Mdb.Create(providerRow).Error; err != nil {
t.Fatalf("create provider row: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: parentResp.ReceiveAddress,
Token: strings.ToUpper(parentResp.Token),
Network: mdb.NetworkTron,
TradeId: parentResp.TradeId,
Amount: parentResp.ActualAmount,
BlockTransactionId: "block_parent_direct_okpay_expire",
})
if err != nil {
t.Fatalf("order processing parent direct pay: %v", err)
}
expiredSub, err := data.GetOrderInfoByTradeId(subOrder.TradeId)
if err != nil {
t.Fatalf("reload sub-order: %v", err)
}
if expiredSub.Status != mdb.StatusExpired {
t.Fatalf("sub-order status = %d, want %d", expiredSub.Status, mdb.StatusExpired)
}
expiredProviderRow, err := data.GetProviderOrderByTradeIDAndProvider(subOrder.TradeId, mdb.PaymentProviderOkPay)
if err != nil {
t.Fatalf("reload provider row: %v", err)
}
if expiredProviderRow.Status != mdb.ProviderOrderStatusExpired {
t.Fatalf("provider row status = %q, want %q", expiredProviderRow.Status, mdb.ProviderOrderStatusExpired)
}
}
// TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks verifies that when one
// sub-order is paid all sibling sub-orders are expired and their runtime locks (as
// well as the parent's lock) are released.