course_handler.go 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089
  1. // 课程 / 排班 / 打卡 HTTP 处理器(端点 44~57,共 14 个契约端点 / 15 条 method+path)——AC-13c。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.4(端点表)/
  4. // §3.4 C 表(字段级定义,**唯一权威**)/ §3.4 总原则 ③(字段准入条件)/ §1.3(分页)/ §1.5(时间与量纲);
  5. // 设计 `academic-domain-design.md` §3.5(SP27 欠费核销)/ §3.8-E4(`unpaid` 废弃列)/ §4.4(端点清单)。
  6. //
  7. // ---------------------------------------------------------------------------
  8. // 端点 → 服务方法 / 权威口径对照(每个方法头再写一次,便于单点核对)
  9. // ---------------------------------------------------------------------------
  10. //
  11. // 44 GET /admin/courses CourseService.ListCourseRows(+teacherIds/recordCount)
  12. // 45 POST /admin/courses CourseService.CreateCourse(AC-11)
  13. // 45 PUT /admin/courses/:id CourseService.UpdateCourse(AC-11)
  14. // 46 GET /admin/courses/:id/classes CourseService.ListClassRows(+Handler 内分页)
  15. // 47 POST /admin/courses/:id/classes AssertNoClassConflict(3122)+ CreateClass(AC-11)
  16. // 48 GET /admin/courses/:id/records RecordService.ListRecordsByQuery(AC-11 List)
  17. // 49 POST /admin/courses/:id/records/generate CourseService.GenerateRecords(AC-11)
  18. // 50 POST /admin/courses/records/:recordId/start RecordService.StartRecord(AC-11)
  19. // 51 POST /admin/courses/records/:recordId/finish RecordService.FinishRecord(AC-11 单事务)
  20. // + RecordViewService.FinishView(只读溯源)
  21. // 52 POST /admin/courses/records/:recordId/attendance RecordService.MarkAttendance(AC-11)
  22. // 53 GET /admin/courses/:id/students CourseService.ListStudentRows
  23. // 54 POST /admin/courses/:id/students CourseService.Enroll
  24. // 55 GET /admin/courses/arrears CourseService.ListArrearsRows(**remainder_receivable > 0**)
  25. // 56 POST /admin/courses/arrears/counteract MaintenanceService.CounteractArrears(AC-11,SP27)
  26. // 57 GET /student/courses/schedule CourseService.ListScheduleRows
  27. //
  28. // ---------------------------------------------------------------------------
  29. // 路由注册顺序(**静态段一律先于 `:id`**;本批已核对:不存在真冲突)
  30. // ---------------------------------------------------------------------------
  31. //
  32. // 按 Fiber 的注册顺序匹配,同深度下"字面段必须在 `:param` 之前"。本批 15 条的静态段:
  33. //
  34. // /admin/courses (GET/POST,2 段)
  35. // /admin/courses/arrears (GET,3 段)
  36. // /admin/courses/arrears/counteract (POST,4 段)
  37. // /admin/courses/records/:recordId/... (POST,5 段)
  38. //
  39. // 逐个核对"会被 `:id` 吃掉"的可能(`/admin/courses/:id` = 3 段 PUT,`/admin/courses/:id/classes`
  40. // = 4 段,`/admin/courses/:id/records|students` = 4 段,`/:id/records/generate` = 5 段):
  41. //
  42. // GET /admin/courses/arrears vs 3 段 GET 的 `:id` 路由 → **不存在**(45 只有 POST/PUT)
  43. // POST /admin/courses/arrears/counteract vs `/admin/courses/:id/students`(4 段 POST)
  44. // → 第 4 段 `counteract` ≠ `students`,**不匹配**
  45. // POST /admin/courses/records/:recordId/start vs `/admin/courses/:id/records/generate`(5 段 POST)
  46. // → 第 3 段 `records` 能匹配 `:id`,但第 4 段要 `records`、请求给的是 `:recordId` 的值,**不匹配**
  47. //
  48. // ⇒ **本批不存在真实的同深度静态/参数冲突**(如实登记,不硬凑一条变红的用例)。
  49. // 顺序纪律仍然保留(静态在前),并由 `route_e2e_test.go` 的 `staticShadowViolations` 持续校验。
  50. //
  51. // ---------------------------------------------------------------------------
  52. // 错误码分工(本文件只产出下面这几类,其余一律由 Service 产出后原样透传)
  53. // ---------------------------------------------------------------------------
  54. //
  55. // 2001 缺参/类型不符 —— `validateReq`(`validator.Validate`)
  56. // 2002 传了但非法 —— 金额 Parse 失败 / 枚举越界 / 时分格式非法 / 路径 id 非数字
  57. // 1001 无作用域 —— `mustScope`
  58. // 1004 跨作用域 —— `campusId`/`institutionId` 与作用域不一致(端点 54、56)
  59. // 3001/3104/3112/3121/3122/3140/5002 —— Service 产出,Handler **不翻译码值**
  60. //
  61. // ⚠ 本文件**不硬编码任何数字码**:一律引 `infraerrors.Err*` / `apperr.Err*` 常量。
  62. package handler
  63. import (
  64. "context"
  65. "strings"
  66. "time"
  67. "github.com/gofiber/fiber/v2"
  68. "go.uber.org/zap"
  69. "yuansiwei/unified-backend/internal/academic/money"
  70. "yuansiwei/unified-backend/internal/academic/service"
  71. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  72. "yuansiwei/unified-backend/internal/infra/response"
  73. appscope "yuansiwei/unified-backend/internal/infra/scope"
  74. )
  75. // ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
  76. //
  77. // 声明顺序 = 注册顺序(静态段在前),理由见文件头「路由注册顺序」。
  78. const (
  79. // PathCourses 端点 44/45:课程列表 / 新建(`/admin/courses`)。
  80. PathCourses = "/admin/courses"
  81. // PathCourseDetail 端点 45:更新课程(`PUT /admin/courses/:id`)。
  82. PathCourseDetail = "/admin/courses/:id"
  83. // PathCourseArrears 端点 55:欠费列表(`GET /admin/courses/arrears`)。
  84. PathCourseArrears = "/admin/courses/arrears"
  85. // PathCourseArrearsCounteract 端点 56:欠费核销(`POST /admin/courses/arrears/counteract`)。
  86. PathCourseArrearsCounteract = "/admin/courses/arrears/counteract"
  87. // PathCourseRecordStart 端点 50:开始上课(`POST /admin/courses/records/:recordId/start`)。
  88. PathCourseRecordStart = "/admin/courses/records/:recordId/start"
  89. // PathCourseRecordFinish 端点 51:结束上课(`POST /admin/courses/records/:recordId/finish`)。
  90. PathCourseRecordFinish = "/admin/courses/records/:recordId/finish"
  91. // PathCourseRecordAttendance 端点 52:打卡(`POST /admin/courses/records/:recordId/attendance`)。
  92. PathCourseRecordAttendance = "/admin/courses/records/:recordId/attendance"
  93. // PathCourseClasses 端点 46/47:排班规则列表 / 新建(`/admin/courses/:id/classes`)。
  94. PathCourseClasses = "/admin/courses/:id/classes"
  95. // PathCourseRecords 端点 48:课表实例(`GET /admin/courses/:id/records`)。
  96. PathCourseRecords = "/admin/courses/:id/records"
  97. // PathCourseRecordsGenerate 端点 49:生成课表(`POST /admin/courses/:id/records/generate`)。
  98. PathCourseRecordsGenerate = "/admin/courses/:id/records/generate"
  99. // PathCourseStudents 端点 53/54:报课学生列表 / 报名(`/admin/courses/:id/students`)。
  100. PathCourseStudents = "/admin/courses/:id/students"
  101. // PathStudentSchedule 端点 57:学生课表(`GET /student/courses/schedule`)。
  102. PathStudentSchedule = "/student/courses/schedule"
  103. )
  104. // 枚举白名单(2002 的唯一判据点——`oneof` 会把"传了非法值"误判成 2001)。
  105. var (
  106. // courseStatusFilter 端点 44 的课程状态过滤白名单(0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。
  107. courseStatusFilter = map[int64]struct{}{0: {}, 1: {}, 2: {}, 3: {}}
  108. // recordStatusFilter 端点 48 的上课状态过滤白名单(0 未开始 / 1 上课中 / 2 已结束)。
  109. recordStatusFilter = map[int64]struct{}{0: {}, 1: {}, 2: {}}
  110. // studentStatusFilter 端点 53 的报课状态过滤白名单(1 正常 / 2 结束)。
  111. studentStatusFilter = map[int64]struct{}{1: {}, 2: {}}
  112. )
  113. // ---------------------------------------------------------------- 端点 44:课程列表
  114. // ListCourses 课程列表(端点 44 `GET /admin/courses`)。
  115. //
  116. // 入参(query):`classify?`、`status?`(0~3)、`keyword?`、`page`/`pageSize`。
  117. // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `courseRowView`
  118. // (含 `teacherIds` 与 `recordCount` 两个派生列)。
  119. // 出参(失败):1001/2002(status 越界或非数字、classify 非数字)/5002。
  120. //
  121. // 作用域:列表天然只含本机构(`Scoped`),跨机构课程**不出现**在结果里("看不见"而非 1004)。
  122. func (h *Handler) ListCourses(c *fiber.Ctx) error {
  123. sc, err := mustScope(c)
  124. if err != nil {
  125. return response.Fail(c, err)
  126. }
  127. classifyID, err := parseIntQuery(c, "classify")
  128. if err != nil {
  129. return response.Fail(c, err)
  130. }
  131. if classifyID < 0 {
  132. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  133. errString("classify 不得为负")))
  134. }
  135. statusFilter, _, err := parseEnumQuery(c, "status", courseStatusFilter)
  136. if err != nil {
  137. return response.Fail(c, err)
  138. }
  139. var statusPtr *int32
  140. if statusFilter != nil {
  141. v := int32(*statusFilter)
  142. statusPtr = &v
  143. }
  144. page, pageSize := parsePage(c)
  145. svc := h.servicesFor(sc)
  146. rows, total, err := svc.course.ListCourseRows(c.Context(), service.CourseListQuery{
  147. ClassifyID: classifyID,
  148. Status: statusPtr,
  149. Keyword: c.Query("keyword"),
  150. Page: page,
  151. PageSize: pageSize,
  152. })
  153. if err != nil {
  154. return response.Fail(c, err)
  155. }
  156. views := make([]courseRowView, 0, len(rows))
  157. for i := range rows {
  158. row := &rows[i]
  159. tids := row.TeacherIDs
  160. if tids == nil {
  161. tids = make([]string, 0)
  162. }
  163. views = append(views, courseRowView{
  164. ID: row.ID,
  165. InstitutionID: row.InstitutionID,
  166. Name: row.Name,
  167. Classify: row.ClassifyID,
  168. Status: row.Status,
  169. TeacherIDs: tids,
  170. RecordCount: row.RecordCount,
  171. })
  172. }
  173. return response.Page(c, views, page, pageSize, total)
  174. }
  175. // ---------------------------------------------------------------- 端点 45:新建课程
  176. // CreateCourse 新建课程模板(端点 45 `POST /admin/courses`)。
  177. //
  178. // 入参(body):见 `CreateCourseReq`(`name` 与 `duration` 必填)。
  179. // 出参(成功):`{id}`。
  180. // 出参(失败):1001/2001/2002(金额/课时/week/时间窗非法)/3001(分类不存在或越权)/1004/5002。
  181. //
  182. // 状态:新建恒落 `status=0`(未发布)——发布走状态机专用入口(AC-11 红线,本端点不开放 status)。
  183. func (h *Handler) CreateCourse(c *fiber.Ctx) error {
  184. sc, err := mustScope(c)
  185. if err != nil {
  186. return response.Fail(c, err)
  187. }
  188. var req CreateCourseReq
  189. if !validateReq(c, &req) {
  190. return nil
  191. }
  192. start, end, err := parseActivityWindow(req.StartTime, req.EndTime)
  193. if err != nil {
  194. return response.Fail(c, err)
  195. }
  196. svc := h.servicesFor(sc)
  197. row, err := svc.course.CreateCourse(c.Context(), service.CreateCourseInput{
  198. SubjectID: req.SubjectID,
  199. GradeID: req.GradeID,
  200. Type: req.Type,
  201. ClassifyID: req.ClassifyID,
  202. Name: strings.TrimSpace(req.Name),
  203. Number: req.Number,
  204. Duration: req.Duration,
  205. Week: req.Week,
  206. StartTime: start,
  207. EndTime: end,
  208. Address: optString(req.Address),
  209. OriginalCost: req.OriginalCost,
  210. RealPrice: req.RealPrice,
  211. DiscountCash: req.DiscountCash,
  212. DiscountRatio: req.DiscountRatio,
  213. MaxPeople: req.MaxPeople,
  214. Remark: optString(req.Remark),
  215. })
  216. if err != nil {
  217. return response.Fail(c, err)
  218. }
  219. return response.OK(c, courseSavedResp{ID: row.ID})
  220. }
  221. // ---------------------------------------------------------------- 端点 45:更新课程
  222. // UpdateCourse 更新课程模板(端点 45 `PUT /admin/courses/:id`,**部分更新**语义)。
  223. //
  224. // 入参(body):见 `UpdateCourseReq`(nil = 不改;传了就校验)。
  225. // 出参(成功):`{id}`。
  226. // 出参(失败):1001/2001(没有任何待更新字段)/2002/3001/5002。
  227. //
  228. // 红线:**不改** `institution_id` / `campus_id` / `status`(各有专用入口,见 AC-11 注释)。
  229. func (h *Handler) UpdateCourse(c *fiber.Ctx) error {
  230. sc, err := mustScope(c)
  231. if err != nil {
  232. return response.Fail(c, err)
  233. }
  234. var req UpdateCourseReq
  235. if !validateReq(c, &req) {
  236. return nil
  237. }
  238. in := service.UpdateCourseInput{
  239. ID: parseIDParam(c.Params("id")),
  240. SubjectID: req.SubjectID,
  241. GradeID: req.GradeID,
  242. Type: req.Type,
  243. ClassifyID: req.ClassifyID,
  244. Name: req.Name,
  245. Number: req.Number,
  246. Duration: req.Duration,
  247. Week: req.Week,
  248. OriginalCost: req.OriginalCost,
  249. RealPrice: req.RealPrice,
  250. DiscountCash: req.DiscountCash,
  251. DiscountRatio: req.DiscountRatio,
  252. MaxPeople: req.MaxPeople,
  253. }
  254. if req.Address != nil {
  255. in.Address = optString(*req.Address)
  256. }
  257. if req.Remark != nil {
  258. in.Remark = optString(*req.Remark)
  259. }
  260. // 时间列:传了就必须能解析(空串 = 不修改,与"清空"区分——本接口不提供清空时间的能力)
  261. if req.StartTime != nil {
  262. at, has, perr := parseTimeParam(*req.StartTime, "startTime")
  263. if perr != nil {
  264. return response.Fail(c, perr)
  265. }
  266. if has {
  267. in.StartTime = &at
  268. }
  269. }
  270. if req.EndTime != nil {
  271. at, has, perr := parseTimeParam(*req.EndTime, "endTime")
  272. if perr != nil {
  273. return response.Fail(c, perr)
  274. }
  275. if has {
  276. in.EndTime = &at
  277. }
  278. }
  279. svc := h.servicesFor(sc)
  280. row, err := svc.course.UpdateCourse(c.Context(), in)
  281. if err != nil {
  282. return response.Fail(c, err)
  283. }
  284. return response.OK(c, courseSavedResp{ID: row.ID})
  285. }
  286. // ---------------------------------------------------------------- 端点 46:排班规则列表
  287. // ListCourseClasses 排班规则列表(端点 46 `GET /admin/courses/:id/classes`)。
  288. //
  289. // 入参(path):`:id` 课程 ID;(query):`page`/`pageSize`。
  290. // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `classRowView`。
  291. // 出参(失败):1001/2002(id 非数字)/3001(课程不存在或不在作用域)/5002。
  292. //
  293. // ⚠ **分页在本 Handler 内做(内存切片)**:一门课程的排班规则数量**有界**(通常 ≤ 10 条,
  294. // 极端情况也就"每个 week 一条"共 7 条),AC-11 的 `ListClasses` 一次取全量更省一次往返。
  295. // 若将来出现"单课程上百条规则"的真实场景,应改为仓储层 `LIMIT/OFFSET`——
  296. // 已在交付报告登记为**已知取舍**(不是遗漏)。
  297. func (h *Handler) ListCourseClasses(c *fiber.Ctx) error {
  298. sc, err := mustScope(c)
  299. if err != nil {
  300. return response.Fail(c, err)
  301. }
  302. page, pageSize := parsePage(c)
  303. svc := h.servicesFor(sc)
  304. rows, err := svc.course.ListClassRows(c.Context(), parseIDParam(c.Params("id")))
  305. if err != nil {
  306. return response.Fail(c, err)
  307. }
  308. total := int64(len(rows))
  309. views := make([]classRowView, 0, len(rows))
  310. for _, row := range rows {
  311. views = append(views, classRowView{
  312. ClassID: row.ClassID,
  313. CourseID: row.CourseID,
  314. TeacherID: row.TeacherID,
  315. Week: row.Week,
  316. Start: row.Start,
  317. End: row.End,
  318. CampusID: row.CampusID,
  319. })
  320. }
  321. views = paginateSlice(views, page, pageSize)
  322. return response.Page(c, views, page, pageSize, total)
  323. }
  324. // ---------------------------------------------------------------- 端点 47:新建排班规则
  325. // CreateCourseClass 新建排班规则(端点 47 `POST /admin/courses/:id/classes`)。
  326. //
  327. // 入参(body):见 `CreateClassReq`(`start` 必填;`teacherId` 给了才做冲突判定)。
  328. // 出参(成功):`{classId}`。
  329. // 出参(失败):1001/2001/2002(时分格式/下课早于上课/week 非法)/
  330. // **3122(排班冲突)**/3001(课程不存在)/5002。
  331. //
  332. // 3122 的判据在 `CourseService.AssertNoClassConflict`(同老师 + 同校区 + 周规则有交集 + 时段重叠)。
  333. // 为什么先查再建(而不是靠唯一键):`t_courses_class` **没有** `(teacher, week, start)` 唯一键
  334. // (DDL 已锁定,见 model 注释),数据库不会替我们拦住重复排班 ⇒ 只能在应用层判。
  335. func (h *Handler) CreateCourseClass(c *fiber.Ctx) error {
  336. sc, err := mustScope(c)
  337. if err != nil {
  338. return response.Fail(c, err)
  339. }
  340. var req CreateClassReq
  341. if !validateReq(c, &req) {
  342. return nil
  343. }
  344. coursesID := parseIDParam(c.Params("id"))
  345. if coursesID <= 0 {
  346. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  347. errString("课程 id 必须 > 0")))
  348. }
  349. in := service.CreateClassInput{
  350. CoursesID: coursesID,
  351. ClassID: req.ClassID,
  352. TeacherID: strings.TrimSpace(req.TeacherID),
  353. Week: req.Week,
  354. StartTime: req.Start,
  355. EndTime: req.End,
  356. StandardLessonCost: req.StandardLessonCost,
  357. LessonCost: req.LessonCost,
  358. DiscountCash: req.DiscountCash,
  359. Number: req.Number,
  360. Address: optString(req.Address),
  361. Remark: optString(req.Remark),
  362. }
  363. svc := h.servicesFor(sc)
  364. if err := svc.course.AssertNoClassConflict(c.Context(), in); err != nil {
  365. return response.Fail(c, err) // 3122
  366. }
  367. row, err := svc.course.CreateClass(c.Context(), in)
  368. if err != nil {
  369. return response.Fail(c, err)
  370. }
  371. return response.OK(c, classCreatedResp{ClassID: row.ClassID})
  372. }
  373. // ---------------------------------------------------------------- 端点 48:课表实例
  374. // ListCourseRecords 课表实例列表(端点 48 `GET /admin/courses/:id/records`)。
  375. //
  376. // 入参(path):`:id` 课程 ID;(query):`teacherId?`、`status?`(0~2)、`from?`、`to?`、分页。
  377. // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `recordRowView`(**无 `room`**,见 DTO 注释)。
  378. // 出参(失败):1001/2002(status 越界、时间格式非法)/5002。
  379. //
  380. // 时间窗**两端都含**(沿用 AC-11 `RecordFilter`):课表按"日期区间"看课,`to=2026-11-30`
  381. // 必须含 30 号当天的课(与契约 §1.3 的"`to` 右开"不同,属有意差异,已登记)。
  382. func (h *Handler) ListCourseRecords(c *fiber.Ctx) error {
  383. sc, err := mustScope(c)
  384. if err != nil {
  385. return response.Fail(c, err)
  386. }
  387. statusFilter, _, err := parseEnumQuery(c, "status", recordStatusFilter)
  388. if err != nil {
  389. return response.Fail(c, err)
  390. }
  391. var statusPtr *int32
  392. if statusFilter != nil {
  393. v := int32(*statusFilter)
  394. statusPtr = &v
  395. }
  396. from, hasFrom, err := parseTimeParam(c.Query("from"), "from")
  397. if err != nil {
  398. return response.Fail(c, err)
  399. }
  400. to, hasTo, err := parseTimeParam(c.Query("to"), "to")
  401. if err != nil {
  402. return response.Fail(c, err)
  403. }
  404. var fromPtr, toPtr *time.Time
  405. if hasFrom {
  406. fromPtr = &from
  407. }
  408. if hasTo {
  409. toPtr = &to
  410. }
  411. page, pageSize := parsePage(c)
  412. svc := h.servicesFor(sc)
  413. rows, total, err := svc.record.ListRecordsByQuery(c.Context(), service.RecordListQuery{
  414. CoursesID: parseIDParam(c.Params("id")),
  415. TeacherID: c.Query("teacherId"),
  416. Status: statusPtr,
  417. From: fromPtr,
  418. To: toPtr,
  419. Page: page,
  420. PageSize: pageSize,
  421. })
  422. if err != nil {
  423. return response.Fail(c, err)
  424. }
  425. views := make([]recordRowView, 0, len(rows))
  426. for i := range rows {
  427. rec := &rows[i]
  428. views = append(views, recordRowView{
  429. RecordID: rec.ID,
  430. CourseID: rec.CoursesID,
  431. TeacherID: stringValue(rec.TeacherID),
  432. CampusID: rec.CampusID,
  433. StartTime: rec.StartTime,
  434. EndTime: rec.EndTime,
  435. Status: rec.Status,
  436. StudentCount: rec.StudentNumber,
  437. })
  438. }
  439. return response.Page(c, views, page, pageSize, total)
  440. }
  441. // ---------------------------------------------------------------- 端点 49:生成课表
  442. // GenerateCourseRecords 按 week 规则生成排班实例(端点 49 `POST /admin/courses/:id/records/generate`)。
  443. //
  444. // 入参(body):见 `GenerateRecordsReq`(`days`/`from` 都可空)。
  445. // 出参(成功):`{generated,days,changed}`(幂等:同 courses+start_time 不重复插,第二次 generated=0)。
  446. // 出参(失败):1001/2002(from 格式非法)/3001/3140(该课程没有生效排班规则)/5002。
  447. func (h *Handler) GenerateCourseRecords(c *fiber.Ctx) error {
  448. sc, err := mustScope(c)
  449. if err != nil {
  450. return response.Fail(c, err)
  451. }
  452. var req GenerateRecordsReq
  453. if len(c.Body()) > 0 {
  454. if !validateReq(c, &req) {
  455. return nil
  456. }
  457. }
  458. var fromPtr *time.Time
  459. if strings.TrimSpace(req.From) != "" {
  460. at, _, perr := parseTimeParam(req.From, "from")
  461. if perr != nil {
  462. return response.Fail(c, perr)
  463. }
  464. fromPtr = &at
  465. }
  466. svc := h.servicesFor(sc)
  467. res, err := svc.course.GenerateRecords(c.Context(), service.GenerateRecordsInput{
  468. CoursesID: parseIDParam(c.Params("id")),
  469. Days: req.Days,
  470. From: fromPtr,
  471. })
  472. if err != nil {
  473. return response.Fail(c, err)
  474. }
  475. days := req.Days
  476. if days <= 0 {
  477. days = service.DefaultGenerateDays
  478. }
  479. if days > service.MaxGenerateDays {
  480. days = service.MaxGenerateDays
  481. }
  482. return response.OK(c, generateRecordsResp{
  483. Generated: res.Created,
  484. Days: days,
  485. Changed: res.Created > 0,
  486. })
  487. }
  488. // ---------------------------------------------------------------- 端点 50:开始上课
  489. // StartCourseRecord 开始上课(端点 50 `POST /admin/courses/records/:recordId/start`)。
  490. //
  491. // 入参:无 body(契约 §2.4 端点 50 无请求字段)。
  492. // 出参(成功):`{recordId, status:1, courseId}`。
  493. // 出参(失败):1001/2001/3001(实例不存在或不在作用域)/**3121(状态非法:重复开始/已结束)**/5002。
  494. //
  495. // `actual_start_time` 只写一次(模型红线):重复调用 → 3121,不会写出第二个开课时间。
  496. func (h *Handler) StartCourseRecord(c *fiber.Ctx) error {
  497. sc, err := mustScope(c)
  498. if err != nil {
  499. return response.Fail(c, err)
  500. }
  501. recordID := parseIDParam(c.Params("recordId"))
  502. svc := h.servicesFor(sc)
  503. res, err := svc.record.StartRecord(c.Context(), recordID)
  504. if err != nil {
  505. return response.Fail(c, err)
  506. }
  507. courseID := int64(0)
  508. if rec, _, gerr := svc.record.Get(c.Context(), recordID); gerr == nil && rec != nil {
  509. courseID = rec.CoursesID
  510. }
  511. return response.OK(c, startRecordResp{
  512. RecordID: res.RecordID,
  513. Status: res.Status,
  514. CourseID: courseID,
  515. })
  516. }
  517. // ---------------------------------------------------------------- 端点 51:结束上课
  518. // FinishCourseRecord 结束上课(端点 51 `POST /admin/courses/records/:recordId/finish`)——**全项目最重的写端点**。
  519. //
  520. // 入参(body):见 `FinishRecordReq`(`items` 可空 = 按在读学生全勤)。
  521. // 出参(成功):`finishRecordResp`(`changed=false` = 幂等命中:此前已结束,本次**未写任何表**)。
  522. // 出参(失败):1001/2001/2002/**3001**(实例不存在)/**3121**(状态非法/并发竞争)/5002。
  523. //
  524. // **单事务四步联动**(AC-11 `RecordService.FinishRecord` 内,任一失败全部回滚):
  525. //
  526. // ① 打卡明细落库(t_courses_record_student:cost / give_cost / teacher_reward)
  527. // ② 扣课时(t_courses_student:finished_number+1 / remainder_number-1 / remainder_cost-cost)
  528. // ③ spend 累加(t_user_expense + t_user_expense_school:**只动 spend 统计列、不动 balance**)
  529. // ④ 分账入账(t_user_divide_into_log + total_income)
  530. // ⑤ 课酬(试算 → 按学生均分 → Σ = record.teacher_reward)
  531. //
  532. // 本 Handler **只做三件事**:解析入参、调服务、把结果装配成契约形状(溯源列的只读回读在
  533. // `RecordViewService.FinishView`,位于事务**之后**,因此装配失败不影响已提交的结账结果)。
  534. func (h *Handler) FinishCourseRecord(c *fiber.Ctx) error {
  535. sc, err := mustScope(c)
  536. if err != nil {
  537. return response.Fail(c, err)
  538. }
  539. var req FinishRecordReq
  540. if len(c.Body()) > 0 {
  541. if !validateReq(c, &req) {
  542. return nil
  543. }
  544. }
  545. switch req.RewardType {
  546. case 0, 1, 2:
  547. // 合法:0 → 服务端按 1(正常结算)
  548. default:
  549. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  550. errString("rewardType 只能是 1 或 2")))
  551. }
  552. items, ok := h.parseAttendanceItems(c, req.Items)
  553. if !ok {
  554. return nil
  555. }
  556. var endPtr *time.Time
  557. if strings.TrimSpace(req.ActualEndTime) != "" {
  558. at, _, perr := parseTimeParam(req.ActualEndTime, "actualEndTime")
  559. if perr != nil {
  560. return response.Fail(c, perr)
  561. }
  562. endPtr = &at
  563. }
  564. svc := h.servicesFor(sc)
  565. res, err := svc.record.FinishRecord(c.Context(), service.FinishRecordInput{
  566. RecordID: parseIDParam(c.Params("recordId")),
  567. ActualEndTime: endPtr,
  568. Items: items,
  569. RewardType: req.RewardType,
  570. Remark: optString(req.Remark),
  571. })
  572. if err != nil {
  573. return response.Fail(c, err)
  574. }
  575. view, err := svc.recordView.FinishView(c.Context(), res)
  576. if err != nil {
  577. return response.Fail(c, err)
  578. }
  579. return response.OK(c, toFinishRecordResp(view))
  580. }
  581. // ---------------------------------------------------------------- 端点 52:打卡
  582. // MarkCourseRecordAttendance 打卡/考勤(端点 52 `POST /admin/courses/records/:recordId/attendance`)。
  583. //
  584. // 入参(body):`list:[{userId,type,pay,cost,giveCost,remark?}]`(必填非空)。
  585. // 出参(成功):`{recordId, checked}`(`checked` = 该实例当前打卡人数)。
  586. // 出参(失败):1001/2001/2002/**3001**/**3121(课已结束,不能补打卡)**/3140(同一学生重复)/5002。
  587. //
  588. // 只写 cost/give_cost/pay/type,**不扣课时、不动余额、不算课酬**(那是端点 51 的联动);
  589. // 幂等:同一 (record, student) 重复打卡 → 覆盖该行,不产生第二行。
  590. func (h *Handler) MarkCourseRecordAttendance(c *fiber.Ctx) error {
  591. sc, err := mustScope(c)
  592. if err != nil {
  593. return response.Fail(c, err)
  594. }
  595. var req MarkAttendanceReq
  596. if !validateReq(c, &req) {
  597. return nil
  598. }
  599. items, ok := h.parseAttendanceItems(c, req.List)
  600. if !ok {
  601. return nil
  602. }
  603. svc := h.servicesFor(sc)
  604. res, err := svc.record.MarkAttendance(c.Context(), service.MarkAttendanceInput{
  605. RecordID: parseIDParam(c.Params("recordId")),
  606. Items: items,
  607. })
  608. if err != nil {
  609. return response.Fail(c, err)
  610. }
  611. return response.OK(c, attendanceResp{RecordID: res.RecordID, Checked: res.StudentNumber})
  612. }
  613. // ---------------------------------------------------------------- 端点 53:报课学生
  614. // ListCourseStudents 报课学生列表(端点 53 `GET /admin/courses/:id/students`)。
  615. //
  616. // 入参(path):`:id` 课程 ID;(query):`status?`(1 正常 / 2 结束)、分页。
  617. // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `studentRowView`。
  618. // 出参(失败):1001/2002/3001(课程不存在或不在作用域)/5002。
  619. //
  620. // ⚠ **欠费字段是 `remainderReceivable`(源列 `remainder_receivable`)**:`unpaid` 是 2018-05
  621. // 起停更的僵尸列(设计 §3.8-E4),**禁展示、禁下发**。
  622. func (h *Handler) ListCourseStudents(c *fiber.Ctx) error {
  623. sc, err := mustScope(c)
  624. if err != nil {
  625. return response.Fail(c, err)
  626. }
  627. statusFilter, _, err := parseEnumQuery(c, "status", studentStatusFilter)
  628. if err != nil {
  629. return response.Fail(c, err)
  630. }
  631. var statusPtr *int32
  632. if statusFilter != nil {
  633. v := int32(*statusFilter)
  634. statusPtr = &v
  635. }
  636. page, pageSize := parsePage(c)
  637. svc := h.servicesFor(sc)
  638. rows, total, err := svc.course.ListStudentRows(c.Context(), service.StudentListQuery{
  639. CoursesID: parseIDParam(c.Params("id")),
  640. Status: statusPtr,
  641. Page: page,
  642. PageSize: pageSize,
  643. })
  644. if err != nil {
  645. return response.Fail(c, err)
  646. }
  647. // 姓名跨域只读(identity 域 `t_user.display_name`):一次批量取,**查不到 → null**。
  648. names := h.displayNames(c.Context(), rows)
  649. views := make([]studentRowView, 0, len(rows))
  650. for i := range rows {
  651. row := &rows[i]
  652. var realName *string
  653. if name, ok := names[row.UserID]; ok && name != "" {
  654. v := name
  655. realName = &v
  656. }
  657. views = append(views, studentRowView{
  658. UserID: row.UserID,
  659. RealName: realName,
  660. Pay: row.Pay.String(),
  661. Cost: row.Cost.String(),
  662. GiveCost: row.GiveCost.String(),
  663. RemainderReceivable: row.RemainderReceivable.String(),
  664. Status: row.Status,
  665. })
  666. }
  667. return response.Page(c, views, page, pageSize, total)
  668. }
  669. // ---------------------------------------------------------------- 端点 54:报名
  670. // EnrollCourseStudent 报名 / 补报名(端点 54 `POST /admin/courses/:id/students`)。
  671. //
  672. // 入参(body):见 `EnrollReq`(`userId`/`pay`/`lessonCost` 必填)。
  673. // 出参(成功):`{enrolled:true, courseId, userId}`。
  674. // 出参(失败):1001/2001/2002/**3001**(课程不存在/越权)/**1004**(campusId 跨校区)/
  675. // **3143**(该学生已在本课程在读)/5002。
  676. //
  677. // 三条资金红线(见 `service.EnrollInput` 注释):不写流水、不动余额;
  678. // `confirm_discount_user_id` 写操作人(否则该行永远进不了欠费核销候选);重复报名拒绝。
  679. func (h *Handler) EnrollCourseStudent(c *fiber.Ctx) error {
  680. sc, err := mustScope(c)
  681. if err != nil {
  682. return response.Fail(c, err)
  683. }
  684. var req EnrollReq
  685. if !validateReq(c, &req) {
  686. return nil
  687. }
  688. svc := h.servicesFor(sc)
  689. row, err := svc.course.Enroll(c.Context(), service.EnrollInput{
  690. CoursesID: parseIDParam(c.Params("id")),
  691. StudentID: strings.TrimSpace(req.UserID),
  692. ClassID: req.ClassID,
  693. CampusID: req.CampusID,
  694. Pay: req.Pay,
  695. RemainderReceivable: req.RemainderReceivable,
  696. LessonCost: req.LessonCost,
  697. Number: req.Number,
  698. GiveNumber: req.GiveNumber,
  699. DiscountCash: req.DiscountCash,
  700. GiveCost: req.GiveCost,
  701. RecommenderUserID: req.RecommenderUserID,
  702. Remark: req.Remark,
  703. OperatorID: sc.UserID,
  704. })
  705. if err != nil {
  706. return response.Fail(c, err)
  707. }
  708. return response.OK(c, enrollResp{Enrolled: true, CourseID: row.CoursesID, UserID: row.StudentID})
  709. }
  710. // ---------------------------------------------------------------- 端点 55:欠费列表
  711. // ListCourseArrears 欠费列表(端点 55 `GET /admin/courses/arrears`)。
  712. //
  713. // 入参(query):`studentId?`、`page`/`pageSize`。
  714. // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `arrearsRowView`。
  715. // 出参(失败):1001/5002。
  716. //
  717. // **口径恒为 `remainder_receivable > 0`**(契约 §2.4 端点 55 注 / 设计 §3.8-E4)。
  718. // ⚠ 写成 `unpaid > 0` 不会报错、不会 500,只会**静默漏掉所有真实欠费行**
  719. // (`unpaid` 2018-05-17 起停止更新、真实数据几乎恒 0)——这是本端点唯一的致命错法。
  720. func (h *Handler) ListCourseArrears(c *fiber.Ctx) error {
  721. sc, err := mustScope(c)
  722. if err != nil {
  723. return response.Fail(c, err)
  724. }
  725. page, pageSize := parsePage(c)
  726. svc := h.servicesFor(sc)
  727. rows, total, err := svc.course.ListArrearsRows(c.Context(), service.ArrearsListQuery{
  728. StudentID: c.Query("studentId"),
  729. Page: page,
  730. PageSize: pageSize,
  731. })
  732. if err != nil {
  733. return response.Fail(c, err)
  734. }
  735. views := make([]arrearsRowView, 0, len(rows))
  736. for i := range rows {
  737. row := &rows[i]
  738. views = append(views, arrearsRowView{
  739. StudentID: row.StudentID,
  740. CourseID: row.CourseID,
  741. RemainderReceivable: row.RemainderReceivable.String(),
  742. Counteracted: row.Counteracted,
  743. })
  744. }
  745. return response.Page(c, views, page, pageSize, total)
  746. }
  747. // ---------------------------------------------------------------- 端点 56:欠费核销
  748. // CounteractCourseArrears 欠费核销(端点 56 `POST /admin/courses/arrears/counteract`,SP27 dump 763435)。
  749. //
  750. // 入参(body):`studentId,institutionId,campusId,money`(四个都必填)。
  751. // 出参(成功):`{counteracted, studentId, amount}`(`amount` = **本次实际核销额**,不是入参金额)。
  752. // 出参(失败):1001/2001/2002/**1004**(institutionId/campusId 与作用域不一致)/**3113**(核销冲突)/5002。
  753. //
  754. // 越权判据(**本端点唯一的作用域断言,必须在这里**):SP27 的 `institutionId`/`campusId` 是
  755. // **显式数据范围参数**(不是从 token 推断),因此非超管传了别的机构/校区必须在 Handler 拒掉——
  756. // 服务层按入参直接查库,数据层没有第二道防线。
  757. func (h *Handler) CounteractCourseArrears(c *fiber.Ctx) error {
  758. sc, err := mustScope(c)
  759. if err != nil {
  760. return response.Fail(c, err)
  761. }
  762. var req CounteractArrearsReq
  763. if !validateReq(c, &req) {
  764. return nil
  765. }
  766. if err := assertScopeMatches(sc, req.InstitutionID, req.CampusID, "欠费核销"); err != nil {
  767. return response.Fail(c, err)
  768. }
  769. amount, ok := parseMoney(c, req.Money, "money")
  770. if !ok {
  771. return nil
  772. }
  773. studentID := strings.TrimSpace(req.StudentID)
  774. svc := h.servicesFor(sc)
  775. res, err := svc.maintenance.CounteractArrears(c.Context(), service.CounteractArrearsInput{
  776. StudentID: studentID,
  777. InstitutionID: req.InstitutionID,
  778. CampusID: req.CampusID,
  779. Money: amount,
  780. })
  781. if err != nil {
  782. return response.Fail(c, err)
  783. }
  784. return response.OK(c, counteractResp{
  785. Counteracted: res.Applied.GreaterThan(0),
  786. StudentID: studentID,
  787. Amount: res.Applied.String(),
  788. })
  789. }
  790. // ---------------------------------------------------------------- 端点 57:学生课表
  791. // GetStudentSchedule 学生课表(端点 57 `GET /student/courses/schedule`)。
  792. //
  793. // 入参(query):`userId?`、`from?`、`to?`、分页。
  794. // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `scheduleRowView`
  795. // (**字段名待移动端契约定稿**,契约 §3.4 C 行 57 原文如此)。
  796. // 出参(失败):1001/2001(既没传 userId 又没有登录用户)/2002(时间格式非法)/5002。
  797. //
  798. // 越权判据(**1004**):
  799. //
  800. // 学生 / 家长:`userId` 缺省 = 自己;传别人 → **1004**(家长与孩子的绑定关系不在本域,
  801. // 一期不做"家长看孩子"——宁可拒,也不做无法验证的授权)
  802. // 老师:可以指定 `userId`(家访/回访场景),但仍受机构作用域约束(查不到 = 空列表)
  803. // 超管:不限
  804. func (h *Handler) GetStudentSchedule(c *fiber.Ctx) error {
  805. sc, err := mustScope(c)
  806. if err != nil {
  807. return response.Fail(c, err)
  808. }
  809. userID := strings.TrimSpace(c.Query("userId"))
  810. if userID == "" {
  811. userID = strings.TrimSpace(sc.UserID)
  812. }
  813. if userID == "" {
  814. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  815. errString("缺少 userId(学生课表必须指定学生)")))
  816. }
  817. switch sc.Role {
  818. case RoleTeacher, RoleSuperAdmin:
  819. // 允许指定他人
  820. default:
  821. if userID != strings.TrimSpace(sc.UserID) {
  822. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrUnscoped,
  823. errString("只能查看本人的课表")))
  824. }
  825. }
  826. from, hasFrom, err := parseTimeParam(c.Query("from"), "from")
  827. if err != nil {
  828. return response.Fail(c, err)
  829. }
  830. to, hasTo, err := parseTimeParam(c.Query("to"), "to")
  831. if err != nil {
  832. return response.Fail(c, err)
  833. }
  834. var fromPtr, toPtr *time.Time
  835. if hasFrom {
  836. fromPtr = &from
  837. }
  838. if hasTo {
  839. toPtr = &to
  840. }
  841. page, pageSize := parsePage(c)
  842. svc := h.servicesFor(sc)
  843. rows, total, err := svc.course.ListScheduleRows(c.Context(), service.ScheduleQuery{
  844. StudentID: userID,
  845. From: fromPtr,
  846. To: toPtr,
  847. Page: page,
  848. PageSize: pageSize,
  849. })
  850. if err != nil {
  851. return response.Fail(c, err)
  852. }
  853. views := make([]scheduleRowView, 0, len(rows))
  854. for i := range rows {
  855. row := &rows[i]
  856. views = append(views, scheduleRowView{
  857. RecordID: row.RecordID,
  858. CourseID: row.CourseID,
  859. CourseName: row.CourseName,
  860. TeacherID: row.TeacherID,
  861. CampusID: row.CampusID,
  862. StartTime: row.StartTime,
  863. EndTime: row.EndTime,
  864. Status: row.Status,
  865. Cost: row.Cost.String(),
  866. GiveCost: row.GiveCost.String(),
  867. Pay: row.Pay,
  868. Checked: row.Checked,
  869. })
  870. }
  871. return response.Page(c, views, page, pageSize, total)
  872. }
  873. // ---------------------------------------------------------------- 本地助手(只做解析/装配,不含业务判断)
  874. // parseAttendanceItems 把请求里的打卡明细转成服务入参(金额字符串 → `money.Amount`)。
  875. //
  876. // 入参:c;raw 请求明细。出参:(服务入参, 是否成功);失败时 **body code 已写出**(2002)。
  877. //
  878. // 只做"字符串 → 定点金额"的解析;`type` 枚举、重复学生、负金额等**业务校验留给 Service**
  879. // (唯一实现点,端点 51/52 共用同一套规则,避免两处判据漂移)。
  880. func (h *Handler) parseAttendanceItems(c *fiber.Ctx, raw []AttendanceItemReq) ([]service.AttendanceItem, bool) {
  881. out := make([]service.AttendanceItem, 0, len(raw))
  882. for i := range raw {
  883. item := &raw[i]
  884. var costPtr, givePtr *money.Amount
  885. if strings.TrimSpace(item.Cost) != "" {
  886. amount, ok := parseMoney(c, item.Cost, "items.cost")
  887. if !ok {
  888. return nil, false
  889. }
  890. costPtr = &amount
  891. }
  892. if strings.TrimSpace(item.GiveCost) != "" {
  893. amount, ok := parseMoney(c, item.GiveCost, "items.giveCost")
  894. if !ok {
  895. return nil, false
  896. }
  897. givePtr = &amount
  898. }
  899. out = append(out, service.AttendanceItem{
  900. StudentID: strings.TrimSpace(item.UserID),
  901. Type: item.Type,
  902. Pay: item.Pay,
  903. Cost: costPtr,
  904. GiveCost: givePtr,
  905. Remark: optString(item.Remark),
  906. })
  907. }
  908. return out, true
  909. }
  910. // toFinishRecordResp 把服务视图转成端点 51 的响应(金额一律两位小数字符串)。
  911. //
  912. // 入参:view 服务层装配结果。出参:finishRecordResp(各数组空时为空切片,非 null)。
  913. func toFinishRecordResp(view *service.RecordFinishView) finishRecordResp {
  914. divide := make([]divideGrantView, 0, len(view.Divide))
  915. for i := range view.Divide {
  916. d := &view.Divide[i]
  917. divide = append(divide, divideGrantView{
  918. UserID: d.UserID,
  919. Type: d.Type,
  920. Amount: d.Amount.String(),
  921. })
  922. }
  923. students := make([]perStudentView, 0, len(view.PerStudent))
  924. for i := range view.PerStudent {
  925. s := &view.PerStudent[i]
  926. students = append(students, perStudentView{
  927. UserID: s.StudentID,
  928. Type: s.Type,
  929. Pay: s.Paid,
  930. Cost: s.Cost.String(),
  931. GiveCost: s.GiveCost.String(),
  932. Counteracted: s.DivideGranted,
  933. })
  934. }
  935. return finishRecordResp{
  936. RecordID: view.RecordID,
  937. CourseID: view.CourseID,
  938. Status: view.Status,
  939. Changed: view.Changed,
  940. TotalSpend: view.TotalSpend.String(),
  941. Divide: divide,
  942. Payroll: payrollTraceView{
  943. DetailID: view.Payroll.DetailID,
  944. Reward: view.Payroll.Reward.String(),
  945. },
  946. PerStudent: students,
  947. }
  948. }
  949. // assertScopeMatches 断言显式传入的机构/校区与当前作用域一致(越界 → **1004**)。
  950. //
  951. // 入参:sc 作用域;institutionID/campusID 入参值(>0,由 validate tag 保证);op 操作名(错误文案)。
  952. // 出参:nil 通过;`infraerrors.ErrUnscoped`(1004) 越界。
  953. //
  954. // 规则:
  955. //
  956. // 超管 → 放行(平台视角,可代任意机构执行)
  957. // 机构不一致 → 1004
  958. // 作用域校区 >0 且校区不一致 → 1004(校区角色只能操作本校区)
  959. // 作用域校区 = 0(机构级角色) → 校区不限(机构内任意校区)
  960. func assertScopeMatches(sc *appscope.Scope, institutionID, campusID int64, op string) error {
  961. if sc == nil {
  962. return infraerrors.ErrUnauth
  963. }
  964. if sc.SuperAdmin {
  965. return nil
  966. }
  967. if institutionID != sc.InstitutionID {
  968. return infraerrors.WithCause(infraerrors.ErrUnscoped,
  969. errString(op+":institutionId 与当前作用域机构不一致"))
  970. }
  971. if sc.CampusID > 0 && campusID != sc.CampusID {
  972. return infraerrors.WithCause(infraerrors.ErrUnscoped,
  973. errString(op+":campusId 与当前作用域校区不一致"))
  974. }
  975. return nil
  976. }
  977. // displayNames 批量解析学生显示名(端点 53 的 `realName`)。
  978. //
  979. // 入参:ctx;rows 报课学生行(取 `UserID` 去重后批量查)。出参:map[ULID]显示名(查不到的不在 map)。
  980. //
  981. // 三条降级策略(都不影响其它字段):
  982. // - 端口未注入(`h.users == nil`,如单测/双跑脚本)→ **返回空 map**,`realName` 全部 null;
  983. // - 端口报错 → **不冒泡**(列表本身是只读展示,为"少一列姓名"让整页 5002 不划算):
  984. // 记 warn 级日志(含条数,不含姓名内容——姓名属个人信息,不进日志);
  985. // - 查不到某个 userID → 该行 `realName=null`(**不填 `""`**,前端要能区分两种"没名字")。
  986. func (h *Handler) displayNames(ctx context.Context, rows []service.StudentRow) map[string]string {
  987. if h.users == nil || len(rows) == 0 {
  988. return map[string]string{}
  989. }
  990. ids := make([]string, 0, len(rows))
  991. for i := range rows {
  992. ids = append(ids, rows[i].UserID)
  993. }
  994. names, err := h.users.DisplayNames(ctx, ids)
  995. if err != nil {
  996. if h.log != nil {
  997. h.log.Warn("academic: 学生姓名解析失败(realName 降级为 null)",
  998. zap.Int("count", len(ids)), zap.Error(err))
  999. }
  1000. return map[string]string{}
  1001. }
  1002. if names == nil {
  1003. return map[string]string{}
  1004. }
  1005. return names
  1006. }
  1007. // optString 空串 → nil(落库 NULL),非空 → 去空白后的指针。
  1008. //
  1009. // 入参:raw 原始字符串。出参:*string。
  1010. func optString(raw string) *string {
  1011. trimmed := strings.TrimSpace(raw)
  1012. if trimmed == "" {
  1013. return nil
  1014. }
  1015. return &trimmed
  1016. }
  1017. // stringValue 取字符串指针的值(nil → 空串)。
  1018. //
  1019. // 入参:s *string。出参:去空白后的字符串。
  1020. func stringValue(s *string) string {
  1021. if s == nil {
  1022. return ""
  1023. }
  1024. return strings.TrimSpace(*s)
  1025. }
  1026. // paginateSlice 对内存切片做分页(端点 46 专用,见该 Handler 的"已知取舍"注释)。
  1027. //
  1028. // 入参:rows 全量切片;page/pageSize 已由 `parsePage` 钳制。
  1029. // 出参:当前页的切片(**非 nil**;越界 → 空切片)。
  1030. func paginateSlice[T any](rows []T, page, pageSize int) []T {
  1031. if page < 1 {
  1032. page = 1
  1033. }
  1034. if pageSize < 1 {
  1035. pageSize = 10 // repository.DefaultPageSize 同值(此处不引 repository:handler 对 repository 零 import)
  1036. }
  1037. start := (page - 1) * pageSize
  1038. if start >= len(rows) {
  1039. return make([]T, 0)
  1040. }
  1041. end := start + pageSize
  1042. if end > len(rows) {
  1043. end = len(rows)
  1044. }
  1045. return rows[start:end]
  1046. }