activity_repo.go 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847
  1. // 活动仓储(t_courses_activity / t_courses_activity_signed)——AC-06 活动报名主链路。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §3.2(活动 9 SP)/ §1.5(幂等语义演进)
  4. // / §1.7(scoping 与两条例外)/ §10.3(事务边界只在 Service)。
  5. //
  6. // 覆盖 SP 映射:
  7. //
  8. // signed_add → UpsertSigned(DELETE+INSERT 幂等,对齐 dump)
  9. // signed_cancel → UpdateSignedStatus + IncreaseNumberSign(服务层组合成状态机)
  10. // + CountSignedByUser(type4Only=false)(取消前置守卫)
  11. // sign 限一次(3106) → CountSignedByUser(type4Only=true)
  12. // 退费回滚赠送金额 → ListDiscountLogsByStudentCourse(按**流水**定位,audit A11;旧 signed←pool←log
  13. // 三表 join 的 ListSignedByStudentCourse 在 dump 中无依据,已删除)
  14. //
  15. // 作用域(关键设计,与 BaseRepo.Scoped 的差异必须读):
  16. //
  17. // t_courses_activity.campus_id = 0 的语义是「本机构所有**自营**校区」(DDL §2「7.」+ 设计 §11-2),
  18. // 因此校园维度过滤**不能**用 `campus_id = ?`,否则全机构活动会被裁掉;
  19. // 本仓储统一用 scopedCampusRows:`institution_id = ? AND campus_id IN (?, 0)`(超集语义)。
  20. // → BaseRepo.CampusScoped 置 false(避免严格等值过滤),机构过滤仍由 scopedCampusRows 强制,
  21. // 安全红线(跨机构越权)不因这条例外而放松。
  22. //
  23. // 事务纪律:写方法一律接收 `tx *gorm.DB`(**仓储不开事务**);读方法经 db(ctx) 取句柄,
  24. // ctx 内已注入事务(WithTxContext)时自动复用同一 tx,保证事务内读一致。
  25. package repository
  26. import (
  27. "context"
  28. "errors"
  29. "fmt"
  30. "strings"
  31. "gorm.io/gorm"
  32. "gorm.io/gorm/clause"
  33. "yuansiwei/unified-backend/internal/academic/model"
  34. "yuansiwei/unified-backend/internal/academic/money"
  35. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  36. appscope "yuansiwei/unified-backend/internal/infra/scope"
  37. )
  38. // 游标方向常量(源自 dump 的固定排序,调用方只能二选一,禁止拼接其它 SQL 片段)。
  39. //
  40. // 语义(dump 口径,**顺序不可改**):
  41. // - OrderAsc :按 id 升序——"消费/使用"路径(先入先出,旧 SP 游标);
  42. // - OrderDesc:按 id 降序——"退回/回滚"路径(后入先退,旧 SP 游标)。
  43. //
  44. // 安全红线:方向值来自本白名单,绝不接受调用方透传的原始字符串(防 ORDER BY 注入)。
  45. const (
  46. // OrderAsc 升序(使用路径)。
  47. OrderAsc = "asc"
  48. // OrderDesc 降序(退回路径)。
  49. OrderDesc = "desc"
  50. )
  51. // normalizeOrder 校验并归一排序方向(白名单;空串按 OrderAsc 处理)。
  52. //
  53. // 入参:order string——调用方传入的方向(大小写/空白不敏感)。
  54. // 出参:归一后的 "asc"/"desc";非法值 → infraerrors.ErrBadParam(2002)。
  55. func normalizeOrder(order string) (string, error) {
  56. switch strings.ToLower(strings.TrimSpace(order)) {
  57. case "", OrderAsc:
  58. return OrderAsc, nil
  59. case OrderDesc:
  60. return OrderDesc, nil
  61. default:
  62. return "", infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 非法排序方向 "+order))
  63. }
  64. }
  65. // scopedCampusRows 追加作用域过滤(机构强制 + 校区"含全机构池"超集)。
  66. //
  67. // 入参:
  68. // - db *gorm.DB:基础查询(nil → 返回 nil)。
  69. // - sc *appscope.Scope:请求作用域(nil → panic fail-fast,与 BaseRepo.Scoped 同口径)。
  70. // - alias string:表别名前缀(如 "signed.");JOIN 场景必须限定,否则 ambiguous column。
  71. //
  72. // 出参:*gorm.DB——追加过滤后的链式查询。
  73. //
  74. // 过滤规则:
  75. // 1. sc.SuperAdmin=true → 不加任何过滤(平台超管跨机构视角);
  76. // 2. 否则强制 `alias.institution_id = sc.InstitutionID`(安全红线,不可绕过);
  77. // 3. 仅当 sc.CampusID > 0 时追加 `alias.campus_id IN (sc.CampusID, 0)`——
  78. // 0=机构所有自营校区,属该校区可见范围(设计 §11-2)。
  79. func scopedCampusRows(db *gorm.DB, sc *appscope.Scope, alias string) *gorm.DB {
  80. if db == nil {
  81. return nil
  82. }
  83. if sc == nil {
  84. panic(errNoScope.Error())
  85. }
  86. if sc.SuperAdmin {
  87. return db
  88. }
  89. col := func(name string) string {
  90. if alias == "" {
  91. return name
  92. }
  93. return alias + "." + name
  94. }
  95. db = db.Where(col("institution_id")+" = ?", sc.InstitutionID)
  96. if sc.CampusID > 0 {
  97. // campus_id=0 = 机构所有自营校区(超集语义,不能用等值)
  98. db = db.Where(col("campus_id")+" IN ?", []int64{sc.CampusID, 0})
  99. }
  100. return db
  101. }
  102. // requireTx 校验写方法必须携带事务句柄(仓储不开事务,设计 §10.3)。
  103. //
  104. // 入参:tx *gorm.DB(可 nil)。出参:nil 合法;ErrInternal 表示调用方漏传事务。
  105. func requireTx(tx *gorm.DB, op string) error {
  106. if tx == nil {
  107. return infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: "+op+" 必须传入 tx(禁止脱离事务写资金/状态)"))
  108. }
  109. return nil
  110. }
  111. // ActivityRepo 活动方案 + 报名仓储。
  112. //
  113. // 表:t_courses_activity(活动方案)/ t_courses_activity_signed(报名·UK(user_id, activity_id))。
  114. // 作用域:两表均带 institution_id + campus_id(冗余作用域列)→ 统一走 scopedCampusRows。
  115. type ActivityRepo struct {
  116. *BaseRepo
  117. }
  118. // NewActivityRepo 构造活动仓储。
  119. //
  120. // 入参:db *gorm.DB(连接池句柄)/ sc *appscope.Scope(请求作用域;系统任务可 nil,一查询即 panic)。
  121. // 出参:*ActivityRepo。
  122. //
  123. // 说明:CampusScoped 置 false —— 校区维度由 scopedCampusRows 以 `IN (campus,0)` 超集语义处理
  124. // (见文件头「作用域」段),机构过滤不受影响。
  125. func NewActivityRepo(db *gorm.DB, sc *appscope.Scope) *ActivityRepo {
  126. r := &ActivityRepo{BaseRepo: NewBaseRepo(db, sc)}
  127. r.CampusScoped = false
  128. return r
  129. }
  130. // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。
  131. //
  132. // 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
  133. func (r *ActivityRepo) db(ctx context.Context) (*gorm.DB, error) {
  134. if r == nil || r.DB == nil {
  135. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ActivityRepo.DB 为 nil"))
  136. }
  137. if tx := TxFromContext(ctx); tx != nil {
  138. return tx, nil
  139. }
  140. if ctx == nil {
  141. return r.DB, nil
  142. }
  143. return r.DB.WithContext(ctx), nil
  144. }
  145. // GetByID 按主键取单个活动(**作用域强制**:跨机构活动一律 ErrNotFound,不泄露存在性)。
  146. //
  147. // 入参:
  148. // - ctx 请求上下文(取消/超时传播);
  149. // - id 活动 ID(<=0 → ErrBadParam,避免全表扫描)。
  150. //
  151. // 出参:
  152. // - *model.CoursesActivity:命中行(含 money 字符串原文,金额换算由 Service 用 money.Amount);
  153. // - ErrNotFound(3001):不存在或不在作用域内;
  154. // - ErrDBError(5002):SQL/驱动异常(不返回 gorm 原错)。
  155. func (r *ActivityRepo) GetByID(ctx context.Context, id int64) (*model.CoursesActivity, error) {
  156. if id <= 0 {
  157. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: activity id 必须 > 0"))
  158. }
  159. db, err := r.db(ctx)
  160. if err != nil {
  161. return nil, err
  162. }
  163. var out model.CoursesActivity
  164. q := scopedCampusRows(db.Model(&model.CoursesActivity{}), r.Sc, "")
  165. if err := q.Where("id = ?", id).First(&out).Error; err != nil {
  166. return nil, wrapQueryErr(err, "GetByID(t_courses_activity)")
  167. }
  168. return &out, nil
  169. }
  170. // GetByIDs 批量取活动(**防 N+1**:一次 IN 查询,返回 map 供调用方 O(1) 索引)。
  171. //
  172. // 入参:
  173. // - ctx 请求上下文;
  174. // - ids 活动 ID 集合(空/全非法 → 返回空 map 且**不发 SQL**)。
  175. //
  176. // 出参:
  177. // - map[int64]*model.CoursesActivity:命中行索引(**缺失的 id 不在 map 中**,调用方按需判缺失,
  178. // 语义上等价"不在作用域内或不存在",禁止据此区分存在性);
  179. // - ErrDBError(5002)。
  180. //
  181. // 实现说明:入参先去重 + 剔除 <=0(防无意义扫描);结果不保证顺序(map 语义)。
  182. func (r *ActivityRepo) GetByIDs(ctx context.Context, ids []int64) (map[int64]*model.CoursesActivity, error) {
  183. out := make(map[int64]*model.CoursesActivity, len(ids))
  184. clean := make([]int64, 0, len(ids))
  185. seen := make(map[int64]struct{}, len(ids))
  186. for _, id := range ids {
  187. if id <= 0 {
  188. continue
  189. }
  190. if _, ok := seen[id]; ok {
  191. continue
  192. }
  193. seen[id] = struct{}{}
  194. clean = append(clean, id)
  195. }
  196. if len(clean) == 0 {
  197. return out, nil
  198. }
  199. db, err := r.db(ctx)
  200. if err != nil {
  201. return nil, err
  202. }
  203. var rows []model.CoursesActivity
  204. q := scopedCampusRows(db.Model(&model.CoursesActivity{}), r.Sc, "")
  205. if err := q.Where("id IN ?", clean).Find(&rows).Error; err != nil {
  206. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  207. }
  208. for i := range rows {
  209. out[rows[i].ID] = &rows[i]
  210. }
  211. return out, nil
  212. }
  213. // UpsertSigned 报名落库(**DELETE+INSERT 幂等**,对齐 dump `signed_add`)。
  214. //
  215. // 依据:dump 的 signed_add 先 `DELETE FROM t_courses_activity_signed WHERE userid=? AND activityid=?`
  216. // 再 INSERT——同一 (user, activity) 只留一条,连历史 status=0 的旧行一并清掉(设计 §1.5)。
  217. // 目标态 UK(user_id, activity_id) 兜底;此处保留 DELETE+INSERT 语义(而非 OnConflict 覆盖),
  218. // 因为状态机要求"重新报名 = 全字段重置"(activity_money/recharge_amount/审核字段清零)。
  219. //
  220. // 入参:
  221. // - ctx 请求上下文;
  222. // - tx 事务句柄(**必传**,nil → ErrInternal);
  223. // - signed 报名行(ActivityID>0、UserID 非空;institution_id 为 0 时用请求作用域补齐)。
  224. //
  225. // 出参:nil 成功;ErrBadParam(参数非法)/ ErrDBError(SQL 异常,事务由调用方回滚)。
  226. //
  227. // 事务/一致性(红线):
  228. // - DELETE **不叠作用域**:UK(user_id, activity_id) 全局唯一,若历史迁移行的作用域列脏,
  229. // 叠作用域会漏删 → INSERT 撞 UK 直接失败;activity_id 的归属已由调用方 GetByID 作用域校验;
  230. // - campus_id **不由本方法补齐**:它必须是活动上的 campus_id 快照(0 有"全自营校区"语义,
  231. // 用请求作用域值覆盖会篡改语义)→ 由 Service 从活动行复制后传入;
  232. // - 报名人数增减(IncreaseNumberSign)由 Service 在同一事务内配套调用。
  233. func (r *ActivityRepo) UpsertSigned(ctx context.Context, tx *gorm.DB, signed *model.CoursesActivitySigned) error {
  234. if err := requireTx(tx, "UpsertSigned"); err != nil {
  235. return err
  236. }
  237. if signed == nil {
  238. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: signed 为 nil"))
  239. }
  240. if signed.ActivityID <= 0 || strings.TrimSpace(signed.UserID) == "" {
  241. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: signed 缺少 activity_id/user_id"))
  242. }
  243. if signed.InstitutionID == 0 {
  244. inst, _ := r.GetScopedID()
  245. signed.InstitutionID = inst
  246. }
  247. // ① 幂等清旧行(dump:DELETE WHERE userid AND activityid)
  248. if err := tx.Where("user_id = ? AND activity_id = ?", signed.UserID, signed.ActivityID).
  249. Delete(&model.CoursesActivitySigned{}).Error; err != nil {
  250. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  251. }
  252. // ② 插入新行(状态机全字段重置)
  253. if err := tx.WithContext(ctxOrBackground(ctx)).Create(signed).Error; err != nil {
  254. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  255. }
  256. return nil
  257. }
  258. // CountSignedByUser 统计用户报名数(**type=4 老带新限一次判定** / 取消报名的守卫条件)。
  259. //
  260. // 入参:
  261. // - ctx 请求上下文;
  262. // - userID 报名用户(canonical_user_id ULID;空 → ErrBadParam);
  263. // - type4Only 判定口径(**两分支语义不同,禁止合并**):
  264. // true = 老带新限一次(3106):只统计老带新活动(activity.type=4)**且活动未停用**(activity.status<>0),
  265. // **不过滤报名行状态**——取消过的报名也算"参加过";
  266. // false = 取消报名前置守卫(signed_cancel):统计该用户**当前有效**报名(signed.status<>0),
  267. // 不限活动类型、不看活动状态。
  268. //
  269. // 出参:
  270. // - int64:命中条数(>0 即命中对应业务规则);
  271. // - ErrBadParam / ErrDBError。
  272. //
  273. // SQL 口径(**逐字对齐 dump**,两分支条件集**互不重叠**):
  274. //
  275. // type4Only=true(dump `yuansiwei_finance.sql:762947`,老带新限一次):
  276. // SELECT COUNT(1) FROM t_courses_activity_signed t1
  277. // INNER JOIN t_courses_activity t2 ON t1.activityid=t2.id AND t2.type=4
  278. // WHERE t1.userid=? AND t2.status != 0 -- ← 只筛**活动** t2.status
  279. // (**本分支没有 `t1.status` 条件**——报名行状态一律不参与过滤,
  280. // 取消过也算"参加过",以此堵住"取消报名后重报再领老带新奖励"的重复发钱口子)
  281. //
  282. // type4Only=false(signed_cancel 第一道守卫):
  283. // SELECT count(*) FROM t_courses_activity_signed
  284. // WHERE user_id=? AND status <> 0 -- ← 这里筛的是**报名行** signed.status
  285. // (本分支不看活动表、不看活动类型/状态)
  286. //
  287. // 两个分支唯一共用的条件是 `user_id = ?`;因此**禁止**把 `signed.status <> 0` 提到分支外
  288. // (提到外面会让 type4 分支重新筛报名状态,退回 P0-1 的重复发钱缺陷)。
  289. //
  290. // 去重语义:跨活动去重由 UK(user_id, activity_id) 保证(一活动一行)。
  291. func (r *ActivityRepo) CountSignedByUser(ctx context.Context, userID string, type4Only bool) (int64, error) {
  292. if strings.TrimSpace(userID) == "" {
  293. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountSignedByUser 需要 user_id"))
  294. }
  295. db, err := r.db(ctx)
  296. if err != nil {
  297. return 0, err
  298. }
  299. q := db.Table("t_courses_activity_signed AS signed").
  300. Where("signed.user_id = ?", userID)
  301. if type4Only {
  302. // dump 口径:判**活动** status(停用=0 的活动不计),报名行状态不参与过滤
  303. q = q.Joins("JOIN t_courses_activity AS activity ON activity.id = signed.activity_id").
  304. Where("activity.type = ?", model.ActivityTypeRecommend).
  305. Where("activity.status <> ?", model.ActivityStatusDisabled)
  306. } else {
  307. // signed_cancel 守卫:只看报名行是否有效
  308. q = q.Where("signed.status <> ?", model.ActivitySignedCancelled)
  309. }
  310. q = scopedCampusRows(q, r.Sc, "signed")
  311. var count int64
  312. if err := q.Count(&count).Error; err != nil {
  313. return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  314. }
  315. return count, nil
  316. }
  317. // UpdateSignedStatus 更新报名状态(状态机推进 / 取消报名)。
  318. //
  319. // 入参:
  320. // - ctx 请求上下文;
  321. // - tx 事务句柄(**必传**);
  322. // - userID 报名用户(ULID);activityID 活动 ID;status 目标状态(model.ActivitySigned*)。
  323. //
  324. // 出参:
  325. // - int64:受影响行数(0 = 该 (user, activity) 不存在或不在作用域内,Service 据此返回 3001);
  326. // - ErrBadParam / ErrDBError。
  327. //
  328. // 说明:状态机的**合法性判定**在 Service(如 2→1 仅取消路径、已审核不可取消 3107),
  329. // 仓储只做「条件更新」,不解释状态流转规则(避免业务规则沉淀到数据层)。
  330. func (r *ActivityRepo) UpdateSignedStatus(ctx context.Context, tx *gorm.DB, userID string, activityID int64, status int32) (int64, error) {
  331. if err := requireTx(tx, "UpdateSignedStatus"); err != nil {
  332. return 0, err
  333. }
  334. if strings.TrimSpace(userID) == "" || activityID <= 0 {
  335. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateSignedStatus 需要 user_id/activity_id"))
  336. }
  337. q := scopedCampusRows(tx.Model(&model.CoursesActivitySigned{}), r.Sc, "")
  338. res := q.Where("user_id = ? AND activity_id = ?", userID, activityID).
  339. UpdateColumn("status", status)
  340. if res.Error != nil {
  341. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  342. }
  343. return res.RowsAffected, nil
  344. }
  345. // IncreaseNumberSign 报名人数原子增减(**下限 0**,防取消竞态把人数减成负数)。
  346. //
  347. // 入参:
  348. // - ctx 请求上下文;
  349. // - tx 事务句柄(**必传**);
  350. // - activityID 活动 ID(<=0 → ErrBadParam);delta 增量(报名 +1 / 取消 -1;0 → 空操作)。
  351. //
  352. // 出参:
  353. // - int64:受影响行数(0 = 活动不存在/不在作用域内);
  354. // - ErrBadParam / ErrDBError。
  355. //
  356. // SQL(跨库可移植的下限保护,不用 GREATEST/MAX 以免方言差异):
  357. //
  358. // UPDATE t_courses_activity
  359. // SET number_sign = CASE WHEN number_sign + <delta> < 0 THEN 0 ELSE number_sign + <delta> END
  360. // WHERE id = ? [AND institution_id = ? AND campus_id IN (?,0)]
  361. //
  362. // 红线:与 UpsertSigned/UpdateSignedStatus **必须同事务**(否则出现"报名行在、人数没加"的脏读)。
  363. func (r *ActivityRepo) IncreaseNumberSign(ctx context.Context, tx *gorm.DB, activityID int64, delta int32) (int64, error) {
  364. if err := requireTx(tx, "IncreaseNumberSign"); err != nil {
  365. return 0, err
  366. }
  367. if activityID <= 0 {
  368. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: IncreaseNumberSign 需要 activity_id"))
  369. }
  370. if delta == 0 {
  371. return 0, nil
  372. }
  373. expr := gorm.Expr("CASE WHEN number_sign + ? < 0 THEN 0 ELSE number_sign + ? END", delta, delta)
  374. q := scopedCampusRows(tx.Model(&model.CoursesActivity{}), r.Sc, "")
  375. res := q.Where("id = ?", activityID).UpdateColumn("number_sign", expr)
  376. if res.Error != nil {
  377. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  378. }
  379. return res.RowsAffected, nil
  380. }
  381. // CancelSigned 取消报名并**归零赠送/活动金额**(dump `yuansiwei_finance.sql:763028`)。
  382. //
  383. // 入参:
  384. // - ctx 请求上下文;
  385. // - tx 事务句柄(**必传**:与报名人数增减、赠送池回滚同事务);
  386. // - userID 报名用户(ULID;空 → ErrBadParam);activityID 活动 ID(<=0 → ErrBadParam)。
  387. //
  388. // 出参:
  389. // - int64:受影响行数(0 = 该 (user, activity) 不存在或不在作用域内 → Service 返回 3001);
  390. // - ErrBadParam / ErrDBError。
  391. //
  392. // SQL(dump 原文:一条 UPDATE 同置三列,**不可拆成多次更新**):
  393. //
  394. // UPDATE t_courses_activity_signed
  395. // SET status = 0, activity_money = 0, activity_money_used = 0
  396. // WHERE user_id = ? AND activity_id = ? [AND institution_id = ? AND campus_id IN (?,0)]
  397. //
  398. // 红线:
  399. // - 三列**必须同一条语句归零**——分步更新会在中间态出现"已取消但金额仍在",被对账/双跑读到;
  400. // - 审核推进(status 单向推进、不动金额)仍走 `UpdateSignedStatus`,两者用途不可混;
  401. // - 归零是"取消"语义的一部分(dump 明确 SET 0),金额不会回补到别处,故调用方必须已做业务校验
  402. // (如老带新已发奖励 → 3108 不可撤销,判定在 Service)。
  403. func (r *ActivityRepo) CancelSigned(ctx context.Context, tx *gorm.DB, userID string, activityID int64) (int64, error) {
  404. if err := requireTx(tx, "CancelSigned"); err != nil {
  405. return 0, err
  406. }
  407. if strings.TrimSpace(userID) == "" || activityID <= 0 {
  408. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  409. errString("repository: CancelSigned 需要 user_id/activity_id"))
  410. }
  411. q := scopedCampusRows(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivitySigned{}), r.Sc, "")
  412. res := q.Where("user_id = ? AND activity_id = ?", userID, activityID).
  413. UpdateColumns(map[string]any{
  414. "status": model.ActivitySignedCancelled,
  415. "activity_money": "0.00",
  416. "activity_money_used": "0.00",
  417. })
  418. if res.Error != nil {
  419. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  420. }
  421. return res.RowsAffected, nil
  422. }
  423. // ListExpirableSignedActivityIDs 列出「退费/清零需要失效」的充值活动 ID(SP9/SP10 同源口径)。
  424. //
  425. // 依据:dump `sp_user_expense_custom_refund`(yuansiwei_finance.sql:764062)与
  426. // `sp_user_expense_school_cleared_balance`(:764338)**逐字相同**的两段临时表填充 SQL,
  427. // 只差最后一行的校区过滤——这正是"自营 / 加盟"分流的唯一区别(设计 §7.2 ④ / §11-2)。
  428. //
  429. // dump 原文(自营分支,`IF(campusSelfSupport)`):
  430. //
  431. // SELECT t1.activityid FROM t_courses_activity_signed AS t1
  432. // LEFT JOIN t_courses_activity AS t2 ON t1.activityid = t2.id
  433. // WHERE t1.userid = ? -- 报名用户
  434. // AND t2.type = 2 -- **只失效充值赠送活动**(代金券/老带新不动)
  435. // AND t1.status != 4 -- 已过期的不重复处理
  436. // AND t2.schoolid = ? -- 机构(旧口径 schoolid → institution_id)
  437. // AND t2.campusid IN (?, 0) -- 自营:本校区 + 全机构池(campus_id=0)
  438. // -- 加盟分支:AND t2.campusid = ? (不含 0=全机构池)
  439. //
  440. // 入参:
  441. // - ctx 请求上下文(可携带事务,实现内部经 db(ctx) 复用同一 tx);
  442. // - userID 报名用户(ULID;空 → ErrBadParam);
  443. // - institutionID 机构 ID(**必填 >0**;与 scope 机构不一致 → ErrUnscoped(1004));
  444. // - campusID 校区 ID(**必填 >0**;dump 的 `campusid<=0` 无意义);
  445. // - selfSupport true=自营(`IN (campus,0)`)/ false=加盟(`= campus`)。
  446. //
  447. // 出参:
  448. // - []int64:活动 ID(**去重 + 升序**,空 → 长度 0 的非 nil 切片);
  449. // - ErrBadParam / ErrUnscoped / ErrDBError。
  450. //
  451. // 红线:
  452. // - **不得**把 `t2.campusid` 的过滤写成 signed 上的 `campus_id`:活动 `campus_id=0`
  453. // 的语义是"机构所有自营校区",用 signed 的冗余列会漏掉这一类活动;
  454. // - 机构过滤必须落在 `activity.institution_id`(dump 的 `t2.schoolid`),不可省略。
  455. func (r *ActivityRepo) ListExpirableSignedActivityIDs(
  456. ctx context.Context, userID string, institutionID, campusID int64, selfSupport bool,
  457. ) ([]int64, error) {
  458. if strings.TrimSpace(userID) == "" {
  459. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  460. errString("repository: ListExpirableSignedActivityIDs 需要 user_id"))
  461. }
  462. if institutionID <= 0 || campusID <= 0 {
  463. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  464. errString("repository: ListExpirableSignedActivityIDs 需要 institution_id>0 且 campus_id>0"))
  465. }
  466. // 机构越权防线(dump 无此校验;目标态多租户红线,与 scopedCampusRows 同口径)
  467. if r == nil || r.Sc == nil {
  468. panic(errNoScope.Error())
  469. }
  470. if !r.Sc.SuperAdmin && r.Sc.InstitutionID != institutionID {
  471. return nil, infraerrors.WithCause(infraerrors.ErrUnscoped,
  472. errString("repository: 活动失效的机构与请求作用域不一致"))
  473. }
  474. db, err := r.db(ctx)
  475. if err != nil {
  476. return nil, err
  477. }
  478. q := db.Table("t_courses_activity_signed AS signed").
  479. Joins("JOIN t_courses_activity AS activity ON activity.id = signed.activity_id").
  480. Where("signed.user_id = ?", userID).
  481. Where("activity.type = ?", model.ActivityTypeRecharge).
  482. Where("signed.status <> ?", model.ActivitySignedExpired).
  483. Where("activity.institution_id = ?", institutionID)
  484. if selfSupport {
  485. // 自营:本校区 + 全机构池(campus_id=0)
  486. q = q.Where("activity.campus_id IN ?", []int64{campusID, 0})
  487. } else {
  488. // 加盟:只能动本校区自己的活动(不含 0=全机构池)
  489. q = q.Where("activity.campus_id = ?", campusID)
  490. }
  491. ids := make([]int64, 0, 8)
  492. if err := q.Distinct().Order("signed.activity_id ASC").Pluck("signed.activity_id", &ids).Error; err != nil {
  493. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  494. }
  495. if ids == nil {
  496. ids = make([]int64, 0)
  497. }
  498. return ids, nil
  499. }
  500. // SetSignedStatusByActivityIDs 批量置报名状态(活动失效 status=4 / 取消充值活动 status=0)。
  501. //
  502. // 入参:
  503. // - ctx 请求上下文;
  504. // - tx 事务句柄(**必传**:与资金增减同事务,否则出现"钱退了、活动还能用");
  505. // - userID 报名用户(ULID;空 → ErrBadParam);
  506. // - activityIDs 活动 ID 集合(空 → no-op 返回 0,不发 SQL);
  507. // - status 目标状态(model.ActivitySigned*)。
  508. //
  509. // 出参:(受影响行数, error);ErrBadParam / ErrInternal / ErrDBError。
  510. //
  511. // SQL(dump 原文,**不带 status 过滤、不带作用域过滤**):
  512. //
  513. // UPDATE t_courses_activity_signed SET status = ? WHERE user_id = ? AND activity_id IN (...)
  514. //
  515. // 为什么不带作用域过滤:候选集合已由 `ListExpirableSignedActivityIDs` 按
  516. // `activity.institution_id + activity.campus_id` 精确筛出(比 signed 的冗余列更准),
  517. // 再叠一层 signed 的 campus_id 等值会把 `campus_id=0` 的活动报名行漏掉。
  518. // 机构越权风险为 0:入参 activityIDs 只能来自上面的受控查询。
  519. func (r *ActivityRepo) SetSignedStatusByActivityIDs(
  520. ctx context.Context, tx *gorm.DB, userID string, activityIDs []int64, status int32,
  521. ) (int64, error) {
  522. if err := requireTx(tx, "SetSignedStatusByActivityIDs"); err != nil {
  523. return 0, err
  524. }
  525. if strings.TrimSpace(userID) == "" {
  526. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  527. errString("repository: SetSignedStatusByActivityIDs 需要 user_id"))
  528. }
  529. clean := make([]int64, 0, len(activityIDs))
  530. for _, id := range activityIDs {
  531. if id > 0 {
  532. clean = append(clean, id)
  533. }
  534. }
  535. if len(clean) == 0 {
  536. return 0, nil
  537. }
  538. res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivitySigned{}).
  539. Where("user_id = ? AND activity_id IN ?", userID, clean).
  540. UpdateColumn("status", status)
  541. if res.Error != nil {
  542. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  543. }
  544. return res.RowsAffected, nil
  545. }
  546. // wrapQueryErr 把查询错误归一为 academic 错误契约(不存在 → ErrNotFound,其余 → ErrDBError)。
  547. //
  548. // 入参:err 原生错误;op 操作名(仅用于日志关联,不进入响应体)。
  549. // 出参:*infraerrors.AppError(ErrNotFound / ErrDBError)。
  550. func wrapQueryErr(err error, op string) error {
  551. if err == nil {
  552. return nil
  553. }
  554. if errors.Is(err, gorm.ErrRecordNotFound) {
  555. return infraerrors.WithCause(infraerrors.ErrNotFound, err)
  556. }
  557. return infraerrors.WithCause(infraerrors.ErrDBError, fmt.Errorf("%s: %w", op, err))
  558. }
  559. // ---------------------------------------------------------------- AC-07 / AC-08 原语
  560. // SignedWithType 报名行 + 活动类型的联合视图(dump `signed_cancel` 游标等价物,762982)。
  561. //
  562. // 字段:
  563. // - SignedID 报名行主键(取消时按它定位,dump 的 `WHERE id=userSignedid`);
  564. // - Status 报名状态(model.ActivitySigned*);
  565. // - ResponseUserID 审核人(非空/非 "0" = 已审核 → 3107 判据);
  566. // - ActivityType 活动类型(model.ActivityType*;决定 3107 还是 3108 分支)。
  567. type SignedWithType struct {
  568. // SignedID 报名行 ID。
  569. SignedID int64 `gorm:"column:signed_id"`
  570. // ActivityID 活动 ID。
  571. ActivityID int64 `gorm:"column:activity_id"`
  572. // UserID 报名用户(ULID)。
  573. UserID string `gorm:"column:user_id"`
  574. // Status 报名状态。
  575. Status int32 `gorm:"column:status"`
  576. // ResponseUserID 审核人(NULL/空串/"0" = 未审核)。
  577. ResponseUserID *string `gorm:"column:response_user_id"`
  578. // ActivityType 活动类型。
  579. ActivityType int8 `gorm:"column:activity_type"`
  580. // ActivityMoney 活动金额快照(元字符串)。
  581. ActivityMoney string `gorm:"column:activity_money"`
  582. // ActivityMoneyUsed 已使用活动金额(元字符串)。
  583. ActivityMoneyUsed string `gorm:"column:activity_money_used"`
  584. // InstitutionID 报名行冗余机构(作用域列)。
  585. InstitutionID int64 `gorm:"column:institution_id"`
  586. // CampusID 报名行冗余校区(作用域列)。
  587. CampusID int64 `gorm:"column:campus_id"`
  588. }
  589. // ExistsSigned 判断用户对该活动是否**已经报过名**(dump 762928 → **3105**)。
  590. //
  591. // SQL(dump 原文,**逐字**):
  592. //
  593. // SELECT COUNT(1) FROM t_courses_activity_signed
  594. // WHERE userid = ? AND activityid = ? AND status != 0
  595. //
  596. // 语义(与 3106 相反,两条判据不得互换):判的是**报名行**状态 ⇒ `status=0`(已取消)
  597. // 不算"已报名" ⇒ **取消后可以重新报名同一活动**。
  598. //
  599. // 为什么不叠作用域过滤:`(user_id, activity_id)` 是全局 UK,报名行的归属已由调用方
  600. // `GetByID`(带作用域)确认;此处若叠 `campus_id IN (?,0)` 而历史行的冗余校区列脏,
  601. // 会漏判 → INSERT 撞 UK 直接 5002(比 3105 更难解释)。
  602. //
  603. // 入参:ctx / userID(空 → ErrBadParam)/ activityID(<=0 → ErrBadParam)。
  604. // 出参:(是否已报名, error);ErrBadParam / ErrDBError。
  605. func (r *ActivityRepo) ExistsSigned(ctx context.Context, userID string, activityID int64) (bool, error) {
  606. if strings.TrimSpace(userID) == "" || activityID <= 0 {
  607. return false, infraerrors.WithCause(infraerrors.ErrBadParam,
  608. errString("repository: ExistsSigned 需要 user_id 且 activity_id>0"))
  609. }
  610. db, err := r.db(ctx)
  611. if err != nil {
  612. return false, err
  613. }
  614. var count int64
  615. if err := db.Model(&model.CoursesActivitySigned{}).
  616. Where("user_id = ? AND activity_id = ? AND status <> ?",
  617. userID, activityID, model.ActivitySignedCancelled).
  618. Count(&count).Error; err != nil {
  619. return false, infraerrors.WithCause(infraerrors.ErrDBError, err)
  620. }
  621. return count > 0, nil
  622. }
  623. // GetSigned 取用户在某活动上的**有效**报名行 + 活动类型(dump `signed_cancel` 游标等价物)。
  624. //
  625. // SQL(dump 762982 原文):
  626. //
  627. // SELECT t1.id,t1.status,t1.response_userid,t2.type FROM t_courses_activity_signed t1
  628. // INNER JOIN t_courses_activity t2 ON t1.activityid=t2.id
  629. // WHERE t1.userid=? AND t1.activityid=? AND t1.status!=0
  630. //
  631. // 入参:ctx / userID(空 → ErrBadParam)/ activityID(<=0 → ErrBadParam)。
  632. // 出参:*SignedWithType(命中行);ErrNotFound(无有效报名行 → 调用方映射 3001);ErrDBError。
  633. //
  634. // 作用域:与 dump 一致**不叠机构/校区过滤**(活动归属已由 `GetByID` 校验;叠 signed 的
  635. // 冗余校区列会在 `campus_id=0` 的活动上漏判)。
  636. func (r *ActivityRepo) GetSigned(ctx context.Context, userID string, activityID int64) (*SignedWithType, error) {
  637. if strings.TrimSpace(userID) == "" || activityID <= 0 {
  638. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  639. errString("repository: GetSigned 需要 user_id 且 activity_id>0"))
  640. }
  641. db, err := r.db(ctx)
  642. if err != nil {
  643. return nil, err
  644. }
  645. var out SignedWithType
  646. q := db.Table("t_courses_activity_signed AS signed").
  647. Select("signed.id AS signed_id, signed.activity_id, signed.user_id, signed.status, "+
  648. "signed.response_user_id, signed.activity_money, signed.activity_money_used, "+
  649. "signed.institution_id, signed.campus_id, activity.type AS activity_type").
  650. Joins("JOIN t_courses_activity AS activity ON activity.id = signed.activity_id").
  651. Where("signed.user_id = ? AND signed.activity_id = ? AND signed.status <> ?",
  652. userID, activityID, model.ActivitySignedCancelled)
  653. if err := q.Take(&out).Error; err != nil {
  654. return nil, wrapQueryErr(err, "GetSigned(t_courses_activity_signed)")
  655. }
  656. return &out, nil
  657. }
  658. // IncSignedActivityMoneyUsed 原子增减报名行的「已使用活动金额」(dump 763269 / 762854)。
  659. //
  660. // 入参:
  661. // - ctx / tx(**必传**:与 used_log 写入同事务);
  662. // - userID / activityID 定位报名行(dump 的 `WHERE activityid=? AND userid=?`);
  663. // - delta 增量(分:使用=+,退回=−;0 → no-op)。
  664. //
  665. // 出参:(受影响行数, error);0 = 报名行不存在(dump 静默 0 行更新);ErrBadParam / ErrDBError。
  666. //
  667. // SQL:`UPDATE t_courses_activity_signed SET activity_money_used = activity_money_used + ?
  668. // WHERE user_id = ? AND activity_id = ?`(UK 唯一命中一行,等价于 dump 的按 id 更新)。
  669. //
  670. // 红线:**不做截断**(`used <= money` 的业务约束由 Service 用剩余额校验后给定 temp);
  671. // 静默截断会破坏 `activity_money_used == |Σ used_log|` 的对账恒等式。
  672. func (r *ActivityRepo) IncSignedActivityMoneyUsed(
  673. ctx context.Context, tx *gorm.DB, userID string, activityID int64, delta money.Amount,
  674. ) (int64, error) {
  675. if err := requireTx(tx, "IncSignedActivityMoneyUsed"); err != nil {
  676. return 0, err
  677. }
  678. if strings.TrimSpace(userID) == "" || activityID <= 0 {
  679. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  680. errString("repository: IncSignedActivityMoneyUsed 需要 user_id 且 activity_id>0"))
  681. }
  682. if delta.IsZero() {
  683. return 0, nil
  684. }
  685. if err := delta.Validate(); err != nil {
  686. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  687. }
  688. res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivitySigned{}).
  689. Where("user_id = ? AND activity_id = ?", userID, activityID).
  690. UpdateColumn("activity_money_used", gorm.Expr("activity_money_used + ?", delta.String()))
  691. if res.Error != nil {
  692. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  693. }
  694. return res.RowsAffected, nil
  695. }
  696. // SetSignedSettled 老带新结算落库(dump 763366:`SET activity_money_used=activityMoney, status=2`)。
  697. //
  698. // 入参:ctx / tx(必传)/ signedID(>0)/ used 已使用活动金额(= 活动金额)/ status 目标状态。
  699. // 出参:(受影响行数, error);0 = 行不存在(调用方按 3001 处理)。
  700. //
  701. // 为什么两列同一条 UPDATE:dump 就是一条语句(`SET activity_money_used=...,status=...`),
  702. // 拆开会出现"金额已登、状态未推进"的中间态被并发对账读到。
  703. func (r *ActivityRepo) SetSignedSettled(
  704. ctx context.Context, tx *gorm.DB, signedID int64, used money.Amount, status int32,
  705. ) (int64, error) {
  706. if err := requireTx(tx, "SetSignedSettled"); err != nil {
  707. return 0, err
  708. }
  709. if signedID <= 0 {
  710. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SetSignedSettled 需要 signed_id"))
  711. }
  712. if err := used.Validate(); err != nil {
  713. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  714. }
  715. res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivitySigned{}).
  716. Where("id = ?", signedID).
  717. UpdateColumns(map[string]any{
  718. "activity_money_used": used.String(),
  719. "status": status,
  720. })
  721. if res.Error != nil {
  722. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  723. }
  724. return res.RowsAffected, nil
  725. }
  726. // RecommendSignedRow 老带新结算的候选行(dump 763329 游标等价物)。
  727. //
  728. // 字段:SignedID 报名行;ActivityID/ActivityTitle/ActivityMoney/InstitutionID/CampusID 取**活动**;
  729. // RecommendUserID 取报名行(NULL = 没填推荐人 → 跳过)。
  730. type RecommendSignedRow struct {
  731. // SignedID 报名行 ID。
  732. SignedID int64 `gorm:"column:signed_id"`
  733. // ActivityID 活动 ID。
  734. ActivityID int64 `gorm:"column:activity_id"`
  735. // ActivityTitle 活动标题(拼 remark 用;NULL → 空串)。
  736. ActivityTitle *string `gorm:"column:activity_title"`
  737. // RecommendUserID 推荐人(NULL/空串/"0" = 无推荐人)。
  738. RecommendUserID *string `gorm:"column:recommend_user_id"`
  739. // ActivityMoney 活动金额快照(元字符串)。
  740. ActivityMoney string `gorm:"column:activity_money"`
  741. // InstitutionID 活动机构 ID(0 → 调用方回查 t_courses_student)。
  742. InstitutionID int64 `gorm:"column:institution_id"`
  743. // CampusID 活动校区 ID(0 → 调用方回查 t_courses_student)。
  744. CampusID int64 `gorm:"column:campus_id"`
  745. }
  746. // ListRecommendSignedByUser 取用户全部**老带新**报名行(dump `use_activity_recommend` 游标,763329)。
  747. //
  748. // SQL(dump 原文,**逐字**):
  749. //
  750. // SELECT t1.id,t1.activityid,t1.recommend_userid,t1.activity_money,t2.schoolid,t2.campusid,t2.title
  751. // FROM t_courses_activity_signed t1
  752. // INNER JOIN t_courses_activity t2 ON t1.activityid=t2.id AND t2.type=4
  753. // WHERE t1.userid = ?
  754. //
  755. // ★ 与 3106 判据的区别(**不得合并**):这里**不过滤 `signed.status`**(取消过也算)。
  756. //
  757. // 排序:dump 游标无 `ORDER BY`(InnoDB 等值扫描≈主键序),Go 显式 `ORDER BY signed.id ASC`
  758. // 保跨引擎确定性;Service 只取**第一行**(dump 的 `LEAVE readLoop` 语义)。
  759. //
  760. // 作用域:**不叠机构/校区过滤**(dump 无;学生可能在多机构参加过老带新)。
  761. // 多租户防线:写在 `activity.institution_id` 目标账户上(机构取自活动行,不由入参决定)。
  762. //
  763. // 入参:ctx / userID(空 → ErrBadParam)。
  764. // 出参:[]RecommendSignedRow(空 → 长度 0 的非 nil 切片);ErrBadParam / ErrDBError。
  765. func (r *ActivityRepo) ListRecommendSignedByUser(ctx context.Context, userID string) ([]RecommendSignedRow, error) {
  766. if strings.TrimSpace(userID) == "" {
  767. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  768. errString("repository: ListRecommendSignedByUser 需要 user_id"))
  769. }
  770. db, err := r.db(ctx)
  771. if err != nil {
  772. return nil, err
  773. }
  774. rows := make([]RecommendSignedRow, 0, 4)
  775. q := db.Table("t_courses_activity_signed AS signed").
  776. Select("signed.id AS signed_id, signed.activity_id, signed.recommend_user_id, "+
  777. "signed.activity_money, activity.title AS activity_title, "+
  778. "activity.institution_id, activity.campus_id").
  779. Joins("JOIN t_courses_activity AS activity ON activity.id = signed.activity_id AND activity.type = ?",
  780. model.ActivityTypeRecommend).
  781. Where("signed.user_id = ?", userID).
  782. Order("signed.id ASC")
  783. if err := q.Find(&rows).Error; err != nil {
  784. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  785. }
  786. return rows, nil
  787. }
  788. // GetByIDForUpdate 按主键取活动并**加行锁**(报名人数上限判定用,3141 是 Go 增强)。
  789. //
  790. // 为什么需要:`number_sign >= number_max` 的判定必须在事务内且与 `number_sign+1` 串行,
  791. // 否则并发报名会一起通过校验(超卖)。dump **没有**这条校验(§3.8 未登记该项),
  792. // 故这是纯 Go 增强,只影响"人数已满"场景,不改变 dump 已有分支的输出。
  793. //
  794. // 入参:ctx / tx(**必传**)/ id(<=0 → ErrBadParam)。
  795. // 出参:*model.CoursesActivity(作用域强制,跨机构 → ErrNotFound);ErrBadParam / ErrNotFound / ErrDBError。
  796. func (r *ActivityRepo) GetByIDForUpdate(ctx context.Context, tx *gorm.DB, id int64) (*model.CoursesActivity, error) {
  797. if err := requireTx(tx, "GetByIDForUpdate"); err != nil {
  798. return nil, err
  799. }
  800. if id <= 0 {
  801. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetByIDForUpdate 需要 id>0"))
  802. }
  803. var out model.CoursesActivity
  804. q := scopedCampusRows(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivity{}), r.Sc, "")
  805. if err := q.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
  806. Where("id = ?", id).Take(&out).Error; err != nil {
  807. return nil, wrapQueryErr(err, "GetByIDForUpdate(t_courses_activity)")
  808. }
  809. return &out, nil
  810. }
  811. // ctxOrBackground 保证传给 gorm 的 ctx 非 nil(gorm.WithContext(nil) 会 panic)。
  812. func ctxOrBackground(ctx context.Context) context.Context {
  813. if ctx != nil {
  814. return ctx
  815. }
  816. return context.Background()
  817. }