// 课程域**补充服务**(端点 44~57 的读装配 / 报名写入 / 结课响应装配)——AC-13c 追加。 // // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.4(端点表)/ // §3.4 C 表(44~57 字段级定义)/ §3.4 总原则 ③「字段准入条件」/ §3.8-E4(`unpaid` 废弃列); // 设计 `academic-domain-design.md` §3.5(SP27 欠费核销)/ §4.4(端点清单)。 // // --------------------------------------------------------------------------- // 本文件与 AC-11 的边界(**只新增,不改既有逻辑**) // --------------------------------------------------------------------------- // // AC-11 交付的 `course_service.go` / `record_service.go` / `maintenance_service.go` **一行未改**。 // 契约要求的下列能力在 AC-11 没有对应方法,故在此**新增**: // // 端点 44 ListCourseRows teacherIds(t_courses_class)+ recordCount(t_courses_record) // 端点 46 ListClassRows week 原值下发(**不改写成 1~7 数字**,理由见 ClassRow.Week) // 端点 47 AssertNoClassConflict 冲突 → 3122(AC-11 的 CreateClass 无冲突判定) // 端点 53 ListStudentRows 报课学生分页(AC-11 只有"取全部在读") // 端点 54 Enroll 报课写入(AC-11 无报课入口) // 端点 55 ListArrearsRows 跨课程欠费分页(AC-11 只有"按单学生取候选") // 端点 57 ListScheduleRows 学生课表(record ⨝ courses_student) // 端点 51 RecordViewService 结课响应装配(divide[] / payroll 溯源列,**只读、不进结账事务**) // // ⚠ 这些是"补 handler 所需的数据能力",不是"修 AC-11 的 bug":AC-11 的服务方法本身 // 在其声明范围内行为正确(已由 `course_service_test.go` / `record_service_test.go` 锁定)。 // // --------------------------------------------------------------------------- // 三条量纲/语义红线(本文件逐条落地) // --------------------------------------------------------------------------- // // 1. 金额一律 `money.Amount` 或两位小数字符串,**不出现 float64**; // 2. 欠费判据**只有** `remainder_receivable`(`unpaid` 是 2018-05 停更的僵尸列,禁读禁判); // 3. 时间窗 `from` 左闭 / `to` 右开(契约 §1.3)。 package service import ( "context" "strconv" "strings" "time" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/apperr" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" "yuansiwei/unified-backend/internal/academic/repository" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // --------------------------------------------------------------------------- // 端点 44:课程列表(含 teacherIds / recordCount) // --------------------------------------------------------------------------- // CourseRow 端点 44 的行(契约 §3.4 C 行 44:`id/institutionId/name/classify/status/teacherIds/recordCount`)。 // // 字段来源(**逐字段列溯源**,契约 §3.4 ③): // // ID ← t_courses.id // InstitutionID ← t_courses.institution_id // Name ← t_courses.name // ClassifyID ← t_courses.classify_id(契约名 `classify`) // Status ← t_courses.status // TeacherIDs ← t_courses_class.teacher_id(去重、升序;空 → []) // RecordCount ← COUNT(t_courses_record)(全状态,见 repository.CountRecordsByCourses 注释) type CourseRow struct { // ID 课程 ID。 ID int64 // InstitutionID 机构 ID(作用域列)。 InstitutionID int64 // Name 课程名称。 Name string // ClassifyID 课程分类 ID(契约 JSON 名 `classify`)。 ClassifyID int64 // Status 课程状态(0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。 Status int32 // TeacherIDs 该课程排班规则涉及的老师(去重升序;无规则 → 空切片)。 TeacherIDs []string // RecordCount 该课程的排班实例条数(全状态)。 RecordCount int64 } // CourseListQuery 端点 44 的查询入参(**Handler 只依赖本层类型**,不直接看到 repository 类型)。 // // 字段:ClassifyID 分类过滤(<=0 不筛);Status 状态过滤(nil 不筛);Keyword 名称模糊; // Page/PageSize 分页(服务层钳制,上限 200)。 type CourseListQuery struct { // ClassifyID 课程分类 ID(<=0 不筛)。 ClassifyID int64 // Status 课程状态(nil 不筛;0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束)。 Status *int32 // Keyword 课程名称模糊匹配(空不筛)。 Keyword string // Page 页码(1 基;<1 → 1)。 Page int // PageSize 每页条数(<1 → 10;>200 → 200)。 PageSize int } // ListCourseRows 分页查询课程列表并补齐 `teacherIds` / `recordCount`(端点 44)。 // // 入参:ctx;q 查询入参。 // 出参:([]CourseRow, total, error);空结果为**非 nil 空切片**;5002。 // // 两次批量查询而不是逐行查(一页最多 200 门课程,逐行 = 400 次往返)。 func (s *CourseService) ListCourseRows(ctx context.Context, q CourseListQuery) ([]CourseRow, int64, error) { courses, total, err := s.courses.ListCourses(ctx, repository.CourseFilter{ ClassifyID: q.ClassifyID, Status: q.Status, Keyword: q.Keyword, }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize}) if err != nil { return nil, 0, err } rows := make([]CourseRow, 0, len(courses)) if len(courses) == 0 { return rows, total, nil } ids := make([]int64, 0, len(courses)) for i := range courses { ids = append(ids, courses[i].ID) } teachers, err := s.courses.ListTeacherIDsByCourses(ctx, ids) if err != nil { return nil, 0, err } counts, err := s.courses.CountRecordsByCourses(ctx, ids) if err != nil { return nil, 0, err } for i := range courses { c := &courses[i] tids := teachers[c.ID] if tids == nil { tids = make([]string, 0) } rows = append(rows, CourseRow{ ID: c.ID, InstitutionID: c.InstitutionID, Name: c.Name, ClassifyID: c.ClassifyID, Status: c.Status, TeacherIDs: tids, RecordCount: counts[c.ID], }) } return rows, total, nil } // --------------------------------------------------------------------------- // 端点 46 / 47:排班规则(t_courses_class) // --------------------------------------------------------------------------- // ClassRow 端点 46 的行(契约 §3.4 C 行 46)。 // // 字段来源: // // ClassID ← t_courses_class.class_id(**不是主键 id**:契约名是 `classId`,且旧库该列是外部班级 id) // CourseID ← t_courses_class.courses_id // TeacherID ← t_courses_class.teacher_id(NULL → 空串,契约 §1.5 用户 ID 是字符串) // Week ← t_courses_class.week(**原值字符串**) // Start ← t_courses_class.start_time(时分语义,1970-01-01 + 时分) // End ← t_courses_class.end_time(同上;可空) // CampusID ← t_courses_class.campus_id // // ⚠ **两处契约冲突(已登记,本实现按"列口径"落地,报告见交付说明)**: // // 1. 契约写 `week:number`「周日序(1~7)」。该列实际是**字符串**,取值 `"-1"`(全部周)/ // `"0"`..`"6"` / 逗号多值(`"1,3,5"`)。数字 1~7 **表达不了**「全部周」与「多值」—— // 按契约发 number 会让最常见的 `-1` 与任意多值规则**静默失真**(前端把"每天"显示成某一天)。 // 因此下发**原值字符串**;若前端确需数字,须先在契约里定义 `-1`/多值如何编码。 // 2. 契约写 `room:string`。**`t_courses_class` 没有 room 列**(只有 `address` / `remark`)。 // 按 §3.4 ③「找不到列的一律不写」⇒ **不下发 `room`**,也**不用 `address` 顶替** // (地址 ≠ 教室,顶替会让前端把"上课地点"当"教室号"展示)。 type ClassRow struct { // ClassID 班级 ID(`t_courses_class.class_id`)。 ClassID int64 // CourseID 课程 ID。 CourseID int64 // TeacherID 老师 ULID(NULL → 空串)。 TeacherID string // Week 周规则原值("-1" 全部周 / "0" 周日 / "1".."6" / 逗号多值)。 Week string // Start 上课时分(date 部分无业务含义,恒 1970-01-01)。 Start *time.Time // End 下课时分(可空 = 未配置)。 End *time.Time // CampusID 校区 ID(0 = 机构级)。 CampusID int64 } // ListClassRows 取某课程的全部排班规则(端点 46,**不分页**——见 Handler 的分页说明)。 // // 入参:ctx;coursesID(<=0 → 2001)。 // 出参:[]ClassRow(非 nil);3001(课程不存在/不在作用域)/5002。 func (s *CourseService) ListClassRows(ctx context.Context, coursesID int64) ([]ClassRow, error) { classes, err := s.ListClasses(ctx, coursesID) if err != nil { return nil, err } rows := make([]ClassRow, 0, len(classes)) for i := range classes { c := &classes[i] rows = append(rows, ClassRow{ ClassID: c.ClassID, CourseID: c.CoursesID, TeacherID: strValue(c.TeacherID), Week: c.Week, Start: c.StartTime, End: c.EndTime, CampusID: c.CampusID, }) } return rows, nil } // classSlot 排班时间槽(冲突判定的比较单位)。 // // 字段:teacher 老师 ULID;campusID 校区;week 周规则;startMin/endMin 当日起止分钟数。 type classSlot struct { teacher string campusID int64 week weekRule startMin int endMin int } // AssertNoClassConflict 新建排班规则前的冲突判定(端点 47,冲突 → **3122**)。 // // - 入参:ctx;in 与 `CreateClass` 同形状的入参(只读用:coursesID/teacherID/week/start/end)。 // - 出参:nil 无冲突;apperr.ErrScheduleConflict(3122) 冲突;2002 时分格式非法;5002。 // // 校区来源(**不取入参**):`CreateClassInput` 没有 `CampusID` 字段——新建规则的校区恒取 // **作用域校区**(`repository.GetScopedID`)。因此冲突判定也必须以作用域校区为准, // 否则一条"给校区 10 排课"的请求会按"校区 0"去比、把真冲突漏掉。 // 超管(作用域校区 = 0,可跨校区排课)**不做校区收敛**:保守地在全部校区里查同一老师的冲突 // (宁可报一次 3122 让人确认,也不放过"同一老师同一时刻在两个校区"这种多半是误操作的情形)。 func (s *CourseService) AssertNoClassConflict(ctx context.Context, in CreateClassInput) error { teacher := strings.TrimSpace(in.TeacherID) if teacher == "" { return nil } _, scopeCampusID := s.courses.GetScopedID() target, err := buildClassSlot(teacher, scopeCampusID, in.Week, in.StartTime, in.EndTime) if err != nil { return err } existing, err := s.courses.ListClassesByCourses(ctx, in.CoursesID, false) if err != nil { return err } for i := range existing { cur := &existing[i] if strValue(cur.TeacherID) != teacher { continue } slot, serr := buildClassSlot(teacher, cur.CampusID, cur.Week, hmString(cur.StartTime), hmString(cur.EndTime)) if serr != nil { // 存量规则的 week/时分写坏:**不阻断新建**(否则一个脏行会让该课程永久无法排班), // 但也不能静默当作"无冲突"——用 5002 暴露给运维(脏数据必须被看见)。 return serr } if slot.campusID != target.campusID && target.campusID != 0 { continue // 作用域校区 >0:只与本校区比;=0(超管):跨校区全查 } if !weekRulesIntersect(slot.week, target.week) { continue } if !slotsOverlap(slot, target) { continue } return infraerrors.WithCause(apperr.ErrScheduleConflict, errString("service: 老师在该时段已有排班规则(classId="+strconv.FormatInt(cur.ClassID, 10)+")")) } return nil } // buildClassSlot 把一条排班规则(或其入参)转成可比较的时间槽。 // // 入参:teacher;campusID;week 周规则;startRaw/endRaw 时分("HH:MM",start 必填)。 // 出参:classSlot;2002(week 非法 / 时分格式非法)。 func buildClassSlot(teacher string, campusID int64, week, startRaw, endRaw string) (classSlot, error) { rule, err := parseWeekRule(week) if err != nil { return classSlot{}, err } start, err := parseHHMM(startRaw, "上课时间") if err != nil { return classSlot{}, err } slot := classSlot{ teacher: strings.TrimSpace(teacher), campusID: campusID, week: rule, startMin: start.Hour()*60 + start.Minute(), } if strings.TrimSpace(endRaw) != "" { end, eerr := parseHHMM(endRaw, "下课时间") if eerr != nil { return classSlot{}, eerr } slot.endMin = end.Hour()*60 + end.Minute() } return slot, nil } // weekRulesIntersect 判断两条周规则是否有交集(`-1` 表示全部周)。 // // 入参:a/b weekRule。出参:true 有交集。 func weekRulesIntersect(a, b weekRule) bool { if a.all || b.all { return true } for day := 0; day < 7; day++ { if a.days[day] && b.days[day] { return true } } return false } // slotsOverlap 判断两个时间槽是否重叠(`[start,end)` 半开区间;end 缺失按"点"处理)。 // // 入参:a/b classSlot(调用方已保证同老师、同校区、周规则有交集)。 // 出参:true 重叠。 func slotsOverlap(a, b classSlot) bool { if a.endMin <= a.startMin && b.endMin <= b.startMin { return a.startMin == b.startMin // 双方都没有下课时间:只能按"同一时刻"判 } aEnd := a.endMin if aEnd <= a.startMin { aEnd = a.startMin + 1 // 单侧缺失:按 1 分钟占位,避免"缺 end 就永不冲突" } bEnd := b.endMin if bEnd <= b.startMin { bEnd = b.startMin + 1 } return a.startMin < bEnd && b.startMin < aEnd } // hmString 把时分列转 "HH:MM"(nil → 空串)。 // // 入参:t 时分列(1970-01-01 + 时分)。出参:`"09:30"` 形态。 func hmString(t *time.Time) string { if t == nil { return "" } return twoDigits(t.Hour()) + ":" + twoDigits(t.Minute()) } // twoDigits 两位左补零(避免引入 fmt 开销与格式串分散)。 func twoDigits(v int) string { if v < 10 { return "0" + strconv.Itoa(v) } return strconv.Itoa(v) } // --------------------------------------------------------------------------- // 端点 53:报课学生列表 // --------------------------------------------------------------------------- // StudentRow 端点 53 的行(契约 §3.4 C 行 53)。 // // 字段来源: // // UserID ← t_courses_student.student_id // RealName ← **不下发**(姓名在 identity 域,academic 不持有 t_user;契约 §3.4 C 写 // `realName:string`,但跨域只读须经 port ⇒ 本批留空不下发,登记偏差) // Pay ← t_courses_student.receivable(应收) // Cost ← t_courses_student.net_receipts(实收) // GiveCost ← t_courses_student.give_cost(已用赠送额) // RemainderReceivable ← t_courses_student.remainder_receivable(剩余应收 = **欠费真值**) // Status ← t_courses_student.status(1 正常 / 2 结束,数字码) type StudentRow struct { // StudentCourseID 报课行 ID(`t_courses_student.id`;契约未列,内部对账用,不下发)。 StudentCourseID int64 // UserID 学生 ULID。 UserID string // Pay 应收(`receivable`)。 Pay money.Amount // Cost 实收(`net_receipts`)。 Cost money.Amount // GiveCost 已用赠送额(`give_cost`)。 GiveCost money.Amount // RemainderReceivable 剩余应收(`remainder_receivable`,欠费真值)。 RemainderReceivable money.Amount // Status 报课状态(1 正常 / 2 结束)。 Status int32 } // StudentListQuery 端点 53 的查询入参。 // // 字段:CoursesID 课程(必填 >0);Status 报课状态(nil 不筛);Page/PageSize 分页。 type StudentListQuery struct { // CoursesID 课程 ID(必填 >0)。 CoursesID int64 // Status 报课状态(nil 不筛;1 正常 / 2 结束)。 Status *int32 // Page 页码(1 基)。 Page int // PageSize 每页条数(上限 200)。 PageSize int } // ListStudentRows 分页查询报课学生(端点 53)。 // // 入参:ctx;q 查询入参。 // 出参:([]StudentRow, total, error);3001(课程不存在/不在作用域)/5002。 // 空结果返回 `[]`(不是 3001:课程存在但没人报名是正常空态)。 func (s *CourseService) ListStudentRows(ctx context.Context, q StudentListQuery) ([]StudentRow, int64, error) { coursesID := q.CoursesID if coursesID <= 0 { return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0")) } if _, err := s.courses.GetCourseByID(ctx, coursesID); err != nil { return nil, 0, err // 3001 } var st *int8 if q.Status != nil { v := int8(*q.Status) st = &v } rows, total, err := s.courses.ListCoursesStudents(ctx, repository.CoursesStudentFilter{ CoursesID: coursesID, Status: st, }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize}) if err != nil { return nil, 0, err } out := make([]StudentRow, 0, len(rows)) for i := range rows { row := &rows[i] pay, err := parseAmount(row.Receivable) if err != nil { return nil, 0, err } cost, err := parseAmount(row.NetReceipts) if err != nil { return nil, 0, err } give, err := parseAmount(row.GiveCost) if err != nil { return nil, 0, err } remainder, err := parseAmount(row.RemainderReceivable) if err != nil { return nil, 0, err } out = append(out, StudentRow{ StudentCourseID: row.ID, UserID: row.StudentID, Pay: pay, Cost: cost, GiveCost: give, RemainderReceivable: remainder, // ⚠ 欠费列:**只读本列**,不读 unpaid(§3.8-E4) Status: int32(row.Status), }) } return out, total, nil } // --------------------------------------------------------------------------- // 端点 54:报名 / 补报名 // --------------------------------------------------------------------------- // EnrollInput 报名入参(端点 54)。 // // ⚠ **契约 §2.4 端点 54 只给了响应字段(`enrolled/courseId/userId`),没有入参形状** ⇒ // 本入参是 AC-13c 自定义的**最小可对账集合**,已在交付报告登记为「契约缺口待补」。 // // 金额口径(**只做快照,不动任何资金账户**): // // net_receipts = Pay(本次实收) // receivable = Pay + RemainderReceivable(应收恒等式:应收 = 实收 + 剩余应收) // remainder_receivable= RemainderReceivable(默认为 0 ⇒ 全额缴清) // remainder_cost = Pay(尚未消耗) // remainder_number = Number + GiveNumber - 0 // lesson_cost = LessonCost(单节课费快照,扣课时依据) // // **为什么不写资金流水**:报课行是"应收/实收快照",真实收款走财务域端点 3(充值)/4(消费), // 本端点若凭空写流水会产生"没有来源的余额变动",破坏 AC-17 的余额恒等式。 type EnrollInput struct { // CoursesID 课程 ID(路径 `:id`)。 CoursesID int64 // StudentID 学生 ULID(必填)。 StudentID string // ClassID 班级 ID(0 = 不指定)。 ClassID int64 // CampusID 校区(0 = 用作用域校区;>0 且与作用域校区不一致 → 1004)。 CampusID int64 // Pay 本次实收(元字符串,必填 ≥0)。 Pay string // RemainderReceivable 剩余应收(元字符串,可空 → 0)。 RemainderReceivable string // LessonCost 单节课费(元字符串,必填;扣课时依据)。 LessonCost string // Number 课次(>=0)。 Number int32 // GiveNumber 赠送课次(>=0,可空 → 0)。 GiveNumber int32 // DiscountCash 现金折扣(元字符串,可空 → 0;**允许负**,模型红线)。 DiscountCash string // GiveCost 已用赠送额(元字符串,可空 → 0)。 GiveCost string // RecommenderUserID 推荐人(ULID,可空;分账上行起点)。 RecommenderUserID string // Remark 备注。 Remark string // OperatorID 操作人(作用域用户;写 confirm_discount_user_id —— 见 Enroll 注释)。 OperatorID string } // Enroll 报名 / 补报名(端点 54)。 // // 入参:ctx;in 入参。 // 出参:*model.CoursesStudent(含自增 ID);2001/2002(金额/课次非法)/ // 3001(课程不存在或不在作用域)/1004(跨校区)/3140(该学生已在本课程在读)/ // 5002(DB)。 // // 三条与"资金"相关的强制约束: // 1. **不写流水、不动余额**(见 EnrollInput 注释); // 2. `confirm_discount_user_id` 写**操作人**(模型红线:该列为空 = 该报课行不可充值/不可核销, // §3.8-A17 的 `confirm_discount_userid > 0` 判据在目标态即"本列非空")—— // 漏写会让这行**永远进不了欠费核销候选**,且没有任何报错; // 3. 重复报名(同课程 + 同学生 + status=1 已存在)**拒绝**(3140)而不是静默成功: // 静默成功会让前端以为建了两行,而实际只有一行(后续扣课时只扣一行,账对不上)。 func (s *CourseService) Enroll(ctx context.Context, in EnrollInput) (*model.CoursesStudent, error) { studentID := strings.TrimSpace(in.StudentID) if in.CoursesID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0")) } if studentID == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 报名需要 studentId")) } if in.Number < 0 || in.GiveNumber < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课次不能为负")) } pay, err := parseCourseMoney(in.Pay, "实收", false) if err != nil { return nil, err } if pay.IsZero() && strings.TrimSpace(in.Pay) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 报名需要 pay(本次实收)")) } remainder, err := parseCourseMoney(in.RemainderReceivable, "剩余应收", false) if err != nil { return nil, err } lessonCost, err := parseCourseMoney(in.LessonCost, "单节课费", false) if err != nil { return nil, err } if strings.TrimSpace(in.LessonCost) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 报名需要 lessonCost(单节课费)")) } discountCash, err := parseCourseMoney(in.DiscountCash, "现金折扣", true) if err != nil { return nil, err } giveCost, err := parseCourseMoney(in.GiveCost, "已用赠送额", false) if err != nil { return nil, err } institutionID, scopeCampusID := s.courses.GetScopedID() if institutionID <= 0 { return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 缺少机构作用域(institution_id)")) } campusID := in.CampusID if campusID <= 0 { campusID = scopeCampusID } else if scopeCampusID > 0 && campusID != scopeCampusID { // 跨校区报名:作用域校区角色只能给本校区报名(1004,不是 3001——这是**作用域越界**) return nil, infraerrors.WithCause(infraerrors.ErrUnscoped, errString("service: campusId 与当前作用域校区不一致")) } bizNo, berr := newBizNo() if berr != nil { return nil, berr } row := &model.CoursesStudent{ BizNo: bizNo, CoursesID: in.CoursesID, ClassID: in.ClassID, InstitutionID: institutionID, CampusID: campusID, StudentID: studentID, Type: model.CoursesStudentTypeNormal, Status: model.CoursesStudentStatusNormal, Receivable: pay.Add(remainder).String(), NetReceipts: pay.String(), Unpaid: "0.00", // ⚠ 废弃列:**有意写 0 且永不重算**(§3.8-E4 目标态不维护) LessonCost: lessonCost.String(), Number: in.Number, FinishedNumber: 0, DiscountCash: discountCash.String(), GiveNumber: in.GiveNumber, GiveCost: giveCost.String(), RemainderNumber: in.Number + in.GiveNumber, RemainderCost: pay.String(), RemainderReceivable: remainder.String(), BadDebt: "0.00", Remark: normalizeOptString(&in.Remark), } if uid := strings.TrimSpace(in.OperatorID); uid != "" { row.ConfirmDiscountUserID = &uid } if rid := strings.TrimSpace(in.RecommenderUserID); rid != "" { row.RecommenderUserID = &rid now := s.now() row.RecommenderTime = &now } err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) if _, cerr := s.courses.GetCourseByID(txCtx, in.CoursesID); cerr != nil { return cerr // 3001:课程不存在 / 不在作用域 } existing, cerr := s.courses.CountActiveEnrollments(txCtx, in.CoursesID, studentID) if cerr != nil { return cerr } if existing > 0 { return infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 该学生已在本课程在读报课,请勿重复报名")) } return s.courses.CreateCoursesStudent(txCtx, tx, row) }) if err != nil { return nil, err } return row, nil } // --------------------------------------------------------------------------- // 端点 55:欠费列表 // --------------------------------------------------------------------------- // ArrearsRow 端点 55 的行(契约 §3.4 C 行 55)。 // // 字段来源: // // StudentID ← t_courses_student.student_id // CourseID ← t_courses_student.courses_id // RemainderReceivable ← t_courses_student.remainder_receivable(**唯一欠费判据**) // Counteracted ← `net_receipts > 0`(该报课行发生过实收/核销) // // ⚠ `counteracted` 的语义澄清:契约只写 `counteracted:bool`,未定义判据;表里**没有** // "已核销"标记列,可追溯到列的最近语义是"该行发生过实收/核销"= `net_receipts > 0`。 // 另一种读法(`remainder_receivable <= 0`)在本列表里**恒 false**(列表本身就是 >0 的行), // 恒 false 的布尔字段等于没下发,故不采用。已登记偏差待契约确认。 type ArrearsRow struct { // StudentID 学生 ULID。 StudentID string // CourseID 课程 ID。 CourseID int64 // RemainderReceivable 剩余应收(> 0)。 RemainderReceivable money.Amount // Counteracted 该报课行是否发生过实收/核销(`net_receipts > 0`)。 Counteracted bool } // ArrearsListQuery 端点 55 的查询入参。 // // 字段:StudentID 学生过滤(空 = 全部欠费);Page/PageSize 分页。 type ArrearsListQuery struct { // StudentID 学生 ULID(空 = 不按学生筛)。 StudentID string // Page 页码(1 基)。 Page int // PageSize 每页条数(上限 200)。 PageSize int } // ListArrearsRows 分页查询欠费报课行(端点 55)。 // // 入参:ctx;q 查询入参。 // 出参:([]ArrearsRow, total, error);空 → `[]`;5002。 // // **筛选条件恒为 `remainder_receivable > 0`**(设计 §3.8-E4 / 契约 §2.4 端点 55 注)。 // 绝不能用 `unpaid > 0`:该列 2018-05-17 起停止更新,真实数据几乎恒 0 ⇒ 用它筛"看起来 // 正常、实际把所有欠费漏掉"(这正是本端点最容易写错、且**没有任何报错**的一处)。 func (s *CourseService) ListArrearsRows(ctx context.Context, q ArrearsListQuery) ([]ArrearsRow, int64, error) { rows, total, err := s.courses.ListCoursesStudents(ctx, repository.CoursesStudentFilter{ StudentID: q.StudentID, OnlyArrears: true, }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize}) if err != nil { return nil, 0, err } out := make([]ArrearsRow, 0, len(rows)) for i := range rows { row := &rows[i] remainder, err := parseAmount(row.RemainderReceivable) if err != nil { return nil, 0, err } netReceipts, err := parseAmount(row.NetReceipts) if err != nil { return nil, 0, err } out = append(out, ArrearsRow{ StudentID: row.StudentID, CourseID: row.CoursesID, RemainderReceivable: remainder, Counteracted: netReceipts.GreaterThan(0), }) } return out, total, nil } // --------------------------------------------------------------------------- // 端点 57:学生课表 // --------------------------------------------------------------------------- // ScheduleRow 端点 57 的行(**字段名待移动端契约定稿**——契约 §3.4 C 行 57 原文 // 「字段名由移动端契约定(本轮 PC mock 未实现)」⇒ 本结构是 AC-13c 的**最小列溯源集合**, // 每个字段都能追到列,已在交付报告登记为「待移动端契约确认」)。 // // 字段来源: // // RecordID ← t_courses_record.id // CourseID ← t_courses_record.courses_id // CourseName ← t_courses.name(批量回查) // TeacherID ← t_courses_record.teacher_id(NULL → 空串) // CampusID ← t_courses_record.campus_id // StartTime ← t_courses_record.start_time // EndTime ← t_courses_record.end_time // Status ← t_courses_record.status(0 未开始 / 1 上课中 / 2 已结束) // Cost ← t_courses_record_student.cost(无打卡行 → 0.00) // GiveCost ← t_courses_record_student.give_cost(无打卡行 → 0.00) // Pay ← t_courses_record_student.pay(无打卡行 → false) // Checked ← 是否存在该学生的打卡行(有行即 true,不看 cost) type ScheduleRow struct { // RecordID 排班实例 ID。 RecordID int64 // CourseID 课程 ID。 CourseID int64 // CourseName 课程名称(回查 t_courses;查不到 → 空串)。 CourseName string // TeacherID 老师 ULID(可空 → 空串)。 TeacherID string // CampusID 校区 ID。 CampusID int64 // StartTime 上课时间。 StartTime *time.Time // EndTime 下课时间。 EndTime *time.Time // Status 上课状态(0/1/2)。 Status int32 // Cost 本次课费(该学生的打卡行)。 Cost money.Amount // GiveCost 本次使用赠送额。 GiveCost money.Amount // Pay 是否已支付。 Pay bool // Checked 是否已有该学生的打卡行。 Checked bool } // ScheduleQuery 端点 57 的查询入参。 // // 字段:StudentID 学生(必填);From/To 上课时间窗(`from <= start_time < to`,右开;nil 不限); // Page/PageSize 分页。 type ScheduleQuery struct { // StudentID 学生 ULID(必填)。 StudentID string // From 窗口起点(含;nil 不限)。 From *time.Time // To 窗口终点(**不含**;nil 不限)。 To *time.Time // Page 页码(1 基)。 Page int // PageSize 每页条数(上限 200)。 PageSize int } // ListScheduleRows 查询某学生的课表(端点 57)。 // // 入参:ctx;q 查询入参。 // 出参:([]ScheduleRow, total, error);2001(缺 studentId)/5002。 func (s *CourseService) ListScheduleRows(ctx context.Context, q ScheduleQuery) ([]ScheduleRow, int64, error) { sid := strings.TrimSpace(q.StudentID) if sid == "" { return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课表需要 studentId")) } records, total, err := s.courses.ListStudentSchedule(ctx, sid, q.From, q.To, repository.PageQuery{Page: q.Page, PageSize: q.PageSize}) if err != nil { return nil, 0, err } out := make([]ScheduleRow, 0, len(records)) if len(records) == 0 { return out, total, nil } recordIDs := make([]int64, 0, len(records)) courseIDs := make([]int64, 0, len(records)) for i := range records { recordIDs = append(recordIDs, records[i].ID) courseIDs = append(courseIDs, records[i].CoursesID) } courses, err := s.courses.ListCoursesByIDs(ctx, courseIDs) if err != nil { return nil, 0, err } names := make(map[int64]string, len(courses)) for i := range courses { names[courses[i].ID] = courses[i].Name } checked, err := s.records.ListRecordStudentsOfStudent(ctx, sid, recordIDs) if err != nil { return nil, 0, err } for i := range records { rec := &records[i] row := ScheduleRow{ RecordID: rec.ID, CourseID: rec.CoursesID, CourseName: names[rec.CoursesID], TeacherID: strValue(rec.TeacherID), CampusID: rec.CampusID, StartTime: rec.StartTime, EndTime: rec.EndTime, Status: rec.Status, } if att, ok := checked[rec.ID]; ok { cost, cerr := parseAmount(att.Cost) if cerr != nil { return nil, 0, cerr } give, cerr := parseAmount(att.GiveCost) if cerr != nil { return nil, 0, cerr } row.Checked = true row.Cost = cost row.GiveCost = give row.Pay = att.Pay == model.CoursesRecordStudentPayPaid } out = append(out, row) } return out, total, nil } // --------------------------------------------------------------------------- // 端点 51:结课响应装配(只读,不进结账事务) // --------------------------------------------------------------------------- // DivideGrantRow 单条分账入账明细(端点 51 的 `divide[]`)——**team-lead 裁决后的最终形状** // `[{userId, type, amount}]`。 // // 字段来源(**逐字段列溯源**,契约 §3.4 ③): // // UserID ← t_user_divide_into_log.user_id(这笔上课的钱分给了谁) // Type ← t_user_divide_into_log.type(分成类型 1 一次付清 / 2 按课次) // Amount ← t_user_divide_into_log.money(本次入账金额) // // ⚠ **契约原 mock 的 `level` 与 `quota` 均不下发(团队裁决,2026-11-09)**: // // `level` —— `t_user_divide_into_log` **没有层级列**;`t_user_divide_into.layer` 是**当前**层级, // 不是"入账当时"的层级,拿当前值顶替历史值 = 「事后调整树结构 ⇒ 历史记录显示错」, // 属静默错,比字段缺失更难查。 // `quota` —— 同上:节点 quota 事后可被 `update_quota` 改,日志里没留"当时额度"; // 用当前值顶替即是伪造历史。 // // 为什么**不是**把整个数组删掉:它承载"这笔钱分给了谁、各多少"这个核心信息; // `level`/`quota` 只是附加维度,缺了不影响主信息。因此改为**补 `type`(有列)**、删两个无列字段。 // 若产品确需"当时层级/当时额度":需在日志表加列并回填 = 迁移变更,另立需求(本批不做)。 // // ⚠ 反模式提醒(团队明确禁止):为了让前端 mock 好看而给 `level` 填常量 1。 // "字段有值、永远等于 1、没人能发现它是假的"是本类缺陷里最难查的一种。 type DivideGrantRow struct { // UserID 获得分成的用户(ULID,源列 `user_id`)。 UserID string // Type 分成类型(源列 `type`:1 一次付清 / 2 按课次)。 Type int32 // Amount 本次入账金额(源列 `money`)。 Amount money.Amount } // PayrollTrace 课酬溯源(端点 51 的 `payroll`)。 // // 字段来源: // // DetailID ← t_course_payment_detail.id(命中的课酬规则;未配置方案 → 0) // Reward ← t_courses_record.teacher_reward(本课课酬合计,**落库值**,不是重新试算值) type PayrollTrace struct { // DetailID 命中的课酬明细规则 ID(0 = 未配置方案 / 无法定位)。 DetailID int64 // Reward 本课课酬合计(= Σ record_student.teacher_reward)。 Reward money.Amount } // RecordFinishView 端点 51 的响应(契约 §3.4 C 行 51 的字段全集,去掉无列字段)。 // // 字段来源: // // RecordID ← t_courses_record.id // CourseID ← t_courses_record.courses_id // Status ← t_courses_record.status(打完课恒 2) // Changed ← 由 `Repeated` 取反(幂等命中 ⇒ false,本次未写任何表) // TotalSpend ← Σ t_courses_record_student.cost == t_courses_record.income // Divide ← t_user_divide_into_log(按 student+order_no 回读) // Payroll ← 见 PayrollTrace // PerStudent ← t_courses_record_student(本次打卡行) type RecordFinishView struct { // RecordID 上课记录 ID。 RecordID int64 // CourseID 课程 ID。 CourseID int64 // Status 上课状态(2 = 已结束)。 Status int32 // Changed 本次是否真的写了库(false = 幂等命中,未扣课时/未分账)。 Changed bool // TotalSpend 本次扣课时合计。 TotalSpend money.Amount // Divide 本次触发的分账入账明细(空 → [])。 Divide []DivideGrantRow // Payroll 课酬溯源。 Payroll PayrollTrace // PerStudent 逐学生打卡明细。 PerStudent []FinishedStudent } // RecordViewService 结课响应装配服务(**只读**;不参与结账事务、不写任何表)。 // // 为什么单独一个服务:`RecordService.FinishRecord` 的返回值只带"结账需要的数据" // (金额合计、逐学生明细),而契约还要求 `divide[]` 与 `payroll.detailId` 两个**溯源**字段。 // 把它们塞进 FinishRecord(AC-11)会让结账事务多做两次无关查询、并在事务内做展示拼装; // 因此改为"事务提交后再只读装配"——即使装配失败也不影响已落库的结账结果(装配失败只报 5002)。 type RecordViewService struct { records *repository.RecordRepo courses *repository.CourseRepo divide *repository.DivideRepo payroll *PayrollService sc *appscope.Scope } // NewRecordViewService 构造结课响应装配服务。 // // 入参:db(必填);sc 作用域;payroll 课酬试算服务(**可 nil**:nil 时 `payroll.detailId` 恒 0, // 仍下发落库课酬 `reward`,前端"课酬金额"不受影响)。 // 出参:*RecordViewService。 func NewRecordViewService(db *gorm.DB, sc *appscope.Scope, payroll *PayrollService) *RecordViewService { return &RecordViewService{ records: repository.NewRecordRepo(db, sc), courses: repository.NewCourseRepo(db, sc), divide: repository.NewDivideRepo(db), payroll: payroll, sc: sc, } } // FinishView 把 `FinishRecordResult` 装配成契约要求的响应(端点 51)。 // // 入参:ctx;res 结账结果(非 nil)。 // 出参:*RecordFinishView;5002(回读失败)。**任一溯源信息取不到都不阻断**: // `divide[]` 为空时给 `[]`、`detailId` 取不到给 0——结账已经提交,展示缺项不该变成失败。 func (s *RecordViewService) FinishView(ctx context.Context, res *FinishRecordResult) (*RecordFinishView, error) { if res == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FinishView 需要结课结果")) } view := &RecordFinishView{ RecordID: res.RecordID, Status: res.Status, Changed: !res.Repeated, TotalSpend: res.Income, Divide: make([]DivideGrantRow, 0, 2), Payroll: PayrollTrace{Reward: res.TeacherReward}, PerStudent: res.Students, } rec, err := s.records.GetRecordByID(ctx, res.RecordID) if err != nil { return nil, err // 3001/5002:刚结完的课读不到 = 环境异常,按原码上抛 } view.CourseID = rec.CoursesID // 分账明细:按 (学生, orderNo=recordId) 回读(与 DivideService.AddLog 的幂等键同源) for i := range res.Students { studentID := res.Students[i].StudentID logs, lerr := s.divide.ListLogsByStudentOrder(ctx, studentID, strconv.FormatInt(res.RecordID, 10)) if lerr != nil { return nil, lerr } for j := range logs { amount, perr := parseAmount(logs[j].Money) if perr != nil { return nil, perr } view.Divide = append(view.Divide, DivideGrantRow{ UserID: logs[j].UserID, Type: int32(logs[j].Type), Amount: amount, }) } } view.Payroll.DetailID = s.lookupPayrollDetailID(ctx, rec, res.ActualEndTime) return view, nil } // lookupPayrollDetailID 回读本课命中的课酬规则 ID(只为**溯源展示**,不影响落库金额)。 // // 入参:ctx;rec 上课记录;at 上课时间(方案 effect_date 判定)。 // 出参:命中的 `t_course_payment_detail.id`;未配置方案 / 无老师 / 无分类 / 任何错误 → **0**。 // // 三条"取不到就返回 0、不冒泡错误"的理由: // 1. 结账已提交,展示缺项不该让整个请求失败(`payroll.reward` 仍有值); // 2. 3112(无方案)在结账侧已被显式降级为 0 课酬,溯源侧同理; // 3. 这里的试算**不带教龄**(见 `recordRewardQuoter` 的偏差注释),只用于定位规则 id。 func (s *RecordViewService) lookupPayrollDetailID(ctx context.Context, rec *model.CoursesRecord, at time.Time) int64 { if s.payroll == nil || rec == nil { return 0 } teacherID := strValue(rec.TeacherID) if teacherID == "" { return 0 } course, err := s.courses.GetCourseByID(ctx, rec.CoursesID) if err != nil || course.ClassifyID <= 0 { return 0 } quote, qerr := s.payroll.QuoteTeacherReward(ctx, QuotePayrollInput{ TeacherID: teacherID, TeacherExp: 0, // ⚠ AC-11 端口不携带教龄,统一按 0(偏差已登记) InstitutionID: rec.InstitutionID, CampusID: rec.CampusID, ClassifyID: course.ClassifyID, Date: at, }) if qerr != nil || quote == nil { return 0 } return quote.DetailID } // recordRewardQuoter 把 `PayrollService` 适配成 `RecordService` 的课酬端口(AC-11 `TeacherRewardQuoter`)—— // **D10 裁决后已补齐"按比例提成"**。 // // 单节课课酬的完整算法(dump `sp_course_payment_detail_get_teacher_detail` 口径): // // ① base = round(Income × percentage / 100) ← 比例部分(percentage 是**百分数**:"85.0" = 85%) // ② base = clamp(base, Min, Max) ← Min/Max 为 0 表示"不限制" // ③ total = base + Reward ← Reward 是"额外课酬"(在比例基础上追加) // // 三条实现红线: // - **全程整数分运算**(`money.Amount` 是分),不在任何一步引入 float; // - 比例用"千分之一"整数承载(`"85.0"` → `850`),`round` 用 `+500 后整除 1000`(四舍五入到分); // - `percentage` 列写坏(非数字、负数)→ **ErrDBError 整体回滚**,不静默按 0 算 // ("课酬算成 0 却结课成功"是少发钱,比报错难查得多)。 // // 已知口径缺口(**不是静默正确**):`TeacherExp` 目前恒 0(AC-11 未接教师档案的年数换算), // 因此**只会命中 `teacher_exp_min = 0` 的档位**;若某机构的规则全从 `teacher_exp_min > 0` 起, // 会走"无规则命中 ⇒ 3112 ⇒ 课酬 0.00"。补齐需接 `t_teacher_information.teacher_exp`(起算日 → 年数)。 type recordRewardQuoter struct { svc *PayrollService } // QuoteRecordReward 实现 `service.TeacherRewardQuoter`。 // // 入参:ctx;in 端口入参(含 `Income` 比例基数、`TeacherExp` 教龄)。 // 出参:本课课酬(元);3112 原样上抛(由 RecordService 降级为 0 课酬);列写坏 → 5002 整体回滚。 func (q recordRewardQuoter) QuoteRecordReward(ctx context.Context, in RewardQuoteInput) (money.Amount, error) { if q.svc == nil { return 0, nil } quote, err := q.svc.QuoteTeacherReward(ctx, QuotePayrollInput{ TeacherID: in.TeacherID, TeacherExp: in.TeacherExp, InstitutionID: in.InstitutionID, CampusID: in.CampusID, ClassifyID: in.ClassifyID, Date: in.RecordDate, }) if err != nil { return 0, err } if quote == nil { return 0, nil } return computeRecordReward(quote, in.Income) } // computeRecordReward 由"命中的课酬规则 + 本次课收入"算出单节课课酬(`recordRewardQuoter` 的算法体)。 // // 入参:quote 命中规则(Percentage/Min/Max/Reward);income 本次课收入。 // 出参:单节课课酬(分);2002(收入为负 = 调用方缺陷)/5002(percentage 列写坏)。 func computeRecordReward(quote *PayrollQuote, income money.Amount) (money.Amount, error) { if quote == nil { return 0, nil } if income.IsNegative() { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课酬试算的收入不能为负")) } base, perr := applyPercentage(quote.Percentage, income) if perr != nil { return 0, perr } // Min/Max 为 0 表示"不限制"(dump 语义),**不能**当成"下限 0/上限 0"去夹 if quote.Min.GreaterThan(0) && base.LessThan(quote.Min) { base = quote.Min } if quote.Max.GreaterThan(0) && base.GreaterThan(quote.Max) { base = quote.Max } total := base.Add(quote.Reward) if total.IsNegative() { return 0, infraerrors.WithCause(infraerrors.ErrDBError, errString("service: 课酬规则算出负值(percentage/min/max/reward 数据异常)")) } return total, nil } // percentageDenom 比例的分母(`percentage` 是百分数:`"85.0"` = 85% ⇒ 分母 100; // 再乘 10 是为了容纳 1 位小数 ⇒ 统一用"千分之一"整数运算,见 `applyPercentage`)。 const percentageDenom = 1000 // applyPercentage 计算"收入 × 比例"的金额(整数分运算,四舍五入到分)。 // // 入参:pct 百分比字符串(`decimal(5,1)`,如 `"85.0"`;空串按 0);income 本次课收入(分)。 // 出参:金额(分);`ErrDBError`(列写坏:非数字 / 多于 2 位小数 / 负数)。 // // 算法:`pctMilli = 百分比 × 10`(`"85.0"` → 850),`base = round(income × pctMilli / 1000)`。 // 为什么不用 `money.Parse` 再乘 float:比例列是 `decimal(5,1)`,用 float 会在 `×0.85` 这类 // 运算里带进二进制尾差(如 0.1+0.2 级),资金口径不允许。 func applyPercentage(pct string, income money.Amount) (money.Amount, error) { text := strings.TrimSpace(pct) if text == "" { return 0, nil // 未配置比例:只算 min/max/reward 部分(dump 的 0 值语义) } if strings.HasPrefix(text, "-") { return 0, infraerrors.WithCause(infraerrors.ErrDBError, errString("service: 课酬比例不能为负 "+text)) } intPart, fracPart, _ := strings.Cut(text, ".") if intPart == "" { return 0, infraerrors.WithCause(infraerrors.ErrDBError, errString("service: 课酬比例格式非法 "+text)) } whole, perr := parseUintDigits(intPart) if perr != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, errString("service: 课酬比例格式非法 "+text)) } // 小数位:只取 1 位(列是 decimal(5,1));第 2 位 >=5 时进位 tenth := int64(0) if fracPart != "" { if len(fracPart) > 2 { return 0, infraerrors.WithCause(infraerrors.ErrDBError, errString("service: 课酬比例小数位过多 "+text)) } first, ferr := parseUintDigits(fracPart[:1]) if ferr != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, errString("service: 课酬比例格式非法 "+text)) } tenth = first if len(fracPart) == 2 { second, serr := parseUintDigits(fracPart[1:]) if serr != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, errString("service: 课酬比例格式非法 "+text)) } if second >= 5 { tenth++ } } } pctMilli := whole*10 + tenth if pctMilli == 0 { return 0, nil } cents := income.Cents() // 溢出兜底:decimal(12,2) 上限 ≈ 1e10 分,pctMilli ≤ 1000 ⇒ 乘积 ≤ 1e13,int64 安全 base := (cents*pctMilli + percentageDenom/2) / percentageDenom return money.FromCents(base), nil } // parseUintDigits 把纯数字串转 int64(比例解析用;非数字 → error)。 func parseUintDigits(digits string) (int64, error) { if digits == "" { return 0, errString("empty") } var out int64 for _, ch := range digits { if ch < '0' || ch > '9' { return 0, errString("not a digit") } out = out*10 + int64(ch-'0') if out > 1_000_000 { // 比例列 decimal(5,1) 不会超过这个量级 return 0, errString("too large") } } return out, nil } // --------------------------------------------------------------------------- // 端点 48:课表实例列表 // --------------------------------------------------------------------------- // RecordListQuery 端点 48 的查询入参(把 AC-11 的 `repository.RecordFilter` 包一层, // 使 Handler 不必 import repository —— 本包依赖方向:handler → service → repository)。 // // 字段:CoursesID 课程(<=0 不筛);TeacherID 老师(空不筛);Status 状态(nil 不筛); // From/To 上课时间窗(`start_time` 闭区间,与 AC-11 `RecordFilter` 语义一致);Page/PageSize 分页。 type RecordListQuery struct { // CoursesID 课程 ID(<=0 不筛)。 CoursesID int64 // TeacherID 老师 ULID(空不筛)。 TeacherID string // Status 上课状态(nil 不筛;0 未开始 / 1 上课中 / 2 已结束)。 Status *int32 // From 上课时间下限(含;nil 不限)。 From *time.Time // To 上课时间上限(含;nil 不限)。 To *time.Time // Page 页码(1 基)。 Page int // PageSize 每页条数(上限 200)。 PageSize int } // ListRecordsByQuery 分页查询排班实例(端点 48)。 // // 入参:ctx;q 查询入参。 // 出参:([]model.CoursesRecord, total, error);5002。 // // 时间窗口径**沿用 AC-11**(`start_time` 闭区间,两端都含):课表是"看某段时间有哪些课", // 与契约 §1.3 的 `from` 闭 / `to` 开**不同**——这里是有意的差异(契约 §1.3 约束的是 // "区间筛选类查询"的通用规则,课表按"日期区间"展示,前端传 `to=2026-11-30` 期望含 30 号当天)。 // 该差异已在交付报告登记。 func (s *RecordService) ListRecordsByQuery(ctx context.Context, q RecordListQuery) ([]model.CoursesRecord, int64, error) { return s.List(ctx, repository.RecordFilter{ CoursesID: q.CoursesID, TeacherID: q.TeacherID, Status: q.Status, From: q.From, To: q.To, }, repository.PageQuery{Page: q.Page, PageSize: q.PageSize}) } // NewRecordRewardQuoter 构造课酬端口适配器(装配层用)。 // // 入参:svc 课酬试算服务(可 nil → 适配器返回 0 课酬)。 // 出参:TeacherRewardQuoter 实现。 func NewRecordRewardQuoter(svc *PayrollService) TeacherRewardQuoter { return recordRewardQuoter{svc: svc} } // NewDivideServiceWithRecordRepo 构造分账服务并注入"首次扣费记录"端口(AC-14 装配用)。 // // 入参:db;sc 请求作用域(仅用于构造 RecordRepo;分账域本身不做租户裁剪)。 // 出参:*DivideService(`firstCharged` 已注入 = `repository.RecordRepo`,结构化满足端口)。 // // 为什么放在 service 包而不是装配层 new 两个对象再拼:`repository.RecordRepo` 必须按 // **请求作用域**构造(它继承 BaseRepo),把这个约束关在本包内,装配层就不必 import repository // (handler 层至今对 repository 零 import,这条依赖方向要保持)。 func NewDivideServiceWithRecordRepo(db *gorm.DB, sc *appscope.Scope) *DivideService { return NewDivideService(db, repository.NewRecordRepo(db, sc)) }