From 0215b2787eb01024dc67e535acad38eec5d49acb Mon Sep 17 00:00:00 2001 From: line-6000 <154492442+line-6000@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:01:09 +0800 Subject: [PATCH] feat: add epay return relay and simplify multi-arch builds --- src/.gitignore | 3 +- src/Makefile | 41 ++++ src/controller/comm/order_controller.go | 1 + src/controller/comm/pay_controller.go | 25 +++ src/model/response/pay_response.go | 22 +- src/model/service/epay_return.go | 167 ++++++++++++++ src/model/service/epay_return_test.go | 155 +++++++++++++ src/model/service/order_service.go | 4 +- src/model/service/pay_service.go | 24 +- src/mq/worker.go | 50 +---- src/route/router.go | 1 + src/route/router_test.go | 277 ++++++++++++++++++++++++ src/util/constant/errno.go | 6 + 13 files changed, 697 insertions(+), 79 deletions(-) create mode 100644 src/Makefile create mode 100644 src/model/service/epay_return.go create mode 100644 src/model/service/epay_return_test.go diff --git a/src/.gitignore b/src/.gitignore index 41a26cd..15a2446 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,3 +1,4 @@ .DS_Store dist/ -*.log \ No newline at end of file +*.log +bin/ \ No newline at end of file diff --git a/src/Makefile b/src/Makefile new file mode 100644 index 0000000..703e516 --- /dev/null +++ b/src/Makefile @@ -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 diff --git a/src/controller/comm/order_controller.go b/src/controller/comm/order_controller.go index ac2e5fb..8fb49de 100644 --- a/src/controller/comm/order_controller.go +++ b/src/controller/comm/order_controller.go @@ -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 diff --git a/src/controller/comm/pay_controller.go b/src/controller/comm/pay_controller.go index 3e9242d..45bef1b 100644 --- a/src/controller/comm/pay_controller.go +++ b/src/controller/comm/pay_controller.go @@ -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. diff --git a/src/model/response/pay_response.go b/src/model/response/pay_response.go index 04cea63..347a4db 100644 --- a/src/model/response/pay_response.go +++ b/src/model/response/pay_response.go @@ -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 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"` + 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"` // 非 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 diff --git a/src/model/service/epay_return.go b/src/model/service/epay_return.go new file mode 100644 index 0000000..78a6ffb --- /dev/null +++ b/src/model/service/epay_return.go @@ -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 +} diff --git a/src/model/service/epay_return_test.go b/src/model/service/epay_return_test.go new file mode 100644 index 0000000..35d49f7 --- /dev/null +++ b/src/model/service/epay_return_test.go @@ -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) + } +} diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index b9d82c5..c53979d 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -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, } diff --git a/src/model/service/pay_service.go b/src/model/service/pay_service.go index 259b8e6..9d8f8a8 100644 --- a/src/model/service/pay_service.go +++ b/src/model/service/pay_service.go @@ -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 { diff --git a/src/mq/worker.go b/src/mq/worker.go index 6ef3093..3037682 100644 --- a/src/mq/worker.go +++ b/src/mq/worker.go @@ -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 diff --git a/src/route/router.go b/src/route/router.go index f3307e9..4797914 100644 --- a/src/route/router.go +++ b/src/route/router.go @@ -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) diff --git a/src/route/router_test.go b/src/route/router_test.go index 8da8548..65dc15e 100644 --- a/src/route/router_test.go +++ b/src/route/router_test.go @@ -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) diff --git a/src/util/constant/errno.go b/src/util/constant/errno.go index b2f414b..32be98f 100644 --- a/src/util/constant/errno.go +++ b/src/util/constant/errno.go @@ -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 {