feat(payment): support placeholder orders for GMPay and EPay

This commit is contained in:
line-6000
2026-06-16 17:57:20 +08:00
parent 04c2b9b0b6
commit 3ab3659fe4
27 changed files with 1755 additions and 134 deletions
+91
View File
@@ -0,0 +1,91 @@
name: sync-www
on:
push:
branches:
- dev
paths-ignore:
- "src/www/**"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: sync-www-${{ github.ref_name }}
cancel-in-progress: false
jobs:
build-and-sync:
name: Build Frontend And Sync src/www
runs-on: ubuntu-latest
env:
FRONTEND_REPOSITORY: ${{ vars.WWW_FRONTEND_REPOSITORY }}
FRONTEND_REF: ${{ vars.WWW_FRONTEND_REF }}
FRONTEND_BUILD_DIR: ${{ vars.WWW_FRONTEND_BUILD_DIR || 'dist' }}
FRONTEND_DIR: epusdt-admin-web
FRONTEND_TOKEN: ${{ secrets.WWW_FRONTEND_TOKEN }}
WWW_TARGET_DIR: src/www
steps:
- name: Checkout Backend
uses: actions/checkout@v6
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: Validate Configuration
run: |
if [[ -z "$FRONTEND_REPOSITORY" ]]; then
echo "Frontend repository is empty"
exit 1
fi
if [[ -z "$FRONTEND_TOKEN" ]]; then
echo "Set repository secret WWW_FRONTEND_TOKEN to read the private frontend repository"
exit 1
fi
- name: Clone Frontend
run: |
frontend_path="$RUNNER_TEMP/$FRONTEND_DIR"
git clone \
--depth=1 \
--branch "$FRONTEND_REF" \
"https://x-access-token:${FRONTEND_TOKEN}@github.com/${FRONTEND_REPOSITORY}.git" \
"$frontend_path"
- name: Set Up Bun
uses: oven-sh/setup-bun@v2
- name: Build Frontend
run: |
cd "$RUNNER_TEMP/$FRONTEND_DIR"
bun install
bun run build
- name: Replace src/www
run: |
cd "$RUNNER_TEMP/$FRONTEND_DIR"
if [[ ! -d "$FRONTEND_BUILD_DIR" ]]; then
echo "Frontend build output does not exist: $FRONTEND_BUILD_DIR"
exit 1
fi
target_path="$GITHUB_WORKSPACE/$WWW_TARGET_DIR"
mkdir -p "$target_path"
rsync -a --delete "$FRONTEND_BUILD_DIR"/ "$target_path"/
- name: Commit And Push
run: |
if [[ -z "$(git status --porcelain -- "$WWW_TARGET_DIR")" ]]; then
echo "src/www is already up to date"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add "$WWW_TARGET_DIR"
git commit -m "chore(www): sync frontend build"
git pull --rebase origin "$GITHUB_REF_NAME"
git push origin "HEAD:$GITHUB_REF_NAME"
+11 -3
View File
@@ -112,7 +112,7 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
if order.ID == 0 {
return c.FailJson(ctx, constant.OrderNotExists)
}
if order.Status != mdb.StatusWaitPay {
if order.Status != mdb.StatusWaitPay && order.Status != mdb.StatusWaitSelect {
return c.FailJson(ctx, constant.OrderNotWaitPay)
}
ok, err := data.CloseOrderManually(tradeID)
@@ -122,8 +122,16 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
if !ok {
return c.FailJson(ctx, constant.OrderStatusConflict)
}
// Release the transaction lock so the amount slot becomes reusable.
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
// Release only concrete on-chain locks. Placeholder orders and provider-only
// orders do not reserve a local wallet amount.
if order.Status == mdb.StatusWaitPay &&
(strings.TrimSpace(order.PayProvider) == "" || order.PayProvider == mdb.PaymentProviderOnChain) &&
strings.TrimSpace(order.Network) != "" &&
strings.TrimSpace(order.ReceiveAddress) != "" &&
strings.TrimSpace(order.Token) != "" &&
order.ActualAmount > 0 {
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
}
return c.SucJson(ctx, nil)
}
+4 -4
View File
@@ -26,9 +26,9 @@ import (
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
//
// - group=epay:
// epay.default_token (string) — token for EPAY orders, e.g. "usdt" (default)
// epay.default_currency (string) — fiat currency for EPAY orders, e.g. "cny" (default)
// epay.default_network (string) — blockchain network for EPAY orders, e.g. "tron" or "ton" (default "tron")
// epay.default_token (string) — default token for EPAY submit.php, e.g. "usdt"; empty allows status=4 placeholders when request token/network are also absent
// epay.default_currency (string) — default fiat currency for EPAY submit.php, e.g. "cny"; empty falls back to cny
// epay.default_network (string) — default network for EPAY submit.php, e.g. "tron" or "ton"; empty allows status=4 placeholders when request token/network are also absent
//
// - group=okpay:
// okpay.enabled (bool) — enable OkPay as a switch-network payment option
@@ -95,7 +95,7 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
// @Summary Upsert settings
// @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n.
// @Description Supported groups: brand, rate, system, epay, okpay.
// @Description epay group keys: epay.default_token (e.g. "usdt" or "ton"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron" or "ton").
// @Description epay group keys: epay.default_token (e.g. "usdt" or "ton", empty allows status=4 placeholders), epay.default_currency (e.g. "cny", empty falls back to cny), epay.default_network (e.g. "tron" or "ton", empty allows status=4 placeholders).
// @Description okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens.
// @Description rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
// @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported.
+11 -9
View File
@@ -25,6 +25,8 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
// CreateTransaction 创建交易
// @Summary Create transaction
// @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded).
// @Description GMPay may omit both token and network to create a status=4 placeholder order; EPay submit.php can also create one when neither request parameters nor database defaults provide token/network. Supplying only one of token/network is invalid.
// @Description payment_type is optional for GMPay. If it is sent, it is a normal signed parameter and must be included when calculating signature.
// @Tags Payment
// @Accept json
// @Accept x-www-form-urlencoded
@@ -32,14 +34,14 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
// @Param order_id formData string false "Merchant order ID"
// @Param currency formData string false "Fiat currency (e.g. cny)"
// @Param token formData string false "Crypto token (e.g. TON, USDT)"
// @Param network formData string false "Network (e.g. ton, tron)"
// @Param token formData string false "Crypto token (e.g. TON, USDT); omit together with network to create a placeholder where supported"
// @Param network formData string false "Network (e.g. ton, tron); omit together with token to create a placeholder where supported"
// @Param amount formData number false "Amount"
// @Param notify_url formData string false "Callback URL"
// @Param signature formData string false "MD5 signature"
// @Param redirect_url formData string false "Redirect URL"
// @Param name formData string false "Order name"
// @Param payment_type formData string false "Payment type"
// @Param payment_type formData string false "Optional GMPay compatibility flag; include in signature when sent"
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel"
// @Router /payments/gmpay/v1/order/create-transaction [post]
@@ -58,17 +60,17 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
return c.SucJson(ctx, resp)
}
// SwitchNetwork 切换支付网络,创建返回子订单
// SwitchNetwork 切换支付网络,补全占位父单或创建/返回子订单
// @Summary Switch payment network
// @Description Switch to a different payment target, creating or returning a sub-order.
// @Description Normal values such as ton/tron/solana/ethereum create on-chain child orders.
// @Description The special value okpay creates or reuses an OkPay-hosted child order and returns its payment_url.
// @Description Switch to a different payment target. A status=4 placeholder is completed in place and returns the same parent trade_id with is_selected=false; an already concrete status=1 parent creates or returns the only sub-order when switching to a different target.
// @Description Normal values such as ton/tron/solana/ethereum select on-chain payment; the special value okpay selects OkPay hosted payment.
// @Description For status=4 placeholders from GMPay or EPay submit.php, both on-chain targets and okpay complete the parent in place without creating a child order. Sub-orders cannot be switched again.
// @Tags Payment
// @Accept json
// @Produce json
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10012 cannot switch sub-order, 10013 not waiting payment, 10016 unsupported asset, 10017/10018/10019 provider errors, 10042 provider order creation failed"
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10011 sub-order limit, 10012 cannot switch sub-order, 10013 not waiting payment, 10016 unsupported asset, 10017/10018/10019 provider errors, 10042 provider order creation failed"
// @Router /pay/switch-network [post]
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
req := new(request.SwitchNetworkRequest)
@@ -98,7 +100,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
// POST (form) per the legacy EPAY protocol; swagger documents POST as
// the canonical form — the GET variant is identical save the transport.
// @Summary Create transaction and redirect (EPAY compat)
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid.
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid. Optional request token/network/currency override database defaults and must be included in the EPay signature when sent. The server injects internal payment_type=Epay after EPay signature verification; merchants do not send GMPay payment_type to this endpoint.
// @Tags Payment
// @Accept x-www-form-urlencoded
// @Produce html
+3 -2
View File
@@ -12,7 +12,8 @@ import (
// CheckoutCounter 收银台
// @Summary Checkout counter page
// @Description Return checkout initialization data when the order exists. This endpoint only confirms order existence and returns base order data; call /pay/check-status/{trade_id} for the current order status (1=waiting payment, 2=paid, 3=expired).
// @Description Return checkout initialization data when the order exists. This endpoint only confirms order existence and returns base order data; call /pay/check-status/{trade_id} for the current order status (1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection).
// @Description When status=4, actual_amount is 0 and token/network/receive_address are empty; this state is produced by GMPay placeholders or EPay submit.php when no token/network request values or database defaults exist. The cashier should guide the payer to choose an on-chain token/network or OkPay and then call /pay/switch-network.
// @Tags Payment
// @Produce json
// @Param trade_id path string true "Trade ID"
@@ -31,7 +32,7 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
// CheckStatus 支付状态检测
// @Summary Check payment status
// @Description Return the current order status by trade ID. Status: 1=waiting payment, 2=paid, 3=expired.
// @Description Return the current order status by trade ID. Status: 1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection.
// @Tags Payment
// @Produce json
// @Param trade_id path string true "Trade ID"
@@ -109,9 +109,9 @@ func (c *BaseCommController) GetPublicConfig(ctx echo.Context) error {
BackgroundImageURL: data.GetBrandBackgroundImageURL(),
},
Epay: response.EpayPublicConfig{
DefaultToken: data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
DefaultToken: data.GetSettingString(mdb.SettingKeyEpayDefaultToken, ""),
DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, ""),
},
OkPay: okpay,
Version: config.GetAppVersion(),
+2 -2
View File
@@ -163,9 +163,9 @@ func seedDefaultSettings() {
defaults := []mdb.Setting{
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyAmountPrecision, Value: "2", Type: mdb.SettingTypeInt},
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeySystemLogLevel, Value: mdb.SettingDefaultSystemLogLevel, Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "", 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.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "", 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},
+57
View File
@@ -95,6 +95,63 @@ func TestSeedDefaultSettingsIncludesSystemLogLevel(t *testing.T) {
}
}
func TestSeedDefaultSettingsUsesEmptyEpayTokenAndNetwork(t *testing.T) {
db := setupSeedSettingsTestDB(t)
Mdb = db
seedDefaultSettings()
rows := make(map[string]mdb.Setting)
for _, key := range []string{
mdb.SettingKeyEpayDefaultToken,
mdb.SettingKeyEpayDefaultCurrency,
mdb.SettingKeyEpayDefaultNetwork,
} {
var row mdb.Setting
if err := Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
t.Fatalf("load %s seed: %v", key, err)
}
rows[key] = row
}
if rows[mdb.SettingKeyEpayDefaultToken].Value != "" {
t.Fatalf("epay.default_token seed = %q, want empty", rows[mdb.SettingKeyEpayDefaultToken].Value)
}
if rows[mdb.SettingKeyEpayDefaultNetwork].Value != "" {
t.Fatalf("epay.default_network seed = %q, want empty", rows[mdb.SettingKeyEpayDefaultNetwork].Value)
}
if rows[mdb.SettingKeyEpayDefaultCurrency].Value != "cny" {
t.Fatalf("epay.default_currency seed = %q, want cny", rows[mdb.SettingKeyEpayDefaultCurrency].Value)
}
}
func TestSeedDefaultSettingsDoesNotOverwriteExistingEpayDefaults(t *testing.T) {
db := setupSeedSettingsTestDB(t)
Mdb = db
for _, row := range []mdb.Setting{
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
} {
if err := Mdb.Create(&row).Error; err != nil {
t.Fatalf("precreate %s: %v", row.Key, err)
}
}
seedDefaultSettings()
for key, want := range map[string]string{
mdb.SettingKeyEpayDefaultToken: "usdt",
mdb.SettingKeyEpayDefaultNetwork: "tron",
} {
var row mdb.Setting
if err := Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
t.Fatalf("load %s seed: %v", key, err)
}
if row.Value != want {
t.Fatalf("%s value = %q, want existing %q", key, row.Value, want)
}
}
}
func TestSeedDefaultSettingsDoesNotOverwriteSystemLogLevel(t *testing.T) {
db := setupSeedSettingsTestDB(t)
Mdb = db
+3 -3
View File
@@ -105,12 +105,12 @@ func CountOrdersByStatus() (map[int]int64, error) {
return out, nil
}
// CloseOrderManually transitions a pending order to expired. Only
// touches rows currently in StatusWaitPay so idempotent / safe.
// CloseOrderManually transitions a pending order to expired. It covers both
// payable orders and placeholder orders waiting for token/network selection.
func CloseOrderManually(tradeID string) (bool, error) {
result := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Where("status = ?", mdb.StatusWaitPay).
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
Update("status", mdb.StatusExpired)
return result.RowsAffected > 0, result.Error
}
+65 -1
View File
@@ -209,7 +209,7 @@ func SaveCallBackOrdersResp(order *mdb.Orders) error {
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
result := dao.Mdb.Model(mdb.Orders{}).
Where("id = ?", id).
Where("status = ?", mdb.StatusWaitPay).
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
Where("created_at <= ?", expirationCutoff).
Update("status", mdb.StatusExpired)
return result.RowsAffected > 0, result.Error
@@ -225,6 +225,15 @@ func CountActiveSubOrders(parentTradeId string) (int64, error) {
return count, err
}
// CountSubOrders counts all sub-orders ever created under a parent.
func CountSubOrders(parentTradeId string) (int64, error) {
var count int64
err := dao.Mdb.Model(&mdb.Orders{}).
Where("parent_trade_id = ?", parentTradeId).
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)
@@ -288,6 +297,61 @@ func MarkOrderSelected(tradeId string) error {
Update("is_selected", true).Error
}
// CompleteWaitSelectOrder fills a placeholder order with concrete chain fields
// and makes it payable. It only updates status=WaitSelect rows so concurrent
// switch-network attempts cannot overwrite a payable order.
func CompleteWaitSelectOrder(tradeID string, network string, token string, receiveAddress string, actualAmount float64) (bool, error) {
result := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Where("status = ?", mdb.StatusWaitSelect).
Updates(map[string]interface{}{
"status": mdb.StatusWaitPay,
"network": strings.ToLower(strings.TrimSpace(network)),
"token": strings.ToUpper(strings.TrimSpace(token)),
"receive_address": receiveAddress,
"actual_amount": actualAmount,
"is_selected": false,
"created_at": time.Now(),
})
return result.RowsAffected > 0, result.Error
}
// CompleteWaitSelectOkPayOrderWithTransaction converts a placeholder parent
// directly into an OkPay order. It intentionally does not create a local chain
// lock because OkPay owns the hosted payment target.
func CompleteWaitSelectOkPayOrderWithTransaction(tx *gorm.DB, tradeID string, token string, actualAmount float64) (bool, error) {
result := tx.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Where("status = ?", mdb.StatusWaitSelect).
Updates(map[string]interface{}{
"status": mdb.StatusWaitPay,
"network": mdb.PaymentProviderOkPay,
"token": strings.ToUpper(strings.TrimSpace(token)),
"receive_address": "OKPAY",
"actual_amount": actualAmount,
"is_selected": false,
"pay_provider": mdb.PaymentProviderOkPay,
"created_at": time.Now(),
})
return result.RowsAffected > 0, result.Error
}
// MarkProviderSwitchParentSelectedWithTransaction marks a switch-network parent
// as selected for a hosted payment provider. It intentionally does not fill
// chain fields or create a transaction lock; the provider child order carries
// the concrete payment target.
func MarkProviderSwitchParentSelectedWithTransaction(tx *gorm.DB, tradeID string) (bool, error) {
result := tx.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
Updates(map[string]interface{}{
"status": mdb.StatusWaitPay,
"is_selected": true,
"created_at": time.Now(),
})
return result.RowsAffected > 0, result.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 {
+21 -1
View File
@@ -4,6 +4,7 @@ import (
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func GetProviderOrderByTradeIDAndProvider(tradeID string, provider string) (*mdb.ProviderOrder, error) {
@@ -20,8 +21,27 @@ func CreateProviderOrderWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) err
return tx.Model(row).Create(row).Error
}
func UpsertProviderOrderCreatingWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) error {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "trade_id"}, {Name: "provider"}},
DoUpdates: clause.AssignmentColumns([]string{
"provider_order_id",
"pay_url",
"amount",
"coin",
"status",
"notify_raw",
"updated_at",
}),
}).Create(row).Error
}
func UpdateProviderOrderCreated(tradeID string, provider string, providerOrderID string, payURL string) error {
return dao.Mdb.Model(&mdb.ProviderOrder{}).
return UpdateProviderOrderCreatedWithTransaction(dao.Mdb, tradeID, provider, providerOrderID, payURL)
}
func UpdateProviderOrderCreatedWithTransaction(tx *gorm.DB, tradeID string, provider string, providerOrderID string, payURL string) error {
return tx.Model(&mdb.ProviderOrder{}).
Where("trade_id = ?", tradeID).
Where("provider = ?", provider).
Updates(map[string]interface{}{
+25 -13
View File
@@ -1,15 +1,24 @@
package mdb
const (
StatusWaitPay = 1
StatusPaySuccess = 2
StatusExpired = 3
StatusWaitPay = 1
StatusPaySuccess = 2
StatusExpired = 3
// StatusWaitSelect is a placeholder order waiting for the payer to choose
// token+network or a hosted provider such as OkPay. It is not payable until
// switch-network moves it to StatusWaitPay.
StatusWaitSelect = 4
CallBackConfirmOk = 1
CallBackConfirmNo = 2
)
const (
// PaymentTypeEpay is the normalized special payment_type value. Orders
// matching it case-insensitively use the legacy EPay callback shape.
PaymentTypeEpay = "Epay"
// PaymentTypeGmpay is the normalized default for GMPay-created orders.
// It uses the default GMPay JSON callback shape.
PaymentTypeGmpay = "Gmpay"
)
const (
@@ -19,9 +28,10 @@ const (
// 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.
// It can be used by a status=4 parent completed in place on first
// switch-network, or by a switch-network-created child order after the
// parent is already concrete. Merchant callback shape is still controlled by
// PaymentType, not by this provider value.
PaymentProviderOkPay = "okpay"
)
@@ -36,21 +46,23 @@ type Orders struct {
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
Token string `gorm:"column:token" json:"token" example:"USDT"`
Network string `gorm:"column:network" json:"network" example:"tron"`
// 订单状态 1=等待支付 2=支付成功 3=已过期
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3" example:"1"`
// 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3,4" example:"1"`
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
// 回调确认状态 1=回调成功 2=未回调/回调失败
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"`
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 is not a channel selector. "Epay" changes callback format
// to legacy EPay case-insensitively; "Gmpay" uses GMPay JSON.
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
// 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
// - chain parent orders and regular chain child orders use on_chain
// - third-party hosted checkout rows use their provider name
// (for example okpay)
//
// Existing rows default to on_chain for backward compatibility so upgrades
+2 -2
View File
@@ -9,8 +9,8 @@ const (
)
// 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.
// row. For OkPay this can bind either to a status=4 parent completed in place
// or to a concrete child order created by later switch-network calls.
//
// Binding rules:
// - trade_id always points to orders.trade_id
+7 -3
View File
@@ -6,14 +6,18 @@ import "github.com/gookit/validate"
type CreateTransactionRequest struct {
OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
Token string `json:"token" form:"token" validate:"required" example:"usdt"` // 币种 如:usdt、ton
Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:ton、tron
Token string `json:"token" form:"token" example:"usdt"` // 币种 如:usdt、ton;可与 network 同时缺省创建占位订单
Network string `json:"network" form:"network" example:"tron"` // 网络 如:ton、tron;可与 token 同时缺省创建占位订单
Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
Name string `json:"name" form:"name" example:"VIP月卡"`
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
// PaymentType is a compatibility flag, not a gateway selector. Only
// "Epay" (case-insensitive) switches callback format to legacy EPay;
// empty or any other value is stored as "Gmpay" and uses GMPay JSON.
// It is optional for GMPay, but must be included in the signature when sent.
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
}
func (r CreateTransactionRequest) Translates() map[string]string {
+1
View File
@@ -9,6 +9,7 @@ type CreateTransactionResponse struct {
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......
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 收银台地址
}
+14 -12
View File
@@ -1,24 +1,26 @@
package response
type CheckoutCounterResponse struct {
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
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"` // 收款钱包地址
Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额status=4 占位订单返回 0
Token string `json:"token" example:"USDT"` // 所属币种status=4 占位订单为空
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址status=4 占位订单为空
Network string `json:"network" example:"tron"` // 网络 TRON ETHstatus=4 占位订单为空
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种;status=4 时前端应引导选择链上 token/network 或 OkPay
PaymentType string `json:"payment_type" enums:"gmpay,epay" example:"gmpay"` // 支付接入类型;底层 Epay/Gmpay 转为小写 epay/gmpay 返回
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单通常为本地收银台OkPay 订单为第三方 payLink
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单为空OkPay 订单为第三方 payLink
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
IsSelected bool `json:"is_selected" example:"false"`
IsSelected bool `json:"is_selected" example:"false"` // 是否已选择当前支付方式;status=4 占位订单和刚补全的占位父单为 false
}
type CheckStatusResponse struct {
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
// 订单状态 1=等待支付 2=支付成功 3=已过期
Status int `json:"status" enums:"1,2,3" example:"1"`
// 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
Status int `json:"status" enums:"1,2,3,4" example:"1"`
}
type ManualPaymentResponse struct {
+3 -3
View File
@@ -7,9 +7,9 @@ type NetworkTokenSupport struct {
}
type EpayPublicConfig struct {
DefaultToken string `json:"default_token" example:"usdt"`
DefaultCurrency string `json:"default_currency" example:"cny"`
DefaultNetwork string `json:"default_network" example:"tron"`
DefaultToken string `json:"default_token" example:""` // EPay default token; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
DefaultCurrency string `json:"default_currency" example:"cny"` // EPay default fiat currency; falls back to cny when unset.
DefaultNetwork string `json:"default_network" example:""` // EPay default network; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
}
type SitePublicConfig struct {
+73
View File
@@ -102,6 +102,79 @@ func TestHandleOkPayNotifySendsPaymentNotificationOnSuccess(t *testing.T) {
}
}
func TestHandleOkPayNotifySettlesPlaceholderParent(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
const (
shopID = "okpay-shop-placeholder"
shopToken = "okpay-shop-placeholder-token"
parentTradeID = "okpay-placeholder-parent-001"
providerOrderID = "okpay-placeholder-provider-001"
)
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, shopID, mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay shop id: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, shopToken, mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay shop token: %v", err)
}
parent := &mdb.Orders{
TradeId: parentTradeID,
OrderId: "merchant-okpay-placeholder-parent-001",
Amount: 1,
Currency: "CNY",
ActualAmount: 0.15,
ReceiveAddress: "OKPAY",
Token: "USDT",
Network: mdb.PaymentProviderOkPay,
Status: mdb.StatusWaitPay,
IsSelected: true,
PaymentType: mdb.PaymentTypeGmpay,
PayProvider: mdb.PaymentProviderOkPay,
}
if err := dao.Mdb.Create(parent).Error; err != nil {
t.Fatalf("seed parent order: %v", err)
}
if err := dao.Mdb.Create(&mdb.ProviderOrder{
TradeId: parentTradeID,
Provider: mdb.PaymentProviderOkPay,
ProviderOrderID: providerOrderID,
Amount: 0.15,
Coin: "USDT",
Status: mdb.ProviderOrderStatusPending,
}).Error; err != nil {
t.Fatalf("seed provider order: %v", err)
}
form := okPayNotifyTestForm(shopID, shopToken, providerOrderID, parentTradeID, "0.15000000", "USDT")
if err := HandleOkPayNotify(form, "placeholder-okpay-form"); err != nil {
t.Fatalf("handle okpay notify: %v", err)
}
reloadedParent, err := data.GetOrderInfoByTradeId(parentTradeID)
if err != nil {
t.Fatalf("reload parent: %v", err)
}
if reloadedParent.Status != mdb.StatusPaySuccess {
t.Fatalf("parent status = %d, want %d", reloadedParent.Status, mdb.StatusPaySuccess)
}
if reloadedParent.PayBySubId != 0 {
t.Fatalf("parent pay_by_sub_id = %d, want 0", reloadedParent.PayBySubId)
}
if reloadedParent.Token != "USDT" || reloadedParent.Network != mdb.PaymentProviderOkPay || reloadedParent.ReceiveAddress != "OKPAY" || reloadedParent.ActualAmount != 0.15 {
t.Fatalf("okpay parent fields changed: token=%q network=%q address=%q actual=%v", reloadedParent.Token, reloadedParent.Network, reloadedParent.ReceiveAddress, reloadedParent.ActualAmount)
}
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(parentTradeID, mdb.PaymentProviderOkPay)
if err != nil {
t.Fatalf("reload provider row: %v", err)
}
if providerRow.Status != mdb.ProviderOrderStatusPaid {
t.Fatalf("provider status = %q, want %q", providerRow.Status, mdb.ProviderOrderStatusPaid)
}
}
func okPayNotifyTestForm(shopID, shopToken, providerOrderID, tradeID, amount, coin string) map[string]string {
form := map[string]string{
"code": "200",
+290 -41
View File
@@ -70,15 +70,44 @@ func ensureEnabledOrderAsset(network, token string) (*mdb.ChainToken, error) {
return tokenRow, nil
}
func validateCreateTransactionTokenNetwork(token, network string) error {
if (token == "") != (network == "") {
return constant.ParamsMarshalErr
}
return nil
}
func buildCreateTransactionResponse(order *mdb.Orders) *response.CreateTransactionResponse {
return &response.CreateTransactionResponse{
TradeId: order.TradeId,
OrderId: order.OrderId,
Amount: order.Amount,
Currency: order.Currency,
ActualAmount: order.ActualAmount,
ReceiveAddress: order.ReceiveAddress,
Token: order.Token,
Status: order.Status,
ExpirationTime: carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp(),
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
}
}
// CreateTransaction creates a new payment order.
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
token := strings.ToUpper(strings.TrimSpace(req.Token))
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
network := strings.ToLower(strings.TrimSpace(req.Network))
if err := validateCreateTransactionTokenNetwork(token, network); err != nil {
return nil, err
}
notifyURL := strings.TrimSpace(req.NotifyUrl)
if err := security.ValidatePublicHTTPURL(notifyURL); err != nil {
return nil, constant.NotifyURLErr
}
paymentType := mdb.PaymentTypeGmpay
if strings.EqualFold(req.PaymentType, mdb.PaymentTypeEpay) {
paymentType = mdb.PaymentTypeEpay
}
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
@@ -93,6 +122,32 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
return nil, constant.OrderAlreadyExists
}
decimalPayAmount := decimal.NewFromFloat(payAmount)
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
if token == "" && network == "" {
tradeID := GenerateCode()
order := &mdb.Orders{
TradeId: tradeID,
OrderId: req.OrderId,
Amount: payAmount,
Currency: currency,
Status: mdb.StatusWaitSelect,
NotifyUrl: notifyURL,
RedirectUrl: req.RedirectUrl,
Name: req.Name,
PaymentType: paymentType,
PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: apiKeyID(apiKey),
}
if err = data.CreateOrderWithTransaction(dao.Mdb, order); err != nil {
return nil, err
}
return buildCreateTransactionResponse(order), nil
}
if !data.IsChainEnabled(network) {
return nil, constant.ChainNotEnabled
}
@@ -105,11 +160,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
return nil, constant.RateAmountErr
}
decimalPayAmount := decimal.NewFromFloat(payAmount)
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
@@ -146,7 +197,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
NotifyUrl: notifyURL,
RedirectUrl: req.RedirectUrl,
Name: req.Name,
PaymentType: req.PaymentType,
PaymentType: paymentType,
PayProvider: mdb.PaymentProviderOnChain,
ApiKeyID: apiKeyID(apiKey),
}
@@ -161,19 +212,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
return nil, err
}
expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
resp := &response.CreateTransactionResponse{
TradeId: order.TradeId,
OrderId: order.OrderId,
Amount: order.Amount,
Currency: order.Currency,
ActualAmount: order.ActualAmount,
ReceiveAddress: order.ReceiveAddress,
Token: order.Token,
ExpirationTime: expirationTime,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
}
return resp, nil
return buildCreateTransactionResponse(order), nil
}
// OrderProcessing marks an order as paid and releases its sqlite reservation.
@@ -206,15 +245,21 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
return err
}
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 {
if strings.TrimSpace(req.Network) != "" && strings.TrimSpace(req.ReceiveAddress) != "" && strings.TrimSpace(req.Token) != "" && req.Amount > 0 {
if unlockErr := data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); unlockErr != nil {
log.Sugar.Warnf("[order] fallback unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, unlockErr)
}
}
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
}
if hasTransactionLock(order) {
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", order.TradeId, err)
}
}
// Parent order paid directly: expire all sub-orders and release their locks
if order.ParentTradeId == "" {
@@ -232,8 +277,10 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
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)
if hasTransactionLock(&sub) {
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
@@ -284,8 +331,10 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
}
// 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)
if hasTransactionLock(parent) {
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)
}
}
// Release sibling locks after their status transitions commit.
@@ -295,8 +344,10 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
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)
if hasTransactionLock(&sib) {
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)
}
}
}
@@ -448,9 +499,20 @@ func isOnChainOrder(payProvider string) bool {
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
}
const MaxSubOrders = 2
func hasTransactionLock(order *mdb.Orders) bool {
return order != nil &&
isOnChainOrder(order.PayProvider) &&
strings.TrimSpace(order.Network) != "" &&
strings.TrimSpace(order.ReceiveAddress) != "" &&
strings.TrimSpace(order.Token) != "" &&
order.ActualAmount > 0
}
// SwitchNetwork creates or returns an existing sub-order for a different token+network.
const MaxSubOrders = 1
// SwitchNetwork completes a status=4 placeholder in place, or creates/returns
// an existing sub-order for a different payment target once the parent is
// already concrete.
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
@@ -469,7 +531,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
if parent.ParentTradeId != "" {
return nil, constant.CannotSwitchSubOrder
}
if parent.Status != mdb.StatusWaitPay {
if parent.Status != mdb.StatusWaitPay && parent.Status != mdb.StatusWaitSelect {
return nil, constant.OrderNotWaitPay
}
@@ -477,6 +539,10 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
return switchToOkPay(parent, token)
}
if parent.Status == mdb.StatusWaitSelect {
return completeWaitSelectOrder(parent, token, network)
}
// 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)
@@ -497,8 +563,9 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
return buildCheckoutResponse(existing), nil
}
// 4. Check sub-order limit
count, err := data.CountActiveSubOrders(parent.TradeId)
// 4. Only one sub-order may ever be created under a parent. Existing
// active sub-orders are reused above; any other target after that is blocked.
count, err := data.CountSubOrders(parent.TradeId)
if err != nil {
return nil, err
}
@@ -585,7 +652,63 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
return buildCheckoutResponse(subOrder), nil
}
func completeWaitSelectOrder(parent *mdb.Orders, token string, network string) (*response.CheckoutCounterResponse, error) {
if !data.IsChainEnabled(network) {
return nil, constant.ChainNotEnabled
}
if _, err := ensureEnabledOrderAsset(network, token); err != nil {
return nil, err
}
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
}
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil {
return nil, err
}
if len(walletAddress) <= 0 {
return nil, constant.NotAvailableWalletAddress
}
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(parent.TradeId, network, token, amount, walletAddress)
if err != nil {
return nil, err
}
if availableAddress == "" {
return nil, constant.NotAvailableAmountErr
}
updated, err := data.CompleteWaitSelectOrder(parent.TradeId, network, token, availableAddress, availableAmount)
if err != nil {
_ = data.UnLockTransactionByTradeId(parent.TradeId)
return nil, err
}
if !updated {
_ = data.UnLockTransactionByTradeId(parent.TradeId)
return nil, constant.OrderStatusConflict
}
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
if err != nil {
return nil, err
}
return buildCheckoutResponse(order), nil
}
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
paymentType := mdb.PaymentTypeGmpay
if strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay) {
paymentType = mdb.PaymentTypeEpay
}
return &response.CheckoutCounterResponse{
TradeId: order.TradeId,
Amount: order.Amount,
@@ -594,14 +717,93 @@ func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse
Currency: order.Currency,
ReceiveAddress: order.ReceiveAddress,
Network: order.Network,
Status: order.Status,
PaymentType: strings.ToLower(paymentType),
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 completeWaitSelectOkPayOrder(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
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
}
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
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()
providerRow := &mdb.ProviderOrder{
TradeId: parent.TradeId,
Provider: mdb.PaymentProviderOkPay,
ProviderOrderID: "",
PayURL: "",
Amount: amount,
Coin: token,
Status: mdb.ProviderOrderStatusCreating,
NotifyRaw: "",
}
if err := data.UpsertProviderOrderCreatingWithTransaction(tx, providerRow); err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
return nil, err
}
okpayOrder, err := createOkPayDepositOrder(parent.TradeId, amount, token, returnURL)
if err != nil {
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
return nil, constant.PaymentProviderCreateErr
}
finalizeTx := dao.Mdb.Begin()
if err = data.UpdateProviderOrderCreatedWithTransaction(finalizeTx, parent.TradeId, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
finalizeTx.Rollback()
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
return nil, err
}
updated, err := data.CompleteWaitSelectOkPayOrderWithTransaction(finalizeTx, parent.TradeId, token, amount)
if err != nil {
finalizeTx.Rollback()
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
return nil, err
}
if !updated {
finalizeTx.Rollback()
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
return nil, constant.OrderStatusConflict
}
if err = finalizeTx.Commit().Error; err != nil {
finalizeTx.Rollback()
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
return nil, err
}
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
if err != nil {
return nil, err
}
resp := buildCheckoutResponse(order)
resp.PaymentUrl = okpayOrder.PayURL
return resp, nil
}
func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
if !data.GetOkPayEnabled() {
return nil, constant.PaymentProviderNotEnabled
@@ -613,6 +815,29 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
return nil, constant.PaymentProviderNotSupport
}
if parent.Status == mdb.StatusWaitSelect {
return completeWaitSelectOkPayOrder(parent, token)
}
if parent.PayProvider == mdb.PaymentProviderOkPay && strings.EqualFold(parent.Token, token) {
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(parent.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.RefreshOrderExpiration(parent.TradeId)
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
if err != nil {
return nil, err
}
resp := buildCheckoutResponse(order)
resp.PaymentUrl = providerRow.PayURL
return resp, nil
}
existing, err := data.GetSubOrderByTokenPayProvider(parent.TradeId, token, mdb.PaymentProviderOkPay)
if err != nil {
return nil, err
@@ -625,16 +850,21 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
return nil, constant.SystemErr
}
_ = data.MarkOrderSelected(parent.TradeId)
updated, markErr := data.MarkProviderSwitchParentSelectedWithTransaction(dao.Mdb, parent.TradeId)
if markErr != nil {
return nil, markErr
}
if !updated {
return nil, constant.OrderStatusConflict
}
_ = 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)
count, err := data.CountSubOrders(parent.TradeId)
if err != nil {
return nil, err
}
@@ -711,14 +941,33 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
_ = data.ExpireOrderByTradeID(subTradeID)
return nil, constant.PaymentProviderCreateErr
}
if err = data.UpdateProviderOrderCreated(subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
finalizeTx := dao.Mdb.Begin()
if err = data.UpdateProviderOrderCreatedWithTransaction(finalizeTx, subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
finalizeTx.Rollback()
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
_ = data.ExpireOrderByTradeID(subTradeID)
return nil, err
}
updated, err := data.MarkProviderSwitchParentSelectedWithTransaction(finalizeTx, parent.TradeId)
if err != nil {
finalizeTx.Rollback()
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
_ = data.ExpireOrderByTradeID(subTradeID)
return nil, err
}
if !updated {
finalizeTx.Rollback()
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
_ = data.ExpireOrderByTradeID(subTradeID)
return nil, constant.OrderStatusConflict
}
if err = finalizeTx.Commit().Error; err != nil {
finalizeTx.Rollback()
_ = 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
+246 -12
View File
@@ -87,6 +87,85 @@ func TestCreateTransactionRejectsPrivateNotifyURL(t *testing.T) {
}
}
func TestCreateTransactionCreatesWaitSelectPlaceholderWithoutTokenNetwork(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
req := newCreateTransactionRequest("order_wait_select_1", 12.34)
req.Token = ""
req.Network = ""
resp, err := CreateTransaction(req, nil)
if err != nil {
t.Fatalf("create placeholder transaction: %v", err)
}
if resp.Status != mdb.StatusWaitSelect {
t.Fatalf("response status = %d, want %d", resp.Status, mdb.StatusWaitSelect)
}
if resp.Token != "" || resp.ReceiveAddress != "" || resp.ActualAmount != 0 {
t.Fatalf("placeholder chain fields = token %q address %q actual %.4f", resp.Token, resp.ReceiveAddress, resp.ActualAmount)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("reload placeholder order: %v", err)
}
if order.Status != mdb.StatusWaitSelect {
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusWaitSelect)
}
if order.PaymentType != mdb.PaymentTypeGmpay {
t.Fatalf("placeholder payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeGmpay)
}
if order.Token != "" || order.Network != "" || order.ReceiveAddress != "" || order.ActualAmount != 0 {
t.Fatalf("placeholder order has chain fields: %+v", order)
}
var locks int64
if err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).Where("trade_id = ?", resp.TradeId).Count(&locks).Error; err != nil {
t.Fatalf("count transaction locks: %v", err)
}
if locks != 0 {
t.Fatalf("placeholder lock count = %d, want 0", locks)
}
}
func TestCreateTransactionRejectsPartialTokenNetwork(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
req := newCreateTransactionRequest("order_partial_token_network_1", 1)
req.Token = ""
if _, err := CreateTransaction(req, nil); err != constant.ParamsMarshalErr {
t.Fatalf("missing token error = %v, want %v", err, constant.ParamsMarshalErr)
}
req = newCreateTransactionRequest("order_partial_token_network_2", 1)
req.Network = ""
if _, err := CreateTransaction(req, nil); err != constant.ParamsMarshalErr {
t.Fatalf("missing network error = %v, want %v", err, constant.ParamsMarshalErr)
}
}
func TestCreateTransactionNormalizesEpayPaymentTypeCase(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
req := newCreateTransactionRequest("order_epay_payment_type_1", 1)
req.Token = ""
req.Network = ""
req.PaymentType = "epay"
resp, err := CreateTransaction(req, nil)
if err != nil {
t.Fatalf("create epay-compatible transaction: %v", err)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if order.PaymentType != mdb.PaymentTypeEpay {
t.Fatalf("payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay)
}
}
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
@@ -459,6 +538,146 @@ func TestSwitchNetworkCreatesTonSubOrderAndRawLock(t *testing.T) {
}
}
func TestSwitchNetworkCompletesWaitSelectPlaceholderInPlace(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
t.Fatalf("set forced rate: %v", err)
}
if _, err := data.AddWalletAddress("TWaitSelectAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
req := newCreateTransactionRequest("order_wait_select_switch_1", 10)
req.Token = ""
req.Network = ""
parentResp, err := CreateTransaction(req, nil)
if err != nil {
t.Fatalf("create placeholder: %v", err)
}
if parentResp.Status != mdb.StatusWaitSelect {
t.Fatalf("parent response status = %d, want %d", parentResp.Status, mdb.StatusWaitSelect)
}
switched, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "USDT",
Network: mdb.NetworkTron,
})
if err != nil {
t.Fatalf("switch placeholder to tron/usdt: %v", err)
}
if switched.TradeId != parentResp.TradeId {
t.Fatalf("switch returned trade_id = %q, want parent %q", switched.TradeId, parentResp.TradeId)
}
if switched.Status != mdb.StatusWaitPay {
t.Fatalf("switch status = %d, want %d", switched.Status, mdb.StatusWaitPay)
}
if switched.IsSelected {
t.Fatal("switch is_selected = true, want false")
}
if switched.PaymentUrl != "" {
t.Fatalf("switch payment_url = %q, want empty for unselected chain parent", switched.PaymentUrl)
}
if switched.Token != "USDT" || switched.Network != mdb.NetworkTron || switched.ReceiveAddress != "TWaitSelectAddress001" {
t.Fatalf("switch chain fields = token %q network %q address %q", switched.Token, switched.Network, switched.ReceiveAddress)
}
if got := fmt.Sprintf("%.2f", switched.ActualAmount); got != "1.00" {
t.Fatalf("switch actual amount = %s, want 1.00", got)
}
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
if err != nil {
t.Fatalf("reload parent: %v", err)
}
if parent.Status != mdb.StatusWaitPay || parent.ParentTradeId != "" {
t.Fatalf("parent status/parent_trade_id = %d/%q", parent.Status, parent.ParentTradeId)
}
if parent.IsSelected {
t.Fatal("parent is_selected = true, want false")
}
if parent.PaymentType != mdb.PaymentTypeGmpay {
t.Fatalf("parent payment_type = %q, want %q", parent.PaymentType, mdb.PaymentTypeGmpay)
}
count, err := data.CountActiveSubOrders(parent.TradeId)
if err != nil {
t.Fatalf("count sub orders: %v", err)
}
if count != 0 {
t.Fatalf("active sub-order count = %d, want 0", count)
}
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, parent.ReceiveAddress, parent.Token, parent.ActualAmount)
if err != nil {
t.Fatalf("lookup parent lock: %v", err)
}
if lockTradeID != parent.TradeId {
t.Fatalf("parent lock trade_id = %q, want %q", lockTradeID, parent.TradeId)
}
}
func TestSwitchNetworkAfterWaitSelectCompletionUsesExistingSubOrderFlow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
t.Fatalf("set forced rate: %v", err)
}
if _, err := data.AddWalletAddress("TWaitSelectAddress002"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455668"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
req := newCreateTransactionRequest("order_wait_select_reswitch_1", 10)
req.Token = ""
req.Network = ""
parentResp, err := CreateTransaction(req, nil)
if err != nil {
t.Fatalf("create placeholder: %v", err)
}
first, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "USDT",
Network: mdb.NetworkTron,
})
if err != nil {
t.Fatalf("first switch: %v", err)
}
second, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "USDT",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("second switch: %v", err)
}
if second.TradeId == first.TradeId {
t.Fatalf("second switch trade_id = parent %q, want sub-order", second.TradeId)
}
if second.Network != mdb.NetworkEthereum || second.Token != "USDT" {
t.Fatalf("second switch token/network = %s/%s", second.Token, second.Network)
}
count, err := data.CountActiveSubOrders(parentResp.TradeId)
if err != nil {
t.Fatalf("count active sub orders: %v", err)
}
if count != 1 {
t.Fatalf("active sub-order count = %d, want 1", count)
}
_, err = SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "USDT",
Network: mdb.NetworkBsc,
})
if err != constant.SubOrderLimitExceeded {
t.Fatalf("third switch error = %v, want %v", err, constant.SubOrderLimitExceeded)
}
}
func TestSwitchNetworkRejectsUnsupportedTonToken(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
@@ -1219,7 +1438,6 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
t.Fatalf("create parent order: %v", err)
}
// Create two sub-orders on different networks.
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
@@ -1229,13 +1447,29 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
t.Fatalf("switch to ethereum sub-order: %v", err)
}
subBscResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkBsc,
})
if err != nil {
t.Fatalf("switch to bsc sub-order: %v", err)
bscTradeID := GenerateCode()
bscAddress := strings.ToLower("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
subBsc := &mdb.Orders{
TradeId: bscTradeID,
OrderId: bscTradeID,
ParentTradeId: parentResp.TradeId,
Amount: parentResp.Amount,
Currency: parentResp.Currency,
ActualAmount: subEthResp.ActualAmount,
ReceiveAddress: bscAddress,
Token: "USDT",
Network: mdb.NetworkBsc,
Status: mdb.StatusWaitPay,
IsSelected: true,
CallBackConfirm: mdb.CallBackConfirmOk,
PaymentType: mdb.PaymentTypeGmpay,
PayProvider: mdb.PaymentProviderOnChain,
}
if err := dao.Mdb.Create(subBsc).Error; err != nil {
t.Fatalf("seed bsc sibling sub-order: %v", err)
}
if err := data.LockTransaction(mdb.NetworkBsc, bscAddress, "USDT", bscTradeID, subBsc.ActualAmount, time.Hour); err != nil {
t.Fatalf("lock bsc sibling amount: %v", err)
}
// Pay the Ethereum sub-order.
@@ -1252,12 +1486,12 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
}
// BSC sibling must be expired.
subBsc, err := data.GetOrderInfoByTradeId(subBscResp.TradeId)
reloadedBsc, err := data.GetOrderInfoByTradeId(subBsc.TradeId)
if err != nil {
t.Fatalf("reload bsc sub-order: %v", err)
}
if subBsc.Status != mdb.StatusExpired {
t.Fatalf("bsc sibling status = %d, want %d (expired)", subBsc.Status, mdb.StatusExpired)
if reloadedBsc.Status != mdb.StatusExpired {
t.Fatalf("bsc sibling status = %d, want %d (expired)", reloadedBsc.Status, mdb.StatusExpired)
}
// Parent runtime lock must be released.
@@ -1272,7 +1506,7 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
// BSC sibling runtime lock must be released.
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
mdb.NetworkBsc, subBscResp.ReceiveAddress, subBscResp.Token, subBscResp.ActualAmount)
mdb.NetworkBsc, subBsc.ReceiveAddress, subBsc.Token, subBsc.ActualAmount)
if err != nil {
t.Fatalf("check bsc sibling runtime lock: %v", err)
}
+18
View File
@@ -1,8 +1,11 @@
package service
import (
"strings"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
)
@@ -20,6 +23,10 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
if orderInfo.ID <= 0 {
return nil, ErrOrder
}
paymentType := mdb.PaymentTypeGmpay
if strings.EqualFold(orderInfo.PaymentType, mdb.PaymentTypeEpay) {
paymentType = mdb.PaymentTypeEpay
}
resp := &response.CheckoutCounterResponse{
TradeId: orderInfo.TradeId,
@@ -29,10 +36,21 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
Currency: orderInfo.Currency,
ReceiveAddress: orderInfo.ReceiveAddress,
Network: orderInfo.Network,
Status: orderInfo.Status,
PaymentType: strings.ToLower(paymentType),
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: orderInfo.RedirectUrl,
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
IsSelected: orderInfo.IsSelected,
}
if orderInfo.PayProvider == mdb.PaymentProviderOkPay {
providerRow, rowErr := data.GetProviderOrderByTradeIDAndProvider(orderInfo.TradeId, mdb.PaymentProviderOkPay)
if rowErr != nil {
return nil, rowErr
}
if providerRow.ID > 0 {
resp.PaymentUrl = providerRow.PayURL
}
}
return resp, nil
}
+17 -4
View File
@@ -48,10 +48,12 @@ const sqliteBusyRetryAttempts = 3
type expirableOrder struct {
ID uint64 `gorm:"column:id"`
TradeId string `gorm:"column:trade_id"`
Status int `gorm:"column:status"`
Network string `gorm:"column:network"`
ReceiveAddress string `gorm:"column:receive_address"`
Token string `gorm:"column:token"`
ActualAmount float64 `gorm:"column:actual_amount"`
PayProvider string `gorm:"column:pay_provider"`
}
func runOrderExpirationLoop() {
@@ -91,8 +93,8 @@ func processExpiredOrders() {
var orders []expirableOrder
err := withSQLiteBusyRetry(func() error {
return dao.Mdb.Model(&mdb.Orders{}).
Select("id", "trade_id", "network", "receive_address", "token", "actual_amount").
Where("status = ?", mdb.StatusWaitPay).
Select("id", "trade_id", "status", "network", "receive_address", "token", "actual_amount", "pay_provider").
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
Where("created_at <= ?", expirationCutoff).
Order("id asc").
Limit(batchSize).
@@ -115,6 +117,17 @@ func processExpiredOrders() {
if !expired {
continue
}
// StatusWaitSelect placeholders have not reserved a wallet or
// amount yet, so there is no transaction lock to release.
if order.Status == mdb.StatusWaitSelect {
continue
}
if strings.TrimSpace(order.PayProvider) != "" && order.PayProvider != mdb.PaymentProviderOnChain {
continue
}
if strings.TrimSpace(order.Network) == "" || strings.TrimSpace(order.ReceiveAddress) == "" || strings.TrimSpace(order.Token) == "" || order.ActualAmount <= 0 {
continue
}
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err)
}
@@ -192,8 +205,8 @@ func sendOrderCallback(order *mdb.Orders) error {
return errors.New("no api key row available for callback")
}
switch order.PaymentType {
case mdb.PaymentTypeEpay:
switch {
case strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay):
// EPAY uses pid (integer) and secret_key as "key".
pidInt, convErr := strconv.Atoi(apiKeyRow.Pid)
if convErr != nil {
+26 -3
View File
@@ -62,6 +62,21 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
t.Fatalf("lock recent order: %v", err)
}
placeholder := &mdb.Orders{
TradeId: "trade_wait_select_expired",
OrderId: "order_wait_select_expired",
Amount: 1,
Currency: "CNY",
Status: mdb.StatusWaitSelect,
NotifyUrl: "https://merchant.example/callback",
}
if err := dao.Mdb.Create(placeholder).Error; err != nil {
t.Fatalf("create placeholder order: %v", err)
}
if err := dao.Mdb.Model(placeholder).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil {
t.Fatalf("age placeholder order: %v", err)
}
processExpiredOrders()
expired, err := data.GetOrderInfoByTradeId(order.TradeId)
@@ -93,6 +108,14 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
if lockTradeID != recentOrder.TradeId {
t.Fatalf("recent order lock = %s, want %s", lockTradeID, recentOrder.TradeId)
}
expiredPlaceholder, err := data.GetOrderInfoByTradeId(placeholder.TradeId)
if err != nil {
t.Fatalf("reload expired placeholder: %v", err)
}
if expiredPlaceholder.Status != mdb.StatusExpired {
t.Fatalf("placeholder status = %d, want %d", expiredPlaceholder.Status, mdb.StatusExpired)
}
}
func TestProcessExpiredOrdersKeepsPaidOrdersPaid(t *testing.T) {
@@ -295,7 +318,7 @@ func TestDispatchPendingCallbacksEpayRequiresAck(t *testing.T) {
BlockTransactionId: "block_callback_epay_fail",
CallbackNum: 0,
CallBackConfirm: mdb.CallBackConfirmNo,
PaymentType: mdb.PaymentTypeEpay,
PaymentType: "epay",
ApiKeyID: epayKey.ID,
}
if err := dao.Mdb.Create(order).Error; err != nil {
@@ -349,7 +372,7 @@ func TestDispatchPendingCallbacksEpayAcceptsTrimmedOk(t *testing.T) {
BlockTransactionId: "block_callback_epay_ok",
CallbackNum: 0,
CallBackConfirm: mdb.CallBackConfirmNo,
PaymentType: mdb.PaymentTypeEpay,
PaymentType: "epay",
ApiKeyID: epayKey.ID,
}
if err := dao.Mdb.Create(order).Error; err != nil {
@@ -550,7 +573,7 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_epay_sign",
PaymentType: mdb.PaymentTypeEpay,
PaymentType: "epay",
ApiKeyID: key.ID,
}
+28
View File
@@ -763,6 +763,34 @@ func TestAdminOrders_CloseNotFound(t *testing.T) {
}
}
func TestAdminOrders_CloseWaitSelectOrder(t *testing.T) {
e, token := setupAdminTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-admin-close-wait-select",
OrderId: "order-admin-close-wait-select",
Amount: 10,
Currency: "CNY",
Status: mdb.StatusWaitSelect,
NotifyUrl: "https://merchant.example/notify",
PayProvider: mdb.PaymentProviderOnChain,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create wait-select order: %v", err)
}
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/close", nil, token)
t.Logf("CloseOrder wait-select: status=%d body=%s", rec.Code, rec.Body.String())
assertOK(t, rec)
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != mdb.StatusExpired {
t.Fatalf("status = %d, want %d", reloaded.Status, mdb.StatusExpired)
}
}
// TestAdminOrders_MarkPaidNotFound verifies mark-paid on non-existent order.
func TestAdminOrders_MarkPaidNotFound(t *testing.T) {
e, token := setupAdminTestEnv(t)
+22 -4
View File
@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"strconv"
"strings"
"github.com/GMWalletApp/epusdt/controller/admin"
"github.com/GMWalletApp/epusdt/controller/comm"
@@ -43,7 +44,9 @@ func RegisterRoute(e *echo.Echo) {
// payment routes
paymentRoute := e.Group("/payments")
// gmpay v1 routes
// gmpay v1 routes. CreateTransaction accepts token+network together
// for a concrete chain order, or omits both for a status=4 placeholder
// that is completed later by /pay/switch-network or routed to OkPay.
gmpayV1 := paymentRoute.Group("/gmpay/v1")
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
gmpayV1.GET("/config", comm.Ctrl.GetPublicConfig)
@@ -129,6 +132,21 @@ func RegisterRoute(e *echo.Echo) {
notifyURL := getString(params, "notify_url")
outTradeNo := getString(params, "out_trade_no")
returnURL := getString(params, "return_url")
token := strings.TrimSpace(getString(params, "token"))
if token == "" {
token = data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "")
}
network := strings.TrimSpace(getString(params, "network"))
if network == "" {
network = data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "")
}
currency := strings.TrimSpace(getString(params, "currency"))
if currency == "" {
currency = data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "")
}
if currency == "" {
currency = "cny"
}
amountFloat, err := strconv.ParseFloat(money, 64)
if err != nil {
@@ -136,9 +154,9 @@ func RegisterRoute(e *echo.Echo) {
}
body := map[string]interface{}{
"token": data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
"currency": data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
"network": data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
"token": token,
"currency": currency,
"network": network,
"amount": amountFloat,
"notify_url": notifyURL,
"order_id": outTradeNo,
+649
View File
@@ -419,6 +419,168 @@ func TestCreateOrderGmpayV1FormData(t *testing.T) {
t.Logf("Form-data order created: trade_id=%v", data["trade_id"])
}
func TestCreateOrderGmpayV1PlaceholderWithoutTokenNetwork(t *testing.T) {
e := setupTestEnv(t)
body := signBody(map[string]interface{}{
"order_id": "test-placeholder-001",
"amount": 1.00,
"currency": "cny",
"notify_url": "https://93.184.216.34/notify",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
respData, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected data in response, got: %v", resp)
}
tradeID, _ := respData["trade_id"].(string)
if tradeID == "" {
t.Fatalf("missing trade_id in response: %v", respData)
}
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload placeholder order: %v", err)
}
if order.PaymentType != mdb.PaymentTypeGmpay {
t.Fatalf("placeholder order payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeGmpay)
}
if got := int(respData["status"].(float64)); got != mdb.StatusWaitSelect {
t.Fatalf("status = %d, want %d", got, mdb.StatusWaitSelect)
}
if respData["token"] != "" || respData["receive_address"] != "" || respData["actual_amount"].(float64) != 0 {
t.Fatalf("placeholder chain fields = %#v", respData)
}
checkoutData := getCheckoutCounterRespData(t, e, tradeID)
if got := int(checkoutData["status"].(float64)); got != mdb.StatusWaitSelect {
t.Fatalf("checkout status = %d, want %d", got, mdb.StatusWaitSelect)
}
if checkoutData["actual_amount"].(float64) != 0 || checkoutData["token"] != "" || checkoutData["network"] != "" || checkoutData["receive_address"] != "" {
t.Fatalf("placeholder checkout chain fields = %#v", checkoutData)
}
if checkoutData["payment_type"] != "gmpay" {
t.Fatalf("placeholder checkout payment_type = %v, want gmpay", checkoutData["payment_type"])
}
if checkoutData["is_selected"].(bool) {
t.Fatalf("placeholder checkout is_selected = true, want false")
}
statusReq := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
statusRec := httptest.NewRecorder()
e.ServeHTTP(statusRec, statusReq)
if statusRec.Code != http.StatusOK {
t.Fatalf("check-status expected 200, got %d: %s", statusRec.Code, statusRec.Body.String())
}
statusResp := parseResp(t, statusRec)
statusData := statusResp["data"].(map[string]interface{})
if got := int(statusData["status"].(float64)); got != mdb.StatusWaitSelect {
t.Fatalf("check-status status = %d, want %d", got, mdb.StatusWaitSelect)
}
}
func TestCreateOrderGmpayV1RejectsPartialTokenNetwork(t *testing.T) {
e := setupTestEnv(t)
body := signBody(map[string]interface{}{
"order_id": "test-placeholder-partial-001",
"amount": 1.00,
"currency": "cny",
"network": "tron",
"notify_url": "https://93.184.216.34/notify",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
if got := int(resp["status_code"].(float64)); got != 10009 {
t.Fatalf("status_code = %d, want 10009; response=%v", got, resp)
}
}
func TestSwitchNetworkCompletesGmpayPlaceholderInPlace(t *testing.T) {
e := setupTestEnv(t)
createBody := signBody(map[string]interface{}{
"order_id": "test-placeholder-switch-001",
"amount": 1.00,
"currency": "cny",
"notify_url": "https://93.184.216.34/notify",
})
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
if createRec.Code != http.StatusOK {
t.Fatalf("create placeholder failed: %d %s", createRec.Code, createRec.Body.String())
}
createResp := parseResp(t, createRec)
tradeID, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
if tradeID == "" {
t.Fatal("missing trade_id in create response")
}
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": tradeID,
"token": "USDT",
"network": "tron",
})
if rec.Code != http.StatusOK {
t.Fatalf("switch placeholder failed: %d %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
respData, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected data in switch response, got: %v", resp)
}
if got, _ := respData["trade_id"].(string); got != tradeID {
t.Fatalf("switch trade_id = %q, want parent %q", got, tradeID)
}
if got := int(respData["status"].(float64)); got != mdb.StatusWaitPay {
t.Fatalf("switch status = %d, want %d", got, mdb.StatusWaitPay)
}
if got, _ := respData["is_selected"].(bool); got {
t.Fatalf("switch is_selected = %v, want false", respData["is_selected"])
}
if respData["payment_url"] != "" {
t.Fatalf("switch payment_url = %v, want empty for unselected chain parent", respData["payment_url"])
}
if respData["payment_type"] != "gmpay" {
t.Fatalf("switch payment_type = %v, want gmpay", respData["payment_type"])
}
if respData["network"] != "tron" || respData["token"] != "USDT" || respData["receive_address"] != "TTestTronAddress001" {
t.Fatalf("switch chain fields = %#v", respData)
}
selectRec := doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": tradeID,
"token": "USDT",
"network": "tron",
})
if selectRec.Code != http.StatusOK {
t.Fatalf("select completed parent failed: %d %s", selectRec.Code, selectRec.Body.String())
}
selectResp := parseResp(t, selectRec)
selectData, _ := selectResp["data"].(map[string]interface{})
if got, _ := selectData["trade_id"].(string); got != tradeID {
t.Fatalf("select trade_id = %q, want parent %q", got, tradeID)
}
if got, _ := selectData["is_selected"].(bool); !got {
t.Fatalf("select is_selected = %v, want true", selectData["is_selected"])
}
count, err := data.CountActiveSubOrders(tradeID)
if err != nil {
t.Fatalf("count sub orders: %v", err)
}
if count != 0 {
t.Fatalf("active sub-order count = %d, want 0", count)
}
}
// getSupportedNetworks is a helper that calls GET /payments/gmpay/v1/config
// and returns a map of network → []token for easy assertions.
func getSupportedNetworks(t *testing.T, e *echo.Echo) map[string][]string {
@@ -549,6 +711,12 @@ func TestGetPublicConfig(t *testing.T) {
t.Cleanup(func() {
config.BuildVersion = oldVersion
})
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "tron", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_network: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandCheckoutName, "asd", mdb.SettingTypeString); err != nil {
t.Fatalf("seed brand.checkout_name: %v", err)
}
@@ -665,6 +833,31 @@ func TestGetPublicConfig(t *testing.T) {
}
}
func TestGetPublicConfig_EpayDefaultsReturnEmptyAfterDelete(t *testing.T) {
e := setupTestEnv(t)
if err := data.DeleteSetting(mdb.SettingKeyEpayDefaultToken); err != nil {
t.Fatalf("delete epay.default_token: %v", err)
}
if err := data.DeleteSetting(mdb.SettingKeyEpayDefaultNetwork); err != nil {
t.Fatalf("delete epay.default_network: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/config", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
respData := resp["data"].(map[string]interface{})
epay := respData["epay"].(map[string]interface{})
if epay["default_token"] != "" || epay["default_network"] != "" {
t.Fatalf("epay defaults = token %v network %v, want empty", epay["default_token"], epay["default_network"])
}
}
func TestGetPublicConfig_BrandLegacyFallback(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandSiteName, "legacy cashier", mdb.SettingTypeString); err != nil {
@@ -1010,6 +1203,13 @@ func TestCreateOrderNetworkIsolation(t *testing.T) {
func TestEpaySubmitPhpGetCompatible(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "tron", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_network: %v", err)
}
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-get-001"},
@@ -1030,6 +1230,141 @@ func TestEpaySubmitPhpGetCompatible(t *testing.T) {
if !strings.HasPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/") {
t.Fatalf("expected checkout redirect, got %q", rec.Header().Get("Location"))
}
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload epay order: %v", err)
}
if order.Status != mdb.StatusWaitPay || order.Token != "USDT" || order.Network != mdb.NetworkTron || order.ReceiveAddress == "" || order.ActualAmount <= 0 {
t.Fatalf("epay order should be concrete status=1 chain order, got status=%d token=%q network=%q address=%q actual=%.4f", order.Status, order.Token, order.Network, order.ReceiveAddress, order.ActualAmount)
}
if order.PaymentType != mdb.PaymentTypeEpay {
t.Fatalf("epay payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay)
}
checkoutData := getCheckoutCounterRespData(t, e, tradeID)
if checkoutData["payment_type"] != "epay" {
t.Fatalf("epay checkout payment_type = %v, want epay", checkoutData["payment_type"])
}
}
func TestEpaySubmitPhpRequestTokenNetworkOverrideDefaults(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "tron", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_network: %v", err)
}
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-override-001"},
"type": {"alipay"},
"money": {"1.00"},
"out_trade_no": {"epay-override-001"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
"token": {"usdt"},
"network": {"solana"},
"currency": {"cny"},
})
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
}
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload epay order: %v", err)
}
if order.Network != mdb.NetworkSolana || order.ReceiveAddress != "SolTestAddress001" || order.Token != "USDT" {
t.Fatalf("epay override order fields = network %q address %q token %q", order.Network, order.ReceiveAddress, order.Token)
}
if order.PaymentType != mdb.PaymentTypeEpay {
t.Fatalf("payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay)
}
}
func TestEpaySubmitPhpWithoutTokenNetworkDefaultsCreatesPlaceholder(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "", mdb.SettingTypeString); err != nil {
t.Fatalf("clear epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "", mdb.SettingTypeString); err != nil {
t.Fatalf("clear epay.default_network: %v", err)
}
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-placeholder-001"},
"type": {"alipay"},
"money": {"1.00"},
"out_trade_no": {"epay-placeholder-001"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
})
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
}
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("reload epay placeholder: %v", err)
}
if order.Status != mdb.StatusWaitSelect || order.Token != "" || order.Network != "" || order.ReceiveAddress != "" || order.ActualAmount != 0 {
t.Fatalf("epay placeholder fields = status %d token %q network %q address %q actual %v", order.Status, order.Token, order.Network, order.ReceiveAddress, order.ActualAmount)
}
if order.PaymentType != mdb.PaymentTypeEpay {
t.Fatalf("payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay)
}
checkoutData := getCheckoutCounterRespData(t, e, tradeID)
if checkoutData["payment_type"] != "epay" || int(checkoutData["status"].(float64)) != mdb.StatusWaitSelect {
t.Fatalf("checkout placeholder data = %#v", checkoutData)
}
}
func TestEpaySubmitPhpRejectsPartialResolvedTokenNetwork(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "", mdb.SettingTypeString); err != nil {
t.Fatalf("clear epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "tron", mdb.SettingTypeString); err != nil {
t.Fatalf("seed epay.default_network: %v", err)
}
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-partial-001"},
"type": {"alipay"},
"money": {"1.00"},
"out_trade_no": {"epay-partial-001"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
})
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
if got := int(resp["status_code"].(float64)); got != 10009 {
t.Fatalf("status_code = %d, want 10009", got)
}
}
func TestEpaySubmitPhpPostFormCompatible(t *testing.T) {
@@ -1399,6 +1734,22 @@ func TestCheckoutCounterResp_ReturnsPaidOrder(t *testing.T) {
}
}
func TestCheckoutCounterResp_NormalizesPaymentTypeCase(t *testing.T) {
e := setupTestEnv(t)
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-ptype-case-001")
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("payment_type", "epay").Error; err != nil {
t.Fatalf("set lowercase payment_type: %v", err)
}
data := getCheckoutCounterRespData(t, e, tradeID)
if data["payment_type"] != "epay" {
t.Fatalf("payment_type = %v, want epay", data["payment_type"])
}
}
func TestCheckoutCounterResp_ReturnsExpiredOrder(t *testing.T) {
e := setupTestEnv(t)
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-expired-001")
@@ -1690,6 +2041,304 @@ func TestSwitchNetwork_OkPayCreatesProviderSubOrder(t *testing.T) {
}
}
func TestSwitchNetwork_OkPayFromWaitSelectPlaceholder(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayEnabled, "true", mdb.SettingTypeBool); err != nil {
t.Fatalf("seed okpay.enabled: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, "shop-placeholder", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_id: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, "token-placeholder", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayCallbackURL, "https://example.com/okpay/notify", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.callback_url: %v", err)
}
origFactory := http_client.ClientFactory
http_client.ClientFactory = func() *resty.Client {
return stubRestyClient(func(req *http.Request) (*http.Response, error) {
if req.URL.String() != "https://api.okaypay.me/shop/payLink" {
t.Fatalf("unexpected okpay URL: %s", req.URL.String())
}
if err := req.ParseForm(); err != nil {
t.Fatalf("parse form: %v", err)
}
if got := req.Form.Get("coin"); got != "USDT" {
t.Fatalf("coin = %q, want USDT", got)
}
body := `{"status":"success","code":200,"data":{"order_id":"okp-placeholder-1","pay_url":"https://pay.okaypay.test/placeholder"}}`
header := make(http.Header)
header.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: header,
Body: io.NopCloser(strings.NewReader(body)),
}, nil
})
}
t.Cleanup(func() {
http_client.ClientFactory = origFactory
})
createBody := signBody(map[string]interface{}{
"order_id": "switch-okpay-placeholder-001",
"amount": 1.00,
"currency": "cny",
"notify_url": "https://93.184.216.34/notify",
})
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
if createRec.Code != http.StatusOK {
t.Fatalf("create placeholder failed: %d %s", createRec.Code, createRec.Body.String())
}
createResp := parseResp(t, createRec)
tradeID, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
if tradeID == "" {
t.Fatal("missing parent trade_id")
}
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": tradeID,
"token": "USDT",
"network": "okpay",
})
if rec.Code != http.StatusOK {
t.Fatalf("switch placeholder to okpay failed: %d %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
dataMap, _ := resp["data"].(map[string]interface{})
if dataMap["payment_url"] != "https://pay.okaypay.test/placeholder" {
t.Fatalf("payment_url = %v, want okpay url", dataMap["payment_url"])
}
if got, _ := dataMap["trade_id"].(string); got != tradeID {
t.Fatalf("switch trade_id = %q, want parent %q", got, tradeID)
}
parent, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("load parent order: %v", err)
}
if parent.Status != mdb.StatusWaitPay {
t.Fatalf("parent status = %d, want %d", parent.Status, mdb.StatusWaitPay)
}
if parent.IsSelected {
t.Fatal("parent is_selected = true, want false")
}
if parent.PayProvider != mdb.PaymentProviderOkPay || parent.Token != "USDT" || parent.Network != mdb.PaymentProviderOkPay || parent.ReceiveAddress != "OKPAY" || parent.ActualAmount <= 0 {
t.Fatalf("okpay placeholder parent fields = provider=%q token=%q network=%q address=%q actual=%v", parent.PayProvider, parent.Token, parent.Network, parent.ReceiveAddress, parent.ActualAmount)
}
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(tradeID, mdb.PaymentProviderOkPay)
if err != nil {
t.Fatalf("load provider row: %v", err)
}
if providerRow.ProviderOrderID != "okp-placeholder-1" || providerRow.PayURL != "https://pay.okaypay.test/placeholder" {
t.Fatalf("provider row = order_id %q pay_url %q", providerRow.ProviderOrderID, providerRow.PayURL)
}
var lockCount int64
if err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
Where("trade_id = ?", tradeID).
Count(&lockCount).Error; err != nil {
t.Fatalf("count transaction locks: %v", err)
}
if lockCount != 0 {
t.Fatalf("transaction lock count = %d, want 0", lockCount)
}
checkoutData := getCheckoutCounterRespData(t, e, tradeID)
if checkoutData["payment_url"] != "https://pay.okaypay.test/placeholder" {
t.Fatalf("checkout payment_url = %v, want okpay url", checkoutData["payment_url"])
}
count, err := data.CountActiveSubOrders(tradeID)
if err != nil {
t.Fatalf("count active sub-orders: %v", err)
}
if count != 0 {
t.Fatalf("active sub-order count after first okpay switch = %d, want 0", count)
}
secondRec := doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": tradeID,
"token": "USDT",
"network": "solana",
})
if secondRec.Code != http.StatusOK {
t.Fatalf("switch okpay parent to solana failed: %d %s", secondRec.Code, secondRec.Body.String())
}
secondResp := parseResp(t, secondRec)
secondData, _ := secondResp["data"].(map[string]interface{})
subTradeID, _ := secondData["trade_id"].(string)
if subTradeID == "" || subTradeID == tradeID {
t.Fatalf("expected chain child trade_id, got %q", subTradeID)
}
subOrder, err := data.GetOrderInfoByTradeId(subTradeID)
if err != nil {
t.Fatalf("load chain sub-order: %v", err)
}
if subOrder.ParentTradeId != tradeID || subOrder.PayProvider != mdb.PaymentProviderOnChain || subOrder.Network != mdb.NetworkSolana {
t.Fatalf("sub-order parent/provider/network = %q/%q/%q", subOrder.ParentTradeId, subOrder.PayProvider, subOrder.Network)
}
}
func TestSwitchNetwork_EpayPlaceholderCompletesChainInPlace(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "", mdb.SettingTypeString); err != nil {
t.Fatalf("clear epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "", mdb.SettingTypeString); err != nil {
t.Fatalf("clear epay.default_network: %v", err)
}
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-switch-chain-ph-001"},
"type": {"alipay"},
"money": {"1.00"},
"out_trade_no": {"epay-switch-chain-ph-001"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
})
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
createRec := httptest.NewRecorder()
e.ServeHTTP(createRec, req)
if createRec.Code != http.StatusFound {
t.Fatalf("create epay placeholder failed: %d %s", createRec.Code, createRec.Body.String())
}
tradeID := strings.TrimPrefix(createRec.Header().Get("Location"), "/pay/checkout-counter/")
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": tradeID,
"token": "USDT",
"network": "tron",
})
if rec.Code != http.StatusOK {
t.Fatalf("switch epay placeholder to chain failed: %d %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
respData, _ := resp["data"].(map[string]interface{})
if got, _ := respData["trade_id"].(string); got != tradeID {
t.Fatalf("switch trade_id = %q, want parent %q", got, tradeID)
}
parent, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("load parent: %v", err)
}
if parent.Status != mdb.StatusWaitPay || parent.PaymentType != mdb.PaymentTypeEpay || parent.Network != mdb.NetworkTron || parent.Token != "USDT" || parent.ReceiveAddress == "" {
t.Fatalf("parent fields = status %d payment_type %q network %q token %q address %q", parent.Status, parent.PaymentType, parent.Network, parent.Token, parent.ReceiveAddress)
}
count, err := data.CountActiveSubOrders(tradeID)
if err != nil {
t.Fatalf("count active sub-orders: %v", err)
}
if count != 0 {
t.Fatalf("active sub-order count = %d, want 0", count)
}
}
func TestSwitchNetwork_OkPayFromEpayWaitSelectPlaceholder(t *testing.T) {
e := setupTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "", mdb.SettingTypeString); err != nil {
t.Fatalf("clear epay.default_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "", mdb.SettingTypeString); err != nil {
t.Fatalf("clear epay.default_network: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayEnabled, "true", mdb.SettingTypeBool); err != nil {
t.Fatalf("seed okpay.enabled: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, "shop-epay-placeholder", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_id: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, "token-epay-placeholder", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.shop_token: %v", err)
}
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayCallbackURL, "https://example.com/okpay/notify", mdb.SettingTypeString); err != nil {
t.Fatalf("seed okpay.callback_url: %v", err)
}
origFactory := http_client.ClientFactory
http_client.ClientFactory = func() *resty.Client {
return stubRestyClient(func(req *http.Request) (*http.Response, error) {
body := `{"status":"success","code":200,"data":{"order_id":"okp-epay-placeholder-1","pay_url":"https://pay.okaypay.test/epay-placeholder"}}`
header := make(http.Header)
header.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: header,
Body: io.NopCloser(strings.NewReader(body)),
}, nil
})
}
t.Cleanup(func() {
http_client.ClientFactory = origFactory
})
values := signEpayValues(url.Values{
"pid": {"1"},
"name": {"epay-switch-okpay-ph-001"},
"type": {"alipay"},
"money": {"1.00"},
"out_trade_no": {"epay-switch-okpay-ph-001"},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {"http://localhost/return"},
})
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
createRec := httptest.NewRecorder()
e.ServeHTTP(createRec, req)
if createRec.Code != http.StatusFound {
t.Fatalf("create epay placeholder failed: %d %s", createRec.Code, createRec.Body.String())
}
tradeID := strings.TrimPrefix(createRec.Header().Get("Location"), "/pay/checkout-counter/")
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
"trade_id": tradeID,
"token": "USDT",
"network": "okpay",
})
if rec.Code != http.StatusOK {
t.Fatalf("switch epay placeholder to okpay failed: %d %s", rec.Code, rec.Body.String())
}
resp := parseResp(t, rec)
respData, _ := resp["data"].(map[string]interface{})
if got, _ := respData["trade_id"].(string); got != tradeID {
t.Fatalf("switch trade_id = %q, want parent %q", got, tradeID)
}
if respData["payment_url"] != "https://pay.okaypay.test/epay-placeholder" {
t.Fatalf("payment_url = %v, want okpay url", respData["payment_url"])
}
parent, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("load parent: %v", err)
}
if parent.PaymentType != mdb.PaymentTypeEpay || parent.PayProvider != mdb.PaymentProviderOkPay || parent.Network != mdb.PaymentProviderOkPay || parent.ReceiveAddress != "OKPAY" {
t.Fatalf("parent fields = payment_type %q provider %q network %q address %q", parent.PaymentType, parent.PayProvider, parent.Network, parent.ReceiveAddress)
}
count, err := data.CountActiveSubOrders(tradeID)
if err != nil {
t.Fatalf("count active sub-orders: %v", err)
}
if count != 0 {
t.Fatalf("active sub-order count = %d, want 0", count)
}
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(tradeID, mdb.PaymentProviderOkPay)
if err != nil {
t.Fatalf("load provider row: %v", err)
}
if providerRow.ProviderOrderID != "okp-epay-placeholder-1" {
t.Fatalf("provider_order_id = %q", providerRow.ProviderOrderID)
}
}
func TestSwitchNetwork_OkPayIntegration(t *testing.T) {
shopID := strings.TrimSpace(os.Getenv("EPUSDT_OKPAY_ID"))
if shopID == "" {
+64 -10
View File
@@ -56,6 +56,7 @@
注意:
- `pid` 必须参与签名。
- GMPay 的 `payment_type` 不是必填;如果请求里传了非空 `payment_type`,它和其他非空参数一样必须参与签名。
- 空字符串和 `null` 不参与签名。
- 参数名区分大小写。
- JSON 数字会按服务端数字格式参与签名,例如 `100.00` 会被解析为 `100`;如果需要保留字符串格式,可使用 `application/x-www-form-urlencoded`
@@ -163,15 +164,17 @@ function epaySign(array $params, string $secretKey): string
| `pid` | string | 是 | 商户 PID,用于查找 API Key,并参与签名。 |
| `order_id` | string | 是 | 商户订单号,最长 32 字符,不能重复。 |
| `currency` | string | 是 | 法币币种,如 `cny``usd`。 |
| `token` | string | | 收款币种,如 `usdt``trx``usdc``sol`。 |
| `network` | string | | 收款网络,如 `tron``solana``ethereum``bsc``polygon``plasma`。 |
| `token` | string | 条件必填 | 收款币种,如 `usdt``trx``usdc``sol`GMPay 可与 `network` 同时省略以创建状态 `4` 占位订单。 |
| `network` | string | 条件必填 | 收款网络,如 `tron``solana``ethereum``bsc``polygon``plasma`GMPay 可与 `token` 同时省略以创建状态 `4` 占位订单。 |
| `amount` | number | 是 | 法币金额,必须大于 `0.01`。 |
| `notify_url` | string | 是 | 支付成功异步回调地址。 |
| `redirect_url` | string | 否 | 支付完成后的同步跳转地址。 |
| `name` | string | 否 | 商品/订单名称。 |
| `payment_type` | string | 否 | 兼容字段。普通 GMPay 不需要传;传 `Epay`使用 EPay 回调格式,且 PID 必须是数字。 |
| `payment_type` | string | 否 | GMPay 兼容字段,不要求必须传;如果传了非空值,必须参与 GMPay `signature` 计算。普通 GMPay 不传时后台会存为 `Gmpay`;传 `Epay`(大小写不敏感)会统一存为 `Epay`使用 EPay 回调格式,且 PID 必须是数字。 |
| `signature` | string | 是 | GMPay 签名。 |
`token``network` 必须同传或同缺。两者同缺时只创建包含 `amount/currency` 的占位订单,状态为 `4`,不会分配钱包、不会计算链上支付金额,也不会锁定交易金额;后续由收银台调用 `/pay/switch-network` 选择具体链和币种或 OkPay。只缺其中一个会返回参数错误。
建议先调用 `/payments/gmpay/v1/config` 获取可用的 `network``token` 组合。
### 成功响应
@@ -188,6 +191,7 @@ function epaySign(array $params, string $secretKey): string
"actual_amount": 14.29,
"receive_address": "TTestTronAddress001",
"token": "USDT",
"status": 1,
"expiration_time": 1779530812,
"payment_url": "https://pay.example.com/pay/checkout-counter/20260523171652123456001"
},
@@ -204,9 +208,12 @@ function epaySign(array $params, string $secretKey): string
| `actual_amount` | number | 实际需支付的加密货币数量。 |
| `receive_address` | string | 收款地址。 |
| `token` | string | 收款币种。 |
| `status` | integer | 订单状态。状态 `4` 表示等待用户选择 `token/network`。 |
| `expiration_time` | integer | 订单过期时间,秒级时间戳。 |
| `payment_url` | string | 收银台地址。该地址会跳转到前端收银台。 |
状态 `4` 占位订单的 `actual_amount``0``receive_address``token` 为空;过期任务或后台关闭只会把它改为状态 `3`,不会执行交易金额解锁。第一次成功调用 `/pay/switch-network` 时,如果选择普通链上 `token/network`,同一个父订单会原地补全链上字段并变为状态 `1`,此时才会创建真实交易锁;如果选择 `network=okpay`,同一个父订单会原地变为 OkPay 订单并返回 OkPay 托管支付链接,不创建子订单,也不会分配本系统钱包地址或链上锁。占位父单首次补全后 `is_selected` 仍为 `false`,后续同目标选择才会把父单标记为已选中;如果后续切到其它支付目标,则创建唯一一条子订单。
## 获取公开支付配置
`GET /payments/gmpay/v1/config`
@@ -241,9 +248,9 @@ function epaySign(array $params, string $secretKey): string
"background_image_url": "https://cdn.example.com/background.png"
},
"epay": {
"default_token": "usdt",
"default_token": "",
"default_currency": "cny",
"default_network": "tron"
"default_network": ""
},
"okpay": {
"enabled": false,
@@ -293,6 +300,8 @@ function epaySign(array $params, string $secretKey): string
"currency": "CNY",
"receive_address": "TTestTronAddress001",
"network": "tron",
"status": 1,
"payment_type": "gmpay",
"expiration_time": 1779530812000,
"redirect_url": "https://merchant.example/return",
"payment_url": "",
@@ -305,6 +314,36 @@ function epaySign(array $params, string $secretKey): string
注意:该接口的 `expiration_time``created_at` 是毫秒级时间戳。
如果订单是状态 `4` 占位订单,返回的仍是同一个父订单 `trade_id`,但链上支付字段尚未生成。该状态可能来自 GMPay 空 token/network 创建,也可能来自 EPay submit.php 在请求和数据库默认值都没有完整 token/network 时创建:
```json
{
"status_code": 200,
"message": "success",
"data": {
"trade_id": "20260523171652123456001",
"amount": 100,
"actual_amount": 0,
"token": "",
"currency": "CNY",
"receive_address": "",
"network": "",
"status": 4,
"payment_type": "gmpay",
"expiration_time": 1779530812000,
"redirect_url": "https://merchant.example/return",
"payment_url": "",
"created_at": 1779530212000,
"is_selected": false
},
"request_id": "b1344d70-ff19-4543-b601-37abfb3b3686"
}
```
`payment_type` 是归一化后的接入类型:底层订单存储为 `Epay/Gmpay`,该接口转为小写 `epay/gmpay` 返回;`epay` 会走 EPay 回调格式,`gmpay` 走默认 GMPay JSON 回调格式。
前端看到 `status=4` 时,应展示选择网络和币种/支付通道的界面,并在用户选择后调用 `/pay/switch-network`。选择链上支付成功后,该父订单会变为 `status=1``actual_amount``token``network``receive_address` 会被补全,但 `is_selected` 保持 `false`,由后续同目标选择流程标记为已选中。选择 OkPay 成功后,接口返回同一个父订单 `trade_id` 和第三方 `payment_url`;父订单会变为 `status=1``is_selected=false``pay_provider=okpay``network=okpay``receive_address=OKPAY`
## 查询支付状态
`GET /pay/check-status/{trade_id}`
@@ -330,6 +369,7 @@ function epaySign(array $params, string $secretKey): string
| `1` | 等待支付 |
| `2` | 支付成功 |
| `3` | 已过期 |
| `4` | 等待选择支付网络/币种 |
## 切换支付网络/通道
@@ -367,13 +407,16 @@ function epaySign(array $params, string $secretKey): string
### 成功响应
返回结构与收银台初始化数据一致。链上订单的 `payment_url` 通常是本地收银台地址OkPay 订单的 `payment_url` 是 OkPay 返回的托管支付链接。
返回结构与收银台初始化数据一致。链上订单的 `payment_url` 为空OkPay 订单的 `payment_url` 是 OkPay 返回的托管支付链接。若父订单仍是 `status=4`,首次切换链上或 OkPay 都会原地补全父订单并返回同一个 `trade_id`
说明:
- 只能对父订单切换网络,不能对子订单继续切换。
- 父订单必须处于等待支付状态。
- 每个父订单最多创建 2 个等待支付中的子订单。
- 父订单必须处于等待支付状态 `1`,或占位状态 `4`
- 状态 `4` 第一次选择具体链和币种时,会原地补全父订单并返回同一个 `trade_id`,不会创建子订单。
- 状态 `4` 第一次选择 `network=okpay` 时,不要求父订单已有链上字段;系统会原地把父订单补成 OkPay 订单并返回同一个 `trade_id` 与 OkPay `payment_url`,不会创建子订单。
- 状态 `4` 补全后订单变为状态 `1`,但 `is_selected` 保持 `false`;之后同目标选择会返回父单并标记选中,切到其它支付目标才创建子订单。
- 每个父订单最多创建 1 个子订单;已经创建过子订单后,不能再用该父单创建第二个新子订单。子订单本身不能继续切换网络。
- 如果切换到同一组 `token + network`,会返回已有订单。
## EPay 兼容创建交易
@@ -399,6 +442,9 @@ function epaySign(array $params, string $secretKey): string
| `return_url` | query/form | string | 否 | 支付完成后的同步跳转地址。 |
| `name` | query/form | string | 否 | 商品/订单名称。 |
| `type` | query/form | string | 否 | 兼容字段,如 `alipay`。创建订单时不决定实际链上币种。 |
| `token` | query/form | string | 否 | 可选收款币种。优先级高于后台 `epay.default_token`;传了就必须参与 EPay 签名。 |
| `network` | query/form | string | 否 | 可选收款网络。优先级高于后台 `epay.default_network`;传了就必须参与 EPay 签名。 |
| `currency` | query/form | string | 否 | 可选法币币种。优先级高于后台 `epay.default_currency`;传了就必须参与 EPay 签名。 |
| `sign` | query/form | string | 是 | EPay 签名。 |
| `sign_type` | query/form | string | 否 | 通常为 `MD5`。 |
@@ -420,7 +466,15 @@ money=100&name=VIP&notify_url=https://merchant.example/notify&out_trade_no=ORD20
sign=b865b0acbb2b01554c35a1bd33351452
```
EPay 接口会使用后台配置的默认 `token``currency``network` 创建实际订单,默认配置可通过 `/payments/gmpay/v1/config``epay` 字段查看。
EPay 接口解析 `token/network/currency` 的优先级:
- `token`:请求参数 `token` > 数据库 `epay.default_token` > 空。
- `network`:请求参数 `network` > 数据库 `epay.default_network` > 空。
- `currency`:请求参数 `currency` > 数据库 `epay.default_currency` > `cny`
- 最终 `token/network` 同时有值时,创建具体链上订单;同时为空时,创建状态 `4` 占位订单;只缺一个时返回参数错误。
- 服务端会在 EPay 签名校验通过后内部注入 `payment_type=Epay`,该字段不参与 EPay 入站签名;但请求里显式传入的 `token/network/currency` 属于原始 EPay 参数,必须参与签名。
后台默认配置可通过 `/payments/gmpay/v1/config``epay` 字段查看;新安装默认只预置 `epay.default_currency=cny``epay.default_token``epay.default_network` 为空,因此 EPay 未显式传 token/network 时会创建状态 `4` 占位订单。已有数据库的配置不会被 seed 覆盖,删除或置空 `epay.default_token``epay.default_network` 后,这两个字段会返回空字符串。
## 商户异步回调
@@ -498,7 +552,7 @@ success
fail
```
Epusdt 会按配置的 OkPay shop token 验证 OkPay 签名,成功后将对应 OkPay 订单标记为已支付,并触发父订单商户回调。
Epusdt 会按配置的 OkPay shop token 验证 OkPay 签名,成功后将对应 OkPay 订单标记为已支付,并触发商户回调;这个 OkPay 订单可能是由 `status=4` 占位父单原地补全而来,也可能是后续切换创建的子订单
## status_code 返回状态码及含义