| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452 |
- // 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)
- }
- }
- }
|