// 收支端点单测(端点 60:记录列表 / 记账 / 类型字典)——AC-13d 第三批。 // // 断言纪律同前几批:只断 body 契约,**每个端点至少一条"写错会变红"的断言**,尽量落在库上。 // // 覆盖: // // 60 GET 记录 平台通用字典(institution_id=0)**必须**补齐 type/name;契约删掉的 // `direction`/`userId` **不得**出现;`money` 恒 2 位;`createUserId` 可空; // `type` 过滤不得漏掉"平台字典类型的记录" // 60 POST 记账 写库列(institution/campus/cost/记账人/bizNo);year/month 与 create_time 同月; // 金额 ≤0 → 2002;类型不存在 / 他机构私有类型 → 3001 // 60 GET types 平台字典 + 本机构字典都在;他机构私有类型不在;超管不指定机构只看到平台字典 // 作用域 跨机构/跨校区一律 **1004**(不是"空列表") package handler import ( "strings" "testing" "time" "github.com/gofiber/fiber/v2" "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" ) // newExpenditureTestDB 端点 60 测试库(资金/活动基座 + 收支 2 表)。 // // 索引处理与 `newCourseTestDB` 同源:SQLite 的索引名是**库级唯一**(MySQL 是表级), // `uk_biz_no` 在多张表上同名,不清会直接建表失败。**测试库放弃唯一约束,生产 MySQL 不受影响**。 func newExpenditureTestDB(t *testing.T) *gorm.DB { t.Helper() base := newHandlerTestDB(t) extra := []any{&model.Expenditure{}, &model.ExpenditureLog{}} var leftovers []string if err := base.Raw( "SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'").Scan(&leftovers).Error; err != nil { t.Fatalf("读取遗留索引失败: %v", err) } for _, name := range leftovers { if err := base.Exec("DROP INDEX IF EXISTS `" + name + "`").Error; err != nil { t.Fatalf("删除遗留索引(%s) 失败: %v", name, err) } } for _, tbl := range extra { if err := base.AutoMigrate(tbl); err != nil { t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err) } stmt := &gorm.Statement{DB: base} if err := stmt.Parse(tbl); err != nil { t.Fatalf("解析表名(%T) 失败: %v", tbl, err) } var idxRows []struct { Name string SQL *string } if err := base.Raw( "SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_%'", stmt.Schema.Table).Scan(&idxRows).Error; err != nil { t.Fatalf("读索引清单(%s) 失败: %v", stmt.Schema.Table, err) } for _, row := range idxRows { if err := base.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil { t.Fatalf("删索引(%s) 失败: %v", row.Name, err) } } } return base } // newExpenditureTestApp 挂端点 60 的 3 条路由(**不做角色白名单**:那是 e2e 的职责)。 // // 作用域由 `X-Test-Scope` 头选择: // // finance(缺省)机构 1 / 校区 10;finance-noCampus 机构 1 / 校区 0; // finance-other 机构 2 / 校区 20;super 超管。 func newExpenditureTestApp(t *testing.T, db *gorm.DB) *fiber.App { t.Helper() h, err := New(Config{DB: db}) if err != nil { t.Fatalf("handler.New 失败: %v", err) } app := fiber.New(fiber.Config{DisableStartupMessage: true}) app.Use(func(c *fiber.Ctx) error { switch c.Get("X-Test-Scope") { case "none": // 无作用域 → 1001 case "finance-noCampus": appscope.Set(c, &appscope.Scope{ InstitutionID: 1, CampusID: 0, UserID: testFinanceUser, Role: RoleFinance, }) case "finance-other": appscope.Set(c, &appscope.Scope{ InstitutionID: 2, CampusID: 20, UserID: "01HFINANCE0000000000000002", Role: RoleFinance, }) case "super": appscope.Set(c, testSuperScope()) case "super-noInst": // 平台超管但**没有机构上下文**(InstitutionID=0):字典只看平台通用那批、 // 记账必须显式给 institutionId(服务端不猜)。 appscope.Set(c, &appscope.Scope{ InstitutionID: 0, CampusID: 0, SuperAdmin: true, UserID: "01HADMIN0000000000000002", Role: RoleSuperAdmin, }) default: appscope.Set(c, &appscope.Scope{ InstitutionID: 1, CampusID: 10, UserID: testFinanceUser, Role: RoleFinance, }) } return c.Next() }) app.Get(PathExpenditureTypes, h.ListExpenditureTypes) app.Get(PathExpenditures, h.ListExpenditureLogs) app.Post(PathExpenditures, h.CreateExpenditureLog) return app } // parseCostCents 把库里的 `cost` 字符串解析成"分"(跨方言比较用)。 // // 入参:text 库值(SQLite 可能回 "100"、MySQL 回 "100.00")。出参:(分, error)。 // // 为什么不直接比字符串:SQLite 的 numeric 亲和性会吃掉尾部 0,断言字面量必红; // 而"按数值比"并不放松口径——真正的 2 位小数断言在**响应**上(`money` 由 `money.Amount.String()` // 产出,与方言无关)。两处各测各的:库里存得对、出口格式化得对。 func parseCostCents(text string) (int64, error) { amount, err := money.Parse(strings.TrimSpace(text)) if err != nil { return 0, err } return amount.Cents(), nil } // testFinanceUser 端点 60 测试用的记账人(财务,机构 1)。 const testFinanceUser = "01HFINANCE0000000000000001" // seedExpenditureType 造一条收支字典(institutionID = 0 即**平台通用字典**)。 func seedExpenditureType(t *testing.T, db *gorm.DB, institutionID int64, typ int32, name string) *model.Expenditure { t.Helper() row := &model.Expenditure{InstitutionID: institutionID, Type: typ, Name: name, ParentID: 0} if err := db.Create(row).Error; err != nil { t.Fatalf("造收支字典(%s) 失败: %v", name, err) } return row } // TestExpenditureCreateAndListUsesPlatformDictionary 端点 60 主链路:记账 + 列表(字典补齐)。 // // 这条用例是整个端点的防线,**四个断言各自对应一个真实可犯的错**: // // ① `name == "房租"` / `type == 1`:字典是**平台通用**(`institution_id = 0`)。 // 若给字典查询套了作用域(`WHERE institution_id = 1`),平台字典 JOIN 不到 ⇒ // `name=""`、`type=0` —— 记录还在、金额分页都对,**只有类型这一列静默变空**。 // (团队裁决原文:"0 是平台通用字典,JOIN 时别被作用域过滤掉"。) // ② 行里**不能有** `direction` / `userId`:契约 v1.9 删掉了它们(两表都没有这两列)。 // 留着 `direction` 会让人拿它跟 `type` 比对,而两者永远一致(本来就是一件事)。 // ③ `money == "100.00"`:金额走定点,恒 2 位小数;"100" 说明没走 `money.Amount`。 // ④ 库里的 `year`/`month` 必须等于 `create_time` 的年月:分桶列若按应用时钟算, // 跨月那一秒的账会落到相邻月份,报表表现为"某月少一条"。 func TestExpenditureCreateAndListUsesPlatformDictionary(t *testing.T) { db := newExpenditureTestDB(t) app := newExpenditureTestApp(t, db) // 平台通用字典(institution_id = 0):机构视角下**必须**可见。 platformType := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租") code, msg, data := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{ "institutionId": 1, "campusId": 10, "expenditureId": platformType.ID, "money": "100.00", "remark": "2026-11 房租", }, "X-Test-Token", "t") if code != 200 { t.Fatalf("记账 = %d(%s),期望 200", code, msg) } created := jsonMap(t, data) logID, _ := created["id"].(float64) if logID <= 0 { t.Fatalf("记账返回 id = %v,期望 >0", created["id"]) } bizNo, _ := created["bizNo"].(string) if len(bizNo) != 26 { t.Fatalf("bizNo = %q(长度 %d),期望 26 位 ULID(后端生成,前端不可指定)", bizNo, len(bizNo)) } // 库值:作用域列 / 金额 / 记账人 / 分桶列 var row model.ExpenditureLog if err := db.Where("id = ?", int64(logID)).Take(&row).Error; err != nil { t.Fatalf("回读收支记录失败: %v", err) } if row.InstitutionID != 1 || row.CampusID != 10 { t.Fatalf("库中 institution/campus = %d/%d,期望 1/10", row.InstitutionID, row.CampusID) } if row.ExpenditureID != platformType.ID { t.Fatalf("库中 expenditure_id = %d,期望 %d", row.ExpenditureID, platformType.ID) } // ⚠ 库值按**数值**比:SQLite 的 numeric 亲和性会把 "100.00" 存成 100.0、读回 "100" // (MySQL 的 decimal(12,2) 会原样返回 "100.00")。字节级的"2 位小数"由响应 `money` 断言兜 //(出口经 `money.Amount.String()`,与方言无关)。 if cents, perr := parseCostCents(row.Cost); perr != nil || cents != 10000 { t.Fatalf("库中 cost = %q(%d 分,错 %v),期望 10000 分 = 100 元", row.Cost, cents, perr) } if row.CreateUserID == nil || *row.CreateUserID != testFinanceUser { t.Fatalf("库中 create_user_id = %v,期望 %q(记账人 = 当前登录用户,不接受前端指定)", row.CreateUserID, testFinanceUser) } if row.Year == nil || row.Month == nil { t.Fatalf("库中 year/month = %v/%v,期望非空(报表分桶列,NULL 会让月报少掉新记录)", row.Year, row.Month) } if int(*row.Year) != row.CreateTime.Year() || int(*row.Month) != int(row.CreateTime.Month()) { t.Fatalf("库中分桶 = %d-%d,而 create_time = %s:分桶必须由**库认定的落库时刻**推导,"+ "用应用时钟会在跨月那一秒把账记到相邻月份(报表上只表现为「某月少一条」)", *row.Year, *row.Month, row.CreateTime.Format(time.RFC3339)) } // 列表:字典补齐 type/name(**平台通用字典不能被作用域过滤掉**) code, msg, data = doJSON(t, app, fiber.MethodGet, PathExpenditures, nil, "X-Test-Token", "t") if code != 200 { t.Fatalf("收支列表 = %d(%s),期望 200", code, msg) } items := pageList(t, data) if len(items) != 1 { t.Fatalf("记录条数 = %d,期望 1", len(items)) } item, _ := items[0].(map[string]any) if got, _ := item["name"].(string); got != "房租" { t.Fatalf("name = %q,期望 \"房租\"(来自**平台通用字典** institution_id=0);"+ "空串说明字典查询被作用域过滤了 —— 记录还在、金额都对,只有类型静默变空", got) } if got, _ := item["type"].(float64); got != float64(model.ExpenditureTypeExpense) { t.Fatalf("type = %v,期望 %d(1 支出 / 2 收入,来自字典)", item["type"], model.ExpenditureTypeExpense) } if got, _ := item["expenditureId"].(float64); got != float64(platformType.ID) { t.Fatalf("expenditureId = %v,期望 %d(缺它前端无法回填编辑表单)", item["expenditureId"], platformType.ID) } if got, _ := item["money"].(string); got != "100.00" { t.Fatalf("money = %v,期望 \"100.00\"(定点 2 位;\"100\" 说明没走 money.Amount)", item["money"]) } if got, _ := item["bizNo"].(string); got != bizNo { t.Fatalf("列表 bizNo = %q,期望 %q", item["bizNo"], bizNo) } if got, _ := item["createUserId"].(string); got != testFinanceUser { t.Fatalf("createUserId = %v,期望 %q(← create_user_id,语义 = 记账人)", item["createUserId"], testFinanceUser) } if got, _ := item["campusId"].(float64); got != 10 { t.Fatalf("campusId = %v,期望 10", item["campusId"]) } if raw, ok := item["createTime"].(string); !ok { t.Fatalf("createTime = %v,期望时间字符串", item["createTime"]) } else if _, perr := time.Parse(time.RFC3339, raw); perr != nil { t.Fatalf("createTime = %q 不是 RFC3339: %v", raw, perr) } // 契约 v1.9 删掉的两个字段**不得出现**(出现即"同一事实两个名字",前端会拿去比对) if _, ok := item["direction"]; ok { t.Fatalf("响应里有 direction:契约 v1.9 已删(与 type 同义,两表都没有该列)") } if _, ok := item["userId"]; ok { t.Fatalf("响应里有 userId:应为 createUserId(记录表只有 create_user_id = 记账人," + "没有「收付对象」列;叫 userId 会让前端把记账人当收付对象展示)") } } // TestExpenditureTypeFilterKeepsPlatformTypes 端点 60 `type` 过滤:**平台字典类型的记录不能消失**。 // // 变红条件:`type` 过滤的子查询把字典按作用域收窄(`WHERE type=? AND institution_id=1`)⇒ // 平台通用类型不在子查询结果里 ⇒ 按"支出"筛选时整批记录凭空消失。 // 这比"少一列"更难查:用户会认为"这个月没有支出"。 func TestExpenditureTypeFilterKeepsPlatformTypes(t *testing.T) { db := newExpenditureTestDB(t) app := newExpenditureTestApp(t, db) expenseType := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租") incomeType := seedExpenditureType(t, db, 0, model.ExpenditureTypeIncome, "学费收入") for _, seed := range []struct { typ *model.Expenditure money string }{ {expenseType, "100.00"}, {incomeType, "200.00"}, } { code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{ "institutionId": 1, "campusId": 10, "expenditureId": seed.typ.ID, "money": seed.money, }, "X-Test-Token", "t") if code != 200 { t.Fatalf("记账(%s) = %d(%s),期望 200", seed.typ.Name, code, msg) } } // type=1(支出)→ 只剩 100.00 那条 code, msg, data := doJSON(t, app, fiber.MethodGet, PathExpenditures+"?type=1", nil, "X-Test-Token", "t") if code != 200 { t.Fatalf("按 type=1 筛选 = %d(%s),期望 200", code, msg) } items := pageList(t, data) if len(items) != 1 { t.Fatalf("type=1 记录条数 = %d,期望 1(0 条说明含平台通用字典的子查询被作用域过滤了)", len(items)) } only, _ := items[0].(map[string]any) if got, _ := only["money"].(string); got != "100.00" { t.Fatalf("type=1 的记录 money = %v,期望 \"100.00\"", only["money"]) } // type=2(收入)→ 只剩 200.00 code, _, data = doJSON(t, app, fiber.MethodGet, PathExpenditures+"?type=2", nil, "X-Test-Token", "t") if code != 200 { t.Fatalf("按 type=2 筛选 = %d,期望 200", code) } items = pageList(t, data) if len(items) != 1 { t.Fatalf("type=2 记录条数 = %d,期望 1", len(items)) } only, _ = items[0].(map[string]any) if got, _ := only["money"].(string); got != "200.00" { t.Fatalf("type=2 的记录 money = %v,期望 \"200.00\"", only["money"]) } // type=3 → 2002(枚举非法,不是"空列表") if code, msg, _ := doJSON(t, app, fiber.MethodGet, PathExpenditures+"?type=3", nil, "X-Test-Token", "t"); code != infraerrors.ErrBadFormat.Code { t.Fatalf("type=3 = %d(%s),期望 2002", code, msg) } } // TestExpenditureTypesIncludesPlatformAndOwn 端点 60 字典:平台 + 本机构都在,他机构的不在。 // // 变红条件: // - 只返回本机构字典(丢了平台字典)→ 机构下拉框空,"新增收支"看起来坏了; // - 只返回平台字典(丢了本机构自建)→ 自建类型永远选不到; // - 不做机构过滤 → 他机构的私有类型混进来(选项重复、`parentId` 指向别人的树)。 func TestExpenditureTypesIncludesPlatformAndOwn(t *testing.T) { db := newExpenditureTestDB(t) app := newExpenditureTestApp(t, db) platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租") own := seedExpenditureType(t, db, 1, model.ExpenditureTypeExpense, "机构1自建") otherInst := seedExpenditureType(t, db, 2, model.ExpenditureTypeExpense, "机构2私有") names := func(scopeHeader, query string) map[string]float64 { t.Helper() headers := []string{"X-Test-Token", "t"} if scopeHeader != "" { headers = append(headers, "X-Test-Scope", scopeHeader) } code, msg, data := doJSON(t, app, fiber.MethodGet, PathExpenditureTypes+query, nil, headers...) if code != 200 { t.Fatalf("字典(%s%s) = %d(%s),期望 200", scopeHeader, query, code, msg) } payload := jsonMap(t, data) list, ok := payload["types"].([]any) if !ok { t.Fatalf("响应缺少 types 数组(契约形状是 {\"types\":[...]}):%v", payload) } out := make(map[string]float64, len(list)) for _, item := range list { row, _ := item.(map[string]any) name, _ := row["name"].(string) id, _ := row["id"].(float64) out[name] = id if _, ok := row["type"]; !ok { t.Fatalf("字典项缺少 type:%v", row) } if _, ok := row["parentId"]; !ok { t.Fatalf("字典项缺少 parentId:%v", row) } } return out } // 机构 1 视角:平台 + 自建;不含机构 2 的私有类型 got := names("finance", "") if got["房租"] != float64(platform.ID) { t.Fatalf("机构视角看不到平台通用字典(房租):%v —— 平台字典 institution_id=0 必须放行", got) } if got["机构1自建"] != float64(own.ID) { t.Fatalf("机构视角看不到自己的字典(机构1自建):%v", got) } if _, ok := got["机构2私有"]; ok { t.Fatalf("机构视角看到了别的机构的私有字典(机构2私有):越权泄露(应为 1004 或不可见)") } // 超管(作用域带机构 1)不传 institutionId → 平台 + 机构 1(沿用作用域机构,语义最直白) superGot := names("super", "") if _, ok := superGot["房租"]; !ok { t.Fatalf("超管视角缺少平台字典:%v", superGot) } if _, ok := superGot["机构1自建"]; !ok { t.Fatalf("超管(作用域机构 1)应看到机构 1 的字典:%v", superGot) } // 超管**没有**机构上下文(作用域机构 0)→ 只看到平台字典。 // 若此时不过滤,多机构的同名类型会混在一起(选项重复、`parentId` 指向别人的树)。 superNoInst := names("super-noInst", "") if _, ok := superNoInst["房租"]; !ok { t.Fatalf("超管无机构上下文时缺少平台字典:%v", superNoInst) } if _, ok := superNoInst["机构1自建"]; ok { t.Fatalf("超管无机构上下文时不应混入机构私有字典(机构1自建):%v", superNoInst) } // 超管指定 institutionId=1 → 平台 + 机构 1 superScoped := names("super", "?institutionId=1") if _, ok := superScoped["房租"]; !ok { t.Fatalf("超管指定机构后缺少平台字典:%v", superScoped) } if superScoped["机构1自建"] != float64(own.ID) { t.Fatalf("超管指定机构后缺少该机构字典:%v", superScoped) } if _, ok := superScoped["机构2私有"]; ok { t.Fatalf("超管指定机构 1 却看到了机构 2 的字典:%v", superScoped) } _ = otherInst } // TestExpenditureCreateValidation 端点 60 记账的参数/类型校验(每条对应一种静默错法)。 func TestExpenditureCreateValidation(t *testing.T) { db := newExpenditureTestDB(t) app := newExpenditureTestApp(t, db) platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租") otherInstType := seedExpenditureType(t, db, 2, model.ExpenditureTypeExpense, "机构2私有") cases := []struct { name string body map[string]any want int }{ {"缺 expenditureId → 2001", map[string]any{"institutionId": 1, "money": "10.00"}, infraerrors.ErrBadParam.Code}, {"缺 money → 2001", map[string]any{"institutionId": 1, "expenditureId": platform.ID}, infraerrors.ErrBadParam.Code}, {"money = 0 → 2002(零元记账只会污染对账)", map[string]any{"institutionId": 1, "expenditureId": platform.ID, "money": "0.00"}, infraerrors.ErrBadFormat.Code}, {"money 为负 → 2002(cost 恒存正数,方向由 type 决定)", map[string]any{"institutionId": 1, "expenditureId": platform.ID, "money": "-1.00"}, infraerrors.ErrBadFormat.Code}, {"money 非数字 → 2002", map[string]any{"institutionId": 1, "expenditureId": platform.ID, "money": "abc"}, infraerrors.ErrBadFormat.Code}, {"类型不存在 → 3001", map[string]any{"institutionId": 1, "expenditureId": 99999, "money": "10.00"}, infraerrors.ErrNotFound.Code}, {"他机构私有类型 → 3001(不泄露存在性)", map[string]any{"institutionId": 1, "expenditureId": otherInstType.ID, "money": "10.00"}, infraerrors.ErrNotFound.Code}, {"缺机构(非超管作用域机构已给出,故用超管无机构验证见下)", nil, 0}, } for _, tc := range cases { if tc.body == nil { continue } code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, tc.body, "X-Test-Token", "t") if code != tc.want { t.Errorf("%s:code = %d(%s),期望 %d", tc.name, code, msg, tc.want) } } // 超管**无机构上下文**且没给 institutionId → 2001(服务端不猜机构:记到哪个机构的账必须显式) code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{ "expenditureId": platform.ID, "money": "10.00", }, "X-Test-Token", "t", "X-Test-Scope", "super-noInst") if code != infraerrors.ErrBadParam.Code { t.Fatalf("超管缺 institutionId = %d(%s),期望 2001", code, msg) } // 对照:合法请求必须 200(否则上面的红可能是"全都红"造成的假阳性) code, msg, _ = doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{ "institutionId": 1, "expenditureId": platform.ID, "money": "10.00", }, "X-Test-Token", "t") if code != 200 { t.Fatalf("合法记账 = %d(%s),期望 200", code, msg) } } // TestExpenditureScopeViolation 端点 60 越权一律 **1004**(不是"空列表")。 // // 为什么非要 1004:SQL 层 `Scoped` 会把跨机构查询变成空列表,症状是"这家机构一条收支都没有", // 排查会先怀疑数据丢失/迁移没跑,而不是"你越权了"。 func TestExpenditureScopeViolation(t *testing.T) { db := newExpenditureTestDB(t) app := newExpenditureTestApp(t, db) platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租") // 机构 1 的财务想看机构 2 → 1004 if code, msg, _ := doJSON(t, app, fiber.MethodGet, PathExpenditures+"?institutionId=2", nil, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code { t.Fatalf("跨机构查列表 = %d(%s),期望 1004", code, msg) } // 校区 10 的财务想看校区 20 → 1004 if code, msg, _ := doJSON(t, app, fiber.MethodGet, PathExpenditures+"?campusId=20", nil, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code { t.Fatalf("跨校区查列表 = %d(%s),期望 1004", code, msg) } // 记账到别的机构 → 1004 if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{ "institutionId": 2, "expenditureId": platform.ID, "money": "10.00", }, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code { t.Fatalf("跨机构记账 = %d(%s),期望 1004", code, msg) } // 记账到别的校区 → 1004 if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{ "institutionId": 1, "campusId": 20, "expenditureId": platform.ID, "money": "10.00", }, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code { t.Fatalf("跨校区记账 = %d(%s),期望 1004", code, msg) } // 对照:本机构本校区必须 200 if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{ "institutionId": 1, "campusId": 10, "expenditureId": platform.ID, "money": "10.00", }, "X-Test-Token", "t"); code != 200 { t.Fatalf("本机构本校区记账 = %d(%s),期望 200", code, msg) } } // TestExpenditureCampusDefaultsToScope 未传 `campusId` 且有校区作用域时,落到作用域校区。 // // 变红条件:默认补 0(机构级)→ 一条本该记在某校区的账落到机构级,**校区报表少一条** // 而机构级报表多一条;两个报表各自都能"自洽",只有对账时才发现。 func TestExpenditureCampusDefaultsToScope(t *testing.T) { db := newExpenditureTestDB(t) app := newExpenditureTestApp(t, db) platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租") code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{ "institutionId": 1, "expenditureId": platform.ID, "money": "10.00", }, "X-Test-Token", "t") if code != 200 { t.Fatalf("记账(不传校区)= %d(%s),期望 200", code, msg) } var row model.ExpenditureLog if err := db.Order("id DESC").Take(&row).Error; err != nil { t.Fatalf("回读收支记录失败: %v", err) } if row.CampusID != 10 { t.Fatalf("库中 campus_id = %d,期望 10(作用域校区);0 = 落到机构级,"+ "会让校区报表少一条、机构级多一条,两边各自"+ "看起来都自洽", row.CampusID) } // 校区作用域为 0 的用户(机构级财务):campusId 保持 0 = 机构级记账 code, msg, _ = doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{ "institutionId": 1, "expenditureId": platform.ID, "money": "20.00", }, "X-Test-Token", "t", "X-Test-Scope", "finance-noCampus") if code != 200 { t.Fatalf("机构级财务记账 = %d(%s),期望 200", code, msg) } var instRow model.ExpenditureLog if err := db.Order("id DESC").Take(&instRow).Error; err != nil { t.Fatalf("回读收支记录失败: %v", err) } if instRow.CampusID != 0 { t.Fatalf("机构级财务记账 campus_id = %d,期望 0(机构级)", instRow.CampusID) } } // TestExpenditureListTimeRange 端点 60 时间区间过滤(`from`/`to` 含两端)。 // // 变红条件:`to` 写成不含上界(`<`)→ 当天的账查不到,用户会以为"今天没记账"。 func TestExpenditureListTimeRange(t *testing.T) { db := newExpenditureTestDB(t) app := newExpenditureTestApp(t, db) platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租") // 直接造库:一条 2026-03-10,一条 2026-04-10(绕开记账接口,才能控时间) for _, seed := range []struct { day string money string bizNo string }{ {"2026-03-10 08:00:00", "100.00", "01HTESTMARCH0000000000001"}, {"2026-04-10 08:00:00", "200.00", "01HTESTAPRIL00000000000001"}, } { row := &model.ExpenditureLog{ BizNo: seed.bizNo, InstitutionID: 1, CampusID: 10, ExpenditureID: platform.ID, Cost: seed.money, } if err := db.Create(row).Error; err != nil { t.Fatalf("造记录失败: %v", err) } if err := db.Exec("UPDATE t_expenditure_log SET create_time = ? WHERE id = ?", seed.day, row.ID).Error; err != nil { t.Fatalf("回填 create_time 失败: %v", err) } } code, msg, data := doJSON(t, app, fiber.MethodGet, PathExpenditures+"?from=2026-04-01&to=2026-04-10", nil, "X-Test-Token", "t") if code != 200 { t.Fatalf("按区间筛 = %d(%s),期望 200", code, msg) } items := pageList(t, data) if len(items) != 1 { t.Fatalf("区间内记录 = %d 条,期望 1(`to` 若写成不含上界,当天的账会查不到)", len(items)) } only, _ := items[0].(map[string]any) if got, _ := only["money"].(string); got != "200.00" { t.Fatalf("区间内记录 money = %v,期望 \"200.00\"", only["money"]) } // to < from → 2002(不静默返回空) if code, msg, _ := doJSON(t, app, fiber.MethodGet, PathExpenditures+"?from=2026-04-10&to=2026-04-01", nil, "X-Test-Token", "t"); code != infraerrors.ErrBadFormat.Code { t.Fatalf("to