feat(payment): add complete payment system with multi-provider support
Add a full payment and subscription system supporting EasyPay (Alipay/WeChat), Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
|
||||
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment/provider"
|
||||
)
|
||||
|
||||
// --- Order Status Constants ---
|
||||
|
||||
const (
|
||||
OrderStatusPending = payment.OrderStatusPending
|
||||
OrderStatusPaid = payment.OrderStatusPaid
|
||||
OrderStatusRecharging = payment.OrderStatusRecharging
|
||||
OrderStatusCompleted = payment.OrderStatusCompleted
|
||||
OrderStatusExpired = payment.OrderStatusExpired
|
||||
OrderStatusCancelled = payment.OrderStatusCancelled
|
||||
OrderStatusFailed = payment.OrderStatusFailed
|
||||
OrderStatusRefundRequested = payment.OrderStatusRefundRequested
|
||||
OrderStatusRefunding = payment.OrderStatusRefunding
|
||||
OrderStatusPartiallyRefunded = payment.OrderStatusPartiallyRefunded
|
||||
OrderStatusRefunded = payment.OrderStatusRefunded
|
||||
OrderStatusRefundFailed = payment.OrderStatusRefundFailed
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultMaxPendingOrders and defaultOrderTimeoutMin are defined in
|
||||
// payment_config_service.go alongside other payment configuration defaults.
|
||||
paymentGraceMinutes = 5
|
||||
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
topUsersLimit = 10
|
||||
amountToleranceCNY = 0.01
|
||||
|
||||
orderIDPrefix = "sub2_"
|
||||
)
|
||||
|
||||
// --- Types ---
|
||||
|
||||
// generateOutTradeNo creates a unique external order ID for payment providers.
|
||||
// Format: sub2_20250409aB3kX9mQ (prefix + date + 8-char random)
|
||||
func generateOutTradeNo() string {
|
||||
date := time.Now().Format("20060102")
|
||||
rnd := generateRandomString(8)
|
||||
return orderIDPrefix + date + rnd
|
||||
}
|
||||
|
||||
func generateRandomString(n int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = charset[rand.IntN(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
type CreateOrderRequest struct {
|
||||
UserID int64
|
||||
Amount float64
|
||||
PaymentType string
|
||||
ClientIP string
|
||||
IsMobile bool
|
||||
SrcHost string
|
||||
SrcURL string
|
||||
OrderType string
|
||||
PlanID int64
|
||||
}
|
||||
|
||||
type CreateOrderResponse struct {
|
||||
OrderID int64 `json:"order_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
PayAmount float64 `json:"pay_amount"`
|
||||
FeeRate float64 `json:"fee_rate"`
|
||||
Status string `json:"status"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
PayURL string `json:"pay_url,omitempty"`
|
||||
QRCode string `json:"qr_code,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
PaymentMode string `json:"payment_mode,omitempty"`
|
||||
}
|
||||
|
||||
type OrderListParams struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Status string
|
||||
OrderType string
|
||||
PaymentType string
|
||||
Keyword string
|
||||
}
|
||||
|
||||
type RefundPlan struct {
|
||||
OrderID int64
|
||||
Order *dbent.PaymentOrder
|
||||
RefundAmount float64
|
||||
GatewayAmount float64
|
||||
Reason string
|
||||
Force bool
|
||||
DeductBalance bool
|
||||
DeductionType string
|
||||
BalanceToDeduct float64
|
||||
SubDaysToDeduct int
|
||||
SubscriptionID int64
|
||||
}
|
||||
|
||||
type RefundResult struct {
|
||||
Success bool `json:"success"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
RequireForce bool `json:"require_force,omitempty"`
|
||||
BalanceDeducted float64 `json:"balance_deducted,omitempty"`
|
||||
SubDaysDeducted int `json:"subscription_days_deducted,omitempty"`
|
||||
}
|
||||
|
||||
type DashboardStats struct {
|
||||
TodayAmount float64 `json:"today_amount"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
TodayCount int `json:"today_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
AvgAmount float64 `json:"avg_amount"`
|
||||
PendingOrders int `json:"pending_orders"`
|
||||
|
||||
DailySeries []DailyStats `json:"daily_series"`
|
||||
PaymentMethods []PaymentMethodStat `json:"payment_methods"`
|
||||
TopUsers []TopUserStat `json:"top_users"`
|
||||
}
|
||||
|
||||
type DailyStats struct {
|
||||
Date string `json:"date"`
|
||||
Amount float64 `json:"amount"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type PaymentMethodStat struct {
|
||||
Type string `json:"type"`
|
||||
Amount float64 `json:"amount"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type TopUserStat struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
// --- Service ---
|
||||
|
||||
type PaymentService struct {
|
||||
providerMu sync.Mutex
|
||||
providersLoaded bool
|
||||
entClient *dbent.Client
|
||||
registry *payment.Registry
|
||||
loadBalancer payment.LoadBalancer
|
||||
redeemService *RedeemService
|
||||
subscriptionSvc *SubscriptionService
|
||||
configService *PaymentConfigService
|
||||
userRepo UserRepository
|
||||
groupRepo GroupRepository
|
||||
}
|
||||
|
||||
func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, loadBalancer payment.LoadBalancer, redeemService *RedeemService, subscriptionSvc *SubscriptionService, configService *PaymentConfigService, userRepo UserRepository, groupRepo GroupRepository) *PaymentService {
|
||||
return &PaymentService{entClient: entClient, registry: registry, loadBalancer: loadBalancer, redeemService: redeemService, subscriptionSvc: subscriptionSvc, configService: configService, userRepo: userRepo, groupRepo: groupRepo}
|
||||
}
|
||||
|
||||
// --- Provider Registry ---
|
||||
|
||||
// EnsureProviders lazily initializes the provider registry on first call.
|
||||
func (s *PaymentService) EnsureProviders(ctx context.Context) {
|
||||
s.providerMu.Lock()
|
||||
defer s.providerMu.Unlock()
|
||||
if !s.providersLoaded {
|
||||
s.loadProviders(ctx)
|
||||
s.providersLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshProviders clears and re-registers all providers from the database.
|
||||
func (s *PaymentService) RefreshProviders(ctx context.Context) {
|
||||
s.providerMu.Lock()
|
||||
defer s.providerMu.Unlock()
|
||||
s.registry.Clear()
|
||||
s.loadProviders(ctx)
|
||||
s.providersLoaded = true
|
||||
}
|
||||
|
||||
func (s *PaymentService) loadProviders(ctx context.Context) {
|
||||
instances, err := s.entClient.PaymentProviderInstance.Query().
|
||||
Where(paymentproviderinstance.EnabledEQ(true)).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
slog.Error("[PaymentService] failed to query provider instances", "error", err)
|
||||
return
|
||||
}
|
||||
for _, inst := range instances {
|
||||
cfg, err := s.loadBalancer.GetInstanceConfig(ctx, int64(inst.ID))
|
||||
if err != nil {
|
||||
slog.Warn("[PaymentService] failed to decrypt config for instance", "instanceID", inst.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if inst.PaymentMode != "" {
|
||||
cfg["paymentMode"] = inst.PaymentMode
|
||||
}
|
||||
instID := fmt.Sprintf("%d", inst.ID)
|
||||
p, err := provider.CreateProvider(inst.ProviderKey, instID, cfg)
|
||||
if err != nil {
|
||||
slog.Warn("[PaymentService] failed to create provider for instance", "instanceID", inst.ID, "key", inst.ProviderKey, "error", err)
|
||||
continue
|
||||
}
|
||||
s.registry.Register(p)
|
||||
}
|
||||
}
|
||||
|
||||
// GetWebhookProvider returns the provider instance that should verify a webhook.
|
||||
// It extracts out_trade_no from the raw body, looks up the order to find the
|
||||
// original provider instance, and creates a provider with that instance's credentials.
|
||||
// Falls back to the registry provider when the order cannot be found.
|
||||
func (s *PaymentService) GetWebhookProvider(ctx context.Context, providerKey, outTradeNo string) (payment.Provider, error) {
|
||||
if outTradeNo != "" {
|
||||
order, err := s.entClient.PaymentOrder.Query().Where(paymentorder.OutTradeNo(outTradeNo)).Only(ctx)
|
||||
if err == nil {
|
||||
p, pErr := s.getOrderProvider(ctx, order)
|
||||
if pErr == nil {
|
||||
return p, nil
|
||||
}
|
||||
slog.Warn("[Webhook] order provider creation failed, falling back to registry", "outTradeNo", outTradeNo, "error", pErr)
|
||||
}
|
||||
}
|
||||
s.EnsureProviders(ctx)
|
||||
return s.registry.GetProviderByKey(providerKey)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func psIsRefundStatus(s string) bool {
|
||||
switch s {
|
||||
case OrderStatusRefundRequested, OrderStatusRefunding, OrderStatusPartiallyRefunded, OrderStatusRefunded, OrderStatusRefundFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func psErrMsg(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func psNilIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func psSliceContains(sl []string, s string) bool {
|
||||
for _, v := range sl {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func psComputeValidityDays(days int, unit string) int {
|
||||
switch unit {
|
||||
case "week":
|
||||
return days * 7
|
||||
case "month":
|
||||
return days * 30
|
||||
default:
|
||||
return days
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PaymentService) getFeeRate(_ string) float64 { return 0 }
|
||||
|
||||
func psStartOfDayUTC(t time.Time) time.Time {
|
||||
y, m, d := t.UTC().Date()
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func applyPagination(pageSize, page int) (size, pg int) {
|
||||
size = pageSize
|
||||
if size <= 0 {
|
||||
size = defaultPageSize
|
||||
}
|
||||
if size > maxPageSize {
|
||||
size = maxPageSize
|
||||
}
|
||||
pg = page
|
||||
if pg < 1 {
|
||||
pg = 1
|
||||
}
|
||||
return size, pg
|
||||
}
|
||||
Reference in New Issue
Block a user