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"`
|
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) {
|
||||||
|
|||||||
@@ -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) ---
|
||||||
|
|||||||
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 })
|
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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user