fix: restore wechat payment oauth and jsapi flow

This commit is contained in:
IanShaw027
2026-04-20 23:34:57 +08:00
parent 6f00efa350
commit 7ef7fd19e7
16 changed files with 1563 additions and 87 deletions

View File

@@ -9,12 +9,14 @@ import (
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -35,6 +37,13 @@ const (
wechatOAuthDefaultFrontendCB = "/auth/wechat/callback"
wechatOAuthProviderKey = "wechat-main"
wechatOAuthLegacyProviderKey = "wechat"
wechatPaymentOAuthCookiePath = "/api/v1/auth/oauth/wechat/payment"
wechatPaymentOAuthStateName = "wechat_payment_oauth_state"
wechatPaymentOAuthRedirect = "wechat_payment_oauth_redirect"
wechatPaymentOAuthContextName = "wechat_payment_oauth_context"
wechatPaymentOAuthScope = "wechat_payment_oauth_scope"
wechatPaymentOAuthDefaultTo = "/purchase"
wechatPaymentOAuthFrontendCB = "/auth/wechat/payment/callback"
wechatOAuthIntentLogin = "login"
wechatOAuthIntentBind = "bind_current_user"
@@ -76,6 +85,13 @@ type wechatOAuthUserInfoResponse struct {
ErrMsg string `json:"errmsg"`
}
type wechatPaymentOAuthContext struct {
PaymentType string `json:"payment_type"`
Amount string `json:"amount,omitempty"`
OrderType string `json:"order_type,omitempty"`
PlanID int64 `json:"plan_id,omitempty"`
}
// WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived
// browser cookies required by the rebuild pending-auth bridge.
func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
@@ -294,6 +310,149 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
redirectToFrontendCallback(c, frontendCallback)
}
// WeChatPaymentOAuthStart starts the WeChat payment OAuth flow.
// GET /api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay&redirect=/purchase
func (h *AuthHandler) WeChatPaymentOAuthStart(c *gin.Context) {
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), "mp", c)
if err != nil {
response.ErrorFrom(c, err)
return
}
paymentType := normalizeWeChatPaymentType(c.Query("payment_type"))
if paymentType == "" {
response.BadRequest(c, "Invalid payment type")
return
}
state, err := oauth.GenerateState()
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
return
}
redirectTo := normalizeWeChatPaymentRedirectPath(sanitizeFrontendRedirectPath(c.Query("redirect")))
if redirectTo == "" {
redirectTo = wechatPaymentOAuthDefaultTo
}
rawContext, err := encodeWeChatPaymentOAuthContext(wechatPaymentOAuthContext{
PaymentType: paymentType,
Amount: strings.TrimSpace(c.Query("amount")),
OrderType: strings.TrimSpace(c.Query("order_type")),
PlanID: parseWeChatPaymentPlanID(c.Query("plan_id")),
})
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONTEXT_ENCODE_FAILED", "failed to encode oauth context").WithCause(err))
return
}
scope := normalizeWeChatPaymentScope(c.Query("scope"))
secureCookie := isRequestHTTPS(c)
wechatPaymentSetCookie(c, wechatPaymentOAuthStateName, encodeCookieValue(state), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatPaymentSetCookie(c, wechatPaymentOAuthRedirect, encodeCookieValue(redirectTo), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatPaymentSetCookie(c, wechatPaymentOAuthContextName, encodeCookieValue(rawContext), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatPaymentSetCookie(c, wechatPaymentOAuthScope, encodeCookieValue(scope), wechatOAuthCookieMaxAgeSec, secureCookie)
cfg.redirectURI = h.resolveWeChatPaymentOAuthCallbackURL(c.Request.Context(), c)
cfg.scope = scope
authURL, err := buildWeChatAuthorizeURL(cfg, state)
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
return
}
c.Redirect(http.StatusFound, authURL)
}
// WeChatPaymentOAuthCallback exchanges a payment OAuth code for an OpenID and
// forwards the browser back to the frontend callback route.
func (h *AuthHandler) WeChatPaymentOAuthCallback(c *gin.Context) {
frontendCallback := wechatPaymentOAuthFrontendCB
if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
return
}
code := strings.TrimSpace(c.Query("code"))
state := strings.TrimSpace(c.Query("state"))
if code == "" || state == "" {
redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
return
}
secureCookie := isRequestHTTPS(c)
defer func() {
wechatPaymentClearCookie(c, wechatPaymentOAuthStateName, secureCookie)
wechatPaymentClearCookie(c, wechatPaymentOAuthRedirect, secureCookie)
wechatPaymentClearCookie(c, wechatPaymentOAuthContextName, secureCookie)
wechatPaymentClearCookie(c, wechatPaymentOAuthScope, secureCookie)
}()
expectedState, err := readCookieDecoded(c, wechatPaymentOAuthStateName)
if err != nil || expectedState == "" || state != expectedState {
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
return
}
redirectTo, _ := readCookieDecoded(c, wechatPaymentOAuthRedirect)
redirectTo = normalizeWeChatPaymentRedirectPath(sanitizeFrontendRedirectPath(redirectTo))
if redirectTo == "" {
redirectTo = wechatPaymentOAuthDefaultTo
}
rawContext, _ := readCookieDecoded(c, wechatPaymentOAuthContextName)
paymentContext, err := decodeWeChatPaymentOAuthContext(rawContext)
if err != nil {
redirectOAuthError(c, frontendCallback, "invalid_context", "invalid oauth context", "")
return
}
if paymentContext.PaymentType == "" {
paymentContext.PaymentType = payment.TypeWxpay
}
scope, _ := readCookieDecoded(c, wechatPaymentOAuthScope)
scope = normalizeWeChatPaymentScope(scope)
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), "mp", c)
if err != nil {
redirectOAuthError(c, frontendCallback, "provider_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
cfg.redirectURI = h.resolveWeChatPaymentOAuthCallbackURL(c.Request.Context(), c)
tokenResp, err := exchangeWeChatOAuthCode(c.Request.Context(), cfg, code)
if err != nil {
redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", err.Error())
return
}
openid := strings.TrimSpace(tokenResp.OpenID)
if openid == "" {
redirectOAuthError(c, frontendCallback, "missing_openid", "missing openid", "")
return
}
if strings.TrimSpace(tokenResp.Scope) != "" {
scope = strings.TrimSpace(tokenResp.Scope)
}
fragment := url.Values{}
fragment.Set("openid", openid)
fragment.Set("state", state)
fragment.Set("scope", scope)
fragment.Set("payment_type", paymentContext.PaymentType)
if paymentContext.Amount != "" {
fragment.Set("amount", paymentContext.Amount)
}
if paymentContext.OrderType != "" {
fragment.Set("order_type", paymentContext.OrderType)
}
if paymentContext.PlanID > 0 {
fragment.Set("plan_id", strconv.FormatInt(paymentContext.PlanID, 10))
}
fragment.Set("redirect", redirectTo)
redirectWithFragment(c, frontendCallback, fragment)
}
type completeWeChatOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
@@ -950,3 +1109,99 @@ func wechatClearCookie(c *gin.Context, name string, secure bool) {
SameSite: http.SameSiteLaxMode,
})
}
func normalizeWeChatPaymentType(raw string) string {
switch strings.TrimSpace(raw) {
case payment.TypeWxpay, payment.TypeWxpayDirect:
return strings.TrimSpace(raw)
default:
return ""
}
}
func normalizeWeChatPaymentScope(raw string) string {
for _, part := range strings.FieldsFunc(strings.TrimSpace(raw), func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
}) {
switch strings.TrimSpace(part) {
case "snsapi_userinfo":
return "snsapi_userinfo"
case "snsapi_base":
return "snsapi_base"
}
}
return "snsapi_base"
}
func normalizeWeChatPaymentRedirectPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return wechatPaymentOAuthDefaultTo
}
if path == "/payment" {
return "/purchase"
}
if strings.HasPrefix(path, "/payment?") {
return "/purchase" + strings.TrimPrefix(path, "/payment")
}
return path
}
func (h *AuthHandler) resolveWeChatPaymentOAuthCallbackURL(ctx context.Context, c *gin.Context) string {
apiBaseURL := ""
if h != nil && h.settingSvc != nil {
if settings, err := h.settingSvc.GetAllSettings(ctx); err == nil && settings != nil {
apiBaseURL = strings.TrimSpace(settings.APIBaseURL)
}
}
return resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/payment/callback")
}
func encodeWeChatPaymentOAuthContext(ctx wechatPaymentOAuthContext) (string, error) {
data, err := json.Marshal(ctx)
if err != nil {
return "", err
}
return string(data), nil
}
func decodeWeChatPaymentOAuthContext(raw string) (wechatPaymentOAuthContext, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return wechatPaymentOAuthContext{}, nil
}
var ctx wechatPaymentOAuthContext
if err := json.Unmarshal([]byte(raw), &ctx); err != nil {
return wechatPaymentOAuthContext{}, err
}
return ctx, nil
}
func parseWeChatPaymentPlanID(raw string) int64 {
id, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
return id
}
func wechatPaymentSetCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: value,
Path: wechatPaymentOAuthCookiePath,
MaxAge: maxAgeSec,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func wechatPaymentClearCookie(c *gin.Context, name string, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: "",
Path: wechatPaymentOAuthCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}

