owpengram-server/internal/app/privacy/facts.go
2026-07-24 11:56:59 +08:00

245 lines
6.8 KiB
Go

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