feat: sync privacy read-model rules

This commit is contained in:
iamxvbaba 2026-07-24 11:56:59 +08:00
parent 9f467f4be7
commit 5d5883a3d1
20 changed files with 1011 additions and 20 deletions

View file

@ -363,6 +363,21 @@ func (s *Service) CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int
return out, nil
}
// ViewerIsPremium reads the same bounded viewer-facts read model used by
// AllowPremium privacy rules. Contact permission checks must not bypass that
// cache with a per-send users-table query.
func (s *Service) ViewerIsPremium(ctx context.Context, viewerUserID int64) (bool, error) {
if viewerUserID == 0 {
return false, nil
}
facts, err := s.loadViewerFacts(ctx, []int64{viewerUserID})
if err != nil {
return false, err
}
fact := facts[viewerUserID]
return fact.Found && !fact.Bot && fact.PremiumUntil > s.now().Unix(), nil
}
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
// 调 CanSee,但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。

View file

@ -3,11 +3,44 @@ package privacy
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type countingBaseUsers struct {
calls int
users map[int64]domain.User
}
func (p *countingBaseUsers) PrivacyBaseUsers(_ context.Context, userIDs []int64) ([]domain.User, error) {
p.calls++
out := make([]domain.User, 0, len(userIDs))
for _, userID := range userIDs {
if user, ok := p.users[userID]; ok {
out = append(out, user)
}
}
return out, nil
}
type countingMemberships struct {
calls int
active map[int64]map[int64]bool
}
func (p *countingMemberships) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
p.calls++
out := make([]int64, 0, len(userIDs))
for _, userID := range userIDs {
if p.active[channelID][userID] {
out = append(out, userID)
}
}
return out, nil
}
func TestDefaultPrivacyRules(t *testing.T) {
ctx := context.Background()
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())
@ -192,3 +225,111 @@ func TestCanSeeMatrixEquivalentToCanSee(t *testing.T) {
}
}
}
func TestViewerFactsReadModelBatchesCachesAndInvalidates(t *testing.T) {
ctx := context.Background()
rules := memory.NewPrivacyStore()
users := &countingBaseUsers{users: map[int64]domain.User{
2001: {ID: 2001, PremiumUntil: 2000},
2002: {ID: 2002, Bot: true},
}}
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(users, nil)
svc.now = func() time.Time { return time.Unix(1000, 0) }
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowPremium},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set premium rules: %v", err)
}
if _, err := svc.SetRules(ctx, 1002, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowBots},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set bot rules: %v", err)
}
got, err := svc.CanSeeMatrix(
ctx,
[]int64{1001, 1002},
[]int64{2001, 2002},
[]domain.PrivacyKey{domain.PrivacyKeyNoPaidMessages},
)
if err != nil {
t.Fatalf("CanSeeMatrix: %v", err)
}
if !got[1001][2001][domain.PrivacyKeyNoPaidMessages] ||
got[1001][2002][domain.PrivacyKeyNoPaidMessages] ||
got[1002][2001][domain.PrivacyKeyNoPaidMessages] ||
!got[1002][2002][domain.PrivacyKeyNoPaidMessages] {
t.Fatalf("unexpected premium/bot visibility matrix: %+v", got)
}
if users.calls != 1 {
t.Fatalf("base user cold loads = %d, want one batched load", users.calls)
}
if premium, err := svc.ViewerIsPremium(ctx, 2001); err != nil || !premium {
t.Fatalf("warm ViewerIsPremium = %v, err=%v; want true", premium, err)
}
if users.calls != 1 {
t.Fatalf("warm viewer facts hit called backend: calls=%d", users.calls)
}
users.users[2001] = domain.User{ID: 2001}
svc.InvalidateViewerFacts(2001)
if premium, err := svc.ViewerIsPremium(ctx, 2001); err != nil || premium {
t.Fatalf("invalidated ViewerIsPremium = %v, err=%v; want false", premium, err)
}
if users.calls != 2 {
t.Fatalf("invalidated viewer facts cold loads = %d, want 2", users.calls)
}
}
func TestMembershipReadModelCachesNegativeFactsAndInvalidatesPair(t *testing.T) {
ctx := context.Background()
rules := memory.NewPrivacyStore()
memberships := &countingMemberships{active: map[int64]map[int64]bool{
9001: {2001: true},
9002: {},
}}
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(nil, memberships)
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyChatInvite, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: []int64{9001, 9002}},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set participant rules: %v", err)
}
got, err := svc.CanSeeMatrix(
ctx,
[]int64{1001},
[]int64{2001, 2002},
[]domain.PrivacyKey{domain.PrivacyKeyChatInvite},
)
if err != nil {
t.Fatalf("CanSeeMatrix: %v", err)
}
if !got[1001][2001][domain.PrivacyKeyChatInvite] ||
got[1001][2002][domain.PrivacyKeyChatInvite] {
t.Fatalf("unexpected membership visibility matrix: %+v", got)
}
if memberships.calls != 2 {
t.Fatalf("membership cold loads = %d, want one batch per referenced chat", memberships.calls)
}
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || allowed {
t.Fatalf("warm negative membership = %v, err=%v; want false", allowed, err)
}
if memberships.calls != 2 {
t.Fatalf("negative cache missed: calls=%d", memberships.calls)
}
memberships.active[9002][2002] = true
svc.InvalidateMembership(9002, 2002)
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || !allowed {
t.Fatalf("invalidated membership = %v, err=%v; want true", allowed, err)
}
if memberships.calls != 3 {
t.Fatalf("pair invalidation reloads = %d, want 3", memberships.calls)
}
}