feat: add epay return relay and simplify multi-arch builds

This commit is contained in:
line-6000
2026-06-17 18:01:09 +08:00
parent 75dfeced01
commit 0215b2787e
13 changed files with 697 additions and 79 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
.DS_Store
dist/
*.log
*.log
bin/
+41
View File
@@ -0,0 +1,41 @@
APP := epusdt
TARGET_DIR := bin
BUILD_TAG ?= 0.0.0-dev
VERSION_PKG := github.com/GMWalletApp/epusdt/config
LDFLAGS := -s -w -X $(VERSION_PKG).BuildVersion=$(BUILD_TAG)
.PHONY: build release build-all swagger \
linux-amd64 linux-arm64 linux-armv7 \
darwin-amd64 darwin-arm64 \
windows-amd64 windows-arm64
build:
go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP) .
release: build
linux-amd64:
GOOS=linux GOARCH=amd64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-linux-amd64 .
linux-arm64:
GOOS=linux GOARCH=arm64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-linux-arm64 .
linux-armv7:
GOOS=linux GOARCH=arm GOARM=7 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-linux-armv7 .
darwin-amd64:
GOOS=darwin GOARCH=amd64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-darwin-amd64 .
darwin-arm64:
GOOS=darwin GOARCH=arm64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-darwin-arm64 .
windows-amd64:
GOOS=windows GOARCH=amd64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-windows-amd64.exe .
windows-arm64:
GOOS=windows GOARCH=arm64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-windows-arm64.exe .
build-all: linux-amd64 linux-arm64 linux-armv7 darwin-amd64 darwin-arm64 windows-amd64 windows-arm64
swagger:
swag init --outputTypes json,yaml --parseInternal --parseDependency -o docs
+1
View File
@@ -65,6 +65,7 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
// @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.
// @Description For EPay orders with a merchant return_url, the returned redirect_url is the internal /pay/return/{trade_id} hop rather than the raw merchant return_url.
// @Tags Payment
// @Accept json
// @Produce json
+25
View File
@@ -1,6 +1,7 @@
package comm
import (
"net/http"
"strings"
"github.com/GMWalletApp/epusdt/model/request"
@@ -14,6 +15,7 @@ import (
// @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, 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.
// @Description For EPay orders with a merchant return_url, the response redirect_url is rewritten to the internal /pay/return/{trade_id} hop; the database still stores the merchant's raw return_url.
// @Tags Payment
// @Produce json
// @Param trade_id path string true "Trade ID"
@@ -30,6 +32,29 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
return c.SucJson(ctx, resp)
}
// ReturnToMerchant performs the browser-facing EPay success return hop.
// Non-EPay or not-yet-paid orders are sent back to the checkout counter.
// @Summary Return to merchant (EPAY compat)
// @Description Browser-facing success return hop for EPay orders. Paid EPay orders are redirected to the merchant return_url with signed legacy EPay query params. Orders that are not EPay or not yet paid are redirected back to the checkout counter.
// @Description This route also returns explicit business errors when the merchant return_url is missing, the order API key is unavailable, or EPay signature construction fails.
// @Tags Payment
// @Produce html
// @Param trade_id path string true "Trade ID"
// @Success 302 "Redirect to merchant return_url or checkout counter"
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10044 invalid order redirect url, 10045 order api key unavailable, 10046 failed to build epay return signature"
// @Router /pay/return/{trade_id} [get]
func (c *BaseCommController) ReturnToMerchant(ctx echo.Context) error {
tradeID := ctx.Param("trade_id")
redirect, err := service.ResolveEPayReturnRedirect(tradeID)
if err != nil {
return c.FailJson(ctx, err)
}
if redirect.IsMerchantRedirect {
ctx.Response().Header().Set("Cache-Control", "no-store")
}
return ctx.Redirect(http.StatusFound, redirect.TargetURL)
}
// CheckStatus 支付状态检测
// @Summary Check payment status
// @Description Return the current order status by trade ID. Status: 1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection.
+11 -11
View File
@@ -1,17 +1,17 @@
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"` // 订单实际需要支付的金额;status=4 占位订单返回 0
Token string `json:"token" example:"USDT"` // 所属币种;status=4 占位订单为空
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址;status=4 占位订单为空
Network string `json:"network" example:"tron"` // 网络 TRON ETHstatus=4 占位订单为空
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种;status=4 时前端应引导选择链上 token/network 或 OkPay
PaymentType string `json:"payment_type" enums:"gmpay,epay" example:"gmpay"` // 支付接入类型;底层 Epay/Gmpay 转为小写 epay/gmpay 返回
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额;status=4 占位订单返回 0
Token string `json:"token" example:"USDT"` // 所属币种;status=4 占位订单为空
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址;status=4 占位订单为空
Network string `json:"network" example:"tron"` // 网络 TRON ETHstatus=4 占位订单为空
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种;status=4 时前端应引导选择链上 token/network 或 OkPay
PaymentType string `json:"payment_type" enums:"gmpay,epay" example:"gmpay"` // 支付接入类型;底层 Epay/Gmpay 转为小写 epay/gmpay 返回
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"` // 非 EPay 时为商户原始回跳地址;EPay 时为内部中转地址 /pay/return/{trade_id}
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"` // 是否已选择当前支付方式;status=4 占位订单和刚补全的占位父单为 false
+167
View File
@@ -0,0 +1,167 @@
package service
import (
"fmt"
"net/url"
"strconv"
"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"
"github.com/GMWalletApp/epusdt/util/sign"
)
type EPayReturnRedirect struct {
TargetURL string
IsMerchantRedirect bool
}
func isEPayOrder(order *mdb.Orders) bool {
return order != nil && strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay)
}
func buildCheckoutCounterPath(tradeID string) string {
return "/pay/checkout-counter/" + tradeID
}
func buildAbsoluteAppURL(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
base := strings.TrimRight(strings.TrimSpace(config.GetAppUri()), "/")
if base == "" {
return path
}
return base + path
}
func buildEPayReturnPath(tradeID string) string {
return "/pay/return/" + tradeID
}
func buildPublicRedirectURL(order *mdb.Orders) string {
if order == nil {
return ""
}
raw := strings.TrimSpace(order.RedirectUrl)
if raw == "" || !isEPayOrder(order) {
return raw
}
return buildAbsoluteAppURL(buildEPayReturnPath(order.TradeId))
}
func ResolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
if order == nil || order.ApiKeyID == 0 {
return nil, constant.OrderApiKeyUnavailableErr
}
row, err := data.GetApiKeyByID(order.ApiKeyID)
if err != nil {
return nil, err
}
if row == nil || row.ID == 0 || row.Status != mdb.ApiKeyStatusEnable {
return nil, constant.OrderApiKeyUnavailableErr
}
return row, nil
}
func BuildEPayResultParams(order *mdb.Orders, apiKeyRow *mdb.ApiKey) (map[string]string, error) {
if order == nil || apiKeyRow == nil {
return nil, constant.EPayReturnSignatureErr
}
pidInt, err := strconv.Atoi(strings.TrimSpace(apiKeyRow.Pid))
if err != nil {
return nil, constant.EPayReturnSignatureErr
}
notifyData := response.OrderNotifyResponseEpay{
PID: pidInt,
TradeNo: order.TradeId,
OutTradeNo: order.OrderId,
Type: "alipay",
Name: order.Name,
Money: fmt.Sprintf("%.4f", order.Amount),
TradeStatus: "TRADE_SUCCESS",
}
signstr, err := sign.Get(notifyData, apiKeyRow.SecretKey)
if err != nil {
return nil, constant.EPayReturnSignatureErr
}
return map[string]string{
"pid": strconv.Itoa(pidInt),
"trade_no": notifyData.TradeNo,
"out_trade_no": notifyData.OutTradeNo,
"type": notifyData.Type,
"name": notifyData.Name,
"money": notifyData.Money,
"trade_status": notifyData.TradeStatus,
"sign": signstr,
"sign_type": "MD5",
}, nil
}
func appendQueryParams(rawURL string, params map[string]string) (string, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return "", constant.OrderRedirectURLErr
}
targetURL, err := url.Parse(rawURL)
if err != nil {
return "", constant.OrderRedirectURLErr
}
query := targetURL.Query()
for key, value := range params {
query.Set(key, value)
}
targetURL.RawQuery = query.Encode()
return targetURL.String(), nil
}
func ResolveEPayReturnRedirect(tradeID string) (*EPayReturnRedirect, error) {
order, err := GetOrderInfoByTradeId(tradeID)
if err != nil {
return nil, err
}
if !isEPayOrder(order) || order.Status != mdb.StatusPaySuccess {
return &EPayReturnRedirect{
TargetURL: buildCheckoutCounterPath(order.TradeId),
}, nil
}
rawMerchantURL := strings.TrimSpace(order.RedirectUrl)
if rawMerchantURL == "" {
return nil, constant.OrderRedirectURLErr
}
apiKeyRow, err := ResolveOrderApiKey(order)
if err != nil {
return nil, err
}
params, err := BuildEPayResultParams(order, apiKeyRow)
if err != nil {
return nil, err
}
targetURL, err := appendQueryParams(rawMerchantURL, params)
if err != nil {
return nil, err
}
return &EPayReturnRedirect{
TargetURL: targetURL,
IsMerchantRedirect: true,
}, nil
}
+155
View File
@@ -0,0 +1,155 @@
package service
import (
"net/url"
"testing"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/sign"
)
func TestBuildPublicRedirectURLRewritesOnlyEpayOrders(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
epayOrder := &mdb.Orders{
TradeId: "trade_epay_redirect",
PaymentType: mdb.PaymentTypeEpay,
RedirectUrl: "https://merchant.example/return",
}
wantRedirect := config.GetAppUri() + "/pay/return/trade_epay_redirect"
if got := buildPublicRedirectURL(epayOrder); got != wantRedirect {
t.Fatalf("epay redirect_url = %q, want internal return route", got)
}
gmpayOrder := &mdb.Orders{
TradeId: "trade_gmpay_redirect",
PaymentType: mdb.PaymentTypeGmpay,
RedirectUrl: "https://merchant.example/return",
}
if got := buildPublicRedirectURL(gmpayOrder); got != "https://merchant.example/return" {
t.Fatalf("gmpay redirect_url = %q, want merchant raw redirect_url", got)
}
if got := buildPublicRedirectURL(&mdb.Orders{TradeId: "trade_empty", PaymentType: mdb.PaymentTypeEpay}); got != "" {
t.Fatalf("empty redirect_url = %q, want empty", got)
}
}
func TestBuildEPayResultParamsMatchesLegacyFields(t *testing.T) {
params, err := BuildEPayResultParams(&mdb.Orders{
TradeId: "trade_epay_params",
OrderId: "order_epay_params",
Name: "VIP",
Amount: 1,
}, &mdb.ApiKey{
Pid: "1001",
SecretKey: "epay-secret",
})
if err != nil {
t.Fatalf("BuildEPayResultParams(): %v", err)
}
expected := map[string]string{
"pid": "1001",
"trade_no": "trade_epay_params",
"out_trade_no": "order_epay_params",
"type": "alipay",
"name": "VIP",
"money": "1.0000",
"trade_status": "TRADE_SUCCESS",
"sign_type": "MD5",
}
for key, want := range expected {
if got := params[key]; got != want {
t.Fatalf("%s = %q, want %q", key, got, want)
}
}
signParams := map[string]interface{}{
"pid": params["pid"],
"trade_no": params["trade_no"],
"out_trade_no": params["out_trade_no"],
"type": params["type"],
"name": params["name"],
"money": params["money"],
"trade_status": params["trade_status"],
}
wantSign, err := sign.Get(signParams, "epay-secret")
if err != nil {
t.Fatalf("sign.Get(): %v", err)
}
if got := params["sign"]; got != wantSign {
t.Fatalf("sign = %q, want %q", got, wantSign)
}
}
func TestBuildEPayResultParamsRejectsNonNumericPid(t *testing.T) {
_, err := BuildEPayResultParams(&mdb.Orders{
TradeId: "trade_bad_pid",
OrderId: "order_bad_pid",
Name: "VIP",
Amount: 1,
}, &mdb.ApiKey{
Pid: "not-a-number",
SecretKey: "epay-secret",
})
if err != constant.EPayReturnSignatureErr {
t.Fatalf("error = %v, want %v", err, constant.EPayReturnSignatureErr)
}
}
func TestResolveOrderApiKeyRejectsUnavailableRows(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := ResolveOrderApiKey(&mdb.Orders{TradeId: "missing_api_key_id"}); err != constant.OrderApiKeyUnavailableErr {
t.Fatalf("missing api_key_id error = %v, want %v", err, constant.OrderApiKeyUnavailableErr)
}
row := &mdb.ApiKey{
Name: "disabled",
Pid: "1201",
SecretKey: "disabled-secret",
Status: mdb.ApiKeyStatusDisable,
}
if err := dao.Mdb.Create(row).Error; err != nil {
t.Fatalf("create disabled api key: %v", err)
}
if _, err := ResolveOrderApiKey(&mdb.Orders{TradeId: "disabled_api_key", ApiKeyID: row.ID}); err != constant.OrderApiKeyUnavailableErr {
t.Fatalf("disabled api key error = %v, want %v", err, constant.OrderApiKeyUnavailableErr)
}
}
func TestAppendQueryParamsPreservesMerchantQuery(t *testing.T) {
target, err := appendQueryParams("https://merchant.example/return?from=merchant", map[string]string{
"pid": "1001",
"trade_no": "trade_epay_redirect",
"trade_status": "TRADE_SUCCESS",
})
if err != nil {
t.Fatalf("appendQueryParams(): %v", err)
}
parsed, err := url.Parse(target)
if err != nil {
t.Fatalf("url.Parse(): %v", err)
}
query := parsed.Query()
if got := query.Get("from"); got != "merchant" {
t.Fatalf("from = %q, want merchant", got)
}
if got := query.Get("pid"); got != "1001" {
t.Fatalf("pid = %q, want 1001", got)
}
if got := query.Get("trade_no"); got != "trade_epay_redirect" {
t.Fatalf("trade_no = %q, want trade_epay_redirect", got)
}
if got := query.Get("trade_status"); got != "TRADE_SUCCESS" {
t.Fatalf("trade_status = %q, want TRADE_SUCCESS", got)
}
}
+2 -2
View File
@@ -706,7 +706,7 @@ func completeWaitSelectOrder(parent *mdb.Orders, token string, network string) (
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
paymentType := mdb.PaymentTypeGmpay
if strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay) {
if isEPayOrder(order) {
paymentType = mdb.PaymentTypeEpay
}
return &response.CheckoutCounterResponse{
@@ -720,7 +720,7 @@ func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse
Status: order.Status,
PaymentType: strings.ToLower(paymentType),
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: order.RedirectUrl,
RedirectUrl: buildPublicRedirectURL(order),
CreatedAt: order.CreatedAt.TimestampMilli(),
IsSelected: order.IsSelected,
}
+1 -23
View File
@@ -1,9 +1,6 @@
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"
@@ -23,26 +20,7 @@ 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,
Amount: orderInfo.Amount,
ActualAmount: orderInfo.ActualAmount,
Token: orderInfo.Token,
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,
}
resp := buildCheckoutResponse(orderInfo)
if orderInfo.PayProvider == mdb.PaymentProviderOkPay {
providerRow, rowErr := data.GetProviderOrderByTradeIDAndProvider(orderInfo.TradeId, mdb.PaymentProviderOkPay)
if rowErr != nil {
+8 -42
View File
@@ -2,9 +2,7 @@ package mq
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
@@ -13,6 +11,7 @@ import (
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/sign"
@@ -25,18 +24,12 @@ import (
// other secret would produce a signature the merchant can't verify.
// The admin can resend the callback after fixing the key.
func resolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
if order.ApiKeyID == 0 {
return nil, fmt.Errorf("order trade_id=%s has no api_key_id", order.TradeId)
}
row, err := data.GetApiKeyByID(order.ApiKeyID)
if err != nil {
return nil, fmt.Errorf("lookup api_key_id=%d: %w", order.ApiKeyID, err)
}
if row.ID == 0 {
return nil, fmt.Errorf("api_key_id=%d not found (deleted?)", order.ApiKeyID)
}
if row.Status != mdb.ApiKeyStatusEnable {
return nil, fmt.Errorf("api_key_id=%d is disabled", order.ApiKeyID)
row, err := service.ResolveOrderApiKey(order)
if err != nil || row == nil || row.ID == 0 {
if err != nil {
return nil, err
}
return nil, errors.New("api key unavailable")
}
return row, nil
}
@@ -207,38 +200,11 @@ func sendOrderCallback(order *mdb.Orders) error {
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 {
return fmt.Errorf("epay pid not numeric: %s", apiKeyRow.Pid)
}
notifyData := response.OrderNotifyResponseEpay{
PID: pidInt,
TradeNo: order.TradeId,
OutTradeNo: order.OrderId,
Type: "alipay",
Name: order.Name,
Money: fmt.Sprintf("%.4f", order.Amount),
TradeStatus: "TRADE_SUCCESS",
}
signstr2, err := sign.Get(notifyData, apiKeyRow.SecretKey)
formData, err := service.BuildEPayResultParams(order, apiKeyRow)
if err != nil {
return err
}
formData := map[string]string{
"pid": fmt.Sprintf("%d", notifyData.PID),
"trade_no": notifyData.TradeNo,
"out_trade_no": notifyData.OutTradeNo,
"type": notifyData.Type,
"name": notifyData.Name,
"money": notifyData.Money,
"trade_status": notifyData.TradeStatus,
"sign": signstr2,
"sign_type": "MD5",
}
epayResp, err := http_client.GetHttpClient().R().SetQueryParams(formData).Get(order.NotifyUrl)
if err != nil {
return err
+1
View File
@@ -37,6 +37,7 @@ func RegisterRoute(e *echo.Echo) {
})
payRoute.GET("/checkout-counter-resp/:trade_id", comm.Ctrl.CheckoutCounter)
payRoute.GET("/return/:trade_id", comm.Ctrl.ReturnToMerchant)
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
payRoute.POST("/submit-tx-hash/:trade_id", comm.Ctrl.SubmitTxHash)
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
+277
View File
@@ -224,6 +224,37 @@ func doFormPost(e *echo.Echo, path string, values url.Values) *httptest.Response
return rec
}
func mustCreateEPayOrder(t *testing.T, e *echo.Echo, orderID string, returnURL string, extra url.Values) string {
t.Helper()
values := url.Values{
"pid": {"1"},
"name": {orderID},
"type": {"alipay"},
"money": {"1.00"},
"out_trade_no": {orderID},
"notify_url": {"https://93.184.216.34/notify"},
"return_url": {returnURL},
}
for key, items := range extra {
clone := append([]string(nil), items...)
values[key] = clone
}
values = signEpayValues(values)
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("create epay order failed: %d %s", rec.Code, rec.Body.String())
}
location := rec.Header().Get("Location")
if !strings.HasPrefix(location, "/pay/checkout-counter/") {
t.Fatalf("expected checkout redirect, got %q", location)
}
return strings.TrimPrefix(location, "/pay/checkout-counter/")
}
func TestRootPostRoute(t *testing.T) {
e := setupTestEnv(t)
@@ -1245,6 +1276,12 @@ func TestEpaySubmitPhpGetCompatible(t *testing.T) {
if checkoutData["payment_type"] != "epay" {
t.Fatalf("epay checkout payment_type = %v, want epay", checkoutData["payment_type"])
}
if got, _ := checkoutData["redirect_url"].(string); got != "http://localhost:8080/pay/return/"+tradeID {
t.Fatalf("epay checkout redirect_url = %q, want internal return route", got)
}
if order.RedirectUrl != "http://localhost/return" {
t.Fatalf("stored redirect_url = %q, want merchant raw return_url", order.RedirectUrl)
}
}
func TestEpaySubmitPhpRequestTokenNetworkOverrideDefaults(t *testing.T) {
@@ -1526,6 +1563,234 @@ func TestSubmitTxHash_SuccessUpdatesCheckStatus(t *testing.T) {
}
}
func TestPayReturn_NotFound(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/pay/return/nonexistent-trade-id", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
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 != 10008 {
t.Fatalf("status_code = %d, want 10008; response=%v", got, resp)
}
}
func TestPayReturn_PaidEpayRedirectsToMerchantWithSignedParams(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-paid-001", "https://merchant.example/return?from=merchant", url.Values{
"token": {"usdt"},
"network": {"tron"},
"currency": {"cny"},
})
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("cache-control = %q, want no-store", got)
}
location := rec.Header().Get("Location")
targetURL, err := url.Parse(location)
if err != nil {
t.Fatalf("parse redirect location: %v", err)
}
if targetURL.Scheme != "https" || targetURL.Host != "merchant.example" || targetURL.Path != "/return" {
t.Fatalf("unexpected redirect target: %q", location)
}
query := targetURL.Query()
if query.Get("from") != "merchant" {
t.Fatalf("preserved query = %q, want merchant", query.Get("from"))
}
if query.Get("pid") != "1" {
t.Fatalf("pid = %q, want 1", query.Get("pid"))
}
if query.Get("trade_no") != tradeID {
t.Fatalf("trade_no = %q, want %q", query.Get("trade_no"), tradeID)
}
if query.Get("out_trade_no") != "epay-return-paid-001" {
t.Fatalf("out_trade_no = %q", query.Get("out_trade_no"))
}
if query.Get("type") != "alipay" {
t.Fatalf("type = %q, want alipay", query.Get("type"))
}
if query.Get("name") != "epay-return-paid-001" {
t.Fatalf("name = %q", query.Get("name"))
}
if query.Get("money") != "1.0000" {
t.Fatalf("money = %q, want 1.0000", query.Get("money"))
}
if query.Get("trade_status") != "TRADE_SUCCESS" {
t.Fatalf("trade_status = %q, want TRADE_SUCCESS", query.Get("trade_status"))
}
if query.Get("sign_type") != "MD5" {
t.Fatalf("sign_type = %q, want MD5", query.Get("sign_type"))
}
signParams := map[string]interface{}{
"pid": query.Get("pid"),
"trade_no": query.Get("trade_no"),
"out_trade_no": query.Get("out_trade_no"),
"type": query.Get("type"),
"name": query.Get("name"),
"money": query.Get("money"),
"trade_status": query.Get("trade_status"),
}
calcSig, err := sign.Get(signParams, testAPIToken)
if err != nil {
t.Fatalf("calc epay return signature: %v", err)
}
if got := query.Get("sign"); got != calcSig {
t.Fatalf("sign = %q, want %q", got, calcSig)
}
}
func TestPayReturn_UnpaidEpayRedirectsBackToCheckout(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-unpaid-001", "https://merchant.example/return", url.Values{
"token": {"usdt"},
"network": {"tron"},
"currency": {"cny"},
})
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/pay/checkout-counter/"+tradeID {
t.Fatalf("location = %q, want checkout counter", got)
}
}
func TestPayReturn_PaidNonEpayRedirectsBackToCheckout(t *testing.T) {
e := setupTestEnv(t)
tradeID := createCheckoutCounterRespTestOrder(t, e, "pay-return-gmpay-001")
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected 302, got %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/pay/checkout-counter/"+tradeID {
t.Fatalf("location = %q, want checkout counter", got)
}
}
func TestPayReturn_PaidEpayWithEmptyRedirectReturnsError(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-empty-001", "https://merchant.example/return", url.Values{
"token": {"usdt"},
"network": {"tron"},
"currency": {"cny"},
})
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Updates(map[string]interface{}{
"status": mdb.StatusPaySuccess,
"redirect_url": "",
}).Error; err != nil {
t.Fatalf("update order: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
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 != 10044 {
t.Fatalf("status_code = %d, want 10044; response=%v", got, resp)
}
}
func TestPayReturn_PaidEpayWithDisabledApiKeyReturnsError(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-disabled-key-001", "https://merchant.example/return", url.Values{
"token": {"usdt"},
"network": {"tron"},
"currency": {"cny"},
})
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("load order: %v", err)
}
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
if err := dao.Mdb.Model(&mdb.ApiKey{}).
Where("id = ?", order.ApiKeyID).
Update("status", mdb.ApiKeyStatusDisable).Error; err != nil {
t.Fatalf("disable api key: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
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 != 10045 {
t.Fatalf("status_code = %d, want 10045; response=%v", got, resp)
}
}
func TestPayReturn_PaidEpayWithNonNumericPidReturnsSignatureError(t *testing.T) {
e := setupTestEnv(t)
tradeID := mustCreateEPayOrder(t, e, "epay-return-bad-pid-001", "https://merchant.example/return", url.Values{
"token": {"usdt"},
"network": {"tron"},
"currency": {"cny"},
})
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
t.Fatalf("load order: %v", err)
}
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
if err := dao.Mdb.Model(&mdb.ApiKey{}).
Where("id = ?", order.ApiKeyID).
Update("pid", "not-a-number").Error; err != nil {
t.Fatalf("break pid: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/pay/return/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
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 != 10046 {
t.Fatalf("status_code = %d, want 10046; response=%v", got, resp)
}
}
func TestSubmitTxHash_VerificationFailureAllowsRetrySameHash(t *testing.T) {
e := setupTestEnv(t)
tradeID := createCheckoutCounterRespTestOrder(t, e, "submit-tx-hash-retry-001")
@@ -2225,6 +2490,9 @@ func TestSwitchNetwork_EpayPlaceholderCompletesChainInPlace(t *testing.T) {
if got, _ := respData["trade_id"].(string); got != tradeID {
t.Fatalf("switch trade_id = %q, want parent %q", got, tradeID)
}
if got, _ := respData["redirect_url"].(string); got != "http://localhost:8080/pay/return/"+tradeID {
t.Fatalf("switch redirect_url = %q, want internal return route", got)
}
parent, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
@@ -2233,6 +2501,9 @@ func TestSwitchNetwork_EpayPlaceholderCompletesChainInPlace(t *testing.T) {
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)
}
if parent.RedirectUrl != "http://localhost/return" {
t.Fatalf("stored redirect_url = %q, want merchant raw return_url", parent.RedirectUrl)
}
count, err := data.CountActiveSubOrders(tradeID)
if err != nil {
t.Fatalf("count active sub-orders: %v", err)
@@ -2315,6 +2586,9 @@ func TestSwitchNetwork_OkPayFromEpayWaitSelectPlaceholder(t *testing.T) {
if respData["payment_url"] != "https://pay.okaypay.test/epay-placeholder" {
t.Fatalf("payment_url = %v, want okpay url", respData["payment_url"])
}
if got, _ := respData["redirect_url"].(string); got != "http://localhost:8080/pay/return/"+tradeID {
t.Fatalf("switch redirect_url = %q, want internal return route", got)
}
parent, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
@@ -2323,6 +2597,9 @@ func TestSwitchNetwork_OkPayFromEpayWaitSelectPlaceholder(t *testing.T) {
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)
}
if parent.RedirectUrl != "http://localhost/return" {
t.Fatalf("stored redirect_url = %q, want merchant raw return_url", parent.RedirectUrl)
}
count, err := data.CountActiveSubOrders(tradeID)
if err != nil {
t.Fatalf("count active sub-orders: %v", err)
+6
View File
@@ -47,6 +47,9 @@ var Errno = map[int]string{
10041: "invalid notify url",
10042: "payment provider order creation failed",
10043: "invalid setting item",
10044: "invalid order redirect url",
10045: "order api key unavailable",
10046: "failed to build epay return signature",
}
var (
@@ -94,6 +97,9 @@ var (
NotifyURLErr = Err(10041)
PaymentProviderCreateErr = Err(10042)
SettingItemErr = Err(10043)
OrderRedirectURLErr = Err(10044)
OrderApiKeyUnavailableErr = Err(10045)
EPayReturnSignatureErr = Err(10046)
)
type RspError struct {