chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
209
internal/app/privacy/cache.go
Normal file
209
internal/app/privacy/cache.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultPrivacyRulesCacheTTL = 24 * time.Hour
|
||||
|
||||
privacySnapshotMaxOwners = 8192
|
||||
)
|
||||
|
||||
var allPrivacyRuleKeys = []domain.PrivacyKey{
|
||||
domain.PrivacyKeyStatusTimestamp,
|
||||
domain.PrivacyKeyChatInvite,
|
||||
domain.PrivacyKeyPhoneCall,
|
||||
domain.PrivacyKeyPhoneP2P,
|
||||
domain.PrivacyKeyForwards,
|
||||
domain.PrivacyKeyProfilePhoto,
|
||||
domain.PrivacyKeyPhoneNumber,
|
||||
domain.PrivacyKeyAddedByPhone,
|
||||
domain.PrivacyKeyVoiceMessages,
|
||||
domain.PrivacyKeyAbout,
|
||||
domain.PrivacyKeyBirthday,
|
||||
domain.PrivacyKeyStarGiftsAutoSave,
|
||||
domain.PrivacyKeyNoPaidMessages,
|
||||
domain.PrivacyKeySavedMusic,
|
||||
}
|
||||
|
||||
// privacyRulesMap 是单个 owner 的全部隐私规则(空 map = 查过且无规则,即负缓存)。
|
||||
type privacyRulesMap map[domain.PrivacyKey]domain.PrivacyRules
|
||||
|
||||
// CachedPrivacyStore 是 account privacy rules 的 owner 级 read-model 缓存,由统一缓存原语承载
|
||||
// (LRU 单条驱逐 / epoch 守卫 / clone)。owner 级、变更稀少:一次性装入某 owner 全部 key,让
|
||||
// projectBatch/CanSeeMatrix 在内存里判 phone/status/photo 可见性,免去反复规划 account_privacy_rules。
|
||||
// 单 owner 走 GetOrLoad,多 owner 走 GetOrLoadBatch(一次 LoadEpoch + 合批 ListPrivacyRules + 写回)。
|
||||
type CachedPrivacyStore struct {
|
||||
inner store.PrivacyStore
|
||||
cache *readmodelcache.Cache[int64, privacyRulesMap]
|
||||
}
|
||||
|
||||
func NewCachedPrivacyStore(inner store.PrivacyStore, ttl time.Duration) *CachedPrivacyStore {
|
||||
if inner == nil {
|
||||
return nil
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultPrivacyRulesCacheTTL
|
||||
}
|
||||
return &CachedPrivacyStore{
|
||||
inner: inner,
|
||||
cache: readmodelcache.New[int64, privacyRulesMap](readmodelcache.Config[int64, privacyRulesMap]{
|
||||
MaxEntries: privacySnapshotMaxOwners,
|
||||
TTL: ttl,
|
||||
Clone: clonePrivacyRulesMap,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedPrivacyStore) GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) {
|
||||
if ownerUserID == 0 {
|
||||
return domain.PrivacyRules{}, false, nil
|
||||
}
|
||||
rules, err := c.ownerRules(ctx, ownerUserID)
|
||||
if err != nil {
|
||||
return domain.PrivacyRules{}, false, err
|
||||
}
|
||||
r, ok := rules[key]
|
||||
if !ok {
|
||||
return domain.PrivacyRules{}, false, nil
|
||||
}
|
||||
return cloneRules(r), true, nil
|
||||
}
|
||||
|
||||
func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error {
|
||||
if err := c.inner.SetPrivacyRules(ctx, rules); err != nil {
|
||||
return err
|
||||
}
|
||||
c.InvalidateOwners(rules.OwnerUserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CachedPrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) {
|
||||
owners := dedupPrivacyOwnerIDs(ownerUserIDs)
|
||||
if len(owners) == 0 || len(keys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
byOwner, err := c.ownerRulesBatch(ctx, owners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keySet := make(map[domain.PrivacyKey]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
keySet[key] = struct{}{}
|
||||
}
|
||||
out := make([]domain.PrivacyRules, 0, len(owners)*len(keys))
|
||||
for _, owner := range owners {
|
||||
for key, rules := range byOwner[owner] {
|
||||
if _, want := keySet[key]; !want {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneRules(rules))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *CachedPrivacyStore) ownerRules(ctx context.Context, ownerUserID int64) (privacyRulesMap, error) {
|
||||
load := func() (privacyRulesMap, error) {
|
||||
list, err := c.inner.ListPrivacyRules(ctx, []int64{ownerUserID}, allPrivacyRuleKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildPrivacyRulesByOwner(list, []int64{ownerUserID})[ownerUserID], nil
|
||||
}
|
||||
if c == nil || c.cache == nil {
|
||||
return load()
|
||||
}
|
||||
return c.cache.GetOrLoad(ctx, ownerUserID, load)
|
||||
}
|
||||
|
||||
func (c *CachedPrivacyStore) ownerRulesBatch(ctx context.Context, owners []int64) (map[int64]privacyRulesMap, error) {
|
||||
loadMissing := func(ctx context.Context, missing []int64) (map[int64]privacyRulesMap, error) {
|
||||
list, err := c.inner.ListPrivacyRules(ctx, missing, allPrivacyRuleKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildPrivacyRulesByOwner(list, missing), nil
|
||||
}
|
||||
if c == nil || c.cache == nil {
|
||||
return loadMissing(ctx, owners)
|
||||
}
|
||||
return c.cache.GetOrLoadBatch(ctx, owners,
|
||||
func(int64) (int64, bool) { return 0, true }, // 纯 TTL,无版本闸门
|
||||
loadMissing,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *CachedPrivacyStore) InvalidateOwners(ids ...int64) {
|
||||
if c == nil || c.cache == nil || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
nonZero := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id != 0 {
|
||||
nonZero = append(nonZero, id)
|
||||
}
|
||||
}
|
||||
c.cache.Invalidate(nonZero...)
|
||||
}
|
||||
|
||||
func (c *CachedPrivacyStore) FlushReadModelCache() {
|
||||
if c == nil || c.cache == nil {
|
||||
return
|
||||
}
|
||||
c.cache.Flush()
|
||||
}
|
||||
|
||||
// buildPrivacyRulesByOwner 把扁平规则按 owner 归组;每个 owner 都建一个条目(无规则即空 map),
|
||||
// 这样「查过且无规则」的 owner 也被负缓存,不会反复打后端。
|
||||
func buildPrivacyRulesByOwner(list []domain.PrivacyRules, owners []int64) map[int64]privacyRulesMap {
|
||||
out := make(map[int64]privacyRulesMap, len(owners))
|
||||
for _, owner := range owners {
|
||||
out[owner] = make(privacyRulesMap)
|
||||
}
|
||||
for _, item := range list {
|
||||
if item.OwnerUserID == 0 || item.Key == "" {
|
||||
continue
|
||||
}
|
||||
m, ok := out[item.OwnerUserID]
|
||||
if !ok {
|
||||
m = make(privacyRulesMap)
|
||||
out[item.OwnerUserID] = m
|
||||
}
|
||||
m[item.Key] = cloneRules(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clonePrivacyRulesMap(in privacyRulesMap) privacyRulesMap {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(privacyRulesMap, len(in))
|
||||
for key, rules := range in {
|
||||
out[key] = cloneRules(rules)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupPrivacyOwnerIDs(ids []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
355
internal/app/privacy/cache_test.go
Normal file
355
internal/app/privacy/cache_test.go
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type blockingFirstPrivacyStore struct {
|
||||
store.PrivacyStore
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
first []domain.PrivacyRules
|
||||
|
||||
mu sync.Mutex
|
||||
firstUsed bool
|
||||
}
|
||||
|
||||
func (s *blockingFirstPrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) {
|
||||
s.mu.Lock()
|
||||
if !s.firstUsed {
|
||||
s.firstUsed = true
|
||||
s.mu.Unlock()
|
||||
close(s.started)
|
||||
select {
|
||||
case <-s.release:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
out := make([]domain.PrivacyRules, len(s.first))
|
||||
for i := range s.first {
|
||||
out[i] = cloneRules(s.first[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return s.PrivacyStore.ListPrivacyRules(ctx, ownerUserIDs, keys)
|
||||
}
|
||||
|
||||
func waitForPrivacyCacheTestSignal(t *testing.T, ch <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for cache test signal")
|
||||
}
|
||||
}
|
||||
|
||||
type countingPrivacyStore struct {
|
||||
store.PrivacyStore
|
||||
getCalls int
|
||||
listCalls int
|
||||
setCalls int
|
||||
}
|
||||
|
||||
func (s *countingPrivacyStore) GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) {
|
||||
s.getCalls++
|
||||
return s.PrivacyStore.GetPrivacyRules(ctx, ownerUserID, key)
|
||||
}
|
||||
|
||||
func (s *countingPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error {
|
||||
s.setCalls++
|
||||
return s.PrivacyStore.SetPrivacyRules(ctx, rules)
|
||||
}
|
||||
|
||||
func (s *countingPrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) {
|
||||
s.listCalls++
|
||||
return s.PrivacyStore.ListPrivacyRules(ctx, ownerUserIDs, keys)
|
||||
}
|
||||
|
||||
func TestCachedPrivacyStoreUsesOwnerSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewPrivacyStore()
|
||||
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyPhoneNumber,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed privacy: %v", err)
|
||||
}
|
||||
counting := &countingPrivacyStore{PrivacyStore: base}
|
||||
cached := NewCachedPrivacyStore(counting, 0)
|
||||
|
||||
first, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("first get ok=%v err=%v", ok, err)
|
||||
}
|
||||
if first.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
|
||||
t.Fatalf("first rules = %+v, want disallow all", first.Rules)
|
||||
}
|
||||
second, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("second get ok=%v err=%v", ok, err)
|
||||
}
|
||||
if second.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
|
||||
t.Fatalf("second rules = %+v, want disallow all", second.Rules)
|
||||
}
|
||||
if counting.getCalls != 0 {
|
||||
t.Fatalf("GetPrivacyRules calls = %d, want 0", counting.getCalls)
|
||||
}
|
||||
if counting.listCalls != 1 {
|
||||
t.Fatalf("ListPrivacyRules calls = %d, want 1 owner snapshot load", counting.listCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewPrivacyStore()
|
||||
counting := &countingPrivacyStore{PrivacyStore: base}
|
||||
cached := NewCachedPrivacyStore(counting, 0)
|
||||
if err := cached.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyPhoneNumber,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("set first: %v", err)
|
||||
}
|
||||
if _, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber); err != nil || !ok {
|
||||
t.Fatalf("prime get ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := cached.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyPhoneNumber,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("set second: %v", err)
|
||||
}
|
||||
got, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("after invalidation get ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got.Rules[0].Kind != domain.PrivacyRuleAllowAll {
|
||||
t.Fatalf("rules after invalidation = %+v, want allow all", got.Rules)
|
||||
}
|
||||
if counting.listCalls != 2 {
|
||||
t.Fatalf("ListPrivacyRules calls = %d, want 2 after invalidation", counting.listCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedPrivacyStoreExternalInvalidationAndFlush(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewPrivacyStore()
|
||||
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyPhoneNumber,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed privacy: %v", err)
|
||||
}
|
||||
counting := &countingPrivacyStore{PrivacyStore: base}
|
||||
cached := NewCachedPrivacyStore(counting, 0)
|
||||
|
||||
if _, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber); err != nil || !ok {
|
||||
t.Fatalf("prime get ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyPhoneNumber,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("direct set: %v", err)
|
||||
}
|
||||
cached.InvalidateOwners(1001)
|
||||
got, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("after external invalidation ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got.Rules[0].Kind != domain.PrivacyRuleAllowAll {
|
||||
t.Fatalf("after invalidation = %+v, want allow all", got.Rules)
|
||||
}
|
||||
|
||||
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyPhoneNumber,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("direct set 2: %v", err)
|
||||
}
|
||||
cached.FlushReadModelCache()
|
||||
got, ok, err = cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("after flush ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
|
||||
t.Fatalf("after flush = %+v, want disallow all", got.Rules)
|
||||
}
|
||||
if counting.listCalls != 3 {
|
||||
t.Fatalf("ListPrivacyRules calls = %d, want 3 after prime+invalidate+flush", counting.listCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedPrivacyStoreDoesNotRefillStaleSnapshotAfterInvalidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewPrivacyStore()
|
||||
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyPhoneNumber,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed privacy: %v", err)
|
||||
}
|
||||
first, err := base.ListPrivacyRules(ctx, []int64{1001}, allPrivacyRuleKeys)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot first privacy rules: %v", err)
|
||||
}
|
||||
blocking := &blockingFirstPrivacyStore{
|
||||
PrivacyStore: base,
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
first: first,
|
||||
}
|
||||
cached := NewCachedPrivacyStore(blocking, 0)
|
||||
|
||||
type readResult struct {
|
||||
rules domain.PrivacyRules
|
||||
ok bool
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan readResult, 1)
|
||||
go func() {
|
||||
rules, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
||||
resultCh <- readResult{rules: rules, ok: ok, err: err}
|
||||
}()
|
||||
waitForPrivacyCacheTestSignal(t, blocking.started)
|
||||
|
||||
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyPhoneNumber,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("update privacy while first load is blocked: %v", err)
|
||||
}
|
||||
cached.InvalidateOwners(1001)
|
||||
close(blocking.release)
|
||||
|
||||
var result readResult
|
||||
select {
|
||||
case result = <-resultCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for privacy read")
|
||||
}
|
||||
if result.err != nil || !result.ok {
|
||||
t.Fatalf("privacy read ok=%v err=%v", result.ok, result.err)
|
||||
}
|
||||
if result.rules.Rules[0].Kind != domain.PrivacyRuleAllowAll {
|
||||
t.Fatalf("privacy after concurrent invalidation = %+v, want allow all", result.rules.Rules)
|
||||
}
|
||||
|
||||
cachedHit, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("cached hit after stale load retry ok=%v err=%v", ok, err)
|
||||
}
|
||||
if cachedHit.Rules[0].Kind != domain.PrivacyRuleAllowAll {
|
||||
t.Fatalf("cached privacy after stale load retry = %+v, want allow all", cachedHit.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedPrivacyStoreDoesNotRefillStaleBatchAfterInvalidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewPrivacyStore()
|
||||
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyProfilePhoto,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed privacy: %v", err)
|
||||
}
|
||||
first, err := base.ListPrivacyRules(ctx, []int64{1001}, allPrivacyRuleKeys)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot first privacy rules: %v", err)
|
||||
}
|
||||
blocking := &blockingFirstPrivacyStore{
|
||||
PrivacyStore: base,
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
first: first,
|
||||
}
|
||||
cached := NewCachedPrivacyStore(blocking, 0)
|
||||
|
||||
type readResult struct {
|
||||
rules []domain.PrivacyRules
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan readResult, 1)
|
||||
go func() {
|
||||
rules, err := cached.ListPrivacyRules(ctx, []int64{1001}, []domain.PrivacyKey{domain.PrivacyKeyProfilePhoto})
|
||||
resultCh <- readResult{rules: rules, err: err}
|
||||
}()
|
||||
waitForPrivacyCacheTestSignal(t, blocking.started)
|
||||
|
||||
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyProfilePhoto,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("update privacy while first batch load is blocked: %v", err)
|
||||
}
|
||||
cached.InvalidateOwners(1001)
|
||||
close(blocking.release)
|
||||
|
||||
var result readResult
|
||||
select {
|
||||
case result = <-resultCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for privacy batch read")
|
||||
}
|
||||
if result.err != nil {
|
||||
t.Fatalf("privacy batch read: %v", result.err)
|
||||
}
|
||||
if len(result.rules) != 1 || result.rules[0].Rules[0].Kind != domain.PrivacyRuleAllowAll {
|
||||
t.Fatalf("privacy batch after concurrent invalidation = %+v, want allow all", result.rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedPrivacyStoreListUsesBatchOwnerSnapshots(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewPrivacyStore()
|
||||
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1001,
|
||||
Key: domain.PrivacyKeyPhoneNumber,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed 1001: %v", err)
|
||||
}
|
||||
if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||
OwnerUserID: 1002,
|
||||
Key: domain.PrivacyKeyProfilePhoto,
|
||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed 1002: %v", err)
|
||||
}
|
||||
counting := &countingPrivacyStore{PrivacyStore: base}
|
||||
cached := NewCachedPrivacyStore(counting, 0)
|
||||
|
||||
keys := []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber, domain.PrivacyKeyProfilePhoto}
|
||||
first, err := cached.ListPrivacyRules(ctx, []int64{1001, 1002}, keys)
|
||||
if err != nil {
|
||||
t.Fatalf("list first: %v", err)
|
||||
}
|
||||
second, err := cached.ListPrivacyRules(ctx, []int64{1001, 1002}, keys)
|
||||
if err != nil {
|
||||
t.Fatalf("list second: %v", err)
|
||||
}
|
||||
if len(first) != 2 || len(second) != 2 {
|
||||
t.Fatalf("list sizes = %d/%d, want 2/2", len(first), len(second))
|
||||
}
|
||||
if counting.listCalls != 1 {
|
||||
t.Fatalf("ListPrivacyRules calls = %d, want 1 batch load", counting.listCalls)
|
||||
}
|
||||
}
|
||||
|
|
@ -110,6 +110,185 @@ func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, k
|
|||
return Evaluate(rules, evalCtx), nil
|
||||
}
|
||||
|
||||
// CanSeeBatch 批量评估多个 owner 对同一 viewer 在多个 key 上的可见性,结果等价于对每个
|
||||
// (owner,key) 调一次 CanSee,但只用一次 ListPrivacyRules + 一次 GetReverseContacts + 内存
|
||||
// Evaluate(消除 projectBatch / fan-out 投影里 per-user 3×CanSee×2行 的 N+1)。返回
|
||||
// map[ownerUserID]map[key]bool;owner==viewer 恒 true(与 CanSee 一致)。
|
||||
func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64, keys []domain.PrivacyKey) (map[int64]map[domain.PrivacyKey]bool, error) {
|
||||
out := make(map[int64]map[domain.PrivacyKey]bool, len(ownerUserIDs))
|
||||
if viewerUserID == 0 || len(ownerUserIDs) == 0 || len(keys) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
for _, k := range keys {
|
||||
if !ValidKey(k) {
|
||||
return nil, domain.ErrPrivacyKeyInvalid
|
||||
}
|
||||
}
|
||||
owners := make([]int64, 0, len(ownerUserIDs))
|
||||
seen := make(map[int64]struct{}, len(ownerUserIDs))
|
||||
for _, id := range ownerUserIDs {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if id == viewerUserID {
|
||||
// 自己恒可见全部 key(与 CanSee 的 ownerUserID==viewerUserID 分支一致)。
|
||||
m := make(map[domain.PrivacyKey]bool, len(keys))
|
||||
for _, k := range keys {
|
||||
m[k] = true
|
||||
}
|
||||
out[id] = m
|
||||
continue
|
||||
}
|
||||
owners = append(owners, id)
|
||||
}
|
||||
if len(owners) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
// 批量取 rules:存在的行进 map,缺失的 (owner,key) 用 defaultRules(复刻 GetRules 兜底)。
|
||||
rulesByOwner := make(map[int64]map[domain.PrivacyKey]domain.PrivacyRules, len(owners))
|
||||
if s != nil && s.rules != nil {
|
||||
list, err := s.rules.ListPrivacyRules(ctx, owners, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range list {
|
||||
if !ValidKey(r.Key) {
|
||||
continue
|
||||
}
|
||||
if len(r.Rules) == 0 {
|
||||
r.Rules = domain.DefaultPrivacyRules(r.Key)
|
||||
}
|
||||
if rulesByOwner[r.OwnerUserID] == nil {
|
||||
rulesByOwner[r.OwnerUserID] = make(map[domain.PrivacyKey]domain.PrivacyRules, len(keys))
|
||||
}
|
||||
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
|
||||
}
|
||||
}
|
||||
// 批量取「viewer 是否在 owner 的联系人里」(owner→viewer 方向,对应 CanSee 的
|
||||
// contacts.Get(owner, viewer))。
|
||||
var reverse map[int64]domain.Contact
|
||||
if s != nil && s.contacts != nil {
|
||||
var err error
|
||||
reverse, err = s.contacts.GetReverseContacts(ctx, viewerUserID, owners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, owner := range owners {
|
||||
_, isContact := reverse[owner]
|
||||
m := make(map[domain.PrivacyKey]bool, len(keys))
|
||||
for _, k := range keys {
|
||||
rules, ok := rulesByOwner[owner][k]
|
||||
if !ok {
|
||||
rules = defaultRules(owner, k)
|
||||
}
|
||||
m[k] = Evaluate(rules, domain.PrivacyContext{
|
||||
OwnerUserID: owner,
|
||||
ViewerUserID: viewerUserID,
|
||||
ViewerIsContact: isContact,
|
||||
})
|
||||
}
|
||||
out[owner] = m
|
||||
}
|
||||
return out, 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。
|
||||
func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs []int64, keys []domain.PrivacyKey) (map[int64]map[int64]map[domain.PrivacyKey]bool, error) {
|
||||
out := make(map[int64]map[int64]map[domain.PrivacyKey]bool, len(ownerUserIDs))
|
||||
if len(ownerUserIDs) == 0 || len(viewerUserIDs) == 0 || len(keys) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
for _, k := range keys {
|
||||
if !ValidKey(k) {
|
||||
return nil, domain.ErrPrivacyKeyInvalid
|
||||
}
|
||||
}
|
||||
owners := dedupNonZero(ownerUserIDs)
|
||||
viewers := dedupNonZero(viewerUserIDs)
|
||||
if len(owners) == 0 || len(viewers) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rulesByOwner := make(map[int64]map[domain.PrivacyKey]domain.PrivacyRules, len(owners))
|
||||
if s != nil && s.rules != nil {
|
||||
list, err := s.rules.ListPrivacyRules(ctx, owners, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range list {
|
||||
if !ValidKey(r.Key) {
|
||||
continue
|
||||
}
|
||||
if len(r.Rules) == 0 {
|
||||
r.Rules = domain.DefaultPrivacyRules(r.Key)
|
||||
}
|
||||
if rulesByOwner[r.OwnerUserID] == nil {
|
||||
rulesByOwner[r.OwnerUserID] = make(map[domain.PrivacyKey]domain.PrivacyRules, len(keys))
|
||||
}
|
||||
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
|
||||
}
|
||||
}
|
||||
for _, owner := range owners {
|
||||
// owner 的联系人中哪些是本批 viewer(= privacy 的 ViewerIsContact,对应 contacts.Get(owner,viewer))。
|
||||
var ownerContacts map[int64]domain.Contact
|
||||
if s != nil && s.contacts != nil {
|
||||
var err error
|
||||
ownerContacts, err = s.contacts.GetMany(ctx, owner, viewers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
perViewer := make(map[int64]map[domain.PrivacyKey]bool, len(viewers))
|
||||
for _, viewer := range viewers {
|
||||
m := make(map[domain.PrivacyKey]bool, len(keys))
|
||||
if owner == viewer {
|
||||
for _, k := range keys {
|
||||
m[k] = true
|
||||
}
|
||||
perViewer[viewer] = m
|
||||
continue
|
||||
}
|
||||
_, isContact := ownerContacts[viewer]
|
||||
for _, k := range keys {
|
||||
rules, ok := rulesByOwner[owner][k]
|
||||
if !ok {
|
||||
rules = defaultRules(owner, k)
|
||||
}
|
||||
m[k] = Evaluate(rules, domain.PrivacyContext{
|
||||
OwnerUserID: owner,
|
||||
ViewerUserID: viewer,
|
||||
ViewerIsContact: isContact,
|
||||
})
|
||||
}
|
||||
perViewer[viewer] = m
|
||||
}
|
||||
out[owner] = perViewer
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dedupNonZero(ids []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func Evaluate(rules domain.PrivacyRules, ctx domain.PrivacyContext) bool {
|
||||
if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID {
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -73,3 +73,96 @@ func TestExplicitDisallowUserWins(t *testing.T) {
|
|||
t.Fatal("explicit disallow user should win over allow all")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanSeeBatchEquivalentToCanSee 锁定批量 privacy 评估与逐 CanSee 字节等价(projectBatch
|
||||
// fan-out N+1 优化的正确性前提):覆盖默认规则/allow-all/disallow-all/allow-contacts(含联系人)/self。
|
||||
func TestCanSeeBatchEquivalentToCanSee(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
contacts := memory.NewContactStore()
|
||||
svc := NewService(memory.NewPrivacyStore(), contacts)
|
||||
const viewer = int64(1002)
|
||||
owners := []int64{1001, 1003, 1004, 1005, viewer}
|
||||
|
||||
if _, err := svc.SetRules(ctx, 1003, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}); err != nil {
|
||||
t.Fatalf("set 1003 phone: %v", err)
|
||||
}
|
||||
if _, err := svc.SetRules(ctx, 1004, domain.PrivacyKeyStatusTimestamp, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
|
||||
t.Fatalf("set 1004 status: %v", err)
|
||||
}
|
||||
if _, err := svc.SetRules(ctx, 1005, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
|
||||
t.Fatalf("set 1005 phone: %v", err)
|
||||
}
|
||||
// owner 1005 把 viewer 加为联系人(GetReverseContacts(viewer,[1005]) 命中 → allow-contacts 可见)。
|
||||
if _, err := contacts.Upsert(ctx, 1005, domain.ContactInput{ContactUserID: viewer}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
|
||||
keys := []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber, domain.PrivacyKeyStatusTimestamp, domain.PrivacyKeyProfilePhoto}
|
||||
batch, err := svc.CanSeeBatch(ctx, owners, viewer, keys)
|
||||
if err != nil {
|
||||
t.Fatalf("CanSeeBatch: %v", err)
|
||||
}
|
||||
for _, owner := range owners {
|
||||
for _, k := range keys {
|
||||
want, err := svc.CanSee(ctx, owner, viewer, k)
|
||||
if err != nil {
|
||||
t.Fatalf("CanSee(%d,%d,%v): %v", owner, viewer, k, err)
|
||||
}
|
||||
got, ok := batch[owner][k]
|
||||
if !ok {
|
||||
t.Fatalf("CanSeeBatch missing owner=%d key=%v", owner, k)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("CanSeeBatch[%d][%v]=%v != CanSee=%v (must be equivalent)", owner, k, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanSeeMatrixEquivalentToCanSee 锁定 owners×viewers×keys 矩阵评估与逐 CanSee 字节等价
|
||||
// (ForViewers fan-out 模板化把 privacy 查询降到 O(owner) 的正确性前提)。覆盖多 owner 多 viewer:
|
||||
// 不同规则、联系人方向(owner 把 viewer 加为联系人才命中 allow-contacts)、self(owner==viewer)。
|
||||
func TestCanSeeMatrixEquivalentToCanSee(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
contacts := memory.NewContactStore()
|
||||
svc := NewService(memory.NewPrivacyStore(), contacts)
|
||||
owners := []int64{6001, 6002, 6003, 6004}
|
||||
viewers := []int64{7001, 7002, 6002} // 6002 既是 owner 又是 viewer → 命中 self 分支
|
||||
|
||||
if _, err := svc.SetRules(ctx, 6002, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}); err != nil {
|
||||
t.Fatalf("set 6002 phone: %v", err)
|
||||
}
|
||||
if _, err := svc.SetRules(ctx, 6003, domain.PrivacyKeyStatusTimestamp, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
|
||||
t.Fatalf("set 6003 status: %v", err)
|
||||
}
|
||||
if _, err := svc.SetRules(ctx, 6004, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
|
||||
t.Fatalf("set 6004 phone: %v", err)
|
||||
}
|
||||
// owner 6004 把 viewer 7001 加为联系人(owner→viewer 方向 = privacy 的 ViewerIsContact)。
|
||||
if _, err := contacts.Upsert(ctx, 6004, domain.ContactInput{ContactUserID: 7001}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
|
||||
keys := []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber, domain.PrivacyKeyStatusTimestamp, domain.PrivacyKeyProfilePhoto}
|
||||
matrix, err := svc.CanSeeMatrix(ctx, owners, viewers, keys)
|
||||
if err != nil {
|
||||
t.Fatalf("CanSeeMatrix: %v", err)
|
||||
}
|
||||
for _, owner := range owners {
|
||||
for _, viewer := range viewers {
|
||||
for _, k := range keys {
|
||||
want, err := svc.CanSee(ctx, owner, viewer, k)
|
||||
if err != nil {
|
||||
t.Fatalf("CanSee(%d,%d,%v): %v", owner, viewer, k, err)
|
||||
}
|
||||
got, ok := matrix[owner][viewer][k]
|
||||
if !ok {
|
||||
t.Fatalf("CanSeeMatrix missing owner=%d viewer=%d key=%v", owner, viewer, k)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("CanSeeMatrix[%d][%d][%v]=%v != CanSee=%v (must be equivalent)", owner, viewer, k, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue