feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
|
|
@ -87,6 +87,34 @@ func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.P
|
|||
return nil
|
||||
}
|
||||
|
||||
func (c *CachedPrivacyStore) SetPrivacyRulesWithUpdate(
|
||||
ctx context.Context,
|
||||
rules domain.PrivacyRules,
|
||||
event domain.UpdateEvent,
|
||||
excludeAuthKeyID [8]byte,
|
||||
excludeSessionID int64,
|
||||
) (domain.UpdateEvent, error) {
|
||||
writer, ok := c.inner.(store.PrivacyUpdateStore)
|
||||
if !ok {
|
||||
return domain.UpdateEvent{}, domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
recorded, err := writer.SetPrivacyRulesWithUpdate(ctx, rules, event, excludeAuthKeyID, excludeSessionID)
|
||||
if err != nil {
|
||||
return domain.UpdateEvent{}, err
|
||||
}
|
||||
c.InvalidateOwners(rules.OwnerUserID)
|
||||
_ = c.WarmOwners(ctx, rules.OwnerUserID)
|
||||
return recorded, nil
|
||||
}
|
||||
|
||||
func (c *CachedPrivacyStore) SupportsDurablePrivacyUpdates() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
capability, ok := c.inner.(interface{ SupportsDurablePrivacyUpdates() bool })
|
||||
return ok && capability.SupportsDurablePrivacyUpdates()
|
||||
}
|
||||
|
||||
// WarmOwners 在低频写/变更通知路径一次性装入 owner 的完整规则集。调用方必须先
|
||||
// InvalidateOwners;epoch 保证预热期间若又发生失效,不会把旧快照写回。
|
||||
func (c *CachedPrivacyStore) WarmOwners(ctx context.Context, ownerUserIDs ...int64) error {
|
||||
|
|
@ -185,6 +213,40 @@ func (c *CachedPrivacyStore) FlushReadModelCache() {
|
|||
c.cache.Flush()
|
||||
}
|
||||
|
||||
// InvalidateOwners lets Service be registered as the single privacy read-model
|
||||
// cache group: rule snapshots and relationship facts then share one invalidation
|
||||
// lifecycle.
|
||||
func (s *Service) InvalidateOwners(ids ...int64) {
|
||||
if s == nil || s.rules == nil {
|
||||
return
|
||||
}
|
||||
if cache, ok := s.rules.(interface{ InvalidateOwners(...int64) }); ok {
|
||||
cache.InvalidateOwners(ids...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) WarmOwners(ctx context.Context, ids ...int64) error {
|
||||
if s == nil || s.rules == nil {
|
||||
return nil
|
||||
}
|
||||
if cache, ok := s.rules.(interface {
|
||||
WarmOwners(context.Context, ...int64) error
|
||||
}); ok {
|
||||
return cache.WarmOwners(ctx, ids...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) FlushReadModelCache() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if cache, ok := s.rules.(interface{ FlushReadModelCache() }); ok {
|
||||
cache.FlushReadModelCache()
|
||||
}
|
||||
s.flushFactCaches()
|
||||
}
|
||||
|
||||
// buildPrivacyRulesByOwner 把扁平规则按 owner 归组;每个 owner 都建一个条目(无规则即空 map),
|
||||
// 这样「查过且无规则」的 owner 也被负缓存,不会反复打后端。
|
||||
func buildPrivacyRulesByOwner(list []domain.PrivacyRules, owners []int64) map[int64]privacyRulesMap {
|
||||
|
|
|
|||
245
internal/app/privacy/facts.go
Normal file
245
internal/app/privacy/facts.go
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPrivacyViewerFactsTTL = 10 * time.Minute
|
||||
defaultPrivacyMembershipTTL = 24 * time.Hour
|
||||
|
||||
privacyViewerFactsMaxEntries = 8192
|
||||
privacyMembershipMaxEntries = 65536
|
||||
)
|
||||
|
||||
// baseUserProvider returns viewer-independent user facts through the users read
|
||||
// model. Implementations must batch cold misses rather than issue one query per
|
||||
// user.
|
||||
type baseUserProvider interface {
|
||||
PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error)
|
||||
}
|
||||
|
||||
// channelMembershipProvider is the cold loader behind the bounded membership
|
||||
// read model. Privacy evaluation never calls it for a warm (chat,user) pair.
|
||||
type channelMembershipProvider interface {
|
||||
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||
}
|
||||
|
||||
type viewerFacts struct {
|
||||
Found bool
|
||||
Bot bool
|
||||
PremiumUntil int64
|
||||
}
|
||||
|
||||
type membershipKey struct {
|
||||
ChatID int64
|
||||
UserID int64
|
||||
}
|
||||
|
||||
type evaluationNeeds struct {
|
||||
viewerBase bool
|
||||
chatIDs []int64
|
||||
}
|
||||
|
||||
func newViewerFactsCache() *readmodelcache.Cache[int64, viewerFacts] {
|
||||
return readmodelcache.New[int64, viewerFacts](readmodelcache.Config[int64, viewerFacts]{
|
||||
MaxEntries: privacyViewerFactsMaxEntries,
|
||||
TTL: defaultPrivacyViewerFactsTTL,
|
||||
})
|
||||
}
|
||||
|
||||
func newMembershipCache() *readmodelcache.Cache[membershipKey, bool] {
|
||||
return readmodelcache.New[membershipKey, bool](readmodelcache.Config[membershipKey, bool]{
|
||||
MaxEntries: privacyMembershipMaxEntries,
|
||||
TTL: defaultPrivacyMembershipTTL,
|
||||
KeyString: func(key membershipKey) string {
|
||||
return strconv.FormatInt(key.ChatID, 10) + ":" + strconv.FormatInt(key.UserID, 10)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func needsForRules(rules domain.PrivacyRules) evaluationNeeds {
|
||||
var needs evaluationNeeds
|
||||
seenChats := make(map[int64]struct{})
|
||||
for _, rule := range rules.Rules {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleAllowPremium,
|
||||
domain.PrivacyRuleAllowBots,
|
||||
domain.PrivacyRuleDisallowBots:
|
||||
needs.viewerBase = true
|
||||
case domain.PrivacyRuleAllowChatParticipants,
|
||||
domain.PrivacyRuleDisallowChatParticipants:
|
||||
for _, chatID := range rule.ChatIDs {
|
||||
if chatID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenChats[chatID]; ok {
|
||||
continue
|
||||
}
|
||||
seenChats[chatID] = struct{}{}
|
||||
needs.chatIDs = append(needs.chatIDs, chatID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return needs
|
||||
}
|
||||
|
||||
func mergeNeeds(dst *evaluationNeeds, src evaluationNeeds) {
|
||||
if src.viewerBase {
|
||||
dst.viewerBase = true
|
||||
}
|
||||
if len(src.chatIDs) == 0 {
|
||||
return
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(dst.chatIDs)+len(src.chatIDs))
|
||||
for _, id := range dst.chatIDs {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range src.chatIDs {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
dst.chatIDs = append(dst.chatIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) loadViewerFacts(ctx context.Context, viewerUserIDs []int64) (map[int64]viewerFacts, error) {
|
||||
ids := dedupNonZero(viewerUserIDs)
|
||||
if len(ids) == 0 {
|
||||
return map[int64]viewerFacts{}, nil
|
||||
}
|
||||
loadMissing := func(ctx context.Context, missing []int64) (map[int64]viewerFacts, error) {
|
||||
out := make(map[int64]viewerFacts, len(missing))
|
||||
for _, id := range missing {
|
||||
out[id] = viewerFacts{} // negative cache: user was not found.
|
||||
}
|
||||
if s == nil || s.baseUsers == nil {
|
||||
return out, nil
|
||||
}
|
||||
users, err := s.baseUsers.PrivacyBaseUsers(ctx, missing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.ID == 0 {
|
||||
continue
|
||||
}
|
||||
out[user.ID] = viewerFacts{
|
||||
Found: true,
|
||||
Bot: user.Bot,
|
||||
PremiumUntil: int64(user.PremiumUntil),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if s == nil || s.viewerFacts == nil {
|
||||
return loadMissing(ctx, ids)
|
||||
}
|
||||
return s.viewerFacts.GetOrLoadBatch(ctx, ids,
|
||||
func(int64) (int64, bool) { return 0, true },
|
||||
loadMissing,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) loadMembershipFacts(ctx context.Context, chatIDs, viewerUserIDs []int64) (map[membershipKey]bool, error) {
|
||||
chats := dedupNonZero(chatIDs)
|
||||
viewers := dedupNonZero(viewerUserIDs)
|
||||
if len(chats) == 0 || len(viewers) == 0 {
|
||||
return map[membershipKey]bool{}, nil
|
||||
}
|
||||
keys := make([]membershipKey, 0, len(chats)*len(viewers))
|
||||
for _, chatID := range chats {
|
||||
for _, viewerID := range viewers {
|
||||
keys = append(keys, membershipKey{ChatID: chatID, UserID: viewerID})
|
||||
}
|
||||
}
|
||||
loadMissing := func(ctx context.Context, missing []membershipKey) (map[membershipKey]bool, error) {
|
||||
out := make(map[membershipKey]bool, len(missing))
|
||||
byChat := make(map[int64][]int64)
|
||||
for _, key := range missing {
|
||||
out[key] = false // negative cache: not an active member.
|
||||
byChat[key.ChatID] = append(byChat[key.ChatID], key.UserID)
|
||||
}
|
||||
if s == nil || s.memberships == nil {
|
||||
return out, nil
|
||||
}
|
||||
for chatID, userIDs := range byChat {
|
||||
active, err := s.memberships.FilterActiveChannelMemberIDs(ctx, chatID, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, userID := range active {
|
||||
out[membershipKey{ChatID: chatID, UserID: userID}] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if s == nil || s.membershipFacts == nil {
|
||||
return loadMissing(ctx, keys)
|
||||
}
|
||||
return s.membershipFacts.GetOrLoadBatch(ctx, keys,
|
||||
func(membershipKey) (int64, bool) { return 0, true },
|
||||
loadMissing,
|
||||
)
|
||||
}
|
||||
|
||||
func applyViewerFacts(ctx *domain.PrivacyContext, facts viewerFacts, now int64) {
|
||||
if ctx == nil || !facts.Found {
|
||||
return
|
||||
}
|
||||
ctx.ViewerIsBot = facts.Bot
|
||||
ctx.ViewerIsPremium = !facts.Bot && facts.PremiumUntil > now
|
||||
}
|
||||
|
||||
func applyMembershipFacts(ctx *domain.PrivacyContext, chatIDs []int64, facts map[membershipKey]bool) {
|
||||
if ctx == nil || len(chatIDs) == 0 {
|
||||
return
|
||||
}
|
||||
for _, chatID := range chatIDs {
|
||||
if facts[membershipKey{ChatID: chatID, UserID: ctx.ViewerUserID}] {
|
||||
ctx.SharedChatIDs = append(ctx.SharedChatIDs, chatID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateViewerFacts invalidates bot/premium facts after a user-base change.
|
||||
func (s *Service) InvalidateViewerFacts(userIDs ...int64) {
|
||||
if s == nil || s.viewerFacts == nil {
|
||||
return
|
||||
}
|
||||
s.viewerFacts.Invalidate(dedupNonZero(userIDs)...)
|
||||
}
|
||||
|
||||
// InvalidateMembership invalidates one membership pair after a channel-member change.
|
||||
func (s *Service) InvalidateMembership(channelID, userID int64) {
|
||||
if s == nil || s.membershipFacts == nil || channelID == 0 || userID == 0 {
|
||||
return
|
||||
}
|
||||
s.membershipFacts.Invalidate(membershipKey{ChatID: channelID, UserID: userID})
|
||||
}
|
||||
|
||||
// InvalidateChannelMemberships invalidates all cached pairs for a changed/deleted channel.
|
||||
func (s *Service) InvalidateChannelMemberships(channelID int64) {
|
||||
if s == nil || s.membershipFacts == nil || channelID == 0 {
|
||||
return
|
||||
}
|
||||
s.membershipFacts.InvalidateWhere(func(key membershipKey) bool { return key.ChatID == channelID })
|
||||
}
|
||||
|
||||
func (s *Service) flushFactCaches() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.viewerFacts != nil {
|
||||
s.viewerFacts.Flush()
|
||||
}
|
||||
if s.membershipFacts != nil {
|
||||
s.membershipFacts.Flush()
|
||||
}
|
||||
}
|
||||
|
|
@ -3,21 +3,49 @@ package privacy
|
|||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const maxPrivacyRules = 100
|
||||
const (
|
||||
maxPrivacyRules = 100
|
||||
maxPrivacyRuleIDs = 5000
|
||||
)
|
||||
|
||||
// Service owns account privacy rules and viewer-specific evaluation.
|
||||
type Service struct {
|
||||
rules store.PrivacyStore
|
||||
contacts store.ContactStore
|
||||
rules store.PrivacyStore
|
||||
contacts store.ContactStore
|
||||
baseUsers baseUserProvider
|
||||
memberships channelMembershipProvider
|
||||
viewerFacts *readmodelcache.Cache[int64, viewerFacts]
|
||||
membershipFacts *readmodelcache.Cache[membershipKey, bool]
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(rules store.PrivacyStore, contacts store.ContactStore) *Service {
|
||||
return &Service{rules: rules, contacts: contacts}
|
||||
return &Service{
|
||||
rules: rules,
|
||||
contacts: contacts,
|
||||
viewerFacts: newViewerFactsCache(),
|
||||
membershipFacts: newMembershipCache(),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigureReadModels wires the cold loaders behind the bounded in-memory
|
||||
// privacy fact caches. It is called after users/channels services are built to
|
||||
// avoid a package dependency cycle.
|
||||
func (s *Service) ConfigureReadModels(users baseUserProvider, memberships channelMembershipProvider) *Service {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
s.baseUsers = users
|
||||
s.memberships = memberships
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, error) {
|
||||
|
|
@ -43,6 +71,52 @@ func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.Pr
|
|||
}
|
||||
|
||||
func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
|
||||
out, err := normalizedRules(ownerUserID, key, rules)
|
||||
if err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
if s != nil && s.rules != nil {
|
||||
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetRulesWithUpdate uses the production atomic write boundary when available.
|
||||
// durable=false means no write was attempted; the RPC layer may then use the
|
||||
// ordinary SetRules + Updates.RecordPrivacy fallback used by memory tests.
|
||||
func (s *Service) SetRulesWithUpdate(
|
||||
ctx context.Context,
|
||||
ownerUserID int64,
|
||||
key domain.PrivacyKey,
|
||||
rules []domain.PrivacyRule,
|
||||
date int,
|
||||
excludeAuthKeyID [8]byte,
|
||||
excludeSessionID int64,
|
||||
) (domain.PrivacyRules, domain.UpdateEvent, bool, error) {
|
||||
out, err := normalizedRules(ownerUserID, key, rules)
|
||||
if err != nil {
|
||||
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, err
|
||||
}
|
||||
capability, ok := s.rules.(interface{ SupportsDurablePrivacyUpdates() bool })
|
||||
if !ok || !capability.SupportsDurablePrivacyUpdates() {
|
||||
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, nil
|
||||
}
|
||||
writer := s.rules.(store.PrivacyUpdateStore)
|
||||
event, err := writer.SetPrivacyRulesWithUpdate(ctx, out, domain.UpdateEvent{
|
||||
Type: domain.UpdateEventPrivacy,
|
||||
Date: date,
|
||||
Privacy: cloneRules(out),
|
||||
PtsCount: 1,
|
||||
}, excludeAuthKeyID, excludeSessionID)
|
||||
if err != nil {
|
||||
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, err
|
||||
}
|
||||
return out, event, true, nil
|
||||
}
|
||||
|
||||
func normalizedRules(ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
|
||||
if !ValidKey(key) {
|
||||
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid
|
||||
}
|
||||
|
|
@ -52,13 +126,7 @@ func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.Pr
|
|||
if err := validateRules(rules); err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
out := domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}
|
||||
if s != nil && s.rules != nil {
|
||||
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) {
|
||||
|
|
@ -96,17 +164,33 @@ func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, k
|
|||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
needs := needsForRules(rules)
|
||||
evalCtx := domain.PrivacyContext{
|
||||
OwnerUserID: ownerUserID,
|
||||
ViewerUserID: viewerUserID,
|
||||
}
|
||||
if s != nil && s.contacts != nil {
|
||||
if _, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
|
||||
if contact, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
|
||||
return false, err
|
||||
} else if found {
|
||||
evalCtx.ViewerIsContact = true
|
||||
evalCtx.ViewerCloseFriend = contact.CloseFriend
|
||||
}
|
||||
}
|
||||
if needs.viewerBase {
|
||||
facts, err := s.loadViewerFacts(ctx, []int64{viewerUserID})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
applyViewerFacts(&evalCtx, facts[viewerUserID], s.now().Unix())
|
||||
}
|
||||
if len(needs.chatIDs) > 0 {
|
||||
facts, err := s.loadMembershipFacts(ctx, needs.chatIDs, []int64{viewerUserID})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
applyMembershipFacts(&evalCtx, needs.chatIDs, facts)
|
||||
}
|
||||
return Evaluate(rules, evalCtx), nil
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +267,16 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
|
|||
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
|
||||
}
|
||||
}
|
||||
var needs evaluationNeeds
|
||||
for _, owner := range owners {
|
||||
for _, key := range keys {
|
||||
rules, ok := rulesByOwner[owner][key]
|
||||
if !ok {
|
||||
rules = defaultRules(owner, key)
|
||||
}
|
||||
mergeNeeds(&needs, needsForRules(rules))
|
||||
}
|
||||
}
|
||||
// 批量取「viewer 是否在 owner 的联系人里」(owner→viewer 方向,对应 CanSee 的
|
||||
// contacts.Get(owner, viewer))。
|
||||
var reverse map[int64]domain.Contact
|
||||
|
|
@ -193,25 +287,82 @@ func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerU
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
var baseFacts map[int64]viewerFacts
|
||||
if needs.viewerBase {
|
||||
var err error
|
||||
baseFacts, err = s.loadViewerFacts(ctx, []int64{viewerUserID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var membershipFacts map[membershipKey]bool
|
||||
if len(needs.chatIDs) > 0 {
|
||||
var err error
|
||||
membershipFacts, err = s.loadMembershipFacts(ctx, needs.chatIDs, []int64{viewerUserID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
now := s.now().Unix()
|
||||
for _, owner := range owners {
|
||||
_, isContact := reverse[owner]
|
||||
contact, 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,
|
||||
})
|
||||
evalCtx := domain.PrivacyContext{
|
||||
OwnerUserID: owner,
|
||||
ViewerUserID: viewerUserID,
|
||||
ViewerIsContact: isContact,
|
||||
ViewerCloseFriend: isContact && contact.CloseFriend,
|
||||
}
|
||||
applyViewerFacts(&evalCtx, baseFacts[viewerUserID], now)
|
||||
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
|
||||
m[k] = Evaluate(rules, evalCtx)
|
||||
}
|
||||
out[owner] = m
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CanContactForFreeBatch evaluates the complete exception predicate for
|
||||
// per-user contact requirements. Contacts are always free because the global
|
||||
// setting is explicitly "noncontact peers"; privacyKeyNoPaidMessages adds
|
||||
// exceptions beyond that relationship. Both facts come from the in-memory
|
||||
// privacy/contact read models after their bounded cold loads.
|
||||
func (s *Service) CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) (map[int64]bool, error) {
|
||||
owners := dedupNonZero(ownerUserIDs)
|
||||
out := make(map[int64]bool, len(owners))
|
||||
if viewerUserID == 0 || len(owners) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
visibility, err := s.CanSeeBatch(
|
||||
ctx,
|
||||
owners,
|
||||
viewerUserID,
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyNoPaidMessages},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var contacts map[int64]domain.Contact
|
||||
if s != nil && s.contacts != nil {
|
||||
contacts, err = s.contacts.GetReverseContacts(ctx, viewerUserID, owners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, ownerUserID := range owners {
|
||||
_, isContact := contacts[ownerUserID]
|
||||
out[ownerUserID] = ownerUserID == viewerUserID ||
|
||||
isContact ||
|
||||
visibility[ownerUserID][domain.PrivacyKeyNoPaidMessages]
|
||||
}
|
||||
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。
|
||||
|
|
@ -249,6 +400,33 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
|
|||
rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r)
|
||||
}
|
||||
}
|
||||
var needs evaluationNeeds
|
||||
for _, owner := range owners {
|
||||
for _, key := range keys {
|
||||
rules, ok := rulesByOwner[owner][key]
|
||||
if !ok {
|
||||
rules = defaultRules(owner, key)
|
||||
}
|
||||
mergeNeeds(&needs, needsForRules(rules))
|
||||
}
|
||||
}
|
||||
var baseFacts map[int64]viewerFacts
|
||||
if needs.viewerBase {
|
||||
var err error
|
||||
baseFacts, err = s.loadViewerFacts(ctx, viewers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var membershipFacts map[membershipKey]bool
|
||||
if len(needs.chatIDs) > 0 {
|
||||
var err error
|
||||
membershipFacts, err = s.loadMembershipFacts(ctx, needs.chatIDs, viewers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
now := s.now().Unix()
|
||||
for _, owner := range owners {
|
||||
// owner 的联系人中哪些是本批 viewer(= privacy 的 ViewerIsContact,对应 contacts.Get(owner,viewer))。
|
||||
var ownerContacts map[int64]domain.Contact
|
||||
|
|
@ -269,17 +447,21 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
|
|||
perViewer[viewer] = m
|
||||
continue
|
||||
}
|
||||
_, isContact := ownerContacts[viewer]
|
||||
contact, 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,
|
||||
})
|
||||
evalCtx := domain.PrivacyContext{
|
||||
OwnerUserID: owner,
|
||||
ViewerUserID: viewer,
|
||||
ViewerIsContact: isContact,
|
||||
ViewerCloseFriend: isContact && contact.CloseFriend,
|
||||
}
|
||||
applyViewerFacts(&evalCtx, baseFacts[viewer], now)
|
||||
applyMembershipFacts(&evalCtx, needs.chatIDs, membershipFacts)
|
||||
m[k] = Evaluate(rules, evalCtx)
|
||||
}
|
||||
perViewer[viewer] = m
|
||||
}
|
||||
|
|
@ -370,6 +552,7 @@ func validateRules(rules []domain.PrivacyRule) error {
|
|||
if len(rules) > maxPrivacyRules {
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
totalIDs := 0
|
||||
for _, rule := range rules {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleAllowContacts,
|
||||
|
|
@ -387,6 +570,20 @@ func validateRules(rules []domain.PrivacyRule) error {
|
|||
default:
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
totalIDs += len(rule.UserIDs) + len(rule.ChatIDs)
|
||||
if totalIDs > maxPrivacyRuleIDs {
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
for _, id := range rule.UserIDs {
|
||||
if id <= 0 {
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
}
|
||||
for _, id := range rule.ChatIDs {
|
||||
if id <= 0 {
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue