// 运维 / 数据修复服务:3 个数据修复型 SP + 2 个迁移型 SP——AC-11。 // // 依据:unified/03-migration/academic-domain-design.md §3.5(课程类 3 SP)/ §3.6(迁移类 2 SP) // / §3.8 BUG#3(TRUNCATE → DELETE+INSERT)/ §4.5(端点 56、61~63)/ §10.3(事务模板)。 // // 五个存储过程的 Go 落点(**逐条按 dump 原文实现,顺序不可改**): // // #27 sp_courses_student_counteract_arrears_course → CounteractArrears(dump 763435) // #28 sp_courses_update() → BackfillCoursesMaxPeople(dump 763490) // #29 sp_courses_update_campusid() → BackfillCoursesCampus(dump 763533) // #30 copy_activity_discountLog_to_usedLog() → RebuildActivityUsedLog(dump 762745,**BUG#3 修正**) // #31 copy_activity_discount_to_signed() → ReplayActivitySigned(dump 762792) // // 幂等纪律(AC-11 ③):三个"修复型"方法必须**可重跑**—— // - SP28 只处理 `max_people = 0` 的行,回填后不再命中 → 第二次扫描 Scanned=0; // - SP29 只处理 `campus_id = 0` 且"只属于单一校区"的课程 → 多校区课程保持 0,重跑不会误填; // - SP27 只在 `remainder_receivable > 0` 时核销,核销到 0 后不再命中。 // // 迁移纪律(AC-11 ④ / BUG#3):SP30 的 `TRUNCATE` 改为 **同事务 `DELETE` + 批量 `INSERT`** —— // TRUNCATE 是 DDL 隐式提交,中途失败即数据全丢;DELETE 在事务内可回滚(单测断言"失败不丢数据")。 // // 越权(设计 §4.5):端点 61~63 仅 super_admin;SP28~SP31 在 Service 层显式断言 SuperAdmin // (SP27 是正常业务动作,由 AC-08 在欠费核销链路调用,不做超管断言,靠作用域校验)。 package service import ( "context" "strconv" "strings" "time" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/apperr" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" "yuansiwei/unified-backend/internal/academic/repository" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // MaintenanceService 运维/修复服务(作用域绑定在构造时;SP28~31 额外要求 SuperAdmin)。 type MaintenanceService struct { // courses 课程/报课账户仓储(SP27 核销、SP28/29 回填)。 courses *repository.CourseRepo // maint 迁移型 SP 专用仓储(全局运维视角,不继承 BaseRepo)。 maint *repository.MaintenanceRepo // runner 事务运行器(每个 SP 一个事务,失败整体回滚)。 runner *repository.TxRunner // sc 请求作用域(用于 SuperAdmin 断言)。 sc *appscope.Scope // now 时钟(单测注入)。 now func() time.Time } // NewMaintenanceService 构造运维服务。 // // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域)。 // 出参:*MaintenanceService。 func NewMaintenanceService(db *gorm.DB, sc *appscope.Scope) *MaintenanceService { return &MaintenanceService{ courses: repository.NewCourseRepo(db, sc), maint: repository.NewMaintenanceRepo(db), runner: repository.NewTxRunner(db), sc: sc, now: time.Now, } } // assertSuperAdmin 端点 61~63 的越权断言(设计 §4.5:维护端点仅 super_admin)。 // // 出参:nil 通过;infraerrors.ErrUnscoped(1004) 非超管。 // // 为什么在 Service 再断言一次:Handler 角色白名单(AC-13/14)是粗粒度第一道防线; // SP28~31 是**全表级**运维动作(SP30 会清空整表),必须在业务层留第二道,防止 // 将来有人从内部调用绕过路由白名单。 func (s *MaintenanceService) assertSuperAdmin(op string) error { if s == nil || s.sc == nil || !s.sc.SuperAdmin { return infraerrors.WithCause(infraerrors.ErrUnscoped, errString("service: "+op+" 仅 super_admin 可执行(维护/迁移型操作)")) } return nil } // --------------------------------------------------------------------------- // SP27 欠费核销(dump 763435 sp_courses_student_counteract_arrears_course) // --------------------------------------------------------------------------- // CounteractArrearsInput 欠费核销入参(端点 56;dump 4 参语义)。 // // 字段:StudentID 学生 ULID;InstitutionID/CampusID 作用域(均 >0);Money 待核销金额(分)。 type CounteractArrearsInput struct { StudentID string InstitutionID int64 CampusID int64 Money money.Amount } // ArrearsApplied 单条报课的核销明细(回显/对账用)。 // // 字段:StudentCourseID 报课记录;Amount 本次核销金额。 type ArrearsApplied struct { StudentCourseID int64 Amount money.Amount } // CounteractArrearsResult 欠费核销结果。 // // 字段:Applied 实际核销总额;Remaining 未用完、**留在账户**的金额(dump 不返还/不扣减余额, // 余额侧由调用方(AC-08 老带新结算)在同一 tx 内已入账);Items 明细。 type CounteractArrearsResult struct { Applied money.Amount Remaining money.Amount Items []ArrearsApplied } // CounteractArrears 把账户侧"被负数抵消"的金额补回欠费课程(SP27)。 // // 入参:ctx;in 入参。 // 出参:*CounteractArrearsResult;2001/2002 参数错;3113 核销冲突;5002。 // // dump 语义(763455~763480,逐行对齐): // // 游标:remainder_receivable>0 且有确认操作人的报课行,按课程开课时分 ASC(最早课优先); // 每行:IF inputMoney <= 0 THEN LEAVE(**金额用完即停止整个循环**); // IF inputMoney < remainderReceivable THEN remainderReceivable = inputMoney(**截到剩余额度**); // UPDATE net_receipts += remainderReceivable, remainder_receivable -= remainderReceivable; // inputMoney -= remainderReceivable; // 结束后:**余额多余部分留在账户**(dump 无退还动作)。 // // 事务纪律:本方法可在外层事务内以同 tx 调用(读 ctx 透传), // 与 AC-08 的 `use_activity_recommend → counteract_arrears` 链路保持单事务(§10.3 红线)。 func (s *MaintenanceService) CounteractArrears(ctx context.Context, in CounteractArrearsInput) (*CounteractArrearsResult, error) { studentID := strings.TrimSpace(in.StudentID) if studentID == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 欠费核销需要 studentId")) } if in.InstitutionID <= 0 || in.CampusID <= 0 { // 与 §1.6 口径一致:campusid<=0 → 3140(旧 helper 取不到校区的参数错) return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 欠费核销需要 institutionId 与 campusId 均 > 0")) } out := &CounteractArrearsResult{Items: make([]ArrearsApplied, 0, 4)} remaining := in.Money if !remaining.GreaterThan(0) { // dump:inputMoney <= 0 → 循环第一轮即 LEAVE,整体 no-op(不是错误)。 // Remaining 归零:没有核销动作,也就没有"未用完留在账户"的金额(把负数原样回显会误导调用方)。 return out, nil } if err := remaining.Validate(); err != nil { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, err) } err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) candidates, err := s.courses.ListArrearsCandidates(txCtx, studentID, in.InstitutionID, in.CampusID) if err != nil { return err } applied := money.Amount(0) for i := range candidates { if !remaining.GreaterThan(0) { break // dump:金额用完 → LEAVE readLoop(停止整个循环,不是 continue) } cand := &candidates[i] owed, perr := parseAmount(cand.RemainderReceivable) if perr != nil { return perr } amount := owed if remaining.LessThan(owed) { amount = remaining } if !amount.GreaterThan(0) { continue // 脏数据(remainder_receivable 解析为 0):跳过,不写 0 元核销 } affected, uerr := s.courses.IncNetReceiptsAndDecRemainderReceivable(txCtx, tx, cand.StudentCourseID, amount) if uerr != nil { return uerr } if affected == 0 { // 并发/作用域变化导致行不可写:核销冲突(3113,dump 无此分支,属 Go 侧显式化) return infraerrors.WithCause(apperr.ErrArrearsCounteractFailed, errString("service: 欠费核销目标行不可写(id="+strconv.FormatInt(cand.StudentCourseID, 10)+")")) } remaining = remaining.Sub(amount) applied = applied.Add(amount) out.Items = append(out.Items, ArrearsApplied{StudentCourseID: cand.StudentCourseID, Amount: amount}) } out.Applied = applied out.Remaining = remaining return nil }) if err != nil { return nil, err } return out, nil } // --------------------------------------------------------------------------- // SP28 修复:课程 max_people 从分类继承(dump 763490 sp_courses_update) // --------------------------------------------------------------------------- // BackfillMaxPeopleResult 修复结果(可重跑:第二次 Scanned=0)。 // // 字段:Scanned 扫描行数(`max_people=0`);Filled 实际回填行数;Skipped 无需回填行数 // (分类缺失或分类 max_people 也是 0 —— 写 0 等于不改);Affected SQL 影响行数合计。 type BackfillMaxPeopleResult struct { Scanned int Filled int Skipped int Affected int64 } // BackfillCoursesMaxPeople 把 `t_courses.max_people = 0` 的行按分类 `max_people` 回填(SP28)。 // // 入参:ctx。 // 出参:*BackfillMaxPeopleResult;1004 非超管;5002。 // // dump 语义(763492~763508): // // CURSOR FOR SELECT t1.id, t2.max_people FROM t_courses t1 LEFT JOIN t_courses_classify t2 // ON t1.classifyid=t2.id WHERE t1.max_people=0 ORDER BY t1.id; // LOOP: UPDATE t_courses SET max_people = maxPeople WHERE id = coursesid; // // 幂等(可重跑):WHERE 固定 `max_people = 0`,回填后不再命中 → 第二次扫描为空。 // 集合化(§10.4):同一目标值的一组课程一次 `UPDATE ... WHERE id IN (...)`,终态与逐行等价; // 目标值为 0 的组**跳过**(写 0 不改变数据,计入 Skipped 以免运维误判"已修复")。 func (s *MaintenanceService) BackfillCoursesMaxPeople(ctx context.Context) (*BackfillMaxPeopleResult, error) { if err := s.assertSuperAdmin("BackfillCoursesMaxPeople"); err != nil { return nil, err } out := &BackfillMaxPeopleResult{} err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) rows, err := s.courses.ListCoursesNeedingMaxPeopleBackfill(txCtx) if err != nil { return err } out.Scanned = len(rows) // 按目标值分组(顺序按首次出现,即 dump 游标 id ASC 顺序) groups := make(map[int32][]int64, 4) order := make([]int32, 0, 4) for i := range rows { row := &rows[i] if row.MaxPeople <= 0 { out.Skipped++ continue } if _, ok := groups[row.MaxPeople]; !ok { order = append(order, row.MaxPeople) } groups[row.MaxPeople] = append(groups[row.MaxPeople], row.CourseID) } for _, value := range order { ids := groups[value] affected, aerr := s.courses.BatchSetMaxPeople(txCtx, tx, value, ids) if aerr != nil { return aerr } out.Affected += affected out.Filled += len(ids) } return nil }) if err != nil { return nil, err } return out, nil } // --------------------------------------------------------------------------- // SP29 修复:课程 campus_id 回填(dump 763533 sp_courses_update_campusid) // --------------------------------------------------------------------------- // BackfillCoursesCampusResult 修复结果(可重跑)。 // // 字段:Scanned 扫描的 `campus_id=0` 课程数;Updated 实际回填数; // SkippedMultiCampus 多校区课程数(**保持 0,不动**);SkippedNoRule 无排班规则课程数(校区数为 0 ≠ 1)。 type BackfillCoursesCampusResult struct { Scanned int Updated int SkippedMultiCampus int SkippedNoRule int } // BackfillCoursesCampus 给"只属于单一校区"的课程回填 `campus_id`(SP29)。 // // 入参:ctx。 // 出参:*BackfillCoursesCampusResult;1004 非超管;5002。 // // dump 语义(763538~763560): // // CURSOR FOR SELECT id FROM t_courses WHERE campusid = 0; // LOOP: // SELECT COUNT(*) FROM (SELECT campusid FROM t_courses_class WHERE coursesid=logId GROUP BY campusid) t; // IF (campusCount = 1) THEN // SELECT campusid FROM t_courses_class WHERE coursesid=logId LIMIT 0,1; // UPDATE t_courses SET campusid = courseCampusid WHERE id = logId; // END IF; // // **反直觉口径(必须配反向断言)**: // - 多校区课程(campusCount >= 2)→ **不回填,保持 0**("机构级课程",误填会把课程锁进某一个校区, // 导致其它校区看不到课程 / 作用域越权); // - 无排班规则(campusCount = 0)→ **不回填**(0 ≠ 1;dump 的 IF 不成立)。 func (s *MaintenanceService) BackfillCoursesCampus(ctx context.Context) (*BackfillCoursesCampusResult, error) { if err := s.assertSuperAdmin("BackfillCoursesCampus"); err != nil { return nil, err } out := &BackfillCoursesCampusResult{} err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) ids, err := s.courses.ListCourseIDsWithZeroCampus(txCtx) if err != nil { return err } out.Scanned = len(ids) for _, courseID := range ids { campusIDs, cerr := s.courses.ListClassCampusIDs(txCtx, courseID) if cerr != nil { return cerr } switch len(campusIDs) { case 0: out.SkippedNoRule++ continue case 1: // 预期分支:唯一校区 → 回填 default: out.SkippedMultiCampus++ continue } if campusIDs[0] <= 0 { // 排班规则的校区本身是 0:把课程从 0 回填成 0 无意义,计入"无规则"跳过 out.SkippedNoRule++ continue } affected, uerr := s.courses.SetCourseCampus(txCtx, tx, courseID, campusIDs[0]) if uerr != nil { return uerr } if affected > 0 { out.Updated++ } } return nil }) if err != nil { return nil, err } return out, nil } // --------------------------------------------------------------------------- // SP30 迁移:重建 used_log(dump 762745 copy_activity_discountLog_to_usedLog,BUG#3 修正) // --------------------------------------------------------------------------- // RebuildUsedLogResult 重建结果。 // // 字段:Scanned 源流水行数;Deleted 清空的旧行数;Inserted 新写入行数; // Skipped 因 NOT NULL 约束无法表达而跳过的行数(见 RebuildActivityUsedLog 注释)。 type RebuildUsedLogResult struct { Scanned int Deleted int64 Inserted int64 Skipped int } // RebuildActivityUsedLog 用 discount_log 全量重建 used_log(SP30)。 // // 入参:ctx。 // 出参:*RebuildActivityUsedLogResult;1004 非超管;5002(**整体回滚,旧数据不丢**)。 // // **BUG#3 修正(设计 §3.8,最重要的一条)**:dump 先 `TRUNCATE TABLE t_courses_activity_used_log`, // TRUNCATE 属 DDL、MySQL 会**隐式提交**当前事务 —— "清空成功、重建失败"时数据永久丢失且无法回滚。 // Go 侧改为同一事务内 `DELETE ... WHERE 1=1` + 批量 `INSERT`:任一失败整体回滚,旧数据完好, // 作业可安全重跑(单测 TestRebuildActivityUsedLogRollbackKeepsRows 用注入失败断言)。 // // 字段映射(**不可改**,口径锚点 dump 762754/762768): // // activityid ← pool.activity_id(LEFT JOIN 未命中 → NULL) // userid ← pool.user_id(同上) // orderid ← log.courses_studentid ★ **映射不得改**: // 旧库该列是报课信息的 UUID 单号,新模型拆成 // courses_student_id(int64) + courses_student_no(旧 UUID), // 而 used_log.order_no 承载的是**单据号**(其它 SP 也写 biz_no), // 故取 courses_student_no,缺失时回落 courses_student_id 的十进制串。 // activity_money_used ← log.discount_amount_used(**原值**,不做符号翻转) // create_time ← log.create_time(业务时间,§5.4 ① 类) // memo ← log.memo // // 偏离登记(有意):dump 的 LEFT JOIN 允许 activityid/userid 为 NULL → 新模型两列 NOT NULL, // 这类行(池行被删的孤儿流水)**跳过并计入 Skipped**(不静默造 0 值行,避免污染 activity=0 的统计)。 func (s *MaintenanceService) RebuildActivityUsedLog(ctx context.Context) (*RebuildUsedLogResult, error) { if err := s.assertSuperAdmin("RebuildActivityUsedLog"); err != nil { return nil, err } out := &RebuildUsedLogResult{} err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) src, err := s.maint.ListUsedLogRebuildRows(txCtx) if err != nil { return err } out.Scanned = len(src) now := s.now() rows := make([]*model.CoursesActivityUsedLog, 0, len(src)) for i := range src { row := &src[i] if row.ActivityID == nil || *row.ActivityID <= 0 || row.UserID == nil || strings.TrimSpace(*row.UserID) == "" { out.Skipped++ continue } orderNo := orderNoFromStudentRef(row.CoursesStudentNo, row.CoursesStudentID) createTime := now if row.CreateTime != nil && !row.CreateTime.IsZero() { createTime = *row.CreateTime } moneyUsed := strings.TrimSpace(row.DiscountAmountUsed) if moneyUsed == "" { moneyUsed = "0.00" } if _, perr := parseAmount(moneyUsed); perr != nil { return perr // 金额列写坏:整笔回滚(不静默归零,避免污染对账) } rows = append(rows, &model.CoursesActivityUsedLog{ ActivityID: *row.ActivityID, UserID: strings.TrimSpace(*row.UserID), OrderNo: stringPtr(orderNo), ActivityMoneyUsed: moneyUsed, CreateTime: createTime, Memo: normalizeOptString(row.Memo), }) } deleted, derr := s.maint.DeleteAllUsedLogs(txCtx, tx) if derr != nil { return derr } out.Deleted = deleted inserted, ierr := s.maint.BatchInsertUsedLogs(txCtx, tx, rows) if ierr != nil { return ierr } out.Inserted = inserted return nil }) if err != nil { return nil, err } return out, nil } // --------------------------------------------------------------------------- // SP31 迁移:重放 signed(dump 762792 copy_activity_discount_to_signed) // --------------------------------------------------------------------------- // ReplayActivitySignedResult 重放结果。 // // 字段:Scanned 源池行数;Updated 回写行数(signed.status>0);SkippedCanceled 命中 0 行 // (该用户已取消报名 status=0 → dump 的 UPDATE 不生效,属预期而非异常);Groups 实际执行的 UPDATE 次数。 type ReplayActivitySignedResult struct { Scanned int Updated int64 SkippedCanceled int64 Groups int } // ReplayActivitySigned 用充值赠送池 + 审批通过工单回写 signed 的 7 列(SP31)。 // // 入参:ctx。 // 出参:*ReplayActivitySignedResult;1004 非超管;5002。 // // dump 语义(762800~762812): // // 游标:pool INNER JOIN balance_request ON pool.balance_requestid = req.id // AND req.aproved = 1 AND req.type = 100(**只认审批通过的普通充值工单**); // 逐行 UPDATE t_courses_activity_signed SET recharge_amount, activity_money, activity_money_used, // requestid, response_userid, response_time, response_msg // WHERE activityid=? AND userid=? AND status > 0; // // 集合化(§10.4):同一 (activity, user) 可能有多条池行(多次充值)→ dump 逐行 UPDATE 是 // **后写覆盖前写**,故 Go 侧按 (activity, user) 保留**最后一条**(pool.id 升序的最后一条 = 最大 id), // 一次 UPDATE 写终态,语义等价且避免 N 次往返。 // // **反直觉口径(配反向断言)**:`status > 0` 不可省 —— 用户已取消报名(status=0)时 // 绝不能被历史工单"复活",否则赠送额度会被重新算回可用池(资金口径污染)。 func (s *MaintenanceService) ReplayActivitySigned(ctx context.Context) (*ReplayActivitySignedResult, error) { if err := s.assertSuperAdmin("ReplayActivitySigned"); err != nil { return nil, err } out := &ReplayActivitySignedResult{} err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) src, err := s.maint.ListSignedReplayRows(txCtx) if err != nil { return err } out.Scanned = len(src) type groupKey struct { activityID int64 userID string } // 保留每个 (activity,user) 的最后一条(pool.id 升序 → 后写覆盖前写) latest := make(map[groupKey]repository.SignedReplayRow, len(src)) order := make([]groupKey, 0, len(src)) for i := range src { row := src[i] key := groupKey{activityID: row.ActivityID, userID: strings.TrimSpace(row.UserID)} if key.activityID <= 0 || key.userID == "" { continue } if _, ok := latest[key]; !ok { order = append(order, key) } latest[key] = row } for _, key := range order { row := latest[key] if _, perr := parseAmount(row.Income); perr != nil { return perr } if _, perr := parseAmount(row.DiscountAmount); perr != nil { return perr } if _, perr := parseAmount(row.DiscountAmountUsed); perr != nil { return perr } affected, uerr := s.maint.UpdateSignedFromReplay(txCtx, tx, key.activityID, key.userID, repository.SignedReplayPatch{ RechargeAmount: row.Income, ActivityMoney: row.DiscountAmount, ActivityMoneyUsed: row.DiscountAmountUsed, RequestNo: normalizeOptString(row.RequestNo), ResponseUserID: normalizeOptString(row.ResponseUserID), ResponseTime: row.ResponseTime, ResponseMsg: normalizeOptString(row.ResponseMsg), }) if uerr != nil { return uerr } out.Groups++ if affected == 0 { out.SkippedCanceled++ continue } out.Updated += affected } return nil }) if err != nil { return nil, err } return out, nil } // orderNoFromStudentRef 还原 used_log.order_no(报课信息单号)。 // // 入参:no 旧 UUID 单号(*string,优先);id 新模型 int64 外键(回落)。 // 出参:单据号字符串(两者皆缺失 → "",调用方落 NULL)。 // // 口径(dump 762756 `orderid = t1.courses_studentid`,**映射不可改**):旧库写的是报课信息 UUID; // 目标态该 UUID 存在 `courses_student_no`/`biz_no`,因此优先取旧单号;仅在迁移未回填 // `courses_student_no` 时回落 int64 id(保证"有单号可查"优于"丢单号")。 func orderNoFromStudentRef(no *string, id *int64) string { if no != nil && strings.TrimSpace(*no) != "" { return strings.TrimSpace(*no) } if id != nil && *id > 0 { return strconv.FormatInt(*id, 10) } return "" }