mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +00:00
feat: integrate okpay switch-network flow and notify handling
This commit is contained in:
@@ -24,6 +24,16 @@ import (
|
||||
// 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" (default)
|
||||
//
|
||||
// - group=okpay:
|
||||
// okpay.enabled (bool) — enable OkPay as a switch-network payment option
|
||||
// okpay.shop_id (string) — OkPay merchant/shop identifier
|
||||
// okpay.shop_token (string) — OkPay signing token
|
||||
// okpay.api_url (string) — OkPay API base URL
|
||||
// okpay.callback_url (string) — server callback URL used for OkPay notify
|
||||
// okpay.return_url (string) — optional default browser return URL after payment
|
||||
// okpay.timeout_seconds (int) — outbound OkPay API timeout in seconds
|
||||
// okpay.allow_tokens (string) — comma-separated allowed tokens, e.g. "USDT,TRX"
|
||||
//
|
||||
// - group=brand:
|
||||
// brand.site_name (string) — site display name
|
||||
// brand.logo_url (string) — logo image URL
|
||||
@@ -34,7 +44,7 @@ import (
|
||||
// - group=system:
|
||||
// system.order_expiration_time (int) — order expiry in minutes
|
||||
type SettingUpsertItem struct {
|
||||
Group string `json:"group" enums:"brand,rate,system,epay" example:"epay"`
|
||||
Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"`
|
||||
Key string `json:"key" example:"epay.default_network"`
|
||||
Value string `json:"value" example:"tron"`
|
||||
Type string `json:"type" enums:"string,int,bool,json" example:"string"`
|
||||
@@ -48,12 +58,12 @@ type SettingsUpsertRequest struct {
|
||||
// ListSettings returns all rows, optionally filtered by group.
|
||||
// @Summary List settings
|
||||
// @Description Returns all settings, optionally filtered by group.
|
||||
// @Description Available groups: brand, rate, system, epay.
|
||||
// @Description Available groups: brand, rate, system, epay, okpay.
|
||||
// @Description See SettingUpsertItem for the full list of supported keys per group.
|
||||
// @Tags Admin Settings
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param group query string false "Group filter (brand|rate|system|epay)"
|
||||
// @Param group query string false "Group filter (brand|rate|system|epay|okpay)"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.Setting}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/settings [get]
|
||||
@@ -71,8 +81,9 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
||||
// ones. Errors are returned per-key so the UI can surface them.
|
||||
// @Summary Upsert settings
|
||||
// @Description Batch insert/update settings. Returns per-key status.
|
||||
// @Description Supported groups: brand, rate, system, epay.
|
||||
// @Description Supported groups: brand, rate, system, epay, okpay.
|
||||
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
|
||||
// @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_usdt_rate (>0 overrides USDT/CNY; <=0 uses rate.api_url), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
||||
// @Description brand group keys: brand.site_name, brand.logo_url, brand.page_title, brand.pay_success_text, brand.support_url.
|
||||
// @Description system group keys: system.order_expiration_time.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package comm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/service"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// OkPayNotify receives the server-to-server OkPay payment callback.
|
||||
// The handler accepts JSON or form-urlencoded payloads and returns plain-text
|
||||
// acknowledgements expected by the upstream provider.
|
||||
// @Summary OkPay notify callback
|
||||
// @Description Receives OkPay deposit callbacks for hosted checkout child orders.
|
||||
// @Description The callback is verified against the configured OkPay shop token and, on success, marks the matching child order as paid.
|
||||
// @Tags Payment
|
||||
// @Accept json
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce plain
|
||||
// @Success 200 {string} string "success"
|
||||
// @Failure 400 {string} string "fail"
|
||||
// @Router /payments/okpay/v1/notify [post]
|
||||
func (c *BaseCommController) OkPayNotify(ctx echo.Context) error {
|
||||
req := ctx.Request()
|
||||
rawBody, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[okpay] read notify body failed err=%v", err)
|
||||
return ctx.String(http.StatusBadRequest, "fail")
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(rawBody))
|
||||
|
||||
form := make(map[string]string)
|
||||
copyForm := func(values url.Values) {
|
||||
for key, items := range values {
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
form[key] = items[0]
|
||||
}
|
||||
}
|
||||
|
||||
copyForm(req.URL.Query())
|
||||
|
||||
contentType := strings.ToLower(req.Header.Get(echo.HeaderContentType))
|
||||
|
||||
if strings.Contains(contentType, echo.MIMEApplicationJSON) && len(rawBody) > 0 {
|
||||
if jsonForm, jsonErr := flattenOkPayJSONBody(rawBody); jsonErr == nil {
|
||||
for key, value := range jsonForm {
|
||||
form[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match the PHP example's $_POST semantics: let Echo/net/http parse both
|
||||
// application/x-www-form-urlencoded and multipart/form-data.
|
||||
if len(form) == 0 {
|
||||
req.Body = io.NopCloser(bytes.NewReader(rawBody))
|
||||
if parsedForm, formErr := ctx.FormParams(); formErr == nil {
|
||||
copyForm(parsedForm)
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback for raw query-string style bodies.
|
||||
if len(form) == 0 && len(rawBody) > 0 && looksLikeQueryPayload(string(rawBody)) {
|
||||
if parsed, parseErr := url.ParseQuery(string(rawBody)); parseErr == nil && len(parsed) > 0 {
|
||||
copyForm(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
rawFormData := string(rawBody)
|
||||
if rawFormData == "" {
|
||||
rawFormData = req.URL.RawQuery
|
||||
}
|
||||
|
||||
if len(form) == 0 {
|
||||
log.Sugar.Warnf("[okpay] empty notify payload method=%s content_type=%s remote=%s raw_query=%q raw_body=%q", req.Method, req.Header.Get(echo.HeaderContentType), ctx.RealIP(), req.URL.RawQuery, rawFormData)
|
||||
return ctx.String(http.StatusBadRequest, "fail")
|
||||
}
|
||||
|
||||
if err = service.HandleOkPayNotify(form, rawFormData); err != nil {
|
||||
log.Sugar.Warnf("[okpay] notify handle failed method=%s content_type=%s remote=%s form=%v raw_query=%q raw_body=%q err=%v", req.Method, req.Header.Get(echo.HeaderContentType), ctx.RealIP(), form, req.URL.RawQuery, rawFormData, err)
|
||||
return ctx.String(http.StatusBadRequest, "fail")
|
||||
}
|
||||
return ctx.String(http.StatusOK, "success")
|
||||
}
|
||||
|
||||
func flattenOkPayJSONBody(rawBody []byte) (map[string]string, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
||||
decoder.UseNumber()
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flat := make(map[string]string)
|
||||
for key, value := range payload {
|
||||
if key == "data" {
|
||||
dataMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for dataKey, dataValue := range dataMap {
|
||||
flat["data["+dataKey+"]"] = stringifyOkPayJSONValue(dataValue)
|
||||
}
|
||||
continue
|
||||
}
|
||||
flat[key] = stringifyOkPayJSONValue(value)
|
||||
}
|
||||
return flat, nil
|
||||
}
|
||||
|
||||
func stringifyOkPayJSONValue(value interface{}) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case json.Number:
|
||||
return v.String()
|
||||
case float64:
|
||||
if v == math.Trunc(v) {
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeQueryPayload(raw string) bool {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(trimmed, "=")
|
||||
}
|
||||
@@ -60,12 +60,14 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
||||
|
||||
// SwitchNetwork 切换支付网络,创建或返回子订单
|
||||
// @Summary Switch payment network
|
||||
// @Description Switch to a different payment network, creating or returning a sub-order
|
||||
// @Description Switch to a different payment target, creating or returning a sub-order.
|
||||
// @Description Normal values such as 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.
|
||||
// @Tags Payment
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /pay/switch-network [post]
|
||||
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
||||
|
||||
@@ -5,38 +5,24 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/middleware"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// GetSupportedAssets 对外公开可用链与 token 列表。数据源是管理后台
|
||||
// 的 chains + chain_tokens + wallet_address 三张表的实时查询,admin
|
||||
// 启用/禁用链或代币后,这个接口在下次调用时就会反映新状态(无缓存)。
|
||||
// 仅返回:链 enabled=true、代币 enabled=true、且该链上至少有一个可用
|
||||
// 钱包地址的组合。
|
||||
// @Summary List supported (network, tokens) groupings
|
||||
// @Description Public endpoint, Auth required in /admin/api/v1/supported-assets
|
||||
// @Tags Supported Assets
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=response.SupportedAssetsResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /payments/gmpay/v1/supported-assets [get]
|
||||
// @Router /admin/api/v1/supported-assets [get]
|
||||
func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
|
||||
func buildSupportedAssets() ([]response.NetworkTokenSupport, error) {
|
||||
chains, err := data.ListEnabledChains()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return nil, err
|
||||
}
|
||||
wallets, err := data.GetAvailableWalletAddress()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A chain only qualifies if it has at least one wallet available —
|
||||
// without a recipient address, payments on that chain can't be
|
||||
// processed even if the chain + token config says "enabled".
|
||||
networkHasWallet := make(map[string]struct{})
|
||||
for _, w := range wallets {
|
||||
networkHasWallet[strings.ToLower(w.Network)] = struct{}{}
|
||||
@@ -50,7 +36,7 @@ func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
|
||||
}
|
||||
tokens, err := data.ListEnabledChainTokensByNetwork(network)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
return nil, err
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
continue
|
||||
@@ -73,18 +59,51 @@ func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
return c.SucJson(ctx, response.SupportedAssetsResponse{Supports: supports})
|
||||
return supports, nil
|
||||
}
|
||||
|
||||
// ListSupportedAssetRecords 查询支持项明细(无需鉴权)。
|
||||
// @Summary List supported asset records
|
||||
// @Description Public endpoint. Returns enabled chain_tokens, optionally filtered by network.
|
||||
// @Tags Supported Assets
|
||||
// GetPublicConfig returns payment config consumed by cashier/frontends.
|
||||
// The public route returns only non-sensitive epay/okpay knobs, while the
|
||||
// authenticated admin route also includes OkPay credentials and internal URLs.
|
||||
// @Summary Get public payment config
|
||||
// @Description Returns supported_assets and epay/okpay config used by cashier/frontends.
|
||||
// @Description The authenticated admin variant (/admin/api/v1/config) also includes OkPay shop credentials and internal callback settings.
|
||||
// @Tags Payment Config
|
||||
// @Produce json
|
||||
// @Param network query string false "Network filter (e.g. tron, ethereum)"
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.ChainToken}
|
||||
// @Success 200 {object} response.ApiResponse{data=response.PublicConfigResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /payments/gmpay/v1/supported-assets/records [get]
|
||||
// @Router /payments/gmpay/v1/config [get]
|
||||
// @Router /admin/api/v1/config [get]
|
||||
func (c *BaseCommController) GetPublicConfig(ctx echo.Context) error {
|
||||
supports, err := buildSupportedAssets()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
okpay := response.OkPayPublicConfig{
|
||||
Enabled: data.GetOkPayEnabled(),
|
||||
AllowTokens: data.GetOkPayAllowTokens(),
|
||||
}
|
||||
if ctx.Get(middleware.AdminUserIDKey) != nil {
|
||||
okpay.ShopID = data.GetOkPayShopID()
|
||||
okpay.ShopToken = data.GetOkPayShopToken()
|
||||
okpay.APIURL = data.GetOkPayAPIURL()
|
||||
okpay.CallbackURL = data.GetOkPayCallbackURL()
|
||||
okpay.ReturnURL = data.GetOkPayReturnURL()
|
||||
okpay.TimeoutSeconds = data.GetOkPayTimeoutSeconds()
|
||||
}
|
||||
return c.SucJson(ctx, response.PublicConfigResponse{
|
||||
SupportedAssets: supports,
|
||||
Epay: response.EpayPublicConfig{
|
||||
DefaultToken: data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
|
||||
DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
||||
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
|
||||
},
|
||||
OkPay: okpay,
|
||||
})
|
||||
}
|
||||
|
||||
// ListSupportedAssetRecords is currently not wired to a public route.
|
||||
// Kept as an internal helper candidate for future admin/debug use.
|
||||
func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
|
||||
network := ctx.QueryParam("network")
|
||||
list, err := data.ListEnabledChainTokens(network)
|
||||
@@ -94,15 +113,8 @@ func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
|
||||
return c.SucJson(ctx, list)
|
||||
}
|
||||
|
||||
// GetSupportedAsset 查询单条支持项(无需鉴权)。
|
||||
// @Summary Get a supported asset
|
||||
// @Description Public endpoint. Returns one chain_token row by ID. Returns 404 if the token is disabled.
|
||||
// @Tags Supported Assets
|
||||
// @Produce json
|
||||
// @Param id path int true "ChainToken ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=mdb.ChainToken}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /payments/gmpay/v1/supported-assets/{id} [get]
|
||||
// GetSupportedAsset is currently not wired to a public route.
|
||||
// Kept as an internal helper candidate for future admin/debug use.
|
||||
func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user