feat: resolve payment results by resume token

This commit is contained in:
IanShaw027
2026-04-20 20:53:46 +08:00
parent c0b24aefba
commit 9bebf1c1a6
7 changed files with 225 additions and 1 deletions

View File

@@ -357,6 +357,10 @@ type VerifyOrderRequest struct {
OutTradeNo string `json:"out_trade_no" binding:"required"` 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 // VerifyOrder actively queries the upstream payment provider to check
// if payment was made, and processes it if so. // if payment was made, and processes it if so.
// POST /api/v1/payment/orders/verify // 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. // 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. // 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) { func requireAuth(c *gin.Context) (middleware2.AuthSubject, bool) {

View File

@@ -49,6 +49,7 @@ func RegisterPaymentRoutes(
public := v1.Group("/payment/public") public := v1.Group("/payment/public")
{ {
public.POST("/orders/verify", paymentHandler.VerifyOrderPublic) public.POST("/orders/verify", paymentHandler.VerifyOrderPublic)
public.POST("/orders/resolve", paymentHandler.ResolveOrderPublicByResumeToken)
} }
// --- Webhook endpoints (no auth) --- // --- Webhook endpoints (no auth) ---

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -72,6 +72,11 @@ export const paymentAPI = {
return apiClient.post<PaymentOrder>('/payment/public/orders/verify', { out_trade_no: outTradeNo }) return apiClient.post<PaymentOrder>('/payment/public/orders/verify', { out_trade_no: outTradeNo })
}, },
/** Resolve an order from a signed resume token without auth */
resolveOrderPublicByResumeToken(resumeToken: string) {
return apiClient.post<PaymentOrder>('/payment/public/orders/resolve', { resume_token: resumeToken })
},
/** Request a refund for a completed order */ /** Request a refund for a completed order */
requestRefund(id: number, data: { reason: string }) { requestRefund(id: number, data: { reason: string }) {
return apiClient.post(`/payment/orders/${id}/refund-request`, data) return apiClient.post(`/payment/orders/${id}/refund-request`, data)

View File

@@ -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 { try {
order.value = await paymentStore.pollOrderStatus(orderId) order.value = await paymentStore.pollOrderStatus(orderId)
} catch (_err: unknown) { } catch (_err: unknown) {

View File

@@ -9,6 +9,7 @@ const routerPush = vi.hoisted(() => vi.fn())
const pollOrderStatus = vi.hoisted(() => vi.fn()) const pollOrderStatus = vi.hoisted(() => vi.fn())
const verifyOrderPublic = vi.hoisted(() => vi.fn()) const verifyOrderPublic = vi.hoisted(() => vi.fn())
const verifyOrder = vi.hoisted(() => vi.fn()) const verifyOrder = vi.hoisted(() => vi.fn())
const resolveOrderPublicByResumeToken = vi.hoisted(() => vi.fn())
vi.mock('vue-router', async () => { vi.mock('vue-router', async () => {
const actual = await vi.importActual<typeof import('vue-router')>('vue-router') const actual = await vi.importActual<typeof import('vue-router')>('vue-router')
@@ -39,6 +40,7 @@ vi.mock('@/api/payment', () => ({
paymentAPI: { paymentAPI: {
verifyOrderPublic, verifyOrderPublic,
verifyOrder, verifyOrder,
resolveOrderPublicByResumeToken,
}, },
})) }))
@@ -67,6 +69,7 @@ describe('PaymentResultView', () => {
pollOrderStatus.mockReset() pollOrderStatus.mockReset()
verifyOrderPublic.mockReset() verifyOrderPublic.mockReset()
verifyOrder.mockReset() verifyOrder.mockReset()
resolveOrderPublicByResumeToken.mockReset()
window.localStorage.clear() window.localStorage.clear()
}) })
@@ -129,4 +132,27 @@ describe('PaymentResultView', () => {
expect(verifyOrderPublic).toHaveBeenCalledWith('legacy-123') expect(verifyOrderPublic).toHaveBeenCalledWith('legacy-123')
expect(wrapper.text()).toContain('payment.result.success') 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()
})
}) })