// PayrollRepo 单测(AC-10):机构级越权守卫 / 方案选取顺序 / 通用档的 NULL teacher 判定 / // `-1` 通配 / 教龄 BETWEEN / 无档案教师按"无证"处理。 // // 为什么这些要在仓储层再测一遍(Service 层已覆盖回退链): // - 回退链的**每一级 SQL 条件**都是"写错也不会报错、只是命中不了"的类型(`IS NULL OR = ""`、 // `IN (x,-1)`、`effect_date <= day`),Service 用例只能证明"某条路径通", // 无法定位"哪一级条件写错";仓储用例把每个条件单独钉住。 // - `assertInstitution` 是**替代 Scoped** 的越权防线(本仓储不调 Scoped 的理由见文件头), // 必须单独证明"跨机构会被 1004 拦住"——否则这条防线写不写都没人发现。 package repository import ( "context" stderrors "errors" "path/filepath" "strings" "testing" "time" gormsqlite "github.com/glebarez/sqlite" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // newPayrollRepoDB 课酬 4 表文件库。 func newPayrollRepoDB(t *testing.T) *gorm.DB { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_payroll_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() } }) migrateTablesDroppingPlainIndexes(t, db, &model.CoursePayment{}, &model.CoursePaymentDetail{}, &model.CoursesClassify{}, &model.TeacherInformation{}, ) return db } // migrateTablesDroppingPlainIndexes 逐表 AutoMigrate,并在每张表后删掉**非唯一**索引。 // // 为什么需要它(与 service 包的 migrateFundTables 同因):SQLite 的索引名是**库级唯一**, // 而 academic model 多张表共用 `idx_payment` / `idx_inst_campus` 等名字,一次性 AutoMigrate // 会报 "index already exists"。唯一约束**必须保留**(业务 UK),故只删非 UNIQUE 索引。 func migrateTablesDroppingPlainIndexes(t *testing.T, db *gorm.DB, tables ...any) { t.Helper() for _, tbl := range tables { if err := db.AutoMigrate(tbl); err != nil { t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err) } stmt := &gorm.Statement{DB: db} if err := stmt.Parse(tbl); err != nil { t.Fatalf("解析表名(%T) 失败: %v", tbl, err) } var rows []struct { Name string SQL *string } if err := db.Raw( "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'", stmt.Schema.Table).Scan(&rows).Error; err != nil { t.Fatalf("读取索引清单(%s) 失败: %v", stmt.Schema.Table, err) } for _, row := range rows { sqlText := "" if row.SQL != nil { sqlText = strings.ToUpper(*row.SQL) } if strings.Contains(sqlText, "UNIQUE") { continue // 唯一约束必须保留(业务 UK / 幂等键) } if err := db.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil { t.Fatalf("删除索引(%s) 失败: %v", row.Name, err) } } } } // payrollRepoScope 普通角色作用域(机构 1 / 校区 10)。 func payrollRepoScope() *appscope.Scope { return &appscope.Scope{InstitutionID: 1, CampusID: 10, UserID: "01HOPERATOR0000000000001", Role: "academic"} } // insertScheme 落一行课酬方案。 func insertScheme(t *testing.T, db *gorm.DB, id, inst, campus int64, effect time.Time, actived bool) { t.Helper() eff := effect row := &model.CoursePayment{ID: id, InstitutionID: inst, CampusID: campus, EffectDate: &eff, Actived: actived} if err := db.Create(row).Error; err != nil { t.Fatalf("seed 方案(%d) 失败: %v", id, err) } } // insertDetail 落一行课酬明细(teacherID=nil 表示通用档)。 func insertDetail(t *testing.T, db *gorm.DB, id, schemeID int64, teacherID *string, cert bool, classType, gradeID, subjectID int32, expMin, expMax string) { t.Helper() row := &model.CoursePaymentDetail{ ID: id, CoursePaymentID: schemeID, TeacherID: teacherID, TeacherCert: cert, ClassType: classType, GradeID: gradeID, SubjectID: subjectID, Percentage: "50.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00", TeacherExpMin: expMin, TeacherExpMax: expMax, Actived: true, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 明细(%d) 失败: %v", id, err) } } // TestPayrollRepoAssertInstitution 机构级越权守卫(替代 Scoped 的那道防线)。 func TestPayrollRepoAssertInstitution(t *testing.T) { db := newPayrollRepoDB(t) ctx := context.Background() insertScheme(t, db, 1, 2, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), true) day := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) // 普通角色查其它机构 → 1004(**不是** ErrNotFound,越权必须显式拒绝) repo := NewPayrollRepo(db, payrollRepoScope()) _, err := repo.FindActiveScheme(ctx, 2, 10, day) wantRepoErr(t, err, infraerrors.CodeScopeViolation, "跨机构查方案") // 超管 → 放行(可查机构 2) admin := &appscope.Scope{InstitutionID: 1, SuperAdmin: true, UserID: "01HADMIN0000000000000001"} got, err := NewPayrollRepo(db, admin).FindActiveScheme(ctx, 2, 10, day) if err != nil { t.Fatalf("超管查机构 2 失败: %v", err) } if got == nil || got.ID != 1 { t.Fatalf("超管应命中方案 1,实得 %+v", got) } // 系统任务视角(Sc 为 nil)→ 放行 if _, err := NewPayrollRepo(db, nil).FindActiveScheme(ctx, 2, 10, day); err != nil { t.Fatalf("nil 作用域应放行: %v", err) } // 机构 <=0 → 2001(机构是必填维度,0 会让查询跨机构) _, err = repo.FindActiveScheme(ctx, 0, 10, day) wantRepoErr(t, err, infraerrors.CodeBadParam, "机构为 0") } // TestPayrollRepoFindActiveScheme 方案选取:仅 actived、仅 `effect_date <= day`(含当日)、取最新。 func TestPayrollRepoFindActiveScheme(t *testing.T) { db := newPayrollRepoDB(t) ctx := context.Background() repo := NewPayrollRepo(db, payrollRepoScope()) base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) insertScheme(t, db, 11, 1, 10, base, true) // 生效 insertScheme(t, db, 12, 1, 10, base.AddDate(0, 3, 0), true) // 更晚生效 insertScheme(t, db, 13, 1, 10, base, false) // 停用 → 不可命中 insertScheme(t, db, 14, 1, 10, base.AddDate(1, 0, 0), true) // 未来生效 → 当日不可命中 insertScheme(t, db, 15, 1, 0, base, true) // 机构级方案 // 试算日 = 2026-05-20 → 校区方案里最新的(<=日) 是 12 got, err := repo.FindActiveScheme(ctx, 1, 10, time.Date(2026, 5, 20, 9, 30, 0, 0, time.UTC)) if err != nil { t.Fatalf("查校区方案失败: %v", err) } if got == nil || got.ID != 12 { t.Fatalf("命中方案 = %+v,期望 12(effect_date 最新且已生效)", got) } // **含当日**:试算日恰为生效日 00:00 → 命中 11/12 中较新的 12 got, err = repo.FindActiveScheme(ctx, 1, 10, time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("查当日生效方案失败: %v", err) } if got == nil || got.ID != 12 { t.Fatalf("当日生效应命中 12,实得 %+v", got) } // 早于任何方案生效日 → nil(不是 ErrNotFound) got, err = repo.FindActiveScheme(ctx, 1, 10, time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("早期查询不应报错: %v", err) } if got != nil { t.Fatalf("应返回 nil(无生效方案),实得 %+v", got) } // 校区 20 无方案 → nil;机构级(campus 0)存在 → 命中 15 got, err = repo.FindActiveScheme(ctx, 1, 20, time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)) if err != nil || got != nil { t.Fatalf("校区 20 应无方案,实得 %+v / %v", got, err) } got, err = repo.FindActiveScheme(ctx, 1, 0, time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("查机构级方案失败: %v", err) } if got == nil || got.ID != 15 { t.Fatalf("机构级应命中 15,实得 %+v", got) } } // TestPayrollRepoTeacherHasCert 无资质档案 = 无证(dump 的 `IF NULL>0` 不成立),**不是错误**。 func TestPayrollRepoTeacherHasCert(t *testing.T) { db := newPayrollRepoDB(t) ctx := context.Background() repo := NewPayrollRepo(db, payrollRepoScope()) cert, err := repo.TeacherHasCert(ctx, "01HTEACHER000000000000001") if err != nil { t.Fatalf("无档案不应报错: %v", err) } if cert { t.Fatal("无档案应判为无证") } if err := db.Create(&model.TeacherInformation{ TeacherID: "01HTEACHER000000000000002", TeacherCert: true, }).Error; err != nil { t.Fatalf("seed 教师资质失败: %v", err) } cert, err = repo.TeacherHasCert(ctx, "01HTEACHER000000000000002") if err != nil { t.Fatalf("持证教师查询失败: %v", err) } if !cert { t.Fatal("持证教师应判为持证") } _, err = repo.TeacherHasCert(ctx, "") wantRepoErr(t, err, infraerrors.CodeBadParam, "空 teacher_id") } // TestPayrollRepoGeneralRuleMatchesNullTeacher 通用档必须匹配 `teacher_id IS NULL`,且不得匹配个人档。 func TestPayrollRepoGeneralRuleMatchesNullTeacher(t *testing.T) { db := newPayrollRepoDB(t) ctx := context.Background() repo := NewPayrollRepo(db, payrollRepoScope()) teacher := "01HTEACHER000000000000010" insertDetail(t, db, 21, 1, nil, true, 1000, -1, -1, "0.00", "5.00") // 持证通用 insertDetail(t, db, 22, 1, &teacher, false, 1000, -1, -1, "0.00", "5.00") // 个人档(**不得**被通用查询命中) cert, err := repo.FindGeneralRule(ctx, 1, true, 1000, 7, 3, "2.00") if err != nil { t.Fatalf("查持证通用档失败: %v", err) } if cert == nil || cert.ID != 21 { t.Fatalf("持证通用档应命中 21,实得 %+v", cert) } // 无证通用档:没有对应行 → nil(不能因为"个人档存在"就误命中 22) noCert, err := repo.FindGeneralRule(ctx, 1, false, 1000, 7, 3, "2.00") if err != nil { t.Fatalf("查无证通用档失败: %v", err) } if noCert != nil { t.Fatalf("无证通用档不应命中个人档 22,实得 %+v", noCert) } } // TestPayrollRepoGeneralRuleWildcardAndExp 年级/科目 `-1` 通配 + 教龄闭区间 + 具体行优先。 func TestPayrollRepoGeneralRuleWildcardAndExp(t *testing.T) { db := newPayrollRepoDB(t) ctx := context.Background() repo := NewPayrollRepo(db, payrollRepoScope()) insertDetail(t, db, 31, 2, nil, false, 1000, -1, -1, "0.00", "1.00") // 全通配,教龄 0~1 insertDetail(t, db, 32, 2, nil, false, 1000, 7, -1, "0.00", "1.00") // 年级精确 7,科目通配 insertDetail(t, db, 33, 2, nil, false, 1000, 7, 3, "3.00", "5.00") // 年级+科目精确,教龄 3~5 // 分类 7/3、教龄 0.50(31/32 的 0~1 档都满足,33 的 3~5 档不满足) // → `ORDER BY grade_id DESC` 让"年级精确 7"的 32 排在"年级 -1 通配"的 31 之前 got, err := repo.FindGeneralRule(ctx, 2, false, 1000, 7, 3, "0.50") if err != nil { t.Fatalf("查通用档失败: %v", err) } if got == nil || got.ID != 32 { t.Fatalf("应命中 32(年级精确优先于 -1 通配),实得 %+v", got) } // 分类 9/3(年级 9 不匹配 7,但匹配 -1)→ 命中 31 got, err = repo.FindGeneralRule(ctx, 2, false, 1000, 9, 3, "0.50") if err != nil { t.Fatalf("查通配档失败: %v", err) } if got == nil || got.ID != 31 { t.Fatalf("应命中 31(-1 通配),实得 %+v", got) } // 教龄 3.00 落在 3.00~5.00 → 命中 33(闭区间下界) got, err = repo.FindGeneralRule(ctx, 2, false, 1000, 7, 3, "3.00") if err != nil { t.Fatalf("查教龄下界失败: %v", err) } if got == nil || got.ID != 33 { t.Fatalf("教龄 3.00 应命中 33,实得 %+v", got) } // 教龄 5.00(闭区间上界)→ 仍命中 33 got, err = repo.FindGeneralRule(ctx, 2, false, 1000, 7, 3, "5.00") if err != nil { t.Fatalf("查教龄上界失败: %v", err) } if got == nil || got.ID != 33 { t.Fatalf("教龄 5.00 应命中 33(闭区间),实得 %+v", got) } // 教龄 5.01 → 所有档都不满足 → nil got, err = repo.FindGeneralRule(ctx, 2, false, 1000, 7, 3, "5.01") if err != nil { t.Fatalf("查越界教龄失败: %v", err) } if got != nil { t.Fatalf("教龄 5.01 不应命中,实得 %+v", got) } } // TestPayrollRepoPersonalQueries 个人档两级的条件差异(分类精确 vs 年级/科目通配)。 func TestPayrollRepoPersonalQueries(t *testing.T) { db := newPayrollRepoDB(t) ctx := context.Background() repo := NewPayrollRepo(db, payrollRepoScope()) teacher := "01HTEACHER000000000000020" // 分类精确档(classify_id=7001) insertDetail(t, db, 41, 3, &teacher, false, 1000, 7, 3, "0.00", "9.00") if err := db.Model(&model.CoursePaymentDetail{}).Where("id = ?", 41). Update("classify_id", 7001).Error; err != nil { t.Fatalf("设置 classify_id 失败: %v", err) } count, err := repo.CountPersonalRules(ctx, 3, teacher, 1000) if err != nil { t.Fatalf("统计个人规则失败: %v", err) } if count != 1 { t.Fatalf("个人规则条数 = %d,期望 1", count) } // 班型不同 → 闸门关闭(count=0) count, err = repo.CountPersonalRules(ctx, 3, teacher, 2000) if err != nil { t.Fatalf("统计个人规则(其它班型)失败: %v", err) } if count != 0 { t.Fatalf("其它班型不应计入个人规则,实得 %d", count) } byClassify, err := repo.FindPersonalByClassify(ctx, 3, teacher, 1000, 7001) if err != nil { t.Fatalf("查分类档失败: %v", err) } if byClassify == nil || byClassify.ID != 41 { t.Fatalf("分类精确档应命中 41,实得 %+v", byClassify) } // 分类不匹配 → nil(`classify_id=0` 不当通配,dump 是本档精确等值) byOtherClassify, err := repo.FindPersonalByClassify(ctx, 3, teacher, 1000, 7002) if err != nil { t.Fatalf("查其它分类档失败: %v", err) } if byOtherClassify != nil { t.Fatalf("其它分类不应命中,实得 %+v", byOtherClassify) } // 年级/科目档仍可命中(同一行同时满足 7/3) byGrade, err := repo.FindPersonalByGradeSubject(ctx, 3, teacher, 1000, 7, 3) if err != nil { t.Fatalf("查年级/科目档失败: %v", err) } if byGrade == nil || byGrade.ID != 41 { t.Fatalf("年级/科目档应命中 41,实得 %+v", byGrade) } } // TestPayrollRepoGetClassify 分类单查:未命中 → 3001;id<=0 → 2001。 func TestPayrollRepoGetClassify(t *testing.T) { db := newPayrollRepoDB(t) ctx := context.Background() repo := NewPayrollRepo(db, payrollRepoScope()) if err := db.Create(&model.CoursesClassify{ ID: 7001, InstitutionID: 1, CampusID: 10, ClassType: 1000, GradeID: 7, SubjectID: 3, Name: "分类", }).Error; err != nil { t.Fatalf("seed 分类失败: %v", err) } got, err := repo.GetClassify(ctx, 7001) if err != nil { t.Fatalf("查分类失败: %v", err) } if got.GradeID != 7 || got.SubjectID != 3 || got.ClassType != 1000 { t.Fatalf("分类维度 = (%d,%d,%d),期望 (7,3,1000)", got.GradeID, got.SubjectID, got.ClassType) } _, err = repo.GetClassify(ctx, 9999) wantRepoErr(t, err, infraerrors.CodeNotFound, "分类不存在") _, err = repo.GetClassify(ctx, 0) wantRepoErr(t, err, infraerrors.CodeBadParam, "分类 id 为 0") } // wantRepoErr 仓储层错误码断言(仓储把 gorm 原错统一转 sentinel)。 func wantRepoErr(t *testing.T, err error, code int, what string) { t.Helper() if err == nil { t.Fatalf("%s:期望错误码 %d,实得 nil", what, code) } var appErr *infraerrors.AppError if !stderrors.As(err, &appErr) { t.Fatalf("%s:期望 *AppError(%d),实得 %T(%v)", what, code, err, err) } if appErr.Code != code { t.Fatalf("%s:期望错误码 %d,实得 %d(%s)", what, code, appErr.Code, appErr.Msg) } } // TestPayrollRepoTeacherExpThreeDecimalScale 教龄列保留 3 位精度时,区间边界的归属必须正确(P0 回归)。 // // 场景(对齐 dump 首行 `0.000 / 0.999` 与下一档 `1.000 / 1.999`): // 教龄恰为 `1.000` 时**只能**命中 `[1.000, 1.999]`,不得命中 `[0.000, 0.999]`。 // // 两种失败形态都能被本用例抓住: // 1. **列精度被收敛**(`decimal(6,3)` → `(6,2)`):MySQL 侧 `0.999` 会存成 `1.00`,两档并列命中, // `id ASC` 取到低档 ⇒ 本用例红。⚠ sqlite 不强制 scale,故这一形态在本仓由 // `model.TestModelsColumnByColumn`(tag ↔ DDL 逐列比对)接力守。 // 2. **比较运算符写错**(`<=`/`>=` 写成 `<`/`>`,或把 BETWEEN 拆成两段错一个):边界值直接漏/多 ⇒ 立刻红。 func TestPayrollRepoTeacherExpThreeDecimalScale(t *testing.T) { db := newPayrollRepoDB(t) ctx := context.Background() repo := NewPayrollRepo(db, payrollRepoScope()) insertDetail(t, db, 1, 77, nil, false, 1000, -1, -1, "0.000", "0.999") insertDetail(t, db, 91, 77, nil, false, 1000, -1, -1, "1.000", "1.999") cases := []struct { exp string want int64 why string }{ {"1.000", 91, "整数年只命中上一档(低档上限 0.999 不含 1.0)"}, {"0.999", 1, "上界闭区间:0.999 ∈ 低档"}, {"1.999", 91, "上界闭区间:1.999 ∈ 高档"}, {"0.000", 1, "下界闭区间:0 ∈ 低档"}, {"2.000", 0, "出界:不应命中任何档"}, } for _, tc := range cases { got, err := repo.FindGeneralRule(ctx, 77, false, 1000, 7, 3, tc.exp) if err != nil { t.Fatalf("教龄 %s 查询失败: %v", tc.exp, err) } if tc.want == 0 { if got != nil { t.Fatalf("教龄 %s 应无命中,实得 %+v(%s)", tc.exp, got, tc.why) } continue } if got == nil || got.ID != tc.want { t.Fatalf("教龄 %s 命中 %+v,期望 detail=%d(%s)", tc.exp, got, tc.want, tc.why) } } }