user_subscription.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. package service
  2. import (
  3. "time"
  4. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  5. )
  6. const subscriptionDayDuration = 24 * time.Hour
  7. type UserSubscription struct {
  8. ID int64
  9. UserID int64
  10. GroupID int64
  11. StartsAt time.Time
  12. ExpiresAt time.Time
  13. Status string
  14. DailyWindowStart *time.Time
  15. WeeklyWindowStart *time.Time
  16. MonthlyWindowStart *time.Time
  17. DailyUsageUSD float64
  18. WeeklyUsageUSD float64
  19. MonthlyUsageUSD float64
  20. AssignedBy *int64
  21. AssignedAt time.Time
  22. Notes string
  23. CreatedAt time.Time
  24. UpdatedAt time.Time
  25. DeletedAt *time.Time
  26. User *User
  27. Group *Group
  28. AssignedByUser *User
  29. }
  30. func (s *UserSubscription) IsActive() bool {
  31. return s.Status == SubscriptionStatusActive && time.Now().Before(s.ExpiresAt)
  32. }
  33. func (s *UserSubscription) IsExpired() bool {
  34. return time.Now().After(s.ExpiresAt)
  35. }
  36. func (s *UserSubscription) DaysRemaining() int {
  37. return s.daysRemainingAt(time.Now())
  38. }
  39. func (s *UserSubscription) daysRemainingAt(now time.Time) int {
  40. remaining := s.ExpiresAt.Sub(now)
  41. if remaining <= 0 {
  42. return 0
  43. }
  44. days := int(remaining / subscriptionDayDuration)
  45. if remaining%subscriptionDayDuration != 0 {
  46. days++
  47. }
  48. return days
  49. }
  50. func (s *UserSubscription) IsWindowActivated() bool {
  51. return s.DailyWindowStart != nil || s.WeeklyWindowStart != nil || s.MonthlyWindowStart != nil
  52. }
  53. func (s *UserSubscription) HasOneTimeDailyQuota() bool {
  54. if s == nil || s.StartsAt.IsZero() || s.ExpiresAt.IsZero() {
  55. return false
  56. }
  57. return !s.ExpiresAt.After(s.StartsAt.AddDate(0, 0, 1))
  58. }
  59. func (s *UserSubscription) NeedsDailyReset() bool {
  60. return s.NeedsDailyResetAt(time.Now())
  61. }
  62. func (s *UserSubscription) NeedsDailyResetAt(now time.Time) bool {
  63. _, ok := s.automaticDailyWindowStartAt(now)
  64. return ok
  65. }
  66. func (s *UserSubscription) NeedsWeeklyReset() bool {
  67. return s.NeedsWeeklyResetAt(time.Now())
  68. }
  69. func (s *UserSubscription) NeedsWeeklyResetAt(now time.Time) bool {
  70. if s.WeeklyWindowStart == nil {
  71. return false
  72. }
  73. return !now.Before(s.WeeklyWindowStart.Add(7 * 24 * time.Hour))
  74. }
  75. func (s *UserSubscription) NeedsMonthlyReset() bool {
  76. return s.NeedsMonthlyResetAt(time.Now())
  77. }
  78. func (s *UserSubscription) NeedsMonthlyResetAt(now time.Time) bool {
  79. if s.MonthlyWindowStart == nil {
  80. return false
  81. }
  82. return !now.Before(s.MonthlyWindowStart.Add(30 * 24 * time.Hour))
  83. }
  84. func (s *UserSubscription) canAutomaticallyResetDailyAt(now time.Time) bool {
  85. _, ok := s.automaticDailyWindowStartAt(now)
  86. return ok
  87. }
  88. // automaticDailyWindowStartAt 计算日窗口按“配置时区日历日”对齐后的当前窗口起点。
  89. // 日额度固定在每天 0 点刷新(与周/月的期限对齐滚动窗口语义不同),因此只要持久化
  90. // 的窗口起点落在更早的日历日,就允许推进到今天 0 点。手动重置、激活等写入的任何
  91. // 非 0 点锚点都会在下一个 0 点被拉回日历日边界,不会永久漂移刷新时刻。
  92. func (s *UserSubscription) automaticDailyWindowStartAt(now time.Time) (time.Time, bool) {
  93. if s.DailyWindowStart == nil {
  94. return time.Time{}, false
  95. }
  96. if s.HasOneTimeDailyQuota() {
  97. return time.Time{}, false
  98. }
  99. today := timezone.StartOfDay(now)
  100. if !today.After(timezone.StartOfDay(*s.DailyWindowStart)) {
  101. return time.Time{}, false
  102. }
  103. return today, true
  104. }
  105. func (s *UserSubscription) canAutomaticallyResetWeeklyAt(now time.Time) bool {
  106. _, ok := s.automaticWindowStartAt(s.WeeklyWindowStart, 7*24*time.Hour, now)
  107. return ok
  108. }
  109. func (s *UserSubscription) canAutomaticallyResetMonthlyAt(now time.Time) bool {
  110. _, ok := s.automaticWindowStartAt(s.MonthlyWindowStart, 30*24*time.Hour, now)
  111. return ok
  112. }
  113. // windowResetAnchor 返回周/月窗口实际推进所依据的锚点。
  114. // 早期订阅把首个窗口初始化在开通日零点;只有这个初始值是无歧义的,之后出现的
  115. // 零点锚点可能来自手动重置,必须保持权威。
  116. // 自动推进(automaticWindowStartAt)与对外展示的重置时间(WeeklyResetTime/
  117. // MonthlyResetTime)必须共用这一修正,否则仪表盘显示的重置时间会早于窗口实际
  118. // 滚动的时间。
  119. // 日窗口按日历日对齐(automaticDailyWindowStartAt),不走这里。
  120. func (s *UserSubscription) windowResetAnchor(previous time.Time) time.Time {
  121. legacyAnchor := startOfDay(s.StartsAt)
  122. if legacyAnchor.Before(s.StartsAt) && previous.Equal(legacyAnchor) {
  123. return s.StartsAt
  124. }
  125. return previous
  126. }
  127. // automaticWindowStartAt 计算周/月窗口(期限对齐滚动窗口)的当前窗口起点。
  128. // 窗口从锚点按整数个 period 步进,且不越过订阅到期时间,避免最后一个不完整
  129. // 周期重复发放额度(issue #5051)。日窗口不走此函数,见 automaticDailyWindowStartAt。
  130. func (s *UserSubscription) automaticWindowStartAt(previous *time.Time, period time.Duration, now time.Time) (time.Time, bool) {
  131. if previous == nil {
  132. return time.Time{}, false
  133. }
  134. anchor := s.windowResetAnchor(*previous)
  135. next := anchor.Add(period)
  136. if now.Before(next) || !next.Before(s.ExpiresAt) {
  137. return time.Time{}, false
  138. }
  139. periods := now.Sub(anchor) / period
  140. lastPeriodBeforeExpiry := (s.ExpiresAt.Sub(anchor) - 1) / period
  141. if periods > lastPeriodBeforeExpiry {
  142. periods = lastPeriodBeforeExpiry
  143. }
  144. return anchor.Add(periods * period), true
  145. }
  146. func (s *UserSubscription) DailyResetTime() *time.Time {
  147. if s.DailyWindowStart == nil {
  148. return nil
  149. }
  150. if s.HasOneTimeDailyQuota() {
  151. t := s.ExpiresAt
  152. return &t
  153. }
  154. // 日窗口按日历日对齐:下次刷新固定在窗口起点所在日的次日 0 点。
  155. t := timezone.StartOfDay(*s.DailyWindowStart).AddDate(0, 0, 1)
  156. return &t
  157. }
  158. func (s *UserSubscription) WeeklyResetTime() *time.Time {
  159. if s.WeeklyWindowStart == nil {
  160. return nil
  161. }
  162. t := s.windowResetAnchor(*s.WeeklyWindowStart).Add(7 * 24 * time.Hour)
  163. return &t
  164. }
  165. func (s *UserSubscription) MonthlyResetTime() *time.Time {
  166. if s.MonthlyWindowStart == nil {
  167. return nil
  168. }
  169. t := s.windowResetAnchor(*s.MonthlyWindowStart).Add(30 * 24 * time.Hour)
  170. return &t
  171. }
  172. func (s *UserSubscription) CheckDailyLimit(group *Group, additionalCost float64) bool {
  173. if !group.HasDailyLimit() {
  174. return true
  175. }
  176. return s.DailyUsageUSD+additionalCost <= *group.DailyLimitUSD
  177. }
  178. func (s *UserSubscription) CheckWeeklyLimit(group *Group, additionalCost float64) bool {
  179. if !group.HasWeeklyLimit() {
  180. return true
  181. }
  182. return s.WeeklyUsageUSD+additionalCost <= *group.WeeklyLimitUSD
  183. }
  184. func (s *UserSubscription) CheckMonthlyLimit(group *Group, additionalCost float64) bool {
  185. if !group.HasMonthlyLimit() {
  186. return true
  187. }
  188. return s.MonthlyUsageUSD+additionalCost <= *group.MonthlyLimitUSD
  189. }
  190. func (s *UserSubscription) CheckAllLimits(group *Group, additionalCost float64) (daily, weekly, monthly bool) {
  191. daily = s.CheckDailyLimit(group, additionalCost)
  192. weekly = s.CheckWeeklyLimit(group, additionalCost)
  193. monthly = s.CheckMonthlyLimit(group, additionalCost)
  194. return
  195. }