// 课程 / 排班 / 打卡 HTTP 处理器(端点 44~57,共 14 个契约端点 / 15 条 method+path)——AC-13c。 // // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.4(端点表)/ // §3.4 C 表(字段级定义,**唯一权威**)/ §3.4 总原则 ③(字段准入条件)/ §1.3(分页)/ §1.5(时间与量纲); // 设计 `academic-domain-design.md` §3.5(SP27 欠费核销)/ §3.8-E4(`unpaid` 废弃列)/ §4.4(端点清单)。 // // --------------------------------------------------------------------------- // 端点 → 服务方法 / 权威口径对照(每个方法头再写一次,便于单点核对) // --------------------------------------------------------------------------- // // 44 GET /admin/courses CourseService.ListCourseRows(+teacherIds/recordCount) // 45 POST /admin/courses CourseService.CreateCourse(AC-11) // 45 PUT /admin/courses/:id CourseService.UpdateCourse(AC-11) // 46 GET /admin/courses/:id/classes CourseService.ListClassRows(+Handler 内分页) // 47 POST /admin/courses/:id/classes AssertNoClassConflict(3122)+ CreateClass(AC-11) // 48 GET /admin/courses/:id/records RecordService.ListRecordsByQuery(AC-11 List) // 49 POST /admin/courses/:id/records/generate CourseService.GenerateRecords(AC-11) // 50 POST /admin/courses/records/:recordId/start RecordService.StartRecord(AC-11) // 51 POST /admin/courses/records/:recordId/finish RecordService.FinishRecord(AC-11 单事务) // + RecordViewService.FinishView(只读溯源) // 52 POST /admin/courses/records/:recordId/attendance RecordService.MarkAttendance(AC-11) // 53 GET /admin/courses/:id/students CourseService.ListStudentRows // 54 POST /admin/courses/:id/students CourseService.Enroll // 55 GET /admin/courses/arrears CourseService.ListArrearsRows(**remainder_receivable > 0**) // 56 POST /admin/courses/arrears/counteract MaintenanceService.CounteractArrears(AC-11,SP27) // 57 GET /student/courses/schedule CourseService.ListScheduleRows // // --------------------------------------------------------------------------- // 路由注册顺序(**静态段一律先于 `:id`**;本批已核对:不存在真冲突) // --------------------------------------------------------------------------- // // 按 Fiber 的注册顺序匹配,同深度下"字面段必须在 `:param` 之前"。本批 15 条的静态段: // // /admin/courses (GET/POST,2 段) // /admin/courses/arrears (GET,3 段) // /admin/courses/arrears/counteract (POST,4 段) // /admin/courses/records/:recordId/... (POST,5 段) // // 逐个核对"会被 `:id` 吃掉"的可能(`/admin/courses/:id` = 3 段 PUT,`/admin/courses/:id/classes` // = 4 段,`/admin/courses/:id/records|students` = 4 段,`/:id/records/generate` = 5 段): // // GET /admin/courses/arrears vs 3 段 GET 的 `:id` 路由 → **不存在**(45 只有 POST/PUT) // POST /admin/courses/arrears/counteract vs `/admin/courses/:id/students`(4 段 POST) // → 第 4 段 `counteract` ≠ `students`,**不匹配** // POST /admin/courses/records/:recordId/start vs `/admin/courses/:id/records/generate`(5 段 POST) // → 第 3 段 `records` 能匹配 `:id`,但第 4 段要 `records`、请求给的是 `:recordId` 的值,**不匹配** // // ⇒ **本批不存在真实的同深度静态/参数冲突**(如实登记,不硬凑一条变红的用例)。 // 顺序纪律仍然保留(静态在前),并由 `route_e2e_test.go` 的 `staticShadowViolations` 持续校验。 // // --------------------------------------------------------------------------- // 错误码分工(本文件只产出下面这几类,其余一律由 Service 产出后原样透传) // --------------------------------------------------------------------------- // // 2001 缺参/类型不符 —— `validateReq`(`validator.Validate`) // 2002 传了但非法 —— 金额 Parse 失败 / 枚举越界 / 时分格式非法 / 路径 id 非数字 // 1001 无作用域 —— `mustScope` // 1004 跨作用域 —— `campusId`/`institutionId` 与作用域不一致(端点 54、56) // 3001/3104/3112/3121/3122/3140/5002 —— Service 产出,Handler **不翻译码值** // // ⚠ 本文件**不硬编码任何数字码**:一律引 `infraerrors.Err*` / `apperr.Err*` 常量。 package handler import ( "context" "strings" "time" "github.com/gofiber/fiber/v2" "go.uber.org/zap" "yuansiwei/unified-backend/internal/academic/money" "yuansiwei/unified-backend/internal/academic/service" infraerrors "yuansiwei/unified-backend/internal/infra/errors" "yuansiwei/unified-backend/internal/infra/response" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // ---------------------------------------------------------------- 路径常量(**不含 `/api`**) // // 声明顺序 = 注册顺序(静态段在前),理由见文件头「路由注册顺序」。 const ( // PathCourses 端点 44/45:课程列表 / 新建(`/admin/courses`)。 PathCourses = "/admin/courses" // PathCourseDetail 端点 45:更新课程(`PUT /admin/courses/:id`)。 PathCourseDetail = "/admin/courses/:id" // PathCourseArrears 端点 55:欠费列表(`GET /admin/courses/arrears`)。 PathCourseArrears = "/admin/courses/arrears" // PathCourseArrearsCounteract 端点 56:欠费核销(`POST /admin/courses/arrears/counteract`)。 PathCourseArrearsCounteract = "/admin/courses/arrears/counteract" // PathCourseRecordStart 端点 50:开始上课(`POST /admin/courses/records/:recordId/start`)。 PathCourseRecordStart = "/admin/courses/records/:recordId/start" // PathCourseRecordFinish 端点 51:结束上课(`POST /admin/courses/records/:recordId/finish`)。 PathCourseRecordFinish = "/admin/courses/records/:recordId/finish" // PathCourseRecordAttendance 端点 52:打卡(`POST /admin/courses/records/:recordId/attendance`)。 PathCourseRecordAttendance = "/admin/courses/records/:recordId/attendance" // PathCourseClasses 端点 46/47:排班规则列表 / 新建(`/admin/courses/:id/classes`)。 PathCourseClasses = "/admin/courses/:id/classes" // PathCourseRecords 端点 48:课表实例(`GET /admin/courses/:id/records`)。 PathCourseRecords = "/admin/courses/:id/records" // PathCourseRecordsGenerate 端点 49:生成课表(`POST /admin/courses/:id/records/generate`)。 PathCourseRecordsGenerate = "/admin/courses/:id/records/generate" // PathCourseStudents 端点 53/54:报课学生列表 / 报名(`/admin/courses/:id/students`)。 PathCourseStudents = "/admin/courses/:id/students" // PathStudentSchedule 端点 57:学生课表(`GET /student/courses/schedule`)。 PathStudentSchedule = "/student/courses/schedule" ) // 枚举白名单(2002 的唯一判据点——`oneof` 会把"传了非法值"误判成 2001)。 var ( // courseStatusFilter 端点 44 的课程状态过滤白名单(0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。 courseStatusFilter = map[int64]struct{}{0: {}, 1: {}, 2: {}, 3: {}} // recordStatusFilter 端点 48 的上课状态过滤白名单(0 未开始 / 1 上课中 / 2 已结束)。 recordStatusFilter = map[int64]struct{}{0: {}, 1: {}, 2: {}} // studentStatusFilter 端点 53 的报课状态过滤白名单(1 正常 / 2 结束)。 studentStatusFilter = map[int64]struct{}{1: {}, 2: {}} ) // ---------------------------------------------------------------- 端点 44:课程列表 // ListCourses 课程列表(端点 44 `GET /admin/courses`)。 // // 入参(query):`classify?`、`status?`(0~3)、`keyword?`、`page`/`pageSize`。 // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `courseRowView` // (含 `teacherIds` 与 `recordCount` 两个派生列)。 // 出参(失败):1001/2002(status 越界或非数字、classify 非数字)/5002。 // // 作用域:列表天然只含本机构(`Scoped`),跨机构课程**不出现**在结果里("看不见"而非 1004)。 func (h *Handler) ListCourses(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } classifyID, err := parseIntQuery(c, "classify") if err != nil { return response.Fail(c, err) } if classifyID < 0 { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("classify 不得为负"))) } statusFilter, _, err := parseEnumQuery(c, "status", courseStatusFilter) if err != nil { return response.Fail(c, err) } var statusPtr *int32 if statusFilter != nil { v := int32(*statusFilter) statusPtr = &v } page, pageSize := parsePage(c) svc := h.servicesFor(sc) rows, total, err := svc.course.ListCourseRows(c.Context(), service.CourseListQuery{ ClassifyID: classifyID, Status: statusPtr, Keyword: c.Query("keyword"), Page: page, PageSize: pageSize, }) if err != nil { return response.Fail(c, err) } views := make([]courseRowView, 0, len(rows)) for i := range rows { row := &rows[i] tids := row.TeacherIDs if tids == nil { tids = make([]string, 0) } views = append(views, courseRowView{ ID: row.ID, InstitutionID: row.InstitutionID, Name: row.Name, Classify: row.ClassifyID, Status: row.Status, TeacherIDs: tids, RecordCount: row.RecordCount, }) } return response.Page(c, views, page, pageSize, total) } // ---------------------------------------------------------------- 端点 45:新建课程 // CreateCourse 新建课程模板(端点 45 `POST /admin/courses`)。 // // 入参(body):见 `CreateCourseReq`(`name` 与 `duration` 必填)。 // 出参(成功):`{id}`。 // 出参(失败):1001/2001/2002(金额/课时/week/时间窗非法)/3001(分类不存在或越权)/1004/5002。 // // 状态:新建恒落 `status=0`(未发布)——发布走状态机专用入口(AC-11 红线,本端点不开放 status)。 func (h *Handler) CreateCourse(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req CreateCourseReq if !validateReq(c, &req) { return nil } start, end, err := parseActivityWindow(req.StartTime, req.EndTime) if err != nil { return response.Fail(c, err) } svc := h.servicesFor(sc) row, err := svc.course.CreateCourse(c.Context(), service.CreateCourseInput{ SubjectID: req.SubjectID, GradeID: req.GradeID, Type: req.Type, ClassifyID: req.ClassifyID, Name: strings.TrimSpace(req.Name), Number: req.Number, Duration: req.Duration, Week: req.Week, StartTime: start, EndTime: end, Address: optString(req.Address), OriginalCost: req.OriginalCost, RealPrice: req.RealPrice, DiscountCash: req.DiscountCash, DiscountRatio: req.DiscountRatio, MaxPeople: req.MaxPeople, Remark: optString(req.Remark), }) if err != nil { return response.Fail(c, err) } return response.OK(c, courseSavedResp{ID: row.ID}) } // ---------------------------------------------------------------- 端点 45:更新课程 // UpdateCourse 更新课程模板(端点 45 `PUT /admin/courses/:id`,**部分更新**语义)。 // // 入参(body):见 `UpdateCourseReq`(nil = 不改;传了就校验)。 // 出参(成功):`{id}`。 // 出参(失败):1001/2001(没有任何待更新字段)/2002/3001/5002。 // // 红线:**不改** `institution_id` / `campus_id` / `status`(各有专用入口,见 AC-11 注释)。 func (h *Handler) UpdateCourse(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req UpdateCourseReq if !validateReq(c, &req) { return nil } in := service.UpdateCourseInput{ ID: parseIDParam(c.Params("id")), SubjectID: req.SubjectID, GradeID: req.GradeID, Type: req.Type, ClassifyID: req.ClassifyID, Name: req.Name, Number: req.Number, Duration: req.Duration, Week: req.Week, OriginalCost: req.OriginalCost, RealPrice: req.RealPrice, DiscountCash: req.DiscountCash, DiscountRatio: req.DiscountRatio, MaxPeople: req.MaxPeople, } if req.Address != nil { in.Address = optString(*req.Address) } if req.Remark != nil { in.Remark = optString(*req.Remark) } // 时间列:传了就必须能解析(空串 = 不修改,与"清空"区分——本接口不提供清空时间的能力) if req.StartTime != nil { at, has, perr := parseTimeParam(*req.StartTime, "startTime") if perr != nil { return response.Fail(c, perr) } if has { in.StartTime = &at } } if req.EndTime != nil { at, has, perr := parseTimeParam(*req.EndTime, "endTime") if perr != nil { return response.Fail(c, perr) } if has { in.EndTime = &at } } svc := h.servicesFor(sc) row, err := svc.course.UpdateCourse(c.Context(), in) if err != nil { return response.Fail(c, err) } return response.OK(c, courseSavedResp{ID: row.ID}) } // ---------------------------------------------------------------- 端点 46:排班规则列表 // ListCourseClasses 排班规则列表(端点 46 `GET /admin/courses/:id/classes`)。 // // 入参(path):`:id` 课程 ID;(query):`page`/`pageSize`。 // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `classRowView`。 // 出参(失败):1001/2002(id 非数字)/3001(课程不存在或不在作用域)/5002。 // // ⚠ **分页在本 Handler 内做(内存切片)**:一门课程的排班规则数量**有界**(通常 ≤ 10 条, // 极端情况也就"每个 week 一条"共 7 条),AC-11 的 `ListClasses` 一次取全量更省一次往返。 // 若将来出现"单课程上百条规则"的真实场景,应改为仓储层 `LIMIT/OFFSET`—— // 已在交付报告登记为**已知取舍**(不是遗漏)。 func (h *Handler) ListCourseClasses(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } page, pageSize := parsePage(c) svc := h.servicesFor(sc) rows, err := svc.course.ListClassRows(c.Context(), parseIDParam(c.Params("id"))) if err != nil { return response.Fail(c, err) } total := int64(len(rows)) views := make([]classRowView, 0, len(rows)) for _, row := range rows { views = append(views, classRowView{ ClassID: row.ClassID, CourseID: row.CourseID, TeacherID: row.TeacherID, Week: row.Week, Start: row.Start, End: row.End, CampusID: row.CampusID, }) } views = paginateSlice(views, page, pageSize) return response.Page(c, views, page, pageSize, total) } // ---------------------------------------------------------------- 端点 47:新建排班规则 // CreateCourseClass 新建排班规则(端点 47 `POST /admin/courses/:id/classes`)。 // // 入参(body):见 `CreateClassReq`(`start` 必填;`teacherId` 给了才做冲突判定)。 // 出参(成功):`{classId}`。 // 出参(失败):1001/2001/2002(时分格式/下课早于上课/week 非法)/ // **3122(排班冲突)**/3001(课程不存在)/5002。 // // 3122 的判据在 `CourseService.AssertNoClassConflict`(同老师 + 同校区 + 周规则有交集 + 时段重叠)。 // 为什么先查再建(而不是靠唯一键):`t_courses_class` **没有** `(teacher, week, start)` 唯一键 // (DDL 已锁定,见 model 注释),数据库不会替我们拦住重复排班 ⇒ 只能在应用层判。 func (h *Handler) CreateCourseClass(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req CreateClassReq if !validateReq(c, &req) { return nil } coursesID := parseIDParam(c.Params("id")) if coursesID <= 0 { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("课程 id 必须 > 0"))) } in := service.CreateClassInput{ CoursesID: coursesID, ClassID: req.ClassID, TeacherID: strings.TrimSpace(req.TeacherID), Week: req.Week, StartTime: req.Start, EndTime: req.End, StandardLessonCost: req.StandardLessonCost, LessonCost: req.LessonCost, DiscountCash: req.DiscountCash, Number: req.Number, Address: optString(req.Address), Remark: optString(req.Remark), } svc := h.servicesFor(sc) if err := svc.course.AssertNoClassConflict(c.Context(), in); err != nil { return response.Fail(c, err) // 3122 } row, err := svc.course.CreateClass(c.Context(), in) if err != nil { return response.Fail(c, err) } return response.OK(c, classCreatedResp{ClassID: row.ClassID}) } // ---------------------------------------------------------------- 端点 48:课表实例 // ListCourseRecords 课表实例列表(端点 48 `GET /admin/courses/:id/records`)。 // // 入参(path):`:id` 课程 ID;(query):`teacherId?`、`status?`(0~2)、`from?`、`to?`、分页。 // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `recordRowView`(**无 `room`**,见 DTO 注释)。 // 出参(失败):1001/2002(status 越界、时间格式非法)/5002。 // // 时间窗**两端都含**(沿用 AC-11 `RecordFilter`):课表按"日期区间"看课,`to=2026-11-30` // 必须含 30 号当天的课(与契约 §1.3 的"`to` 右开"不同,属有意差异,已登记)。 func (h *Handler) ListCourseRecords(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } statusFilter, _, err := parseEnumQuery(c, "status", recordStatusFilter) if err != nil { return response.Fail(c, err) } var statusPtr *int32 if statusFilter != nil { v := int32(*statusFilter) statusPtr = &v } from, hasFrom, err := parseTimeParam(c.Query("from"), "from") if err != nil { return response.Fail(c, err) } to, hasTo, err := parseTimeParam(c.Query("to"), "to") if err != nil { return response.Fail(c, err) } var fromPtr, toPtr *time.Time if hasFrom { fromPtr = &from } if hasTo { toPtr = &to } page, pageSize := parsePage(c) svc := h.servicesFor(sc) rows, total, err := svc.record.ListRecordsByQuery(c.Context(), service.RecordListQuery{ CoursesID: parseIDParam(c.Params("id")), TeacherID: c.Query("teacherId"), Status: statusPtr, From: fromPtr, To: toPtr, Page: page, PageSize: pageSize, }) if err != nil { return response.Fail(c, err) } views := make([]recordRowView, 0, len(rows)) for i := range rows { rec := &rows[i] views = append(views, recordRowView{ RecordID: rec.ID, CourseID: rec.CoursesID, TeacherID: stringValue(rec.TeacherID), CampusID: rec.CampusID, StartTime: rec.StartTime, EndTime: rec.EndTime, Status: rec.Status, StudentCount: rec.StudentNumber, }) } return response.Page(c, views, page, pageSize, total) } // ---------------------------------------------------------------- 端点 49:生成课表 // GenerateCourseRecords 按 week 规则生成排班实例(端点 49 `POST /admin/courses/:id/records/generate`)。 // // 入参(body):见 `GenerateRecordsReq`(`days`/`from` 都可空)。 // 出参(成功):`{generated,days,changed}`(幂等:同 courses+start_time 不重复插,第二次 generated=0)。 // 出参(失败):1001/2002(from 格式非法)/3001/3140(该课程没有生效排班规则)/5002。 func (h *Handler) GenerateCourseRecords(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req GenerateRecordsReq if len(c.Body()) > 0 { if !validateReq(c, &req) { return nil } } var fromPtr *time.Time if strings.TrimSpace(req.From) != "" { at, _, perr := parseTimeParam(req.From, "from") if perr != nil { return response.Fail(c, perr) } fromPtr = &at } svc := h.servicesFor(sc) res, err := svc.course.GenerateRecords(c.Context(), service.GenerateRecordsInput{ CoursesID: parseIDParam(c.Params("id")), Days: req.Days, From: fromPtr, }) if err != nil { return response.Fail(c, err) } days := req.Days if days <= 0 { days = service.DefaultGenerateDays } if days > service.MaxGenerateDays { days = service.MaxGenerateDays } return response.OK(c, generateRecordsResp{ Generated: res.Created, Days: days, Changed: res.Created > 0, }) } // ---------------------------------------------------------------- 端点 50:开始上课 // StartCourseRecord 开始上课(端点 50 `POST /admin/courses/records/:recordId/start`)。 // // 入参:无 body(契约 §2.4 端点 50 无请求字段)。 // 出参(成功):`{recordId, status:1, courseId}`。 // 出参(失败):1001/2001/3001(实例不存在或不在作用域)/**3121(状态非法:重复开始/已结束)**/5002。 // // `actual_start_time` 只写一次(模型红线):重复调用 → 3121,不会写出第二个开课时间。 func (h *Handler) StartCourseRecord(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } recordID := parseIDParam(c.Params("recordId")) svc := h.servicesFor(sc) res, err := svc.record.StartRecord(c.Context(), recordID) if err != nil { return response.Fail(c, err) } courseID := int64(0) if rec, _, gerr := svc.record.Get(c.Context(), recordID); gerr == nil && rec != nil { courseID = rec.CoursesID } return response.OK(c, startRecordResp{ RecordID: res.RecordID, Status: res.Status, CourseID: courseID, }) } // ---------------------------------------------------------------- 端点 51:结束上课 // FinishCourseRecord 结束上课(端点 51 `POST /admin/courses/records/:recordId/finish`)——**全项目最重的写端点**。 // // 入参(body):见 `FinishRecordReq`(`items` 可空 = 按在读学生全勤)。 // 出参(成功):`finishRecordResp`(`changed=false` = 幂等命中:此前已结束,本次**未写任何表**)。 // 出参(失败):1001/2001/2002/**3001**(实例不存在)/**3121**(状态非法/并发竞争)/5002。 // // **单事务四步联动**(AC-11 `RecordService.FinishRecord` 内,任一失败全部回滚): // // ① 打卡明细落库(t_courses_record_student:cost / give_cost / teacher_reward) // ② 扣课时(t_courses_student:finished_number+1 / remainder_number-1 / remainder_cost-cost) // ③ spend 累加(t_user_expense + t_user_expense_school:**只动 spend 统计列、不动 balance**) // ④ 分账入账(t_user_divide_into_log + total_income) // ⑤ 课酬(试算 → 按学生均分 → Σ = record.teacher_reward) // // 本 Handler **只做三件事**:解析入参、调服务、把结果装配成契约形状(溯源列的只读回读在 // `RecordViewService.FinishView`,位于事务**之后**,因此装配失败不影响已提交的结账结果)。 func (h *Handler) FinishCourseRecord(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req FinishRecordReq if len(c.Body()) > 0 { if !validateReq(c, &req) { return nil } } switch req.RewardType { case 0, 1, 2: // 合法:0 → 服务端按 1(正常结算) default: return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("rewardType 只能是 1 或 2"))) } items, ok := h.parseAttendanceItems(c, req.Items) if !ok { return nil } var endPtr *time.Time if strings.TrimSpace(req.ActualEndTime) != "" { at, _, perr := parseTimeParam(req.ActualEndTime, "actualEndTime") if perr != nil { return response.Fail(c, perr) } endPtr = &at } svc := h.servicesFor(sc) res, err := svc.record.FinishRecord(c.Context(), service.FinishRecordInput{ RecordID: parseIDParam(c.Params("recordId")), ActualEndTime: endPtr, Items: items, RewardType: req.RewardType, Remark: optString(req.Remark), }) if err != nil { return response.Fail(c, err) } view, err := svc.recordView.FinishView(c.Context(), res) if err != nil { return response.Fail(c, err) } return response.OK(c, toFinishRecordResp(view)) } // ---------------------------------------------------------------- 端点 52:打卡 // MarkCourseRecordAttendance 打卡/考勤(端点 52 `POST /admin/courses/records/:recordId/attendance`)。 // // 入参(body):`list:[{userId,type,pay,cost,giveCost,remark?}]`(必填非空)。 // 出参(成功):`{recordId, checked}`(`checked` = 该实例当前打卡人数)。 // 出参(失败):1001/2001/2002/**3001**/**3121(课已结束,不能补打卡)**/3140(同一学生重复)/5002。 // // 只写 cost/give_cost/pay/type,**不扣课时、不动余额、不算课酬**(那是端点 51 的联动); // 幂等:同一 (record, student) 重复打卡 → 覆盖该行,不产生第二行。 func (h *Handler) MarkCourseRecordAttendance(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req MarkAttendanceReq if !validateReq(c, &req) { return nil } items, ok := h.parseAttendanceItems(c, req.List) if !ok { return nil } svc := h.servicesFor(sc) res, err := svc.record.MarkAttendance(c.Context(), service.MarkAttendanceInput{ RecordID: parseIDParam(c.Params("recordId")), Items: items, }) if err != nil { return response.Fail(c, err) } return response.OK(c, attendanceResp{RecordID: res.RecordID, Checked: res.StudentNumber}) } // ---------------------------------------------------------------- 端点 53:报课学生 // ListCourseStudents 报课学生列表(端点 53 `GET /admin/courses/:id/students`)。 // // 入参(path):`:id` 课程 ID;(query):`status?`(1 正常 / 2 结束)、分页。 // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `studentRowView`。 // 出参(失败):1001/2002/3001(课程不存在或不在作用域)/5002。 // // ⚠ **欠费字段是 `remainderReceivable`(源列 `remainder_receivable`)**:`unpaid` 是 2018-05 // 起停更的僵尸列(设计 §3.8-E4),**禁展示、禁下发**。 func (h *Handler) ListCourseStudents(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } statusFilter, _, err := parseEnumQuery(c, "status", studentStatusFilter) if err != nil { return response.Fail(c, err) } var statusPtr *int32 if statusFilter != nil { v := int32(*statusFilter) statusPtr = &v } page, pageSize := parsePage(c) svc := h.servicesFor(sc) rows, total, err := svc.course.ListStudentRows(c.Context(), service.StudentListQuery{ CoursesID: parseIDParam(c.Params("id")), Status: statusPtr, Page: page, PageSize: pageSize, }) if err != nil { return response.Fail(c, err) } // 姓名跨域只读(identity 域 `t_user.display_name`):一次批量取,**查不到 → null**。 names := h.displayNames(c.Context(), rows) views := make([]studentRowView, 0, len(rows)) for i := range rows { row := &rows[i] var realName *string if name, ok := names[row.UserID]; ok && name != "" { v := name realName = &v } views = append(views, studentRowView{ UserID: row.UserID, RealName: realName, Pay: row.Pay.String(), Cost: row.Cost.String(), GiveCost: row.GiveCost.String(), RemainderReceivable: row.RemainderReceivable.String(), Status: row.Status, }) } return response.Page(c, views, page, pageSize, total) } // ---------------------------------------------------------------- 端点 54:报名 // EnrollCourseStudent 报名 / 补报名(端点 54 `POST /admin/courses/:id/students`)。 // // 入参(body):见 `EnrollReq`(`userId`/`pay`/`lessonCost` 必填)。 // 出参(成功):`{enrolled:true, courseId, userId}`。 // 出参(失败):1001/2001/2002/**3001**(课程不存在/越权)/**1004**(campusId 跨校区)/ // **3143**(该学生已在本课程在读)/5002。 // // 三条资金红线(见 `service.EnrollInput` 注释):不写流水、不动余额; // `confirm_discount_user_id` 写操作人(否则该行永远进不了欠费核销候选);重复报名拒绝。 func (h *Handler) EnrollCourseStudent(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req EnrollReq if !validateReq(c, &req) { return nil } svc := h.servicesFor(sc) row, err := svc.course.Enroll(c.Context(), service.EnrollInput{ CoursesID: parseIDParam(c.Params("id")), StudentID: strings.TrimSpace(req.UserID), ClassID: req.ClassID, CampusID: req.CampusID, Pay: req.Pay, RemainderReceivable: req.RemainderReceivable, LessonCost: req.LessonCost, Number: req.Number, GiveNumber: req.GiveNumber, DiscountCash: req.DiscountCash, GiveCost: req.GiveCost, RecommenderUserID: req.RecommenderUserID, Remark: req.Remark, OperatorID: sc.UserID, }) if err != nil { return response.Fail(c, err) } return response.OK(c, enrollResp{Enrolled: true, CourseID: row.CoursesID, UserID: row.StudentID}) } // ---------------------------------------------------------------- 端点 55:欠费列表 // ListCourseArrears 欠费列表(端点 55 `GET /admin/courses/arrears`)。 // // 入参(query):`studentId?`、`page`/`pageSize`。 // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `arrearsRowView`。 // 出参(失败):1001/5002。 // // **口径恒为 `remainder_receivable > 0`**(契约 §2.4 端点 55 注 / 设计 §3.8-E4)。 // ⚠ 写成 `unpaid > 0` 不会报错、不会 500,只会**静默漏掉所有真实欠费行** // (`unpaid` 2018-05-17 起停止更新、真实数据几乎恒 0)——这是本端点唯一的致命错法。 func (h *Handler) ListCourseArrears(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } page, pageSize := parsePage(c) svc := h.servicesFor(sc) rows, total, err := svc.course.ListArrearsRows(c.Context(), service.ArrearsListQuery{ StudentID: c.Query("studentId"), Page: page, PageSize: pageSize, }) if err != nil { return response.Fail(c, err) } views := make([]arrearsRowView, 0, len(rows)) for i := range rows { row := &rows[i] views = append(views, arrearsRowView{ StudentID: row.StudentID, CourseID: row.CourseID, RemainderReceivable: row.RemainderReceivable.String(), Counteracted: row.Counteracted, }) } return response.Page(c, views, page, pageSize, total) } // ---------------------------------------------------------------- 端点 56:欠费核销 // CounteractCourseArrears 欠费核销(端点 56 `POST /admin/courses/arrears/counteract`,SP27 dump 763435)。 // // 入参(body):`studentId,institutionId,campusId,money`(四个都必填)。 // 出参(成功):`{counteracted, studentId, amount}`(`amount` = **本次实际核销额**,不是入参金额)。 // 出参(失败):1001/2001/2002/**1004**(institutionId/campusId 与作用域不一致)/**3113**(核销冲突)/5002。 // // 越权判据(**本端点唯一的作用域断言,必须在这里**):SP27 的 `institutionId`/`campusId` 是 // **显式数据范围参数**(不是从 token 推断),因此非超管传了别的机构/校区必须在 Handler 拒掉—— // 服务层按入参直接查库,数据层没有第二道防线。 func (h *Handler) CounteractCourseArrears(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req CounteractArrearsReq if !validateReq(c, &req) { return nil } if err := assertScopeMatches(sc, req.InstitutionID, req.CampusID, "欠费核销"); err != nil { return response.Fail(c, err) } amount, ok := parseMoney(c, req.Money, "money") if !ok { return nil } studentID := strings.TrimSpace(req.StudentID) svc := h.servicesFor(sc) res, err := svc.maintenance.CounteractArrears(c.Context(), service.CounteractArrearsInput{ StudentID: studentID, InstitutionID: req.InstitutionID, CampusID: req.CampusID, Money: amount, }) if err != nil { return response.Fail(c, err) } return response.OK(c, counteractResp{ Counteracted: res.Applied.GreaterThan(0), StudentID: studentID, Amount: res.Applied.String(), }) } // ---------------------------------------------------------------- 端点 57:学生课表 // GetStudentSchedule 学生课表(端点 57 `GET /student/courses/schedule`)。 // // 入参(query):`userId?`、`from?`、`to?`、分页。 // 出参(成功):`{list,total,page,pageSize,pages}`,行见 `scheduleRowView` // (**字段名待移动端契约定稿**,契约 §3.4 C 行 57 原文如此)。 // 出参(失败):1001/2001(既没传 userId 又没有登录用户)/2002(时间格式非法)/5002。 // // 越权判据(**1004**): // // 学生 / 家长:`userId` 缺省 = 自己;传别人 → **1004**(家长与孩子的绑定关系不在本域, // 一期不做"家长看孩子"——宁可拒,也不做无法验证的授权) // 老师:可以指定 `userId`(家访/回访场景),但仍受机构作用域约束(查不到 = 空列表) // 超管:不限 func (h *Handler) GetStudentSchedule(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } userID := strings.TrimSpace(c.Query("userId")) if userID == "" { userID = strings.TrimSpace(sc.UserID) } if userID == "" { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("缺少 userId(学生课表必须指定学生)"))) } switch sc.Role { case RoleTeacher, RoleSuperAdmin: // 允许指定他人 default: if userID != strings.TrimSpace(sc.UserID) { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrUnscoped, errString("只能查看本人的课表"))) } } from, hasFrom, err := parseTimeParam(c.Query("from"), "from") if err != nil { return response.Fail(c, err) } to, hasTo, err := parseTimeParam(c.Query("to"), "to") if err != nil { return response.Fail(c, err) } var fromPtr, toPtr *time.Time if hasFrom { fromPtr = &from } if hasTo { toPtr = &to } page, pageSize := parsePage(c) svc := h.servicesFor(sc) rows, total, err := svc.course.ListScheduleRows(c.Context(), service.ScheduleQuery{ StudentID: userID, From: fromPtr, To: toPtr, Page: page, PageSize: pageSize, }) if err != nil { return response.Fail(c, err) } views := make([]scheduleRowView, 0, len(rows)) for i := range rows { row := &rows[i] views = append(views, scheduleRowView{ RecordID: row.RecordID, CourseID: row.CourseID, CourseName: row.CourseName, TeacherID: row.TeacherID, CampusID: row.CampusID, StartTime: row.StartTime, EndTime: row.EndTime, Status: row.Status, Cost: row.Cost.String(), GiveCost: row.GiveCost.String(), Pay: row.Pay, Checked: row.Checked, }) } return response.Page(c, views, page, pageSize, total) } // ---------------------------------------------------------------- 本地助手(只做解析/装配,不含业务判断) // parseAttendanceItems 把请求里的打卡明细转成服务入参(金额字符串 → `money.Amount`)。 // // 入参:c;raw 请求明细。出参:(服务入参, 是否成功);失败时 **body code 已写出**(2002)。 // // 只做"字符串 → 定点金额"的解析;`type` 枚举、重复学生、负金额等**业务校验留给 Service** // (唯一实现点,端点 51/52 共用同一套规则,避免两处判据漂移)。 func (h *Handler) parseAttendanceItems(c *fiber.Ctx, raw []AttendanceItemReq) ([]service.AttendanceItem, bool) { out := make([]service.AttendanceItem, 0, len(raw)) for i := range raw { item := &raw[i] var costPtr, givePtr *money.Amount if strings.TrimSpace(item.Cost) != "" { amount, ok := parseMoney(c, item.Cost, "items.cost") if !ok { return nil, false } costPtr = &amount } if strings.TrimSpace(item.GiveCost) != "" { amount, ok := parseMoney(c, item.GiveCost, "items.giveCost") if !ok { return nil, false } givePtr = &amount } out = append(out, service.AttendanceItem{ StudentID: strings.TrimSpace(item.UserID), Type: item.Type, Pay: item.Pay, Cost: costPtr, GiveCost: givePtr, Remark: optString(item.Remark), }) } return out, true } // toFinishRecordResp 把服务视图转成端点 51 的响应(金额一律两位小数字符串)。 // // 入参:view 服务层装配结果。出参:finishRecordResp(各数组空时为空切片,非 null)。 func toFinishRecordResp(view *service.RecordFinishView) finishRecordResp { divide := make([]divideGrantView, 0, len(view.Divide)) for i := range view.Divide { d := &view.Divide[i] divide = append(divide, divideGrantView{ UserID: d.UserID, Type: d.Type, Amount: d.Amount.String(), }) } students := make([]perStudentView, 0, len(view.PerStudent)) for i := range view.PerStudent { s := &view.PerStudent[i] students = append(students, perStudentView{ UserID: s.StudentID, Type: s.Type, Pay: s.Paid, Cost: s.Cost.String(), GiveCost: s.GiveCost.String(), Counteracted: s.DivideGranted, }) } return finishRecordResp{ RecordID: view.RecordID, CourseID: view.CourseID, Status: view.Status, Changed: view.Changed, TotalSpend: view.TotalSpend.String(), Divide: divide, Payroll: payrollTraceView{ DetailID: view.Payroll.DetailID, Reward: view.Payroll.Reward.String(), }, PerStudent: students, } } // assertScopeMatches 断言显式传入的机构/校区与当前作用域一致(越界 → **1004**)。 // // 入参:sc 作用域;institutionID/campusID 入参值(>0,由 validate tag 保证);op 操作名(错误文案)。 // 出参:nil 通过;`infraerrors.ErrUnscoped`(1004) 越界。 // // 规则: // // 超管 → 放行(平台视角,可代任意机构执行) // 机构不一致 → 1004 // 作用域校区 >0 且校区不一致 → 1004(校区角色只能操作本校区) // 作用域校区 = 0(机构级角色) → 校区不限(机构内任意校区) func assertScopeMatches(sc *appscope.Scope, institutionID, campusID int64, op string) error { if sc == nil { return infraerrors.ErrUnauth } if sc.SuperAdmin { return nil } if institutionID != sc.InstitutionID { return infraerrors.WithCause(infraerrors.ErrUnscoped, errString(op+":institutionId 与当前作用域机构不一致")) } if sc.CampusID > 0 && campusID != sc.CampusID { return infraerrors.WithCause(infraerrors.ErrUnscoped, errString(op+":campusId 与当前作用域校区不一致")) } return nil } // displayNames 批量解析学生显示名(端点 53 的 `realName`)。 // // 入参:ctx;rows 报课学生行(取 `UserID` 去重后批量查)。出参:map[ULID]显示名(查不到的不在 map)。 // // 三条降级策略(都不影响其它字段): // - 端口未注入(`h.users == nil`,如单测/双跑脚本)→ **返回空 map**,`realName` 全部 null; // - 端口报错 → **不冒泡**(列表本身是只读展示,为"少一列姓名"让整页 5002 不划算): // 记 warn 级日志(含条数,不含姓名内容——姓名属个人信息,不进日志); // - 查不到某个 userID → 该行 `realName=null`(**不填 `""`**,前端要能区分两种"没名字")。 func (h *Handler) displayNames(ctx context.Context, rows []service.StudentRow) map[string]string { if h.users == nil || len(rows) == 0 { return map[string]string{} } ids := make([]string, 0, len(rows)) for i := range rows { ids = append(ids, rows[i].UserID) } names, err := h.users.DisplayNames(ctx, ids) if err != nil { if h.log != nil { h.log.Warn("academic: 学生姓名解析失败(realName 降级为 null)", zap.Int("count", len(ids)), zap.Error(err)) } return map[string]string{} } if names == nil { return map[string]string{} } return names } // optString 空串 → nil(落库 NULL),非空 → 去空白后的指针。 // // 入参:raw 原始字符串。出参:*string。 func optString(raw string) *string { trimmed := strings.TrimSpace(raw) if trimmed == "" { return nil } return &trimmed } // stringValue 取字符串指针的值(nil → 空串)。 // // 入参:s *string。出参:去空白后的字符串。 func stringValue(s *string) string { if s == nil { return "" } return strings.TrimSpace(*s) } // paginateSlice 对内存切片做分页(端点 46 专用,见该 Handler 的"已知取舍"注释)。 // // 入参:rows 全量切片;page/pageSize 已由 `parsePage` 钳制。 // 出参:当前页的切片(**非 nil**;越界 → 空切片)。 func paginateSlice[T any](rows []T, page, pageSize int) []T { if page < 1 { page = 1 } if pageSize < 1 { pageSize = 10 // repository.DefaultPageSize 同值(此处不引 repository:handler 对 repository 零 import) } start := (page - 1) * pageSize if start >= len(rows) { return make([]T, 0) } end := start + pageSize if end > len(rows) { end = len(rows) } return rows[start:end] }