feat: resolve payment results by resume token
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
35
backend/internal/service/payment_resume_lookup.go
Normal file
35
backend/internal/service/payment_resume_lookup.go
Normal 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
|
||||
}
|
||||
118
backend/internal/service/payment_resume_lookup_test.go
Normal file
118
backend/internal/service/payment_resume_lookup_test.go
Normal 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")
|
||||
}
|
||||
@@ -72,6 +72,11 @@ export const paymentAPI = {
|
||||
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 */
|
||||
requestRefund(id: number, data: { reason: string }) {
|
||||
return apiClient.post(`/payment/orders/${id}/refund-request`, data)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<typeof import('vue-router')>('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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user