feat: add payment order provider snapshots
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
116
backend/internal/service/payment_order_provider_snapshot_test.go
Normal file
116
backend/internal/service/payment_order_provider_snapshot_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user