mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 02:06:16 +00:00
feat(payment): support placeholder orders for GMPay and EPay
This commit is contained in:
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"` // 收银台地址
|
||||
}
|
||||
|
||||
@@ -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 ETH;status=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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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¬ify_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 返回状态码及含义
|
||||
|
||||
|
||||
Reference in New Issue
Block a user