maintenance_repo.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. // 运维/迁移型 SP 仓储(2 个 copy_* 存储过程的数据访问层)——AC-11。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §3.6(迁移类 2 SP)/ §3.8 BUG#3
  4. // / §10.3(事务模板)/ §10.4(游标 → 集合)。
  5. //
  6. // 覆盖:
  7. //
  8. // copy_activity_discountLog_to_usedLog() → ListUsedLogRebuildRows + DeleteAllUsedLogs
  9. // + BatchInsertUsedLogs(SP30,dump 762745)
  10. // copy_activity_discount_to_signed() → ListSignedReplayRows + UpdateSignedFromReplay
  11. // (SP31,dump 762792)
  12. //
  13. // **本仓储有意不继承 BaseRepo**(设计 §1.7 例外的运维延伸,评审必看):
  14. // - SP30 的业务语义是"**全表重建**"(dump 用 `TRUNCATE t_courses_activity_used_log` 清空整表),
  15. // 按机构/校区裁剪会把其它租户的流水删掉/漏建 —— 作用域过滤在此**语义上不成立**;
  16. // - SP31 同理,dump 的游标全表扫描 `t_courses_activity_user_discount`。
  17. // - 越权防线改为:端点 61~63 **仅 super_admin**(设计 §4.5)+ Service 层 `assertSuperAdmin()` 断言;
  18. // 且本仓储只被 MaintenanceService 调用,禁止被业务链路复用(需租户裁剪时请走 ActivityRepo)。
  19. //
  20. // 事务纪律:删除 + 批量插入**必须同一 tx**(BUG#3 修正的核心:TRUNCATE 是 DDL,隐式提交,
  21. // 中途失败即数据全丢;`DELETE + INSERT` 在事务内失败会整体回滚,旧数据完好)。
  22. package repository
  23. import (
  24. "context"
  25. "strings"
  26. "time"
  27. "gorm.io/gorm"
  28. "yuansiwei/unified-backend/internal/academic/model"
  29. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  30. )
  31. // MaintenanceRepo 迁移/修复型 SP 的专用仓储(**全局运维视角,不继承 BaseRepo**,见文件头)。
  32. type MaintenanceRepo struct {
  33. // DB 数据库句柄(连接池由 infra/db 统一建立;nil 时任何查询返回 ErrInternal)。
  34. DB *gorm.DB
  35. }
  36. // NewMaintenanceRepo 构造运维仓储。
  37. //
  38. // 入参:db *gorm.DB——连接池句柄(必填)。出参:*MaintenanceRepo。
  39. func NewMaintenanceRepo(db *gorm.DB) *MaintenanceRepo { return &MaintenanceRepo{DB: db} }
  40. // db 取查询句柄(ctx 内含 tx 时复用同一事务)。
  41. //
  42. // 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
  43. func (r *MaintenanceRepo) db(ctx context.Context) (*gorm.DB, error) {
  44. if r == nil || r.DB == nil {
  45. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: MaintenanceRepo.DB 为 nil"))
  46. }
  47. if tx := TxFromContext(ctx); tx != nil {
  48. return tx, nil
  49. }
  50. if ctx == nil {
  51. return r.DB, nil
  52. }
  53. return r.DB.WithContext(ctx), nil
  54. }
  55. // UsedLogRebuildRow 重建 used_log 的一行源数据(discount_log 左连 discount 池)。
  56. //
  57. // 字段(**可空**:dump 是 LEFT JOIN,池行缺失时会取出 NULL):
  58. // - ActivityID *int64:活动 ID(来自池;LEFT JOIN 未命中 → nil);
  59. // - UserID *string:用户(来自池;未命中 → nil);
  60. // - CoursesStudentID *int64:报课记录 ID(新模型回填列;迁移期可能 NULL);
  61. // - CoursesStudentNo *string:报课记录旧单号(旧 UUID,= t_courses_student.biz_no);
  62. // - DiscountAmountUsed string:变动金额(元字符串;负=消费/正=退回);
  63. // - CreateTime *time.Time:**业务时间**(使用时间,§5.4 ① 类,逐值比对);
  64. // - Memo *string:备注。
  65. type UsedLogRebuildRow struct {
  66. ActivityID *int64 `gorm:"column:activity_id"`
  67. UserID *string `gorm:"column:user_id"`
  68. CoursesStudentID *int64 `gorm:"column:courses_student_id"`
  69. CoursesStudentNo *string `gorm:"column:courses_student_no"`
  70. DiscountAmountUsed string `gorm:"column:discount_amount_used"`
  71. CreateTime *time.Time `gorm:"column:create_time"`
  72. Memo *string `gorm:"column:memo"`
  73. }
  74. // ListUsedLogRebuildRows 取 used_log 全量重建的源数据(SP30 游标集合化)。
  75. //
  76. // 入参:ctx 请求上下文。
  77. // 出参:[]UsedLogRebuildRow(**按 discount_log.id 升序** —— dump 游标 `ORDER BY t1.id`,顺序不可改);ErrDBError。
  78. //
  79. // dump 原文(762754):
  80. //
  81. // SELECT t2.activityid,t2.userid,t1.courses_studentid,t1.discount_amount_used,t1.create_time,t1.memo
  82. // FROM t_courses_activity_user_discount_log t1
  83. // LEFT JOIN t_courses_activity_user_discount t2 ON t1.activity_user_discountid=t2.id
  84. // ORDER BY t1.id;
  85. //
  86. // 1:N 说明:discount_log → 池 是 N:1(池 id 唯一),故不会出现同一流水被取多次。
  87. func (r *MaintenanceRepo) ListUsedLogRebuildRows(ctx context.Context) ([]UsedLogRebuildRow, error) {
  88. db, err := r.db(ctx)
  89. if err != nil {
  90. return nil, err
  91. }
  92. rows := make([]UsedLogRebuildRow, 0, 256)
  93. q := db.Table("t_courses_activity_user_discount_log AS dlog").
  94. Select("pool.activity_id AS activity_id, pool.user_id AS user_id, " +
  95. "dlog.courses_student_id AS courses_student_id, dlog.courses_student_no AS courses_student_no, " +
  96. "dlog.discount_amount_used AS discount_amount_used, dlog.create_time AS create_time, dlog.memo AS memo").
  97. Joins("LEFT JOIN t_courses_activity_user_discount AS pool ON pool.id = dlog.activity_user_discount_id").
  98. Order("dlog.id ASC")
  99. if err := q.Find(&rows).Error; err != nil {
  100. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  101. }
  102. return rows, nil
  103. }
  104. // DeleteAllUsedLogs 清空 used_log(**替代 dump 的 TRUNCATE**,BUG#3 修正)。
  105. //
  106. // 入参:ctx;tx(**必传**:与后续批量插入同事务,失败整体回滚)。
  107. // 出参:int64 删除行数;ErrDBError。
  108. //
  109. // 为什么必须是 DELETE 而不是 TRUNCATE(设计 §3.8 BUG#3):
  110. // - TRUNCATE 是 DDL,MySQL 会**隐式提交**当前事务 → "清空成功、重建失败"时数据直接丢失,
  111. // 且事务回滚也救不回来;
  112. // - DELETE 走 MVCC/undo,与 INSERT 同事务 → 任一失败回滚后旧数据完好(可重跑)。
  113. //
  114. // `Where("1 = 1")`:GORM 默认拒绝无条件的全表 Delete(安全护栏),语义上等价 `DELETE ... WHERE 1=1`。
  115. func (r *MaintenanceRepo) DeleteAllUsedLogs(ctx context.Context, tx *gorm.DB) (int64, error) {
  116. if err := requireTx(tx, "DeleteAllUsedLogs"); err != nil {
  117. return 0, err
  118. }
  119. res := tx.WithContext(ctxOrBackground(ctx)).Where("1 = 1").Delete(&model.CoursesActivityUsedLog{})
  120. if res.Error != nil {
  121. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  122. }
  123. return res.RowsAffected, nil
  124. }
  125. // BatchInsertUsedLogs 批量写入 used_log(单条 `INSERT ... VALUES (...),(...)`,每批 200 行)。
  126. //
  127. // 入参:ctx;tx(**必传**);rows 待插入行(调用方保证 ActivityID>0、UserID 非空)。
  128. // 出参:int64 插入行数;ErrDBError。
  129. //
  130. // 红线:`activity_money_used` **原值写入**(dump 的复制作业不做符号翻转;老带新结算才写负值),
  131. // 双跑时该列是重点比对项(§3.8 表格 ★)。
  132. func (r *MaintenanceRepo) BatchInsertUsedLogs(ctx context.Context, tx *gorm.DB, rows []*model.CoursesActivityUsedLog) (int64, error) {
  133. if err := requireTx(tx, "BatchInsertUsedLogs"); err != nil {
  134. return 0, err
  135. }
  136. if len(rows) == 0 {
  137. return 0, nil
  138. }
  139. for _, row := range rows {
  140. if row == nil || row.ActivityID <= 0 || strings.TrimSpace(row.UserID) == "" {
  141. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  142. errString("repository: BatchInsertUsedLogs 出现 activity_id/user_id 缺失的行"))
  143. }
  144. }
  145. if err := tx.WithContext(ctxOrBackground(ctx)).CreateInBatches(rows, 200).Error; err != nil {
  146. return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  147. }
  148. return int64(len(rows)), nil
  149. }
  150. // SignedReplayRow signed 重放的一行源数据(discount 池内连 balance_request 审批通过行)。
  151. //
  152. // 字段:
  153. // - PoolID int64:t_courses_activity_user_discount.id(游标排序键,**改动顺序会改变"后写覆盖前写"的终态**);
  154. // - ActivityID int64:活动 ID;UserID string:用户;
  155. // - Income string:充值金额;DiscountAmount string:赠送额;DiscountAmountUsed string:已用赠送额;
  156. // - RequestNo *string:工单业务单号(旧 requestid → t_user_balance_request.biz_no);
  157. // - ResponseUserID *string:审核人(ULID);ResponseTime *time.Time:审核时间;ResponseMsg *string:审核意见。
  158. type SignedReplayRow struct {
  159. PoolID int64 `gorm:"column:pool_id"`
  160. ActivityID int64 `gorm:"column:activity_id"`
  161. UserID string `gorm:"column:user_id"`
  162. Income string `gorm:"column:income"`
  163. DiscountAmount string `gorm:"column:discount_amount"`
  164. DiscountAmountUsed string `gorm:"column:discount_amount_used"`
  165. RequestNo *string `gorm:"column:request_no"`
  166. ResponseUserID *string `gorm:"column:response_user_id"`
  167. ResponseTime *time.Time `gorm:"column:response_time"`
  168. ResponseMsg *string `gorm:"column:response_msg"`
  169. }
  170. // ListSignedReplayRows 取 signed 重放的源数据(SP31 游标集合化)。
  171. //
  172. // 入参:ctx 请求上下文。
  173. // 出参:[]SignedReplayRow(**按 pool.id 升序** —— dump 游标无 ORDER BY,MySQL 实际按 PK 序返回;
  174. // 该顺序决定"同一 (activity,user) 多次充值谁覆盖谁",因此**必须固定为 id 升序**);ErrDBError。
  175. //
  176. // dump 原文(762800):
  177. //
  178. // SELECT t1.activityid,t1.userid,t1.income,t1.discount_amount,t1.discount_amount_used,
  179. // t1.balance_requestid,t2.response_userid,t2.response_time,t2.response_msg
  180. // FROM t_courses_activity_user_discount t1
  181. // INNER JOIN t_user_balance_request t2
  182. // ON t1.balance_requestid=t2.id AND t2.aproved=1 AND t2.type=100;
  183. //
  184. // 等价改写说明:`balance_requestid`(旧 varchar UUID)→ 新模型 `balance_request_id`(int64 FK),
  185. // 而 signed 表要落的是 `request_no`(业务单号)→ 取 `req.biz_no`(旧 UUID 原样,§2.2 拍板 11-10)。
  186. func (r *MaintenanceRepo) ListSignedReplayRows(ctx context.Context) ([]SignedReplayRow, error) {
  187. db, err := r.db(ctx)
  188. if err != nil {
  189. return nil, err
  190. }
  191. rows := make([]SignedReplayRow, 0, 256)
  192. q := db.Table("t_courses_activity_user_discount AS pool").
  193. Select("pool.id AS pool_id, pool.activity_id AS activity_id, pool.user_id AS user_id, "+
  194. "pool.income AS income, pool.discount_amount AS discount_amount, "+
  195. "pool.discount_amount_used AS discount_amount_used, req.biz_no AS request_no, "+
  196. "req.response_user_id AS response_user_id, req.response_time AS response_time, "+
  197. "req.response_msg AS response_msg").
  198. Joins("INNER JOIN t_user_balance_request AS req ON req.id = pool.balance_request_id "+
  199. "AND req.approved = ? AND req.type = ?",
  200. model.BalanceRequestApprovedPassed, model.BalanceRequestTypeRecharge).
  201. Order("pool.id ASC")
  202. if err := q.Find(&rows).Error; err != nil {
  203. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  204. }
  205. return rows, nil
  206. }
  207. // SignedReplayPatch 重放写入 signed 的列(dump 的 UPDATE SET 清单,7 列)。
  208. //
  209. // 字段:RechargeAmount 充值金额;ActivityMoney 活动金额;ActivityMoneyUsed 已用活动金额;
  210. // RequestNo 申请单号;ResponseUserID 审核人;ResponseTime 审核时间;ResponseMsg 审核意见。
  211. type SignedReplayPatch struct {
  212. RechargeAmount string
  213. ActivityMoney string
  214. ActivityMoneyUsed string
  215. RequestNo *string
  216. ResponseUserID *string
  217. ResponseTime *time.Time
  218. ResponseMsg *string
  219. }
  220. // UpdateSignedFromReplay 按(活动 + 用户)回写 signed 的 7 列(**仅 status > 0 的行**)。
  221. //
  222. // 入参:ctx;tx(**必传**);activityID(>0);userID(非空);patch 列值。
  223. // 出参:int64 受影响行数(0 = 该用户已取消报名 status=0 → dump 不更新,属正常);ErrBadParam / ErrDBError。
  224. //
  225. // dump 原文(762812):
  226. //
  227. // UPDATE t_courses_activity_signed SET recharge_amount=?, activity_money=?, activity_money_used=?,
  228. // requestid=?, response_userid=?, response_time=?, response_msg=?
  229. // WHERE activityid=? AND userid=? AND status>0;
  230. //
  231. // 红线:`status > 0` 条件**不可省** —— 已取消(status=0)的报名不能被历史工单"复活",
  232. // 否则会把用户已撤销的赠送额度重新算回可用池(资金口径污染)。
  233. func (r *MaintenanceRepo) UpdateSignedFromReplay(ctx context.Context, tx *gorm.DB, activityID int64, userID string, patch SignedReplayPatch) (int64, error) {
  234. if err := requireTx(tx, "UpdateSignedFromReplay"); err != nil {
  235. return 0, err
  236. }
  237. if activityID <= 0 || strings.TrimSpace(userID) == "" {
  238. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  239. errString("repository: UpdateSignedFromReplay 需要 activity_id/user_id"))
  240. }
  241. fields := map[string]any{
  242. "recharge_amount": patch.RechargeAmount,
  243. "activity_money": patch.ActivityMoney,
  244. "activity_money_used": patch.ActivityMoneyUsed,
  245. }
  246. // 可空列:NULL 也要能覆盖(dump 直接把 selectXxx 写进去,可能为 NULL)
  247. fields["request_no"] = patch.RequestNo
  248. fields["response_user_id"] = patch.ResponseUserID
  249. fields["response_time"] = patch.ResponseTime
  250. fields["response_msg"] = patch.ResponseMsg
  251. res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivitySigned{}).
  252. Where("activity_id = ? AND user_id = ? AND status > 0", activityID, strings.TrimSpace(userID)).
  253. Updates(fields)
  254. if res.Error != nil {
  255. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  256. }
  257. return res.RowsAffected, nil
  258. }