activity_repo.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  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. // sign 限一次(3106) → CountSignedByUser(type4Only=true)
  11. // 退费回滚赠送金额 → ListSignedByStudentCourse(按报课记录定位报名行)
  12. //
  13. // 作用域(关键设计,与 BaseRepo.Scoped 的差异必须读):
  14. //
  15. // t_courses_activity.campus_id = 0 的语义是「本机构所有**自营**校区」(DDL §2「7.」+ 设计 §11-2),
  16. // 因此校园维度过滤**不能**用 `campus_id = ?`,否则全机构活动会被裁掉;
  17. // 本仓储统一用 scopedCampusRows:`institution_id = ? AND campus_id IN (?, 0)`(超集语义)。
  18. // → BaseRepo.CampusScoped 置 false(避免严格等值过滤),机构过滤仍由 scopedCampusRows 强制,
  19. // 安全红线(跨机构越权)不因这条例外而放松。
  20. //
  21. // 事务纪律:写方法一律接收 `tx *gorm.DB`(**仓储不开事务**);读方法经 db(ctx) 取句柄,
  22. // ctx 内已注入事务(WithTxContext)时自动复用同一 tx,保证事务内读一致。
  23. package repository
  24. import (
  25. "context"
  26. "errors"
  27. "fmt"
  28. "strings"
  29. "time"
  30. "gorm.io/gorm"
  31. "yuansiwei/unified-backend/internal/academic/model"
  32. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  33. appscope "yuansiwei/unified-backend/internal/infra/scope"
  34. )
  35. // 游标方向常量(源自 dump 的固定排序,调用方只能二选一,禁止拼接其它 SQL 片段)。
  36. //
  37. // 语义(dump 口径,**顺序不可改**):
  38. // - OrderAsc :按 id 升序——"消费/使用"路径(先入先出,旧 SP 游标);
  39. // - OrderDesc:按 id 降序——"退回/回滚"路径(后入先退,旧 SP 游标)。
  40. //
  41. // 安全红线:方向值来自本白名单,绝不接受调用方透传的原始字符串(防 ORDER BY 注入)。
  42. const (
  43. // OrderAsc 升序(使用路径)。
  44. OrderAsc = "asc"
  45. // OrderDesc 降序(退回路径)。
  46. OrderDesc = "desc"
  47. )
  48. // normalizeOrder 校验并归一排序方向(白名单;空串按 OrderAsc 处理)。
  49. //
  50. // 入参:order string——调用方传入的方向(大小写/空白不敏感)。
  51. // 出参:归一后的 "asc"/"desc";非法值 → infraerrors.ErrBadParam(2002)。
  52. func normalizeOrder(order string) (string, error) {
  53. switch strings.ToLower(strings.TrimSpace(order)) {
  54. case "", OrderAsc:
  55. return OrderAsc, nil
  56. case OrderDesc:
  57. return OrderDesc, nil
  58. default:
  59. return "", infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 非法排序方向 "+order))
  60. }
  61. }
  62. // scopedCampusRows 追加作用域过滤(机构强制 + 校区"含全机构池"超集)。
  63. //
  64. // 入参:
  65. // - db *gorm.DB:基础查询(nil → 返回 nil)。
  66. // - sc *appscope.Scope:请求作用域(nil → panic fail-fast,与 BaseRepo.Scoped 同口径)。
  67. // - alias string:表别名前缀(如 "signed.");JOIN 场景必须限定,否则 ambiguous column。
  68. //
  69. // 出参:*gorm.DB——追加过滤后的链式查询。
  70. //
  71. // 过滤规则:
  72. // 1. sc.SuperAdmin=true → 不加任何过滤(平台超管跨机构视角);
  73. // 2. 否则强制 `alias.institution_id = sc.InstitutionID`(安全红线,不可绕过);
  74. // 3. 仅当 sc.CampusID > 0 时追加 `alias.campus_id IN (sc.CampusID, 0)`——
  75. // 0=机构所有自营校区,属该校区可见范围(设计 §11-2)。
  76. func scopedCampusRows(db *gorm.DB, sc *appscope.Scope, alias string) *gorm.DB {
  77. if db == nil {
  78. return nil
  79. }
  80. if sc == nil {
  81. panic(errNoScope.Error())
  82. }
  83. if sc.SuperAdmin {
  84. return db
  85. }
  86. col := func(name string) string {
  87. if alias == "" {
  88. return name
  89. }
  90. return alias + "." + name
  91. }
  92. db = db.Where(col("institution_id")+" = ?", sc.InstitutionID)
  93. if sc.CampusID > 0 {
  94. // campus_id=0 = 机构所有自营校区(超集语义,不能用等值)
  95. db = db.Where(col("campus_id")+" IN ?", []int64{sc.CampusID, 0})
  96. }
  97. return db
  98. }
  99. // requireTx 校验写方法必须携带事务句柄(仓储不开事务,设计 §10.3)。
  100. //
  101. // 入参:tx *gorm.DB(可 nil)。出参:nil 合法;ErrInternal 表示调用方漏传事务。
  102. func requireTx(tx *gorm.DB, op string) error {
  103. if tx == nil {
  104. return infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: "+op+" 必须传入 tx(禁止脱离事务写资金/状态)"))
  105. }
  106. return nil
  107. }
  108. // ActivityRepo 活动方案 + 报名仓储。
  109. //
  110. // 表:t_courses_activity(活动方案)/ t_courses_activity_signed(报名·UK(user_id, activity_id))。
  111. // 作用域:两表均带 institution_id + campus_id(冗余作用域列)→ 统一走 scopedCampusRows。
  112. type ActivityRepo struct {
  113. *BaseRepo
  114. }
  115. // NewActivityRepo 构造活动仓储。
  116. //
  117. // 入参:db *gorm.DB(连接池句柄)/ sc *appscope.Scope(请求作用域;系统任务可 nil,一查询即 panic)。
  118. // 出参:*ActivityRepo。
  119. //
  120. // 说明:CampusScoped 置 false —— 校区维度由 scopedCampusRows 以 `IN (campus,0)` 超集语义处理
  121. // (见文件头「作用域」段),机构过滤不受影响。
  122. func NewActivityRepo(db *gorm.DB, sc *appscope.Scope) *ActivityRepo {
  123. r := &ActivityRepo{BaseRepo: NewBaseRepo(db, sc)}
  124. r.CampusScoped = false
  125. return r
  126. }
  127. // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。
  128. //
  129. // 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
  130. func (r *ActivityRepo) db(ctx context.Context) (*gorm.DB, error) {
  131. if r == nil || r.DB == nil {
  132. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ActivityRepo.DB 为 nil"))
  133. }
  134. if tx := TxFromContext(ctx); tx != nil {
  135. return tx, nil
  136. }
  137. if ctx == nil {
  138. return r.DB, nil
  139. }
  140. return r.DB.WithContext(ctx), nil
  141. }
  142. // GetByID 按主键取单个活动(**作用域强制**:跨机构活动一律 ErrNotFound,不泄露存在性)。
  143. //
  144. // 入参:
  145. // - ctx 请求上下文(取消/超时传播);
  146. // - id 活动 ID(<=0 → ErrBadParam,避免全表扫描)。
  147. //
  148. // 出参:
  149. // - *model.CoursesActivity:命中行(含 money 字符串原文,金额换算由 Service 用 money.Amount);
  150. // - ErrNotFound(3001):不存在或不在作用域内;
  151. // - ErrDBError(5002):SQL/驱动异常(不返回 gorm 原错)。
  152. func (r *ActivityRepo) GetByID(ctx context.Context, id int64) (*model.CoursesActivity, error) {
  153. if id <= 0 {
  154. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: activity id 必须 > 0"))
  155. }
  156. db, err := r.db(ctx)
  157. if err != nil {
  158. return nil, err
  159. }
  160. var out model.CoursesActivity
  161. q := scopedCampusRows(db.Model(&model.CoursesActivity{}), r.Sc, "")
  162. if err := q.Where("id = ?", id).First(&out).Error; err != nil {
  163. return nil, wrapQueryErr(err, "GetByID(t_courses_activity)")
  164. }
  165. return &out, nil
  166. }
  167. // GetByIDs 批量取活动(**防 N+1**:一次 IN 查询,返回 map 供调用方 O(1) 索引)。
  168. //
  169. // 入参:
  170. // - ctx 请求上下文;
  171. // - ids 活动 ID 集合(空/全非法 → 返回空 map 且**不发 SQL**)。
  172. //
  173. // 出参:
  174. // - map[int64]*model.CoursesActivity:命中行索引(**缺失的 id 不在 map 中**,调用方按需判缺失,
  175. // 语义上等价"不在作用域内或不存在",禁止据此区分存在性);
  176. // - ErrDBError(5002)。
  177. //
  178. // 实现说明:入参先去重 + 剔除 <=0(防无意义扫描);结果不保证顺序(map 语义)。
  179. func (r *ActivityRepo) GetByIDs(ctx context.Context, ids []int64) (map[int64]*model.CoursesActivity, error) {
  180. out := make(map[int64]*model.CoursesActivity, len(ids))
  181. clean := make([]int64, 0, len(ids))
  182. seen := make(map[int64]struct{}, len(ids))
  183. for _, id := range ids {
  184. if id <= 0 {
  185. continue
  186. }
  187. if _, ok := seen[id]; ok {
  188. continue
  189. }
  190. seen[id] = struct{}{}
  191. clean = append(clean, id)
  192. }
  193. if len(clean) == 0 {
  194. return out, nil
  195. }
  196. db, err := r.db(ctx)
  197. if err != nil {
  198. return nil, err
  199. }
  200. var rows []model.CoursesActivity
  201. q := scopedCampusRows(db.Model(&model.CoursesActivity{}), r.Sc, "")
  202. if err := q.Where("id IN ?", clean).Find(&rows).Error; err != nil {
  203. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  204. }
  205. for i := range rows {
  206. out[rows[i].ID] = &rows[i]
  207. }
  208. return out, nil
  209. }
  210. // UpsertSigned 报名落库(**DELETE+INSERT 幂等**,对齐 dump `signed_add`)。
  211. //
  212. // 依据:dump 的 signed_add 先 `DELETE FROM t_courses_activity_signed WHERE userid=? AND activityid=?`
  213. // 再 INSERT——同一 (user, activity) 只留一条,连历史 status=0 的旧行一并清掉(设计 §1.5)。
  214. // 目标态 UK(user_id, activity_id) 兜底;此处保留 DELETE+INSERT 语义(而非 OnConflict 覆盖),
  215. // 因为状态机要求"重新报名 = 全字段重置"(activity_money/recharge_amount/审核字段清零)。
  216. //
  217. // 入参:
  218. // - ctx 请求上下文;
  219. // - tx 事务句柄(**必传**,nil → ErrInternal);
  220. // - signed 报名行(ActivityID>0、UserID 非空;institution_id 为 0 时用请求作用域补齐)。
  221. //
  222. // 出参:nil 成功;ErrBadParam(参数非法)/ ErrDBError(SQL 异常,事务由调用方回滚)。
  223. //
  224. // 事务/一致性(红线):
  225. // - DELETE **不叠作用域**:UK(user_id, activity_id) 全局唯一,若历史迁移行的作用域列脏,
  226. // 叠作用域会漏删 → INSERT 撞 UK 直接失败;activity_id 的归属已由调用方 GetByID 作用域校验;
  227. // - campus_id **不由本方法补齐**:它必须是活动上的 campus_id 快照(0 有"全自营校区"语义,
  228. // 用请求作用域值覆盖会篡改语义)→ 由 Service 从活动行复制后传入;
  229. // - 报名人数增减(IncreaseNumberSign)由 Service 在同一事务内配套调用。
  230. func (r *ActivityRepo) UpsertSigned(ctx context.Context, tx *gorm.DB, signed *model.CoursesActivitySigned) error {
  231. if err := requireTx(tx, "UpsertSigned"); err != nil {
  232. return err
  233. }
  234. if signed == nil {
  235. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: signed 为 nil"))
  236. }
  237. if signed.ActivityID <= 0 || strings.TrimSpace(signed.UserID) == "" {
  238. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: signed 缺少 activity_id/user_id"))
  239. }
  240. if signed.InstitutionID == 0 {
  241. inst, _ := r.GetScopedID()
  242. signed.InstitutionID = inst
  243. }
  244. // ① 幂等清旧行(dump:DELETE WHERE userid AND activityid)
  245. if err := tx.Where("user_id = ? AND activity_id = ?", signed.UserID, signed.ActivityID).
  246. Delete(&model.CoursesActivitySigned{}).Error; err != nil {
  247. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  248. }
  249. // ② 插入新行(状态机全字段重置;审计时间兜底,见 ensureCreateTime)
  250. ensureCreateTime(&signed.CreateTime)
  251. if err := tx.WithContext(ctxOrBackground(ctx)).Create(signed).Error; err != nil {
  252. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  253. }
  254. return nil
  255. }
  256. // CountSignedByUser 统计用户有效报名数(**type=4 跨活动去重判定**,3106 老带新限一次)。
  257. //
  258. // 入参:
  259. // - ctx 请求上下文;
  260. // - userID 报名用户(canonical_user_id ULID;空 → ErrBadParam);
  261. // - type4Only true=只统计老带新(t_courses_activity.type=4)活动;false=统计全部活动类型。
  262. //
  263. // 出参:
  264. // - int64:有效报名记录数(**status != 0**,已取消/过期行不计);
  265. // - ErrBadParam / ErrDBError。
  266. //
  267. // SQL 口径(对齐 dump,表名/列名限定防 JOIN ambiguous):
  268. //
  269. // SELECT count(*) FROM t_courses_activity_signed signed
  270. // JOIN t_courses_activity activity ON activity.id = signed.activity_id
  271. // WHERE signed.user_id = ? AND signed.status <> 0 AND activity.type = 4
  272. // AND signed.institution_id = ? AND signed.campus_id IN (?, 0) -- 作用域
  273. //
  274. // 去重语义:跨活动去重由 UK(user_id, activity_id) 保证(一活动一行),故 COUNT(*) 即
  275. // "该用户参加过多少个 type=4 活动";>0 即可返回 3106(设计 §3.2 signed_add)。
  276. func (r *ActivityRepo) CountSignedByUser(ctx context.Context, userID string, type4Only bool) (int64, error) {
  277. if strings.TrimSpace(userID) == "" {
  278. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountSignedByUser 需要 user_id"))
  279. }
  280. db, err := r.db(ctx)
  281. if err != nil {
  282. return 0, err
  283. }
  284. q := db.Table("t_courses_activity_signed AS signed").
  285. Where("signed.user_id = ?", userID).
  286. Where("signed.status <> ?", model.ActivitySignedCancelled)
  287. if type4Only {
  288. q = q.Joins("JOIN t_courses_activity AS activity ON activity.id = signed.activity_id").
  289. Where("activity.type = ?", model.ActivityTypeRecommend)
  290. }
  291. q = scopedCampusRows(q, r.Sc, "signed")
  292. var count int64
  293. if err := q.Count(&count).Error; err != nil {
  294. return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  295. }
  296. return count, nil
  297. }
  298. // UpdateSignedStatus 更新报名状态(状态机推进 / 取消报名)。
  299. //
  300. // 入参:
  301. // - ctx 请求上下文;
  302. // - tx 事务句柄(**必传**);
  303. // - userID 报名用户(ULID);activityID 活动 ID;status 目标状态(model.ActivitySigned*)。
  304. //
  305. // 出参:
  306. // - int64:受影响行数(0 = 该 (user, activity) 不存在或不在作用域内,Service 据此返回 3001);
  307. // - ErrBadParam / ErrDBError。
  308. //
  309. // 说明:状态机的**合法性判定**在 Service(如 2→1 仅取消路径、已审核不可取消 3107),
  310. // 仓储只做「条件更新」,不解释状态流转规则(避免业务规则沉淀到数据层)。
  311. func (r *ActivityRepo) UpdateSignedStatus(ctx context.Context, tx *gorm.DB, userID string, activityID int64, status int32) (int64, error) {
  312. if err := requireTx(tx, "UpdateSignedStatus"); err != nil {
  313. return 0, err
  314. }
  315. if strings.TrimSpace(userID) == "" || activityID <= 0 {
  316. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateSignedStatus 需要 user_id/activity_id"))
  317. }
  318. q := scopedCampusRows(tx.Model(&model.CoursesActivitySigned{}), r.Sc, "")
  319. res := q.Where("user_id = ? AND activity_id = ?", userID, activityID).
  320. UpdateColumn("status", status)
  321. if res.Error != nil {
  322. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  323. }
  324. return res.RowsAffected, nil
  325. }
  326. // IncreaseNumberSign 报名人数原子增减(**下限 0**,防取消竞态把人数减成负数)。
  327. //
  328. // 入参:
  329. // - ctx 请求上下文;
  330. // - tx 事务句柄(**必传**);
  331. // - activityID 活动 ID(<=0 → ErrBadParam);delta 增量(报名 +1 / 取消 -1;0 → 空操作)。
  332. //
  333. // 出参:
  334. // - int64:受影响行数(0 = 活动不存在/不在作用域内);
  335. // - ErrBadParam / ErrDBError。
  336. //
  337. // SQL(跨库可移植的下限保护,不用 GREATEST/MAX 以免方言差异):
  338. //
  339. // UPDATE t_courses_activity
  340. // SET number_sign = CASE WHEN number_sign + <delta> < 0 THEN 0 ELSE number_sign + <delta> END
  341. // WHERE id = ? [AND institution_id = ? AND campus_id IN (?,0)]
  342. //
  343. // 红线:与 UpsertSigned/UpdateSignedStatus **必须同事务**(否则出现"报名行在、人数没加"的脏读)。
  344. func (r *ActivityRepo) IncreaseNumberSign(ctx context.Context, tx *gorm.DB, activityID int64, delta int32) (int64, error) {
  345. if err := requireTx(tx, "IncreaseNumberSign"); err != nil {
  346. return 0, err
  347. }
  348. if activityID <= 0 {
  349. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: IncreaseNumberSign 需要 activity_id"))
  350. }
  351. if delta == 0 {
  352. return 0, nil
  353. }
  354. expr := gorm.Expr("CASE WHEN number_sign + ? < 0 THEN 0 ELSE number_sign + ? END", delta, delta)
  355. q := scopedCampusRows(tx.Model(&model.CoursesActivity{}), r.Sc, "")
  356. res := q.Where("id = ?", activityID).UpdateColumn("number_sign", expr)
  357. if res.Error != nil {
  358. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  359. }
  360. return res.RowsAffected, nil
  361. }
  362. // ListSignedByStudentCourse 按报课记录回溯报名行(退费/撤销时定位需要回滚的 signed)。
  363. //
  364. // 发现路径(dump 的退费回滚链):报课记录 id → 赠送金额使用流水 log.courses_student_id
  365. // → 赠送金额池 pool.id → (pool.activity_id, pool.user_id) → signed(同活动同用户)。
  366. //
  367. // 入参:
  368. // - ctx 请求上下文;
  369. // - stuCourseID 报课记录 ID(t_courses_student.id;<=0 → ErrBadParam);
  370. // - order 游标方向(OrderAsc/OrderDesc,**顺序写死不可改**):
  371. // OrderAsc = 按使用先后(先用的活动先处理,dump 消费路径);
  372. // OrderDesc = 按最近使用优先(dump 退回路径)。
  373. //
  374. // 出参:
  375. // - []model.CoursesActivitySigned:去重后的报名行(同一活动多笔流水只返回一行);
  376. // - ErrBadParam / ErrDBError。
  377. //
  378. // 事务:只读(ctx 内注入 tx 时自动复用)。
  379. func (r *ActivityRepo) ListSignedByStudentCourse(ctx context.Context, stuCourseID int64, order string) ([]model.CoursesActivitySigned, error) {
  380. if stuCourseID <= 0 {
  381. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListSignedByStudentCourse 需要 courses_student_id"))
  382. }
  383. dir, err := normalizeOrder(order)
  384. if err != nil {
  385. return nil, err
  386. }
  387. db, err := r.db(ctx)
  388. if err != nil {
  389. return nil, err
  390. }
  391. q := db.Table("t_courses_activity_signed AS signed").
  392. Distinct("signed.*").
  393. Joins("JOIN t_courses_activity_user_discount AS pool ON pool.activity_id = signed.activity_id AND pool.user_id = signed.user_id").
  394. Joins("JOIN t_courses_activity_user_discount_log AS dlog ON dlog.activity_user_discount_id = pool.id").
  395. Where("dlog.courses_student_id = ?", stuCourseID).
  396. Where("signed.status <> ?", model.ActivitySignedCancelled)
  397. q = scopedCampusRows(q, r.Sc, "signed")
  398. // 游标方向来自白名单常量(dump 固定顺序),禁止调用方拼 SQL
  399. q = q.Order("signed.id " + dir)
  400. var rows []model.CoursesActivitySigned
  401. if err := q.Find(&rows).Error; err != nil {
  402. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  403. }
  404. return rows, nil
  405. }
  406. // wrapQueryErr 把查询错误归一为 academic 错误契约(不存在 → ErrNotFound,其余 → ErrDBError)。
  407. //
  408. // 入参:err 原生错误;op 操作名(仅用于日志关联,不进入响应体)。
  409. // 出参:*infraerrors.AppError(ErrNotFound / ErrDBError)。
  410. func wrapQueryErr(err error, op string) error {
  411. if err == nil {
  412. return nil
  413. }
  414. if errors.Is(err, gorm.ErrRecordNotFound) {
  415. return infraerrors.WithCause(infraerrors.ErrNotFound, err)
  416. }
  417. return infraerrors.WithCause(infraerrors.ErrDBError, fmt.Errorf("%s: %w", op, err))
  418. }
  419. // ctxOrBackground 保证传给 gorm 的 ctx 非 nil(gorm.WithContext(nil) 会 panic)。
  420. func ctxOrBackground(ctx context.Context) context.Context {
  421. if ctx != nil {
  422. return ctx
  423. }
  424. return context.Background()
  425. }
  426. // ensureCreateTime 审计时间兜底(零值 → 当前时间)。
  427. //
  428. // 为什么需要(实测缺陷):AC-01 的活动域 model 用的是 `CreateTime` 字段名(非 GORM 约定的
  429. // `CreatedAt`),且 tag 只有 `not null` 没有 `default:CURRENT_TIMESTAMP(3)`;
  430. // 实测 GORM 会把 time.Time 零值**显式**写进 INSERT(得到 0001-01-01),DB 默认值根本兜不住。
  431. // 审计时间是对账依据(红线),故在仓储写入前统一兜底,避免调用方漏填造成时间列被写坏。
  432. //
  433. // 入参:dst *time.Time——待写入行的 create_time 字段地址(nil 时忽略)。
  434. func ensureCreateTime(dst *time.Time) {
  435. if dst != nil && dst.IsZero() {
  436. *dst = time.Now()
  437. }
  438. }