feat: add payment order provider snapshots

This commit is contained in:
IanShaw027
2026-04-21 12:41:27 +08:00
parent 440536a93d
commit 561405ab00
14 changed files with 440 additions and 23 deletions

View File

@@ -3,6 +3,7 @@ package admin
import (
"strconv"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -66,7 +67,7 @@ func (h *PaymentHandler) ListOrders(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
response.Paginated(c, orders, int64(total), page, pageSize)
response.Paginated(c, sanitizeAdminPaymentOrdersForResponse(orders), int64(total), page, pageSize)
}
// GetOrderDetail returns detailed information about a single order.
@@ -82,7 +83,7 @@ func (h *PaymentHandler) GetOrderDetail(c *gin.Context) {
return
}
auditLogs, _ := h.paymentService.GetOrderAuditLogs(c.Request.Context(), orderID)
response.Success(c, gin.H{"order": order, "auditLogs": auditLogs})
response.Success(c, gin.H{"order": sanitizeAdminPaymentOrderForResponse(order), "auditLogs": auditLogs})
}
// CancelOrder cancels a pending order (admin).
@@ -114,6 +115,26 @@ func (h *PaymentHandler) RetryFulfillment(c *gin.Context) {
response.Success(c, gin.H{"message": "fulfillment retried"})
}
func sanitizeAdminPaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*dbent.PaymentOrder {
if len(orders) == 0 {
return orders
}
out := make([]*dbent.PaymentOrder, 0, len(orders))
for _, order := range orders {
out = append(out, sanitizeAdminPaymentOrderForResponse(order))
}
return out
}
func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *dbent.PaymentOrder {
if order == nil {
return nil
}
cloned := *order
cloned.ProviderSnapshot = nil
return &cloned
}
// AdminProcessRefundRequest is the request body for admin refund processing.
type AdminProcessRefundRequest struct {
Amount float64 `json:"amount"`

View File

@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
@@ -327,7 +328,7 @@ func (h *PaymentHandler) GetMyOrders(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
response.Paginated(c, orders, int64(total), page, pageSize)
response.Paginated(c, sanitizePaymentOrdersForResponse(orders), int64(total), page, pageSize)
}
// GetOrder returns a single order for the authenticated user.
@@ -349,7 +350,7 @@ func (h *PaymentHandler) GetOrder(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
response.Success(c, order)
response.Success(c, sanitizePaymentOrderForResponse(order))
}
// CancelOrder cancels a pending order for the authenticated user.
@@ -445,7 +446,7 @@ func (h *PaymentHandler) VerifyOrder(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
response.Success(c, order)
response.Success(c, sanitizePaymentOrderForResponse(order))
}
// PublicOrderResult is the limited order info returned by the public verify endpoint.
@@ -523,6 +524,26 @@ func isMobile(c *gin.Context) bool {
return false
}
func sanitizePaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*dbent.PaymentOrder {
if len(orders) == 0 {
return orders
}
out := make([]*dbent.PaymentOrder, 0, len(orders))
for _, order := range orders {
out = append(out, sanitizePaymentOrderForResponse(order))
}
return out
}
func sanitizePaymentOrderForResponse(order *dbent.PaymentOrder) *dbent.PaymentOrder {
if order == nil {
return nil
}
cloned := *order
cloned.ProviderSnapshot = nil
return &cloned
}
func isWeChatBrowser(c *gin.Context) bool {
return strings.Contains(strings.ToLower(c.GetHeader("User-Agent")), "micromessenger")
}

View File

@@ -73,7 +73,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
if oauthResp != nil {
return oauthResp, nil
}
order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount)
order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount, sel)
if err != nil {
return nil, err
}
@@ -122,7 +122,7 @@ func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRe
return plan, nil
}
func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, orderAmount, limitAmount, feeRate, payAmount float64) (*dbent.PaymentOrder, error) {
func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, orderAmount, limitAmount, feeRate, payAmount float64, sel *payment.InstanceSelection) (*dbent.PaymentOrder, error) {
tx, err := s.entClient.Tx(ctx)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
@@ -139,6 +139,13 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
tm = defaultOrderTimeoutMin
}
exp := time.Now().Add(time.Duration(tm) * time.Minute)
providerSnapshot := buildPaymentOrderProviderSnapshot(sel)
selectedInstanceID := ""
selectedProviderKey := ""
if sel != nil {
selectedInstanceID = strings.TrimSpace(sel.InstanceID)
selectedProviderKey = strings.TrimSpace(sel.ProviderKey)
}
b := tx.PaymentOrder.Create().
SetUserID(req.UserID).
SetUserEmail(user.Email).
@@ -159,6 +166,15 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
if req.SrcURL != "" {
b.SetSrcURL(req.SrcURL)
}
if selectedInstanceID != "" {
b.SetProviderInstanceID(selectedInstanceID)
}
if selectedProviderKey != "" {
b.SetProviderKey(selectedProviderKey)
}
if providerSnapshot != nil {
b.SetProviderSnapshot(providerSnapshot)
}
if plan != nil {
b.SetPlanID(plan.ID).SetSubscriptionGroupID(plan.GroupID).SetSubscriptionDays(psComputeValidityDays(plan.ValidityDays, plan.ValidityUnit))
}
@@ -192,6 +208,35 @@ func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, us
return nil
}
func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection) map[string]any {
if sel == nil {
return nil
}
snapshot := map[string]any{}
snapshot["schema_version"] = 1
instanceID := strings.TrimSpace(sel.InstanceID)
if instanceID != "" {
snapshot["provider_instance_id"] = instanceID
}
providerKey := strings.TrimSpace(sel.ProviderKey)
if providerKey != "" {
snapshot["provider_key"] = providerKey
}
paymentMode := strings.TrimSpace(sel.PaymentMode)
if paymentMode != "" {
snapshot["payment_mode"] = paymentMode
}
if len(snapshot) == 1 {
return nil
}
return snapshot
}
func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, userID int64, amount, limit float64) error {
if limit <= 0 {
return nil

View File

@@ -0,0 +1,116 @@
//go:build unit
package service
import (
"context"
"strconv"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/stretchr/testify/require"
)
func TestBuildPaymentOrderProviderSnapshot_ExcludesSensitiveConfig(t *testing.T) {
t.Parallel()
sel := &payment.InstanceSelection{
InstanceID: "12",
ProviderKey: payment.TypeWxpay,
SupportedTypes: "wxpay,wxpay_direct",
PaymentMode: "popup",
Config: map[string]string{
"privateKey": "secret",
"apiV3Key": "secret-v3",
"appId": "wx-app-id",
},
}
snapshot := buildPaymentOrderProviderSnapshot(sel)
require.Equal(t, map[string]any{
"schema_version": 1,
"provider_instance_id": "12",
"provider_key": payment.TypeWxpay,
"payment_mode": "popup",
}, snapshot)
require.NotContains(t, snapshot, "config")
require.NotContains(t, snapshot, "privateKey")
require.NotContains(t, snapshot, "apiV3Key")
require.NotContains(t, snapshot, "supported_types")
require.NotContains(t, snapshot, "instance_name")
}
func TestCreateOrderInTx_WritesProviderSnapshot(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
user, err := client.User.Create().
SetEmail("snapshot@example.com").
SetPasswordHash("hash").
SetUsername("snapshot-user").
Save(ctx)
require.NoError(t, err)
instance, err := client.PaymentProviderInstance.Create().
SetProviderKey(payment.TypeAlipay).
SetName("Primary Alipay").
SetConfig(`{"secretKey":"do-not-copy"}`).
SetSupportedTypes("alipay,alipay_direct").
SetPaymentMode("redirect").
SetEnabled(true).
Save(ctx)
require.NoError(t, err)
svc := &PaymentService{entClient: client}
order, err := svc.createOrderInTx(
ctx,
CreateOrderRequest{
UserID: user.ID,
PaymentType: payment.TypeAlipay,
OrderType: payment.OrderTypeBalance,
ClientIP: "127.0.0.1",
SrcHost: "app.example.com",
},
&User{
ID: user.ID,
Email: user.Email,
Username: user.Username,
},
nil,
&PaymentConfig{
MaxPendingOrders: 3,
OrderTimeoutMin: 30,
},
88,
88,
0,
88,
&payment.InstanceSelection{
InstanceID: strconv.FormatInt(instance.ID, 10),
ProviderKey: payment.TypeAlipay,
SupportedTypes: "alipay,alipay_direct",
PaymentMode: "redirect",
Config: map[string]string{
"secretKey": "do-not-copy",
},
},
)
require.NoError(t, err)
require.Equal(t, strconv.FormatInt(instance.ID, 10), valueOrEmpty(order.ProviderInstanceID))
require.Equal(t, payment.TypeAlipay, valueOrEmpty(order.ProviderKey))
require.Equal(t, float64(1), order.ProviderSnapshot["schema_version"])
require.Equal(t, strconv.FormatInt(instance.ID, 10), order.ProviderSnapshot["provider_instance_id"])
require.Equal(t, payment.TypeAlipay, order.ProviderSnapshot["provider_key"])
require.Equal(t, "redirect", order.ProviderSnapshot["payment_mode"])
require.NotContains(t, order.ProviderSnapshot, "config")
require.NotContains(t, order.ProviderSnapshot, "secretKey")
require.NotContains(t, order.ProviderSnapshot, "supported_types")
require.NotContains(t, order.ProviderSnapshot, "instance_name")
}
func valueOrEmpty(v *string) string {
if v == nil {
return ""
}
return *v
}