maintenance_service.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. // 运维 / 数据修复服务:3 个数据修复型 SP + 2 个迁移型 SP——AC-11。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §3.5(课程类 3 SP)/ §3.6(迁移类 2 SP)
  4. // / §3.8 BUG#3(TRUNCATE → DELETE+INSERT)/ §4.5(端点 56、61~63)/ §10.3(事务模板)。
  5. //
  6. // 五个存储过程的 Go 落点(**逐条按 dump 原文实现,顺序不可改**):
  7. //
  8. // #27 sp_courses_student_counteract_arrears_course → CounteractArrears(dump 763435)
  9. // #28 sp_courses_update() → BackfillCoursesMaxPeople(dump 763490)
  10. // #29 sp_courses_update_campusid() → BackfillCoursesCampus(dump 763533)
  11. // #30 copy_activity_discountLog_to_usedLog() → RebuildActivityUsedLog(dump 762745,**BUG#3 修正**)
  12. // #31 copy_activity_discount_to_signed() → ReplayActivitySigned(dump 762792)
  13. //
  14. // 幂等纪律(AC-11 ③):三个"修复型"方法必须**可重跑**——
  15. // - SP28 只处理 `max_people = 0` 的行,回填后不再命中 → 第二次扫描 Scanned=0;
  16. // - SP29 只处理 `campus_id = 0` 且"只属于单一校区"的课程 → 多校区课程保持 0,重跑不会误填;
  17. // - SP27 只在 `remainder_receivable > 0` 时核销,核销到 0 后不再命中。
  18. //
  19. // 迁移纪律(AC-11 ④ / BUG#3):SP30 的 `TRUNCATE` 改为 **同事务 `DELETE` + 批量 `INSERT`** ——
  20. // TRUNCATE 是 DDL 隐式提交,中途失败即数据全丢;DELETE 在事务内可回滚(单测断言"失败不丢数据")。
  21. //
  22. // 越权(设计 §4.5):端点 61~63 仅 super_admin;SP28~SP31 在 Service 层显式断言 SuperAdmin
  23. // (SP27 是正常业务动作,由 AC-08 在欠费核销链路调用,不做超管断言,靠作用域校验)。
  24. package service
  25. import (
  26. "context"
  27. "strconv"
  28. "strings"
  29. "time"
  30. "gorm.io/gorm"
  31. "yuansiwei/unified-backend/internal/academic/apperr"
  32. "yuansiwei/unified-backend/internal/academic/model"
  33. "yuansiwei/unified-backend/internal/academic/money"
  34. "yuansiwei/unified-backend/internal/academic/repository"
  35. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  36. appscope "yuansiwei/unified-backend/internal/infra/scope"
  37. )
  38. // MaintenanceService 运维/修复服务(作用域绑定在构造时;SP28~31 额外要求 SuperAdmin)。
  39. type MaintenanceService struct {
  40. // courses 课程/报课账户仓储(SP27 核销、SP28/29 回填)。
  41. courses *repository.CourseRepo
  42. // maint 迁移型 SP 专用仓储(全局运维视角,不继承 BaseRepo)。
  43. maint *repository.MaintenanceRepo
  44. // runner 事务运行器(每个 SP 一个事务,失败整体回滚)。
  45. runner *repository.TxRunner
  46. // sc 请求作用域(用于 SuperAdmin 断言)。
  47. sc *appscope.Scope
  48. // now 时钟(单测注入)。
  49. now func() time.Time
  50. }
  51. // NewMaintenanceService 构造运维服务。
  52. //
  53. // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域)。
  54. // 出参:*MaintenanceService。
  55. func NewMaintenanceService(db *gorm.DB, sc *appscope.Scope) *MaintenanceService {
  56. return &MaintenanceService{
  57. courses: repository.NewCourseRepo(db, sc),
  58. maint: repository.NewMaintenanceRepo(db),
  59. runner: repository.NewTxRunner(db),
  60. sc: sc,
  61. now: time.Now,
  62. }
  63. }
  64. // assertSuperAdmin 端点 61~63 的越权断言(设计 §4.5:维护端点仅 super_admin)。
  65. //
  66. // 出参:nil 通过;infraerrors.ErrUnscoped(1004) 非超管。
  67. //
  68. // 为什么在 Service 再断言一次:Handler 角色白名单(AC-13/14)是粗粒度第一道防线;
  69. // SP28~31 是**全表级**运维动作(SP30 会清空整表),必须在业务层留第二道,防止
  70. // 将来有人从内部调用绕过路由白名单。
  71. func (s *MaintenanceService) assertSuperAdmin(op string) error {
  72. if s == nil || s.sc == nil || !s.sc.SuperAdmin {
  73. return infraerrors.WithCause(infraerrors.ErrUnscoped,
  74. errString("service: "+op+" 仅 super_admin 可执行(维护/迁移型操作)"))
  75. }
  76. return nil
  77. }
  78. // ---------------------------------------------------------------------------
  79. // SP27 欠费核销(dump 763435 sp_courses_student_counteract_arrears_course)
  80. // ---------------------------------------------------------------------------
  81. // CounteractArrearsInput 欠费核销入参(端点 56;dump 4 参语义)。
  82. //
  83. // 字段:StudentID 学生 ULID;InstitutionID/CampusID 作用域(均 >0);Money 待核销金额(分)。
  84. type CounteractArrearsInput struct {
  85. StudentID string
  86. InstitutionID int64
  87. CampusID int64
  88. Money money.Amount
  89. }
  90. // ArrearsApplied 单条报课的核销明细(回显/对账用)。
  91. //
  92. // 字段:StudentCourseID 报课记录;Amount 本次核销金额。
  93. type ArrearsApplied struct {
  94. StudentCourseID int64
  95. Amount money.Amount
  96. }
  97. // CounteractArrearsResult 欠费核销结果。
  98. //
  99. // 字段:Applied 实际核销总额;Remaining 未用完、**留在账户**的金额(dump 不返还/不扣减余额,
  100. // 余额侧由调用方(AC-08 老带新结算)在同一 tx 内已入账);Items 明细。
  101. type CounteractArrearsResult struct {
  102. Applied money.Amount
  103. Remaining money.Amount
  104. Items []ArrearsApplied
  105. }
  106. // CounteractArrears 把账户侧"被负数抵消"的金额补回欠费课程(SP27)。
  107. //
  108. // 入参:ctx;in 入参。
  109. // 出参:*CounteractArrearsResult;2001/2002 参数错;3113 核销冲突;5002。
  110. //
  111. // dump 语义(763455~763480,逐行对齐):
  112. //
  113. // 游标:remainder_receivable>0 且有确认操作人的报课行,按课程开课时分 ASC(最早课优先);
  114. // 每行:IF inputMoney <= 0 THEN LEAVE(**金额用完即停止整个循环**);
  115. // IF inputMoney < remainderReceivable THEN remainderReceivable = inputMoney(**截到剩余额度**);
  116. // UPDATE net_receipts += remainderReceivable, remainder_receivable -= remainderReceivable;
  117. // inputMoney -= remainderReceivable;
  118. // 结束后:**余额多余部分留在账户**(dump 无退还动作)。
  119. //
  120. // 事务纪律:本方法可在外层事务内以同 tx 调用(读 ctx 透传),
  121. // 与 AC-08 的 `use_activity_recommend → counteract_arrears` 链路保持单事务(§10.3 红线)。
  122. func (s *MaintenanceService) CounteractArrears(ctx context.Context, in CounteractArrearsInput) (*CounteractArrearsResult, error) {
  123. studentID := strings.TrimSpace(in.StudentID)
  124. if studentID == "" {
  125. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 欠费核销需要 studentId"))
  126. }
  127. if in.InstitutionID <= 0 || in.CampusID <= 0 {
  128. // 与 §1.6 口径一致:campusid<=0 → 3140(旧 helper 取不到校区的参数错)
  129. return nil, infraerrors.WithCause(apperr.ErrBusinessParam,
  130. errString("service: 欠费核销需要 institutionId 与 campusId 均 > 0"))
  131. }
  132. out := &CounteractArrearsResult{Items: make([]ArrearsApplied, 0, 4)}
  133. remaining := in.Money
  134. if !remaining.GreaterThan(0) {
  135. // dump:inputMoney <= 0 → 循环第一轮即 LEAVE,整体 no-op(不是错误)。
  136. // Remaining 归零:没有核销动作,也就没有"未用完留在账户"的金额(把负数原样回显会误导调用方)。
  137. return out, nil
  138. }
  139. if err := remaining.Validate(); err != nil {
  140. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  141. }
  142. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  143. txCtx := repository.WithTxContext(ctx, tx)
  144. candidates, err := s.courses.ListArrearsCandidates(txCtx, studentID, in.InstitutionID, in.CampusID)
  145. if err != nil {
  146. return err
  147. }
  148. applied := money.Amount(0)
  149. for i := range candidates {
  150. if !remaining.GreaterThan(0) {
  151. break // dump:金额用完 → LEAVE readLoop(停止整个循环,不是 continue)
  152. }
  153. cand := &candidates[i]
  154. owed, perr := parseAmount(cand.RemainderReceivable)
  155. if perr != nil {
  156. return perr
  157. }
  158. amount := owed
  159. if remaining.LessThan(owed) {
  160. amount = remaining
  161. }
  162. if !amount.GreaterThan(0) {
  163. continue // 脏数据(remainder_receivable 解析为 0):跳过,不写 0 元核销
  164. }
  165. affected, uerr := s.courses.IncNetReceiptsAndDecRemainderReceivable(txCtx, tx, cand.StudentCourseID, amount)
  166. if uerr != nil {
  167. return uerr
  168. }
  169. if affected == 0 {
  170. // 并发/作用域变化导致行不可写:核销冲突(3113,dump 无此分支,属 Go 侧显式化)
  171. return infraerrors.WithCause(apperr.ErrArrearsCounteractFailed,
  172. errString("service: 欠费核销目标行不可写(id="+strconv.FormatInt(cand.StudentCourseID, 10)+")"))
  173. }
  174. remaining = remaining.Sub(amount)
  175. applied = applied.Add(amount)
  176. out.Items = append(out.Items, ArrearsApplied{StudentCourseID: cand.StudentCourseID, Amount: amount})
  177. }
  178. out.Applied = applied
  179. out.Remaining = remaining
  180. return nil
  181. })
  182. if err != nil {
  183. return nil, err
  184. }
  185. return out, nil
  186. }
  187. // ---------------------------------------------------------------------------
  188. // SP28 修复:课程 max_people 从分类继承(dump 763490 sp_courses_update)
  189. // ---------------------------------------------------------------------------
  190. // BackfillMaxPeopleResult 修复结果(可重跑:第二次 Scanned=0)。
  191. //
  192. // 字段:Scanned 扫描行数(`max_people=0`);Filled 实际回填行数;Skipped 无需回填行数
  193. // (分类缺失或分类 max_people 也是 0 —— 写 0 等于不改);Affected SQL 影响行数合计。
  194. type BackfillMaxPeopleResult struct {
  195. Scanned int
  196. Filled int
  197. Skipped int
  198. Affected int64
  199. }
  200. // BackfillCoursesMaxPeople 把 `t_courses.max_people = 0` 的行按分类 `max_people` 回填(SP28)。
  201. //
  202. // 入参:ctx。
  203. // 出参:*BackfillMaxPeopleResult;1004 非超管;5002。
  204. //
  205. // dump 语义(763492~763508):
  206. //
  207. // CURSOR FOR SELECT t1.id, t2.max_people FROM t_courses t1 LEFT JOIN t_courses_classify t2
  208. // ON t1.classifyid=t2.id WHERE t1.max_people=0 ORDER BY t1.id;
  209. // LOOP: UPDATE t_courses SET max_people = maxPeople WHERE id = coursesid;
  210. //
  211. // 幂等(可重跑):WHERE 固定 `max_people = 0`,回填后不再命中 → 第二次扫描为空。
  212. // 集合化(§10.4):同一目标值的一组课程一次 `UPDATE ... WHERE id IN (...)`,终态与逐行等价;
  213. // 目标值为 0 的组**跳过**(写 0 不改变数据,计入 Skipped 以免运维误判"已修复")。
  214. func (s *MaintenanceService) BackfillCoursesMaxPeople(ctx context.Context) (*BackfillMaxPeopleResult, error) {
  215. if err := s.assertSuperAdmin("BackfillCoursesMaxPeople"); err != nil {
  216. return nil, err
  217. }
  218. out := &BackfillMaxPeopleResult{}
  219. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  220. txCtx := repository.WithTxContext(ctx, tx)
  221. rows, err := s.courses.ListCoursesNeedingMaxPeopleBackfill(txCtx)
  222. if err != nil {
  223. return err
  224. }
  225. out.Scanned = len(rows)
  226. // 按目标值分组(顺序按首次出现,即 dump 游标 id ASC 顺序)
  227. groups := make(map[int32][]int64, 4)
  228. order := make([]int32, 0, 4)
  229. for i := range rows {
  230. row := &rows[i]
  231. if row.MaxPeople <= 0 {
  232. out.Skipped++
  233. continue
  234. }
  235. if _, ok := groups[row.MaxPeople]; !ok {
  236. order = append(order, row.MaxPeople)
  237. }
  238. groups[row.MaxPeople] = append(groups[row.MaxPeople], row.CourseID)
  239. }
  240. for _, value := range order {
  241. ids := groups[value]
  242. affected, aerr := s.courses.BatchSetMaxPeople(txCtx, tx, value, ids)
  243. if aerr != nil {
  244. return aerr
  245. }
  246. out.Affected += affected
  247. out.Filled += len(ids)
  248. }
  249. return nil
  250. })
  251. if err != nil {
  252. return nil, err
  253. }
  254. return out, nil
  255. }
  256. // ---------------------------------------------------------------------------
  257. // SP29 修复:课程 campus_id 回填(dump 763533 sp_courses_update_campusid)
  258. // ---------------------------------------------------------------------------
  259. // BackfillCoursesCampusResult 修复结果(可重跑)。
  260. //
  261. // 字段:Scanned 扫描的 `campus_id=0` 课程数;Updated 实际回填数;
  262. // SkippedMultiCampus 多校区课程数(**保持 0,不动**);SkippedNoRule 无排班规则课程数(校区数为 0 ≠ 1)。
  263. type BackfillCoursesCampusResult struct {
  264. Scanned int
  265. Updated int
  266. SkippedMultiCampus int
  267. SkippedNoRule int
  268. }
  269. // BackfillCoursesCampus 给"只属于单一校区"的课程回填 `campus_id`(SP29)。
  270. //
  271. // 入参:ctx。
  272. // 出参:*BackfillCoursesCampusResult;1004 非超管;5002。
  273. //
  274. // dump 语义(763538~763560):
  275. //
  276. // CURSOR FOR SELECT id FROM t_courses WHERE campusid = 0;
  277. // LOOP:
  278. // SELECT COUNT(*) FROM (SELECT campusid FROM t_courses_class WHERE coursesid=logId GROUP BY campusid) t;
  279. // IF (campusCount = 1) THEN
  280. // SELECT campusid FROM t_courses_class WHERE coursesid=logId LIMIT 0,1;
  281. // UPDATE t_courses SET campusid = courseCampusid WHERE id = logId;
  282. // END IF;
  283. //
  284. // **反直觉口径(必须配反向断言)**:
  285. // - 多校区课程(campusCount >= 2)→ **不回填,保持 0**("机构级课程",误填会把课程锁进某一个校区,
  286. // 导致其它校区看不到课程 / 作用域越权);
  287. // - 无排班规则(campusCount = 0)→ **不回填**(0 ≠ 1;dump 的 IF 不成立)。
  288. func (s *MaintenanceService) BackfillCoursesCampus(ctx context.Context) (*BackfillCoursesCampusResult, error) {
  289. if err := s.assertSuperAdmin("BackfillCoursesCampus"); err != nil {
  290. return nil, err
  291. }
  292. out := &BackfillCoursesCampusResult{}
  293. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  294. txCtx := repository.WithTxContext(ctx, tx)
  295. ids, err := s.courses.ListCourseIDsWithZeroCampus(txCtx)
  296. if err != nil {
  297. return err
  298. }
  299. out.Scanned = len(ids)
  300. for _, courseID := range ids {
  301. campusIDs, cerr := s.courses.ListClassCampusIDs(txCtx, courseID)
  302. if cerr != nil {
  303. return cerr
  304. }
  305. switch len(campusIDs) {
  306. case 0:
  307. out.SkippedNoRule++
  308. continue
  309. case 1:
  310. // 预期分支:唯一校区 → 回填
  311. default:
  312. out.SkippedMultiCampus++
  313. continue
  314. }
  315. if campusIDs[0] <= 0 {
  316. // 排班规则的校区本身是 0:把课程从 0 回填成 0 无意义,计入"无规则"跳过
  317. out.SkippedNoRule++
  318. continue
  319. }
  320. affected, uerr := s.courses.SetCourseCampus(txCtx, tx, courseID, campusIDs[0])
  321. if uerr != nil {
  322. return uerr
  323. }
  324. if affected > 0 {
  325. out.Updated++
  326. }
  327. }
  328. return nil
  329. })
  330. if err != nil {
  331. return nil, err
  332. }
  333. return out, nil
  334. }
  335. // ---------------------------------------------------------------------------
  336. // SP30 迁移:重建 used_log(dump 762745 copy_activity_discountLog_to_usedLog,BUG#3 修正)
  337. // ---------------------------------------------------------------------------
  338. // RebuildUsedLogResult 重建结果。
  339. //
  340. // 字段:Scanned 源流水行数;Deleted 清空的旧行数;Inserted 新写入行数;
  341. // Skipped 因 NOT NULL 约束无法表达而跳过的行数(见 RebuildActivityUsedLog 注释)。
  342. type RebuildUsedLogResult struct {
  343. Scanned int
  344. Deleted int64
  345. Inserted int64
  346. Skipped int
  347. }
  348. // RebuildActivityUsedLog 用 discount_log 全量重建 used_log(SP30)。
  349. //
  350. // 入参:ctx。
  351. // 出参:*RebuildActivityUsedLogResult;1004 非超管;5002(**整体回滚,旧数据不丢**)。
  352. //
  353. // **BUG#3 修正(设计 §3.8,最重要的一条)**:dump 先 `TRUNCATE TABLE t_courses_activity_used_log`,
  354. // TRUNCATE 属 DDL、MySQL 会**隐式提交**当前事务 —— "清空成功、重建失败"时数据永久丢失且无法回滚。
  355. // Go 侧改为同一事务内 `DELETE ... WHERE 1=1` + 批量 `INSERT`:任一失败整体回滚,旧数据完好,
  356. // 作业可安全重跑(单测 TestRebuildActivityUsedLogRollbackKeepsRows 用注入失败断言)。
  357. //
  358. // 字段映射(**不可改**,口径锚点 dump 762754/762768):
  359. //
  360. // activityid ← pool.activity_id(LEFT JOIN 未命中 → NULL)
  361. // userid ← pool.user_id(同上)
  362. // orderid ← log.courses_studentid ★ **映射不得改**:
  363. // 旧库该列是报课信息的 UUID 单号,新模型拆成
  364. // courses_student_id(int64) + courses_student_no(旧 UUID),
  365. // 而 used_log.order_no 承载的是**单据号**(其它 SP 也写 biz_no),
  366. // 故取 courses_student_no,缺失时回落 courses_student_id 的十进制串。
  367. // activity_money_used ← log.discount_amount_used(**原值**,不做符号翻转)
  368. // create_time ← log.create_time(业务时间,§5.4 ① 类)
  369. // memo ← log.memo
  370. //
  371. // 偏离登记(有意):dump 的 LEFT JOIN 允许 activityid/userid 为 NULL → 新模型两列 NOT NULL,
  372. // 这类行(池行被删的孤儿流水)**跳过并计入 Skipped**(不静默造 0 值行,避免污染 activity=0 的统计)。
  373. func (s *MaintenanceService) RebuildActivityUsedLog(ctx context.Context) (*RebuildUsedLogResult, error) {
  374. if err := s.assertSuperAdmin("RebuildActivityUsedLog"); err != nil {
  375. return nil, err
  376. }
  377. out := &RebuildUsedLogResult{}
  378. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  379. txCtx := repository.WithTxContext(ctx, tx)
  380. src, err := s.maint.ListUsedLogRebuildRows(txCtx)
  381. if err != nil {
  382. return err
  383. }
  384. out.Scanned = len(src)
  385. now := s.now()
  386. rows := make([]*model.CoursesActivityUsedLog, 0, len(src))
  387. for i := range src {
  388. row := &src[i]
  389. if row.ActivityID == nil || *row.ActivityID <= 0 || row.UserID == nil || strings.TrimSpace(*row.UserID) == "" {
  390. out.Skipped++
  391. continue
  392. }
  393. orderNo := orderNoFromStudentRef(row.CoursesStudentNo, row.CoursesStudentID)
  394. createTime := now
  395. if row.CreateTime != nil && !row.CreateTime.IsZero() {
  396. createTime = *row.CreateTime
  397. }
  398. moneyUsed := strings.TrimSpace(row.DiscountAmountUsed)
  399. if moneyUsed == "" {
  400. moneyUsed = "0.00"
  401. }
  402. if _, perr := parseAmount(moneyUsed); perr != nil {
  403. return perr // 金额列写坏:整笔回滚(不静默归零,避免污染对账)
  404. }
  405. rows = append(rows, &model.CoursesActivityUsedLog{
  406. ActivityID: *row.ActivityID,
  407. UserID: strings.TrimSpace(*row.UserID),
  408. OrderNo: stringPtr(orderNo),
  409. ActivityMoneyUsed: moneyUsed,
  410. CreateTime: createTime,
  411. Memo: normalizeOptString(row.Memo),
  412. })
  413. }
  414. deleted, derr := s.maint.DeleteAllUsedLogs(txCtx, tx)
  415. if derr != nil {
  416. return derr
  417. }
  418. out.Deleted = deleted
  419. inserted, ierr := s.maint.BatchInsertUsedLogs(txCtx, tx, rows)
  420. if ierr != nil {
  421. return ierr
  422. }
  423. out.Inserted = inserted
  424. return nil
  425. })
  426. if err != nil {
  427. return nil, err
  428. }
  429. return out, nil
  430. }
  431. // ---------------------------------------------------------------------------
  432. // SP31 迁移:重放 signed(dump 762792 copy_activity_discount_to_signed)
  433. // ---------------------------------------------------------------------------
  434. // ReplayActivitySignedResult 重放结果。
  435. //
  436. // 字段:Scanned 源池行数;Updated 回写行数(signed.status>0);SkippedCanceled 命中 0 行
  437. // (该用户已取消报名 status=0 → dump 的 UPDATE 不生效,属预期而非异常);Groups 实际执行的 UPDATE 次数。
  438. type ReplayActivitySignedResult struct {
  439. Scanned int
  440. Updated int64
  441. SkippedCanceled int64
  442. Groups int
  443. }
  444. // ReplayActivitySigned 用充值赠送池 + 审批通过工单回写 signed 的 7 列(SP31)。
  445. //
  446. // 入参:ctx。
  447. // 出参:*ReplayActivitySignedResult;1004 非超管;5002。
  448. //
  449. // dump 语义(762800~762812):
  450. //
  451. // 游标:pool INNER JOIN balance_request ON pool.balance_requestid = req.id
  452. // AND req.aproved = 1 AND req.type = 100(**只认审批通过的普通充值工单**);
  453. // 逐行 UPDATE t_courses_activity_signed SET recharge_amount, activity_money, activity_money_used,
  454. // requestid, response_userid, response_time, response_msg
  455. // WHERE activityid=? AND userid=? AND status > 0;
  456. //
  457. // 集合化(§10.4):同一 (activity, user) 可能有多条池行(多次充值)→ dump 逐行 UPDATE 是
  458. // **后写覆盖前写**,故 Go 侧按 (activity, user) 保留**最后一条**(pool.id 升序的最后一条 = 最大 id),
  459. // 一次 UPDATE 写终态,语义等价且避免 N 次往返。
  460. //
  461. // **反直觉口径(配反向断言)**:`status > 0` 不可省 —— 用户已取消报名(status=0)时
  462. // 绝不能被历史工单"复活",否则赠送额度会被重新算回可用池(资金口径污染)。
  463. func (s *MaintenanceService) ReplayActivitySigned(ctx context.Context) (*ReplayActivitySignedResult, error) {
  464. if err := s.assertSuperAdmin("ReplayActivitySigned"); err != nil {
  465. return nil, err
  466. }
  467. out := &ReplayActivitySignedResult{}
  468. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  469. txCtx := repository.WithTxContext(ctx, tx)
  470. src, err := s.maint.ListSignedReplayRows(txCtx)
  471. if err != nil {
  472. return err
  473. }
  474. out.Scanned = len(src)
  475. type groupKey struct {
  476. activityID int64
  477. userID string
  478. }
  479. // 保留每个 (activity,user) 的最后一条(pool.id 升序 → 后写覆盖前写)
  480. latest := make(map[groupKey]repository.SignedReplayRow, len(src))
  481. order := make([]groupKey, 0, len(src))
  482. for i := range src {
  483. row := src[i]
  484. key := groupKey{activityID: row.ActivityID, userID: strings.TrimSpace(row.UserID)}
  485. if key.activityID <= 0 || key.userID == "" {
  486. continue
  487. }
  488. if _, ok := latest[key]; !ok {
  489. order = append(order, key)
  490. }
  491. latest[key] = row
  492. }
  493. for _, key := range order {
  494. row := latest[key]
  495. if _, perr := parseAmount(row.Income); perr != nil {
  496. return perr
  497. }
  498. if _, perr := parseAmount(row.DiscountAmount); perr != nil {
  499. return perr
  500. }
  501. if _, perr := parseAmount(row.DiscountAmountUsed); perr != nil {
  502. return perr
  503. }
  504. affected, uerr := s.maint.UpdateSignedFromReplay(txCtx, tx, key.activityID, key.userID,
  505. repository.SignedReplayPatch{
  506. RechargeAmount: row.Income,
  507. ActivityMoney: row.DiscountAmount,
  508. ActivityMoneyUsed: row.DiscountAmountUsed,
  509. RequestNo: normalizeOptString(row.RequestNo),
  510. ResponseUserID: normalizeOptString(row.ResponseUserID),
  511. ResponseTime: row.ResponseTime,
  512. ResponseMsg: normalizeOptString(row.ResponseMsg),
  513. })
  514. if uerr != nil {
  515. return uerr
  516. }
  517. out.Groups++
  518. if affected == 0 {
  519. out.SkippedCanceled++
  520. continue
  521. }
  522. out.Updated += affected
  523. }
  524. return nil
  525. })
  526. if err != nil {
  527. return nil, err
  528. }
  529. return out, nil
  530. }
  531. // orderNoFromStudentRef 还原 used_log.order_no(报课信息单号)。
  532. //
  533. // 入参:no 旧 UUID 单号(*string,优先);id 新模型 int64 外键(回落)。
  534. // 出参:单据号字符串(两者皆缺失 → "",调用方落 NULL)。
  535. //
  536. // 口径(dump 762756 `orderid = t1.courses_studentid`,**映射不可改**):旧库写的是报课信息 UUID;
  537. // 目标态该 UUID 存在 `courses_student_no`/`biz_no`,因此优先取旧单号;仅在迁移未回填
  538. // `courses_student_no` 时回落 int64 id(保证"有单号可查"优于"丢单号")。
  539. func orderNoFromStudentRef(no *string, id *int64) string {
  540. if no != nil && strings.TrimSpace(*no) != "" {
  541. return strings.TrimSpace(*no)
  542. }
  543. if id != nil && *id > 0 {
  544. return strconv.FormatInt(*id, 10)
  545. }
  546. return ""
  547. }