fix: sync moderation refresh cache bypass

This commit is contained in:
iamxvbaba 2026-07-24 11:57:00 +08:00
parent 3dd9c345d7
commit 6cafa40c7b
6 changed files with 187 additions and 0 deletions

View file

@ -216,6 +216,21 @@ func (s *Service) GetChannels(ctx context.Context, userID int64, channelIDs []in
return s.channels.GetChannels(ctx, userID, ids)
}
// GetChannelsAuthoritative bypasses the app-level versioned read model for a
// durable channel_state refresh. PostgreSQL GetChannels is a bounded direct
// projection query, so the returned flag snapshot cannot be the pre-commit
// value that the event is intended to invalidate.
func (s *Service) GetChannelsAuthoritative(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) {
if s == nil || s.channels == nil || userID == 0 {
return nil, domain.ErrChannelInvalid
}
ids := uniqueNonZero(channelIDs)
if len(ids) == 0 {
return nil, nil
}
return s.channels.GetChannels(ctx, userID, ids)
}
// GetJoinableChannel returns a channel shell so RPC can verify access hash before join.
func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {

View file

@ -176,6 +176,27 @@ func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int6
return s.projectUsers(ctx, currentUserID, users)
}
// ByIDsAuthoritative reloads an explicit profile-refresh target from the
// durable store, then replaces the shared base cache before applying the
// viewer projection. It is intentionally reserved for durable update
// delivery; ordinary reads continue to use ByIDs.
func (s *Service) ByIDsAuthoritative(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
if currentUserID == 0 {
return nil, ErrNotAuthorized
}
ids := uniqueUserIDs(userIDs, maxBatchUsers)
if len(ids) == 0 {
return nil, nil
}
s.dropCachedUsers(ctx, ids...)
users, err := s.users.ByIDs(ctx, ids)
if err != nil {
return nil, err
}
s.putCachedUsers(ctx, users...)
return s.projectUsers(ctx, currentUserID, users)
}
// ByIDsForViewers 跨多个 viewer 批量投影同一组 userfan-out 模板化base user 只加载一次,
// 隐私/改名/头像投影经 userprojection.ForViewers 压成 O(owner) 查询。返回 map[viewerID][]User
// 每个切片与 ByIDs(viewer, ids) 字节等价——**唯一例外是 personal photo overlay**ForViewers v1

View file

@ -259,6 +259,49 @@ func TestServiceUsesBaseCacheWithoutCachingViewerOverlay(t *testing.T) {
}
}
func TestServiceAuthoritativeUserReloadReplacesStaleBaseCache(t *testing.T) {
ctx := context.Background()
base := memory.NewUserStore()
viewer, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000011", FirstName: "Viewer"})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000012", FirstName: "Target"})
if err != nil {
t.Fatalf("create target: %v", err)
}
store := &countingUserStore{UserStore: base}
cache := newMemoryBaseUserCache()
svc := NewService(store, WithBaseUserCache(cache))
primed, err := svc.ByIDs(ctx, viewer.ID, []int64{target.ID})
if err != nil || len(primed) != 1 || primed[0].Scam {
t.Fatalf("prime ByIDs users=%+v err=%v", primed, err)
}
if _, err := base.SetScamFake(ctx, target.ID, true, false); err != nil {
t.Fatalf("commit moderation flags behind cache: %v", err)
}
stale, err := svc.ByIDs(ctx, viewer.ID, []int64{target.ID})
if err != nil || len(stale) != 1 || stale[0].Scam {
t.Fatalf("ordinary cached ByIDs users=%+v err=%v, want stale scam=false", stale, err)
}
fresh, err := svc.ByIDsAuthoritative(ctx, viewer.ID, []int64{target.ID})
if err != nil || len(fresh) != 1 || !fresh[0].Scam || fresh[0].Fake {
t.Fatalf("authoritative ByIDs users=%+v err=%v, want scam=true fake=false", fresh, err)
}
if store.byIDsCalls != 2 {
t.Fatalf("store ByIDs calls=%d, want prime + authoritative reload", store.byIDsCalls)
}
cached, err := svc.ByIDs(ctx, viewer.ID, []int64{target.ID})
if err != nil || len(cached) != 1 || !cached[0].Scam || cached[0].Fake {
t.Fatalf("replaced cache ByIDs users=%+v err=%v, want scam=true fake=false", cached, err)
}
if store.byIDsCalls != 2 {
t.Fatalf("store ByIDs calls after cached read=%d, want unchanged", store.byIDsCalls)
}
}
func TestServiceRefreshesBaseCacheAfterProfileUpdate(t *testing.T) {
ctx := context.Background()
base := memory.NewUserStore()