activity_expiry.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. // 活动失效器(自定义退费 / 余额清零共用)——AC-05。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §7.2 ④(custom_refund 的活动失效时序)、
  4. // §3.1 #9/#10、§11-2(自营/加盟判定字段 self_support)。
  5. //
  6. // 为什么单独抽一个"失效器":
  7. //
  8. // dump 的 `sp_user_expense_custom_refund`(yuansiwei_finance.sql:764062)与
  9. // `sp_user_expense_school_cleared_balance`(:764338)在这一点上**逐字相同**——
  10. // 同一段临时表填充 SQL + 同一句 `UPDATE ... SET status=4 ... IN (tmp)`,
  11. // 唯一差异是"自营校区 `campus_id IN (x,0)` / 加盟校区 `= x`"。
  12. // 抽成一个失效器后:① 两处调用共享同一份口径,不会各改一半;
  13. // ② 自营/加盟的分流只有**一个**判定点(`port.CampusReader` + repo 的 `selfSupport` 参数)。
  14. //
  15. // 事务纪律(红线):失效动作**必须与资金变动同事务**——`ExpireForUser` 只接受调用方传入的
  16. // `tx`,并在内部用 `repository.WithTxContext(ctx, tx)` 把同一 tx 传给仓储(§10.3 L4 约定:
  17. // 事务内跨组件调用必须显式透传,否则会新开独立事务,"钱退了、活动还能用"将不再回滚)。
  18. package service
  19. import (
  20. "context"
  21. "gorm.io/gorm"
  22. "yuansiwei/unified-backend/internal/academic/model"
  23. "yuansiwei/unified-backend/internal/academic/repository"
  24. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  25. appscope "yuansiwei/unified-backend/internal/infra/scope"
  26. )
  27. // activitySignedExpired 报名状态「过期不能使用」(model.ActivitySignedExpired 的本地别名,
  28. // 使本文件的 SQL 语义读起来与 dump 的 `status=4` 一一对应)。
  29. const activitySignedExpired int32 = model.ActivitySignedExpired
  30. // ActivityExpirer 活动失效器(SP9 / SP10 的共用副作用)。
  31. //
  32. // 字段:
  33. // - repo:活动仓储(报名行 + 活动方案)。
  34. type ActivityExpirer struct {
  35. repo *repository.ActivityRepo
  36. }
  37. // NewActivityExpirer 构造活动失效器。
  38. //
  39. // 入参:db *gorm.DB 连接池句柄;sc *appscope.Scope 请求作用域(机构越权防线需要)。
  40. // 出参:*ActivityExpirer。
  41. func NewActivityExpirer(db *gorm.DB, sc *appscope.Scope) *ActivityExpirer {
  42. return &ActivityExpirer{repo: repository.NewActivityRepo(db, sc)}
  43. }
  44. // ExpireForUser 在**当前事务内**把用户的"充值赠送"活动报名置为过期(status=4)。
  45. // 入参:
  46. // - ctx 请求上下文;
  47. // - tx 事务句柄(**必传**:与资金变动同事务,任一步失败整体回滚);
  48. // - userID 报名用户(ULID);
  49. // - institutionID 机构 ID(>0;由 CampusReader 解析或调用方传入);
  50. // - campusID 校区 ID(>0);
  51. // - selfSupport 自营 = true(失效范围 `campus_id IN (campusID, 0)`)
  52. // / 加盟 = false(只失效 `campus_id = campusID`)。
  53. //
  54. // 出参:
  55. // - []int64:本次被置为过期的活动 ID(**升序去重**;空 → 长度 0 的非 nil 切片,§4.6「空传 []」);
  56. // - ErrBadParam / ErrUnscoped(1004) / ErrDBError。
  57. //
  58. // 业务口径(dump 逐字,两处 SP 相同):
  59. // - 只处理 `t_courses_activity.type = 2`(充值赠送)——代金券(type=3)/老带新(type=4) 不动;
  60. // - 只处理报名行 `status != 4`(已过期的重复处理无意义);
  61. // - 机构必须等于 `activity.institution_id`(旧 `t2.schoolid`);
  62. // - 自营校区额外覆盖 `campus_id = 0` 的"全机构池"活动,加盟校区不覆盖(避免误失效他校区的活动)。
  63. func (e *ActivityExpirer) ExpireForUser(
  64. ctx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64, selfSupport bool,
  65. ) ([]int64, error) {
  66. if tx == nil {
  67. return nil, infraerrors.WithCause(infraerrors.ErrInternal,
  68. errString("service: ActivityExpirer.ExpireForUser 必须传入 tx(与资金变动同事务)"))
  69. }
  70. if e == nil || e.repo == nil {
  71. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityExpirer 未装配"))
  72. }
  73. // 同一 tx 注入 ctx:仓储读方法(db(ctx))据此复用事务快照
  74. txCtx := repository.WithTxContext(ctx, tx)
  75. ids, err := e.repo.ListExpirableSignedActivityIDs(txCtx, userID, institutionID, campusID, selfSupport)
  76. if err != nil {
  77. return nil, err
  78. }
  79. if len(ids) == 0 {
  80. return make([]int64, 0), nil
  81. }
  82. if _, err := e.repo.SetSignedStatusByActivityIDs(txCtx, tx, userID, ids, activitySignedExpired); err != nil {
  83. return nil, err
  84. }
  85. return ids, nil
  86. }
  87. // SetSignedStatus 在**当前事务内**批量置报名状态(取消充值活动的 `status=0` 等场景复用本方法)。
  88. //
  89. // 入参:ctx / tx(必传)/ userID / activityIDs / status 目标状态。
  90. // 出参:(受影响行数, error)。
  91. //
  92. // 与 ExpireForUser 的分工:本方法是"不加业务判据的纯写",判据(type=2 / status!=4 / 机构校区过滤)
  93. // 一律在 ExpireForUser 内完成——避免调用方各自拼 SQL 导致失效范围漂移。
  94. func (e *ActivityExpirer) SetSignedStatus(
  95. ctx context.Context, tx *gorm.DB, userID string, activityIDs []int64, status int32,
  96. ) (int64, error) {
  97. if e == nil || e.repo == nil {
  98. return 0, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityExpirer 未装配"))
  99. }
  100. return e.repo.SetSignedStatusByActivityIDs(repository.WithTxContext(ctx, tx), tx, userID, activityIDs, status)
  101. }