| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385 |
- package service
- import (
- "bytes"
- "context"
- "encoding/hex"
- "fmt"
- "log/slog"
- "math/rand/v2"
- "os"
- "strings"
- "sync"
- "time"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- "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
- OrderStatusRefundPending = payment.OrderStatusRefundPending
- 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_"
- )
- const paymentResumeSigningKeyEnv = "PAYMENT_RESUME_SIGNING_KEY"
- // --- 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
- OpenID string
- ClientIP string
- IsMobile bool
- IsWeChatBrowser bool
- SrcHost string
- SrcURL string
- ReturnURL string
- PaymentSource string
- OrderType string
- PlanID int64
- Locale string
- }
- 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"`
- ResultType payment.CreatePaymentResultType `json:"result_type,omitempty"`
- PaymentType string `json:"payment_type"`
- OutTradeNo string `json:"out_trade_no,omitempty"`
- PayURL string `json:"pay_url,omitempty"`
- QRCode string `json:"qr_code,omitempty"`
- ClientSecret string `json:"client_secret,omitempty"`
- IntentID string `json:"intent_id,omitempty"`
- Currency string `json:"currency,omitempty"`
- CountryCode string `json:"country_code,omitempty"`
- PaymentEnv string `json:"payment_env,omitempty"`
- OAuth *payment.WechatOAuthInfo `json:"oauth,omitempty"`
- JSAPI *payment.WechatJSAPIPayload `json:"jsapi,omitempty"`
- JSAPIPayload *payment.WechatJSAPIPayload `json:"jsapi_payload,omitempty"`
- ExpiresAt time.Time `json:"expires_at"`
- PaymentMode string `json:"payment_mode,omitempty"`
- ResumeToken string `json:"resume_token,omitempty"`
- AlipayMobilePrecreateDeepLink bool `json:"alipay_mobile_precreate_deep_link,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 CurrencyAmounts `json:"today_amount"`
- TotalAmount CurrencyAmounts `json:"total_amount"`
- TodayCount int `json:"today_count"`
- TotalCount int `json:"total_count"`
- AvgAmount CurrencyAmounts `json:"avg_amount"`
- PendingOrders int `json:"pending_orders"`
- DailySeries []DailyStats `json:"daily_series"`
- PaymentMethods []PaymentMethodStat `json:"payment_methods"`
- TopUsers TopUsersByCurrency `json:"top_users"`
- }
- // CurrencyAmounts holds payment amounts keyed by their ISO 4217 currency.
- // Amounts in different currencies must never be added together.
- type CurrencyAmounts map[string]float64
- type DailyStats struct {
- Date string `json:"date"`
- Amount CurrencyAmounts `json:"amount"`
- Count int `json:"count"`
- }
- type PaymentMethodStat struct {
- Type string `json:"type"`
- Amount CurrencyAmounts `json:"amount"`
- Count int `json:"count"`
- }
- type TopUserStat struct {
- UserID int64 `json:"user_id"`
- Email string `json:"email"`
- Amount float64 `json:"amount"`
- }
- // TopUsersByCurrency contains an independent ranked user list for each
- // currency. A single cross-currency leaderboard would be misleading.
- type TopUsersByCurrency map[string][]TopUserStat
- // --- 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
- resumeService *PaymentResumeService
- affiliateService *AffiliateService
- notificationEmailService *NotificationEmailService
- }
- func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, loadBalancer payment.LoadBalancer, redeemService *RedeemService, subscriptionSvc *SubscriptionService, configService *PaymentConfigService, userRepo UserRepository, groupRepo GroupRepository, affiliateService *AffiliateService) *PaymentService {
- svc := &PaymentService{entClient: entClient, registry: registry, loadBalancer: newVisibleMethodLoadBalancer(loadBalancer, configService), redeemService: redeemService, subscriptionSvc: subscriptionSvc, configService: configService, userRepo: userRepo, groupRepo: groupRepo, affiliateService: affiliateService}
- svc.resumeService = psNewPaymentResumeService(configService)
- return svc
- }
- func (s *PaymentService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) {
- s.notificationEmailService = notificationEmailService
- }
- // --- 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)
- }
- }
- // --- Helpers ---
- func psIsRefundStatus(s string) bool {
- switch s {
- case OrderStatusRefundRequested, OrderStatusRefunding, OrderStatusRefundPending, 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 (s *PaymentService) paymentResume() *PaymentResumeService {
- if s.resumeService != nil {
- return s.resumeService
- }
- return psNewPaymentResumeService(s.configService)
- }
- func NewLegacyAwarePaymentResumeService(legacyKey []byte) *PaymentResumeService {
- return newLegacyAwarePaymentResumeService(legacyKey)
- }
- func psNewPaymentResumeService(configService *PaymentConfigService) *PaymentResumeService {
- return newLegacyAwarePaymentResumeService(psResumeLegacyVerificationKey(configService))
- }
- func newLegacyAwarePaymentResumeService(legacyKey []byte) *PaymentResumeService {
- signingKey, verifyFallbacks := resolvePaymentResumeSigningKeys(legacyKey)
- return NewPaymentResumeService(signingKey, verifyFallbacks...)
- }
- func psResumeLegacyVerificationKey(configService *PaymentConfigService) []byte {
- if configService == nil {
- return nil
- }
- return configService.encryptionKey
- }
- func resolvePaymentResumeSigningKeys(legacyKey []byte) ([]byte, [][]byte) {
- signingKey := parsePaymentResumeSigningKey(os.Getenv(paymentResumeSigningKeyEnv))
- if len(signingKey) == 0 {
- if len(legacyKey) == 0 {
- return nil, nil
- }
- return legacyKey, nil
- }
- if len(legacyKey) == 0 || bytes.Equal(legacyKey, signingKey) {
- return signingKey, nil
- }
- return signingKey, [][]byte{legacyKey}
- }
- func parsePaymentResumeSigningKey(raw string) []byte {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return nil
- }
- if len(raw) >= 64 && len(raw)%2 == 0 {
- if decoded, err := hex.DecodeString(raw); err == nil && len(decoded) > 0 {
- return decoded
- }
- }
- return []byte(raw)
- }
- func psSliceContains(sl []string, s string) bool {
- for _, v := range sl {
- if v == s {
- return true
- }
- }
- return false
- }
- // Subscription validity period unit constants.
- const (
- validityUnitWeek = "week"
- validityUnitWeeks = "weeks"
- validityUnitMonth = "month"
- validityUnitMonths = "months"
- )
- func psComputeValidityDays(days int, unit string) int {
- switch unit {
- case validityUnitWeek, validityUnitWeeks:
- return days * 7
- case validityUnitMonth, validityUnitMonths:
- return days * 30
- default:
- return days
- }
- }
- 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
- }
|