activity_repo_test.go 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  1. // ActivityRepo 单测(AC-06):作用域超集语义 / 批量防 N+1 / DELETE+INSERT 幂等 /
  2. // type=4 跨活动去重 / number_sign 下限 / 游标方向白名单。
  3. //
  4. // 测试栈:glebarez/sqlite(纯 Go,CGO_ENABLED=0 可跑)——真实库用例走 t.TempDir 文件库,
  5. // SQL 形状用例走 DryRun 句柄(与 base_test.go 同一套 helper)。
  6. package repository
  7. import (
  8. "context"
  9. "errors"
  10. "path/filepath"
  11. "strings"
  12. "testing"
  13. "time"
  14. gormsqlite "github.com/glebarez/sqlite"
  15. "gorm.io/gorm"
  16. "yuansiwei/unified-backend/internal/academic/model"
  17. "yuansiwei/unified-backend/internal/academic/money"
  18. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  19. appscope "yuansiwei/unified-backend/internal/infra/scope"
  20. )
  21. // newActivityRepoDB 活动域真实库(文件库;含报名/赠送池/流水 5 表 AutoMigrate)。
  22. func newActivityRepoDB(t *testing.T) *gorm.DB {
  23. t.Helper()
  24. dsn := filepath.Join(t.TempDir(), "academic_activity_repo_test.db")
  25. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  26. if err != nil {
  27. t.Fatalf("gorm.Open(活动文件库) 失败: %v", err)
  28. }
  29. t.Cleanup(func() {
  30. if sqlDB, e := db.DB(); e == nil {
  31. _ = sqlDB.Close()
  32. }
  33. })
  34. if err := db.AutoMigrate(
  35. &model.CoursesActivity{},
  36. &model.CoursesActivitySigned{},
  37. &model.CoursesActivityUserDiscount{},
  38. &model.CoursesActivityUserDiscountLog{},
  39. &model.CoursesActivityUsedLog{},
  40. ); err != nil {
  41. t.Fatalf("AutoMigrate(活动 5 表) 失败: %v", err)
  42. }
  43. return db
  44. }
  45. // seedActivity 插入一条活动(显式 ID,便于断言)。
  46. func seedActivity(t *testing.T, db *gorm.DB, id, inst, campus int64, typ int8, money string) *model.CoursesActivity {
  47. t.Helper()
  48. row := &model.CoursesActivity{
  49. ID: id,
  50. Type: typ,
  51. InstitutionID: inst,
  52. CampusID: campus,
  53. Money: money,
  54. Status: model.ActivityStatusEnabled,
  55. CreateTime: time.Now(),
  56. UpdatedAt: time.Now(),
  57. }
  58. if err := db.Create(row).Error; err != nil {
  59. t.Fatalf("seed activity(%d) 失败: %v", id, err)
  60. }
  61. return row
  62. }
  63. // seedSigned 插入一条报名行(显式 ID)。
  64. func seedSigned(t *testing.T, db *gorm.DB, id, activityID, inst, campus int64, userID string, status int32) {
  65. t.Helper()
  66. row := &model.CoursesActivitySigned{
  67. ID: id,
  68. ActivityID: activityID,
  69. UserID: userID,
  70. InstitutionID: inst,
  71. CampusID: campus,
  72. Status: status,
  73. CreateTime: time.Now(),
  74. }
  75. if err := db.Create(row).Error; err != nil {
  76. t.Fatalf("seed signed(%d) 失败: %v", id, err)
  77. }
  78. }
  79. // captureSQL 注册 Query/Update 回调,收集执行过的 SQL 语句(断言防 N+1 / 单语句 CASE)。
  80. func captureSQL(t *testing.T, db *gorm.DB) *[]string {
  81. t.Helper()
  82. out := make([]string, 0, 8)
  83. collect := func(d *gorm.DB) {
  84. if d.Statement != nil && d.Statement.SQL.Len() > 0 {
  85. out = append(out, d.Statement.SQL.String())
  86. }
  87. }
  88. if err := db.Callback().Query().After("gorm:query").Register("test:capture_query", collect); err != nil {
  89. t.Fatalf("注册 Query 回调失败: %v", err)
  90. }
  91. if err := db.Callback().Update().After("gorm:update").Register("test:capture_update", collect); err != nil {
  92. t.Fatalf("注册 Update 回调失败: %v", err)
  93. }
  94. return &out
  95. }
  96. // TestScopedCampusRowsSQL 校区过滤必须是 `IN (campus, 0)` 超集(不是等值),机构过滤强制注入。
  97. func TestScopedCampusRowsSQL(t *testing.T) {
  98. db := newDryRunDB(t)
  99. sc := &appscope.Scope{InstitutionID: 42, CampusID: 7}
  100. q := scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), sc, "")
  101. var out []model.CoursesActivity
  102. if err := q.Where("id = ?", 1).Find(&out).Error; err != nil {
  103. t.Fatalf("DryRun Find 失败: %v", err)
  104. }
  105. sql := q.Statement.SQL.String()
  106. if !strings.Contains(sql, "institution_id = ?") {
  107. t.Errorf("应强制注入 institution_id,SQL=%q", sql)
  108. }
  109. if !strings.Contains(sql, "campus_id IN") {
  110. t.Errorf("校区过滤必须是 IN (campus,0) 超集(campus_id=0 语义=全自营校区),SQL=%q", sql)
  111. }
  112. if strings.Contains(sql, "campus_id = ?") {
  113. t.Errorf("不得用等值过滤(会裁掉全机构活动),SQL=%q", sql)
  114. }
  115. }
  116. // TestScopedCampusRowsSuperAdminAndCampusZero 超管不过滤;campus=0 时不加校区条件。
  117. func TestScopedCampusRowsSuperAdminAndCampusZero(t *testing.T) {
  118. db := newDryRunDB(t)
  119. // 超管
  120. sc := &appscope.Scope{InstitutionID: 42, CampusID: 7, SuperAdmin: true}
  121. q := scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), sc, "")
  122. var out []model.CoursesActivity
  123. if err := q.Find(&out).Error; err != nil {
  124. t.Fatalf("DryRun Find 失败: %v", err)
  125. }
  126. if sql := q.Statement.SQL.String(); strings.Contains(sql, "institution_id") || strings.Contains(sql, "campus_id") {
  127. t.Errorf("超管不应注入作用域过滤,SQL=%q", sql)
  128. }
  129. // 机构级视角(campus=0)
  130. sc2 := &appscope.Scope{InstitutionID: 42, CampusID: 0}
  131. q2 := scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), sc2, "")
  132. if err := q2.Find(&out).Error; err != nil {
  133. t.Fatalf("DryRun Find 失败: %v", err)
  134. }
  135. if sql := q2.Statement.SQL.String(); strings.Contains(sql, "campus_id") {
  136. t.Errorf("CampusID=0 不应注入校区过滤,SQL=%q", sql)
  137. }
  138. }
  139. // TestScopedCampusRowsNilScopePanics 无作用域进入数据层 → panic(fail-fast)。
  140. func TestScopedCampusRowsNilScopePanics(t *testing.T) {
  141. db := newDryRunDB(t)
  142. defer func() {
  143. rec := recover()
  144. if rec == nil {
  145. t.Fatal("Sc 为 nil 时应 panic")
  146. }
  147. if !strings.Contains(rec.(string), "missing tenant scope") {
  148. t.Fatalf("panic 文案不符: %v", rec)
  149. }
  150. }()
  151. _ = scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), nil, "")
  152. }
  153. // TestGetByIDScoping 跨机构活动 → ErrNotFound;campus_id=0 活动在本校区视角可见。
  154. func TestGetByIDScoping(t *testing.T) {
  155. db := newActivityRepoDB(t)
  156. seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "100.00")
  157. seedActivity(t, db, 2, 1, 0, model.ActivityTypeVoucher, "50.00") // 0=机构所有自营校区
  158. seedActivity(t, db, 3, 2, 2, model.ActivityTypeRecommend, "30.00")
  159. repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  160. if got, err := repo.GetByID(context.Background(), 1); err != nil || got.ID != 1 {
  161. t.Fatalf("GetByID(1) 应命中,got=%v err=%v", got, err)
  162. }
  163. if _, err := repo.GetByID(context.Background(), 2); err != nil {
  164. t.Fatalf("campus_id=0 的活动应在本校区视角可见(IN (campus,0)),实得 %v", err)
  165. }
  166. if _, err := repo.GetByID(context.Background(), 3); !errors.Is(err, infraerrors.ErrNotFound) {
  167. t.Fatalf("跨机构活动必须 ErrNotFound(不泄露存在性),实得 %v", err)
  168. }
  169. if _, err := repo.GetByID(context.Background(), 0); !errors.Is(err, infraerrors.ErrBadParam) {
  170. t.Fatalf("id<=0 应 ErrBadParam,实得 %v", err)
  171. }
  172. }
  173. // TestGetByIDsNoNPlusOne 批量取数:去重 + 单条 SQL(防 N+1)+ 缺失 id 不在 map 中。
  174. func TestGetByIDsNoNPlusOne(t *testing.T) {
  175. db := newActivityRepoDB(t)
  176. seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "100.00")
  177. seedActivity(t, db, 2, 1, 2, model.ActivityTypeVoucher, "50.00")
  178. seedActivity(t, db, 3, 9, 2, model.ActivityTypeRecommend, "30.00") // 他机构:应被裁掉
  179. repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  180. sqls := captureSQL(t, db)
  181. got, err := repo.GetByIDs(context.Background(), []int64{1, 2, 2, 3, 0, -1})
  182. if err != nil {
  183. t.Fatalf("GetByIDs 失败: %v", err)
  184. }
  185. if len(got) != 2 || got[1] == nil || got[2] == nil {
  186. t.Fatalf("应返回 {1,2} 两行(去重+作用域裁剪),实得 %v", got)
  187. }
  188. if len(*sqls) != 1 {
  189. t.Fatalf("批量取数必须是 1 条 SQL(防 N+1),实得 %d 条: %v", len(*sqls), *sqls)
  190. }
  191. if !strings.Contains((*sqls)[0], "IN") {
  192. t.Fatalf("应为 IN 批量查询,SQL=%q", (*sqls)[0])
  193. }
  194. // 空/全非法 → 空 map 且不发 SQL
  195. *sqls = (*sqls)[:0]
  196. empty, err := repo.GetByIDs(context.Background(), []int64{0, -3})
  197. if err != nil || len(empty) != 0 {
  198. t.Fatalf("空 ids 应返回空 map 且无错误,实得 %v err=%v", empty, err)
  199. }
  200. if len(*sqls) != 0 {
  201. t.Fatalf("空 ids 不应发 SQL,实得 %v", *sqls)
  202. }
  203. }
  204. // TestUpsertSignedDeleteInsertIdempotent DELETE+INSERT 幂等:同一 (user, activity) 只留一条且全字段重置。
  205. func TestUpsertSignedDeleteInsertIdempotent(t *testing.T) {
  206. db := newActivityRepoDB(t)
  207. seedActivity(t, db, 10, 1, 2, model.ActivityTypeRecharge, "100.00")
  208. repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  209. runner := NewTxRunner(db)
  210. ctx := context.Background()
  211. var firstID int64
  212. err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  213. row := &model.CoursesActivitySigned{
  214. ActivityID: 10,
  215. UserID: "01HUSER000000000000000001",
  216. CampusID: 2,
  217. Status: model.ActivitySignedSigned,
  218. ActivityMoney: "100.00",
  219. CreateTime: time.Now(),
  220. }
  221. if err := repo.UpsertSigned(ctx, tx, row); err != nil {
  222. return err
  223. }
  224. firstID = row.ID
  225. return nil
  226. })
  227. if err != nil {
  228. t.Fatalf("首次 UpsertSigned 失败: %v", err)
  229. }
  230. // 模拟"先取消(status=0)后重新报名":旧行必须被 DELETE 掉,不能残留两条
  231. err = runner.WithinTx(ctx, func(tx *gorm.DB) error {
  232. row := &model.CoursesActivitySigned{
  233. ActivityID: 10,
  234. UserID: "01HUSER000000000000000001",
  235. CampusID: 2,
  236. Status: model.ActivitySignedAttended,
  237. ActivityMoney: "88.00",
  238. CreateTime: time.Now(),
  239. }
  240. return repo.UpsertSigned(ctx, tx, row)
  241. })
  242. if err != nil {
  243. t.Fatalf("二次 UpsertSigned 失败: %v", err)
  244. }
  245. var rows []model.CoursesActivitySigned
  246. if err := db.Where("user_id = ? AND activity_id = ?", "01HUSER000000000000000001", 10).Find(&rows).Error; err != nil {
  247. t.Fatalf("查询报名行失败: %v", err)
  248. }
  249. if len(rows) != 1 {
  250. t.Fatalf("DELETE+INSERT 幂等后应仅 1 行,实得 %d", len(rows))
  251. }
  252. if rows[0].Status != model.ActivitySignedAttended {
  253. t.Fatalf("重新报名应全字段重置,实得 status=%d", rows[0].Status)
  254. }
  255. // 金额列在 sqlite 下按 NUMERIC 亲和回读("88.00" → 88),故用 money 归一后比较语义值
  256. if amt, perr := money.Parse(rows[0].ActivityMoney); perr != nil || amt.Cents() != 8800 {
  257. t.Fatalf("重新报名应重置 activity_money=88.00,实得 %q (err=%v)", rows[0].ActivityMoney, perr)
  258. }
  259. if rows[0].ID == firstID {
  260. t.Fatalf("重新报名应插入新行(旧行被 DELETE),ID 未变 = %d", rows[0].ID)
  261. }
  262. if rows[0].InstitutionID != 1 {
  263. t.Fatalf("institution_id 应由作用域补齐为 1,实得 %d", rows[0].InstitutionID)
  264. }
  265. }
  266. // TestUpsertSignedGuards 事务/参数防御。
  267. func TestUpsertSignedGuards(t *testing.T) {
  268. repo := NewActivityRepo(newActivityRepoDB(t), &appscope.Scope{InstitutionID: 1, CampusID: 2})
  269. ctx := context.Background()
  270. if err := repo.UpsertSigned(ctx, nil, &model.CoursesActivitySigned{ActivityID: 1, UserID: "u"}); !errors.Is(err, infraerrors.ErrInternal) {
  271. t.Fatalf("tx 为 nil 应 ErrInternal(禁止脱离事务写),实得 %v", err)
  272. }
  273. if err := repo.UpsertSigned(ctx, repo.DB, &model.CoursesActivitySigned{ActivityID: 0, UserID: "u"}); !errors.Is(err, infraerrors.ErrBadParam) {
  274. t.Fatalf("activity_id<=0 应 ErrBadParam,实得 %v", err)
  275. }
  276. if err := repo.UpsertSigned(ctx, repo.DB, nil); !errors.Is(err, infraerrors.ErrBadParam) {
  277. t.Fatalf("nil 行应 ErrBadParam,实得 %v", err)
  278. }
  279. }
  280. // TestCountSignedByUserType4Dedup type=4 判据(**逐字对齐 dump `yuansiwei_finance.sql:762947`**)。
  281. //
  282. // dump 口径:`WHERE t1.userid=? AND t2.status != 0`(判**活动** status,**不过滤报名行 status**)。
  283. // 审计 P0-1 修复前本实现筛的是 `signed.status <> 0` → "取消报名后可重报再领老带新奖励"(重复发钱)。
  284. func TestCountSignedByUserType4Dedup(t *testing.T) {
  285. db := newActivityRepoDB(t)
  286. seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecommend, "10.00")
  287. seedActivity(t, db, 2, 1, 2, model.ActivityTypeRecommend, "20.00")
  288. seedActivity(t, db, 3, 1, 2, model.ActivityTypeRecommend, "30.00")
  289. seedActivity(t, db, 4, 1, 2, model.ActivityTypeRecharge, "40.00")
  290. // 活动 3 已停用(status=0)→ dump 口径不计(停用活动可重报)
  291. if err := db.Model(&model.CoursesActivity{}).Where("id = ?", 3).
  292. UpdateColumn("status", model.ActivityStatusDisabled).Error; err != nil {
  293. t.Fatalf("停用活动 3 失败: %v", err)
  294. }
  295. user := "01HUSER000000000000000002"
  296. seedSigned(t, db, 1, 1, 1, 2, user, model.ActivitySignedSigned)
  297. seedSigned(t, db, 2, 2, 1, 2, user, model.ActivitySignedCancelled) // **已取消也计**(参加过)
  298. seedSigned(t, db, 3, 3, 1, 2, user, model.ActivitySignedSigned) // 活动已停用 → 不计
  299. seedSigned(t, db, 4, 4, 1, 2, user, model.ActivitySignedSigned) // type=2 → type4Only 不计
  300. seedSigned(t, db, 5, 1, 1, 2, "01HUSER000000000000000009", model.ActivitySignedSigned)
  301. repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  302. ctx := context.Background()
  303. type4, err := repo.CountSignedByUser(ctx, user, true)
  304. if err != nil {
  305. t.Fatalf("CountSignedByUser(type4) 失败: %v", err)
  306. }
  307. // 活动 1(有效报名)+ 活动 2(**取消过的报名**)= 2;活动 3(停用)与活动 4(非 type=4)不计
  308. if type4 != 2 {
  309. t.Fatalf("type=4 计数应为 2(取消过也算、停用活动不计),实得 %d", type4)
  310. }
  311. // A1-1:取消过 type=4 报名后**仍拒绝**(3106 语义)——只留活动 2 的取消行
  312. if err := db.Where("id IN ?", []int64{1, 3, 4, 5}).Delete(&model.CoursesActivitySigned{}).Error; err != nil {
  313. t.Fatalf("清理报名行失败: %v", err)
  314. }
  315. if again, err := repo.CountSignedByUser(ctx, user, true); err != nil || again != 1 {
  316. t.Fatalf("取消过报名仍应计数 1(防取消后重报重复领奖),实得 %d err=%v", again, err)
  317. }
  318. // A1-2:活动停用后可重报(停用活动不再计入 type=4 判据)
  319. if err := db.Model(&model.CoursesActivity{}).Where("id = ?", 2).
  320. UpdateColumn("status", model.ActivityStatusDisabled).Error; err != nil {
  321. t.Fatalf("停用活动 2 失败: %v", err)
  322. }
  323. if again, err := repo.CountSignedByUser(ctx, user, true); err != nil || again != 0 {
  324. t.Fatalf("活动停用后应可重报(计数 0),实得 %d err=%v", again, err)
  325. }
  326. // 守卫口径(type4Only=false):只看报名行是否有效 → 已取消行不计
  327. all, err := repo.CountSignedByUser(ctx, user, false)
  328. if err != nil {
  329. t.Fatalf("CountSignedByUser(all) 失败: %v", err)
  330. }
  331. if all != 0 {
  332. t.Fatalf("signed_cancel 守卫口径下已取消行应为 0,实得 %d", all)
  333. }
  334. if _, err := repo.CountSignedByUser(ctx, " ", true); !errors.Is(err, infraerrors.ErrBadParam) {
  335. t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
  336. }
  337. }
  338. // TestIncreaseNumberSignFloorZero 报名人数原子增减且下限 0。
  339. func TestIncreaseNumberSignFloorZero(t *testing.T) {
  340. db := newActivityRepoDB(t)
  341. seedActivity(t, db, 7, 1, 2, model.ActivityTypeRecharge, "0.00")
  342. repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  343. runner := NewTxRunner(db)
  344. ctx := context.Background()
  345. readSign := func() int32 {
  346. var row model.CoursesActivity
  347. if err := db.Where("id = ?", 7).First(&row).Error; err != nil {
  348. t.Fatalf("读活动失败: %v", err)
  349. }
  350. return row.NumberSign
  351. }
  352. // 取消到负数 → 钳制为 0
  353. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  354. _, e := repo.IncreaseNumberSign(ctx, tx, 7, -5)
  355. return e
  356. }); err != nil {
  357. t.Fatalf("IncreaseNumberSign(-5) 失败: %v", err)
  358. }
  359. if got := readSign(); got != 0 {
  360. t.Fatalf("number_sign 下限应为 0(防取消竞态),实得 %d", got)
  361. }
  362. // +2
  363. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  364. _, e := repo.IncreaseNumberSign(ctx, tx, 7, 2)
  365. return e
  366. }); err != nil {
  367. t.Fatalf("IncreaseNumberSign(+2) 失败: %v", err)
  368. }
  369. if got := readSign(); got != 2 {
  370. t.Fatalf("number_sign 应为 2,实得 %d", got)
  371. }
  372. // -1
  373. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  374. _, e := repo.IncreaseNumberSign(ctx, tx, 7, -1)
  375. return e
  376. }); err != nil {
  377. t.Fatalf("IncreaseNumberSign(-1) 失败: %v", err)
  378. }
  379. if got := readSign(); got != 1 {
  380. t.Fatalf("number_sign 应为 1,实得 %d", got)
  381. }
  382. // delta=0 → 空操作
  383. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  384. n, e := repo.IncreaseNumberSign(ctx, tx, 7, 0)
  385. if n != 0 {
  386. t.Fatalf("delta=0 应无影响行,实得 %d", n)
  387. }
  388. return e
  389. }); err != nil {
  390. t.Fatalf("IncreaseNumberSign(0) 失败: %v", err)
  391. }
  392. // 不存在/越权活动 → 0 行
  393. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  394. n, e := repo.IncreaseNumberSign(ctx, tx, 999, 1)
  395. if e != nil {
  396. return e
  397. }
  398. if n != 0 {
  399. t.Fatalf("活动不存在应 0 行,实得 %d", n)
  400. }
  401. return nil
  402. }); err != nil {
  403. t.Fatalf("IncreaseNumberSign(999) 失败: %v", err)
  404. }
  405. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  406. _, e := repo.IncreaseNumberSign(ctx, nil, 7, 1)
  407. return e
  408. }); !errors.Is(err, infraerrors.ErrInternal) {
  409. t.Fatalf("tx 为 nil 应 ErrInternal,实得 %v", err)
  410. }
  411. }
  412. // TestUpdateSignedStatusRows 状态更新返回影响行数(0 → Service 判 3001)。
  413. func TestUpdateSignedStatusRows(t *testing.T) {
  414. db := newActivityRepoDB(t)
  415. seedActivity(t, db, 5, 1, 2, model.ActivityTypeVoucher, "0.00")
  416. user := "01HUSER000000000000000003"
  417. seedSigned(t, db, 1, 5, 1, 2, user, model.ActivitySignedSigned)
  418. repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  419. runner := NewTxRunner(db)
  420. ctx := context.Background()
  421. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  422. n, e := repo.UpdateSignedStatus(ctx, tx, user, 5, model.ActivitySignedCancelled)
  423. if e != nil {
  424. return e
  425. }
  426. if n != 1 {
  427. t.Fatalf("应更新 1 行,实得 %d", n)
  428. }
  429. return nil
  430. }); err != nil {
  431. t.Fatalf("UpdateSignedStatus 失败: %v", err)
  432. }
  433. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  434. n, e := repo.UpdateSignedStatus(ctx, tx, "01HNOPE000000000000000001", 5, model.ActivitySignedSigned)
  435. if e != nil {
  436. return e
  437. }
  438. if n != 0 {
  439. t.Fatalf("不存在的报名行应 0 行,实得 %d", n)
  440. }
  441. return nil
  442. }); err != nil {
  443. t.Fatalf("UpdateSignedStatus(缺失行) 失败: %v", err)
  444. }
  445. var row model.CoursesActivitySigned
  446. if err := db.Where("id = ?", 1).First(&row).Error; err != nil {
  447. t.Fatalf("读报名行失败: %v", err)
  448. }
  449. if row.Status != model.ActivitySignedCancelled {
  450. t.Fatalf("状态应更新为 0,实得 %d", row.Status)
  451. }
  452. }
  453. // TestListDiscountLogsByStudentCourseCursor 赠送退回对象(audit A11,dump 763057):
  454. // 只取负值流水、固定 id DESC、不接收方向参数(从根上消除 ORDER BY 注入面)。
  455. //
  456. // 替换说明:原 `TestListSignedByStudentCourseCursor` 测的 `signed←pool←log` 三表 join 路径
  457. // **在 dump 中没有任何对应 SP 依据**(audit A11 已删除该方法)。为避免"测错了 → 完全没测",
  458. // 本用例保留同样的造数,改为测新原语:退回对象一律以 `discount_log` 负值流水为准。
  459. func TestListDiscountLogsByStudentCourseCursor(t *testing.T) {
  460. db := newActivityRepoDB(t)
  461. ctx := context.Background()
  462. seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "10.00")
  463. seedActivity(t, db, 2, 1, 2, model.ActivityTypeVoucher, "20.00")
  464. user := "01HUSER000000000000000004"
  465. seedSigned(t, db, 1, 1, 1, 2, user, model.ActivitySignedSigned)
  466. seedSigned(t, db, 2, 2, 1, 2, user, model.ActivitySignedDeal)
  467. pools := []model.CoursesActivityUserDiscount{
  468. {ID: 1, ActivityID: 1, InstitutionID: 1, CampusID: 2, UserID: user, Income: "100.00", DiscountAmount: "10.00", CreateTime: time.Now()},
  469. {ID: 2, ActivityID: 2, InstitutionID: 1, CampusID: 2, UserID: user, Income: "200.00", DiscountAmount: "20.00", CreateTime: time.Now()},
  470. }
  471. for i := range pools {
  472. if err := db.Create(&pools[i]).Error; err != nil {
  473. t.Fatalf("seed pool 失败: %v", err)
  474. }
  475. }
  476. stuCourse := int64(900)
  477. otherCourse := int64(901)
  478. logs := []model.CoursesActivityUserDiscountLog{
  479. {ID: 1, ActivityUserDiscountID: 1, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-10.00", CreateTime: time.Now()},
  480. {ID: 2, ActivityUserDiscountID: 2, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-20.00", CreateTime: time.Now()},
  481. {ID: 3, ActivityUserDiscountID: 1, CoursesStudentID: &stuCourse, DiscountAmountUsed: "5.00", CreateTime: time.Now()}, // 正值=退回动作,不得作为退回对象
  482. {ID: 4, ActivityUserDiscountID: 1, CoursesStudentID: &otherCourse, DiscountAmountUsed: "-7.00", CreateTime: time.Now()}, // 别的报课记录
  483. }
  484. for i := range logs {
  485. if err := db.Create(&logs[i]).Error; err != nil {
  486. t.Fatalf("seed discount log 失败: %v", err)
  487. }
  488. }
  489. repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  490. rows, err := repo.ListDiscountLogsByStudentCourse(ctx, stuCourse)
  491. if err != nil {
  492. t.Fatalf("ListDiscountLogsByStudentCourse 失败: %v", err)
  493. }
  494. // 只含该报课记录的**负值**行,且固定 id DESC(dump 退回顺序:最近使用优先)
  495. if len(rows) != 2 {
  496. t.Fatalf("应只返回 2 条负值流水,实得 %d:%+v", len(rows), rows)
  497. }
  498. if rows[0].ID != 2 || rows[1].ID != 1 {
  499. t.Fatalf("必须按 id DESC 固定排序,实得 %d,%d", rows[0].ID, rows[1].ID)
  500. }
  501. for _, r := range rows {
  502. if amountOfLog(t, r.DiscountAmountUsed) >= 0 {
  503. t.Fatalf("退回对象必须来自负值流水,实得 %s", r.DiscountAmountUsed)
  504. }
  505. }
  506. // 无匹配 → 非 nil 空切片
  507. empty, err := repo.ListDiscountLogsByStudentCourse(ctx, 999)
  508. if err != nil {
  509. t.Fatalf("无匹配查询失败: %v", err)
  510. }
  511. if empty == nil || len(empty) != 0 {
  512. t.Fatalf("无匹配应返回非 nil 空切片,实得 %v", empty)
  513. }
  514. // 参数防御
  515. if _, err := repo.ListDiscountLogsByStudentCourse(ctx, 0); !errors.Is(err, infraerrors.ErrBadParam) {
  516. t.Fatalf("stuCourseID<=0 应 ErrBadParam,实得 %v", err)
  517. }
  518. }
  519. // amountOfLog 把流水金额字符串转分(测试断言用;解析失败判红)。
  520. func amountOfLog(t *testing.T, raw string) money.Amount {
  521. t.Helper()
  522. amount, err := money.Parse(raw)
  523. if err != nil {
  524. t.Fatalf("解析流水金额 %q 失败: %v", raw, err)
  525. }
  526. return amount
  527. }
  528. // TestCancelSignedZeroesMoney 取消报名三列同置 0(audit V-05 / dump 763028)。
  529. //
  530. // dump:`SET status=0, activity_money=0, activity_money_used=0`(一条 UPDATE)。
  531. // 红线断言:**金额必须归零**(旧实现只改 status,残留金额会被对账/双跑当成"已使用的赠送额")。
  532. func TestCancelSignedZeroesMoney(t *testing.T) {
  533. db := newActivityRepoDB(t)
  534. ctx := context.Background()
  535. seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "10.00")
  536. user := "01HUSER000000000000000022"
  537. row := &model.CoursesActivitySigned{
  538. ID: 1, ActivityID: 1, UserID: user, InstitutionID: 1, CampusID: 2,
  539. Status: model.ActivitySignedSigned, ActivityMoney: "10.00", ActivityMoneyUsed: "3.00",
  540. CreateTime: time.Now(),
  541. }
  542. if err := db.Create(row).Error; err != nil {
  543. t.Fatalf("seed signed 失败: %v", err)
  544. }
  545. repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  546. runner := NewTxRunner(db)
  547. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  548. n, e := repo.CancelSigned(ctx, tx, user, 1)
  549. if e != nil {
  550. return e
  551. }
  552. if n != 1 {
  553. t.Fatalf("应影响 1 行,实得 %d", n)
  554. }
  555. return nil
  556. }); err != nil {
  557. t.Fatalf("CancelSigned 失败: %v", err)
  558. }
  559. var got model.CoursesActivitySigned
  560. if err := db.Where("id = ?", 1).First(&got).Error; err != nil {
  561. t.Fatalf("回读报名行失败: %v", err)
  562. }
  563. if got.Status != model.ActivitySignedCancelled {
  564. t.Fatalf("status 应归 0,实得 %d", got.Status)
  565. }
  566. if amountOfLog(t, got.ActivityMoney) != 0 || amountOfLog(t, got.ActivityMoneyUsed) != 0 {
  567. t.Fatalf("activity_money/activity_money_used 必须归零,实得 %s/%s",
  568. got.ActivityMoney, got.ActivityMoneyUsed)
  569. }
  570. // 越权/不存在:跨机构行数 0(不报错,由 Service 判 3001)
  571. other := NewActivityRepo(db, &appscope.Scope{InstitutionID: 2, CampusID: 5})
  572. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  573. n, e := other.CancelSigned(ctx, tx, user, 1)
  574. if e != nil {
  575. return e
  576. }
  577. if n != 0 {
  578. t.Fatalf("跨机构取消应 0 行(作用域拦截),实得 %d", n)
  579. }
  580. return nil
  581. }); err != nil {
  582. t.Fatalf("跨机构用例失败: %v", err)
  583. }
  584. // 参数防御
  585. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  586. _, e := repo.CancelSigned(ctx, tx, "", 1)
  587. if !errors.Is(e, infraerrors.ErrBadParam) {
  588. t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", e)
  589. }
  590. return nil
  591. }); err != nil {
  592. t.Fatalf("参数防御用例失败: %v", err)
  593. }
  594. }
  595. // TestNormalizeOrder 方向归一(大小写/空白容忍,非法拒绝)。
  596. func TestNormalizeOrder(t *testing.T) {
  597. for _, in := range []string{"", "asc", "ASC", " Asc "} {
  598. if got, err := normalizeOrder(in); err != nil || got != OrderAsc {
  599. t.Fatalf("normalizeOrder(%q) = (%q,%v),期望 (asc,nil)", in, got, err)
  600. }
  601. }
  602. if got, err := normalizeOrder("DESC"); err != nil || got != OrderDesc {
  603. t.Fatalf("normalizeOrder(DESC) = (%q,%v),期望 (desc,nil)", got, err)
  604. }
  605. if _, err := normalizeOrder("id desc, (select 1)"); !errors.Is(err, infraerrors.ErrBadParam) {
  606. t.Fatalf("非法方向应 ErrBadParam,实得 %v", err)
  607. }
  608. }
  609. // TestWriteAuditTimeFallback 零值 create_time 必须由 **DB 默认值**兜底(不再有 Go 侧兜底函数)。
  610. //
  611. // 背景(实测缺陷 → 根因修复):活动域 model 的审计列名为 `CreateTime`(非 GORM 约定的 `CreatedAt`),
  612. // 原先 tag 只有 `not null`、没有 default → GORM 会把 time.Time 零值**显式**写成 0001-01-01,
  613. // DB 默认值兜不住。根因已在 model 层修复(`default:CURRENT_TIMESTAMP`):零值列不进 INSERT,
  614. // 由 DB 应用默认值;非零值仍原样写入。故三条写路径(signed / discount_log / used_log)现在
  615. // **不需要任何仓储兜底**,断言改为「读回时间晚于 2020 年」(证明确实落的是当前时钟而非零值)。
  616. func TestWriteAuditTimeFallback(t *testing.T) {
  617. db := newActivityRepoDB(t)
  618. seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "0.00")
  619. seedPool(t, db, 1, 1, 2, "01HUSER000000000000000099", "10.00", "0.00")
  620. repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  621. drepo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
  622. runner := NewTxRunner(db)
  623. ctx := context.Background()
  624. user := "01HUSER000000000000000099"
  625. stuCourse := int64(902)
  626. if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
  627. if e := repo.UpsertSigned(ctx, tx, &model.CoursesActivitySigned{
  628. ActivityID: 1, UserID: user, CampusID: 2, Status: model.ActivitySignedSigned,
  629. }); e != nil {
  630. return e
  631. }
  632. if e := drepo.InsertDiscountLog(ctx, tx, &model.CoursesActivityUserDiscountLog{
  633. ActivityUserDiscountID: 1, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-1.00",
  634. }); e != nil {
  635. return e
  636. }
  637. return drepo.InsertUsedLog(ctx, tx, &model.CoursesActivityUsedLog{
  638. ActivityID: 1, UserID: user, OrderNo: ptrString("902"), ActivityMoneyUsed: "-1.00",
  639. })
  640. }); err != nil {
  641. t.Fatalf("写入失败: %v", err)
  642. }
  643. // 阈值:任何真实写入都必须晚于 2020 年(零值 0001-01-01 会被判红)
  644. floor := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
  645. var signed model.CoursesActivitySigned
  646. if err := db.Where("user_id = ?", user).First(&signed).Error; err != nil {
  647. t.Fatalf("读 signed 失败: %v", err)
  648. }
  649. if !signed.CreateTime.After(floor) {
  650. t.Fatalf("signed.create_time = %s,期望由 DB 默认值落当前时间(> 2020)", signed.CreateTime)
  651. }
  652. var dlog model.CoursesActivityUserDiscountLog
  653. if err := db.Where("activity_user_discount_id = ?", 1).First(&dlog).Error; err != nil {
  654. t.Fatalf("读 discount log 失败: %v", err)
  655. }
  656. if !dlog.CreateTime.After(floor) {
  657. t.Fatalf("discount_log.create_time = %s,期望由 DB 默认值落当前时间(> 2020)", dlog.CreateTime)
  658. }
  659. var ulog model.CoursesActivityUsedLog
  660. if err := db.Where("user_id = ?", user).First(&ulog).Error; err != nil {
  661. t.Fatalf("读 used log 失败: %v", err)
  662. }
  663. if !ulog.CreateTime.After(floor) {
  664. t.Fatalf("used_log.create_time = %s,期望由 DB 默认值落当前时间(> 2020)", ulog.CreateTime)
  665. }
  666. // 显式值必须**不被默认值覆盖**(历史回放/人工录入依赖此语义)
  667. explicit := time.Date(2015, 3, 4, 5, 6, 7, 0, time.UTC)
  668. if err := db.Create(&model.CoursesActivity{
  669. ID: 2,
  670. Type: model.ActivityTypeRecharge,
  671. InstitutionID: 1,
  672. CampusID: 2,
  673. Money: "0.00",
  674. Status: model.ActivityStatusEnabled,
  675. CreateTime: explicit,
  676. UpdatedAt: explicit,
  677. }).Error; err != nil {
  678. t.Fatalf("显式 create_time INSERT 失败: %v", err)
  679. }
  680. var back model.CoursesActivity
  681. if err := db.Where("id = ?", 2).First(&back).Error; err != nil {
  682. t.Fatalf("回读 activity#2 失败: %v", err)
  683. }
  684. if !back.CreateTime.Equal(explicit) {
  685. t.Fatalf("显式 create_time 被默认值覆盖:写 %s,读 %s", explicit, back.CreateTime)
  686. }
  687. }