View File

@@ -204,10 +204,12 @@ func (h *PaymentHandler) GetLimits(c *gin.Context) {
type CreateOrderRequest struct {
Amount float64 `json:"amount"`
PaymentType string `json:"payment_type" binding:"required"`
OpenID string `json:"openid"`
ReturnURL string `json:"return_url"`
PaymentSource string `json:"payment_source"`
OrderType string `json:"order_type"`
PlanID int64 `json:"plan_id"`
IsMobile *bool `json:"is_mobile,omitempty"`
}
// CreateOrder creates a new payment order.
@@ -224,17 +226,25 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
return
}
mobile := isMobile(c)
if req.IsMobile != nil {
mobile = *req.IsMobile
}
result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{
UserID: subject.UserID,
Amount: req.Amount,
PaymentType: req.PaymentType,
ClientIP: c.ClientIP(),
IsMobile: isMobile(c),
SrcHost: c.Request.Host,
ReturnURL: req.ReturnURL,
PaymentSource: req.PaymentSource,
OrderType: req.OrderType,
PlanID: req.PlanID,
UserID: subject.UserID,
Amount: req.Amount,
PaymentType: req.PaymentType,
OpenID: req.OpenID,
ClientIP: c.ClientIP(),
IsMobile: mobile,
IsWeChatBrowser: isWeChatBrowser(c),
SrcHost: c.Request.Host,
SrcURL: c.Request.Referer(),
ReturnURL: req.ReturnURL,
PaymentSource: req.PaymentSource,
OrderType: req.OrderType,
PlanID: req.PlanID,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -467,3 +477,7 @@ func isMobile(c *gin.Context) bool {
}
return false
}
func isWeChatBrowser(c *gin.Context) bool {
return strings.Contains(strings.ToLower(c.GetHeader("User-Agent")), "micromessenger")
}

View File

