promo_service.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. package service
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "encoding/hex"
  6. "fmt"
  7. "strings"
  8. "time"
  9. dbent "github.com/Wei-Shaw/sub2api/ent"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  12. )
  13. var (
  14. ErrPromoCodeNotFound = infraerrors.NotFound("PROMO_CODE_NOT_FOUND", "promo code not found")
  15. ErrPromoCodeExpired = infraerrors.BadRequest("PROMO_CODE_EXPIRED", "promo code has expired")
  16. ErrPromoCodeDisabled = infraerrors.BadRequest("PROMO_CODE_DISABLED", "promo code is disabled")
  17. ErrPromoCodeMaxUsed = infraerrors.BadRequest("PROMO_CODE_MAX_USED", "promo code has reached maximum uses")
  18. ErrPromoCodeAlreadyUsed = infraerrors.Conflict("PROMO_CODE_ALREADY_USED", "you have already used this promo code")
  19. ErrPromoCodeInvalid = infraerrors.BadRequest("PROMO_CODE_INVALID", "invalid promo code")
  20. )
  21. // PromoService 优惠码服务
  22. type PromoService struct {
  23. promoRepo PromoCodeRepository
  24. userRepo UserRepository
  25. billingCacheService *BillingCacheService
  26. entClient *dbent.Client
  27. authCacheInvalidator APIKeyAuthCacheInvalidator
  28. }
  29. // NewPromoService 创建优惠码服务实例
  30. func NewPromoService(
  31. promoRepo PromoCodeRepository,
  32. userRepo UserRepository,
  33. billingCacheService *BillingCacheService,
  34. entClient *dbent.Client,
  35. authCacheInvalidator APIKeyAuthCacheInvalidator,
  36. ) *PromoService {
  37. return &PromoService{
  38. promoRepo: promoRepo,
  39. userRepo: userRepo,
  40. billingCacheService: billingCacheService,
  41. entClient: entClient,
  42. authCacheInvalidator: authCacheInvalidator,
  43. }
  44. }
  45. // ValidatePromoCode 验证优惠码(注册前调用)
  46. // 返回 nil, nil 表示空码(不报错)
  47. func (s *PromoService) ValidatePromoCode(ctx context.Context, code string) (*PromoCode, error) {
  48. code = strings.TrimSpace(code)
  49. if code == "" {
  50. return nil, nil // 空码不报错,直接返回
  51. }
  52. promoCode, err := s.promoRepo.GetByCode(ctx, code)
  53. if err != nil {
  54. // 保留原始错误类型,不要统一映射为 NotFound
  55. return nil, err
  56. }
  57. if err := s.validatePromoCodeStatus(promoCode); err != nil {
  58. return nil, err
  59. }
  60. return promoCode, nil
  61. }
  62. // validatePromoCodeStatus 验证优惠码状态
  63. func (s *PromoService) validatePromoCodeStatus(promoCode *PromoCode) error {
  64. if !promoCode.CanUse() {
  65. if promoCode.IsExpired() {
  66. return ErrPromoCodeExpired
  67. }
  68. if promoCode.Status == PromoCodeStatusDisabled {
  69. return ErrPromoCodeDisabled
  70. }
  71. if promoCode.MaxUses > 0 && promoCode.UsedCount >= promoCode.MaxUses {
  72. return ErrPromoCodeMaxUsed
  73. }
  74. return ErrPromoCodeInvalid
  75. }
  76. return nil
  77. }
  78. // ApplyPromoCode 应用优惠码(注册成功后调用)
  79. // 使用事务和行锁确保并发安全
  80. func (s *PromoService) ApplyPromoCode(ctx context.Context, userID int64, code string) error {
  81. code = strings.TrimSpace(code)
  82. if code == "" {
  83. return nil
  84. }
  85. // 开启事务
  86. tx, err := s.entClient.Tx(ctx)
  87. if err != nil {
  88. return fmt.Errorf("begin transaction: %w", err)
  89. }
  90. defer func() { _ = tx.Rollback() }()
  91. txCtx := dbent.NewTxContext(ctx, tx)
  92. // 在事务中获取并锁定优惠码记录(FOR UPDATE)
  93. promoCode, err := s.promoRepo.GetByCodeForUpdate(txCtx, code)
  94. if err != nil {
  95. return err
  96. }
  97. // 在事务中验证优惠码状态
  98. if err := s.validatePromoCodeStatus(promoCode); err != nil {
  99. return err
  100. }
  101. // 在事务中检查用户是否已使用过此优惠码
  102. existing, err := s.promoRepo.GetUsageByPromoCodeAndUser(txCtx, promoCode.ID, userID)
  103. if err != nil {
  104. return fmt.Errorf("check existing usage: %w", err)
  105. }
  106. if existing != nil {
  107. return ErrPromoCodeAlreadyUsed
  108. }
  109. // 增加用户余额
  110. if err := s.userRepo.UpdateBalance(txCtx, userID, promoCode.BonusAmount); err != nil {
  111. return fmt.Errorf("update user balance: %w", err)
  112. }
  113. // 创建使用记录
  114. usage := &PromoCodeUsage{
  115. PromoCodeID: promoCode.ID,
  116. UserID: userID,
  117. BonusAmount: promoCode.BonusAmount,
  118. UsedAt: time.Now(),
  119. }
  120. if err := s.promoRepo.CreateUsage(txCtx, usage); err != nil {
  121. return fmt.Errorf("create usage record: %w", err)
  122. }
  123. // 增加使用次数
  124. if err := s.promoRepo.IncrementUsedCount(txCtx, promoCode.ID); err != nil {
  125. return fmt.Errorf("increment used count: %w", err)
  126. }
  127. if err := tx.Commit(); err != nil {
  128. return fmt.Errorf("commit transaction: %w", err)
  129. }
  130. s.invalidatePromoCaches(ctx, userID, promoCode.BonusAmount)
  131. // 失效余额缓存
  132. if s.billingCacheService != nil {
  133. go func() {
  134. cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  135. defer cancel()
  136. _ = s.billingCacheService.InvalidateUserBalance(cacheCtx, userID)
  137. }()
  138. }
  139. return nil
  140. }
  141. func (s *PromoService) invalidatePromoCaches(ctx context.Context, userID int64, bonusAmount float64) {
  142. if bonusAmount == 0 || s.authCacheInvalidator == nil {
  143. return
  144. }
  145. s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
  146. }
  147. // GenerateRandomCode 生成随机优惠码
  148. func (s *PromoService) GenerateRandomCode() (string, error) {
  149. bytes := make([]byte, 8)
  150. if _, err := rand.Read(bytes); err != nil {
  151. return "", fmt.Errorf("generate random bytes: %w", err)
  152. }
  153. return strings.ToUpper(hex.EncodeToString(bytes)), nil
  154. }
  155. // Create 创建优惠码
  156. func (s *PromoService) Create(ctx context.Context, input *CreatePromoCodeInput) (*PromoCode, error) {
  157. code := strings.TrimSpace(input.Code)
  158. if code == "" {
  159. // 自动生成
  160. var err error
  161. code, err = s.GenerateRandomCode()
  162. if err != nil {
  163. return nil, err
  164. }
  165. }
  166. promoCode := &PromoCode{
  167. Code: strings.ToUpper(code),
  168. BonusAmount: input.BonusAmount,
  169. MaxUses: input.MaxUses,
  170. UsedCount: 0,
  171. Status: PromoCodeStatusActive,
  172. ExpiresAt: input.ExpiresAt,
  173. Notes: input.Notes,
  174. }
  175. if err := s.promoRepo.Create(ctx, promoCode); err != nil {
  176. return nil, fmt.Errorf("create promo code: %w", err)
  177. }
  178. return promoCode, nil
  179. }
  180. // GetByID 根据ID获取优惠码
  181. func (s *PromoService) GetByID(ctx context.Context, id int64) (*PromoCode, error) {
  182. code, err := s.promoRepo.GetByID(ctx, id)
  183. if err != nil {
  184. return nil, err
  185. }
  186. return code, nil
  187. }
  188. // Update 更新优惠码
  189. func (s *PromoService) Update(ctx context.Context, id int64, input *UpdatePromoCodeInput) (*PromoCode, error) {
  190. promoCode, err := s.promoRepo.GetByID(ctx, id)
  191. if err != nil {
  192. return nil, err
  193. }
  194. if input.Code != nil {
  195. promoCode.Code = strings.ToUpper(strings.TrimSpace(*input.Code))
  196. }
  197. if input.BonusAmount != nil {
  198. promoCode.BonusAmount = *input.BonusAmount
  199. }
  200. if input.MaxUses != nil {
  201. promoCode.MaxUses = *input.MaxUses
  202. }
  203. if input.Status != nil {
  204. promoCode.Status = *input.Status
  205. }
  206. if input.ExpiresAt != nil {
  207. if input.ExpiresAt.IsZero() {
  208. input.ExpiresAt = nil
  209. }
  210. promoCode.ExpiresAt = input.ExpiresAt
  211. }
  212. if input.Notes != nil {
  213. promoCode.Notes = *input.Notes
  214. }
  215. if err := s.promoRepo.Update(ctx, promoCode); err != nil {
  216. return nil, fmt.Errorf("update promo code: %w", err)
  217. }
  218. return promoCode, nil
  219. }
  220. // Delete 删除优惠码
  221. func (s *PromoService) Delete(ctx context.Context, id int64) error {
  222. if err := s.promoRepo.Delete(ctx, id); err != nil {
  223. return fmt.Errorf("delete promo code: %w", err)
  224. }
  225. return nil
  226. }
  227. // List 获取优惠码列表
  228. func (s *PromoService) List(ctx context.Context, params pagination.PaginationParams, status, search string) ([]PromoCode, *pagination.PaginationResult, error) {
  229. return s.promoRepo.ListWithFilters(ctx, params, status, search)
  230. }
  231. // ListUsages 获取使用记录
  232. func (s *PromoService) ListUsages(ctx context.Context, promoCodeID int64, params pagination.PaginationParams) ([]PromoCodeUsage, *pagination.PaginationResult, error) {
  233. return s.promoRepo.ListUsagesByPromoCode(ctx, promoCodeID, params)
  234. }