package loaders import ( "context" "database/sql" "sync" "sync/atomic" "testing" "perms-system-server/internal/consts" userModel "perms-system-server/internal/model/user" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // countingUserModel 包装真实 SysUserModel, 仅拦截 FindOne 计数。 // 其余 40+ 方法通过 embedding 直接 pass-through, 无需手写。 type countingUserModel struct { userModel.SysUserModel findOneHits int64 } func (c *countingUserModel) FindOne(ctx context.Context, id int64) (*userModel.SysUser, error) { atomic.AddInt64(&c.findOneHits, 1) return c.SysUserModel.FindOne(ctx, id) } // TC-0792: L-5 延伸 —— UserDetailsLoader 必须用 singleflight 合并同一 key 的并发 Load, // 保证缓存 miss 时 DB 只被打一次, 防止冷启动/缓存击穿。 // 实现方式: 用 countingUserModel 拦截 SysUserModel.FindOne, 断言 N 个并发 Load // 触发的 FindOne 次数远少于 N (严格来说, 在我们控制的并发时序下必须恰好 1 次)。 // 为避免 "第一个 goroutine 太快, 写完缓存后其他 goroutine 走 cache 路径也只是少调用" // 这种"假阳性平局", 本用例刻意先 Del 缓存 + 用 WaitGroup barrier 同时释放所有 goroutine, // 把所有 goroutine 都塞进 singleflight.Do 的同一 key flight 里。 func TestLoader_Load_SingleflightCollapsesConcurrentCalls(t *testing.T) { ctx := context.Background() rds := testRedis() realModels := testModels() counting := &countingUserModel{SysUserModel: realModels.SysUserModel} // 替换 models 里的 SysUserModel 为计数包装; 其他模型保持真实以便 loader 的产品/成员/部门/角色/权限流转能跑通 wrappedModels := *realModels wrappedModels.SysUserModel = counting loader := NewUserDetailsLoader(rds, testKeyPrefix, &wrappedModels) u := &userModel.SysUser{ Username: "ld_sf_" + uniqueId(), Password: hashPwd("x"), Nickname: "sf", Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo, Status: consts.StatusEnabled, CreateTime: now(), UpdateTime: now(), } userId := insertUser(ctx, t, realModels, u) t.Cleanup(func() { cleanTable(ctx, testConn(), "sys_user", userId) }) // 确保缓存为空 loader.Del(ctx, userId, "") loader.Clean(ctx, userId) const workers = 50 var ( wg sync.WaitGroup start = make(chan struct{}) ptrs = make([]*UserDetails, workers) ) for i := 0; i < workers; i++ { wg.Add(1) go func(idx int) { defer wg.Done() <-start ptrs[idx] = loader.Load(ctx, userId, "") }(i) } close(start) wg.Wait() // 每个 goroutine 都应拿到完整的用户数据 for i, p := range ptrs { require.NotNil(t, p, "worker %d 返回 nil", i) assert.Equal(t, u.Username, p.Username, "worker %d 读到的 Username 错乱", i) } hits := atomic.LoadInt64(&counting.findOneHits) assert.LessOrEqual(t, hits, int64(workers/5), "singleflight 必须把 DB 命中压到极少次 (远低于 workers=%d); 实际 FindOne 被调 %d 次", workers, hits) assert.Greater(t, hits, int64(0), "至少要有一次 DB 命中 (否则说明缓存未被真正清空)") } // TC-0793: L-5 延伸 —— 第二波 Load 必须命中缓存, FindOne 不再增加。 // 这是对 TC-0762 的成对断言: singleflight 合并仅作用于"同一飞行中的并发", // 而一旦首次加载完成并写入 Redis, 后续读取应进入 cache fast-path 而非再次走 DB。 func TestLoader_Load_SecondRoundHitsCache(t *testing.T) { ctx := context.Background() rds := testRedis() realModels := testModels() counting := &countingUserModel{SysUserModel: realModels.SysUserModel} wrappedModels := *realModels wrappedModels.SysUserModel = counting loader := NewUserDetailsLoader(rds, testKeyPrefix, &wrappedModels) u := &userModel.SysUser{ Username: "ld_sf2_" + uniqueId(), Password: hashPwd("x"), Nickname: "sf2", Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo, Status: consts.StatusEnabled, CreateTime: now(), UpdateTime: now(), } userId := insertUser(ctx, t, realModels, u) t.Cleanup(func() { cleanTable(ctx, testConn(), "sys_user", userId) }) loader.Del(ctx, userId, "") loader.Clean(ctx, userId) _ = loader.Load(ctx, userId, "") firstHits := atomic.LoadInt64(&counting.findOneHits) require.Equal(t, int64(1), firstHits, "首次 Load 应命中 DB 一次") for i := 0; i < 20; i++ { _ = loader.Load(ctx, userId, "") } secondRoundHits := atomic.LoadInt64(&counting.findOneHits) - firstHits assert.Equal(t, int64(0), secondRoundHits, "后续 Load 必须命中 Redis 缓存; 若持续打到 DB, 说明 cache 写入失败或 TTL 异常") }