From 3ab3659fe4b95a6a139c220fbc17b228f11cd5de Mon Sep 17 00:00:00 2001 From: line-6000 <154492442+line-6000@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:57:20 +0800 Subject: [PATCH] feat(payment): support placeholder orders for GMPay and EPay --- .github/workflows/sync-www.yml | 91 +++ src/controller/admin/order_controller.go | 14 +- src/controller/admin/settings_controller.go | 8 +- src/controller/comm/order_controller.go | 20 +- src/controller/comm/pay_controller.go | 5 +- .../comm/supported_asset_controller.go | 4 +- src/model/dao/mdb_table_init.go | 4 +- src/model/dao/mdb_table_init_test.go | 57 ++ src/model/data/order_admin_data.go | 6 +- src/model/data/order_data.go | 66 +- src/model/data/provider_order_data.go | 22 +- src/model/mdb/orders_mdb.go | 38 +- src/model/mdb/provider_order.go | 4 +- src/model/request/order_request.go | 10 +- src/model/response/order_response.go | 1 + src/model/response/pay_response.go | 26 +- src/model/response/support_response.go | 6 +- src/model/service/okpay_service_test.go | 73 ++ src/model/service/order_service.go | 331 +++++++-- src/model/service/order_service_test.go | 258 ++++++- src/model/service/pay_service.go | 18 + src/mq/worker.go | 21 +- src/mq/worker_test.go | 29 +- src/route/admin_router_test.go | 28 + src/route/router.go | 26 +- src/route/router_test.go | 649 ++++++++++++++++++ wiki/API.md | 74 +- 27 files changed, 1755 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/sync-www.yml diff --git a/.github/workflows/sync-www.yml b/.github/workflows/sync-www.yml new file mode 100644 index 0000000..21ab419 --- /dev/null +++ b/.github/workflows/sync-www.yml @@ -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" diff --git a/src/controller/admin/order_controller.go b/src/controller/admin/order_controller.go index d3ac5f1..243e744 100644 --- a/src/controller/admin/order_controller.go +++ b/src/controller/admin/order_controller.go @@ -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) } diff --git a/src/controller/admin/settings_controller.go b/src/controller/admin/settings_controller.go index 2d748b6..c8c4203 100644 --- a/src/controller/admin/settings_controller.go +++ b/src/controller/admin/settings_controller.go @@ -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. diff --git a/src/controller/comm/order_controller.go b/src/controller/comm/order_controller.go index 1b41c5a..ac2e5fb 100644 --- a/src/controller/comm/order_controller.go +++ b/src/controller/comm/order_controller.go @@ -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 diff --git a/src/controller/comm/pay_controller.go b/src/controller/comm/pay_controller.go index 205b825..3e9242d 100644 --- a/src/controller/comm/pay_controller.go +++ b/src/controller/comm/pay_controller.go @@ -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" diff --git a/src/controller/comm/supported_asset_controller.go b/src/controller/comm/supported_asset_controller.go index f8df9cd..c8aa38e 100644 --- a/src/controller/comm/supported_asset_controller.go +++ b/src/controller/comm/supported_asset_controller.go @@ -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(), diff --git a/src/model/dao/mdb_table_init.go b/src/model/dao/mdb_table_init.go index 59379d5..7357168 100644 --- a/src/model/dao/mdb_table_init.go +++ b/src/model/dao/mdb_table_init.go @@ -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}, diff --git a/src/model/dao/mdb_table_init_test.go b/src/model/dao/mdb_table_init_test.go index a1d3985..662ce6d 100644 --- a/src/model/dao/mdb_table_init_test.go +++ b/src/model/dao/mdb_table_init_test.go @@ -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 diff --git a/src/model/data/order_admin_data.go b/src/model/data/order_admin_data.go index f64d006..fc76f53 100644 --- a/src/model/data/order_admin_data.go +++ b/src/model/data/order_admin_data.go @@ -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 } diff --git a/src/model/data/order_data.go b/src/model/data/order_data.go index b70423e..f358c65 100644 --- a/src/model/data/order_data.go +++ b/src/model/data/order_data.go @@ -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 { diff --git a/src/model/data/provider_order_data.go b/src/model/data/provider_order_data.go index 9c91968..eb461b8 100644 --- a/src/model/data/provider_order_data.go +++ b/src/model/data/provider_order_data.go @@ -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{}{ diff --git a/src/model/mdb/orders_mdb.go b/src/model/mdb/orders_mdb.go index c54f55e..ba0a061 100644 --- a/src/model/mdb/orders_mdb.go +++ b/src/model/mdb/orders_mdb.go @@ -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 diff --git a/src/model/mdb/provider_order.go b/src/model/mdb/provider_order.go index 74b0de6..8012fd5 100644 --- a/src/model/mdb/provider_order.go +++ b/src/model/mdb/provider_order.go @@ -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 diff --git a/src/model/request/order_request.go b/src/model/request/order_request.go index 1c09948..c4d61b3 100644 --- a/src/model/request/order_request.go +++ b/src/model/request/order_request.go @@ -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 { diff --git a/src/model/response/order_response.go b/src/model/response/order_response.go index 2a65808..8f2151c 100644 --- a/src/model/response/order_response.go +++ b/src/model/response/order_response.go @@ -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"` // 收银台地址 } diff --git a/src/model/response/pay_response.go b/src/model/response/pay_response.go index 1b81357..04cea63 100644 --- a/src/model/response/pay_response.go +++ b/src/model/response/pay_response.go @@ -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 { diff --git a/src/model/response/support_response.go b/src/model/response/support_response.go index ed3d17a..cf4913c 100644 --- a/src/model/response/support_response.go +++ b/src/model/response/support_response.go @@ -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 { diff --git a/src/model/service/okpay_service_test.go b/src/model/service/okpay_service_test.go index cb9258e..bc007fc 100644 --- a/src/model/service/okpay_service_test.go +++ b/src/model/service/okpay_service_test.go @@ -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", diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index df7b6fc..b9d82c5 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -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 diff --git a/src/model/service/order_service_test.go b/src/model/service/order_service_test.go index 7eb6a13..bf81d02 100644 --- a/src/model/service/order_service_test.go +++ b/src/model/service/order_service_test.go @@ -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) } diff --git a/src/model/service/pay_service.go b/src/model/service/pay_service.go index 625c131..259b8e6 100644 --- a/src/model/service/pay_service.go +++ b/src/model/service/pay_service.go @@ -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 } diff --git a/src/mq/worker.go b/src/mq/worker.go index dfcb829..6ef3093 100644 --- a/src/mq/worker.go +++ b/src/mq/worker.go @@ -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 { diff --git a/src/mq/worker_test.go b/src/mq/worker_test.go index 4ed9549..4261a1d 100644 --- a/src/mq/worker_test.go +++ b/src/mq/worker_test.go @@ -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, } diff --git a/src/route/admin_router_test.go b/src/route/admin_router_test.go index dfad3e7..7a83da6 100644 --- a/src/route/admin_router_test.go +++ b/src/route/admin_router_test.go @@ -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) diff --git a/src/route/router.go b/src/route/router.go index 268bfa1..f3307e9 100644 --- a/src/route/router.go +++ b/src/route/router.go @@ -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, diff --git a/src/route/router_test.go b/src/route/router_test.go index da483f3..8da8548 100644 --- a/src/route/router_test.go +++ b/src/route/router_test.go @@ -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 == "" { diff --git a/wiki/API.md b/wiki/API.md index 652f6fa..10d40f5 100644 --- a/wiki/API.md +++ b/wiki/API.md @@ -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 返回状态码及含义