mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
feat: add configurable payment amount precision
- add system.amount_precision setting with 2-6 validation
This commit is contained in:
@@ -139,6 +139,7 @@ func seedDefaultSettings() {
|
||||
okPayCallbackURL += "/payments/okpay/v1/notify"
|
||||
}
|
||||
defaults := []mdb.Setting{
|
||||
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyAmountPrecision, Value: "2", Type: mdb.SettingTypeInt},
|
||||
{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},
|
||||
|
||||
@@ -44,6 +44,9 @@ func RuntimeInit() error {
|
||||
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_address_token_amount_uindex").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_network_address_token_amount_uindex").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
|
||||
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
|
||||
return err
|
||||
|
||||
@@ -23,9 +23,14 @@ type PendingCallbackOrder struct {
|
||||
UpdatedAt carbon.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func normalizeAmount(amount float64, precision int) (int64, string) {
|
||||
precision = NormalizeAmountPrecision(precision)
|
||||
value := decimal.NewFromFloat(amount).Round(int32(precision))
|
||||
return value.Shift(int32(precision)).IntPart(), value.StringFixed(int32(precision))
|
||||
}
|
||||
|
||||
func normalizeLockAmount(amount float64) (int64, string) {
|
||||
value := decimal.NewFromFloat(amount).Round(2)
|
||||
return value.Shift(2).IntPart(), value.StringFixed(2)
|
||||
return normalizeAmount(amount, GetAmountPrecision())
|
||||
}
|
||||
|
||||
func normalizeLockNetwork(network string) string {
|
||||
@@ -53,6 +58,30 @@ func applyLockAddressFilter(tx *gorm.DB, network, address string) *gorm.DB {
|
||||
return tx.Where("address = ?", address)
|
||||
}
|
||||
|
||||
func activeLocksForAddress(tx *gorm.DB, network, address, token string, now time.Time) *gorm.DB {
|
||||
query := tx.Model(&mdb.TransactionLock{}).
|
||||
Where("network = ?", normalizeLockNetwork(network)).
|
||||
Where("token = ?", normalizeLockToken(token)).
|
||||
Where("expires_at > ?", now)
|
||||
return applyLockAddressFilter(query, network, address)
|
||||
}
|
||||
|
||||
func lockMatchesAmount(lock mdb.TransactionLock, amount float64) bool {
|
||||
precision := NormalizeAmountPrecision(lock.AmountPrecision)
|
||||
scaledAmount, _ := normalizeAmount(amount, precision)
|
||||
return lock.AmountScaled == scaledAmount
|
||||
}
|
||||
|
||||
func lockAmountDecimal(lock mdb.TransactionLock) decimal.Decimal {
|
||||
if strings.TrimSpace(lock.AmountText) != "" {
|
||||
if value, err := decimal.NewFromString(lock.AmountText); err == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
precision := NormalizeAmountPrecision(lock.AmountPrecision)
|
||||
return decimal.NewFromInt(lock.AmountScaled).Shift(int32(-precision))
|
||||
}
|
||||
|
||||
// GetOrderInfoByOrderId fetches an order by merchant order id.
|
||||
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
@@ -278,45 +307,43 @@ func ExpireOrderByTradeId(tradeId string) error {
|
||||
func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
scaledAmount, _ := normalizeLockAmount(amount)
|
||||
var lock mdb.TransactionLock
|
||||
query := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
||||
Where("network = ?", network).
|
||||
Where("token = ?", normalizeLockToken(token)).
|
||||
Where("amount_scaled = ?", scaledAmount).
|
||||
Where("expires_at > ?", time.Now())
|
||||
query = applyLockAddressFilter(query, network, address)
|
||||
err := query.Limit(1).Find(&lock).Error
|
||||
var locks []mdb.TransactionLock
|
||||
err := activeLocksForAddress(dao.RuntimeDB, network, address, token, time.Now()).
|
||||
Order("created_at ASC").
|
||||
Find(&locks).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if lock.ID <= 0 {
|
||||
return "", nil
|
||||
for _, lock := range locks {
|
||||
if lockMatchesAmount(lock, amount) {
|
||||
return lock.TradeId, nil
|
||||
}
|
||||
}
|
||||
return lock.TradeId, nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
|
||||
func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
scaledAmount, amountText := normalizeLockAmount(amount)
|
||||
precision := GetAmountPrecision()
|
||||
scaledAmount, amountText := normalizeAmount(amount, precision)
|
||||
normalizedToken := normalizeLockToken(token)
|
||||
now := time.Now()
|
||||
lock := &mdb.TransactionLock{
|
||||
Network: network,
|
||||
Address: address,
|
||||
Token: normalizedToken,
|
||||
AmountScaled: scaledAmount,
|
||||
AmountText: amountText,
|
||||
TradeId: tradeID,
|
||||
ExpiresAt: now.Add(expirationTime),
|
||||
Network: network,
|
||||
Address: address,
|
||||
Token: normalizedToken,
|
||||
AmountScaled: scaledAmount,
|
||||
AmountText: amountText,
|
||||
AmountPrecision: precision,
|
||||
TradeId: tradeID,
|
||||
ExpiresAt: now.Add(expirationTime),
|
||||
}
|
||||
|
||||
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
|
||||
expiredQuery := tx.Where("network = ?", network).
|
||||
Where("token = ?", normalizedToken).
|
||||
Where("amount_scaled = ?", scaledAmount).
|
||||
Where("expires_at <= ?", now)
|
||||
expiredQuery = applyLockAddressFilter(expiredQuery, network, address)
|
||||
if err := expiredQuery.Delete(&mdb.TransactionLock{}).Error; err != nil {
|
||||
@@ -326,6 +353,18 @@ func LockTransaction(network, address, token, tradeID string, amount float64, ex
|
||||
return err
|
||||
}
|
||||
|
||||
var existing []mdb.TransactionLock
|
||||
if err := activeLocksForAddress(tx, network, address, normalizedToken, now).
|
||||
Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
candidateAmount := lockAmountDecimal(*lock)
|
||||
for _, item := range existing {
|
||||
if lockAmountDecimal(item).Equal(candidateAmount) {
|
||||
return ErrTransactionLocked
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
@@ -341,13 +380,21 @@ func LockTransaction(network, address, token, tradeID string, amount float64, ex
|
||||
func UnLockTransaction(network string, address string, token string, amount float64) error {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
scaledAmount, _ := normalizeLockAmount(amount)
|
||||
query := dao.RuntimeDB.
|
||||
Where("network = ?", network).
|
||||
Where("token = ?", normalizeLockToken(token)).
|
||||
Where("amount_scaled = ?", scaledAmount)
|
||||
query = applyLockAddressFilter(query, network, address)
|
||||
return query.Delete(&mdb.TransactionLock{}).Error
|
||||
var locks []mdb.TransactionLock
|
||||
if err := activeLocksForAddress(dao.RuntimeDB, network, address, token, time.Now()).
|
||||
Find(&locks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]uint64, 0, len(locks))
|
||||
for _, lock := range locks {
|
||||
if lockMatchesAmount(lock, amount) {
|
||||
ids = append(ids, lock.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.RuntimeDB.Where("id IN ?", ids).Delete(&mdb.TransactionLock{}).Error
|
||||
}
|
||||
|
||||
func UnLockTransactionByTradeId(tradeID string) error {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -41,6 +42,51 @@ func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "2", mdb.SettingTypeInt); err != nil {
|
||||
t.Fatalf("set precision 2: %v", err)
|
||||
}
|
||||
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress001", "USDT", "trade-old", 1.23, time.Hour); err != nil {
|
||||
t.Fatalf("lock old transaction: %v", err)
|
||||
}
|
||||
|
||||
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil {
|
||||
t.Fatalf("set precision 4: %v", err)
|
||||
}
|
||||
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress001", "USDT", "trade-equivalent", 1.2300, time.Hour); !errors.Is(err, ErrTransactionLocked) {
|
||||
t.Fatalf("equivalent lock error = %v, want %v", err, ErrTransactionLocked)
|
||||
}
|
||||
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress001", "USDT", "trade-new", 1.2301, time.Hour); err != nil {
|
||||
t.Fatalf("distinct precision lock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionLockLookupUsesStoredPrecision(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil {
|
||||
t.Fatalf("set precision 4: %v", err)
|
||||
}
|
||||
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress002", "USDT", "trade-precise", 1.2345, time.Hour); err != nil {
|
||||
t.Fatalf("lock precise transaction: %v", err)
|
||||
}
|
||||
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "2", mdb.SettingTypeInt); err != nil {
|
||||
t.Fatalf("set precision 2: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, "TPrecisionAddress002", "USDT", 1.2345)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup transaction lock: %v", err)
|
||||
}
|
||||
if gotTradeID != "trade-precise" {
|
||||
t.Fatalf("trade id = %q, want trade-precise", gotTradeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -119,6 +119,25 @@ func GetSettingBool(key string, fallback bool) bool {
|
||||
return b
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultAmountPrecision = 2
|
||||
MinAmountPrecision = 2
|
||||
MaxAmountPrecision = 6
|
||||
)
|
||||
|
||||
// NormalizeAmountPrecision clamps external precision values to the supported
|
||||
// range used by order creation and transaction matching.
|
||||
func NormalizeAmountPrecision(precision int) int {
|
||||
if precision < MinAmountPrecision || precision > MaxAmountPrecision {
|
||||
return DefaultAmountPrecision
|
||||
}
|
||||
return precision
|
||||
}
|
||||
|
||||
func GetAmountPrecision() int {
|
||||
return NormalizeAmountPrecision(GetSettingInt(mdb.SettingKeyAmountPrecision, DefaultAmountPrecision))
|
||||
}
|
||||
|
||||
// SetSetting upserts a setting row and refreshes the cache entry.
|
||||
func SetSetting(group, key, value, valueType string) error {
|
||||
if valueType == "" {
|
||||
|
||||
@@ -30,6 +30,7 @@ const (
|
||||
SettingKeyInitAdminPasswordFetched = "system.init_admin_password_fetched"
|
||||
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
|
||||
SettingKeyOrderExpiration = "system.order_expiration_time"
|
||||
SettingKeyAmountPrecision = "system.amount_precision"
|
||||
SettingKeyBrandCheckoutName = "brand.checkout_name"
|
||||
SettingKeyBrandSiteName = "brand.site_name"
|
||||
SettingKeyBrandLogoUrl = "brand.logo_url"
|
||||
|
||||
@@ -3,16 +3,17 @@ package mdb
|
||||
import "time"
|
||||
|
||||
type TransactionLock struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"`
|
||||
Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"`
|
||||
Token string `gorm:"column:token;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:3" json:"token"`
|
||||
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:4" json:"amount_scaled"`
|
||||
AmountText string `gorm:"column:amount_text" json:"amount_text"`
|
||||
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"`
|
||||
Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"`
|
||||
Token string `gorm:"column:token;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:3" json:"token"`
|
||||
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:4" json:"amount_scaled"`
|
||||
AmountText string `gorm:"column:amount_text" json:"amount_text"`
|
||||
AmountPrecision int `gorm:"column:amount_precision;default:2;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:5" json:"amount_precision"`
|
||||
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (t *TransactionLock) TableName() string {
|
||||
|
||||
@@ -2,41 +2,41 @@ package response
|
||||
|
||||
// CreateTransactionResponse 创建订单成功返回
|
||||
type CreateTransactionResponse struct {
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
|
||||
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
||||
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址
|
||||
}
|
||||
|
||||
// OrderNotifyResponse 订单异步回调结构体
|
||||
type OrderNotifyResponse struct {
|
||||
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123..."` // 区块id
|
||||
Signature string `json:"signature" example:"a1b2c3d4e5f6..."` // 签名 MD5(sorted_params + secret_key)
|
||||
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
|
||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123..."` // 区块id
|
||||
Signature string `json:"signature" example:"a1b2c3d4e5f6..."` // 签名 MD5(sorted_params + secret_key)
|
||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||
Status int `json:"status" enums:"1,2,3" example:"2"`
|
||||
}
|
||||
|
||||
// OrderNotifyResponseEpay epay订单异步回调结构体
|
||||
type OrderNotifyResponseEpay struct {
|
||||
PID int `json:"pid" example:"1001"` // 商户ID
|
||||
TradeNo string `json:"trade_no" example:"T2026041612345678"` // 平台订单号
|
||||
PID int `json:"pid" example:"1001"` // 商户ID
|
||||
TradeNo string `json:"trade_no" example:"T2026041612345678"` // 平台订单号
|
||||
OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
|
||||
Type string `json:"type" example:"usdt"` // 订单类型
|
||||
Name string `json:"name" example:"VIP月卡"` // 商品名称
|
||||
Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
|
||||
Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
|
||||
SignType string `json:"sign_type" example:"MD5"` // 签名类型
|
||||
TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
|
||||
Type string `json:"type" example:"usdt"` // 订单类型
|
||||
Name string `json:"name" example:"VIP月卡"` // 商品名称
|
||||
Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
|
||||
Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
|
||||
SignType string `json:"sign_type" example:"MD5"` // 签名类型
|
||||
TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ 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位小数 加密货币金额
|
||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
|
||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数 加密货币金额
|
||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
|
||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||
|
||||
@@ -86,7 +86,7 @@ func createOkPayDepositOrder(uniqueID string, amount float64, coin string, retur
|
||||
form := map[string]string{
|
||||
"unique_id": uniqueID,
|
||||
"name": uniqueID,
|
||||
"amount": fmt.Sprintf("%.2f", amount),
|
||||
"amount": fmt.Sprintf("%.*f", data.GetAmountPrecision(), amount),
|
||||
"coin": strings.ToUpper(strings.TrimSpace(coin)),
|
||||
"callback_url": callbackURL,
|
||||
"return_url": returnURL,
|
||||
@@ -254,8 +254,9 @@ func HandleOkPayNotify(form map[string]string, rawFormData string) error {
|
||||
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)
|
||||
precision := data.GetAmountPrecision()
|
||||
if fmt.Sprintf("%.*f", precision, notifyAmount) != fmt.Sprintf("%.*f", precision, order.ActualAmount) {
|
||||
return fmt.Errorf("okpay amount mismatch: got=%.*f want=%.*f", precision, notifyAmount, precision, order.ActualAmount)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
const (
|
||||
CnyMinimumPaymentAmount = 0.01
|
||||
UsdtMinimumPaymentAmount = 0.01
|
||||
UsdtAmountPerIncrement = 0.01
|
||||
IncrementalMaximumNumber = 100
|
||||
)
|
||||
|
||||
@@ -61,7 +60,8 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
||||
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
||||
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||
network := strings.ToLower(strings.TrimSpace(req.Network))
|
||||
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
|
||||
amountPrecision := data.GetAmountPrecision()
|
||||
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
|
||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
||||
if rate <= 0 {
|
||||
return nil, constant.RateAmountErr
|
||||
@@ -96,7 +96,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
||||
}
|
||||
|
||||
tradeID := GenerateCode()
|
||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
|
||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), amountPrecision)
|
||||
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -109,7 +109,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
||||
order := &mdb.Orders{
|
||||
TradeId: tradeID,
|
||||
OrderId: req.OrderId,
|
||||
Amount: req.Amount,
|
||||
Amount: payAmount,
|
||||
Currency: currency,
|
||||
ActualAmount: availableAmount,
|
||||
ReceiveAddress: availableAddress,
|
||||
@@ -280,6 +280,7 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
||||
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
|
||||
availableAddress := ""
|
||||
availableAmount := amount
|
||||
amountPrecision := data.GetAmountPrecision()
|
||||
|
||||
tryLockWalletFunc := func(targetAmount float64) (string, error) {
|
||||
for _, address := range walletAddress {
|
||||
@@ -303,8 +304,8 @@ func ReserveAvailableWalletAndAmount(tradeID string, network string, token strin
|
||||
}
|
||||
if address == "" {
|
||||
decimalOldAmount := decimal.NewFromFloat(availableAmount)
|
||||
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement)
|
||||
availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64()
|
||||
decimalIncr := decimal.New(1, int32(-amountPrecision))
|
||||
availableAmount = math.MustParsePrecFloat64(decimalOldAmount.Add(decimalIncr).InexactFloat64(), amountPrecision)
|
||||
continue
|
||||
}
|
||||
availableAddress = address
|
||||
@@ -414,7 +415,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
||||
}
|
||||
|
||||
subTradeID := GenerateCode()
|
||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
|
||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
||||
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(subTradeID, network, token, amount, walletAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -531,7 +532,7 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
|
||||
}
|
||||
|
||||
subTradeID := GenerateCode()
|
||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
|
||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
||||
returnURL := strings.TrimSpace(parent.RedirectUrl)
|
||||
if returnURL == "" {
|
||||
returnURL = data.GetOkPayReturnURL()
|
||||
|
||||
@@ -97,6 +97,59 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionUsesConfiguredAmountPrecision(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil {
|
||||
t.Fatalf("set amount precision: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddress("wallet_precision_1"); err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_precision_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create first transaction: %v", err)
|
||||
}
|
||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_precision_2", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create second transaction: %v", err)
|
||||
}
|
||||
|
||||
if got := fmt.Sprintf("%.4f", resp1.ActualAmount); got != "1.0000" {
|
||||
t.Fatalf("first actual amount = %s, want 1.0000", got)
|
||||
}
|
||||
if got := fmt.Sprintf("%.4f", resp2.ActualAmount); got != "1.0001" {
|
||||
t.Fatalf("second actual amount = %s, want 1.0001", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionStoresNormalizedMerchantAmount(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("wallet_normalized_1"); err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_normalized_1", 100.129), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
if got := fmt.Sprintf("%.2f", resp.Amount); got != "100.13" {
|
||||
t.Fatalf("response amount = %s, want 100.13", got)
|
||||
}
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("load order: %v", err)
|
||||
}
|
||||
if got := fmt.Sprintf("%.2f", order.Amount); got != "100.13" {
|
||||
t.Fatalf("stored amount = %s, want 100.13", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionUsesRateAPIWhenForcedSettingIsNotPositive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -471,7 +471,7 @@ const (
|
||||
Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
|
||||
)
|
||||
|
||||
// ADJustAmount 将链上原始金额转为可读金额(除以 10^decimals,保留 2 位小数)
|
||||
// ADJustAmount 将链上原始金额转为可读金额(除以 10^decimals,保留到系统支持的最大匹配精度)
|
||||
func ADJustAmount(amount uint64, decimals int) float64 {
|
||||
if amount == 0 {
|
||||
return 0
|
||||
@@ -480,8 +480,7 @@ func ADJustAmount(amount uint64, decimals int) float64 {
|
||||
// 10^decimals
|
||||
decimalDivisor := decimal.New(1, int32(decimals))
|
||||
adjustedAmount := decimalAmount.Div(decimalDivisor)
|
||||
// Round to 2 decimal places
|
||||
return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), 2)
|
||||
return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), data.MaxAmountPrecision)
|
||||
}
|
||||
|
||||
func MatchUsdtAtaAddress(address string, ataTo string) bool {
|
||||
|
||||
@@ -236,7 +236,7 @@ func TestAdjustAmount(t *testing.T) {
|
||||
name: "USDT amount (6 decimals)",
|
||||
amount: 123456789,
|
||||
decimals: 6,
|
||||
want: 123.46,
|
||||
want: 123.456789,
|
||||
},
|
||||
{
|
||||
name: "USDC amount (6 decimals)",
|
||||
@@ -260,7 +260,7 @@ func TestAdjustAmount(t *testing.T) {
|
||||
name: "Small amount",
|
||||
amount: 1,
|
||||
decimals: 6,
|
||||
want: 0.0,
|
||||
want: 0.000001,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ func TryProcessTronTRC20Transfer(token mdb.ChainToken, toAddr string, rawValue *
|
||||
}
|
||||
|
||||
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(decimals))).InexactFloat64(), 2)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(decimals))).InexactFloat64(), data.MaxAmountPrecision)
|
||||
if amount <= 0 {
|
||||
return
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func TryProcessTronTRXTransfer(toAddr string, rawSun int64, txHash string, block
|
||||
}
|
||||
|
||||
decimalQuant := decimal.NewFromInt(rawSun)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), data.MaxAmountPrecision)
|
||||
if amount <= 0 {
|
||||
return
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
|
||||
pow := decimal.New(1, int32(decimals))
|
||||
|
||||
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(pow).InexactFloat64(), 2)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(pow).InexactFloat64(), data.MaxAmountPrecision)
|
||||
if amount <= 0 {
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
|
||||
return
|
||||
@@ -293,11 +293,13 @@ func sendPaymentNotification(order *mdb.Orders) {
|
||||
}
|
||||
}
|
||||
|
||||
precision := data.GetAmountPrecision()
|
||||
amountFormat := fmt.Sprintf("%%.%df", precision)
|
||||
msg := fmt.Sprintf(
|
||||
"🎉 <b>收款成功通知</b>\n\n"+
|
||||
"💰 <b>金额信息</b>\n"+
|
||||
"├ 订单金额:<code>%.2f %s</code>\n"+
|
||||
"└ 实际到账:<code>%.2f %s</code>\n\n"+
|
||||
"├ 订单金额:<code>"+amountFormat+" %s</code>\n"+
|
||||
"└ 实际到账:<code>"+amountFormat+" %s</code>\n\n"+
|
||||
"📋 <b>订单信息</b>\n"+
|
||||
"├ 交易号:<code>%s</code>\n"+
|
||||
"├ 订单号:<code>%s</code>\n"+
|
||||
|
||||
Reference in New Issue
Block a user