// ActivityRepo 单测(AC-06):作用域超集语义 / 批量防 N+1 / DELETE+INSERT 幂等 / // type=4 跨活动去重 / number_sign 下限 / 游标方向白名单。 // // 测试栈:glebarez/sqlite(纯 Go,CGO_ENABLED=0 可跑)——真实库用例走 t.TempDir 文件库, // SQL 形状用例走 DryRun 句柄(与 base_test.go 同一套 helper)。 package repository import ( "context" "errors" "path/filepath" "strings" "testing" "time" gormsqlite "github.com/glebarez/sqlite" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // newActivityRepoDB 活动域真实库(文件库;含报名/赠送池/流水 5 表 AutoMigrate)。 func newActivityRepoDB(t *testing.T) *gorm.DB { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_activity_repo_test.db") db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{}) if err != nil { t.Fatalf("gorm.Open(活动文件库) 失败: %v", err) } t.Cleanup(func() { if sqlDB, e := db.DB(); e == nil { _ = sqlDB.Close() } }) if err := db.AutoMigrate( &model.CoursesActivity{}, &model.CoursesActivitySigned{}, &model.CoursesActivityUserDiscount{}, &model.CoursesActivityUserDiscountLog{}, &model.CoursesActivityUsedLog{}, ); err != nil { t.Fatalf("AutoMigrate(活动 5 表) 失败: %v", err) } return db } // seedActivity 插入一条活动(显式 ID,便于断言)。 func seedActivity(t *testing.T, db *gorm.DB, id, inst, campus int64, typ int8, money string) *model.CoursesActivity { t.Helper() row := &model.CoursesActivity{ ID: id, Type: typ, InstitutionID: inst, CampusID: campus, Money: money, Status: model.ActivityStatusEnabled, CreateTime: time.Now(), UpdatedAt: time.Now(), } if err := db.Create(row).Error; err != nil { t.Fatalf("seed activity(%d) 失败: %v", id, err) } return row } // seedSigned 插入一条报名行(显式 ID)。 func seedSigned(t *testing.T, db *gorm.DB, id, activityID, inst, campus int64, userID string, status int32) { t.Helper() row := &model.CoursesActivitySigned{ ID: id, ActivityID: activityID, UserID: userID, InstitutionID: inst, CampusID: campus, Status: status, CreateTime: time.Now(), } if err := db.Create(row).Error; err != nil { t.Fatalf("seed signed(%d) 失败: %v", id, err) } } // captureSQL 注册 Query/Update 回调,收集执行过的 SQL 语句(断言防 N+1 / 单语句 CASE)。 func captureSQL(t *testing.T, db *gorm.DB) *[]string { t.Helper() out := make([]string, 0, 8) collect := func(d *gorm.DB) { if d.Statement != nil && d.Statement.SQL.Len() > 0 { out = append(out, d.Statement.SQL.String()) } } if err := db.Callback().Query().After("gorm:query").Register("test:capture_query", collect); err != nil { t.Fatalf("注册 Query 回调失败: %v", err) } if err := db.Callback().Update().After("gorm:update").Register("test:capture_update", collect); err != nil { t.Fatalf("注册 Update 回调失败: %v", err) } return &out } // TestScopedCampusRowsSQL 校区过滤必须是 `IN (campus, 0)` 超集(不是等值),机构过滤强制注入。 func TestScopedCampusRowsSQL(t *testing.T) { db := newDryRunDB(t) sc := &appscope.Scope{InstitutionID: 42, CampusID: 7} q := scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), sc, "") var out []model.CoursesActivity if err := q.Where("id = ?", 1).Find(&out).Error; err != nil { t.Fatalf("DryRun Find 失败: %v", err) } sql := q.Statement.SQL.String() if !strings.Contains(sql, "institution_id = ?") { t.Errorf("应强制注入 institution_id,SQL=%q", sql) } if !strings.Contains(sql, "campus_id IN") { t.Errorf("校区过滤必须是 IN (campus,0) 超集(campus_id=0 语义=全自营校区),SQL=%q", sql) } if strings.Contains(sql, "campus_id = ?") { t.Errorf("不得用等值过滤(会裁掉全机构活动),SQL=%q", sql) } } // TestScopedCampusRowsSuperAdminAndCampusZero 超管不过滤;campus=0 时不加校区条件。 func TestScopedCampusRowsSuperAdminAndCampusZero(t *testing.T) { db := newDryRunDB(t) // 超管 sc := &appscope.Scope{InstitutionID: 42, CampusID: 7, SuperAdmin: true} q := scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), sc, "") var out []model.CoursesActivity if err := q.Find(&out).Error; err != nil { t.Fatalf("DryRun Find 失败: %v", err) } if sql := q.Statement.SQL.String(); strings.Contains(sql, "institution_id") || strings.Contains(sql, "campus_id") { t.Errorf("超管不应注入作用域过滤,SQL=%q", sql) } // 机构级视角(campus=0) sc2 := &appscope.Scope{InstitutionID: 42, CampusID: 0} q2 := scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), sc2, "") if err := q2.Find(&out).Error; err != nil { t.Fatalf("DryRun Find 失败: %v", err) } if sql := q2.Statement.SQL.String(); strings.Contains(sql, "campus_id") { t.Errorf("CampusID=0 不应注入校区过滤,SQL=%q", sql) } } // TestScopedCampusRowsNilScopePanics 无作用域进入数据层 → panic(fail-fast)。 func TestScopedCampusRowsNilScopePanics(t *testing.T) { db := newDryRunDB(t) defer func() { rec := recover() if rec == nil { t.Fatal("Sc 为 nil 时应 panic") } if !strings.Contains(rec.(string), "missing tenant scope") { t.Fatalf("panic 文案不符: %v", rec) } }() _ = scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), nil, "") } // TestGetByIDScoping 跨机构活动 → ErrNotFound;campus_id=0 活动在本校区视角可见。 func TestGetByIDScoping(t *testing.T) { db := newActivityRepoDB(t) seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "100.00") seedActivity(t, db, 2, 1, 0, model.ActivityTypeVoucher, "50.00") // 0=机构所有自营校区 seedActivity(t, db, 3, 2, 2, model.ActivityTypeRecommend, "30.00") repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) if got, err := repo.GetByID(context.Background(), 1); err != nil || got.ID != 1 { t.Fatalf("GetByID(1) 应命中,got=%v err=%v", got, err) } if _, err := repo.GetByID(context.Background(), 2); err != nil { t.Fatalf("campus_id=0 的活动应在本校区视角可见(IN (campus,0)),实得 %v", err) } if _, err := repo.GetByID(context.Background(), 3); !errors.Is(err, infraerrors.ErrNotFound) { t.Fatalf("跨机构活动必须 ErrNotFound(不泄露存在性),实得 %v", err) } if _, err := repo.GetByID(context.Background(), 0); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("id<=0 应 ErrBadParam,实得 %v", err) } } // TestGetByIDsNoNPlusOne 批量取数:去重 + 单条 SQL(防 N+1)+ 缺失 id 不在 map 中。 func TestGetByIDsNoNPlusOne(t *testing.T) { db := newActivityRepoDB(t) seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "100.00") seedActivity(t, db, 2, 1, 2, model.ActivityTypeVoucher, "50.00") seedActivity(t, db, 3, 9, 2, model.ActivityTypeRecommend, "30.00") // 他机构:应被裁掉 repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) sqls := captureSQL(t, db) got, err := repo.GetByIDs(context.Background(), []int64{1, 2, 2, 3, 0, -1}) if err != nil { t.Fatalf("GetByIDs 失败: %v", err) } if len(got) != 2 || got[1] == nil || got[2] == nil { t.Fatalf("应返回 {1,2} 两行(去重+作用域裁剪),实得 %v", got) } if len(*sqls) != 1 { t.Fatalf("批量取数必须是 1 条 SQL(防 N+1),实得 %d 条: %v", len(*sqls), *sqls) } if !strings.Contains((*sqls)[0], "IN") { t.Fatalf("应为 IN 批量查询,SQL=%q", (*sqls)[0]) } // 空/全非法 → 空 map 且不发 SQL *sqls = (*sqls)[:0] empty, err := repo.GetByIDs(context.Background(), []int64{0, -3}) if err != nil || len(empty) != 0 { t.Fatalf("空 ids 应返回空 map 且无错误,实得 %v err=%v", empty, err) } if len(*sqls) != 0 { t.Fatalf("空 ids 不应发 SQL,实得 %v", *sqls) } } // TestUpsertSignedDeleteInsertIdempotent DELETE+INSERT 幂等:同一 (user, activity) 只留一条且全字段重置。 func TestUpsertSignedDeleteInsertIdempotent(t *testing.T) { db := newActivityRepoDB(t) seedActivity(t, db, 10, 1, 2, model.ActivityTypeRecharge, "100.00") repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) runner := NewTxRunner(db) ctx := context.Background() var firstID int64 err := runner.WithinTx(ctx, func(tx *gorm.DB) error { row := &model.CoursesActivitySigned{ ActivityID: 10, UserID: "01HUSER000000000000000001", CampusID: 2, Status: model.ActivitySignedSigned, ActivityMoney: "100.00", CreateTime: time.Now(), } if err := repo.UpsertSigned(ctx, tx, row); err != nil { return err } firstID = row.ID return nil }) if err != nil { t.Fatalf("首次 UpsertSigned 失败: %v", err) } // 模拟"先取消(status=0)后重新报名":旧行必须被 DELETE 掉,不能残留两条 err = runner.WithinTx(ctx, func(tx *gorm.DB) error { row := &model.CoursesActivitySigned{ ActivityID: 10, UserID: "01HUSER000000000000000001", CampusID: 2, Status: model.ActivitySignedAttended, ActivityMoney: "88.00", CreateTime: time.Now(), } return repo.UpsertSigned(ctx, tx, row) }) if err != nil { t.Fatalf("二次 UpsertSigned 失败: %v", err) } var rows []model.CoursesActivitySigned if err := db.Where("user_id = ? AND activity_id = ?", "01HUSER000000000000000001", 10).Find(&rows).Error; err != nil { t.Fatalf("查询报名行失败: %v", err) } if len(rows) != 1 { t.Fatalf("DELETE+INSERT 幂等后应仅 1 行,实得 %d", len(rows)) } if rows[0].Status != model.ActivitySignedAttended { t.Fatalf("重新报名应全字段重置,实得 status=%d", rows[0].Status) } // 金额列在 sqlite 下按 NUMERIC 亲和回读("88.00" → 88),故用 money 归一后比较语义值 if amt, perr := money.Parse(rows[0].ActivityMoney); perr != nil || amt.Cents() != 8800 { t.Fatalf("重新报名应重置 activity_money=88.00,实得 %q (err=%v)", rows[0].ActivityMoney, perr) } if rows[0].ID == firstID { t.Fatalf("重新报名应插入新行(旧行被 DELETE),ID 未变 = %d", rows[0].ID) } if rows[0].InstitutionID != 1 { t.Fatalf("institution_id 应由作用域补齐为 1,实得 %d", rows[0].InstitutionID) } } // TestUpsertSignedGuards 事务/参数防御。 func TestUpsertSignedGuards(t *testing.T) { repo := NewActivityRepo(newActivityRepoDB(t), &appscope.Scope{InstitutionID: 1, CampusID: 2}) ctx := context.Background() if err := repo.UpsertSigned(ctx, nil, &model.CoursesActivitySigned{ActivityID: 1, UserID: "u"}); !errors.Is(err, infraerrors.ErrInternal) { t.Fatalf("tx 为 nil 应 ErrInternal(禁止脱离事务写),实得 %v", err) } if err := repo.UpsertSigned(ctx, repo.DB, &model.CoursesActivitySigned{ActivityID: 0, UserID: "u"}); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("activity_id<=0 应 ErrBadParam,实得 %v", err) } if err := repo.UpsertSigned(ctx, repo.DB, nil); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("nil 行应 ErrBadParam,实得 %v", err) } } // TestCountSignedByUserType4Dedup type=4 判据(**逐字对齐 dump `yuansiwei_finance.sql:762947`**)。 // // dump 口径:`WHERE t1.userid=? AND t2.status != 0`(判**活动** status,**不过滤报名行 status**)。 // 审计 P0-1 修复前本实现筛的是 `signed.status <> 0` → "取消报名后可重报再领老带新奖励"(重复发钱)。 func TestCountSignedByUserType4Dedup(t *testing.T) { db := newActivityRepoDB(t) seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecommend, "10.00") seedActivity(t, db, 2, 1, 2, model.ActivityTypeRecommend, "20.00") seedActivity(t, db, 3, 1, 2, model.ActivityTypeRecommend, "30.00") seedActivity(t, db, 4, 1, 2, model.ActivityTypeRecharge, "40.00") // 活动 3 已停用(status=0)→ dump 口径不计(停用活动可重报) if err := db.Model(&model.CoursesActivity{}).Where("id = ?", 3). UpdateColumn("status", model.ActivityStatusDisabled).Error; err != nil { t.Fatalf("停用活动 3 失败: %v", err) } user := "01HUSER000000000000000002" seedSigned(t, db, 1, 1, 1, 2, user, model.ActivitySignedSigned) seedSigned(t, db, 2, 2, 1, 2, user, model.ActivitySignedCancelled) // **已取消也计**(参加过) seedSigned(t, db, 3, 3, 1, 2, user, model.ActivitySignedSigned) // 活动已停用 → 不计 seedSigned(t, db, 4, 4, 1, 2, user, model.ActivitySignedSigned) // type=2 → type4Only 不计 seedSigned(t, db, 5, 1, 1, 2, "01HUSER000000000000000009", model.ActivitySignedSigned) repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) ctx := context.Background() type4, err := repo.CountSignedByUser(ctx, user, true) if err != nil { t.Fatalf("CountSignedByUser(type4) 失败: %v", err) } // 活动 1(有效报名)+ 活动 2(**取消过的报名**)= 2;活动 3(停用)与活动 4(非 type=4)不计 if type4 != 2 { t.Fatalf("type=4 计数应为 2(取消过也算、停用活动不计),实得 %d", type4) } // A1-1:取消过 type=4 报名后**仍拒绝**(3106 语义)——只留活动 2 的取消行 if err := db.Where("id IN ?", []int64{1, 3, 4, 5}).Delete(&model.CoursesActivitySigned{}).Error; err != nil { t.Fatalf("清理报名行失败: %v", err) } if again, err := repo.CountSignedByUser(ctx, user, true); err != nil || again != 1 { t.Fatalf("取消过报名仍应计数 1(防取消后重报重复领奖),实得 %d err=%v", again, err) } // A1-2:活动停用后可重报(停用活动不再计入 type=4 判据) if err := db.Model(&model.CoursesActivity{}).Where("id = ?", 2). UpdateColumn("status", model.ActivityStatusDisabled).Error; err != nil { t.Fatalf("停用活动 2 失败: %v", err) } if again, err := repo.CountSignedByUser(ctx, user, true); err != nil || again != 0 { t.Fatalf("活动停用后应可重报(计数 0),实得 %d err=%v", again, err) } // 守卫口径(type4Only=false):只看报名行是否有效 → 已取消行不计 all, err := repo.CountSignedByUser(ctx, user, false) if err != nil { t.Fatalf("CountSignedByUser(all) 失败: %v", err) } if all != 0 { t.Fatalf("signed_cancel 守卫口径下已取消行应为 0,实得 %d", all) } if _, err := repo.CountSignedByUser(ctx, " ", true); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err) } } // TestIncreaseNumberSignFloorZero 报名人数原子增减且下限 0。 func TestIncreaseNumberSignFloorZero(t *testing.T) { db := newActivityRepoDB(t) seedActivity(t, db, 7, 1, 2, model.ActivityTypeRecharge, "0.00") repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) runner := NewTxRunner(db) ctx := context.Background() readSign := func() int32 { var row model.CoursesActivity if err := db.Where("id = ?", 7).First(&row).Error; err != nil { t.Fatalf("读活动失败: %v", err) } return row.NumberSign } // 取消到负数 → 钳制为 0 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { _, e := repo.IncreaseNumberSign(ctx, tx, 7, -5) return e }); err != nil { t.Fatalf("IncreaseNumberSign(-5) 失败: %v", err) } if got := readSign(); got != 0 { t.Fatalf("number_sign 下限应为 0(防取消竞态),实得 %d", got) } // +2 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { _, e := repo.IncreaseNumberSign(ctx, tx, 7, 2) return e }); err != nil { t.Fatalf("IncreaseNumberSign(+2) 失败: %v", err) } if got := readSign(); got != 2 { t.Fatalf("number_sign 应为 2,实得 %d", got) } // -1 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { _, e := repo.IncreaseNumberSign(ctx, tx, 7, -1) return e }); err != nil { t.Fatalf("IncreaseNumberSign(-1) 失败: %v", err) } if got := readSign(); got != 1 { t.Fatalf("number_sign 应为 1,实得 %d", got) } // delta=0 → 空操作 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { n, e := repo.IncreaseNumberSign(ctx, tx, 7, 0) if n != 0 { t.Fatalf("delta=0 应无影响行,实得 %d", n) } return e }); err != nil { t.Fatalf("IncreaseNumberSign(0) 失败: %v", err) } // 不存在/越权活动 → 0 行 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { n, e := repo.IncreaseNumberSign(ctx, tx, 999, 1) if e != nil { return e } if n != 0 { t.Fatalf("活动不存在应 0 行,实得 %d", n) } return nil }); err != nil { t.Fatalf("IncreaseNumberSign(999) 失败: %v", err) } if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { _, e := repo.IncreaseNumberSign(ctx, nil, 7, 1) return e }); !errors.Is(err, infraerrors.ErrInternal) { t.Fatalf("tx 为 nil 应 ErrInternal,实得 %v", err) } } // TestUpdateSignedStatusRows 状态更新返回影响行数(0 → Service 判 3001)。 func TestUpdateSignedStatusRows(t *testing.T) { db := newActivityRepoDB(t) seedActivity(t, db, 5, 1, 2, model.ActivityTypeVoucher, "0.00") user := "01HUSER000000000000000003" seedSigned(t, db, 1, 5, 1, 2, user, model.ActivitySignedSigned) repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) runner := NewTxRunner(db) ctx := context.Background() if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { n, e := repo.UpdateSignedStatus(ctx, tx, user, 5, model.ActivitySignedCancelled) if e != nil { return e } if n != 1 { t.Fatalf("应更新 1 行,实得 %d", n) } return nil }); err != nil { t.Fatalf("UpdateSignedStatus 失败: %v", err) } if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { n, e := repo.UpdateSignedStatus(ctx, tx, "01HNOPE000000000000000001", 5, model.ActivitySignedSigned) if e != nil { return e } if n != 0 { t.Fatalf("不存在的报名行应 0 行,实得 %d", n) } return nil }); err != nil { t.Fatalf("UpdateSignedStatus(缺失行) 失败: %v", err) } var row model.CoursesActivitySigned if err := db.Where("id = ?", 1).First(&row).Error; err != nil { t.Fatalf("读报名行失败: %v", err) } if row.Status != model.ActivitySignedCancelled { t.Fatalf("状态应更新为 0,实得 %d", row.Status) } } // TestListDiscountLogsByStudentCourseCursor 赠送退回对象(audit A11,dump 763057): // 只取负值流水、固定 id DESC、不接收方向参数(从根上消除 ORDER BY 注入面)。 // // 替换说明:原 `TestListSignedByStudentCourseCursor` 测的 `signed←pool←log` 三表 join 路径 // **在 dump 中没有任何对应 SP 依据**(audit A11 已删除该方法)。为避免"测错了 → 完全没测", // 本用例保留同样的造数,改为测新原语:退回对象一律以 `discount_log` 负值流水为准。 func TestListDiscountLogsByStudentCourseCursor(t *testing.T) { db := newActivityRepoDB(t) ctx := context.Background() seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "10.00") seedActivity(t, db, 2, 1, 2, model.ActivityTypeVoucher, "20.00") user := "01HUSER000000000000000004" seedSigned(t, db, 1, 1, 1, 2, user, model.ActivitySignedSigned) seedSigned(t, db, 2, 2, 1, 2, user, model.ActivitySignedDeal) pools := []model.CoursesActivityUserDiscount{ {ID: 1, ActivityID: 1, InstitutionID: 1, CampusID: 2, UserID: user, Income: "100.00", DiscountAmount: "10.00", CreateTime: time.Now()}, {ID: 2, ActivityID: 2, InstitutionID: 1, CampusID: 2, UserID: user, Income: "200.00", DiscountAmount: "20.00", CreateTime: time.Now()}, } for i := range pools { if err := db.Create(&pools[i]).Error; err != nil { t.Fatalf("seed pool 失败: %v", err) } } stuCourse := int64(900) otherCourse := int64(901) logs := []model.CoursesActivityUserDiscountLog{ {ID: 1, ActivityUserDiscountID: 1, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-10.00", CreateTime: time.Now()}, {ID: 2, ActivityUserDiscountID: 2, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-20.00", CreateTime: time.Now()}, {ID: 3, ActivityUserDiscountID: 1, CoursesStudentID: &stuCourse, DiscountAmountUsed: "5.00", CreateTime: time.Now()}, // 正值=退回动作,不得作为退回对象 {ID: 4, ActivityUserDiscountID: 1, CoursesStudentID: &otherCourse, DiscountAmountUsed: "-7.00", CreateTime: time.Now()}, // 别的报课记录 } for i := range logs { if err := db.Create(&logs[i]).Error; err != nil { t.Fatalf("seed discount log 失败: %v", err) } } repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) rows, err := repo.ListDiscountLogsByStudentCourse(ctx, stuCourse) if err != nil { t.Fatalf("ListDiscountLogsByStudentCourse 失败: %v", err) } // 只含该报课记录的**负值**行,且固定 id DESC(dump 退回顺序:最近使用优先) if len(rows) != 2 { t.Fatalf("应只返回 2 条负值流水,实得 %d:%+v", len(rows), rows) } if rows[0].ID != 2 || rows[1].ID != 1 { t.Fatalf("必须按 id DESC 固定排序,实得 %d,%d", rows[0].ID, rows[1].ID) } for _, r := range rows { if amountOfLog(t, r.DiscountAmountUsed) >= 0 { t.Fatalf("退回对象必须来自负值流水,实得 %s", r.DiscountAmountUsed) } } // 无匹配 → 非 nil 空切片 empty, err := repo.ListDiscountLogsByStudentCourse(ctx, 999) if err != nil { t.Fatalf("无匹配查询失败: %v", err) } if empty == nil || len(empty) != 0 { t.Fatalf("无匹配应返回非 nil 空切片,实得 %v", empty) } // 参数防御 if _, err := repo.ListDiscountLogsByStudentCourse(ctx, 0); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("stuCourseID<=0 应 ErrBadParam,实得 %v", err) } } // amountOfLog 把流水金额字符串转分(测试断言用;解析失败判红)。 func amountOfLog(t *testing.T, raw string) money.Amount { t.Helper() amount, err := money.Parse(raw) if err != nil { t.Fatalf("解析流水金额 %q 失败: %v", raw, err) } return amount } // TestCancelSignedZeroesMoney 取消报名三列同置 0(audit V-05 / dump 763028)。 // // dump:`SET status=0, activity_money=0, activity_money_used=0`(一条 UPDATE)。 // 红线断言:**金额必须归零**(旧实现只改 status,残留金额会被对账/双跑当成"已使用的赠送额")。 func TestCancelSignedZeroesMoney(t *testing.T) { db := newActivityRepoDB(t) ctx := context.Background() seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "10.00") user := "01HUSER000000000000000022" row := &model.CoursesActivitySigned{ ID: 1, ActivityID: 1, UserID: user, InstitutionID: 1, CampusID: 2, Status: model.ActivitySignedSigned, ActivityMoney: "10.00", ActivityMoneyUsed: "3.00", CreateTime: time.Now(), } if err := db.Create(row).Error; err != nil { t.Fatalf("seed signed 失败: %v", err) } repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) runner := NewTxRunner(db) if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { n, e := repo.CancelSigned(ctx, tx, user, 1) if e != nil { return e } if n != 1 { t.Fatalf("应影响 1 行,实得 %d", n) } return nil }); err != nil { t.Fatalf("CancelSigned 失败: %v", err) } var got model.CoursesActivitySigned if err := db.Where("id = ?", 1).First(&got).Error; err != nil { t.Fatalf("回读报名行失败: %v", err) } if got.Status != model.ActivitySignedCancelled { t.Fatalf("status 应归 0,实得 %d", got.Status) } if amountOfLog(t, got.ActivityMoney) != 0 || amountOfLog(t, got.ActivityMoneyUsed) != 0 { t.Fatalf("activity_money/activity_money_used 必须归零,实得 %s/%s", got.ActivityMoney, got.ActivityMoneyUsed) } // 越权/不存在:跨机构行数 0(不报错,由 Service 判 3001) other := NewActivityRepo(db, &appscope.Scope{InstitutionID: 2, CampusID: 5}) if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { n, e := other.CancelSigned(ctx, tx, user, 1) if e != nil { return e } if n != 0 { t.Fatalf("跨机构取消应 0 行(作用域拦截),实得 %d", n) } return nil }); err != nil { t.Fatalf("跨机构用例失败: %v", err) } // 参数防御 if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { _, e := repo.CancelSigned(ctx, tx, "", 1) if !errors.Is(e, infraerrors.ErrBadParam) { t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", e) } return nil }); err != nil { t.Fatalf("参数防御用例失败: %v", err) } } // TestNormalizeOrder 方向归一(大小写/空白容忍,非法拒绝)。 func TestNormalizeOrder(t *testing.T) { for _, in := range []string{"", "asc", "ASC", " Asc "} { if got, err := normalizeOrder(in); err != nil || got != OrderAsc { t.Fatalf("normalizeOrder(%q) = (%q,%v),期望 (asc,nil)", in, got, err) } } if got, err := normalizeOrder("DESC"); err != nil || got != OrderDesc { t.Fatalf("normalizeOrder(DESC) = (%q,%v),期望 (desc,nil)", got, err) } if _, err := normalizeOrder("id desc, (select 1)"); !errors.Is(err, infraerrors.ErrBadParam) { t.Fatalf("非法方向应 ErrBadParam,实得 %v", err) } } // TestWriteAuditTimeFallback 零值 create_time 必须由 **DB 默认值**兜底(不再有 Go 侧兜底函数)。 // // 背景(实测缺陷 → 根因修复):活动域 model 的审计列名为 `CreateTime`(非 GORM 约定的 `CreatedAt`), // 原先 tag 只有 `not null`、没有 default → GORM 会把 time.Time 零值**显式**写成 0001-01-01, // DB 默认值兜不住。根因已在 model 层修复(`default:CURRENT_TIMESTAMP`):零值列不进 INSERT, // 由 DB 应用默认值;非零值仍原样写入。故三条写路径(signed / discount_log / used_log)现在 // **不需要任何仓储兜底**,断言改为「读回时间晚于 2020 年」(证明确实落的是当前时钟而非零值)。 func TestWriteAuditTimeFallback(t *testing.T) { db := newActivityRepoDB(t) seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "0.00") seedPool(t, db, 1, 1, 2, "01HUSER000000000000000099", "10.00", "0.00") repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) drepo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2}) runner := NewTxRunner(db) ctx := context.Background() user := "01HUSER000000000000000099" stuCourse := int64(902) if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { if e := repo.UpsertSigned(ctx, tx, &model.CoursesActivitySigned{ ActivityID: 1, UserID: user, CampusID: 2, Status: model.ActivitySignedSigned, }); e != nil { return e } if e := drepo.InsertDiscountLog(ctx, tx, &model.CoursesActivityUserDiscountLog{ ActivityUserDiscountID: 1, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-1.00", }); e != nil { return e } return drepo.InsertUsedLog(ctx, tx, &model.CoursesActivityUsedLog{ ActivityID: 1, UserID: user, OrderNo: ptrString("902"), ActivityMoneyUsed: "-1.00", }) }); err != nil { t.Fatalf("写入失败: %v", err) } // 阈值:任何真实写入都必须晚于 2020 年(零值 0001-01-01 会被判红) floor := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) var signed model.CoursesActivitySigned if err := db.Where("user_id = ?", user).First(&signed).Error; err != nil { t.Fatalf("读 signed 失败: %v", err) } if !signed.CreateTime.After(floor) { t.Fatalf("signed.create_time = %s,期望由 DB 默认值落当前时间(> 2020)", signed.CreateTime) } var dlog model.CoursesActivityUserDiscountLog if err := db.Where("activity_user_discount_id = ?", 1).First(&dlog).Error; err != nil { t.Fatalf("读 discount log 失败: %v", err) } if !dlog.CreateTime.After(floor) { t.Fatalf("discount_log.create_time = %s,期望由 DB 默认值落当前时间(> 2020)", dlog.CreateTime) } var ulog model.CoursesActivityUsedLog if err := db.Where("user_id = ?", user).First(&ulog).Error; err != nil { t.Fatalf("读 used log 失败: %v", err) } if !ulog.CreateTime.After(floor) { t.Fatalf("used_log.create_time = %s,期望由 DB 默认值落当前时间(> 2020)", ulog.CreateTime) } // 显式值必须**不被默认值覆盖**(历史回放/人工录入依赖此语义) explicit := time.Date(2015, 3, 4, 5, 6, 7, 0, time.UTC) if err := db.Create(&model.CoursesActivity{ ID: 2, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 2, Money: "0.00", Status: model.ActivityStatusEnabled, CreateTime: explicit, UpdatedAt: explicit, }).Error; err != nil { t.Fatalf("显式 create_time INSERT 失败: %v", err) } var back model.CoursesActivity if err := db.Where("id = ?", 2).First(&back).Error; err != nil { t.Fatalf("回读 activity#2 失败: %v", err) } if !back.CreateTime.Equal(explicit) { t.Fatalf("显式 create_time 被默认值覆盖:写 %s,读 %s", explicit, back.CreateTime) } }