From dd314c41e3e23dbe598dd1e0f83811c5d95586e4 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Wed, 22 Apr 2026 11:17:23 +0800 Subject: [PATCH] fix(payment): restore public resume and result flows --- backend/internal/handler/payment_handler.go | 84 ++++++---- .../handler/payment_handler_resume_test.go | 152 +++++++++++++++++- backend/internal/server/routes/payment.go | 6 +- backend/internal/service/payment_order.go | 9 +- .../service/payment_resume_service.go | 27 +++- .../service/payment_resume_service_test.go | 55 ++++++- frontend/src/api/__tests__/payment.spec.ts | 8 +- frontend/src/api/payment.ts | 5 + .../payment/__tests__/paymentFlow.spec.ts | 29 ++++ .../src/components/payment/paymentFlow.ts | 6 +- frontend/src/views/user/PaymentResultView.vue | 44 +++-- frontend/src/views/user/PaymentView.vue | 13 +- .../user/__tests__/PaymentResultView.spec.ts | 40 ++--- .../views/user/__tests__/PaymentView.spec.ts | 37 +++++ .../src/views/user/paymentWechatResume.ts | 10 +- 15 files changed, 435 insertions(+), 90 deletions(-) diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 16b25355..09580442 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -2,9 +2,9 @@ package handler import ( "fmt" - "net/http" "strconv" "strings" + "time" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/payment" @@ -454,29 +454,65 @@ func (h *PaymentHandler) VerifyOrder(c *gin.Context) { // PublicOrderResult is the limited order info returned by the public verify endpoint. // No user details are exposed — only payment status information. type PublicOrderResult struct { - ID int64 `json:"id"` - OutTradeNo string `json:"out_trade_no"` - Amount float64 `json:"amount"` - PayAmount float64 `json:"pay_amount"` - PaymentType string `json:"payment_type"` - OrderType string `json:"order_type"` - Status string `json:"status"` + ID int64 `json:"id"` + OutTradeNo string `json:"out_trade_no"` + Amount float64 `json:"amount"` + PayAmount float64 `json:"pay_amount"` + FeeRate float64 `json:"fee_rate"` + PaymentType string `json:"payment_type"` + OrderType string `json:"order_type"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + PaidAt *time.Time `json:"paid_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + RefundAmount float64 `json:"refund_amount"` + RefundReason *string `json:"refund_reason,omitempty"` + RefundRequestedAt *time.Time `json:"refund_requested_at,omitempty"` + RefundRequestedBy *string `json:"refund_requested_by,omitempty"` + RefundRequestReason *string `json:"refund_request_reason,omitempty"` + PlanID *int64 `json:"plan_id,omitempty"` } -var errPaymentPublicOrderVerifyRemoved = infraerrors.New( - http.StatusGone, - "PAYMENT_PUBLIC_ORDER_VERIFY_REMOVED", - "public payment order verification by out_trade_no has been removed; use resume_token recovery instead", -).WithMetadata(map[string]string{ - "replacement_endpoint": "/api/v1/payment/public/orders/resolve", - "replacement_field": "resume_token", -}) +func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult { + return PublicOrderResult{ + ID: order.ID, + OutTradeNo: order.OutTradeNo, + Amount: order.Amount, + PayAmount: order.PayAmount, + FeeRate: order.FeeRate, + PaymentType: order.PaymentType, + OrderType: order.OrderType, + Status: order.Status, + CreatedAt: order.CreatedAt, + ExpiresAt: order.ExpiresAt, + PaidAt: order.PaidAt, + CompletedAt: order.CompletedAt, + RefundAmount: order.RefundAmount, + RefundReason: order.RefundReason, + RefundRequestedAt: order.RefundRequestedAt, + RefundRequestedBy: order.RefundRequestedBy, + RefundRequestReason: order.RefundRequestReason, + PlanID: order.PlanID, + } +} -// VerifyOrderPublic is kept as a compatibility shim for the removed anonymous -// out_trade_no lookup endpoint and always returns HTTP 410 Gone. +// VerifyOrderPublic keeps the legacy anonymous out_trade_no lookup available as +// a compatibility path for older result pages and staggered deploys. // POST /api/v1/payment/public/orders/verify func (h *PaymentHandler) VerifyOrderPublic(c *gin.Context) { - response.ErrorFrom(c, errPaymentPublicOrderVerifyRemoved) + var req VerifyOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + order, err := h.paymentService.VerifyOrderPublic(c.Request.Context(), req.OutTradeNo) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, buildPublicOrderResult(order)) } // ResolveOrderPublicByResumeToken resolves a payment order from a signed resume token. @@ -493,15 +529,7 @@ func (h *PaymentHandler) ResolveOrderPublicByResumeToken(c *gin.Context) { response.ErrorFrom(c, err) return } - response.Success(c, PublicOrderResult{ - ID: order.ID, - OutTradeNo: order.OutTradeNo, - Amount: order.Amount, - PayAmount: order.PayAmount, - PaymentType: order.PaymentType, - OrderType: order.OrderType, - Status: order.Status, - }) + response.Success(c, buildPublicOrderResult(order)) } // requireAuth extracts the authenticated subject from the context. diff --git a/backend/internal/handler/payment_handler_resume_test.go b/backend/internal/handler/payment_handler_resume_test.go index 28da15d9..5a2ecb46 100644 --- a/backend/internal/handler/payment_handler_resume_test.go +++ b/backend/internal/handler/payment_handler_resume_test.go @@ -4,16 +4,17 @@ package handler import ( "bytes" + "context" "database/sql" "encoding/json" "net/http" "net/http/httptest" "testing" + "time" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/enttest" "github.com/Wei-Shaw/sub2api/internal/payment" - "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -74,7 +75,7 @@ func TestApplyWeChatPaymentResumeClaimsRejectsPaymentTypeMismatch(t *testing.T) } } -func TestVerifyOrderPublicReturnsGone(t *testing.T) { +func TestVerifyOrderPublicReturnsLegacyOrderState(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -90,6 +91,32 @@ func TestVerifyOrderPublicReturnsGone(t *testing.T) { client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv))) t.Cleanup(func() { _ = client.Close() }) + user, err := client.User.Create(). + SetEmail("public-verify@example.com"). + SetPasswordHash("hash"). + SetUsername("public-verify-user"). + Save(context.Background()) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(88). + SetPayAmount(90.64). + SetFeeRate(0.03). + SetRechargeCode("PUBLIC-VERIFY"). + SetOutTradeNo("legacy-order-no"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo("trade-public-verify"). + SetOrderType(payment.OrderTypeBalance). + SetStatus(service.OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(context.Background()) + require.NoError(t, err) + paymentSvc := service.NewPaymentService(client, payment.NewRegistry(), nil, nil, nil, nil, nil, nil) h := NewPaymentHandler(paymentSvc, nil, nil) @@ -104,11 +131,122 @@ func TestVerifyOrderPublicReturnsGone(t *testing.T) { h.VerifyOrderPublic(ctx) - require.Equal(t, http.StatusGone, recorder.Code) + require.Equal(t, http.StatusOK, recorder.Code) - var resp response.Response + var resp struct { + Code int `json:"code"` + Data struct { + ID int64 `json:"id"` + OutTradeNo string `json:"out_trade_no"` + Amount float64 `json:"amount"` + PayAmount float64 `json:"pay_amount"` + FeeRate float64 `json:"fee_rate"` + PaymentType string `json:"payment_type"` + OrderType string `json:"order_type"` + Status string `json:"status"` + RefundAmount float64 `json:"refund_amount"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + } `json:"data"` + } require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) - require.Equal(t, http.StatusGone, resp.Code) - require.Equal(t, "PAYMENT_PUBLIC_ORDER_VERIFY_REMOVED", resp.Reason) - require.Contains(t, resp.Message, "removed") + require.Equal(t, 0, resp.Code) + require.Equal(t, order.ID, resp.Data.ID) + require.Equal(t, "legacy-order-no", resp.Data.OutTradeNo) + require.Equal(t, 90.64, resp.Data.PayAmount) + require.Equal(t, 0.03, resp.Data.FeeRate) + require.Equal(t, payment.TypeAlipay, resp.Data.PaymentType) + require.Equal(t, payment.OrderTypeBalance, resp.Data.OrderType) + require.Equal(t, service.OrderStatusPending, resp.Data.Status) + require.Equal(t, 0.0, resp.Data.RefundAmount) + require.NotEmpty(t, resp.Data.CreatedAt) + require.NotEmpty(t, resp.Data.ExpiresAt) +} + +func TestResolveOrderPublicByResumeTokenReturnsFrontendContractFields(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + + db, err := sql.Open("sqlite", "file:payment_handler_public_resolve?mode=memory&cache=shared") + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.Exec("PRAGMA foreign_keys = ON") + require.NoError(t, err) + + drv := entsql.OpenDB(dialect.SQLite, db) + client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv))) + t.Cleanup(func() { _ = client.Close() }) + + user, err := client.User.Create(). + SetEmail("public-resolve@example.com"). + SetPasswordHash("hash"). + SetUsername("public-resolve-user"). + Save(context.Background()) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(100). + SetPayAmount(103). + SetFeeRate(0.03). + SetRechargeCode("PUBLIC-RESOLVE"). + SetOutTradeNo("resolve-order-no"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo("trade-public-resolve"). + SetOrderType(payment.OrderTypeBalance). + SetStatus(service.OrderStatusPaid). + SetExpiresAt(time.Now().Add(time.Hour)). + SetPaidAt(time.Now()). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(context.Background()) + require.NoError(t, err) + + resumeSvc := service.NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef")) + token, err := resumeSvc.CreateToken(service.ResumeTokenClaims{ + OrderID: order.ID, + UserID: user.ID, + PaymentType: payment.TypeAlipay, + CanonicalReturnURL: "https://app.example.com/payment/result", + }) + require.NoError(t, err) + + configSvc := service.NewPaymentConfigService(client, nil, []byte("0123456789abcdef0123456789abcdef")) + paymentSvc := service.NewPaymentService(client, payment.NewRegistry(), nil, nil, nil, configSvc, nil, nil) + h := NewPaymentHandler(paymentSvc, nil, nil) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest( + http.MethodPost, + "/api/v1/payment/public/orders/resolve", + bytes.NewBufferString(`{"resume_token":"`+token+`"}`), + ) + ctx.Request.Header.Set("Content-Type", "application/json") + + h.ResolveOrderPublicByResumeToken(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + + var resp struct { + Code int `json:"code"` + Data map[string]any `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.Equal(t, float64(order.ID), resp.Data["id"]) + require.Equal(t, "resolve-order-no", resp.Data["out_trade_no"]) + require.Equal(t, 100.0, resp.Data["amount"]) + require.Equal(t, 103.0, resp.Data["pay_amount"]) + require.Equal(t, 0.03, resp.Data["fee_rate"]) + require.Equal(t, payment.TypeAlipay, resp.Data["payment_type"]) + require.Equal(t, payment.OrderTypeBalance, resp.Data["order_type"]) + require.Equal(t, service.OrderStatusPaid, resp.Data["status"]) + require.Contains(t, resp.Data, "created_at") + require.Contains(t, resp.Data, "expires_at") + require.Contains(t, resp.Data, "refund_amount") } diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index ec340d94..e4828ead 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -44,9 +44,9 @@ func RegisterPaymentRoutes( } // --- Public payment endpoints (no auth) --- - // Signed resume-token recovery is the supported public lookup path. - // The legacy anonymous out_trade_no verify endpoint is kept only as a - // compatibility shim that returns HTTP 410 Gone. + // Signed resume-token recovery is the preferred public lookup path. + // The legacy anonymous out_trade_no verify endpoint remains available as a + // persisted-state compatibility path for staggered upgrades. public := v1.Group("/payment/public") { public.POST("/orders/verify", paymentHandler.VerifyOrderPublic) diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 3fdcecb5..15d4509d 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -379,16 +379,13 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen } subject := s.buildPaymentSubject(plan, limitAmount, cfg) outTradeNo := order.OutTradeNo - canonicalReturnURL, err := CanonicalizeReturnURL(req.ReturnURL, req.SrcHost) + canonicalReturnURL, err := CanonicalizeReturnURL(req.ReturnURL, req.SrcHost, req.SrcURL) if err != nil { return nil, err } resumeToken := "" if resume := s.paymentResume(); resume != nil { - if canonicalReturnURL != "" { - if err := resume.ensureSigningKey(); err != nil { - return nil, err - } + if canonicalReturnURL != "" && resume.isSigningConfigured() { resumeToken, err = resume.CreateToken(ResumeTokenClaims{ OrderID: order.ID, UserID: order.UserID, @@ -402,7 +399,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen } } } - providerReturnURL, err := buildPaymentReturnURL(canonicalReturnURL, order.ID, resumeToken) + providerReturnURL, err := buildPaymentReturnURL(canonicalReturnURL, order.ID, outTradeNo, resumeToken) if err != nil { return nil, err } diff --git a/backend/internal/service/payment_resume_service.go b/backend/internal/service/payment_resume_service.go index 6e8acccb..438aa59f 100644 --- a/backend/internal/service/payment_resume_service.go +++ b/backend/internal/service/payment_resume_service.go @@ -209,7 +209,7 @@ func visibleMethodSourceSettingKey(method string) string { } } -func CanonicalizeReturnURL(raw string, srcHost string) (string, error) { +func CanonicalizeReturnURL(raw string, srcHost string, srcURL string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", nil @@ -228,13 +228,29 @@ func CanonicalizeReturnURL(raw string, srcHost string) (string, error) { if parsed.Path != paymentResultReturnPath { return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must target the canonical internal payment result page") } - if !sameOriginHost(parsed.Host, srcHost) { - return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use the same host as the current site") + if !allowedReturnURLHost(parsed.Host, srcHost, srcURL) { + return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use the same host as the current site or browser origin") } return parsed.String(), nil } -func buildPaymentReturnURL(base string, orderID int64, resumeToken string) (string, error) { +func allowedReturnURLHost(returnURLHost string, requestHost string, refererURL string) bool { + if sameOriginHost(returnURLHost, requestHost) { + return true + } + + refererURL = strings.TrimSpace(refererURL) + if refererURL == "" { + return false + } + parsedReferer, err := url.Parse(refererURL) + if err != nil || parsedReferer.Host == "" { + return false + } + return sameOriginHost(returnURLHost, parsedReferer.Host) +} + +func buildPaymentReturnURL(base string, orderID int64, outTradeNo string, resumeToken string) (string, error) { canonical := strings.TrimSpace(base) if canonical == "" { return "", nil @@ -253,6 +269,9 @@ func buildPaymentReturnURL(base string, orderID int64, resumeToken string) (stri if orderID > 0 { query.Set("order_id", strconv.FormatInt(orderID, 10)) } + if strings.TrimSpace(outTradeNo) != "" { + query.Set("out_trade_no", strings.TrimSpace(outTradeNo)) + } if strings.TrimSpace(resumeToken) != "" { query.Set("resume_token", strings.TrimSpace(resumeToken)) } diff --git a/backend/internal/service/payment_resume_service_test.go b/backend/internal/service/payment_resume_service_test.go index 78b6bba3..ffa55e69 100644 --- a/backend/internal/service/payment_resume_service_test.go +++ b/backend/internal/service/payment_resume_service_test.go @@ -64,7 +64,7 @@ func TestNormalizePaymentSource(t *testing.T) { func TestCanonicalizeReturnURL(t *testing.T) { t.Parallel() - got, err := CanonicalizeReturnURL("https://example.com/payment/result?b=2#a", "example.com") + got, err := CanonicalizeReturnURL("https://example.com/payment/result?b=2#a", "example.com", "") if err != nil { t.Fatalf("CanonicalizeReturnURL returned error: %v", err) } @@ -76,7 +76,7 @@ func TestCanonicalizeReturnURL(t *testing.T) { func TestCanonicalizeReturnURLRejectsRelativeURL(t *testing.T) { t.Parallel() - if _, err := CanonicalizeReturnURL("/payment/result", "example.com"); err == nil { + if _, err := CanonicalizeReturnURL("/payment/result", "example.com", ""); err == nil { t.Fatal("CanonicalizeReturnURL should reject relative URLs") } } @@ -84,15 +84,31 @@ func TestCanonicalizeReturnURLRejectsRelativeURL(t *testing.T) { func TestCanonicalizeReturnURLRejectsExternalHost(t *testing.T) { t.Parallel() - if _, err := CanonicalizeReturnURL("https://evil.example/payment/result", "app.example.com"); err == nil { + if _, err := CanonicalizeReturnURL("https://evil.example/payment/result", "app.example.com", ""); err == nil { t.Fatal("CanonicalizeReturnURL should reject external hosts") } } +func TestCanonicalizeReturnURLAllowsConfiguredFrontendHost(t *testing.T) { + t.Parallel() + + got, err := CanonicalizeReturnURL( + "https://app.example.com/payment/result?from=checkout", + "api.example.com", + "https://app.example.com/purchase", + ) + if err != nil { + t.Fatalf("CanonicalizeReturnURL returned error: %v", err) + } + if got != "https://app.example.com/payment/result?from=checkout" { + t.Fatalf("CanonicalizeReturnURL = %q, want %q", got, "https://app.example.com/payment/result?from=checkout") + } +} + func TestCanonicalizeReturnURLRejectsNonCanonicalPath(t *testing.T) { t.Parallel() - if _, err := CanonicalizeReturnURL("https://app.example.com/orders/42", "app.example.com"); err == nil { + if _, err := CanonicalizeReturnURL("https://app.example.com/orders/42", "app.example.com", ""); err == nil { t.Fatal("CanonicalizeReturnURL should reject non-canonical result paths") } } @@ -100,7 +116,7 @@ func TestCanonicalizeReturnURLRejectsNonCanonicalPath(t *testing.T) { func TestBuildPaymentReturnURL(t *testing.T) { t.Parallel() - got, err := buildPaymentReturnURL("https://example.com/payment/result?from=checkout#fragment", 42, "resume-token") + got, err := buildPaymentReturnURL("https://example.com/payment/result?from=checkout#fragment", 42, "sub2_42", "resume-token") if err != nil { t.Fatalf("buildPaymentReturnURL returned error: %v", err) } @@ -119,6 +135,9 @@ func TestBuildPaymentReturnURL(t *testing.T) { if query.Get("order_id") != strconv.FormatInt(42, 10) { t.Fatalf("order_id = %q", query.Get("order_id")) } + if query.Get("out_trade_no") != "sub2_42" { + t.Fatalf("out_trade_no = %q", query.Get("out_trade_no")) + } if query.Get("resume_token") != "resume-token" { t.Fatalf("resume_token = %q", query.Get("resume_token")) } @@ -127,10 +146,34 @@ func TestBuildPaymentReturnURL(t *testing.T) { } } +func TestBuildPaymentReturnURLWithoutResumeTokenStillIncludesOutTradeNo(t *testing.T) { + t.Parallel() + + got, err := buildPaymentReturnURL("https://example.com/payment/result", 42, "sub2_42", "") + if err != nil { + t.Fatalf("buildPaymentReturnURL returned error: %v", err) + } + + parsed, err := url.Parse(got) + if err != nil { + t.Fatalf("url.Parse returned error: %v", err) + } + query := parsed.Query() + if query.Get("order_id") != "42" { + t.Fatalf("order_id = %q", query.Get("order_id")) + } + if query.Get("out_trade_no") != "sub2_42" { + t.Fatalf("out_trade_no = %q", query.Get("out_trade_no")) + } + if query.Get("resume_token") != "" { + t.Fatalf("resume_token = %q, want empty", query.Get("resume_token")) + } +} + func TestBuildPaymentReturnURLEmptyBase(t *testing.T) { t.Parallel() - got, err := buildPaymentReturnURL("", 42, "resume-token") + got, err := buildPaymentReturnURL("", 42, "sub2_42", "resume-token") if err != nil { t.Fatalf("buildPaymentReturnURL returned error: %v", err) } diff --git a/frontend/src/api/__tests__/payment.spec.ts b/frontend/src/api/__tests__/payment.spec.ts index 3006484e..e38fba57 100644 --- a/frontend/src/api/__tests__/payment.spec.ts +++ b/frontend/src/api/__tests__/payment.spec.ts @@ -22,8 +22,12 @@ describe('payment api', () => { post.mockResolvedValue({ data: {} }) }) - it('does not expose anonymous public out_trade_no verification', () => { - expect(Object.prototype.hasOwnProperty.call(paymentAPI, 'verifyOrderPublic')).toBe(false) + it('keeps legacy public out_trade_no verification for upgrade compatibility', async () => { + await paymentAPI.verifyOrderPublic('legacy-order-no') + + expect(post).toHaveBeenCalledWith('/payment/public/orders/verify', { + out_trade_no: 'legacy-order-no', + }) }) it('keeps signed public resume-token resolve endpoint', async () => { diff --git a/frontend/src/api/payment.ts b/frontend/src/api/payment.ts index e866e184..92b0ec90 100644 --- a/frontend/src/api/payment.ts +++ b/frontend/src/api/payment.ts @@ -67,6 +67,11 @@ export const paymentAPI = { return apiClient.post('/payment/orders/verify', { out_trade_no: outTradeNo }) }, + /** Legacy-compatible public order lookup by out_trade_no */ + verifyOrderPublic(outTradeNo: string) { + return apiClient.post('/payment/public/orders/verify', { out_trade_no: outTradeNo }) + }, + /** Resolve an order from a signed resume token without auth */ resolveOrderPublicByResumeToken(resumeToken: string) { return apiClient.post('/payment/public/orders/resolve', { resume_token: resumeToken }) diff --git a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts index 7f4d6186..48c77dfb 100644 --- a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts +++ b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts @@ -73,6 +73,7 @@ describe('decidePaymentLaunch', () => { expect(decision.paymentState.paymentType).toBe('alipay') expect(decision.stripeMethod).toBe('alipay') expect(decision.recovery.resumeToken).toBe('resume-1') + expect(decision.recovery.outTradeNo).toBe('') }) it('uses Stripe route flow for mobile WeChat client secret', () => { @@ -94,6 +95,7 @@ describe('decidePaymentLaunch', () => { pay_url: 'https://pay.example.com/session/abc', payment_mode: 'popup', resume_token: 'resume-2', + out_trade_no: 'sub2_abc', }), { visibleMethod: 'wxpay', orderType: 'balance', @@ -103,6 +105,7 @@ describe('decidePaymentLaunch', () => { expect(decision.kind).toBe('redirect_waiting') expect(decision.paymentState.payUrl).toBe('https://pay.example.com/session/abc') expect(decision.recovery.paymentMode).toBe('popup') + expect(decision.recovery.outTradeNo).toBe('sub2_abc') expect(decision.recovery.resumeToken).toBe('resume-2') }) @@ -225,6 +228,7 @@ describe('readPaymentRecoverySnapshot', () => { expiresAt: '2099-01-01T00:10:00.000Z', paymentType: 'alipay', payUrl: 'https://pay.example.com/session/33', + outTradeNo: 'sub2_33', clientSecret: '', payAmount: 18, orderType: 'balance', @@ -249,6 +253,7 @@ describe('readPaymentRecoverySnapshot', () => { expiresAt: '2024-01-01T00:10:00.000Z', paymentType: 'wxpay', payUrl: 'https://pay.example.com/session/55', + outTradeNo: 'sub2_55', clientSecret: '', payAmount: 18, orderType: 'balance', @@ -264,10 +269,34 @@ describe('readPaymentRecoverySnapshot', () => { expect(readPaymentRecoverySnapshot(JSON.stringify({ ...expiredSnapshot, + outTradeNo: 'sub2_55', expiresAt: '2099-01-01T00:10:00.000Z', }), { now: Date.UTC(2099, 0, 1, 0, 1, 0), resumeToken: 'other-token', })).toBeNull() }) + + it('keeps backward compatibility with snapshots written before outTradeNo existed', () => { + const restored = readPaymentRecoverySnapshot(JSON.stringify({ + orderId: 44, + amount: 18, + qrCode: '', + expiresAt: '2099-01-01T00:10:00.000Z', + paymentType: 'alipay', + payUrl: 'https://pay.example.com/session/44', + clientSecret: '', + payAmount: 18, + orderType: 'balance', + paymentMode: 'popup', + resumeToken: 'resume-44', + createdAt: Date.UTC(2099, 0, 1, 0, 0, 0), + }), { + now: Date.UTC(2099, 0, 1, 0, 1, 0), + resumeToken: 'resume-44', + }) + + expect(restored?.orderId).toBe(44) + expect(restored?.outTradeNo).toBe('') + }) }) diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts index 7fbc1435..05f36ed0 100644 --- a/frontend/src/components/payment/paymentFlow.ts +++ b/frontend/src/components/payment/paymentFlow.ts @@ -34,6 +34,7 @@ export interface PaymentRecoverySnapshot { expiresAt: string paymentType: string payUrl: string + outTradeNo: string clientSecret: string payAmount: number orderType: OrderType | '' @@ -132,6 +133,7 @@ export function decidePaymentLaunch( expiresAt: result.expires_at || '', paymentType: visibleMethod, payUrl: result.pay_url || '', + outTradeNo: result.out_trade_no || '', clientSecret: result.client_secret || '', payAmount: result.pay_amount, orderType: context.orderType, @@ -227,6 +229,7 @@ export function readPaymentRecoverySnapshot( || typeof parsed.expiresAt !== 'string' || typeof parsed.paymentType !== 'string' || typeof parsed.payUrl !== 'string' + || (parsed.outTradeNo != null && typeof parsed.outTradeNo !== 'string') || typeof parsed.clientSecret !== 'string' || typeof parsed.payAmount !== 'number' || typeof parsed.paymentMode !== 'string' @@ -241,7 +244,7 @@ export function readPaymentRecoverySnapshot( if (Number.isFinite(expiresAt) && expiresAt <= now) { return null } - if (options.resumeToken && parsed.resumeToken && parsed.resumeToken !== options.resumeToken) { + if (options.resumeToken && parsed.resumeToken !== options.resumeToken) { return null } @@ -252,6 +255,7 @@ export function readPaymentRecoverySnapshot( expiresAt: parsed.expiresAt, paymentType: parsed.paymentType, payUrl: parsed.payUrl, + outTradeNo: parsed.outTradeNo || '', clientSecret: parsed.clientSecret, payAmount: parsed.payAmount, orderType: parsed.orderType === 'subscription' ? 'subscription' : 'balance', diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index 1af34540..cbebaa83 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -190,6 +190,15 @@ async function resolveOrderFromResumeToken(resumeToken: string): Promise { + try { + const result = await paymentAPI.verifyOrderPublic(outTradeNo) + return result.data + } catch (_err: unknown) { + return null + } +} + function clearStatusRefreshTimer(): void { if (statusRefreshTimer !== null) { clearTimeout(statusRefreshTimer) @@ -234,24 +243,19 @@ onMounted(async () => { ? route.query.resume_token : '' const routeOrderId = Number(route.query.order_id) || 0 - const outTradeNo = String(route.query.out_trade_no || '') + let outTradeNo = String(route.query.out_trade_no || '') let orderId = 0 - if (resumeToken && typeof window !== 'undefined') { + if (typeof window !== 'undefined') { const restored = readPaymentRecoverySnapshot( window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY), - { resumeToken }, + resumeToken ? { resumeToken } : {}, ) if (restored?.orderId) { orderId = restored.orderId } - } - - if (!order.value && resumeToken && orderId) { - try { - order.value = await paymentStore.pollOrderStatus(orderId) - } catch (_err: unknown) { - // Fall through to signed resume-token recovery below. + if (!outTradeNo && restored?.outTradeNo) { + outTradeNo = restored.outTradeNo } } @@ -269,6 +273,20 @@ onMounted(async () => { orderId = routeOrderId } + const hasLegacyFallbackContext = typeof route.query.trade_status === 'string' + && route.query.trade_status.trim() !== '' + const shouldUsePublicOutTradeNo = !resumeToken && outTradeNo !== '' && (hasLegacyFallbackContext || routeOrderId > 0 || orderId > 0) + + if (!order.value && shouldUsePublicOutTradeNo) { + const legacyOrder = await resolveOrderFromOutTradeNo(outTradeNo) + if (legacyOrder) { + order.value = legacyOrder + if (!orderId) { + orderId = legacyOrder.id + } + } + } + if (!order.value && !resumeToken && orderId) { try { order.value = await paymentStore.pollOrderStatus(orderId) @@ -277,8 +295,6 @@ onMounted(async () => { } } - const hasLegacyFallbackContext = typeof route.query.trade_status === 'string' - && route.query.trade_status.trim() !== '' if (!order.value && !resumeToken && !orderId && outTradeNo && hasLegacyFallbackContext) { returnInfo.value = { outTradeNo, @@ -293,6 +309,10 @@ onMounted(async () => { return await resolveOrderFromResumeToken(resumeToken) } + if (shouldUsePublicOutTradeNo) { + return await resolveOrderFromOutTradeNo(outTradeNo) + } + if (orderId) { return await paymentStore.pollOrderStatus(orderId) } diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 10aa7019..1577039e 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -276,7 +276,7 @@ import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue' import Icon from '@/components/icons/Icon.vue' import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue' import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx' -import { parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume' +import { hasWechatResumeQuery, parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume' const { t } = useI18n() const route = useRoute() @@ -329,6 +329,7 @@ function emptyPaymentState(): PaymentRecoverySnapshot { expiresAt: '', paymentType: '', payUrl: '', + outTradeNo: '', clientSecret: '', payAmount: 0, orderType: '', @@ -396,6 +397,9 @@ async function redirectToPaymentResult(state: PaymentRecoverySnapshot): Promise< if (state.orderId > 0) { query.order_id = String(state.orderId) } + if (state.outTradeNo) { + query.out_trade_no = state.outTradeNo + } if (state.resumeToken) { query.resume_token = state.resumeToken } @@ -809,9 +813,14 @@ onMounted(async () => { selectedMethod.value = sorted[0] } if (typeof window !== 'undefined') { + if (hasWechatResumeQuery(route.query)) { + removeRecoverySnapshot() + } const routeResumeToken = typeof route.query.resume_token === 'string' ? route.query.resume_token - : undefined + : typeof route.query.wechat_resume_token === 'string' + ? route.query.wechat_resume_token + : undefined const restored = readPaymentRecoverySnapshot( window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY), { resumeToken: routeResumeToken }, diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts index 94ae6ef8..91741963 100644 --- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts @@ -7,7 +7,7 @@ const routeState = vi.hoisted(() => ({ const routerPush = vi.hoisted(() => vi.fn()) const pollOrderStatus = vi.hoisted(() => vi.fn()) -const verifyOrder = vi.hoisted(() => vi.fn()) +const verifyOrderPublic = vi.hoisted(() => vi.fn()) const resolveOrderPublicByResumeToken = vi.hoisted(() => vi.fn()) vi.mock('vue-router', async () => { @@ -37,7 +37,7 @@ vi.mock('@/stores/payment', () => ({ vi.mock('@/api/payment', () => ({ paymentAPI: { - verifyOrder, + verifyOrderPublic, resolveOrderPublicByResumeToken, }, })) @@ -67,6 +67,7 @@ const recoverySnapshotFactory = (resumeToken: string) => ({ expiresAt: '2099-01-01T00:10:00.000Z', paymentType: 'alipay', payUrl: 'https://pay.example.com/session/42', + outTradeNo: 'sub2_20260420abcd1234', clientSecret: '', payAmount: 88, orderType: 'balance', @@ -80,7 +81,7 @@ describe('PaymentResultView', () => { routeState.query = {} routerPush.mockReset() pollOrderStatus.mockReset() - verifyOrder.mockReset() + verifyOrderPublic.mockReset() resolveOrderPublicByResumeToken.mockReset() window.localStorage.clear() }) @@ -102,6 +103,7 @@ describe('PaymentResultView', () => { expiresAt: '2099-01-01T00:10:00.000Z', paymentType: 'alipay', payUrl: 'https://pay.example.com/session/42', + outTradeNo: 'sub2_20260420abcd1234', clientSecret: '', payAmount: 88, orderType: 'balance', @@ -109,7 +111,9 @@ describe('PaymentResultView', () => { resumeToken: 'resume-42', createdAt: Date.UTC(2099, 0, 1, 0, 0, 0), })) - pollOrderStatus.mockResolvedValue(orderFactory('PENDING')) + resolveOrderPublicByResumeToken.mockResolvedValue({ + data: orderFactory('PENDING'), + }) const wrapper = mount(PaymentResultView, { global: { @@ -121,7 +125,8 @@ describe('PaymentResultView', () => { await flushPromises() - expect(pollOrderStatus).toHaveBeenCalledWith(42) + expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-42') + expect(pollOrderStatus).not.toHaveBeenCalled() expect(wrapper.text()).toContain('payment.result.processing') expect(wrapper.text()).not.toContain('payment.result.success') expect(wrapper.text()).not.toContain('payment.result.failed') @@ -140,6 +145,7 @@ describe('PaymentResultView', () => { expiresAt: '2099-01-01T00:10:00.000Z', paymentType: 'alipay', payUrl: 'https://pay.example.com/session/42', + outTradeNo: 'sub2_20260420abcd1234', clientSecret: '', payAmount: 88, orderType: 'balance', @@ -147,12 +153,6 @@ describe('PaymentResultView', () => { resumeToken: 'resume-authoritative', createdAt: Date.UTC(2099, 0, 1, 0, 0, 0), })) - pollOrderStatus.mockResolvedValue({ - ...orderFactory('PENDING'), - amount: 88, - pay_amount: 88, - fee_rate: 0, - }) resolveOrderPublicByResumeToken.mockResolvedValue({ data: { ...orderFactory('PAID'), @@ -172,7 +172,7 @@ describe('PaymentResultView', () => { await flushPromises() - expect(pollOrderStatus).toHaveBeenCalledWith(42) + expect(pollOrderStatus).not.toHaveBeenCalled() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-authoritative') expect(wrapper.text()).toContain('payment.result.success') expect(wrapper.text()).toContain('103.00') @@ -227,7 +227,6 @@ describe('PaymentResultView', () => { trade_status: 'TRADE_SUCCESS', } resolveOrderPublicByResumeToken.mockRejectedValueOnce(new Error('resume failed')) - mount(PaymentResultView, { global: { stubs: { @@ -239,16 +238,19 @@ describe('PaymentResultView', () => { await flushPromises() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-fail') - expect(verifyOrder).not.toHaveBeenCalled() + expect(verifyOrderPublic).not.toHaveBeenCalled() }) - it('does not use anonymous out_trade_no verification when no signed resume context is available', async () => { + it('uses public out_trade_no verification when no signed resume context is available', async () => { routeState.query = { out_trade_no: 'legacy-123', trade_status: 'TRADE_SUCCESS', } + verifyOrderPublic.mockResolvedValue({ + data: orderFactory('PAID'), + }) - mount(PaymentResultView, { + const wrapper = mount(PaymentResultView, { global: { stubs: { OrderStatusBadge: true, @@ -258,7 +260,9 @@ describe('PaymentResultView', () => { await flushPromises() - expect(verifyOrder).not.toHaveBeenCalled() + expect(verifyOrderPublic).toHaveBeenCalledWith('legacy-123') + expect(pollOrderStatus).not.toHaveBeenCalled() + 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 () => { @@ -276,7 +280,7 @@ describe('PaymentResultView', () => { await flushPromises() - expect(verifyOrder).not.toHaveBeenCalled() + expect(verifyOrderPublic).not.toHaveBeenCalled() }) it('resolves order by resume token when local recovery snapshot is missing', async () => { diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts index f60ea962..66648da4 100644 --- a/frontend/src/views/user/__tests__/PaymentView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts @@ -117,6 +117,7 @@ function jsapiOrderFixture(resumeToken: string) { fee_rate: 0, expires_at: '2099-01-01T00:10:00.000Z', payment_type: 'wxpay', + out_trade_no: 'sub2_jsapi_123', result_type: 'jsapi_ready' as const, resume_token: resumeToken, jsapi: { @@ -175,6 +176,7 @@ describe('PaymentView WeChat JSAPI flow', () => { path: '/payment/result', query: { order_id: '123', + out_trade_no: 'sub2_jsapi_123', resume_token: 'resume-token-123', }, }) @@ -202,4 +204,39 @@ describe('PaymentView WeChat JSAPI flow', () => { expect(routerPush).not.toHaveBeenCalled() expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() }) + + it('clears a stale recovery snapshot before handling wechat resume callback params', async () => { + createOrder.mockRejectedValueOnce(new Error('resume failed')) + window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({ + orderId: 999, + amount: 66, + qrCode: 'stale-qr', + expiresAt: '2099-01-01T00:10:00.000Z', + paymentType: 'alipay', + payUrl: 'https://pay.example.com/stale', + outTradeNo: 'stale-out-trade-no', + clientSecret: '', + payAmount: 66, + orderType: 'balance', + paymentMode: 'popup', + resumeToken: '', + createdAt: Date.UTC(2099, 0, 1, 0, 0, 0), + })) + + shallowMount(PaymentView, { + global: { + stubs: { + Teleport: true, + Transition: false, + }, + }, + }) + await flushPromises() + await flushPromises() + + expect(createOrder).toHaveBeenCalledWith(expect.objectContaining({ + wechat_resume_token: 'resume-token-123', + })) + expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() + }) }) diff --git a/frontend/src/views/user/paymentWechatResume.ts b/frontend/src/views/user/paymentWechatResume.ts index f53c8457..64f254da 100644 --- a/frontend/src/views/user/paymentWechatResume.ts +++ b/frontend/src/views/user/paymentWechatResume.ts @@ -19,12 +19,20 @@ function readQueryString(query: LocationQuery, key: string): string { return typeof value === 'string' ? value : '' } +export function hasWechatResumeQuery(query: LocationQuery): boolean { + if (readQueryString(query, 'wechat_resume') === '1') { + return true + } + return readQueryString(query, 'wechat_resume_token') !== '' + || readQueryString(query, 'openid') !== '' +} + export function parseWechatResumeRoute( query: LocationQuery, plans: SubscriptionPlan[], fallbackBalanceAmount: number, ): ParsedWechatResumeRoute | null { - if (readQueryString(query, 'wechat_resume') !== '1') { + if (!hasWechatResumeQuery(query)) { return null }