| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572 |
- // 运维 / 数据修复服务: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 ""
- }
|