diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go index 95993dfc..4d25a763 100644 --- a/backend/internal/handler/auth_wechat_oauth.go +++ b/backend/internal/handler/auth_wechat_oauth.go @@ -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, + }) +} diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 6440cdfd..d54cbe92 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -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") +} diff --git a/backend/internal/payment/provider/wxpay.go b/backend/internal/payment/provider/wxpay.go index 0b41c4fb..84b324a8 100644 --- a/backend/internal/payment/provider/wxpay.go +++ b/backend/internal/payment/provider/wxpay.go @@ -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 "" diff --git a/backend/internal/payment/provider/wxpay_test.go b/backend/internal/payment/provider/wxpay_test.go index b8b99537..5074c545 100644 --- a/backend/internal/payment/provider/wxpay_test.go +++ b/backend/internal/payment/provider/wxpay_test.go @@ -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") + } +} diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index 5d613a4a..bb125247 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -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. diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index 1f28e9c3..20d3d9b4 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -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, diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 4b9b1872..221d6b94 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -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) { diff --git a/backend/internal/service/payment_order_result_test.go b/backend/internal/service/payment_order_result_test.go new file mode 100644 index 00000000..0daa8213 --- /dev/null +++ b/backend/internal/service/payment_order_result_test.go @@ -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) + } +} diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index d3175ba6..73bbb256 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -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 { diff --git a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts index fa1a0931..66c41fe3 100644 --- a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts +++ b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts @@ -105,6 +105,50 @@ describe('decidePaymentLaunch', () => { expect(decision.recovery.paymentMode).toBe('popup') expect(decision.recovery.resumeToken).toBe('resume-2') }) + + it('returns wechat oauth launch when backend requires in-app authorization', () => { + const decision = decidePaymentLaunch(createOrderResult({ + result_type: 'oauth_required', + payment_type: 'wxpay', + oauth: { + authorize_url: '/api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay', + appid: 'wx123', + scope: 'snsapi_base', + redirect_url: '/auth/wechat/payment/callback', + }, + }), { + visibleMethod: 'wxpay', + orderType: 'balance', + isMobile: true, + }) + + expect(decision.kind).toBe('wechat_oauth') + expect(decision.oauth?.authorize_url).toContain('/api/v1/auth/oauth/wechat/payment/start') + expect(decision.paymentState.paymentType).toBe('wxpay') + }) + + it('returns wechat jsapi launch when backend has a jsapi payload ready', () => { + const decision = decidePaymentLaunch(createOrderResult({ + result_type: 'jsapi_ready', + payment_type: 'wxpay', + jsapi: { + appId: 'wx123', + timeStamp: '1712345678', + nonceStr: 'nonce-123', + package: 'prepay_id=wx123', + signType: 'RSA', + paySign: 'signed-payload', + }, + }), { + visibleMethod: 'wxpay', + orderType: 'subscription', + isMobile: true, + }) + + expect(decision.kind).toBe('wechat_jsapi') + expect(decision.jsapi?.appId).toBe('wx123') + expect(decision.paymentState.orderType).toBe('subscription') + }) }) describe('buildCreateOrderPayload', () => { diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts index ac5a27a0..955fa3ba 100644 --- a/frontend/src/components/payment/paymentFlow.ts +++ b/frontend/src/components/payment/paymentFlow.ts @@ -1,4 +1,11 @@ -import type { CreateOrderRequest, CreateOrderResult, MethodLimit, OrderType } from '@/types/payment' +import type { + CreateOrderRequest, + CreateOrderResult, + MethodLimit, + OrderType, + WechatJSAPIPayload, + WechatOAuthInfo, +} from '@/types/payment' export const PAYMENT_RECOVERY_STORAGE_KEY = 'payment.recovery.current' @@ -16,6 +23,8 @@ export type PaymentLaunchKind = | 'redirect_waiting' | 'stripe_popup' | 'stripe_route' + | 'wechat_oauth' + | 'wechat_jsapi' | 'unhandled' export interface PaymentRecoverySnapshot { @@ -47,6 +56,8 @@ export interface PaymentLaunchDecision { paymentState: PaymentRecoverySnapshot recovery: PaymentRecoverySnapshot stripeMethod?: StripeVisibleMethod + oauth?: WechatOAuthInfo + jsapi?: WechatJSAPIPayload } export interface BuildCreateOrderPayloadInput { @@ -139,6 +150,15 @@ export function decidePaymentLaunch( return { kind, paymentState, recovery: paymentState, stripeMethod } } + if (result.result_type === 'oauth_required' && result.oauth?.authorize_url) { + return { kind: 'wechat_oauth', paymentState: baseState, recovery: baseState, oauth: result.oauth } + } + + const jsapiPayload = result.jsapi ?? result.jsapi_payload + if (result.result_type === 'jsapi_ready' && jsapiPayload) { + return { kind: 'wechat_jsapi', paymentState: baseState, recovery: baseState, jsapi: jsapiPayload } + } + if (baseState.qrCode) { return { kind: 'qr_waiting', paymentState: baseState, recovery: baseState } } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index beaa1da2..492d3f53 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -92,6 +92,15 @@ const routes: RouteRecordRaw[] = [ title: 'WeChat OAuth Callback' } }, + { + path: '/auth/wechat/payment/callback', + name: 'WeChatPaymentOAuthCallback', + component: () => import('@/views/auth/WechatPaymentCallbackView.vue'), + meta: { + requiresAuth: false, + title: 'WeChat Payment Callback' + } + }, { path: '/auth/oidc/callback', name: 'OIDCOAuthCallback', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index fe0f794a..5cd49064 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -156,6 +156,28 @@ export interface CreateOrderRequest { plan_id?: number return_url?: string payment_source?: string + openid?: string + is_mobile?: boolean +} + +export type CreateOrderResultType = 'order_created' | 'oauth_required' | 'jsapi_ready' + +export interface WechatOAuthInfo { + authorize_url?: string + appid?: string + openid?: string + scope?: string + state?: string + redirect_url?: string +} + +export interface WechatJSAPIPayload { + appId?: string + timeStamp?: string + nonceStr?: string + package?: string + signType?: string + paySign?: string } export interface CreateOrderResult { @@ -167,8 +189,14 @@ export interface CreateOrderResult { pay_amount: number fee_rate: number expires_at: string + result_type?: CreateOrderResultType + payment_type?: string + out_trade_no?: string payment_mode?: string resume_token?: string + oauth?: WechatOAuthInfo + jsapi?: WechatJSAPIPayload + jsapi_payload?: WechatJSAPIPayload } export interface DashboardStats { diff --git a/frontend/src/views/auth/WechatPaymentCallbackView.vue b/frontend/src/views/auth/WechatPaymentCallbackView.vue new file mode 100644 index 00000000..422a0bb8 --- /dev/null +++ b/frontend/src/views/auth/WechatPaymentCallbackView.vue @@ -0,0 +1,155 @@ + + + + + + {{ callbackTitleText }} + + + {{ errorMessage || callbackProcessingText }} + + + + + + + + + {{ errorMessage }} + + + {{ backToPaymentText }} + + + + + + + + diff --git a/frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts b/frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts new file mode 100644 index 00000000..cfbd9f1c --- /dev/null +++ b/frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts @@ -0,0 +1,80 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import WechatPaymentCallbackView from '@/views/auth/WechatPaymentCallbackView.vue' + +const { replaceMock, routeState, locationState } = vi.hoisted(() => ({ + replaceMock: vi.fn(), + routeState: { + query: {} as Record, + }, + locationState: { + current: { + href: 'http://localhost/auth/wechat/payment/callback', + hash: '', + search: '', + pathname: '/auth/wechat/payment/callback', + origin: 'http://localhost', + } as Location & { origin: string }, + }, +})) + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ + replace: replaceMock, + }), +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + locale: { value: 'zh-CN' }, + }), +})) + +describe('WechatPaymentCallbackView', () => { + beforeEach(() => { + replaceMock.mockReset() + routeState.query = {} + locationState.current = { + href: 'http://localhost/auth/wechat/payment/callback', + hash: '', + search: '', + pathname: '/auth/wechat/payment/callback', + origin: 'http://localhost', + } as Location & { origin: string } + Object.defineProperty(window, 'location', { + configurable: true, + value: locationState.current, + }) + }) + + it('redirects back to purchase with openid and payment context from hash fragment', async () => { + locationState.current.hash = '#openid=openid-123&payment_type=wxpay&amount=12.5&order_type=balance&redirect=%2Fpurchase%3Ffrom%3Dwechat' + + mount(WechatPaymentCallbackView) + await flushPromises() + + expect(replaceMock).toHaveBeenCalledWith({ + path: '/purchase', + query: { + from: 'wechat', + wechat_resume: '1', + openid: 'openid-123', + payment_type: 'wxpay', + amount: '12.5', + order_type: 'balance', + }, + }) + }) + + it('shows an error when the callback payload is missing openid', async () => { + locationState.current.hash = '#payment_type=wxpay' + + const wrapper = mount(WechatPaymentCallbackView) + await flushPromises() + + expect(replaceMock).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('微信支付回调缺少 openid。') + }) +}) diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index f973ad5b..bfb9dae2 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -309,6 +309,20 @@ const previewImage = ref('') const paymentPhase = ref<'select' | 'paying'>('select') +interface CreateOrderOptions { + openid?: string + paymentType?: string + isResume?: boolean +} + +interface WeixinJSBridgeLike { + invoke( + action: string, + payload: Record, + callback: (result: Record) => void, + ): void +} + function emptyPaymentState(): PaymentRecoverySnapshot { return { orderId: 0, @@ -326,6 +340,48 @@ function emptyPaymentState(): PaymentRecoverySnapshot { } } +function readRouteQueryValue(value: unknown): string { + if (Array.isArray(value)) { + return typeof value[0] === 'string' ? value[0] : '' + } + return typeof value === 'string' ? value : '' +} + +function getWeixinJSBridge(): WeixinJSBridgeLike | undefined { + return (window as Window & { WeixinJSBridge?: WeixinJSBridgeLike }).WeixinJSBridge +} + +function waitForWeixinJSBridge(timeoutMs = 4000): Promise { + const existing = getWeixinJSBridge() + if (existing) return Promise.resolve(existing) + + return new Promise((resolve) => { + let settled = false + const finish = (bridge: WeixinJSBridgeLike | null) => { + if (settled) return + settled = true + document.removeEventListener('WeixinJSBridgeReady', handleReady) + document.removeEventListener('onWeixinJSBridgeReady', handleReady) + window.clearTimeout(timer) + resolve(bridge) + } + const handleReady = () => finish(getWeixinJSBridge() ?? null) + const timer = window.setTimeout(() => finish(getWeixinJSBridge() ?? null), timeoutMs) + document.addEventListener('WeixinJSBridgeReady', handleReady, false) + document.addEventListener('onWeixinJSBridgeReady', handleReady, false) + }) +} + +async function invokeWechatJsapiPayment(payload: Record): Promise> { + const bridge = await waitForWeixinJSBridge() + if (!bridge) { + throw new Error('WeixinJSBridge is unavailable') + } + return new Promise((resolve) => { + bridge.invoke('getBrandWCPayRequest', payload, (result) => resolve(result || {})) + }) +} + const paymentState = ref(emptyPaymentState()) function persistRecoverySnapshot(snapshot: PaymentRecoverySnapshot) { @@ -560,25 +616,32 @@ async function confirmSubscribe() { await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id) } -async function createOrder(orderAmount: number, orderType: OrderType, planId?: number) { +async function createOrder(orderAmount: number, orderType: OrderType, planId?: number, options: CreateOrderOptions = {}) { submitting.value = true errorMessage.value = '' try { - const result = await paymentStore.createOrder(buildCreateOrderPayload({ + const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value + const payload = buildCreateOrderPayload({ amount: orderAmount, - paymentType: selectedMethod.value, + paymentType: requestType, orderType, planId, origin: typeof window !== 'undefined' ? window.location.origin : '', isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent), - })) as CreateOrderResult & { resume_token?: string } + }) + if (options.openid) { + payload.openid = options.openid + } + payload.is_mobile = isMobileDevice() + + const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string } const openWindow = (url: string, features = POPUP_WINDOW_FEATURES) => { const win = window.open(url, 'paymentPopup', features) if (!win || win.closed) { window.location.href = url } } - const visibleMethod = normalizeVisibleMethod(selectedMethod.value) || selectedMethod.value + const visibleMethod = normalizeVisibleMethod(requestType) || requestType const stripeMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay' const stripeRouteUrl = result.client_secret ? router.resolve({ @@ -599,6 +662,11 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n stripeRouteUrl, }) + if (decision.kind === 'wechat_oauth' && decision.oauth?.authorize_url) { + window.location.href = decision.oauth.authorize_url + return + } + if (decision.kind === 'unhandled') { errorMessage.value = t('payment.result.failed') appStore.showError(errorMessage.value) @@ -617,6 +685,16 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n window.location.href = decision.paymentState.payUrl return } + if (decision.kind === 'wechat_jsapi' && decision.jsapi) { + const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record) + const errMsg = String(jsapiResult.err_msg || '').toLowerCase() + if (errMsg.includes('cancel')) { + appStore.showInfo(t('payment.qr.cancelled')) + } else if (errMsg && !errMsg.includes('ok')) { + appStore.showError(t('payment.result.failed')) + } + return + } if (decision.kind === 'redirect_waiting' && decision.paymentState.payUrl) { if (isMobileDevice()) { window.location.href = decision.paymentState.payUrl @@ -640,6 +718,50 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n } } +async function resumeWechatPaymentFromQuery() { + const openid = readRouteQueryValue(route.query.openid) + if (readRouteQueryValue(route.query.wechat_resume) !== '1' || !openid) { + return + } + + const paymentType = normalizeVisibleMethod(readRouteQueryValue(route.query.payment_type)) || 'wxpay' + const orderType = readRouteQueryValue(route.query.order_type) === 'subscription' ? 'subscription' : 'balance' + const planId = Number.parseInt(readRouteQueryValue(route.query.plan_id), 10) + const rawAmount = Number.parseFloat(readRouteQueryValue(route.query.amount)) + const orderAmount = Number.isFinite(rawAmount) && rawAmount > 0 + ? rawAmount + : (orderType === 'subscription' + ? (checkout.value.plans.find(plan => plan.id === planId)?.price ?? 0) + : validAmount.value) + + selectedMethod.value = paymentType + if (orderType === 'balance' && orderAmount > 0) { + amount.value = orderAmount + } + if (orderType === 'subscription' && Number.isFinite(planId) && planId > 0) { + selectedPlan.value = checkout.value.plans.find(plan => plan.id === planId) ?? null + } + + const nextQuery = { ...route.query } + delete nextQuery.wechat_resume + delete nextQuery.openid + delete nextQuery.state + delete nextQuery.scope + delete nextQuery.payment_type + delete nextQuery.amount + delete nextQuery.order_type + delete nextQuery.plan_id + await router.replace({ path: route.path, query: nextQuery }) + + if (orderAmount > 0) { + await createOrder(orderAmount, orderType, Number.isFinite(planId) && planId > 0 ? planId : undefined, { + openid, + paymentType, + isResume: true, + }) + } +} + onMounted(async () => { try { const res = await paymentAPI.getCheckoutInfo() @@ -672,6 +794,7 @@ onMounted(async () => { removeRecoverySnapshot() } } + await resumeWechatPaymentFromQuery() if (checkout.value.balance_disabled) { activeTab.value = 'subscription' }
+ {{ errorMessage || callbackProcessingText }} +
+ {{ errorMessage }} +