course_extra_repo.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. // 课程域**补充数据访问**(端点 44/46/48/53/54/55/57 所需,AC-13c 追加)——只新增方法,
  2. // **不修改** AC-11 已交付的任何仓储方法(`course_repo.go` / `record_repo.go` 一行未动)。
  3. //
  4. // 依据:unified/03-migration/academic-frontend-contract-v1.md §3.4 C 表(字段级定义)+
  5. // §3.4 总原则 ③「字段准入条件」(每个响应字段必须能追溯到某个具体业务列)。
  6. //
  7. // ---------------------------------------------------------------------------
  8. // 为什么需要这些方法(AC-11 的读能力够不上契约字段)
  9. // ---------------------------------------------------------------------------
  10. //
  11. // 端点 44 `teacherIds` ← `t_courses_class.teacher_id` 去重(`t_courses` **没有** teacher 列)
  12. // 端点 44 `recordCount` ← `t_courses_record` 计数(课程下的排班实例数)
  13. // 端点 53 报课学生分页 ← `t_courses_student` 按课程过滤 + 分页(AC-11 只有"取全部在读")
  14. // 端点 54 报名写入 ← `t_courses_student` INSERT(AC-11 没有报课入口,只有扣课时/核销)
  15. // 端点 55 欠费跨课程分页 ← `remainder_receivable > 0`(AC-11 只有"按单个学生取候选")
  16. // 端点 57 学生课表 ← `t_courses_record` ⨝ `t_courses_student`(按学生本人过滤)
  17. //
  18. // **聚合位置的选择**:`teacherIds` / `recordCount` 用"批量 IN 查询 + 内存映射"而不是
  19. // 逐行查(N+1):一页最多 200 门课程,逐行查 = 400 次往返;批量是 2 次。
  20. //
  21. // 事务纪律(与既有仓储一致):写方法接收 `tx *gorm.DB`(仓储不开事务);读方法经 db(ctx) 取句柄。
  22. package repository
  23. import (
  24. "context"
  25. "strings"
  26. "time"
  27. "gorm.io/gorm"
  28. "yuansiwei/unified-backend/internal/academic/model"
  29. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  30. )
  31. // ---------------------------------------------------------------------------
  32. // 端点 44:课程列表的聚合一列
  33. // ---------------------------------------------------------------------------
  34. // ListTeacherIDsByCourses 批量取"课程 → 老师 ULID 列表"(端点 44 的 `teacherIds`)。
  35. //
  36. // 入参:ctx;courseIDs 课程 ID 集合(空集合 → 返回空 map,不发 SQL)。
  37. // 出参:map[课程 ID][]老师 ULID(每课程内**去重 + 升序**;无排班规则的课程不出现该 key);
  38. // ErrDBError。
  39. //
  40. // 来源列:`t_courses_class.teacher_id`(`size:26` 的 ULID,可 NULL)。
  41. // 为什么 NULL/空串要剔除:契约 §1.5「用户 ID 是字符串(ULID 26 位)」——把 NULL 变成一个
  42. // 空串元素会让前端 `teacherIds.map(...)` 出现一个查不到的老师,是典型的静默脏数据。
  43. //
  44. // 排序(去重 + 升序)是**有意**的:契约未定义顺序,但固定顺序让"两次调用结果不同"
  45. // 不会成为前端偶发渲染差异的来源。
  46. func (r *CourseRepo) ListTeacherIDsByCourses(ctx context.Context, courseIDs []int64) (map[int64][]string, error) {
  47. out := make(map[int64][]string, len(courseIDs))
  48. ids := dedupPositive(courseIDs)
  49. if len(ids) == 0 {
  50. return out, nil
  51. }
  52. db, err := r.db(ctx)
  53. if err != nil {
  54. return nil, err
  55. }
  56. // 两列都带 institution_id/campus_id(Scoped 可注入);teacher_id 为 NULL 的行由 SQL 剔除。
  57. type row struct {
  58. CoursesID int64 `gorm:"column:courses_id"`
  59. TeacherID string `gorm:"column:teacher_id"`
  60. }
  61. rows := make([]row, 0, len(ids))
  62. q := r.Scoped(db.Model(&model.CoursesClass{})).
  63. Select("courses_id, teacher_id").
  64. Where("courses_id IN ?", ids).
  65. Where("teacher_id IS NOT NULL AND teacher_id <> ''").
  66. Distinct()
  67. if err := q.Order("courses_id ASC, teacher_id ASC").Find(&rows).Error; err != nil {
  68. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  69. }
  70. for i := range rows {
  71. cid := rows[i].CoursesID
  72. tid := strings.TrimSpace(rows[i].TeacherID)
  73. if tid == "" {
  74. continue
  75. }
  76. if last, ok := out[cid]; ok && len(last) > 0 && last[len(last)-1] == tid {
  77. continue // DISTINCT 已去重;这里只防大小写/空格归一后的重复
  78. }
  79. out[cid] = append(out[cid], tid)
  80. }
  81. return out, nil
  82. }
  83. // CountRecordsByCourses 批量取"课程 → 排班实例条数"(端点 44 的 `recordCount`)。
  84. //
  85. // 入参:ctx;courseIDs(空集合 → 空 map)。
  86. // 出参:map[课程 ID]条数(无实例的课程不出现该 key,调用方按 0 处理);ErrDBError。
  87. //
  88. // 来源列:`t_courses_record.courses_id`。**计入全部状态**(0 未开始 / 1 上课中 / 2 已结束)——
  89. // 契约字段名是 `recordCount`(排班实例数),不是"已上课程数";按状态过滤会让"排了 10 节课、
  90. // 上了 1 节"的课程显示 1,与排班页的语义冲突。
  91. func (r *CourseRepo) CountRecordsByCourses(ctx context.Context, courseIDs []int64) (map[int64]int64, error) {
  92. out := make(map[int64]int64, len(courseIDs))
  93. ids := dedupPositive(courseIDs)
  94. if len(ids) == 0 {
  95. return out, nil
  96. }
  97. db, err := r.db(ctx)
  98. if err != nil {
  99. return nil, err
  100. }
  101. type row struct {
  102. CoursesID int64 `gorm:"column:courses_id"`
  103. Total int64 `gorm:"column:total"`
  104. }
  105. rows := make([]row, 0, len(ids))
  106. q := r.Scoped(db.Model(&model.CoursesRecord{})).
  107. Select("courses_id, COUNT(*) AS total").
  108. Where("courses_id IN ?", ids).
  109. Group("courses_id")
  110. if err := q.Find(&rows).Error; err != nil {
  111. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  112. }
  113. for i := range rows {
  114. out[rows[i].CoursesID] = rows[i].Total
  115. }
  116. return out, nil
  117. }
  118. // ---------------------------------------------------------------------------
  119. // 端点 53 / 55:报课学生(t_courses_student)
  120. // ---------------------------------------------------------------------------
  121. // CoursesStudentFilter 报课行查询过滤(端点 53 与 55 共用)。
  122. //
  123. // 字段:
  124. // - CoursesID int64:课程 ID(<=0 不筛);
  125. // - StudentID string:学生 ULID(空不筛);
  126. // - Status *int8:报课状态(nil 不筛;1 正常 / 2 结束);
  127. // - OnlyArrears bool:**只要 `remainder_receivable > 0`**(端点 55 的唯一判据,
  128. // 见设计 §3.8-E4:`unpaid` 是 2018-05 起停更的僵尸列,**不得**作为欠费判据)。
  129. type CoursesStudentFilter struct {
  130. CoursesID int64
  131. StudentID string
  132. Status *int8
  133. OnlyArrears bool
  134. }
  135. // ListCoursesStudents 分页查询报课行(端点 53 / 55)。
  136. //
  137. // 入参:ctx;f 过滤;pg 分页(内部钳制 pageSize ∈ [1,200])。
  138. // 出参:([]model.CoursesStudent, total int64, error);空结果为**非 nil 空切片**;
  139. // ErrDBError。
  140. //
  141. // 排序:`id ASC`(报课先后)。**有意不用 `remainder_receivable DESC`**——金额排序会让
  142. // 同一页在"核销发生"后行序跳动,前端翻页会重复/漏行;欠费额度排序交给前端对当前页做。
  143. func (r *CourseRepo) ListCoursesStudents(ctx context.Context, f CoursesStudentFilter, pg PageQuery) ([]model.CoursesStudent, int64, error) {
  144. db, err := r.db(ctx)
  145. if err != nil {
  146. return nil, 0, err
  147. }
  148. build := func() *gorm.DB {
  149. q := r.Scoped(db.Model(&model.CoursesStudent{}))
  150. if f.CoursesID > 0 {
  151. q = q.Where("courses_id = ?", f.CoursesID)
  152. }
  153. if sid := strings.TrimSpace(f.StudentID); sid != "" {
  154. q = q.Where("student_id = ?", sid)
  155. }
  156. if f.Status != nil {
  157. q = q.Where("status = ?", *f.Status)
  158. }
  159. if f.OnlyArrears {
  160. // 欠费判据(**唯一口径**):remainder_receivable > 0。
  161. // ⚠ 禁止改成 `unpaid > 0`:该列 2018-05-17 起停止更新,真实数据几乎恒 0,
  162. // 用它筛会把"确实欠费"的行**全部漏掉**,且列表显示"无人欠费"看起来完全正常。
  163. q = q.Where("remainder_receivable > 0")
  164. }
  165. return q
  166. }
  167. var total int64
  168. if err := build().Count(&total).Error; err != nil {
  169. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  170. }
  171. _, size := pg.Normalize()
  172. rows := make([]model.CoursesStudent, 0, size)
  173. if err := build().Order("id ASC").Offset(pg.Offset()).Limit(pg.Limit()).Find(&rows).Error; err != nil {
  174. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  175. }
  176. return rows, total, nil
  177. }
  178. // CountActiveEnrollments 统计"某学生某课程的在读报课行数"(端点 54 的重复报名判据)。
  179. //
  180. // 入参:ctx;coursesID(>0);studentID(ULID)。
  181. // 出参:条数;ErrBadParam / ErrDBError。
  182. //
  183. // 为什么在仓储里数而不是"先查一条再看":重复报名是**写入前的排他判据**,
  184. // 用 COUNT 表达"是否存在在读行"可读且不会因多行脏数据漏判(>0 即重复)。
  185. func (r *CourseRepo) CountActiveEnrollments(ctx context.Context, coursesID int64, studentID string) (int64, error) {
  186. if coursesID <= 0 || strings.TrimSpace(studentID) == "" {
  187. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  188. errString("repository: CountActiveEnrollments 需要 courses_id/student_id"))
  189. }
  190. db, err := r.db(ctx)
  191. if err != nil {
  192. return 0, err
  193. }
  194. var total int64
  195. q := r.Scoped(db.Model(&model.CoursesStudent{})).
  196. Where("courses_id = ? AND student_id = ?", coursesID, strings.TrimSpace(studentID)).
  197. Where("status = ?", model.CoursesStudentStatusNormal)
  198. if err := q.Count(&total).Error; err != nil {
  199. return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  200. }
  201. return total, nil
  202. }
  203. // CreateCoursesStudent 新建报课行(端点 54)。
  204. //
  205. // 入参:ctx;tx(**必传**);row(BizNo/CoursesID/StudentID 必填,金额列已由 Service 归一)。
  206. // 出参:nil 成功(回填自增 ID);ErrBadParam / ErrDBError。
  207. func (r *CourseRepo) CreateCoursesStudent(ctx context.Context, tx *gorm.DB, row *model.CoursesStudent) error {
  208. if err := requireTx(tx, "CreateCoursesStudent"); err != nil {
  209. return err
  210. }
  211. if row == nil || row.CoursesID <= 0 || strings.TrimSpace(row.StudentID) == "" || strings.TrimSpace(row.BizNo) == "" {
  212. return infraerrors.WithCause(infraerrors.ErrBadParam,
  213. errString("repository: CreateCoursesStudent 需要 biz_no/courses_id/student_id"))
  214. }
  215. if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
  216. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  217. }
  218. return nil
  219. }
  220. // ---------------------------------------------------------------------------
  221. // 端点 57:学生课表(t_courses_record ⨝ t_courses_student)
  222. // ---------------------------------------------------------------------------
  223. // ListStudentSchedule 分页查询"某学生报名课程的排班实例"(端点 57)。
  224. //
  225. // 入参:ctx;studentID 学生 ULID(必填);from/to 上课时间窗(`from <= start_time < to`,
  226. // 右开,契约 §1.3;nil 表示该端不限);pg 分页。
  227. // 出参:([]model.CoursesRecord, total int64, error);ErrBadParam / ErrDBError。
  228. //
  229. // SQL 形状(**为什么走子查询而不是 JOIN**):
  230. //
  231. // SELECT * FROM t_courses_record
  232. // WHERE <scope(t_courses_record)> AND courses_id IN (
  233. // SELECT courses_id FROM t_courses_student
  234. // WHERE <scope(t_courses_student)> AND student_id = ? AND status = 1
  235. // )
  236. // ORDER BY start_time ASC, id ASC
  237. //
  238. // - 只用 `record_student`(打卡行)会**漏掉还没打卡的未来课**,那正是"课表"最主要的用途;
  239. // - 只用 `record` 会列出学生没报的课程 ⇒ 必须经 `t_courses_student` 限定范围;
  240. // - `s.status = 1`(在读):已结课的老课程不该继续出现在课表里;
  241. // - ⚠ **不能写成 `JOIN t_courses_student`**:`BaseRepo.Scoped` 注入的是**裸列名**
  242. // `institution_id`,两张表都有该列 ⇒ MySQL/SQLite 都会报 `ambiguous column name`。
  243. // 子查询让每张表的过滤条件各自解析,既消除歧义,作用域也没被放松(内外层都过 Scoped)。
  244. func (r *CourseRepo) ListStudentSchedule(ctx context.Context, studentID string, from, to *time.Time, pg PageQuery) ([]model.CoursesRecord, int64, error) {
  245. sid := strings.TrimSpace(studentID)
  246. if sid == "" {
  247. return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  248. errString("repository: ListStudentSchedule 需要 student_id"))
  249. }
  250. db, err := r.db(ctx)
  251. if err != nil {
  252. return nil, 0, err
  253. }
  254. // 学生报名的课程集合(内层已按作用域过滤:跨机构的报课行不会把课程带出来)
  255. enrolled := r.Scoped(db.Model(&model.CoursesStudent{}).Select("courses_id")).
  256. Where("student_id = ?", sid).
  257. Where("status = ?", model.CoursesStudentStatusNormal)
  258. build := func() *gorm.DB {
  259. q := r.Scoped(db.Model(&model.CoursesRecord{})).
  260. Where("courses_id IN (?)", enrolled)
  261. if from != nil && !from.IsZero() {
  262. q = q.Where("start_time >= ?", *from)
  263. }
  264. if to != nil && !to.IsZero() {
  265. q = q.Where("start_time < ?", *to)
  266. }
  267. return q
  268. }
  269. var total int64
  270. if err := build().Count(&total).Error; err != nil {
  271. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  272. }
  273. _, size := pg.Normalize()
  274. rows := make([]model.CoursesRecord, 0, size)
  275. order := "start_time ASC, id ASC"
  276. if err := build().Order(order).Offset(pg.Offset()).Limit(pg.Limit()).Find(&rows).Error; err != nil {
  277. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  278. }
  279. return rows, total, nil
  280. }
  281. // ListRecordStudentsOfStudent 批量取"排班实例 → 该学生的打卡行"(端点 57 的考勤列)。
  282. //
  283. // 入参:ctx;studentID(ULID);recordIDs 排班实例 ID 集合(空 → 空 map,不发 SQL)。
  284. // 出参:map[recordID]model.CoursesRecordStudent(每个实例最多一行);ErrDBError。
  285. //
  286. // 用途:课表页要显示"这节课我的课费/赠送/是否已打卡",来源是 `t_courses_record_student`
  287. // 里**该学生**的那一行。批量而非逐行查,理由同 `ListTeacherIDsByCourses`(防 N+1)。
  288. func (r *RecordRepo) ListRecordStudentsOfStudent(ctx context.Context, studentID string, recordIDs []int64) (map[int64]model.CoursesRecordStudent, error) {
  289. out := make(map[int64]model.CoursesRecordStudent, len(recordIDs))
  290. sid := strings.TrimSpace(studentID)
  291. ids := dedupPositive(recordIDs)
  292. if sid == "" || len(ids) == 0 {
  293. return out, nil
  294. }
  295. db, err := r.db(ctx)
  296. if err != nil {
  297. return nil, err
  298. }
  299. rows := make([]model.CoursesRecordStudent, 0, len(ids))
  300. q := r.Scoped(db.Model(&model.CoursesRecordStudent{})).
  301. Where("student_id = ?", sid).
  302. Where("record_id IN ?", ids).
  303. Order("id ASC")
  304. if err := q.Find(&rows).Error; err != nil {
  305. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  306. }
  307. for i := range rows {
  308. // 同一 (record, student) 理论上一行;多行脏数据取**最早一行**(id ASC 的第一次),
  309. // 不静默合并金额(合并会把两笔课费的算术事故藏起来)。
  310. if _, exists := out[rows[i].RecordID]; exists {
  311. continue
  312. }
  313. out[rows[i].RecordID] = rows[i]
  314. }
  315. return out, nil
  316. }
  317. // ListCoursesByIDs 批量取课程模板(端点 57 回显课程名用)。
  318. //
  319. // 入参:ctx;ids(空 → 空切片)。
  320. // 出参:[]model.Courses(**非 nil 空切片**);ErrDBError。
  321. //
  322. // 说明:走 `Scoped`(课程表带作用域列)——跨机构的课程 ID 查不到,属于预期的"看不见"。
  323. func (r *CourseRepo) ListCoursesByIDs(ctx context.Context, ids []int64) ([]model.Courses, error) {
  324. out := make([]model.Courses, 0, len(ids))
  325. clean := dedupPositive(ids)
  326. if len(clean) == 0 {
  327. return out, nil
  328. }
  329. db, err := r.db(ctx)
  330. if err != nil {
  331. return nil, err
  332. }
  333. q := r.Scoped(db.Model(&model.Courses{})).Where("id IN ?", clean).Order("id ASC")
  334. if err := q.Find(&out).Error; err != nil {
  335. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  336. }
  337. return out, nil
  338. }