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