fix: restore wechat payment oauth and jsapi flow
This commit is contained in:
@@ -9,12 +9,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
|
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
@@ -35,6 +37,13 @@ const (
|
|||||||
wechatOAuthDefaultFrontendCB = "/auth/wechat/callback"
|
wechatOAuthDefaultFrontendCB = "/auth/wechat/callback"
|
||||||
wechatOAuthProviderKey = "wechat-main"
|
wechatOAuthProviderKey = "wechat-main"
|
||||||
wechatOAuthLegacyProviderKey = "wechat"
|
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"
|
wechatOAuthIntentLogin = "login"
|
||||||
wechatOAuthIntentBind = "bind_current_user"
|
wechatOAuthIntentBind = "bind_current_user"
|
||||||
@@ -76,6 +85,13 @@ type wechatOAuthUserInfoResponse struct {
|
|||||||
ErrMsg string `json:"errmsg"`
|
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
|
// WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived
|
||||||
// browser cookies required by the rebuild pending-auth bridge.
|
// browser cookies required by the rebuild pending-auth bridge.
|
||||||
func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
|
func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
|
||||||
@@ -294,6 +310,149 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
|
|||||||
redirectToFrontendCallback(c, frontendCallback)
|
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 {
|
type completeWeChatOAuthRequest struct {
|
||||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||||
@@ -950,3 +1109,99 @@ func wechatClearCookie(c *gin.Context, name string, secure bool) {
|
|||||||
SameSite: http.SameSiteLaxMode,
|
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 {
|
type CreateOrderRequest struct {
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
PaymentType string `json:"payment_type" binding:"required"`
|
PaymentType string `json:"payment_type" binding:"required"`
|
||||||
|
OpenID string `json:"openid"`
|
||||||
ReturnURL string `json:"return_url"`
|
ReturnURL string `json:"return_url"`
|
||||||
PaymentSource string `json:"payment_source"`
|
PaymentSource string `json:"payment_source"`
|
||||||
OrderType string `json:"order_type"`
|
OrderType string `json:"order_type"`
|
||||||
PlanID int64 `json:"plan_id"`
|
PlanID int64 `json:"plan_id"`
|
||||||
|
IsMobile *bool `json:"is_mobile,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateOrder creates a new payment order.
|
// CreateOrder creates a new payment order.
|
||||||
@@ -224,17 +226,25 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mobile := isMobile(c)
|
||||||
|
if req.IsMobile != nil {
|
||||||
|
mobile = *req.IsMobile
|
||||||
|
}
|
||||||
|
|
||||||
result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{
|
result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{
|
||||||
UserID: subject.UserID,
|
UserID: subject.UserID,
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
PaymentType: req.PaymentType,
|
PaymentType: req.PaymentType,
|
||||||
ClientIP: c.ClientIP(),
|
OpenID: req.OpenID,
|
||||||
IsMobile: isMobile(c),
|
ClientIP: c.ClientIP(),
|
||||||
SrcHost: c.Request.Host,
|
IsMobile: mobile,
|
||||||
ReturnURL: req.ReturnURL,
|
IsWeChatBrowser: isWeChatBrowser(c),
|
||||||
PaymentSource: req.PaymentSource,
|
SrcHost: c.Request.Host,
|
||||||
OrderType: req.OrderType,
|
SrcURL: c.Request.Referer(),
|
||||||
PlanID: req.PlanID,
|
ReturnURL: req.ReturnURL,
|
||||||
|
PaymentSource: req.PaymentSource,
|
||||||
|
OrderType: req.OrderType,
|
||||||
|
PlanID: req.PlanID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
@@ -467,3 +477,7 @@ func isMobile(c *gin.Context) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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"
|
"crypto/rsa"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
|
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
|
||||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
"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/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/payments/native"
|
||||||
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
|
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
|
||||||
"github.com/wechatpay-apiv3/wechatpay-go/utils"
|
"github.com/wechatpay-apiv3/wechatpay-go/utils"
|
||||||
@@ -26,8 +27,16 @@ import (
|
|||||||
|
|
||||||
// WeChat Pay constants.
|
// WeChat Pay constants.
|
||||||
const (
|
const (
|
||||||
wxpayCurrency = "CNY"
|
wxpayCurrency = "CNY"
|
||||||
wxpayH5Type = "Wap"
|
wxpayH5Type = "Wap"
|
||||||
|
wxpayResultPath = "/payment/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WeChat Pay create-payment modes.
|
||||||
|
const (
|
||||||
|
wxpayModeNative = "native"
|
||||||
|
wxpayModeH5 = "h5"
|
||||||
|
wxpayModeJSAPI = "jsapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WeChat Pay trade states.
|
// WeChat Pay trade states.
|
||||||
@@ -48,6 +57,18 @@ const (
|
|||||||
wxpayErrNoAuth = "NO_AUTH"
|
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 {
|
type Wxpay struct {
|
||||||
instanceID string
|
instanceID string
|
||||||
config map[string]string
|
config map[string]string
|
||||||
@@ -75,6 +96,16 @@ func (w *Wxpay) SupportedTypes() []payment.PaymentType {
|
|||||||
return []payment.PaymentType{payment.TypeWxpay}
|
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 {
|
func formatPEM(key, keyType string) string {
|
||||||
key = strings.TrimSpace(key)
|
key = strings.TrimSpace(key)
|
||||||
if strings.HasPrefix(key, "-----BEGIN") {
|
if strings.HasPrefix(key, "-----BEGIN") {
|
||||||
@@ -139,30 +170,68 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("wxpay create payment: %w", err)
|
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 {
|
if err == nil {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), wxpayErrNoAuth) {
|
if strings.Contains(err.Error(), wxpayErrNoAuth) {
|
||||||
return nil, err
|
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) {
|
func (w *Wxpay) prepayJSAPI(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
|
||||||
if useH5 {
|
svc := jsapi.JsapiApiService{Client: c}
|
||||||
return w.prepayH5(ctx, c, req, notifyURL, totalFen)
|
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) {
|
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}
|
svc := native.NativeApiService{Client: c}
|
||||||
cur := wxpayCurrency
|
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"]),
|
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
|
||||||
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
|
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
|
||||||
NotifyUrl: core.String(notifyURL),
|
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}
|
svc := h5.H5ApiService{Client: c}
|
||||||
cur := wxpayCurrency
|
cur := wxpayCurrency
|
||||||
tp := wxpayH5Type
|
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"]),
|
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
|
||||||
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
|
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
|
||||||
NotifyUrl: core.String(notifyURL),
|
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 {
|
if resp.H5Url != nil {
|
||||||
h5URL = *resp.H5Url
|
h5URL = *resp.H5Url
|
||||||
}
|
}
|
||||||
|
h5URL, err = appendWxpayRedirectURL(h5URL, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil
|
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 {
|
func wxSV(s *string) string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
package provider
|
package provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
"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) {
|
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
|
Subject string // Product description
|
||||||
NotifyURL string // Webhook callback URL
|
NotifyURL string // Webhook callback URL
|
||||||
ReturnURL string // Browser redirect URL after payment
|
ReturnURL string // Browser redirect URL after payment
|
||||||
|
OpenID string // WeChat JSAPI payer OpenID when available
|
||||||
ClientIP string // Payer's IP address
|
ClientIP string // Payer's IP address
|
||||||
IsMobile bool // Whether the request comes from a mobile device
|
IsMobile bool // Whether the request comes from a mobile device
|
||||||
InstanceSubMethods string // Comma-separated sub-methods from instance supported_types (for Stripe)
|
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.
|
// CreatePaymentResponse is returned after successfully initiating a payment.
|
||||||
type CreatePaymentResponse struct {
|
type CreatePaymentResponse struct {
|
||||||
TradeNo string // Third-party transaction ID
|
TradeNo string // Third-party transaction ID
|
||||||
PayURL string // H5 payment URL (alipay/wxpay)
|
PayURL string // H5 payment URL (alipay/wxpay)
|
||||||
QRCode string // QR code content for scanning
|
QRCode string // QR code content for scanning
|
||||||
ClientSecret string // Stripe PaymentIntent client secret
|
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.
|
// 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/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
|
||||||
auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart)
|
auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart)
|
||||||
auth.GET("/oauth/wechat/callback", h.Auth.WeChatOAuthCallback)
|
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",
|
auth.POST("/oauth/pending/exchange",
|
||||||
rateLimiter.LimitWithOptions("oauth-pending-exchange", 20, time.Minute, middleware.RateLimitOptions{
|
rateLimiter.LimitWithOptions("oauth-pending-exchange", 20, time.Minute, middleware.RateLimitOptions{
|
||||||
FailureMode: middleware.RateLimitFailClose,
|
FailureMode: middleware.RateLimitFailClose,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -57,11 +59,25 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
|
|||||||
feeRate := cfg.RechargeFeeRate
|
feeRate := cfg.RechargeFeeRate
|
||||||
payAmountStr := payment.CalculatePayAmount(limitAmount, feeRate)
|
payAmountStr := payment.CalculatePayAmount(limitAmount, feeRate)
|
||||||
payAmount, _ := strconv.ParseFloat(payAmountStr, 64)
|
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)
|
order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
_, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID).
|
_, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID).
|
||||||
SetStatus(OrderStatusFailed).
|
SetStatus(OrderStatusFailed).
|
||||||
@@ -199,9 +215,7 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user
|
|||||||
return nil
|
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) {
|
func (s *PaymentService) selectCreateOrderInstance(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig, payAmount float64) (*payment.InstanceSelection, 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").
|
|
||||||
sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
|
sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment method (%s) is not configured", req.PaymentType))
|
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 {
|
if sel == nil {
|
||||||
return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no available payment instance")
|
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)
|
prov, err := provider.CreateProvider(sel.ProviderKey, sel.InstanceID, sel.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{
|
providerReq := buildProviderCreatePaymentRequest(CreateOrderRequest{
|
||||||
OrderID: outTradeNo,
|
PaymentType: req.PaymentType,
|
||||||
Amount: payAmountStr,
|
OpenID: req.OpenID,
|
||||||
PaymentType: req.PaymentType,
|
ClientIP: req.ClientIP,
|
||||||
Subject: subject,
|
IsMobile: req.IsMobile,
|
||||||
ReturnURL: providerReturnURL,
|
ReturnURL: providerReturnURL,
|
||||||
ClientIP: req.ClientIP,
|
}, sel, outTradeNo, payAmountStr, subject)
|
||||||
IsMobile: req.IsMobile,
|
pr, err := prov.CreatePayment(ctx, providerReq)
|
||||||
InstanceSubMethods: sel.SupportedTypes,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
|
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).
|
_, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).
|
||||||
SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).
|
SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).
|
||||||
@@ -269,20 +285,34 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
|
|||||||
"orderType": req.OrderType,
|
"orderType": req.OrderType,
|
||||||
"paymentSource": NormalizePaymentSource(req.PaymentSource),
|
"paymentSource": NormalizePaymentSource(req.PaymentSource),
|
||||||
})
|
})
|
||||||
return &CreateOrderResponse{
|
resultType := pr.ResultType
|
||||||
OrderID: order.ID,
|
if resultType == "" {
|
||||||
Amount: order.Amount,
|
resultType = payment.CreatePaymentResultOrderCreated
|
||||||
PayAmount: payAmount,
|
}
|
||||||
FeeRate: order.FeeRate,
|
resp := buildCreateOrderResponse(order, req, payAmount, sel, pr, resultType)
|
||||||
Status: OrderStatusPending,
|
resp.ResumeToken = resumeToken
|
||||||
PaymentType: req.PaymentType,
|
return resp, nil
|
||||||
PayURL: pr.PayURL,
|
}
|
||||||
QRCode: pr.QRCode,
|
|
||||||
ClientSecret: pr.ClientSecret,
|
func buildProviderCreatePaymentRequest(req CreateOrderRequest, sel *payment.InstanceSelection, orderID, amount, subject string) payment.CreatePaymentRequest {
|
||||||
ExpiresAt: order.ExpiresAt,
|
return payment.CreatePaymentRequest{
|
||||||
PaymentMode: sel.PaymentMode,
|
OrderID: orderID,
|
||||||
ResumeToken: resumeToken,
|
Amount: amount,
|
||||||
}, nil
|
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 {
|
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"
|
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 ---
|
// --- Order Queries ---
|
||||||
|
|
||||||
func (s *PaymentService) GetOrder(ctx context.Context, orderID, userID int64) (*dbent.PaymentOrder, error) {
|
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 {
|
type CreateOrderRequest struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
Amount float64
|
Amount float64
|
||||||
PaymentType string
|
PaymentType string
|
||||||
ClientIP string
|
OpenID string
|
||||||
IsMobile bool
|
ClientIP string
|
||||||
SrcHost string
|
IsMobile bool
|
||||||
SrcURL string
|
IsWeChatBrowser bool
|
||||||
ReturnURL string
|
SrcHost string
|
||||||
PaymentSource string
|
SrcURL string
|
||||||
OrderType string
|
ReturnURL string
|
||||||
PlanID int64
|
PaymentSource string
|
||||||
|
OrderType string
|
||||||
|
PlanID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateOrderResponse struct {
|
type CreateOrderResponse struct {
|
||||||
OrderID int64 `json:"order_id"`
|
OrderID int64 `json:"order_id"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
PayAmount float64 `json:"pay_amount"`
|
PayAmount float64 `json:"pay_amount"`
|
||||||
FeeRate float64 `json:"fee_rate"`
|
FeeRate float64 `json:"fee_rate"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
PaymentType string `json:"payment_type"`
|
ResultType payment.CreatePaymentResultType `json:"result_type,omitempty"`
|
||||||
PayURL string `json:"pay_url,omitempty"`
|
PaymentType string `json:"payment_type"`
|
||||||
QRCode string `json:"qr_code,omitempty"`
|
OutTradeNo string `json:"out_trade_no,omitempty"`
|
||||||
ClientSecret string `json:"client_secret,omitempty"`
|
PayURL string `json:"pay_url,omitempty"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
QRCode string `json:"qr_code,omitempty"`
|
||||||
PaymentMode string `json:"payment_mode,omitempty"`
|
ClientSecret string `json:"client_secret,omitempty"`
|
||||||
ResumeToken string `json:"resume_token,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 {
|
type OrderListParams struct {
|
||||||
|
|||||||
@@ -105,6 +105,50 @@ describe('decidePaymentLaunch', () => {
|
|||||||
expect(decision.recovery.paymentMode).toBe('popup')
|
expect(decision.recovery.paymentMode).toBe('popup')
|
||||||
expect(decision.recovery.resumeToken).toBe('resume-2')
|
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', () => {
|
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'
|
export const PAYMENT_RECOVERY_STORAGE_KEY = 'payment.recovery.current'
|
||||||
|
|
||||||
@@ -16,6 +23,8 @@ export type PaymentLaunchKind =
|
|||||||
| 'redirect_waiting'
|
| 'redirect_waiting'
|
||||||
| 'stripe_popup'
|
| 'stripe_popup'
|
||||||
| 'stripe_route'
|
| 'stripe_route'
|
||||||
|
| 'wechat_oauth'
|
||||||
|
| 'wechat_jsapi'
|
||||||
| 'unhandled'
|
| 'unhandled'
|
||||||
|
|
||||||
export interface PaymentRecoverySnapshot {
|
export interface PaymentRecoverySnapshot {
|
||||||
@@ -47,6 +56,8 @@ export interface PaymentLaunchDecision {
|
|||||||
paymentState: PaymentRecoverySnapshot
|
paymentState: PaymentRecoverySnapshot
|
||||||
recovery: PaymentRecoverySnapshot
|
recovery: PaymentRecoverySnapshot
|
||||||
stripeMethod?: StripeVisibleMethod
|
stripeMethod?: StripeVisibleMethod
|
||||||
|
oauth?: WechatOAuthInfo
|
||||||
|
jsapi?: WechatJSAPIPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildCreateOrderPayloadInput {
|
export interface BuildCreateOrderPayloadInput {
|
||||||
@@ -139,6 +150,15 @@ export function decidePaymentLaunch(
|
|||||||
return { kind, paymentState, recovery: paymentState, stripeMethod }
|
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) {
|
if (baseState.qrCode) {
|
||||||
return { kind: 'qr_waiting', paymentState: baseState, recovery: baseState }
|
return { kind: 'qr_waiting', paymentState: baseState, recovery: baseState }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
title: 'WeChat OAuth Callback'
|
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',
|
path: '/auth/oidc/callback',
|
||||||
name: 'OIDCOAuthCallback',
|
name: 'OIDCOAuthCallback',
|
||||||
|
|||||||
@@ -156,6 +156,28 @@ export interface CreateOrderRequest {
|
|||||||
plan_id?: number
|
plan_id?: number
|
||||||
return_url?: string
|
return_url?: string
|
||||||
payment_source?: 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 {
|
export interface CreateOrderResult {
|
||||||
@@ -167,8 +189,14 @@ export interface CreateOrderResult {
|
|||||||
pay_amount: number
|
pay_amount: number
|
||||||
fee_rate: number
|
fee_rate: number
|
||||||
expires_at: string
|
expires_at: string
|
||||||
|
result_type?: CreateOrderResultType
|
||||||
|
payment_type?: string
|
||||||
|
out_trade_no?: string
|
||||||
payment_mode?: string
|
payment_mode?: string
|
||||||
resume_token?: string
|
resume_token?: string
|
||||||
|
oauth?: WechatOAuthInfo
|
||||||
|
jsapi?: WechatJSAPIPayload
|
||||||
|
jsapi_payload?: WechatJSAPIPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardStats {
|
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')
|
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 {
|
function emptyPaymentState(): PaymentRecoverySnapshot {
|
||||||
return {
|
return {
|
||||||
orderId: 0,
|
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())
|
const paymentState = ref<PaymentRecoverySnapshot>(emptyPaymentState())
|
||||||
|
|
||||||
function persistRecoverySnapshot(snapshot: PaymentRecoverySnapshot) {
|
function persistRecoverySnapshot(snapshot: PaymentRecoverySnapshot) {
|
||||||
@@ -560,25 +616,32 @@ async function confirmSubscribe() {
|
|||||||
await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id)
|
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
|
submitting.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
const result = await paymentStore.createOrder(buildCreateOrderPayload({
|
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
|
||||||
|
const payload = buildCreateOrderPayload({
|
||||||
amount: orderAmount,
|
amount: orderAmount,
|
||||||
paymentType: selectedMethod.value,
|
paymentType: requestType,
|
||||||
orderType,
|
orderType,
|
||||||
planId,
|
planId,
|
||||||
origin: typeof window !== 'undefined' ? window.location.origin : '',
|
origin: typeof window !== 'undefined' ? window.location.origin : '',
|
||||||
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
|
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 openWindow = (url: string, features = POPUP_WINDOW_FEATURES) => {
|
||||||
const win = window.open(url, 'paymentPopup', features)
|
const win = window.open(url, 'paymentPopup', features)
|
||||||
if (!win || win.closed) {
|
if (!win || win.closed) {
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const visibleMethod = normalizeVisibleMethod(selectedMethod.value) || selectedMethod.value
|
const visibleMethod = normalizeVisibleMethod(requestType) || requestType
|
||||||
const stripeMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
|
const stripeMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
|
||||||
const stripeRouteUrl = result.client_secret
|
const stripeRouteUrl = result.client_secret
|
||||||
? router.resolve({
|
? router.resolve({
|
||||||
@@ -599,6 +662,11 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
stripeRouteUrl,
|
stripeRouteUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (decision.kind === 'wechat_oauth' && decision.oauth?.authorize_url) {
|
||||||
|
window.location.href = decision.oauth.authorize_url
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (decision.kind === 'unhandled') {
|
if (decision.kind === 'unhandled') {
|
||||||
errorMessage.value = t('payment.result.failed')
|
errorMessage.value = t('payment.result.failed')
|
||||||
appStore.showError(errorMessage.value)
|
appStore.showError(errorMessage.value)
|
||||||
@@ -617,6 +685,16 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
window.location.href = decision.paymentState.payUrl
|
window.location.href = decision.paymentState.payUrl
|
||||||
return
|
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 (decision.kind === 'redirect_waiting' && decision.paymentState.payUrl) {
|
||||||
if (isMobileDevice()) {
|
if (isMobileDevice()) {
|
||||||
window.location.href = decision.paymentState.payUrl
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await paymentAPI.getCheckoutInfo()
|
const res = await paymentAPI.getCheckoutInfo()
|
||||||
@@ -672,6 +794,7 @@ onMounted(async () => {
|
|||||||
removeRecoverySnapshot()
|
removeRecoverySnapshot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await resumeWechatPaymentFromQuery()
|
||||||
if (checkout.value.balance_disabled) {
|
if (checkout.value.balance_disabled) {
|
||||||
activeTab.value = 'subscription'
|
activeTab.value = 'subscription'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user