// 活动失效器(自定义退费 / 余额清零共用)——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) }