| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048 |
- // 课程 / 排班 / 打卡 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 (
- "strings"
- "time"
- "github.com/gofiber/fiber/v2"
- "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)
- }
- views := make([]studentRowView, 0, len(rows))
- for i := range rows {
- row := &rows[i]
- views = append(views, studentRowView{
- UserID: row.UserID,
- 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 跨校区)/
- // **3140**(该学生已在本课程在读)/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
- }
- // 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]
- }
|