course_handler_test.go 76 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706
  1. // 课程 / 排班 / 打卡端点单测(AC-13c:端点 44~57)。
  2. //
  3. // 断言纪律(与前几批一致):
  4. // - 只断 body 契约(HTTP 恒 200 + body.code),不断 HTTP 状态码;
  5. // - **每个端点至少一条"写错会变红"的断言**,且尽量落在**数据库**上而不是只断响应体
  6. // (响应体可以"改一个字段名"骗过自己,库里的列不会);
  7. // - 错误码只比对 `apperr` / `infraerrors` 的常量,**不硬编码数字**。
  8. //
  9. // 覆盖结构:
  10. //
  11. // 44 课程列表 teacherIds 来自 t_courses_class、recordCount 来自 t_courses_record;
  12. // 跨机构课程**不出现**(Scoped)
  13. // 45 新建/更新课程 新建恒 status=0(未发布);PUT 是部分更新(未传字段不变)
  14. // 46 排班规则 status=2(已结束)的规则**必须出现**(onlyActive 会漏);week 原值
  15. // 47 新建规则 同老师+同周+时段重叠 → **3122**;不同周 → 200
  16. // 49 生成课表 幂等:第二次 generated=0 / changed=false
  17. // 50 开始上课 actual_start_time 落库;重复开始 → **3121**
  18. // 51 结束上课 跨 5 张表断言(record_student / expense / expense_school / divide_log / payroll)
  19. // + 分账失败整体回滚(变异)
  20. // 52 打卡 已结束的课 → **3121**;未结束 → 写 cost 并回显 checked
  21. // 53 报课学生 欠费字段取 remainder_receivable(造 unpaid=999 的陷阱行)
  22. // 54 报名 快照恒等式 receivable = net_receipts + remainder_receivable;
  23. // confirm_discount_user_id 必须写(否则永远进不了核销候选);重复 → 3140
  24. // 55 欠费列表 **remainder_receivable > 0**(造 unpaid=999/remainder=0 的陷阱行必须不出现)
  25. // 56 欠费核销 net_receipts 增、remainder_receivable 减(同一条 UPDATE);跨机构 → 1004
  26. // 57 学生课表 按学生过滤(另一个学生的课不出现);跨用户查 → 1004
  27. package handler
  28. import (
  29. "context"
  30. "encoding/json"
  31. "errors"
  32. "strings"
  33. "testing"
  34. "time"
  35. "github.com/gofiber/fiber/v2"
  36. "gorm.io/gorm"
  37. "yuansiwei/unified-backend/internal/academic/apperr"
  38. "yuansiwei/unified-backend/internal/academic/model"
  39. "yuansiwei/unified-backend/internal/academic/money"
  40. "yuansiwei/unified-backend/internal/academic/port"
  41. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  42. appscope "yuansiwei/unified-backend/internal/infra/scope"
  43. )
  44. // ---------------------------------------------------------------- 测试基础设施
  45. // 测试用常量(ULID 形态的假 ID,长度 26 与真实约束一致)。
  46. const (
  47. courseTestStudent = "01HSTUDENT00000000000001"
  48. courseTestOther = "01HSTUDENT00000000000002"
  49. courseTestTeacher = "01HTEACHER00000000000001"
  50. courseTestTeacher2 = "01HTEACHER00000000000002"
  51. courseTestRecommender = "01HRECOMMENDER00000000001"
  52. courseTestUpline = "01HUPLINE00000000000001"
  53. courseTestOperator = "01HOPERATOR0000000000001"
  54. )
  55. // newCourseTestDB 课程域测试库:资金四表(复用)+ 课程 6 表 + 课酬 3 表 + 分账 2 表。
  56. //
  57. // 与 `newHandlerTestDB` 同构地逐表 AutoMigrate 并删除**非唯一索引**(SQLite 索引名库级唯一,
  58. // 而 academic model 多表共用 `idx_inst_campus` 等名字)。
  59. func newCourseTestDB(t *testing.T) *gorm.DB {
  60. t.Helper()
  61. base := newHandlerTestDB(t) // 资金 4 表 + 活动 6 表 + t_campus(机构 1 / 校区 10)
  62. extra := []any{
  63. &model.Courses{}, &model.CoursesClassify{}, &model.CoursesClass{},
  64. &model.CoursesStudent{}, &model.CoursesRecord{}, &model.CoursesRecordStudent{},
  65. &model.CoursePayment{}, &model.CoursePaymentDetail{}, &model.TeacherInformation{},
  66. &model.UserDivideInto{}, &model.UserDivideIntoLog{},
  67. }
  68. // 先把 `newHandlerTestDB` 留下的**唯一索引**清干净:`uk_biz_no` 在 t_user_balance_request
  69. // (资金域)与 3 张课程表上同名,SQLite 的索引名是**库级唯一**,而 MySQL 是**表级**
  70. // ⇒ 不清会直接建表失败。
  71. // ⚠ 测试库因此**放弃唯一约束**(含 `uk_biz_no` / `uk_user_inst_campus` 等)——
  72. // 这只是**规避 sqlite 索引名冲突**的手段:**生产 MySQL 的唯一约束照常生效**,
  73. // 幂等/去重行为不依赖测试库(那由 `INSERT IGNORE` + 唯一键在真库上保证)。
  74. var leftovers []string
  75. if err := base.Raw(
  76. "SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'").Scan(&leftovers).Error; err != nil {
  77. t.Fatalf("读取遗留索引失败: %v", err)
  78. }
  79. for _, name := range leftovers {
  80. if err := base.Exec("DROP INDEX IF EXISTS `" + name + "`").Error; err != nil {
  81. t.Fatalf("删除遗留索引(%s) 失败: %v", name, err)
  82. }
  83. }
  84. for _, tbl := range extra {
  85. if err := base.AutoMigrate(tbl); err != nil {
  86. t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
  87. }
  88. stmt := &gorm.Statement{DB: base}
  89. if err := stmt.Parse(tbl); err != nil {
  90. t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
  91. }
  92. var idxRows []struct {
  93. Name string
  94. SQL *string
  95. }
  96. if err := base.Raw(
  97. "SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_%'",
  98. stmt.Schema.Table).Scan(&idxRows).Error; err != nil {
  99. t.Fatalf("读索引清单(%s) 失败: %v", stmt.Schema.Table, err)
  100. }
  101. for _, row := range idxRows {
  102. // ⚠ **唯一索引也一并删除**(与 `newHandlerTestDB` 不同):academic model 里
  103. // `uk_biz_no` 同时出现在 t_courses_student / t_courses_record / t_courses_record_student
  104. // 三张表上,而 SQLite 的索引名是**库级唯一**(MySQL 是表级)⇒ 第二张表建索引必报
  105. // "index uk_biz_no already exists"。测试库因此放弃唯一约束(生产是 MySQL,不受影响;
  106. // 服务层测试 `migrateLearningTables` 同款处理)。
  107. if err := base.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil {
  108. t.Fatalf("删索引(%s) 失败: %v", row.Name, err)
  109. }
  110. }
  111. }
  112. return base
  113. }
  114. // newCourseTestApp 挂 15 条课程域路由(**不做角色白名单**:那是 e2e 的职责,见 route_e2e_test.go)。
  115. //
  116. // 作用域由 `X-Test-Scope` 头选择:`none` = 不注入(1001)、`super` = 超管、`student` = 学生、
  117. // `teacher` = 老师、缺省 = 财务(机构 1 / 校区 10)。
  118. func newCourseTestApp(t *testing.T, db *gorm.DB) *fiber.App {
  119. t.Helper()
  120. return newCourseTestAppWithUsers(t, db, nil)
  121. }
  122. // newCourseTestAppWithUsers 同 `newCourseTestApp`,但可注入用户姓名端口(D4 用)。
  123. //
  124. // 入参:users `port.UserNameReader`(nil = 未注入,端点 53 的 realName 应全部为 null)。
  125. func newCourseTestAppWithUsers(t *testing.T, db *gorm.DB, users port.UserNameReader) *fiber.App {
  126. t.Helper()
  127. h, err := New(Config{DB: db, Users: users})
  128. if err != nil {
  129. t.Fatalf("handler.New 失败: %v", err)
  130. }
  131. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  132. app.Use(func(c *fiber.Ctx) error {
  133. switch c.Get("X-Test-Scope") {
  134. case "none":
  135. // 不写作用域 → 未登录语义(1001)
  136. case "super":
  137. appscope.Set(c, testSuperScope())
  138. case "student":
  139. appscope.Set(c, &appscope.Scope{
  140. InstitutionID: 1, CampusID: 10,
  141. UserID: courseTestStudent, Role: RoleStudent,
  142. })
  143. case "teacher":
  144. appscope.Set(c, &appscope.Scope{
  145. InstitutionID: 1, CampusID: 10,
  146. UserID: courseTestTeacher, Role: RoleTeacher,
  147. })
  148. default:
  149. appscope.Set(c, testScope())
  150. }
  151. return c.Next()
  152. })
  153. // 注册顺序与 route.go 一致(静态段先于 `:id`)
  154. app.Get(PathCourses, h.ListCourses)
  155. app.Post(PathCourses, h.CreateCourse)
  156. app.Get(PathCourseArrears, h.ListCourseArrears)
  157. app.Post(PathCourseArrearsCounteract, h.CounteractCourseArrears)
  158. app.Post(PathCourseRecordStart, h.StartCourseRecord)
  159. app.Post(PathCourseRecordFinish, h.FinishCourseRecord)
  160. app.Post(PathCourseRecordAttendance, h.MarkCourseRecordAttendance)
  161. app.Put(PathCourseDetail, h.UpdateCourse)
  162. app.Get(PathCourseClasses, h.ListCourseClasses)
  163. app.Post(PathCourseClasses, h.CreateCourseClass)
  164. app.Get(PathCourseRecords, h.ListCourseRecords)
  165. app.Post(PathCourseRecordsGenerate, h.GenerateCourseRecords)
  166. app.Get(PathCourseStudents, h.ListCourseStudents)
  167. app.Post(PathCourseStudents, h.EnrollCourseStudent)
  168. app.Get(PathStudentSchedule, h.GetStudentSchedule)
  169. return app
  170. }
  171. // seedCourse 建一门课程(机构 1 / 指定校区;classify 默认 0)。
  172. func seedCourse(t *testing.T, db *gorm.DB, name string, campusID int64) *model.Courses {
  173. t.Helper()
  174. row := &model.Courses{
  175. InstitutionID: 1, CampusID: campusID, Name: name,
  176. Status: model.CoursesStatusUnpublished, Number: 10, Duration: "1.0", Week: "-1",
  177. OriginalCost: "100.00", RealPrice: "100.00", DiscountCash: "0.00",
  178. }
  179. if err := db.Create(row).Error; err != nil {
  180. t.Fatalf("建课程失败: %v", err)
  181. }
  182. return row
  183. }
  184. // seedClassRule 建一条排班规则。
  185. func seedClassRule(t *testing.T, db *gorm.DB, coursesID, classID int64, teacher, week string, status int32, startHM, endHM string) *model.CoursesClass {
  186. t.Helper()
  187. start := mustHM(t, startHM)
  188. var end *time.Time
  189. if endHM != "" {
  190. end = mustHM(t, endHM)
  191. }
  192. row := &model.CoursesClass{
  193. Status: status, CoursesID: coursesID, ClassID: classID,
  194. TeacherID: &teacher, InstitutionID: 1, CampusID: 10,
  195. StandardLessonCost: "100.00", Number: 10, LessonCost: "100.00",
  196. DiscountCash: "0.00", FinishedNumber: 0, Confirm: model.CoursesClassConfirmNo,
  197. Week: week, StartTime: start, EndTime: end,
  198. }
  199. if err := db.Create(row).Error; err != nil {
  200. t.Fatalf("建排班规则失败: %v", err)
  201. }
  202. return row
  203. }
  204. // mustHM 把 "HH:MM" 转成时分列(1970-01-01 + 时分,UTC),与服务层口径一致。
  205. func mustHM(t *testing.T, hm string) *time.Time {
  206. t.Helper()
  207. parts := strings.Split(hm, ":")
  208. hour := mustAtoi(t, parts[0])
  209. minute := 0
  210. if len(parts) > 1 {
  211. minute = mustAtoi(t, parts[1])
  212. }
  213. at := time.Date(1970, 1, 1, hour, minute, 0, 0, time.UTC)
  214. return &at
  215. }
  216. // mustAtoi 测试用 Atoi(失败即 fatal)。
  217. func mustAtoi(t *testing.T, raw string) int {
  218. t.Helper()
  219. value := 0
  220. for _, ch := range strings.TrimSpace(raw) {
  221. if ch < '0' || ch > '9' {
  222. t.Fatalf("非数字: %q", raw)
  223. }
  224. value = value*10 + int(ch-'0')
  225. }
  226. return value
  227. }
  228. // seedEnrollment 建报课行(**故意把 unpaid 写成垃圾值**,用于证明"欠费口径不用 unpaid")。
  229. func seedEnrollment(t *testing.T, db *gorm.DB, coursesID int64, studentID, bizNo string,
  230. lessonCost, receivable, netReceipts, remainderReceivable, unpaid string) *model.CoursesStudent {
  231. t.Helper()
  232. confirm := courseTestOperator
  233. row := &model.CoursesStudent{
  234. BizNo: bizNo, CoursesID: coursesID, InstitutionID: 1, CampusID: 10,
  235. StudentID: studentID, Type: model.CoursesStudentTypeNormal,
  236. Status: model.CoursesStudentStatusNormal,
  237. Receivable: receivable, NetReceipts: netReceipts, Unpaid: unpaid,
  238. LessonCost: lessonCost, Number: 10, FinishedNumber: 0,
  239. DiscountCash: "0.00", GiveNumber: 0, GiveCost: "0.00",
  240. RemainderNumber: 10, RemainderCost: netReceipts,
  241. RemainderReceivable: remainderReceivable, BadDebt: "0.00",
  242. ConfirmDiscountUserID: &confirm,
  243. }
  244. if err := db.Create(row).Error; err != nil {
  245. t.Fatalf("建报课行失败: %v", err)
  246. }
  247. return row
  248. }
  249. // seedRecord 建排班实例。
  250. func seedRecord(t *testing.T, db *gorm.DB, coursesID, classID int64, bizNo string, status int32, at time.Time) *model.CoursesRecord {
  251. t.Helper()
  252. teacher := courseTestTeacher
  253. end := at.Add(time.Hour)
  254. row := &model.CoursesRecord{
  255. BizNo: bizNo, CoursesID: coursesID, ClassID: classID,
  256. TeacherID: &teacher, InstitutionID: 1, CampusID: 10, Status: status,
  257. StartTime: &at, EndTime: &end,
  258. }
  259. if err := db.Create(row).Error; err != nil {
  260. t.Fatalf("建排班实例失败: %v", err)
  261. }
  262. return row
  263. }
  264. // requireMoney 比较"元字符串"金额(**不比较字面量**)。
  265. //
  266. // 为什么不直接比字符串:SQLite 没有 DECIMAL 类型,`"100.00"` 读回来是 `"100"`;
  267. // 生产 MySQL 读回来是 `"100.00"`。断言要跨两种方言成立,就必须比**分值**而不是比字面量。
  268. func requireMoney(t *testing.T, label, got, want string) {
  269. t.Helper()
  270. if strings.TrimSpace(got) == "" {
  271. t.Fatalf("%s 为空(读不到列),期望 %s", label, want)
  272. }
  273. g, gerr := money.Parse(got)
  274. if gerr != nil {
  275. t.Fatalf("%s = %q 不是合法金额: %v", label, got, gerr)
  276. }
  277. w, werr := money.Parse(want)
  278. if werr != nil {
  279. t.Fatalf("期望值 %q 不是合法金额: %v", want, werr)
  280. }
  281. if g.Cents() != w.Cents() {
  282. t.Fatalf("%s = %q(%d 分),期望 %q(%d 分)", label, got, g.Cents(), want, w.Cents())
  283. }
  284. }
  285. // jsonMap 把响应 data 解成 map(断言"某个键必须不存在"时用)。
  286. func jsonMap(t *testing.T, raw json.RawMessage) map[string]any {
  287. t.Helper()
  288. out := map[string]any{}
  289. if len(raw) == 0 {
  290. return out
  291. }
  292. if err := json.Unmarshal(raw, &out); err != nil {
  293. t.Fatalf("响应 data 不是 JSON 对象: %v(原文 %s)", err, string(raw))
  294. }
  295. return out
  296. }
  297. // pageList 取分页响应的 list 字段([]any)。
  298. func pageList(t *testing.T, raw json.RawMessage) []any {
  299. t.Helper()
  300. got := jsonMap(t, raw)
  301. items, ok := got["list"].([]any)
  302. if !ok {
  303. t.Fatalf("响应缺少 list 数组:%s", string(raw))
  304. }
  305. return items
  306. }
  307. // ---------------------------------------------------------------- 端点 44:课程列表
  308. // TestListCoursesAggregatesTeacherIDsAndRecordCount 端点 44 的两个派生列必须来自**另外两张表**。
  309. //
  310. // 变红条件:
  311. // - `teacherIds` 恒空(漏 join `t_courses_class`)→ 断言 1 红;
  312. // - `recordCount` 恒 0(漏 count `t_courses_record`)→ 断言 2 红;
  313. // - 机构过滤失效(把机构 2 的课程也返回)→ 断言 3 红。
  314. func TestListCoursesAggregatesTeacherIDsAndRecordCount(t *testing.T) {
  315. db := newCourseTestDB(t)
  316. course := seedCourse(t, db, "数学A", 10)
  317. // 两条规则、三个不同老师(含一条重复老师)→ teacherIds 必须去重且升序
  318. seedClassRule(t, db, course.ID, 1001, courseTestTeacher2, "1", model.CoursesClassStatusOngoing, "09:00", "10:00")
  319. seedClassRule(t, db, course.ID, 1001, courseTestTeacher, "3", model.CoursesClassStatusEnded, "11:00", "12:00")
  320. seedClassRule(t, db, course.ID, 1002, courseTestTeacher2, "5", model.CoursesClassStatusOngoing, "13:00", "14:00")
  321. for i := 0; i < 3; i++ {
  322. seedRecord(t, db, course.ID, 1001, "REC"+string(rune('A'+i)), model.CoursesRecordStatusNotStarted,
  323. time.Date(2026, 11, 10+i, 9, 0, 0, 0, time.Local))
  324. }
  325. // 另一机构的课程(不得出现在机构 1 的列表里)
  326. other := &model.Courses{
  327. InstitutionID: 2, CampusID: 20, Name: "别家课程", Status: model.CoursesStatusPublished,
  328. Duration: "1.0", Week: "-1", OriginalCost: "0.00", RealPrice: "0.00", DiscountCash: "0.00",
  329. }
  330. if err := db.Create(other).Error; err != nil {
  331. t.Fatalf("建跨机构课程失败: %v", err)
  332. }
  333. app := newCourseTestApp(t, db)
  334. code, msg, data := doJSON(t, app, fiber.MethodGet, PathCourses, nil, "X-Test-Token", "t")
  335. if code != 200 {
  336. t.Fatalf("GET %s = %d(%s),期望 200", PathCourses, code, msg)
  337. }
  338. payload := jsonMap(t, data)
  339. items, _ := payload["list"].([]any)
  340. if len(items) != 1 {
  341. t.Fatalf("列表行数 = %d,期望 1(跨机构课程必须被 Scoped 过滤):%s", len(items), string(data))
  342. }
  343. row, _ := items[0].(map[string]any)
  344. // 断言 1:teacherIds 去重 + 升序,且**来自 t_courses_class**
  345. teacherIDs, _ := row["teacherIds"].([]any)
  346. if len(teacherIDs) != 2 {
  347. t.Fatalf("teacherIds = %v,期望 2 个(去重后 %s/%s)—— 空数组说明没查 t_courses_class",
  348. row["teacherIds"], courseTestTeacher, courseTestTeacher2)
  349. }
  350. if teacherIDs[0] != courseTestTeacher || teacherIDs[1] != courseTestTeacher2 {
  351. t.Fatalf("teacherIds = %v,期望升序 [%s %s]", teacherIDs, courseTestTeacher, courseTestTeacher2)
  352. }
  353. // 断言 2:recordCount 来自 t_courses_record 计数
  354. if got, _ := row["recordCount"].(float64); got != 3 {
  355. t.Fatalf("recordCount = %v,期望 3(t_courses_record 三行);0 说明没做计数聚合", row["recordCount"])
  356. }
  357. // 断言 3:作用域键(机构/校区)必须下发
  358. if got, _ := row["institutionId"].(float64); got != 1 {
  359. t.Fatalf("institutionId = %v,期望 1", row["institutionId"])
  360. }
  361. }
  362. // ---------------------------------------------------------------- 端点 45:课程 CRUD
  363. // TestCreateCourseIsUnpublished 端点 45 新建:恒落 status=0(未发布),作用域列取 token 而非 body。
  364. //
  365. // 变红条件:把 body 的 status/institutionId 透传进库(或忘写作用域列)→ 库里 status/institution_id 不符。
  366. func TestCreateCourseIsUnpublished(t *testing.T) {
  367. db := newCourseTestDB(t)
  368. app := newCourseTestApp(t, db)
  369. code, msg, data := doJSON(t, app, fiber.MethodPost, PathCourses, map[string]any{
  370. "name": "英语B", "duration": "1.5", "number": 8, "realPrice": "200.00",
  371. "week": "1,3,5", "status": 3, "institutionId": 99, // status/institutionId 必须被忽略
  372. }, "X-Test-Token", "t")
  373. if code != 200 {
  374. t.Fatalf("POST %s = %d(%s),期望 200", PathCourses, code, msg)
  375. }
  376. id, _ := jsonMap(t, data)["id"].(float64)
  377. if id <= 0 {
  378. t.Fatalf("响应 id = %v,期望 >0", jsonMap(t, data)["id"])
  379. }
  380. var row model.Courses
  381. if err := db.First(&row, int64(id)).Error; err != nil {
  382. t.Fatalf("回读课程失败: %v", err)
  383. }
  384. // status 断言拆到 TestCreateCourseStatusIsUnpublished(D11 的回归门禁,含缺陷成因与修法)。
  385. if row.InstitutionID != 1 {
  386. t.Fatalf("institution_id = %d,期望 1(取作用域,不信 body 的 99)", row.InstitutionID)
  387. }
  388. if row.CampusID != 10 {
  389. t.Fatalf("campus_id = %d,期望 10(作用域校区)", row.CampusID)
  390. }
  391. if row.Week != "1,3,5" {
  392. t.Fatalf("week = %q,期望原值 1,3,5", row.Week)
  393. }
  394. // 2001:缺 name
  395. if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathCourses, map[string]any{
  396. "duration": "1.0",
  397. }, "X-Test-Token", "t"); code != infraerrors.ErrBadParam.Code {
  398. t.Fatalf("缺 name = %d(%s),期望 2001", code, msg)
  399. }
  400. }
  401. // TestCreateCourseStatusIsUnpublished **新建课程必须落 status=0(未发布)**——D11 的回归门禁。
  402. //
  403. // 缺陷原貌(2026-11-09 实测):`CourseService.CreateCourse` 显式写 `Status=0`,但
  404. // `model.Courses.Status` 的 gorm tag 带 `default:1` ⇒ GORM 对"有 default tag 且值为零"的列
  405. // **跳过该列**、由 DB 默认值填充 ⇒ 落库 `status=1`("新建即发布":课程直接出现在可报名列表)。
  406. // MySQL 与 SQLite 走同一段 GORM 回调,生产同样成立。
  407. //
  408. // 修复(team-lead 裁决):**去掉 `model.Courses.Status` 的 `default:1`(DDL 不动)**,
  409. // 而不是 `tx.Select("*").Create(row)` —— 后者会把 `created_at`/`updated_at` 等零值一并写入,
  410. // 正是 `a1971b7`(审计时间零值落库)修过的反向坑。
  411. //
  412. // 本用例是**回归门禁**:把 `default:1` 加回 tag,本用例必红(实测见交付报告)。
  413. func TestCreateCourseStatusIsUnpublished(t *testing.T) {
  414. db := newCourseTestDB(t)
  415. app := newCourseTestApp(t, db)
  416. code, msg, data := doJSON(t, app, fiber.MethodPost, PathCourses, map[string]any{
  417. "name": "状态测试课", "duration": "1.0",
  418. }, "X-Test-Token", "t")
  419. if code != 200 {
  420. t.Fatalf("POST %s = %d(%s),期望 200", PathCourses, code, msg)
  421. }
  422. id, _ := jsonMap(t, data)["id"].(float64)
  423. var row model.Courses
  424. if err := db.First(&row, int64(id)).Error; err != nil {
  425. t.Fatalf("回读课程失败: %v", err)
  426. }
  427. if row.Status != model.CoursesStatusUnpublished {
  428. t.Fatalf("新建课程 status = %d,期望 0(未发布)—— status 只能走状态机入口", row.Status)
  429. }
  430. }
  431. // TestUpdateCourseIsPartial 端点 45 更新:只改传入列(未传的 name/number 必须保持原值)。
  432. //
  433. // 变红条件:写成"整体替换"(未传字段被清空/归零)→ name 或 number 变空/0。
  434. func TestUpdateCourseIsPartial(t *testing.T) {
  435. db := newCourseTestDB(t)
  436. course := seedCourse(t, db, "物理C", 10)
  437. course.Number = 12
  438. if err := db.Save(course).Error; err != nil {
  439. t.Fatalf("更新种子课程失败: %v", err)
  440. }
  441. app := newCourseTestApp(t, db)
  442. code, msg, _ := doJSON(t, app, fiber.MethodPut, "/admin/courses/"+itoa(course.ID), map[string]any{
  443. "remark": "只改备注",
  444. }, "X-Test-Token", "t")
  445. if code != 200 {
  446. t.Fatalf("PUT 课程 = %d(%s),期望 200", code, msg)
  447. }
  448. var row model.Courses
  449. if err := db.First(&row, course.ID).Error; err != nil {
  450. t.Fatalf("回读课程失败: %v", err)
  451. }
  452. if row.Name != "物理C" {
  453. t.Fatalf("name = %q,期望保持 物理C —— 部分更新被写成了整体替换", row.Name)
  454. }
  455. if row.Number != 12 {
  456. t.Fatalf("number = %d,期望保持 12 —— 未传字段被清零", row.Number)
  457. }
  458. if row.Remark == nil || *row.Remark != "只改备注" {
  459. t.Fatalf("remark = %v,期望 只改备注", row.Remark)
  460. }
  461. }
  462. // itoa 测试用整数转字符串(避免引入 strconv 之外的依赖)。
  463. func itoa(v int64) string {
  464. if v == 0 {
  465. return "0"
  466. }
  467. digits := make([]byte, 0, 20)
  468. for v > 0 {
  469. digits = append([]byte{byte('0' + v%10)}, digits...)
  470. v /= 10
  471. }
  472. return string(digits)
  473. }
  474. // ---------------------------------------------------------------- 端点 46:排班规则
  475. // TestListCourseClassesKeepsEndedRules 端点 46 必须列出**全部**规则(含 status=2 已结束)。
  476. //
  477. // 为什么这条重要:AC-11 的 `ListClasses(onlyActive=true)` 是"排班生成"专用过滤,
  478. // 若 handler 顺手复用它,已结束的规则会静默消失(前端看到的是"这个班没排过课")。
  479. // 变红条件:把 onlyActive 传成 true → total 从 2 变 1。
  480. func TestListCourseClassesKeepsEndedRules(t *testing.T) {
  481. db := newCourseTestDB(t)
  482. course := seedCourse(t, db, "化学D", 10)
  483. seedClassRule(t, db, course.ID, 2001, courseTestTeacher, "1,3,5", model.CoursesClassStatusOngoing, "09:00", "10:00")
  484. seedClassRule(t, db, course.ID, 2002, courseTestTeacher2, "2", model.CoursesClassStatusEnded, "14:00", "15:00")
  485. app := newCourseTestApp(t, db)
  486. code, msg, data := doJSON(t, app, fiber.MethodGet, "/admin/courses/"+itoa(course.ID)+"/classes", nil, "X-Test-Token", "t")
  487. if code != 200 {
  488. t.Fatalf("GET classes = %d(%s),期望 200", code, msg)
  489. }
  490. total, _ := jsonMap(t, data)["total"].(float64)
  491. if total != 2 {
  492. t.Fatalf("total = %v,期望 2(含 status=2 的规则)—— 1 说明误用了 onlyActive 过滤", total)
  493. }
  494. items := pageList(t, data)
  495. first, _ := items[0].(map[string]any)
  496. // week 必须是**原值字符串**(契约写 number 1~7,但该列是多值字符串,见 classRowView 注释)
  497. if week, _ := first["week"].(string); week != "1,3,5" {
  498. t.Fatalf("week = %v(%T),期望原值字符串 \"1,3,5\"", first["week"], first["week"])
  499. }
  500. if _, hasRoom := first["room"]; hasRoom {
  501. t.Fatalf("响应出现了 room 字段(%s):t_courses_class 没有 room 列,不得下发", string(data))
  502. }
  503. }
  504. // ---------------------------------------------------------------- 端点 47:新建排班规则
  505. // TestCreateCourseClassConflictReturns3122 端点 47:同老师 + 同周 + 时段重叠 → 3122。
  506. //
  507. // 变红条件:去掉冲突判定 → 冲突请求返回 200(并且库里多出一条重叠规则)。
  508. func TestCreateCourseClassConflictReturns3122(t *testing.T) {
  509. db := newCourseTestDB(t)
  510. course := seedCourse(t, db, "生物E", 10)
  511. seedClassRule(t, db, course.ID, 3001, courseTestTeacher, "1", model.CoursesClassStatusOngoing, "09:00", "10:00")
  512. app := newCourseTestApp(t, db)
  513. base := map[string]any{
  514. "teacherId": courseTestTeacher, "week": "1", "start": "09:30", "end": "10:30",
  515. "lessonCost": "100.00", "number": 10,
  516. }
  517. code, msg, _ := doJSON(t, app, fiber.MethodPost, "/admin/courses/"+itoa(course.ID)+"/classes", base, "X-Test-Token", "t")
  518. if code != apperr.CodeScheduleConflict {
  519. t.Fatalf("重叠排班 = %d(%s),期望 3122(排班冲突)", code, msg)
  520. }
  521. var count int64
  522. db.Model(&model.CoursesClass{}).Where("courses_id = ?", course.ID).Count(&count)
  523. if count != 1 {
  524. t.Fatalf("冲突被拒后规则数 = %d,期望仍为 1(不得先写后报错)", count)
  525. }
  526. // 对照:换一天(week=2)必须成功,否则说明冲突判定过宽
  527. okBody := map[string]any{
  528. "teacherId": courseTestTeacher, "week": "2", "start": "09:30", "end": "10:30",
  529. "lessonCost": "100.00", "number": 10,
  530. }
  531. code, msg, data := doJSON(t, app, fiber.MethodPost, "/admin/courses/"+itoa(course.ID)+"/classes", okBody, "X-Test-Token", "t")
  532. if code != 200 {
  533. t.Fatalf("不同周的排班 = %d(%s),期望 200(冲突判定过宽)", code, msg)
  534. }
  535. if _, ok := jsonMap(t, data)["classId"]; !ok {
  536. t.Fatalf("响应缺 classId:%s", string(data))
  537. }
  538. }
  539. // ---------------------------------------------------------------- 端点 49:生成课表
  540. // TestGenerateRecordsIdempotent 端点 49 幂等:同 courses+start_time 不重复插。
  541. //
  542. // 变红条件:去掉 `FindByCoursesStart` 幂等判据 → 第二次 generated>0、库里行数翻倍。
  543. func TestGenerateRecordsIdempotent(t *testing.T) {
  544. db := newCourseTestDB(t)
  545. course := seedCourse(t, db, "地理F", 10)
  546. seedClassRule(t, db, course.ID, 4001, courseTestTeacher, "-1", model.CoursesClassStatusOngoing, "09:00", "10:00")
  547. app := newCourseTestApp(t, db)
  548. body := map[string]any{"days": 3, "from": "2026-11-10"}
  549. code, msg, data := doJSON(t, app, fiber.MethodPost, "/admin/courses/"+itoa(course.ID)+"/records/generate", body, "X-Test-Token", "t")
  550. if code != 200 {
  551. t.Fatalf("生成课表 = %d(%s),期望 200", code, msg)
  552. }
  553. if got, _ := jsonMap(t, data)["generated"].(float64); got != 3 {
  554. t.Fatalf("首次 generated = %v,期望 3", jsonMap(t, data)["generated"])
  555. }
  556. code, msg, data = doJSON(t, app, fiber.MethodPost, "/admin/courses/"+itoa(course.ID)+"/records/generate", body, "X-Test-Token", "t")
  557. if code != 200 {
  558. t.Fatalf("二次生成 = %d(%s),期望 200", code, msg)
  559. }
  560. payload := jsonMap(t, data)
  561. if got, _ := payload["generated"].(float64); got != 0 {
  562. t.Fatalf("二次 generated = %v,期望 0(幂等:同 courses+start_time 不重复插)", payload["generated"])
  563. }
  564. if changed, _ := payload["changed"].(bool); changed {
  565. t.Fatalf("二次 changed = true,期望 false(本次无新增)")
  566. }
  567. var count int64
  568. db.Model(&model.CoursesRecord{}).Where("courses_id = ?", course.ID).Count(&count)
  569. if count != 3 {
  570. t.Fatalf("库中实例数 = %d,期望 3(幂等失败会翻倍成 6)", count)
  571. }
  572. }
  573. // ---------------------------------------------------------------- 端点 50 / 52:开始上课 / 打卡
  574. // TestStartRecordWritesActualStart 端点 50:status 0→1 且 `actual_start_time` 落库;重复开始 → 3121。
  575. //
  576. // 变红条件:只改 status 不写 actual_start_time → 断言 1 红;重复开始返回 200 → 断言 2 红。
  577. func TestStartRecordWritesActualStart(t *testing.T) {
  578. db := newCourseTestDB(t)
  579. course := seedCourse(t, db, "历史G", 10)
  580. record := seedRecord(t, db, course.ID, 5001, "RECSTART", model.CoursesRecordStatusNotStarted,
  581. time.Now().Add(-time.Hour))
  582. app := newCourseTestApp(t, db)
  583. path := "/admin/courses/records/" + itoa(record.ID) + "/start"
  584. code, msg, data := doJSON(t, app, fiber.MethodPost, path, nil, "X-Test-Token", "t")
  585. if code != 200 {
  586. t.Fatalf("开始上课 = %d(%s),期望 200", code, msg)
  587. }
  588. payload := jsonMap(t, data)
  589. if got, _ := payload["status"].(float64); got != float64(model.CoursesRecordStatusOngoing) {
  590. t.Fatalf("status = %v,期望 1", payload["status"])
  591. }
  592. if got, _ := payload["courseId"].(float64); got != float64(course.ID) {
  593. t.Fatalf("courseId = %v,期望 %d", payload["courseId"], course.ID)
  594. }
  595. var row struct {
  596. Status int32 `gorm:"column:status"`
  597. ActualStart *time.Time `gorm:"column:actual_start_time"`
  598. }
  599. if err := db.Raw("SELECT status, actual_start_time FROM t_courses_record WHERE id = ?", record.ID).Scan(&row).Error; err != nil {
  600. t.Fatalf("回读实例失败: %v", err)
  601. }
  602. if row.Status != model.CoursesRecordStatusOngoing || row.ActualStart == nil {
  603. t.Fatalf("库里 status=%d actual_start_time=%v,期望 1 且非空(模型红线:开课时间必写)", row.Status, row.ActualStart)
  604. }
  605. // 重复开始 → 3121(不会写出第二个开课时间)
  606. if code, msg, _ := doJSON(t, app, fiber.MethodPost, path, nil, "X-Test-Token", "t"); code != apperr.CodeRecordStateInvalid {
  607. t.Fatalf("重复开始上课 = %d(%s),期望 3121", code, msg)
  608. }
  609. }
  610. // TestAttendanceRejectsFinishedRecord 端点 52:已结束的课不能补打卡(3121);未结束则写 cost 并回显人数。
  611. //
  612. // 变红条件:去掉"已结束"判定 → 返回 200 且库中出现结课后追加的打卡行(income 对账缺口)。
  613. func TestAttendanceRejectsFinishedRecord(t *testing.T) {
  614. db := newCourseTestDB(t)
  615. course := seedCourse(t, db, "政治H", 10)
  616. open := seedRecord(t, db, course.ID, 6001, "RECOPEN", model.CoursesRecordStatusOngoing,
  617. time.Now().Add(-time.Hour))
  618. closed := seedRecord(t, db, course.ID, 6001, "RECCLOSED", model.CoursesRecordStatusFinished,
  619. time.Now().Add(-24*time.Hour))
  620. app := newCourseTestApp(t, db)
  621. body := map[string]any{"list": []map[string]any{
  622. {"userId": courseTestStudent, "type": 1, "pay": 1, "cost": "100.00", "giveCost": "0.00"},
  623. }}
  624. code, msg, data := doJSON(t, app, fiber.MethodPost,
  625. "/admin/courses/records/"+itoa(open.ID)+"/attendance", body, "X-Test-Token", "t")
  626. if code != 200 {
  627. t.Fatalf("未结束课打卡 = %d(%s),期望 200", code, msg)
  628. }
  629. if got, _ := jsonMap(t, data)["checked"].(float64); got != 1 {
  630. t.Fatalf("checked = %v,期望 1", jsonMap(t, data)["checked"])
  631. }
  632. var cost struct {
  633. Cost string `gorm:"column:cost"`
  634. Pay int8 `gorm:"column:pay"`
  635. Typ int32 `gorm:"column:type"`
  636. }
  637. if err := db.Raw("SELECT cost, pay, type FROM t_courses_record_student WHERE record_id = ? AND student_id = ?",
  638. open.ID, courseTestStudent).Scan(&cost).Error; err != nil {
  639. t.Fatalf("回读打卡行失败: %v", err)
  640. }
  641. requireMoney(t, "打卡行 cost", cost.Cost, "100.00")
  642. // 已结束的课 → 3121
  643. code, msg, _ = doJSON(t, app, fiber.MethodPost,
  644. "/admin/courses/records/"+itoa(closed.ID)+"/attendance", body, "X-Test-Token", "t")
  645. if code != apperr.CodeRecordStateInvalid {
  646. t.Fatalf("已结束课补打卡 = %d(%s),期望 3121", code, msg)
  647. }
  648. }
  649. // ---------------------------------------------------------------- 端点 53:报课学生
  650. // TestListCourseStudentsUsesRemainderReceivable 端点 53 的欠费字段必须取 `remainder_receivable`。
  651. //
  652. // 陷阱行:`unpaid=999.00`(僵尸列的脏值)、`remainder_receivable=100.00`(真值)。
  653. // 变红条件:字段取成 `unpaid` → 响应里 remainderReceivable 变成 999.00;
  654. //
  655. // 把 unpaid 一起下发 → 断言"响应不含 unpaid 键"红。
  656. func TestListCourseStudentsUsesRemainderReceivable(t *testing.T) {
  657. db := newCourseTestDB(t)
  658. course := seedCourse(t, db, "语文I", 10)
  659. seedEnrollment(t, db, course.ID, courseTestStudent, "ENROLL-1", "100.00", "400.00", "300.00", "100.00", "999.00")
  660. app := newCourseTestApp(t, db)
  661. code, msg, data := doJSON(t, app, fiber.MethodGet,
  662. "/admin/courses/"+itoa(course.ID)+"/students", nil, "X-Test-Token", "t")
  663. if code != 200 {
  664. t.Fatalf("报课学生列表 = %d(%s),期望 200", code, msg)
  665. }
  666. items := pageList(t, data)
  667. if len(items) != 1 {
  668. t.Fatalf("行数 = %d,期望 1", len(items))
  669. }
  670. row, _ := items[0].(map[string]any)
  671. if got, _ := row["remainderReceivable"].(string); got != "100.00" {
  672. t.Fatalf("remainderReceivable = %v,期望 \"100.00\"(源列 remainder_receivable);"+
  673. "若为 999.00 说明误取了废弃列 unpaid", row["remainderReceivable"])
  674. }
  675. if got, _ := row["pay"].(string); got != "400.00" {
  676. t.Fatalf("pay = %v,期望 400.00(源列 receivable)", row["pay"])
  677. }
  678. if got, _ := row["cost"].(string); got != "300.00" {
  679. t.Fatalf("cost = %v,期望 300.00(源列 net_receipts)", row["cost"])
  680. }
  681. if _, hasUnpaid := row["unpaid"]; hasUnpaid {
  682. t.Fatalf("响应下发了 unpaid(%s):废弃列禁下发(设计 §3.8-E4)", string(data))
  683. }
  684. // 未注入姓名端口时 `realName` 必须是 **null**(键在、值为 nil):
  685. // - 不下发该键 → 前端 `row.realName` undefined 与 "" 混在一起,无法区分;
  686. // - 下发 "" → 把"没有这个人"和"姓名为空"混在一起。
  687. nameValue, hasName := row["realName"]
  688. if !hasName {
  689. t.Fatalf("响应缺少 realName 键(%s):未注入端口时应下发 null,而不是省掉该键", string(data))
  690. }
  691. if nameValue != nil {
  692. t.Fatalf("realName = %v,期望 null(未注入姓名端口)", nameValue)
  693. }
  694. }
  695. // fakeUserNameReader 姓名端口测试替身(**只实现端口契约**:查不到的不进 map)。
  696. type fakeUserNameReader struct {
  697. names map[string]string
  698. err error
  699. // calls 记录调用次数(断言"批量一次"而不是逐行 N 次)
  700. calls int
  701. last []string
  702. }
  703. // DisplayNames 实现 `port.UserNameReader`。
  704. func (f *fakeUserNameReader) DisplayNames(_ context.Context, userIDs []string) (map[string]string, error) {
  705. f.calls++
  706. f.last = append([]string{}, userIDs...)
  707. if f.err != nil {
  708. return nil, f.err
  709. }
  710. out := make(map[string]string, len(userIDs))
  711. for _, id := range userIDs {
  712. if name, ok := f.names[id]; ok {
  713. out[id] = name
  714. }
  715. }
  716. return out, nil
  717. }
  718. // TestListCourseStudentsResolvesRealName 端点 53 的 `realName`(D4:跨域只读 port)。
  719. //
  720. // 三行数据覆盖三种情形:
  721. //
  722. // A:端口能查到 → `realName` = 姓名
  723. // B:端口查不到 → `realName` = **null**(不是 "")
  724. // C:端口返回空串 → 同样按"没名字"→ null(空串不该出现在响应里)
  725. //
  726. // 变红条件:
  727. // - 端口没接(仍是 null)→ A 红;
  728. // - 用空串兜底 → B/C 红;
  729. // - 逐行查询 → `calls != 1` 红(N+1)。
  730. func TestListCourseStudentsResolvesRealName(t *testing.T) {
  731. db := newCourseTestDB(t)
  732. course := seedCourse(t, db, "姓名测试课", 10)
  733. const studentB = "01HSTUDENT0000000000000B"
  734. const studentC = "01HSTUDENT0000000000000C"
  735. seedEnrollment(t, db, course.ID, courseTestStudent, "NAME-1", "100.00", "300.00", "300.00", "0.00", "0.00")
  736. seedEnrollment(t, db, course.ID, studentB, "NAME-2", "100.00", "300.00", "300.00", "0.00", "0.00")
  737. seedEnrollment(t, db, course.ID, studentC, "NAME-3", "100.00", "300.00", "300.00", "0.00", "0.00")
  738. reader := &fakeUserNameReader{names: map[string]string{
  739. courseTestStudent: "张三",
  740. studentC: "", // 端口返回空串:调用方必须当成"没名字"
  741. }}
  742. app := newCourseTestAppWithUsers(t, db, reader)
  743. code, msg, data := doJSON(t, app, fiber.MethodGet,
  744. "/admin/courses/"+itoa(course.ID)+"/students?pageSize=50", nil, "X-Test-Token", "t")
  745. if code != 200 {
  746. t.Fatalf("报课学生列表 = %d(%s),期望 200", code, msg)
  747. }
  748. items := pageList(t, data)
  749. if len(items) != 3 {
  750. t.Fatalf("行数 = %d,期望 3", len(items))
  751. }
  752. byUser := make(map[string]map[string]any, len(items))
  753. for _, item := range items {
  754. row, _ := item.(map[string]any)
  755. uid, _ := row["userId"].(string)
  756. byUser[uid] = row
  757. }
  758. if got, _ := byUser[courseTestStudent]["realName"].(string); got != "张三" {
  759. t.Fatalf("A 行 realName = %v,期望 张三(端口已查到,不得为 null)",
  760. byUser[courseTestStudent]["realName"])
  761. }
  762. if v, ok := byUser[studentB]["realName"]; !ok || v != nil {
  763. t.Fatalf("B 行 realName = %v,期望 null(端口查不到;空串会让前端无法区分两种没名字)", v)
  764. }
  765. if v, ok := byUser[studentC]["realName"]; !ok || v != nil {
  766. t.Fatalf("C 行 realName = %v,期望 null(端口返回空串 ⇒ 等同于没名字)", v)
  767. }
  768. // 批量:一页 3 行只应调用端口 1 次(N+1 会变成 3 次)
  769. if reader.calls != 1 {
  770. t.Fatalf("姓名端口调用 %d 次,期望 1 次(批量)—— 多次说明逐行查", reader.calls)
  771. }
  772. if len(reader.last) != 3 {
  773. t.Fatalf("端口收到 %d 个 ID,期望 3(一页全部学生一次传入)", len(reader.last))
  774. }
  775. }
  776. // TestListCourseStudentsNameReaderFailureDegradesToNull 端口报错时**降级为 null**、列表仍 200。
  777. //
  778. // 为什么不让它 5002:列表页因"少一列姓名"整页失败,用户连报课/欠费都看不到——
  779. // 展示类依赖必须可降级(与 `MembershipChecker` 那种安全依赖相反)。
  780. func TestListCourseStudentsNameReaderFailureDegradesToNull(t *testing.T) {
  781. db := newCourseTestDB(t)
  782. course := seedCourse(t, db, "降级测试课", 10)
  783. seedEnrollment(t, db, course.ID, courseTestStudent, "DEGRADE-1", "100.00", "300.00", "300.00", "0.00", "0.00")
  784. reader := &fakeUserNameReader{err: errors.New("identity 库不可用")}
  785. app := newCourseTestAppWithUsers(t, db, reader)
  786. code, msg, data := doJSON(t, app, fiber.MethodGet,
  787. "/admin/courses/"+itoa(course.ID)+"/students", nil, "X-Test-Token", "t")
  788. if code != 200 {
  789. t.Fatalf("姓名端口报错时列表 = %d(%s),期望 200(展示类依赖可降级)", code, msg)
  790. }
  791. items := pageList(t, data)
  792. if len(items) != 1 {
  793. t.Fatalf("行数 = %d,期望 1(降级不影响数据行)", len(items))
  794. }
  795. row, _ := items[0].(map[string]any)
  796. if v, ok := row["realName"]; !ok || v != nil {
  797. t.Fatalf("realName = %v,期望 null(端口报错时降级)", v)
  798. }
  799. if got, _ := row["remainderReceivable"].(string); got != "0.00" {
  800. t.Fatalf("remainderReceivable = %v,期望 0.00(降级不得影响其它字段)", row["remainderReceivable"])
  801. }
  802. }
  803. // ---------------------------------------------------------------- 端点 54:报名
  804. // TestEnrollCreatesSnapshotAndRejectsDuplicate 端点 54 报课行快照恒等式 + 重复报名 3140 + 跨校区 1004。
  805. //
  806. // 变红条件:
  807. // - receivable ≠ net_receipts + remainder_receivable → 对账恒等式断言红;
  808. // - `confirm_discount_user_id` 为空 → 该行永远进不了欠费核销候选,断言红;
  809. // - 重复报名静默成功 → 库里出现第二行(断言行数)。
  810. func TestEnrollCreatesSnapshotAndRejectsDuplicate(t *testing.T) {
  811. db := newCourseTestDB(t)
  812. course := seedCourse(t, db, "化学J", 10)
  813. app := newCourseTestApp(t, db)
  814. path := "/admin/courses/" + itoa(course.ID) + "/students"
  815. body := map[string]any{
  816. "userId": courseTestStudent, "pay": "500.00", "remainderReceivable": "200.00",
  817. "lessonCost": "100.00", "number": 5, "giveNumber": 1,
  818. }
  819. code, msg, data := doJSON(t, app, fiber.MethodPost, path, body, "X-Test-Token", "t")
  820. if code != 200 {
  821. t.Fatalf("报名 = %d(%s),期望 200", code, msg)
  822. }
  823. payload := jsonMap(t, data)
  824. if enrolled, _ := payload["enrolled"].(bool); !enrolled {
  825. t.Fatalf("enrolled = %v,期望 true", payload["enrolled"])
  826. }
  827. var row model.CoursesStudent
  828. if err := db.Where("courses_id = ? AND student_id = ?", course.ID, courseTestStudent).First(&row).Error; err != nil {
  829. t.Fatalf("回读报课行失败: %v", err)
  830. }
  831. // 恒等式:receivable = net_receipts + remainder_receivable(这是"应收"的唯一定义)
  832. receivable, _ := money.Parse(row.Receivable)
  833. netReceipts, _ := money.Parse(row.NetReceipts)
  834. remainder, _ := money.Parse(row.RemainderReceivable)
  835. if receivable.Cents() != netReceipts.Cents()+remainder.Cents() {
  836. t.Fatalf("receivable(%s) ≠ net_receipts(%s) + remainder_receivable(%s):应收恒等式被破坏",
  837. row.Receivable, row.NetReceipts, row.RemainderReceivable)
  838. }
  839. if row.RemainderNumber != 6 {
  840. t.Fatalf("remainder_number = %d,期望 6(number 5 + give_number 1)", row.RemainderNumber)
  841. }
  842. if row.ConfirmDiscountUserID == nil || strings.TrimSpace(*row.ConfirmDiscountUserID) == "" {
  843. t.Fatalf("confirm_discount_user_id 为空:该报课行将**永远进不了欠费核销候选**(§3.8-A17)")
  844. }
  845. requireMoney(t, "unpaid(废弃列)", row.Unpaid, "0.00")
  846. // 重复报名 → 3140,且库里仍只有一行
  847. code, msg, _ = doJSON(t, app, fiber.MethodPost, path, body, "X-Test-Token", "t")
  848. if code != apperr.CodeEnrollmentDuplicate {
  849. t.Fatalf("重复报名 = %d(%s),期望 3143(重复报名)", code, msg)
  850. }
  851. var count int64
  852. db.Model(&model.CoursesStudent{}).Where("courses_id = ? AND student_id = ?", course.ID, courseTestStudent).Count(&count)
  853. if count != 1 {
  854. t.Fatalf("报课行数 = %d,期望 1", count)
  855. }
  856. // 跨校区报名 → 1004(作用域校区 10,传入 99)
  857. crossBody := map[string]any{
  858. "userId": courseTestOther, "pay": "100.00", "lessonCost": "100.00", "campusId": 99,
  859. }
  860. if code, msg, _ := doJSON(t, app, fiber.MethodPost, path, crossBody, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
  861. t.Fatalf("跨校区报名 = %d(%s),期望 1004", code, msg)
  862. }
  863. }
  864. // ---------------------------------------------------------------- 端点 55:欠费列表
  865. // TestArrearsListUsesRemainderReceivable **本端点最容易写错、且写错不会报错的一处**。
  866. //
  867. // 两条数据:
  868. //
  869. // A:remainder_receivable=50.00 且 unpaid=0.00 ← **真欠费**(用 unpaid 判会被漏掉)
  870. // B:remainder_receivable=0.00 且 unpaid=999.00 ← **不欠费**(用 unpaid 判会被误报)
  871. //
  872. // 变红条件:判据换成 `unpaid > 0` → 列表里出现 B、且不含 A(两条断言同时红)。
  873. func TestArrearsListUsesRemainderReceivable(t *testing.T) {
  874. db := newCourseTestDB(t)
  875. course := seedCourse(t, db, "英语K", 10)
  876. seedEnrollment(t, db, course.ID, courseTestStudent, "ARR-1", "100.00", "300.00", "250.00", "50.00", "0.00")
  877. seedEnrollment(t, db, course.ID, courseTestOther, "ARR-2", "100.00", "300.00", "300.00", "0.00", "999.00")
  878. app := newCourseTestApp(t, db)
  879. code, msg, data := doJSON(t, app, fiber.MethodGet, PathCourseArrears, nil, "X-Test-Token", "t")
  880. if code != 200 {
  881. t.Fatalf("欠费列表 = %d(%s),期望 200", code, msg)
  882. }
  883. items := pageList(t, data)
  884. if len(items) != 1 {
  885. t.Fatalf("欠费行数 = %d,期望 1(只有 remainder_receivable>0 的那条):%s", len(items), string(data))
  886. }
  887. row, _ := items[0].(map[string]any)
  888. if got, _ := row["studentId"].(string); got != courseTestStudent {
  889. t.Fatalf("欠费学生 = %v,期望 %s —— 若为 %s 说明判据用了废弃列 unpaid",
  890. row["studentId"], courseTestStudent, courseTestOther)
  891. }
  892. if got, _ := row["remainderReceivable"].(string); got != "50.00" {
  893. t.Fatalf("remainderReceivable = %v,期望 \"50.00\"", row["remainderReceivable"])
  894. }
  895. if got, _ := row["courseId"].(float64); got != float64(course.ID) {
  896. t.Fatalf("courseId = %v,期望 %d", row["courseId"], course.ID)
  897. }
  898. if _, has := row["unpaid"]; has {
  899. t.Fatalf("欠费列表下发了 unpaid:废弃列禁下发(设计 §3.8-E4)")
  900. }
  901. }
  902. // ---------------------------------------------------------------- 端点 56:欠费核销
  903. // TestCounteractArrearsWritesBothColumns 端点 56:`net_receipts` 增、`remainder_receivable` 减(同一条 UPDATE);
  904. // 跨机构 → 1004。
  905. //
  906. // 变红条件:只写一列(实收加了、应收没减)→ 两列断言之一红;去掉机构校验 → 1004 用例红。
  907. func TestCounteractArrearsWritesBothColumns(t *testing.T) {
  908. db := newCourseTestDB(t)
  909. course := seedCourse(t, db, "数学L", 10)
  910. // SP27 的候选集要 INNER JOIN t_courses_class(按 courses_id + class_id),故必须有一条规则
  911. seedClassRule(t, db, course.ID, 7001, courseTestTeacher, "1", model.CoursesClassStatusOngoing, "09:00", "10:00")
  912. enroll := seedEnrollment(t, db, course.ID, courseTestStudent, "CARR-1", "100.00", "300.00", "200.00", "100.00", "0.00")
  913. // SP27 的候选集是 `t1 INNER JOIN t_courses_class ON (coursesid, classid)` ⇒ 报课行的
  914. // class_id 必须与排班规则一致,否则候选集为空(核销变成静默 no-op)
  915. if err := db.Model(&model.CoursesStudent{}).Where("id = ?", enroll.ID).
  916. UpdateColumn("class_id", 7001).Error; err != nil {
  917. t.Fatalf("对齐报课行 class_id 失败: %v", err)
  918. }
  919. app := newCourseTestApp(t, db)
  920. body := map[string]any{
  921. "studentId": courseTestStudent, "institutionId": 1, "campusId": 10, "money": "60.00",
  922. }
  923. code, msg, data := doJSON(t, app, fiber.MethodPost, PathCourseArrearsCounteract, body, "X-Test-Token", "t")
  924. if code != 200 {
  925. t.Fatalf("欠费核销 = %d(%s),期望 200", code, msg)
  926. }
  927. payload := jsonMap(t, data)
  928. if counteracted, _ := payload["counteracted"].(bool); !counteracted {
  929. t.Fatalf("counteracted = %v,期望 true", payload["counteracted"])
  930. }
  931. if got, _ := payload["amount"].(string); got != "60.00" {
  932. t.Fatalf("amount = %v,期望 \"60.00\"(实际核销额)", payload["amount"])
  933. }
  934. var row struct {
  935. NetReceipts string `gorm:"column:net_receipts"`
  936. Remainder string `gorm:"column:remainder_receivable"`
  937. }
  938. if err := db.Raw("SELECT net_receipts, remainder_receivable FROM t_courses_student WHERE id = ?", enroll.ID).
  939. Scan(&row).Error; err != nil {
  940. t.Fatalf("回读报课行失败: %v", err)
  941. }
  942. requireMoney(t, "net_receipts", row.NetReceipts, "260.00") // 200 + 60
  943. requireMoney(t, "remainder_receivable", row.Remainder, "40.00") // 100 - 60(只加实收不减应收是典型错法)
  944. // 跨机构(作用域机构 1,传入 2)→ 1004
  945. crossBody := map[string]any{
  946. "studentId": courseTestStudent, "institutionId": 2, "campusId": 10, "money": "10.00",
  947. }
  948. if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathCourseArrearsCounteract, crossBody, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
  949. t.Fatalf("跨机构核销 = %d(%s),期望 1004", code, msg)
  950. }
  951. }
  952. // ---------------------------------------------------------------- 端点 57:学生课表
  953. // TestStudentScheduleIsPerStudent 端点 57:课表按学生过滤(另一个学生报的课不得出现);跨用户查 → 1004。
  954. //
  955. // 变红条件:漏掉 `s.student_id = ?` 过滤 → 两个学生都会看到两门课(行数 2)。
  956. func TestStudentScheduleIsPerStudent(t *testing.T) {
  957. db := newCourseTestDB(t)
  958. mine := seedCourse(t, db, "我的课", 10)
  959. other := seedCourse(t, db, "别人的课", 10)
  960. seedEnrollment(t, db, mine.ID, courseTestStudent, "SCH-1", "100.00", "100.00", "100.00", "0.00", "0.00")
  961. seedEnrollment(t, db, other.ID, courseTestOther, "SCH-2", "100.00", "100.00", "100.00", "0.00", "0.00")
  962. mineRecord := seedRecord(t, db, mine.ID, 8001, "SCHREC-1", model.CoursesRecordStatusOngoing,
  963. time.Date(2026, 11, 20, 9, 0, 0, 0, time.Local))
  964. seedRecord(t, db, other.ID, 8002, "SCHREC-2", model.CoursesRecordStatusOngoing,
  965. time.Date(2026, 11, 20, 11, 0, 0, 0, time.Local))
  966. // 本人已打卡 → cost 回显
  967. att := &model.CoursesRecordStudent{
  968. BizNo: "SCHATT-1", CoursesID: mine.ID, ClassID: 8001, InstitutionID: 1, CampusID: 10,
  969. RecordID: mineRecord.ID, StudentID: courseTestStudent, Type: model.CoursesRecordStudentTypeNormal,
  970. StartTime: *mineRecord.StartTime, Cost: "100.00", GiveCost: "10.00",
  971. Pay: model.CoursesRecordStudentPayPaid,
  972. }
  973. if err := db.Create(att).Error; err != nil {
  974. t.Fatalf("建打卡行失败: %v", err)
  975. }
  976. app := newCourseTestApp(t, db)
  977. code, msg, data := doJSON(t, app, fiber.MethodGet, PathStudentSchedule, nil,
  978. "X-Test-Token", "t", "X-Test-Scope", "student")
  979. if code != 200 {
  980. t.Fatalf("学生课表 = %d(%s),期望 200", code, msg)
  981. }
  982. items := pageList(t, data)
  983. if len(items) != 1 {
  984. t.Fatalf("课表行数 = %d,期望 1(只含本人报名的课程)—— 2 说明漏了 student_id 过滤:%s",
  985. len(items), string(data))
  986. }
  987. row, _ := items[0].(map[string]any)
  988. if got, _ := row["courseId"].(float64); got != float64(mine.ID) {
  989. t.Fatalf("courseId = %v,期望 %d(本人课程)", row["courseId"], mine.ID)
  990. }
  991. if checked, _ := row["checked"].(bool); !checked {
  992. t.Fatalf("checked = false,期望 true(本人已有打卡行)")
  993. }
  994. if got, _ := row["cost"].(string); got != "100.00" {
  995. t.Fatalf("cost = %v,期望 \"100.00\"", row["cost"])
  996. }
  997. if got, _ := row["courseName"].(string); got != "我的课" {
  998. t.Fatalf("courseName = %v,期望 我的课", row["courseName"])
  999. }
  1000. // 学生查别人 → 1004
  1001. if code, msg, _ := doJSON(t, app, fiber.MethodGet,
  1002. PathStudentSchedule+"?userId="+courseTestOther, nil,
  1003. "X-Test-Token", "t", "X-Test-Scope", "student"); code != infraerrors.ErrUnscoped.Code {
  1004. t.Fatalf("学生查他人课表 = %d(%s),期望 1004", code, msg)
  1005. }
  1006. }
  1007. // TestFinishRecordResolvesTeacherExpFromProfile **教龄必须从教师档案解析,且按 365 天口径**。
  1008. //
  1009. // 夹具:老师在 `t_teacher_information.teacher_exp` 的起算日 = **365 天前**(教龄 = 1.0 年),
  1010. // 两档规则:
  1011. //
  1012. // 档 A:[0.000, 0.999] reward=10.00 ← 教龄解析成 0(未接档案)时会落到这里
  1013. // 档 B:[1.000, 5.000] reward=20.00 ← 正确解析(1.0 年)应命中这里
  1014. //
  1015. // 变红条件:
  1016. // - 适配器没有回落档案(`TeacherExp` 恒 0)→ 得 10.00;
  1017. // - 用"年份相减"算教龄(跨年边界差整整 1 岁)→ 也会落到错误档;
  1018. // - 教龄没按 1 位小数舍入 → 0.999/1.000 这类端点会跨档。
  1019. func TestFinishRecordResolvesTeacherExpFromProfile(t *testing.T) {
  1020. db := newCourseTestDB(t)
  1021. fx := seedFinishFixture(t, db)
  1022. // 两档规则:把原夹具那条改成"档 A",再加一条"档 B"
  1023. if err := db.Model(&model.CoursePaymentDetail{}).Where("id = ?", fx.DetailID).
  1024. Updates(map[string]any{
  1025. "percentage": "0.0", "reward": "10.00",
  1026. "teacher_exp_min": "0.000", "teacher_exp_max": "0.999",
  1027. }).Error; err != nil {
  1028. t.Fatalf("改写档 A 失败: %v", err)
  1029. }
  1030. higher := &model.CoursePaymentDetail{
  1031. CoursePaymentID: fx.DetailID, // 占位,下面改成真 scheme id
  1032. GradeID: -1, SubjectID: -1, ClassType: 1000, ClassifyID: 0,
  1033. Percentage: "0.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "20.00",
  1034. TeacherCert: false, TeacherExpMin: "1.000", TeacherExpMax: "5.000", Actived: true,
  1035. }
  1036. // 取回原 detail 的 scheme id
  1037. var schemeID int64
  1038. db.Raw("SELECT course_payment_id FROM t_course_payment_detail WHERE id = ?", fx.DetailID).Scan(&schemeID)
  1039. higher.CoursePaymentID = schemeID
  1040. if err := db.Create(higher).Error; err != nil {
  1041. t.Fatalf("建档 B 失败: %v", err)
  1042. }
  1043. // 教师档案:起算日 = 365 天前 ⇒ 教龄 1.0 年 ⇒ 命中档 B
  1044. expDate := time.Now().AddDate(0, 0, -365)
  1045. if err := db.Create(&model.TeacherInformation{
  1046. TeacherID: courseTestTeacher, TeacherExp: &expDate,
  1047. }).Error; err != nil {
  1048. t.Fatalf("建教师档案失败: %v", err)
  1049. }
  1050. app := newCourseTestApp(t, db)
  1051. code, msg, data := doJSON(t, app, fiber.MethodPost,
  1052. "/admin/courses/records/"+itoa(fx.Record.ID)+"/finish", map[string]any{}, "X-Test-Token", "t")
  1053. if code != 200 {
  1054. t.Fatalf("结束上课 = %d(%s),期望 200", code, msg)
  1055. }
  1056. payroll, _ := jsonMap(t, data)["payroll"].(map[string]any)
  1057. if got, _ := payroll["reward"].(string); got != "20.00" {
  1058. t.Fatalf("payroll.reward = %v,期望 20.00(教龄 1.0 年命中 [1.000,5.000] 档);"+
  1059. "10.00 说明教龄解析成 0(没接档案或用错换算口径)", payroll["reward"])
  1060. }
  1061. if got, _ := payroll["detailId"].(float64); got != float64(higher.ID) {
  1062. t.Fatalf("payroll.detailId = %v,期望 %d(档 B)", payroll["detailId"], higher.ID)
  1063. }
  1064. }
  1065. // ---------------------------------------------------------------- 端点 51:课酬分摊口径(D16)
  1066. // TestFinishRecordRewardDistributionSkipsGiveRows **D16:课酬只分给 `type∈{1,2}`,赠送行恒 0**。
  1067. //
  1068. // PHP 原文(`CoursesRecordHelper.php:806-818`):`$stuCount = count(type IN (1,2))`,
  1069. // 平摊与写回**都只针对 `type IN (1,2)`**,赠送行**永远保持 0**。
  1070. //
  1071. // 夹具:1 条 type=1(正常)+ 1 条 type=3(赠送),单节课课酬合计 = 100.00。
  1072. //
  1073. // 正确(PHP):正常行 **100.00**、赠送行 **0.00**、Σ(type∈{1,2}) == 100.00
  1074. // 错误(除数用全部行数):正常行 50.00、赠送行 50.00 ⇒ **多条断言同时红**
  1075. func TestFinishRecordRewardDistributionSkipsGiveRows(t *testing.T) {
  1076. db := newCourseTestDB(t)
  1077. fx := seedFinishFixture(t, db)
  1078. const giveStudent = "01HSTUDENT0000000000000G"
  1079. app := newCourseTestApp(t, db)
  1080. code, msg, data := doJSON(t, app, fiber.MethodPost,
  1081. "/admin/courses/records/"+itoa(fx.Record.ID)+"/finish",
  1082. map[string]any{"items": []map[string]any{
  1083. {"userId": courseTestStudent, "type": 1, "cost": "100.00"},
  1084. {"userId": giveStudent, "type": 3, "cost": "0.00"},
  1085. }}, "X-Test-Token", "t")
  1086. if code != 200 {
  1087. t.Fatalf("结束上课(含赠送生)= %d(%s),期望 200", code, msg)
  1088. }
  1089. payroll, _ := jsonMap(t, data)["payroll"].(map[string]any)
  1090. if got, _ := payroll["reward"].(string); got != "100.00" {
  1091. t.Fatalf("payroll.reward = %v,期望 100.00(单节课课酬合计,与分摊无关)", payroll["reward"])
  1092. }
  1093. requireMoney(t, "正常行 teacher_reward", readTeacherReward(t, db, fx.Record.ID, courseTestStudent), "100.00")
  1094. requireMoney(t, "赠送行 teacher_reward", readTeacherReward(t, db, fx.Record.ID, giveStudent), "0.00")
  1095. requireMoney(t, "Σ(type∈{1,2})", readTeacherRewardSumExcludingGive(t, db, fx.Record.ID), "100.00")
  1096. // student_number 是**全部行数**(含赠送),与平摊人数是两个数
  1097. var studentNumber int32
  1098. db.Raw("SELECT student_number FROM t_courses_record WHERE id = ?", fx.Record.ID).Scan(&studentNumber)
  1099. if studentNumber != 2 {
  1100. t.Fatalf("student_number = %d,期望 2(全部打卡行数,含赠送生)", studentNumber)
  1101. }
  1102. }
  1103. // TestFinishRecordAllGiveClassKeepsRecordReward **D16 边界:全班赠送(stuCount=0)**。
  1104. //
  1105. // PHP 的 `if($stuCount)` 不进入 ⇒ **一行都不写**(学生行全 0),但 record 上**仍写课酬**。
  1106. // ⇒ 恒等式在这一情形**不成立**;断言只能是"所有行 0",**不得**断言 Σ == record
  1107. // (无条件断言会让"全赠送班"恒红,最后被人改成跳过 = 把防线删掉)。
  1108. func TestFinishRecordAllGiveClassKeepsRecordReward(t *testing.T) {
  1109. db := newCourseTestDB(t)
  1110. fx := seedFinishFixture(t, db)
  1111. app := newCourseTestApp(t, db)
  1112. code, msg, data := doJSON(t, app, fiber.MethodPost,
  1113. "/admin/courses/records/"+itoa(fx.Record.ID)+"/finish",
  1114. map[string]any{"items": []map[string]any{
  1115. {"userId": courseTestStudent, "type": 3, "cost": "0.00"},
  1116. }}, "X-Test-Token", "t")
  1117. if code != 200 {
  1118. t.Fatalf("结束上课(全班赠送)= %d(%s),期望 200", code, msg)
  1119. }
  1120. requireMoney(t, "赠送行 teacher_reward", readTeacherReward(t, db, fx.Record.ID, courseTestStudent), "0.00")
  1121. payroll, _ := jsonMap(t, data)["payroll"].(map[string]any)
  1122. if got, _ := payroll["reward"].(string); got != "100.00" {
  1123. t.Fatalf("payroll.reward = %v,期望 100.00(全班赠送照常算课酬,只是不分摊)", payroll["reward"])
  1124. }
  1125. var recReward string
  1126. db.Raw("SELECT teacher_reward FROM t_courses_record WHERE id = ?", fx.Record.ID).Scan(&recReward)
  1127. requireMoney(t, "record.teacher_reward", recReward, "100.00")
  1128. }
  1129. // readTeacherReward 读某学生在该课的 `teacher_reward`(无行 → 空串,断言会红)。
  1130. func readTeacherReward(t *testing.T, db *gorm.DB, recordID int64, studentID string) string {
  1131. t.Helper()
  1132. var out struct {
  1133. Reward string `gorm:"column:teacher_reward"`
  1134. }
  1135. if err := db.Raw("SELECT teacher_reward FROM t_courses_record_student WHERE record_id = ? AND student_id = ?",
  1136. recordID, studentID).Scan(&out).Error; err != nil {
  1137. t.Fatalf("读 teacher_reward 失败: %v", err)
  1138. }
  1139. return out.Reward
  1140. }
  1141. // readTeacherRewardSumExcludingGive 求和非赠送行的 `teacher_reward`(D16 条件式恒等式)。
  1142. func readTeacherRewardSumExcludingGive(t *testing.T, db *gorm.DB, recordID int64) string {
  1143. t.Helper()
  1144. var out struct {
  1145. Sum string `gorm:"column:sum_reward"`
  1146. }
  1147. if err := db.Raw("SELECT COALESCE(SUM(teacher_reward),0) AS sum_reward FROM t_courses_record_student "+
  1148. "WHERE record_id = ? AND type IN (1,2)", recordID).Scan(&out).Error; err != nil {
  1149. t.Fatalf("求和非赠送行 teacher_reward 失败: %v", err)
  1150. }
  1151. return out.Sum
  1152. }
  1153. // ---------------------------------------------------------------- 端点 51:结束上课(跨 5 表)
  1154. // courseFinishFixture 端点 51 的完整夹具(结课链路的五张表全部就位)。
  1155. type courseFinishFixture struct {
  1156. Course *model.Courses
  1157. Record *model.CoursesRecord
  1158. Enroll *model.CoursesStudent
  1159. DetailID int64
  1160. Recommender string
  1161. Upline string
  1162. }
  1163. // seedFinishFixture 造一节课的完整数据:资金双账户 + 报课行 + 排班实例 + 分账树 + 课酬方案。
  1164. //
  1165. // 关键设计(每条都对应一个断言):
  1166. // - 报课行 lesson_cost=100 → 全勤结课的 cost 应为 100.00;
  1167. // - 分账树:推荐人 A 的上级 B 的 quota=20.00 → 结课后 B 应入账 20.00;
  1168. // - 课酬方案:命中 detail 的 reward=100.00 → record.teacher_reward 应为 100.00 且 detailId == 该行 ID。
  1169. func seedFinishFixture(t *testing.T, db *gorm.DB) *courseFinishFixture {
  1170. t.Helper()
  1171. // ① 课程 + 分类(课酬匹配要用 class_type)
  1172. classify := &model.CoursesClassify{
  1173. InstitutionID: 1, CampusID: 10, ClassType: 1000, GradeID: 0, SubjectID: 0,
  1174. Name: "集体班", UnitPrice: "100.00", MinPeople: 1, MaxPeople: 10,
  1175. }
  1176. if err := db.Create(classify).Error; err != nil {
  1177. t.Fatalf("建分类失败: %v", err)
  1178. }
  1179. course := &model.Courses{
  1180. InstitutionID: 1, CampusID: 10, ClassifyID: classify.ID, Name: "结课测试课",
  1181. Status: model.CoursesStatusOngoing, Number: 10, Duration: "1.0", Week: "-1",
  1182. OriginalCost: "100.00", RealPrice: "100.00", DiscountCash: "0.00",
  1183. }
  1184. if err := db.Create(course).Error; err != nil {
  1185. t.Fatalf("建课程失败: %v", err)
  1186. }
  1187. // ② 资金双账户(spend 统计列的累加对象)
  1188. if err := db.Create(&model.UserExpense{
  1189. UserID: courseTestStudent, Balance: "1000.00", Spend: "0.00",
  1190. TotalIncome: "1000.00", TotalExpenditure: "0.00",
  1191. TotalRecommendedAmount: "0.00", SpecialExpenses: "0.00",
  1192. }).Error; err != nil {
  1193. t.Fatalf("建全局账户失败: %v", err)
  1194. }
  1195. if err := db.Create(&model.UserExpenseSchool{
  1196. UserID: courseTestStudent, InstitutionID: 1, CampusID: 10, Balance: "500.00", Spend: "0.00",
  1197. TotalIncome: "500.00", TotalExpenditure: "0.00",
  1198. TotalRecommendedAmount: "0.00", SpecialExpenses: "0.00",
  1199. }).Error; err != nil {
  1200. t.Fatalf("建校区账户失败: %v", err)
  1201. }
  1202. // ③ 报课行(带推荐人 → 结课触发分账)
  1203. confirm := courseTestOperator
  1204. recommender := courseTestRecommender
  1205. enroll := &model.CoursesStudent{
  1206. BizNo: "FINISH-ENROLL-1", CoursesID: course.ID, ClassID: 9001,
  1207. InstitutionID: 1, CampusID: 10, StudentID: courseTestStudent,
  1208. Type: model.CoursesStudentTypeNormal, Status: model.CoursesStudentStatusNormal,
  1209. Receivable: "1000.00", NetReceipts: "1000.00", Unpaid: "0.00",
  1210. LessonCost: "100.00", Number: 10, FinishedNumber: 0, DiscountCash: "0.00",
  1211. GiveNumber: 0, GiveCost: "0.00", RemainderNumber: 10, RemainderCost: "1000.00",
  1212. RemainderReceivable: "0.00", BadDebt: "0.00",
  1213. ConfirmDiscountUserID: &confirm, RecommenderUserID: &recommender,
  1214. }
  1215. if err := db.Create(enroll).Error; err != nil {
  1216. t.Fatalf("建报课行失败: %v", err)
  1217. }
  1218. // ④ 排班实例(上课中)
  1219. record := seedRecord(t, db, course.ID, 9001, "FINISH-REC-1", model.CoursesRecordStatusOngoing,
  1220. time.Now().Add(-time.Hour))
  1221. // ⑤ 分账树:B(根,quota 20)→ A(推荐人)
  1222. parent := courseTestUpline
  1223. root := courseTestUpline
  1224. if err := db.Create(&model.UserDivideInto{
  1225. UserID: courseTestUpline, Type: model.DivideTypePerLesson, Quota: "20.00", TotalIncome: "0.00",
  1226. RootUserID: &root, ParentUserID: nil, LeftVal: 1, RightVal: 4, Layer: 1,
  1227. }).Error; err != nil {
  1228. t.Fatalf("建分账根节点失败: %v", err)
  1229. }
  1230. if err := db.Create(&model.UserDivideInto{
  1231. UserID: courseTestRecommender, Type: model.DivideTypePerLesson, Quota: "0.00", TotalIncome: "0.00",
  1232. RootUserID: &root, ParentUserID: &parent, LeftVal: 2, RightVal: 3, Layer: 2,
  1233. }).Error; err != nil {
  1234. t.Fatalf("建推荐人节点失败: %v", err)
  1235. }
  1236. // ⑥ 课酬方案 + 规则(命中:reward=100.00)
  1237. effect := time.Now().AddDate(0, 0, -30)
  1238. scheme := &model.CoursePayment{
  1239. InstitutionID: 1, CampusID: 10, Actived: true, EffectDate: &effect,
  1240. }
  1241. if err := db.Create(scheme).Error; err != nil {
  1242. t.Fatalf("建课酬方案失败: %v", err)
  1243. }
  1244. detail := &model.CoursePaymentDetail{
  1245. CoursePaymentID: scheme.ID, GradeID: -1, SubjectID: -1, ClassType: 1000, ClassifyID: 0,
  1246. Percentage: "0.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "100.00",
  1247. TeacherCert: false, TeacherExpMin: "0.000", TeacherExpMax: "100.000", Actived: true,
  1248. }
  1249. if err := db.Create(detail).Error; err != nil {
  1250. t.Fatalf("建课酬规则失败: %v", err)
  1251. }
  1252. return &courseFinishFixture{
  1253. Course: course, Record: record, Enroll: enroll,
  1254. DetailID: detail.ID, Recommender: courseTestRecommender, Upline: courseTestUpline,
  1255. }
  1256. }
  1257. // TestFinishRecordCrossTableLinkage 端点 51:**一次调用 = 五张表联动**,逐表断言。
  1258. //
  1259. // 变红矩阵(每条断言对应一种真实错法):
  1260. //
  1261. // ① t_courses_record_student.cost/give_cost/teacher_reward → 漏写打卡行/课酬分摊
  1262. // ② t_user_expense.spend → 漏累加全局统计列
  1263. // ③ t_user_expense_school.spend → 只加了全局、漏了校区
  1264. // ④ t_user_divide_into_log(money=quota)+ total_income → 分账未入账/入错金额
  1265. // ⑤ t_courses_record(status=2 + income + teacher_reward) → 汇总列没落/落错
  1266. // ⑥ t_courses_student(finished_number/remainder_*) → 扣课时没扣
  1267. func TestFinishRecordCrossTableLinkage(t *testing.T) {
  1268. db := newCourseTestDB(t)
  1269. fx := seedFinishFixture(t, db)
  1270. app := newCourseTestApp(t, db)
  1271. code, msg, data := doJSON(t, app, fiber.MethodPost,
  1272. "/admin/courses/records/"+itoa(fx.Record.ID)+"/finish", map[string]any{}, "X-Test-Token", "t")
  1273. if code != 200 {
  1274. t.Fatalf("结束上课 = %d(%s),期望 200", code, msg)
  1275. }
  1276. payload := jsonMap(t, data)
  1277. if got, _ := payload["status"].(float64); got != float64(model.CoursesRecordStatusFinished) {
  1278. t.Fatalf("status = %v,期望 2", payload["status"])
  1279. }
  1280. if changed, _ := payload["changed"].(bool); !changed {
  1281. t.Fatalf("changed = false,期望 true(首次结课必须真的写库)")
  1282. }
  1283. if got, _ := payload["totalSpend"].(string); got != "100.00" {
  1284. t.Fatalf("totalSpend = %v,期望 \"100.00\"", payload["totalSpend"])
  1285. }
  1286. payroll, _ := payload["payroll"].(map[string]any)
  1287. if got, _ := payroll["reward"].(string); got != "100.00" {
  1288. t.Fatalf("payroll.reward = %v,期望 \"100.00\"(方案 reward 列);"+
  1289. "为 0.00 说明课酬端口没接上(quoter=nil)", payroll["reward"])
  1290. }
  1291. if got, _ := payroll["detailId"].(float64); got != float64(fx.DetailID) {
  1292. t.Fatalf("payroll.detailId = %v,期望 %d(命中的课酬规则 ID)", payroll["detailId"], fx.DetailID)
  1293. }
  1294. divide, _ := payload["divide"].([]any)
  1295. if len(divide) != 1 {
  1296. t.Fatalf("divide 条数 = %d,期望 1(推荐人的上级应入账):%s", len(divide), string(data))
  1297. }
  1298. d0, _ := divide[0].(map[string]any)
  1299. if got, _ := d0["amount"].(string); got != "20.00" {
  1300. t.Fatalf("divide[0].amount = %v,期望 \"20.00\"(源列 money)", d0["amount"])
  1301. }
  1302. if got, _ := d0["userId"].(string); got != fx.Upline {
  1303. t.Fatalf("divide[0].userId = %v,期望 %s", d0["userId"], fx.Upline)
  1304. }
  1305. // 裁决后形状 `{userId, type, amount}`:type 必须来自日志行的 `type`(夹具用 type=2 按课次)
  1306. if got, _ := d0["type"].(float64); got != float64(model.DivideTypePerLesson) {
  1307. t.Fatalf("divide[0].type = %v,期望 %d(源列 type = 按课次)", d0["type"], model.DivideTypePerLesson)
  1308. }
  1309. // 防回潮:`level`/`quota` 无对应列,**任何形式的下发都不允许**(包括"填常量让它看起来有值")
  1310. for _, forbidden := range []string{"level", "quota"} {
  1311. if v, has := d0[forbidden]; has {
  1312. t.Fatalf("divide[0] 下发了 %q=%v:该字段在 t_user_divide_into_log 里没有对应列,"+
  1313. "填常量顶替属伪造历史(团队裁决已删)", forbidden, v)
  1314. }
  1315. }
  1316. // ① 打卡行三列
  1317. var att struct {
  1318. Cost string `gorm:"column:cost"`
  1319. GiveCost string `gorm:"column:give_cost"`
  1320. Reward string `gorm:"column:teacher_reward"`
  1321. EndTime *time.Time `gorm:"column:end_time"`
  1322. }
  1323. if err := db.Raw(`SELECT cost, give_cost, teacher_reward, end_time FROM t_courses_record_student
  1324. WHERE record_id = ? AND student_id = ?`, fx.Record.ID, courseTestStudent).Scan(&att).Error; err != nil {
  1325. t.Fatalf("回读打卡行失败: %v", err)
  1326. }
  1327. requireMoney(t, "打卡行 cost", att.Cost, "100.00")
  1328. requireMoney(t, "打卡行 give_cost", att.GiveCost, "0.00")
  1329. requireMoney(t, "打卡行 teacher_reward", att.Reward, "100.00")
  1330. if att.EndTime == nil {
  1331. t.Fatalf("打卡行 end_time 为空:非空才是「已完成」(分成归属判据)")
  1332. }
  1333. // ② 全局账户 spend(**balance 必须不动**)
  1334. var global struct {
  1335. Spend string `gorm:"column:spend"`
  1336. Balance string `gorm:"column:balance"`
  1337. }
  1338. if err := db.Raw("SELECT spend, balance FROM t_user_expense WHERE user_id = ?", courseTestStudent).
  1339. Scan(&global).Error; err != nil {
  1340. t.Fatalf("回读全局账户失败: %v", err)
  1341. }
  1342. requireMoney(t, "t_user_expense.spend", global.Spend, "100.00") // 漏累加统计列
  1343. requireMoney(t, "t_user_expense.balance", global.Balance, "1000.00") // spend 是统计列,**不得动 balance**
  1344. // ③ 校区账户 spend
  1345. var school struct {
  1346. Spend string `gorm:"column:spend"`
  1347. }
  1348. if err := db.Raw("SELECT spend FROM t_user_expense_school WHERE user_id = ? AND campus_id = 10",
  1349. courseTestStudent).Scan(&school).Error; err != nil {
  1350. t.Fatalf("回读校区账户失败: %v", err)
  1351. }
  1352. requireMoney(t, "t_user_expense_school.spend", school.Spend, "100.00") // 只加全局漏了校区
  1353. // ④ 分账明细 + total_income
  1354. var divideRow struct {
  1355. UserID string `gorm:"column:user_id"`
  1356. Money string `gorm:"column:money"`
  1357. OrderNo string `gorm:"column:order_no"`
  1358. }
  1359. if err := db.Raw("SELECT user_id, money, order_no FROM t_user_divide_into_log WHERE student_id = ?",
  1360. courseTestStudent).Scan(&divideRow).Error; err != nil {
  1361. t.Fatalf("回读分账明细失败: %v", err)
  1362. }
  1363. if divideRow.UserID != fx.Upline {
  1364. t.Fatalf("分账明细 user = %s,期望 %s", divideRow.UserID, fx.Upline)
  1365. }
  1366. requireMoney(t, "分账明细 money", divideRow.Money, "20.00")
  1367. if divideRow.OrderNo != itoa(fx.Record.ID) {
  1368. t.Fatalf("分账 order_no = %q,期望上课记录 ID %d(按课次幂等键)", divideRow.OrderNo, fx.Record.ID)
  1369. }
  1370. var upline struct {
  1371. TotalIncome string `gorm:"column:total_income"`
  1372. }
  1373. if err := db.Raw("SELECT total_income FROM t_user_divide_into WHERE user_id = ?", fx.Upline).
  1374. Scan(&upline).Error; err != nil {
  1375. t.Fatalf("回读分账节点失败: %v", err)
  1376. }
  1377. requireMoney(t, "t_user_divide_into.total_income", upline.TotalIncome, "20.00")
  1378. // ⑤ record 汇总列
  1379. var rec struct {
  1380. Status int32 `gorm:"column:status"`
  1381. Income string `gorm:"column:income"`
  1382. TeacherReward string `gorm:"column:teacher_reward"`
  1383. StudentNumber int32 `gorm:"column:student_number"`
  1384. }
  1385. if err := db.Raw("SELECT status, income, teacher_reward, student_number FROM t_courses_record WHERE id = ?",
  1386. fx.Record.ID).Scan(&rec).Error; err != nil {
  1387. t.Fatalf("回读实例失败: %v", err)
  1388. }
  1389. if rec.Status != model.CoursesRecordStatusFinished || rec.StudentNumber != 1 {
  1390. t.Fatalf("record = status %d / students %d,期望 2/1", rec.Status, rec.StudentNumber)
  1391. }
  1392. requireMoney(t, "record.income", rec.Income, "100.00")
  1393. requireMoney(t, "record.teacher_reward", rec.TeacherReward, "100.00")
  1394. // ⑥ 扣课时
  1395. var enrollment struct {
  1396. Finished int32 `gorm:"column:finished_number"`
  1397. Remainder int32 `gorm:"column:remainder_number"`
  1398. RemainderCost string `gorm:"column:remainder_cost"`
  1399. }
  1400. if err := db.Raw(`SELECT finished_number, remainder_number, remainder_cost FROM t_courses_student
  1401. WHERE id = ?`, fx.Enroll.ID).Scan(&enrollment).Error; err != nil {
  1402. t.Fatalf("回读报课行失败: %v", err)
  1403. }
  1404. if enrollment.Finished != 1 || enrollment.Remainder != 9 {
  1405. t.Fatalf("扣课时 = finished %d / remainder %d,期望 1/9", enrollment.Finished, enrollment.Remainder)
  1406. }
  1407. requireMoney(t, "remainder_cost", enrollment.RemainderCost, "900.00")
  1408. // 幂等:再结一次 → changed=false,且分账**不重复入账**
  1409. code, msg, data = doJSON(t, app, fiber.MethodPost,
  1410. "/admin/courses/records/"+itoa(fx.Record.ID)+"/finish", map[string]any{}, "X-Test-Token", "t")
  1411. if code != 200 {
  1412. t.Fatalf("重复结课 = %d(%s),期望 200(幂等成功)", code, msg)
  1413. }
  1414. if changed, _ := jsonMap(t, data)["changed"].(bool); changed {
  1415. t.Fatalf("重复结课 changed = true,期望 false(幂等命中:本次未写任何表)")
  1416. }
  1417. var logCount int64
  1418. db.Model(&model.UserDivideIntoLog{}).Where("student_id = ?", courseTestStudent).Count(&logCount)
  1419. if logCount != 1 {
  1420. t.Fatalf("分账明细条数 = %d,期望 1(重复结课不得重复入账)", logCount)
  1421. }
  1422. }
  1423. // TestFinishRecordRewardFormula 端点 51 的课酬公式 —— **逐条对齐 PHP 原文**
  1424. // (`CoursesRecordHelper.php:781-821`,权威在应用层而非 dump)。
  1425. //
  1426. // 六个子例,每个对应一条"写错就错钱"的顺序/口径:
  1427. //
  1428. // ① 纯比例:pct=85.0 → 100×85% = **85.00**
  1429. // ② 下限:85% 算出 85 < min 90 → **90.00**
  1430. // ③ 上限:pct=200 → 200 > max 150 → **150.00**
  1431. // ④ **顺序(核心)**:pct=100 + reward 50 = 150,先加再加夹 max 120 → **120.00**
  1432. // (错误顺序"先夹 min/max 再加 reward"会得 100+50 = 150 ⇒ 多发 30)
  1433. // ⑤ **顺序(下限侧)**:pct=0 + reward 50 = 50,夹到 min 80 → **80.00**
  1434. // (错误顺序会得 clamp(0)=80 + 50 = 130 ⇒ 多发 50)
  1435. // ⑥ **固定课酬 `teacher_reward_type=2`**:忽略 pct/reward,1 人 → makeupCost[1] = **70.00**
  1436. //
  1437. // 变红条件:把顺序写反(④⑤)、漏 elseif 上限优先(③)、漏 type=2 分支(⑥)、
  1438. // 把 percentage 当 0~1 比率(①)—— 任一条都会红。
  1439. func TestFinishRecordRewardFormula(t *testing.T) {
  1440. cases := []struct {
  1441. name string
  1442. percentage string
  1443. minPayment string
  1444. maxPayment string
  1445. reward string
  1446. rewardType int8
  1447. want string
  1448. }{
  1449. {"纯比例 85%", "85.0", "0.00", "0.00", "0.00", 1, "85.00"},
  1450. {"下限抬到 90", "85.0", "90.00", "0.00", "0.00", 1, "90.00"},
  1451. {"上限压到 150", "200.0", "0.00", "150.00", "0.00", 1, "150.00"},
  1452. {"顺序:先加 reward 再夹上限(reward 被吃掉)", "100.0", "0.00", "120.00", "50.00", 1, "120.00"},
  1453. {"顺序:先加 reward 再夹下限", "0.0", "80.00", "0.00", "50.00", 1, "80.00"},
  1454. {"固定课酬 type=2:1 人 → 70", "85.0", "0.00", "0.00", "999.00", 2, "70.00"},
  1455. }
  1456. for _, tc := range cases {
  1457. t.Run(tc.name, func(t *testing.T) {
  1458. db := newCourseTestDB(t)
  1459. fx := seedFinishFixture(t, db)
  1460. if err := db.Model(&model.CoursePaymentDetail{}).Where("id = ?", fx.DetailID).
  1461. Updates(map[string]any{
  1462. "percentage": tc.percentage,
  1463. "min_payment": tc.minPayment,
  1464. "max_payment": tc.maxPayment,
  1465. "reward": tc.reward,
  1466. }).Error; err != nil {
  1467. t.Fatalf("改写课酬规则失败: %v", err)
  1468. }
  1469. app := newCourseTestApp(t, db)
  1470. code, msg, data := doJSON(t, app, fiber.MethodPost,
  1471. "/admin/courses/records/"+itoa(fx.Record.ID)+"/finish",
  1472. map[string]any{"rewardType": tc.rewardType}, "X-Test-Token", "t")
  1473. if code != 200 {
  1474. t.Fatalf("结束上课 = %d(%s),期望 200", code, msg)
  1475. }
  1476. payroll, _ := jsonMap(t, data)["payroll"].(map[string]any)
  1477. if got, _ := payroll["reward"].(string); got != tc.want {
  1478. t.Fatalf("payroll.reward = %v,期望 %s(percentage=%s min=%s max=%s reward=%s type=%d,课收入 100.00/1 人)",
  1479. payroll["reward"], tc.want, tc.percentage, tc.minPayment, tc.maxPayment, tc.reward, tc.rewardType)
  1480. }
  1481. // 落库一致性:record.teacher_reward 与打卡行 teacher_reward 都必须等于该值
  1482. var rec struct {
  1483. TeacherReward string `gorm:"column:teacher_reward"`
  1484. }
  1485. if err := db.Raw("SELECT teacher_reward FROM t_courses_record WHERE id = ?", fx.Record.ID).
  1486. Scan(&rec).Error; err != nil {
  1487. t.Fatalf("回读实例失败: %v", err)
  1488. }
  1489. requireMoney(t, "record.teacher_reward", rec.TeacherReward, tc.want)
  1490. var stu struct {
  1491. TeacherReward string `gorm:"column:teacher_reward"`
  1492. }
  1493. if err := db.Raw("SELECT teacher_reward FROM t_courses_record_student WHERE record_id = ? AND student_id = ?",
  1494. fx.Record.ID, courseTestStudent).Scan(&stu).Error; err != nil {
  1495. t.Fatalf("回读打卡行失败: %v", err)
  1496. }
  1497. requireMoney(t, "打卡行 teacher_reward", stu.TeacherReward, tc.want)
  1498. })
  1499. }
  1500. }
  1501. // TestFinishRecordRollsBackWhenDivideFails 端点 51 的**回滚变异**:让第 3 步(分账)失败,
  1502. // 断言前两步(扣课时 / spend / 打卡行)**全部回滚**。
  1503. //
  1504. // 手法:请求前 `DROP TABLE t_user_divide_into_log` ⇒ 结课事务内 INSERT 分账明细必然失败。
  1505. // 为什么不用"注入错误":handler 层测试无法替换内部服务(`servicesFor` 现场构造),
  1506. // 而 DROP 表是**真实基础设施故障**的同构复现(表不存在 = SQL 报错 = 事务回滚)。
  1507. //
  1508. // 变红条件:把分账放到事务外(或吞掉错误)→ 断言 1 变红(接口返回 200),断言 2~5 变红(数据已落库)。
  1509. func TestFinishRecordRollsBackWhenDivideFails(t *testing.T) {
  1510. db := newCourseTestDB(t)
  1511. fx := seedFinishFixture(t, db)
  1512. if err := db.Exec("DROP TABLE t_user_divide_into_log").Error; err != nil {
  1513. t.Fatalf("删分账明细表失败: %v", err)
  1514. }
  1515. app := newCourseTestApp(t, db)
  1516. code, msg, _ := doJSON(t, app, fiber.MethodPost,
  1517. "/admin/courses/records/"+itoa(fx.Record.ID)+"/finish", map[string]any{}, "X-Test-Token", "t")
  1518. // 断言 1:分账失败必须让整个请求失败,且**必须是数据库级失败**。
  1519. //
  1520. // 为什么钉死成 5002:夹具数据全部合法(实例存在且上课中、有在读学生、有课酬方案),
  1521. // 唯一缺失的是分账明细表 ⇒ 能导致失败的只可能是第 ④ 步的 INSERT。
  1522. // 若这里变成 2001/3121/3001,说明失败发生在第 ④ 步**之前**,该用例就退化成"空断言"。
  1523. if code == 200 {
  1524. t.Fatalf("分账失败时结课仍返回 200(%s):分账没在结课事务内", msg)
  1525. }
  1526. if code != infraerrors.ErrDBError.Code {
  1527. t.Fatalf("失败码 = %d(%s),期望 5002(数据库异常)——"+
  1528. "若不是 5002,说明失败发生在前几步,回滚断言会变成空断言", code, msg)
  1529. }
  1530. // 断言 2:报课行扣课时**必须回滚**
  1531. var enrollment struct {
  1532. Finished int32 `gorm:"column:finished_number"`
  1533. Remainder int32 `gorm:"column:remainder_number"`
  1534. RemainderCost string `gorm:"column:remainder_cost"`
  1535. }
  1536. if err := db.Raw(`SELECT finished_number, remainder_number, remainder_cost FROM t_courses_student
  1537. WHERE id = ?`, fx.Enroll.ID).Scan(&enrollment).Error; err != nil {
  1538. t.Fatalf("回读报课行失败: %v", err)
  1539. }
  1540. if enrollment.Finished != 0 || enrollment.Remainder != 10 {
  1541. t.Fatalf("扣课时未回滚:finished %d / remainder %d,期望 0/10",
  1542. enrollment.Finished, enrollment.Remainder)
  1543. }
  1544. requireMoney(t, "remainder_cost(回滚后)", enrollment.RemainderCost, "1000.00")
  1545. // 断言 3 / 4:两个账户的 spend 必须回滚
  1546. var global struct {
  1547. Spend string `gorm:"column:spend"`
  1548. }
  1549. db.Raw("SELECT spend FROM t_user_expense WHERE user_id = ?", courseTestStudent).Scan(&global)
  1550. requireMoney(t, "t_user_expense.spend(回滚后)", global.Spend, "0.00")
  1551. var school struct {
  1552. Spend string `gorm:"column:spend"`
  1553. }
  1554. db.Raw("SELECT spend FROM t_user_expense_school WHERE user_id = ? AND campus_id = 10", courseTestStudent).Scan(&school)
  1555. requireMoney(t, "t_user_expense_school.spend(回滚后)", school.Spend, "0.00")
  1556. // 断言 5:打卡行不得留下 cost>0 的痕迹
  1557. var att struct {
  1558. Cost string `gorm:"column:cost"`
  1559. }
  1560. db.Raw(`SELECT cost FROM t_courses_record_student WHERE record_id = ? AND student_id = ?`,
  1561. fx.Record.ID, courseTestStudent).Scan(&att)
  1562. if strings.TrimSpace(att.Cost) != "" {
  1563. amount, perr := money.Parse(att.Cost)
  1564. if perr != nil || amount.Cents() != 0 {
  1565. t.Fatalf("打卡行 cost = %q,期望无行或 0.00(未随失败回滚)", att.Cost)
  1566. }
  1567. }
  1568. // 断言 6:实例状态不得变成已结束
  1569. var rec struct {
  1570. Status int32 `gorm:"column:status"`
  1571. }
  1572. db.Raw("SELECT status FROM t_courses_record WHERE id = ?", fx.Record.ID).Scan(&rec)
  1573. if rec.Status == model.CoursesRecordStatusFinished {
  1574. t.Fatalf("实例 status = 2,期望仍为 1(未随失败回滚)")
  1575. }
  1576. }
  1577. // ---------------------------------------------------------------- 作用域 / 鉴权冒烟
  1578. // TestCourseEndpointsRequireScope 15 条课程端点全部要求作用域(无 scope → 1001)。
  1579. //
  1580. // 这条防的是"某条端点漏挂 mustScope"(无作用域时 Scoped 会 panic → 5001,
  1581. // 而 5001 看起来像"服务故障",会让人去查 DB 而不是查中间件链)。
  1582. func TestCourseEndpointsRequireScope(t *testing.T) {
  1583. db := newCourseTestDB(t)
  1584. app := newCourseTestApp(t, db)
  1585. cases := []struct {
  1586. method string
  1587. path string
  1588. }{
  1589. {fiber.MethodGet, PathCourses},
  1590. {fiber.MethodPost, PathCourses},
  1591. {fiber.MethodPut, "/admin/courses/1"},
  1592. {fiber.MethodGet, "/admin/courses/1/classes"},
  1593. {fiber.MethodPost, "/admin/courses/1/classes"},
  1594. {fiber.MethodGet, "/admin/courses/1/records"},
  1595. {fiber.MethodPost, "/admin/courses/1/records/generate"},
  1596. {fiber.MethodPost, "/admin/courses/records/1/start"},
  1597. {fiber.MethodPost, "/admin/courses/records/1/finish"},
  1598. {fiber.MethodPost, "/admin/courses/records/1/attendance"},
  1599. {fiber.MethodGet, "/admin/courses/1/students"},
  1600. {fiber.MethodPost, "/admin/courses/1/students"},
  1601. {fiber.MethodGet, PathCourseArrears},
  1602. {fiber.MethodPost, PathCourseArrearsCounteract},
  1603. {fiber.MethodGet, PathStudentSchedule},
  1604. }
  1605. for _, tc := range cases {
  1606. code, msg, _ := doJSON(t, app, tc.method, tc.path, nil, "X-Test-Token", "t", "X-Test-Scope", "none")
  1607. if code != infraerrors.ErrUnauth.Code {
  1608. t.Errorf("%s %s 无作用域时 code = %d(%s),期望 1001", tc.method, tc.path, code, msg)
  1609. }
  1610. }
  1611. }