redeem_service.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  1. package service
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "encoding/hex"
  6. "errors"
  7. "fmt"
  8. "strings"
  9. "time"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  14. )
  15. var (
  16. ErrRedeemCodeNotFound = infraerrors.NotFound("REDEEM_CODE_NOT_FOUND", "redeem code not found")
  17. ErrRedeemCodeUsed = infraerrors.Conflict("REDEEM_CODE_USED", "redeem code already used")
  18. ErrRedeemCodeExpired = infraerrors.Conflict("REDEEM_CODE_EXPIRED", "redeem code expired")
  19. ErrInsufficientBalance = infraerrors.BadRequest("INSUFFICIENT_BALANCE", "insufficient balance")
  20. ErrRedeemRateLimited = infraerrors.TooManyRequests("REDEEM_RATE_LIMITED", "too many failed attempts, please try again later")
  21. ErrRedeemCodeLocked = infraerrors.Conflict("REDEEM_CODE_LOCKED", "redeem code is being processed, please try again")
  22. )
  23. const (
  24. redeemMaxFailedAttempts = 30
  25. redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁
  26. )
  27. type redeemRateLimitPolicy uint8
  28. const (
  29. enforceRedeemRateLimit redeemRateLimitPolicy = iota
  30. bypassRedeemRateLimit
  31. )
  32. type ctxKeySkipRedeemAffiliate struct{}
  33. // ContextSkipRedeemAffiliate returns a context that suppresses the redeem-level
  34. // affiliate rebate. Used by payment fulfillment which handles rebate separately
  35. // via applyAffiliateRebateForOrder (with audit-log deduplication).
  36. func ContextSkipRedeemAffiliate(ctx context.Context) context.Context {
  37. return context.WithValue(ctx, ctxKeySkipRedeemAffiliate{}, true)
  38. }
  39. // RedeemCache defines cache operations for redeem service
  40. type RedeemCache interface {
  41. GetRedeemAttemptCount(ctx context.Context, userID int64) (int, error)
  42. IncrementRedeemAttemptCount(ctx context.Context, userID int64) error
  43. AcquireRedeemLock(ctx context.Context, code string, ttl time.Duration) (bool, error)
  44. ReleaseRedeemLock(ctx context.Context, code string) error
  45. }
  46. type RedeemCodeRepository interface {
  47. Create(ctx context.Context, code *RedeemCode) error
  48. CreateBatch(ctx context.Context, codes []RedeemCode) error
  49. GetByID(ctx context.Context, id int64) (*RedeemCode, error)
  50. GetByCode(ctx context.Context, code string) (*RedeemCode, error)
  51. Update(ctx context.Context, code *RedeemCode) error
  52. BatchUpdate(ctx context.Context, ids []int64, fields RedeemCodeBatchUpdateFields) (int64, error)
  53. Delete(ctx context.Context, id int64) error
  54. Use(ctx context.Context, id, userID int64) error
  55. List(ctx context.Context, params pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error)
  56. ListWithFilters(ctx context.Context, params pagination.PaginationParams, codeType, status, search string) ([]RedeemCode, *pagination.PaginationResult, error)
  57. ListByUser(ctx context.Context, userID int64, limit int) ([]RedeemCode, error)
  58. // ListByUserPaginated returns paginated balance/concurrency history for a specific user.
  59. // codeType filter is optional - pass empty string to return all types.
  60. ListByUserPaginated(ctx context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]RedeemCode, *pagination.PaginationResult, error)
  61. // SumPositiveBalanceByUser returns the total recharged amount (sum of positive balance values) for a user.
  62. SumPositiveBalanceByUser(ctx context.Context, userID int64) (float64, error)
  63. }
  64. // GenerateCodesRequest 生成兑换码请求
  65. type GenerateCodesRequest struct {
  66. Count int `json:"count"`
  67. Value float64 `json:"value"`
  68. Type string `json:"type"`
  69. }
  70. // RedeemCodeResponse 兑换码响应
  71. type RedeemCodeResponse struct {
  72. Code string `json:"code"`
  73. Value float64 `json:"value"`
  74. Status string `json:"status"`
  75. CreatedAt time.Time `json:"created_at"`
  76. }
  77. type NullableTimeUpdate struct {
  78. Set bool
  79. Value *time.Time
  80. }
  81. type NullableInt64Update struct {
  82. Set bool
  83. Value *int64
  84. }
  85. type RedeemCodeBatchUpdateFields struct {
  86. Status *string
  87. ExpiresAt NullableTimeUpdate
  88. Notes *string
  89. GroupID NullableInt64Update
  90. // Core fields are intentionally modeled only so service validation can
  91. // reject payloads that try to mutate redemption value semantics in bulk.
  92. Type *string
  93. Value *float64
  94. }
  95. func (f RedeemCodeBatchUpdateFields) HasChanges() bool {
  96. return f.Status != nil ||
  97. f.ExpiresAt.Set ||
  98. f.Notes != nil ||
  99. f.GroupID.Set ||
  100. f.Type != nil ||
  101. f.Value != nil
  102. }
  103. func (f RedeemCodeBatchUpdateFields) HasCoreFieldChanges() bool {
  104. return f.Type != nil || f.Value != nil
  105. }
  106. func (f RedeemCodeBatchUpdateFields) TouchesUsedSensitiveFields() bool {
  107. return f.Status != nil || f.ExpiresAt.Set || f.GroupID.Set
  108. }
  109. type RedeemCodeBatchUpdateInput struct {
  110. IDs []int64
  111. Fields RedeemCodeBatchUpdateFields
  112. }
  113. type RedeemCodeBatchUpdateResult struct {
  114. Updated int64 `json:"updated"`
  115. }
  116. // RedeemService 兑换码服务
  117. type RedeemService struct {
  118. redeemRepo RedeemCodeRepository
  119. userRepo UserRepository
  120. redeemUserRepo RedeemUserAdjustmentRepository
  121. subscriptionService *SubscriptionService
  122. cache RedeemCache
  123. billingCacheService *BillingCacheService
  124. entClient *dbent.Client
  125. authCacheInvalidator APIKeyAuthCacheInvalidator
  126. affiliateService *AffiliateService
  127. }
  128. // NewRedeemService 创建兑换码服务实例
  129. func NewRedeemService(
  130. redeemRepo RedeemCodeRepository,
  131. userRepo UserRepository,
  132. subscriptionService *SubscriptionService,
  133. cache RedeemCache,
  134. billingCacheService *BillingCacheService,
  135. entClient *dbent.Client,
  136. authCacheInvalidator APIKeyAuthCacheInvalidator,
  137. affiliateService *AffiliateService,
  138. ) *RedeemService {
  139. redeemUserRepo, _ := userRepo.(RedeemUserAdjustmentRepository)
  140. return &RedeemService{
  141. redeemRepo: redeemRepo,
  142. userRepo: userRepo,
  143. redeemUserRepo: redeemUserRepo,
  144. subscriptionService: subscriptionService,
  145. cache: cache,
  146. billingCacheService: billingCacheService,
  147. entClient: entClient,
  148. authCacheInvalidator: authCacheInvalidator,
  149. affiliateService: affiliateService,
  150. }
  151. }
  152. // GenerateRandomCode 生成随机兑换码
  153. func (s *RedeemService) GenerateRandomCode() (string, error) {
  154. // 生成16字节随机数据
  155. bytes := make([]byte, 16)
  156. if _, err := rand.Read(bytes); err != nil {
  157. return "", fmt.Errorf("generate random bytes: %w", err)
  158. }
  159. // 转换为十六进制字符串
  160. code := hex.EncodeToString(bytes)
  161. // 格式化为 XXXX-XXXX-XXXX-XXXX 格式
  162. parts := []string{
  163. strings.ToUpper(code[0:8]),
  164. strings.ToUpper(code[8:16]),
  165. strings.ToUpper(code[16:24]),
  166. strings.ToUpper(code[24:32]),
  167. }
  168. return strings.Join(parts, "-"), nil
  169. }
  170. // GenerateCodes 批量生成兑换码
  171. func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequest) ([]RedeemCode, error) {
  172. if req.Count <= 0 {
  173. return nil, errors.New("count must be greater than 0")
  174. }
  175. // 邀请码类型不需要数值,其他类型需要非零值(支持负数用于退款)
  176. if req.Type != RedeemTypeInvitation && req.Value == 0 {
  177. return nil, errors.New("value must not be zero")
  178. }
  179. if req.Count > 1000 {
  180. return nil, errors.New("cannot generate more than 1000 codes at once")
  181. }
  182. codeType := req.Type
  183. if codeType == "" {
  184. codeType = RedeemTypeBalance
  185. }
  186. // 邀请码类型的 value 设为 0
  187. value := req.Value
  188. if codeType == RedeemTypeInvitation {
  189. value = 0
  190. }
  191. codes := make([]RedeemCode, 0, req.Count)
  192. for i := 0; i < req.Count; i++ {
  193. code, err := s.GenerateRandomCode()
  194. if err != nil {
  195. return nil, fmt.Errorf("generate code: %w", err)
  196. }
  197. codes = append(codes, RedeemCode{
  198. Code: code,
  199. Type: codeType,
  200. Value: value,
  201. Status: StatusUnused,
  202. })
  203. }
  204. // 批量插入
  205. if err := s.redeemRepo.CreateBatch(ctx, codes); err != nil {
  206. return nil, fmt.Errorf("create batch codes: %w", err)
  207. }
  208. return codes, nil
  209. }
  210. // CreateCode creates a redeem code with caller-provided code value.
  211. // It is primarily used by admin integrations that require an external order ID
  212. // to be mapped to a deterministic redeem code.
  213. func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error {
  214. if code == nil {
  215. return errors.New("redeem code is required")
  216. }
  217. code.Code = strings.TrimSpace(code.Code)
  218. if code.Code == "" {
  219. return errors.New("code is required")
  220. }
  221. if code.Type == "" {
  222. code.Type = RedeemTypeBalance
  223. }
  224. if code.Type != RedeemTypeInvitation && code.Value == 0 {
  225. return errors.New("value must not be zero")
  226. }
  227. if code.Status == "" {
  228. code.Status = StatusUnused
  229. }
  230. if code.IsExpired() {
  231. return ErrRedeemCodeExpired
  232. }
  233. if err := s.redeemRepo.Create(ctx, code); err != nil {
  234. return fmt.Errorf("create redeem code: %w", err)
  235. }
  236. return nil
  237. }
  238. func (s *RedeemService) BatchUpdate(ctx context.Context, input *RedeemCodeBatchUpdateInput) (*RedeemCodeBatchUpdateResult, error) {
  239. if input == nil {
  240. return nil, infraerrors.BadRequest("REDEEM_CODE_BATCH_UPDATE_INVALID", "batch update input is required")
  241. }
  242. if len(input.IDs) == 0 {
  243. return nil, infraerrors.BadRequest("REDEEM_CODE_BATCH_UPDATE_IDS_REQUIRED", "ids are required")
  244. }
  245. if !input.Fields.HasChanges() {
  246. return nil, infraerrors.BadRequest("REDEEM_CODE_BATCH_UPDATE_EMPTY", "at least one field must be selected")
  247. }
  248. if input.Fields.HasCoreFieldChanges() {
  249. return nil, infraerrors.BadRequest("REDEEM_CODE_CORE_FIELDS_IMMUTABLE", "type and value cannot be batch updated")
  250. }
  251. ids := make([]int64, 0, len(input.IDs))
  252. seen := make(map[int64]struct{}, len(input.IDs))
  253. for _, id := range input.IDs {
  254. if id <= 0 {
  255. return nil, infraerrors.BadRequest("REDEEM_CODE_BATCH_UPDATE_INVALID_ID", "ids must be positive")
  256. }
  257. if _, ok := seen[id]; ok {
  258. continue
  259. }
  260. seen[id] = struct{}{}
  261. ids = append(ids, id)
  262. }
  263. if len(ids) == 0 {
  264. return nil, infraerrors.BadRequest("REDEEM_CODE_BATCH_UPDATE_IDS_REQUIRED", "ids are required")
  265. }
  266. if input.Fields.Status != nil {
  267. switch *input.Fields.Status {
  268. case StatusUnused, StatusDisabled:
  269. default:
  270. return nil, infraerrors.BadRequest("REDEEM_CODE_STATUS_INVALID", "status must be unused or disabled")
  271. }
  272. }
  273. if input.Fields.ExpiresAt.Set && input.Fields.ExpiresAt.Value != nil {
  274. expiresAt := input.Fields.ExpiresAt.Value.UTC()
  275. if !expiresAt.After(time.Now().UTC()) {
  276. return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRES_AT_INVALID", "expires_at must be in the future")
  277. }
  278. input.Fields.ExpiresAt.Value = &expiresAt
  279. }
  280. if input.Fields.GroupID.Set && input.Fields.GroupID.Value != nil && *input.Fields.GroupID.Value <= 0 {
  281. return nil, infraerrors.BadRequest("REDEEM_CODE_GROUP_ID_INVALID", "group_id must be positive")
  282. }
  283. updated, err := s.redeemRepo.BatchUpdate(ctx, ids, input.Fields)
  284. if err != nil {
  285. return nil, err
  286. }
  287. return &RedeemCodeBatchUpdateResult{Updated: updated}, nil
  288. }
  289. // checkRedeemRateLimit 检查用户兑换错误次数是否超限
  290. func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) error {
  291. if s.cache == nil {
  292. return nil
  293. }
  294. count, err := s.cache.GetRedeemAttemptCount(ctx, userID)
  295. if err != nil {
  296. // Redis 出错时不阻止用户操作
  297. return nil
  298. }
  299. if count >= redeemMaxFailedAttempts {
  300. return ErrRedeemRateLimited
  301. }
  302. return nil
  303. }
  304. // incrementRedeemErrorCount 增加用户兑换错误计数
  305. func (s *RedeemService) incrementRedeemErrorCount(ctx context.Context, userID int64) {
  306. if s.cache == nil {
  307. return
  308. }
  309. _ = s.cache.IncrementRedeemAttemptCount(ctx, userID)
  310. }
  311. // acquireRedeemLock 尝试获取兑换码的分布式锁
  312. // 返回 true 表示获取成功,false 表示锁已被占用
  313. func (s *RedeemService) acquireRedeemLock(ctx context.Context, code string) bool {
  314. if s.cache == nil {
  315. return true // 无 Redis 时降级为不加锁
  316. }
  317. ok, err := s.cache.AcquireRedeemLock(ctx, code, redeemLockDuration)
  318. if err != nil {
  319. // Redis 出错时不阻止操作,依赖数据库层面的状态检查
  320. return true
  321. }
  322. return ok
  323. }
  324. // releaseRedeemLock 释放兑换码的分布式锁
  325. func (s *RedeemService) releaseRedeemLock(ctx context.Context, code string) {
  326. if s.cache == nil {
  327. return
  328. }
  329. _ = s.cache.ReleaseRedeemLock(ctx, code)
  330. }
  331. func unsupportedRedeemTypeError(codeType string) error {
  332. if codeType == RedeemTypeInvitation {
  333. return infraerrors.BadRequest("REDEEM_CODE_UNSUPPORTED_TYPE", "invitation codes can only be used during registration")
  334. }
  335. return infraerrors.BadRequest("REDEEM_CODE_UNSUPPORTED_TYPE", fmt.Sprintf("unsupported redeem type: %s", codeType))
  336. }
  337. // Redeem 使用兑换码
  338. func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (*RedeemCode, error) {
  339. return s.redeem(ctx, userID, code, enforceRedeemRateLimit)
  340. }
  341. // redeemForPaymentFulfillment is restricted to trusted payment fulfillment.
  342. // Payment retries must not be blocked by, or contribute to, a user's public
  343. // redeem failure counter. All code validation and transactional updates remain
  344. // identical to the public redemption path.
  345. func (s *RedeemService) redeemForPaymentFulfillment(ctx context.Context, userID int64, code string) (*RedeemCode, error) {
  346. return s.redeem(ContextSkipRedeemAffiliate(ctx), userID, code, bypassRedeemRateLimit)
  347. }
  348. // RedeemForAdminFulfillment is restricted to trusted admin fulfillment.
  349. // Admin retries must not be blocked by, or contribute to, a user's public
  350. // redeem failure counter. Unlike payment fulfillment, the normal redeem-level
  351. // affiliate rebate remains enabled.
  352. func (s *RedeemService) RedeemForAdminFulfillment(ctx context.Context, userID int64, code string) (*RedeemCode, error) {
  353. return s.redeem(ctx, userID, code, bypassRedeemRateLimit)
  354. }
  355. func (s *RedeemService) redeem(ctx context.Context, userID int64, code string, rateLimitPolicy redeemRateLimitPolicy) (*RedeemCode, error) {
  356. if rateLimitPolicy == enforceRedeemRateLimit {
  357. if err := s.checkRedeemRateLimit(ctx, userID); err != nil {
  358. return nil, err
  359. }
  360. }
  361. // 获取分布式锁,防止同一兑换码并发使用
  362. if !s.acquireRedeemLock(ctx, code) {
  363. return nil, ErrRedeemCodeLocked
  364. }
  365. defer s.releaseRedeemLock(ctx, code)
  366. // 查找兑换码
  367. redeemCode, err := s.redeemRepo.GetByCode(ctx, code)
  368. if err != nil {
  369. if errors.Is(err, ErrRedeemCodeNotFound) {
  370. if rateLimitPolicy == enforceRedeemRateLimit {
  371. s.incrementRedeemErrorCount(ctx, userID)
  372. }
  373. return nil, ErrRedeemCodeNotFound
  374. }
  375. return nil, fmt.Errorf("get redeem code: %w", err)
  376. }
  377. // 检查兑换码状态和码本身的过期时间
  378. if redeemCode.IsExpired() {
  379. if rateLimitPolicy == enforceRedeemRateLimit {
  380. s.incrementRedeemErrorCount(ctx, userID)
  381. }
  382. return nil, ErrRedeemCodeExpired
  383. }
  384. if !redeemCode.CanUse() {
  385. if rateLimitPolicy == enforceRedeemRateLimit {
  386. s.incrementRedeemErrorCount(ctx, userID)
  387. }
  388. return nil, ErrRedeemCodeUsed
  389. }
  390. // 验证兑换码类型的前置条件。邀请码属于注册流程,不能通过普通兑换接口使用。
  391. switch redeemCode.Type {
  392. case RedeemTypeBalance, RedeemTypeConcurrency:
  393. case RedeemTypeSubscription:
  394. if redeemCode.GroupID == nil {
  395. return nil, infraerrors.BadRequest("REDEEM_CODE_INVALID", "invalid subscription redeem code: missing group_id")
  396. }
  397. default:
  398. return nil, unsupportedRedeemTypeError(redeemCode.Type)
  399. }
  400. // 获取用户信息
  401. _, err = s.userRepo.GetByID(ctx, userID)
  402. if err != nil {
  403. return nil, fmt.Errorf("get user: %w", err)
  404. }
  405. // 使用数据库事务保证兑换码标记与权益发放的原子性
  406. tx, err := s.entClient.Tx(ctx)
  407. if err != nil {
  408. return nil, fmt.Errorf("begin transaction: %w", err)
  409. }
  410. defer func() { _ = tx.Rollback() }()
  411. // 将事务放入 context,使 repository 方法能够使用同一事务
  412. txCtx := dbent.NewTxContext(ctx, tx)
  413. // 【关键】先标记兑换码为已使用,确保并发安全
  414. // 利用数据库乐观锁(WHERE status = 'unused')保证原子性
  415. if err := s.redeemRepo.Use(txCtx, redeemCode.ID, userID); err != nil {
  416. if errors.Is(err, ErrRedeemCodeNotFound) || errors.Is(err, ErrRedeemCodeUsed) {
  417. return nil, ErrRedeemCodeUsed
  418. }
  419. return nil, fmt.Errorf("mark code as used: %w", err)
  420. }
  421. // 执行兑换逻辑(兑换码已被锁定,此时可安全操作)
  422. switch redeemCode.Type {
  423. case RedeemTypeBalance:
  424. amount := redeemCode.Value
  425. if amount < 0 {
  426. if s.redeemUserRepo == nil {
  427. return nil, errors.New("user repository does not support atomic redeem balance adjustments")
  428. }
  429. if err := s.redeemUserRepo.ApplyRedeemBalanceAdjustment(txCtx, userID, amount); err != nil {
  430. return nil, fmt.Errorf("update user balance: %w", err)
  431. }
  432. } else if err := s.userRepo.UpdateBalance(txCtx, userID, amount); err != nil {
  433. return nil, fmt.Errorf("update user balance: %w", err)
  434. }
  435. case RedeemTypeConcurrency:
  436. delta := int(redeemCode.Value)
  437. if delta < 0 {
  438. if s.redeemUserRepo == nil {
  439. return nil, errors.New("user repository does not support atomic redeem concurrency adjustments")
  440. }
  441. if err := s.redeemUserRepo.ApplyRedeemConcurrencyAdjustment(txCtx, userID, delta); err != nil {
  442. return nil, fmt.Errorf("update user concurrency: %w", err)
  443. }
  444. } else if err := s.userRepo.UpdateConcurrency(txCtx, userID, delta); err != nil {
  445. return nil, fmt.Errorf("update user concurrency: %w", err)
  446. }
  447. case RedeemTypeSubscription:
  448. validityDays := redeemCode.ValidityDays
  449. if validityDays < 0 {
  450. // 负数天数:缩短订阅,减到 0 则取消订阅
  451. if err := s.reduceOrCancelSubscription(txCtx, userID, *redeemCode.GroupID, -validityDays, redeemCode.Code); err != nil {
  452. return nil, fmt.Errorf("reduce or cancel subscription: %w", err)
  453. }
  454. } else {
  455. if validityDays == 0 {
  456. validityDays = 30
  457. }
  458. _, _, err := s.subscriptionService.AssignOrExtendSubscription(txCtx, &AssignSubscriptionInput{
  459. UserID: userID,
  460. GroupID: *redeemCode.GroupID,
  461. ValidityDays: validityDays,
  462. AssignedBy: 0, // 系统分配
  463. Notes: fmt.Sprintf("通过兑换码 %s 兑换", redeemCode.Code),
  464. })
  465. if err != nil {
  466. return nil, fmt.Errorf("assign or extend subscription: %w", err)
  467. }
  468. }
  469. default:
  470. return nil, unsupportedRedeemTypeError(redeemCode.Type)
  471. }
  472. // 提交事务
  473. if err := tx.Commit(); err != nil {
  474. return nil, fmt.Errorf("commit transaction: %w", err)
  475. }
  476. // 事务提交成功后失效缓存
  477. s.invalidateRedeemCaches(ctx, userID, redeemCode)
  478. // 余额类正数兑换码触发邀请返利(best-effort,失败不影响兑换结果)
  479. if redeemCode.Type == RedeemTypeBalance && redeemCode.Value > 0 {
  480. s.tryAccrueAffiliateRebateForRedeem(ctx, userID, redeemCode.Value)
  481. }
  482. // 重新获取更新后的兑换码
  483. redeemCode, err = s.redeemRepo.GetByID(ctx, redeemCode.ID)
  484. if err != nil {
  485. return nil, fmt.Errorf("get updated redeem code: %w", err)
  486. }
  487. return redeemCode, nil
  488. }
  489. // invalidateRedeemCaches 失效兑换相关的缓存
  490. func (s *RedeemService) invalidateRedeemCaches(ctx context.Context, userID int64, redeemCode *RedeemCode) {
  491. switch redeemCode.Type {
  492. case RedeemTypeBalance:
  493. if s.authCacheInvalidator != nil {
  494. s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
  495. }
  496. if s.billingCacheService == nil {
  497. return
  498. }
  499. go func() {
  500. cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  501. defer cancel()
  502. _ = s.billingCacheService.InvalidateUserBalance(cacheCtx, userID)
  503. }()
  504. case RedeemTypeConcurrency:
  505. if s.authCacheInvalidator != nil {
  506. s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
  507. }
  508. if s.billingCacheService == nil {
  509. return
  510. }
  511. case RedeemTypeSubscription:
  512. if s.authCacheInvalidator != nil {
  513. s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
  514. }
  515. if s.billingCacheService == nil {
  516. return
  517. }
  518. if redeemCode.GroupID != nil {
  519. groupID := *redeemCode.GroupID
  520. go func() {
  521. cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  522. defer cancel()
  523. _ = s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
  524. }()
  525. }
  526. }
  527. }
  528. func (s *RedeemService) tryAccrueAffiliateRebateForRedeem(ctx context.Context, userID int64, amount float64) {
  529. if ctx.Value(ctxKeySkipRedeemAffiliate{}) != nil {
  530. return
  531. }
  532. if s.affiliateService == nil {
  533. return
  534. }
  535. if !s.affiliateService.IsEnabled(ctx) {
  536. return
  537. }
  538. rebate, err := s.affiliateService.AccrueInviteRebate(ctx, userID, amount)
  539. if err != nil {
  540. logger.LegacyPrintf("service.redeem", "[Redeem] affiliate rebate failed for user %d amount %.2f: %v", userID, amount, err)
  541. return
  542. }
  543. if rebate > 0 {
  544. logger.LegacyPrintf("service.redeem", "[Redeem] affiliate rebate accrued %.8f for inviter of user %d", rebate, userID)
  545. }
  546. }
  547. // GetByID 根据ID获取兑换码
  548. func (s *RedeemService) GetByID(ctx context.Context, id int64) (*RedeemCode, error) {
  549. code, err := s.redeemRepo.GetByID(ctx, id)
  550. if err != nil {
  551. return nil, fmt.Errorf("get redeem code: %w", err)
  552. }
  553. return code, nil
  554. }
  555. // GetByCode 根据Code获取兑换码
  556. func (s *RedeemService) GetByCode(ctx context.Context, code string) (*RedeemCode, error) {
  557. redeemCode, err := s.redeemRepo.GetByCode(ctx, code)
  558. if err != nil {
  559. return nil, fmt.Errorf("get redeem code: %w", err)
  560. }
  561. return redeemCode, nil
  562. }
  563. // List 获取兑换码列表(管理员功能)
  564. func (s *RedeemService) List(ctx context.Context, params pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error) {
  565. codes, pagination, err := s.redeemRepo.List(ctx, params)
  566. if err != nil {
  567. return nil, nil, fmt.Errorf("list redeem codes: %w", err)
  568. }
  569. return codes, pagination, nil
  570. }
  571. // Delete 删除兑换码(管理员功能)
  572. func (s *RedeemService) Delete(ctx context.Context, id int64) error {
  573. // 检查兑换码是否存在
  574. code, err := s.redeemRepo.GetByID(ctx, id)
  575. if err != nil {
  576. return fmt.Errorf("get redeem code: %w", err)
  577. }
  578. // 不允许删除已使用的兑换码
  579. if code.IsUsed() {
  580. return infraerrors.Conflict("REDEEM_CODE_DELETE_USED", "cannot delete used redeem code")
  581. }
  582. if err := s.redeemRepo.Delete(ctx, id); err != nil {
  583. return fmt.Errorf("delete redeem code: %w", err)
  584. }
  585. return nil
  586. }
  587. // GetStats 获取兑换码统计信息
  588. func (s *RedeemService) GetStats(ctx context.Context) (map[string]any, error) {
  589. // TODO: 实现统计逻辑
  590. // 统计未使用、已使用的兑换码数量
  591. // 统计总面值等
  592. stats := map[string]any{
  593. "total_codes": 0,
  594. "unused_codes": 0,
  595. "used_codes": 0,
  596. "total_value": 0.0,
  597. }
  598. return stats, nil
  599. }
  600. // GetUserHistory 获取用户的兑换历史
  601. func (s *RedeemService) GetUserHistory(ctx context.Context, userID int64, limit int) ([]RedeemCode, error) {
  602. codes, err := s.redeemRepo.ListByUser(ctx, userID, limit)
  603. if err != nil {
  604. return nil, fmt.Errorf("get user redeem history: %w", err)
  605. }
  606. return codes, nil
  607. }
  608. // reduceOrCancelSubscription 缩短订阅天数,剩余天数 <= 0 时取消订阅
  609. func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID, groupID int64, reduceDays int, code string) error {
  610. sub, err := s.subscriptionService.userSubRepo.GetByUserIDAndGroupID(ctx, userID, groupID)
  611. if err != nil {
  612. return ErrSubscriptionNotFound
  613. }
  614. now := time.Now()
  615. remaining := int(sub.ExpiresAt.Sub(now).Hours() / 24)
  616. if remaining < 0 {
  617. remaining = 0
  618. }
  619. notes := fmt.Sprintf("通过兑换码 %s 退款扣减 %d 天", code, reduceDays)
  620. if remaining <= reduceDays {
  621. // 剩余天数不足,直接取消订阅
  622. if err := s.subscriptionService.userSubRepo.UpdateStatus(ctx, sub.ID, SubscriptionStatusExpired); err != nil {
  623. return fmt.Errorf("cancel subscription: %w", err)
  624. }
  625. // 设置过期时间为当前时间
  626. if err := s.subscriptionService.userSubRepo.ExtendExpiry(ctx, sub.ID, now); err != nil {
  627. return fmt.Errorf("set subscription expiry: %w", err)
  628. }
  629. } else {
  630. // 缩短天数
  631. newExpiresAt := sub.ExpiresAt.AddDate(0, 0, -reduceDays)
  632. if err := s.subscriptionService.userSubRepo.ExtendExpiry(ctx, sub.ID, newExpiresAt); err != nil {
  633. return fmt.Errorf("reduce subscription: %w", err)
  634. }
  635. }
  636. // 追加备注
  637. newNotes := sub.Notes
  638. if newNotes != "" {
  639. newNotes += "\n"
  640. }
  641. newNotes += notes
  642. if err := s.subscriptionService.userSubRepo.UpdateNotes(ctx, sub.ID, newNotes); err != nil {
  643. return fmt.Errorf("update subscription notes: %w", err)
  644. }
  645. // 失效缓存
  646. s.subscriptionService.InvalidateSubCache(userID, groupID)
  647. return nil
  648. }