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