Add a full payment and subscription system supporting EasyPay (Alipay/WeChat), Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
306 lines
8.5 KiB
Go
306 lines
8.5 KiB
Go
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
|
|
}
|