@@ -6,8 +6,8 @@ import (
"crypto/rsa"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -19,6 +19,7 @@ import (
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
@@ -26,8 +27,16 @@ import (
// WeChat Pay constants.
const (
wxpayCurrency = "CNY"
wxpayH5Type = "Wap"
wxpayCurrency = "CNY"
wxpayH5Type = "Wap"
wxpayResultPath = "/payment/result"
)
// WeChat Pay create-payment modes.
const (
wxpayModeNative = "native"
wxpayModeH5 = "h5"
wxpayModeJSAPI = "jsapi"
)
// WeChat Pay trade states.
@@ -48,6 +57,18 @@ const (
wxpayErrNoAuth = "NO_AUTH"
)
var (
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
return svc.Prepay(ctx, req)
}
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
return svc.Prepay(ctx, req)
}
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
return svc.PrepayWithRequestPayment(ctx, req)
}
)
type Wxpay struct {
instanceID string
config map[string]string
@@ -75,6 +96,16 @@ func (w *Wxpay) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeWxpay}
}
// ResolveWxpayJSAPIAppID returns the AppID that JSAPI prepay will use for a
// given provider config. A dedicated MP AppID takes precedence over the base
// merchant AppID.
func ResolveWxpayJSAPIAppID(config map[string]string) string {
if appID := strings.TrimSpace(config["mpAppId"]); appID != "" {
return appID
}
return strings.TrimSpace(config["appId"])
}
func formatPEM(key, keyType string) string {
key = strings.TrimSpace(key)
if strings.HasPrefix(key, "-----BEGIN") {
@@ -139,30 +170,68 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ
if err != nil {
return nil, fmt.Errorf("wxpay create payment: %w", err)
}
if req.IsMobile && req.ClientIP != "" {
resp, err := w.createOrder(ctx, client, req, notifyURL, totalFen, true)
mode, err := resolveWxpayCreateMode(req)
if err != nil {
return nil, err
}
switch mode {
case wxpayModeJSAPI:
return w.prepayJSAPI(ctx, client, req, notifyURL, totalFen)
case wxpayModeH5:
resp, err := w.prepayH5(ctx, client, req, notifyURL, totalFen)
if err == nil {
return resp, nil
}
if !strings.Contains(err.Error(), wxpayErrNoAuth) {
return nil, err
if strings.Contains(err.Error(), wxpayErrNoAuth) {
return nil, fmt.Errorf("wxpay h5 payments are not authorized for this merchant: %w", err)
}
slog.Warn("wxpay H5 payment not authorized, falling back to native", "order", req.OrderID)
return nil, err
case wxpayModeNative:
return w.prepayNative(ctx, client, req, notifyURL, totalFen)
default:
return nil, fmt.Errorf("wxpay create payment: unsupported mode %q", mode)
}
return w.createOrder(ctx, client, req, notifyURL, totalFen, false)
}
func (w *Wxpay) createOrder(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64, useH5 bool) (*payment.CreatePaymentResponse, error) {
if useH5 {
return w.prepayH5(ctx, c, req, notifyURL, totalFen)
func (w *Wxpay) prepayJSAPI(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
svc := jsapi.JsapiApiService{Client: c}
cur := wxpayCurrency
appID := ResolveWxpayJSAPIAppID(w.config)
prepayReq := jsapi.PrepayRequest{
Appid: core.String(appID),
Mchid: core.String(w.config["mchId"]),
Description: core.String(req.Subject),
OutTradeNo: core.String(req.OrderID),
NotifyUrl: core.String(notifyURL),
Amount: &jsapi.Amount{Total: core.Int64(totalFen), Currency: &cur},
Payer: &jsapi.Payer{Openid: core.String(strings.TrimSpace(req.OpenID))},
}
return w.prepayNative(ctx, c, req, notifyURL, totalFen)
if clientIP := strings.TrimSpace(req.ClientIP); clientIP != "" {
prepayReq.SceneInfo = &jsapi.SceneInfo{PayerClientIp: core.String(clientIP)}
}
resp, _, err := wxpayJSAPIPrepayWithRequestPayment(ctx, svc, prepayReq)
if err != nil {
return nil, fmt.Errorf("wxpay jsapi prepay: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
ResultType: payment.CreatePaymentResultJSAPIReady,
JSAPI: &payment.WechatJSAPIPayload{
AppID: wxSV(resp.Appid),
TimeStamp: wxSV(resp.TimeStamp),
NonceStr: wxSV(resp.NonceStr),
Package: wxSV(resp.Package),
SignType: wxSV(resp.SignType),
PaySign: wxSV(resp.PaySign),
},
}, nil
}
func (w *Wxpay) prepayNative(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
svc := native.NativeApiService{Client: c}
cur := wxpayCurrency
resp, _, err := svc.Prepay(ctx, native.PrepayRequest{
resp, _, err := wxpayNativePrepay(ctx, svc, native.PrepayRequest{
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
NotifyUrl: core.String(notifyURL),
@@ -182,7 +251,7 @@ func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.Create
svc := h5.H5ApiService{Client: c}
cur := wxpayCurrency
tp := wxpayH5Type
resp, _, err := svc.Prepay(ctx, h5.PrepayRequest{
resp, _, err := wxpayH5Prepay(ctx, svc, h5.PrepayRequest{
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
NotifyUrl: core.String(notifyURL),
@@ -196,9 +265,63 @@ func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.Create
if resp.H5Url != nil {
h5URL = *resp.H5Url
}
h5URL, err = appendWxpayRedirectURL(h5URL, req)
if err != nil {
return nil, err
}
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil
}
func resolveWxpayCreateMode(req payment.CreatePaymentRequest) (string, error) {
if strings.TrimSpace(req.OpenID) != "" {
return wxpayModeJSAPI, nil
}
if req.IsMobile {
if strings.TrimSpace(req.ClientIP) == "" {
return "", fmt.Errorf("wxpay H5 payment requires client IP")
}
return wxpayModeH5, nil
}
return wxpayModeNative, nil
}
func appendWxpayRedirectURL(h5URL string, req payment.CreatePaymentRequest) (string, error) {
h5URL = strings.TrimSpace(h5URL)
returnURL := strings.TrimSpace(req.ReturnURL)
if h5URL == "" || returnURL == "" {
return h5URL, nil
}
redirectURL, err := buildWxpayResultURL(returnURL, req)
if err != nil {
return "", err
}
sep := "&"
if !strings.Contains(h5URL, "?") {
sep = "?"
}
return h5URL + sep + "redirect_url=" + url.QueryEscape(redirectURL), nil
}
func buildWxpayResultURL(returnURL string, req payment.CreatePaymentRequest) (string, error) {
u, err := url.Parse(returnURL)
if err != nil || !u.IsAbs() || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return "", fmt.Errorf("return URL must be an absolute http(s) URL")
}
values := url.Values{}
values.Set("out_trade_no", strings.TrimSpace(req.OrderID))
if paymentType := strings.TrimSpace(req.PaymentType); paymentType != "" {
values.Set("payment_type", paymentType)
}
u.Path = wxpayResultPath
u.RawPath = ""
u.RawQuery = values.Encode()
u.Fragment = ""
return u.String(), nil
}
func wxSV(s *string) string {
if s == nil {
return ""

View File

@@ -3,10 +3,15 @@
package provider
import (
"context"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
)
func TestMapWxState(t *testing.T) {
@@ -257,3 +262,197 @@ func TestNewWxpay(t *testing.T) {
})
}
}
func TestResolveWxpayJSAPIAppID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config map[string]string
want string
}{
{
name: "prefers dedicated mp app id",
config: map[string]string{
"mpAppId": "wx-mp-app",
"appId": "wx-merchant-app",
},
want: "wx-mp-app",
},
{
name: "falls back to merchant app id",
config: map[string]string{
"appId": "wx-merchant-app",
},
want: "wx-merchant-app",
},
{
name: "missing app ids returns empty",
config: map[string]string{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := ResolveWxpayJSAPIAppID(tt.config); got != tt.want {
t.Fatalf("ResolveWxpayJSAPIAppID() = %q, want %q", got, tt.want)
}
})
}
}
func TestResolveWxpayCreateMode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
req payment.CreatePaymentRequest
wantMode string
wantErr string
}{
{
name: "desktop uses native",
req: payment.CreatePaymentRequest{},
wantMode: wxpayModeNative,
},
{
name: "mobile uses h5 when client ip is present",
req: payment.CreatePaymentRequest{
IsMobile: true,
ClientIP: "203.0.113.10",
},
wantMode: wxpayModeH5,
},
{
name: "mobile without client ip returns clear error",
req: payment.CreatePaymentRequest{
IsMobile: true,
},
wantErr: "requires client IP",
},
{
name: "openid uses jsapi mode",
req: payment.CreatePaymentRequest{
OpenID: "openid-123",
},
wantMode: wxpayModeJSAPI,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := resolveWxpayCreateMode(tt.req)
if tt.wantErr != "" {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error %q should contain %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.wantMode {
t.Fatalf("resolveWxpayCreateMode() = %q, want %q", got, tt.wantMode)
}
})
}
}
func TestCreatePaymentWithOpenIDReturnsJSAPIResult(t *testing.T) {
origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment
origNativePrepay := wxpayNativePrepay
origH5Prepay := wxpayH5Prepay
t.Cleanup(func() {
wxpayJSAPIPrepayWithRequestPayment = origJSAPIPrepay
wxpayNativePrepay = origNativePrepay
wxpayH5Prepay = origH5Prepay
})
jsapiCalls := 0
nativeCalls := 0
h5Calls := 0
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
jsapiCalls++
if got := wxSV(req.Payer.Openid); got != "openid-123" {
t.Fatalf("openid = %q, want %q", got, "openid-123")
}
if req.SceneInfo == nil || wxSV(req.SceneInfo.PayerClientIp) != "203.0.113.10" {
t.Fatalf("scene_info payer_client_ip = %q, want %q", wxSV(req.SceneInfo.PayerClientIp), "203.0.113.10")
}
return &jsapi.PrepayWithRequestPaymentResponse{
Appid: core.String("wx123"),
TimeStamp: core.String("1712345678"),
NonceStr: core.String("nonce-123"),
Package: core.String("prepay_id=wx_prepay_123"),
SignType: core.String("RSA"),
PaySign: core.String("signed-payload"),
}, nil, nil
}
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
nativeCalls++
return &native.PrepayResponse{}, nil, nil
}
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
h5Calls++
return &h5.PrepayResponse{}, nil, nil
}
provider := &Wxpay{
config: map[string]string{
"appId": "wx123",
"mchId": "mch123",
},
coreClient: &core.Client{},
}
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
OrderID: "sub2_88",
Amount: "66.88",
PaymentType: payment.TypeWxpay,
NotifyURL: "https://merchant.example/payment/notify",
OpenID: "openid-123",
ClientIP: "203.0.113.10",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if jsapiCalls != 1 {
t.Fatalf("jsapi prepay calls = %d, want 1", jsapiCalls)
}
if nativeCalls != 0 {
t.Fatalf("native prepay calls = %d, want 0", nativeCalls)
}
if h5Calls != 0 {
t.Fatalf("h5 prepay calls = %d, want 0", h5Calls)
}
if resp.ResultType != payment.CreatePaymentResultJSAPIReady {
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultJSAPIReady)
}
if resp.JSAPI == nil {
t.Fatal("expected jsapi payload, got nil")
}
if resp.JSAPI.AppID != "wx123" {
t.Fatalf("jsapi appId = %q, want %q", resp.JSAPI.AppID, "wx123")
}
if resp.JSAPI.TimeStamp != "1712345678" {
t.Fatalf("jsapi timeStamp = %q, want %q", resp.JSAPI.TimeStamp, "1712345678")
}
if resp.JSAPI.NonceStr != "nonce-123" {
t.Fatalf("jsapi nonceStr = %q, want %q", resp.JSAPI.NonceStr, "nonce-123")
}
if resp.JSAPI.Package != "prepay_id=wx_prepay_123" {
t.Fatalf("jsapi package = %q, want %q", resp.JSAPI.Package, "prepay_id=wx_prepay_123")
}
if resp.JSAPI.SignType != "RSA" {
t.Fatalf("jsapi signType = %q, want %q", resp.JSAPI.SignType, "RSA")
}
if resp.JSAPI.PaySign != "signed-payload" {
t.Fatalf("jsapi paySign = %q, want %q", resp.JSAPI.PaySign, "signed-payload")
}
}

View File

@@ -101,17 +101,50 @@ type CreatePaymentRequest struct {
Subject string // Product description
NotifyURL string // Webhook callback URL
ReturnURL string // Browser redirect URL after payment
OpenID string // WeChat JSAPI payer OpenID when available
ClientIP string // Payer's IP address
IsMobile bool // Whether the request comes from a mobile device
InstanceSubMethods string // Comma-separated sub-methods from instance supported_types (for Stripe)
}
// CreatePaymentResultType describes the shape of the create-payment result.
type CreatePaymentResultType = string
const (
CreatePaymentResultOrderCreated CreatePaymentResultType = "order_created"
CreatePaymentResultOAuthRequired CreatePaymentResultType = "oauth_required"
CreatePaymentResultJSAPIReady CreatePaymentResultType = "jsapi_ready"
)
// WechatOAuthInfo describes the next step when WeChat OAuth is required before payment.
type WechatOAuthInfo struct {
AuthorizeURL string `json:"authorize_url,omitempty"`
AppID string `json:"appid,omitempty"`
OpenID string `json:"openid,omitempty"`
Scope string `json:"scope,omitempty"`
State string `json:"state,omitempty"`
RedirectURL string `json:"redirect_url,omitempty"`
}
// WechatJSAPIPayload contains the fields the frontend needs to invoke WeChat JSAPI payment.
type WechatJSAPIPayload struct {
AppID string `json:"appId,omitempty"`
TimeStamp string `json:"timeStamp,omitempty"`
NonceStr string `json:"nonceStr,omitempty"`
Package string `json:"package,omitempty"`
SignType string `json:"signType,omitempty"`
PaySign string `json:"paySign,omitempty"`
}
// CreatePaymentResponse is returned after successfully initiating a payment.
type CreatePaymentResponse struct {
TradeNo string // Third-party transaction ID
PayURL string // H5 payment URL (alipay/wxpay)
QRCode string // QR code content for scanning
ClientSecret string // Stripe PaymentIntent client secret
TradeNo string // Third-party transaction ID
PayURL string // H5 payment URL (alipay/wxpay)
QRCode string // QR code content for scanning
ClientSecret string // Stripe PaymentIntent client secret
ResultType CreatePaymentResultType // Typed result contract for frontend flows
OAuth *WechatOAuthInfo // WeChat OAuth bootstrap payload when required
JSAPI *WechatJSAPIPayload // WeChat JSAPI invocation payload when ready
}
// QueryOrderResponse describes the payment status from the upstream provider.

View File

@@ -66,6 +66,8 @@ func RegisterAuthRoutes(
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart)
auth.GET("/oauth/wechat/callback", h.Auth.WeChatOAuthCallback)
auth.GET("/oauth/wechat/payment/start", h.Auth.WeChatPaymentOAuthStart)
auth.GET("/oauth/wechat/payment/callback", h.Auth.WeChatPaymentOAuthCallback)
auth.POST("/oauth/pending/exchange",
rateLimiter.LimitWithOptions("oauth-pending-exchange", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"log/slog"
"math"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -57,11 +59,25 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
feeRate := cfg.RechargeFeeRate
payAmountStr := payment.CalculatePayAmount(limitAmount, feeRate)
payAmount, _ := strconv.ParseFloat(payAmountStr, 64)
sel, err := s.selectCreateOrderInstance(ctx, req, cfg, payAmount)
if err != nil {
return nil, err
}
if err := s.validateSelectedCreateOrderInstance(ctx, req, sel); err != nil {
return nil, err
}
oauthResp, err := s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, limitAmount, payAmount, feeRate, sel)
if err != nil {
return nil, err
}
if oauthResp != nil {
return oauthResp, nil
}
order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount)
if err != nil {
return nil, err
}
resp, err := s.invokeProvider(ctx, order, req, cfg, limitAmount, payAmountStr, payAmount, plan)
resp, err := s.invokeProvider(ctx, order, req, cfg, limitAmount, payAmountStr, payAmount, plan, sel)
if err != nil {
_, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID).
SetStatus(OrderStatusFailed).
@@ -199,9 +215,7 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user
return nil
}
func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan) (*CreateOrderResponse, error) {
// Select an instance across all providers that support the requested payment type.
// This enables cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay").
func (s *PaymentService) selectCreateOrderInstance(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig, payAmount float64) (*payment.InstanceSelection, error) {
sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
if err != nil {
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment method (%s) is not configured", req.PaymentType))
@@ -209,6 +223,10 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
if sel == nil {
return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no available payment instance")
}
return sel, nil
}
func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
prov, err := provider.CreateProvider(sel.ProviderKey, sel.InstanceID, sel.Config)
if err != nil {
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable")
@@ -237,19 +255,17 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
if err != nil {
return nil, err
}
pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{
OrderID: outTradeNo,
Amount: payAmountStr,
PaymentType: req.PaymentType,
Subject: subject,
ReturnURL: providerReturnURL,
ClientIP: req.ClientIP,
IsMobile: req.IsMobile,
InstanceSubMethods: sel.SupportedTypes,
})
providerReq := buildProviderCreatePaymentRequest(CreateOrderRequest{
PaymentType: req.PaymentType,
OpenID: req.OpenID,
ClientIP: req.ClientIP,
IsMobile: req.IsMobile,
ReturnURL: providerReturnURL,
}, sel, outTradeNo, payAmountStr, subject)
pr, err := prov.CreatePayment(ctx, providerReq)
if err != nil {
slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
return nil, classifyCreatePaymentError(req, sel.ProviderKey, err)
}
_, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).
SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).
@@ -269,20 +285,34 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
"orderType": req.OrderType,
"paymentSource": NormalizePaymentSource(req.PaymentSource),
})
return &CreateOrderResponse{
OrderID: order.ID,
Amount: order.Amount,
PayAmount: payAmount,
FeeRate: order.FeeRate,
Status: OrderStatusPending,
PaymentType: req.PaymentType,
PayURL: pr.PayURL,
QRCode: pr.QRCode,
ClientSecret: pr.ClientSecret,
ExpiresAt: order.ExpiresAt,
PaymentMode: sel.PaymentMode,
ResumeToken: resumeToken,
}, nil
resultType := pr.ResultType
if resultType == "" {
resultType = payment.CreatePaymentResultOrderCreated
}
resp := buildCreateOrderResponse(order, req, payAmount, sel, pr, resultType)
resp.ResumeToken = resumeToken
return resp, nil
}
func buildProviderCreatePaymentRequest(req CreateOrderRequest, sel *payment.InstanceSelection, orderID, amount, subject string) payment.CreatePaymentRequest {
return payment.CreatePaymentRequest{
OrderID: orderID,
Amount: amount,
PaymentType: req.PaymentType,
Subject: subject,
ReturnURL: req.ReturnURL,
OpenID: strings.TrimSpace(req.OpenID),
ClientIP: req.ClientIP,
IsMobile: req.IsMobile,
InstanceSubMethods: selectedInstanceSupportedTypes(sel),
}
}
func selectedInstanceSupportedTypes(sel *payment.InstanceSelection) string {
if sel == nil {
return ""
}
return sel.SupportedTypes
}
func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limitAmount float64, cfg *PaymentConfig) string {
@@ -301,6 +331,183 @@ func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limit
return "Sub2API " + amountStr + " CNY"
}
func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) {
return s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, amount, payAmount, feeRate, nil)
}
func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponseForSelection(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
if sel != nil && sel.ProviderKey != "" && sel.ProviderKey != payment.TypeWxpay {
return nil, nil
}
if strings.TrimSpace(req.OpenID) != "" || !req.IsWeChatBrowser || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
return nil, nil
}
return s.buildWeChatOAuthRequiredResponse(ctx, req, amount, payAmount, feeRate)
}
func (s *PaymentService) buildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) {
appID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
if err != nil {
return nil, err
}
authorizeURL, err := buildWeChatPaymentOAuthStartURL(req, "snsapi_base")
if err != nil {
return nil, err
}
return &CreateOrderResponse{
Amount: amount,
PayAmount: payAmount,
FeeRate: feeRate,
ResultType: payment.CreatePaymentResultOAuthRequired,
PaymentType: req.PaymentType,
OAuth: &payment.WechatOAuthInfo{
AuthorizeURL: authorizeURL,
AppID: appID,
Scope: "snsapi_base",
RedirectURL: "/auth/wechat/payment/callback",
},
}, nil
}
func (s *PaymentService) validateSelectedCreateOrderInstance(ctx context.Context, req CreateOrderRequest, sel *payment.InstanceSelection) error {
if !requiresWeChatJSAPICompatibleSelection(req, sel) {
return nil
}
expectedAppID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
if err != nil {
return err
}
selectedAppID := provider.ResolveWxpayJSAPIAppID(sel.Config)
if selectedAppID == "" || selectedAppID != expectedAppID {
return infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "selected payment instance is not compatible with the current WeChat OAuth app")
}
return nil
}
func requiresWeChatJSAPICompatibleSelection(req CreateOrderRequest, sel *payment.InstanceSelection) bool {
if sel == nil || sel.ProviderKey != payment.TypeWxpay || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
return false
}
return req.IsWeChatBrowser || strings.TrimSpace(req.OpenID) != ""
}
func (s *PaymentService) getWeChatPaymentOAuthCredential(context.Context) (string, string, error) {
appID := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID"))
appSecret := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET"))
if appID == "" || appSecret == "" {
return "", "", infraerrors.ServiceUnavailable(
"WECHAT_PAYMENT_MP_NOT_CONFIGURED",
"wechat in-app payment requires a complete WeChat MP OAuth credential",
)
}
return appID, appSecret, nil
}
func classifyCreatePaymentError(req CreateOrderRequest, providerKey string, err error) error {
if err == nil {
return nil
}
if providerKey == payment.TypeWxpay &&
payment.GetBasePaymentType(req.PaymentType) == payment.TypeWxpay &&
strings.Contains(err.Error(), "wxpay h5 payments are not authorized for this merchant") {
return infraerrors.ServiceUnavailable(
"WECHAT_H5_NOT_AUTHORIZED",
"wechat h5 payment is not available for this merchant",
).WithMetadata(map[string]string{
"action": "open_in_wechat_or_scan_qr",
})
}
return infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
}
func buildCreateOrderResponse(order *dbent.PaymentOrder, req CreateOrderRequest, payAmount float64, sel *payment.InstanceSelection, pr *payment.CreatePaymentResponse, resultType payment.CreatePaymentResultType) *CreateOrderResponse {
return &CreateOrderResponse{
OrderID: order.ID,
Amount: order.Amount,
PayAmount: payAmount,
FeeRate: order.FeeRate,
Status: OrderStatusPending,
ResultType: resultType,
PaymentType: req.PaymentType,
OutTradeNo: order.OutTradeNo,
PayURL: pr.PayURL,
QRCode: pr.QRCode,
ClientSecret: pr.ClientSecret,
OAuth: pr.OAuth,
JSAPI: pr.JSAPI,
JSAPIPayload: pr.JSAPI,
ExpiresAt: order.ExpiresAt,
PaymentMode: sel.PaymentMode,
}
}
func buildWeChatPaymentOAuthStartURL(req CreateOrderRequest, scope string) (string, error) {
u, err := url.Parse("/api/v1/auth/oauth/wechat/payment/start")
if err != nil {
return "", fmt.Errorf("build wechat payment oauth start url: %w", err)
}
q := u.Query()
q.Set("payment_type", strings.TrimSpace(req.PaymentType))
if req.Amount > 0 {
q.Set("amount", strconv.FormatFloat(req.Amount, 'f', -1, 64))
}
if orderType := strings.TrimSpace(req.OrderType); orderType != "" {
q.Set("order_type", orderType)
}
if req.PlanID > 0 {
q.Set("plan_id", strconv.FormatInt(req.PlanID, 10))
}
if scope = strings.TrimSpace(scope); scope != "" {
q.Set("scope", scope)
}
if redirectTo := paymentRedirectPathFromURL(req.SrcURL); redirectTo != "" {
q.Set("redirect", redirectTo)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func paymentRedirectPathFromURL(rawURL string) string {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return "/purchase"
}
if strings.HasPrefix(rawURL, "/") && !strings.HasPrefix(rawURL, "//") {
return normalizePaymentRedirectPath(rawURL)
}
u, err := url.Parse(rawURL)
if err != nil {
return "/purchase"
}
path := strings.TrimSpace(u.EscapedPath())
if path == "" {
path = strings.TrimSpace(u.Path)
}
if path == "" || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
return "/purchase"
}
if strings.TrimSpace(u.RawQuery) != "" {
path += "?" + u.RawQuery
}
return normalizePaymentRedirectPath(path)
}
func normalizePaymentRedirectPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return "/purchase"
}
if path == "/payment" {
return "/purchase"
}
if strings.HasPrefix(path, "/payment?") {
return "/purchase" + strings.TrimPrefix(path, "/payment")
}
return path
}
// --- Order Queries ---
func (s *PaymentService) GetOrder(ctx context.Context, orderID, userID int64) (*dbent.PaymentOrder, error) {

View File

@@ -0,0 +1,177 @@
package service
import (
"context"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
func TestBuildCreateOrderResponseDefaultsToOrderCreated(t *testing.T) {
t.Parallel()
expiresAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
resp := buildCreateOrderResponse(
&dbent.PaymentOrder{
ID: 42,
Amount: 12.34,
FeeRate: 0.03,
ExpiresAt: expiresAt,
OutTradeNo: "sub2_42",
},
CreateOrderRequest{PaymentType: payment.TypeWxpay},
12.71,
&payment.InstanceSelection{PaymentMode: "qrcode"},
&payment.CreatePaymentResponse{
TradeNo: "sub2_42",
QRCode: "weixin://wxpay/bizpayurl?pr=test",
},
payment.CreatePaymentResultOrderCreated,
)
if resp.ResultType != payment.CreatePaymentResultOrderCreated {
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultOrderCreated)
}
if resp.OutTradeNo != "sub2_42" {
t.Fatalf("out_trade_no = %q, want %q", resp.OutTradeNo, "sub2_42")
}
if resp.QRCode != "weixin://wxpay/bizpayurl?pr=test" {
t.Fatalf("qr_code = %q, want %q", resp.QRCode, "weixin://wxpay/bizpayurl?pr=test")
}
if resp.JSAPI != nil || resp.JSAPIPayload != nil {
t.Fatal("order_created response should not include jsapi payload")
}
if !resp.ExpiresAt.Equal(expiresAt) {
t.Fatalf("expires_at = %v, want %v", resp.ExpiresAt, expiresAt)
}
}
func TestBuildCreateOrderResponseCopiesJSAPIPayload(t *testing.T) {
t.Parallel()
jsapiPayload := &payment.WechatJSAPIPayload{
AppID: "wx123",
TimeStamp: "1712345678",
NonceStr: "nonce-123",
Package: "prepay_id=wx123",
SignType: "RSA",
PaySign: "signed-payload",
}
resp := buildCreateOrderResponse(
&dbent.PaymentOrder{
ID: 88,
Amount: 66.88,
FeeRate: 0.01,
ExpiresAt: time.Date(2026, 4, 16, 13, 0, 0, 0, time.UTC),
OutTradeNo: "sub2_88",
},
CreateOrderRequest{PaymentType: payment.TypeWxpay},
67.55,
&payment.InstanceSelection{PaymentMode: "popup"},
&payment.CreatePaymentResponse{
TradeNo: "sub2_88",
ResultType: payment.CreatePaymentResultJSAPIReady,
JSAPI: jsapiPayload,
},
payment.CreatePaymentResultJSAPIReady,
)
if resp.ResultType != payment.CreatePaymentResultJSAPIReady {
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultJSAPIReady)
}
if resp.JSAPI == nil || resp.JSAPIPayload == nil {
t.Fatal("expected jsapi payload aliases to be populated")
}
if resp.JSAPI != jsapiPayload || resp.JSAPIPayload != jsapiPayload {
t.Fatal("expected jsapi aliases to preserve the original pointer")
}
}
func TestMaybeBuildWeChatOAuthRequiredResponse(t *testing.T) {
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx123456")
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wechat-secret")
svc := &PaymentService{}
resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{
Amount: 12.5,
PaymentType: payment.TypeWxpay,
IsWeChatBrowser: true,
SrcURL: "https://merchant.example/payment?from=wechat",
OrderType: payment.OrderTypeBalance,
}, 12.5, 12.88, 0.03)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil {
t.Fatal("expected oauth_required response, got nil")
}
if resp.ResultType != payment.CreatePaymentResultOAuthRequired {
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultOAuthRequired)
}
if resp.OAuth == nil {
t.Fatal("expected oauth payload, got nil")
}
if resp.OAuth.AppID != "wx123456" {
t.Fatalf("appid = %q, want %q", resp.OAuth.AppID, "wx123456")
}
if resp.OAuth.Scope != "snsapi_base" {
t.Fatalf("scope = %q, want %q", resp.OAuth.Scope, "snsapi_base")
}
if resp.OAuth.RedirectURL != "/auth/wechat/payment/callback" {
t.Fatalf("redirect_url = %q, want %q", resp.OAuth.RedirectURL, "/auth/wechat/payment/callback")
}
if resp.OAuth.AuthorizeURL != "/api/v1/auth/oauth/wechat/payment/start?amount=12.5&order_type=balance&payment_type=wxpay&redirect=%2Fpurchase%3Ffrom%3Dwechat&scope=snsapi_base" {
t.Fatalf("authorize_url = %q", resp.OAuth.AuthorizeURL)
}
}
func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testing.T) {
t.Parallel()
svc := &PaymentService{}
resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{
Amount: 12.5,
PaymentType: payment.TypeWxpay,
IsWeChatBrowser: true,
SrcURL: "https://merchant.example/payment?from=wechat",
OrderType: payment.OrderTypeBalance,
}, 12.5, 12.88, 0.03)
if resp != nil {
t.Fatalf("expected nil response, got %+v", resp)
}
if err == nil {
t.Fatal("expected error, got nil")
}
appErr := infraerrors.FromError(err)
if appErr.Reason != "WECHAT_PAYMENT_MP_NOT_CONFIGURED" {
t.Fatalf("reason = %q, want %q", appErr.Reason, "WECHAT_PAYMENT_MP_NOT_CONFIGURED")
}
}
func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t *testing.T) {
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx123456")
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wechat-secret")
svc := &PaymentService{}
resp, err := svc.maybeBuildWeChatOAuthRequiredResponseForSelection(context.Background(), CreateOrderRequest{
Amount: 12.5,
PaymentType: payment.TypeWxpay,
IsWeChatBrowser: true,
OrderType: payment.OrderTypeBalance,
}, 12.5, 12.88, 0.03, &payment.InstanceSelection{
ProviderKey: payment.TypeEasyPay,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp != nil {
t.Fatalf("expected nil response, got %+v", resp)
}
}

View File

@@ -64,32 +64,39 @@ func generateRandomString(n int) string {
}
type CreateOrderRequest struct {
UserID int64
Amount float64
PaymentType string
ClientIP string
IsMobile bool
SrcHost string
SrcURL string
ReturnURL string
PaymentSource string
OrderType string
PlanID int64
UserID int64
Amount float64
PaymentType string
OpenID string
ClientIP string
IsMobile bool
IsWeChatBrowser bool
SrcHost string
SrcURL string
ReturnURL string
PaymentSource string
OrderType string
PlanID int64
}
type CreateOrderResponse struct {
OrderID int64 `json:"order_id"`
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
Status string `json:"status"`
PaymentType string `json:"payment_type"`
PayURL string `json:"pay_url,omitempty"`
QRCode string `json:"qr_code,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
PaymentMode string `json:"payment_mode,omitempty"`
ResumeToken string `json:"resume_token,omitempty"`
OrderID int64 `json:"order_id"`
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
Status string `json:"status"`
ResultType payment.CreatePaymentResultType `json:"result_type,omitempty"`
PaymentType string `json:"payment_type"`
OutTradeNo string `json:"out_trade_no,omitempty"`
PayURL string `json:"pay_url,omitempty"`
QRCode string `json:"qr_code,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
OAuth *payment.WechatOAuthInfo `json:"oauth,omitempty"`
JSAPI *payment.WechatJSAPIPayload `json:"jsapi,omitempty"`
JSAPIPayload *payment.WechatJSAPIPayload `json:"jsapi_payload,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
PaymentMode string `json:"payment_mode,omitempty"`
ResumeToken string `json:"resume_token,omitempty"`
}
type OrderListParams struct {