// 课程/排班规则/报课账户仓储(t_courses / t_courses_classify / t_courses_class / t_courses_student) // ——AC-11 课程域最小闭环。 // // 依据:unified/03-migration/academic-domain-design.md §3.5(课程类 3 SP)/ §11-3(排班/打卡盘点结论) // / §1.4(事务模型)/ §1.7(scoping)/ §10.3(仓储不开事务)/ §10.4(游标 → 集合)。 // // 表语义分层(详见 model/models_course.go 文件头): // // t_courses(课程模板:周规则 week + 次数 number + 单价 + 时长) // → t_courses_class(排班规则:某课在某班由某老师按 week + 起止时分 排课) // → t_courses_record(排班实例,见 record_repo.go) // → t_courses_student(报课订单 = 学费账户 / 课次账户,欠费核销与扣课时的作用对象) // // 覆盖的 dump 存储过程映射(**逐条对齐 dump 原文,行号为 yuansiwei_finance.sql**): // // sp_courses_update() → ListCoursesNeedingMaxPeopleBackfill + BatchSetMaxPeople(SP28,763490) // sp_courses_update_campusid() → ListCourseIDsWithZeroCampus + ListClassCampusIDs + SetCourseCampus(SP29,763533) // sp_courses_student_counteract_arrears_course() → ListArrearsCandidates // + IncNetReceiptsAndDecRemainderReceivable(SP27,763435) // // 作用域(设计 §10.8 A 类):四张表均带 institution_id/campus_id(NOT NULL 默认 0), // 因此统一继承 BaseRepo 并**每条查询**经 `r.Scoped(db)` 注入机构/校区过滤。 // 特例说明:`t_courses.campus_id = 0` 表示"尚未归属单一校区"(由 SP29 回填), // 普通角色(scope.CampusID > 0)的 `campus_id = ?` 严格过滤会看不到这些待回填行—— // 这是**有意的**(校区角色不该看到未归属课程),SP29 只允许 super_admin 执行, // 其 scope.SuperAdmin=true 时 Scoped 不加过滤,故回填不受影响(见 MaintenanceService 断言)。 // // 事务纪律:写方法一律接收 `tx *gorm.DB`(**仓储不开事务**,边界在 Service); // 读方法经 db(ctx) 取句柄,ctx 内已注入事务(WithContext)时自动复用同一 tx。 package repository import ( "context" "strings" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // CourseRepo 课程模板 + 分类 + 排班规则 + 报课账户仓储(多租户,继承 BaseRepo)。 type CourseRepo struct { *BaseRepo } // NewCourseRepo 构造课程仓储。 // // 入参: // - db *gorm.DB:连接池句柄(必填)。 // - sc *appscope.Scope:请求作用域(系统任务可 nil,但一旦查询即 panic fail-fast,见 BaseRepo.Scoped)。 // // 出参:*CourseRepo(CampusScoped=true:四张表都带 campus_id)。 func NewCourseRepo(db *gorm.DB, sc *appscope.Scope) *CourseRepo { r := &CourseRepo{BaseRepo: NewBaseRepo(db, sc)} r.CampusScoped = true return r } // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。 // // 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。 func (r *CourseRepo) db(ctx context.Context) (*gorm.DB, error) { if r == nil || r.DB == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: CourseRepo.DB 为 nil")) } if tx := TxFromContext(ctx); tx != nil { return tx, nil } if ctx == nil { return r.DB, nil } return r.DB.WithContext(ctx), nil } // --------------------------------------------------------------------------- // 课程模板(t_courses) // --------------------------------------------------------------------------- // GetCourseByID 按主键取课程模板(**作用域强制**:跨机构/校区一律 ErrNotFound,不泄露存在性)。 // // 入参:ctx 请求上下文;id 课程 ID(<=0 → ErrBadParam,避免无意义查询)。 // 出参:*model.Courses;ErrNotFound(3001) / ErrBadParam(2002) / ErrDBError(5002)。 func (r *CourseRepo) GetCourseByID(ctx context.Context, id int64) (*model.Courses, error) { if id <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: course id 必须 > 0")) } db, err := r.db(ctx) if err != nil { return nil, err } var out model.Courses q := r.Scoped(db.Model(&model.Courses{})) if err := q.Where("id = ?", id).First(&out).Error; err != nil { return nil, wrapQueryErr(err, "GetCourseByID(t_courses)") } return &out, nil } // CourseFilter 课程列表过滤条件(端点 44:classify/status/keyword + 分页)。 // // 字段: // - ClassifyID int64:分类 ID(<=0 不筛); // - Status *int32:状态(nil 不筛;用指针以区分"未传"与"传了 0=未发布"); // - Keyword string:名称模糊匹配(trim 后空串不筛)。 type CourseFilter struct { ClassifyID int64 Status *int32 Keyword string } // ListCourses 分页查询课程模板(按 id 倒序,新建在前)。 // // 入参:ctx;f CourseFilter;pg PageQuery(内部钳制 pageSize ∈ [1,200])。 // 出参:([]model.Courses, total int64, error);空结果为**非 nil 空切片**(设计 §10.6)。 func (r *CourseRepo) ListCourses(ctx context.Context, f CourseFilter, pg PageQuery) ([]model.Courses, int64, error) { db, err := r.db(ctx) if err != nil { return nil, 0, err } build := func() *gorm.DB { q := r.Scoped(db.Model(&model.Courses{})) if f.ClassifyID > 0 { q = q.Where("classify_id = ?", f.ClassifyID) } if f.Status != nil { q = q.Where("status = ?", *f.Status) } if kw := strings.TrimSpace(f.Keyword); kw != "" { // 名称模糊匹配:LIKE 通配符由参数承载(GORM 参数化,无注入面) q = q.Where("name LIKE ?", "%"+kw+"%") } return q } var total int64 if err := build().Count(&total).Error; err != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } page, size := pg.Normalize() rows := make([]model.Courses, 0, size) if err := build().Order("id DESC").Offset(PageOffset(page, size)).Limit(size).Find(&rows).Error; err != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } return rows, total, nil } // CreateCourse 新建课程模板(作用域列由 Service 用 GetScopedID 补齐后传入)。 // // 入参:ctx;tx(**必传**);row 课程行(Name 非空;金额列已由 Service 归一为 "0.00" 形式)。 // 出参:nil 成功(回填自增 ID);ErrBadParam / ErrDBError。 func (r *CourseRepo) CreateCourse(ctx context.Context, tx *gorm.DB, row *model.Courses) error { if err := requireTx(tx, "CreateCourse"); err != nil { return err } if row == nil || strings.TrimSpace(row.Name) == "" { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateCourse 需要 name")) } if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil } // UpdateCourse 按**列白名单**更新课程模板(只更新调用方显式给出的列)。 // // 入参: // - ctx;tx(**必传**); // - row 课程行(ID 必填,其余字段按 columns 选择性落库); // - columns 列名集合(**调用方必须用 model 列名**,非空)。 // // 出参:int64 受影响行数(0 = 不存在或不在作用域内);ErrBadParam / ErrDBError。 // // 说明:`Select(columns).Updates(row)` 是"部分更新"的唯一安全形状——不带 Select 会写入零值 // (把未传字段清空),带 map 则丢失类型信息(decimal 字符串无法校验)。列名由 Service 以字面量传入, // 不接受外部字符串(无注入面)。 func (r *CourseRepo) UpdateCourse(ctx context.Context, tx *gorm.DB, row *model.Courses, columns []string) (int64, error) { if err := requireTx(tx, "UpdateCourse"); err != nil { return 0, err } if row == nil || row.ID <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateCourse 需要 id")) } if len(columns) == 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateCourse 需要显式列清单")) } // 作用域过滤:tx 上再叠机构/校区(防跨租户改) q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.Courses{})) res := q.Where("id = ?", row.ID).Select(columns).Updates(row) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // --------------------------------------------------------------------------- // 课程分类(t_courses_classify) // --------------------------------------------------------------------------- // GetClassifyByID 按主键取课程分类(SP28 回填 max_people 的来源;课酬试算的输入)。 // // 入参:ctx;id 分类 ID(<=0 → ErrBadParam)。 // 出参:*model.CoursesClassify;ErrNotFound / ErrBadParam / ErrDBError。 func (r *CourseRepo) GetClassifyByID(ctx context.Context, id int64) (*model.CoursesClassify, error) { if id <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: classify id 必须 > 0")) } db, err := r.db(ctx) if err != nil { return nil, err } var out model.CoursesClassify q := r.Scoped(db.Model(&model.CoursesClassify{})) if err := q.Where("id = ?", id).First(&out).Error; err != nil { return nil, wrapQueryErr(err, "GetClassifyByID(t_courses_classify)") } return &out, nil } // --------------------------------------------------------------------------- // 排班规则(t_courses_class)——「班级」在 API 层即本表 // --------------------------------------------------------------------------- // ListClassesByCourses 取某课程的全部排班规则(按 id 升序,稳定顺序便于排班展开复现)。 // // 入参: // - ctx 请求上下文; // - coursesID 课程 ID(<=0 → ErrBadParam); // - onlyActive true 表示只取 status=1(进行中)的规则(排班生成专用)。 // // 出参:[]model.CoursesClass(空结果为非 nil 空切片);ErrBadParam / ErrDBError。 func (r *CourseRepo) ListClassesByCourses(ctx context.Context, coursesID int64, onlyActive bool) ([]model.CoursesClass, error) { if coursesID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListClassesByCourses 需要 courses_id")) } db, err := r.db(ctx) if err != nil { return nil, err } q := r.Scoped(db.Model(&model.CoursesClass{})).Where("courses_id = ?", coursesID) if onlyActive { q = q.Where("status = ?", model.CoursesClassStatusOngoing) } rows := make([]model.CoursesClass, 0, 8) if err := q.Order("id ASC").Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } return rows, nil } // GetClassByID 按主键取排班规则(打卡/结束上课时取 lesson_cost / number 快照)。 // // 入参:ctx;id 规则 ID(<=0 → ErrBadParam)。 // 出参:*model.CoursesClass;ErrNotFound / ErrBadParam / ErrDBError。 func (r *CourseRepo) GetClassByID(ctx context.Context, id int64) (*model.CoursesClass, error) { if id <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: class id 必须 > 0")) } db, err := r.db(ctx) if err != nil { return nil, err } var out model.CoursesClass q := r.Scoped(db.Model(&model.CoursesClass{})) if err := q.Where("id = ?", id).First(&out).Error; err != nil { return nil, wrapQueryErr(err, "GetClassByID(t_courses_class)") } return &out, nil } // CreateClass 新建排班规则(week + 起止时分 + 老师 + 课费)。 // // 入参:ctx;tx(**必传**);row 规则行(CoursesID>0;Week 已由 Service 校验)。 // 出参:nil 成功(回填 ID);ErrBadParam / ErrDBError。 func (r *CourseRepo) CreateClass(ctx context.Context, tx *gorm.DB, row *model.CoursesClass) error { if err := requireTx(tx, "CreateClass"); err != nil { return err } if row == nil || row.CoursesID <= 0 { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateClass 需要 courses_id")) } if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil } // ListClassCampusIDs 取某课程排班规则涉及的**去重校区**列表(SP29 判"是否只属于单一校区")。 // // 入参:ctx;coursesID 课程 ID(<=0 → ErrBadParam)。 // 出参:[]int64 去重校区 ID(升序;空切片表示该课程尚无排班规则);ErrBadParam / ErrDBError。 // // dump 原文(763546):`SELECT COUNT(*) FROM (SELECT campusid FROM t_courses_class WHERE coursesid=logId // GROUP BY campusid) t` → 只关心"去重后的校区个数",故等价改写为 DISTINCT 列表(集合化,§10.4)。 func (r *CourseRepo) ListClassCampusIDs(ctx context.Context, coursesID int64) ([]int64, error) { if coursesID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListClassCampusIDs 需要 courses_id")) } db, err := r.db(ctx) if err != nil { return nil, err } var ids []int64 q := r.Scoped(db.Model(&model.CoursesClass{})).Where("courses_id = ?", coursesID) if err := q.Distinct().Order("campus_id ASC").Pluck("campus_id", &ids).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } if ids == nil { ids = make([]int64, 0) } return ids, nil } // --------------------------------------------------------------------------- // SP28 数据修复:max_people 从分类继承(dump 763490 sp_courses_update) // --------------------------------------------------------------------------- // MaxPeopleBackfillRow 待回填行(课程 ID + 其分类的 max_people)。 // // 字段:CourseID 课程 ID;MaxPeople 分类表的目标值(0 表示分类也未配置 → 回填后仍为 0,无实际变化)。 type MaxPeopleBackfillRow struct { CourseID int64 `gorm:"column:course_id"` MaxPeople int32 `gorm:"column:max_people"` } // ListCoursesNeedingMaxPeopleBackfill 取 `max_people = 0` 的课程及其分类的 max_people。 // // 入参:ctx 请求上下文。 // 出参:[]MaxPeopleBackfillRow(按 course_id 升序 —— 对齐 dump 游标 `ORDER BY t1.id`);ErrDBError。 // // dump 原文(763492): // // 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; // // ⚠ LEFT JOIN 语义(**不可改 INNER JOIN**):分类缺失时 t2.max_people 为 NULL → dump 的 // `UPDATE t_courses SET max_people = NULL` 在有 NOT NULL 约束的新表上会失败;目标态该列 // `NOT NULL DEFAULT 0`,故分类缺失归为 0("无分类可继承"= 保持 0,与 INNER JOIN 的跳过等价, // 但**行仍计入扫描数**,便于运维核对)。 // // 实现说明(**两查而非 SQL JOIN**):两张表都带 institution_id/campus_id,JOIN 后 // `Scoped` 注入的裸列名 `institution_id` 会变成 ambiguous column(MySQL/SQLite 均报错), // 而 Scoped 不接受表别名。改为"先取课程行 → 再按 classify_id 批量取分类"(2 条索引查询、 // 无 N+1),结果与 LEFT JOIN 等价(缺失分类 → 0)。 func (r *CourseRepo) ListCoursesNeedingMaxPeopleBackfill(ctx context.Context) ([]MaxPeopleBackfillRow, error) { db, err := r.db(ctx) if err != nil { return nil, err } type courseRow struct { CourseID int64 `gorm:"column:id"` ClassifyID int64 `gorm:"column:classify_id"` } courses := make([]courseRow, 0, 32) q := r.Scoped(db.Model(&model.Courses{})).Select("id, classify_id").Where("max_people = 0").Order("id ASC") if err := q.Find(&courses).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } classifyIDs := make([]int64, 0, len(courses)) for i := range courses { if courses[i].ClassifyID > 0 { classifyIDs = append(classifyIDs, courses[i].ClassifyID) } } classifyMax := make(map[int64]int32, len(classifyIDs)) if len(classifyIDs) > 0 { type classifyRow struct { ID int64 `gorm:"column:id"` MaxPeople int32 `gorm:"column:max_people"` } rows := make([]classifyRow, 0, len(classifyIDs)) cq := r.Scoped(db.Model(&model.CoursesClassify{})).Select("id, max_people").Where("id IN ?", dedupPositive(classifyIDs)) if err := cq.Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } for i := range rows { classifyMax[rows[i].ID] = rows[i].MaxPeople } } out := make([]MaxPeopleBackfillRow, 0, len(courses)) for i := range courses { target := classifyMax[courses[i].ClassifyID] // 缺失/无分类 → 0(保持 0,等价 dump 的 NULL 归一) out = append(out, MaxPeopleBackfillRow{CourseID: courses[i].CourseID, MaxPeople: target}) } return out, nil } // BatchSetMaxPeople 批量回填 max_people(同一目标值的课程一次 UPDATE,`WHERE id IN (...)`)。 // // 入参:ctx;tx(**必传**);maxPeople 目标值(>0 才有意义,Service 保证);courseIDs 课程 ID 集合。 // 出参:int64 受影响行数;ErrBadParam / ErrDBError。 // // 集合化说明(§10.4):dump 是游标逐行 `UPDATE ... WHERE id = curId`;同一 max_people 值的一组 // 课程终态与逐行更新等价,故按值分组一次 UPDATE(组内 ORDER BY 已在外层 SELECT 保证, // **分配顺序不变**)。`max_people = 0` 的组由 Service 跳过(写 0 等于不改,RowsAffected 会误导运维)。 func (r *CourseRepo) BatchSetMaxPeople(ctx context.Context, tx *gorm.DB, maxPeople int32, courseIDs []int64) (int64, error) { if err := requireTx(tx, "BatchSetMaxPeople"); err != nil { return 0, err } if maxPeople <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: BatchSetMaxPeople 需要 max_people > 0")) } ids := dedupPositive(courseIDs) if len(ids) == 0 { return 0, nil } q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.Courses{})) res := q.Where("id IN ?", ids).UpdateColumn("max_people", maxPeople) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // --------------------------------------------------------------------------- // SP29 数据修复:campus_id 回填(dump 763533 sp_courses_update_campusid) // --------------------------------------------------------------------------- // ListCourseIDsWithZeroCampus 取 `campus_id = 0`(未归属单一校区)的课程 ID。 // // 入参:ctx 请求上下文。 // 出参:[]int64(升序;本方法**刻意加** ORDER BY —— dump 游标无 ORDER BY,行序不保证; // 回填结果与行序无关(每行独立判定),加排序只为让重跑日志可比对、可复现);ErrDBError。 func (r *CourseRepo) ListCourseIDsWithZeroCampus(ctx context.Context) ([]int64, error) { db, err := r.db(ctx) if err != nil { return nil, err } var ids []int64 q := r.Scoped(db.Model(&model.Courses{})).Where("campus_id = 0") if err := q.Order("id ASC").Pluck("id", &ids).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } if ids == nil { ids = make([]int64, 0) } return ids, nil } // SetCourseCampus 回填课程归属校区(仅当 Service 判定"该课程只属于 1 个校区"时调用)。 // // 入参:ctx;tx(**必传**);id 课程 ID(>0);campusID 目标校区(>0)。 // 出参:int64 受影响行数(0 = 并发下已被回填或不在作用域);ErrBadParam / ErrDBError。 // // 红线:**不得**在 campus_count != 1 时调用(多校区课程保持 0,dump 763552 的 IF 分支); // WHERE 同时限 `campus_id = 0` 以防并发回填覆盖人工指定值(幂等)。 func (r *CourseRepo) SetCourseCampus(ctx context.Context, tx *gorm.DB, id, campusID int64) (int64, error) { if err := requireTx(tx, "SetCourseCampus"); err != nil { return 0, err } if id <= 0 || campusID <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SetCourseCampus 需要 id 与 campus_id 均 > 0")) } q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.Courses{})) res := q.Where("id = ? AND campus_id = 0", id).UpdateColumn("campus_id", campusID) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // --------------------------------------------------------------------------- // 报课账户(t_courses_student):SP27 欠费核销 + 结束上课扣课时 // --------------------------------------------------------------------------- // ArrearsCandidate 欠费核销候选行(报课记录 ID + 剩余应收)。 // // 字段: // - StudentCourseID int64:t_courses_student.id; // - RemainderReceivable string:剩余应收(**元字符串**;sqlite NUMERIC 回读不能直接落 money.Amount, // 由 Service 用 money.Parse 转分,口径与全仓一致)。 type ArrearsCandidate struct { StudentCourseID int64 `gorm:"column:student_course_id"` RemainderReceivable string `gorm:"column:remainder_receivable"` } // ListArrearsCandidates 取某学生在本机构/校区的欠费报课列表(SP27 游标集合化)。 // // 入参:ctx;studentID 学生(ULID);institutionID/campusID 作用域(均 >0,Service 已校验)。 // // 出参:[]ArrearsCandidate(按"课程开课时分升序,其次报课 id 升序");ErrBadParam / ErrDBError。 // // dump 原文(763446,**逐字对齐**): // // SELECT t1.id, t1.remainder_receivable FROM t_courses_student t1 // INNER JOIN t_courses_class t2 ON t1.coursesid=t2.coursesid AND t1.classid=t2.classid // WHERE t1.studentid=inputStudentid AND t1.schoolid=inputSchoolid AND t1.campusid=inputCampusid // AND t1.remainder_receivable>0 AND t1.confirm_discount_userid>0 // ORDER BY t2.start_time ASC; // // 两处**有意归一**(均已登记交付说明,评审重点): // 1. `confirm_discount_userid > 0`:新模型该列是 ULID 字符串(*string),"有一个确认操作人"的 // 等价判据是 `IS NOT NULL AND <> ”`;**NULL/空串 = 不可充值**(模型红线),故不能用 0 替代。 // 2. `INNER JOIN t_courses_class` 是 1:N —— 同一 (courses_id, class_id) 有多条排班规则时会把 // **同一报课行**重复取出,旧 SP 游标逐行核销就会把这一行核多次。Go 侧改为 // `MIN(start_time)` 聚合子查询:**同一报课行只出现一次**,排序依据仍是课程开课时分(顺序不变)。 // // 这一处是 **`defensive flattening`(防御性折平)**,不是对**已观测缺陷**的修正。依据(AC-15 实测): // · 机制成立:`t_courses_class` 上**没有** `(coursesid, classid)` 唯一索引, // 所以"同一对多条排班"在 schema 上是合法形态; // · 但真实数据 **0 例**:6173 行 `t_courses_class` 里 `(coursesid,classid)` 重复组数 = 0, // 且 `t_courses_student` 里 `(coursesid,classid,studentid)` 重复组数也是 0 // ⇒ "旧 SP 重复核销"这件事**从未在真实数据上发生过**; // · 保留理由:schema 未禁止该形态,折平成本只是一个 `GROUP BY`;而如果哪天有人把这里 // 简化回普通 JOIN,双跑 case RA-2 会红(`internal/academic/dualrun/stage2`)。 // // ⚠ 措辞上的红线:不要把它写成"dump 缺陷"。写"缺陷"会让人以为有真实受害数据、 // 进而去别处找"修复前/后"的账目差 —— 那是找不到的,因为根本没有实例(F21)。 // 3. 排序额外补了 `t1.id ASC`:旧 SP 只有 `ORDER BY t2.start_time ASC`,**没有 tie-break**。 // 真实数据里 `(学生, start_time)` 分组 5535 组中有 4846 组并列(87.5%), // 并列时旧 SP 的"先核哪一行"由执行计划决定 ⇒ 补一个确定性次序,才能让结果可复现。 func (r *CourseRepo) ListArrearsCandidates(ctx context.Context, studentID string, institutionID, campusID int64) ([]ArrearsCandidate, error) { if strings.TrimSpace(studentID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListArrearsCandidates 需要 student_id")) } if institutionID <= 0 || campusID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListArrearsCandidates 需要 institution_id 与 campus_id 均 > 0")) } db, err := r.db(ctx) if err != nil { return nil, err } const firstStartSub = "SELECT courses_id, class_id, MIN(start_time) AS first_start " + "FROM t_courses_class GROUP BY courses_id, class_id" rows := make([]ArrearsCandidate, 0, 8) q := r.Scoped(db.Table("t_courses_student AS t1")). Select("t1.id AS student_course_id, t1.remainder_receivable AS remainder_receivable"). Joins("INNER JOIN ("+firstStartSub+") AS t2 ON t1.courses_id = t2.courses_id AND t1.class_id = t2.class_id"). Where("t1.student_id = ?", strings.TrimSpace(studentID)). Where("t1.institution_id = ? AND t1.campus_id = ?", institutionID, campusID). Where("t1.remainder_receivable > 0"). Where("t1.confirm_discount_user_id IS NOT NULL AND t1.confirm_discount_user_id <> ''"). Order("t2.first_start ASC, t1.id ASC") if err := q.Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } return rows, nil } // IncNetReceiptsAndDecRemainderReceivable 欠费核销落账(`net_receipts + amount`、`remainder_receivable - amount`)。 // // 入参:ctx;tx(**必传**);studentCourseID 报课记录 ID(>0);amount 本次核销金额(>0)。 // 出参:int64 受影响行数(0 = 行不存在/不在作用域);ErrBadParam / ErrDBError。 // // dump 原文(763476): // // UPDATE t_courses_student SET net_receipts = net_receipts + remainderReceivable, // remainder_receivable = remainder_receivable - remainderReceivable WHERE id = stuCourseid; // // 红线:两列**同一条 UPDATE**(拆两条会出现"实收加了、应收没减"的中间态); // `unpaid` 列 dump **不动**(§3.5 表格文字写 unpaid>0,与 dump 原文不一致,以 dump 为准)。 func (r *CourseRepo) IncNetReceiptsAndDecRemainderReceivable(ctx context.Context, tx *gorm.DB, studentCourseID int64, amount money.Amount) (int64, error) { if err := requireTx(tx, "IncNetReceiptsAndDecRemainderReceivable"); err != nil { return 0, err } if studentCourseID <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 核销需要 t_courses_student.id")) } if !amount.GreaterThan(0) { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 核销金额必须 > 0")) } if err := amount.Validate(); err != nil { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err) } q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesStudent{})) res := q.Where("id = ?", studentCourseID).Updates(map[string]any{ "net_receipts": gorm.Expr("net_receipts + ?", amount.String()), "remainder_receivable": gorm.Expr("remainder_receivable - ?", amount.String()), }) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // ListActiveStudentsByCourses 取某课程**在读**报课学生(结束上课时"未显式打卡则按在读名单全勤")。 // // 入参:ctx;coursesID 课程 ID(<=0 → ErrBadParam)。 // 出参:[]model.CoursesStudent(按 id 升序 —— 固定顺序保证扣课时与分账的**锁序可复现**); // ErrBadParam / ErrDBError。 func (r *CourseRepo) ListActiveStudentsByCourses(ctx context.Context, coursesID int64) ([]model.CoursesStudent, error) { if coursesID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListActiveStudentsByCourses 需要 courses_id")) } db, err := r.db(ctx) if err != nil { return nil, err } rows := make([]model.CoursesStudent, 0, 16) q := r.Scoped(db.Model(&model.CoursesStudent{})). Where("courses_id = ?", coursesID). Where("status = ?", model.CoursesStudentStatusNormal) if err := q.Order("id ASC").Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } return rows, nil } // GetStudentCourse 按(课程 + 学生)取在读报课记录(扣课时前锁定扣减基数)。 // // 入参:ctx;coursesID 课程 ID(>0);studentID 学生(ULID,非空)。 // 出参:*model.CoursesStudent;ErrNotFound(未报课)/ ErrBadParam / ErrDBError。 // // 说明:同一学生对同一课程理论上一条在读记录;按 id 升序取第一条(多行脏数据不阻断流程, // 由运维按报表修复)。 func (r *CourseRepo) GetStudentCourse(ctx context.Context, coursesID int64, studentID string) (*model.CoursesStudent, error) { if coursesID <= 0 || strings.TrimSpace(studentID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetStudentCourse 需要 courses_id/student_id")) } db, err := r.db(ctx) if err != nil { return nil, err } var out model.CoursesStudent q := r.Scoped(db.Model(&model.CoursesStudent{})).Where("courses_id = ? AND student_id = ?", coursesID, studentID) if err := q.Order("id ASC").First(&out).Error; err != nil { return nil, wrapQueryErr(err, "GetStudentCourse(t_courses_student)") } return &out, nil } // ApplyLessonDeduction 扣课时:`finished_number + 1`、`remainder_number - 1`(下限 0)、`remainder_cost - cost`。 // // 入参:ctx;tx(**必传**);studentCourseID 报课记录 ID(>0);cost 本次课费(>=0;0 表示赠送/免费)。 // 出参:int64 受影响行数;ErrBadParam / ErrDBError。 // // 语义(无 dump 依据 —— 本期为 PHP 逻辑的 Go 化最小闭环,设计 §11-3): // - `finished_number` 只增(打卡计数); // - `remainder_number` 用 `CASE WHEN > 0 THEN -1 ELSE 0` 兜底,避免脏数据把剩余课次刷成负数; // - `remainder_cost` **不做下限截断**:负数即"课时已用超"的账实不符信号,必须暴露而非静默抹平 // (与 remainder_number 的取舍不同:课次是计数、费用是金额,金额静默截断会破坏对账恒等式)。 func (r *CourseRepo) ApplyLessonDeduction(ctx context.Context, tx *gorm.DB, studentCourseID int64, cost money.Amount) (int64, error) { if err := requireTx(tx, "ApplyLessonDeduction"); err != nil { return 0, err } if studentCourseID <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ApplyLessonDeduction 需要 t_courses_student.id")) } if cost.IsNegative() { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 扣课金额不能为负")) } if err := cost.Validate(); err != nil { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err) } fields := map[string]any{ "finished_number": gorm.Expr("finished_number + 1"), "remainder_number": gorm.Expr("CASE WHEN remainder_number > 0 THEN remainder_number - 1 ELSE 0 END"), } if !cost.IsZero() { fields["remainder_cost"] = gorm.Expr("remainder_cost - ?", cost.String()) } q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesStudent{})) res := q.Where("id = ?", studentCourseID).Updates(fields) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // dedupPositive 去重并剔除非正数(批量 UPDATE 的 IN 列表净化)。 // // 入参:ids int64 集合(可含重复/0/负数)。出参:去重后的正数切片(保持首次出现顺序)。 func dedupPositive(ids []int64) []int64 { out := make([]int64, 0, len(ids)) seen := make(map[int64]struct{}, len(ids)) for _, id := range ids { if id <= 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } return out }