// 课程域请求 / 响应 DTO(端点 44~57 的**入参形状**与响应拼装)——AC-13c。 // // 依据:unified/03-migration/academic-frontend-contract-v1.md // - §2.4 课程/排班/打卡(端点 44~57 的 method/path/角色/请求要点); // - §3.4 C 表(响应字段名,**唯一权威**)+ 总原则 ③「字段准入条件」; // - §1.5 B1/B2(时间与量纲:`startTime`/`endTime` 是业务时间列,金额是两位小数字符串)。 // // --------------------------------------------------------------------------- // 三条 DTO 铁律(与 `dto_fund.go` / `dto_activity.go` 同源,**不另立一套**) // --------------------------------------------------------------------------- // // 1. **金额一律 `string`**(`"100.00"`),Handler 用 `parseMoney` / `parseMoneyOrZero` 解析; // 不声明 float64(浮点误差)也不声明 money.Amount(会让 JSON number 被静默接受)。 // 2. **缺参 → 2001 由 `validate` tag 表达;值非法 → 2002 由 Handler 显式判**。枚举字段 // 不写 `oneof=`(`oneof` 失败会被归成 2001,而"传了非法枚举"属 2002)。 // 3. **响应字段逐个列溯源**:每个 JSON 键都必须能追到一个具体列,写不出来的一律不做 // (本批因此删了 `room` 与 `divide[].level`,并保留了 `week` 的字符串原值——见各结构注释)。 // // --------------------------------------------------------------------------- // 本批**不接受**的请求字段(写了也无效,故不声明) // --------------------------------------------------------------------------- // // institutionId 由请求作用域决定(非超管传别的机构 → 1004,由 `resolveInstitutionID` 判) // status(课程) 发布状态机专用入口,不在 CRUD 里改(AC-11 `CreateCourse` 恒落"未发布") // numberSign / // unpaid / unpaid 类欠费列 —— **废弃列**(2018-05 停更),任何入参都不写它 package handler import "time" // ---------------------------------------------------------------- 端点 45:课程 CRUD // CreateCourseReq 端点 45 新建课程请求体(`POST /admin/courses`)。 // // 字段 → 目标列(DDL 第四部分 t_courses): // // name→name / subjectId→subject_id / gradeId→grade_id / type→type / classifyId→classify_id / // number→number / duration→duration / week→week / startTime→start_time / endTime→end_time / // address→address / originalCost→original_cost / realPrice→real_price / // discountCash→discount_cash / discountRatio→discount_ratio / maxPeople→max_people / remark→remark type CreateCourseReq struct { // Name 课程名称(必填)。 Name string `json:"name" validate:"required,max=500"` // SubjectID 科目(0 = 混合)。 SubjectID int32 `json:"subjectId"` // GradeID 年级(0 = 混合)。 GradeID int32 `json:"gradeId"` // Type 班型编码(0 → 1000 集体班)。 Type int32 `json:"type"` // ClassifyID 课程分类(0 = 不挂分类;>0 时须存在且同作用域,否则 3001)。 ClassifyID int64 `json:"classifyId"` // Number 上课次数(>=0)。 Number int32 `json:"number"` // Duration 单节课时长("1.5" 这类课时字符串,1 位小数;**必填**,>0)。 Duration string `json:"duration" validate:"required,max=10"` // Week 周规则("-1" 全部 / "0" 周日 / "1".."6" / 逗号多值;空 → "-1")。 Week string `json:"week" validate:"max=50"` // StartTime 课程有效期起(RFC3339 或 `2006-01-02`;可空)。 StartTime string `json:"startTime"` // EndTime 课程有效期止(同上;不得早于 StartTime → 否则 2002)。 EndTime string `json:"endTime"` // Address 上课地址。 Address string `json:"address"` // OriginalCost 原价(元字符串;可空 → "0.00")。 OriginalCost string `json:"originalCost"` // RealPrice 实价(元字符串;可空 → "0.00")。 RealPrice string `json:"realPrice"` // DiscountCash 现金折扣(元字符串;**允许负**,模型红线)。 DiscountCash string `json:"discountCash"` // DiscountRatio 折扣率(整数百分比,仅展示/试算)。 DiscountRatio int32 `json:"discountRatio"` // MaxPeople 最多人数(0 → 由修复 SP 从分类继承)。 MaxPeople int32 `json:"maxPeople"` // Remark 备注。 Remark string `json:"remark"` } // UpdateCourseReq 端点 45 更新课程请求体(`PUT /admin/courses/:id`)。 // // 语义:**部分更新**(nil = 不改)。与新建的唯一差异是"全部可空",因此一律用指针, // 以便区分"没传"(不改)与"传了零值"(改成 0 / 空串)。 type UpdateCourseReq struct { // Name 课程名称(传了就不得为空)。 Name *string `json:"name"` // SubjectID 科目。 SubjectID *int32 `json:"subjectId"` // GradeID 年级。 GradeID *int32 `json:"gradeId"` // Type 班型。 Type *int32 `json:"type"` // ClassifyID 分类(>=0;>0 须存在)。 ClassifyID *int64 `json:"classifyId"` // Number 上课次数(>=0)。 Number *int32 `json:"number"` // Duration 单节课时长(课时字符串,>0)。 Duration *string `json:"duration"` // Week 周规则。 Week *string `json:"week"` // StartTime 有效期起。 StartTime *string `json:"startTime"` // EndTime 有效期止。 EndTime *string `json:"endTime"` // Address 上课地址。 Address *string `json:"address"` // OriginalCost 原价。 OriginalCost *string `json:"originalCost"` // RealPrice 实价。 RealPrice *string `json:"realPrice"` // DiscountCash 现金折扣(允许负)。 DiscountCash *string `json:"discountCash"` // DiscountRatio 折扣率。 DiscountRatio *int32 `json:"discountRatio"` // MaxPeople 最多人数。 MaxPeople *int32 `json:"maxPeople"` // Remark 备注。 Remark *string `json:"remark"` } // ---------------------------------------------------------------- 端点 47:新建排班规则 // CreateClassReq 端点 47 新建排班规则请求体(`POST /admin/courses/:id/classes`)。 // // 时分用 `"HH:MM"` 字符串(而非 datetime):旧库该两列是"时分语义",date 部分无业务含义。 type CreateClassReq struct { // ClassID 班级 ID(旧库外部班级 id;0 = 不指定)。 ClassID int64 `json:"classId"` // TeacherID 老师 ULID(可空:空 = 未定老师;给了才会做 3122 冲突判定)。 TeacherID string `json:"teacherId" validate:"max=26"` // Week 周规则("-1"/"0".."6"/逗号多值;空 → "-1")。 Week string `json:"week" validate:"max=50"` // Start 上课时分(`"09:00"`;**必填**)。 Start string `json:"start" validate:"required,max=5"` // End 下课时分(`"10:30"`;可空 → 按课程 duration 推算;给了必须晚于 Start)。 End string `json:"end" validate:"max=5"` // StandardLessonCost 课程标准单价(元字符串;可空 → "0.00")。 StandardLessonCost string `json:"standardLessonCost"` // LessonCost 一次课课费(元字符串;可空 → "0.00";打卡扣课时依据)。 LessonCost string `json:"lessonCost"` // DiscountCash 课程优惠(元字符串;允许负)。 DiscountCash string `json:"discountCash"` // Number 开课总量(课次;>=0)。 Number int32 `json:"number"` // Address 上课地址。 Address string `json:"address"` // Remark 备注。 Remark string `json:"remark"` } // ---------------------------------------------------------------- 端点 49:生成课表 // GenerateRecordsReq 端点 49 生成课表请求体(`POST /admin/courses/:id/records/generate`)。 // // 两个字段都可空(空 → days=7、from=今天),但**传了必须合法**(非法 → 2002)。 type GenerateRecordsReq struct { // Days 生成窗口天数(<=0 → 7;>366 → 钳制 366)。 Days int `json:"days"` // From 窗口起点(`2006-01-02` 或 RFC3339;只取日期部分;空 → 服务端当天)。 From string `json:"from"` } // ---------------------------------------------------------------- 端点 51 / 52:打卡明细 // AttendanceItemReq 单条打卡明细(端点 52 请求 `list[]` 元素,端点 51 的 `items[]` 同构)。 // // 字段 → 目标列(t_courses_record_student): // // userId→student_id / type→type / pay→pay / cost→cost / giveCost→give_cost / remark→remark // // ⚠ `pay` 是**是否已支付**(int8 0/1),不是金额;金额是 `cost`。契约 §3.4 C 行 52 明确 // 请求体为 `list:[{userId,type,pay,cost,giveCost}]`(mock 旧名 `attendance/payCost` 已废弃)。 type AttendanceItemReq struct { // UserID 学生 ULID(必填)。 UserID string `json:"userId" validate:"required,max=26"` // Type 上课类型(1 正常 / 2 插班 / 3 赠送;0 → 1;其它 → 2002)。 Type int32 `json:"type"` // Pay 是否已支付(0 未付 / 1 已付;nil = 不改,结课时按"已报课即已付")。 Pay *int8 `json:"pay"` // Cost 本次课费(元字符串;可空 = 取报课记录的 lesson_cost 快照)。 Cost string `json:"cost"` // GiveCost 本次使用赠送额(元字符串;可空 → 0)。 GiveCost string `json:"giveCost"` // Remark 备注。 Remark string `json:"remark"` } // MarkAttendanceReq 端点 52 打卡请求体(`POST /admin/courses/records/:recordId/attendance`)。 type MarkAttendanceReq struct { // List 打卡明细(必填非空;同一学生出现两次 → 3140)。 List []AttendanceItemReq `json:"list" validate:"required,min=1,dive"` } // FinishRecordReq 端点 51 结束上课请求体(`POST /admin/courses/records/:recordId/finish`)。 type FinishRecordReq struct { // Items 本次打卡明细(**可空**:空 = 按在读学生全勤自动生成,成本取 lesson_cost 快照)。 Items []AttendanceItemReq `json:"items"` // ActualEndTime 实际下课时间(RFC3339 或 `2006-01-02`;空 → 服务端当前时刻)。 ActualEndTime string `json:"actualEndTime"` // RewardType 课酬结算方式(0 → 1 正常 / 2 按原课程老师课酬无最低课酬;其它 → 2002)。 RewardType int8 `json:"rewardType"` // Remark 结课备注(当前仅日志展示,不落库——`t_courses_record.remark` 属排班备注)。 Remark string `json:"remark"` } // ---------------------------------------------------------------- 端点 54:报名 // EnrollReq 端点 54 报名请求体(`POST /admin/courses/:id/students`)。 // // ⚠ **契约 §2.4 端点 54 只给了响应字段,没有入参形状** ⇒ 本请求体是 AC-13c 自定义的 // 最小可对账集合(诊断见 `service.EnrollInput` 注释),已在交付报告登记为「契约缺口待补」。 type EnrollReq struct { // UserID 学生 ULID(必填)。 UserID string `json:"userId" validate:"required,max=26"` // ClassID 班级 ID(0 = 不指定)。 ClassID int64 `json:"classId"` // CampusID 校区(0 = 用作用域校区;>0 且与作用域校区不一致 → 1004)。 CampusID int64 `json:"campusId"` // Pay 本次实收(元字符串,必填;**不写资金流水**,只做报课行快照)。 Pay string `json:"pay" validate:"required"` // RemainderReceivable 剩余应收(元字符串;可空 → 0 = 全额缴清)。 RemainderReceivable string `json:"remainderReceivable"` // LessonCost 单节课费(元字符串,必填;扣课时依据)。 LessonCost string `json:"lessonCost" validate:"required"` // Number 课次(>=0)。 Number int32 `json:"number"` // GiveNumber 赠送课次(>=0)。 GiveNumber int32 `json:"giveNumber"` // DiscountCash 现金折扣(元字符串;允许负)。 DiscountCash string `json:"discountCash"` // GiveCost 已用赠送额(元字符串;可空 → 0)。 GiveCost string `json:"giveCost"` // RecommenderUserID 推荐人 ULID(可空;分账上行起点)。 RecommenderUserID string `json:"recommenderUserId" validate:"max=26"` // Remark 备注。 Remark string `json:"remark"` } // ---------------------------------------------------------------- 端点 56:欠费核销 // CounteractArrearsReq 端点 56 欠费核销请求体(`POST /admin/courses/arrears/counteract`)。 // // 契约 §2.4 行 56 原文:`studentId,institutionId,campusId,money`(**四个都必填**)。 // `institutionId`/`campusId` 是 SP27 的**显式数据范围参数**(不是作用域推断), // 因此非超管传了与本作用域不一致的值 → **1004**(不是 2002)。 type CounteractArrearsReq struct { // StudentID 学生 ULID(必填)。 StudentID string `json:"studentId" validate:"required,max=26"` // InstitutionID 机构 ID(必填 >0)。 InstitutionID int64 `json:"institutionId" validate:"required,gt=0"` // CampusID 校区 ID(必填 >0)。 CampusID int64 `json:"campusId" validate:"required,gt=0"` // Money 待核销金额(元字符串,必填;<=0 → 服务端 no-op,不是错误)。 Money string `json:"money" validate:"required"` } // ---------------------------------------------------------------- 响应拼装(只放契约要求的键) // courseSavedResp 端点 45 响应(契约 §3.4 C 行 45:只要求 `id`)。 type courseSavedResp struct { // ID 课程 ID。 ID int64 `json:"id"` } // courseRowView 端点 44 的行(契约 §3.4 C 行 44)。 // // 键名 `classify`(不是 `classifyId`)是**契约既定**:前端按 `classify` 读,改名会静默空白。 type courseRowView struct { // ID 课程 ID。 ID int64 `json:"id"` // InstitutionID 机构 ID。 InstitutionID int64 `json:"institutionId"` // Name 课程名称。 Name string `json:"name"` // Classify 课程分类 ID(源列 `classify_id`;契约键名 `classify`)。 Classify int64 `json:"classify"` // Status 课程状态(0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。 Status int32 `json:"status"` // TeacherIDs 排班规则上的老师(去重升序;空 → [])。 TeacherIDs []string `json:"teacherIds"` // RecordCount 排班实例条数(全状态)。 RecordCount int64 `json:"recordCount"` } // classCreatedResp 端点 47 响应(契约 §3.4 C 行 47:`classId`)。 type classCreatedResp struct { // ClassID 新规则的班级 ID(`t_courses_class.class_id`)。 ClassID int64 `json:"classId"` } // classRowView 端点 46 的行(契约 §3.4 C 行 46 + 本批两处偏差)。 // // ⚠ 与契约的两处差异(已在交付报告登记): // // ① `week` 下发**原值字符串**而不是 1~7 的数字:该列取值 `"-1"`(全部周)/`"0"`..`"6"`/ // 逗号多值,"1~7 数字"表达不了「全部周」与「多值」; // ② **无 `room`**:`t_courses_class` 没有 room 列(只有 address/remark),按 §3.4 ③ 不下发。 type classRowView struct { // ClassID 班级 ID(源列 `class_id`)。 ClassID int64 `json:"classId"` // CourseID 课程 ID。 CourseID int64 `json:"courseId"` // TeacherID 老师 ULID(可空 → 空串)。 TeacherID string `json:"teacherId"` // Week 周规则原值("-1"/"0".."6"/逗号多值)。 Week string `json:"week"` // Start 上课时分(**date 部分恒 1970-01-01**,只取时分;见 model.CoursesClass 注释)。 Start *time.Time `json:"start"` // End 下课时分(可空)。 End *time.Time `json:"end"` // CampusID 校区 ID。 CampusID int64 `json:"campusId"` } // recordRowView 端点 48 的行(契约 §3.4 C 行 48)。 // // ⚠ **无 `room`**(同端点 46:`t_courses_record` 只有 `address`,没有 room 列)。 type recordRowView struct { // RecordID 排班实例 ID。 RecordID int64 `json:"recordId"` // CourseID 课程 ID。 CourseID int64 `json:"courseId"` // TeacherID 老师 ULID。 TeacherID string `json:"teacherId"` // CampusID 校区 ID。 CampusID int64 `json:"campusId"` // StartTime 上课时间。 StartTime *time.Time `json:"startTime"` // EndTime 下课时间。 EndTime *time.Time `json:"endTime"` // Status 上课状态(0 未开始 / 1 上课中 / 2 已结束)。 Status int32 `json:"status"` // StudentCount 上课人数(源列 `student_number`,结课时写入)。 StudentCount int32 `json:"studentCount"` } // generateRecordsResp 端点 49 响应(契约 §3.4 C 行 49)。 type generateRecordsResp struct { // Generated 本次新建的排班实例条数(幂等:第二次为 0)。 Generated int `json:"generated"` // Days 生效的生成窗口天数(已钳制)。 Days int `json:"days"` // Changed 本次是否真的写了库(false = 无新增)。 Changed bool `json:"changed"` } // startRecordResp 端点 50 响应(契约 §3.4 C 行 50)。 type startRecordResp struct { // RecordID 排班实例 ID。 RecordID int64 `json:"recordId"` // Status 新状态(恒 1 = 上课中)。 Status int32 `json:"status"` // CourseID 课程 ID。 CourseID int64 `json:"courseId"` } // attendanceResp 端点 52 响应(契约 §3.4 C 行 52:`recordId` + `checked`)。 type attendanceResp struct { // RecordID 排班实例 ID。 RecordID int64 `json:"recordId"` // Checked 当前打卡人数(= 该实例的打卡行总数,含此前已打卡的)。 Checked int32 `json:"checked"` } // divideGrantView 端点 51 `divide[]` 的元素(契约 §3.4 C 行 51 去掉无列字段 `level`)。 type divideGrantView struct { // UserID 获得分成的用户(源列 `t_user_divide_into_log.user_id`;契约未列,用于区分元素)。 UserID string `json:"userId"` // Quota 本次分成额度(源列 `t_user_divide_into_log.money`,入账当时即节点 quota)。 Quota string `json:"quota"` // Amount 本次入账金额(同上列)。 Amount string `json:"amount"` } // payrollTraceView 端点 51 `payroll` 对象。 type payrollTraceView struct { // DetailID 命中的课酬规则 ID(0 = 未配置方案/无法定位)。 DetailID int64 `json:"detailId"` // Reward 本课课酬合计(源列 `t_courses_record.teacher_reward`,**落库值**)。 Reward string `json:"reward"` } // perStudentView 端点 51 `perStudent[]` 的元素(契约 §3.4 C 行 51,字段名与端点 52 请求同义)。 type perStudentView struct { // UserID 学生 ULID。 UserID string `json:"userId"` // Type 上课类型(1 正常 / 2 插班 / 3 赠送)。 Type int32 `json:"type"` // Pay 是否已支付。 Pay bool `json:"pay"` // Cost 本次课费。 Cost string `json:"cost"` // GiveCost 本次使用赠送额。 GiveCost string `json:"giveCost"` // Counteracted 该学生本次是否触发了分账入账(源列:本次是否新增 // `t_user_divide_into_log` 行;**不是**欠费核销,契约命名与语义在报告登记)。 Counteracted bool `json:"counteracted"` } // finishRecordResp 端点 51 响应(契约 §3.4 C 行 51)。 type finishRecordResp struct { // RecordID 排班实例 ID。 RecordID int64 `json:"recordId"` // CourseID 课程 ID。 CourseID int64 `json:"courseId"` // Status 上课状态(恒 2 = 已结束)。 Status int32 `json:"status"` // Changed 本次是否真的写了库(false = 幂等命中:未重复扣课时/未重复分账)。 Changed bool `json:"changed"` // TotalSpend 本次扣课时合计(= `t_courses_record.income` = Σ record_student.cost)。 TotalSpend string `json:"totalSpend"` // Divide 本次分账入账明细(空 → [])。 Divide []divideGrantView `json:"divide"` // Payroll 课酬溯源。 Payroll payrollTraceView `json:"payroll"` // PerStudent 逐学生打卡明细(空 → [])。 PerStudent []perStudentView `json:"perStudent"` } // studentRowView 端点 53 的行(契约 §3.4 C 行 53)。 // // ⚠ **无 `realName`**:姓名在 identity 域(`t_user`),academic 域不持有、跨域只读要经 port, // 本批未注入姓名解析器 ⇒ 不下发(空字符串会让前端显示空白且无法区分"未填"与"查不到")。 type studentRowView struct { // UserID 学生 ULID(源列 `student_id`)。 UserID string `json:"userId"` // Pay 应收(源列 `receivable`)。 Pay string `json:"pay"` // Cost 实收(源列 `net_receipts`)。 Cost string `json:"cost"` // GiveCost 已用赠送额(源列 `give_cost`)。 GiveCost string `json:"giveCost"` // RemainderReceivable 剩余应收(源列 `remainder_receivable`,**欠费真值**)。 RemainderReceivable string `json:"remainderReceivable"` // Status 报课状态(1 正常 / 2 结束,数字码)。 Status int32 `json:"status"` } // enrollResp 端点 54 响应(契约 §3.4 C 行 54)。 type enrollResp struct { // Enrolled 是否已报名(成功恒 true)。 Enrolled bool `json:"enrolled"` // CourseID 课程 ID。 CourseID int64 `json:"courseId"` // UserID 学生 ULID。 UserID string `json:"userId"` } // arrearsRowView 端点 55 的行(契约 §3.4 C 行 55)。 type arrearsRowView struct { // StudentID 学生 ULID。 StudentID string `json:"studentId"` // CourseID 课程 ID。 CourseID int64 `json:"courseId"` // RemainderReceivable 剩余应收(>0;**唯一欠费口径**)。 RemainderReceivable string `json:"remainderReceivable"` // Counteracted 该报课行是否发生过实收/核销(源列 `net_receipts > 0`;契约未定义判据,见报告)。 Counteracted bool `json:"counteracted"` } // counteractResp 端点 56 响应(契约 §3.4 C 行 56)。 type counteractResp struct { // Counteracted 本次是否真的核销了金额(false = 无欠费/金额<=0,服务端 no-op)。 Counteracted bool `json:"counteracted"` // StudentID 学生 ULID。 StudentID string `json:"studentId"` // Amount 实际核销总额(元;未核销 → "0.00")。 Amount string `json:"amount"` } // scheduleRowView 端点 57 的行(**字段名待移动端契约定稿**,见 service.ScheduleRow 注释)。 type scheduleRowView struct { // RecordID 排班实例 ID。 RecordID int64 `json:"recordId"` // CourseID 课程 ID。 CourseID int64 `json:"courseId"` // CourseName 课程名称(回查 t_courses.name)。 CourseName string `json:"courseName"` // TeacherID 老师 ULID。 TeacherID string `json:"teacherId"` // CampusID 校区 ID。 CampusID int64 `json:"campusId"` // StartTime 上课时间。 StartTime *time.Time `json:"startTime"` // EndTime 下课时间。 EndTime *time.Time `json:"endTime"` // Status 上课状态(0/1/2)。 Status int32 `json:"status"` // Cost 本次课费(无打卡行 → "0.00")。 Cost string `json:"cost"` // GiveCost 本次使用赠送额(无打卡行 → "0.00")。 GiveCost string `json:"giveCost"` // Pay 是否已支付(无打卡行 → false)。 Pay bool `json:"pay"` // Checked 是否已有本人的打卡行。 Checked bool `json:"checked"` }