// 课程域**补充数据访问**(端点 44/46/48/53/54/55/57 所需,AC-13c 追加)——只新增方法, // **不修改** AC-11 已交付的任何仓储方法(`course_repo.go` / `record_repo.go` 一行未动)。 // // 依据:unified/03-migration/academic-frontend-contract-v1.md §3.4 C 表(字段级定义)+ // §3.4 总原则 ③「字段准入条件」(每个响应字段必须能追溯到某个具体业务列)。 // // --------------------------------------------------------------------------- // 为什么需要这些方法(AC-11 的读能力够不上契约字段) // --------------------------------------------------------------------------- // // 端点 44 `teacherIds` ← `t_courses_class.teacher_id` 去重(`t_courses` **没有** teacher 列) // 端点 44 `recordCount` ← `t_courses_record` 计数(课程下的排班实例数) // 端点 53 报课学生分页 ← `t_courses_student` 按课程过滤 + 分页(AC-11 只有"取全部在读") // 端点 54 报名写入 ← `t_courses_student` INSERT(AC-11 没有报课入口,只有扣课时/核销) // 端点 55 欠费跨课程分页 ← `remainder_receivable > 0`(AC-11 只有"按单个学生取候选") // 端点 57 学生课表 ← `t_courses_record` ⨝ `t_courses_student`(按学生本人过滤) // // **聚合位置的选择**:`teacherIds` / `recordCount` 用"批量 IN 查询 + 内存映射"而不是 // 逐行查(N+1):一页最多 200 门课程,逐行查 = 400 次往返;批量是 2 次。 // // 事务纪律(与既有仓储一致):写方法接收 `tx *gorm.DB`(仓储不开事务);读方法经 db(ctx) 取句柄。 package repository import ( "context" "strings" "time" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" infraerrors "yuansiwei/unified-backend/internal/infra/errors" ) // --------------------------------------------------------------------------- // 端点 44:课程列表的聚合一列 // --------------------------------------------------------------------------- // ListTeacherIDsByCourses 批量取"课程 → 老师 ULID 列表"(端点 44 的 `teacherIds`)。 // // 入参:ctx;courseIDs 课程 ID 集合(空集合 → 返回空 map,不发 SQL)。 // 出参:map[课程 ID][]老师 ULID(每课程内**去重 + 升序**;无排班规则的课程不出现该 key); // ErrDBError。 // // 来源列:`t_courses_class.teacher_id`(`size:26` 的 ULID,可 NULL)。 // 为什么 NULL/空串要剔除:契约 §1.5「用户 ID 是字符串(ULID 26 位)」——把 NULL 变成一个 // 空串元素会让前端 `teacherIds.map(...)` 出现一个查不到的老师,是典型的静默脏数据。 // // 排序(去重 + 升序)是**有意**的:契约未定义顺序,但固定顺序让"两次调用结果不同" // 不会成为前端偶发渲染差异的来源。 func (r *CourseRepo) ListTeacherIDsByCourses(ctx context.Context, courseIDs []int64) (map[int64][]string, error) { out := make(map[int64][]string, len(courseIDs)) ids := dedupPositive(courseIDs) if len(ids) == 0 { return out, nil } db, err := r.db(ctx) if err != nil { return nil, err } // 两列都带 institution_id/campus_id(Scoped 可注入);teacher_id 为 NULL 的行由 SQL 剔除。 type row struct { CoursesID int64 `gorm:"column:courses_id"` TeacherID string `gorm:"column:teacher_id"` } rows := make([]row, 0, len(ids)) q := r.Scoped(db.Model(&model.CoursesClass{})). Select("courses_id, teacher_id"). Where("courses_id IN ?", ids). Where("teacher_id IS NOT NULL AND teacher_id <> ''"). Distinct() if err := q.Order("courses_id ASC, teacher_id ASC").Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } for i := range rows { cid := rows[i].CoursesID tid := strings.TrimSpace(rows[i].TeacherID) if tid == "" { continue } if last, ok := out[cid]; ok && len(last) > 0 && last[len(last)-1] == tid { continue // DISTINCT 已去重;这里只防大小写/空格归一后的重复 } out[cid] = append(out[cid], tid) } return out, nil } // CountRecordsByCourses 批量取"课程 → 排班实例条数"(端点 44 的 `recordCount`)。 // // 入参:ctx;courseIDs(空集合 → 空 map)。 // 出参:map[课程 ID]条数(无实例的课程不出现该 key,调用方按 0 处理);ErrDBError。 // // 来源列:`t_courses_record.courses_id`。**计入全部状态**(0 未开始 / 1 上课中 / 2 已结束)—— // 契约字段名是 `recordCount`(排班实例数),不是"已上课程数";按状态过滤会让"排了 10 节课、 // 上了 1 节"的课程显示 1,与排班页的语义冲突。 func (r *CourseRepo) CountRecordsByCourses(ctx context.Context, courseIDs []int64) (map[int64]int64, error) { out := make(map[int64]int64, len(courseIDs)) ids := dedupPositive(courseIDs) if len(ids) == 0 { return out, nil } db, err := r.db(ctx) if err != nil { return nil, err } type row struct { CoursesID int64 `gorm:"column:courses_id"` Total int64 `gorm:"column:total"` } rows := make([]row, 0, len(ids)) q := r.Scoped(db.Model(&model.CoursesRecord{})). Select("courses_id, COUNT(*) AS total"). Where("courses_id IN ?", ids). Group("courses_id") if err := q.Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } for i := range rows { out[rows[i].CoursesID] = rows[i].Total } return out, nil } // --------------------------------------------------------------------------- // 端点 53 / 55:报课学生(t_courses_student) // --------------------------------------------------------------------------- // CoursesStudentFilter 报课行查询过滤(端点 53 与 55 共用)。 // // 字段: // - CoursesID int64:课程 ID(<=0 不筛); // - StudentID string:学生 ULID(空不筛); // - Status *int8:报课状态(nil 不筛;1 正常 / 2 结束); // - OnlyArrears bool:**只要 `remainder_receivable > 0`**(端点 55 的唯一判据, // 见设计 §3.8-E4:`unpaid` 是 2018-05 起停更的僵尸列,**不得**作为欠费判据)。 type CoursesStudentFilter struct { CoursesID int64 StudentID string Status *int8 OnlyArrears bool } // ListCoursesStudents 分页查询报课行(端点 53 / 55)。 // // 入参:ctx;f 过滤;pg 分页(内部钳制 pageSize ∈ [1,200])。 // 出参:([]model.CoursesStudent, total int64, error);空结果为**非 nil 空切片**; // ErrDBError。 // // 排序:`id ASC`(报课先后)。**有意不用 `remainder_receivable DESC`**——金额排序会让 // 同一页在"核销发生"后行序跳动,前端翻页会重复/漏行;欠费额度排序交给前端对当前页做。 func (r *CourseRepo) ListCoursesStudents(ctx context.Context, f CoursesStudentFilter, pg PageQuery) ([]model.CoursesStudent, int64, error) { db, err := r.db(ctx) if err != nil { return nil, 0, err } build := func() *gorm.DB { q := r.Scoped(db.Model(&model.CoursesStudent{})) if f.CoursesID > 0 { q = q.Where("courses_id = ?", f.CoursesID) } if sid := strings.TrimSpace(f.StudentID); sid != "" { q = q.Where("student_id = ?", sid) } if f.Status != nil { q = q.Where("status = ?", *f.Status) } if f.OnlyArrears { // 欠费判据(**唯一口径**):remainder_receivable > 0。 // ⚠ 禁止改成 `unpaid > 0`:该列 2018-05-17 起停止更新,真实数据几乎恒 0, // 用它筛会把"确实欠费"的行**全部漏掉**,且列表显示"无人欠费"看起来完全正常。 q = q.Where("remainder_receivable > 0") } return q } var total int64 if err := build().Count(&total).Error; err != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } _, size := pg.Normalize() rows := make([]model.CoursesStudent, 0, size) if err := build().Order("id ASC").Offset(pg.Offset()).Limit(pg.Limit()).Find(&rows).Error; err != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } return rows, total, nil } // CountActiveEnrollments 统计"某学生某课程的在读报课行数"(端点 54 的重复报名判据)。 // // 入参:ctx;coursesID(>0);studentID(ULID)。 // 出参:条数;ErrBadParam / ErrDBError。 // // 为什么在仓储里数而不是"先查一条再看":重复报名是**写入前的排他判据**, // 用 COUNT 表达"是否存在在读行"可读且不会因多行脏数据漏判(>0 即重复)。 func (r *CourseRepo) CountActiveEnrollments(ctx context.Context, coursesID int64, studentID string) (int64, error) { if coursesID <= 0 || strings.TrimSpace(studentID) == "" { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountActiveEnrollments 需要 courses_id/student_id")) } db, err := r.db(ctx) if err != nil { return 0, err } var total int64 q := r.Scoped(db.Model(&model.CoursesStudent{})). Where("courses_id = ? AND student_id = ?", coursesID, strings.TrimSpace(studentID)). Where("status = ?", model.CoursesStudentStatusNormal) if err := q.Count(&total).Error; err != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } return total, nil } // CreateCoursesStudent 新建报课行(端点 54)。 // // 入参:ctx;tx(**必传**);row(BizNo/CoursesID/StudentID 必填,金额列已由 Service 归一)。 // 出参:nil 成功(回填自增 ID);ErrBadParam / ErrDBError。 func (r *CourseRepo) CreateCoursesStudent(ctx context.Context, tx *gorm.DB, row *model.CoursesStudent) error { if err := requireTx(tx, "CreateCoursesStudent"); err != nil { return err } if row == nil || row.CoursesID <= 0 || strings.TrimSpace(row.StudentID) == "" || strings.TrimSpace(row.BizNo) == "" { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateCoursesStudent 需要 biz_no/courses_id/student_id")) } if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil } // --------------------------------------------------------------------------- // 端点 57:学生课表(t_courses_record ⨝ t_courses_student) // --------------------------------------------------------------------------- // ListStudentSchedule 分页查询"某学生报名课程的排班实例"(端点 57)。 // // 入参:ctx;studentID 学生 ULID(必填);from/to 上课时间窗(`from <= start_time < to`, // 右开,契约 §1.3;nil 表示该端不限);pg 分页。 // 出参:([]model.CoursesRecord, total int64, error);ErrBadParam / ErrDBError。 // // SQL 形状(**为什么走子查询而不是 JOIN**): // // SELECT * FROM t_courses_record // WHERE AND courses_id IN ( // SELECT courses_id FROM t_courses_student // WHERE AND student_id = ? AND status = 1 // ) // ORDER BY start_time ASC, id ASC // // - 只用 `record_student`(打卡行)会**漏掉还没打卡的未来课**,那正是"课表"最主要的用途; // - 只用 `record` 会列出学生没报的课程 ⇒ 必须经 `t_courses_student` 限定范围; // - `s.status = 1`(在读):已结课的老课程不该继续出现在课表里; // - ⚠ **不能写成 `JOIN t_courses_student`**:`BaseRepo.Scoped` 注入的是**裸列名** // `institution_id`,两张表都有该列 ⇒ MySQL/SQLite 都会报 `ambiguous column name`。 // 子查询让每张表的过滤条件各自解析,既消除歧义,作用域也没被放松(内外层都过 Scoped)。 func (r *CourseRepo) ListStudentSchedule(ctx context.Context, studentID string, from, to *time.Time, pg PageQuery) ([]model.CoursesRecord, int64, error) { sid := strings.TrimSpace(studentID) if sid == "" { return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListStudentSchedule 需要 student_id")) } db, err := r.db(ctx) if err != nil { return nil, 0, err } // 学生报名的课程集合(内层已按作用域过滤:跨机构的报课行不会把课程带出来) enrolled := r.Scoped(db.Model(&model.CoursesStudent{}).Select("courses_id")). Where("student_id = ?", sid). Where("status = ?", model.CoursesStudentStatusNormal) build := func() *gorm.DB { q := r.Scoped(db.Model(&model.CoursesRecord{})). Where("courses_id IN (?)", enrolled) if from != nil && !from.IsZero() { q = q.Where("start_time >= ?", *from) } if to != nil && !to.IsZero() { q = q.Where("start_time < ?", *to) } return q } var total int64 if err := build().Count(&total).Error; err != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } _, size := pg.Normalize() rows := make([]model.CoursesRecord, 0, size) order := "start_time ASC, id ASC" if err := build().Order(order).Offset(pg.Offset()).Limit(pg.Limit()).Find(&rows).Error; err != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } return rows, total, nil } // ListRecordStudentsOfStudent 批量取"排班实例 → 该学生的打卡行"(端点 57 的考勤列)。 // // 入参:ctx;studentID(ULID);recordIDs 排班实例 ID 集合(空 → 空 map,不发 SQL)。 // 出参:map[recordID]model.CoursesRecordStudent(每个实例最多一行);ErrDBError。 // // 用途:课表页要显示"这节课我的课费/赠送/是否已打卡",来源是 `t_courses_record_student` // 里**该学生**的那一行。批量而非逐行查,理由同 `ListTeacherIDsByCourses`(防 N+1)。 func (r *RecordRepo) ListRecordStudentsOfStudent(ctx context.Context, studentID string, recordIDs []int64) (map[int64]model.CoursesRecordStudent, error) { out := make(map[int64]model.CoursesRecordStudent, len(recordIDs)) sid := strings.TrimSpace(studentID) ids := dedupPositive(recordIDs) if sid == "" || len(ids) == 0 { return out, nil } db, err := r.db(ctx) if err != nil { return nil, err } rows := make([]model.CoursesRecordStudent, 0, len(ids)) q := r.Scoped(db.Model(&model.CoursesRecordStudent{})). Where("student_id = ?", sid). Where("record_id IN ?", ids). Order("id ASC") if err := q.Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } for i := range rows { // 同一 (record, student) 理论上一行;多行脏数据取**最早一行**(id ASC 的第一次), // 不静默合并金额(合并会把两笔课费的算术事故藏起来)。 if _, exists := out[rows[i].RecordID]; exists { continue } out[rows[i].RecordID] = rows[i] } return out, nil } // ListCoursesByIDs 批量取课程模板(端点 57 回显课程名用)。 // // 入参:ctx;ids(空 → 空切片)。 // 出参:[]model.Courses(**非 nil 空切片**);ErrDBError。 // // 说明:走 `Scoped`(课程表带作用域列)——跨机构的课程 ID 查不到,属于预期的"看不见"。 func (r *CourseRepo) ListCoursesByIDs(ctx context.Context, ids []int64) ([]model.Courses, error) { out := make([]model.Courses, 0, len(ids)) clean := dedupPositive(ids) if len(clean) == 0 { return out, nil } db, err := r.db(ctx) if err != nil { return nil, err } q := r.Scoped(db.Model(&model.Courses{})).Where("id IN ?", clean).Order("id ASC") if err := q.Find(&out).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } return out, nil }