feat: integrate okpay switch-network flow and notify handling

This commit is contained in:
mcmc
2026-05-09 18:06:27 +08:00
parent a4022df7b4
commit 377176a0f2
209 changed files with 1842 additions and 304 deletions
+15 -4
View File
@@ -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.
+154
View File
@@ -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, "=")
}
+4 -2
View File
@@ -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 {