diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index d5c3d7c8..6440cdfd 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -357,6 +357,10 @@ type VerifyOrderRequest struct { OutTradeNo string `json:"out_trade_no" binding:"required"` } +type ResolveOrderByResumeTokenRequest struct { + ResumeToken string `json:"resume_token" binding:"required"` +} + // VerifyOrder actively queries the upstream payment provider to check // if payment was made, and processes it if so. // POST /api/v1/payment/orders/verify @@ -417,6 +421,31 @@ func (h *PaymentHandler) VerifyOrderPublic(c *gin.Context) { }) } +// ResolveOrderPublicByResumeToken resolves a payment order from a signed resume token. +// POST /api/v1/payment/public/orders/resolve +func (h *PaymentHandler) ResolveOrderPublicByResumeToken(c *gin.Context) { + var req ResolveOrderByResumeTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + order, err := h.paymentService.GetPublicOrderByResumeToken(c.Request.Context(), req.ResumeToken) + if err != nil { + 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, + }) +} + // requireAuth extracts the authenticated subject from the context. // Returns the subject and true on success; on failure it writes an Unauthorized response and returns false. func requireAuth(c *gin.Context) (middleware2.AuthSubject, bool) { diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index 23bd58ad..dff14a70 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -49,6 +49,7 @@ func RegisterPaymentRoutes( public := v1.Group("/payment/public") { public.POST("/orders/verify", paymentHandler.VerifyOrderPublic) + public.POST("/orders/resolve", paymentHandler.ResolveOrderPublicByResumeToken) } // --- Webhook endpoints (no auth) --- diff --git a/backend/internal/service/payment_resume_lookup.go b/backend/internal/service/payment_resume_lookup.go new file mode 100644 index 00000000..493ca325 --- /dev/null +++ b/backend/internal/service/payment_resume_lookup.go @@ -0,0 +1,35 @@ +package service + +import ( + "context" + "fmt" + "strings" + + dbent "github.com/Wei-Shaw/sub2api/ent" +) + +func (s *PaymentService) GetPublicOrderByResumeToken(ctx context.Context, token string) (*dbent.PaymentOrder, error) { + claims, err := s.paymentResume().ParseToken(strings.TrimSpace(token)) + if err != nil { + return nil, err + } + + order, err := s.entClient.PaymentOrder.Get(ctx, claims.OrderID) + if err != nil { + return nil, fmt.Errorf("get order by resume token: %w", err) + } + if claims.UserID > 0 && order.UserID != claims.UserID { + return nil, fmt.Errorf("resume token user mismatch") + } + if claims.ProviderInstanceID != "" && strings.TrimSpace(psStringValue(order.ProviderInstanceID)) != claims.ProviderInstanceID { + return nil, fmt.Errorf("resume token provider instance mismatch") + } + if claims.ProviderKey != "" && strings.TrimSpace(psStringValue(order.ProviderKey)) != claims.ProviderKey { + return nil, fmt.Errorf("resume token provider key mismatch") + } + if claims.PaymentType != "" && strings.TrimSpace(order.PaymentType) != claims.PaymentType { + return nil, fmt.Errorf("resume token payment type mismatch") + } + + return order, nil +} diff --git a/backend/internal/service/payment_resume_lookup_test.go b/backend/internal/service/payment_resume_lookup_test.go new file mode 100644 index 00000000..d411398e --- /dev/null +++ b/backend/internal/service/payment_resume_lookup_test.go @@ -0,0 +1,118 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/stretchr/testify/require" +) + +func TestGetPublicOrderByResumeTokenReturnsMatchingOrder(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + user, err := client.User.Create(). + SetEmail("resume@example.com"). + SetPasswordHash("hash"). + SetUsername("resume-user"). + Save(ctx) + require.NoError(t, err) + + instanceID := "12" + providerKey := payment.TypeEasyPay + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(88). + SetPayAmount(88). + SetFeeRate(0). + SetRechargeCode("RESUME-ORDER"). + SetOutTradeNo("sub2_resume_lookup"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo("trade-1"). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + SetProviderInstanceID(instanceID). + SetProviderKey(providerKey). + Save(ctx) + require.NoError(t, err) + + resumeSvc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef")) + token, err := resumeSvc.CreateToken(ResumeTokenClaims{ + OrderID: order.ID, + UserID: user.ID, + ProviderInstanceID: instanceID, + ProviderKey: providerKey, + PaymentType: payment.TypeAlipay, + CanonicalReturnURL: "https://app.example.com/payment/result", + }) + require.NoError(t, err) + + svc := &PaymentService{ + entClient: client, + resumeService: resumeSvc, + } + + got, err := svc.GetPublicOrderByResumeToken(ctx, token) + require.NoError(t, err) + require.Equal(t, order.ID, got.ID) +} + +func TestGetPublicOrderByResumeTokenRejectsSnapshotMismatch(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + user, err := client.User.Create(). + SetEmail("resume-mismatch@example.com"). + SetPasswordHash("hash"). + SetUsername("resume-mismatch-user"). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(88). + SetPayAmount(88). + SetFeeRate(0). + SetRechargeCode("RESUME-MISMATCH"). + SetOutTradeNo("sub2_resume_lookup_mismatch"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo("trade-2"). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + SetProviderInstanceID("12"). + SetProviderKey(payment.TypeEasyPay). + Save(ctx) + require.NoError(t, err) + + resumeSvc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef")) + token, err := resumeSvc.CreateToken(ResumeTokenClaims{ + OrderID: order.ID, + UserID: user.ID, + ProviderInstanceID: "99", + ProviderKey: payment.TypeEasyPay, + PaymentType: payment.TypeAlipay, + CanonicalReturnURL: "https://app.example.com/payment/result", + }) + require.NoError(t, err) + + svc := &PaymentService{ + entClient: client, + resumeService: resumeSvc, + } + + _, err = svc.GetPublicOrderByResumeToken(ctx, token) + require.Error(t, err) + require.Contains(t, err.Error(), "resume token") +} diff --git a/frontend/src/api/payment.ts b/frontend/src/api/payment.ts index 5cedb107..91b16866 100644 --- a/frontend/src/api/payment.ts +++ b/frontend/src/api/payment.ts @@ -72,6 +72,11 @@ export const paymentAPI = { 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 }) + }, + /** Request a refund for a completed order */ requestRefund(id: number, data: { reason: string }) { return apiClient.post(`/payment/orders/${id}/refund-request`, data) diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index e1db3ce2..9687d1c7 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -150,7 +150,17 @@ onMounted(async () => { } } - if (orderId) { + if (!order.value && !orderId && resumeToken) { + try { + const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken) + order.value = result.data + orderId = result.data.id + } catch (_err: unknown) { + // Resume token recovery failed, continue to legacy fallback paths. + } + } + + if (!order.value && orderId) { try { order.value = await paymentStore.pollOrderStatus(orderId) } catch (_err: unknown) { diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts index b06217ab..d23a60d9 100644 --- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts @@ -9,6 +9,7 @@ const routerPush = vi.hoisted(() => vi.fn()) const pollOrderStatus = vi.hoisted(() => vi.fn()) const verifyOrderPublic = vi.hoisted(() => vi.fn()) const verifyOrder = vi.hoisted(() => vi.fn()) +const resolveOrderPublicByResumeToken = vi.hoisted(() => vi.fn()) vi.mock('vue-router', async () => { const actual = await vi.importActual('vue-router') @@ -39,6 +40,7 @@ vi.mock('@/api/payment', () => ({ paymentAPI: { verifyOrderPublic, verifyOrder, + resolveOrderPublicByResumeToken, }, })) @@ -67,6 +69,7 @@ describe('PaymentResultView', () => { pollOrderStatus.mockReset() verifyOrderPublic.mockReset() verifyOrder.mockReset() + resolveOrderPublicByResumeToken.mockReset() window.localStorage.clear() }) @@ -129,4 +132,27 @@ describe('PaymentResultView', () => { expect(verifyOrderPublic).toHaveBeenCalledWith('legacy-123') expect(wrapper.text()).toContain('payment.result.success') }) + + it('resolves order by resume token when local recovery snapshot is missing', async () => { + routeState.query = { + resume_token: 'resume-77', + } + resolveOrderPublicByResumeToken.mockResolvedValue({ + data: orderFactory('PAID'), + }) + + const wrapper = mount(PaymentResultView, { + global: { + stubs: { + OrderStatusBadge: true, + }, + }, + }) + + await flushPromises() + + expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-77') + expect(wrapper.text()).toContain('payment.result.success') + expect(verifyOrderPublic).not.toHaveBeenCalled() + }) })