// BillService 单测(AC-10):账单汇总 / 明细分页 / 三种比较语义并存 / 赠送额回退口径。 // // 断言纪律(对照 §5.1 纪律 4「横切优先」): // 1. **账单恒等式**(跨字段):`EndBalance − PrevBalance == FlowExclusiveEnd`, // 且 `EndBalance` 必须与 **`t_user_expense.balance` 账户表**一致(跨表)—— // 只断言"字段非空"或"条数对了"的实现,在"漏算某个 type / 符号写反"时依然全绿。 // 2. **两种时间口径并存**(dump 的既有不对称,不得统一): // `FlowInclusiveEnd − FlowExclusiveEnd == 恰好落在 t = To 的那一笔`, // 且该笔**计入**「当期发生额」(BETWEEN 含右端)而**不计入**「期末余额」(`< To`)。 // 3. **期初/期末不随分页变化**:翻页只影响明细。 // 4. **赠送额三段链**:`GiveUsed = Gift + GivePrevBalance − GiveEndBalance`(dump 764194)。 package service import ( "context" "path/filepath" "reflect" "testing" "time" gormsqlite "github.com/glebarez/sqlite" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" infraerrors "yuansiwei/unified-backend/internal/infra/errors" ) // ---------------------------------------------------------------- 辅助 // newBillServiceDB 账单域测试库(流水 4 表 + 全局钱包)。 func newBillServiceDB(t *testing.T) *gorm.DB { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_bill_service_test.db") db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{}) if err != nil { t.Fatalf("gorm.Open(账单域文件库) 失败: %v", err) } sqlDB, err := db.DB() if err != nil { t.Fatalf("取 sql.DB 失败: %v", err) } sqlDB.SetMaxOpenConns(1) t.Cleanup(func() { _ = sqlDB.Close() }) migrateFundTables(t, db, &model.UserExpense{}, &model.UserExpenseLog{}, &model.UserBalanceRequest{}, &model.CoursesActivitySigned{}, &model.CoursesActivityUsedLog{}, ) return db } // seedBillLog 落一行资金流水(logID 为 nil 表示旧库 `logid IS NULL`)。 func seedBillLog(t *testing.T, db *gorm.DB, userID string, logType int32, amount string, createTime time.Time, logID *string) { t.Helper() row := &model.UserExpenseLog{ UserID: userID, Type: logType, Money: amount, CreateTime: createTime, LogID: logID, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 流水(type=%d) 失败: %v", logType, err) } } // seedBillAccount 落一行全局钱包(**余额必须与流水累计一致**,否则跨表恒等式用例失去意义)。 func seedBillAccount(t *testing.T, db *gorm.DB, userID, balance string) { t.Helper() row := &model.UserExpense{UserID: userID, Balance: balance} if err := db.Create(row).Error; err != nil { t.Fatalf("seed 全局钱包 失败: %v", err) } } // seedBillRechargeRequest 落一行已通过的普通充值工单(赠送额链路的 join 起点)。 func seedBillRechargeRequest(t *testing.T, db *gorm.DB, bizNo, userID string, responseTime time.Time) { t.Helper() approved := model.BalanceRequestApprovedPassed respAt := responseTime row := &model.UserBalanceRequest{ BizNo: bizNo, Type: model.BalanceRequestTypeRecharge, UserID: userID, Approved: approved, ResponseTime: &respAt, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 充值工单 失败: %v", err) } } // seedBillSigned 落一行活动报名(赠送额来源)。 func seedBillSigned(t *testing.T, db *gorm.DB, activityID int64, userID, activityMoney, requestNo string) { t.Helper() no := requestNo row := &model.CoursesActivitySigned{ ActivityID: activityID, UserID: userID, ActivityMoney: activityMoney, RequestNo: &no, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 活动报名 失败: %v", err) } } // seedBillUsed 落一行老带新获得/使用记录。 func seedBillUsed(t *testing.T, db *gorm.DB, activityID int64, userID, used string, createTime time.Time) { t.Helper() row := &model.CoursesActivityUsedLog{ ActivityID: activityID, UserID: userID, ActivityMoneyUsed: used, CreateTime: createTime, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed used_log 失败: %v", err) } } // billFixture 标准账单种子(返回值即"按 dump 手算"的期望,用例逐字段比对)。 // // 时间轴(window: from=2026-03-01T00:00, to=2026-04-01T00:00): // // 02-20 +500.00 type=100 log=REQ-A ← 只看得到"期初" // 03-05 -100.00 type=101 ← 当期消费 // 03-10 used_log(9001) -20.00 ← 赠送额使用 // 03-31 +30.00 type=107 ← 当期推荐额 // 04-01 +200.00 type=100 log=REQ-B ← **恰好落在 to**:进"当期发生额"、不进"期末" // // 请求工单:REQ-A response_time=03-02(区间内,但**没有区间内的 type=100 流水指向它**)→ 不参与赠送; // // REQ-B response_time=04-01(BETWEEN 含右端,命中)→ signed(9001, 50.00) 计入当期赠送。 func billFixture(t *testing.T) (*gorm.DB, *BillService, string, time.Time, time.Time) { t.Helper() db := newBillServiceDB(t) userID := fundUserID(900) reqA, reqB := "REQ-A", "REQ-B" seedBillLog(t, db, userID, model.ExpenseLogTypeRecharge, "500.00", time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), &reqA) seedBillLog(t, db, userID, model.ExpenseLogTypeCourseCampus, "-100.00", time.Date(2026, 3, 5, 9, 0, 0, 0, time.UTC), nil) seedBillLog(t, db, userID, model.ExpenseLogTypeRecommend, "30.00", time.Date(2026, 3, 31, 18, 0, 0, 0, time.UTC), nil) seedBillLog(t, db, userID, model.ExpenseLogTypeRecharge, "200.00", time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), &reqB) // 账户余额 = Σ全部流水 = 500 − 100 + 30 + 200 seedBillAccount(t, db, userID, "630.00") seedBillRechargeRequest(t, db, "REQ-A", userID, time.Date(2026, 3, 2, 8, 0, 0, 0, time.UTC)) seedBillRechargeRequest(t, db, "REQ-B", userID, time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)) seedBillSigned(t, db, 9001, userID, "50.00", "REQ-B") seedBillUsed(t, db, 9001, userID, "-20.00", time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC)) svc := NewBillService(db, fundSuperScope()) from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) return db, svc, userID, from, to } // ---------------------------------------------------------------- 汇总口径(dump 764168-764194) // TestGetBillSummaryMatchesDump 主用例:9 个汇总列逐个对齐 dump 的 SELECT 顺序与口径。 func TestGetBillSummaryMatchesDump(t *testing.T) { _, svc, userID, from, to := billFixture(t) res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to}) if err != nil { t.Fatalf("GetBill 失败: %v", err) } s := res.Summary // 764176 期初 / 764174 期末(均 `< 边界`) if got, want := s.PrevBalance.String(), "500.00"; got != want { t.Errorf("期初余额 = %s,期望 %s", got, want) } if got, want := s.EndBalance.String(), "430.00"; got != want { t.Errorf("期末余额 = %s,期望 %s(04-01 那笔因 `< to` 不计入)", got, want) } // 764170 当期推荐额 / 764172 当期消费 / 充值(764168 游标) if got, want := s.RecommendAmount.String(), "30.00"; got != want { t.Errorf("当期推荐额 = %s,期望 %s", got, want) } if got, want := s.Spend.String(), "-100.00"; got != want { t.Errorf("当期消费 = %s,期望 %s", got, want) } if got, want := s.Recharge.String(), "200.00"; got != want { t.Errorf("当期充值 = %s,期望 %s(BETWEEN 含右端,04-01 计入)", got, want) } // 赠送额三段链(764178-764194) if got, want := s.Gift.String(), "50.00"; got != want { t.Errorf("当期赠送额 = %s,期望 %s", got, want) } if got, want := s.GivePrevBalance.String(), "0.00"; got != want { t.Errorf("期初赠送结余 = %s,期望 %s(RIGHT JOIN 使'无 used_log 的活动'不进结余)", got, want) } if got, want := s.GiveEndBalance.String(), "30.00"; got != want { t.Errorf("期末赠送结余 = %s,期望 %s(signed 50 + used −20)", got, want) } if got, want := s.GiveUsed.String(), "20.00"; got != want { t.Errorf("当期使用赠送 = %s,期望 %s(= 赠送 50 + 上期 0 − 期末 30)", got, want) } } // TestGetBillIdentityAcrossAccountTable 横切断言 ①:期末余额 == 账户表余额(跨表自证)。 // // 取 to 覆盖**全部流水**的窗口:此时"期末余额"就是账户快照。 // 若实现漏算某个 type、或把某类流水符号写反,本用例立刻红(dump 的账户余额正是 Σ全部流水)。 func TestGetBillIdentityAcrossAccountTable(t *testing.T) { db, svc, userID, from, _ := billFixture(t) var account model.UserExpense if err := db.Where("user_id = ?", userID).Take(&account).Error; err != nil { t.Fatalf("读全局钱包失败: %v", err) } to := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) // 覆盖 04-01 那笔 res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to}) if err != nil { t.Fatalf("GetBill 失败: %v", err) } if res.Summary.EndBalance != mustAmount(t, account.Balance) { t.Fatalf("期末余额 %s != 账户表余额 %s", res.Summary.EndBalance.String(), account.Balance) } // 恒等式:期末 − 期初 == 窗口内(左闭右开)全类型流水合计 delta := res.Summary.EndBalance.Sub(res.Summary.PrevBalance) if delta != res.Summary.FlowExclusiveEnd { t.Fatalf("恒等式破:期末−期初 = %s,窗口内流水 = %s", delta.String(), res.Summary.FlowExclusiveEnd.String()) } if got, want := res.Summary.Gift.String(), "50.00"; got != want { t.Errorf("当期赠送额 = %s,期望 %s", got, want) } } // TestGetBillTimeBoundaryDiffersByExactlyOneFlow 横切断言 ②:两种比较语义并在 `t = To` 处差一笔。 // // 断言三件事(缺任何一条,实现都可能"统一成一种语义"而全绿): // 1. `FlowInclusiveEnd − FlowExclusiveEnd` == 恰好那一笔(+200.00); // 2. `EndBalance − PrevBalance` == `FlowExclusiveEnd`(**不含**该笔); // 3. 该笔**计入**「当期充值」(BETWEEN),因此 Recharge 与 (期末−期初) 天然对不上。 func TestGetBillTimeBoundaryDiffersByExactlyOneFlow(t *testing.T) { _, svc, userID, from, to := billFixture(t) res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to}) if err != nil { t.Fatalf("GetBill 失败: %v", err) } s := res.Summary if got, want := s.FlowExclusiveEnd.String(), "-70.00"; got != want { t.Errorf("窗口内(左闭右开)流水 = %s,期望 %s", got, want) } if got, want := s.FlowInclusiveEnd.String(), "130.00"; got != want { t.Errorf("窗口内(两端闭)流水 = %s,期望 %s", got, want) } diff := s.FlowInclusiveEnd.Sub(s.FlowExclusiveEnd) if got, want := diff.String(), "200.00"; got != want { t.Fatalf("两种口径的差额 = %s,期望 %s(必须恰好等于落在 t=to 的那笔)", got, want) } if delta := s.EndBalance.Sub(s.PrevBalance); delta != s.FlowExclusiveEnd { t.Fatalf("期末−期初(%s) != FlowExclusiveEnd(%s)", delta.String(), s.FlowExclusiveEnd.String()) } } // TestGetBillSummaryInvariantAcrossPagination 横切断言 ③:翻页只影响明细(汇总逐字段不变)。 // // v1.4 起明细**按窗口过滤**(`from <= create_time < to`),故 fixture 窗口内只有 2 笔 // (03-05 消费、03-31 推荐);pageSize=2 时第 1 页满、第 2 页空——这正好同时验证 // "翻页不改变汇总"与"total 与 list 同口径"(total=2 而非历史 4 笔)。 func TestGetBillSummaryInvariantAcrossPagination(t *testing.T) { _, svc, userID, from, to := billFixture(t) ctx := context.Background() page1, err := svc.GetBill(ctx, GetBillInput{UserID: userID, From: from, To: to, Page: 1, PageSize: 2}) if err != nil { t.Fatalf("第 1 页失败: %v", err) } page2, err := svc.GetBill(ctx, GetBillInput{UserID: userID, From: from, To: to, Page: 2, PageSize: 2}) if err != nil { t.Fatalf("第 2 页失败: %v", err) } if !reflect.DeepEqual(page1.Summary, page2.Summary) { t.Fatalf("汇总随分页变化(必须不随分页变化):\n第1页 %+v\n第2页 %+v", page1.Summary, page2.Summary) } if page1.Total != 2 || page2.Total != 2 { t.Fatalf("明细总数 = %d/%d,期望 2/2(**窗口内**流水,不是历史 4 笔)", page1.Total, page2.Total) } if len(page1.Details) != 2 || len(page2.Details) != 0 { t.Fatalf("分页失效:len=%d/%d,期望第 1 页满 2 条、第 2 页 0 条", len(page1.Details), len(page2.Details)) } // 明细按 `create_time DESC, id DESC`:窗口内倒序应是 03-31(107) → 03-05(101) if page1.Details[0].Type != model.ExpenseLogTypeRecommend || page1.Details[0].Money.String() != "30.00" { t.Fatalf("第 1 页首行应为 03-31 的 107/+30.00,实得 %+v", page1.Details[0]) } if page1.Details[1].Type != model.ExpenseLogTypeCourseCampus { t.Fatalf("第 1 页次行应为 03-05 的 101,实得 %+v", page1.Details[1]) } } // TestGetBillDetailsRespectWindow 明细必须受查询窗口约束(v1.4):右开,且与 FlowExclusiveEnd 同口径。 // // 三笔流水分别在窗口前(02-20)、窗口内(03-05/03-31)、窗口右端点上(04-01 = `to`): // - 窗口前那笔**不得**出现在明细(否则前端选"3 月"会看到 2 月流水); // - 落在 `t = to` 的那笔**不得**出现在明细(右开),但它**计入** `FlowInclusiveEnd` // —— 这就是 summary 与明细之间唯一的、且被显式下发的口径差。 func TestGetBillDetailsRespectWindow(t *testing.T) { _, svc, userID, from, to := billFixture(t) res, err := svc.GetBill(context.Background(), GetBillInput{ UserID: userID, From: from, To: to, Page: 1, PageSize: 10, }) if err != nil { t.Fatalf("GetBill 失败: %v", err) } if len(res.Details) != 2 { t.Fatalf("窗口内明细应为 2 条,实得 %d 条:%+v", len(res.Details), res.Details) } for _, d := range res.Details { if d.CreateTime.Before(from) { t.Fatalf("明细含**窗口前**流水(%s < from %s)", d.CreateTime, from) } if !d.CreateTime.Before(to) { t.Fatalf("明细含**窗口右端点**流水(%s >= to %s):右开窗口必须排除", d.CreateTime, to) } if d.LogID == "REQ-A" || d.LogID == "REQ-B" { t.Fatalf("明细含窗口外工单流水 %s(REQ-A 在窗口前、REQ-B 恰在 to)", d.LogID) } } // 口径差显式可见:右端点那笔进 inclusive、不进明细 diff := res.Summary.FlowInclusiveEnd.Sub(res.Summary.FlowExclusiveEnd) if got, want := diff.String(), "200.00"; got != want { t.Fatalf("inclusive−exclusive = %s,期望 %s(= 落 to 的那笔,它不进明细)", got, want) } } // TestGetBillCampusIDsIsParsedOnlyNotFiltered dump 第二入参是死参数:只解析回显、不过滤。 // // 用 "1,2,3" 与 "" 两次查询比对汇总**完全一致**——若有人"顺手实现"了校区过滤, // 本用例立刻红(这会静默改变账单口径,见 bill_service.go 文件头)。 func TestGetBillCampusIDsIsParsedOnlyNotFiltered(t *testing.T) { _, svc, userID, from, to := billFixture(t) ctx := context.Background() plain, err := svc.GetBill(ctx, GetBillInput{UserID: userID, From: from, To: to}) if err != nil { t.Fatalf("无 campusIds 查询失败: %v", err) } withCampus, err := svc.GetBill(ctx, GetBillInput{ UserID: userID, CampusIDs: "10, 20", From: from, To: to, }) if err != nil { t.Fatalf("带 campusIds 查询失败: %v", err) } if !reflect.DeepEqual(withCampus.Summary.CampusIDs, []int64{10, 20}) { t.Fatalf("campusIds 回显 = %v,期望 [10 20]", withCampus.Summary.CampusIDs) } withCampus.Summary.CampusIDs = plain.Summary.CampusIDs if !reflect.DeepEqual(plain.Summary, withCampus.Summary) { t.Fatalf("campusIds 影响了汇总(dump 里它是死参数,不得过滤):\n无 %+v\n有 %+v", plain.Summary, withCampus.Summary) } if plain.Total != withCampus.Total { t.Fatalf("campusIds 影响了明细条数:%d vs %d", plain.Total, withCampus.Total) } } // TestGetBillEmptyAccount 无流水的学生:全部汇总为 0、明细为空切片(不是 null)。 func TestGetBillEmptyAccount(t *testing.T) { db := newBillServiceDB(t) svc := NewBillService(db, fundSuperScope()) res, err := svc.GetBill(context.Background(), GetBillInput{ UserID: fundUserID(901), From: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("GetBill 失败: %v", err) } if res.Details == nil { t.Fatal("明细为 nil,应为长度 0 的切片") } if len(res.Details) != 0 || res.Total != 0 { t.Fatalf("无流水却返回明细 %d 条 / total=%d", len(res.Details), res.Total) } if !res.Summary.EndBalance.IsZero() || !res.Summary.PrevBalance.IsZero() || !res.Summary.Gift.IsZero() || !res.Summary.GiveUsed.IsZero() { t.Fatalf("无流水时汇总应全 0,实得 %+v", res.Summary) } if res.Page != 1 || res.PageSize != 10 { t.Fatalf("分页默认值应为 (1,10),实得 (%d,%d)", res.Page, res.PageSize) } } // TestGetBillRejectsBadParams 入参守卫(2001);非法 campusIds 必须报错而不是"当没传"。 func TestGetBillRejectsBadParams(t *testing.T) { db := newBillServiceDB(t) svc := NewBillService(db, fundSuperScope()) ctx := context.Background() from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) cases := []struct { name string in GetBillInput }{ {"缺 user_id", GetBillInput{From: from, To: to}}, {"缺 from", GetBillInput{UserID: fundUserID(1), To: to}}, {"缺 to", GetBillInput{UserID: fundUserID(1), From: from}}, {"from 晚于 to", GetBillInput{UserID: fundUserID(1), From: to, To: from}}, {"campusIds 非法片段", GetBillInput{UserID: fundUserID(1), CampusIDs: "1,a", From: from, To: to}}, {"campusIds 非正数", GetBillInput{UserID: fundUserID(1), CampusIDs: "0", From: from, To: to}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := svc.GetBill(ctx, tc.in) wantAppErr(t, err, infraerrors.CodeBadParam, tc.name) }) } } // TestGetBillDetailTitleAndMoney 明细展示字段:两位小数与类型名(金额不得输出浮点)。 func TestGetBillDetailTitleAndMoney(t *testing.T) { _, svc, userID, from, to := billFixture(t) res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to, PageSize: 10}) if err != nil { t.Fatalf("GetBill 失败: %v", err) } wantTitles := map[int32]string{ model.ExpenseLogTypeRecharge: "充值", model.ExpenseLogTypeCourseCampus: "课程消费(校区)", model.ExpenseLogTypeRecommend: "老带新推荐", } if len(res.Details) == 0 { t.Fatal("窗口内应有明细,实得 0 条") } for _, d := range res.Details { if want, ok := wantTitles[d.Type]; ok && d.TypeTitle != want { t.Errorf("type=%d 标题 = %q,期望 %q", d.Type, d.TypeTitle, want) } if d.Money.String() != "200.00" && d.Money.String() != "-100.00" && d.Money.String() != "30.00" && d.Money.String() != "500.00" { t.Errorf("明细金额格式异常: %s(应两位小数)", d.Money.String()) } } // 窗口内倒序首行 = 03-31 的 107(logId 为空),末行 = 03-05 的 101 if res.Details[0].Type != model.ExpenseLogTypeRecommend { t.Errorf("首行 type = %d,期望 107(窗口内倒序首行)", res.Details[0].Type) } if res.Details[len(res.Details)-1].Type != model.ExpenseLogTypeCourseCampus { t.Errorf("末行 type = %d,期望 101", res.Details[len(res.Details)-1].Type) } } // TestBillServiceErrorCodeContract DB 未装配时必须 5001(不能把配置错误伪装成业务错误)。 func TestBillServiceErrorCodeContract(t *testing.T) { svc := NewBillService(nil, fundSuperScope()) _, err := svc.GetBill(context.Background(), GetBillInput{ UserID: fundUserID(1), From: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), }) wantAppErr(t, err, infraerrors.CodeInternal, "DB 未装配") } // TestBillPagesBoundaries 分页总页数的两个边界(`pages` 字段,契约 v1.4 rev.2)。 // // 契约要求:`PageSize <= 0` → 0(防除零);其余 `ceil(Total/PageSize)`。 // 两个最容易写错的点,逐个钉住: // - `Total = 0` → **0** 页(不是 1 页;空结果集不该显示"第 1/1 页"); // - `Total = 25, PageSize = 10` → **3** 页(向上取整;写成整除会得 2)。 func TestBillPagesBoundaries(t *testing.T) { cases := []struct { total int64 pageSize int want int }{ {0, 10, 0}, // 空结果集 → 0 页 {25, 10, 3}, // 向上取整(整除会得 2) {10, 10, 1}, // 正好整页 {11, 10, 2}, // 多一条即多一页 {1, 200, 1}, // 单条 {5, 0, 0}, // 防除零 {-1, 10, 0}, // 脏数据不产生负页数 } for _, tc := range cases { if got := billPages(tc.total, tc.pageSize); got != tc.want { t.Errorf("billPages(total=%d, pageSize=%d) = %d,期望 %d", tc.total, tc.pageSize, got, tc.want) } } } // TestGetBillPagesFollowsWindowedTotal `pages` 必须由**窗口内** total 与**钳制后** pageSize 算出。 // // 若 pages 用历史全量 total(4)算,会得 1 页(pageSize=2 → 2 页);窗口内 total=2、pageSize=2 → 1 页。 // 本用例把两种错法都排除:断言 Pages == 1(窗口内 2 条 / 每页 2 条), // 并且 Total 明确等于窗口内条数而非历史总数。 func TestGetBillPagesFollowsWindowedTotal(t *testing.T) { _, svc, userID, from, to := billFixture(t) res, err := svc.GetBill(context.Background(), GetBillInput{ UserID: userID, From: from, To: to, Page: 1, PageSize: 2, }) if err != nil { t.Fatalf("GetBill 失败: %v", err) } if res.Total != 2 || res.PageSize != 2 || res.Pages != 1 { t.Fatalf("total/pageSize/pages = %d/%d/%d,期望 2/2/1(pages 用窗口内 total + 钳制后 pageSize)", res.Total, res.PageSize, res.Pages) } // 超大 pageSize 被钳制到 200 后,pages 也必须按 200 算(而不是按请求值) big, err := svc.GetBill(context.Background(), GetBillInput{ UserID: userID, From: from, To: to, Page: 1, PageSize: 5000, }) if err != nil { t.Fatalf("GetBill(pageSize=5000) 失败: %v", err) } if big.PageSize != 200 || big.Pages != 1 { t.Fatalf("pageSize/pages = %d/%d,期望 200/1(按钳制后的 200 算,不是请求的 5000)", big.PageSize, big.Pages) } } // TestGetBillGiveBalanceDeduplicatesSignedRows 赠送结余必须按活动**去重**(设计 §3.8-A21,DUMP-BUGFIX)。 // // --------------------------------------------------------------------------- // dump 的缺陷(**刻意不复刻**,见 giveBalanceBefore 注释第 3 条) // --------------------------------------------------------------------------- // // dump 764182-764189 的结余是 `SUM(activity_money_used)` 建在 // `signed RIGHT JOIN (SELECT … GROUP BY activityid)` 上:同一活动有 **k 条 signed** 时, // 那条 used 汇总值会被 JOIN 扇出成 k 行 ⇒ 被加 k 次(`activity_money` 因 GROUP BY 只出现一次, // used 却被放大)⇒ `giveEndBalance`/`givePrevBalance` 双计 ⇒ `giveUsed` 虚增。 // // 真实数据仅 1 例(`user 32596 / activity 1`,两条各 1000.000;cutoff 2020-01-01 时 // dump=737.022 / Go=1737.022,差 1000.000)。本用例用"1 活动 2 条 signed"把这个形态造出来: // // expected(Go,去重):giveEnd = signed(1000+1000) + used(−100) = 1900.00 // dump(扇出,错误) :giveEnd = signed(1000+1000) + used(−100 × 2 条 signed) = 1800.00 // // 断言两者**不相等**,并锁定 1900.00 —— 若有人"按注释把第三条补回去",本用例立刻红。 // // 注:目标态 `t_courses_activity_signed` 有 UK(user_id, activity_id),造不出重复行, // 故本用例先 DROP 该唯一索引来模拟**旧库形态**(老库无此 UK,正是 A21 那 1 例脏数据的成因)。 func TestGetBillGiveBalanceDeduplicatesSignedRows(t *testing.T) { db := newBillServiceDB(t) userID := fundUserID(950) from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) // 去掉 UK 以复现旧库形态(老库允许同 (user, activity) 多行 → A21 的来源) if err := db.Exec("DROP INDEX IF EXISTS uk_user_activity").Error; err != nil { t.Fatalf("DROP INDEX uk_user_activity 失败: %v", err) } no := "REQ-DUP" for i := 0; i < 2; i++ { // 同一活动 2 条 signed,各 1000.00 if err := db.Create(&model.CoursesActivitySigned{ ActivityID: 9002, UserID: userID, ActivityMoney: "1000.00", RequestNo: &no, }).Error; err != nil { t.Fatalf("seed 重复报名(第 %d 条) 失败: %v", i+1, err) } } if err := db.Create(&model.CoursesActivityUsedLog{ ActivityID: 9002, UserID: userID, ActivityMoneyUsed: "-100.00", CreateTime: time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC), }).Error; err != nil { t.Fatalf("seed used_log 失败: %v", err) } svc := NewBillService(db, fundSuperScope()) res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to}) if err != nil { t.Fatalf("GetBill 失败: %v", err) } // 去重口径:signed 2000 + used 只计一次 −100 = 1900 if got, want := res.Summary.GiveEndBalance.String(), "1900.00"; got != want { t.Fatalf("期末赠送结余 = %s,期望 %s(used 只计一次;若得 1800.00 说明复刻了 A21 的扇出缺陷)", got, want) } if res.Summary.GiveEndBalance.String() == "1800.00" { t.Fatal("复刻了 dump 的 RIGHT JOIN 扇出(A21,DUMP-BUGFIX)——该缺陷已明确不复刻") } // giveUsed = Gift(0) + GivePrev(0) − GiveEnd(1900) = −1900(本用例只关心去重,不构造 gift 链) if got, want := res.Summary.GiveUsed.String(), "-1900.00"; got != want { t.Fatalf("当期使用赠送 = %s,期望 %s", got, want) } }