payment_stats.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "log/slog"
  6. "math"
  7. "sort"
  8. "strconv"
  9. "time"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. "github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
  12. "github.com/Wei-Shaw/sub2api/ent/paymentorder"
  13. )
  14. // --- Dashboard & Analytics ---
  15. func (s *PaymentService) GetDashboardStats(ctx context.Context, days int) (*DashboardStats, error) {
  16. if days <= 0 {
  17. days = 30
  18. }
  19. now := time.Now()
  20. since := now.AddDate(0, 0, -days)
  21. todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
  22. paidStatuses := []string{OrderStatusCompleted, OrderStatusPaid, OrderStatusRecharging}
  23. orders, err := s.entClient.PaymentOrder.Query().
  24. Where(
  25. paymentorder.StatusIn(paidStatuses...),
  26. paymentorder.PaidAtGTE(since),
  27. ).
  28. All(ctx)
  29. if err != nil {
  30. return nil, err
  31. }
  32. st := &DashboardStats{}
  33. computeBasicStats(st, orders, todayStart)
  34. st.PendingOrders, err = s.entClient.PaymentOrder.Query().
  35. Where(paymentorder.StatusEQ(OrderStatusPending)).
  36. Count(ctx)
  37. if err != nil {
  38. return nil, err
  39. }
  40. st.DailySeries = buildDailySeries(orders, since, days)
  41. st.PaymentMethods = buildMethodDistribution(orders)
  42. st.TopUsers = buildTopUsers(orders)
  43. return st, nil
  44. }
  45. func computeBasicStats(st *DashboardStats, orders []*dbent.PaymentOrder, todayStart time.Time) {
  46. st.TotalAmount = make(CurrencyAmounts)
  47. st.TodayAmount = make(CurrencyAmounts)
  48. st.AvgAmount = make(CurrencyAmounts)
  49. currencyCounts := make(map[string]int)
  50. var todayCount int
  51. for _, o := range orders {
  52. currency := PaymentOrderCurrency(o)
  53. st.TotalAmount[currency] += o.PayAmount
  54. currencyCounts[currency]++
  55. if o.PaidAt != nil && !o.PaidAt.Before(todayStart) {
  56. st.TodayAmount[currency] += o.PayAmount
  57. todayCount++
  58. }
  59. }
  60. st.TotalCount = len(orders)
  61. st.TodayCount = todayCount
  62. for currency, totalAmount := range st.TotalAmount {
  63. st.AvgAmount[currency] = roundAmount(totalAmount / float64(currencyCounts[currency]))
  64. }
  65. roundCurrencyAmounts(st.TotalAmount)
  66. roundCurrencyAmounts(st.TodayAmount)
  67. }
  68. func buildDailySeries(orders []*dbent.PaymentOrder, since time.Time, days int) []DailyStats {
  69. dailyMap := make(map[string]*DailyStats)
  70. for _, o := range orders {
  71. if o.PaidAt == nil {
  72. continue
  73. }
  74. date := o.PaidAt.Format("2006-01-02")
  75. ds, ok := dailyMap[date]
  76. if !ok {
  77. ds = &DailyStats{Date: date, Amount: make(CurrencyAmounts)}
  78. dailyMap[date] = ds
  79. }
  80. ds.Amount[PaymentOrderCurrency(o)] += o.PayAmount
  81. ds.Count++
  82. }
  83. series := make([]DailyStats, 0, days)
  84. for i := 0; i < days; i++ {
  85. date := since.AddDate(0, 0, i+1).Format("2006-01-02")
  86. if ds, ok := dailyMap[date]; ok {
  87. roundCurrencyAmounts(ds.Amount)
  88. series = append(series, *ds)
  89. } else {
  90. series = append(series, DailyStats{Date: date, Amount: make(CurrencyAmounts)})
  91. }
  92. }
  93. return series
  94. }
  95. func buildMethodDistribution(orders []*dbent.PaymentOrder) []PaymentMethodStat {
  96. methodMap := make(map[string]*PaymentMethodStat)
  97. for _, o := range orders {
  98. ms, ok := methodMap[o.PaymentType]
  99. if !ok {
  100. ms = &PaymentMethodStat{Type: o.PaymentType, Amount: make(CurrencyAmounts)}
  101. methodMap[o.PaymentType] = ms
  102. }
  103. ms.Amount[PaymentOrderCurrency(o)] += o.PayAmount
  104. ms.Count++
  105. }
  106. methods := make([]PaymentMethodStat, 0, len(methodMap))
  107. for _, ms := range methodMap {
  108. roundCurrencyAmounts(ms.Amount)
  109. methods = append(methods, *ms)
  110. }
  111. sort.Slice(methods, func(i, j int) bool {
  112. return methods[i].Type < methods[j].Type
  113. })
  114. return methods
  115. }
  116. func buildTopUsers(orders []*dbent.PaymentOrder) TopUsersByCurrency {
  117. userMap := make(map[string]map[int64]*TopUserStat)
  118. for _, o := range orders {
  119. currency := PaymentOrderCurrency(o)
  120. users, ok := userMap[currency]
  121. if !ok {
  122. users = make(map[int64]*TopUserStat)
  123. userMap[currency] = users
  124. }
  125. us, ok := users[o.UserID]
  126. if !ok {
  127. us = &TopUserStat{UserID: o.UserID, Email: o.UserEmail}
  128. users[o.UserID] = us
  129. }
  130. us.Amount += o.PayAmount
  131. }
  132. result := make(TopUsersByCurrency, len(userMap))
  133. for currency, users := range userMap {
  134. userList := make([]*TopUserStat, 0, len(users))
  135. for _, us := range users {
  136. us.Amount = roundAmount(us.Amount)
  137. userList = append(userList, us)
  138. }
  139. sort.Slice(userList, func(i, j int) bool {
  140. return userList[i].Amount > userList[j].Amount
  141. })
  142. limit := topUsersLimit
  143. if len(userList) < limit {
  144. limit = len(userList)
  145. }
  146. result[currency] = make([]TopUserStat, 0, limit)
  147. for i := 0; i < limit; i++ {
  148. result[currency] = append(result[currency], *userList[i])
  149. }
  150. }
  151. return result
  152. }
  153. func roundCurrencyAmounts(amounts CurrencyAmounts) {
  154. for currency, amount := range amounts {
  155. amounts[currency] = roundAmount(amount)
  156. }
  157. }
  158. func roundAmount(amount float64) float64 {
  159. return math.Round(amount*100) / 100
  160. }
  161. // --- Audit Logs ---
  162. func (s *PaymentService) writeAuditLog(ctx context.Context, oid int64, action, op string, detail map[string]any) {
  163. dj, _ := json.Marshal(detail)
  164. _, err := s.entClient.PaymentAuditLog.Create().SetOrderID(strconv.FormatInt(oid, 10)).SetAction(action).SetDetail(string(dj)).SetOperator(op).Save(ctx)
  165. if err != nil {
  166. slog.Error("audit log failed", "orderID", oid, "action", action, "error", err)
  167. }
  168. }
  169. func (s *PaymentService) GetOrderAuditLogs(ctx context.Context, oid int64) ([]*dbent.PaymentAuditLog, error) {
  170. return s.entClient.PaymentAuditLog.Query().Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(oid, 10))).Order(paymentauditlog.ByCreatedAt()).All(ctx)
  171. }