chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View 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
}

View 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)
}
}

View file

@ -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

View file

@ -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)
}
}
}
}
}