fix: restore wechat payment oauth and jsapi flow
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
177
backend/internal/service/payment_order_result_test.go
Normal file
177
backend/internal/service/payment_order_result_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
155
frontend/src/views/auth/WechatPaymentCallbackView.vue
Normal file
155
frontend/src/views/auth/WechatPaymentCallbackView.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 px-4 py-10 dark:bg-dark-900">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="card p-6">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ callbackTitleText }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ errorMessage || callbackProcessingText }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="!errorMessage"
|
||||
class="mt-6 flex items-center justify-center py-10"
|
||||
>
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-700/50 dark:bg-red-900/20"
|
||||
>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-primary mt-4"
|
||||
type="button"
|
||||
@click="goBackToPayment"
|
||||
>
|
||||
{{ backToPaymentText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const errorMessage = ref('')
|
||||
|
||||
function textWithFallback(key: string, zh: string, en: string): string {
|
||||
const translated = t(key)
|
||||
if (translated !== key) return translated
|
||||
return String(locale.value).toLowerCase().startsWith('zh') ? zh : en
|
||||
}
|
||||
|
||||
const callbackProcessingText = computed(() =>
|
||||
textWithFallback(
|
||||
'auth.wechatPayment.callbackProcessing',
|
||||
'正在恢复微信支付...',
|
||||
'Resuming WeChat payment...',
|
||||
))
|
||||
const callbackTitleText = computed(() =>
|
||||
textWithFallback(
|
||||
'auth.wechatPayment.callbackTitle',
|
||||
'正在恢复微信支付',
|
||||
'Resuming WeChat payment',
|
||||
))
|
||||
const backToPaymentText = computed(() =>
|
||||
textWithFallback(
|
||||
'auth.wechatPayment.backToPayment',
|
||||
'返回支付页',
|
||||
'Back to payment',
|
||||
))
|
||||
|
||||
function readQueryString(key: string): string {
|
||||
const value = route.query[key]
|
||||
if (Array.isArray(value)) {
|
||||
return typeof value[0] === 'string' ? value[0] : ''
|
||||
}
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||
return new URLSearchParams(hash)
|
||||
}
|
||||
|
||||
function normalizeRedirectPath(path: string | null | undefined): string {
|
||||
const value = (path || '').trim()
|
||||
if (!value) return '/purchase'
|
||||
if (!value.startsWith('/')) return '/purchase'
|
||||
if (value.startsWith('//') || value.includes('://')) return '/purchase'
|
||||
if (value === '/payment') return '/purchase'
|
||||
if (value.startsWith('/payment?')) return '/purchase' + value.slice('/payment'.length)
|
||||
return value
|
||||
}
|
||||
|
||||
function goBackToPayment() {
|
||||
void router.replace('/purchase')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const fragment = parseFragmentParams()
|
||||
const readParam = (key: string) => fragment.get(key) || readQueryString(key)
|
||||
|
||||
const error = readParam('error') || readParam('err_msg') || readParam('errmsg')
|
||||
const errorDescription = readParam('error_description') || readParam('message')
|
||||
|
||||
if (error) {
|
||||
errorMessage.value = errorDescription || error
|
||||
return
|
||||
}
|
||||
|
||||
const openid = readParam('openid')
|
||||
const state = readParam('state')
|
||||
const scope = readParam('scope')
|
||||
const paymentType = readParam('payment_type')
|
||||
const amount = readParam('amount')
|
||||
const orderType = readParam('order_type')
|
||||
const planId = readParam('plan_id')
|
||||
const redirectURL = new URL(
|
||||
normalizeRedirectPath(readParam('redirect')),
|
||||
window.location.origin,
|
||||
)
|
||||
|
||||
if (!openid) {
|
||||
errorMessage.value = textWithFallback(
|
||||
'auth.wechatPayment.callbackMissingOpenId',
|
||||
'微信支付回调缺少 openid。',
|
||||
'The WeChat payment callback is missing the openid.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const query: Record<string, string> = {
|
||||
...Object.fromEntries(redirectURL.searchParams.entries()),
|
||||
wechat_resume: '1',
|
||||
openid,
|
||||
}
|
||||
if (state) query.state = state
|
||||
if (scope) query.scope = scope
|
||||
if (paymentType) query.payment_type = paymentType
|
||||
if (amount) query.amount = amount
|
||||
if (orderType) query.order_type = orderType
|
||||
if (planId) query.plan_id = planId
|
||||
|
||||
await router.replace({
|
||||
path: redirectURL.pathname,
|
||||
query,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -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<string, unknown>,
|
||||
},
|
||||
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。')
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>,
|
||||
callback: (result: Record<string, unknown>) => 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<WeixinJSBridgeLike | null> {
|
||||
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<string, unknown>): Promise<Record<string, unknown>> {
|
||||
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<PaymentRecoverySnapshot>(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<string, unknown>)
|
||||
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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user