package adminuser import ( "errors" "strings" "testing" "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/rentergrowth" "gorm.io/gorm" ) func TestDepositFreeQuotaAmountCent(t *testing.T) { tests := []struct { name string req DepositFreeQuotaRequest want int64 }{ { name: "使用分字段", req: DepositFreeQuotaRequest{AmountCent: 1234}, want: 1234, }, { name: "负分拒绝", req: DepositFreeQuotaRequest{AmountCent: -1}, want: -1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := depositFreeQuotaAmountCent(tt.req); got != tt.want { t.Fatalf("depositFreeQuotaAmountCent() = %d, want %d", got, tt.want) } }) } } func TestParseBoolQuery(t *testing.T) { tests := []struct { name string value string want bool }{ {name: "true 开启", value: "true", want: true}, {name: "数字 1 开启", value: "1", want: true}, {name: "on 开启", value: "on", want: true}, {name: "空值关闭", value: "", want: false}, {name: "false 关闭", value: "false", want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := parseBoolQuery(tt.value); got != tt.want { t.Fatalf("parseBoolQuery(%q) = %v, want %v", tt.value, got, tt.want) } }) } } func TestApplyUserFilterHasDepositFreeQuota(t *testing.T) { db := database.NewDryRunDB() stmt := applyUserFilter(db.Table("users AS u"), ListQuery{ Status: "active", HasDepositFreeQuota: true, }, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement sql := stmt.SQL.String() if !strings.Contains(sql, "u.deposit_free_quota_cent > 0 OR u.renter_growth_points >=") { t.Fatalf("SQL 未包含免押额度过滤:%s", sql) } if len(stmt.Vars) != 5 || stmt.Vars[0] != "active" || stmt.Vars[1] != int64(300) { t.Fatalf("筛选变量不正确:%v", stmt.Vars) } overrideCodes := stmt.Vars[2:] if len(overrideCodes) != 3 || overrideCodes[0] != "platinum" || overrideCodes[1] != "diamond" || overrideCodes[2] != "peak" { t.Fatalf("手动等级筛选变量不正确:%v", overrideCodes) } } func TestApplyUserFilterNoneDepositFreeQuota(t *testing.T) { db := database.NewDryRunDB() stmt := applyUserFilter(db.Table("users AS u"), ListQuery{ DepositFreeQuota: "none", }, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement sql := stmt.SQL.String() if !strings.Contains(sql, "NOT (u.deposit_free_quota_cent > 0 OR u.renter_growth_points >= ? OR u.renter_growth_level_override IN (?,?,?))") { t.Fatalf("SQL 未包含无免押额度过滤:%s", sql) } } func TestApplyUserFilterOwnerTypeAndGrowthLevel(t *testing.T) { db := database.NewDryRunDB() stmt := applyUserFilter(db.Table("users AS u"), ListQuery{ OwnerType: "normal", GrowthLevel: "diamond", }, rentergrowth.DefaultConfig()).Find(&[]struct{}{}).Statement sql := stmt.SQL.String() if !strings.Contains(sql, "u.quality_owner_status <> ?") { t.Fatalf("SQL 未包含普通号主过滤:%s", sql) } if !strings.Contains(sql, "u.renter_growth_level = ?") { t.Fatalf("SQL 未包含成长等级过滤:%s", sql) } if len(stmt.Vars) != 2 || stmt.Vars[0] != "active" || stmt.Vars[1] != "diamond" { t.Fatalf("筛选变量不正确:%v", stmt.Vars) } } func TestNormalizeEnumQuery(t *testing.T) { tests := []struct { input string want string }{ {input: " QUALITY ", want: "quality"}, {input: "normal", want: "normal"}, {input: "unknown", want: ""}, {input: "", want: ""}, } for _, tt := range tests { if got := normalizeEnumQuery(tt.input, "quality", "normal"); got != tt.want { t.Fatalf("normalizeEnumQuery(%q) = %q, want %q", tt.input, got, tt.want) } } } func TestListFiltersByDepositQuotaOwnerTypeAndGrowthLevel(t *testing.T) { db := setupAdminUserTestDB(t) users := []model.User{ {Phone: "13900001001", Nickname: "普通无免押", Status: "active", QualityOwnerStatus: "active", RenterGrowthLevel: "normal"}, {Phone: "13900001002", Nickname: "铂金有免押", Status: "active", QualityOwnerStatus: "none", RenterGrowthPoints: 300, RenterGrowthLevel: "platinum"}, {Phone: "13900001003", Nickname: "钻石有免押", Status: "active", QualityOwnerStatus: "none", RenterGrowthPoints: 1000, RenterGrowthLevel: "diamond"}, {Phone: "13900001004", Nickname: "人工免押", Status: "active", QualityOwnerStatus: "none", DepositFreeQuotaCent: 20000, RenterGrowthLevel: "normal"}, } if err := db.Create(&users).Error; err != nil { t.Fatalf("创建用户失败:%v", err) } repo := NewRepository(db) tests := []struct { name string query ListQuery wantIDs []uint64 }{ { name: "有免押额度", query: ListQuery{DepositFreeQuota: "has"}, wantIDs: []uint64{users[1].ID, users[2].ID, users[3].ID}, }, { name: "无免押额度", query: ListQuery{DepositFreeQuota: "none"}, wantIDs: []uint64{users[0].ID}, }, { name: "优质号主", query: ListQuery{OwnerType: "quality"}, wantIDs: []uint64{users[0].ID}, }, { name: "普通用户", query: ListQuery{OwnerType: "normal"}, wantIDs: []uint64{users[1].ID, users[2].ID, users[3].ID}, }, { name: "铂金等级", query: ListQuery{GrowthLevel: "platinum"}, wantIDs: []uint64{users[1].ID}, }, { name: "组合筛选", query: ListQuery{OwnerType: "normal", GrowthLevel: "normal", DepositFreeQuota: "has"}, wantIDs: []uint64{users[3].ID}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := repo.List(t.Context(), 1, 20, tt.query) if err != nil { t.Fatalf("List() error = %v", err) } if result.Total != int64(len(tt.wantIDs)) { t.Fatalf("Total = %d, want %d", result.Total, len(tt.wantIDs)) } items := result.Items.([]UserDTO) got := make(map[uint64]bool, len(items)) for _, item := range items { got[item.ID] = true } for _, id := range tt.wantIDs { if !got[id] { t.Fatalf("结果缺少用户 %d:%v", id, got) } } }) } } func TestAdjustGrowthPointsWritesRepeatableLedgers(t *testing.T) { db := setupAdminUserTestDB(t) user := model.User{ Phone: "13900000005", Status: "active", RenterGrowthPoints: 290, RenterGrowthLevel: rentergrowth.DefaultLevelCode, } if err := db.Create(&user).Error; err != nil { t.Fatalf("创建用户失败:%v", err) } repo := NewRepository(db) if _, err := repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{ TargetPoints: 1000, Reason: "运营活动赠送", }, AuditMeta{RequestID: "growth-1"}); err != nil { t.Fatalf("第一次调整积分失败:%v", err) } got, err := repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{ TargetPoints: 200, Reason: "修正错误赠送", }, AuditMeta{RequestID: "growth-2"}) if err != nil { t.Fatalf("第二次调整积分失败:%v", err) } if got.RenterGrowthPoints != 200 || got.RenterGrowthLevel != rentergrowth.DefaultLevelCode { t.Fatalf("调整后成长信息 = %d/%s, want 200/normal", got.RenterGrowthPoints, got.RenterGrowthLevel) } var ledgers []model.RenterGrowthLedger if err := db.Where("user_id = ? AND source = ?", user.ID, rentergrowth.SourceAdminAdjustment).Order("id ASC").Find(&ledgers).Error; err != nil { t.Fatalf("查询积分流水失败:%v", err) } if len(ledgers) != 2 || ledgers[0].Points != 710 || ledgers[1].Points != -800 { t.Fatalf("积分流水不正确:%+v", ledgers) } for _, ledger := range ledgers { if ledger.OrderID != nil || ledger.OperatorAdminID == nil || *ledger.OperatorAdminID != 66 { t.Fatalf("人工积分流水关联不正确:%+v", ledger) } } } func TestAdjustGrowthLevelLocksLevelAndWritesAudit(t *testing.T) { db := setupAdminUserTestDB(t) user := model.User{ Phone: "13900000006", Status: "active", RenterGrowthPoints: 100, RenterGrowthLevel: rentergrowth.DefaultLevelCode, } if err := db.Create(&user).Error; err != nil { t.Fatalf("创建用户失败:%v", err) } repo := NewRepository(db) got, err := repo.AdjustGrowthLevel(t.Context(), 66, user.ID, GrowthLevelAdjustRequest{ TargetLevel: "platinum", Reason: "客服核验后手动调整", }, AuditMeta{RequestID: "level-1"}) if err != nil { t.Fatalf("手动调整等级失败:%v", err) } if got.RenterGrowthLevel != "platinum" || !got.RenterGrowthLevelManual || got.RenterGrowthLevelOverride != "platinum" { t.Fatalf("手动等级结果不正确:%+v", got) } got, err = repo.AdjustGrowthPoints(t.Context(), 66, user.ID, GrowthPointsAdjustRequest{ TargetPoints: 1000, Reason: "补充成长积分", }, AuditMeta{RequestID: "points-after-level"}) if err != nil { t.Fatalf("调整积分失败:%v", err) } if got.RenterGrowthLevel != "platinum" || got.RenterGrowthLevelOverride != "platinum" { t.Fatalf("积分调整不应覆盖手动等级:%+v", got) } got, err = repo.AdjustGrowthLevel(t.Context(), 66, user.ID, GrowthLevelAdjustRequest{ TargetLevel: "", Reason: "恢复积分自动计算", }, AuditMeta{RequestID: "level-auto"}) if err != nil { t.Fatalf("恢复自动等级失败:%v", err) } if got.RenterGrowthLevel != "diamond" || got.RenterGrowthLevelManual || got.RenterGrowthLevelOverride != "" { t.Fatalf("恢复自动等级结果不正确:%+v", got) } var auditCount int64 if err := db.Model(&model.AuditLog{}). Where("action = ? AND biz_id = ?", "admin_user.adjust_growth_level", user.ID). Count(&auditCount).Error; err != nil { t.Fatalf("查询审计日志失败:%v", err) } if auditCount != 2 { t.Fatalf("等级调整审计日志数量 = %d, want 2", auditCount) } } func TestManualRealnameUpdatesUserRecordAndAudit(t *testing.T) { db := setupAdminUserTestDB(t) user := model.User{ Phone: "13900000001", Nickname: "待实名用户", RealnameStatus: "rejected", Status: "active", } if err := db.Create(&user).Error; err != nil { t.Fatalf("创建用户失败:%v", err) } repo := NewRepository(db) got, err := repo.ManualRealname(t.Context(), 99, user.ID, ManualRealnameRequest{ Name: "周奕男", IDNo: "130682200604080615", Reason: "第三方服务异常,客服人工核验通过", }, AuditMeta{IP: "127.0.0.1", UserAgent: "test", RequestID: "req-1"}) if err != nil { t.Fatalf("ManualRealname() error = %v", err) } if got.RealnameStatus != "verified" { t.Fatalf("用户实名状态 = %s, want verified", got.RealnameStatus) } var savedUser model.User if err := db.First(&savedUser, user.ID).Error; err != nil { t.Fatalf("查询用户失败:%v", err) } if savedUser.RealnameStatus != "verified" { t.Fatalf("数据库用户实名状态 = %s, want verified", savedUser.RealnameStatus) } var record model.UserRealname if err := db.Where("user_id = ?", user.ID).First(&record).Error; err != nil { t.Fatalf("查询实名记录失败:%v", err) } if record.Provider != "admin_manual" || record.Status != "verified" { t.Fatalf("实名记录不正确:provider=%s status=%s", record.Provider, record.Status) } if record.MaskedName != "周**" || record.MaskedIDNo != "1306**********0615" { t.Fatalf("脱敏信息不正确:name=%s id=%s", record.MaskedName, record.MaskedIDNo) } if record.EncryptedName != "周奕男" || record.EncryptedIDNo != "130682200604080615" { t.Fatalf("加密字段未写入:name=%s id=%s", record.EncryptedName, record.EncryptedIDNo) } if record.VerifiedAt == nil { t.Fatal("VerifiedAt 不能为空") } var audit model.AuditLog if err := db.Where("action = ?", "admin_user.manual_realname").First(&audit).Error; err != nil { t.Fatalf("查询审计日志失败:%v", err) } if audit.ActorID != 99 || audit.BizID == nil || *audit.BizID != user.ID { t.Fatalf("审计日志主体不正确:actor=%d biz=%v", audit.ActorID, audit.BizID) } if strings.Contains(string(audit.Detail), "130682200604080615") { t.Fatalf("审计日志不应包含完整身份证号:%s", string(audit.Detail)) } } func TestManualRealnameRejectsVerifiedUser(t *testing.T) { db := setupAdminUserTestDB(t) user := model.User{ Phone: "13900000002", Nickname: "已实名用户", RealnameStatus: "verified", Status: "active", } if err := db.Create(&user).Error; err != nil { t.Fatalf("创建用户失败:%v", err) } repo := NewRepository(db) _, err := repo.ManualRealname(t.Context(), 99, user.ID, ManualRealnameRequest{ Name: "周奕男", IDNo: "130682200604080615", }, AuditMeta{}) if !errors.Is(err, ErrRealnameAlreadyVerified) { t.Fatalf("ManualRealname() error = %v, want ErrRealnameAlreadyVerified", err) } } func TestManualRealnameRejectsIdentityUsedByAnotherUser(t *testing.T) { db := setupAdminUserTestDB(t) first := model.User{Phone: "13900000011", Nickname: "用户一", Status: "active"} second := model.User{Phone: "13900000012", Nickname: "用户二", Status: "active"} if err := db.Create(&first).Error; err != nil { t.Fatalf("创建用户一失败:%v", err) } if err := db.Create(&second).Error; err != nil { t.Fatalf("创建用户二失败:%v", err) } repo := NewRepository(db) repo.SetRealnameIDHashKey("test-realname-id-hash-key") req := ManualRealnameRequest{Name: "周奕男", IDNo: "130682200604080615"} if _, err := repo.ManualRealname(t.Context(), 99, first.ID, req, AuditMeta{}); err != nil { t.Fatalf("用户一人工实名失败:%v", err) } if _, err := repo.ManualRealname(t.Context(), 99, second.ID, req, AuditMeta{}); !errors.Is(err, ErrRealnameIDNoUsed) { t.Fatalf("同一身份证人工实名第二账号 error = %v, want ErrRealnameIDNoUsed", err) } } func TestAdjustWalletDebitAndAudit(t *testing.T) { db := setupAdminUserTestDB(t) user := model.User{ Phone: "13900000003", Nickname: "有余额用户", RealnameStatus: "verified", Status: "active", } if err := db.Create(&user).Error; err != nil { t.Fatalf("创建用户失败:%v", err) } if err := db.Create(&model.WalletAccount{ UserID: user.ID, AvailableBalanceCent: 15000, FrozenBalanceCent: 3000, Status: "active", }).Error; err != nil { t.Fatalf("创建钱包失败:%v", err) } repo := NewRepository(db) got, err := repo.AdjustWallet(t.Context(), 88, user.ID, WalletAdjustRequest{ Direction: "out", AmountCent: 5000, Reason: "线下已转账核销", ReferenceNo: "OFFLINE-001", }, AuditMeta{IP: "127.0.0.1", UserAgent: "test", RequestID: "req-wallet-1"}) if err != nil { t.Fatalf("AdjustWallet() error = %v", err) } if got.AvailableBalanceCent != 10000 { t.Fatalf("可用余额 = %d, want 10000", got.AvailableBalanceCent) } if got.FrozenBalanceCent != 3000 { t.Fatalf("冻结余额不应变化,got %d", got.FrozenBalanceCent) } var ledger model.WalletLedger if err := db.Where("user_id = ? AND biz_type = ?", user.ID, "admin_debit").First(&ledger).Error; err != nil { t.Fatalf("查询资金流水失败:%v", err) } if ledger.Direction != "out" || ledger.AmountCent != 5000 || ledger.BalanceType != "available" { t.Fatalf("流水不正确:%+v", ledger) } var audit model.AuditLog if err := db.Where("action = ?", "admin_user.wallet_adjust").First(&audit).Error; err != nil { t.Fatalf("查询审计日志失败:%v", err) } if audit.ActorID != 88 || audit.BizID == nil || *audit.BizID != user.ID { t.Fatalf("审计主体不正确:actor=%d biz=%v", audit.ActorID, audit.BizID) } detail := string(audit.Detail) if !strings.Contains(detail, "线下已转账核销") || !strings.Contains(detail, "OFFLINE-001") { t.Fatalf("审计详情缺少原因或外部单号:%s", detail) } } func TestAdjustWalletInsufficientBalance(t *testing.T) { db := setupAdminUserTestDB(t) user := model.User{ Phone: "13900000004", Status: "active", } if err := db.Create(&user).Error; err != nil { t.Fatalf("创建用户失败:%v", err) } if err := db.Create(&model.WalletAccount{ UserID: user.ID, AvailableBalanceCent: 100, Status: "active", }).Error; err != nil { t.Fatalf("创建钱包失败:%v", err) } repo := NewRepository(db) _, err := repo.AdjustWallet(t.Context(), 1, user.ID, WalletAdjustRequest{ Direction: "out", AmountCent: 200, Reason: "超额扣款", }, AuditMeta{}) if !errors.Is(err, ErrInsufficientBalance) { t.Fatalf("AdjustWallet() error = %v, want ErrInsufficientBalance", err) } } func TestFreezeAndUnfreezeBumpUserTokenVersion(t *testing.T) { db := setupAdminUserTestDB(t) // 显式给出版本起点,避免 GORM 对零值字段应用 default:1 造成歧义。 user := model.User{Phone: "13900000006", Status: "active", TokenVersion: 1} if err := db.Create(&user).Error; err != nil { t.Fatalf("创建用户失败:%v", err) } repo := NewRepository(db) got, err := repo.Freeze(t.Context(), 66, user.ID, FreezeRequest{Reason: "风险处置"}, AuditMeta{}) if err != nil { t.Fatalf("冻结用户失败:%v", err) } var saved model.User if err := db.First(&saved, user.ID).Error; err != nil { t.Fatalf("查询冻结用户失败:%v", err) } if got.Status != "frozen" || saved.TokenVersion != 2 { t.Fatalf("冻结后状态/版本 = %s/%d, want frozen/2", got.Status, saved.TokenVersion) } got, err = repo.Unfreeze(t.Context(), 66, user.ID, AuditMeta{}) if err != nil { t.Fatalf("解冻用户失败:%v", err) } if err := db.First(&saved, user.ID).Error; err != nil { t.Fatalf("查询解冻用户失败:%v", err) } if got.Status != "active" || saved.TokenVersion != 3 { t.Fatalf("解冻后状态/版本 = %s/%d, want active/3", got.Status, saved.TokenVersion) } } func setupAdminUserTestDB(t *testing.T) *gorm.DB { t.Helper() db := database.NewTestDB() if err := database.AutoMigrateIfMissing(db, &model.User{}, &model.UserRealname{}, &model.AuditLog{}, &model.RentalOrder{}, &model.RentalListing{}, &model.Dispute{}, &model.WalletAccount{}, &model.WalletLedger{}, &model.SystemConfig{}, &model.RenterGrowthLedger{}, ); err != nil { t.Fatalf("数据库迁移失败:%v", err) } return db }