方案一(业务逻辑正确性测试): - 修复 SocialAccount.CreatedAt/UpdatedAt NULL 扫描问题(改为 *time.Time 指针) - 修复 OPLOG_003~006 数据隔离(改用唯一前缀+Search方法隔离) - 修复 DEV_008 设备列表测试(改用UserID过滤器隔离) - 修复并发测试 cache=private → cache=shared(SQLite连接共享) - 新增 testEnv 隔离架构(独立DB + 独立 httptest.Server) 方案二(真实数据规模测试): - 新增 LatencyStats P99/P95 百分位统计采集器 - 全部 16 个测试迁移至 newIsolatedDB(独立内存DB,WAL模式) - 关键查询添加 P99 多次采样统计(UL/LL/DV/DS/PR/AUTH/OPLOG) - 新增 CONC_SCALE_001~003 并发压测(50-100 goroutine) - 删除旧 setupScaleTestDB 死代码 - 双阈值体系:SQLite本地宽松阈值 vs PostgreSQL生产严格目标 共计 84 测试通过(68 业务逻辑 + 16 规模测试)
1788 lines
56 KiB
Go
1788 lines
56 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sort"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/user-management-system/internal/domain"
|
||
"github.com/user-management-system/internal/pagination"
|
||
"github.com/user-management-system/internal/repository"
|
||
gormsqlite "gorm.io/driver/sqlite"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/logger"
|
||
)
|
||
|
||
// =============================================================================
|
||
// Real Data Scale Tests (方案二:真实数据规模测试) — 优化版
|
||
//
|
||
// 改进点(v2 → v3):
|
||
// 1. 每个测试完全隔离:独立内存数据库(cache=shared),无交叉污染
|
||
// 2. WAL 模式 + busy_timeout:优化并发读写性能
|
||
// 3. P99/P95 延迟统计:关键查询多次采样,输出百分位指标
|
||
// 4. 新增并发压测:模拟真实多用户并发场景(CONC_SCALE_001~003)
|
||
// 5. 双阈值体系:SQLite 本地阈值(宽松)+ PostgreSQL 生产目标阈值(严格)
|
||
//
|
||
// 行业最佳实践数据量参考:
|
||
// - 用户规模:中型企业 10万~100万用户,这里取 10万(留 100个分页)
|
||
// - 登录日志:1000并发用户,每用户每天 20次登录(含重试/多设备/多会话),
|
||
// 保留 90天 = 1000×20×90 = 1,800,000 条(留 100个分页)
|
||
// 保留 180天 = 3,600,000 条(留 200个分页)
|
||
// - 设备日志:每用户平均 3台设备,10万用户 = 30万台设备
|
||
// - 操作日志:中等规模系统每天约 5,000~20,000 条操作记录
|
||
//
|
||
// SLA 阈值体系:
|
||
// ┌────────────────────┬──────────────────┬──────────────────┐
|
||
// │ 操作 │ SQLite 本地阈值 │ PG 生产目标阈值 │
|
||
// ├────────────────────┼──────────────────┼──────────────────┤
|
||
// │ 分页查询(20条) │ P99 < 500ms │ P99 < 50ms │
|
||
// │ Count 聚合 │ P99 < 200ms │ P99 < 10ms │
|
||
// │ 时间范围查询(100) │ P99 < 2s │ P99 < 200ms │
|
||
// │ 批量插入(1000条) │ 总计 < 5s │ 总计 < 1s │
|
||
// │ 索引条件查询 │ P99 < 100ms │ P99 < 5ms │
|
||
// └────────────────────┴──────────────────┴──────────────────┘
|
||
// =============================================================================
|
||
|
||
// =============================================================================
|
||
// ⚡ 延迟统计采集器 — P50/P95/P99 百分位统计
|
||
// =============================================================================
|
||
|
||
// LatencyStats 延迟统计结果
|
||
type LatencyStats struct {
|
||
Samples int // 采样次数
|
||
Min time.Duration // 最小值
|
||
Max time.Duration // 最大值
|
||
Mean time.Duration // 平均值
|
||
P50 time.Duration // 中位数
|
||
P95 time.Duration // 95th 百分位
|
||
P99 time.Duration // 99th 百分位
|
||
rawDurations []time.Duration // 原始数据(内部使用)
|
||
}
|
||
|
||
// NewLatencyCollector 创建延迟采集器
|
||
func NewLatencyCollector() *LatencyStats {
|
||
return &LatencyStats{rawDurations: make([]time.Duration, 0, 100)}
|
||
}
|
||
|
||
// Record 记录一次采样
|
||
func (s *LatencyStats) Record(d time.Duration) {
|
||
s.rawDurations = append(s.rawDurations, d)
|
||
}
|
||
|
||
// Compute 计算百分位统计(调用前必须先 Record 足够样本)
|
||
func (s *LatencyStats) Compute() LatencyStats {
|
||
if len(s.rawDurations) == 0 {
|
||
return *s
|
||
}
|
||
durations := make([]time.Duration, len(s.rawDurations))
|
||
copy(durations, s.rawDurations)
|
||
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
|
||
|
||
n := len(durations)
|
||
var sum time.Duration
|
||
for _, d := range durations {
|
||
sum += d
|
||
}
|
||
|
||
result := LatencyStats{
|
||
Samples: n,
|
||
Min: durations[0],
|
||
Max: durations[n-1],
|
||
Mean: sum / time.Duration(n),
|
||
P50: durations[n*50/100],
|
||
P95: durations[n*95/100],
|
||
P99: durations[n*99/100],
|
||
}
|
||
return result
|
||
}
|
||
|
||
// String 返回格式化的统计报告
|
||
func (s LatencyStats) String() string {
|
||
return fmt.Sprintf("n=%d min=%v max=%v mean=%v p50=%v p95=%v p99=%v",
|
||
s.Samples, s.Min, s.Max, s.Mean, s.P50, s.P95, s.P99)
|
||
}
|
||
|
||
// AssertSLA 断言是否满足 SLA 阈值,不满足时输出 t.Error
|
||
func (s LatencyStats) AssertSLA(t *testing.T, sla time.Duration, label string) {
|
||
t.Helper()
|
||
if s.P99 > sla {
|
||
t.Errorf("SLA BREACH [%s]: P99=%v exceeds threshold %v | %s", label, s.P99, sla, s.String())
|
||
} else {
|
||
t.Logf("✅ PASS [%s]: P99=%v ≤ threshold %v | %s", label, s.P99, sla, s.String())
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// ⚡ 隔离测试数据库(与 business_logic_test.go 共用模式)
|
||
// =============================================================================
|
||
|
||
// newIsolatedDB 为每个规模测试创建独立的内存数据库
|
||
// 使用 cache=private 确保跨测试零数据污染
|
||
func newIsolatedDB(t *testing.T) *gorm.DB {
|
||
t.Helper()
|
||
dsn := fmt.Sprintf("file:scale_%s_%d?mode=memory&cache=shared",
|
||
sanitizeScaleName(t.Name()), time.Now().UnixNano())
|
||
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
||
DriverName: "sqlite",
|
||
DSN: dsn,
|
||
}), &gorm.Config{
|
||
Logger: logger.Default.LogMode(logger.Silent),
|
||
})
|
||
if err != nil {
|
||
t.Skipf("skipping test (SQLite unavailable): %v", err)
|
||
return nil
|
||
}
|
||
// WAL 模式提升并发写入性能
|
||
db.Exec("PRAGMA journal_mode=WAL")
|
||
db.Exec("PRAGMA synchronous=NORMAL")
|
||
db.Exec("PRAGMA busy_timeout=5000")
|
||
|
||
if err := db.AutoMigrate(
|
||
&domain.User{},
|
||
&domain.Role{},
|
||
&domain.Permission{},
|
||
&domain.UserRole{},
|
||
&domain.RolePermission{},
|
||
&domain.Device{},
|
||
&domain.LoginLog{},
|
||
&domain.OperationLog{},
|
||
); err != nil {
|
||
t.Fatalf("db migration failed: %v", err)
|
||
}
|
||
|
||
t.Cleanup(func() {
|
||
if sqlDB, err := db.DB(); err == nil {
|
||
sqlDB.Close()
|
||
}
|
||
})
|
||
return db
|
||
}
|
||
|
||
// sanitizeScaleName 将测试名转为合法标识符
|
||
func sanitizeScaleName(name string) string {
|
||
result := make([]byte, 0, len(name))
|
||
for i := 0; i < len(name) && i < 30; i++ {
|
||
c := name[i]
|
||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
|
||
result = append(result, c)
|
||
} else {
|
||
result = append(result, '_')
|
||
}
|
||
}
|
||
return string(result)
|
||
}
|
||
|
||
// TestScale_UL_001_100KUsersPagination tests user list pagination at 100K scale
|
||
// 行业参考:中型企业 10万~100万用户,这里取 10万
|
||
func TestScale_UL_001_100KUsersPagination(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
// 行业标准:中型企业 10万用户
|
||
const userCount = 100000
|
||
t.Logf("Generating %d users for pagination test (industry: 100K-1M for mid-size enterprise)...", userCount)
|
||
|
||
userRepo := repository.NewUserRepository(db)
|
||
users := generateTestUsers(userCount)
|
||
|
||
start := time.Now()
|
||
// 分批插入,每批 50 条(SQLite MAX_VARIABLE_NUMBER 限制)
|
||
if err := db.CreateInBatches(users, 50).Error; err != nil {
|
||
t.Fatalf("failed to create users: %v", err)
|
||
}
|
||
t.Logf("Created %d users in %v", userCount, time.Since(start))
|
||
|
||
// 测试分页性能 — P99 < 500ms (SQLite) / P99 < 50ms (PG生产目标)
|
||
ctx := context.Background()
|
||
testCases := []struct {
|
||
page int
|
||
pageSize int
|
||
sla time.Duration
|
||
label string
|
||
}{
|
||
{1, 20, 500 * time.Millisecond, "首页"},
|
||
{100, 20, 500 * time.Millisecond, "早期分页"},
|
||
{1000, 20, 500 * time.Millisecond, "中部分页(offset=20000)"},
|
||
{5000, 20, 500 * time.Millisecond, "深层分页(offset=100000)"},
|
||
}
|
||
|
||
pagStats := NewLatencyCollector()
|
||
for round := 0; round < 5; round++ {
|
||
for _, tc := range testCases {
|
||
start := time.Now()
|
||
offset := (tc.page - 1) * tc.pageSize
|
||
result, total, err := userRepo.List(ctx, offset, tc.pageSize)
|
||
elapsed := time.Since(start)
|
||
pagStats.Record(elapsed)
|
||
|
||
if err != nil {
|
||
t.Errorf("List page %d failed: %v", tc.page, err)
|
||
continue
|
||
}
|
||
if total != int64(userCount) {
|
||
t.Errorf("expected total %d, got %d", userCount, total)
|
||
}
|
||
if len(result) != tc.pageSize && int(total) >= tc.page*tc.pageSize {
|
||
t.Errorf("page %d: expected %d results, got %d", tc.page, tc.pageSize, len(result))
|
||
}
|
||
}
|
||
}
|
||
stats := pagStats.Compute()
|
||
t.Logf("Pagination P99 stats: %s", stats.String())
|
||
stats.AssertSLA(t, 500*time.Millisecond, "UL_001_Pagination_P99(SQLite)")
|
||
}
|
||
|
||
// TestScale_UL_002_KeywordSearch tests keyword search at scale
|
||
// 行业标准:Like 查询在 10万用户下应 < 2s(需索引支持)
|
||
func TestScale_UL_002_KeywordSearch(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
const userCount = 100000
|
||
userRepo := repository.NewUserRepository(db)
|
||
|
||
// 创建用户(使用带连字符的格式避免 LIKE 转义问题)
|
||
users := make([]*domain.User, userCount)
|
||
for i := 0; i < userCount; i++ {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("searchuser-%08d", i),
|
||
Email: ptrString(fmt.Sprintf("searchuser-%08d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
}
|
||
|
||
start := time.Now()
|
||
if err := db.CreateInBatches(users, 50).Error; err != nil {
|
||
t.Fatalf("failed to create users: %v", err)
|
||
}
|
||
t.Logf("Created %d users in %v", userCount, time.Since(start))
|
||
|
||
// 测试搜索性能 — P99 < 2s (SQLite) / P99 < 200ms (PG生产目标)
|
||
ctx := context.Background()
|
||
testCases := []struct {
|
||
keyword string
|
||
expected int // 期望精确命中数量
|
||
sla time.Duration
|
||
label string
|
||
}{
|
||
{"searchuser-00002500", 1, 2 * time.Second, "精确搜索"},
|
||
{"searchuser-0000", 100, 2 * time.Second, "前缀搜索"},
|
||
{"notexist-99999999", 0, 2 * time.Second, "无结果搜索"},
|
||
}
|
||
|
||
searchStats := NewLatencyCollector()
|
||
for round := 0; round < 5; round++ {
|
||
for _, tc := range testCases {
|
||
start := time.Now()
|
||
results, _, err := userRepo.Search(ctx, tc.keyword, 0, 100)
|
||
elapsed := time.Since(start)
|
||
searchStats.Record(elapsed)
|
||
|
||
if err != nil {
|
||
t.Errorf("Search '%s' failed: %v", tc.keyword, err)
|
||
continue
|
||
}
|
||
if tc.expected > 0 && len(results) == 0 {
|
||
t.Errorf("Search '%s': expected results but got none", tc.keyword)
|
||
}
|
||
if tc.expected == 0 && len(results) > 0 {
|
||
t.Errorf("Search '%s': expected no results but got %d", tc.keyword, len(results))
|
||
}
|
||
}
|
||
}
|
||
stats := searchStats.Compute()
|
||
t.Logf("Keyword Search P99 stats: %s", stats.String())
|
||
stats.AssertSLA(t, 2*time.Second, "UL_002_KeywordSearch_P99(SQLite)")
|
||
}
|
||
|
||
// TestScale_LL_001_1YearLoginLogRetention tests login log at 180-day retention scale
|
||
// 行业标准:1000并发用户,每用户每天 20次登录(含重试/多设备/会话刷新),
|
||
// 保留 180天 = 1000×20×180 = 3,600,000 条
|
||
// 这里用 180天规模,因为 90天(1,800,000)仍然太小,测不出年度归档场景
|
||
func TestScale_LL_001_180DayLoginLogRetention(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
// 行业标准:1000用户×20次/天×180天 = 3,600,000 条
|
||
// 为保证测试速度,缩减到 500,000 条(约 25天量),仍比之前 50K 多 10 倍
|
||
const logCount = 500000
|
||
loginLogRepo := repository.NewLoginLogRepository(db)
|
||
|
||
// 创建用户引用
|
||
users := make([]*domain.User, 1000)
|
||
for i := range users {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("lluser-%d", i),
|
||
Email: ptrString(fmt.Sprintf("lluser-%d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
}
|
||
if err := db.CreateInBatches(users, 500).Error; err != nil {
|
||
t.Fatalf("failed to create users: %v", err)
|
||
}
|
||
|
||
t.Logf("Generating %d login logs (1000 users × ~500 logs × 180-day retention scenario)...", logCount)
|
||
start := time.Now()
|
||
|
||
// 按时间分布生成日志:模拟 90% 成功,10% 失败
|
||
batchSize := 10000
|
||
for i := 0; i < logCount; i += batchSize {
|
||
logs := make([]*domain.LoginLog, 0, batchSize)
|
||
for j := 0; j < batchSize && (i+j) < logCount; j++ {
|
||
idx := i + j
|
||
status := 1
|
||
if idx%10 == 0 {
|
||
status = 0 // 10% 失败率
|
||
}
|
||
userID := int64(idx % 1000)
|
||
logs = append(logs, &domain.LoginLog{
|
||
UserID: &users[userID].ID,
|
||
LoginType: int(domain.LoginTypePassword),
|
||
IP: fmt.Sprintf("192.168.%d.%d", idx%256, (idx+100)%256),
|
||
Location: "测试城市",
|
||
Status: status,
|
||
CreatedAt: time.Now().Add(-time.Duration(idx%180) * 24 * time.Hour), // 均匀分布在 180 天内
|
||
})
|
||
}
|
||
if err := db.CreateInBatches(logs, 2000).Error; err != nil {
|
||
t.Fatalf("failed to create logs: %v", err)
|
||
}
|
||
if i%50000 == 0 {
|
||
t.Logf(" progress: %d / %d logs created", i, logCount)
|
||
}
|
||
}
|
||
|
||
t.Logf("Created %d login logs in %v (%.2f logs/sec)",
|
||
logCount, time.Since(start), float64(logCount)/time.Since(start).Seconds())
|
||
|
||
// 测试分页查询 — P99 < 500ms (早期) / P99 < 2s (深分页)
|
||
ctx := context.Background()
|
||
testCases := []struct {
|
||
page int
|
||
pageSize int
|
||
sla time.Duration
|
||
label string
|
||
}{
|
||
{1, 50, 500 * time.Millisecond, "首页"},
|
||
{100, 50, 500 * time.Millisecond, "早期分页"},
|
||
{1000, 50, 2 * time.Second, "深分页(offset=49950)"},
|
||
{2000, 50, 3 * time.Second, "超深分页(offset=99950)"},
|
||
}
|
||
|
||
pageStats := NewLatencyCollector()
|
||
for round := 0; round < 5; round++ {
|
||
for _, tc := range testCases {
|
||
start := time.Now()
|
||
offset := (tc.page - 1) * tc.pageSize
|
||
_, total, err := loginLogRepo.List(ctx, offset, tc.pageSize)
|
||
elapsed := time.Since(start)
|
||
pageStats.Record(elapsed)
|
||
|
||
if err != nil {
|
||
t.Errorf("List page %d failed: %v", tc.page, err)
|
||
continue
|
||
}
|
||
if total != int64(logCount) {
|
||
t.Errorf("expected total %d, got %d", logCount, total)
|
||
}
|
||
}
|
||
}
|
||
stats := pageStats.Compute()
|
||
t.Logf("LoginLog Pagination P99 stats: %s", stats.String())
|
||
stats.AssertSLA(t, 2*time.Second, "LL_001_LoginLogPagination_P99(SQLite)")
|
||
}
|
||
|
||
// TestScale_LL_001C_CursorPagination benchmarks cursor-based (keyset) pagination
|
||
// against the traditional offset-based approach for deep pagination.
|
||
// Key expectation: Cursor P99 should be < 50ms even at page 10000, while offset
|
||
// at the same depth takes > 1s due to O(offset) scanning.
|
||
func TestScale_LL_001C_CursorPagination(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
const logCount = 100000
|
||
loginLogRepo := repository.NewLoginLogRepository(db)
|
||
|
||
// Create users
|
||
users := make([]*domain.User, 1000)
|
||
for i := range users {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("llcuser-%d", i),
|
||
Email: ptrString(fmt.Sprintf("llcuser-%d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
}
|
||
if err := db.CreateInBatches(users, 500).Error; err != nil {
|
||
t.Fatalf("failed to create users: %v", err)
|
||
}
|
||
|
||
// Generate login logs with time distribution
|
||
t.Logf("Generating %d logs for cursor benchmark...", logCount)
|
||
batchSize := 10000
|
||
for i := 0; i < logCount; i += batchSize {
|
||
logs := make([]*domain.LoginLog, 0, batchSize)
|
||
for j := 0; j < batchSize && (i+j) < logCount; j++ {
|
||
idx := i + j
|
||
logs = append(logs, &domain.LoginLog{
|
||
UserID: ptrInt64(int64(idx % 1000)),
|
||
LoginType: 1,
|
||
IP: "127.0.0.1",
|
||
Status: 1,
|
||
CreatedAt: time.Now().Add(-time.Duration(idx) * time.Second),
|
||
})
|
||
}
|
||
if err := db.CreateInBatches(logs, 50).Error; err != nil {
|
||
t.Fatalf("batch insert failed at %d: %v", i, err)
|
||
}
|
||
}
|
||
t.Logf("Created %d logs", logCount)
|
||
|
||
ctx := context.Background()
|
||
limit := 50
|
||
|
||
// Phase 1: Walk through ALL pages using cursor pagination and measure each fetch
|
||
cursorStats := NewLatencyCollector()
|
||
var currentCursor *pagination.Cursor
|
||
pageCount := 0
|
||
totalFetched := 0
|
||
|
||
for {
|
||
start := time.Now()
|
||
logs, hasMore, err := loginLogRepo.ListCursor(ctx, limit, currentCursor)
|
||
elapsed := time.Since(start)
|
||
cursorStats.Record(elapsed)
|
||
|
||
if err != nil {
|
||
t.Fatalf("ListCursor failed at page %d: %v", pageCount+1, err)
|
||
}
|
||
totalFetched += len(logs)
|
||
pageCount++
|
||
|
||
if !hasMore || len(logs) == 0 {
|
||
break
|
||
}
|
||
|
||
// Build next cursor from last item
|
||
last := logs[len(logs)-1]
|
||
currentCursor = &pagination.Cursor{LastID: last.ID, LastValue: last.CreatedAt}
|
||
}
|
||
|
||
cursorStatsComputed := cursorStats.Compute()
|
||
t.Logf("=== CURSOR PAGINATION RESULTS ===")
|
||
t.Logf("Total pages fetched: %d, total items: %d", pageCount, totalFetched)
|
||
t.Logf("Cursor P99 stats: %s", cursorStatsComputed.String())
|
||
|
||
// The key SLA: cursor P99 should be dramatically better than offset-based
|
||
// Target: P99 < 50ms (vs offset's ~1740ms at deep pages)
|
||
cursorStatsComputed.AssertSLA(t, 100*time.Millisecond, "LL_001C_CursorPagination_P99")
|
||
|
||
// Phase 2: Compare with a few offset-based queries at equivalent depth
|
||
offsetStats := NewLatencyCollector()
|
||
offsetTestPages := []int{1, 100, 1000, 2000} // Equivalent to different depths
|
||
for _, pg := range offsetTestPages {
|
||
start := time.Now()
|
||
_, _, err := loginLogRepo.List(ctx, (pg-1)*limit, limit)
|
||
elapsed := time.Since(start)
|
||
offsetStats.Record(elapsed)
|
||
if err != nil {
|
||
t.Errorf("Offset List at page %d failed: %v", pg, err)
|
||
}
|
||
}
|
||
offsetStatsComputed := offsetStats.Compute()
|
||
t.Logf("Offset P99 stats (sampled): %s", offsetStatsComputed.String())
|
||
t.Logf("CURSOR vs OFFSET P99 ratio: %.1fx faster (offset %.2fms vs cursor %.2fms)",
|
||
float64(offsetStatsComputed.P99)/float64(cursorStatsComputed.P99),
|
||
float64(offsetStatsComputed.P99), float64(cursorStatsComputed.P99))
|
||
}
|
||
|
||
// TestScale_OPLOG_001C_OperationLogCursorPagination benchmarks cursor pagination for operation logs
|
||
func TestScale_OPLOG_001C_OperationLogCursorPagination(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
const logCount = 100000
|
||
opLogRepo := repository.NewOperationLogRepository(db)
|
||
|
||
users := make([]*domain.User, 100)
|
||
for i := range users {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("olcuser-%d", i),
|
||
Email: ptrString(fmt.Sprintf("olcuser-%d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
}
|
||
db.Create(&users)
|
||
|
||
batchSize := 5000
|
||
for i := 0; i < logCount; i += batchSize {
|
||
logs := make([]*domain.OperationLog, 0, batchSize)
|
||
for j := 0; j < batchSize && (i+j) < logCount; j++ {
|
||
idx := i + j
|
||
uid := int64(idx % 100)
|
||
logs = append(logs, &domain.OperationLog{
|
||
OperationType: "read",
|
||
OperationName: fmt.Sprintf("op_%d", idx),
|
||
RequestMethod: "GET",
|
||
RequestPath: fmt.Sprintf("/api/resource/%d", idx%1000),
|
||
ResponseStatus: 200,
|
||
IP: "10.0.0." + string(rune('1'+idx%9)),
|
||
UserAgent: "test-agent",
|
||
UserID: &uid,
|
||
CreatedAt: time.Now().Add(-time.Duration(idx) * time.Second),
|
||
})
|
||
}
|
||
db.CreateInBatches(logs, 2000)
|
||
}
|
||
|
||
ctx := context.Background()
|
||
const limit = 50
|
||
|
||
cursorStats := NewLatencyCollector()
|
||
var currentCursor *pagination.Cursor
|
||
pageCount := 0
|
||
|
||
for {
|
||
start := time.Now()
|
||
logs, hasMore, err := opLogRepo.ListCursor(ctx, limit, currentCursor)
|
||
cursorStats.Record(time.Since(start))
|
||
if err != nil {
|
||
t.Fatalf("OpLog ListCursor failed: %v", err)
|
||
}
|
||
pageCount++
|
||
if !hasMore || len(logs) == 0 {
|
||
break
|
||
}
|
||
last := logs[len(logs)-1]
|
||
currentCursor = &pagination.Cursor{LastID: last.ID, LastValue: last.CreatedAt}
|
||
}
|
||
|
||
stats := cursorStats.Compute()
|
||
t.Logf("=== OPLOG CURSOR PAGINATION ===")
|
||
t.Logf("Pages: %d, Stats: %s", pageCount, stats.String())
|
||
stats.AssertSLA(t, 100*time.Millisecond, "OPLOG_001C_CursorPagination_P99")
|
||
}
|
||
|
||
// TestScale_LL_002_LoginLogTimeRangeQuery tests login log time range query performance
|
||
// 行业标准:查询最近 7/30/90 天的日志应在 < 3s 内返回(需分区或索引优化)
|
||
func TestScale_LL_002_LoginLogTimeRangeQuery(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
// 50K 条日志,足够测试时间范围查询的索引使用
|
||
const logCount = 50000
|
||
loginLogRepo := repository.NewLoginLogRepository(db)
|
||
|
||
// 创建用户
|
||
users := make([]*domain.User, 100)
|
||
for i := range users {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("ll2user-%d", i),
|
||
Email: ptrString(fmt.Sprintf("ll2user-%d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
}
|
||
if err := db.CreateInBatches(users, 100).Error; err != nil {
|
||
t.Fatalf("failed to create users: %v", err)
|
||
}
|
||
|
||
// 生成日志:80% 在最近 7 天,20% 在 8-30 天前
|
||
t.Logf("Generating %d login logs with time distribution...", logCount)
|
||
batchSize := 10000
|
||
|
||
for i := 0; i < logCount; i += batchSize {
|
||
logs := make([]*domain.LoginLog, 0, batchSize)
|
||
for j := 0; j < batchSize && (i+j) < logCount; j++ {
|
||
idx := i + j
|
||
var createdAt time.Time
|
||
if idx%5 != 0 {
|
||
// 最近 7 天
|
||
daysOffset := float64(idx % 7)
|
||
hoursOffset := float64((idx * 13) % 24)
|
||
createdAt = time.Now().Add(-time.Duration(daysOffset*24)*time.Hour - time.Duration(hoursOffset)*time.Hour)
|
||
} else {
|
||
// 8-30 天前
|
||
createdAt = time.Now().Add(-time.Duration(8+idx%23) * 24 * time.Hour)
|
||
}
|
||
userID := int64(idx % 100)
|
||
logs = append(logs, &domain.LoginLog{
|
||
UserID: &users[userID].ID,
|
||
LoginType: int(domain.LoginTypePassword),
|
||
IP: fmt.Sprintf("10.0.%d.%d", idx%256, (j*7)%256),
|
||
Status: 1,
|
||
CreatedAt: createdAt,
|
||
})
|
||
}
|
||
if err := db.CreateInBatches(logs, 2000).Error; err != nil {
|
||
t.Fatalf("failed to create logs: %v", err)
|
||
}
|
||
}
|
||
|
||
// 测试时间范围查询 — P99 < 3s (SQLite) / P99 < 200ms (PG生产目标)
|
||
ctx := context.Background()
|
||
testCases := []struct {
|
||
days int
|
||
label string
|
||
}{
|
||
{7, "last 7 days"},
|
||
{30, "last 30 days"},
|
||
{90, "last 90 days"},
|
||
}
|
||
|
||
rangeStats := NewLatencyCollector()
|
||
for round := 0; round < 5; round++ {
|
||
for _, tc := range testCases {
|
||
startTime := time.Now().Add(-time.Duration(tc.days) * 24 * time.Hour)
|
||
endTime := time.Now()
|
||
|
||
start := time.Now()
|
||
results, total, err := loginLogRepo.ListByTimeRange(ctx, startTime, endTime, 0, 100)
|
||
elapsed := time.Since(start)
|
||
rangeStats.Record(elapsed)
|
||
|
||
if err != nil {
|
||
t.Errorf("ListByTimeRange (%s) failed: %v", tc.label, err)
|
||
continue
|
||
}
|
||
_ = results // 避免未使用变量
|
||
_ = total
|
||
}
|
||
}
|
||
stats := rangeStats.Compute()
|
||
t.Logf("Time Range Query P99 stats: %s", stats.String())
|
||
stats.AssertSLA(t, 3*time.Second, "LL_002_TimeRange_P99(SQLite)")
|
||
}
|
||
|
||
// TestScale_LL_003_LoginLogRetentionCleanup tests cleanup of logs beyond retention period
|
||
// 行业标准:数据保留策略清理(90天/180天/1年),批量删除应在合理时间内完成
|
||
func TestScale_LL_003_LoginLogRetentionCleanup(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
// 创建 180 天前的日志(应被清理)和 7 天内的日志(应保留)
|
||
loginLogRepo := repository.NewLoginLogRepository(db)
|
||
|
||
users := make([]*domain.User, 10)
|
||
for i := range users {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("cleanup-user-%d", i),
|
||
Email: ptrString(fmt.Sprintf("cleanup-user-%d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
}
|
||
if err := db.CreateInBatches(users, 10).Error; err != nil {
|
||
t.Fatalf("failed to create users: %v", err)
|
||
}
|
||
|
||
// 写入 100 条旧日志(200天前)和 50 条新日志(7天前)
|
||
oldLogs := make([]*domain.LoginLog, 100)
|
||
for i := range oldLogs {
|
||
oldLogs[i] = &domain.LoginLog{
|
||
UserID: &users[i%10].ID,
|
||
LoginType: int(domain.LoginTypePassword),
|
||
IP: fmt.Sprintf("192.168.0.%d", i),
|
||
Status: 1,
|
||
CreatedAt: time.Now().Add(-200 * 24 * time.Hour), // 200 天前(超过 180 天保留期)
|
||
}
|
||
}
|
||
if err := db.CreateInBatches(oldLogs, 100).Error; err != nil {
|
||
t.Fatalf("failed to create old logs: %v", err)
|
||
}
|
||
|
||
newLogs := make([]*domain.LoginLog, 50)
|
||
for i := range newLogs {
|
||
newLogs[i] = &domain.LoginLog{
|
||
UserID: &users[i%10].ID,
|
||
LoginType: int(domain.LoginTypePassword),
|
||
IP: fmt.Sprintf("192.168.1.%d", i),
|
||
Status: 1,
|
||
CreatedAt: time.Now().Add(-7 * 24 * time.Hour), // 7 天前(保留期内)
|
||
}
|
||
}
|
||
if err := db.CreateInBatches(newLogs, 50).Error; err != nil {
|
||
t.Fatalf("failed to create new logs: %v", err)
|
||
}
|
||
|
||
totalBefore, _, _ := loginLogRepo.List(context.Background(), 0, 1000)
|
||
t.Logf("Before cleanup: %d total logs", len(totalBefore))
|
||
|
||
// 执行清理(保留 180 天)
|
||
start := time.Now()
|
||
retentionDays := 180
|
||
err := loginLogRepo.DeleteOlderThan(context.Background(), retentionDays)
|
||
elapsed := time.Since(start)
|
||
|
||
if err != nil {
|
||
t.Fatalf("DeleteOlderThan failed: %v", err)
|
||
}
|
||
|
||
// 清理后旧日志(200天前)应被删除,新日志(7天前)应保留
|
||
// 旧日志 100 条全部删除,新日志 50 条保留
|
||
t.Logf("Cleaned up logs older than %d days in %v", retentionDays, elapsed)
|
||
|
||
// 验证新日志仍在
|
||
remaining, _, _ := loginLogRepo.List(context.Background(), 0, 1000)
|
||
if len(remaining) != 50 {
|
||
t.Errorf("expected 50 logs remaining after cleanup, got %d", len(remaining))
|
||
}
|
||
}
|
||
|
||
// TestScale_DV_001_300KDevicesPagination tests device list at 300K scale
|
||
// 行业标准:每用户平均 2-5 台设备,10万用户 = 20万~50万台设备
|
||
func TestScale_DV_001_300KDevicesPagination(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
// 行业标准:30万设备(10万用户 × 平均 3 台设备)
|
||
const deviceCount = 300000
|
||
deviceRepo := repository.NewDeviceRepository(db)
|
||
|
||
// 创建 10 万用户
|
||
t.Logf("Generating %d users...", 100000)
|
||
userBatch := 10000
|
||
for batch := 0; batch < 100000/userBatch; batch++ {
|
||
users := make([]*domain.User, userBatch)
|
||
for i := 0; i < userBatch; i++ {
|
||
idx := batch*userBatch + i
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("dvuser-%08d", idx),
|
||
Email: ptrString(fmt.Sprintf("dvuser-%08d@test.com", idx)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
}
|
||
if err := db.CreateInBatches(users, 1000).Error; err != nil {
|
||
t.Fatalf("failed to create users: %v", err)
|
||
}
|
||
}
|
||
|
||
// 生成 30 万设备(每用户 3 台)
|
||
t.Logf("Generating %d devices...", deviceCount)
|
||
start := time.Now()
|
||
deviceBatch := 30000
|
||
for batch := 0; batch < deviceCount/deviceBatch; batch++ {
|
||
devices := make([]*domain.Device, deviceBatch)
|
||
for i := 0; i < deviceBatch; i++ {
|
||
idx := batch*deviceBatch + i
|
||
devices[i] = &domain.Device{
|
||
UserID: int64(idx%100000) + 1,
|
||
DeviceID: fmt.Sprintf("device-%08d", idx),
|
||
DeviceName: fmt.Sprintf("Device %d", idx),
|
||
DeviceType: domain.DeviceTypeWeb,
|
||
IP: fmt.Sprintf("192.168.%d.%d", idx%256, (idx+50)%256),
|
||
Location: "Test Location",
|
||
Status: domain.DeviceStatusActive,
|
||
LastActiveTime: time.Now().Add(-time.Duration(idx%86400) * time.Second),
|
||
}
|
||
}
|
||
if err := db.CreateInBatches(devices, 50).Error; err != nil {
|
||
t.Fatalf("failed to create devices: %v", err)
|
||
}
|
||
if batch%2 == 0 {
|
||
t.Logf(" progress: %d / %d devices", batch*deviceBatch+deviceBatch, deviceCount)
|
||
}
|
||
}
|
||
t.Logf("Created %d devices in %v", deviceCount, time.Since(start))
|
||
|
||
// 测试分页 SLA: < 500ms
|
||
ctx := context.Background()
|
||
testCases := []struct {
|
||
page int
|
||
pageSize int
|
||
sla time.Duration
|
||
}{
|
||
{1, 20, 500 * time.Millisecond},
|
||
{100, 20, 500 * time.Millisecond},
|
||
{1000, 20, 500 * time.Millisecond},
|
||
{5000, 20, 500 * time.Millisecond},
|
||
}
|
||
|
||
for _, tc := range testCases {
|
||
start := time.Now()
|
||
offset := (tc.page - 1) * tc.pageSize
|
||
result, total, err := deviceRepo.List(ctx, offset, tc.pageSize)
|
||
elapsed := time.Since(start)
|
||
|
||
if err != nil {
|
||
t.Errorf("List page %d failed: %v", tc.page, err)
|
||
continue
|
||
}
|
||
if total != int64(deviceCount) {
|
||
t.Errorf("expected total %d, got %d", deviceCount, total)
|
||
}
|
||
|
||
t.Logf("Page %d (offset=%d): %d results in %v [SLA=%v]", tc.page, offset, len(result), elapsed, tc.sla)
|
||
if elapsed > tc.sla {
|
||
t.Errorf("SLA BREACH: Page %d took %v, exceeded %v", tc.page, elapsed, tc.sla)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestScale_DV_002_DeviceMultiConditionFilter tests device filtering with multiple conditions
|
||
func TestScale_DV_002_DeviceMultiConditionFilter(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
const deviceCount = 100000
|
||
deviceRepo := repository.NewDeviceRepository(db)
|
||
|
||
// 创建用户
|
||
users := make([]*domain.User, 1000)
|
||
for i := range users {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("dv002user-%d", i),
|
||
Email: ptrString(fmt.Sprintf("dv002user-%d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
}
|
||
if err := db.CreateInBatches(users, 500).Error; err != nil {
|
||
t.Fatalf("failed to create users: %v", err)
|
||
}
|
||
|
||
// 生成设备:混合状态和信任状态
|
||
devices := make([]*domain.Device, deviceCount)
|
||
for i := 0; i < deviceCount; i++ {
|
||
status := domain.DeviceStatusActive
|
||
if i%5 == 0 {
|
||
status = domain.DeviceStatusInactive
|
||
}
|
||
isTrusted := i%3 == 0
|
||
devices[i] = &domain.Device{
|
||
UserID: int64(i%1000) + 1,
|
||
DeviceID: fmt.Sprintf("dv002-device-%08d", i),
|
||
DeviceName: fmt.Sprintf("Device %d", i),
|
||
DeviceType: domain.DeviceTypeWeb,
|
||
Status: status,
|
||
IsTrusted: isTrusted,
|
||
LastActiveTime: time.Now().Add(-time.Duration(i%86400) * time.Second),
|
||
}
|
||
}
|
||
|
||
start := time.Now()
|
||
if err := db.CreateInBatches(devices, 50).Error; err != nil {
|
||
t.Fatalf("failed to create devices: %v", err)
|
||
}
|
||
t.Logf("Created %d devices in %v", deviceCount, time.Since(start))
|
||
|
||
// 测试多条件筛选 — P99 < 500ms (SQLite) / P99 < 50ms (PG生产目标)
|
||
ctx := context.Background()
|
||
trusted := true
|
||
params := &repository.ListDevicesParams{
|
||
Status: ptrDeviceStatus(domain.DeviceStatusActive),
|
||
IsTrusted: &trusted,
|
||
Offset: 0,
|
||
Limit: 50,
|
||
}
|
||
|
||
filterStats := NewLatencyCollector()
|
||
for round := 0; round < 5; round++ {
|
||
start := time.Now()
|
||
results, total, err := deviceRepo.ListAll(ctx, params)
|
||
elapsed := time.Since(start)
|
||
filterStats.Record(elapsed)
|
||
|
||
if err != nil {
|
||
t.Errorf("ListAll failed: %v", err)
|
||
continue
|
||
}
|
||
_ = results
|
||
_ = total
|
||
}
|
||
stats := filterStats.Compute()
|
||
t.Logf("Device Multi-Condition Filter P99 stats: %s", stats.String())
|
||
stats.AssertSLA(t, 500*time.Millisecond, "DV_002_DeviceFilter_P99(SQLite)")
|
||
}
|
||
|
||
// TestScale_DS_001_DashboardStatsAccuracy tests dashboard stats accuracy at 100K scale
|
||
func TestScale_DS_001_DashboardStatsAccuracy(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
const userCount = 100000
|
||
userRepo := repository.NewUserRepository(db)
|
||
loginLogRepo := repository.NewLoginLogRepository(db)
|
||
|
||
// 状态分布:80% 活跃,10% 未激活,5% 锁定,5% 禁用
|
||
// 生成用户
|
||
t.Logf("Generating %d users with status distribution...", userCount)
|
||
|
||
statusWeights := []struct {
|
||
status domain.UserStatus
|
||
ratio float64
|
||
}{
|
||
{domain.UserStatusActive, 0.80},
|
||
{domain.UserStatusInactive, 0.10},
|
||
{domain.UserStatusLocked, 0.05},
|
||
{domain.UserStatusDisabled, 0.05},
|
||
}
|
||
|
||
userBatch := 10000
|
||
for batch := 0; batch < userCount/userBatch; batch++ {
|
||
users := make([]*domain.User, userBatch)
|
||
for i := 0; i < userBatch; i++ {
|
||
idx := batch*userBatch + i
|
||
var status domain.UserStatus
|
||
// 按权重分配状态
|
||
r := float64(idx) / float64(userCount)
|
||
acc := 0.0
|
||
for _, sw := range statusWeights {
|
||
acc += sw.ratio
|
||
if r < acc {
|
||
status = sw.status
|
||
break
|
||
}
|
||
}
|
||
if status == 0 {
|
||
status = domain.UserStatusActive
|
||
}
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("statsuser-%08d", idx),
|
||
Email: ptrString(fmt.Sprintf("statsuser-%08d@test.com", idx)),
|
||
Password: "$2a$10$dummy",
|
||
Status: status,
|
||
}
|
||
}
|
||
if err := db.CreateInBatches(users, 50).Error; err != nil {
|
||
t.Fatalf("failed to create users: %v", err)
|
||
}
|
||
}
|
||
|
||
// 精确统计数据库中的各状态数量
|
||
var totalUsers, activeUsers, inactiveUsers, lockedUsers, disabledUsers int64
|
||
db.Model(&domain.User{}).Count(&totalUsers)
|
||
db.Model(&domain.User{}).Where("status = ?", domain.UserStatusActive).Count(&activeUsers)
|
||
db.Model(&domain.User{}).Where("status = ?", domain.UserStatusInactive).Count(&inactiveUsers)
|
||
db.Model(&domain.User{}).Where("status = ?", domain.UserStatusLocked).Count(&lockedUsers)
|
||
db.Model(&domain.User{}).Where("status = ?", domain.UserStatusDisabled).Count(&disabledUsers)
|
||
|
||
t.Logf("DB counts: total=%d, active=%d, inactive=%d, locked=%d, disabled=%d",
|
||
totalUsers, activeUsers, inactiveUsers, lockedUsers, disabledUsers)
|
||
|
||
// 测试服务层统计 — P99 < 5s (SQLite) / P99 < 200ms (PG生产目标)
|
||
statsSvc := NewStatsService(userRepo, loginLogRepo)
|
||
ctx := context.Background()
|
||
|
||
statsCollector := NewLatencyCollector()
|
||
for i := 0; i < 5; i++ {
|
||
start := time.Now()
|
||
stats, err := statsSvc.GetUserStats(ctx)
|
||
elapsed := time.Since(start)
|
||
statsCollector.Record(elapsed)
|
||
if err != nil {
|
||
t.Fatalf("GetUserStats failed: %v", err)
|
||
}
|
||
_ = stats
|
||
}
|
||
stats := statsCollector.Compute()
|
||
t.Logf("GetUserStats P99 stats: %s", stats.String())
|
||
statsCollector.AssertSLA(t, 5*time.Second, "DS_001_GetUserStats_P99(SQLite)")
|
||
|
||
// 验证精确性
|
||
finalStats, _ := statsSvc.GetUserStats(ctx)
|
||
if finalStats.TotalUsers != totalUsers {
|
||
t.Errorf("TotalUsers mismatch: expected %d, got %d", totalUsers, finalStats.TotalUsers)
|
||
}
|
||
if finalStats.ActiveUsers != activeUsers {
|
||
t.Errorf("ActiveUsers mismatch: expected %d, got %d", activeUsers, finalStats.ActiveUsers)
|
||
}
|
||
if finalStats.InactiveUsers != inactiveUsers {
|
||
t.Errorf("InactiveUsers mismatch: expected %d, got %d", inactiveUsers, finalStats.InactiveUsers)
|
||
}
|
||
}
|
||
|
||
// TestScale_BO_001_BatchUserCreation tests batch user creation performance
|
||
// 行业标准:批量创建 1000 用户应在 < 5s 内完成
|
||
func TestScale_BO_001_BatchUserCreation(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
const batchSize = 1000
|
||
userRepo := repository.NewUserRepository(db)
|
||
userRoleRepo := repository.NewUserRoleRepository(db)
|
||
roleRepo := repository.NewRoleRepository(db)
|
||
passwordHistoryRepo := repository.NewPasswordHistoryRepository(db)
|
||
userSvc := NewUserService(userRepo, userRoleRepo, roleRepo, passwordHistoryRepo)
|
||
|
||
users := make([]*domain.User, batchSize)
|
||
for i := 0; i < batchSize; i++ {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("batchuser-%08d", i),
|
||
Email: ptrString(fmt.Sprintf("batchuser-%08d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
}
|
||
|
||
ctx := context.Background()
|
||
start := time.Now()
|
||
|
||
for i := 0; i < batchSize; i++ {
|
||
if err := userSvc.Create(ctx, users[i]); err != nil {
|
||
t.Fatalf("Create user %d failed: %v", i, err)
|
||
}
|
||
}
|
||
|
||
elapsed := time.Since(start)
|
||
throughput := float64(batchSize) / elapsed.Seconds()
|
||
t.Logf("Created %d users in %v (%.2f users/sec) [SLA=5s]", batchSize, elapsed, throughput)
|
||
|
||
if elapsed > 5*time.Second {
|
||
t.Errorf("SLA BREACH: Batch creation took %v, exceeded 5s", elapsed)
|
||
}
|
||
|
||
// 验证数量
|
||
_, total, err := userSvc.List(ctx, 0, int(batchSize*2))
|
||
if err != nil {
|
||
t.Fatalf("List failed: %v", err)
|
||
}
|
||
if total < int64(batchSize) {
|
||
t.Errorf("expected at least %d users, got %d", batchSize, total)
|
||
}
|
||
}
|
||
|
||
// TestScale_BO_002_BatchStatusUpdate tests batch status update performance
|
||
func TestScale_BO_002_BatchStatusUpdate(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
const userCount = 1000
|
||
userRepo := repository.NewUserRepository(db)
|
||
userRoleRepo := repository.NewUserRoleRepository(db)
|
||
roleRepo := repository.NewRoleRepository(db)
|
||
passwordHistoryRepo := repository.NewPasswordHistoryRepository(db)
|
||
userSvc := NewUserService(userRepo, userRoleRepo, roleRepo, passwordHistoryRepo)
|
||
|
||
// 预先创建用户
|
||
users := make([]*domain.User, userCount)
|
||
for i := 0; i < userCount; i++ {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("boustatus-%08d", i),
|
||
Email: ptrString(fmt.Sprintf("boustatus-%08d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
}
|
||
ctx := context.Background()
|
||
for i := 0; i < userCount; i++ {
|
||
if err := userSvc.Create(ctx, users[i]); err != nil {
|
||
t.Fatalf("Create user %d failed: %v", i, err)
|
||
}
|
||
}
|
||
|
||
// 批量禁用所有用户
|
||
start := time.Now()
|
||
for i := 0; i < userCount; i++ {
|
||
if err := userSvc.UpdateStatus(ctx, users[i].ID, domain.UserStatusDisabled); err != nil {
|
||
t.Fatalf("UpdateStatus failed for user %d: %v", i, err)
|
||
}
|
||
}
|
||
elapsed := time.Since(start)
|
||
|
||
t.Logf("Batch disabled %d users in %v (%.2f updates/sec)",
|
||
userCount, elapsed, float64(userCount)/elapsed.Seconds())
|
||
|
||
// 验证全部禁用
|
||
for i := 0; i < userCount; i++ {
|
||
user, _ := userSvc.GetByID(ctx, users[i].ID)
|
||
if user.Status != domain.UserStatusDisabled {
|
||
t.Errorf("user %d expected disabled, got %d", i, user.Status)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestScale_PR_001_PermissionTreeLoad tests permission tree loading at scale
|
||
// 行业标准:500 权限节点应在 < 500ms 内加载完成
|
||
func TestScale_PR_001_PermissionTreeLoad(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
const permissionCount = 500
|
||
permRepo := repository.NewPermissionRepository(db)
|
||
|
||
t.Logf("Generating %d permissions in tree structure...", permissionCount)
|
||
|
||
permissions := generatePermissionTree(permissionCount)
|
||
|
||
start := time.Now()
|
||
if err := db.CreateInBatches(permissions, 100).Error; err != nil {
|
||
t.Fatalf("failed to create permissions: %v", err)
|
||
}
|
||
t.Logf("Created %d permissions in %v", permissionCount, time.Since(start))
|
||
|
||
// 测试加载性能 — P99 < 500ms (SQLite) / P99 < 50ms (PG生产目标)
|
||
ctx := context.Background()
|
||
loadStats := NewLatencyCollector()
|
||
for i := 0; i < 5; i++ {
|
||
start = time.Now()
|
||
allPerms, _, err := permRepo.List(ctx, 0, 1000)
|
||
elapsed := time.Since(start)
|
||
loadStats.Record(elapsed)
|
||
if err != nil {
|
||
t.Fatalf("List permissions failed: %v", err)
|
||
}
|
||
if len(allPerms) < permissionCount {
|
||
t.Errorf("expected %d permissions, got %d", permissionCount, len(allPerms))
|
||
}
|
||
}
|
||
stats := loadStats.Compute()
|
||
t.Logf("Permission tree load P99 stats: %s", stats.String())
|
||
stats.AssertSLA(t, 500*time.Millisecond, "PR_001_PermissionTreeLoad_P99(SQLite)")
|
||
}
|
||
|
||
// TestScale_PR_002_PermissionTreeExplosion tests 1000+ permission nodes
|
||
func TestScale_PR_002_PermissionTreeExplosion(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
const permissionCount = 1000
|
||
permRepo := repository.NewPermissionRepository(db)
|
||
|
||
t.Logf("Generating %d permissions (permission tree explosion test)...", permissionCount)
|
||
|
||
permissions := generateDeepPermissionTree(permissionCount)
|
||
|
||
start := time.Now()
|
||
if err := db.CreateInBatches(permissions, 200).Error; err != nil {
|
||
t.Fatalf("failed to create permissions: %v", err)
|
||
}
|
||
t.Logf("Created %d permissions in %v", len(permissions), time.Since(start))
|
||
|
||
// 测试 SLA: P99 < 1s(1000 节点加载)
|
||
ctx := context.Background()
|
||
loadStats := NewLatencyCollector()
|
||
for i := 0; i < 5; i++ {
|
||
start = time.Now()
|
||
allPerms, _, err := permRepo.List(ctx, 0, 2000)
|
||
elapsed := time.Since(start)
|
||
loadStats.Record(elapsed)
|
||
if err != nil {
|
||
t.Fatalf("List permissions failed: %v", err)
|
||
}
|
||
if len(allPerms) < permissionCount {
|
||
t.Errorf("expected %d permissions, got %d", permissionCount, len(allPerms))
|
||
}
|
||
}
|
||
stats := loadStats.Compute()
|
||
t.Logf("Permission explosion load P99 stats: %s", stats.String())
|
||
stats.AssertSLA(t, 1*time.Second, "PR_002_PermissionTreeExplosion_P99(SQLite)")
|
||
}
|
||
|
||
// TestScale_AUTH_001_LoginFailureLogScale tests recording massive login failures
|
||
// 行业标准:模拟暴力破解场景,1000用户 × 10次失败 = 10000 条失败日志
|
||
func TestScale_AUTH_001_LoginFailureLogScale(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
const userCount = 1000
|
||
const failuresPerUser = 10
|
||
const totalFailures = userCount * failuresPerUser
|
||
|
||
loginLogRepo := repository.NewLoginLogRepository(db)
|
||
|
||
// 创建用户
|
||
users := make([]*domain.User, userCount)
|
||
for i := 0; i < userCount; i++ {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("authtest-%d", i),
|
||
Email: ptrString(fmt.Sprintf("authtest-%d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
if err := db.Create(users[i]).Error; err != nil {
|
||
t.Fatalf("Create user %d failed: %v", i, err)
|
||
}
|
||
}
|
||
|
||
// 记录失败登录日志
|
||
t.Logf("Recording %d login failures (simulating brute-force scenario)...", totalFailures)
|
||
start := time.Now()
|
||
|
||
failReasons := []string{"密码错误", "账号已锁定", "账号已禁用", "验证码错误"}
|
||
batchSize := 1000
|
||
for batch := 0; batch < totalFailures/batchSize; batch++ {
|
||
for i := 0; i < batchSize; i++ {
|
||
idx := batch*batchSize + i
|
||
userIdx := idx % userCount
|
||
failIdx := (idx / userCount) % len(failReasons)
|
||
loginLogRepo.Create(context.Background(), &domain.LoginLog{
|
||
UserID: &users[userIdx].ID,
|
||
LoginType: int(domain.LoginTypePassword),
|
||
IP: fmt.Sprintf("10.0.%d.%d", idx%256, failIdx),
|
||
Status: 0,
|
||
FailReason: failReasons[failIdx],
|
||
})
|
||
}
|
||
}
|
||
|
||
elapsed := time.Since(start)
|
||
t.Logf("Recorded %d login failures in %v (%.2f logs/sec)",
|
||
totalFailures, elapsed, float64(totalFailures)/elapsed.Seconds())
|
||
|
||
// 验证失败日志计数 — P99 < 200ms (SQLite) / P99 < 20ms (PG生产目标)
|
||
ctx := context.Background()
|
||
queryStats := NewLatencyCollector()
|
||
for i := 0; i < 5; i++ {
|
||
start = time.Now()
|
||
allFailed, _, err := loginLogRepo.ListByStatus(ctx, 0, 0, 1000)
|
||
queryElapsed := time.Since(start)
|
||
queryStats.Record(queryElapsed)
|
||
if err != nil {
|
||
t.Fatalf("ListByStatus failed: %v", err)
|
||
}
|
||
_ = allFailed
|
||
}
|
||
stats := queryStats.Compute()
|
||
t.Logf("Failed login query P99 stats: %s", stats.String())
|
||
stats.AssertSLA(t, 200*time.Millisecond, "AUTH_001_FailedLoginQuery_P99(SQLite)")
|
||
}
|
||
|
||
// TestScale_OPLOG_001_OperationLogScale tests operation log at scale
|
||
// 行业标准:中等规模系统每天 5000-20000 条操作记录,保留 90 天 = 45万-180万条
|
||
func TestScale_OPLOG_001_OperationLogScale(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
// 10K 条操作日志(约 1-2 天量),测试基本 CRUD 性能
|
||
const logCount = 10000
|
||
opLogRepo := repository.NewOperationLogRepository(db)
|
||
|
||
// 创建用户
|
||
user := &domain.User{
|
||
Username: "oplog_user",
|
||
Email: ptrString("oplog@test.com"),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
if err := db.Create(user).Error; err != nil {
|
||
t.Fatalf("Create user failed: %v", err)
|
||
}
|
||
|
||
t.Logf("Generating %d operation logs...", logCount)
|
||
start := time.Now()
|
||
|
||
operations := []string{"user.create", "user.update", "user.delete", "role.assign", "permission.grant", "user.login", "user.logout"}
|
||
for i := 0; i < logCount; i++ {
|
||
opLogRepo.Create(context.Background(), &domain.OperationLog{
|
||
UserID: &user.ID,
|
||
OperationType: operations[i%len(operations)],
|
||
OperationName: "TestOperation",
|
||
RequestMethod: "POST",
|
||
RequestPath: "/api/v1/test",
|
||
ResponseStatus: 200,
|
||
IP: fmt.Sprintf("192.168.%d.%d", i%256, (i*7)%256),
|
||
UserAgent: "TestAgent/1.0",
|
||
})
|
||
}
|
||
|
||
elapsed := time.Since(start)
|
||
t.Logf("Created %d operation logs in %v (%.2f logs/sec)",
|
||
logCount, elapsed, float64(logCount)/elapsed.Seconds())
|
||
|
||
// 查询性能 — P99 < 500ms (SQLite) / P99 < 50ms (PG生产目标)
|
||
ctx := context.Background()
|
||
queryStats := NewLatencyCollector()
|
||
for i := 0; i < 5; i++ {
|
||
start = time.Now()
|
||
recentLogs, _, err := opLogRepo.List(ctx, 0, 100)
|
||
queryElapsed := time.Since(start)
|
||
queryStats.Record(queryElapsed)
|
||
if err != nil {
|
||
t.Fatalf("List operation logs failed: %v", err)
|
||
}
|
||
_ = recentLogs
|
||
}
|
||
stats := queryStats.Compute()
|
||
t.Logf("Operation log query P99 stats: %s", stats.String())
|
||
stats.AssertSLA(t, 500*time.Millisecond, "OPLOG_001_ListQuery_P99(SQLite)")
|
||
}
|
||
|
||
// TestScale_DEV_003_DeviceActiveUpdate tests device last_active_time updates at scale
|
||
// 行业标准:批量更新设备活跃时间,5000 条应在 < 5s 内完成
|
||
func TestScale_DEV_003_DeviceActiveUpdate(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
const deviceCount = 5000
|
||
deviceRepo := repository.NewDeviceRepository(db)
|
||
|
||
// 创建用户和设备
|
||
users := make([]*domain.User, 100)
|
||
for i := range users {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("devact-%d", i),
|
||
Email: ptrString(fmt.Sprintf("devact-%d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
if err := db.Create(users[i]).Error; err != nil {
|
||
t.Fatalf("Create user failed: %v", err)
|
||
}
|
||
}
|
||
|
||
devices := make([]*domain.Device, deviceCount)
|
||
for i := 0; i < deviceCount; i++ {
|
||
devices[i] = &domain.Device{
|
||
UserID: int64(i%100) + 1,
|
||
DeviceID: fmt.Sprintf("devact-%08d", i),
|
||
DeviceName: fmt.Sprintf("Device %d", i),
|
||
DeviceType: domain.DeviceTypeWeb,
|
||
Status: domain.DeviceStatusActive,
|
||
LastActiveTime: time.Now().Add(-time.Duration(i) * time.Second),
|
||
}
|
||
if err := db.Create(devices[i]).Error; err != nil {
|
||
t.Fatalf("Create device %d failed: %v", i, err)
|
||
}
|
||
}
|
||
|
||
// 更新活跃时间 SLA: < 5s
|
||
t.Logf("Updating last_active_time for %d devices...", deviceCount)
|
||
start := time.Now()
|
||
|
||
for i := 0; i < deviceCount; i++ {
|
||
deviceRepo.UpdateLastActiveTime(context.Background(), devices[i].ID)
|
||
}
|
||
|
||
elapsed := time.Since(start)
|
||
t.Logf("Updated %d devices in %v (%.2f updates/sec) [SLA=5s]",
|
||
deviceCount, elapsed, float64(deviceCount)/elapsed.Seconds())
|
||
|
||
if elapsed > 5*time.Second {
|
||
t.Errorf("SLA BREACH: Device active update took %v, exceeded 5s", elapsed)
|
||
}
|
||
}
|
||
|
||
// TestScale_ROLE_001_RolePermissionAssignmentScale tests large-scale role-permission assignment
|
||
// 行业标准:50 角色 × 500 权限 = 25000 条角色权限关联,应在 < 10s 内完成
|
||
func TestScale_ROLE_001_RolePermissionAssignmentScale(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
|
||
const roleCount = 50
|
||
const permCount = 500
|
||
|
||
roleRepo := repository.NewRoleRepository(db)
|
||
permRepo := repository.NewPermissionRepository(db)
|
||
rolePermRepo := repository.NewRolePermissionRepository(db)
|
||
|
||
// 创建角色
|
||
t.Logf("Creating %d roles...", roleCount)
|
||
roles := make([]*domain.Role, roleCount)
|
||
for i := 0; i < roleCount; i++ {
|
||
roles[i] = &domain.Role{
|
||
Name: fmt.Sprintf("scale_role_%d", i),
|
||
Code: fmt.Sprintf("scale_role_%d", i),
|
||
}
|
||
if err := roleRepo.Create(context.Background(), roles[i]); err != nil {
|
||
t.Fatalf("Create role %d failed: %v", i, err)
|
||
}
|
||
}
|
||
|
||
// 创建权限
|
||
t.Logf("Creating %d permissions...", permCount)
|
||
perms := make([]*domain.Permission, permCount)
|
||
for i := 0; i < permCount; i++ {
|
||
perms[i] = &domain.Permission{
|
||
Name: fmt.Sprintf("scale_perm_%d", i),
|
||
Code: fmt.Sprintf("scale:perm:%d", i),
|
||
Type: 1,
|
||
}
|
||
if err := permRepo.Create(context.Background(), perms[i]); err != nil {
|
||
t.Fatalf("Create permission %d failed: %v", i, err)
|
||
}
|
||
}
|
||
|
||
// 创建角色服务用于分配权限
|
||
roleSvc := NewRoleService(roleRepo, rolePermRepo)
|
||
|
||
// 分配所有权限给所有角色
|
||
t.Logf("Assigning %d permissions to %d roles...", permCount, roleCount)
|
||
start := time.Now()
|
||
|
||
for i := 0; i < roleCount; i++ {
|
||
permIDs := make([]int64, permCount)
|
||
for j := 0; j < permCount; j++ {
|
||
permIDs[j] = perms[j].ID
|
||
}
|
||
if err := roleSvc.AssignPermissions(context.Background(), roles[i].ID, permIDs); err != nil {
|
||
t.Fatalf("AssignPermissions for role %d failed: %v", i, err)
|
||
}
|
||
}
|
||
|
||
elapsed := time.Since(start)
|
||
totalAssignments := roleCount * permCount
|
||
t.Logf("Assigned %d permissions (%d roles x %d perms) in %v (%.2f assigns/sec) [SLA=10s]",
|
||
totalAssignments, roleCount, permCount, elapsed, float64(totalAssignments)/elapsed.Seconds())
|
||
|
||
if elapsed > 10*time.Second {
|
||
t.Errorf("SLA BREACH: Role-permission assignment took %v, exceeded 10s", elapsed)
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// ⚡ 并发压测(CONC_SCALE_001~003)
|
||
// 模拟真实多用户并发场景:50~100 goroutine 同时操作
|
||
// SQLite WAL 模式下可支持 100+ 并发写入
|
||
// =============================================================================
|
||
|
||
// runConcurrent 辅助函数:并发运行 n 个 goroutine,返回成功次数
|
||
func runConcurrent(n int, fn func(idx int) error) int {
|
||
const maxRetries = 5
|
||
var wg sync.WaitGroup
|
||
var mu sync.Mutex
|
||
successCount := 0
|
||
|
||
wg.Add(n)
|
||
for i := 0; i < n; i++ {
|
||
go func(idx int) {
|
||
defer wg.Done()
|
||
var err error
|
||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||
err = fn(idx)
|
||
if err == nil {
|
||
break
|
||
}
|
||
if attempt < maxRetries {
|
||
time.Sleep(time.Duration(attempt+1) * 2 * time.Millisecond)
|
||
continue
|
||
}
|
||
}
|
||
if err == nil {
|
||
mu.Lock()
|
||
successCount++
|
||
mu.Unlock()
|
||
}
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
return successCount
|
||
}
|
||
|
||
// TestScale_CONC_001_ConcurrentUserRegistration 并发注册:50 goroutine 同时注册不同用户
|
||
// 行业参考:峰值注册并发约 50~100 req/s
|
||
func TestScale_CONC_001_ConcurrentUserRegistration(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
userRepo := repository.NewUserRepository(db)
|
||
userRoleRepo := repository.NewUserRoleRepository(db)
|
||
roleRepo := repository.NewRoleRepository(db)
|
||
passwordHistoryRepo := repository.NewPasswordHistoryRepository(db)
|
||
userSvc := NewUserService(userRepo, userRoleRepo, roleRepo, passwordHistoryRepo)
|
||
|
||
ctx := context.Background()
|
||
const goroutines = 50
|
||
start := time.Now()
|
||
successCount := runConcurrent(goroutines, func(idx int) error {
|
||
user := &domain.User{
|
||
Username: fmt.Sprintf("conc001_user_%d_%d", time.Now().UnixNano(), idx),
|
||
Email: ptrString(fmt.Sprintf("conc001_%d_%d@test.com", time.Now().UnixNano(), idx)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
return userSvc.Create(ctx, user)
|
||
})
|
||
elapsed := time.Since(start)
|
||
|
||
t.Logf("Concurrent registration: %d/%d succeeded in %v (%.1f req/s)",
|
||
successCount, goroutines, elapsed, float64(successCount)/elapsed.Seconds())
|
||
|
||
// 至少 90% 成功
|
||
minExpected := goroutines * 90 / 100
|
||
if successCount < minExpected {
|
||
t.Errorf("expected at least %d successes, got %d", minExpected, successCount)
|
||
}
|
||
}
|
||
|
||
// TestScale_CONC_002_ConcurrentDeviceCreation 并发创建设备:50 goroutine 同时为不同用户创建设备
|
||
// 行业参考:IoT 场景下 50+ 设备同时注册
|
||
func TestScale_CONC_002_ConcurrentDeviceCreation(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
deviceRepo := repository.NewDeviceRepository(db)
|
||
userRepo := repository.NewUserRepository(db)
|
||
deviceSvc := NewDeviceService(deviceRepo, userRepo)
|
||
|
||
// 预先创建 50 个用户
|
||
ctx := context.Background()
|
||
users := make([]*domain.User, 50)
|
||
for i := 0; i < 50; i++ {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("conc002_user_%d", i),
|
||
Email: ptrString(fmt.Sprintf("conc002_%d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
if err := db.Create(users[i]).Error; err != nil {
|
||
t.Fatalf("Create user %d failed: %v", i, err)
|
||
}
|
||
}
|
||
|
||
const goroutines = 50
|
||
start := time.Now()
|
||
successCount := runConcurrent(goroutines, func(idx int) error {
|
||
_, err := deviceSvc.CreateDevice(ctx, users[idx].ID, &CreateDeviceRequest{
|
||
DeviceID: fmt.Sprintf("conc002_device_%d_%d", time.Now().UnixNano(), idx),
|
||
DeviceName: fmt.Sprintf("Device %d", idx),
|
||
DeviceType: int(domain.DeviceTypeWeb),
|
||
})
|
||
return err
|
||
})
|
||
elapsed := time.Since(start)
|
||
|
||
t.Logf("Concurrent device creation: %d/%d succeeded in %v (%.1f req/s)",
|
||
successCount, goroutines, elapsed, float64(successCount)/elapsed.Seconds())
|
||
|
||
minExpected := goroutines * 90 / 100
|
||
if successCount < minExpected {
|
||
t.Errorf("expected at least %d successes, got %d", minExpected, successCount)
|
||
}
|
||
}
|
||
|
||
// TestScale_CONC_003_ConcurrentLoginLogWrite 并发登录日志写入:100 goroutine 同时写登录日志
|
||
// 行业参考:1000 并发用户,每用户每秒约 0.5~2 次登录,峰值约 100~500 log/s
|
||
func TestScale_CONC_003_ConcurrentLoginLogWrite(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping scale test in short mode")
|
||
}
|
||
|
||
db := newIsolatedDB(t)
|
||
loginLogRepo := repository.NewLoginLogRepository(db)
|
||
loginLogSvc := NewLoginLogService(loginLogRepo)
|
||
|
||
// 预先创建用户
|
||
ctx := context.Background()
|
||
users := make([]*domain.User, 10)
|
||
for i := 0; i < 10; i++ {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("conc003_user_%d", i),
|
||
Email: ptrString(fmt.Sprintf("conc003_%d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: domain.UserStatusActive,
|
||
}
|
||
if err := db.Create(users[i]).Error; err != nil {
|
||
t.Fatalf("Create user failed: %v", err)
|
||
}
|
||
}
|
||
|
||
const goroutines = 100
|
||
start := time.Now()
|
||
successCount := runConcurrent(goroutines, func(idx int) error {
|
||
return loginLogSvc.RecordLogin(ctx, &RecordLoginRequest{
|
||
UserID: users[idx%10].ID,
|
||
LoginType: int(domain.LoginTypePassword),
|
||
IP: fmt.Sprintf("10.%d.%d.%d", idx/65536, (idx/256)%256, idx%256),
|
||
Status: 1,
|
||
})
|
||
})
|
||
elapsed := time.Since(start)
|
||
|
||
t.Logf("Concurrent login log write: %d/%d written in %v (%.1f logs/sec)",
|
||
successCount, goroutines, elapsed, float64(successCount)/elapsed.Seconds())
|
||
|
||
minExpected := goroutines * 80 / 100
|
||
if successCount < minExpected {
|
||
t.Errorf("expected at least %d successes, got %d", minExpected, successCount)
|
||
}
|
||
}
|
||
|
||
|
||
// =============================================================================
|
||
// Helper Functions
|
||
// =============================================================================
|
||
|
||
// generateTestUsers generates test users with status distribution
|
||
func generateTestUsers(count int) []*domain.User {
|
||
users := make([]*domain.User, count)
|
||
statuses := []domain.UserStatus{
|
||
domain.UserStatusActive,
|
||
domain.UserStatusActive,
|
||
domain.UserStatusActive,
|
||
domain.UserStatusActive,
|
||
domain.UserStatusInactive,
|
||
domain.UserStatusLocked,
|
||
domain.UserStatusDisabled,
|
||
}
|
||
|
||
for i := 0; i < count; i++ {
|
||
users[i] = &domain.User{
|
||
Username: fmt.Sprintf("testuser_%08d", i),
|
||
Email: ptrString(fmt.Sprintf("testuser_%08d@test.com", i)),
|
||
Password: "$2a$10$dummy",
|
||
Status: statuses[i%len(statuses)],
|
||
}
|
||
}
|
||
return users
|
||
}
|
||
|
||
func generatePermissionTree(count int) []*domain.Permission {
|
||
permissions := make([]*domain.Permission, count)
|
||
|
||
// Create root permissions
|
||
rootCount := 10
|
||
for i := 0; i < rootCount && i < count; i++ {
|
||
permissions[i] = &domain.Permission{
|
||
Name: fmt.Sprintf("模块_%d", i),
|
||
Code: fmt.Sprintf("module_%d", i),
|
||
ParentID: nil,
|
||
Sort: i,
|
||
}
|
||
}
|
||
|
||
// Create child permissions under each root
|
||
childIndex := rootCount
|
||
for rootIdx := 0; rootIdx < rootCount && childIndex < count; rootIdx++ {
|
||
parentID := int64(rootIdx + 1)
|
||
childrenPerRoot := (count - rootCount) / rootCount
|
||
|
||
for j := 0; j < childrenPerRoot && childIndex < count; j++ {
|
||
permissions[childIndex] = &domain.Permission{
|
||
Name: fmt.Sprintf("权限_%d_%d", rootIdx, j),
|
||
Code: fmt.Sprintf("perm_%d_%d", rootIdx, j),
|
||
ParentID: &parentID,
|
||
Sort: j,
|
||
}
|
||
childIndex++
|
||
}
|
||
}
|
||
|
||
return permissions[:childIndex]
|
||
}
|
||
|
||
func ptrString(s string) *string {
|
||
return &s
|
||
}
|
||
|
||
func ptrDeviceStatus(s domain.DeviceStatus) *domain.DeviceStatus {
|
||
return &s
|
||
}
|
||
|
||
func ptrInt64(i int64) *int64 {
|
||
return &i
|
||
}
|
||
|
||
func generateDeepPermissionTree(count int) []*domain.Permission {
|
||
permissions := make([]*domain.Permission, 0, count)
|
||
|
||
// Create a deep hierarchical tree: root -> module -> category -> subcategory -> action
|
||
levels := 5
|
||
childrenPerLevel := 5
|
||
|
||
currentID := int64(1)
|
||
var addChildren func(parentID *int64, level, remaining int)
|
||
addChildren = func(parentID *int64, level, remaining int) {
|
||
if level >= levels || remaining <= 0 {
|
||
return
|
||
}
|
||
for i := 0; i < childrenPerLevel && remaining > 0; i++ {
|
||
perm := &domain.Permission{
|
||
Name: fmt.Sprintf("level%d_child%d", level, i),
|
||
Code: fmt.Sprintf("l%d_c%d_%d", level, i, currentID),
|
||
ParentID: parentID,
|
||
Sort: i,
|
||
}
|
||
permissions = append(permissions, perm)
|
||
currentID++
|
||
remaining--
|
||
|
||
if level < levels-1 && remaining > 0 {
|
||
newParentID := currentID - 1
|
||
addChildren(&newParentID, level+1, remaining)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add root permissions
|
||
for i := 0; i < 3 && currentID <= int64(count); i++ {
|
||
rootID := currentID
|
||
perm := &domain.Permission{
|
||
Name: fmt.Sprintf("root_module_%d", i),
|
||
Code: fmt.Sprintf("root_%d", i),
|
||
ParentID: nil,
|
||
Sort: i,
|
||
}
|
||
permissions = append(permissions, perm)
|
||
currentID++
|
||
|
||
addChildren(&rootID, 1, count-len(permissions))
|
||
}
|
||
|
||
// Fill remaining with flat permissions if needed
|
||
for len(permissions) < count {
|
||
permissions = append(permissions, &domain.Permission{
|
||
Name: fmt.Sprintf("flat_perm_%d", currentID),
|
||
Code: fmt.Sprintf("flat_%d", currentID),
|
||
ParentID: nil,
|
||
Sort: int(currentID),
|
||
})
|
||
currentID++
|
||
}
|
||
|
||
return permissions[:count]
|
||
}
|