| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353 |
- // 课程域**补充数据访问**(端点 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 <scope(t_courses_record)> AND courses_id IN (
- // SELECT courses_id FROM t_courses_student
- // WHERE <scope(t_courses_student)> 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
- }
|