diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go index b078b804..45de30a8 100644 --- a/backend/internal/handler/auth_wechat_oauth.go +++ b/backend/internal/handler/auth_wechat_oauth.go @@ -435,24 +435,34 @@ func (h *AuthHandler) WeChatPaymentOAuthCallback(c *gin.Context) { 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.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("wechat_resume_token", resumeToken) fragment.Set("redirect", redirectTo) 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 { InvitationCode string `json:"invitation_code" binding:"required"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` diff --git a/backend/internal/handler/auth_wechat_oauth_test.go b/backend/internal/handler/auth_wechat_oauth_test.go index def9d5d6..c65f4cd1 100644 --- a/backend/internal/handler/auth_wechat_oauth_test.go +++ b/backend/internal/handler/auth_wechat_oauth_test.go @@ -21,6 +21,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/pendingauthsession" dbuser "github.com/Wei-Shaw/sub2api/ent/user" "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/service" "github.com/gin-gonic/gin" @@ -175,6 +176,66 @@ func TestWeChatOAuthCallbackRejectsMissingUnionID(t *testing.T) { 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) { testCases := []struct { name string diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index d54cbe92..5fd6b43e 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -1,9 +1,12 @@ package handler import ( + "fmt" "strconv" "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/response" 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. type CreateOrderRequest struct { - Amount float64 `json:"amount"` - PaymentType string `json:"payment_type" binding:"required"` - OpenID string `json:"openid"` - ReturnURL string `json:"return_url"` - PaymentSource string `json:"payment_source"` - OrderType string `json:"order_type"` - PlanID int64 `json:"plan_id"` - IsMobile *bool `json:"is_mobile,omitempty"` + Amount float64 `json:"amount"` + PaymentType string `json:"payment_type" binding:"required"` + OpenID string `json:"openid"` + WechatResumeToken string `json:"wechat_resume_token"` + ReturnURL string `json:"return_url"` + PaymentSource string `json:"payment_source"` + OrderType string `json:"order_type"` + PlanID int64 `json:"plan_id"` + IsMobile *bool `json:"is_mobile,omitempty"` } // CreateOrder creates a new payment order. @@ -225,6 +229,17 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) 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) if req.IsMobile != nil { @@ -253,6 +268,44 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) { 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. // GET /api/v1/payment/orders/my func (h *PaymentHandler) GetMyOrders(c *gin.Context) { diff --git a/backend/internal/handler/payment_handler_resume_test.go b/backend/internal/handler/payment_handler_resume_test.go new file mode 100644 index 00000000..323f7292 --- /dev/null +++ b/backend/internal/handler/payment_handler_resume_test.go @@ -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") + } +} diff --git a/backend/internal/service/payment_resume_lookup.go b/backend/internal/service/payment_resume_lookup.go index 493ca325..69033afd 100644 --- a/backend/internal/service/payment_resume_lookup.go +++ b/backend/internal/service/payment_resume_lookup.go @@ -33,3 +33,7 @@ func (s *PaymentService) GetPublicOrderByResumeToken(ctx context.Context, token return order, nil } + +func (s *PaymentService) ParseWeChatPaymentResumeToken(token string) (*WeChatPaymentResumeClaims, error) { + return s.paymentResume().ParseWeChatPaymentResumeToken(strings.TrimSpace(token)) +} diff --git a/backend/internal/service/payment_resume_service.go b/backend/internal/service/payment_resume_service.go index 4f63645e..64d1d125 100644 --- a/backend/internal/service/payment_resume_service.go +++ b/backend/internal/service/payment_resume_service.go @@ -31,6 +31,8 @@ const ( VisibleMethodSourceEasyPayAlipay = "easypay_alipay" VisibleMethodSourceOfficialWechat = "official_wxpay" VisibleMethodSourceEasyPayWechat = "easypay_wxpay" + + wechatPaymentResumeTokenType = "wechat_payment_resume" ) type ResumeTokenClaims struct { @@ -43,6 +45,18 @@ type ResumeTokenClaims struct { 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 { signingKey []byte } @@ -232,6 +246,66 @@ func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, er if claims.IssuedAt == 0 { 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) if err != nil { 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 } -func (s *PaymentResumeService) ParseToken(token string) (*ResumeTokenClaims, error) { +func (s *PaymentResumeService) parseSignedToken(token string, dest any) error { parts := strings.Split(token, ".") 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]))) { - 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]) 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 - 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 + return json.Unmarshal(payload, dest) } func (s *PaymentResumeService) sign(payload string) string { diff --git a/backend/internal/service/payment_resume_service_test.go b/backend/internal/service/payment_resume_service_test.go index 9c35ac3d..24d50494 100644 --- a/backend/internal/service/payment_resume_service_test.go +++ b/backend/internal/service/payment_resume_service_test.go @@ -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) { t.Parallel() diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 5cd49064..77fbe689 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -157,6 +157,7 @@ export interface CreateOrderRequest { return_url?: string payment_source?: string openid?: string + wechat_resume_token?: string is_mobile?: boolean } diff --git a/frontend/src/views/auth/WechatPaymentCallbackView.vue b/frontend/src/views/auth/WechatPaymentCallbackView.vue index 422a0bb8..73095102 100644 --- a/frontend/src/views/auth/WechatPaymentCallbackView.vue +++ b/frontend/src/views/auth/WechatPaymentCallbackView.vue @@ -114,23 +114,17 @@ onMounted(async () => { 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 resumeToken = readParam('wechat_resume_token') const redirectURL = new URL( normalizeRedirectPath(readParam('redirect')), window.location.origin, ) - if (!openid) { + if (!resumeToken) { errorMessage.value = textWithFallback( - 'auth.wechatPayment.callbackMissingOpenId', - '微信支付回调缺少 openid。', - 'The WeChat payment callback is missing the openid.', + 'auth.wechatPayment.callbackMissingResumeToken', + '微信支付回调缺少恢复令牌。', + 'The WeChat payment callback is missing the resume token.', ) return } @@ -138,14 +132,8 @@ onMounted(async () => { const query: Record = { ...Object.fromEntries(redirectURL.searchParams.entries()), 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({ path: redirectURL.pathname, diff --git a/frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts b/frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts index cfbd9f1c..400e50d5 100644 --- a/frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts @@ -49,8 +49,8 @@ describe('WechatPaymentCallbackView', () => { }) }) - 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' + it('redirects back to purchase with an opaque resume token from hash fragment', async () => { + locationState.current.hash = '#wechat_resume_token=resume-token-123&redirect=%2Fpurchase%3Ffrom%3Dwechat' mount(WechatPaymentCallbackView) await flushPromises() @@ -60,21 +60,18 @@ describe('WechatPaymentCallbackView', () => { query: { from: 'wechat', wechat_resume: '1', - openid: 'openid-123', - payment_type: 'wxpay', - amount: '12.5', - order_type: 'balance', + wechat_resume_token: 'resume-token-123', }, }) }) - 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' const wrapper = mount(WechatPaymentCallbackView) await flushPromises() expect(replaceMock).not.toHaveBeenCalled() - expect(wrapper.text()).toContain('微信支付回调缺少 openid。') + expect(wrapper.text()).toContain('微信支付回调缺少恢复令牌。') }) }) diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index e1bcbbe5..5c843d1f 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -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) { returnInfo.value = { outTradeNo, diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 019de16a..a3a81ccb 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -284,6 +284,7 @@ import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue' import Icon from '@/components/icons/Icon.vue' import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue' import { describePaymentScenarioError } from './paymentUx' +import { parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume' const { t } = useI18n() const route = useRoute() @@ -315,6 +316,7 @@ const paymentPhase = ref<'select' | 'paying'>('select') interface CreateOrderOptions { openid?: string + wechatResumeToken?: string paymentType?: string 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 { return (window as Window & { WeixinJSBridge?: WeixinJSBridgeLike }).WeixinJSBridge } @@ -637,6 +632,9 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n if (options.openid) { payload.openid = options.openid } + if (options.wechatResumeToken) { + payload.wechat_resume_token = options.wechatResumeToken + } payload.is_mobile = isMobileDevice() const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string } @@ -744,44 +742,34 @@ function applyScenarioError(err: unknown, paymentMethod: string) { } async function resumeWechatPaymentFromQuery() { - const openid = readRouteQueryValue(route.query.openid) - if (readRouteQueryValue(route.query.wechat_resume) !== '1' || !openid) { + const resume = parseWechatResumeRoute(route.query, checkout.value.plans, validAmount.value) + if (!resume) { 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 + selectedMethod.value = resume.paymentType + if (resume.orderType === 'balance' && resume.orderAmount > 0) { + amount.value = resume.orderAmount } - if (orderType === 'subscription' && Number.isFinite(planId) && planId > 0) { - selectedPlan.value = checkout.value.plans.find(plan => plan.id === planId) ?? null + if (resume.orderType === 'subscription' && resume.planId) { + selectedPlan.value = checkout.value.plans.find(plan => plan.id === resume.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 }) + await router.replace({ path: route.path, query: stripWechatResumeQuery(route.query) }) - if (orderAmount > 0) { - await createOrder(orderAmount, orderType, Number.isFinite(planId) && planId > 0 ? planId : undefined, { - openid, - paymentType, + if (resume.wechatResumeToken) { + await createOrder(0, resume.orderType, resume.planId, { + wechatResumeToken: resume.wechatResumeToken, + 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, }) } diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts index bfc044a7..d8199e3b 100644 --- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts @@ -157,6 +157,25 @@ describe('PaymentResultView', () => { 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 () => { routeState.query = { resume_token: 'resume-77', diff --git a/frontend/src/views/user/__tests__/paymentWechatResume.spec.ts b/frontend/src/views/user/__tests__/paymentWechatResume.spec.ts new file mode 100644 index 00000000..c850ec1b --- /dev/null +++ b/frontend/src/views/user/__tests__/paymentWechatResume.spec.ts @@ -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', + }) + }) +}) diff --git a/frontend/src/views/user/paymentWechatResume.ts b/frontend/src/views/user/paymentWechatResume.ts new file mode 100644 index 00000000..f53c8457 --- /dev/null +++ b/frontend/src/views/user/paymentWechatResume.ts @@ -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 +}