subscription_service.go 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "log"
  6. "math/rand/v2"
  7. "strconv"
  8. "strings"
  9. "time"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  15. "github.com/dgraph-io/ristretto"
  16. "golang.org/x/sync/singleflight"
  17. )
  18. // MaxExpiresAt is the maximum allowed expiration date (year 2099)
  19. // This prevents time.Time JSON serialization errors (RFC 3339 requires year <= 9999)
  20. var MaxExpiresAt = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
  21. // MaxValidityDays is the maximum allowed validity days for subscriptions (100 years)
  22. const MaxValidityDays = 36500
  23. var (
  24. ErrSubscriptionNotFound = infraerrors.NotFound("SUBSCRIPTION_NOT_FOUND", "subscription not found")
  25. ErrSubscriptionExpired = infraerrors.Forbidden("SUBSCRIPTION_EXPIRED", "subscription has expired")
  26. ErrSubscriptionSuspended = infraerrors.Forbidden("SUBSCRIPTION_SUSPENDED", "subscription is suspended")
  27. ErrSubscriptionAlreadyExists = infraerrors.Conflict("SUBSCRIPTION_ALREADY_EXISTS", "subscription already exists for this user and group")
  28. ErrSubscriptionAssignConflict = infraerrors.Conflict("SUBSCRIPTION_ASSIGN_CONFLICT", "subscription exists but request conflicts with existing assignment semantics")
  29. ErrSubscriptionNotRevoked = infraerrors.Conflict("SUBSCRIPTION_NOT_REVOKED", "subscription is not revoked")
  30. ErrSubscriptionRestoreConflict = infraerrors.Conflict("SUBSCRIPTION_RESTORE_CONFLICT", "subscription already exists for this user and group")
  31. ErrGroupNotSubscriptionType = infraerrors.BadRequest("GROUP_NOT_SUBSCRIPTION_TYPE", "group is not a subscription type")
  32. ErrInvalidInput = infraerrors.BadRequest("INVALID_INPUT", "at least one of resetDaily, resetWeekly, or resetMonthly must be true")
  33. ErrDailyLimitExceeded = infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily usage limit exceeded")
  34. ErrWeeklyLimitExceeded = infraerrors.TooManyRequests("WEEKLY_LIMIT_EXCEEDED", "weekly usage limit exceeded")
  35. ErrMonthlyLimitExceeded = infraerrors.TooManyRequests("MONTHLY_LIMIT_EXCEEDED", "monthly usage limit exceeded")
  36. ErrSubscriptionNilInput = infraerrors.BadRequest("SUBSCRIPTION_NIL_INPUT", "subscription input cannot be nil")
  37. ErrAdjustWouldExpire = infraerrors.BadRequest("ADJUST_WOULD_EXPIRE", "adjustment would result in expired subscription (remaining days must be > 0)")
  38. )
  39. // SubscriptionService 订阅服务
  40. type SubscriptionService struct {
  41. groupRepo GroupRepository
  42. userSubRepo UserSubscriptionRepository
  43. billingCacheService *BillingCacheService
  44. entClient *dbent.Client
  45. // L1 缓存:加速中间件热路径的订阅查询
  46. subCacheL1 *ristretto.Cache
  47. subCacheGroup singleflight.Group
  48. subCacheTTL time.Duration
  49. subCacheJitter int // 抖动百分比
  50. maintenanceQueue *SubscriptionMaintenanceQueue
  51. now func() time.Time
  52. }
  53. // NewSubscriptionService 创建订阅服务
  54. func NewSubscriptionService(groupRepo GroupRepository, userSubRepo UserSubscriptionRepository, billingCacheService *BillingCacheService, entClient *dbent.Client, cfg *config.Config) *SubscriptionService {
  55. svc := &SubscriptionService{
  56. groupRepo: groupRepo,
  57. userSubRepo: userSubRepo,
  58. billingCacheService: billingCacheService,
  59. entClient: entClient,
  60. now: time.Now,
  61. }
  62. svc.initSubCache(cfg)
  63. svc.initMaintenanceQueue(cfg)
  64. svc.StartSubCacheInvalidationSubscriber(context.Background())
  65. return svc
  66. }
  67. func (s *SubscriptionService) initMaintenanceQueue(cfg *config.Config) {
  68. if cfg == nil {
  69. return
  70. }
  71. mc := cfg.SubscriptionMaintenance
  72. if mc.WorkerCount <= 0 || mc.QueueSize <= 0 {
  73. return
  74. }
  75. s.maintenanceQueue = NewSubscriptionMaintenanceQueue(mc.WorkerCount, mc.QueueSize)
  76. }
  77. // Stop stops the maintenance worker pool.
  78. func (s *SubscriptionService) Stop() {
  79. if s == nil {
  80. return
  81. }
  82. if s.maintenanceQueue != nil {
  83. s.maintenanceQueue.Stop()
  84. }
  85. }
  86. // initSubCache 初始化订阅 L1 缓存
  87. func (s *SubscriptionService) initSubCache(cfg *config.Config) {
  88. if cfg == nil {
  89. return
  90. }
  91. sc := cfg.SubscriptionCache
  92. if sc.L1Size <= 0 || sc.L1TTLSeconds <= 0 {
  93. return
  94. }
  95. cache, err := ristretto.NewCache(&ristretto.Config{
  96. NumCounters: int64(sc.L1Size) * 10,
  97. MaxCost: int64(sc.L1Size),
  98. BufferItems: 64,
  99. })
  100. if err != nil {
  101. log.Printf("Warning: failed to init subscription L1 cache: %v", err)
  102. return
  103. }
  104. s.subCacheL1 = cache
  105. s.subCacheTTL = time.Duration(sc.L1TTLSeconds) * time.Second
  106. s.subCacheJitter = sc.JitterPercent
  107. }
  108. // subCacheKey 生成订阅缓存 key(热路径,避免 fmt.Sprintf 开销)
  109. func subCacheKey(userID, groupID int64) string {
  110. return "sub:" + strconv.FormatInt(userID, 10) + ":" + strconv.FormatInt(groupID, 10)
  111. }
  112. // jitteredTTL 为 TTL 添加抖动,避免集中过期
  113. func (s *SubscriptionService) jitteredTTL(ttl time.Duration) time.Duration {
  114. if ttl <= 0 || s.subCacheJitter <= 0 {
  115. return ttl
  116. }
  117. pct := s.subCacheJitter
  118. if pct > 100 {
  119. pct = 100
  120. }
  121. delta := float64(pct) / 100
  122. factor := 1 - delta + rand.Float64()*(2*delta)
  123. if factor <= 0 {
  124. return ttl
  125. }
  126. return time.Duration(float64(ttl) * factor)
  127. }
  128. // InvalidateSubCache 失效指定用户+分组的订阅 L1 缓存
  129. func (s *SubscriptionService) InvalidateSubCache(userID, groupID int64) {
  130. if s.subCacheL1 == nil {
  131. return
  132. }
  133. s.subCacheL1.Del(subCacheKey(userID, groupID))
  134. }
  135. // InvalidateSubCacheSync 失效订阅 L1 缓存并等待 Ristretto 删除操作生效。
  136. func (s *SubscriptionService) InvalidateSubCacheSync(userID, groupID int64) {
  137. s.invalidateSubCacheKeySync(subCacheKey(userID, groupID))
  138. }
  139. func (s *SubscriptionService) invalidateSubCacheKeySync(key string) {
  140. if s.subCacheL1 == nil {
  141. return
  142. }
  143. s.subCacheL1.Del(key)
  144. s.subCacheL1.Wait()
  145. }
  146. // StartSubCacheInvalidationSubscriber 启动跨实例订阅 L1 缓存失效订阅。
  147. func (s *SubscriptionService) StartSubCacheInvalidationSubscriber(ctx context.Context) {
  148. if s.billingCacheService == nil || s.subCacheL1 == nil {
  149. return
  150. }
  151. if err := s.billingCacheService.SubscribeSubscriptionCacheInvalidation(ctx, func(cacheKey string) {
  152. s.invalidateSubCacheKeySync(cacheKey)
  153. }); err != nil {
  154. log.Printf("Warning: failed to start subscription cache invalidation subscriber: %v", err)
  155. }
  156. }
  157. func (s *SubscriptionService) invalidateSubscriptionCaches(userID, groupID int64) error {
  158. s.InvalidateSubCacheSync(userID, groupID)
  159. if s.billingCacheService == nil {
  160. return nil
  161. }
  162. cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  163. defer cancel()
  164. if err := s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID); err != nil {
  165. return fmt.Errorf("invalidate billing subscription cache: %w", err)
  166. }
  167. if err := s.billingCacheService.PublishSubscriptionCacheInvalidation(cacheCtx, subCacheKey(userID, groupID)); err != nil {
  168. return fmt.Errorf("publish subscription cache invalidation: %w", err)
  169. }
  170. return nil
  171. }
  172. // AssignSubscriptionInput 分配订阅输入
  173. type AssignSubscriptionInput struct {
  174. UserID int64
  175. GroupID int64
  176. ValidityDays int
  177. AssignedBy int64
  178. Notes string
  179. }
  180. // AssignSubscription 分配订阅给用户(不允许重复分配)
  181. func (s *SubscriptionService) AssignSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, error) {
  182. sub, _, err := s.assignSubscriptionWithReuse(ctx, input)
  183. if err != nil {
  184. return nil, err
  185. }
  186. return sub, nil
  187. }
  188. // AssignOrExtendSubscription 分配或续期订阅(用于兑换码等场景)
  189. // 如果用户已有同分组的订阅:
  190. // - 未过期:从当前过期时间累加天数
  191. // - 已过期:从当前时间开始计算新的过期时间,并激活订阅
  192. //
  193. // 如果没有订阅:创建新订阅
  194. func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) {
  195. return s.assignOrExtendSubscription(ctx, input, false)
  196. }
  197. func (s *SubscriptionService) assignOrExtendSubscription(ctx context.Context, input *AssignSubscriptionInput, deferCacheInvalidation bool) (*UserSubscription, bool, error) {
  198. // 检查分组是否存在且为订阅类型
  199. group, err := s.groupRepo.GetByID(ctx, input.GroupID)
  200. if err != nil {
  201. return nil, false, fmt.Errorf("group not found: %w", err)
  202. }
  203. if !group.IsSubscriptionType() {
  204. return nil, false, ErrGroupNotSubscriptionType
  205. }
  206. // 查询是否已有订阅
  207. existingSub, err := s.userSubRepo.GetByUserIDAndGroupID(ctx, input.UserID, input.GroupID)
  208. if err != nil {
  209. // 不存在记录是正常情况,其他错误需要返回
  210. existingSub = nil
  211. }
  212. validityDays := input.ValidityDays
  213. if validityDays <= 0 {
  214. validityDays = 30
  215. }
  216. if validityDays > MaxValidityDays {
  217. validityDays = MaxValidityDays
  218. }
  219. // 已有订阅,执行续期(在事务中完成所有更新)
  220. if existingSub != nil {
  221. if err := s.updateExistingSubscriptionTerm(ctx, existingSub.ID, validityDays, input.Notes, false); err != nil {
  222. return nil, false, err
  223. }
  224. // 失效订阅缓存
  225. s.maybeInvalidateAssignmentCaches(input.UserID, input.GroupID, deferCacheInvalidation)
  226. // 返回更新后的订阅
  227. sub, err := s.userSubRepo.GetByID(ctx, existingSub.ID)
  228. return sub, true, err // true 表示是续期
  229. }
  230. // 没有订阅,创建新订阅
  231. sub, err := s.createSubscription(ctx, input)
  232. if err != nil {
  233. return nil, false, err
  234. }
  235. // 失效订阅缓存
  236. s.maybeInvalidateAssignmentCaches(input.UserID, input.GroupID, deferCacheInvalidation)
  237. return sub, false, nil // false 表示是新建
  238. }
  239. func (s *SubscriptionService) maybeInvalidateAssignmentCaches(userID, groupID int64, deferred bool) {
  240. // Payment fulfillment owns an outer transaction and performs a synchronous
  241. // invalidation after commit. Invalidating inside that transaction can reload
  242. // the pre-commit subscription into cache.
  243. if deferred {
  244. return
  245. }
  246. s.InvalidateSubCache(userID, groupID)
  247. if s.billingCacheService != nil {
  248. go func() {
  249. cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  250. defer cancel()
  251. _ = s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
  252. }()
  253. }
  254. }
  255. func (s *SubscriptionService) updateExistingSubscriptionTerm(
  256. ctx context.Context,
  257. subscriptionID int64,
  258. validityDays int,
  259. notes string,
  260. assignmentSemantics bool,
  261. ) error {
  262. return s.withSubscriptionUpdateTx(ctx, func(txCtx context.Context) error {
  263. existingSub, err := s.userSubRepo.GetByIDForUpdate(txCtx, subscriptionID)
  264. if err != nil {
  265. return fmt.Errorf("lock subscription for renewal: %w", err)
  266. }
  267. if assignmentSemantics && existingSub.Status == SubscriptionStatusSuspended {
  268. return nil
  269. }
  270. now := time.Now()
  271. if s.now != nil {
  272. now = s.now()
  273. }
  274. isExpired := !existingSub.ExpiresAt.After(now)
  275. if assignmentSemantics {
  276. isExpired = existingSub.Status == SubscriptionStatusExpired ||
  277. (existingSub.Status != SubscriptionStatusSuspended && !existingSub.ExpiresAt.After(now))
  278. }
  279. newExpiresAt := existingSub.ExpiresAt.AddDate(0, 0, validityDays)
  280. if isExpired {
  281. newExpiresAt = now.AddDate(0, 0, validityDays)
  282. }
  283. if newExpiresAt.After(MaxExpiresAt) {
  284. newExpiresAt = MaxExpiresAt
  285. }
  286. if assignmentSemantics && strings.TrimSpace(existingSub.Notes) == strings.TrimSpace(notes) {
  287. notes = ""
  288. }
  289. if isExpired {
  290. renewed := renewedSubscriptionTerm(existingSub, notes, now, newExpiresAt)
  291. if err := s.userSubRepo.Update(txCtx, renewed); err != nil {
  292. return fmt.Errorf("renew expired subscription: %w", err)
  293. }
  294. return nil
  295. }
  296. // 更新过期时间
  297. if err := s.userSubRepo.ExtendExpiry(txCtx, existingSub.ID, newExpiresAt); err != nil {
  298. return fmt.Errorf("extend subscription: %w", err)
  299. }
  300. // 如果订阅被暂停,恢复为 active 状态
  301. if existingSub.Status != SubscriptionStatusActive {
  302. if err := s.userSubRepo.UpdateStatus(txCtx, existingSub.ID, SubscriptionStatusActive); err != nil {
  303. return fmt.Errorf("update subscription status: %w", err)
  304. }
  305. }
  306. // 追加备注
  307. if notes != "" {
  308. if err := s.userSubRepo.UpdateNotes(txCtx, existingSub.ID, appendSubscriptionNotes(existingSub.Notes, notes)); err != nil {
  309. return fmt.Errorf("update subscription notes: %w", err)
  310. }
  311. }
  312. return nil
  313. })
  314. }
  315. func (s *SubscriptionService) withSubscriptionUpdateTx(ctx context.Context, fn func(context.Context) error) error {
  316. if dbent.TxFromContext(ctx) != nil {
  317. return fn(ctx)
  318. }
  319. if s.entClient == nil {
  320. return fn(ctx)
  321. }
  322. tx, err := s.entClient.Tx(ctx)
  323. if err != nil {
  324. return fmt.Errorf("begin transaction: %w", err)
  325. }
  326. txCtx := dbent.NewTxContext(ctx, tx)
  327. if err := fn(txCtx); err != nil {
  328. _ = tx.Rollback()
  329. return err
  330. }
  331. if err := tx.Commit(); err != nil {
  332. return fmt.Errorf("commit transaction: %w", err)
  333. }
  334. return nil
  335. }
  336. func renewedSubscriptionTerm(existingSub *UserSubscription, notes string, startsAt, expiresAt time.Time) *UserSubscription {
  337. renewed := *existingSub
  338. // 日窗口按日历日对齐(0 点刷新);周/月窗口按订阅期限对齐(锚点为新周期起点)。
  339. dailyWindowStart := timezone.StartOfDay(startsAt)
  340. periodicWindowStart := startsAt
  341. renewed.StartsAt = startsAt
  342. renewed.ExpiresAt = expiresAt
  343. renewed.Status = SubscriptionStatusActive
  344. renewed.DailyWindowStart = &dailyWindowStart
  345. renewed.WeeklyWindowStart = &periodicWindowStart
  346. renewed.MonthlyWindowStart = &periodicWindowStart
  347. renewed.DailyUsageUSD = 0
  348. renewed.WeeklyUsageUSD = 0
  349. renewed.MonthlyUsageUSD = 0
  350. renewed.Notes = appendSubscriptionNotes(existingSub.Notes, notes)
  351. return &renewed
  352. }
  353. func appendSubscriptionNotes(existingNotes, newNotes string) string {
  354. if newNotes == "" {
  355. return existingNotes
  356. }
  357. if existingNotes == "" {
  358. return newNotes
  359. }
  360. return existingNotes + "\n" + newNotes
  361. }
  362. // createSubscription 创建新订阅(内部方法)
  363. func (s *SubscriptionService) createSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, error) {
  364. validityDays := input.ValidityDays
  365. if validityDays <= 0 {
  366. validityDays = 30
  367. }
  368. if validityDays > MaxValidityDays {
  369. validityDays = MaxValidityDays
  370. }
  371. now := time.Now()
  372. expiresAt := now.AddDate(0, 0, validityDays)
  373. if expiresAt.After(MaxExpiresAt) {
  374. expiresAt = MaxExpiresAt
  375. }
  376. sub := &UserSubscription{
  377. UserID: input.UserID,
  378. GroupID: input.GroupID,
  379. StartsAt: now,
  380. ExpiresAt: expiresAt,
  381. Status: SubscriptionStatusActive,
  382. AssignedAt: now,
  383. Notes: input.Notes,
  384. CreatedAt: now,
  385. UpdatedAt: now,
  386. }
  387. // 只有当 AssignedBy > 0 时才设置(0 表示系统分配,如兑换码)
  388. if input.AssignedBy > 0 {
  389. sub.AssignedBy = &input.AssignedBy
  390. }
  391. if err := s.userSubRepo.Create(ctx, sub); err != nil {
  392. return nil, err
  393. }
  394. // 重新获取完整订阅信息(包含关联)
  395. return s.userSubRepo.GetByID(ctx, sub.ID)
  396. }
  397. // BulkAssignSubscriptionInput 批量分配订阅输入
  398. type BulkAssignSubscriptionInput struct {
  399. UserIDs []int64
  400. GroupID int64
  401. ValidityDays int
  402. AssignedBy int64
  403. Notes string
  404. }
  405. // BulkAssignResult 批量分配结果
  406. type BulkAssignResult struct {
  407. SuccessCount int
  408. CreatedCount int
  409. ReusedCount int
  410. FailedCount int
  411. Subscriptions []UserSubscription
  412. Errors []string
  413. Statuses map[int64]string
  414. }
  415. // BulkAssignSubscription 批量分配订阅
  416. func (s *SubscriptionService) BulkAssignSubscription(ctx context.Context, input *BulkAssignSubscriptionInput) (*BulkAssignResult, error) {
  417. result := &BulkAssignResult{
  418. Subscriptions: make([]UserSubscription, 0),
  419. Errors: make([]string, 0),
  420. Statuses: make(map[int64]string),
  421. }
  422. for _, userID := range input.UserIDs {
  423. sub, reused, err := s.assignSubscriptionWithReuse(ctx, &AssignSubscriptionInput{
  424. UserID: userID,
  425. GroupID: input.GroupID,
  426. ValidityDays: input.ValidityDays,
  427. AssignedBy: input.AssignedBy,
  428. Notes: input.Notes,
  429. })
  430. if err != nil {
  431. result.FailedCount++
  432. result.Errors = append(result.Errors, fmt.Sprintf("user %d: %v", userID, err))
  433. result.Statuses[userID] = "failed"
  434. } else {
  435. result.SuccessCount++
  436. result.Subscriptions = append(result.Subscriptions, *sub)
  437. if reused {
  438. result.ReusedCount++
  439. result.Statuses[userID] = "reused"
  440. } else {
  441. result.CreatedCount++
  442. result.Statuses[userID] = "created"
  443. }
  444. }
  445. }
  446. return result, nil
  447. }
  448. func (s *SubscriptionService) assignSubscriptionWithReuse(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) {
  449. // 检查分组是否存在且为订阅类型
  450. group, err := s.groupRepo.GetByID(ctx, input.GroupID)
  451. if err != nil {
  452. return nil, false, fmt.Errorf("group not found: %w", err)
  453. }
  454. if !group.IsSubscriptionType() {
  455. return nil, false, ErrGroupNotSubscriptionType
  456. }
  457. // 检查是否已存在订阅;若已存在,则按幂等成功返回现有订阅
  458. exists, err := s.userSubRepo.ExistsByUserIDAndGroupID(ctx, input.UserID, input.GroupID)
  459. if err != nil {
  460. return nil, false, err
  461. }
  462. if exists {
  463. sub, getErr := s.userSubRepo.GetByUserIDAndGroupID(ctx, input.UserID, input.GroupID)
  464. if getErr != nil {
  465. return nil, false, getErr
  466. }
  467. now := time.Now()
  468. if sub.Status == SubscriptionStatusExpired ||
  469. (sub.Status != SubscriptionStatusSuspended && !sub.ExpiresAt.After(now)) {
  470. validityDays := normalizeAssignValidityDays(input.ValidityDays)
  471. if err := s.updateExistingSubscriptionTerm(ctx, sub.ID, validityDays, input.Notes, true); err != nil {
  472. return nil, false, err
  473. }
  474. s.maybeInvalidateAssignmentCaches(input.UserID, input.GroupID, false)
  475. renewed, getErr := s.userSubRepo.GetByID(ctx, sub.ID)
  476. return renewed, true, getErr
  477. }
  478. if conflictReason, conflict := detectAssignSemanticConflict(sub, input); conflict {
  479. return nil, false, ErrSubscriptionAssignConflict.WithMetadata(map[string]string{
  480. "conflict_reason": conflictReason,
  481. })
  482. }
  483. return sub, true, nil
  484. }
  485. sub, err := s.createSubscription(ctx, input)
  486. if err != nil {
  487. return nil, false, err
  488. }
  489. // 失效订阅缓存
  490. s.InvalidateSubCache(input.UserID, input.GroupID)
  491. if s.billingCacheService != nil {
  492. userID, groupID := input.UserID, input.GroupID
  493. go func() {
  494. cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  495. defer cancel()
  496. _ = s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
  497. }()
  498. }
  499. return sub, false, nil
  500. }
  501. func detectAssignSemanticConflict(existing *UserSubscription, input *AssignSubscriptionInput) (string, bool) {
  502. if existing == nil || input == nil {
  503. return "", false
  504. }
  505. normalizedDays := normalizeAssignValidityDays(input.ValidityDays)
  506. if !existing.StartsAt.IsZero() {
  507. expectedExpiresAt := existing.StartsAt.AddDate(0, 0, normalizedDays)
  508. if expectedExpiresAt.After(MaxExpiresAt) {
  509. expectedExpiresAt = MaxExpiresAt
  510. }
  511. if !existing.ExpiresAt.Equal(expectedExpiresAt) {
  512. return "validity_days_mismatch", true
  513. }
  514. }
  515. existingNotes := strings.TrimSpace(existing.Notes)
  516. inputNotes := strings.TrimSpace(input.Notes)
  517. if existingNotes != inputNotes {
  518. return "notes_mismatch", true
  519. }
  520. return "", false
  521. }
  522. func normalizeAssignValidityDays(days int) int {
  523. if days <= 0 {
  524. days = 30
  525. }
  526. if days > MaxValidityDays {
  527. days = MaxValidityDays
  528. }
  529. return days
  530. }
  531. // RevokeSubscription 撤销订阅
  532. func (s *SubscriptionService) RevokeSubscription(ctx context.Context, subscriptionID int64) error {
  533. // 先获取订阅信息用于失效缓存
  534. sub, err := s.userSubRepo.GetByID(ctx, subscriptionID)
  535. if err != nil {
  536. return err
  537. }
  538. if err := s.userSubRepo.Delete(ctx, subscriptionID); err != nil {
  539. return err
  540. }
  541. if err := s.invalidateSubscriptionCaches(sub.UserID, sub.GroupID); err != nil {
  542. return err
  543. }
  544. return nil
  545. }
  546. // RestoreSubscription 恢复已撤销订阅
  547. func (s *SubscriptionService) RestoreSubscription(ctx context.Context, subscriptionID int64) (*UserSubscription, error) {
  548. sub, err := s.userSubRepo.GetByIDIncludeDeleted(ctx, subscriptionID)
  549. if err != nil {
  550. return nil, err
  551. }
  552. if sub.DeletedAt == nil {
  553. return nil, ErrSubscriptionNotRevoked
  554. }
  555. exists, err := s.userSubRepo.ExistsActiveByUserIDAndGroupID(ctx, sub.UserID, sub.GroupID)
  556. if err != nil {
  557. return nil, err
  558. }
  559. if exists {
  560. return nil, ErrSubscriptionRestoreConflict
  561. }
  562. restoredStatus := sub.Status
  563. now := time.Now()
  564. if restoredStatus == SubscriptionStatusActive && !sub.ExpiresAt.After(now) {
  565. restoredStatus = SubscriptionStatusExpired
  566. }
  567. restored, err := s.userSubRepo.Restore(ctx, subscriptionID, restoredStatus)
  568. if err != nil {
  569. return nil, err
  570. }
  571. if err := s.invalidateSubscriptionCaches(restored.UserID, restored.GroupID); err != nil {
  572. return nil, err
  573. }
  574. return restored, nil
  575. }
  576. // ExtendSubscription 调整订阅时长(正数延长,负数缩短)
  577. func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscriptionID int64, days int) (*UserSubscription, error) {
  578. sub, err := s.userSubRepo.GetByID(ctx, subscriptionID)
  579. if err != nil {
  580. return nil, ErrSubscriptionNotFound
  581. }
  582. // 限制调整天数范围
  583. if days > MaxValidityDays {
  584. days = MaxValidityDays
  585. }
  586. if days < -MaxValidityDays {
  587. days = -MaxValidityDays
  588. }
  589. now := time.Now()
  590. isExpired := !sub.ExpiresAt.After(now)
  591. // 如果订阅已过期,不允许负向调整
  592. if isExpired && days < 0 {
  593. return nil, infraerrors.BadRequest("CANNOT_SHORTEN_EXPIRED", "cannot shorten an expired subscription")
  594. }
  595. // 计算新的过期时间
  596. var newExpiresAt time.Time
  597. if isExpired {
  598. // 已过期:从当前时间开始增加天数
  599. newExpiresAt = now.AddDate(0, 0, days)
  600. } else {
  601. // 未过期:从原过期时间增加/减少天数
  602. newExpiresAt = sub.ExpiresAt.AddDate(0, 0, days)
  603. }
  604. if newExpiresAt.After(MaxExpiresAt) {
  605. newExpiresAt = MaxExpiresAt
  606. }
  607. // 检查新的过期时间必须大于当前时间
  608. if !newExpiresAt.After(now) {
  609. return nil, ErrAdjustWouldExpire
  610. }
  611. if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil {
  612. return nil, err
  613. }
  614. // 如果订阅已过期,恢复为active状态
  615. if sub.Status == SubscriptionStatusExpired {
  616. if err := s.userSubRepo.UpdateStatus(ctx, subscriptionID, SubscriptionStatusActive); err != nil {
  617. return nil, err
  618. }
  619. }
  620. // 失效订阅缓存
  621. s.InvalidateSubCache(sub.UserID, sub.GroupID)
  622. if s.billingCacheService != nil {
  623. userID, groupID := sub.UserID, sub.GroupID
  624. go func() {
  625. cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  626. defer cancel()
  627. _ = s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
  628. }()
  629. }
  630. return s.userSubRepo.GetByID(ctx, subscriptionID)
  631. }
  632. // GetByID 根据ID获取订阅
  633. func (s *SubscriptionService) GetByID(ctx context.Context, id int64) (*UserSubscription, error) {
  634. return s.userSubRepo.GetByID(ctx, id)
  635. }
  636. // GetActiveSubscription 获取用户对特定分组的有效订阅
  637. // 使用 L1 缓存 + singleflight 加速中间件热路径。
  638. // 返回缓存对象的浅拷贝,调用方可安全修改字段而不会污染缓存或触发 data race。
  639. func (s *SubscriptionService) GetActiveSubscription(ctx context.Context, userID, groupID int64) (*UserSubscription, error) {
  640. key := subCacheKey(userID, groupID)
  641. // L1 缓存命中:返回浅拷贝
  642. if s.subCacheL1 != nil {
  643. if v, ok := s.subCacheL1.Get(key); ok {
  644. if sub, ok := v.(*UserSubscription); ok {
  645. cp := *sub
  646. return &cp, nil
  647. }
  648. }
  649. }
  650. // singleflight 防止并发击穿
  651. value, err, _ := s.subCacheGroup.Do(key, func() (any, error) {
  652. sub, err := s.userSubRepo.GetActiveByUserIDAndGroupID(ctx, userID, groupID)
  653. if err != nil {
  654. return nil, err // 直接透传 repo 已翻译的错误(NotFound → ErrSubscriptionNotFound,其他错误原样返回)
  655. }
  656. // 写入 L1 缓存
  657. if s.subCacheL1 != nil {
  658. _ = s.subCacheL1.SetWithTTL(key, sub, 1, s.jitteredTTL(s.subCacheTTL))
  659. }
  660. return sub, nil
  661. })
  662. if err != nil {
  663. return nil, err
  664. }
  665. // singleflight 返回的也是缓存指针,需要浅拷贝
  666. sub, ok := value.(*UserSubscription)
  667. if !ok || sub == nil {
  668. return nil, ErrSubscriptionNotFound
  669. }
  670. cp := *sub
  671. return &cp, nil
  672. }
  673. // ListUserSubscriptions 获取用户的所有订阅
  674. func (s *SubscriptionService) ListUserSubscriptions(ctx context.Context, userID int64) ([]UserSubscription, error) {
  675. subs, err := s.userSubRepo.ListByUserID(ctx, userID)
  676. if err != nil {
  677. return nil, err
  678. }
  679. normalizeExpiredWindows(subs)
  680. normalizeSubscriptionStatus(subs)
  681. return subs, nil
  682. }
  683. // ListActiveUserSubscriptions 获取用户的所有有效订阅
  684. func (s *SubscriptionService) ListActiveUserSubscriptions(ctx context.Context, userID int64) ([]UserSubscription, error) {
  685. subs, err := s.userSubRepo.ListActiveByUserID(ctx, userID)
  686. if err != nil {
  687. return nil, err
  688. }
  689. normalizeExpiredWindows(subs)
  690. return subs, nil
  691. }
  692. // ListGroupSubscriptions 获取分组的所有订阅
  693. func (s *SubscriptionService) ListGroupSubscriptions(ctx context.Context, groupID int64, page, pageSize int) ([]UserSubscription, *pagination.PaginationResult, error) {
  694. params := pagination.PaginationParams{Page: page, PageSize: pageSize}
  695. subs, pag, err := s.userSubRepo.ListByGroupID(ctx, groupID, params)
  696. if err != nil {
  697. return nil, nil, err
  698. }
  699. normalizeExpiredWindows(subs)
  700. normalizeSubscriptionStatus(subs)
  701. return subs, pag, nil
  702. }
  703. // List 获取所有订阅(分页,支持筛选和排序)
  704. func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) {
  705. params := pagination.PaginationParams{Page: page, PageSize: pageSize}
  706. subs, pag, err := s.userSubRepo.List(ctx, params, userID, groupID, status, platform, sortBy, sortOrder)
  707. if err != nil {
  708. return nil, nil, err
  709. }
  710. normalizeExpiredWindows(subs)
  711. normalizeSubscriptionStatus(subs)
  712. return subs, pag, nil
  713. }
  714. // normalizeExpiredWindows 将已过期窗口的数据清零(仅影响返回数据,不影响数据库)
  715. // 这确保前端显示正确的当前窗口状态,而不是过期窗口的历史数据
  716. func normalizeExpiredWindows(subs []UserSubscription) {
  717. normalizeExpiredWindowsAt(subs, time.Now())
  718. }
  719. func normalizeExpiredWindowsAt(subs []UserSubscription, now time.Time) {
  720. for i := range subs {
  721. sub := &subs[i]
  722. // 日窗口过期:清零展示数据
  723. if sub.canAutomaticallyResetDailyAt(now) {
  724. sub.DailyWindowStart = nil
  725. sub.DailyUsageUSD = 0
  726. }
  727. // 周窗口过期:清零展示数据
  728. if sub.canAutomaticallyResetWeeklyAt(now) {
  729. sub.WeeklyWindowStart = nil
  730. sub.WeeklyUsageUSD = 0
  731. }
  732. // 月窗口过期:清零展示数据
  733. if sub.canAutomaticallyResetMonthlyAt(now) {
  734. sub.MonthlyWindowStart = nil
  735. sub.MonthlyUsageUSD = 0
  736. }
  737. }
  738. }
  739. // normalizeSubscriptionStatus 根据实际过期时间修正状态(仅影响返回数据,不影响数据库)
  740. // 这确保前端显示正确的状态,即使定时任务尚未更新数据库
  741. func normalizeSubscriptionStatus(subs []UserSubscription) {
  742. now := time.Now()
  743. for i := range subs {
  744. sub := &subs[i]
  745. if sub.Status == SubscriptionStatusActive && !sub.ExpiresAt.After(now) {
  746. sub.Status = SubscriptionStatusExpired
  747. }
  748. }
  749. }
  750. // startOfDay 返回给定时间所在日期的零点(保持原时区)
  751. func startOfDay(t time.Time) time.Time {
  752. return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
  753. }
  754. // CheckAndActivateWindow 检查并激活窗口(首次使用时)
  755. func (s *SubscriptionService) CheckAndActivateWindow(ctx context.Context, sub *UserSubscription) error {
  756. return s.checkAndActivateWindowAt(ctx, sub, s.now())
  757. }
  758. func (s *SubscriptionService) checkAndActivateWindowAt(ctx context.Context, sub *UserSubscription, now time.Time) error {
  759. if sub.IsWindowActivated() {
  760. return nil
  761. }
  762. // 日窗口锚定当天 0 点(日历日语义);周/月窗口锚定首次使用时刻(期限对齐语义,
  763. // 锚点不得早于 StartsAt,否则最后一个不完整周期会重复发放额度,见 issue #5051)。
  764. return s.userSubRepo.ActivateWindows(ctx, sub.ID, timezone.StartOfDay(now), now)
  765. }
  766. // AdminResetQuota manually resets the daily, weekly, and/or monthly usage windows.
  767. func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionID int64, resetDaily, resetWeekly, resetMonthly bool) (*UserSubscription, error) {
  768. if !resetDaily && !resetWeekly && !resetMonthly {
  769. return nil, ErrInvalidInput
  770. }
  771. sub, err := s.userSubRepo.GetByID(ctx, subscriptionID)
  772. if err != nil {
  773. return nil, err
  774. }
  775. now := s.now()
  776. // 日窗口锚点取当天 0 点:手动重置只清空用量,不改变“每天 0 点刷新”的节奏。
  777. // 周/月窗口保持锚定重置时刻(期限对齐滚动窗口语义)。
  778. if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, timezone.StartOfDay(now), now); err != nil {
  779. return nil, err
  780. }
  781. // Invalidate L1 ristretto cache. Ristretto's Del() is asynchronous by design,
  782. // so call Wait() immediately after to flush pending operations and guarantee
  783. // the deleted key is not returned on the very next Get() call.
  784. s.InvalidateSubCacheSync(sub.UserID, sub.GroupID)
  785. if s.billingCacheService != nil {
  786. _ = s.billingCacheService.InvalidateSubscription(ctx, sub.UserID, sub.GroupID)
  787. }
  788. // Return the refreshed subscription from DB
  789. return s.userSubRepo.GetByID(ctx, subscriptionID)
  790. }
  791. // CheckAndResetWindows 检查并重置过期的窗口
  792. func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *UserSubscription) error {
  793. now := s.now()
  794. needsInvalidateCache := false
  795. // 日窗口重置(每天 0 点刷新,按日历日对齐)
  796. if windowStart, ok := sub.automaticDailyWindowStartAt(now); ok {
  797. expectedWindowStart := sub.DailyWindowStart
  798. if err := s.userSubRepo.ResetDailyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil {
  799. return err
  800. }
  801. sub.DailyWindowStart = &windowStart
  802. sub.DailyUsageUSD = 0
  803. needsInvalidateCache = true
  804. }
  805. // 周窗口重置(7天)
  806. if windowStart, ok := sub.automaticWindowStartAt(sub.WeeklyWindowStart, 7*24*time.Hour, now); ok {
  807. expectedWindowStart := sub.WeeklyWindowStart
  808. if err := s.userSubRepo.ResetWeeklyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil {
  809. return err
  810. }
  811. sub.WeeklyWindowStart = &windowStart
  812. sub.WeeklyUsageUSD = 0
  813. needsInvalidateCache = true
  814. }
  815. // 月窗口重置(30天)
  816. if windowStart, ok := sub.automaticWindowStartAt(sub.MonthlyWindowStart, 30*24*time.Hour, now); ok {
  817. expectedWindowStart := sub.MonthlyWindowStart
  818. if err := s.userSubRepo.ResetMonthlyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil {
  819. return err
  820. }
  821. sub.MonthlyWindowStart = &windowStart
  822. sub.MonthlyUsageUSD = 0
  823. needsInvalidateCache = true
  824. }
  825. // 如果有窗口被重置,失效缓存以保持一致性
  826. if needsInvalidateCache {
  827. s.InvalidateSubCache(sub.UserID, sub.GroupID)
  828. if s.billingCacheService != nil {
  829. _ = s.billingCacheService.InvalidateSubscription(ctx, sub.UserID, sub.GroupID)
  830. }
  831. }
  832. return nil
  833. }
  834. // EnsureWindowMaintenance advances expired usage windows before a request is
  835. // allowed to proceed. It returns a fresh database snapshot because a competing
  836. // request may have won one of the conditional resets.
  837. func (s *SubscriptionService) EnsureWindowMaintenance(ctx context.Context, sub *UserSubscription) (*UserSubscription, error) {
  838. if sub == nil {
  839. return nil, ErrSubscriptionNilInput
  840. }
  841. if !sub.IsWindowActivated() {
  842. if err := s.CheckAndActivateWindow(ctx, sub); err != nil {
  843. return nil, err
  844. }
  845. }
  846. if err := s.CheckAndResetWindows(ctx, sub); err != nil {
  847. return nil, err
  848. }
  849. // GetByID bypasses the service caches. This prevents a stale loser of the
  850. // CAS from validating limits against zeroed in-memory usage.
  851. refreshed, err := s.userSubRepo.GetByID(ctx, sub.ID)
  852. if err != nil {
  853. return nil, err
  854. }
  855. s.InvalidateSubCacheSync(sub.UserID, sub.GroupID)
  856. return refreshed, nil
  857. }
  858. // CheckUsageLimits 检查使用限额(返回错误如果超限)
  859. // 用于中间件的快速预检查,additionalCost 通常为 0
  860. func (s *SubscriptionService) CheckUsageLimits(ctx context.Context, sub *UserSubscription, group *Group, additionalCost float64) error {
  861. if !sub.CheckDailyLimit(group, additionalCost) {
  862. return ErrDailyLimitExceeded
  863. }
  864. if !sub.CheckWeeklyLimit(group, additionalCost) {
  865. return ErrWeeklyLimitExceeded
  866. }
  867. if !sub.CheckMonthlyLimit(group, additionalCost) {
  868. return ErrMonthlyLimitExceeded
  869. }
  870. return nil
  871. }
  872. // ValidateAndCheckLimits 合并验证+限额检查(中间件热路径专用)
  873. // 仅做内存检查,不触发 DB 写入。调用方必须在放行请求前同步完成窗口维护。
  874. // 返回 needsMaintenance 表示是否需要执行窗口维护并回读数据库快照。
  875. func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, group *Group) (needsMaintenance bool, err error) {
  876. now := s.now()
  877. // 1. 验证订阅状态
  878. if sub.Status == SubscriptionStatusExpired {
  879. return false, ErrSubscriptionExpired
  880. }
  881. if sub.Status == SubscriptionStatusSuspended {
  882. return false, ErrSubscriptionSuspended
  883. }
  884. if !sub.ExpiresAt.After(now) {
  885. return false, ErrSubscriptionExpired
  886. }
  887. // 2. 内存中修正过期窗口的用量,确保预检查不会误拒绝用户。
  888. // 调用方随后同步推进 DB 窗口,并用回读快照重新校验。
  889. if sub.canAutomaticallyResetDailyAt(now) {
  890. sub.DailyUsageUSD = 0
  891. needsMaintenance = true
  892. }
  893. if sub.canAutomaticallyResetWeeklyAt(now) {
  894. sub.WeeklyUsageUSD = 0
  895. needsMaintenance = true
  896. }
  897. if sub.canAutomaticallyResetMonthlyAt(now) {
  898. sub.MonthlyUsageUSD = 0
  899. needsMaintenance = true
  900. }
  901. if !sub.IsWindowActivated() {
  902. needsMaintenance = true
  903. }
  904. // 3. 检查用量限额
  905. if !sub.CheckDailyLimit(group, 0) {
  906. return needsMaintenance, ErrDailyLimitExceeded
  907. }
  908. if !sub.CheckWeeklyLimit(group, 0) {
  909. return needsMaintenance, ErrWeeklyLimitExceeded
  910. }
  911. if !sub.CheckMonthlyLimit(group, 0) {
  912. return needsMaintenance, ErrMonthlyLimitExceeded
  913. }
  914. return needsMaintenance, nil
  915. }
  916. // DoWindowMaintenance 异步执行窗口维护(激活+重置)
  917. // 使用独立 context,不受请求取消影响。
  918. // 注意:此方法仅在 ValidateAndCheckLimits 返回 needsMaintenance=true 时调用,
  919. // 而 IsExpired()=true 的订阅在 ValidateAndCheckLimits 中已被拦截返回错误,
  920. // 因此进入此方法的订阅一定未过期,无需处理过期状态同步。
  921. func (s *SubscriptionService) DoWindowMaintenance(sub *UserSubscription) {
  922. if s == nil {
  923. return
  924. }
  925. if s.maintenanceQueue != nil {
  926. err := s.maintenanceQueue.TryEnqueue(func() {
  927. s.doWindowMaintenance(sub)
  928. })
  929. if err != nil {
  930. log.Printf("Subscription maintenance enqueue failed: %v", err)
  931. }
  932. return
  933. }
  934. s.doWindowMaintenance(sub)
  935. }
  936. func (s *SubscriptionService) doWindowMaintenance(sub *UserSubscription) {
  937. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  938. defer cancel()
  939. // 激活窗口(首次使用时)
  940. if !sub.IsWindowActivated() {
  941. if err := s.CheckAndActivateWindow(ctx, sub); err != nil {
  942. log.Printf("Failed to activate subscription windows: %v", err)
  943. }
  944. }
  945. // 重置过期窗口
  946. if err := s.CheckAndResetWindows(ctx, sub); err != nil {
  947. log.Printf("Failed to reset subscription windows: %v", err)
  948. }
  949. // 失效 L1 缓存,确保后续请求拿到更新后的数据
  950. s.InvalidateSubCache(sub.UserID, sub.GroupID)
  951. }
  952. // RecordUsage 记录使用量到订阅
  953. func (s *SubscriptionService) RecordUsage(ctx context.Context, subscriptionID int64, costUSD float64) error {
  954. return s.userSubRepo.IncrementUsage(ctx, subscriptionID, costUSD)
  955. }
  956. // SubscriptionProgress 订阅进度
  957. type SubscriptionProgress struct {
  958. ID int64 `json:"id"`
  959. GroupName string `json:"group_name"`
  960. ExpiresAt time.Time `json:"expires_at"`
  961. ExpiresInDays int `json:"expires_in_days"`
  962. Daily *UsageWindowProgress `json:"daily,omitempty"`
  963. Weekly *UsageWindowProgress `json:"weekly,omitempty"`
  964. Monthly *UsageWindowProgress `json:"monthly,omitempty"`
  965. }
  966. // UsageWindowProgress 使用窗口进度
  967. type UsageWindowProgress struct {
  968. LimitUSD float64 `json:"limit_usd"`
  969. UsedUSD float64 `json:"used_usd"`
  970. RemainingUSD float64 `json:"remaining_usd"`
  971. Percentage float64 `json:"percentage"`
  972. WindowStart time.Time `json:"window_start"`
  973. ResetsAt time.Time `json:"resets_at"`
  974. ResetsInSeconds int64 `json:"resets_in_seconds"`
  975. }
  976. // GetSubscriptionProgress 获取订阅使用进度
  977. func (s *SubscriptionService) GetSubscriptionProgress(ctx context.Context, subscriptionID int64) (*SubscriptionProgress, error) {
  978. sub, err := s.userSubRepo.GetByID(ctx, subscriptionID)
  979. if err != nil {
  980. return nil, ErrSubscriptionNotFound
  981. }
  982. group := sub.Group
  983. if group == nil {
  984. group, err = s.groupRepo.GetByID(ctx, sub.GroupID)
  985. if err != nil {
  986. return nil, err
  987. }
  988. }
  989. return s.calculateProgress(sub, group), nil
  990. }
  991. // calculateProgress 根据已加载的订阅和分组数据计算使用进度(纯内存计算,无 DB 查询)
  992. func (s *SubscriptionService) calculateProgress(sub *UserSubscription, group *Group) *SubscriptionProgress {
  993. progress := &SubscriptionProgress{
  994. ID: sub.ID,
  995. GroupName: group.Name,
  996. ExpiresAt: sub.ExpiresAt,
  997. ExpiresInDays: sub.DaysRemaining(),
  998. }
  999. // 日进度
  1000. if group.HasDailyLimit() && sub.DailyWindowStart != nil {
  1001. limit := *group.DailyLimitUSD
  1002. resetsAt := sub.DailyWindowStart.Add(24 * time.Hour)
  1003. if dailyResetTime := sub.DailyResetTime(); dailyResetTime != nil {
  1004. resetsAt = *dailyResetTime
  1005. }
  1006. progress.Daily = &UsageWindowProgress{
  1007. LimitUSD: limit,
  1008. UsedUSD: sub.DailyUsageUSD,
  1009. RemainingUSD: limit - sub.DailyUsageUSD,
  1010. Percentage: (sub.DailyUsageUSD / limit) * 100,
  1011. WindowStart: *sub.DailyWindowStart,
  1012. ResetsAt: resetsAt,
  1013. ResetsInSeconds: int64(time.Until(resetsAt).Seconds()),
  1014. }
  1015. if progress.Daily.RemainingUSD < 0 {
  1016. progress.Daily.RemainingUSD = 0
  1017. }
  1018. if progress.Daily.Percentage > 100 {
  1019. progress.Daily.Percentage = 100
  1020. }
  1021. if progress.Daily.ResetsInSeconds < 0 {
  1022. progress.Daily.ResetsInSeconds = 0
  1023. }
  1024. }
  1025. // 周进度
  1026. if group.HasWeeklyLimit() && sub.WeeklyWindowStart != nil {
  1027. limit := *group.WeeklyLimitUSD
  1028. resetsAt := sub.WeeklyWindowStart.Add(7 * 24 * time.Hour)
  1029. if weeklyResetTime := sub.WeeklyResetTime(); weeklyResetTime != nil {
  1030. resetsAt = *weeklyResetTime
  1031. }
  1032. progress.Weekly = &UsageWindowProgress{
  1033. LimitUSD: limit,
  1034. UsedUSD: sub.WeeklyUsageUSD,
  1035. RemainingUSD: limit - sub.WeeklyUsageUSD,
  1036. Percentage: (sub.WeeklyUsageUSD / limit) * 100,
  1037. WindowStart: *sub.WeeklyWindowStart,
  1038. ResetsAt: resetsAt,
  1039. ResetsInSeconds: int64(time.Until(resetsAt).Seconds()),
  1040. }
  1041. if progress.Weekly.RemainingUSD < 0 {
  1042. progress.Weekly.RemainingUSD = 0
  1043. }
  1044. if progress.Weekly.Percentage > 100 {
  1045. progress.Weekly.Percentage = 100
  1046. }
  1047. if progress.Weekly.ResetsInSeconds < 0 {
  1048. progress.Weekly.ResetsInSeconds = 0
  1049. }
  1050. }
  1051. // 月进度
  1052. if group.HasMonthlyLimit() && sub.MonthlyWindowStart != nil {
  1053. limit := *group.MonthlyLimitUSD
  1054. resetsAt := sub.MonthlyWindowStart.Add(30 * 24 * time.Hour)
  1055. if monthlyResetTime := sub.MonthlyResetTime(); monthlyResetTime != nil {
  1056. resetsAt = *monthlyResetTime
  1057. }
  1058. progress.Monthly = &UsageWindowProgress{
  1059. LimitUSD: limit,
  1060. UsedUSD: sub.MonthlyUsageUSD,
  1061. RemainingUSD: limit - sub.MonthlyUsageUSD,
  1062. Percentage: (sub.MonthlyUsageUSD / limit) * 100,
  1063. WindowStart: *sub.MonthlyWindowStart,
  1064. ResetsAt: resetsAt,
  1065. ResetsInSeconds: int64(time.Until(resetsAt).Seconds()),
  1066. }
  1067. if progress.Monthly.RemainingUSD < 0 {
  1068. progress.Monthly.RemainingUSD = 0
  1069. }
  1070. if progress.Monthly.Percentage > 100 {
  1071. progress.Monthly.Percentage = 100
  1072. }
  1073. if progress.Monthly.ResetsInSeconds < 0 {
  1074. progress.Monthly.ResetsInSeconds = 0
  1075. }
  1076. }
  1077. return progress
  1078. }
  1079. // GetUserSubscriptionsWithProgress 获取用户所有订阅及进度
  1080. func (s *SubscriptionService) GetUserSubscriptionsWithProgress(ctx context.Context, userID int64) ([]SubscriptionProgress, error) {
  1081. // ListActiveByUserID 已使用 .WithGroup() eager-load Group 关联,1 次查询获取所有数据
  1082. subs, err := s.userSubRepo.ListActiveByUserID(ctx, userID)
  1083. if err != nil {
  1084. return nil, err
  1085. }
  1086. progresses := make([]SubscriptionProgress, 0, len(subs))
  1087. for i := range subs {
  1088. sub := &subs[i]
  1089. group := sub.Group
  1090. if group == nil {
  1091. continue
  1092. }
  1093. progresses = append(progresses, *s.calculateProgress(sub, group))
  1094. }
  1095. return progresses, nil
  1096. }
  1097. // ValidateSubscription 验证订阅是否有效
  1098. func (s *SubscriptionService) ValidateSubscription(ctx context.Context, sub *UserSubscription) error {
  1099. if sub.Status == SubscriptionStatusExpired {
  1100. return ErrSubscriptionExpired
  1101. }
  1102. if sub.Status == SubscriptionStatusSuspended {
  1103. return ErrSubscriptionSuspended
  1104. }
  1105. if sub.IsExpired() {
  1106. // 更新状态
  1107. _ = s.userSubRepo.UpdateStatus(ctx, sub.ID, SubscriptionStatusExpired)
  1108. return ErrSubscriptionExpired
  1109. }
  1110. return nil
  1111. }