course_handler.go 40 KB

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