// 排班实例 + 打卡明细仓储(t_courses_record / t_courses_record_student)——AC-11。 // // 依据:unified/03-migration/academic-domain-design.md §11-3(排班/打卡盘点结论) // / §7.3 外延(开始上课 → 结束上课联动)/ §3.3 #23(首次扣费记录判据)/ §10.3(仓储不开事务)。 // // 两张表的生命周期(无 dump SP 覆盖,逻辑源自旧 PHP `modules/courses/*`,设计 §11-3): // // t_courses_record 一次课(排班实例):status 0 未开始 → 1 上课中 → 2 已结束 // UK 语义:本仓 DDL 无 (courses_id, start_time) 唯一键(DDL 已锁定, // 见 model/testdata 指纹),故排班生成的幂等靠"同事务先查后插"(见 FindByCoursesStart)。 // t_courses_record_student 一次打卡一行:cost = 本次课费、give_cost = 本次使用赠送额、 // end_time 非空 = 已完成(分成归属判据)。 // // 事务纪律:写方法一律接收 `tx *gorm.DB`;读方法经 db(ctx) 取句柄(ctx 内已注入事务时复用同一 tx)。 package repository import ( "context" "errors" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // RecordRepo 排班实例 + 打卡明细仓储(多租户,继承 BaseRepo)。 // // ⚠ 额外职责(跨服务端口实现):`FirstChargedRecordID` 实现 // `service.FirstChargedRecordReader` 契约(Go 结构化接口,无需反向依赖 service 包), // 由装配层注入 DivideService,使 `divide_into_log_delete` 的 D11「改绑保留」分支在生产真正生效 // (此前端口未注入 → 永远走"回滚删除"分支,属静默少发/多发缺陷)。 type RecordRepo struct { *BaseRepo } // NewRecordRepo 构造排班实例仓储。 // // 入参: // - db *gorm.DB:连接池句柄(必填)。 // - sc *appscope.Scope:请求作用域;系统/运维调用(如分账撤销的首扣判定)可传 nil —— // 此类调用只能走**不调用 Scoped** 的方法(如 FirstChargedRecordID),否则 panic fail-fast。 // // 出参:*RecordRepo(CampusScoped=true:两张表都带 campus_id)。 func NewRecordRepo(db *gorm.DB, sc *appscope.Scope) *RecordRepo { r := &RecordRepo{BaseRepo: NewBaseRepo(db, sc)} r.CampusScoped = true return r } // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。 // // 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。 func (r *RecordRepo) db(ctx context.Context) (*gorm.DB, error) { if r == nil || r.DB == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: RecordRepo.DB 为 nil")) } if tx := TxFromContext(ctx); tx != nil { return tx, nil } if ctx == nil { return r.DB, nil } return r.DB.WithContext(ctx), nil } // --------------------------------------------------------------------------- // 排班实例(t_courses_record) // --------------------------------------------------------------------------- // GetRecordByID 按主键取排班实例(**作用域强制**)。 // // 入参:ctx;id 上课记录 ID(<=0 → ErrBadParam)。 // 出参:*model.CoursesRecord;ErrNotFound(3001) / ErrBadParam / ErrDBError。 func (r *RecordRepo) GetRecordByID(ctx context.Context, id int64) (*model.CoursesRecord, error) { if id <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: record id 必须 > 0")) } db, err := r.db(ctx) if err != nil { return nil, err } var out model.CoursesRecord q := r.Scoped(db.Model(&model.CoursesRecord{})) if err := q.Where("id = ?", id).First(&out).Error; err != nil { return nil, wrapQueryErr(err, "GetRecordByID(t_courses_record)") } return &out, nil } // GetRecordForUpdate 取排班实例并加行锁(开始/结束上课的**状态机判定前提**)。 // // 入参:ctx;tx(**必传**:行锁只在事务内有效);id 上课记录 ID(<=0 → ErrBadParam)。 // 出参:*model.CoursesRecord;ErrNotFound / ErrBadParam / ErrDBError。 // // SQL:`SELECT * FROM t_courses_record WHERE id = ? AND LIMIT 1 FOR UPDATE`。 // sqlite 单测栈不渲染行锁(clause 已挂载,MySQL 生效)。 // // 锁序说明(设计 §8 AC-07 锁序基线):本行锁是"一节课"的聚合根锁,**先于**账户锁; // 账户锁(全局 → 校区)**先于**活动/优惠池行锁。本节流程不触碰活动池,故顺序为 // 「record 行 → 报课行/账户行 → 分账表」。 func (r *RecordRepo) GetRecordForUpdate(ctx context.Context, tx *gorm.DB, id int64) (*model.CoursesRecord, error) { if err := requireTx(tx, "GetRecordForUpdate"); err != nil { return nil, err } if id <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetRecordForUpdate 需要 record id")) } var out model.CoursesRecord // 事务内读:显式用参数 tx(含行锁);ctx 内的 tx 与参数 tx 同源(Service 已透传),此处以参数为准。 q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate})) if err := q.Where("id = ?", id).Take(&out).Error; err != nil { return nil, wrapQueryErr(err, "GetRecordForUpdate(t_courses_record)") } return &out, nil } // RecordFilter 课表列表过滤(端点 48:按课程/老师/状态/时间窗)。 // // 字段: // - CoursesID int64:课程 ID(<=0 不筛); // - TeacherID string:老师 ULID(空不筛); // - Status *int32:状态(nil 不筛); // - From/To *time.Time:start_time 闭区间(任一为空表示该端不限)。 type RecordFilter struct { CoursesID int64 TeacherID string Status *int32 From *time.Time To *time.Time } // ListRecords 分页查询排班实例(按 start_time 升序 = 课表顺序,同日按 id 升序稳定)。 // // 入参:ctx;f RecordFilter;pg PageQuery(内部钳制)。 // 出参:([]model.CoursesRecord, total int64, error);空结果为非 nil 空切片。 func (r *RecordRepo) ListRecords(ctx context.Context, f RecordFilter, pg PageQuery) ([]model.CoursesRecord, int64, error) { db, err := r.db(ctx) if err != nil { return nil, 0, err } build := func() *gorm.DB { q := r.Scoped(db.Model(&model.CoursesRecord{})) if f.CoursesID > 0 { q = q.Where("courses_id = ?", f.CoursesID) } if tid := strings.TrimSpace(f.TeacherID); tid != "" { q = q.Where("teacher_id = ?", tid) } if f.Status != nil { q = q.Where("status = ?", *f.Status) } if f.From != nil { q = q.Where("start_time >= ?", *f.From) } if f.To != nil { q = q.Where("start_time <= ?", *f.To) } return q } var total int64 if err := build().Count(&total).Error; err != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } page, size := pg.Normalize() rows := make([]model.CoursesRecord, 0, size) if err := build().Order("start_time ASC, id ASC").Offset(PageOffset(page, size)).Limit(size).Find(&rows).Error; err != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } return rows, total, nil } // FindByCoursesStart 按「课程 + 上课时间」查排班实例(**排班生成幂等判据**)。 // // 入参:ctx;coursesID 课程 ID(>0);startTime 上课时间(零值 → ErrBadParam)。 // 出参:*model.CoursesRecord;ErrNotFound(可插入)/ ErrBadParam / ErrDBError。 // // 幂等口径(AC-11 ①):同一 `courses_id + start_time` 不重复插入。 // 实现为"同事务先查后插"——DDL 无该组合唯一键(DDL 指纹已锁定,本期不改), // 故并发下理论仍可能双插,由 Handler 层重跑/运维去重兜底(已登记交付说明遗留项)。 func (r *RecordRepo) FindByCoursesStart(ctx context.Context, coursesID int64, startTime time.Time) (*model.CoursesRecord, error) { if coursesID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindByCoursesStart 需要 courses_id")) } if startTime.IsZero() { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindByCoursesStart 需要 start_time")) } db, err := r.db(ctx) if err != nil { return nil, err } var out model.CoursesRecord q := r.Scoped(db.Model(&model.CoursesRecord{})) if err := q.Where("courses_id = ? AND start_time = ?", coursesID, startTime). Order("id ASC").First(&out).Error; err != nil { return nil, wrapQueryErr(err, "FindByCoursesStart(t_courses_record)") } return &out, nil } // CreateRecord 新建排班实例(BizNo 由 Service 生成)。 // // 入参:ctx;tx(**必传**);row 实例(BizNo/CoursesID/StartTime 必填)。 // 出参:nil 成功(回填 ID);ErrBadParam / ErrDBError。 func (r *RecordRepo) CreateRecord(ctx context.Context, tx *gorm.DB, row *model.CoursesRecord) error { if err := requireTx(tx, "CreateRecord"); err != nil { return err } if row == nil || row.CoursesID <= 0 || strings.TrimSpace(row.BizNo) == "" || row.StartTime == nil { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateRecord 需要 biz_no/courses_id/start_time")) } if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil } // MarkRecordStarted 开始上课:CAS 把 `status 0 → 1` 并写 actual_start_time。 // // 入参:ctx;tx(**必传**);id 上课记录 ID(>0);at 实际上课时间(零值 → ErrBadParam)。 // 出参:int64 受影响行数(0 = 状态已非 0,调用方按 3121 处理);ErrBadParam / ErrDBError。 // // CAS 条件 `status = 0 AND actual_start_time IS NULL`:并发双击"开始上课"只有一个能成功, // 另一个 RowsAffected=0 → Service 返回 3121(模型红线:actual_start_time 只写一次)。 func (r *RecordRepo) MarkRecordStarted(ctx context.Context, tx *gorm.DB, id int64, at time.Time) (int64, error) { if err := requireTx(tx, "MarkRecordStarted"); err != nil { return 0, err } if id <= 0 || at.IsZero() { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: MarkRecordStarted 需要 id 与时间")) } q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesRecord{})) res := q.Where("id = ? AND status = ? AND actual_start_time IS NULL", id, model.CoursesRecordStatusNotStarted). Updates(map[string]any{ "status": model.CoursesRecordStatusOngoing, "actual_start_time": at, }) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // RecordFinishPatch 结束上课要落库的汇总列(Service 计算后一次性传入)。 // // 字段: // - ActualEndTime time.Time:实际下课时间(必填); // - StudentNumber int32:上课人数(= 打卡明细行数); // - Income money.Amount:课程收入(= Σ record_student.cost); // - TeacherReward money.Amount:老师课酬(= Σ record_student.teacher_reward); // - TeacherRewardType int8:课酬结算方式(1 正常 / 2 按原课程老师课酬无最低课酬)。 type RecordFinishPatch struct { ActualEndTime time.Time StudentNumber int32 Income money.Amount TeacherReward money.Amount TeacherRewardType int8 } // MarkRecordFinished 结束上课:`status → 2` + actual_end_time + 汇总列(**CAS 保证只成功一次**)。 // // 入参:ctx;tx(**必传**);id 上课记录 ID(>0);patch 汇总列。 // 出参:int64 受影响行数(0 = 已被并发结束,Service 按幂等成功处理);ErrBadParam / ErrDBError。 // // CAS 条件 `status IN (0,1) AND actual_end_time IS NULL`:结束上课的幂等判据是 // "actual_end_time 非空即已结束"(模型红线),并发双击第二个 RowsAffected=0。 func (r *RecordRepo) MarkRecordFinished(ctx context.Context, tx *gorm.DB, id int64, patch RecordFinishPatch) (int64, error) { if err := requireTx(tx, "MarkRecordFinished"); err != nil { return 0, err } if id <= 0 || patch.ActualEndTime.IsZero() { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: MarkRecordFinished 需要 id 与 actual_end_time")) } if err := patch.Income.Validate(); err != nil { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err) } if err := patch.TeacherReward.Validate(); err != nil { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err) } rewardType := patch.TeacherRewardType if rewardType == 0 { rewardType = model.CoursesRecordRewardTypeNormal } q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesRecord{})) res := q.Where("id = ? AND status IN ? AND actual_end_time IS NULL", id, []int32{model.CoursesRecordStatusNotStarted, model.CoursesRecordStatusOngoing}). Updates(map[string]any{ "status": model.CoursesRecordStatusFinished, "actual_end_time": patch.ActualEndTime, "student_number": patch.StudentNumber, "income": patch.Income.String(), "teacher_reward": patch.TeacherReward.String(), "teacher_reward_type": rewardType, }) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // UpdateRecordSummary 打卡阶段刷新汇总列(不动 status / actual_end_time)。 // // 入参:ctx;tx(**必传**);id;studentNumber 当前打卡行数;income 当前 Σcost。 // 出参:int64 受影响行数;ErrBadParam / ErrDBError。 // // 用途:端点 52(打卡/考勤)多次调用的增量汇总;teacher_reward 只在结束时算,故不在本方法内。 func (r *RecordRepo) UpdateRecordSummary(ctx context.Context, tx *gorm.DB, id int64, studentNumber int32, income money.Amount) (int64, error) { if err := requireTx(tx, "UpdateRecordSummary"); err != nil { return 0, err } if id <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateRecordSummary 需要 id")) } if err := income.Validate(); err != nil { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err) } q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesRecord{})) res := q.Where("id = ?", id).Updates(map[string]any{ "student_number": studentNumber, "income": income.String(), }) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // --------------------------------------------------------------------------- // 打卡明细(t_courses_record_student) // --------------------------------------------------------------------------- // ListRecordStudents 取某节课的全部打卡明细(按学生 ID 升序,顺序稳定)。 // // 入参:ctx;recordID 上课记录 ID(<=0 → ErrBadParam)。 // 出参:[]model.CoursesRecordStudent(空结果非 nil);ErrBadParam / ErrDBError。 func (r *RecordRepo) ListRecordStudents(ctx context.Context, recordID int64) ([]model.CoursesRecordStudent, error) { if recordID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListRecordStudents 需要 record_id")) } db, err := r.db(ctx) if err != nil { return nil, err } rows := make([]model.CoursesRecordStudent, 0, 16) q := r.Scoped(db.Model(&model.CoursesRecordStudent{})).Where("record_id = ?", recordID) if err := q.Order("student_id ASC, id ASC").Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } return rows, nil } // GetRecordStudent 按(上课记录 + 学生)取打卡明细(打卡 upsert 的存在性判据)。 // // 入参:ctx;recordID(>0);studentID(ULID,非空)。 // 出参:*model.CoursesRecordStudent;ErrNotFound / ErrBadParam / ErrDBError。 func (r *RecordRepo) GetRecordStudent(ctx context.Context, recordID int64, studentID string) (*model.CoursesRecordStudent, error) { if recordID <= 0 || strings.TrimSpace(studentID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetRecordStudent 需要 record_id/student_id")) } db, err := r.db(ctx) if err != nil { return nil, err } var out model.CoursesRecordStudent q := r.Scoped(db.Model(&model.CoursesRecordStudent{})).Where("record_id = ? AND student_id = ?", recordID, studentID) if err := q.Order("id ASC").First(&out).Error; err != nil { return nil, wrapQueryErr(err, "GetRecordStudent(t_courses_record_student)") } return &out, nil } // CreateRecordStudent 新建打卡明细(BizNo 由 Service 生成)。 // // 入参:ctx;tx(**必传**);row(BizNo/RecordID/StudentID/StartTime 必填)。 // 出参:nil 成功(回填 ID);ErrBadParam / ErrDBError。 func (r *RecordRepo) CreateRecordStudent(ctx context.Context, tx *gorm.DB, row *model.CoursesRecordStudent) error { if err := requireTx(tx, "CreateRecordStudent"); err != nil { return err } if row == nil || strings.TrimSpace(row.BizNo) == "" || row.RecordID <= 0 || strings.TrimSpace(row.StudentID) == "" { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateRecordStudent 需要 biz_no/record_id/student_id")) } if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil } // RecordStudentPatch 打卡明细的可变列(结束上课时一次性写全)。 // // 字段:Type 上课类型;Pay 是否已支付;Cost 本次课费;GiveCost 本次使用赠送额; // TeacherReward 本次分摊课酬;EndTime 下课时间(nil = 未完成)。 type RecordStudentPatch struct { Type int32 Pay int8 Cost money.Amount GiveCost money.Amount TeacherReward money.Amount EndTime *time.Time Remark *string } // UpdateRecordStudent 更新打卡明细(按主键,**同时**重算 cost/give_cost/teacher_reward)。 // // 入参:ctx;tx(**必传**);id 明细 ID(>0);patch 列值。 // 出参:int64 受影响行数;ErrBadParam / ErrDBError。 // // 红线:`cost`(真扣费)与 `give_cost`(赠送额)必须分别落库 —— // 合并成一列会让"赠送不使用余额"的判据失效(模型注释:赠送课次不动余额)。 func (r *RecordRepo) UpdateRecordStudent(ctx context.Context, tx *gorm.DB, id int64, patch RecordStudentPatch) (int64, error) { if err := requireTx(tx, "UpdateRecordStudent"); err != nil { return 0, err } if id <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateRecordStudent 需要 id")) } for _, amt := range []money.Amount{patch.Cost, patch.GiveCost, patch.TeacherReward} { if amt.IsNegative() { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 打卡金额不能为负")) } if err := amt.Validate(); err != nil { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err) } } typ := patch.Type if typ == 0 { typ = model.CoursesRecordStudentTypeNormal } fields := map[string]any{ "type": typ, "pay": patch.Pay, "cost": patch.Cost.String(), "give_cost": patch.GiveCost.String(), "teacher_reward": patch.TeacherReward.String(), } if patch.EndTime != nil { fields["end_time"] = *patch.EndTime } if patch.Remark != nil { fields["remark"] = *patch.Remark } q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesRecordStudent{})) res := q.Where("id = ?", id).Updates(fields) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // FirstChargedRecordID 取学生「首次扣费上课记录」的 ID —— 实现 service.FirstChargedRecordReader。 // // 入参:ctx 请求上下文;studentID 报课学生(canonical_user_id ULID)。 // 出参:(recordID int64, found bool, err error);found=false 表示"尚无扣费记录"(不是错误)。 // // **契约逐字对齐 dump `yuansiwei_finance.sql:763811`(四个谓词缺一不可,禁止自选口径)**: // // SELECT recordid FROM t_courses_record_student // WHERE student_id = ? AND cost > 0 AND end_time IS NOT NULL // ORDER BY start_time ASC LIMIT 1; // // 1. `student_id`:只认本人; // 2. `cost > 0`:真扣费(排除免费/试听/纯赠送行 —— 赠送行 cost=0,如果算进来会把 // "首次扣费"错判成赠送课,分成改绑到不该绑的订单上); // 3. `end_time IS NOT NULL`:已结课(未结课的打卡行不是"已发生的扣费事实"); // 4. `ORDER BY start_time ASC LIMIT 1`:最早一节; // 5. **返回 `record_id` 列(不是本表主键 id)** —— dump 原文取 recordid;取错列会让撤销时 // 的 order_no 与分成明细侧对不上 → 改绑失效、分成悬空(D11 改绑分支的核心陷阱)。 // // 作用域说明(**有意不加 Scoped**):本方法是分账撤销(divide_into_log_delete)的内部判定, // dump 的取数口径无机构/校区过滤;入参 studentID 来自本学生自己的分成明细行 // (ListLogsByStudentOrder 已按 (student_id, order_no) 精确锁定),不存在跨租户扫描面。 // 因此这里用裸 db(ctx)(分账撤销场景 reqctx 内可能没有 Scope,若走 Scoped 会 panic)。 func (r *RecordRepo) FirstChargedRecordID(ctx context.Context, studentID string) (int64, bool, error) { studentID = strings.TrimSpace(studentID) if studentID == "" { return 0, false, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FirstChargedRecordID 需要 student_id")) } db, err := r.db(ctx) if err != nil { return 0, false, err } var row struct { RecordID int64 `gorm:"column:record_id"` } q := db.WithContext(ctxOrBackground(ctx)).Table(model.CoursesRecordStudent{}.TableName()). Select("record_id"). Where("student_id = ? AND cost > 0 AND end_time IS NOT NULL", studentID). Order("start_time ASC"). Limit(1) if err := q.Take(&row).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return 0, false, nil // 无扣费记录:走"回滚并删除分成"分支(D11) } return 0, false, infraerrors.WithCause(infraerrors.ErrDBError, err) } if row.RecordID <= 0 { // 数据异常(record_id 未回填):按"无首扣记录"处理更安全(宁可回滚也不改绑到 0) return 0, false, nil } return row.RecordID, true, nil }