| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- // 排班实例 + 打卡明细仓储(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 <scope> 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
- }
|