|
|
@@ -0,0 +1,182 @@
|
|
|
+package stage2
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "fmt"
|
|
|
+
|
|
|
+ "gorm.io/gorm"
|
|
|
+
|
|
|
+ "yuansiwei/unified-backend/internal/academic/port"
|
|
|
+ "yuansiwei/unified-backend/internal/academic/service"
|
|
|
+ appscope "yuansiwei/unified-backend/internal/infra/scope"
|
|
|
+)
|
|
|
+
|
|
|
+// 本文件是 **空用户**(case RS-6)的结果集双跑。
|
|
|
+//
|
|
|
+// # 为什么单开一条
|
|
|
+//
|
|
|
+// `sp_user_expense_get_bill` 的 9 列全是标量子查询 + `IFNULL` 兜底:
|
|
|
+//
|
|
|
+// SELECT(SELECT SUM(money) FROM temp_user_expense_log WHERE …) INTO currentSpend;
|
|
|
+// …
|
|
|
+// IF(ISNULL(currentSpend)) THEN SET currentSpend = 0; END IF;
|
|
|
+//
|
|
|
+// `SUM` 无行时是 **NULL**,不是 0。target 侧是 Go 定点累加(零值即 0)。
|
|
|
+// 若把 NULL 折成 0 而不声明,这条 case 看起来会"两侧都是 0 ⇒ 一致",
|
|
|
+// 但那是**没测**:真正要钉的是"空用户下 legacy 也返回 0.00 而不是 NULL"。
|
|
|
+//
|
|
|
+// 所以本 case 的判据只有一句话:**无任何流水时,两侧都返回 1 行、9 列全是 0.00(非 NULL)**。
|
|
|
+// 期望值写成 `"0.00"` 而不是"空":一旦 legacy 返回 NULL,`normalizeCell` 会把 NULL 归一成
|
|
|
+// `"NULL"`,与 `"0.00"` 不等 ⇒ 逐列断言直接红(这就是"别糊"的可判定形式)。
|
|
|
+
|
|
|
+// seedLegacyEmptyUser 只做清理(不写任何流水)。
|
|
|
+//
|
|
|
+// 为什么清理也算种子:合成用户 880001 是三个 case 共用的;虽然每条 case 都在独立事务里
|
|
|
+// 且结束后回滚,但"先删干净"能把"上一个 case 的残留"这类失效模式挡在外面。
|
|
|
+func seedLegacyEmptyUser(ctx context.Context, tx *gorm.DB) error {
|
|
|
+ for _, stmt := range []string{
|
|
|
+ `DELETE FROM t_user_expense_log WHERE userid = ?`,
|
|
|
+ `DELETE FROM t_courses_activity_signed WHERE userid = ?`,
|
|
|
+ `DELETE FROM t_courses_activity_used_log WHERE userid = ?`,
|
|
|
+ `DELETE FROM t_user_balance_request WHERE userid = ?`,
|
|
|
+ } {
|
|
|
+ if err := tx.Exec(stmt, rsUserID).Error; err != nil {
|
|
|
+ return fmt.Errorf("清理 legacy 空用户种子失败(%s):%w", stmt, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// seedTargetEmptyUser 建身份(越权断言需要),不写流水。
|
|
|
+func seedTargetEmptyUser(ctx context.Context, tx *gorm.DB, uid string) error {
|
|
|
+ if err := seedResultSetIdentityTarget(ctx, tx, uid, rsUserID, rsInstID, rsCampusID); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ for _, stmt := range []string{
|
|
|
+ `DELETE FROM t_user_expense_log WHERE user_id = ?`,
|
|
|
+ `DELETE FROM t_courses_activity_signed WHERE user_id = ?`,
|
|
|
+ `DELETE FROM t_courses_activity_used_log WHERE user_id = ?`,
|
|
|
+ `DELETE FROM t_user_balance_request WHERE user_id = ?`,
|
|
|
+ } {
|
|
|
+ if err := tx.Exec(stmt, uid).Error; err != nil {
|
|
|
+ return fmt.Errorf("清理 target 空用户种子失败(%s):%w", stmt, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// verifyLegacyEmptyUserSeed / verifyTargetEmptyUserSeed:断言"确实一条流水都没有"。
|
|
|
+//
|
|
|
+// 这条读回**必须存在**:空用户 case 的全部意义是"无行时返回什么",
|
|
|
+// 若种子没删干净(或别的 case 残留),测的就变成了"有流水时的账单" —— 一条彻底的假绿。
|
|
|
+func verifyLegacyEmptyUserSeed(ctx context.Context, tx *gorm.DB) (string, error) {
|
|
|
+ return verifyEmptyUserSeed(ctx, tx, "legacy", rsUserID, false)
|
|
|
+}
|
|
|
+
|
|
|
+func verifyTargetEmptyUserSeed(ctx context.Context, tx *gorm.DB, uid string) (string, error) {
|
|
|
+ return verifyEmptyUserSeed(ctx, tx, "target", uid, true)
|
|
|
+}
|
|
|
+
|
|
|
+func verifyEmptyUserSeed(ctx context.Context, tx *gorm.DB, side string, who any, target bool) (string, error) {
|
|
|
+ userCol := "userid"
|
|
|
+ if target {
|
|
|
+ userCol = "user_id"
|
|
|
+ }
|
|
|
+ tables := []string{"t_user_expense_log", "t_courses_activity_signed",
|
|
|
+ "t_courses_activity_used_log", "t_user_balance_request"}
|
|
|
+ for _, t := range tables {
|
|
|
+ var n int
|
|
|
+ if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE %s = ?`, t, userCol), who).
|
|
|
+ Row().Scan(&n); err != nil {
|
|
|
+ return "", fmt.Errorf("[%s] 读回 %s 条数失败:%w", side, t, err)
|
|
|
+ }
|
|
|
+ if n != 0 {
|
|
|
+ return "", fmt.Errorf("[%s] %s 还有 %d 行 —— 空用户前提不成立,本 case 会测成另一件事", side, t, n)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return "四张来源表均为 0 行", nil
|
|
|
+}
|
|
|
+
|
|
|
+// rsEmptyUserCase 组装 RS-6。
|
|
|
+func rsEmptyUserCase() ResultSetCase {
|
|
|
+ from := mustParse(rsWinStart + " 00:00:00")
|
|
|
+ to := mustParse(rsWinEnd + " 00:00:00")
|
|
|
+ zeros := map[string]string{}
|
|
|
+ for _, c := range rsBillColumns() {
|
|
|
+ zeros[c.Legacy] = "0.00"
|
|
|
+ }
|
|
|
+ return ResultSetCase{
|
|
|
+ Name: "RS-6 账单·空用户:无流水时 9 列全是 0.00(不是 NULL)",
|
|
|
+ Note: "`SUM` 无行是 NULL,dump 末尾逐个 IFNULL 才变 0;target 是 Go 定点累加(零值即 0)。" +
|
|
|
+ "本 case 钉的是「空用户也返回 1 行、且 9 列非 NULL」——若把 NULL 与 0 混为一谈,这条就退化成空过",
|
|
|
+ LegacyUserID: rsUserID, InstitutionID: rsInstID, CampusID: rsCampusID,
|
|
|
+ SeedLegacy: seedLegacyEmptyUser,
|
|
|
+ SeedTarget: seedTargetEmptyUser,
|
|
|
+ VerifyLegacy: verifyLegacyEmptyUserSeed,
|
|
|
+ VerifyTarget: verifyTargetEmptyUserSeed,
|
|
|
+ LegacyCall: "CALL sp_user_expense_get_bill(?, ?, ?, ?)",
|
|
|
+ LegacyArgs: []any{rsUserID, "990101", rsWinStart, rsWinEnd},
|
|
|
+ CallTarget: func(ctx context.Context, tx *gorm.DB, uid string, sc *appscope.Scope) (TargetPayload, error) {
|
|
|
+ svc := service.NewBillService(tx, sc).WithMembershipChecker(port.NewOrgMembershipChecker(tx))
|
|
|
+ res, err := svc.GetBill(ctx, service.GetBillInput{
|
|
|
+ UserID: uid, CampusIDs: "990101", From: from, To: to, Page: 1, PageSize: 20,
|
|
|
+ })
|
|
|
+ if err != nil {
|
|
|
+ return TargetPayload{}, err
|
|
|
+ }
|
|
|
+ s := res.Summary
|
|
|
+ return TargetPayload{
|
|
|
+ Label: "BillService.GetBill",
|
|
|
+ Values: map[string]Cell{
|
|
|
+ "PrevBalance": MoneyCell(int64(s.PrevBalance)),
|
|
|
+ "GivePrevBalance": MoneyCell(int64(s.GivePrevBalance)),
|
|
|
+ "Recharge": MoneyCell(int64(s.Recharge)),
|
|
|
+ "Gift": MoneyCell(int64(s.Gift)),
|
|
|
+ "RecommendAmount": MoneyCell(int64(s.RecommendAmount)),
|
|
|
+ "Spend": MoneyCell(int64(s.Spend)),
|
|
|
+ "GiveUsed": MoneyCell(int64(s.GiveUsed)),
|
|
|
+ "EndBalance": MoneyCell(int64(s.EndBalance)),
|
|
|
+ "GiveEndBalance": MoneyCell(int64(s.GiveEndBalance)),
|
|
|
+ "FlowExclusiveEnd": MoneyCell(int64(s.FlowExclusiveEnd)),
|
|
|
+ "FlowInclusiveEnd": MoneyCell(int64(s.FlowInclusiveEnd)),
|
|
|
+ "CampusIDs": TextCell("990101"),
|
|
|
+ },
|
|
|
+ Evidence: fmt.Sprintf("total=%d(应为 0)", res.Total),
|
|
|
+ }, nil
|
|
|
+ },
|
|
|
+ Compare: func(lg ResultSet, tg TargetPayload) ResultSetInput {
|
|
|
+ return ResultSetInput{
|
|
|
+ CaseName: "RS-6",
|
|
|
+ Legacy: lg,
|
|
|
+ ExpectRows: 1, // 空用户也必须返回 1 行(聚合查询恒一行)
|
|
|
+ Target: tg,
|
|
|
+ Columns: rsBillColumns(),
|
|
|
+ Expect: zeros,
|
|
|
+ PrecisionCheck: true,
|
|
|
+ TargetOnly: []TargetOnlyColumn{
|
|
|
+ {Target: "FlowExclusiveEnd", Kind: CellMoney, Expect: MoneyCell(0), Note: "空窗口 ⇒ 0.00"},
|
|
|
+ {Target: "FlowInclusiveEnd", Kind: CellMoney, Expect: MoneyCell(0), Note: "空窗口 ⇒ 0.00"},
|
|
|
+ {Target: "CampusIDs", Kind: CellText, Expect: TextCell("990101"), Note: "回显不受数据影响"},
|
|
|
+ },
|
|
|
+ ExtraAssertions: func(lg ResultSet, tg TargetPayload) []Assertion {
|
|
|
+ var nulls []string
|
|
|
+ for _, c := range rsBillColumns() {
|
|
|
+ cell, ok := RawCellOf(lg, 0, c.Legacy)
|
|
|
+ if !ok || cell.Null {
|
|
|
+ nulls = append(nulls, c.Legacy)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return []Assertion{{
|
|
|
+ Kind: AssertionBusiness,
|
|
|
+ Name: "空用户:dump 的 9 列**没有一列是 NULL**(`SUM` 无行 + 末尾 IFNULL 兜底的结果)",
|
|
|
+ Pass: len(nulls) == 0,
|
|
|
+ Detail: fmt.Sprintf("NULL 的列 %v | 声明:9 列全为 0.00。"+
|
|
|
+ "若这里出现 NULL,说明 dump 的 IFNULL 兜底没覆盖到该列 —— "+
|
|
|
+ "而 target 只有 0,两者会在逐列比对上同时红(本断言把它归因到 NULL,而不是「算错了」)",
|
|
|
+ nulls),
|
|
|
+ }}
|
|
|
+ },
|
|
|
+ }
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|