| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124 |
- 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 异常")
- }
|