payment_service.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/hex"
  6. "fmt"
  7. "log/slog"
  8. "math/rand/v2"
  9. "os"
  10. "strings"
  11. "sync"
  12. "time"
  13. dbent "github.com/Wei-Shaw/sub2api/ent"
  14. "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
  15. "github.com/Wei-Shaw/sub2api/internal/payment"
  16. "github.com/Wei-Shaw/sub2api/internal/payment/provider"
  17. )
  18. // --- Order Status Constants ---
  19. const (
  20. OrderStatusPending = payment.OrderStatusPending
  21. OrderStatusPaid = payment.OrderStatusPaid
  22. OrderStatusRecharging = payment.OrderStatusRecharging
  23. OrderStatusCompleted = payment.OrderStatusCompleted
  24. OrderStatusExpired = payment.OrderStatusExpired
  25. OrderStatusCancelled = payment.OrderStatusCancelled
  26. OrderStatusFailed = payment.OrderStatusFailed
  27. OrderStatusRefundRequested = payment.OrderStatusRefundRequested
  28. OrderStatusRefunding = payment.OrderStatusRefunding
  29. OrderStatusRefundPending = payment.OrderStatusRefundPending
  30. OrderStatusPartiallyRefunded = payment.OrderStatusPartiallyRefunded
  31. OrderStatusRefunded = payment.OrderStatusRefunded
  32. OrderStatusRefundFailed = payment.OrderStatusRefundFailed
  33. )
  34. const (
  35. // defaultMaxPendingOrders and defaultOrderTimeoutMin are defined in
  36. // payment_config_service.go alongside other payment configuration defaults.
  37. paymentGraceMinutes = 5
  38. defaultPageSize = 20
  39. maxPageSize = 100
  40. topUsersLimit = 10
  41. amountToleranceCNY = 0.01
  42. orderIDPrefix = "sub2_"
  43. )
  44. const paymentResumeSigningKeyEnv = "PAYMENT_RESUME_SIGNING_KEY"
  45. // --- Types ---
  46. // generateOutTradeNo creates a unique external order ID for payment providers.
  47. // Format: sub2_20250409aB3kX9mQ (prefix + date + 8-char random)
  48. func generateOutTradeNo() string {
  49. date := time.Now().Format("20060102")
  50. rnd := generateRandomString(8)
  51. return orderIDPrefix + date + rnd
  52. }
  53. func generateRandomString(n int) string {
  54. const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
  55. b := make([]byte, n)
  56. for i := range b {
  57. b[i] = charset[rand.IntN(len(charset))]
  58. }
  59. return string(b)
  60. }
  61. type CreateOrderRequest struct {
  62. UserID int64
  63. Amount float64
  64. PaymentType string
  65. OpenID string
  66. ClientIP string
  67. IsMobile bool
  68. IsWeChatBrowser bool
  69. SrcHost string
  70. SrcURL string
  71. ReturnURL string
  72. PaymentSource string
  73. OrderType string
  74. PlanID int64
  75. Locale string
  76. }
  77. type CreateOrderResponse struct {
  78. OrderID int64 `json:"order_id"`
  79. Amount float64 `json:"amount"`
  80. PayAmount float64 `json:"pay_amount"`
  81. FeeRate float64 `json:"fee_rate"`
  82. Status string `json:"status"`
  83. ResultType payment.CreatePaymentResultType `json:"result_type,omitempty"`
  84. PaymentType string `json:"payment_type"`
  85. OutTradeNo string `json:"out_trade_no,omitempty"`
  86. PayURL string `json:"pay_url,omitempty"`
  87. QRCode string `json:"qr_code,omitempty"`
  88. ClientSecret string `json:"client_secret,omitempty"`
  89. IntentID string `json:"intent_id,omitempty"`
  90. Currency string `json:"currency,omitempty"`
  91. CountryCode string `json:"country_code,omitempty"`
  92. PaymentEnv string `json:"payment_env,omitempty"`
  93. OAuth *payment.WechatOAuthInfo `json:"oauth,omitempty"`
  94. JSAPI *payment.WechatJSAPIPayload `json:"jsapi,omitempty"`
  95. JSAPIPayload *payment.WechatJSAPIPayload `json:"jsapi_payload,omitempty"`
  96. ExpiresAt time.Time `json:"expires_at"`
  97. PaymentMode string `json:"payment_mode,omitempty"`
  98. ResumeToken string `json:"resume_token,omitempty"`
  99. AlipayMobilePrecreateDeepLink bool `json:"alipay_mobile_precreate_deep_link,omitempty"`
  100. }
  101. type OrderListParams struct {
  102. Page int
  103. PageSize int
  104. Status string
  105. OrderType string
  106. PaymentType string
  107. Keyword string
  108. }
  109. type RefundPlan struct {
  110. OrderID int64
  111. Order *dbent.PaymentOrder
  112. RefundAmount float64
  113. GatewayAmount float64
  114. Reason string
  115. Force bool
  116. DeductBalance bool
  117. DeductionType string
  118. BalanceToDeduct float64
  119. SubDaysToDeduct int
  120. SubscriptionID int64
  121. }
  122. type RefundResult struct {
  123. Success bool `json:"success"`
  124. Warning string `json:"warning,omitempty"`
  125. RequireForce bool `json:"require_force,omitempty"`
  126. BalanceDeducted float64 `json:"balance_deducted,omitempty"`
  127. SubDaysDeducted int `json:"subscription_days_deducted,omitempty"`
  128. }
  129. type DashboardStats struct {
  130. TodayAmount CurrencyAmounts `json:"today_amount"`
  131. TotalAmount CurrencyAmounts `json:"total_amount"`
  132. TodayCount int `json:"today_count"`
  133. TotalCount int `json:"total_count"`
  134. AvgAmount CurrencyAmounts `json:"avg_amount"`
  135. PendingOrders int `json:"pending_orders"`
  136. DailySeries []DailyStats `json:"daily_series"`
  137. PaymentMethods []PaymentMethodStat `json:"payment_methods"`
  138. TopUsers TopUsersByCurrency `json:"top_users"`
  139. }
  140. // CurrencyAmounts holds payment amounts keyed by their ISO 4217 currency.
  141. // Amounts in different currencies must never be added together.
  142. type CurrencyAmounts map[string]float64
  143. type DailyStats struct {
  144. Date string `json:"date"`
  145. Amount CurrencyAmounts `json:"amount"`
  146. Count int `json:"count"`
  147. }
  148. type PaymentMethodStat struct {
  149. Type string `json:"type"`
  150. Amount CurrencyAmounts `json:"amount"`
  151. Count int `json:"count"`
  152. }
  153. type TopUserStat struct {
  154. UserID int64 `json:"user_id"`
  155. Email string `json:"email"`
  156. Amount float64 `json:"amount"`
  157. }
  158. // TopUsersByCurrency contains an independent ranked user list for each
  159. // currency. A single cross-currency leaderboard would be misleading.
  160. type TopUsersByCurrency map[string][]TopUserStat
  161. // --- Service ---
  162. type PaymentService struct {
  163. providerMu sync.Mutex
  164. providersLoaded bool
  165. entClient *dbent.Client
  166. registry *payment.Registry
  167. loadBalancer payment.LoadBalancer
  168. redeemService *RedeemService
  169. subscriptionSvc *SubscriptionService
  170. configService *PaymentConfigService
  171. userRepo UserRepository
  172. groupRepo GroupRepository
  173. resumeService *PaymentResumeService
  174. affiliateService *AffiliateService
  175. notificationEmailService *NotificationEmailService
  176. }
  177. func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, loadBalancer payment.LoadBalancer, redeemService *RedeemService, subscriptionSvc *SubscriptionService, configService *PaymentConfigService, userRepo UserRepository, groupRepo GroupRepository, affiliateService *AffiliateService) *PaymentService {
  178. svc := &PaymentService{entClient: entClient, registry: registry, loadBalancer: newVisibleMethodLoadBalancer(loadBalancer, configService), redeemService: redeemService, subscriptionSvc: subscriptionSvc, configService: configService, userRepo: userRepo, groupRepo: groupRepo, affiliateService: affiliateService}
  179. svc.resumeService = psNewPaymentResumeService(configService)
  180. return svc
  181. }
  182. func (s *PaymentService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) {
  183. s.notificationEmailService = notificationEmailService
  184. }
  185. // --- Provider Registry ---
  186. // EnsureProviders lazily initializes the provider registry on first call.
  187. func (s *PaymentService) EnsureProviders(ctx context.Context) {
  188. s.providerMu.Lock()
  189. defer s.providerMu.Unlock()
  190. if !s.providersLoaded {
  191. s.loadProviders(ctx)
  192. s.providersLoaded = true
  193. }
  194. }
  195. // RefreshProviders clears and re-registers all providers from the database.
  196. func (s *PaymentService) RefreshProviders(ctx context.Context) {
  197. s.providerMu.Lock()
  198. defer s.providerMu.Unlock()
  199. s.registry.Clear()
  200. s.loadProviders(ctx)
  201. s.providersLoaded = true
  202. }
  203. func (s *PaymentService) loadProviders(ctx context.Context) {
  204. instances, err := s.entClient.PaymentProviderInstance.Query().
  205. Where(paymentproviderinstance.EnabledEQ(true)).
  206. All(ctx)
  207. if err != nil {
  208. slog.Error("[PaymentService] failed to query provider instances", "error", err)
  209. return
  210. }
  211. for _, inst := range instances {
  212. cfg, err := s.loadBalancer.GetInstanceConfig(ctx, int64(inst.ID))
  213. if err != nil {
  214. slog.Warn("[PaymentService] failed to decrypt config for instance", "instanceID", inst.ID, "error", err)
  215. continue
  216. }
  217. if inst.PaymentMode != "" {
  218. cfg["paymentMode"] = inst.PaymentMode
  219. }
  220. instID := fmt.Sprintf("%d", inst.ID)
  221. p, err := provider.CreateProvider(inst.ProviderKey, instID, cfg)
  222. if err != nil {
  223. slog.Warn("[PaymentService] failed to create provider for instance", "instanceID", inst.ID, "key", inst.ProviderKey, "error", err)
  224. continue
  225. }
  226. s.registry.Register(p)
  227. }
  228. }
  229. // --- Helpers ---
  230. func psIsRefundStatus(s string) bool {
  231. switch s {
  232. case OrderStatusRefundRequested, OrderStatusRefunding, OrderStatusRefundPending, OrderStatusPartiallyRefunded, OrderStatusRefunded, OrderStatusRefundFailed:
  233. return true
  234. }
  235. return false
  236. }
  237. func psErrMsg(err error) string {
  238. if err == nil {
  239. return ""
  240. }
  241. return err.Error()
  242. }
  243. func psNilIfEmpty(s string) *string {
  244. if s == "" {
  245. return nil
  246. }
  247. return &s
  248. }
  249. func (s *PaymentService) paymentResume() *PaymentResumeService {
  250. if s.resumeService != nil {
  251. return s.resumeService
  252. }
  253. return psNewPaymentResumeService(s.configService)
  254. }
  255. func NewLegacyAwarePaymentResumeService(legacyKey []byte) *PaymentResumeService {
  256. return newLegacyAwarePaymentResumeService(legacyKey)
  257. }
  258. func psNewPaymentResumeService(configService *PaymentConfigService) *PaymentResumeService {
  259. return newLegacyAwarePaymentResumeService(psResumeLegacyVerificationKey(configService))
  260. }
  261. func newLegacyAwarePaymentResumeService(legacyKey []byte) *PaymentResumeService {
  262. signingKey, verifyFallbacks := resolvePaymentResumeSigningKeys(legacyKey)
  263. return NewPaymentResumeService(signingKey, verifyFallbacks...)
  264. }
  265. func psResumeLegacyVerificationKey(configService *PaymentConfigService) []byte {
  266. if configService == nil {
  267. return nil
  268. }
  269. return configService.encryptionKey
  270. }
  271. func resolvePaymentResumeSigningKeys(legacyKey []byte) ([]byte, [][]byte) {
  272. signingKey := parsePaymentResumeSigningKey(os.Getenv(paymentResumeSigningKeyEnv))
  273. if len(signingKey) == 0 {
  274. if len(legacyKey) == 0 {
  275. return nil, nil
  276. }
  277. return legacyKey, nil
  278. }
  279. if len(legacyKey) == 0 || bytes.Equal(legacyKey, signingKey) {
  280. return signingKey, nil
  281. }
  282. return signingKey, [][]byte{legacyKey}
  283. }
  284. func parsePaymentResumeSigningKey(raw string) []byte {
  285. raw = strings.TrimSpace(raw)
  286. if raw == "" {
  287. return nil
  288. }
  289. if len(raw) >= 64 && len(raw)%2 == 0 {
  290. if decoded, err := hex.DecodeString(raw); err == nil && len(decoded) > 0 {
  291. return decoded
  292. }
  293. }
  294. return []byte(raw)
  295. }
  296. func psSliceContains(sl []string, s string) bool {
  297. for _, v := range sl {
  298. if v == s {
  299. return true
  300. }
  301. }
  302. return false
  303. }
  304. // Subscription validity period unit constants.
  305. const (
  306. validityUnitWeek = "week"
  307. validityUnitWeeks = "weeks"
  308. validityUnitMonth = "month"
  309. validityUnitMonths = "months"
  310. )
  311. func psComputeValidityDays(days int, unit string) int {
  312. switch unit {
  313. case validityUnitWeek, validityUnitWeeks:
  314. return days * 7
  315. case validityUnitMonth, validityUnitMonths:
  316. return days * 30
  317. default:
  318. return days
  319. }
  320. }
  321. func psStartOfDayUTC(t time.Time) time.Time {
  322. y, m, d := t.UTC().Date()
  323. return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
  324. }
  325. func applyPagination(pageSize, page int) (size, pg int) {
  326. size = pageSize
  327. if size <= 0 {
  328. size = defaultPageSize
  329. }
  330. if size > maxPageSize {
  331. size = maxPageSize
  332. }
  333. pg = page
  334. if pg < 1 {
  335. pg = 1
  336. }
  337. return size, pg
  338. }