record_repo.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. // 排班实例 + 打卡明细仓储(t_courses_record / t_courses_record_student)——AC-11。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §11-3(排班/打卡盘点结论)
  4. // / §7.3 外延(开始上课 → 结束上课联动)/ §3.3 #23(首次扣费记录判据)/ §10.3(仓储不开事务)。
  5. //
  6. // 两张表的生命周期(无 dump SP 覆盖,逻辑源自旧 PHP `modules/courses/*`,设计 §11-3):
  7. //
  8. // t_courses_record 一次课(排班实例):status 0 未开始 → 1 上课中 → 2 已结束
  9. // UK 语义:本仓 DDL 无 (courses_id, start_time) 唯一键(DDL 已锁定,
  10. // 见 model/testdata 指纹),故排班生成的幂等靠"同事务先查后插"(见 FindByCoursesStart)。
  11. // t_courses_record_student 一次打卡一行:cost = 本次课费、give_cost = 本次使用赠送额、
  12. // end_time 非空 = 已完成(分成归属判据)。
  13. //
  14. // 事务纪律:写方法一律接收 `tx *gorm.DB`;读方法经 db(ctx) 取句柄(ctx 内已注入事务时复用同一 tx)。
  15. package repository
  16. import (
  17. "context"
  18. "errors"
  19. "strings"
  20. "time"
  21. "gorm.io/gorm"
  22. "gorm.io/gorm/clause"
  23. "yuansiwei/unified-backend/internal/academic/model"
  24. "yuansiwei/unified-backend/internal/academic/money"
  25. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  26. appscope "yuansiwei/unified-backend/internal/infra/scope"
  27. )
  28. // RecordRepo 排班实例 + 打卡明细仓储(多租户,继承 BaseRepo)。
  29. //
  30. // ⚠ 额外职责(跨服务端口实现):`FirstChargedRecordID` 实现
  31. // `service.FirstChargedRecordReader` 契约(Go 结构化接口,无需反向依赖 service 包),
  32. // 由装配层注入 DivideService,使 `divide_into_log_delete` 的 D11「改绑保留」分支在生产真正生效
  33. // (此前端口未注入 → 永远走"回滚删除"分支,属静默少发/多发缺陷)。
  34. type RecordRepo struct {
  35. *BaseRepo
  36. }
  37. // NewRecordRepo 构造排班实例仓储。
  38. //
  39. // 入参:
  40. // - db *gorm.DB:连接池句柄(必填)。
  41. // - sc *appscope.Scope:请求作用域;系统/运维调用(如分账撤销的首扣判定)可传 nil ——
  42. // 此类调用只能走**不调用 Scoped** 的方法(如 FirstChargedRecordID),否则 panic fail-fast。
  43. //
  44. // 出参:*RecordRepo(CampusScoped=true:两张表都带 campus_id)。
  45. func NewRecordRepo(db *gorm.DB, sc *appscope.Scope) *RecordRepo {
  46. r := &RecordRepo{BaseRepo: NewBaseRepo(db, sc)}
  47. r.CampusScoped = true
  48. return r
  49. }
  50. // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。
  51. //
  52. // 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
  53. func (r *RecordRepo) db(ctx context.Context) (*gorm.DB, error) {
  54. if r == nil || r.DB == nil {
  55. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: RecordRepo.DB 为 nil"))
  56. }
  57. if tx := TxFromContext(ctx); tx != nil {
  58. return tx, nil
  59. }
  60. if ctx == nil {
  61. return r.DB, nil
  62. }
  63. return r.DB.WithContext(ctx), nil
  64. }
  65. // ---------------------------------------------------------------------------
  66. // 排班实例(t_courses_record)
  67. // ---------------------------------------------------------------------------
  68. // GetRecordByID 按主键取排班实例(**作用域强制**)。
  69. //
  70. // 入参:ctx;id 上课记录 ID(<=0 → ErrBadParam)。
  71. // 出参:*model.CoursesRecord;ErrNotFound(3001) / ErrBadParam / ErrDBError。
  72. func (r *RecordRepo) GetRecordByID(ctx context.Context, id int64) (*model.CoursesRecord, error) {
  73. if id <= 0 {
  74. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: record id 必须 > 0"))
  75. }
  76. db, err := r.db(ctx)
  77. if err != nil {
  78. return nil, err
  79. }
  80. var out model.CoursesRecord
  81. q := r.Scoped(db.Model(&model.CoursesRecord{}))
  82. if err := q.Where("id = ?", id).First(&out).Error; err != nil {
  83. return nil, wrapQueryErr(err, "GetRecordByID(t_courses_record)")
  84. }
  85. return &out, nil
  86. }
  87. // GetRecordForUpdate 取排班实例并加行锁(开始/结束上课的**状态机判定前提**)。
  88. //
  89. // 入参:ctx;tx(**必传**:行锁只在事务内有效);id 上课记录 ID(<=0 → ErrBadParam)。
  90. // 出参:*model.CoursesRecord;ErrNotFound / ErrBadParam / ErrDBError。
  91. //
  92. // SQL:`SELECT * FROM t_courses_record WHERE id = ? AND <scope> LIMIT 1 FOR UPDATE`。
  93. // sqlite 单测栈不渲染行锁(clause 已挂载,MySQL 生效)。
  94. //
  95. // 锁序说明(设计 §8 AC-07 锁序基线):本行锁是"一节课"的聚合根锁,**先于**账户锁;
  96. // 账户锁(全局 → 校区)**先于**活动/优惠池行锁。本节流程不触碰活动池,故顺序为
  97. // 「record 行 → 报课行/账户行 → 分账表」。
  98. func (r *RecordRepo) GetRecordForUpdate(ctx context.Context, tx *gorm.DB, id int64) (*model.CoursesRecord, error) {
  99. if err := requireTx(tx, "GetRecordForUpdate"); err != nil {
  100. return nil, err
  101. }
  102. if id <= 0 {
  103. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetRecordForUpdate 需要 record id"))
  104. }
  105. var out model.CoursesRecord
  106. // 事务内读:显式用参数 tx(含行锁);ctx 内的 tx 与参数 tx 同源(Service 已透传),此处以参数为准。
  107. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}))
  108. if err := q.Where("id = ?", id).Take(&out).Error; err != nil {
  109. return nil, wrapQueryErr(err, "GetRecordForUpdate(t_courses_record)")
  110. }
  111. return &out, nil
  112. }
  113. // RecordFilter 课表列表过滤(端点 48:按课程/老师/状态/时间窗)。
  114. //
  115. // 字段:
  116. // - CoursesID int64:课程 ID(<=0 不筛);
  117. // - TeacherID string:老师 ULID(空不筛);
  118. // - Status *int32:状态(nil 不筛);
  119. // - From/To *time.Time:start_time 闭区间(任一为空表示该端不限)。
  120. type RecordFilter struct {
  121. CoursesID int64
  122. TeacherID string
  123. Status *int32
  124. From *time.Time
  125. To *time.Time
  126. }
  127. // ListRecords 分页查询排班实例(按 start_time 升序 = 课表顺序,同日按 id 升序稳定)。
  128. //
  129. // 入参:ctx;f RecordFilter;pg PageQuery(内部钳制)。
  130. // 出参:([]model.CoursesRecord, total int64, error);空结果为非 nil 空切片。
  131. func (r *RecordRepo) ListRecords(ctx context.Context, f RecordFilter, pg PageQuery) ([]model.CoursesRecord, int64, error) {
  132. db, err := r.db(ctx)
  133. if err != nil {
  134. return nil, 0, err
  135. }
  136. build := func() *gorm.DB {
  137. q := r.Scoped(db.Model(&model.CoursesRecord{}))
  138. if f.CoursesID > 0 {
  139. q = q.Where("courses_id = ?", f.CoursesID)
  140. }
  141. if tid := strings.TrimSpace(f.TeacherID); tid != "" {
  142. q = q.Where("teacher_id = ?", tid)
  143. }
  144. if f.Status != nil {
  145. q = q.Where("status = ?", *f.Status)
  146. }
  147. if f.From != nil {
  148. q = q.Where("start_time >= ?", *f.From)
  149. }
  150. if f.To != nil {
  151. q = q.Where("start_time <= ?", *f.To)
  152. }
  153. return q
  154. }
  155. var total int64
  156. if err := build().Count(&total).Error; err != nil {
  157. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  158. }
  159. page, size := pg.Normalize()
  160. rows := make([]model.CoursesRecord, 0, size)
  161. if err := build().Order("start_time ASC, id ASC").Offset(PageOffset(page, size)).Limit(size).Find(&rows).Error; err != nil {
  162. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  163. }
  164. return rows, total, nil
  165. }
  166. // FindByCoursesStart 按「课程 + 上课时间」查排班实例(**排班生成幂等判据**)。
  167. //
  168. // 入参:ctx;coursesID 课程 ID(>0);startTime 上课时间(零值 → ErrBadParam)。
  169. // 出参:*model.CoursesRecord;ErrNotFound(可插入)/ ErrBadParam / ErrDBError。
  170. //
  171. // 幂等口径(AC-11 ①):同一 `courses_id + start_time` 不重复插入。
  172. // 实现为"同事务先查后插"——DDL 无该组合唯一键(DDL 指纹已锁定,本期不改),
  173. // 故并发下理论仍可能双插,由 Handler 层重跑/运维去重兜底(已登记交付说明遗留项)。
  174. func (r *RecordRepo) FindByCoursesStart(ctx context.Context, coursesID int64, startTime time.Time) (*model.CoursesRecord, error) {
  175. if coursesID <= 0 {
  176. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindByCoursesStart 需要 courses_id"))
  177. }
  178. if startTime.IsZero() {
  179. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindByCoursesStart 需要 start_time"))
  180. }
  181. db, err := r.db(ctx)
  182. if err != nil {
  183. return nil, err
  184. }
  185. var out model.CoursesRecord
  186. q := r.Scoped(db.Model(&model.CoursesRecord{}))
  187. if err := q.Where("courses_id = ? AND start_time = ?", coursesID, startTime).
  188. Order("id ASC").First(&out).Error; err != nil {
  189. return nil, wrapQueryErr(err, "FindByCoursesStart(t_courses_record)")
  190. }
  191. return &out, nil
  192. }
  193. // CreateRecord 新建排班实例(BizNo 由 Service 生成)。
  194. //
  195. // 入参:ctx;tx(**必传**);row 实例(BizNo/CoursesID/StartTime 必填)。
  196. // 出参:nil 成功(回填 ID);ErrBadParam / ErrDBError。
  197. func (r *RecordRepo) CreateRecord(ctx context.Context, tx *gorm.DB, row *model.CoursesRecord) error {
  198. if err := requireTx(tx, "CreateRecord"); err != nil {
  199. return err
  200. }
  201. if row == nil || row.CoursesID <= 0 || strings.TrimSpace(row.BizNo) == "" || row.StartTime == nil {
  202. return infraerrors.WithCause(infraerrors.ErrBadParam,
  203. errString("repository: CreateRecord 需要 biz_no/courses_id/start_time"))
  204. }
  205. if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
  206. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  207. }
  208. return nil
  209. }
  210. // MarkRecordStarted 开始上课:CAS 把 `status 0 → 1` 并写 actual_start_time。
  211. //
  212. // 入参:ctx;tx(**必传**);id 上课记录 ID(>0);at 实际上课时间(零值 → ErrBadParam)。
  213. // 出参:int64 受影响行数(0 = 状态已非 0,调用方按 3121 处理);ErrBadParam / ErrDBError。
  214. //
  215. // CAS 条件 `status = 0 AND actual_start_time IS NULL`:并发双击"开始上课"只有一个能成功,
  216. // 另一个 RowsAffected=0 → Service 返回 3121(模型红线:actual_start_time 只写一次)。
  217. func (r *RecordRepo) MarkRecordStarted(ctx context.Context, tx *gorm.DB, id int64, at time.Time) (int64, error) {
  218. if err := requireTx(tx, "MarkRecordStarted"); err != nil {
  219. return 0, err
  220. }
  221. if id <= 0 || at.IsZero() {
  222. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: MarkRecordStarted 需要 id 与时间"))
  223. }
  224. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesRecord{}))
  225. res := q.Where("id = ? AND status = ? AND actual_start_time IS NULL", id, model.CoursesRecordStatusNotStarted).
  226. Updates(map[string]any{
  227. "status": model.CoursesRecordStatusOngoing,
  228. "actual_start_time": at,
  229. })
  230. if res.Error != nil {
  231. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  232. }
  233. return res.RowsAffected, nil
  234. }
  235. // RecordFinishPatch 结束上课要落库的汇总列(Service 计算后一次性传入)。
  236. //
  237. // 字段:
  238. // - ActualEndTime time.Time:实际下课时间(必填);
  239. // - StudentNumber int32:上课人数(= 打卡明细行数);
  240. // - Income money.Amount:课程收入(= Σ record_student.cost);
  241. // - TeacherReward money.Amount:老师课酬(= Σ record_student.teacher_reward);
  242. // - TeacherRewardType int8:课酬结算方式(1 正常 / 2 按原课程老师课酬无最低课酬)。
  243. type RecordFinishPatch struct {
  244. ActualEndTime time.Time
  245. StudentNumber int32
  246. Income money.Amount
  247. TeacherReward money.Amount
  248. TeacherRewardType int8
  249. }
  250. // MarkRecordFinished 结束上课:`status → 2` + actual_end_time + 汇总列(**CAS 保证只成功一次**)。
  251. //
  252. // 入参:ctx;tx(**必传**);id 上课记录 ID(>0);patch 汇总列。
  253. // 出参:int64 受影响行数(0 = 已被并发结束,Service 按幂等成功处理);ErrBadParam / ErrDBError。
  254. //
  255. // CAS 条件 `status IN (0,1) AND actual_end_time IS NULL`:结束上课的幂等判据是
  256. // "actual_end_time 非空即已结束"(模型红线),并发双击第二个 RowsAffected=0。
  257. func (r *RecordRepo) MarkRecordFinished(ctx context.Context, tx *gorm.DB, id int64, patch RecordFinishPatch) (int64, error) {
  258. if err := requireTx(tx, "MarkRecordFinished"); err != nil {
  259. return 0, err
  260. }
  261. if id <= 0 || patch.ActualEndTime.IsZero() {
  262. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: MarkRecordFinished 需要 id 与 actual_end_time"))
  263. }
  264. if err := patch.Income.Validate(); err != nil {
  265. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  266. }
  267. if err := patch.TeacherReward.Validate(); err != nil {
  268. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  269. }
  270. rewardType := patch.TeacherRewardType
  271. if rewardType == 0 {
  272. rewardType = model.CoursesRecordRewardTypeNormal
  273. }
  274. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesRecord{}))
  275. res := q.Where("id = ? AND status IN ? AND actual_end_time IS NULL", id,
  276. []int32{model.CoursesRecordStatusNotStarted, model.CoursesRecordStatusOngoing}).
  277. Updates(map[string]any{
  278. "status": model.CoursesRecordStatusFinished,
  279. "actual_end_time": patch.ActualEndTime,
  280. "student_number": patch.StudentNumber,
  281. "income": patch.Income.String(),
  282. "teacher_reward": patch.TeacherReward.String(),
  283. "teacher_reward_type": rewardType,
  284. })
  285. if res.Error != nil {
  286. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  287. }
  288. return res.RowsAffected, nil
  289. }
  290. // UpdateRecordSummary 打卡阶段刷新汇总列(不动 status / actual_end_time)。
  291. //
  292. // 入参:ctx;tx(**必传**);id;studentNumber 当前打卡行数;income 当前 Σcost。
  293. // 出参:int64 受影响行数;ErrBadParam / ErrDBError。
  294. //
  295. // 用途:端点 52(打卡/考勤)多次调用的增量汇总;teacher_reward 只在结束时算,故不在本方法内。
  296. func (r *RecordRepo) UpdateRecordSummary(ctx context.Context, tx *gorm.DB, id int64, studentNumber int32, income money.Amount) (int64, error) {
  297. if err := requireTx(tx, "UpdateRecordSummary"); err != nil {
  298. return 0, err
  299. }
  300. if id <= 0 {
  301. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateRecordSummary 需要 id"))
  302. }
  303. if err := income.Validate(); err != nil {
  304. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  305. }
  306. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesRecord{}))
  307. res := q.Where("id = ?", id).Updates(map[string]any{
  308. "student_number": studentNumber,
  309. "income": income.String(),
  310. })
  311. if res.Error != nil {
  312. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  313. }
  314. return res.RowsAffected, nil
  315. }
  316. // ---------------------------------------------------------------------------
  317. // 打卡明细(t_courses_record_student)
  318. // ---------------------------------------------------------------------------
  319. // ListRecordStudents 取某节课的全部打卡明细(按学生 ID 升序,顺序稳定)。
  320. //
  321. // 入参:ctx;recordID 上课记录 ID(<=0 → ErrBadParam)。
  322. // 出参:[]model.CoursesRecordStudent(空结果非 nil);ErrBadParam / ErrDBError。
  323. func (r *RecordRepo) ListRecordStudents(ctx context.Context, recordID int64) ([]model.CoursesRecordStudent, error) {
  324. if recordID <= 0 {
  325. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListRecordStudents 需要 record_id"))
  326. }
  327. db, err := r.db(ctx)
  328. if err != nil {
  329. return nil, err
  330. }
  331. rows := make([]model.CoursesRecordStudent, 0, 16)
  332. q := r.Scoped(db.Model(&model.CoursesRecordStudent{})).Where("record_id = ?", recordID)
  333. if err := q.Order("student_id ASC, id ASC").Find(&rows).Error; err != nil {
  334. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  335. }
  336. return rows, nil
  337. }
  338. // GetRecordStudent 按(上课记录 + 学生)取打卡明细(打卡 upsert 的存在性判据)。
  339. //
  340. // 入参:ctx;recordID(>0);studentID(ULID,非空)。
  341. // 出参:*model.CoursesRecordStudent;ErrNotFound / ErrBadParam / ErrDBError。
  342. func (r *RecordRepo) GetRecordStudent(ctx context.Context, recordID int64, studentID string) (*model.CoursesRecordStudent, error) {
  343. if recordID <= 0 || strings.TrimSpace(studentID) == "" {
  344. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetRecordStudent 需要 record_id/student_id"))
  345. }
  346. db, err := r.db(ctx)
  347. if err != nil {
  348. return nil, err
  349. }
  350. var out model.CoursesRecordStudent
  351. q := r.Scoped(db.Model(&model.CoursesRecordStudent{})).Where("record_id = ? AND student_id = ?", recordID, studentID)
  352. if err := q.Order("id ASC").First(&out).Error; err != nil {
  353. return nil, wrapQueryErr(err, "GetRecordStudent(t_courses_record_student)")
  354. }
  355. return &out, nil
  356. }
  357. // CreateRecordStudent 新建打卡明细(BizNo 由 Service 生成)。
  358. //
  359. // 入参:ctx;tx(**必传**);row(BizNo/RecordID/StudentID/StartTime 必填)。
  360. // 出参:nil 成功(回填 ID);ErrBadParam / ErrDBError。
  361. func (r *RecordRepo) CreateRecordStudent(ctx context.Context, tx *gorm.DB, row *model.CoursesRecordStudent) error {
  362. if err := requireTx(tx, "CreateRecordStudent"); err != nil {
  363. return err
  364. }
  365. if row == nil || strings.TrimSpace(row.BizNo) == "" || row.RecordID <= 0 || strings.TrimSpace(row.StudentID) == "" {
  366. return infraerrors.WithCause(infraerrors.ErrBadParam,
  367. errString("repository: CreateRecordStudent 需要 biz_no/record_id/student_id"))
  368. }
  369. if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
  370. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  371. }
  372. return nil
  373. }
  374. // RecordStudentPatch 打卡明细的可变列(结束上课时一次性写全)。
  375. //
  376. // 字段:Type 上课类型;Pay 是否已支付;Cost 本次课费;GiveCost 本次使用赠送额;
  377. // TeacherReward 本次分摊课酬;EndTime 下课时间(nil = 未完成)。
  378. type RecordStudentPatch struct {
  379. Type int32
  380. Pay int8
  381. Cost money.Amount
  382. GiveCost money.Amount
  383. TeacherReward money.Amount
  384. EndTime *time.Time
  385. Remark *string
  386. }
  387. // UpdateRecordStudent 更新打卡明细(按主键,**同时**重算 cost/give_cost/teacher_reward)。
  388. //
  389. // 入参:ctx;tx(**必传**);id 明细 ID(>0);patch 列值。
  390. // 出参:int64 受影响行数;ErrBadParam / ErrDBError。
  391. //
  392. // 红线:`cost`(真扣费)与 `give_cost`(赠送额)必须分别落库 ——
  393. // 合并成一列会让"赠送不使用余额"的判据失效(模型注释:赠送课次不动余额)。
  394. func (r *RecordRepo) UpdateRecordStudent(ctx context.Context, tx *gorm.DB, id int64, patch RecordStudentPatch) (int64, error) {
  395. if err := requireTx(tx, "UpdateRecordStudent"); err != nil {
  396. return 0, err
  397. }
  398. if id <= 0 {
  399. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateRecordStudent 需要 id"))
  400. }
  401. for _, amt := range []money.Amount{patch.Cost, patch.GiveCost, patch.TeacherReward} {
  402. if amt.IsNegative() {
  403. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 打卡金额不能为负"))
  404. }
  405. if err := amt.Validate(); err != nil {
  406. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  407. }
  408. }
  409. typ := patch.Type
  410. if typ == 0 {
  411. typ = model.CoursesRecordStudentTypeNormal
  412. }
  413. fields := map[string]any{
  414. "type": typ,
  415. "pay": patch.Pay,
  416. "cost": patch.Cost.String(),
  417. "give_cost": patch.GiveCost.String(),
  418. "teacher_reward": patch.TeacherReward.String(),
  419. }
  420. if patch.EndTime != nil {
  421. fields["end_time"] = *patch.EndTime
  422. }
  423. if patch.Remark != nil {
  424. fields["remark"] = *patch.Remark
  425. }
  426. q := r.Scoped(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesRecordStudent{}))
  427. res := q.Where("id = ?", id).Updates(fields)
  428. if res.Error != nil {
  429. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  430. }
  431. return res.RowsAffected, nil
  432. }
  433. // FirstChargedRecordID 取学生「首次扣费上课记录」的 ID —— 实现 service.FirstChargedRecordReader。
  434. //
  435. // 入参:ctx 请求上下文;studentID 报课学生(canonical_user_id ULID)。
  436. // 出参:(recordID int64, found bool, err error);found=false 表示"尚无扣费记录"(不是错误)。
  437. //
  438. // **契约逐字对齐 dump `yuansiwei_finance.sql:763811`(四个谓词缺一不可,禁止自选口径)**:
  439. //
  440. // SELECT recordid FROM t_courses_record_student
  441. // WHERE student_id = ? AND cost > 0 AND end_time IS NOT NULL
  442. // ORDER BY start_time ASC LIMIT 1;
  443. //
  444. // 1. `student_id`:只认本人;
  445. // 2. `cost > 0`:真扣费(排除免费/试听/纯赠送行 —— 赠送行 cost=0,如果算进来会把
  446. // "首次扣费"错判成赠送课,分成改绑到不该绑的订单上);
  447. // 3. `end_time IS NOT NULL`:已结课(未结课的打卡行不是"已发生的扣费事实");
  448. // 4. `ORDER BY start_time ASC LIMIT 1`:最早一节;
  449. // 5. **返回 `record_id` 列(不是本表主键 id)** —— dump 原文取 recordid;取错列会让撤销时
  450. // 的 order_no 与分成明细侧对不上 → 改绑失效、分成悬空(D11 改绑分支的核心陷阱)。
  451. //
  452. // 作用域说明(**有意不加 Scoped**):本方法是分账撤销(divide_into_log_delete)的内部判定,
  453. // dump 的取数口径无机构/校区过滤;入参 studentID 来自本学生自己的分成明细行
  454. // (ListLogsByStudentOrder 已按 (student_id, order_no) 精确锁定),不存在跨租户扫描面。
  455. // 因此这里用裸 db(ctx)(分账撤销场景 reqctx 内可能没有 Scope,若走 Scoped 会 panic)。
  456. func (r *RecordRepo) FirstChargedRecordID(ctx context.Context, studentID string) (int64, bool, error) {
  457. studentID = strings.TrimSpace(studentID)
  458. if studentID == "" {
  459. return 0, false, infraerrors.WithCause(infraerrors.ErrBadParam,
  460. errString("repository: FirstChargedRecordID 需要 student_id"))
  461. }
  462. db, err := r.db(ctx)
  463. if err != nil {
  464. return 0, false, err
  465. }
  466. var row struct {
  467. RecordID int64 `gorm:"column:record_id"`
  468. }
  469. q := db.WithContext(ctxOrBackground(ctx)).Table(model.CoursesRecordStudent{}.TableName()).
  470. Select("record_id").
  471. Where("student_id = ? AND cost > 0 AND end_time IS NOT NULL", studentID).
  472. Order("start_time ASC").
  473. Limit(1)
  474. if err := q.Take(&row).Error; err != nil {
  475. if errors.Is(err, gorm.ErrRecordNotFound) {
  476. return 0, false, nil // 无扣费记录:走"回滚并删除分成"分支(D11)
  477. }
  478. return 0, false, infraerrors.WithCause(infraerrors.ErrDBError, err)
  479. }
  480. if row.RecordID <= 0 {
  481. // 数据异常(record_id 未回填):按"无首扣记录"处理更安全(宁可回滚也不改绑到 0)
  482. return 0, false, nil
  483. }
  484. return row.RecordID, true, nil
  485. }