mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-04 21:20:51 +08:00
Tighten WeChat payment resume flow
This commit is contained in:
@@ -435,24 +435,34 @@ func (h *AuthHandler) WeChatPaymentOAuthCallback(c *gin.Context) {
|
|||||||
scope = strings.TrimSpace(tokenResp.Scope)
|
scope = strings.TrimSpace(tokenResp.Scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resumeToken, err := h.wechatPaymentResumeService().CreateWeChatPaymentResumeToken(service.WeChatPaymentResumeClaims{
|
||||||
|
OpenID: openid,
|
||||||
|
PaymentType: paymentContext.PaymentType,
|
||||||
|
Amount: paymentContext.Amount,
|
||||||
|
OrderType: paymentContext.OrderType,
|
||||||
|
PlanID: paymentContext.PlanID,
|
||||||
|
RedirectTo: redirectTo,
|
||||||
|
Scope: scope,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
redirectOAuthError(c, frontendCallback, "invalid_context", "failed to encode payment resume context", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
fragment := url.Values{}
|
fragment := url.Values{}
|
||||||
fragment.Set("openid", openid)
|
fragment.Set("wechat_resume_token", resumeToken)
|
||||||
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)
|
fragment.Set("redirect", redirectTo)
|
||||||
redirectWithFragment(c, frontendCallback, fragment)
|
redirectWithFragment(c, frontendCallback, fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) wechatPaymentResumeService() *service.PaymentResumeService {
|
||||||
|
key, err := payment.ProvideEncryptionKey(h.cfg)
|
||||||
|
if err != nil {
|
||||||
|
return service.NewPaymentResumeService(nil)
|
||||||
|
}
|
||||||
|
return service.NewPaymentResumeService([]byte(key))
|
||||||
|
}
|
||||||
|
|
||||||
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"`
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
||||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/repository"
|
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -175,6 +176,66 @@ func TestWeChatOAuthCallbackRejectsMissingUnionID(t *testing.T) {
|
|||||||
require.Zero(t, count)
|
require.Zero(t, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T) {
|
||||||
|
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx-mp-app")
|
||||||
|
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wx-mp-secret")
|
||||||
|
|
||||||
|
originalAccessTokenURL := wechatOAuthAccessTokenURL
|
||||||
|
t.Cleanup(func() {
|
||||||
|
wechatOAuthAccessTokenURL = originalAccessTokenURL
|
||||||
|
})
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.Contains(r.URL.Path, "/sns/oauth2/access_token") {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"wechat-access","openid":"openid-123","scope":"snsapi_base"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token"
|
||||||
|
|
||||||
|
handler, client := newWeChatOAuthTestHandler(t, false)
|
||||||
|
defer client.Close()
|
||||||
|
handler.cfg.Totp.EncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/payment/callback?code=wechat-code&state=state-123", nil)
|
||||||
|
req.Host = "api.example.com"
|
||||||
|
req.AddCookie(encodedCookie(wechatPaymentOAuthStateName, "state-123"))
|
||||||
|
req.AddCookie(encodedCookie(wechatPaymentOAuthRedirect, "/purchase?from=wechat"))
|
||||||
|
req.AddCookie(encodedCookie(wechatPaymentOAuthContextName, `{"payment_type":"wxpay","amount":"12.5","order_type":"subscription","plan_id":7}`))
|
||||||
|
req.AddCookie(encodedCookie(wechatPaymentOAuthScope, "snsapi_base"))
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
handler.WeChatPaymentOAuthCallback(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
location := recorder.Header().Get("Location")
|
||||||
|
parsed, err := url.Parse(location)
|
||||||
|
require.NoError(t, err)
|
||||||
|
fragment, err := url.ParseQuery(parsed.Fragment)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "/purchase?from=wechat", fragment.Get("redirect"))
|
||||||
|
require.NotEmpty(t, fragment.Get("wechat_resume_token"))
|
||||||
|
require.Empty(t, fragment.Get("openid"))
|
||||||
|
require.Empty(t, fragment.Get("payment_type"))
|
||||||
|
require.Empty(t, fragment.Get("amount"))
|
||||||
|
require.Empty(t, fragment.Get("order_type"))
|
||||||
|
require.Empty(t, fragment.Get("plan_id"))
|
||||||
|
|
||||||
|
claims, err := handler.wechatPaymentResumeService().ParseWeChatPaymentResumeToken(fragment.Get("wechat_resume_token"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "openid-123", claims.OpenID)
|
||||||
|
require.Equal(t, payment.TypeWxpay, claims.PaymentType)
|
||||||
|
require.Equal(t, "12.5", claims.Amount)
|
||||||
|
require.Equal(t, payment.OrderTypeSubscription, claims.OrderType)
|
||||||
|
require.EqualValues(t, 7, claims.PlanID)
|
||||||
|
require.Equal(t, "/purchase?from=wechat", claims.RedirectTo)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *testing.T) {
|
func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||||
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
@@ -202,14 +205,15 @@ func (h *PaymentHandler) GetLimits(c *gin.Context) {
|
|||||||
|
|
||||||
// CreateOrderRequest is the request body for creating a payment order.
|
// CreateOrderRequest is the request body for creating a payment order.
|
||||||
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"`
|
OpenID string `json:"openid"`
|
||||||
ReturnURL string `json:"return_url"`
|
WechatResumeToken string `json:"wechat_resume_token"`
|
||||||
PaymentSource string `json:"payment_source"`
|
ReturnURL string `json:"return_url"`
|
||||||
OrderType string `json:"order_type"`
|
PaymentSource string `json:"payment_source"`
|
||||||
PlanID int64 `json:"plan_id"`
|
OrderType string `json:"order_type"`
|
||||||
IsMobile *bool `json:"is_mobile,omitempty"`
|
PlanID int64 `json:"plan_id"`
|
||||||
|
IsMobile *bool `json:"is_mobile,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateOrder creates a new payment order.
|
// CreateOrder creates a new payment order.
|
||||||
@@ -225,6 +229,17 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
|||||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(req.WechatResumeToken) != "" {
|
||||||
|
claims, err := h.paymentService.ParseWeChatPaymentResumeToken(req.WechatResumeToken)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := applyWeChatPaymentResumeClaims(&req, claims); err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mobile := isMobile(c)
|
mobile := isMobile(c)
|
||||||
if req.IsMobile != nil {
|
if req.IsMobile != nil {
|
||||||
@@ -253,6 +268,44 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
|||||||
response.Success(c, result)
|
response.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyWeChatPaymentResumeClaims(req *CreateOrderRequest, claims *service.WeChatPaymentResumeClaims) error {
|
||||||
|
if req == nil || claims == nil {
|
||||||
|
return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume context is missing")
|
||||||
|
}
|
||||||
|
openid := strings.TrimSpace(claims.OpenID)
|
||||||
|
if openid == "" {
|
||||||
|
return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing openid")
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentType := service.NormalizeVisibleMethod(claims.PaymentType)
|
||||||
|
if paymentType == "" {
|
||||||
|
paymentType = payment.TypeWxpay
|
||||||
|
}
|
||||||
|
if req.PaymentType != "" {
|
||||||
|
requestPaymentType := service.NormalizeVisibleMethod(req.PaymentType)
|
||||||
|
if requestPaymentType != "" && requestPaymentType != paymentType {
|
||||||
|
return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token payment type mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.PaymentType = paymentType
|
||||||
|
req.OpenID = openid
|
||||||
|
|
||||||
|
if strings.TrimSpace(claims.Amount) != "" {
|
||||||
|
amount, err := strconv.ParseFloat(strings.TrimSpace(claims.Amount), 64)
|
||||||
|
if err != nil || amount <= 0 {
|
||||||
|
return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", fmt.Sprintf("invalid resume amount: %s", claims.Amount))
|
||||||
|
}
|
||||||
|
req.Amount = amount
|
||||||
|
}
|
||||||
|
if claims.OrderType != "" {
|
||||||
|
req.OrderType = claims.OrderType
|
||||||
|
}
|
||||||
|
if claims.PlanID > 0 {
|
||||||
|
req.PlanID = claims.PlanID
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetMyOrders returns the authenticated user's orders.
|
// GetMyOrders returns the authenticated user's orders.
|
||||||
// GET /api/v1/payment/orders/my
|
// GET /api/v1/payment/orders/my
|
||||||
func (h *PaymentHandler) GetMyOrders(c *gin.Context) {
|
func (h *PaymentHandler) GetMyOrders(c *gin.Context) {
|
||||||
|
|||||||
61
backend/internal/handler/payment_handler_resume_test.go
Normal file
61
backend/internal/handler/payment_handler_resume_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//go:build unit
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyWeChatPaymentResumeClaims(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := CreateOrderRequest{
|
||||||
|
Amount: 0,
|
||||||
|
PaymentType: payment.TypeWxpay,
|
||||||
|
OrderType: payment.OrderTypeBalance,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := applyWeChatPaymentResumeClaims(&req, &service.WeChatPaymentResumeClaims{
|
||||||
|
OpenID: "openid-123",
|
||||||
|
PaymentType: payment.TypeWxpay,
|
||||||
|
Amount: "12.50",
|
||||||
|
OrderType: payment.OrderTypeSubscription,
|
||||||
|
PlanID: 7,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("applyWeChatPaymentResumeClaims returned error: %v", err)
|
||||||
|
}
|
||||||
|
if req.OpenID != "openid-123" {
|
||||||
|
t.Fatalf("openid = %q, want %q", req.OpenID, "openid-123")
|
||||||
|
}
|
||||||
|
if req.Amount != 12.5 {
|
||||||
|
t.Fatalf("amount = %v, want 12.5", req.Amount)
|
||||||
|
}
|
||||||
|
if req.OrderType != payment.OrderTypeSubscription {
|
||||||
|
t.Fatalf("order_type = %q, want %q", req.OrderType, payment.OrderTypeSubscription)
|
||||||
|
}
|
||||||
|
if req.PlanID != 7 {
|
||||||
|
t.Fatalf("plan_id = %d, want 7", req.PlanID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyWeChatPaymentResumeClaimsRejectsPaymentTypeMismatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := CreateOrderRequest{
|
||||||
|
PaymentType: payment.TypeAlipay,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := applyWeChatPaymentResumeClaims(&req, &service.WeChatPaymentResumeClaims{
|
||||||
|
OpenID: "openid-123",
|
||||||
|
PaymentType: payment.TypeWxpay,
|
||||||
|
Amount: "12.50",
|
||||||
|
OrderType: payment.OrderTypeBalance,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("applyWeChatPaymentResumeClaims should reject mismatched payment types")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,3 +33,7 @@ func (s *PaymentService) GetPublicOrderByResumeToken(ctx context.Context, token
|
|||||||
|
|
||||||
return order, nil
|
return order, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PaymentService) ParseWeChatPaymentResumeToken(token string) (*WeChatPaymentResumeClaims, error) {
|
||||||
|
return s.paymentResume().ParseWeChatPaymentResumeToken(strings.TrimSpace(token))
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const (
|
|||||||
VisibleMethodSourceEasyPayAlipay = "easypay_alipay"
|
VisibleMethodSourceEasyPayAlipay = "easypay_alipay"
|
||||||
VisibleMethodSourceOfficialWechat = "official_wxpay"
|
VisibleMethodSourceOfficialWechat = "official_wxpay"
|
||||||
VisibleMethodSourceEasyPayWechat = "easypay_wxpay"
|
VisibleMethodSourceEasyPayWechat = "easypay_wxpay"
|
||||||
|
|
||||||
|
wechatPaymentResumeTokenType = "wechat_payment_resume"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ResumeTokenClaims struct {
|
type ResumeTokenClaims struct {
|
||||||
@@ -43,6 +45,18 @@ type ResumeTokenClaims struct {
|
|||||||
IssuedAt int64 `json:"iat"`
|
IssuedAt int64 `json:"iat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WeChatPaymentResumeClaims struct {
|
||||||
|
TokenType string `json:"tk,omitempty"`
|
||||||
|
OpenID string `json:"openid"`
|
||||||
|
PaymentType string `json:"pt,omitempty"`
|
||||||
|
Amount string `json:"amt,omitempty"`
|
||||||
|
OrderType string `json:"ot,omitempty"`
|
||||||
|
PlanID int64 `json:"pid,omitempty"`
|
||||||
|
RedirectTo string `json:"rd,omitempty"`
|
||||||
|
Scope string `json:"scp,omitempty"`
|
||||||
|
IssuedAt int64 `json:"iat"`
|
||||||
|
}
|
||||||
|
|
||||||
type PaymentResumeService struct {
|
type PaymentResumeService struct {
|
||||||
signingKey []byte
|
signingKey []byte
|
||||||
}
|
}
|
||||||
@@ -232,6 +246,66 @@ func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, er
|
|||||||
if claims.IssuedAt == 0 {
|
if claims.IssuedAt == 0 {
|
||||||
claims.IssuedAt = time.Now().Unix()
|
claims.IssuedAt = time.Now().Unix()
|
||||||
}
|
}
|
||||||
|
return s.createSignedToken(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PaymentResumeService) ParseToken(token string) (*ResumeTokenClaims, error) {
|
||||||
|
var claims ResumeTokenClaims
|
||||||
|
if err := s.parseSignedToken(token, &claims); err != nil {
|
||||||
|
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is invalid")
|
||||||
|
}
|
||||||
|
if claims.OrderID <= 0 {
|
||||||
|
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token missing order id")
|
||||||
|
}
|
||||||
|
return &claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PaymentResumeService) CreateWeChatPaymentResumeToken(claims WeChatPaymentResumeClaims) (string, error) {
|
||||||
|
claims.OpenID = strings.TrimSpace(claims.OpenID)
|
||||||
|
if claims.OpenID == "" {
|
||||||
|
return "", fmt.Errorf("wechat payment resume token requires openid")
|
||||||
|
}
|
||||||
|
if claims.IssuedAt == 0 {
|
||||||
|
claims.IssuedAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
if normalized := NormalizeVisibleMethod(claims.PaymentType); normalized != "" {
|
||||||
|
claims.PaymentType = normalized
|
||||||
|
}
|
||||||
|
if claims.PaymentType == "" {
|
||||||
|
claims.PaymentType = payment.TypeWxpay
|
||||||
|
}
|
||||||
|
if claims.OrderType == "" {
|
||||||
|
claims.OrderType = payment.OrderTypeBalance
|
||||||
|
}
|
||||||
|
claims.TokenType = wechatPaymentResumeTokenType
|
||||||
|
return s.createSignedToken(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PaymentResumeService) ParseWeChatPaymentResumeToken(token string) (*WeChatPaymentResumeClaims, error) {
|
||||||
|
var claims WeChatPaymentResumeClaims
|
||||||
|
if err := s.parseSignedToken(token, &claims); err != nil {
|
||||||
|
return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token payload is invalid")
|
||||||
|
}
|
||||||
|
if claims.TokenType != wechatPaymentResumeTokenType {
|
||||||
|
return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token type mismatch")
|
||||||
|
}
|
||||||
|
claims.OpenID = strings.TrimSpace(claims.OpenID)
|
||||||
|
if claims.OpenID == "" {
|
||||||
|
return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing openid")
|
||||||
|
}
|
||||||
|
if normalized := NormalizeVisibleMethod(claims.PaymentType); normalized != "" {
|
||||||
|
claims.PaymentType = normalized
|
||||||
|
}
|
||||||
|
if claims.PaymentType == "" {
|
||||||
|
claims.PaymentType = payment.TypeWxpay
|
||||||
|
}
|
||||||
|
if claims.OrderType == "" {
|
||||||
|
claims.OrderType = payment.OrderTypeBalance
|
||||||
|
}
|
||||||
|
return &claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PaymentResumeService) createSignedToken(claims any) (string, error) {
|
||||||
payload, err := json.Marshal(claims)
|
payload, err := json.Marshal(claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("marshal resume claims: %w", err)
|
return "", fmt.Errorf("marshal resume claims: %w", err)
|
||||||
@@ -240,26 +314,19 @@ func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, er
|
|||||||
return encodedPayload + "." + s.sign(encodedPayload), nil
|
return encodedPayload + "." + s.sign(encodedPayload), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PaymentResumeService) ParseToken(token string) (*ResumeTokenClaims, error) {
|
func (s *PaymentResumeService) parseSignedToken(token string, dest any) error {
|
||||||
parts := strings.Split(token, ".")
|
parts := strings.Split(token, ".")
|
||||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token is malformed")
|
return infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token is malformed")
|
||||||
}
|
}
|
||||||
if !hmac.Equal([]byte(parts[1]), []byte(s.sign(parts[0]))) {
|
if !hmac.Equal([]byte(parts[1]), []byte(s.sign(parts[0]))) {
|
||||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token signature mismatch")
|
return infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token signature mismatch")
|
||||||
}
|
}
|
||||||
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is malformed")
|
return infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is malformed")
|
||||||
}
|
}
|
||||||
var claims ResumeTokenClaims
|
return json.Unmarshal(payload, dest)
|
||||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
||||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is invalid")
|
|
||||||
}
|
|
||||||
if claims.OrderID <= 0 {
|
|
||||||
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token missing order id")
|
|
||||||
}
|
|
||||||
return &claims, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PaymentResumeService) sign(payload string) string {
|
func (s *PaymentResumeService) sign(payload string) string {
|
||||||
|
|||||||
@@ -150,6 +150,39 @@ func TestPaymentResumeTokenRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWeChatPaymentResumeTokenRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
|
||||||
|
token, err := svc.CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
|
||||||
|
OpenID: "openid-123",
|
||||||
|
PaymentType: payment.TypeWxpay,
|
||||||
|
Amount: "12.50",
|
||||||
|
OrderType: payment.OrderTypeSubscription,
|
||||||
|
PlanID: 7,
|
||||||
|
RedirectTo: "/purchase?from=wechat",
|
||||||
|
Scope: "snsapi_base",
|
||||||
|
IssuedAt: 1234567890,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWeChatPaymentResumeToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := svc.ParseWeChatPaymentResumeToken(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseWeChatPaymentResumeToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
if claims.OpenID != "openid-123" || claims.PaymentType != payment.TypeWxpay {
|
||||||
|
t.Fatalf("claims mismatch: %+v", claims)
|
||||||
|
}
|
||||||
|
if claims.Amount != "12.50" || claims.OrderType != payment.OrderTypeSubscription || claims.PlanID != 7 {
|
||||||
|
t.Fatalf("claims payment context mismatch: %+v", claims)
|
||||||
|
}
|
||||||
|
if claims.RedirectTo != "/purchase?from=wechat" || claims.Scope != "snsapi_base" {
|
||||||
|
t.Fatalf("claims redirect/scope mismatch: %+v", claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeVisibleMethodSource(t *testing.T) {
|
func TestNormalizeVisibleMethodSource(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export interface CreateOrderRequest {
|
|||||||
return_url?: string
|
return_url?: string
|
||||||
payment_source?: string
|
payment_source?: string
|
||||||
openid?: string
|
openid?: string
|
||||||
|
wechat_resume_token?: string
|
||||||
is_mobile?: boolean
|
is_mobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,23 +114,17 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const openid = readParam('openid')
|
const resumeToken = readParam('wechat_resume_token')
|
||||||
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(
|
const redirectURL = new URL(
|
||||||
normalizeRedirectPath(readParam('redirect')),
|
normalizeRedirectPath(readParam('redirect')),
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!openid) {
|
if (!resumeToken) {
|
||||||
errorMessage.value = textWithFallback(
|
errorMessage.value = textWithFallback(
|
||||||
'auth.wechatPayment.callbackMissingOpenId',
|
'auth.wechatPayment.callbackMissingResumeToken',
|
||||||
'微信支付回调缺少 openid。',
|
'微信支付回调缺少恢复令牌。',
|
||||||
'The WeChat payment callback is missing the openid.',
|
'The WeChat payment callback is missing the resume token.',
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -138,14 +132,8 @@ onMounted(async () => {
|
|||||||
const query: Record<string, string> = {
|
const query: Record<string, string> = {
|
||||||
...Object.fromEntries(redirectURL.searchParams.entries()),
|
...Object.fromEntries(redirectURL.searchParams.entries()),
|
||||||
wechat_resume: '1',
|
wechat_resume: '1',
|
||||||
openid,
|
wechat_resume_token: resumeToken,
|
||||||
}
|
}
|
||||||
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({
|
await router.replace({
|
||||||
path: redirectURL.pathname,
|
path: redirectURL.pathname,
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ describe('WechatPaymentCallbackView', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('redirects back to purchase with openid and payment context from hash fragment', async () => {
|
it('redirects back to purchase with an opaque resume token from hash fragment', async () => {
|
||||||
locationState.current.hash = '#openid=openid-123&payment_type=wxpay&amount=12.5&order_type=balance&redirect=%2Fpurchase%3Ffrom%3Dwechat'
|
locationState.current.hash = '#wechat_resume_token=resume-token-123&redirect=%2Fpurchase%3Ffrom%3Dwechat'
|
||||||
|
|
||||||
mount(WechatPaymentCallbackView)
|
mount(WechatPaymentCallbackView)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -60,21 +60,18 @@ describe('WechatPaymentCallbackView', () => {
|
|||||||
query: {
|
query: {
|
||||||
from: 'wechat',
|
from: 'wechat',
|
||||||
wechat_resume: '1',
|
wechat_resume: '1',
|
||||||
openid: 'openid-123',
|
wechat_resume_token: 'resume-token-123',
|
||||||
payment_type: 'wxpay',
|
|
||||||
amount: '12.5',
|
|
||||||
order_type: 'balance',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows an error when the callback payload is missing openid', async () => {
|
it('shows an error when the callback payload is missing the resume token', async () => {
|
||||||
locationState.current.hash = '#payment_type=wxpay'
|
locationState.current.hash = '#payment_type=wxpay'
|
||||||
|
|
||||||
const wrapper = mount(WechatPaymentCallbackView)
|
const wrapper = mount(WechatPaymentCallbackView)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(replaceMock).not.toHaveBeenCalled()
|
expect(replaceMock).not.toHaveBeenCalled()
|
||||||
expect(wrapper.text()).toContain('微信支付回调缺少 openid。')
|
expect(wrapper.text()).toContain('微信支付回调缺少恢复令牌。')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -188,7 +188,8 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasLegacyFallbackContext = Boolean(route.query.trade_status || route.query.money || route.query.type)
|
const hasLegacyFallbackContext = typeof route.query.trade_status === 'string'
|
||||||
|
&& route.query.trade_status.trim() !== ''
|
||||||
if (!order.value && !resumeToken && !orderId && outTradeNo && hasLegacyFallbackContext) {
|
if (!order.value && !resumeToken && !orderId && outTradeNo && hasLegacyFallbackContext) {
|
||||||
returnInfo.value = {
|
returnInfo.value = {
|
||||||
outTradeNo,
|
outTradeNo,
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
|
|||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
||||||
import { describePaymentScenarioError } from './paymentUx'
|
import { describePaymentScenarioError } from './paymentUx'
|
||||||
|
import { parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -315,6 +316,7 @@ const paymentPhase = ref<'select' | 'paying'>('select')
|
|||||||
|
|
||||||
interface CreateOrderOptions {
|
interface CreateOrderOptions {
|
||||||
openid?: string
|
openid?: string
|
||||||
|
wechatResumeToken?: string
|
||||||
paymentType?: string
|
paymentType?: string
|
||||||
isResume?: boolean
|
isResume?: boolean
|
||||||
}
|
}
|
||||||
@@ -344,13 +346,6 @@ 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 {
|
function getWeixinJSBridge(): WeixinJSBridgeLike | undefined {
|
||||||
return (window as Window & { WeixinJSBridge?: WeixinJSBridgeLike }).WeixinJSBridge
|
return (window as Window & { WeixinJSBridge?: WeixinJSBridgeLike }).WeixinJSBridge
|
||||||
}
|
}
|
||||||
@@ -637,6 +632,9 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
if (options.openid) {
|
if (options.openid) {
|
||||||
payload.openid = options.openid
|
payload.openid = options.openid
|
||||||
}
|
}
|
||||||
|
if (options.wechatResumeToken) {
|
||||||
|
payload.wechat_resume_token = options.wechatResumeToken
|
||||||
|
}
|
||||||
payload.is_mobile = isMobileDevice()
|
payload.is_mobile = isMobileDevice()
|
||||||
|
|
||||||
const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string }
|
const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string }
|
||||||
@@ -744,44 +742,34 @@ function applyScenarioError(err: unknown, paymentMethod: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resumeWechatPaymentFromQuery() {
|
async function resumeWechatPaymentFromQuery() {
|
||||||
const openid = readRouteQueryValue(route.query.openid)
|
const resume = parseWechatResumeRoute(route.query, checkout.value.plans, validAmount.value)
|
||||||
if (readRouteQueryValue(route.query.wechat_resume) !== '1' || !openid) {
|
if (!resume) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentType = normalizeVisibleMethod(readRouteQueryValue(route.query.payment_type)) || 'wxpay'
|
selectedMethod.value = resume.paymentType
|
||||||
const orderType = readRouteQueryValue(route.query.order_type) === 'subscription' ? 'subscription' : 'balance'
|
if (resume.orderType === 'balance' && resume.orderAmount > 0) {
|
||||||
const planId = Number.parseInt(readRouteQueryValue(route.query.plan_id), 10)
|
amount.value = resume.orderAmount
|
||||||
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) {
|
if (resume.orderType === 'subscription' && resume.planId) {
|
||||||
selectedPlan.value = checkout.value.plans.find(plan => plan.id === planId) ?? null
|
selectedPlan.value = checkout.value.plans.find(plan => plan.id === resume.planId) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextQuery = { ...route.query }
|
await router.replace({ path: route.path, query: stripWechatResumeQuery(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) {
|
if (resume.wechatResumeToken) {
|
||||||
await createOrder(orderAmount, orderType, Number.isFinite(planId) && planId > 0 ? planId : undefined, {
|
await createOrder(0, resume.orderType, resume.planId, {
|
||||||
openid,
|
wechatResumeToken: resume.wechatResumeToken,
|
||||||
paymentType,
|
paymentType: resume.paymentType,
|
||||||
|
isResume: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resume.orderAmount > 0 && resume.openid) {
|
||||||
|
await createOrder(resume.orderAmount, resume.orderType, resume.planId, {
|
||||||
|
openid: resume.openid,
|
||||||
|
paymentType: resume.paymentType,
|
||||||
isResume: true,
|
isResume: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,25 @@ describe('PaymentResultView', () => {
|
|||||||
expect(wrapper.text()).toContain('payment.result.success')
|
expect(wrapper.text()).toContain('payment.result.success')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not use public out_trade_no verification for bare order numbers without legacy return markers', async () => {
|
||||||
|
routeState.query = {
|
||||||
|
out_trade_no: 'legacy-bare',
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(PaymentResultView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
OrderStatusBadge: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(verifyOrderPublic).not.toHaveBeenCalled()
|
||||||
|
expect(verifyOrder).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves order by resume token when local recovery snapshot is missing', async () => {
|
it('resolves order by resume token when local recovery snapshot is missing', async () => {
|
||||||
routeState.query = {
|
routeState.query = {
|
||||||
resume_token: 'resume-77',
|
resume_token: 'resume-77',
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { parseWechatResumeRoute, stripWechatResumeQuery } from '../paymentWechatResume'
|
||||||
|
|
||||||
|
describe('parseWechatResumeRoute', () => {
|
||||||
|
it('prefers the opaque resume token over legacy openid query params', () => {
|
||||||
|
expect(parseWechatResumeRoute({
|
||||||
|
wechat_resume: '1',
|
||||||
|
wechat_resume_token: 'resume-token-123',
|
||||||
|
openid: 'openid-123',
|
||||||
|
payment_type: 'wxpay',
|
||||||
|
amount: '12.5',
|
||||||
|
order_type: 'subscription',
|
||||||
|
plan_id: '7',
|
||||||
|
}, [], 88)).toEqual({
|
||||||
|
wechatResumeToken: 'resume-token-123',
|
||||||
|
paymentType: 'wxpay',
|
||||||
|
orderType: 'balance',
|
||||||
|
orderAmount: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to legacy openid-based resume when opaque token is absent', () => {
|
||||||
|
expect(parseWechatResumeRoute({
|
||||||
|
wechat_resume: '1',
|
||||||
|
openid: 'openid-123',
|
||||||
|
payment_type: 'wxpay',
|
||||||
|
amount: '12.5',
|
||||||
|
order_type: 'balance',
|
||||||
|
}, [], 88)).toEqual({
|
||||||
|
openid: 'openid-123',
|
||||||
|
paymentType: 'wxpay',
|
||||||
|
orderType: 'balance',
|
||||||
|
orderAmount: 12.5,
|
||||||
|
planId: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('stripWechatResumeQuery', () => {
|
||||||
|
it('removes both opaque-token and legacy resume params from the route query', () => {
|
||||||
|
expect(stripWechatResumeQuery({
|
||||||
|
foo: 'bar',
|
||||||
|
wechat_resume: '1',
|
||||||
|
wechat_resume_token: 'resume-token-123',
|
||||||
|
openid: 'openid-123',
|
||||||
|
payment_type: 'wxpay',
|
||||||
|
amount: '12.5',
|
||||||
|
order_type: 'subscription',
|
||||||
|
plan_id: '7',
|
||||||
|
state: 'state-123',
|
||||||
|
scope: 'snsapi_base',
|
||||||
|
})).toEqual({
|
||||||
|
foo: 'bar',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
77
frontend/src/views/user/paymentWechatResume.ts
Normal file
77
frontend/src/views/user/paymentWechatResume.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { LocationQuery, LocationQueryRaw } from 'vue-router'
|
||||||
|
import type { SubscriptionPlan } from '@/types/payment'
|
||||||
|
import { normalizeVisibleMethod } from '@/components/payment/paymentFlow'
|
||||||
|
|
||||||
|
export interface ParsedWechatResumeRoute {
|
||||||
|
orderAmount: number
|
||||||
|
orderType: 'balance' | 'subscription'
|
||||||
|
paymentType: string
|
||||||
|
planId?: number
|
||||||
|
openid?: string
|
||||||
|
wechatResumeToken?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function readQueryString(query: LocationQuery, key: string): string {
|
||||||
|
const value = query[key]
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return typeof value[0] === 'string' ? value[0] : ''
|
||||||
|
}
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWechatResumeRoute(
|
||||||
|
query: LocationQuery,
|
||||||
|
plans: SubscriptionPlan[],
|
||||||
|
fallbackBalanceAmount: number,
|
||||||
|
): ParsedWechatResumeRoute | null {
|
||||||
|
if (readQueryString(query, 'wechat_resume') !== '1') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const wechatResumeToken = readQueryString(query, 'wechat_resume_token')
|
||||||
|
if (wechatResumeToken) {
|
||||||
|
return {
|
||||||
|
wechatResumeToken,
|
||||||
|
paymentType: 'wxpay',
|
||||||
|
orderType: 'balance',
|
||||||
|
orderAmount: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openid = readQueryString(query, 'openid')
|
||||||
|
if (!openid) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentType = normalizeVisibleMethod(readQueryString(query, 'payment_type')) || 'wxpay'
|
||||||
|
const orderType = readQueryString(query, 'order_type') === 'subscription' ? 'subscription' : 'balance'
|
||||||
|
const planId = Number.parseInt(readQueryString(query, 'plan_id'), 10)
|
||||||
|
const rawAmount = Number.parseFloat(readQueryString(query, 'amount'))
|
||||||
|
const orderAmount = Number.isFinite(rawAmount) && rawAmount > 0
|
||||||
|
? rawAmount
|
||||||
|
: (orderType === 'subscription'
|
||||||
|
? (plans.find(plan => plan.id === planId)?.price ?? 0)
|
||||||
|
: fallbackBalanceAmount)
|
||||||
|
|
||||||
|
return {
|
||||||
|
openid,
|
||||||
|
paymentType,
|
||||||
|
orderType,
|
||||||
|
orderAmount,
|
||||||
|
planId: Number.isFinite(planId) && planId > 0 ? planId : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripWechatResumeQuery(query: LocationQuery): LocationQueryRaw {
|
||||||
|
const nextQuery: LocationQueryRaw = { ...query }
|
||||||
|
delete nextQuery.wechat_resume
|
||||||
|
delete nextQuery.wechat_resume_token
|
||||||
|
delete nextQuery.openid
|
||||||
|
delete nextQuery.state
|
||||||
|
delete nextQuery.scope
|
||||||
|
delete nextQuery.payment_type
|
||||||
|
delete nextQuery.amount
|
||||||
|
delete nextQuery.order_type
|
||||||
|
delete nextQuery.plan_id
|
||||||
|
return nextQuery
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user